Compare commits
486 Commits
python-sec
...
graph-tab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8b80306f | ||
|
|
b4ef389639 | ||
|
|
27889fbd33 | ||
|
|
4af06d5697 | ||
|
|
728d2e3363 | ||
|
|
4a603b78aa | ||
|
|
a2c1a7ce45 | ||
|
|
94d9c47e16 | ||
|
|
f5c4bf2568 | ||
|
|
e61deeca7f | ||
|
|
abccde532c | ||
|
|
2dc2bf04bc | ||
|
|
8346aa1572 | ||
|
|
ce5f2e6fe8 | ||
|
|
262e22683c | ||
|
|
9e082293b5 | ||
|
|
a6e59c318e | ||
|
|
16686c54d5 | ||
|
|
3cdec2d499 | ||
|
|
88b0015557 | ||
|
|
b4061dc151 | ||
|
|
b8858dc735 | ||
|
|
abf6721bde | ||
|
|
9d36aea329 | ||
|
|
3abc24881f | ||
|
|
187d77e61a | ||
|
|
e847af7379 | ||
|
|
e08f3af6b5 | ||
|
|
62765cdb17 | ||
|
|
ef5df4d757 | ||
|
|
d7eb9eb86b | ||
|
|
57dfb6dc2a | ||
|
|
ae2200f1c2 | ||
|
|
94c8c5287f | ||
|
|
14b7975613 | ||
|
|
08a80d7047 | ||
|
|
2a53ccb93f | ||
|
|
34527055d6 | ||
|
|
ce4f7e4b09 | ||
|
|
37de244380 | ||
|
|
77b3a47cea | ||
|
|
5264a10205 | ||
|
|
74a2bbaf9f | ||
|
|
f53d7917e3 | ||
|
|
39f775516b | ||
|
|
c7f42e866a | ||
|
|
bf75ac26b2 | ||
|
|
dd4581f2e5 | ||
|
|
ae2071b875 | ||
|
|
85371a7298 | ||
|
|
6dc85458ec | ||
|
|
098b1fc826 | ||
|
|
c740caa5bb | ||
|
|
b7556bf964 | ||
|
|
06efa792ed | ||
|
|
85ce13c5b3 | ||
|
|
a1e6c01458 | ||
|
|
1ffecf3d9e | ||
|
|
c4a847322f | ||
|
|
a7a38fd4ca | ||
|
|
486bc64112 | ||
|
|
3101e32edd | ||
|
|
b51c20c9b0 | ||
|
|
8cdf482f1f | ||
|
|
5d173e849d | ||
|
|
aabd4344fb | ||
|
|
f94822749b | ||
|
|
d3a30cc4ab | ||
|
|
c8958bea54 | ||
|
|
dd579af20c | ||
|
|
0d57823e39 | ||
|
|
d0b96d16ed | ||
|
|
3bea2940a6 | ||
|
|
7b0f4cfb9e | ||
|
|
925d8d76ee | ||
|
|
f1a8e6a623 | ||
|
|
8a9b4fbd05 | ||
|
|
1deee13b0c | ||
|
|
b89c4b6431 | ||
|
|
a0f9335ec0 | ||
|
|
8d717aded4 | ||
|
|
2c7848edb7 | ||
|
|
edeac448d2 | ||
|
|
290b486e1a | ||
|
|
67345120b6 | ||
|
|
24ee602364 | ||
|
|
7bdffa72a6 | ||
|
|
4b228cd956 | ||
|
|
7da7ca3839 | ||
|
|
9e3af41bda | ||
|
|
b2ca4d492a | ||
|
|
c0636ba772 | ||
|
|
506fcd0970 | ||
|
|
4a8d9e2085 | ||
|
|
1188beea7d | ||
|
|
538318a38d | ||
|
|
bd0387cd60 | ||
|
|
0471190c74 | ||
|
|
3407b668a3 | ||
|
|
ec826befeb | ||
|
|
092bf6d679 | ||
|
|
16a258250c | ||
|
|
8c9e5886ab | ||
|
|
d161b663ae | ||
|
|
95a86c4acc | ||
|
|
51b02e259b | ||
|
|
8ef14ebcf1 | ||
|
|
d7e0b28be5 | ||
|
|
75231939b5 | ||
|
|
5f91a003bb | ||
|
|
278fc0c46c | ||
|
|
c7086253b6 | ||
|
|
c83ea462d2 | ||
|
|
306d80de92 | ||
|
|
a345fb257d | ||
|
|
2f65a02744 | ||
|
|
b4a9ece8d8 | ||
|
|
75b3a5b509 | ||
|
|
18db799cd0 | ||
|
|
ea1b32a7c1 | ||
|
|
fe8f2dbe37 | ||
|
|
1c9f3d4fe1 | ||
|
|
667563b11b | ||
|
|
d697be5665 | ||
|
|
c1030a16f7 | ||
|
|
b5e5bd328a | ||
|
|
777f3a47da | ||
|
|
9042477db1 | ||
|
|
52f602f0c2 | ||
|
|
b1f42b9497 | ||
|
|
704bbd280e | ||
|
|
5e370f937c | ||
|
|
662fc910b6 | ||
|
|
4448f190fa | ||
|
|
00f58316a7 | ||
|
|
d956fa61d5 | ||
|
|
ca6372be38 | ||
|
|
5e226fee6e | ||
|
|
89e92ee71f | ||
|
|
a111938611 | ||
|
|
fc95d79d04 | ||
|
|
d8d32ecb32 | ||
|
|
e4b1d46e78 | ||
|
|
f8a7f0f2d8 | ||
|
|
ca9b70efbe | ||
|
|
c1841b41cb | ||
|
|
070009d6aa | ||
|
|
d7078d30a1 | ||
|
|
a22f42d04e | ||
|
|
c50d16407f | ||
|
|
c46e93e25f | ||
|
|
8de115c0d6 | ||
|
|
e18f364ab2 | ||
|
|
73a024c794 | ||
|
|
58da27dc1a | ||
|
|
dee89e8f31 | ||
|
|
934d5dfd3f | ||
|
|
47b00082ff | ||
|
|
328b9a8988 | ||
|
|
d16d5c77eb | ||
|
|
5d62ce224d | ||
|
|
4d8df063ae | ||
|
|
e70210ede1 | ||
|
|
30baf69e2c | ||
|
|
9070d8592f | ||
|
|
57709669e1 | ||
|
|
17df352531 | ||
|
|
ff524bc410 | ||
|
|
544cacc4a5 | ||
|
|
4603d73aa5 | ||
|
|
16952f5d4c | ||
|
|
0cb4318906 | ||
|
|
836dbe993f | ||
|
|
3b5d5a5e6b | ||
|
|
ace6b58330 | ||
|
|
c2525e9fdb | ||
|
|
da7385a275 | ||
|
|
e09bba85ef | ||
|
|
31549a9745 | ||
|
|
3e26069789 | ||
|
|
0124c006a7 | ||
|
|
3143e4bddb | ||
|
|
1aefab5285 | ||
|
|
31bb4085dd | ||
|
|
7fb2dc1b2a | ||
|
|
ba5860f72d | ||
|
|
a839e0948b | ||
|
|
750fce2617 | ||
|
|
455bf8f6ed | ||
|
|
290e5dc5d4 | ||
|
|
e255be5b45 | ||
|
|
a1bb788f46 | ||
|
|
bb05aceda2 | ||
|
|
1d56cce877 | ||
|
|
591db0554f | ||
|
|
13b26368cd | ||
|
|
70486e25d5 | ||
|
|
5fd6d2477d | ||
|
|
c6b0a63cd0 | ||
|
|
f644c57b7b | ||
|
|
a27baf8ea1 | ||
|
|
cd2a7f6519 | ||
|
|
e0930a4019 | ||
|
|
203c115810 | ||
|
|
2885b08b35 | ||
|
|
a30a4c9652 | ||
|
|
e9682a4907 | ||
|
|
85eb1b5076 | ||
|
|
1b310378c4 | ||
|
|
01d481c618 | ||
|
|
cc21ddb5d0 | ||
|
|
9d740a86aa | ||
|
|
86c5570385 | ||
|
|
c63accec35 | ||
|
|
74a5d9cdd8 | ||
|
|
019c7fdd8b | ||
|
|
46f3302c7d | ||
|
|
8dd28bdc68 | ||
|
|
ed3df65de7 | ||
|
|
4646272bc0 | ||
|
|
aa334a09ec | ||
|
|
ed70fe2e6c | ||
|
|
8d90453a16 | ||
|
|
5a3c32cbf0 | ||
|
|
633d8b80b2 | ||
|
|
3c7116b01c | ||
|
|
8d3ffad856 | ||
|
|
57159b6017 | ||
|
|
afecaddbd3 | ||
|
|
496b420f34 | ||
|
|
6b1da0b2b5 | ||
|
|
d763760ddc | ||
|
|
2c9d550ba2 | ||
|
|
58fbea310e | ||
|
|
264dde2418 | ||
|
|
8df148bac2 | ||
|
|
c994dc7fc8 | ||
|
|
daf6357fc6 | ||
|
|
0c58d96ace | ||
|
|
af2bcec859 | ||
|
|
8e488b7527 | ||
|
|
964e9c12cf | ||
|
|
fa2033d129 | ||
|
|
495c5c63de | ||
|
|
a0b3e19657 | ||
|
|
76ccbe1622 | ||
|
|
7907a6ccb4 | ||
|
|
f2c159652c | ||
|
|
909331f485 | ||
|
|
7855a5b795 | ||
|
|
6ba64985da | ||
|
|
69d5973321 | ||
|
|
48597d14b4 | ||
|
|
5fd46596cb | ||
|
|
6d5fec7122 | ||
|
|
783e2254d6 | ||
|
|
0ab3735bf8 | ||
|
|
631800884f | ||
|
|
4e992e8909 | ||
|
|
a3e14d9641 | ||
|
|
6af8f6e242 | ||
|
|
05c94b0ce3 | ||
|
|
7bb0bbaa8c | ||
|
|
a6fb7c76c3 | ||
|
|
e1a8b7e90b | ||
|
|
705dd79908 | ||
|
|
ba739ee291 | ||
|
|
40ee74bb0b | ||
|
|
4d8a03d04a | ||
|
|
2fff970042 | ||
|
|
d8f39ea54f | ||
|
|
2d69e365bb | ||
|
|
24141d20d0 | ||
|
|
42c3fbe7e3 | ||
|
|
e1a77204d0 | ||
|
|
584f10c2c5 | ||
|
|
c0484509b8 | ||
|
|
8eb100fa52 | ||
|
|
5e4b1619f8 | ||
|
|
d8cf6d4ebb | ||
|
|
2b92e87ac6 | ||
|
|
a05468ffcf | ||
|
|
38ad323d39 | ||
|
|
359c63bfc7 | ||
|
|
70a7f318c8 | ||
|
|
a5f12bd5cc | ||
|
|
610eb93c6c | ||
|
|
c7b6f06b57 | ||
|
|
6c8268adb2 | ||
|
|
8e776d2b66 | ||
|
|
37da925746 | ||
|
|
49a00dea09 | ||
|
|
ce44815e8e | ||
|
|
a4c37283fc | ||
|
|
d191b6c0a1 | ||
|
|
0cfd5936c5 | ||
|
|
573ba81c1e | ||
|
|
de0616bd90 | ||
|
|
8f7fa76f87 | ||
|
|
7f9886044a | ||
|
|
becf8db5df | ||
|
|
c78784afef | ||
|
|
ac2c41b83f | ||
|
|
c38c69059e | ||
|
|
f83890077a | ||
|
|
a5922d9323 | ||
|
|
866dbe4be9 | ||
|
|
bd3c618303 | ||
|
|
ffbe648b7b | ||
|
|
e080d9e1ff | ||
|
|
af88617937 | ||
|
|
7bff4573e3 | ||
|
|
098370174a | ||
|
|
14facfdbb7 | ||
|
|
ddc9c7c6c3 | ||
|
|
63f4dcbf7c | ||
|
|
7f35c17f3e | ||
|
|
4f98f13971 | ||
|
|
651918f3e8 | ||
|
|
59c652d537 | ||
|
|
bc3687c4a5 | ||
|
|
05bb1daa42 | ||
|
|
6b6d55cd22 | ||
|
|
1361b9bda0 | ||
|
|
6644f846e8 | ||
|
|
98b5096eda | ||
|
|
0cc951e1a4 | ||
|
|
24910610c4 | ||
|
|
12fd210a41 | ||
|
|
7484249328 | ||
|
|
d034244735 | ||
|
|
3b837f7593 | ||
|
|
36419de6e7 | ||
|
|
b488c1b082 | ||
|
|
869cfcc024 | ||
|
|
81a7572e0a | ||
|
|
2ff73cd864 | ||
|
|
3ebdecc2d2 | ||
|
|
f0bfd6898f | ||
|
|
1b6528c9f1 | ||
|
|
f8f2e235f6 | ||
|
|
9086aa10e9 | ||
|
|
cfa8c389b2 | ||
|
|
c670809704 | ||
|
|
1f78793830 | ||
|
|
4e342e8481 | ||
|
|
b61455bb53 | ||
|
|
18c743403e | ||
|
|
b1fd062915 | ||
|
|
15b446ab04 | ||
|
|
0f61a79a6b | ||
|
|
9ebbf353f6 | ||
|
|
be25ff92a1 | ||
|
|
11ef81fdee | ||
|
|
ce08daf26f | ||
|
|
e2b5e9dd9d | ||
|
|
49bbcf7467 | ||
|
|
757cca20db | ||
|
|
b196d10ad3 | ||
|
|
3e92c2d2ad | ||
|
|
f1d495dfde | ||
|
|
a14b9c1b3b | ||
|
|
6cffef70f5 | ||
|
|
a5a8fa1f1e | ||
|
|
6c7102aa37 | ||
|
|
809e5b6724 | ||
|
|
5f75951717 | ||
|
|
8ff78faef0 | ||
|
|
2bd68fc928 | ||
|
|
288f07c709 | ||
|
|
1f199c1dfb | ||
|
|
51efac3d49 | ||
|
|
a9ccb8def6 | ||
|
|
b2dacc7a92 | ||
|
|
d769903e13 | ||
|
|
167af6a806 | ||
|
|
e2ad85b627 | ||
|
|
c1c1926751 | ||
|
|
40dbb2dd5e | ||
|
|
f206ac8d9b | ||
|
|
c93f52c810 | ||
|
|
fac1eda9fa | ||
|
|
5baa9d4709 | ||
|
|
78272586f8 | ||
|
|
86926f69d3 | ||
|
|
0ee7e2fa48 | ||
|
|
c3f7c0e3f8 | ||
|
|
1774564beb | ||
|
|
557ce7c3c6 | ||
|
|
a60fa0c2cb | ||
|
|
39e4f68e31 | ||
|
|
072c06e2bd | ||
|
|
e781690132 | ||
|
|
68b19966fc | ||
|
|
8b5db401e8 | ||
|
|
946fc08c43 | ||
|
|
f5cbb8cc48 | ||
|
|
44d6075e8c | ||
|
|
fa2ae8e884 | ||
|
|
36e3b6ec3a | ||
|
|
ba9a841466 | ||
|
|
5ebc738f29 | ||
|
|
045e9a224f | ||
|
|
a6e22daac1 | ||
|
|
6bdd48ffe3 | ||
|
|
5bb0f1f560 | ||
|
|
2d263d19e3 | ||
|
|
5e98f44b80 | ||
|
|
da6555f5cf | ||
|
|
937f3f7b03 | ||
|
|
71d065f0a9 | ||
|
|
b30071a69a | ||
|
|
407fc2a5ab | ||
|
|
de113f72ce | ||
|
|
6199e0056f | ||
|
|
fd6b176446 | ||
|
|
42d1202c18 | ||
|
|
d1a4656cb8 | ||
|
|
cc934ea766 | ||
|
|
8bf117b047 | ||
|
|
caea0b31cf | ||
|
|
1d6dd0d16d | ||
|
|
dca47362f8 | ||
|
|
2f8b666797 | ||
|
|
f3aef4f4ad | ||
|
|
5ac6249eb3 | ||
|
|
8acbee8000 | ||
|
|
bfd2ad8f0f | ||
|
|
a982db6bf5 | ||
|
|
3e6d266a33 | ||
|
|
e197cf97af | ||
|
|
97d89cc855 | ||
|
|
955cc0b4be | ||
|
|
1b4424393d | ||
|
|
3337769411 | ||
|
|
0b07779984 | ||
|
|
2e54d68fb2 | ||
|
|
e67c3e625d | ||
|
|
325fe07ab1 | ||
|
|
dc53fec17e | ||
|
|
17c3ed6635 | ||
|
|
f661f0c21e | ||
|
|
89d1394fb7 | ||
|
|
0cac006357 | ||
|
|
c9a5ea4dd1 | ||
|
|
79f7d83c4d | ||
|
|
56a1ac2862 | ||
|
|
8d0acd1436 | ||
|
|
b6628af72f | ||
|
|
0169970a8b | ||
|
|
9cc27dfa59 | ||
|
|
9221d59b7e | ||
|
|
22047bb890 | ||
|
|
772216505c | ||
|
|
3d7541f3c7 | ||
|
|
5a59eef925 | ||
|
|
dc05f59514 | ||
|
|
f64ab4ebab | ||
|
|
19497a7470 | ||
|
|
823690bc76 | ||
|
|
82930d7f02 | ||
|
|
fa9b90beb1 | ||
|
|
d4d0c64400 | ||
|
|
cb70117c05 | ||
|
|
3d9c45e126 | ||
|
|
516df0c70e | ||
|
|
4cd2aa7c3e | ||
|
|
e6943a7389 | ||
|
|
a43c90f357 | ||
|
|
91a42b4fac | ||
|
|
34689d9a05 | ||
|
|
528b264f87 | ||
|
|
0580e2dc34 | ||
|
|
91c985eca9 | ||
|
|
3bb34fa1c9 | ||
|
|
f539b334dd | ||
|
|
9cfe59b1a2 | ||
|
|
fb59bf9325 | ||
|
|
2b2ff2dae5 | ||
|
|
0dd7d78c07 | ||
|
|
33daf1d822 | ||
|
|
9faad636cb | ||
|
|
b201dca6cf | ||
|
|
f1982e56e3 | ||
|
|
439c3f3be5 | ||
|
|
37449925fa |
@@ -1,7 +1,7 @@
|
||||
;; -*- comment-column: 100; -*-
|
||||
{:config-paths ["macros" "src" "config"]
|
||||
:linters
|
||||
{ ;;
|
||||
{;;
|
||||
;; enabled optional linters (these are disabled by default)
|
||||
;;
|
||||
|
||||
@@ -178,6 +178,11 @@
|
||||
metabase.notification.models/create-notification!
|
||||
metabase.pulse.send/send-pulse!
|
||||
metabase-enterprise.transforms-python.python-runner-test/execute!
|
||||
;; Workspace isolation functions are safe in parallel tests because they operate on
|
||||
;; isolated, test-specific workspaces and databases that don't affect other tests
|
||||
metabase-enterprise.workspaces.isolation/destroy-workspace-isolation!
|
||||
metabase-enterprise.workspaces.isolation/ensure-database-isolation!
|
||||
metabase-enterprise.workspaces.test-util/create-ready-ws!
|
||||
;; Safe in tests, where we typically use a private, temporary index per thread.
|
||||
metabase.search.core/reindex!
|
||||
metabase.util/run-count!
|
||||
@@ -191,7 +196,7 @@
|
||||
:metabase/disallow-hardcoded-driver-names-in-tests
|
||||
;; don't include `:h2` since that will probably lead to a mountain of tests where we are specifically testing
|
||||
;; middleware or compilation behavior with just H2 since it's the default.
|
||||
{:drivers #{ ;; core drivers
|
||||
{:drivers #{;; core drivers
|
||||
#_:h2
|
||||
:postgres
|
||||
:mysql
|
||||
@@ -422,6 +427,7 @@
|
||||
metabase-enterprise.transforms.api-test/with-worker-runs clojure.core/let
|
||||
metabase-enterprise.transforms.test-util/with-transform-cleanup! clojure.core/let
|
||||
metabase-enterprise.transforms.execute/with-transform-lifecycle clojure.core/let
|
||||
metabase-enterprise.workspaces.test-util/with-workspaces! clojure.core/let
|
||||
metabase.actions.test-util/with-actions clojure.core/let
|
||||
metabase.api.common/let-404 clojure.core/let
|
||||
metabase.app-db.custom-migrations.util/with-temp-scheduler! clojure.core/fn
|
||||
|
||||
@@ -534,6 +534,11 @@
|
||||
metabase.driver.sql-jdbc.execute
|
||||
metabase.driver.sql-jdbc.execute.diagnostic
|
||||
metabase.driver.sql-jdbc.sync
|
||||
;; TODO (lbrdnk 2025/12/17): We should revisit addition of the following line. I've added it to make
|
||||
;; tests pass as we are using it directly with ee/workspaces module. I believe later, when workspaces
|
||||
;; are in better state quality wise, it should become part of the driver api and that should be used
|
||||
;; instead of calling directly into the normalize module.
|
||||
metabase.driver.sql.normalize
|
||||
metabase.driver.sql.query-processor
|
||||
metabase.driver.sql.util
|
||||
metabase.driver.sync
|
||||
@@ -2279,6 +2284,7 @@
|
||||
{:team "Querying"
|
||||
:api #{metabase-enterprise.transforms.api
|
||||
metabase-enterprise.transforms.core
|
||||
metabase-enterprise.transforms.execute
|
||||
metabase-enterprise.transforms.init
|
||||
metabase-enterprise.transforms.instrumentation
|
||||
metabase-enterprise.transforms.interface
|
||||
@@ -2312,6 +2318,7 @@
|
||||
app-db
|
||||
config
|
||||
driver
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
query-processor
|
||||
@@ -2326,7 +2333,25 @@
|
||||
models
|
||||
premium-features
|
||||
upload
|
||||
util}}}
|
||||
util}}
|
||||
|
||||
enterprise/workspaces
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.workspaces.api
|
||||
metabase-enterprise.workspaces.init}
|
||||
:uses #{api
|
||||
api-keys
|
||||
app-db
|
||||
driver
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
queries
|
||||
request
|
||||
system
|
||||
util
|
||||
enterprise/transforms}}}
|
||||
|
||||
;; namespaces matching these patterns (with `re-find`) are excluded from the module linter. Since regex literals
|
||||
;; aren't allowed in EDN just used the `(str <regex>)` version i.e. two slashes instead of one.
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
metabase-enterprise.test/with-gtaps! [[:inner 0]]
|
||||
metabase-enterprise.test/with-gtaps-for-user! [[:inner 0]]
|
||||
metabase-enterprise.test/with-user-attributes [[:block 2]]
|
||||
metabase-enterprise.workspaces.models.workspace-log/track! [[:block 2]]
|
||||
metabase.actions.actions-test/with-actions-test-data-and-actions-permissively-enabled [[:block 0]]
|
||||
metabase.actions.test-util/with-actions [[:block 1]]
|
||||
metabase.actions.test-util/with-actions-disabled [[:block 0]]
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
(eval . (put-clojure-indent 'u/prog1 1))
|
||||
(eval . (put-clojure-indent 'u/select-keys-when '(:form)))
|
||||
(eval . (put-clojure-indent 'with-meta '(:form)))
|
||||
(eval . (put-clojure-indent 'ws.log/track! 2))
|
||||
;; these ones have to be done with `define-clojure-indent' for now because of upstream bug
|
||||
;; https://github.com/clojure-emacs/clojure-mode/issues/600 once that's resolved we should use `put-clojure-indent'
|
||||
;; instead. Please don't add new entries unless they don't work with `put-clojure-indent'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# execute this in your shell to enable custom merge driver:
|
||||
# echo '\n[include]\npath=../.gitconfig' >> .git/config
|
||||
#
|
||||
# TODO (Bryan 12/12/25) -- reenable this when we can completely verify correctness
|
||||
# [merge "yaml-migrations"]
|
||||
# name = YAML migration changeset-aware merge
|
||||
# driver = mage -merge-yaml-migrations %O %A %B %L %P
|
||||
[merge "yaml-migrations"]
|
||||
name = YAML migration changeset-aware merge
|
||||
driver = bin/mage -merge-yaml-migrations %O %A %B %L %P
|
||||
|
||||
317
.github/scripts/generate-llms-txt.js
vendored
Normal file
@@ -0,0 +1,317 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const REPO = "metabase/metabase";
|
||||
const OUTPUT_FILE = "llms.txt";
|
||||
|
||||
// Sections to generate llms-<name>-full.txt for.
|
||||
// Each section can define a preamble (introductory content before the main docs).
|
||||
const LLMS_FULL_TO_GENERATE = [
|
||||
{
|
||||
section: "embedding",
|
||||
getPreamble: (branch) =>
|
||||
aboveVersion(branch, 57) ? getModularEmbeddingGotchaNotes() : "",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* List directory recursively to get markdown files
|
||||
*
|
||||
* @param {string} docsPath - full path to docs directory
|
||||
* @returns {Array} list of markdown file objects with fullPath and relativePath
|
||||
*/
|
||||
function getMarkdownFilesList(docsPath) {
|
||||
if (!fs.existsSync(docsPath)) {
|
||||
console.error(`Error: Docs directory '${docsPath}' does not exist`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(docsPath, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
return entries
|
||||
.filter((entry) => {
|
||||
return (
|
||||
entry.isFile() &&
|
||||
// Only markdown files
|
||||
entry.name.endsWith(".md") &&
|
||||
// Ignore README.md
|
||||
entry.name !== "README.md" &&
|
||||
// Ignore node_modules and hidden directories
|
||||
!entry.parentPath
|
||||
.split(path.sep)
|
||||
.some((dir) => dir === "node_modules" || dir.startsWith("."))
|
||||
);
|
||||
})
|
||||
.map((entry) => {
|
||||
const fullPath = path
|
||||
.join(entry.parentPath, entry.name)
|
||||
.replace(/\\/g, "/");
|
||||
|
||||
const relativePath = path
|
||||
.relative(docsPath, fullPath)
|
||||
.replace(/\\/g, "/");
|
||||
|
||||
return { fullPath, relativePath };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate llms.txt file with links to all markdown files in docs/
|
||||
*
|
||||
* @param {string} branch - branch name (e.g. 'master' or 'release-x.58.x')
|
||||
* @param {Array} files - list of all markdown file objects
|
||||
* @param {Array<Object>} sectionsConfig - list of section config objects to generate complete references for
|
||||
*/
|
||||
function generateLlmsTxt(branch, files, sectionsConfig) {
|
||||
const baseUrl = `https://raw.githubusercontent.com/${REPO}/refs/heads/${branch}`;
|
||||
|
||||
const filesToExtract = files
|
||||
// Files in embedding/sdk/api/snippets does not exist in the repo.
|
||||
.filter(
|
||||
(file) => !file.relativePath.startsWith("embedding/sdk/api/snippets"),
|
||||
);
|
||||
|
||||
const entries = filesToExtract.map((file) => {
|
||||
const content = fs.readFileSync(file.fullPath, "utf8");
|
||||
const title = extractTitle(content, file.relativePath);
|
||||
const url = `${baseUrl}/docs/${file.relativePath}`;
|
||||
|
||||
return { title, url, path: file.relativePath };
|
||||
});
|
||||
|
||||
// Generate table of contents entries
|
||||
const tableOfContents = entries.map(
|
||||
(entry) => `- [${entry.title}](${entry.url})`,
|
||||
);
|
||||
|
||||
// Generate complete references entries
|
||||
const completeReferences = sectionsConfig
|
||||
.filter((config) =>
|
||||
files.some((file) => file.relativePath.startsWith(`${config.section}/`)),
|
||||
)
|
||||
.map((config) => {
|
||||
const capitalized =
|
||||
config.section.charAt(0).toUpperCase() + config.section.slice(1);
|
||||
return `- [${capitalized} - Complete Reference](${baseUrl}/llms-${config.section}-full.txt)`;
|
||||
});
|
||||
|
||||
// Generate llms.txt content using template literal
|
||||
const content = `# Metabase Documentation
|
||||
Metabase - The simplest, fastest way to get business intelligence and analytics to everyone in your company.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
${tableOfContents.join("\n")}
|
||||
|
||||
## Complete References
|
||||
|
||||
These files are very large and are around 90,000 tokens. Do not use by default unless the context window is huge or RAG is supported in your editor.
|
||||
|
||||
${completeReferences.join("\n")}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(OUTPUT_FILE, content, "utf8");
|
||||
|
||||
console.log(
|
||||
`✅ Generated ${OUTPUT_FILE} with ${entries.length} markdown files`,
|
||||
);
|
||||
console.log(` Branch: ${branch}`);
|
||||
console.log(` Base URL: ${baseUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate llms-full.txt files for specified sections
|
||||
*
|
||||
* @param {string} branch - branch name (e.g., 'master', 'release-x.58.x')
|
||||
* @param {Array} files - list of all markdown file objects
|
||||
* @param {Array<Object>} sectionsConfig - list of section config objects to generate full files for
|
||||
*/
|
||||
function generateLlmsFullFiles(branch, files, sectionsConfig) {
|
||||
const baseUrl = `https://raw.githubusercontent.com/${REPO}/refs/heads/${branch}`;
|
||||
|
||||
sectionsConfig.forEach((config) => {
|
||||
generateLlmsFullTxt(branch, config, files, baseUrl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* LLMs are likely to pay attention to the very first lines.
|
||||
*
|
||||
* We add the most important context for LLMs to avoid
|
||||
* confusion and pitfalls like out-of-date APIs in trained data.
|
||||
*
|
||||
* @returns {string} Markdown formatted gotcha notes
|
||||
*/
|
||||
function getModularEmbeddingGotchaNotes() {
|
||||
return `> **Important Version Notes**
|
||||
>
|
||||
> Watch out for these deprecated props and gotchas for Metabase 57 onwards, for modular embedding:
|
||||
>
|
||||
> 1. \`config\` prop on MetabaseProvider no longer exist - it is replaced by \`authConfig\`.
|
||||
> 2. \`authProviderUri\` field no longer exist.
|
||||
> 3. \`jwtProviderUri\` optional field only exists in v58+. This is used to make JWT auth faster by skipping the \`GET /auth/sso\` discovery request. Not needed for initial implementation.
|
||||
> 4. \`fetchRequestToken\` is not needed by default. This is only used to customize how the SDK fetches the request token.
|
||||
> 5. Numeric IDs must be integers not strings, e.g. \`dashboardId={1}\`. When the ID is retrieved from the URL and it is numeric, convert it to an integer via \`parseInt\` before passing it to the SDK. IDs can also be strings for entity ids, so you should not parse all IDs as numbers if entity ids are also to be expected.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate llms-full.txt file for a specific section
|
||||
* @param {string} branch - The branch name
|
||||
* @param {Object} config - Section config object with section name and optional getPreamble function
|
||||
* @param {Array} files - Array of all markdown file objects
|
||||
* @param {string} baseUrl - Base URL for raw GitHub links
|
||||
*/
|
||||
function generateLlmsFullTxt(branch, config, files, baseUrl) {
|
||||
const { section, getPreamble } = config;
|
||||
|
||||
// Filter docs for this section
|
||||
const sectionFiles = files.filter((file) =>
|
||||
file.relativePath.startsWith(`${section}/`),
|
||||
);
|
||||
|
||||
if (sectionFiles.length === 0) {
|
||||
console.log(` Skipping llms-full.txt for ${section} (no files found)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build content
|
||||
const sectionCapitalized = section.charAt(0).toUpperCase() + section.slice(1);
|
||||
const header = `# Metabase ${sectionCapitalized} - Complete Reference for AI agents
|
||||
|
||||
> Table of contents: ${baseUrl}/${OUTPUT_FILE}
|
||||
|
||||
`;
|
||||
|
||||
// Add preamble if provided (e.g., version-specific notes, warnings)
|
||||
const preamble = getPreamble ? getPreamble(branch) : "";
|
||||
|
||||
// Concatenate all documents
|
||||
const docs = sectionFiles
|
||||
.map((file) => readAndCleanMarkdown(file.fullPath))
|
||||
.join("\n");
|
||||
|
||||
// Write llms-full.txt file at repo root
|
||||
const content = header + preamble + "\n" + docs;
|
||||
const llmsFullPath = `llms-${section}-full.txt`;
|
||||
fs.writeFileSync(llmsFullPath, content, "utf8");
|
||||
|
||||
console.log(
|
||||
`✅ Generated ${llmsFullPath} with ${sectionFiles.length} documents`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract title from markdown file frontmatter or filename.
|
||||
*
|
||||
* We try to extract the title in this order: 1) YAML frontmatter
|
||||
* 2) first H1 heading, and 3) the Markdown file name.
|
||||
*
|
||||
* Example Markdown and frontmatter format:
|
||||
* ```markdown
|
||||
* ---
|
||||
* title: "Getting Started"
|
||||
* description: "A guide"
|
||||
* ---
|
||||
* # Getting Started
|
||||
* ```
|
||||
*
|
||||
* @param {string} content - file content
|
||||
* @param {string} filePath - relative file path
|
||||
* @returns {string} title of content
|
||||
*/
|
||||
function extractTitle(content, filePath) {
|
||||
// Try to extract title from YAML frontmatter
|
||||
// First, match against the YAML frontmatter delimiter `---`
|
||||
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
||||
|
||||
if (frontmatterMatch) {
|
||||
// Then, match against the title property
|
||||
// Example: `title: "My Page"`
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const titleMatch = frontmatter.match(/^title:\s*(.+)$/m);
|
||||
|
||||
// Remove quotes if present
|
||||
if (titleMatch) {
|
||||
return titleMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract title from first H1 heading
|
||||
// Example: `# My Page`
|
||||
const h1Match = content.match(/^#\s+(.+)$/m);
|
||||
|
||||
if (h1Match) {
|
||||
return h1Match[1].trim();
|
||||
}
|
||||
|
||||
// Fallback to filename, e.g. `my-page.md`
|
||||
const filename = path.basename(filePath, ".md");
|
||||
|
||||
// Convert kebab-case or snake_case to Title Case
|
||||
// `my-page.md` would become "My Page"
|
||||
return filename
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and clean markdown file content
|
||||
*
|
||||
* @param {string} filePath - full path to the markdown file
|
||||
* @returns {string} cleaned markdown content with separator
|
||||
*/
|
||||
function readAndCleanMarkdown(filePath) {
|
||||
let content = fs.readFileSync(filePath, "utf8");
|
||||
|
||||
// Strip YAML frontmatter
|
||||
content = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
|
||||
|
||||
// Strip Jekyll/Liquid template syntax
|
||||
content = content.replace(/\{%.*?%\}/g, "");
|
||||
content = content.replace(/\{\{.*?\}\}/g, "");
|
||||
|
||||
return `${content.trim()}\n\n---\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if branch version is above target version
|
||||
* @param {string} branch - Branch name (e.g., 'master', 'release-x.58.x')
|
||||
* @param {number} targetVersion - Target version number
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function aboveVersion(branch, targetVersion) {
|
||||
if (branch === "master") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse version from release branch like "release-x.58.x" -> 58
|
||||
const match = branch.match(/release-x\.(\d+)\.x/);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const version = parseInt(match[1], 10);
|
||||
|
||||
return version >= targetVersion;
|
||||
}
|
||||
|
||||
// Main execution
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const docsPath = args[0] || "docs";
|
||||
const branch = args[1] || "master";
|
||||
|
||||
const resolvedDocsPath = path.resolve(docsPath);
|
||||
const markdownFiles = getMarkdownFilesList(resolvedDocsPath);
|
||||
|
||||
// Sort all files upfront for consistent ordering across all generated files
|
||||
markdownFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
|
||||
generateLlmsTxt(branch, markdownFiles, LLMS_FULL_TO_GENERATE);
|
||||
generateLlmsFullFiles(branch, markdownFiles, LLMS_FULL_TO_GENERATE);
|
||||
}
|
||||
193
.github/workflows/llms-txt-generate.yml
vendored
Normal file
@@ -0,0 +1,193 @@
|
||||
name: Generate llms.txt file
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Metabase version (e.g. v0.57.0)'
|
||||
type: string
|
||||
required: true
|
||||
branch:
|
||||
description: 'Target branch (e.g. release-x.57.x)'
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Metabase version (e.g. v0.57.0)'
|
||||
type: string
|
||||
required: true
|
||||
branch:
|
||||
description: 'Target branch (e.g. release-x.57.x)'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check if minor or major release
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
should_generate: ${{ steps.check.outputs.should_generate }}
|
||||
target_branch: ${{ steps.check.outputs.target_branch }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- name: Check version
|
||||
id: check
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
BRANCH="${{ inputs.branch }}"
|
||||
|
||||
# Extract version number (e.g., v0.57.0 -> 0.57.0)
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
|
||||
# Extract patch version (last number)
|
||||
PATCH_VERSION=$(echo "$VERSION_NUM" | cut -d. -f3)
|
||||
|
||||
# Only proceed if this is a minor or major release (patch version is 0)
|
||||
# Note: This is a safety check even though the workflow should only be called
|
||||
# for minor/major releases, just in case the workflow is dispatched manually.
|
||||
if [ "$PATCH_VERSION" != "0" ]; then
|
||||
echo "⏭️ This is a patch release ($VERSION). Skipping llms.txt generation."
|
||||
echo "should_generate=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "✅ This is a minor or major release ($VERSION). Proceeding with llms.txt generation for branch: $BRANCH"
|
||||
echo "should_generate=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
echo "target_branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
generate-llms-txt:
|
||||
name: Generate llms.txt
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_generate == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
|
||||
- name: Get the generate-llms-txt.js node script via sparse checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
ref: ${{ github.ref }}
|
||||
sparse-checkout: |
|
||||
.github/scripts
|
||||
path: workflow-scripts
|
||||
|
||||
- name: Checkout target branch for docs
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
ref: ${{ needs.check-version.outputs.target_branch }}
|
||||
path: metabase
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Generate llms.txt
|
||||
id: generate
|
||||
run: |
|
||||
node workflow-scripts/.github/scripts/generate-llms-txt.js metabase/docs ${{ needs.check-version.outputs.target_branch }}
|
||||
|
||||
- name: Sanity check generated files
|
||||
run: |
|
||||
if [ ! -f "llms.txt" ]; then
|
||||
echo "❌ Error: llms.txt was not generated"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "llms-embedding-full.txt" ]; then
|
||||
echo "❌ Error: llms-embedding-full.txt was not generated"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check that llms.txt contains URLs with the expected GitHub raw URL prefix
|
||||
URL_COUNT=$(grep -o "https://raw.githubusercontent.com/metabase/metabase/refs/heads/${{ needs.check-version.outputs.target_branch }}/docs" llms.txt | wc -l)
|
||||
if [ "$URL_COUNT" -lt 10 ]; then
|
||||
echo "❌ Error: llms.txt appears to be malformed. Found only $URL_COUNT URLs (expected at least 10)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Both llms.txt and llms-embedding-full.txt were generated successfully"
|
||||
echo "✅ llms.txt contains $URL_COUNT documentation URLs"
|
||||
|
||||
- name: Set up git configuration
|
||||
working-directory: metabase
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
BRANCH_NAME="llms-txt/auto-update-${{ needs.check-version.outputs.target_branch }}-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
echo "Branch name will be: $BRANCH_NAME"
|
||||
|
||||
- name: Copy generated files to metabase repo
|
||||
run: |
|
||||
cp llms.txt metabase/
|
||||
cp llms-embedding-full.txt metabase/
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create_pr
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
path: metabase
|
||||
branch: ${{ env.BRANCH_NAME }}
|
||||
base: ${{ needs.check-version.outputs.target_branch }}
|
||||
commit-message: |
|
||||
Update llms.txt and llms-full.txt (${{ needs.check-version.outputs.target_branch }})
|
||||
|
||||
Regenerated llms.txt to include all markdown files from the docs/ directory.
|
||||
Also generated llms-full.txt files for the embedding section.
|
||||
|
||||
🤖 Generated automatically by llms-txt-generate workflow
|
||||
|
||||
Co-Authored-By: GitHub Actions <actions@github.com>
|
||||
committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
title: "[chore] Update llms.txt for ${{ needs.check-version.outputs.target_branch }}"
|
||||
body: |
|
||||
## Summary
|
||||
This PR contains automatically generated updates to `llms.txt` and `llms-embedding-full.txt` files, triggered by a minor or major release.
|
||||
|
||||
## Changes
|
||||
- The `llms.txt` file has been regenerated to include all markdown files from the `docs/` directory.
|
||||
- The `llms-embedding-full.txt` file has been generated for the embedding section (concatenated documentation for RAG).
|
||||
|
||||
## Generation
|
||||
These files were generated using:
|
||||
- Script: `.github/scripts/generate-llms-txt.js`
|
||||
- Branch: `${{ needs.check-version.outputs.target_branch }}`
|
||||
- Base URL: `https://raw.githubusercontent.com/metabase/metabase/refs/heads/${{ needs.check-version.outputs.target_branch }}`
|
||||
|
||||
Please review the changes and merge if they look correct.
|
||||
|
||||
🤖 Generated automatically by the `llms-txt-generate` workflow.
|
||||
|
||||
Co-Authored-By: GitHub Actions <actions@github.com>
|
||||
labels: |
|
||||
Type:Documentation
|
||||
no-backport
|
||||
add-paths: |
|
||||
llms.txt
|
||||
llms-embedding-full.txt
|
||||
|
||||
- name: Auto approve PR
|
||||
if: steps.create_pr.outputs.pull-request-number != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr review "${{ steps.create_pr.outputs.pull-request-number }}" --approve --body "Auto-approved by llms-txt-generate workflow"
|
||||
|
||||
- name: Auto merge PR
|
||||
if: steps.create_pr.outputs.pull-request-number != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: |
|
||||
gh pr merge "${{ steps.create_pr.outputs.pull-request-number }}" --auto --squash
|
||||
|
After Width: | Height: | Size: 16 KiB |
BIN
.loki/reference/chrome_laptop_viz_PieChart_Dark_Theme.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
.loki/reference/chrome_laptop_viz_PieChart_Embedded_Dashcard.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
.loki/reference/chrome_laptop_viz_PieChart_Embedded_Question.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
@@ -24,6 +24,9 @@
|
||||
"dev.failsafe" {"failsafe" {:resource "LICENSE"}}
|
||||
"colorize" {"colorize" {:resource "EPL.txt"}}
|
||||
"com.github.jnr" {"jffi$native" {:resource "apache2_0.txt"}}
|
||||
"com.google.api" {"gax" {:resource "BSD.txt"}
|
||||
"gax-grpc" {:resource "BSD.txt"}
|
||||
"gax-httpjson" {:resource "BSD.txt"}}
|
||||
"com.google.api-client" {"google-api-client" {:resource "apache2_0.txt"}}
|
||||
"com.google.auto.value" {"auto-value-annotations" {:resource "apache2_0.txt"}}
|
||||
"com.google.http-client" {"google-http-client" {:resource "apache2_0.txt"}
|
||||
@@ -60,6 +63,7 @@
|
||||
"net.thisptr" {"jackson-jq" {:resource "apache2_0.txt"}}
|
||||
"org.antlr" {"antlr-runtime" {:resource "BSD.txt"}
|
||||
"antlr4-runtime" {:resource "BSD.txt"}}
|
||||
"org.codehaus.mojo" {"animal-sniffer-annotations" {:resource "MIT.txt"}}
|
||||
"org.eclipse.jgit" {"org.eclipse.jgit" {:resource "BSD.txt"}}
|
||||
"org.eclipse.jetty" {"jetty-webapp" {:resource "EPL.txt"}}
|
||||
"org.eclipse.jetty.toolchain" {"jetty-jakarta-servlet-api" {:resource "EPL.txt"}}
|
||||
|
||||
@@ -144,6 +144,18 @@
|
||||
change-log
|
||||
#(pos? (compare % "v49.00-000"))))
|
||||
|
||||
(defn require-primary-key-exists-has-table-name
|
||||
"Ensures that all primaryKeyExists preconditions specify a tableName."
|
||||
[change-log]
|
||||
(doseq [{:keys [changeSet]} change-log
|
||||
:when changeSet
|
||||
:let [s (pr-str (:preConditions changeSet))]
|
||||
:when (and (str/includes? s ":primaryKeyExists")
|
||||
(not (str/includes? s ":tableName")))]
|
||||
(throw (validation-error
|
||||
(format "Migration '%s' has primaryKeyExists precondition without tableName" (:id changeSet))
|
||||
{:id (:id changeSet)}))))
|
||||
|
||||
(s/def ::changeSet
|
||||
(s/spec :change-set.strict/change-set))
|
||||
|
||||
@@ -163,6 +175,7 @@
|
||||
(require-no-bare-blob-or-text-types change-log)
|
||||
(require-no-bare-boolean-types change-log)
|
||||
(require-no-datetime-type change-log)
|
||||
(require-primary-key-exists-has-table-name change-log)
|
||||
(let [{:keys [changeSet]
|
||||
:as all} (group-by only-key change-log)]
|
||||
(when-not (set/subset? (set (keys all)) #{:property :objectQuotingStrategy :changeSet})
|
||||
|
||||
13
deps.edn
@@ -36,7 +36,7 @@
|
||||
com.clearspring.analytics/stream {:mvn/version "2.9.8" ; Various sketching algorithms
|
||||
:exclusions [it.unimi.dsi/fastutil
|
||||
org.slf4j/slf4j-api]}
|
||||
com.clojure-goes-fast/jvm-alloc-rate-meter{:mvn/version "0.1.4"} ; Monitor heap allocation rate.
|
||||
com.clojure-goes-fast/jvm-alloc-rate-meter {:mvn/version "0.1.4"} ; Monitor heap allocation rate.
|
||||
com.clojure-goes-fast/jvm-hiccup-meter {:mvn/version "0.1.1"} ; Monitor GC pauses and other system-induced pauses.
|
||||
com.draines/postal {:mvn/version "2.0.5" ; SMTP library
|
||||
:exclusions [com.sun.mail/jakarta.mail]}
|
||||
@@ -45,7 +45,7 @@
|
||||
com.github.albfernandez/juniversalchardet {:mvn/version "2.5.0"}
|
||||
com.github.blagerweij/liquibase-sessionlock {:mvn/version "1.6.9"} ; session lock for liquibase migrations
|
||||
com.github.jknack/handlebars ^:antq/exclude ; 4.4.0 requires java 17+
|
||||
{:mvn/version "4.3.1"} ; templating engine
|
||||
{:mvn/version "4.3.1"} ; templating engine
|
||||
com.github.oliyh/martian-clj-http {:mvn/version "0.2.1"} ; openapi client
|
||||
com.github.seancorfield/honeysql {:mvn/version "2.7.1350"} ; Honey SQL 2. SQL generation from Clojure data maps
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.1070"} ; Talk to JDBC DBs
|
||||
@@ -53,12 +53,12 @@
|
||||
com.github.vertical-blank/sql-formatter {:mvn/version "2.0.5"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter
|
||||
com.google.guava/guava {:mvn/version "33.5.0-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697
|
||||
com.h2database/h2 ^:antq/exclude ; https://metaboat.slack.com/archives/CKZEMT1MJ/p1727191010259979
|
||||
{:mvn/version "2.1.214"} ; embedded SQL database
|
||||
{:mvn/version "2.1.214"} ; embedded SQL database
|
||||
com.ibm.icu/icu4j {:mvn/version "78.1"} ; Used to transliterate Unicode characters into Latin characters
|
||||
com.knuddels/jtokkit {:mvn/version "1.1.0"} ; Java tokenizer library for OpenAI models, used for token counting in semantic search
|
||||
com.mchange/c3p0 {:mvn/version "0.11.2"} ; DB connection pool
|
||||
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
|
||||
{:mvn/version "1.0.1"} ; Snowplow analytics
|
||||
{:mvn/version "1.0.1"} ; Snowplow analytics
|
||||
com.sun.mail/jakarta.mail ^:antq/exclude ; 2+ changes imports from javax.mail to jakarta.mail
|
||||
{:mvn/version "1.6.8"} ; SMTP lib, required by postal
|
||||
com.taoensso/encore {:mvn/version "3.158.0"} ; Utilities library (used by Nippy)
|
||||
@@ -88,7 +88,7 @@
|
||||
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
;; TODO: replace back to normal artifact
|
||||
io.github.metabase/macaw {:mvn/version "0.2.29"} ; Parse native SQL queries
|
||||
io.github.metabase/macaw {:mvn/version "0.2.31"} ; Parse native SQL queries
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
|
||||
junegunn/grouper {:mvn/version "0.1.1"} ; Batch processing helper
|
||||
kixi/stats {:mvn/version "0.5.7" ; Various statistic measures implemented as transducers
|
||||
@@ -891,8 +891,7 @@
|
||||
{:exec-args {:only ["enterprise/backend/test/metabase_enterprise/transforms"
|
||||
"enterprise/backend/test/metabase_enterprise/transforms_python"]}}
|
||||
|
||||
|
||||
;; test all MBQL related stuff: MLv2, the legacy shared `metabase.legacy-mbql` code, and the QP
|
||||
;; test all MBQL related stuff: MLv2, the legacy shared `metabase.legacy-mbql` code, and the QP
|
||||
;;
|
||||
;; clj -X:dev:ee:ee-dev:test:test/mbql
|
||||
:test/mbql
|
||||
|
||||
@@ -8,6 +8,11 @@ title: Driver interface changelog
|
||||
|
||||
- Added `sql-jdbc.execute/db-type-name` multimethod. Override this if something more than the default is needed in your sql-jdbc-based driver. See the `:mysql` implementation as an example.
|
||||
|
||||
- Added workspace isolation multimethods for enterprise workspaces feature:
|
||||
- `init-workspace-isolation!` - Initialize database isolation for a workspace (create isolated schema/database and user credentials)
|
||||
- `destroy-workspace-isolation!` - Destroy all database resources created for workspace isolation
|
||||
- `grant-workspace-read-access!` - Grant read access on specified tables to a workspace's isolated user
|
||||
|
||||
## Metabase 0.58.0
|
||||
|
||||
- Added a `:collate` feature for drivers that support collation settings on text fields
|
||||
|
||||
BIN
docs/embedding/images/create-shared-collection.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/embedding/images/edit-tenant-strategy.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
docs/embedding/images/edit-tenant.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/embedding/images/new-tenant.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
docs/embedding/images/slug-attribute.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/embedding/images/tenant-collections.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/embedding/images/tenant-groups.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/embedding/images/tenants.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
1264
docs/embedding/llms.txt
Normal file
421
docs/embedding/tenants.md
Normal file
@@ -0,0 +1,421 @@
|
||||
---
|
||||
title: Tenants
|
||||
summary: Tenants let you serve multiple customers from a single Metabase while keeping their data isolated. Configure tenant users, groups, and shared collections, then use row-level security or impersonation to ensure each tenant only sees their own data.
|
||||
---
|
||||
|
||||
# Tenants
|
||||
|
||||
{% include plans-blockquote.html feature="Tenants" is_plural=true %}
|
||||
|
||||
A Tenant is a named set of attributes you can assign to a user to isolate them from other tenants. If you're building a SaaS app with embedded Metabase dashboards, you can assign the customers of your SaaS to tenants.
|
||||
|
||||
The big advantage of grouping users into tenants is that you can use the same content and group permissions for all tenants, so you don't have to create all new stuff for each tenant, and each tenant only ever sees their own data.
|
||||
|
||||

|
||||
|
||||
You can use tenants to:
|
||||
|
||||
- Simplify bulk permissions through tenant groups and attributes.
|
||||
- Create collections of shared assets to avoid duplicating dashboards.
|
||||
- Provision tenants with SSO.
|
||||
|
||||
To get started with tenants:
|
||||
|
||||
1. [Get familiar with tenant concepts](#concepts).
|
||||
2. [Enable tenants](#enable-multi-tenant-strategy).
|
||||
3. Create a [tenant](#create-new-tenants-in-metabase) and [tenant users](#create-tenant-users-in-metabase), or [provision them with SSO](#provisioning-and-assigning-tenants-with-jwt).
|
||||
4. [Create shared collections](#create-shared-collections-for-tenants).
|
||||
5. Set up [data](#data-permission-overview) and [collection](#collection-permissions-for-tenants) permissions.
|
||||
|
||||
## Concepts
|
||||
|
||||
**Tenant** is an abstraction representing groups of users that share some properties but should be isolated from other users. For example, if you're building a SaaS app with embedded Metabase dashboards, the customers of your SaaS are tenants.
|
||||
|
||||
While working with tenants in Metabase, you'll encounter different user and group types, and some special collection types. Let's establish some terminology.
|
||||
|
||||
### User types
|
||||
|
||||
- **Tenant users** are the end users. In a B2B SaaS context, these are the your customer's users. Usually tenant users interact with Metabase through an intermediate app (for example, through an embedded dashboard).
|
||||
|
||||
- **Internal users** are "normal" Metabase users who don't belong to any tenant. Think Metabase admins, or devs building the dashboards that will be shared with tenants.
|
||||
|
||||
### Group types
|
||||
|
||||
Tenant users and and internal users can be organized into Metabase [groups](../people-and-groups/managing.md#groups).
|
||||
|
||||

|
||||
|
||||
- **All tenant users** is a special group representing all individual end users across all tenants.
|
||||
|
||||
This group can be used to [configure permissions](#data-permissions-for-tenants) for all tenant users. If you need more granular permission controls within each tenant, you can also create tenant groups.
|
||||
|
||||
- **Tenant groups** can be used to create additional permission levels for tenant users.
|
||||
|
||||
For example, if you're making recruiting software, every end user can be a recruiter (with access to all data about all open roles) or a hiring manager (with access to analytics for a specific role). So in Metabase, you can create two tenant groups - "Recruiters" and "Hiring managers" and configure appropriate permissions.
|
||||
|
||||
Every tenant will be able to use tenant groups (so for example, every customer can have "Recruiters" and "Hiring managers").
|
||||
|
||||
- **All internal users** is a special group for all people who _aren't_ members of a tenant.
|
||||
|
||||
These are the people working directly in Metabase. This group is equivalent to "All users" group when multi-tenancy is not enabled.
|
||||
|
||||
- **Internal groups** are additional groups for internal users.
|
||||
|
||||
For example, you might have a special group for your internal group "Analytics developers" who only have access to create dashboards that will be later shared with tenants, but don't have full admin access to your whole Metabase.
|
||||
|
||||
Tenant users are completely isolated from internal users: tenant users can't be added to internal user groups, and internal users can't be added to tenant groups.
|
||||
|
||||
### Collection types
|
||||
|
||||
Collections are like folders that can contain charts, dashboard, and models. They also serve as an organizational unit for permission management: if a certain group of people should have access to a certain set of assets, you should put those assets into a collection.
|
||||
|
||||

|
||||
|
||||
- **Shared collections** contain dashboards and charts that are shared between all tenants.
|
||||
|
||||
For example, every tenant of a recruiting app should be able to see the number of job applications by date. You can create a [Metabase question](../questions/start.md) for "Count of applications by date" and save it into a shared collection.
|
||||
|
||||
You'll need to [configure data permissions](#data-permissions-for-tenants) so that each tenant only sees _their_ job applications, and not _everyone's_ job applications (that would be bad).
|
||||
|
||||
You can create many shared collections (or no shared collections at all, for that matter). For example, you can have a shared collection for analytics around recruitment and a separate shared collection for analytics around interviews.
|
||||
|
||||
Shared collections can be optionally [synced to GitHub](#sync-shared-collections-to-github).
|
||||
|
||||
- **Tenant collections** are collection specific to each tenant. They are created automatically for each tenant.
|
||||
|
||||
If you have special customers with bespoke analytics that only this customer gets, you can put those bespoke dashboards and charts into the tenant's collection. Tenant collections can also serve as a place for the tenant users to create and save new questions that can be shared with their fellow tenant users (but not with users from other tenants).
|
||||
|
||||
- **Internal collections**. Internal collections are exclusively an internal user concern. These are "normal" collections where you and other users internal to your Metabase can put the stuff that you don't want your end users to see (dashboards in development, internal metrics, maybe even analytics _about_ your tenants). Tenant users can't access any internal collections.
|
||||
|
||||
- **Personal collection**. Every Metabase user, including tenant users, gets a personal collection - their own space to save new questions and dashboards (of course, if they otherwise have the permissions to build and save new questions).
|
||||
|
||||
See [Collection permissions](#collection-permissions-for-tenants) for configuring access to different collection types.
|
||||
|
||||
## End user experience
|
||||
|
||||
End users who are members of tenants will not know that they are members of tenants.
|
||||
In the experiences that expose users to to Metabase collections (e.g. when using full-app embedding, modular embedding components with save enabled, or if tenant users are logging in directly into metabase), tenant users see all the tenant and shared collections they have access to as just collections.
|
||||
|
||||
### Enable multi-tenant strategy
|
||||
|
||||
_Admin setting > People_
|
||||
|
||||
You can create and manage you tenants exclusively through Metabase UI, or, if that's not your jam, [through SSO](#provisioning-and-assigning-tenants-with-jwt). Regardless of how you manage your tenants, you'll need to enable multi-tenant strategy in Metabase first.
|
||||
|
||||

|
||||
|
||||
1. Go to **Admin settings > People**.
|
||||
2. Click on the **gear** icon above the list of people.
|
||||
3. Choose **Multi-tenant strategy**.
|
||||
|
||||
Changing Metabase to multi-tenant strategy enables special [user](#user-types) and [collection](#collection-types) types. You can create new tenants, tenant groups, and collection, and you get some additional admin settings in the People and Permissions tabs.
|
||||
|
||||
If you have an existing permissions and collection setup that you'd like to translate to use tenants, see [Changing tenant strategy](#changing-tenant-strategy).
|
||||
|
||||
Once you enable multi-tenant strategy, keep in mind that switching _from_ multi-tenant to single-tenant is a destructive action: all your tenant users and your tenant and shared collections will be disabled. See [Changing tenant strategy](#changing-tenant-strategy).
|
||||
|
||||
## Create new tenants in Metabase
|
||||
|
||||
_Admin setting > People > Tenant_
|
||||
|
||||

|
||||
|
||||
To create new tenants in Metabase:
|
||||
|
||||
1. [Enable multi-tenant strategy](#enable-multi-tenant-strategy), if you haven't yet.
|
||||
2. Go to **Admin settings > People** .
|
||||
3. Select **Tenants** on the left sidebar and click **New tenant**
|
||||
4. Fill out the information for the tenant:
|
||||
- **Tenant name**: Display name for the tenant that will be displayed to _internal_ users. Not exposed to external users. This name can be changed later.
|
||||
- **Tenant slug**: unique identifier of the tenant. It can be used to [match JWT claims](#use-tenant-claim-to-sign-in-users) and for setting up [data permissions](#use-tenant-attributes-for-data-permissions). See [Tenant slug](#special-tenant-slug-attribute) for more information.
|
||||
- **Tenant attributes**: you can define tenant attributes that will be inherited by every tenant user, see [Tenant attributes](#tenant-attributes).
|
||||
|
||||
You can avoid manually setting up tenants in Metabase by [provisioning tenants with JWT](#provisioning-and-assigning-tenants-with-jwt).
|
||||
|
||||
## Create tenant groups
|
||||
|
||||
_Admin setting > People > Tenant groups_
|
||||
|
||||
Tenant groups are applicable across tenants. For example, you can have tenant groups "Basic users" and "Premium users", and every tenant will be able to use those groups. Groups can be used to configure permissions so that, among the tenant's users, some get Basic permissions while others get Premium. See [Tenant concepts](#concepts) for more information.
|
||||
|
||||
To create a tenant group:
|
||||
|
||||
1. [Enable multi-tenant strategy](#enable-multi-tenant-strategy), if you haven't yet.
|
||||
2. Go to **Admin settings > People**.
|
||||
3. Select **Tenant groups** on the left sidebar and click **Create a group**.
|
||||
4. Name your group.
|
||||
|
||||
To add people to tenant groups, see [Add people to groups](../people-and-groups/managing.md#adding-people-to-groups).
|
||||
|
||||
## Create tenant users in Metabase
|
||||
|
||||
_Admin setting > People > Tenant users_
|
||||
|
||||
Tenant users are the end users in tenants. In a B2B SaaS context, these are the your customer's users. Usually tenant users interact with Metabase through an intermediate app (for example, through an embedded dashboard).
|
||||
To add tenant users in Metabase:
|
||||
|
||||
1. [Create a tenant](#create-tenant-users-in-metabase).
|
||||
2. Go to **Admin settings > People** .
|
||||
3. Select **Tenants users** on the left sidebar and click **New tenant user**.
|
||||
4. Fill out the user information, including the tenant and tenant groups.
|
||||
|
||||
If your tenant has [tenant attributes](#tenant-attributes), they'll be inherited by the user, but you can override the value in "Attributes".
|
||||
|
||||
You can also [provision tenant users with JWT](#tenant-attributes).
|
||||
|
||||
## Create shared collections for tenants
|
||||
|
||||

|
||||
|
||||
Shared collections contain dashboards and charts that are shared between all tenants. If you're using shared collections, make sure that you configure [data permissions](#data-permissions-for-tenants) so that tenants can only see _their data_ in the shared collections. See [Tenant concepts](#concepts) for more information.
|
||||
|
||||
To create a shared collection:
|
||||
|
||||
1. [Enable multi-tenant strategy](#enable-multi-tenant-strategy), if you haven't yet.
|
||||
2. Open Metabase navigation sidebar by clicking on the **three lines** in top left (that's regular Metabase, not Admin settings).
|
||||
3. You should see "External collections" in the sidebar. If you don't make sure you have enabled multi-tenant strategy.
|
||||
4. Click on the **+** next to "External collections" to create a shared collection.
|
||||
|
||||
You can have multiple shared collections and nested shared collections. You can also [sync shared collections to GitHub](#sync-shared-collections-to-github).
|
||||
|
||||
## Sync shared collections to GitHub
|
||||
|
||||
_Admin setting > Remote Sync_
|
||||
|
||||
You can set up [Remote sync](../installation-and-operation/remote-sync.md) for shared collections. This means you'll be able to develop shared content in one Metabase, push it to a GitHub repo, and then have the shared content in your production Metabase always synced with that repo. See [Remote sync docs](../installation-and-operation/remote-sync.md) for more details.
|
||||
|
||||
## Tenant attributes
|
||||
|
||||
_Admin setting > People > Tenants_
|
||||
|
||||
You can create tenant-level [user attributes](#tenant-attributes) which all users of the tenant inherit. This is useful for configuring attribute-based data permissions like [row-level security](../permissions/row-and-column-security.md), [impersonation](../permissions/impersonation.md), or [database routing](../permissions/database-routing.md).
|
||||
|
||||

|
||||
|
||||
To add a tenant attribute:
|
||||
|
||||
1. Go to **Admin setting > People**
|
||||
2. Select **Tenants** on the left sidebar.
|
||||
3. Click on **three dots** next to the tenant.
|
||||
4. Input the attribute key and value.
|
||||
|
||||
Once you add a tenant attribute, all users of that tenant will inherit the attribute, but the value can overridden for any particular user, see [Edit user attributes](../people-and-groups/managing.md#adding-a-user-attribute).
|
||||
|
||||
Currently, you can't assign custom tenant attributes with SSO. The only way to assign attributes is through the Metabase UI (but you can provision attributes for _individual users_ through SSO, see [JWT user attributes](../people-and-groups/authenticating-with-jwt.md)). However, if you're using user attribute to set permissions, then you can use the [special slug attribute](#special-tenant-slug-attribute) which Metabase creates automatically.
|
||||
|
||||
### Special tenant slug attribute
|
||||
|
||||
Each tenant user will get system-defined attribute `@tenant.slug` that corresponds to the slug of the tenant. For example, if you created a tenant "Meowdern Solutions" with the slug `meowdern_solutions`, then every user from this tenant will get a special attribute `@tenant.slug : "meowdern_solutions"`.
|
||||
|
||||

|
||||
|
||||
If you create Metabase tenants through Metabase UI, you can choose the slug when creating the tenant. If you're [using JWT to provision tenants](#provisioning-and-assigning-tenants-with-jwt), tenant slug is the value of the `@tenant` claim for JWT (or another tenant assignment attribute you selected). Slug cannot be changed later.
|
||||
|
||||
The special `@tenant.slug` attribute can be used just like a normal attribute to configure attribute-based permissions like [row-level security](../permissions/row-and-column-security.md), [impersonation](../permissions/impersonation.md), or [database routing](../permissions/database-routing.md). Your chosen tenant slug should correspond to how the tenant is actually identified in your setup.
|
||||
|
||||
For example, if you want to use row-level security, and tenants are identified in your tables by their IDs (instead of names), then your tenant slug should be an ID as well.
|
||||
|
||||
For example, if your data looks like this
|
||||
|
||||
```
|
||||
| Customer ID | Order number | Order date | Order total |
|
||||
| ----------- | ------------ | ---------- | ----------- |
|
||||
| 175924 | 3 | 2025-10-13 | 175.34 |
|
||||
| 680452 | 7 | 2025-10-13 | 34.56 |
|
||||
```
|
||||
|
||||
and you want to enforce row-level security by `Customer ID`, then the tenant slug should have the form `175924` so that it could be matched to the Customer ID in your table.
|
||||
|
||||
Similarly, if you want to use tenant slug for impersonation, you'll have to map the tenant slug to a database role, and for database routing - to a database.
|
||||
|
||||
## Provisioning and assigning tenants with JWT
|
||||
|
||||
### Use tenant claim to sign in users
|
||||
|
||||
You can [set up JWT SSO](../people-and-groups/authenticating-with-jwt.md) and use the JWT to sign in tenant users.
|
||||
|
||||
Once you [enable multi-tenant user strategy](#enable-multi-tenant-strategy), Metabase will look for a `@tenant` claim in your JWT to determine if the user is a tenant user, and which tenant the user belongs to. The value of `@tenant` key should be the tenant's slug. Here's an example of a JWT claim to sign in a tenant user:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "mittens@example.com",
|
||||
"first_name": "Mister",
|
||||
"last_name": "Mittens",
|
||||
"@tenant": "meowdern_solutions"
|
||||
}
|
||||
```
|
||||
|
||||
If the user has already been assigned to a tenant (for example, through Metabase UI), then the JWT _must_ contain the tenant claim to sign the user in.
|
||||
|
||||
### Customize tenant claim
|
||||
|
||||
By default, Metabase looks for a `@tenant` key in your JWT. To set up a different key
|
||||
|
||||
1. Go to **Admin** > **Settings** > **Authentication** > **JWT** > **User attribute configuration**
|
||||
2. Change the **Tenant assignment attribute** key to your preferred identifier.
|
||||
|
||||
### Provisioning tenants and users
|
||||
|
||||
You can [turn on JWT user provisioning](../people-and-groups/authenticating-with-jwt.md) so that Metabase will automatically create users and tenants mentioned in the JWT.
|
||||
|
||||
When user provisioning with JWT is enabled:
|
||||
|
||||
1. Metabase reads the tenant identifier from the JWT claim. By default, this is the `@tenant` key (you can configure this).
|
||||
2. If the tenant doesn't exist, Metabase automatically creates it. Metabase will use the value of the `@tenant` key (or your chosen assignment attribute) as the tenant slug.
|
||||
3. New users are automatically assigned to the tenant from their JWT.
|
||||
|
||||
### Troubleshooting JWT authentication with tenants
|
||||
|
||||
Some auth common error messages and what they mean:
|
||||
|
||||
- **Cannot add tenant claim to internal user**: JWT includes a tenant, but the user is an internal user. Only tenant users can have a tenant.
|
||||
- **Tenant claim required for external user**: JWT lacks a tenant claim, but the user is an external user.
|
||||
- **Tenant ID mismatch with existing user**: JWT has a different tenant than the user's assigned tenant.
|
||||
- **Tenant is not active**: The tenant exists but has been deactivated.
|
||||
|
||||
## Data permissions for tenants
|
||||
|
||||
_Admin setting > Permissions_
|
||||
|
||||
Data permissions control what data people can see on charts and dashboards, and what they can do with that data. To control _which_ charts people see, you can use [collection permissions](#collection-permissions-for-tenants) instead.
|
||||
|
||||
### Data permission overview
|
||||
|
||||
For an overview of how data permissions work in Metabase, see [Data permissions](../permissions/data.md). Here are the highlights (but please do read the full [Data permissions](../permissions/data.md) documentation):
|
||||
|
||||
- **"View data"** controls which data each user group can see in the on on charts.
|
||||
|
||||
For example, if your tenant data is commingled in one database, then you can use [**Row and column security**](../permissions/row-and-column-security.md) or [**Impersonation**](../permissions/impersonation.md) "View data" permissions to provide tenant users with access to only certain rows and columns.
|
||||
|
||||
If every tenant has their data in a separate database, then instead of using permissions for data access control, you can use [**database routing**](../permissions/database-routing.md) to route queries to appropriate databases directly.
|
||||
|
||||
- **Create queries** controls whether tenant users can create queries on the data they see. If you want to give your tenant users the ability to drill through (e.g., through `drills` parameter in [modular embedding](../embedding/modular-embedding.md)), you need to give them "Create queries" permissions, because a drill through is a new query.
|
||||
|
||||
- **Download results** controls, unsurprisingly, whether people can download results of queries. You need to set download permissions if you want to give your users the option to download their data as a spreadsheet (for example, through `with-downloads` parameter in [modular embedding](../embedding/modular-embedding.md))
|
||||
|
||||
Data permissions in Metabase are can be specified on database or table level and are granted to groups. You'll need to use the special **All tenant users**, and your tenant groups (if any) to assign data permissions. Keep in mind that Metabase permissions are additive, so if someone is a member of two different groups, they will be granted the _most_ permissive access. In particular, if "All tenant users" has "Can view" access to an entire table, but another tenant group has restricted access (e.g. "Row and column security"), then the users of the tenant group will still see all the data in the table because they get it via the "All tenant users" group. If you're using tenant groups, we recommend revoking access for "All tenant users" and configuring access on group-by-group basis.
|
||||
|
||||
Please review [Data permissions documentation](../permissions/data.md) for more details on permissions setup.
|
||||
|
||||
### Use tenant attributes for data permissions
|
||||
|
||||
[Row and column security](../permissions/row-and-column-security.md), [Impersonation](../permissions/impersonation.md), and Database routing require user attributes. You can [specify custom tenant attributes](#tenant-attributes) to configure data permissions based on attribute values. See [Tenant attributes](#tenant-attributes). Alternatively, you can use the special tenant slug attribute, see [Special slug attribute](#special-tenant-slug-attribute).
|
||||
|
||||
## Collection permissions for tenants
|
||||
|
||||
Collection permissions control which entities (dashboards, questions, models etc) people can see.
|
||||
|
||||
To configure what _data_ can people see in those entities, and what they can do with that data, see [Data permissions](#data-permissions-for-tenants) instead.
|
||||
|
||||
In Metabase, there are different levels of collection permissions: **No** access, **View**-only access, and **Curate** access (allows for creating and saving new entities like dashboards). For more general information about collection permissions in Metabase, see [Collection permissions](#collection-permissions-for-tenants).
|
||||
|
||||
Permissions are granted to groups. Which permissions are available to each group depend on the type of the group (external/tenant or internal) and the type of the collection.
|
||||
|
||||
### Tenant user collection permissions
|
||||
|
||||
- For **internal collections**, tenant users will have **No** access.
|
||||
- For **shared collections**, tenant users can only have **View** or **No** access. This means that at _most_, tenant users can see existing entities but not create new ones.
|
||||
|
||||
Different tenant groups can have different levels access to different shared collections. For example, you can have a "Basic analytics" shared collection viewable by all users, and "Advanced analytics" collection only viewable by tenant group "Premium users".
|
||||
|
||||
See [Configuring shared collection permissions](#configuring-shared-collections-permissions).
|
||||
|
||||
- For **tenant collections**, tenant users will always have **Curate** permissions, which means that tenant users will always be able to save new questions in their tenant collection.
|
||||
|
||||
If you don't want your tenant users to create and save their own charts, you'll need to disable "Create queries" [data permissions](#data-permissions-for-tenants) for tenant users, and, if you're embedding Metabase, configure the embedded UI components to disable saving.
|
||||
|
||||
- For their own **personal collections**, tenant users will always have **Curate** permissions.
|
||||
|
||||
### Internal user collection permissions
|
||||
|
||||
- Metabase Admins will have **Curate** access to all shared collections and all tenant collections.
|
||||
- Other internal groups can be granted **View** or **Curate** access to **shared collections**, see [Configuring shared collection permissions](#configuring-shared-collections-permissions),
|
||||
- Non-admin internal users will have **No** access to tenant-specific collections. Currently, this can't be configured.
|
||||
|
||||
For configuring permissions to _internal_ collections for internal users, see [general docs on collection permissions](../permissions/collections.md)
|
||||
|
||||
### Configuring shared collections permissions
|
||||
|
||||
_Admin setting > Permissions_
|
||||
|
||||
To configure access to shared collections for tenant and internal groups, go to **Admin settings > Permissions > Shared collections**.
|
||||
|
||||
You can configure access for each shared collections and their subcollections for both internal and external users. See general docs on [collection permissions](#collection-permissions-for-tenants).
|
||||
|
||||
Special **Root shared collection** controls who has access to _all_ shared collections. For example, if you want to make sure your internal users don't have access to any tenant shared collections, you can revoke permissions from the Root shared collection.
|
||||
|
||||
When configuring permissions, remember that in Metabase, all permissions are additive, so if someone is a member of two different groups, they will be granted the _most_ permissive access. In particular, if "All tenant users" has "View" access to a shared collection, but another tenant group has "No" access to that collection specified in the permission settings, the users of the tenant group will still get "View" access because they have it via the "All tenant groups". If you're using tenant groups, we recommend revoking access for "All tenant users" and configuring access on group-by-group basis.
|
||||
|
||||
## Subscription permissions for tenants
|
||||
|
||||
_Admin setting > Permissions_
|
||||
|
||||
By default, all tenant users will be created with **No** [subscription permissions](../permissions/application.md#subscriptions-and-alerts). If you want your users to be able to create subscriptions (either in full-app embedding, modular embedding, or by logging in directly to Metabase), you'll need to change the Subscription and alerts permissions to "Yes".
|
||||
|
||||
## Deactivate a tenant
|
||||
|
||||
_Admin setting > People > Tenants_
|
||||
|
||||
**Deactivating a tenant will also deactivate all users who belong to this tenant**.
|
||||
|
||||
To deactivate a tenant:
|
||||
|
||||
1. Go to **Admin settings > People**.
|
||||
2. Select **Tenants** on the left sidebar.
|
||||
3. Click on **three dots** next to the tenant.
|
||||
4. Choose **Deactivate tenant**
|
||||
|
||||
All tenant users will be deactivated and won't be able to sign in anymore. Tenant users will not be permanently deleted (Metabase does not delete users, only deactivates), so even though the tenant users will be deactivated, you won't be able to create new users with the same email.
|
||||
|
||||
## Changing tenant strategy
|
||||
|
||||
### From single-tenant to multi-tenant
|
||||
|
||||
When you enable multi-tenant strategy, all users that currently exist in Metabase will be considered "internal" users. If you don't want any of those users to become [tenant users](#concepts), you can just proceed with tenant setup as if you had a fresh new instance (create tenants, create collections, set up permissions, etc).
|
||||
|
||||
However, if you want to assign some existing users to tenants, you'll need to:
|
||||
|
||||
1. Mark them as tenant users using the API call:
|
||||
|
||||
```sh
|
||||
PUT /api/user/:id
|
||||
{"tenant_id": 1}
|
||||
```
|
||||
|
||||
2. If you're using JWT for SSO, [add a `@tenant` claim to your JWT](#provisioning-and-assigning-tenants-with-jwt).
|
||||
3. Set up [tenant groups](#concepts), [data permissions](#data-permissions-for-tenants), and [collection permissions](#collection-permissions-for-tenants) because you won't be able to use existing internal groups for tenant permissions.
|
||||
|
||||
### From multi-tenant to single-tenant
|
||||
|
||||
If you disable multi-tenant strategy, _all your tenant users will be deactivated_ and _all collections will be deleted_ (although you'll get both users and collections back if you reactivate multi-tenant strategy later). So if you want to wish to keep the active users but just disable tenancy features, you'll need some extra prep.
|
||||
|
||||
1. Replicate the tenant setup you have with regular groups and collections (instead of tenant groups and shared collections). Review documentation for Data permissions, Collection permissions, and User groups. Make sure to thoroughly test your setup with test users. A [development instance](../installation-and-operation/development-instance.md) might come in handy.
|
||||
2. If you're using any tenant groups, remove the tenant group memberships of all tenant users.
|
||||
3. Change the tenant users to internal users using API:
|
||||
|
||||
```sh
|
||||
PUT /api/user/:id
|
||||
{"tenant_id": null}
|
||||
```
|
||||
|
||||
**If you don't do this step, all your users will be deactivated.**
|
||||
|
||||
4. Finally, disable the feature once everything is verified to work
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Tenant collections and personal collections can't be disabled**.
|
||||
|
||||
If you don't want your tenant users to create and save their own charts, you can disable "Create queries" [data permissions](#data-permissions-for-tenants), and, if you're embedding Metabase, configure the embedded UI components to disable saving.
|
||||
|
||||
- **Tenant users can't change tenants.** Once an external user is assigned to a tenant, they cannot switch to another tenant.
|
||||
|
||||
- **If you disable multi-tenant strategy, deactivated tenant users will not show up in 'Deactivated users**, but Metabase will still keep track of them and won't allow creating new users with the same email. If you want to
|
||||
|
||||
- **There are no tenant-specific groups**. Tenant groups are are shared between all tenants. If you need a special group for just some of your tenants, create a tenant group but don't add any members from the tenants that the group isn't applicable to.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Embedding overview](./start.md)
|
||||
- [JWT authentication](../people-and-groups/authenticating-with-jwt.md)
|
||||
- [Permissions overview](../permissions/start.md)
|
||||
BIN
docs/installation-and-operation/images/branch-dropdown.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
6
docs/llms.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
# Metabase
|
||||
|
||||
Links to `llms.txt` files for Metabase. These files contains the full documentation and guide for AI agents on these Metabase-related topics:
|
||||
|
||||
- [Embedding Metabase with Modular Embedding](./embedding/llms.txt)
|
||||
|
||||
@@ -19,6 +19,7 @@ export const DataStudio = {
|
||||
editDefinition: () => cy.findByRole("link", { name: "Edit definition" }),
|
||||
queryEditor: () => cy.findByTestId("transform-query-editor"),
|
||||
runTab: () => DataStudio.Transforms.header().findByText("Run"),
|
||||
targetTab: () => DataStudio.Transforms.header().findByText("Target"),
|
||||
settingsTab: () => DataStudio.Transforms.header().findByText("Settings"),
|
||||
dependenciesTab: () =>
|
||||
DataStudio.Transforms.header().findByText("Dependencies"),
|
||||
|
||||
@@ -78,7 +78,11 @@ export function entityPickerModalItem(level, name) {
|
||||
}
|
||||
|
||||
export function entityPickerModalTab(name) {
|
||||
return cy.findAllByRole("tab").filter(`:contains(${name})`);
|
||||
if (typeof name === "string") {
|
||||
return cy.findAllByRole("tab").filter(`:contains(${name})`);
|
||||
} else {
|
||||
return cy.findAllByRole("tab").first().parent().findByText(name);
|
||||
}
|
||||
}
|
||||
|
||||
// displays at least these tabs:
|
||||
|
||||
130
e2e/support/helpers/e2e-workspaces-helpers.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { WorkspaceId } from "metabase-types/api";
|
||||
|
||||
export const Workspaces = {
|
||||
visit: (id: WorkspaceId) => {
|
||||
return cy.visit(`/data-studio/workspace/${id}`);
|
||||
},
|
||||
|
||||
visitDataStudio() {
|
||||
return cy.visit("/data-studio");
|
||||
},
|
||||
|
||||
visitWorkspaces() {
|
||||
return cy.visit("/data-studio/workspaces");
|
||||
},
|
||||
|
||||
visitTransformListPage() {
|
||||
return cy.visit("/data-studio/transforms");
|
||||
},
|
||||
|
||||
getWorkspacesPage() {
|
||||
return cy.findByTestId("workspaces-page");
|
||||
},
|
||||
|
||||
getWorkspacePage() {
|
||||
return cy.findByTestId("workspace-page");
|
||||
},
|
||||
|
||||
getWorkspaceContent() {
|
||||
return cy.findByTestId("workspace-content");
|
||||
},
|
||||
|
||||
getWorkspaceTabs() {
|
||||
return cy.findByTestId("workspace-tabs");
|
||||
},
|
||||
|
||||
getWorkspaceSidebar() {
|
||||
return cy.findByTestId("workspace-sidebar");
|
||||
},
|
||||
|
||||
openCodeTab() {
|
||||
return this.getWorkspaceSidebar().within(() => {
|
||||
return cy.findByRole("tab", { name: "Code" }).click();
|
||||
});
|
||||
},
|
||||
|
||||
openDataTab() {
|
||||
return this.getWorkspaceSidebar().within(() => {
|
||||
return cy.findByRole("tab", { name: "Data" }).click();
|
||||
});
|
||||
},
|
||||
|
||||
getWorkspacesSection() {
|
||||
return cy.findByTestId("workspaces-section");
|
||||
},
|
||||
|
||||
getWorkspaceItem(name: string | RegExp) {
|
||||
return this.getWorkspacesSection().find(`button[name="${name}"]`);
|
||||
},
|
||||
|
||||
getWorkspaceItemStatus(name: string | RegExp) {
|
||||
return this.getWorkspaceItem(name).findByTestId("workspace-status");
|
||||
},
|
||||
|
||||
getWorkspaceItemActions(name: string | RegExp) {
|
||||
return this.getWorkspaceItem(name).findByLabelText("More actions");
|
||||
},
|
||||
|
||||
getWorkspaceNameInput() {
|
||||
return cy.findByPlaceholderText("Workspace name");
|
||||
},
|
||||
|
||||
getMergeWorkspaceButton() {
|
||||
return cy.findByRole("button", { name: /Merge/ });
|
||||
},
|
||||
|
||||
getMergeCommitInput() {
|
||||
return cy.findByPlaceholderText(
|
||||
"Describe the changes you made in this workspace...",
|
||||
);
|
||||
},
|
||||
|
||||
getWorkspaceDatabaseSelect() {
|
||||
return cy.findByPlaceholderText("Select a database");
|
||||
},
|
||||
|
||||
getTransformTabHeader() {
|
||||
return cy.findByTestId("transform-tab-header");
|
||||
},
|
||||
|
||||
getTransformTargetButton() {
|
||||
return this.getTransformTabHeader().findByRole("button", {
|
||||
name: /Change target/,
|
||||
});
|
||||
},
|
||||
|
||||
getRunTransformButton() {
|
||||
return this.getTransformTabHeader().findByTestId("run-button");
|
||||
},
|
||||
|
||||
getSaveTransformButton() {
|
||||
return this.getTransformTabHeader().findByRole("button", { name: /Save/ });
|
||||
},
|
||||
|
||||
getWorkspaceTransforms() {
|
||||
return cy.findByTestId("workspace-transforms");
|
||||
},
|
||||
|
||||
getMainlandTransforms() {
|
||||
return cy.findByTestId("mainland-transforms");
|
||||
},
|
||||
|
||||
getNewWorkspaceButton() {
|
||||
return cy.findByRole("button", { name: /New workspace/ });
|
||||
},
|
||||
|
||||
getNewWorkspaceNameInput() {
|
||||
return cy.findByPlaceholderText("Enter workspace name");
|
||||
},
|
||||
|
||||
getNewWorkspaceDatabaseInput() {
|
||||
return cy.findByPlaceholderText("Select a database");
|
||||
},
|
||||
|
||||
getTransformStatusDot(name: string | RegExp) {
|
||||
return cy
|
||||
.findByText(name)
|
||||
.parent()
|
||||
.find('[class*="statusDot"]');
|
||||
},
|
||||
};
|
||||
@@ -13,9 +13,9 @@ export * from "./e2e-comment-helpers";
|
||||
export * from "./e2e-custom-column-helpers";
|
||||
export * from "./e2e-dashboard-helpers";
|
||||
export * from "./e2e-dashboard-visualizer-helpers";
|
||||
export * from "./e2e-data-studio-helpers";
|
||||
export * from "./e2e-database-metadata-helpers";
|
||||
export * from "./e2e-datamodel-helpers";
|
||||
export * from "./e2e-data-studio-helpers";
|
||||
export * from "./e2e-dependency-graph-helpers";
|
||||
export * from "./e2e-detail-view-helpers";
|
||||
export * from "./e2e-dimension-list-helpers";
|
||||
@@ -60,4 +60,5 @@ export * from "./e2e-upload-helpers";
|
||||
export * from "./e2e-users-helpers";
|
||||
export * from "./e2e-visual-tests-helpers";
|
||||
export * from "./e2e-viz-settings-helpers";
|
||||
export * from "./e2e-workspaces-helpers";
|
||||
export * from "./embedding-sdk-helpers/constants";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { createQuestion } from "e2e/support/helpers";
|
||||
import { uploadTranslationDictionaryViaAPI } from "e2e/support/helpers/e2e-content-translation-helpers";
|
||||
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
|
||||
import { mountInteractiveQuestion } from "e2e/support/helpers/embedding-sdk-component-testing";
|
||||
import {
|
||||
mockAuthProviderAndJwtSignIn,
|
||||
signInAsAdminAndEnableEmbeddingSdk,
|
||||
} from "e2e/support/helpers/embedding-sdk-testing";
|
||||
import type { Card } from "metabase-types/api";
|
||||
|
||||
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
describe("scenarios > embedding-sdk > content-translations", () => {
|
||||
describe("question", () => {
|
||||
const setup = ({ display }: { display?: Card["display"] } = {}) => {
|
||||
signInAsAdminAndEnableEmbeddingSdk();
|
||||
|
||||
createQuestion({
|
||||
name: "Question Embed SDK",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["max", ["field", ORDERS.QUANTITY, null]]],
|
||||
breakout: [["field", ORDERS.PRODUCT_ID, null]],
|
||||
limit: 2,
|
||||
},
|
||||
display,
|
||||
}).then(({ body: question }) => {
|
||||
cy.wrap(question.id).as("questionId");
|
||||
});
|
||||
|
||||
cy.signOut();
|
||||
};
|
||||
|
||||
describe("content translation", () => {
|
||||
it("should show question content with applied content translation", () => {
|
||||
setup();
|
||||
|
||||
cy.signInAsAdmin();
|
||||
|
||||
uploadTranslationDictionaryViaAPI([
|
||||
{
|
||||
locale: "de",
|
||||
msgid: "Question Embed SDK",
|
||||
msgstr: "Override title für Deutsch",
|
||||
},
|
||||
{
|
||||
locale: "de",
|
||||
msgid: "Product ID",
|
||||
msgstr: "Override Product ID für Deutsch",
|
||||
},
|
||||
]);
|
||||
|
||||
cy.signOut();
|
||||
|
||||
mockAuthProviderAndJwtSignIn();
|
||||
|
||||
cy.get("@questionId").then(async () => {
|
||||
mountInteractiveQuestion(
|
||||
{ title: true },
|
||||
{
|
||||
sdkProviderProps: {
|
||||
locale: "de",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Override title für Deutsch").should("be.visible");
|
||||
cy.findByText("Override Product ID für Deutsch").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
const { H } = cy;
|
||||
|
||||
const TENANT_NAMESPACE = "shared-tenant-collection";
|
||||
|
||||
/**
|
||||
* Create a collection in the shared-tenant-collection namespace.
|
||||
* Collections with this namespace are shared collections.
|
||||
*/
|
||||
function createSharedCollection(name: string, parentId?: number) {
|
||||
return cy.request("POST", "/api/collection", {
|
||||
name,
|
||||
namespace: TENANT_NAMESPACE,
|
||||
parent_id: parentId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
describe("scenarios > collections > collection picker with tenants", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.updateSetting("use-tenants", true);
|
||||
});
|
||||
|
||||
it("should not allow marking collections as official in shared collections and its sub-collections", () => {
|
||||
createSharedCollection("Test Shared Collection").then((response) => {
|
||||
const sharedCollectionId = response.body.id;
|
||||
|
||||
cy.log("Navigate to the shared collection");
|
||||
H.visitCollection(sharedCollectionId);
|
||||
|
||||
cy.log("Verify collection loaded");
|
||||
cy.findByTestId("collection-name-heading").should(
|
||||
"have.text",
|
||||
"Test Shared Collection",
|
||||
);
|
||||
|
||||
cy.log("Click on the three dots menu and verify cannot mark as official");
|
||||
H.openCollectionMenu();
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.log("Should not have 'Make collection official' option");
|
||||
cy.findByText("Make collection official").should("not.exist");
|
||||
cy.findByText("Remove Official badge").should("not.exist");
|
||||
});
|
||||
|
||||
// Close the popover
|
||||
cy.findByTestId("app-bar").click();
|
||||
|
||||
cy.log("Click 'Create a new collection' button");
|
||||
cy.findByTestId("collection-menu")
|
||||
.findByLabelText("Create a new collection")
|
||||
.click();
|
||||
|
||||
cy.findByTestId("new-collection-modal").within(() => {
|
||||
cy.log("Should not see 'Collection type' picker");
|
||||
cy.findByText(/Collection type/i).should("not.exist");
|
||||
cy.findByText("Regular").should("not.exist");
|
||||
cy.findByText("Official").should("not.exist");
|
||||
|
||||
cy.log("Verify we're creating inside the shared collection");
|
||||
cy.findByTestId("collection-picker-button").should(
|
||||
"contain",
|
||||
"Test Shared Collection",
|
||||
);
|
||||
|
||||
cy.log("Edge case: Change target collection to a normal collection");
|
||||
cy.findByTestId("collection-picker-button").click();
|
||||
});
|
||||
|
||||
H.entityPickerModal().within(() => {
|
||||
cy.log("Select 'Our analytics' (a normal collection)");
|
||||
H.entityPickerModalTab("Collections").click();
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.button("Select").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("new-collection-modal").within(() => {
|
||||
cy.log("Wait for collection picker to update to 'Our analytics'");
|
||||
cy.findByTestId("collection-picker-button").should(
|
||||
"contain",
|
||||
"Our analytics",
|
||||
);
|
||||
|
||||
cy.log("Should now see 'Collection type' picker");
|
||||
cy.findByText(/Collection type/i).should("exist");
|
||||
cy.findByText("Regular").should("exist");
|
||||
cy.findByText("Official").should("exist");
|
||||
|
||||
cy.log("Close modal without creating");
|
||||
cy.findByLabelText("Close").click();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should block official collections in sub-collections of shared collections", () => {
|
||||
createSharedCollection("Shared Parent Collection").then((response) => {
|
||||
const sharedParentId = response.body.id;
|
||||
|
||||
createSharedCollection("Shared Sub Collection", sharedParentId).then(
|
||||
(subResponse) => {
|
||||
const sharedSubCollectionId = subResponse.body.id;
|
||||
|
||||
cy.log("Navigate to the sub-collection");
|
||||
H.visitCollection(sharedSubCollectionId);
|
||||
|
||||
cy.log("Verify sub-collection loaded");
|
||||
cy.findByTestId("collection-name-heading").should(
|
||||
"have.text",
|
||||
"Shared Sub Collection",
|
||||
);
|
||||
|
||||
cy.log("Open collection menu");
|
||||
H.openCollectionMenu();
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.log("Should not have 'Make collection official' option");
|
||||
cy.findByText("Make collection official").should("not.exist");
|
||||
cy.findByText("Remove Official badge").should("not.exist");
|
||||
});
|
||||
|
||||
// Close the popover
|
||||
cy.findByTestId("app-bar").click();
|
||||
|
||||
cy.log("Create a new sub-sub-collection");
|
||||
cy.findByTestId("collection-menu")
|
||||
.findByLabelText("Create a new collection")
|
||||
.click();
|
||||
|
||||
cy.findByTestId("new-collection-modal").within(() => {
|
||||
cy.log("Should not see 'Collection type' picker");
|
||||
cy.findByText(/Collection type/i).should("not.exist");
|
||||
cy.findByText("Regular").should("not.exist");
|
||||
cy.findByText("Official").should("not.exist");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide collection type picker when switching from normal to shared collection in create modal", () => {
|
||||
createSharedCollection("Target Shared Collection");
|
||||
|
||||
cy.log("Start creating a new collection from root");
|
||||
cy.visit("/collection/root");
|
||||
H.startNewCollectionFromSidebar();
|
||||
|
||||
cy.findByTestId("new-collection-modal").within(() => {
|
||||
cy.log("Initially should see collection type picker");
|
||||
cy.findByText(/Collection type/i).should("exist");
|
||||
|
||||
cy.log("Click collection picker to change target");
|
||||
cy.findByTestId("collection-picker-button").click();
|
||||
});
|
||||
|
||||
H.entityPickerModal().within(() => {
|
||||
H.entityPickerModalTab("Collections").click();
|
||||
cy.log("Navigate to Shared collections");
|
||||
cy.findByText("Shared collections").click();
|
||||
cy.findByText("Target Shared Collection").click();
|
||||
cy.button("Select").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("new-collection-modal").within(() => {
|
||||
cy.log("Should no longer see collection type picker");
|
||||
cy.findByText(/Collection type/i).should("not.exist");
|
||||
cy.findByText("Regular").should("not.exist");
|
||||
cy.findByText("Official").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show collection type picker when switching from shared to normal collection in create modal", () => {
|
||||
createSharedCollection("Shared Collection for Switching").then(
|
||||
(response) => {
|
||||
const sharedCollectionId = response.body.id;
|
||||
|
||||
cy.log("Navigate inside shared collection");
|
||||
H.visitCollection(sharedCollectionId);
|
||||
|
||||
cy.log("Start creating a sub-collection");
|
||||
cy.findByTestId("collection-menu")
|
||||
.findByLabelText("Create a new collection")
|
||||
.click();
|
||||
|
||||
cy.findByTestId("new-collection-modal").within(() => {
|
||||
cy.log("Should not see collection type picker initially");
|
||||
cy.findByText(/Collection type/i).should("not.exist");
|
||||
|
||||
cy.log("Click collection picker to change target");
|
||||
cy.findByTestId("collection-picker-button").click();
|
||||
});
|
||||
|
||||
H.entityPickerModal().within(() => {
|
||||
H.entityPickerModalTab("Collections").click();
|
||||
cy.log("Select 'Our analytics' (normal collection)");
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.button("Select").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("new-collection-modal").within(() => {
|
||||
cy.log("Wait for collection picker to update to 'Our analytics'");
|
||||
cy.findByTestId("collection-picker-button").should(
|
||||
"contain",
|
||||
"Our analytics",
|
||||
);
|
||||
|
||||
cy.log("Should now see collection type picker");
|
||||
cy.findByText(/Collection type/i).should("exist");
|
||||
cy.findByText("Regular").should("exist");
|
||||
cy.findByText("Official").should("exist");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import { SAMPLE_DB_ID, SAMPLE_DB_SCHEMA_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
|
||||
const { H } = cy;
|
||||
const { MeasureList, MeasureEditor, MeasureRevisionHistory } = H.DataModel;
|
||||
const { ORDERS, ORDERS_ID, PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
describe("scenarios > data studio > data model > measures", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
|
||||
cy.intercept("POST", "/api/measure").as("createMeasure");
|
||||
cy.intercept("PUT", "/api/measure/*").as("updateMeasure");
|
||||
cy.intercept("GET", "/api/table/*/query_metadata*").as("metadata");
|
||||
});
|
||||
|
||||
describe("Measure list", () => {
|
||||
it("should show empty state and navigation when no measures exist", () => {
|
||||
visitDataStudioMeasures(ORDERS_ID);
|
||||
|
||||
cy.log("verify empty state");
|
||||
MeasureList.getEmptyState().scrollIntoView().should("be.visible");
|
||||
MeasureList.get()
|
||||
.findByText(
|
||||
"Create a measure to define a reusable aggregation for this table.",
|
||||
)
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify new measure link and navigation");
|
||||
MeasureList.getNewMeasureLink().scrollIntoView().click();
|
||||
|
||||
cy.url().should("include", `${getMeasuresBaseUrl(ORDERS_ID)}/new`);
|
||||
});
|
||||
|
||||
it("should display measures and allow navigation to edit page", () => {
|
||||
createTestMeasure({
|
||||
name: "Total Revenue",
|
||||
aggregation: ["sum", ["field", ORDERS.TOTAL, null]],
|
||||
});
|
||||
visitDataStudioMeasures(ORDERS_ID);
|
||||
|
||||
cy.log("verify measure in list with aggregation description");
|
||||
MeasureList.getMeasure("Total Revenue")
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
MeasureList.get()
|
||||
.findByTestId("list-item-description")
|
||||
.should("contain", "Sum of Total");
|
||||
|
||||
cy.log("navigate to edit page");
|
||||
MeasureList.getMeasure("Total Revenue").click();
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
cy.url().should(
|
||||
"include",
|
||||
`${getMeasuresBaseUrl(ORDERS_ID)}/${measureId}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate between Fields and Measures tabs", () => {
|
||||
visitDataStudioTable(ORDERS_ID);
|
||||
|
||||
cy.log("verify both tabs visible");
|
||||
cy.findByRole("tab", { name: /Fields/i }).scrollIntoView();
|
||||
cy.findByRole("tab", { name: /Measures/i }).should("be.visible");
|
||||
|
||||
cy.log("navigate to measures tab");
|
||||
cy.findByRole("tab", { name: /Measures/i }).click();
|
||||
cy.url().should(
|
||||
"include",
|
||||
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/measures`,
|
||||
);
|
||||
MeasureList.getEmptyState().scrollIntoView().should("be.visible");
|
||||
|
||||
cy.log("verify tab selection preserved on refresh");
|
||||
cy.reload();
|
||||
cy.wait("@metadata");
|
||||
cy.findByRole("tab", { name: /Measures/i })
|
||||
.scrollIntoView()
|
||||
.should("have.attr", "aria-selected", "true");
|
||||
|
||||
cy.log("navigate back to fields tab");
|
||||
cy.findByRole("tab", { name: /Fields/i }).click();
|
||||
cy.url().should("include", "/field");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Measure creation", () => {
|
||||
it("should create a measure with aggregation and verify across features", () => {
|
||||
visitDataStudioMeasures(ORDERS_ID);
|
||||
|
||||
cy.log("navigate to new measure page");
|
||||
MeasureList.getNewMeasureLink().scrollIntoView().click();
|
||||
|
||||
cy.log("fill in measure name");
|
||||
MeasureEditor.getNameInput().type("Total Revenue");
|
||||
|
||||
cy.log("add aggregation");
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Sum of ...").click();
|
||||
H.popover().findByText("Total").click();
|
||||
|
||||
cy.log("verify aggregation was added");
|
||||
MeasureEditor.get()
|
||||
.findByText(/Sum of Total/i)
|
||||
.should("exist");
|
||||
|
||||
cy.log("save measure");
|
||||
MeasureEditor.getSaveButton().click();
|
||||
cy.wait("@createMeasure");
|
||||
|
||||
cy.log("verify redirect to edit page and toast");
|
||||
H.undoToast().should("contain.text", "Measure created");
|
||||
cy.url().should(
|
||||
"match",
|
||||
new RegExp(
|
||||
`${getMeasuresBaseUrl(ORDERS_ID).replace(/\//g, "\\/")}\/\\d+$`,
|
||||
),
|
||||
);
|
||||
|
||||
cy.log("verify measure in query builder");
|
||||
verifyMeasureInQueryBuilder("Total Revenue");
|
||||
});
|
||||
|
||||
it("should add aggregation and show preview in menu", () => {
|
||||
visitDataStudioMeasures(PRODUCTS_ID);
|
||||
|
||||
MeasureList.getNewMeasureLink().scrollIntoView().click();
|
||||
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Average of ...").click();
|
||||
H.popover().findByText("Price").click();
|
||||
|
||||
cy.log("verify aggregation was added");
|
||||
MeasureEditor.get()
|
||||
.findByText(/Average of Price/i)
|
||||
.should("exist");
|
||||
|
||||
cy.log("verify preview is available in menu");
|
||||
MeasureEditor.getActionsButton().click();
|
||||
H.popover().findByText("Preview").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Measure editing", () => {
|
||||
it("should display and update existing measure", () => {
|
||||
createTestMeasure({
|
||||
name: "Test Measure",
|
||||
description: "Test description",
|
||||
});
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
visitDataModelMeasure(ORDERS_ID, measureId);
|
||||
});
|
||||
|
||||
cy.log("verify existing data displayed");
|
||||
MeasureEditor.get()
|
||||
.findByDisplayValue("Test Measure")
|
||||
.should("be.visible");
|
||||
MeasureEditor.getDescriptionInput().should(
|
||||
"have.value",
|
||||
"Test description",
|
||||
);
|
||||
|
||||
cy.log("update measure name (saves immediately on blur/enter)");
|
||||
MeasureEditor.get()
|
||||
.findByDisplayValue("Test Measure")
|
||||
.click()
|
||||
.type(" Updated{enter}");
|
||||
cy.wait("@updateMeasure");
|
||||
|
||||
cy.log("verify toast for name update");
|
||||
H.undoToast().should("contain.text", "Measure name updated");
|
||||
|
||||
cy.log("update description");
|
||||
MeasureEditor.getDescriptionInput().clear().type("Updated description");
|
||||
MeasureEditor.getSaveButton().click();
|
||||
cy.wait("@updateMeasure");
|
||||
|
||||
cy.log("verify updated measure in query builder");
|
||||
verifyMeasureInQueryBuilder("Test Measure Updated");
|
||||
});
|
||||
|
||||
it("should navigate back to measures tab via breadcrumb", () => {
|
||||
createTestMeasure({ name: "Breadcrumb Test Measure" });
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
visitDataModelMeasure(ORDERS_ID, measureId);
|
||||
});
|
||||
|
||||
MeasureEditor.getBreadcrumb("Orders").click();
|
||||
|
||||
cy.url().should(
|
||||
"include",
|
||||
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/measures`,
|
||||
);
|
||||
cy.findByRole("tab", { name: /Measures/i })
|
||||
.scrollIntoView()
|
||||
.should("have.attr", "aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Measure deletion", () => {
|
||||
it("should remove measure via more menu", () => {
|
||||
createTestMeasure({ name: "Measure to Delete" });
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
visitDataModelMeasure(ORDERS_ID, measureId);
|
||||
});
|
||||
|
||||
cy.log("delete via more menu");
|
||||
MeasureEditor.getActionsButton().click();
|
||||
H.popover().findByText("Remove measure").click();
|
||||
H.modal().button("Remove").click();
|
||||
cy.wait("@updateMeasure");
|
||||
|
||||
cy.log("verify redirect to list and removal");
|
||||
H.undoToast().should("contain.text", "Measure removed");
|
||||
cy.url().should(
|
||||
"include",
|
||||
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/measures`,
|
||||
);
|
||||
MeasureList.get()
|
||||
.findByText("Measure to Delete", { timeout: 1000 })
|
||||
.should("not.exist");
|
||||
|
||||
cy.log("verify measure removed from query builder");
|
||||
verifyMeasureNotInQueryBuilder("Total Revenue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unsaved changes", () => {
|
||||
it("should show leave confirmation with unsaved changes", () => {
|
||||
visitDataStudioMeasures(ORDERS_ID);
|
||||
|
||||
MeasureList.getNewMeasureLink().scrollIntoView().click();
|
||||
MeasureEditor.getNameInput().type("Unsaved Measure");
|
||||
|
||||
cy.log("attempt to navigate away");
|
||||
MeasureEditor.getBreadcrumb("Orders").click();
|
||||
|
||||
cy.log("verify confirmation modal");
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Discard your changes?").should("be.visible");
|
||||
cy.button("Cancel").click();
|
||||
});
|
||||
|
||||
cy.log("verify still on editor");
|
||||
MeasureEditor.get().findByText("Unsaved Measure").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Measure with implicit joins", () => {
|
||||
it("should create a measure with implicit join aggregation", () => {
|
||||
visitDataStudioMeasures(ORDERS_ID);
|
||||
|
||||
cy.log("navigate to new measure page");
|
||||
MeasureList.getNewMeasureLink().scrollIntoView().click();
|
||||
|
||||
cy.log("fill in measure name");
|
||||
MeasureEditor.getNameInput().type("Average Product Price");
|
||||
|
||||
cy.log("add aggregation via implicit join");
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Average of ...").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Product").click();
|
||||
cy.findByText("Price").click();
|
||||
});
|
||||
|
||||
cy.log("verify aggregation was added and save");
|
||||
MeasureEditor.get()
|
||||
.findByText(/Average of Product → Price/i)
|
||||
.should("exist");
|
||||
MeasureEditor.getSaveButton().click();
|
||||
cy.wait("@createMeasure");
|
||||
|
||||
cy.log("verify redirected to edit page with measure name");
|
||||
MeasureEditor.get().should("be.visible");
|
||||
MeasureEditor.get()
|
||||
.findByDisplayValue("Average Product Price")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify measure works in query builder");
|
||||
verifyMeasureInQueryBuilder("Average Product Price");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Revision history", () => {
|
||||
it("should display revision history with changes to name, description, and aggregation", () => {
|
||||
createTestMeasure({
|
||||
name: "Original Name",
|
||||
description: "Original description",
|
||||
aggregation: ["count"],
|
||||
});
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
// Fetch the measure to get the current pMBQL definition
|
||||
cy.request("GET", `/api/measure/${measureId}`).then(({ body }) => {
|
||||
const currentDefinition = body.definition;
|
||||
|
||||
cy.log("update measure name");
|
||||
cy.request("PUT", `/api/measure/${measureId}`, {
|
||||
name: "Updated Name",
|
||||
description: "Original description",
|
||||
revision_message: "Updated from Data Studio",
|
||||
definition: currentDefinition,
|
||||
});
|
||||
|
||||
cy.log("update measure description");
|
||||
cy.request("PUT", `/api/measure/${measureId}`, {
|
||||
name: "Updated Name",
|
||||
description: "Updated description",
|
||||
revision_message: "Updated from Data Studio",
|
||||
definition: currentDefinition,
|
||||
});
|
||||
|
||||
cy.log("update measure aggregation");
|
||||
// Update aggregation in the pMBQL definition
|
||||
const updatedDefinition = {
|
||||
...currentDefinition,
|
||||
stages: [
|
||||
{
|
||||
...currentDefinition.stages[0],
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
],
|
||||
};
|
||||
cy.request("PUT", `/api/measure/${measureId}`, {
|
||||
name: "Updated Name",
|
||||
description: "Updated description",
|
||||
revision_message: "Updated from Data Studio",
|
||||
definition: updatedDefinition,
|
||||
});
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
visitDataModelMeasure(ORDERS_ID, measureId);
|
||||
});
|
||||
});
|
||||
|
||||
cy.log("navigate to revision history tab");
|
||||
MeasureEditor.getRevisionHistoryTab().click();
|
||||
|
||||
cy.log("verify URL");
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
cy.url().should(
|
||||
"include",
|
||||
`${getMeasuresBaseUrl(ORDERS_ID)}/${measureId}/revisions`,
|
||||
);
|
||||
});
|
||||
|
||||
cy.log("verify revision history entries");
|
||||
MeasureRevisionHistory.get().within(() => {
|
||||
cy.findByText(/created this measure/i)
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
cy.findByText(/renamed the measure/i)
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
cy.findByText(/changed the aggregation/i)
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
cy.findByText(/updated the description/i)
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dependencies", () => {
|
||||
it("should display dependency graph for a measure", () => {
|
||||
createTestMeasure({ name: "Dependencies Test Measure" });
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
visitDataModelMeasure(ORDERS_ID, measureId);
|
||||
});
|
||||
|
||||
cy.log("navigate to dependencies tab");
|
||||
MeasureEditor.getDependenciesTab().click();
|
||||
|
||||
cy.log("verify URL and dependency graph display");
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
cy.url().should(
|
||||
"include",
|
||||
`${getMeasuresBaseUrl(ORDERS_ID)}/${measureId}/dependencies`,
|
||||
);
|
||||
});
|
||||
H.DependencyGraph.graph().should("be.visible");
|
||||
H.DependencyGraph.graph()
|
||||
.findByText("Dependencies Test Measure")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function visitDataStudioTable(tableId: number) {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId,
|
||||
});
|
||||
}
|
||||
|
||||
function visitDataStudioMeasures(tableId: number) {
|
||||
H.DataModel.visitDataStudioMeasures({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId,
|
||||
});
|
||||
}
|
||||
|
||||
function getMeasuresBaseUrl(tableId: number) {
|
||||
return `/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${tableId}/measures`;
|
||||
}
|
||||
|
||||
function visitDataModelMeasure(tableId: number, measureId: number) {
|
||||
cy.visit(`${getMeasuresBaseUrl(tableId)}/${measureId}`);
|
||||
}
|
||||
|
||||
function createTestMeasure(
|
||||
opts: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
tableId?: number;
|
||||
aggregation?: unknown[];
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
name = "Test Measure",
|
||||
description,
|
||||
tableId = ORDERS_ID,
|
||||
aggregation = ["sum", ["field", ORDERS.TOTAL, null]],
|
||||
} = opts;
|
||||
|
||||
H.createMeasure({
|
||||
name,
|
||||
description,
|
||||
table_id: tableId,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
aggregation: [aggregation],
|
||||
},
|
||||
},
|
||||
}).then(({ body }) => {
|
||||
cy.wrap(body.id).as("measureId");
|
||||
});
|
||||
}
|
||||
|
||||
function verifyMeasureInQueryBuilder(
|
||||
measureName: string,
|
||||
tableId: number = ORDERS_ID,
|
||||
) {
|
||||
H.openTable({ table: tableId, mode: "notebook" });
|
||||
|
||||
H.getNotebookStep("data").button("Summarize").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Measures").click();
|
||||
cy.findByText(measureName).click();
|
||||
});
|
||||
H.visualize();
|
||||
cy.findByTestId("scalar-value").should("be.visible");
|
||||
}
|
||||
|
||||
function verifyMeasureNotInQueryBuilder(
|
||||
measureName: string,
|
||||
tableId: number = ORDERS_ID,
|
||||
) {
|
||||
H.openTable({ table: tableId, mode: "notebook" });
|
||||
|
||||
H.getNotebookStep("data").button("Summarize").click();
|
||||
H.popover().findByText(measureName).should("not.exist");
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
|
||||
const { H } = cy;
|
||||
const { MeasureList, MeasureEditor } = H.DataModel;
|
||||
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
// Majority of the measures pages functionality is covered in the data-model/measures-data-studio.cy.spec.ts spec
|
||||
// This spec is focused on the published tables measures pages functionality while doing some smoke tests
|
||||
describe("scenarios > data studio > library > published tables > measures", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.createLibrary();
|
||||
H.publishTables({ table_ids: [ORDERS_ID] });
|
||||
|
||||
cy.intercept("POST", "/api/measure").as("createMeasure");
|
||||
cy.intercept("PUT", "/api/measure/*").as("updateMeasure");
|
||||
});
|
||||
|
||||
describe("Measure list", () => {
|
||||
it("should show empty state and navigate to new measure page", () => {
|
||||
H.DataStudio.Tables.visitMeasuresPage(ORDERS_ID);
|
||||
|
||||
MeasureList.getEmptyState().scrollIntoView().should("be.visible");
|
||||
MeasureList.getNewMeasureLink().scrollIntoView().click();
|
||||
|
||||
cy.url().should(
|
||||
"include",
|
||||
`/data-studio/library/tables/${ORDERS_ID}/measures/new`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should display measures and navigate to edit page", () => {
|
||||
createTestMeasure({ name: "Total Revenue" });
|
||||
H.DataStudio.Tables.visitMeasuresPage(ORDERS_ID);
|
||||
|
||||
MeasureList.getMeasure("Total Revenue").click();
|
||||
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
cy.url().should(
|
||||
"include",
|
||||
`/data-studio/library/tables/${ORDERS_ID}/measures/${measureId}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate between Overview, Fields, and Measures tabs", () => {
|
||||
H.DataStudio.Tables.visitOverviewPage(ORDERS_ID);
|
||||
|
||||
H.DataStudio.Tables.overviewTab().should("be.visible");
|
||||
H.DataStudio.Tables.fieldsTab().should("be.visible");
|
||||
H.DataStudio.Tables.measuresTab().should("be.visible");
|
||||
|
||||
H.DataStudio.Tables.measuresTab().click();
|
||||
cy.url().should(
|
||||
"include",
|
||||
`/data-studio/library/tables/${ORDERS_ID}/measures`,
|
||||
);
|
||||
|
||||
H.DataStudio.Tables.overviewTab().click();
|
||||
cy.url().should("include", `/data-studio/library/tables/${ORDERS_ID}`);
|
||||
cy.url().should("not.include", "/measures");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Measure creation", () => {
|
||||
it("should create a measure and redirect to edit page", () => {
|
||||
H.DataStudio.Tables.visitMeasuresPage(ORDERS_ID);
|
||||
MeasureList.getNewMeasureLink().click();
|
||||
|
||||
MeasureEditor.getNameInput().type("Total Revenue");
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Sum of ...").click();
|
||||
H.popover().findByText("Total").click();
|
||||
|
||||
MeasureEditor.getSaveButton().click();
|
||||
cy.wait("@createMeasure");
|
||||
|
||||
H.undoToast().should("contain.text", "Measure created");
|
||||
cy.url().should(
|
||||
"match",
|
||||
new RegExp(`/data-studio/library/tables/${ORDERS_ID}/measures/\\d+$`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Breadcrumbs", () => {
|
||||
it("should display collection-based breadcrumbs", () => {
|
||||
createTestMeasure({ name: "Breadcrumb Test Measure" });
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
H.DataStudio.Tables.visitMeasurePage(ORDERS_ID, measureId);
|
||||
});
|
||||
|
||||
MeasureEditor.get().findByText("Data").should("be.visible");
|
||||
});
|
||||
|
||||
it("should navigate back to published table measures via breadcrumb", () => {
|
||||
createTestMeasure({ name: "Breadcrumb Nav Test" });
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
H.DataStudio.Tables.visitMeasurePage(ORDERS_ID, measureId);
|
||||
});
|
||||
|
||||
MeasureEditor.getBreadcrumb("Orders").click();
|
||||
|
||||
cy.url().should(
|
||||
"include",
|
||||
`/data-studio/library/tables/${ORDERS_ID}/measures`,
|
||||
);
|
||||
cy.url().should("not.match", /measures\/\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Measure deletion", () => {
|
||||
it("should redirect to published table measures list after deletion", () => {
|
||||
createTestMeasure({ name: "Measure to Delete" });
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
H.DataStudio.Tables.visitMeasurePage(ORDERS_ID, measureId);
|
||||
});
|
||||
|
||||
MeasureEditor.getActionsButton().click();
|
||||
H.popover().findByText("Remove measure").click();
|
||||
H.modal().button("Remove").click();
|
||||
cy.wait("@updateMeasure");
|
||||
|
||||
H.undoToast().should("contain.text", "Measure removed");
|
||||
cy.url().should(
|
||||
"include",
|
||||
`/data-studio/library/tables/${ORDERS_ID}/measures`,
|
||||
);
|
||||
cy.url().should("not.match", /measures\/\d+/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createTestMeasure(opts: { name?: string; description?: string } = {}) {
|
||||
const { name = "Test Measure", description } = opts;
|
||||
|
||||
H.createMeasure({
|
||||
name,
|
||||
description,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
},
|
||||
}).then(({ body }) => {
|
||||
cy.wrap(body.id).as("measureId");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { openNotebook } from "e2e/support/helpers";
|
||||
import type { Measure, TableId } from "metabase-types/api";
|
||||
|
||||
const { H } = cy;
|
||||
const { MeasureEditor } = H.DataModel;
|
||||
const { ORDERS_ID, ORDERS, PRODUCTS } = SAMPLE_DATABASE;
|
||||
|
||||
const MEASURE_NAME = "Table Measure";
|
||||
|
||||
describe("scenarios > data studio > measures > queries", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
});
|
||||
|
||||
describe("measures queries", () => {
|
||||
it("should create a measure with an aggregation without columns", () => {
|
||||
startNewMeasure();
|
||||
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Count of rows").click();
|
||||
|
||||
saveMeasure();
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("18,760");
|
||||
});
|
||||
|
||||
it("should create a measure with with a column from the main data source", () => {
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of ...").click();
|
||||
cy.findByText("Total").click();
|
||||
});
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("1,510,621.68");
|
||||
});
|
||||
|
||||
it("should create a measure with with a column from the main data source using offset", () => {
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type("Offset(Sum([Total]), -1)");
|
||||
H.CustomExpressionEditor.nameInput().type("Offset Measure");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion({
|
||||
customizeQuery() {
|
||||
breakout("Created At");
|
||||
},
|
||||
});
|
||||
verifyRowValues([["April 2022"], ["May 2022", "52.76"]]);
|
||||
});
|
||||
|
||||
it("should create a measure with a column from an implicit join", () => {
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Average of ...").click();
|
||||
cy.findByText("Product").click();
|
||||
cy.findByText("Price").click();
|
||||
});
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("55.69");
|
||||
});
|
||||
|
||||
it("should create a measure with a column from an implicit join using offset", () => {
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type("Offset(Average([Product -> Price]), -1)");
|
||||
H.CustomExpressionEditor.nameInput().type("Offset Measure");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion({
|
||||
customizeQuery() {
|
||||
breakout("Created At");
|
||||
},
|
||||
});
|
||||
verifyRowValues([["April 2022"], ["May 2022", "49.54"]]);
|
||||
});
|
||||
|
||||
it("should create a measure with a custom aggregation expression", () => {
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type(
|
||||
"DistinctIf([Product → ID], [ID] > 1) + DistinctIf([ID], [Product → ID] > 1)",
|
||||
);
|
||||
H.CustomExpressionEditor.nameInput().type("Custom");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("18,867");
|
||||
});
|
||||
|
||||
it("should create a measure based on a segment", () => {
|
||||
H.createSegment({
|
||||
name: "TotalSegment",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
filter: ["<", ["field", ORDERS.TOTAL, null], 100],
|
||||
},
|
||||
});
|
||||
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type("CountIf([TotalSegment])");
|
||||
H.CustomExpressionEditor.nameInput().type("Custom");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("13,005");
|
||||
});
|
||||
|
||||
it("should create a measure based on another measure with an identity expression", () => {
|
||||
H.createMeasure({
|
||||
name: "TotalMeasure",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type("[TotalMeasure]");
|
||||
H.CustomExpressionEditor.nameInput().type("Custom");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("1,510,621.68");
|
||||
});
|
||||
|
||||
it("should create a measure based on another measure", () => {
|
||||
H.createMeasure({
|
||||
name: "TotalMeasure",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type("floor([TotalMeasure])");
|
||||
H.CustomExpressionEditor.nameInput().type("Custom");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("1,510,621");
|
||||
});
|
||||
|
||||
it("should create a measure based on another measure with an identity expression", () => {
|
||||
H.createMeasure({
|
||||
name: "TotalMeasure",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type("[TotalMeasure]");
|
||||
H.CustomExpressionEditor.nameInput().type("Custom");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("1,510,621.68");
|
||||
});
|
||||
|
||||
it("should not be possible to create a measure that references itself", () => {
|
||||
cy.log("create a new measure");
|
||||
H.DataStudio.Tables.visitNewMeasurePage(ORDERS_ID);
|
||||
|
||||
MeasureEditor.getNameInput().type(MEASURE_NAME);
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Count of rows").click();
|
||||
|
||||
MeasureEditor.getSaveButton().click();
|
||||
H.undoToast().should("contain.text", "Measure created");
|
||||
|
||||
MeasureEditor.get().findByText("Count").click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.clear().type("[Table Measure]").blur();
|
||||
|
||||
H.popover().button("Update").click();
|
||||
MeasureEditor.getSaveButton().click();
|
||||
|
||||
H.undoToast().should("contain.text", "Failed to update measure");
|
||||
});
|
||||
|
||||
it("should not be possible to create a measure that references a metric", () => {
|
||||
H.createQuestion({
|
||||
name: "OrdersCount",
|
||||
type: "metric",
|
||||
description: "A metric",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
});
|
||||
|
||||
H.DataStudio.Tables.visitNewMeasurePage(ORDERS_ID);
|
||||
MeasureEditor.getNameInput().type(MEASURE_NAME);
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
|
||||
H.popover().findByText("Metrics").should("not.exist");
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
|
||||
H.CustomExpressionEditor.type("[OrdersCount]").blur();
|
||||
H.popover()
|
||||
.findByText("Unknown Aggregation, Measure or Metric: OrdersCount")
|
||||
.should("be.visible");
|
||||
H.popover().button("Done").should("be.disabled");
|
||||
});
|
||||
|
||||
it("should be possible to create measures with filters like CountIf", () => {
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type("CountIf([Total] > 10)");
|
||||
H.CustomExpressionEditor.nameInput().type("Custom");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("18,758");
|
||||
});
|
||||
|
||||
it("should be possible to create measures with filters like CountIf based on segments", () => {
|
||||
H.createSegment({
|
||||
name: "LargeTotal",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
filter: [">", ["field", ORDERS.TOTAL, null], 10],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type("CountIf([LargeTotal])");
|
||||
H.CustomExpressionEditor.nameInput().type("Custom");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("18,758");
|
||||
});
|
||||
|
||||
it("should be possible to create measures with filters like based on segments that are nested", () => {
|
||||
H.createSegment({
|
||||
name: "LargeTotal",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
filter: [">", ["field", ORDERS.TOTAL, null], 10],
|
||||
},
|
||||
},
|
||||
}).then(({ body: segment }) => {
|
||||
H.createSegment({
|
||||
name: "NestedSegment",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
filter: [
|
||||
"or",
|
||||
["<", ["field", ORDERS.TOTAL, null], 5],
|
||||
["segment", segment.id],
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
startNewMeasure();
|
||||
MeasureEditor.getAggregationPlaceholder().click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.CustomExpressionEditor.type("CountIf([NestedSegment])");
|
||||
H.CustomExpressionEditor.nameInput().type("Custom");
|
||||
H.popover().button("Done").click();
|
||||
saveMeasure();
|
||||
|
||||
useMeasureInAdhocQuestion();
|
||||
verifyScalarValue("18,759");
|
||||
});
|
||||
|
||||
it("should be possible to offset a measure in a query", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
useMeasureInAdhocQuestion({
|
||||
customizeQuery() {
|
||||
H.getNotebookStep("summarize").findByText("Table Measure").click();
|
||||
H.CustomExpressionEditor.clear().type(
|
||||
`Offset([${MEASURE_NAME}], -1)`,
|
||||
);
|
||||
H.popover().button("Update").click();
|
||||
|
||||
breakout("Created At");
|
||||
},
|
||||
});
|
||||
|
||||
verifyRowValues([["April 2022"], ["May 2022", "52.76"]]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should be possible to order by an aggregation using a measure directly", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
useMeasureInAdhocQuestion({
|
||||
customizeQuery() {
|
||||
breakout("Created At");
|
||||
|
||||
H.sort();
|
||||
H.popover().findByText("Table Measure").click();
|
||||
H.getNotebookStep("sort").findByText("Table Measure").click();
|
||||
},
|
||||
});
|
||||
|
||||
verifyRowValues([
|
||||
["January 2026", "52,249.59"],
|
||||
["January 2025", "51,634.16"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should be possible to order by an aggregation using a custom expression based on a measure", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
useMeasureInAdhocQuestion({
|
||||
customizeQuery() {
|
||||
H.getNotebookStep("summarize").findByText("Table Measure").click();
|
||||
cy.log("Use weird formula to get different ordering in results");
|
||||
H.CustomExpressionEditor.clear().type(
|
||||
`length(text(log([${MEASURE_NAME}])))`,
|
||||
);
|
||||
H.popover().button("Update").click();
|
||||
|
||||
breakout("Created At");
|
||||
|
||||
H.sort();
|
||||
H.popover().findByText("Table Measure").click();
|
||||
},
|
||||
});
|
||||
|
||||
verifyRowValues([
|
||||
["August 2023", "16"],
|
||||
["April 2026", "16"],
|
||||
["May 2022", "17"],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("follow up stages", () => {
|
||||
it("should be possible to use results of a measure in follow up stages", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
useMeasureInAdhocQuestion({
|
||||
customizeQuery() {
|
||||
breakout("Created At");
|
||||
|
||||
H.getNotebookStep("summarize").button("Filter").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText(MEASURE_NAME).click();
|
||||
cy.findByPlaceholderText("Min").type("100");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
H.getNotebookStep("summarize").button("Custom column").click();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `floor([${MEASURE_NAME}] * 2)`,
|
||||
name: "Double measure",
|
||||
clickDone: true,
|
||||
});
|
||||
|
||||
H.getNotebookStep("filter", { stage: 1 })
|
||||
.findByText("Summarize")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Minimum of ...").click();
|
||||
cy.findByText("Double measure").click();
|
||||
});
|
||||
},
|
||||
});
|
||||
verifyScalarValue("2,531");
|
||||
});
|
||||
|
||||
it("should be possible to join on a measure in a follow up stage", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
useMeasureInAdhocQuestion({
|
||||
customizeQuery() {
|
||||
breakout("Created At");
|
||||
|
||||
H.getNotebookStep("summarize").button("Join data").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
|
||||
H.popover().findByText("Table Measure").click();
|
||||
H.popover().findByText("Total").click();
|
||||
},
|
||||
});
|
||||
|
||||
verifyRowValues([["April 2022", "52.76", "8685"]]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should be possible to join on a measure in a follow up stage with a custom expression", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
useMeasureInAdhocQuestion({
|
||||
customizeQuery() {
|
||||
breakout("Created At");
|
||||
|
||||
H.getNotebookStep("summarize").button("Join data").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
|
||||
H.CustomExpressionEditor.clear()
|
||||
.type("floor([Table Measure]/10)")
|
||||
.blur();
|
||||
H.popover().findByText("Done").click();
|
||||
|
||||
H.popover().findByText("ID").click();
|
||||
},
|
||||
});
|
||||
|
||||
verifyRowValues([["April 2022", "52.76", "5", "1"]]);
|
||||
});
|
||||
|
||||
describe("measure refs", () => {
|
||||
it("should be possible to rename a measure without breaking queries that reference it", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
}).then(({ body: measure }) => {
|
||||
useMeasureInAdhocQuestion();
|
||||
|
||||
H.updateMeasure({
|
||||
id: measure.id,
|
||||
name: "Renamed measure",
|
||||
});
|
||||
|
||||
cy.reload();
|
||||
verifyScalarValue("1,510,621.68");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be possible to rename an aggregation expression based on a measure without breaking it", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
useMeasureInAdhocQuestion({
|
||||
customizeQuery() {
|
||||
H.getNotebookStep("summarize").findByText("Table Measure").click();
|
||||
H.CustomExpressionEditor.nameInput()
|
||||
.clear()
|
||||
.type("Renamed aggregation");
|
||||
H.popover().button("Update").click();
|
||||
},
|
||||
});
|
||||
|
||||
verifyScalarValue("1,510,621.68");
|
||||
});
|
||||
|
||||
it("changing the top-level aggregation expression in a measure might break queries that reference it in follow up stages", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
}).then(({ body: measure }) => {
|
||||
H.createQuestion({
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [
|
||||
["measure", { "display-name": measure.name }, measure.id],
|
||||
],
|
||||
breakout: [
|
||||
["field", ORDERS.CREATED_AT, { "temporal-unit": "day" }],
|
||||
],
|
||||
},
|
||||
}).then(({ body: question }) => {
|
||||
cy.log("Add a second stage that references the measure");
|
||||
H.visitQuestion(question.id);
|
||||
openNotebook();
|
||||
H.getNotebookStep("summarize").findByText("Filter").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText(MEASURE_NAME).click();
|
||||
cy.findByPlaceholderText("Min").type("10");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("qb-header").button("Save").click();
|
||||
H.modal().within(() => {
|
||||
cy.log("Ensure that 'Replace original question' is checked");
|
||||
cy.findByLabelText(/Replace original question/i).should(
|
||||
"be.checked",
|
||||
);
|
||||
cy.button("Save").click();
|
||||
});
|
||||
|
||||
H.updateMeasure({
|
||||
id: measure.id,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
cy.log("Add a second stage that references the measure");
|
||||
H.visitQuestion(question.id);
|
||||
cy.findByTestId("query-builder-main")
|
||||
.findByText("There was a problem with your question")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should be possible to use a measure in a pivot table", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
}).then(({ body: measure }) => {
|
||||
H.createQuestion(
|
||||
{
|
||||
name: "Question with measure",
|
||||
display: "pivot",
|
||||
visualization_settings: {
|
||||
"table.pivot_column": "Created At: Week",
|
||||
"table.cell_column": "Table Measure",
|
||||
},
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [
|
||||
["measure", { "display-name": measure.name }, measure.id],
|
||||
],
|
||||
breakout: [
|
||||
["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }],
|
||||
[
|
||||
"field",
|
||||
PRODUCTS.CATEGORY,
|
||||
{ "base-type": "type/Text", "source-field": ORDERS.PRODUCT_ID },
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
});
|
||||
|
||||
cy.findByTestId("pivot-table").within(() => {
|
||||
cy.findByText("Row totals").should("be.visible");
|
||||
cy.findByText("Grand totals").should("be.visible");
|
||||
cy.findAllByTestId("pivot-table-cell")
|
||||
.should("have.length", 42)
|
||||
.eq(12) // a random cell
|
||||
.should("have.text", "9,031.56");
|
||||
});
|
||||
});
|
||||
|
||||
describe("using measures in saved questions", () => {
|
||||
it("should be possible to use measure results in a saved question as source for a follow up question", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
}).then(({ body: measure }) => {
|
||||
H.createQuestion({
|
||||
name: "Question with measure",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [
|
||||
["measure", { "display-name": measure.name }, measure.id],
|
||||
],
|
||||
breakout: [
|
||||
["field", ORDERS.CREATED_AT, { "temporal-unit": "week" }],
|
||||
],
|
||||
},
|
||||
}).then(({ body: question }) => {
|
||||
H.createQuestion(
|
||||
{
|
||||
query: {
|
||||
"source-table": `card__${question.id}`,
|
||||
},
|
||||
display: "scalar",
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
H.openNotebook();
|
||||
|
||||
H.filter({ mode: "notebook" });
|
||||
H.popover().within(() => {
|
||||
cy.findByText(MEASURE_NAME).click();
|
||||
cy.findByPlaceholderText("Min").type("100");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
H.summarize({ mode: "notebook" });
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `Sum([${MEASURE_NAME}])`,
|
||||
name: "Table Measure Sum",
|
||||
clickDone: true,
|
||||
});
|
||||
|
||||
H.visualize();
|
||||
verifyScalarValue("1,510,568.93");
|
||||
});
|
||||
|
||||
it("should be possible to use measure results in a saved question as source for a follow up model", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
},
|
||||
}).then(({ body: measure }) => {
|
||||
H.createQuestion({
|
||||
name: "Question with measure",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [
|
||||
["measure", { "display-name": measure.name }, measure.id],
|
||||
],
|
||||
breakout: [
|
||||
["field", ORDERS.CREATED_AT, { "temporal-unit": "week" }],
|
||||
],
|
||||
},
|
||||
}).then(({ body: question }) => {
|
||||
H.createQuestion(
|
||||
{
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": `card__${question.id}`,
|
||||
},
|
||||
display: "scalar",
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
H.openQuestionActions("Edit query definition");
|
||||
|
||||
H.filter({ mode: "notebook" });
|
||||
H.popover().within(() => {
|
||||
cy.findByText(MEASURE_NAME).click();
|
||||
cy.findByPlaceholderText("Min").type("100");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
H.summarize({ mode: "notebook" });
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `Sum([${MEASURE_NAME}])`,
|
||||
name: "Table Measure Sum",
|
||||
clickDone: true,
|
||||
});
|
||||
|
||||
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
|
||||
verifyScalarValue("1,510,568.93");
|
||||
});
|
||||
|
||||
it("should be possible x-ray a question containing a measure", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
})
|
||||
.then(({ body: measure }) => {
|
||||
H.createQuestion({
|
||||
name: "Test model",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [
|
||||
["measure", { "display-name": measure.name }, measure.id],
|
||||
],
|
||||
breakout: [
|
||||
["field", ORDERS.CREATED_AT, { "temporal-unit": "day" }],
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
.then(({ body: model }) => {
|
||||
cy.visit(`/auto/dashboard/question/${model.id}`);
|
||||
|
||||
H.main()
|
||||
.findByText("A look at the Table Measure")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("using measures in models", () => {
|
||||
it("should be possible x-ray a model containing a measure", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
})
|
||||
.then(({ body: measure }) => {
|
||||
H.createQuestion({
|
||||
type: "model",
|
||||
name: "Test model",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [
|
||||
["measure", { "display-name": measure.name }, measure.id],
|
||||
],
|
||||
breakout: [
|
||||
["field", ORDERS.CREATED_AT, { "temporal-unit": "day" }],
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
.then(({ body: model }) => {
|
||||
cy.visit(`/auto/dashboard/model/${model.id}`);
|
||||
|
||||
H.main()
|
||||
.findByText("A look at the Table Measure")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function startNewMeasure({
|
||||
name = MEASURE_NAME,
|
||||
tableId = ORDERS_ID,
|
||||
}: {
|
||||
name?: string;
|
||||
tableId?: TableId;
|
||||
} = {}) {
|
||||
cy.log("create a new measure");
|
||||
H.DataStudio.Tables.visitNewMeasurePage(tableId);
|
||||
MeasureEditor.getNameInput().type(name);
|
||||
}
|
||||
|
||||
function saveMeasure(): Cypress.Chainable<Measure> {
|
||||
cy.intercept("POST", "/api/measure").as("measureCreate");
|
||||
MeasureEditor.getSaveButton().click();
|
||||
|
||||
return cy.wait("@measureCreate").then(({ response }) => {
|
||||
H.undoToast().should("contain.text", "Measure created");
|
||||
return cy.wrap(response?.body as Measure);
|
||||
});
|
||||
}
|
||||
|
||||
function useMeasureInAdhocQuestion({
|
||||
measureName = MEASURE_NAME,
|
||||
tableId = ORDERS_ID,
|
||||
customizeQuery,
|
||||
}: {
|
||||
measureName?: string;
|
||||
tableId?: TableId;
|
||||
customizeQuery?: () => void;
|
||||
} = {}) {
|
||||
cy.log("verify the measure works in query builder");
|
||||
H.openTable({ table: tableId, mode: "notebook" });
|
||||
H.summarize({ mode: "notebook" });
|
||||
H.popover().findByText("Measures").click();
|
||||
H.popover().findByText(measureName).click();
|
||||
|
||||
customizeQuery?.();
|
||||
|
||||
H.visualize();
|
||||
}
|
||||
|
||||
function breakout(columnName = "Created At") {
|
||||
H.getNotebookStep("summarize")
|
||||
.findByText("Pick a column to group by")
|
||||
.click();
|
||||
H.popover().findByText(columnName).click();
|
||||
}
|
||||
|
||||
function verifyScalarValue(scalarValue: string) {
|
||||
H.queryBuilderMain()
|
||||
.findByTestId("scalar-value")
|
||||
.should("have.text", scalarValue);
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
}
|
||||
|
||||
// Custom implementation of H.assertTableData that allows for empty cells
|
||||
function verifyRowValues(rowValues: string[][]) {
|
||||
cy.findByTestId("view-footer").within(() => {
|
||||
cy.findByLabelText("Switch to data").click(); // Switch to the tabular view...
|
||||
});
|
||||
|
||||
rowValues.flat().forEach((value, index) => {
|
||||
H.tableInteractiveBody()
|
||||
.findAllByTestId("cell-data")
|
||||
.should("have.length.gt", rowValues.length)
|
||||
.eq(index)
|
||||
.should("have.text", value);
|
||||
});
|
||||
}
|
||||
1195
e2e/test/scenarios/data-studio/workspaces.cy.spec.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
const { H } = cy;
|
||||
|
||||
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import type { CardId, CardType, FieldId, TableId } from "metabase-types/api";
|
||||
|
||||
const { ORDERS_ID, ORDERS, REVIEWS } = SAMPLE_DATABASE;
|
||||
|
||||
const WRITABLE_TABLE_NAME = "scoreboard_actions";
|
||||
|
||||
const VALID_CARD_COLUMN_ID = "Valid card column with id";
|
||||
const VALID_CARD_COLUMN_NAME = "Valid card column with name";
|
||||
const VALID_NATIVE_CARD = "Valid native card";
|
||||
const VALID_SEGMENT = "Valid segment";
|
||||
const VALID_METRIC = "Valid metric";
|
||||
|
||||
const VALID_ENTITY_NAMES = [
|
||||
VALID_CARD_COLUMN_ID,
|
||||
VALID_CARD_COLUMN_NAME,
|
||||
VALID_NATIVE_CARD,
|
||||
VALID_SEGMENT,
|
||||
VALID_METRIC,
|
||||
];
|
||||
|
||||
const CARD_COLUMN_ID_MISSING = "Card with a non-existing column with an id";
|
||||
const CARD_COLUMN_WRONG_TABLE = "Card with a column on a wrong table";
|
||||
const CARD_COLUMN_NAME_MISSING = "Card with a non-existing column with a name";
|
||||
const NATIVE_CARD_COLUMN_MISSING = "Native card with a non-existing column";
|
||||
const NATIVE_CARD_TABLE_ALIAS_MISSING =
|
||||
"Native card with a missing table alias";
|
||||
const NATIVE_CARD_SYNTAX_ERROR = "Native card with a syntax error";
|
||||
|
||||
const BROKEN_MBQL_CARD_NAMES = [
|
||||
CARD_COLUMN_ID_MISSING,
|
||||
CARD_COLUMN_WRONG_TABLE,
|
||||
CARD_COLUMN_NAME_MISSING,
|
||||
];
|
||||
|
||||
const BROKEN_MBQL_CARD_ERRORS = {
|
||||
[CARD_COLUMN_ID_MISSING]: { "1 missing column": ["Unknown Field"] },
|
||||
[CARD_COLUMN_WRONG_TABLE]: { "1 missing column": ["RATING"] },
|
||||
[CARD_COLUMN_NAME_MISSING]: { "1 missing column": ["BAD_NAME"] },
|
||||
};
|
||||
|
||||
const BROKEN_NATIVE_CARD_NAMES = [
|
||||
NATIVE_CARD_COLUMN_MISSING,
|
||||
NATIVE_CARD_TABLE_ALIAS_MISSING,
|
||||
NATIVE_CARD_SYNTAX_ERROR,
|
||||
];
|
||||
|
||||
const BROKEN_NATIVE_CARD_ERRORS = {
|
||||
[NATIVE_CARD_COLUMN_MISSING]: { "1 missing column": ["RATING"] },
|
||||
[NATIVE_CARD_TABLE_ALIAS_MISSING]: { "1 missing table alias": ["P"] },
|
||||
[NATIVE_CARD_SYNTAX_ERROR]: { "1 syntax error": [] },
|
||||
};
|
||||
|
||||
const BROKEN_CARD_NAMES = [
|
||||
...BROKEN_MBQL_CARD_NAMES,
|
||||
...BROKEN_NATIVE_CARD_NAMES,
|
||||
];
|
||||
|
||||
const BROKEN_CARD_ERRORS = {
|
||||
...BROKEN_MBQL_CARD_ERRORS,
|
||||
...BROKEN_NATIVE_CARD_ERRORS,
|
||||
};
|
||||
|
||||
const SEGMENT_COLUMN_ID_MISSING =
|
||||
"Segment with a non-existing column with an id";
|
||||
const BROKEN_SEGMENT_NAMES = [SEGMENT_COLUMN_ID_MISSING];
|
||||
|
||||
const TRANSFORM_COLUMN_ID_MISSING =
|
||||
"Transform with a non-existing column with an id";
|
||||
const TRANSFORM_COLUMN_WRONG_TABLE = "Transform with a column on a wrong table";
|
||||
const TRANSFORM_CARD_COLUMN_MISSING =
|
||||
"Native transform with a non-existing column";
|
||||
const TRANSFORM_CARD_TABLE_ALIAS_MISSING =
|
||||
"Native transform with a missing table alias";
|
||||
const TRANSFORM_CARD_SYNTAX_ERROR = "Native transform with a syntax error";
|
||||
|
||||
const BROKEN_TRANSFORM_NAMES = [
|
||||
TRANSFORM_COLUMN_ID_MISSING,
|
||||
TRANSFORM_COLUMN_WRONG_TABLE,
|
||||
TRANSFORM_CARD_COLUMN_MISSING,
|
||||
TRANSFORM_CARD_TABLE_ALIAS_MISSING,
|
||||
TRANSFORM_CARD_SYNTAX_ERROR,
|
||||
];
|
||||
|
||||
const BROKEN_TRANSFORM_ERRORS = {
|
||||
[TRANSFORM_COLUMN_ID_MISSING]: { "1 missing column": ["Unknown Field"] },
|
||||
[TRANSFORM_COLUMN_WRONG_TABLE]: { "1 missing column": ["Unknown Field"] },
|
||||
[TRANSFORM_CARD_COLUMN_MISSING]: { "1 missing column": ["rating"] },
|
||||
[TRANSFORM_CARD_TABLE_ALIAS_MISSING]: { "1 missing table alias": ["P"] },
|
||||
[TRANSFORM_CARD_SYNTAX_ERROR]: { "1 syntax error": [] },
|
||||
};
|
||||
|
||||
describe("scenarios > dependencies > broken list", () => {
|
||||
beforeEach(() => {
|
||||
H.restore("postgres-writable");
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.resetTestTable({ type: "postgres", table: WRITABLE_TABLE_NAME });
|
||||
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: WRITABLE_TABLE_NAME });
|
||||
cy.viewport(1600, 1400);
|
||||
});
|
||||
|
||||
describe("analysis", () => {
|
||||
it("should not show valid entities", () => {
|
||||
createValidEntities();
|
||||
createBrokenCards({ type: "question" });
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
VALID_ENTITY_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show broken questions", () => {
|
||||
createBrokenCards({ type: "question" });
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
BROKEN_CARD_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show broken models", () => {
|
||||
createBrokenCards({ type: "model" });
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
BROKEN_CARD_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show broken metrics", () => {
|
||||
createBrokenCards({ type: "metric" });
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
BROKEN_MBQL_CARD_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show broken segments", () => {
|
||||
createBrokenSegments();
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
BROKEN_SEGMENT_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show broken transforms", () => {
|
||||
createBrokenTransforms();
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
BROKEN_TRANSFORM_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("should search for entities", () => {
|
||||
createBrokenCards({ type: "question" });
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.searchInput().type(CARD_COLUMN_ID_MISSING);
|
||||
checkList({
|
||||
visibleEntities: [CARD_COLUMN_ID_MISSING],
|
||||
hiddenEntities: [CARD_COLUMN_WRONG_TABLE],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filters", () => {
|
||||
it("should filter entities by type", () => {
|
||||
createBrokenCards({ type: "question" });
|
||||
createBrokenSegments();
|
||||
createBrokenTransforms();
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
checkList({
|
||||
visibleEntities: [...BROKEN_CARD_NAMES, ...BROKEN_TRANSFORM_NAMES],
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.popover().findByText("Question").click();
|
||||
checkList({
|
||||
visibleEntities: [...BROKEN_CARD_NAMES],
|
||||
hiddenEntities: [...BROKEN_TRANSFORM_NAMES],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar", () => {
|
||||
it("should show the sidebar for questions with error info", () => {
|
||||
createBrokenCards({ type: "question" });
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
openSidebarAndCheckErrors(BROKEN_CARD_ERRORS);
|
||||
});
|
||||
|
||||
it("should show the sidebar for models with error info", () => {
|
||||
createBrokenCards({ type: "model" });
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
openSidebarAndCheckErrors(BROKEN_CARD_ERRORS);
|
||||
});
|
||||
|
||||
it("should show the sidebar for metrics with error info", () => {
|
||||
createBrokenCards({ type: "metric" });
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
openSidebarAndCheckErrors(BROKEN_MBQL_CARD_ERRORS);
|
||||
});
|
||||
|
||||
it("should show the sidebar for transforms with error info", () => {
|
||||
createBrokenTransforms();
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
openSidebarAndCheckErrors(BROKEN_TRANSFORM_ERRORS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createValidEntities() {
|
||||
createCardWithFieldIdRef({
|
||||
name: VALID_CARD_COLUMN_ID,
|
||||
type: "question",
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: ORDERS.TOTAL,
|
||||
});
|
||||
createCardWithFieldNameRef({
|
||||
name: VALID_CARD_COLUMN_NAME,
|
||||
type: "model",
|
||||
cardId: ORDERS_QUESTION_ID,
|
||||
fieldName: "TOTAL",
|
||||
baseType: "type/Float",
|
||||
});
|
||||
createNativeCard({
|
||||
name: VALID_NATIVE_CARD,
|
||||
type: "metric",
|
||||
query: "SELECT ID, TOTAL FROM ORDERS",
|
||||
});
|
||||
createSegmentWithFieldIdRef({
|
||||
name: VALID_SEGMENT,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: ORDERS.TOTAL,
|
||||
});
|
||||
}
|
||||
|
||||
function createBrokenCards({ type }: { type: CardType }) {
|
||||
createCardWithFieldIdRef({
|
||||
name: CARD_COLUMN_WRONG_TABLE,
|
||||
type,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
createCardWithFieldIdRef({
|
||||
name: CARD_COLUMN_ID_MISSING,
|
||||
type,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: 1000,
|
||||
});
|
||||
createCardWithFieldNameRef({
|
||||
name: CARD_COLUMN_NAME_MISSING,
|
||||
type,
|
||||
cardId: ORDERS_QUESTION_ID,
|
||||
fieldName: "BAD_NAME",
|
||||
baseType: "type/Integer",
|
||||
});
|
||||
|
||||
if (type !== "metric") {
|
||||
createNativeCard({
|
||||
name: NATIVE_CARD_COLUMN_MISSING,
|
||||
type,
|
||||
query: "SELECT ID, RATING FROM ORDERS",
|
||||
});
|
||||
createNativeCard({
|
||||
name: NATIVE_CARD_TABLE_ALIAS_MISSING,
|
||||
type,
|
||||
query: "SELECT P.ID, P.PRICE FROM ORDERS",
|
||||
});
|
||||
createNativeCard({
|
||||
name: NATIVE_CARD_SYNTAX_ERROR,
|
||||
type,
|
||||
query: "SELECT FROM",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createBrokenSegments() {
|
||||
createSegmentWithFieldIdRef({
|
||||
name: SEGMENT_COLUMN_ID_MISSING,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
}
|
||||
|
||||
function createBrokenTransforms() {
|
||||
H.getTableId({ name: WRITABLE_TABLE_NAME, databaseId: WRITABLE_DB_ID }).then(
|
||||
(tableId) => {
|
||||
createTransformWithFieldIdRef({
|
||||
name: TRANSFORM_COLUMN_ID_MISSING,
|
||||
tableId: tableId,
|
||||
fieldId: 1000,
|
||||
});
|
||||
createTransformWithFieldIdRef({
|
||||
name: TRANSFORM_COLUMN_WRONG_TABLE,
|
||||
tableId: tableId,
|
||||
fieldId: ORDERS.TOTAL,
|
||||
});
|
||||
createNativeTransform({
|
||||
name: TRANSFORM_CARD_COLUMN_MISSING,
|
||||
query: `SELECT RATING FROM "${WRITABLE_TABLE_NAME}"`,
|
||||
});
|
||||
createNativeTransform({
|
||||
name: TRANSFORM_CARD_TABLE_ALIAS_MISSING,
|
||||
query: `SELECT P.ID, P.PRICE FROM "${WRITABLE_TABLE_NAME}"`,
|
||||
});
|
||||
createNativeTransform({
|
||||
name: TRANSFORM_CARD_SYNTAX_ERROR,
|
||||
query: "SELECT FROM",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createCardWithFieldIdRef({
|
||||
name,
|
||||
type,
|
||||
tableId,
|
||||
fieldId,
|
||||
}: {
|
||||
name: string;
|
||||
type: CardType;
|
||||
tableId: TableId;
|
||||
fieldId: FieldId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: ["not-null", ["field", fieldId, null]],
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createCardWithFieldNameRef({
|
||||
name,
|
||||
type,
|
||||
cardId,
|
||||
fieldName,
|
||||
baseType,
|
||||
}: {
|
||||
name: string;
|
||||
type: CardType;
|
||||
cardId: CardId;
|
||||
fieldName: string;
|
||||
baseType: string;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type,
|
||||
query: {
|
||||
"source-table": `card__${cardId}`,
|
||||
filter: ["not-null", ["field", fieldName, { "base-type": baseType }]],
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createNativeCard({
|
||||
name,
|
||||
type,
|
||||
query,
|
||||
}: {
|
||||
name: string;
|
||||
type: CardType;
|
||||
query: string;
|
||||
}) {
|
||||
return H.createNativeQuestion({
|
||||
name,
|
||||
type,
|
||||
native: {
|
||||
query,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createSegmentWithFieldIdRef({
|
||||
name,
|
||||
tableId,
|
||||
fieldId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
fieldId: FieldId;
|
||||
}) {
|
||||
return H.createSegment({
|
||||
name,
|
||||
table_id: tableId,
|
||||
definition: {
|
||||
"source-table": tableId,
|
||||
filter: ["not-null", ["field", fieldId, null]],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createTransformWithFieldIdRef({
|
||||
name,
|
||||
tableId,
|
||||
fieldId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
fieldId: FieldId;
|
||||
}) {
|
||||
return H.createTransform({
|
||||
name,
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
type: "query",
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: ["not-null", ["field", fieldId, null]],
|
||||
},
|
||||
},
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name,
|
||||
schema: "public",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createNativeTransform({
|
||||
name,
|
||||
query,
|
||||
}: {
|
||||
name: string;
|
||||
query: string;
|
||||
}) {
|
||||
return H.createTransform({
|
||||
name,
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
database: WRITABLE_DB_ID,
|
||||
type: "native",
|
||||
native: { query },
|
||||
},
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name,
|
||||
schema: "public",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function checkList({
|
||||
visibleEntities = [],
|
||||
hiddenEntities = [],
|
||||
}: {
|
||||
visibleEntities?: string[];
|
||||
hiddenEntities?: string[];
|
||||
}) {
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
visibleEntities.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
hiddenEntities.forEach((name) => {
|
||||
cy.findByText(name).should("not.exist");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkSidebar({
|
||||
entityName,
|
||||
errors = {},
|
||||
}: {
|
||||
entityName: string;
|
||||
locationName?: string;
|
||||
creatorName?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
}) {
|
||||
H.DataStudio.Tasks.Sidebar.header()
|
||||
.findByText(entityName)
|
||||
.should("be.visible");
|
||||
|
||||
Object.entries(errors).forEach(([label, errors]) => {
|
||||
H.DataStudio.Tasks.Sidebar.errorInfo(label).within(() => {
|
||||
errors.forEach((error) => {
|
||||
cy.findByText(error).should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openSidebarAndCheckErrors(
|
||||
errors: Record<string, Record<string, string[]>>,
|
||||
) {
|
||||
Object.entries(errors).forEach(([entityName, errors]) => {
|
||||
H.DataStudio.Tasks.list().findByText(entityName).click();
|
||||
checkSidebar({
|
||||
entityName,
|
||||
errors,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
const { H } = cy;
|
||||
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { FIRST_COLLECTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import type {
|
||||
CardId,
|
||||
CollectionId,
|
||||
FieldId,
|
||||
NativeQuerySnippetId,
|
||||
SegmentId,
|
||||
TableId,
|
||||
} from "metabase-types/api";
|
||||
import { createMockParameter } from "metabase-types/api/mocks";
|
||||
|
||||
const { ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
const MODEL_FOR_QUESTION_DATA_SOURCE = "Model for question data source";
|
||||
const MODEL_FOR_MODEL_DATA_SOURCE = "Model for model data source";
|
||||
const MODEL_FOR_METRIC_DATA_SOURCE = "Model for metric data source";
|
||||
const MODEL_FOR_NATIVE_QUESTION_CARD_TAG = "Model for native question card tag";
|
||||
const MODEL_FOR_NATIVE_QUESTION_PARAMETER_SOURCE =
|
||||
"Model for native question parameter source";
|
||||
const MODEL_FOR_DASHBOARD_CARD = "Model for dashboard card";
|
||||
const MODEL_FOR_DASHBOARD_PARAMETER_SOURCE =
|
||||
"Model for dashboard parameter source";
|
||||
const SEGMENT_FOR_QUESTION_FILTER = "Segment for question filter";
|
||||
const SEGMENT_FOR_MODEL_FILTER = "Segment for model filter";
|
||||
const SEGMENT_FOR_SEGMENT_FILTER = "Segment for segment filter";
|
||||
const SEGMENT_FOR_METRIC_FILTER = "Segment for metric filter";
|
||||
const METRIC_FOR_QUESTION_AGGREGATION = "Metric for question aggregation";
|
||||
const METRIC_FOR_MODEL_AGGREGATION = "Metric for model aggregation";
|
||||
const METRIC_FOR_METRIC_AGGREGATION = "Metric for metric aggregation";
|
||||
const METRIC_FOR_DASHBOARD_CARD = "Metric for dashboard card";
|
||||
const SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG =
|
||||
"Snippet for native question card tag";
|
||||
const SNIPPET_FOR_SNIPPET_TAG = "Snippet for snippet tag";
|
||||
|
||||
const MODEL_NAMES = [
|
||||
MODEL_FOR_QUESTION_DATA_SOURCE,
|
||||
MODEL_FOR_MODEL_DATA_SOURCE,
|
||||
MODEL_FOR_METRIC_DATA_SOURCE,
|
||||
MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
MODEL_FOR_NATIVE_QUESTION_PARAMETER_SOURCE,
|
||||
MODEL_FOR_DASHBOARD_CARD,
|
||||
MODEL_FOR_DASHBOARD_PARAMETER_SOURCE,
|
||||
];
|
||||
|
||||
const SEGMENT_NAMES = [
|
||||
SEGMENT_FOR_QUESTION_FILTER,
|
||||
SEGMENT_FOR_MODEL_FILTER,
|
||||
SEGMENT_FOR_SEGMENT_FILTER,
|
||||
SEGMENT_FOR_METRIC_FILTER,
|
||||
];
|
||||
|
||||
const METRIC_NAMES = [
|
||||
METRIC_FOR_QUESTION_AGGREGATION,
|
||||
METRIC_FOR_MODEL_AGGREGATION,
|
||||
METRIC_FOR_METRIC_AGGREGATION,
|
||||
METRIC_FOR_DASHBOARD_CARD,
|
||||
];
|
||||
|
||||
const SNIPPET_NAMES = [
|
||||
SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
SNIPPET_FOR_SNIPPET_TAG,
|
||||
];
|
||||
|
||||
const ENTITY_NAMES = [
|
||||
...MODEL_NAMES,
|
||||
...SEGMENT_NAMES,
|
||||
...METRIC_NAMES,
|
||||
...SNIPPET_NAMES,
|
||||
];
|
||||
|
||||
describe("scenarios > dependencies > unreferenced list", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.viewport(1600, 1400);
|
||||
});
|
||||
|
||||
describe("analysis", () => {
|
||||
it("should show unreferenced entities", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
ENTITY_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show referenced entities", () => {
|
||||
createEntities({ withReferences: true });
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
ENTITY_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("should search for entities", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type(MODEL_FOR_QUESTION_DATA_SOURCE);
|
||||
checkList({
|
||||
visibleEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
|
||||
hiddenEntities: [MODEL_FOR_MODEL_DATA_SOURCE],
|
||||
});
|
||||
});
|
||||
|
||||
it("should search for entities with type filters", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type("tag");
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
SNIPPET_FOR_SNIPPET_TAG,
|
||||
],
|
||||
hiddenEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.popover().findByText("Model").click();
|
||||
checkList({
|
||||
visibleEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG],
|
||||
hiddenEntities: [SNIPPET_FOR_SNIPPET_TAG],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filters", () => {
|
||||
it("should filter entities by type", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
checkList({ visibleEntities: ENTITY_NAMES });
|
||||
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.popover().findByText("Model").click();
|
||||
checkList({ visibleEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG] });
|
||||
|
||||
H.popover().findByText("Segment").click();
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
SEGMENT_FOR_QUESTION_FILTER,
|
||||
],
|
||||
});
|
||||
|
||||
H.popover().findByText("Metric").click();
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
SEGMENT_FOR_QUESTION_FILTER,
|
||||
METRIC_FOR_QUESTION_AGGREGATION,
|
||||
],
|
||||
});
|
||||
|
||||
H.popover().findByText("Snippet").click();
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
SEGMENT_FOR_QUESTION_FILTER,
|
||||
METRIC_FOR_QUESTION_AGGREGATION,
|
||||
SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar", () => {
|
||||
it("should show the sidebar for supported entities", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
.findByText(MODEL_FOR_QUESTION_DATA_SOURCE)
|
||||
.click();
|
||||
checkSidebar({
|
||||
entityName: MODEL_FOR_QUESTION_DATA_SOURCE,
|
||||
locationName: "Our analytics",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list().findByText(MODEL_FOR_MODEL_DATA_SOURCE).click();
|
||||
checkSidebar({
|
||||
entityName: MODEL_FOR_MODEL_DATA_SOURCE,
|
||||
locationName: "First collection",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list().findByText(SEGMENT_FOR_QUESTION_FILTER).click();
|
||||
checkSidebar({
|
||||
entityName: SEGMENT_FOR_QUESTION_FILTER,
|
||||
locationName: "Orders",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
.findByText(METRIC_FOR_QUESTION_AGGREGATION)
|
||||
.click();
|
||||
checkSidebar({
|
||||
entityName: METRIC_FOR_QUESTION_AGGREGATION,
|
||||
locationName: "Our analytics",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
.findByText(METRIC_FOR_MODEL_AGGREGATION)
|
||||
.click();
|
||||
checkSidebar({
|
||||
entityName: METRIC_FOR_MODEL_AGGREGATION,
|
||||
locationName: "First collection",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
.findByText(SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG)
|
||||
.click();
|
||||
checkSidebar({
|
||||
entityName: SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
locationName: "SQL snippets",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createEntities({
|
||||
withReferences = false,
|
||||
}: { withReferences?: boolean } = {}) {
|
||||
createModelContent({ withReferences });
|
||||
createSegmentContent({ withReferences });
|
||||
createMetricContent({ withReferences });
|
||||
createSnippetContent({ withReferences });
|
||||
}
|
||||
|
||||
function createModelContent({
|
||||
withReferences = false,
|
||||
}: {
|
||||
withReferences?: boolean;
|
||||
}) {
|
||||
createModelWithTableDataSource({
|
||||
name: MODEL_FOR_QUESTION_DATA_SOURCE,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: model }) => {
|
||||
if (withReferences) {
|
||||
createQuestionWithModelDataSource({
|
||||
name: `${MODEL_FOR_QUESTION_DATA_SOURCE} -> Question`,
|
||||
modelId: model.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createModelWithTableDataSource({
|
||||
name: MODEL_FOR_MODEL_DATA_SOURCE,
|
||||
tableId: ORDERS_ID,
|
||||
collectionId: FIRST_COLLECTION_ID,
|
||||
}).then(({ body: model }) => {
|
||||
if (withReferences) {
|
||||
createModelWithModelDataSource({
|
||||
name: `${MODEL_FOR_MODEL_DATA_SOURCE} -> Model`,
|
||||
modelId: model.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createModelWithTableDataSource({
|
||||
name: MODEL_FOR_METRIC_DATA_SOURCE,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: model }) => {
|
||||
if (withReferences) {
|
||||
createMetricWithModelDataSource({
|
||||
name: `${MODEL_FOR_METRIC_DATA_SOURCE} -> Metric`,
|
||||
modelId: model.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createModelWithTableDataSource({
|
||||
name: MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: model }) => {
|
||||
if (withReferences) {
|
||||
createNativeQuestionWithCardTag({
|
||||
name: `${MODEL_FOR_NATIVE_QUESTION_CARD_TAG} -> Question`,
|
||||
cardId: model.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createModelWithTableDataSource({
|
||||
name: MODEL_FOR_NATIVE_QUESTION_PARAMETER_SOURCE,
|
||||
tableId: PRODUCTS_ID,
|
||||
}).then(({ body: model }) => {
|
||||
if (withReferences) {
|
||||
createNativeQuestionWithParameterWithCardSource({
|
||||
name: `${MODEL_FOR_NATIVE_QUESTION_PARAMETER_SOURCE} -> Question`,
|
||||
tableName: "PRODUCTS",
|
||||
tableFieldId: PRODUCTS.CATEGORY,
|
||||
cardId: model.id,
|
||||
cardFieldName: "CATEGORY",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createModelWithTableDataSource({
|
||||
name: MODEL_FOR_DASHBOARD_CARD,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: model }) => {
|
||||
if (withReferences) {
|
||||
createDashboardWithCard({
|
||||
name: `${MODEL_FOR_DASHBOARD_CARD} -> Dashboard Card`,
|
||||
cardId: model.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createModelWithTableDataSource({
|
||||
name: MODEL_FOR_DASHBOARD_PARAMETER_SOURCE,
|
||||
tableId: PRODUCTS_ID,
|
||||
}).then(({ body: model }) => {
|
||||
if (withReferences) {
|
||||
createDashboardWithParameterWithCardSource({
|
||||
name: `${MODEL_FOR_DASHBOARD_PARAMETER_SOURCE} -> Dashboard`,
|
||||
cardId: model.id,
|
||||
cardFieldName: "CATEGORY",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createSegmentContent({
|
||||
withReferences = false,
|
||||
}: {
|
||||
withReferences?: boolean;
|
||||
}) {
|
||||
createSegmentWithTableDataSource({
|
||||
name: SEGMENT_FOR_QUESTION_FILTER,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: segment }) => {
|
||||
if (withReferences) {
|
||||
createQuestionWithSegmentClause({
|
||||
name: `${SEGMENT_FOR_QUESTION_FILTER} -> Question`,
|
||||
tableId: ORDERS_ID,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createSegmentWithTableDataSource({
|
||||
name: SEGMENT_FOR_MODEL_FILTER,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: segment }) => {
|
||||
if (withReferences) {
|
||||
createModelWithSegmentClause({
|
||||
name: `${SEGMENT_FOR_MODEL_FILTER} -> Model`,
|
||||
tableId: ORDERS_ID,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createSegmentWithTableDataSource({
|
||||
name: SEGMENT_FOR_SEGMENT_FILTER,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: segment }) => {
|
||||
if (withReferences) {
|
||||
createSegmentWithSegmentClause({
|
||||
name: `${SEGMENT_FOR_SEGMENT_FILTER} -> Segment`,
|
||||
tableId: ORDERS_ID,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createSegmentWithTableDataSource({
|
||||
name: SEGMENT_FOR_METRIC_FILTER,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: segment }) => {
|
||||
if (withReferences) {
|
||||
createMetricWithSegmentClause({
|
||||
name: `${SEGMENT_FOR_METRIC_FILTER} -> Metric`,
|
||||
tableId: ORDERS_ID,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createMetricContent({
|
||||
withReferences = false,
|
||||
}: {
|
||||
withReferences?: boolean;
|
||||
}) {
|
||||
createMetricWithTableDataSource({
|
||||
name: METRIC_FOR_QUESTION_AGGREGATION,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: metric }) => {
|
||||
if (withReferences) {
|
||||
createQuestionWithMetricClause({
|
||||
name: `${METRIC_FOR_QUESTION_AGGREGATION} -> Question`,
|
||||
tableId: ORDERS_ID,
|
||||
metricId: metric.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createMetricWithTableDataSource({
|
||||
name: METRIC_FOR_MODEL_AGGREGATION,
|
||||
tableId: ORDERS_ID,
|
||||
collectionId: FIRST_COLLECTION_ID,
|
||||
}).then(({ body: metric }) => {
|
||||
if (withReferences) {
|
||||
createModelWithMetricClause({
|
||||
name: `${METRIC_FOR_MODEL_AGGREGATION} -> Model`,
|
||||
tableId: ORDERS_ID,
|
||||
metricId: metric.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createMetricWithTableDataSource({
|
||||
name: METRIC_FOR_METRIC_AGGREGATION,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: metric }) => {
|
||||
if (withReferences) {
|
||||
createMetricWithMetricClause({
|
||||
name: `${METRIC_FOR_METRIC_AGGREGATION} -> Metric`,
|
||||
tableId: ORDERS_ID,
|
||||
metricId: metric.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createMetricWithTableDataSource({
|
||||
name: METRIC_FOR_DASHBOARD_CARD,
|
||||
tableId: ORDERS_ID,
|
||||
}).then(({ body: metric }) => {
|
||||
if (withReferences) {
|
||||
createDashboardWithCard({
|
||||
name: `${METRIC_FOR_DASHBOARD_CARD} -> Dashboard Card`,
|
||||
cardId: metric.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createSnippetContent({
|
||||
withReferences = false,
|
||||
}: {
|
||||
withReferences?: boolean;
|
||||
}) {
|
||||
createSnippetWithBasicFilter({
|
||||
name: SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
}).then(({ body: snippet }) => {
|
||||
if (withReferences) {
|
||||
createNativeQuestionWithSnippetTag({
|
||||
name: `${SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG} -> Question`,
|
||||
tableName: "ORDERS",
|
||||
snippetId: snippet.id,
|
||||
snippetName: snippet.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createSnippetWithBasicFilter({ name: SNIPPET_FOR_SNIPPET_TAG }).then(
|
||||
({ body: snippet }) => {
|
||||
if (withReferences) {
|
||||
createSnippetWithSnippetTag({
|
||||
name: `${SNIPPET_FOR_SNIPPET_TAG} -> Snippet`,
|
||||
snippetName: snippet.name,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createQuestionWithModelDataSource({
|
||||
name,
|
||||
modelId,
|
||||
}: {
|
||||
name: string;
|
||||
modelId: CardId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "question",
|
||||
query: {
|
||||
"source-table": `card__${modelId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createQuestionWithSegmentClause({
|
||||
name,
|
||||
tableId,
|
||||
segmentId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
segmentId: SegmentId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "question",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: ["segment", segmentId],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createQuestionWithMetricClause({
|
||||
name,
|
||||
tableId,
|
||||
metricId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
metricId: CardId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "question",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
aggregation: [["metric", metricId]],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createNativeQuestionWithCardTag({
|
||||
name,
|
||||
cardId,
|
||||
}: {
|
||||
name: string;
|
||||
cardId: CardId;
|
||||
}) {
|
||||
const tagName = `#${cardId}`;
|
||||
|
||||
return H.createNativeQuestion({
|
||||
name,
|
||||
type: "question",
|
||||
native: {
|
||||
query: `select * from {{${tagName}}}`,
|
||||
"template-tags": {
|
||||
[tagName]: {
|
||||
id: "10422a0f-292d-10a3-fd90-407cc9e3e20e",
|
||||
type: "card",
|
||||
name: tagName,
|
||||
"display-name": tagName,
|
||||
"card-id": cardId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createNativeQuestionWithParameterWithCardSource({
|
||||
name,
|
||||
tableName,
|
||||
tableFieldId,
|
||||
cardId,
|
||||
cardFieldName,
|
||||
}: {
|
||||
name: string;
|
||||
tableName: string;
|
||||
tableFieldId: FieldId;
|
||||
cardId: CardId;
|
||||
cardFieldName: string;
|
||||
}) {
|
||||
return H.createNativeQuestion({
|
||||
name,
|
||||
native: {
|
||||
query: `select * from ${tableName} where {{filter}}`,
|
||||
"template-tags": {
|
||||
filter: {
|
||||
id: "10422a0f-292d-10a3-fd90-407cc9e3e20e",
|
||||
type: "dimension",
|
||||
name: "filter",
|
||||
"display-name": "Filter",
|
||||
dimension: ["field", tableFieldId, null],
|
||||
"widget-type": "string/=",
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: [
|
||||
createMockParameter({
|
||||
id: "10422a0f-292d-10a3-fd90-407cc9e3e20e",
|
||||
name: "Filter",
|
||||
slug: "filter",
|
||||
type: "string/=",
|
||||
target: ["dimension", ["template-tag", "filter"]],
|
||||
values_source_type: "card",
|
||||
values_source_config: {
|
||||
card_id: cardId,
|
||||
value_field: ["field", cardFieldName, { "base-type": "type/Text" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function createNativeQuestionWithSnippetTag({
|
||||
name,
|
||||
tableName,
|
||||
snippetId,
|
||||
snippetName,
|
||||
}: {
|
||||
name: string;
|
||||
tableName: string;
|
||||
snippetId: NativeQuerySnippetId;
|
||||
snippetName: string;
|
||||
}) {
|
||||
const tagName = `snippet: ${snippetName}`;
|
||||
|
||||
return H.createNativeQuestion({
|
||||
name,
|
||||
type: "question",
|
||||
native: {
|
||||
query: `select * from ${tableName} where {{${tagName}}}`,
|
||||
"template-tags": {
|
||||
[tagName]: {
|
||||
id: "10422a0f-292d-10a3-fd90-407cc9e3e20e",
|
||||
type: "snippet",
|
||||
name: tagName,
|
||||
"display-name": snippetName,
|
||||
"snippet-id": snippetId,
|
||||
"snippet-name": snippetName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createModelWithTableDataSource({
|
||||
name,
|
||||
tableId,
|
||||
collectionId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
collectionId?: CollectionId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
},
|
||||
collection_id: collectionId,
|
||||
});
|
||||
}
|
||||
|
||||
function createModelWithModelDataSource({
|
||||
name,
|
||||
modelId,
|
||||
}: {
|
||||
name: string;
|
||||
modelId: CardId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": `card__${modelId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createModelWithSegmentClause({
|
||||
name,
|
||||
tableId,
|
||||
segmentId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
segmentId: SegmentId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: ["segment", segmentId],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createModelWithMetricClause({
|
||||
name,
|
||||
tableId,
|
||||
metricId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
metricId: CardId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
aggregation: [["metric", metricId]],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createSegmentWithTableDataSource({
|
||||
name,
|
||||
tableId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
}) {
|
||||
return H.createSegment({
|
||||
name,
|
||||
table_id: tableId,
|
||||
definition: {
|
||||
"source-table": tableId,
|
||||
filter: [["=", "A", "A"]],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createSegmentWithSegmentClause({
|
||||
name,
|
||||
tableId,
|
||||
segmentId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
segmentId: SegmentId;
|
||||
}) {
|
||||
return H.createSegment({
|
||||
name,
|
||||
table_id: tableId,
|
||||
definition: {
|
||||
"source-table": tableId,
|
||||
filter: ["segment", segmentId],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMetricWithTableDataSource({
|
||||
name,
|
||||
tableId,
|
||||
collectionId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
collectionId?: CollectionId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "metric",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
collection_id: collectionId,
|
||||
});
|
||||
}
|
||||
|
||||
function createMetricWithModelDataSource({
|
||||
name,
|
||||
modelId,
|
||||
}: {
|
||||
name: string;
|
||||
modelId: CardId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "metric",
|
||||
query: {
|
||||
"source-table": `card__${modelId}`,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMetricWithSegmentClause({
|
||||
name,
|
||||
tableId,
|
||||
segmentId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
segmentId: SegmentId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "metric",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: ["segment", segmentId],
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMetricWithMetricClause({
|
||||
name,
|
||||
tableId,
|
||||
metricId,
|
||||
}: {
|
||||
name: string;
|
||||
tableId: TableId;
|
||||
metricId: CardId;
|
||||
}) {
|
||||
return H.createQuestion({
|
||||
name,
|
||||
type: "metric",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
aggregation: [["metric", metricId]],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createSnippetWithBasicFilter({ name }: { name: string }) {
|
||||
return H.createSnippet({
|
||||
name,
|
||||
content: "1 = 1",
|
||||
});
|
||||
}
|
||||
|
||||
function createSnippetWithSnippetTag({
|
||||
name,
|
||||
snippetName,
|
||||
}: {
|
||||
name: string;
|
||||
snippetName: string;
|
||||
}) {
|
||||
return H.createSnippet({
|
||||
name,
|
||||
content: `{{snippet: ${snippetName}}}`,
|
||||
});
|
||||
}
|
||||
|
||||
function createDashboardWithCard({
|
||||
name,
|
||||
cardId,
|
||||
}: {
|
||||
name: string;
|
||||
cardId: CardId;
|
||||
}) {
|
||||
return H.createDashboard({ name }).then(({ body: dashboard }) => {
|
||||
H.updateDashboardCards({
|
||||
dashboard_id: dashboard.id,
|
||||
cards: [
|
||||
{
|
||||
card_id: cardId,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createDashboardWithParameterWithCardSource({
|
||||
name,
|
||||
cardId,
|
||||
cardFieldName,
|
||||
}: {
|
||||
name: string;
|
||||
cardId: CardId;
|
||||
cardFieldName: string;
|
||||
}) {
|
||||
return H.createDashboard({
|
||||
name,
|
||||
parameters: [
|
||||
createMockParameter({
|
||||
id: "10422a0f",
|
||||
name: "Filter",
|
||||
slug: "filter",
|
||||
type: "string/=",
|
||||
values_source_type: "card",
|
||||
values_source_config: {
|
||||
card_id: cardId,
|
||||
value_field: ["field", cardFieldName, { "base-type": "type/Text" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function checkList({
|
||||
visibleEntities = [],
|
||||
hiddenEntities = [],
|
||||
}: {
|
||||
visibleEntities?: string[];
|
||||
hiddenEntities?: string[];
|
||||
}) {
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
visibleEntities.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
hiddenEntities.forEach((name) => {
|
||||
cy.findByText(name).should("not.exist");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkSidebar({
|
||||
entityName,
|
||||
locationName,
|
||||
creatorName,
|
||||
}: {
|
||||
entityName: string;
|
||||
locationName?: string;
|
||||
creatorName?: string;
|
||||
}) {
|
||||
H.DataStudio.Tasks.sidebar().within(() => {
|
||||
H.DataStudio.Tasks.Sidebar.header()
|
||||
.findByText(entityName)
|
||||
.should("be.visible");
|
||||
if (locationName) {
|
||||
H.DataStudio.Tasks.Sidebar.locationInfo()
|
||||
.findByText(locationName)
|
||||
.should("be.visible");
|
||||
}
|
||||
if (creatorName) {
|
||||
H.DataStudio.Tasks.Sidebar.creationInfo().should(
|
||||
"contain.text",
|
||||
creatorName,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { DATA_GROUP_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import type { CollectionPermission } from "metabase-types/api";
|
||||
|
||||
const { H } = cy;
|
||||
const { ORDERS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
const READ: CollectionPermission = "read";
|
||||
const NONE: CollectionPermission = "none";
|
||||
|
||||
const setupEmbed = (elementHtml: string) => {
|
||||
H.visitCustomHtmlPage(`
|
||||
${H.getNewEmbedScriptTag()}
|
||||
${H.getNewEmbedConfigurationScript({})}
|
||||
${elementHtml}
|
||||
`);
|
||||
};
|
||||
|
||||
describe("scenarios > embedding > sdk iframe embedding > metabase-browser", () => {
|
||||
describe("collection permissions", () => {
|
||||
it("should show an error when initial-collection points to a collection the user has no access to", () => {
|
||||
H.prepareSdkIframeEmbedTest({
|
||||
withToken: "bleeding-edge",
|
||||
signOut: false,
|
||||
});
|
||||
|
||||
H.createCollection({
|
||||
name: "Restricted Collection",
|
||||
}).then(({ body: collection }) => {
|
||||
cy.updateCollectionGraph({
|
||||
[DATA_GROUP_ID]: {
|
||||
root: READ,
|
||||
[collection.id]: NONE,
|
||||
},
|
||||
});
|
||||
|
||||
cy.signIn("nocollection");
|
||||
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="${collection.id}"
|
||||
read-only="false"
|
||||
/>
|
||||
`);
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("You don't have access to this collection").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("New exploration").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show New exploration button when user has no curate permissions on initial-collection", () => {
|
||||
H.prepareSdkIframeEmbedTest({
|
||||
withToken: "bleeding-edge",
|
||||
signOut: false,
|
||||
});
|
||||
|
||||
H.createCollection({
|
||||
name: "Read Only Collection",
|
||||
}).then(({ body: collection }) => {
|
||||
cy.updateCollectionGraph({
|
||||
[DATA_GROUP_ID]: {
|
||||
root: READ,
|
||||
[collection.id]: READ,
|
||||
},
|
||||
});
|
||||
|
||||
H.createQuestion({
|
||||
name: "Test Question",
|
||||
query: { "source-table": ORDERS_ID },
|
||||
collection_id: collection.id,
|
||||
});
|
||||
|
||||
cy.signIn("nocollection");
|
||||
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="${collection.id}"
|
||||
read-only="false"
|
||||
/>
|
||||
`);
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
// User can see the collection contents (they have read access)
|
||||
cy.findByText("Test Question").should("be.visible");
|
||||
|
||||
// But New exploration button should be hidden since they can't save
|
||||
cy.findByText("New exploration").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show Save button when opening an existing question from a read-only collection", () => {
|
||||
H.prepareSdkIframeEmbedTest({
|
||||
withToken: "bleeding-edge",
|
||||
signOut: false,
|
||||
});
|
||||
|
||||
H.createCollection({
|
||||
name: "Read Only Collection",
|
||||
}).then(({ body: collection }) => {
|
||||
cy.updateCollectionGraph({
|
||||
[DATA_GROUP_ID]: {
|
||||
root: READ,
|
||||
[collection.id]: READ,
|
||||
},
|
||||
});
|
||||
|
||||
H.createQuestion({
|
||||
name: "Test Question",
|
||||
query: { "source-table": ORDERS_ID },
|
||||
collection_id: collection.id,
|
||||
});
|
||||
|
||||
cy.signIn("nocollection");
|
||||
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="${collection.id}"
|
||||
read-only="false"
|
||||
/>
|
||||
`);
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("Test Question").should("be.visible").click();
|
||||
|
||||
cy.findByTestId("visualization-root").should("be.visible");
|
||||
|
||||
cy.findByTestId("interactive-question-result-toolbar")
|
||||
.findByText("Filter")
|
||||
.click();
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findAllByTestId("dimension-list-item").findByText("ID").click();
|
||||
cy.findByPlaceholderText("Enter an ID").type("1");
|
||||
cy.findByText("Add filter").click();
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByRole("button", { name: "Save" }).should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should reset `Exploration` editor state when clicking 'new exploration' breadcrumb after selecting a filter", () => {
|
||||
H.prepareSdkIframeEmbedTest({
|
||||
withToken: "bleeding-edge",
|
||||
signOut: false,
|
||||
});
|
||||
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="root"
|
||||
read-only="false"
|
||||
/>
|
||||
`);
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("New exploration").click();
|
||||
|
||||
cy.findByText("Pick your starting data").should("be.visible");
|
||||
|
||||
cy.findByText("Orders").click();
|
||||
|
||||
cy.findByTestId("sdk-breadcrumbs").findByText("New exploration").click();
|
||||
|
||||
cy.findByText("Pick your starting data").should("be.visible");
|
||||
cy.findByText("Orders").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@
|
||||
[metabase-enterprise.transforms-python.api]
|
||||
[metabase-enterprise.transforms.api]
|
||||
[metabase-enterprise.upload-management.api]
|
||||
[metabase-enterprise.workspaces.api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
@@ -73,8 +74,9 @@
|
||||
:upload-management (deferred-tru "Upload Management")
|
||||
:database-routing (deferred-tru "Database Routing")
|
||||
:cloud-custom-smtp (deferred-tru "Custom SMTP")
|
||||
:support-users (deferred-tru "Support Users")
|
||||
:transforms-python (deferred-tru "Transforms Python")})
|
||||
:support-users (deferred-tru "Support Users")
|
||||
:transforms-python (deferred-tru "Transforms Python")
|
||||
:workspaces (deferred-tru "Workspaces")})
|
||||
|
||||
(defn- premium-handler [handler required-feature]
|
||||
(let [handler (cond-> handler
|
||||
@@ -134,7 +136,8 @@
|
||||
"/transform-job" (premium-handler metabase-enterprise.transforms.api/transform-job-routes :transforms)
|
||||
"/transform-tag" (premium-handler metabase-enterprise.transforms.api/transform-tag-routes :transforms)
|
||||
"/tenant" (premium-handler metabase-enterprise.tenants.api/routes :tenants)
|
||||
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)})
|
||||
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)
|
||||
"/workspace" (premium-handler metabase-enterprise.workspaces.api/routes :workspaces)})
|
||||
;;; ↑↑↑ KEEP THIS SORTED OR ELSE ↑↑↑
|
||||
|
||||
(def ^:private routes-map
|
||||
|
||||
@@ -22,4 +22,5 @@
|
||||
[metabase-enterprise.stale.init]
|
||||
[metabase-enterprise.support-access-grants.init]
|
||||
[metabase-enterprise.transforms-python.init]
|
||||
[metabase-enterprise.transforms.init]))
|
||||
[metabase-enterprise.transforms.init]
|
||||
[metabase-enterprise.workspaces.init]))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
(ns metabase-enterprise.dependencies.analysis
|
||||
(:require
|
||||
[metabase-enterprise.dependencies.native-validation :as deps.native]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.schema :as lib.schema]
|
||||
[metabase.lib.schema.id :as lib.schema.id]
|
||||
[metabase.lib.schema.metadata :as lib.schema.metadata]
|
||||
[metabase.lib.schema.validate :as lib.schema.validate]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(mu/defn returned-columns
|
||||
"Get the returned columns of a `query`"
|
||||
[driver :- :keyword
|
||||
query :- ::lib.schema/query]
|
||||
(if (lib/any-native-stage? query)
|
||||
(deps.native/native-result-metadata driver query)
|
||||
(lib/returned-columns query)))
|
||||
|
||||
;; Analyzing an entity in memory ================================================================
|
||||
(mu/defn- check-query :- [:set [:ref ::lib.schema.validate/error]]
|
||||
"Find any bad refs in a `query`."
|
||||
[driver :- :keyword
|
||||
query :- ::lib.schema/query]
|
||||
(if (lib/any-native-stage? query)
|
||||
(deps.native/validate-native-query driver query)
|
||||
(lib/find-bad-refs query)))
|
||||
|
||||
(defmulti check-entity
|
||||
"Given a metadata provider, an entity type and an entity id, find any bad refs in that entity."
|
||||
{:arglists '([metadata-provider entity-type entity-id])}
|
||||
(fn [_metadata-provider entity-type _entity-id]
|
||||
entity-type))
|
||||
|
||||
(defmethod check-entity :default
|
||||
[_metadata-provider _entity-type _entity-id]
|
||||
nil)
|
||||
|
||||
(mu/defmethod check-entity :card :- [:set [:ref ::lib.schema.validate/error]]
|
||||
"Given a `MetadataProvider` and a card ID, analyses the card's query to find any bad refs or other issues.
|
||||
Returns any findings, and `nil` for a clean query."
|
||||
[metadata-provider :- ::lib.schema.metadata/metadata-provider
|
||||
_entity-type
|
||||
card-id :- ::lib.schema.id/card]
|
||||
(let [query (lib/query metadata-provider (:dataset-query (lib.metadata/card metadata-provider card-id)))
|
||||
driver (:engine (lib.metadata/database query))]
|
||||
(check-query driver query)))
|
||||
|
||||
(mu/defmethod check-entity :transform :- [:set [:ref ::lib.schema.validate/error]]
|
||||
"Given a `MetadataProvider` and a transform ID, analyses the transform's query to find any bad refs or other issues.
|
||||
|
||||
Returns any findings, and `nil` for a clean transform."
|
||||
[metadata-provider :- ::lib.schema.metadata/metadata-provider
|
||||
_entity-type
|
||||
transform-id :- ::lib.schema.id/transform]
|
||||
(let [{{query :query} :source
|
||||
:as _transform} (lib.metadata/transform metadata-provider transform-id)
|
||||
driver (:engine (lib.metadata/database metadata-provider))
|
||||
query (lib/query metadata-provider query)
|
||||
output-fields (returned-columns driver query)
|
||||
duplicated-fields (->> output-fields
|
||||
(group-by :name)
|
||||
vals
|
||||
(keep #(when (> (count %) 1)
|
||||
(lib/duplicate-column-error (-> % first :name))))
|
||||
seq)]
|
||||
(cond-> (check-query driver query)
|
||||
duplicated-fields (into duplicated-fields))))
|
||||
|
||||
(mu/defmethod check-entity :segment :- [:set [:ref ::lib.schema.validate/error]]
|
||||
[metadata-provider :- ::lib.schema.metadata/metadata-provider
|
||||
_entity-type
|
||||
segment-id :- ::lib.schema.id/segment]
|
||||
(let [query (->> (lib.metadata/segment metadata-provider segment-id)
|
||||
:definition
|
||||
(lib/query metadata-provider))
|
||||
driver (:engine (lib.metadata/database query))]
|
||||
(check-query driver query)))
|
||||
@@ -42,6 +42,12 @@
|
||||
query-deps
|
||||
param-card-ids)))
|
||||
|
||||
(mu/defn upstream-deps:python-transform :- ::deps.schema/upstream-deps
|
||||
"Given a Toucan `:model/Transform`, return its upstream dependencies as a map from the kind to a set of IDs."
|
||||
[{{tables :source-tables} :source :as _py-transform}
|
||||
:- [:map [:source-tables {:optional true} [:map-of :string :int]]]]
|
||||
{:table (set (vals tables))})
|
||||
|
||||
(mu/defn upstream-deps:transform :- ::deps.schema/upstream-deps
|
||||
"Given a Transform (in Toucan form), return its upstream dependencies."
|
||||
[{{:keys [query], source-type :type} :source :as transform} :-
|
||||
@@ -50,9 +56,15 @@
|
||||
[:query
|
||||
[:map [:query ::lib.schema/query]]]
|
||||
[:python
|
||||
[:map]]]]]]
|
||||
(if (= (keyword source-type) :query)
|
||||
[:map [:source-tables {:optional true} [:map-of :string :int]]]]]]]]
|
||||
(cond
|
||||
(= (keyword source-type) :query)
|
||||
(upstream-deps:query query)
|
||||
|
||||
(= (keyword source-type) :python)
|
||||
(upstream-deps:python-transform transform)
|
||||
|
||||
:else
|
||||
(do (log/warnf "Don't know how to analyze the deps of Transform %d with source type '%s'" (:id transform) source-type)
|
||||
{})))
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
(ns metabase-enterprise.dependencies.dependency-types
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(def model->dependency-type
|
||||
"A map relating models to their corresponding dependency type."
|
||||
{:model/Card :card
|
||||
:model/Table :table
|
||||
:model/NativeQuerySnippet :snippet
|
||||
:model/Transform :transform
|
||||
:model/Dashboard :dashboard
|
||||
:model/Document :document
|
||||
:model/Sandbox :sandbox
|
||||
:model/Segment :segment
|
||||
:model/Measure :measure})
|
||||
|
||||
(def dependency-type->model
|
||||
"A map relating dependency types to their corresponding model."
|
||||
(set/map-invert model->dependency-type))
|
||||
|
||||
(def dependency-types
|
||||
"The set of all dependency types."
|
||||
(-> model->dependency-type vals set))
|
||||
|
||||
(def models
|
||||
"The set of all models that are handled by dependencies."
|
||||
(-> model->dependency-type keys set))
|
||||
|
||||
(mr/def ::dependency-types
|
||||
(ms/enum-decode-keyword dependency-types))
|
||||
@@ -0,0 +1,72 @@
|
||||
(ns metabase-enterprise.dependencies.findings
|
||||
(:require
|
||||
[metabase-enterprise.dependencies.analysis :as deps.analysis]
|
||||
[metabase-enterprise.dependencies.dependency-types :as deps.dependency-types]
|
||||
[metabase-enterprise.dependencies.models.analysis-finding :as deps.analysis-finding]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defmulti ^:private instance-db-id
|
||||
"Gets the database id for a toucan instance"
|
||||
{:arglists '([toucan-instance])}
|
||||
t2/model)
|
||||
|
||||
(defmethod instance-db-id :default
|
||||
[toucan-instance]
|
||||
(:database_id toucan-instance))
|
||||
|
||||
(defmethod instance-db-id :model/Transform
|
||||
[toucan-instance]
|
||||
(some-> toucan-instance :source :query lib/database-id))
|
||||
|
||||
(defmethod instance-db-id :model/Segment
|
||||
[toucan-instance]
|
||||
(some-> toucan-instance :definition lib/database-id))
|
||||
|
||||
(defn upsert-analysis!
|
||||
"Given a Toucan entity, run its analysis and write the results into `:model/AnalysisFinding`.
|
||||
|
||||
If any row exists already, it is replaced. If it does not exist, it is created."
|
||||
[toucan-instance]
|
||||
(when-not (lib-be/metadata-provider-cache)
|
||||
(throw (ex-info "FIXME: deps.findings/upsert-analysis! ran without reusing `MetadataProvider`s"
|
||||
{:instance toucan-instance})))
|
||||
(when-let [db-id (instance-db-id toucan-instance)]
|
||||
(let [mp (lib-be/application-database-metadata-provider db-id)
|
||||
model (t2/model toucan-instance)
|
||||
results (try (deps.analysis/check-entity mp (deps.dependency-types/model->dependency-type model) (:id toucan-instance))
|
||||
(catch Exception e
|
||||
(log/error e "Error analyzing entity")
|
||||
[(lib/validation-exception-error (.getMessage e))]))
|
||||
success (empty? results)]
|
||||
(deps.analysis-finding/upsert-analysis! (deps.dependency-types/model->dependency-type model) (:id toucan-instance) success results))))
|
||||
|
||||
(defn analyze-instances!
|
||||
"Given a series of toucan entities, upsert analyses for all of them and catch errors."
|
||||
[instances]
|
||||
(doseq [instance instances]
|
||||
(try (upsert-analysis! instance)
|
||||
(catch Exception e
|
||||
(log/errorf e "Analyzing entity %s %s failed"
|
||||
(t2/model instance) (:id instance))))))
|
||||
|
||||
(def supported-entities
|
||||
"Entities supported by the analysis findings code"
|
||||
#{:card :transform :segment})
|
||||
|
||||
(mu/defn analyze-batch! :- nat-int?
|
||||
"Add or update analyses for a batch of entities.
|
||||
|
||||
Takes in an entity type and batch size, and looks for a batch of entities with missing or out of date
|
||||
AnalysisFindings and then upsert new analyses for them."
|
||||
[type :- (into [:enum] supported-entities)
|
||||
batch-size :- pos-int?]
|
||||
(let [instances (deps.analysis-finding/instances-for-analysis type batch-size)]
|
||||
(lib-be/with-metadata-provider-cache
|
||||
(analyze-instances! instances))
|
||||
(count instances)))
|
||||
@@ -0,0 +1,63 @@
|
||||
(ns metabase-enterprise.dependencies.models.analysis-finding
|
||||
(:require
|
||||
[metabase-enterprise.dependencies.dependency-types :as deps.dependency-types]
|
||||
[metabase.lib.normalize :as lib.normalize]
|
||||
[metabase.models.interface :as mi]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/AnalysisFinding [_model] :analysis_finding)
|
||||
|
||||
(derive :model/AnalysisFinding :metabase/model)
|
||||
|
||||
(defn- analysis-finding-details-out [finding]
|
||||
(some->> (mi/json-out-with-keywordization finding)
|
||||
(into #{} (map #(lib.normalize/normalize :metabase.lib.schema.validate/error %)))))
|
||||
|
||||
(t2/deftransforms :model/AnalysisFinding
|
||||
{:analyzed_entity_type mi/transform-keyword
|
||||
:finding_details {:in mi/json-in
|
||||
:out analysis-finding-details-out}})
|
||||
|
||||
(def ^:dynamic *current-analysis-finding-version*
|
||||
"Current version of the query validation logic.
|
||||
This should be incremented when the analysis logic changes.
|
||||
The background task will re-analyze anything with out-of-date analyses.
|
||||
|
||||
This generally shouldn't be rebound in real code, but making it dynamic is convenient for testing"
|
||||
3)
|
||||
|
||||
(defn upsert-analysis!
|
||||
"Given the details of an AnalysisFinding row, upsert the data into the actual db."
|
||||
[type instance-id result finding-details]
|
||||
(let [update {:analyzed_at (mi/now)
|
||||
:analysis_version *current-analysis-finding-version*
|
||||
:result result
|
||||
:finding_details finding-details}
|
||||
existing-id (t2/select-one-fn :id [:model/AnalysisFinding :id]
|
||||
:analyzed_entity_type type
|
||||
:analyzed_entity_id instance-id)]
|
||||
(if existing-id
|
||||
(t2/update! :model/AnalysisFinding existing-id update)
|
||||
(t2/insert! :model/AnalysisFinding
|
||||
(assoc update
|
||||
:analyzed_entity_type type
|
||||
:analyzed_entity_id instance-id)))))
|
||||
|
||||
(defn instances-for-analysis
|
||||
"Find a batch of instances with missing or outdated AnalysisFindings"
|
||||
[type batch-size]
|
||||
(let [model (deps.dependency-types/dependency-type->model type)
|
||||
table-name (t2/table-name model)
|
||||
id-field (keyword (name table-name) "id")
|
||||
table-wildcard (keyword (name table-name) "*")]
|
||||
(t2/select model
|
||||
{:select [table-wildcard]
|
||||
:from table-name
|
||||
:left-join [:analysis_finding [:and
|
||||
[:= :analysis_finding.analyzed_entity_id id-field]
|
||||
[:= :analysis_finding.analyzed_entity_type (name type)]]]
|
||||
:where [:<
|
||||
[:coalesce :analysis_finding.analysis_version 0]
|
||||
*current-analysis-finding-version*]
|
||||
:limit batch-size})))
|
||||
@@ -0,0 +1,58 @@
|
||||
(ns metabase-enterprise.dependencies.task.entity-check
|
||||
(:require
|
||||
[metabase-enterprise.dependencies.findings :as deps.findings]
|
||||
[metabase-enterprise.dependencies.settings :as deps.settings]
|
||||
[metabase-enterprise.dependencies.task-util :as deps.task-util]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.task.core :as task]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- check-entities!
|
||||
[]
|
||||
(when (premium-features/has-feature? :dependencies)
|
||||
(-> (reduce (fn [batch-size entity-type]
|
||||
(if (< batch-size 1)
|
||||
(reduced 0)
|
||||
(let [processed (deps.findings/analyze-batch! entity-type batch-size)]
|
||||
(when (pos? processed)
|
||||
(log/info "Updated" processed "entities of type" entity-type))
|
||||
(- batch-size processed))))
|
||||
(deps.settings/dependency-entity-check-batch-size)
|
||||
deps.findings/supported-entities)
|
||||
(< 1))))
|
||||
|
||||
(declare schedule-next-run!)
|
||||
|
||||
(task/defjob
|
||||
^{:doc "Check all entities for validity"
|
||||
org.quartz.DisallowConcurrentExecution true}
|
||||
DependencyEntityCheck [ctx]
|
||||
(log/info "Executing DependencyEntityCheck job...")
|
||||
(check-entities!)
|
||||
(let [delay-in-seconds (deps.task-util/job-delay
|
||||
(deps.settings/dependency-entity-check-delay-minutes)
|
||||
(deps.settings/dependency-entity-check-variance-minutes))]
|
||||
(schedule-next-run! delay-in-seconds (.getScheduler ctx))))
|
||||
|
||||
(def ^:private job-key "metabase.dependencies.task.entity-check.job")
|
||||
(def ^:private trigger-key "metabase.dependencies.task.entity-check.trigger")
|
||||
|
||||
(defn- schedule-next-run!
|
||||
([delay-in-seconds] (schedule-next-run! delay-in-seconds nil))
|
||||
([delay-in-seconds scheduler]
|
||||
(deps.task-util/schedule-next-run!
|
||||
{:job-type DependencyEntityCheck
|
||||
:job-name "Dependency Entity Check"
|
||||
:job-key job-key
|
||||
:trigger-key trigger-key
|
||||
:delay-in-seconds delay-in-seconds
|
||||
:scheduler scheduler})))
|
||||
|
||||
(defmethod task/init! ::DependencyEntityCheck [_]
|
||||
(if (pos? (deps.settings/dependency-entity-check-batch-size))
|
||||
(-> (deps.settings/dependency-entity-check-variance-minutes)
|
||||
deps.task-util/job-initial-delay
|
||||
schedule-next-run!)
|
||||
(log/info "Not starting dependency entity check job because the batch size is not positive")))
|
||||
@@ -0,0 +1,50 @@
|
||||
(ns metabase-enterprise.dependencies.task-util
|
||||
(:require
|
||||
[clojurewerkz.quartzite.jobs :as jobs]
|
||||
[clojurewerkz.quartzite.scheduler :as qs]
|
||||
[clojurewerkz.quartzite.triggers :as triggers]
|
||||
[java-time.api :as t]
|
||||
[metabase.task.core :as task]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn job-delay
|
||||
"Generates a random delay in seconds based on `delay` and `variance`.
|
||||
|
||||
Both `delay` and `variance` should be in minutes, and the result will vary between `delay-variance` and
|
||||
`delay+variance`."
|
||||
[delay variance]
|
||||
(let [delay-duration (t/duration delay :minutes)
|
||||
variance-duration (t/duration variance :minutes)]
|
||||
(max 0 (.. delay-duration
|
||||
(minus variance-duration)
|
||||
(plusSeconds (rand-int (.. variance-duration
|
||||
(multipliedBy 2)
|
||||
toSeconds)))
|
||||
toSeconds))))
|
||||
|
||||
(defn job-initial-delay
|
||||
"Generates a random delay in seconds based on `variance`.
|
||||
|
||||
`variance` is in minutes and the result will vary between 0 and `variance`"
|
||||
[variance]
|
||||
(rand-int (.toSeconds (t/duration variance :minutes))))
|
||||
|
||||
(defn schedule-next-run!
|
||||
"Schedule the passed in job after a given delay."
|
||||
[{:keys [job-type job-name job-key trigger-key delay-in-seconds scheduler]}]
|
||||
(let [start-at (-> (t/instant)
|
||||
(t/+ (t/duration delay-in-seconds :seconds))
|
||||
java.util.Date/from)
|
||||
trigger (triggers/build
|
||||
(triggers/with-identity (triggers/key (str trigger-key \. (random-uuid))))
|
||||
(triggers/for-job job-key)
|
||||
(triggers/start-at start-at))]
|
||||
(log/info "Scheduling next run of job" job-name "at" start-at)
|
||||
(if scheduler
|
||||
;; re-scheduling from the job
|
||||
(qs/add-trigger scheduler trigger)
|
||||
;; first schedule
|
||||
(let [job (jobs/build (jobs/of-type job-type) (jobs/with-identity job-key))]
|
||||
(task/schedule-task! job trigger)))))
|
||||
@@ -116,4 +116,16 @@
|
||||
"User"
|
||||
"UserKeyValue"
|
||||
"UserParameterValue"
|
||||
"ViewLog"])
|
||||
"ViewLog"
|
||||
"Workspace"
|
||||
;; TODO (lbrdnk 2025/12/17): I've added rest of the workspace models. I believe some of those will be available for
|
||||
;; serialization, hence we should revisit this later in the project.
|
||||
"WorkspaceDependency"
|
||||
"WorkspaceInput"
|
||||
"WorkspaceInputExternal"
|
||||
"WorkspaceLog"
|
||||
"WorkspaceMerge"
|
||||
"WorkspaceMergeTransform"
|
||||
"WorkspaceOutput"
|
||||
"WorkspaceOutputExternal"
|
||||
"WorkspaceTransform"])
|
||||
|
||||
@@ -81,9 +81,13 @@
|
||||
|
||||
(defn get-transforms
|
||||
"Get a list of transforms."
|
||||
[& {:keys [last_run_start_time last_run_statuses tag_ids]}]
|
||||
[& {:keys [last_run_start_time last_run_statuses tag_ids database_id type]}]
|
||||
(api/check-superuser)
|
||||
(let [transforms (t2/select :model/Transform {:order-by [[:id :asc]]})]
|
||||
(let [type (when type (map #(if (= % "query") "mbql" %) type))
|
||||
where (cond-> [:and]
|
||||
type (conj [:in :source_type type])
|
||||
database_id (conj [:= :target_db_id database_id]))
|
||||
transforms (t2/select :model/Transform {:where where :order-by [[:id :asc]]})]
|
||||
(into []
|
||||
(comp (transforms.util/->date-field-filter-xf [:last_run :start_time] last_run_start_time)
|
||||
(transforms.util/->status-filter-xf [:last_run :status] last_run_statuses)
|
||||
@@ -106,14 +110,35 @@
|
||||
[:map
|
||||
[:last_run_start_time {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:last_run_statuses {:optional true} [:maybe (ms/QueryVectorOf [:enum "started" "succeeded" "failed" "timeout"])]]
|
||||
[:tag_ids {:optional true} [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]]
|
||||
[:tag_ids {:optional true} [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]
|
||||
[:database_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:type {:optional true} [:maybe (ms/QueryVectorOf [:enum "query" "native" "python"])]]]]
|
||||
(get-transforms query-params))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/"
|
||||
(defn create-transform!
|
||||
"Create new transform in the appdb.
|
||||
Optionally accepts a creator-id to use instead of the current user (for workspace merges)."
|
||||
([body]
|
||||
(create-transform! body nil))
|
||||
([body creator-id]
|
||||
(let [creator-id (or creator-id api/*current-user-id*)
|
||||
transform (t2/with-transaction [_]
|
||||
(let [tag-ids (:tag_ids body)
|
||||
transform (t2/insert-returning-instance!
|
||||
:model/Transform
|
||||
(assoc (select-keys body [:name :description :source :target :run_trigger])
|
||||
:creator_id creator-id))]
|
||||
;; Add tag associations if provided
|
||||
(when (seq tag-ids)
|
||||
(transform.model/update-transform-tags! (:id transform) tag-ids))
|
||||
;; Return with hydrated tag_ids
|
||||
(t2/hydrate transform :transform_tag_ids :creator)))]
|
||||
(events/publish-event! :event/transform-create {:object transform :user-id api/*current-user-id*})
|
||||
transform)))
|
||||
|
||||
;; TODO (chris 2025/12/16) fully populate the result schema
|
||||
;; TODO (lbrdnk 2025/12/16) relaxed result schema to unblock FE. This should be properly handled later.
|
||||
(api.macros/defendpoint :post "/" :- [:map [:name :string] [:description {:optional true} [:maybe :string]]]
|
||||
"Create a new transform."
|
||||
[_route-params
|
||||
_query-params
|
||||
@@ -183,6 +208,34 @@
|
||||
dependencies (map id->transform dep-ids)]
|
||||
(t2/hydrate dependencies :creator)))
|
||||
|
||||
(def ^:private MergeHistoryEntry
|
||||
[:map
|
||||
[:id ms/PositiveInt]
|
||||
[:workspace_merge_id ms/PositiveInt]
|
||||
[:commit_message :string]
|
||||
[:workspace_id [:maybe ms/PositiveInt]]
|
||||
[:workspace_name :string]
|
||||
[:merging_user_id ms/PositiveInt]
|
||||
[:created_at :any]])
|
||||
|
||||
(api.macros/defendpoint :get "/:id/merge-history"
|
||||
:- [:sequential MergeHistoryEntry]
|
||||
"Get merge history for a transform. Returns all merge events that affected this transform,
|
||||
ordered by created_at descending (newest first)."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(api/check-404 (t2/select-one :model/Transform id))
|
||||
(t2/select [:model/WorkspaceMergeTransform
|
||||
:id
|
||||
:workspace_merge_id
|
||||
:commit_message
|
||||
:workspace_id
|
||||
:workspace_name
|
||||
:merging_user_id
|
||||
:created_at]
|
||||
{:where [:= :transform_id id]
|
||||
:order-by [[:created_at :desc]]}))
|
||||
|
||||
;; TODO (Cam 10/28/25) -- fix this endpoint so it uses kebab-case for query parameters for consistency with the rest
|
||||
;; of the REST API
|
||||
;;
|
||||
@@ -210,24 +263,10 @@
|
||||
:limit (request/limit)))
|
||||
(update :data #(map transforms.util/localize-run-timestamps %))))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :put "/:id"
|
||||
"Update a transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]
|
||||
_query-params
|
||||
body :- [:map
|
||||
[:name {:optional true} :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:source {:optional true} ::transforms.schema/transform-source]
|
||||
[:target {:optional true} ::transforms.schema/transform-target]
|
||||
[:run_trigger {:optional true} ::run-trigger]
|
||||
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]]]
|
||||
(api/check-superuser)
|
||||
(defn update-transform!
|
||||
"Update a transform. Validates features, database support, cycles, and target conflicts.
|
||||
Returns the updated transform with hydrated associations."
|
||||
[id body]
|
||||
(let [transform (t2/with-transaction [_]
|
||||
;; Cycle detection should occur within the transaction to avoid race
|
||||
(let [old (t2/select-one :model/Transform id)
|
||||
@@ -252,6 +291,35 @@
|
||||
(events/publish-event! :event/transform-update {:object transform :user-id api/*current-user-id*})
|
||||
transform))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :put "/:id"
|
||||
"Update a transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]
|
||||
_query-params
|
||||
body :- [:map
|
||||
[:name {:optional true} :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:source {:optional true} ::transforms.schema/transform-source]
|
||||
[:target {:optional true} ::transforms.schema/transform-target]
|
||||
[:run_trigger {:optional true} ::run-trigger]
|
||||
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]]]
|
||||
(api/check-superuser)
|
||||
(update-transform! id body))
|
||||
|
||||
(defn delete-transform!
|
||||
"Delete a transform and publish the delete event."
|
||||
[transform]
|
||||
(t2/delete! :model/Transform (:id transform))
|
||||
(events/publish-event! :event/transform-delete
|
||||
{:object transform
|
||||
:user-id api/*current-user-id*})
|
||||
nil)
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
@@ -261,12 +329,7 @@
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(let [transform (api/check-404 (t2/select-one :model/Transform id))]
|
||||
(t2/delete! :model/Transform id)
|
||||
(events/publish-event! :event/transform-delete
|
||||
{:object transform
|
||||
:user-id api/*current-user-id*}))
|
||||
nil)
|
||||
(delete-transform! (api/check-404 (t2/select-one :model/Transform id))))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
@@ -296,18 +359,12 @@
|
||||
(transforms.canceling/cancel-run! (:id run))))
|
||||
nil)
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/:id/run"
|
||||
"Run a transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(let [transform (api/check-404 (t2/select-one :model/Transform id))
|
||||
_ (check-feature-enabled! transform)
|
||||
start-promise (promise)]
|
||||
(defn run-transform!
|
||||
"Run a transform. Returns a 202 response with run_id.
|
||||
The transform must already be fetched and validated."
|
||||
[transform]
|
||||
(check-feature-enabled! transform)
|
||||
(let [start-promise (promise)]
|
||||
(u.jvm/in-virtual-thread*
|
||||
(transforms.execute/execute! transform {:start-promise start-promise
|
||||
:run-method :manual}))
|
||||
@@ -320,6 +377,17 @@
|
||||
:run_id run-id})
|
||||
(assoc :status 202)))))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/:id/run"
|
||||
"Run a transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(run-transform! (api/check-404 (t2/select-one :model/Transform id))))
|
||||
|
||||
(defn- extract-columns-from-query
|
||||
"Attempts to extract column names from an MBQL query without executing it.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns metabase-enterprise.transforms.core
|
||||
"API namespace for the `metabase-enterprise.transform` module."
|
||||
(:require
|
||||
[metabase-enterprise.transforms.models.transform]
|
||||
[metabase-enterprise.transforms.models.transform-run]
|
||||
[metabase-enterprise.transforms.settings]
|
||||
[metabase-enterprise.transforms.util]
|
||||
@@ -12,6 +13,9 @@
|
||||
[metabase-enterprise.transforms.util
|
||||
native-query-transform?
|
||||
python-transform?
|
||||
query-transform?]
|
||||
query-transform?
|
||||
transform-source-type]
|
||||
[metabase-enterprise.transforms.models.transform-run
|
||||
timeout-run!])
|
||||
timeout-run!]
|
||||
[metabase-enterprise.transforms.models.transform
|
||||
update-transform-tags!])
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
[transform]
|
||||
(-> transform :source :type keyword))
|
||||
|
||||
(defmulti source-db-id
|
||||
"Return the ID of the source database for a given `transform`. The source database is where the data originates from
|
||||
before being transformed and written to the target destination.
|
||||
|
||||
NOTE: Currently returns a single database ID, but we may want to change this to return multiple database IDs in the
|
||||
future since transforms can have multiple input sources."
|
||||
{:added "0.57.0" :arglists '([transform])}
|
||||
transform->transform-type)
|
||||
|
||||
(defmulti target-db-id
|
||||
"Return the ID of the target database for a given `transform`. The target database is the destination where the
|
||||
transformed data will be written. Often this is the same database as the source database."
|
||||
|
||||
@@ -54,13 +54,13 @@
|
||||
#{:transforms})
|
||||
|
||||
(t2/define-before-insert :model/Transform
|
||||
[{:keys [source target collection_id] :as transform}]
|
||||
[{:keys [source collection_id] :as transform}]
|
||||
(collection/check-collection-namespace :model/Transform collection_id)
|
||||
(when collection_id
|
||||
(collection/check-allowed-content :model/Transform collection_id))
|
||||
(assoc transform
|
||||
:source_type (transforms.util/transform-source-type source)
|
||||
:target_db_id (or (:database target) (:target_db_id transform))))
|
||||
:target_db_id (transforms.i/target-db-id transform)))
|
||||
|
||||
(t2/define-before-update :model/Transform
|
||||
[{:keys [source target] :as transform}]
|
||||
|
||||
@@ -14,13 +14,11 @@
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defmethod transforms.i/source-db-id :query
|
||||
[transform]
|
||||
(-> transform :source :query :database))
|
||||
|
||||
(defmethod transforms.i/target-db-id :query
|
||||
[transform]
|
||||
(or (get-in transform [:target :database])
|
||||
;; Fallback to source. https://github.com/metabase/metabase/pull/67084#discussion_r2630147855
|
||||
(-> transform :source :query :database)
|
||||
(:target_db_id transform)))
|
||||
|
||||
(mr/def ::transform-details
|
||||
|
||||
@@ -45,14 +45,14 @@
|
||||
[:map
|
||||
[:database {:optional true} :int]
|
||||
[:type [:= "table"]]
|
||||
[:schema {:optional true} [:or ms/NonBlankString :nil]]
|
||||
[:schema {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:name :string]])
|
||||
|
||||
(mr/def ::table-incremental-target
|
||||
[:map
|
||||
[:database {:optional true} :int]
|
||||
[:type [:= "table-incremental"]]
|
||||
[:schema {:optional true} [:or ms/NonBlankString :nil]]
|
||||
[:schema {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:name :string]
|
||||
[:target-incremental-strategy ::target-incremental-strategy]])
|
||||
|
||||
|
||||
@@ -436,33 +436,28 @@
|
||||
(defn- matching-timestamp?
|
||||
[job field-path {:keys [start end]}]
|
||||
(when-let [field-instant (->instant (get-in job field-path))]
|
||||
(let [start-instant (some-> start u.date/parse ->instant)
|
||||
end-instant (some-> end u.date/parse ->instant)]
|
||||
(and (or (nil? start)
|
||||
(not (.isBefore field-instant start-instant)))
|
||||
(or (nil? end)
|
||||
(.isAfter end-instant field-instant))))))
|
||||
(let [parse #(-> % u.date/parse ->instant)]
|
||||
;; logic here is to find when it's not matching and invert this
|
||||
(not (or (and start (.isBefore field-instant (parse start)))
|
||||
(and end (.isAfter field-instant (parse end))))))))
|
||||
|
||||
(defn ->date-field-filter-xf
|
||||
"Returns an xform for a date filter."
|
||||
[field-path filter-value]
|
||||
(let [range (some-> filter-value (params.dates/date-string->range {:inclusive-end? false}))]
|
||||
(if range
|
||||
(filter #(matching-timestamp? % field-path range))
|
||||
identity)))
|
||||
(if-let [range (some-> filter-value (params.dates/date-string->range {:inclusive-end? false}))]
|
||||
(filter #(matching-timestamp? % field-path range))
|
||||
identity))
|
||||
|
||||
(defn ->status-filter-xf
|
||||
"Returns an xform for a transform run status filter."
|
||||
[field-path statuses]
|
||||
(let [statuses (->> statuses (map keyword) set not-empty)]
|
||||
(if statuses
|
||||
(filter #(statuses (get-in % field-path)))
|
||||
identity)))
|
||||
(if-let [statuses (->> statuses (map keyword) set not-empty)]
|
||||
(filter #(statuses (get-in % field-path)))
|
||||
identity))
|
||||
|
||||
(defn ->tag-filter-xf
|
||||
"Returns an xform for a transform tag filter."
|
||||
[field-path tag-ids]
|
||||
(let [tag-ids (-> tag-ids set not-empty)]
|
||||
(if tag-ids
|
||||
(filter #(some tag-ids (get-in % field-path)))
|
||||
identity)))
|
||||
(if-let [tag-ids (-> tag-ids set not-empty)]
|
||||
(filter #(some tag-ids (get-in % field-path)))
|
||||
identity))
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
[metabase-enterprise.transforms-python.settings :as transforms-python.settings]
|
||||
[metabase-enterprise.transforms.core :as transforms]
|
||||
[metabase-enterprise.transforms.instrumentation :as transforms.instrumentation]
|
||||
[metabase-enterprise.transforms.interface :as transforms.i]
|
||||
[metabase-enterprise.transforms.util :as transforms.util]
|
||||
[metabase.app-db.core :as app-db]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.format :as u.format]
|
||||
[metabase.util.i18n :as i18n]
|
||||
@@ -353,13 +355,14 @@
|
||||
(try
|
||||
(let [message-log (empty-message-log)
|
||||
{:keys [target] transform-id :id} transform
|
||||
{driver :engine :as db} (t2/select-one :model/Database (:database target))
|
||||
{driver :engine :as db} (t2/select-one :model/Database (transforms.i/target-db-id transform))
|
||||
{run-id :id} (transforms.util/try-start-unless-already-running transform-id run-method)]
|
||||
(some-> start-promise (deliver [:started run-id]))
|
||||
(log! message-log (i18n/tru "Executing Python transform"))
|
||||
(log/info "Executing Python transform" transform-id "with target" (pr-str target))
|
||||
(let [start-ms (u/start-timer)
|
||||
transform-details {:db-id (:id db)
|
||||
:transform-id transform-id
|
||||
:transform-type (keyword (:type target))
|
||||
:conn-spec (driver/connection-spec driver db)
|
||||
:output-schema (:schema target)
|
||||
@@ -372,7 +375,8 @@
|
||||
result (transforms.instrumentation/with-stage-timing [run-id [:computation :python-execution]]
|
||||
(transforms.util/run-cancelable-transform! run-id driver transform-details run-fn :ex-message-fn ex-message-fn))]
|
||||
(transforms.instrumentation/with-stage-timing [run-id [:import :table-sync]]
|
||||
(transforms.util/sync-target! target db))
|
||||
(transforms.util/sync-target! target db)
|
||||
(events/publish-event! :event/transform-run-complete {:object transform-details}))
|
||||
{:run_id run-id
|
||||
:result result}))
|
||||
(catch Throwable t
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
(or (get-in transform [:target :database])
|
||||
(:target_db_id transform)))
|
||||
|
||||
(defmethod transforms.i/source-db-id :python
|
||||
[transform]
|
||||
(-> transform :source :database))
|
||||
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(defmethod transforms.i/execute! :python
|
||||
[transform options]
|
||||
|
||||
897
enterprise/backend/src/metabase_enterprise/workspaces/api.clj
Normal file
@@ -0,0 +1,897 @@
|
||||
(ns metabase-enterprise.workspaces.api
|
||||
"`/api/ee/workspace/` routes"
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[honey.sql.helpers :as sql.helpers]
|
||||
[metabase-enterprise.transforms.core :as transforms]
|
||||
[metabase-enterprise.transforms.util :as transforms.util]
|
||||
[metabase-enterprise.workspaces.common :as ws.common]
|
||||
[metabase-enterprise.workspaces.impl :as ws.impl]
|
||||
[metabase-enterprise.workspaces.merge :as ws.merge]
|
||||
[metabase-enterprise.workspaces.models.workspace :as ws.model]
|
||||
[metabase-enterprise.workspaces.models.workspace-input-external]
|
||||
[metabase-enterprise.workspaces.models.workspace-log]
|
||||
[metabase-enterprise.workspaces.models.workspace-output-external]
|
||||
[metabase-enterprise.workspaces.types :as ws.t]
|
||||
[metabase-enterprise.workspaces.validation :as ws.validation]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.queries.schema :as queries.schema]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :as i18n :refer [deferred-tru tru]]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private log-limit "Maximum number of recent workspace log items to show" 20)
|
||||
|
||||
(mr/def ::appdb-or-ref-id [:or ::ws.t/appdb-id ::ws.t/ref-id])
|
||||
|
||||
(def ^:private computed-statuses
|
||||
"All possible values returned by computed-status (computed from base_status, etc.)"
|
||||
#{:uninitialized :pending :ready :broken :archived})
|
||||
|
||||
(mr/def ::status (into [:enum] computed-statuses))
|
||||
|
||||
(def ^:private Workspace
|
||||
[:map
|
||||
[:id ::ws.t/appdb-id]
|
||||
[:name :string]
|
||||
[:collection_id ::ws.t/appdb-id]
|
||||
[:database_id ::ws.t/appdb-id]
|
||||
[:status ::status]
|
||||
[:created_at ms/TemporalInstant]
|
||||
[:updated_at ms/TemporalInstant]])
|
||||
|
||||
;; Transform-related schemas (adapted from transforms/api.clj)
|
||||
;; TODO we should reuse these schemas, by exposing common types from the transforms module. they *can* match exactly.
|
||||
|
||||
(mr/def ::transform-source
|
||||
[:multi {:dispatch (comp keyword :type)}
|
||||
[:query
|
||||
[:map
|
||||
[:type [:= "query"]]
|
||||
[:query ::queries.schema/query]]]
|
||||
[:python
|
||||
[:map {:closed true}
|
||||
[:source-database {:optional true} :int]
|
||||
[:source-tables [:map-of [:string {:min 1}] :int]]
|
||||
[:type [:= "python"]]
|
||||
[:body :string]]]])
|
||||
|
||||
(mr/def ::transform-target
|
||||
[:map {:closed true}
|
||||
[:database {:optional true} ::ws.t/appdb-id]
|
||||
[:type [:enum "table"]]
|
||||
[:schema {:optional true} [:maybe [:string {:min 1}]]]
|
||||
[:name [:string {:min 1}]]])
|
||||
|
||||
(mr/def ::run-trigger
|
||||
[:enum "none" "global-schedule"])
|
||||
|
||||
(defn- check-transforms-enabled!
|
||||
[db-id]
|
||||
(let [database (api/check-400 (t2/select-one :model/Database db-id)
|
||||
(deferred-tru "The target database cannot be found."))]
|
||||
(api/check (transforms.util/check-feature-enabled nil)
|
||||
[402 (deferred-tru "Premium features required for transforms are not enabled.")])
|
||||
(api/check-400 (not (:is_sample database))
|
||||
(deferred-tru "Cannot run transforms on the sample database."))
|
||||
(api/check-400 (not (:is_audit database))
|
||||
(deferred-tru "Cannot run transforms on audit databases."))
|
||||
(api/check-400 (driver.u/supports? (:engine database) :transforms/table database)
|
||||
(deferred-tru "The database does not support the requested transform target type."))
|
||||
(api/check-400 (not (transforms.util/db-routing-enabled? database))
|
||||
(deferred-tru "Transforms are not supported on databases with DB routing enabled."))))
|
||||
|
||||
(defn- ws->response
|
||||
"Transform a workspace record into an API response, computing the backwards-compatible status."
|
||||
[ws]
|
||||
(-> ws
|
||||
(select-keys [:id :name :collection_id :database_id :created_at :updated_at])
|
||||
(assoc :status (ws.model/computed-status ws))))
|
||||
|
||||
;;; routes
|
||||
|
||||
(def ^:private WorkspaceListing
|
||||
[:map {:closed true}
|
||||
[:id ::ws.t/appdb-id]
|
||||
[:database_id ::ws.t/appdb-id]
|
||||
[:name :string]
|
||||
[:status ::status]
|
||||
[:updated_at ms/TemporalInstant]])
|
||||
|
||||
(api.macros/defendpoint :get "/" :- [:map {:closed true}
|
||||
[:items [:sequential WorkspaceListing]]
|
||||
[:limit [:maybe :int]]
|
||||
[:offset [:maybe :int]]]
|
||||
"Get a list of all workspaces"
|
||||
[_route-params
|
||||
_query-params]
|
||||
{:items (->> (t2/select [:model/Workspace :id :name :database_id :base_status :db_status :updated_at]
|
||||
(cond-> {:order-by [[:created_at :desc]]}
|
||||
(request/limit) (sql.helpers/limit (request/limit))
|
||||
(request/offset) (sql.helpers/offset (request/offset))))
|
||||
(mapv #(-> %
|
||||
(dissoc :base_status :db_status)
|
||||
(assoc :status (ws.model/computed-status %)))))
|
||||
:limit (request/limit)
|
||||
:offset (request/offset)})
|
||||
|
||||
(mr/def ::input-table
|
||||
[:map
|
||||
;; Future-proof with cross-db Python transforms
|
||||
[:db_id ::ws.t/appdb-id]
|
||||
[:schema [:maybe :string]]
|
||||
[:table :string]
|
||||
[:table_id [:maybe ::ws.t/appdb-id]]])
|
||||
|
||||
(mr/def ::output-table
|
||||
[:map
|
||||
;; Future-proof with multi-db Workspaces, plus necessary to resolve table references when id is null.
|
||||
[:db_id ::ws.t/appdb-id]
|
||||
[:global [:map
|
||||
;; transform_id is nil for workspace outputs, int for external outputs
|
||||
[:transform_id [:maybe ::ws.t/appdb-id]]
|
||||
[:schema [:maybe :string]]
|
||||
[:table :string]
|
||||
[:table_id [:maybe ::ws.t/appdb-id]]]]
|
||||
[:isolated [:map
|
||||
;; transform_id is ref_id (string) for workspace outputs, int for external outputs
|
||||
[:transform_id [:or ::ws.t/ref-id ::ws.t/appdb-id]]
|
||||
[:schema :string]
|
||||
[:table :string]
|
||||
[:table_id [:maybe ::ws.t/appdb-id]]]]])
|
||||
|
||||
(defn- batch-lookup-table-ids
|
||||
"Given a bounded list of tables, all within the same database, return an association list of [db schema table] => id"
|
||||
[db-id schema-key table-key table-refs]
|
||||
(when (seq table-refs)
|
||||
(t2/select-fn-vec (juxt (juxt (constantly db-id) :schema :name) :id)
|
||||
[:model/Table :id :schema :name]
|
||||
:db_id db-id
|
||||
{:where (into [:or] (for [tr table-refs]
|
||||
[:and
|
||||
[:= :schema (get tr schema-key)]
|
||||
[:= :name (get tr table-key)]]))})))
|
||||
|
||||
(defn- table-ids-fallbacks
|
||||
"Given a list of maps holding [db_id schema table], return a mapping from those tuples => table_id"
|
||||
[schema-key table-key id-key table-refs]
|
||||
(when-let [table-refs (seq (remove id-key table-refs))]
|
||||
;; These are ordered by db, so this will partition fine.
|
||||
(u/for-map [table-refs (partition-by :db_id table-refs)
|
||||
:let [db_id (:db_id (first table-refs))]
|
||||
;; Guesstimating a number that prevents this query being too large.
|
||||
table-refs (partition-all 20 table-refs)
|
||||
map-entry (batch-lookup-table-ids db_id schema-key table-key table-refs)]
|
||||
map-entry)))
|
||||
|
||||
(api.macros/defendpoint :get "/:id/table"
|
||||
:- [:map {:closed true}
|
||||
[:inputs [:sequential ::input-table]]
|
||||
[:outputs [:sequential ::output-table]]]
|
||||
"Get workspace tables"
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
;; Trigger creation of the Workspace*External entries
|
||||
_ (ws.impl/get-or-calculate-graph workspace)
|
||||
order-by {:order-by [:db_id :global_schema :global_table]}
|
||||
outputs (t2/select [:model/WorkspaceOutput
|
||||
:db_id :global_schema :global_table :global_table_id
|
||||
:isolated_schema :isolated_table :isolated_table_id :ref_id]
|
||||
:workspace_id id order-by)
|
||||
external-outputs (t2/select [:model/WorkspaceOutputExternal
|
||||
:db_id :global_schema :global_table :global_table_id
|
||||
:isolated_schema :isolated_table :isolated_table_id :transform_id]
|
||||
:workspace_id id order-by)
|
||||
all-outputs (concat outputs external-outputs)
|
||||
raw-inputs (t2/select [:model/WorkspaceInput :db_id :schema :table :table_id]
|
||||
:workspace_id id {:order-by [:db_id :schema :table]})
|
||||
external-inputs (t2/select [:model/WorkspaceInputExternal :db_id :schema :table :table_id]
|
||||
:workspace_id id {:order-by [:db_id :schema :table]})
|
||||
all-raw-inputs (concat raw-inputs external-inputs)
|
||||
;; Some of our inputs may be shadowed by the outputs of other transforms. We only want external inputs.
|
||||
shadowed? (into #{} (map (juxt :db_id :global_schema :global_table)) all-outputs)
|
||||
inputs (remove (comp shadowed? (juxt :db_id :schema :table)) all-raw-inputs)
|
||||
;; Build a map of [d s t] => id for every table that has been synced since the output row was written.
|
||||
fallback-map (merge
|
||||
(table-ids-fallbacks :global_schema :global_table :global_table_id all-outputs)
|
||||
(table-ids-fallbacks :isolated_schema :isolated_table :isolated_table_id all-outputs))]
|
||||
{:inputs (sort-by (juxt :db_id :schema :table) inputs)
|
||||
:outputs (sort-by
|
||||
(juxt :db_id (comp (juxt :schema :table) :global))
|
||||
(concat
|
||||
;; Workspace transform outputs
|
||||
(for [{:keys [ref_id db_id global_schema global_table global_table_id
|
||||
isolated_schema isolated_table isolated_table_id]} outputs]
|
||||
{:db_id db_id
|
||||
:global {:transform_id nil
|
||||
:schema global_schema
|
||||
:table global_table
|
||||
:table_id (or global_table_id (get fallback-map [db_id global_schema global_table]))}
|
||||
:isolated {:transform_id ref_id
|
||||
:schema isolated_schema
|
||||
:table isolated_table
|
||||
:table_id (or isolated_table_id (get fallback-map [db_id isolated_schema isolated_table]))}})
|
||||
;; External transform outputs
|
||||
(for [{:keys [transform_id db_id global_schema global_table global_table_id
|
||||
isolated_schema isolated_table isolated_table_id]} external-outputs]
|
||||
{:db_id db_id
|
||||
:global {:transform_id transform_id
|
||||
:schema global_schema
|
||||
:table global_table
|
||||
:table_id (or global_table_id (get fallback-map [db_id global_schema global_table]))}
|
||||
:isolated {:transform_id transform_id
|
||||
:schema isolated_schema
|
||||
:table isolated_table
|
||||
:table_id (or isolated_table_id (get fallback-map [db_id isolated_schema isolated_table]))}})))}))
|
||||
|
||||
(api.macros/defendpoint :get "/:id" :- Workspace
|
||||
"Get a single workspace by ID"
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params]
|
||||
(-> (t2/select-one :model/Workspace :id id) api/check-404 ws->response))
|
||||
|
||||
(api.macros/defendpoint :get "/:id/log"
|
||||
:- [:map
|
||||
[:workspace_id ms/PositiveInt]
|
||||
[:status ::status]
|
||||
[:updated_at :any]
|
||||
[:last_completed_at [:maybe :any]]
|
||||
[:logs [:sequential [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:task :keyword]
|
||||
[:description ms/LocalizedString]
|
||||
[:started_at :any]
|
||||
[:updated_at :any]
|
||||
[:completed_at [:maybe :any]]
|
||||
[:status [:maybe :keyword]]
|
||||
[:message [:maybe :string]]]]]]
|
||||
"Get workspace creation status and recent log entries for polling during async setup"
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
logs (t2/select [:model/WorkspaceLog
|
||||
:id :task :started_at :completed_at :status :message
|
||||
:updated_at]
|
||||
:workspace_id id
|
||||
{:order-by [[:started_at :desc]]
|
||||
:limit log-limit})]
|
||||
{:workspace_id id
|
||||
:status (ws.model/computed-status workspace)
|
||||
:logs logs
|
||||
:updated_at (->> (map :updated_at logs) sort reverse first)
|
||||
:last_completed_at (->> (seq (keep :completed_at logs)) sort reverse first)}))
|
||||
|
||||
(def ^:private CreateWorkspace
|
||||
[:map
|
||||
[:name {:optional true} [:string {:min 1}]]
|
||||
[:database_id {:optional true} ::ws.t/appdb-id]])
|
||||
|
||||
(defn- first-supported-database-id
|
||||
"Return the ID of the first database that supports workspaces, or nil if none."
|
||||
[]
|
||||
(:id
|
||||
(u/seek #(driver.u/supports? (:engine %) :workspace %)
|
||||
(t2/select :model/Database :is_audit false :is_sample false {:order-by [:name]}))))
|
||||
|
||||
(api.macros/defendpoint :post "/" :- Workspace
|
||||
"Create a new workspace
|
||||
|
||||
Potential payload:
|
||||
{:name \"a\" :database_id 2}}"
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [database_id] :as body} :- CreateWorkspace]
|
||||
|
||||
(when database_id
|
||||
(check-transforms-enabled! database_id))
|
||||
|
||||
;; If no database_id provided, use first supported DB as provisional default (uninitialized workspace)
|
||||
(let [provisional? (not database_id)
|
||||
database-id (or database_id
|
||||
(api/check-400 (first-supported-database-id)
|
||||
(tru "No supported databases configured. Please add a database that supports workspaces.")))]
|
||||
(ws->response (ws.common/create-workspace! api/*current-user-id*
|
||||
(assoc body
|
||||
:database_id database-id
|
||||
:provisional? provisional?)))))
|
||||
|
||||
(defn- db-unsupported-reason [db]
|
||||
(when (not (driver.u/supports? (:engine db) :workspace db))
|
||||
"Database type not supported."))
|
||||
|
||||
(api.macros/defendpoint :get "/enabled" :- [:map [:supported :boolean] [:reason {:optional true} :string]]
|
||||
"Test whether the current user can use Workspaces. Optionally takes a specific database.
|
||||
Factors include: driver support, database user privileges, metabase permissions."
|
||||
[_url-params
|
||||
{:keys [database-id]} :- [:map [:database-id {:optional true} ms/PositiveInt]]]
|
||||
(if-let [reason (or (when (not api/*is-superuser?*)
|
||||
(tru "Not allowed."))
|
||||
(when database-id
|
||||
(db-unsupported-reason (api/check-404 (t2/select-one :model/Database database-id)))))]
|
||||
{:supported false, :reason reason}
|
||||
{:supported true}))
|
||||
|
||||
(api.macros/defendpoint :get "/database" :- [:map
|
||||
[:databases
|
||||
[:sequential [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:name :string]
|
||||
[:supported :boolean]
|
||||
[:reason {:optional true} :string]]]]]
|
||||
"Get a list of databases to show in the workspace picker, along with whether they're supported."
|
||||
[_url-params
|
||||
_query-params]
|
||||
{:databases (->> (t2/select :model/Database :is_audit false :is_sample false {:order-by [:name]})
|
||||
;; Omit those we don't even support
|
||||
(filter #(driver.u/supports? (:engine %) :workspace %))
|
||||
(mapv (fn [db]
|
||||
(merge (select-keys db [:id :name])
|
||||
(if-let [reason (db-unsupported-reason db)]
|
||||
{:supported false, :reason reason}
|
||||
{:supported true})))))})
|
||||
|
||||
(api.macros/defendpoint :put "/:id" :- Workspace
|
||||
"Update simple workspace properties.
|
||||
|
||||
Can set database_id only on uninitialized workspaces."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [name database_id]} :- [:map {:closed true}
|
||||
[:name {:optional true} [:string {:min 1}]]
|
||||
[:database_id {:optional true} ::ws.t/appdb-id]]]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
_ (api/check-400 (not= :archived (:base_status workspace)) "Cannot update an archived workspace")
|
||||
data (cond-> {}
|
||||
database_id (-> (u/prog1
|
||||
(api/check-400 (= :uninitialized (:db_status workspace))
|
||||
"Can only set database_id on uninitialized workspace")
|
||||
(check-transforms-enabled! database_id))
|
||||
(assoc :database_id database_id))
|
||||
name (assoc :name name))]
|
||||
(ws->response
|
||||
(if (seq data)
|
||||
(do
|
||||
(t2/update! :model/Workspace id data)
|
||||
(t2/select-one :model/Workspace :id id))
|
||||
workspace))))
|
||||
|
||||
(api.macros/defendpoint :post "/:id/archive" :- Workspace
|
||||
"Archive a workspace. Deletes the isolated schema and tables, but preserves mirrored entities."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
_body-params]
|
||||
(let [ws (api/check-404 (t2/select-one :model/Workspace :id id))]
|
||||
(api/check-400 (not= :archived (:base_status ws)) "You cannot archive an archived workspace")
|
||||
(ws.model/archive! ws)
|
||||
(-> (t2/select-one :model/Workspace :id id)
|
||||
ws->response)))
|
||||
|
||||
(api.macros/defendpoint :post "/:id/unarchive" :- Workspace
|
||||
"Restore an archived workspace. Recreates the isolated schema and tables."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
_body-params]
|
||||
(let [ws (api/check-404 (t2/select-one :model/Workspace :id id))]
|
||||
(api/check-400 (= :archived (:base_status ws)) "You cannot unarchive a workspace that is not archived")
|
||||
(ws.model/unarchive! ws)
|
||||
(-> (t2/select-one :model/Workspace :id id)
|
||||
ws->response)))
|
||||
|
||||
(api.macros/defendpoint :delete "/:ws-id" :- [:map [:ok [:= true]]]
|
||||
"Delete a workspace and all its contents, including mirrored entities."
|
||||
[{:keys [ws-id]} :- [:map [:ws-id ms/PositiveInt]]
|
||||
_query-params]
|
||||
(let [ws (api/check-404 (t2/select-one :model/Workspace :id ws-id))]
|
||||
(api/check-400 (= :archived (:base_status ws)) "You cannot delete a workspace without first archiving it")
|
||||
(ws.model/delete! ws)
|
||||
{:ok true}))
|
||||
|
||||
(def ^:private ExternalTransform
|
||||
;; Might be interesting to show whether they're enclosed, once we have the graph.
|
||||
;; When they're enclosed, it could also be interesting to know whether they're stale.
|
||||
[:map
|
||||
[:id ::ws.t/appdb-id]
|
||||
[:name :string]
|
||||
[:source_type :keyword]
|
||||
[:checkout_disabled [:maybe :string]]])
|
||||
|
||||
(api.macros/defendpoint :get "/:ws-id/external/transform" :- [:map [:transforms [:sequential ExternalTransform]]]
|
||||
"Get transforms that are external to the workspace, i.e. no matching workspace_transform row exists."
|
||||
[{:keys [ws-id]} :- [:map [:ws-id ::ws.t/appdb-id]]
|
||||
{:keys [database-id]} :- [:map [:database-id {:optional true} ::ws.t/appdb-id]]]
|
||||
(api/check-superuser)
|
||||
(let [db-id (or database-id
|
||||
(:database_id (api/check-404 (t2/select-one [:model/Workspace :database_id] ws-id))))
|
||||
transforms (t2/select [:model/Transform :id :name :source_type :source]
|
||||
{:left-join [[:workspace_transform :wt]
|
||||
[:and
|
||||
[:= :transform.id :wt.global_id]
|
||||
[:= :wt.workspace_id ws-id]]]
|
||||
;; NULL workspace_id means transform is not checked out into this workspace
|
||||
:where [:and
|
||||
[:= nil :wt.workspace_id]
|
||||
[:= :target_db_id db-id]]
|
||||
:order-by [[:id :asc]]})]
|
||||
{:transforms
|
||||
(into []
|
||||
(map #(-> %
|
||||
(dissoc :source)
|
||||
(assoc :checkout_disabled (case (:source_type %)
|
||||
:mbql "mbql"
|
||||
:native (when (seq (lib/template-tags-referenced-cards (:query (:source %))))
|
||||
"card-reference")
|
||||
:python nil
|
||||
"unknown-type"))))
|
||||
;; (Sanya 2025-12-24) - do we need this line?
|
||||
#_(map #(update % :last_run transforms.util/localize-run-timestamps))
|
||||
transforms
|
||||
;; Perhaps we want to expose some of this later?
|
||||
#_(t2/hydrate transforms :last_run :creator))}))
|
||||
|
||||
(api.macros/defendpoint :post "/:ws-id/run"
|
||||
:- [:map
|
||||
[:succeeded [:sequential ::ws.t/ref-id]]
|
||||
[:failed [:sequential ::ws.t/ref-id]]
|
||||
[:not_run [:sequential ::ws.t/ref-id]]]
|
||||
"Execute all transforms in the workspace in dependency order.
|
||||
Returns which transforms succeeded, failed, and were not run."
|
||||
[{:keys [ws-id]} :- [:map [:ws-id ::ws.t/appdb-id]]
|
||||
_query-params
|
||||
;; Hmmm, I wonder why this isn't a boolean? T_T
|
||||
{:keys [stale_only]} :- [:map [:stale_only {:optional true} [:or [:= 1] :boolean]]]]
|
||||
(let [workspace (t2/select-one :model/Workspace :id ws-id)]
|
||||
(api/check-404 workspace)
|
||||
(api/check-400 (not= :archived (:base_status workspace)) "Cannot execute archived workspace")
|
||||
(ws.impl/execute-workspace! workspace {:stale-only stale_only})))
|
||||
|
||||
(mr/def ::graph-node-type [:enum :input-table :external-transform :workspace-transform])
|
||||
|
||||
(mr/def ::graph-node
|
||||
[:map
|
||||
[:id ::appdb-or-ref-id]
|
||||
[:type [:enum :input-table :external-transform :workspace-transform]]
|
||||
[:dependents_count [:map-of ::graph-node-type ms/PositiveInt]]
|
||||
[:data :map]])
|
||||
|
||||
(mr/def ::graph-edge
|
||||
[:map
|
||||
[:from_entity_id ::appdb-or-ref-id]
|
||||
[:from_entity_type :string]
|
||||
[:to_entity_id ::appdb-or-ref-id]
|
||||
[:to_entity_type :string]])
|
||||
|
||||
(def ^:private GraphResult
|
||||
[:map
|
||||
[:nodes [:sequential ::graph-node]]
|
||||
[:edges [:sequential ::graph-edge]]])
|
||||
|
||||
(defn- node-type [node]
|
||||
(let [nt (:node-type node)]
|
||||
(case nt
|
||||
:table :input-table
|
||||
nt)))
|
||||
|
||||
(defn- node-id [{:keys [node-type id]}]
|
||||
(case node-type
|
||||
:workspace-transform id
|
||||
:external-transform id
|
||||
:table (str (:db id) "-" (:schema id) "-" (:table id))))
|
||||
|
||||
(defn- target->spec [{:keys [target] :as tx}]
|
||||
(assoc tx :target {:db (:database target)
|
||||
:schema (:schema target)
|
||||
:table (:name target)}))
|
||||
|
||||
;; TODO we'll want to bulk query this of course...
|
||||
(defn- node-data [{:keys [node-type id]}]
|
||||
(case node-type
|
||||
:table id
|
||||
:external-transform (t2/select-one-fn target->spec [:model/Transform :id :name :target] id)
|
||||
;; TODO we'll want to select by workspace as well here, to relax uniqueness assumption
|
||||
:workspace-transform (t2/select-one-fn target->spec [:model/WorkspaceTransform :ref_id :name :target] :ref_id id)))
|
||||
|
||||
(api.macros/defendpoint :get "/:ws-id/graph" :- GraphResult
|
||||
"Display the dependency graph between the Changeset and the (potentially external) entities that they depend on."
|
||||
[{:keys [ws-id]} :- [:map [:ws-id ms/PositiveInt]]
|
||||
_query-params]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id ws-id))
|
||||
{:keys [inputs entities dependencies]} (ws.impl/get-or-calculate-graph workspace)
|
||||
;; TODO Graph analysis doesn't return this currently, we need to invert the deps graph
|
||||
;; It could be cheaper to build it as we go.
|
||||
inverted (reduce
|
||||
(fn [inv [c parents]]
|
||||
(reduce (fn [inv p] (update inv p (fnil conj #{}) c)) inv parents))
|
||||
{}
|
||||
dependencies)
|
||||
dep-count #(frequencies (map node-type (get inverted %)))]
|
||||
|
||||
{:nodes (concat (for [i inputs]
|
||||
{:type :input-table
|
||||
;; Use an id that's independent of whether the table exists yet.
|
||||
:id (node-id {:id i, :node-type :table})
|
||||
:data i
|
||||
:dependents_count (dep-count {:node-type :table, :id i})})
|
||||
(for [e entities]
|
||||
{:type (node-type e)
|
||||
:id (:id e)
|
||||
:data (node-data e)
|
||||
:dependents_count (dep-count e)}))
|
||||
:edges (for [[child parents] dependencies, parent parents]
|
||||
;; Yeah, this graph points to dependents, not dependencies
|
||||
{:from_entity_type (name (node-type parent))
|
||||
:from_entity_id (node-id parent)
|
||||
:to_entity_type (name (node-type child))
|
||||
:to_entity_id (node-id child)})}))
|
||||
|
||||
;;; ---------------------------------------- Problems/Validation ----------------------------------------
|
||||
|
||||
(api.macros/defendpoint :get "/:id/problem" :- [:sequential ::ws.t/problem]
|
||||
"Detect problems in the workspace that would affect downstream transforms after merge.
|
||||
|
||||
Returns a list of problems, each with:
|
||||
- category: the problem category (e.g. 'unused', 'internal-downstream', 'external-downstream')
|
||||
- problem: the specific problem (e.g. 'not-run', 'stale', 'removed-field')
|
||||
- severity: :error, :warning, or :info
|
||||
- block-merge: whether this problem prevents merging
|
||||
- data: extra information, shape depends on the problem type
|
||||
|
||||
See `metabase-enterprise.workspaces.types/problem-types` for the full list."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
graph (ws.impl/get-or-calculate-graph workspace)]
|
||||
(ws.validation/find-downstream-problems id graph)))
|
||||
|
||||
(def ^:private db+schema+table (juxt :database :schema :name))
|
||||
|
||||
(defn- internal-target-conflict?
|
||||
"Check whether the given table is the target of another transform within the workspace. Ignores global transforms."
|
||||
[ws-id target & [tx-id]]
|
||||
(contains?
|
||||
(t2/select-fn-set (comp db+schema+table :target)
|
||||
[:model/WorkspaceTransform :target]
|
||||
:ref_id [:not= tx-id]
|
||||
:workspace_id ws-id)
|
||||
(db+schema+table target)))
|
||||
|
||||
(api.macros/defendpoint :post "/:ws-id/transform/validate/target"
|
||||
:- [:map [:status :int] [:body [:or :string i18n/LocalizedString]]]
|
||||
"Validate the target table for a workspace transform"
|
||||
[{:keys [ws-id]} :- [:map [:ws-id ::ws.t/appdb-id]]
|
||||
{:keys [transform-id]} :- [:map [:transform-id {:optional true} ::ws.t/ref-id]]
|
||||
{:keys [db_id target]} :- [:map
|
||||
[:db_id {:optional true} ::ws.t/appdb-id]
|
||||
[:target [:map
|
||||
[:database {:optional true} ::ws.t/appdb-id]
|
||||
[:type :string]
|
||||
[:schema [:maybe :string]]
|
||||
[:name :string]]]]]
|
||||
(let [workspace (api/check-404 (t2/select-one [:model/Workspace :database_id :db_status] ws-id))
|
||||
target (update target :database #(or % db_id))
|
||||
tx-id (when transform-id (parse-long transform-id))
|
||||
;; For uninitialized workspaces we skip over checks for their db id
|
||||
ws-db-id (when (not= :uninitialized (:db_status workspace)) (:database_id workspace))]
|
||||
(cond
|
||||
(not= "table" (:type target))
|
||||
{:status 403 :body (deferred-tru "Unsupported target type")}
|
||||
|
||||
(and db_id ws-db-id (not= db_id ws-db-id))
|
||||
{:status 403 :body (deferred-tru "Must target the workspace database")}
|
||||
|
||||
(not (or db_id ws-db-id))
|
||||
{:status 403 :body (deferred-tru "Must target a database")}
|
||||
|
||||
(when-let [schema (:schema target)] (str/starts-with? schema "mb__isolation_"))
|
||||
{:status 403 :body (deferred-tru "Must not target an isolated workspace schema")}
|
||||
|
||||
;; Within a workspace, we defer blocking on conflicts outside the workspace
|
||||
#_{:status 403 :body (deferred-tru "A table with that name already exists.")}
|
||||
|
||||
;; TODO consider deferring this validation until merge also.
|
||||
(internal-target-conflict? ws-id target tx-id)
|
||||
{:status 403 :body (deferred-tru "Another transform in this workspace already targets that table")}
|
||||
|
||||
:else
|
||||
{:status 200 :body "OK"})))
|
||||
|
||||
(defn- malli-map-keys [schema]
|
||||
(into [] (map first) (rest schema)))
|
||||
|
||||
(defn- select-malli-keys
|
||||
"Like select-keys, but with the arguments reversed, and taking the malli schema for the output map.
|
||||
Nothing smart - for example, it doesn't understand optional verus maybe."
|
||||
([schema m]
|
||||
(select-keys m (malli-map-keys schema)))
|
||||
([schema alias-map m]
|
||||
(merge (select-malli-keys schema m)
|
||||
(u/for-map [[to from] alias-map] [to (from m)]))))
|
||||
|
||||
(defn- select-model-malli-keys
|
||||
"Build the model-fields vector for a t2/select from a malli schema for the output row(s)"
|
||||
([model schema]
|
||||
(into [model] (malli-map-keys schema)))
|
||||
([model schema alias-map]
|
||||
(into [model]
|
||||
(map (fn [to] (if-let [from (to alias-map)] [from to] to)))
|
||||
(malli-map-keys schema))))
|
||||
|
||||
(def ^:private WorkspaceTransform
|
||||
[:map
|
||||
[:ref_id ::ws.t/ref-id]
|
||||
[:global_id [:maybe ::ws.t/appdb-id]]
|
||||
[:name :string]
|
||||
[:description [:maybe :string]]
|
||||
[:source :map]
|
||||
[:target :map]
|
||||
;; Not yet calculated, see https://linear.app/metabase/issue/BOT-684/mark-stale-transforms-workspace-only
|
||||
[:target_stale :boolean]
|
||||
[:workspace_id ::ws.t/appdb-id]
|
||||
[:creator_id [:maybe ::ws.t/appdb-id]]
|
||||
[:archived_at :any]
|
||||
[:created_at :any]
|
||||
[:updated_at :any]
|
||||
[:last_run_at :any]
|
||||
[:last_run_message [:maybe :string]]])
|
||||
|
||||
(def ^:private workspace-transform-alias {:target_stale :stale})
|
||||
|
||||
(defn- attach-isolated-target [{:keys [workspace_id ref_id] :as ws-transform}]
|
||||
(let [{:keys [db_id isolated_schema isolated_table]}
|
||||
(t2/select-one :model/WorkspaceOutput :workspace_id workspace_id :ref_id ref_id)]
|
||||
(assoc ws-transform :target_isolated {:type "table"
|
||||
:database db_id
|
||||
:schema isolated_schema
|
||||
:name isolated_table})))
|
||||
|
||||
(api.macros/defendpoint :post "/:ws-id/transform"
|
||||
:- WorkspaceTransform
|
||||
"Add another transform to the Changeset. This could be a fork of an existing global transform, or something new."
|
||||
[{:keys [ws-id]} :- [:map [:ws-id ::ws.t/appdb-id]]
|
||||
_query-params
|
||||
body :- [:map #_{:closed true}
|
||||
[:name :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:source ::transform-source]
|
||||
;; Not sure why this schema is giving trouble
|
||||
#_[:target ::transform-target]]]
|
||||
(api/check (transforms.util/check-feature-enabled body)
|
||||
[402 (deferred-tru "Premium features required for this transform type are not enabled.")])
|
||||
(t2/with-transaction [_tx]
|
||||
(let [workspace (u/prog1 (api/check-404 (t2/select-one :model/Workspace :id ws-id))
|
||||
(api/check-400 (not= :archived (:base_status <>)) "Cannot create transforms in an archived workspace"))
|
||||
;; TODO why 400 here and 403 in the validation route? T_T
|
||||
_ (api/check-400 (not (internal-target-conflict? ws-id (:target body)))
|
||||
(deferred-tru "Another transform in this workspace already targets that table"))
|
||||
global-id (:global_id body (:id body))
|
||||
;; For uninitialized workspaces, preserve the target database from the request body
|
||||
;; (add-to-changeset! will reinitialize the workspace with it if different from provisional)
|
||||
;; For initialized workspaces, ensure the target database matches the workspace database
|
||||
body (-> body (dissoc :global_id)
|
||||
(cond-> (not= :uninitialized (:db_status workspace))
|
||||
(update :target assoc :database (:database_id workspace))))
|
||||
transform (ws.common/add-to-changeset! api/*current-user-id* workspace :transform global-id body)]
|
||||
(attach-isolated-target (select-malli-keys WorkspaceTransform workspace-transform-alias transform)))))
|
||||
|
||||
;; TODO Confirm precisely which fields are needed by the FE
|
||||
(def ^:private WorkspaceTransformListing
|
||||
"Schema for a transform in a workspace"
|
||||
[:map {:closed true}
|
||||
[:ref_id ::ws.t/ref-id]
|
||||
[:global_id [:maybe ::ws.t/appdb-id]]
|
||||
[:name :string]
|
||||
[:source_type [:maybe :keyword]]
|
||||
[:creator_id [:maybe ::ws.t/appdb-id]]
|
||||
;[:last_run :map]
|
||||
; See https://metaboat.slack.com/archives/C099RKNLP6U/p1765205882655869?thread_ts=1765205222.888209&cid=C099RKNLP6U
|
||||
#_[:target_stale :boolean]])
|
||||
|
||||
;; Global transforms precalculate these in a toucan hook - maybe we should do that too? Let's review later.
|
||||
(defn- map-source-type [ws-tx]
|
||||
(-> ws-tx
|
||||
(assoc :source_type (transforms/transform-source-type (:source ws-tx)))
|
||||
(dissoc :source)))
|
||||
|
||||
(api.macros/defendpoint :get "/:id/transform" :- [:map [:transforms [:sequential WorkspaceTransformListing]]]
|
||||
"Get all transforms in a workspace."
|
||||
[{:keys [id]} :- [:map [:id ::ws.t/appdb-id]]]
|
||||
(api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
{:transforms (->> (t2/select [:model/WorkspaceTransform :ref_id :global_id :name :source :creator_id]
|
||||
:workspace_id id {:order-by [:created_at]})
|
||||
(map map-source-type))})
|
||||
|
||||
(defn- fetch-ws-transform [ws-id tx-id]
|
||||
;; TODO We still need to do some hydration, e.g. of the target table (both internal and external)
|
||||
(-> (select-model-malli-keys :model/WorkspaceTransform WorkspaceTransform workspace-transform-alias)
|
||||
(t2/select-one :ref_id tx-id :workspace_id ws-id)
|
||||
api/check-404
|
||||
attach-isolated-target))
|
||||
|
||||
(api.macros/defendpoint :get "/:id/transform/:tx-id" :- WorkspaceTransform
|
||||
"Get a specific transform in a workspace."
|
||||
[{:keys [id tx-id]} :- [:map [:id ::ws.t/appdb-id] [:tx-id ::ws.t/ref-id]]]
|
||||
(fetch-ws-transform id tx-id))
|
||||
|
||||
(api.macros/defendpoint :put "/:ws-id/transform/:tx-id" :- WorkspaceTransform
|
||||
"Update a transform in a workspace."
|
||||
[{:keys [ws-id tx-id]} :- [:map [:ws-id ::ws.t/appdb-id] [:tx-id ::ws.t/ref-id]]
|
||||
_query-params
|
||||
body :- [:map
|
||||
[:name {:optional true} :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:source {:optional true} ::transform-source]
|
||||
[:target {:optional true} ::transform-target]]]
|
||||
(t2/with-transaction [_tx]
|
||||
(api/check-404 (t2/select-one :model/WorkspaceTransform :ref_id tx-id :workspace_id ws-id))
|
||||
;; If source or target changed, mark as stale in the same update
|
||||
(let [source-or-target-changed? (or (:source body) (:target body))
|
||||
update-body (cond-> body
|
||||
source-or-target-changed? (assoc :analysis_stale true))]
|
||||
(t2/update! :model/WorkspaceTransform tx-id update-body)
|
||||
;; Mark workspace as stale too if source/target changed
|
||||
(when source-or-target-changed?
|
||||
(ws.impl/mark-workspace-stale! ws-id)))
|
||||
(fetch-ws-transform ws-id tx-id)))
|
||||
|
||||
(api.macros/defendpoint :post "/:id/transform/:tx-id/archive" :- :nil
|
||||
"Mark the given transform to be archived when the workspace is merged.
|
||||
For provisional transforms we will skip even creating it in the first place."
|
||||
[{:keys [id tx-id]} :- [:map [:id ::ws.t/appdb-id] [:tx-id ::ws.t/ref-id]]]
|
||||
(api/check-404 (pos? (t2/update! :model/WorkspaceTransform {:ref_id tx-id :workspace_id id} {:archived_at [:now]})))
|
||||
(ws.impl/mark-workspace-stale! id)
|
||||
nil)
|
||||
|
||||
(api.macros/defendpoint :post "/:id/transform/:tx-id/unarchive" :- :nil
|
||||
"Unmark the given transform for archival. This will recall the last definition it had within the workspace."
|
||||
[{:keys [id tx-id]} :- [:map [:id ::ws.t/appdb-id] [:tx-id ::ws.t/ref-id]]]
|
||||
(api/check-404 (pos? (t2/update! :model/WorkspaceTransform
|
||||
{:ref_id tx-id :workspace_id id}
|
||||
{:archived_at nil, :analysis_stale true})))
|
||||
(ws.impl/mark-workspace-stale! id)
|
||||
nil)
|
||||
|
||||
(api.macros/defendpoint :delete "/:id/transform/:tx-id" :- :nil
|
||||
"Discard a transform from the changeset.
|
||||
Equivalent to resetting a checked-out transform to its global definition, or deleting a provisional transform."
|
||||
[{:keys [id tx-id]} :- [:map [:id ::ws.t/appdb-id] [:tx-id ::ws.t/ref-id]]]
|
||||
(api/check-404 (pos? (t2/delete! :model/WorkspaceTransform :ref_id tx-id :workspace_id id)))
|
||||
;; Mark workspace as stale since the graph has changed
|
||||
(ws.impl/mark-workspace-stale! id)
|
||||
nil)
|
||||
|
||||
(api.macros/defendpoint :post "/:id/transform/:tx-id/run"
|
||||
:- ::ws.t/execution-result
|
||||
"Run a transform in a workspace.
|
||||
|
||||
App DB changes are rolled back. Warehouse DB changes persist."
|
||||
[{:keys [id tx-id]} :- [:map [:id ::ws.t/appdb-id] [:tx-id ::ws.t/ref-id]]]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace id))
|
||||
transform (api/check-404 (t2/select-one :model/WorkspaceTransform :ref_id tx-id :workspace_id id))]
|
||||
(api/check-400 (not= :archived (:base_status workspace)) "Cannot execute archived workspace")
|
||||
(check-transforms-enabled! (:database_id workspace))
|
||||
;; Ensure analysis is up-to-date, as we may need grants to external input tables.
|
||||
(ws.impl/analyze-transform-if-stale! workspace transform)
|
||||
(ws.impl/run-transform! workspace transform)))
|
||||
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-route-uses-kebab-case]}
|
||||
(api.macros/defendpoint :get "/checkout"
|
||||
:- [:map
|
||||
[:transforms [:sequential
|
||||
[:map
|
||||
[:id ::ws.t/ref-id]
|
||||
[:name :string]
|
||||
[:workspace [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:name :string]]]]]]]
|
||||
"Get all downstream transforms for a transform that is not in a workspace.
|
||||
Returns the transforms that were mirrored from this upstream transform, with workspace info."
|
||||
[_route-params
|
||||
{:keys [transform-id]} :- [:map {:closed true} [:transform-id ms/PositiveInt]]]
|
||||
(let [transforms (t2/select [:model/WorkspaceTransform :ref_id :name :workspace_id] :global_id transform-id)
|
||||
workspace-ids (map :workspace_id transforms)
|
||||
workspaces-by-id (when (seq transforms)
|
||||
(t2/select-fn->fn :id identity [:model/Workspace :id :name] :id [:in workspace-ids]))]
|
||||
{:transforms (for [{:keys [ref_id name workspace_id]} transforms]
|
||||
{:id ref_id
|
||||
:name name
|
||||
:workspace (get workspaces-by-id workspace_id)})}))
|
||||
|
||||
(api.macros/defendpoint :post "/:ws-id/merge"
|
||||
:- [:or
|
||||
[:map
|
||||
[:merged
|
||||
[:map
|
||||
[:transforms [:sequential
|
||||
[:map
|
||||
[:op [:enum :create :delete :update :noop]]
|
||||
[:global_id {:optional true} [:maybe ::ws.t/appdb-id]]
|
||||
[:ref_id ::ws.t/ref-id]]]]]]
|
||||
[:errors
|
||||
[:sequential
|
||||
[:map
|
||||
[:op [:enum :create :delete :update :noop]]
|
||||
[:global_id {:optional true} [:maybe ::ws.t/appdb-id]]
|
||||
[:ref_id ::ws.t/ref-id]]]]
|
||||
[:workspace [:map [:id ::ws.t/appdb-id] [:name :string]]]]
|
||||
;; error message from check-404 or check-400
|
||||
:string]
|
||||
"This will:
|
||||
1. Update original transforms with workspace versions
|
||||
2. Delete the workspace and clean up isolated resources
|
||||
Returns a report of merged entities, or error in errors key.
|
||||
|
||||
Request body may include:
|
||||
- commit-message: A description of what changes are being merged (optional, will be required in future)"
|
||||
[{:keys [ws-id] :as _query-params} :- [:map [:ws-id ::ws.t/appdb-id]]
|
||||
_query-params
|
||||
{:keys [commit-message]
|
||||
:or {commit-message "Placeholder for merge commit message. Should be required on FE"}} :- [:map
|
||||
[:commit-message {:optional true} [:string {:min 1}]]]]
|
||||
(let [ws (u/prog1 (t2/select-one :model/Workspace :id ws-id)
|
||||
(api/check-404 <>)
|
||||
(api/check-400 (not= :archived (:base_status <>)) "Cannot merge an archived workspace"))
|
||||
{:keys [merged
|
||||
errors]} (-> (ws.merge/merge-workspace! ws api/*current-user-id* commit-message)
|
||||
(update :errors
|
||||
(partial mapv #(-> %
|
||||
(update :error (fn [e] (.getMessage ^Throwable e)))
|
||||
(set/rename-keys {:error :message})))))]
|
||||
(u/prog1
|
||||
{:merged merged
|
||||
:errors errors
|
||||
:workspace {:id ws-id, :name (:name ws)}}
|
||||
(when-not (seq errors)
|
||||
(ws.model/delete! ws)))))
|
||||
|
||||
(api.macros/defendpoint :post "/:ws-id/transform/:tx-id/merge"
|
||||
:- [:map
|
||||
[:op [:enum :create :delete :update :noop]]
|
||||
[:global_id [:maybe ::ws.t/appdb-id]]
|
||||
[:ref_id ::ws.t/ref-id]
|
||||
[:message {:optional true} :string]]
|
||||
"Merge single transform from workspace back to the core. If workspace transform is archived
|
||||
the corresponding core transform is deleted.
|
||||
|
||||
Request body may include:
|
||||
- commit-message: A description of what changes are being merged (optional, will be required in future)"
|
||||
[{:keys [ws-id tx-id] :as _query-params} :- [:map
|
||||
[:ws-id ::ws.t/appdb-id]
|
||||
[:tx-id ::ws.t/ref-id]]
|
||||
_query-params
|
||||
{:keys [commit-message]
|
||||
:or {commit-message "Placeholder for merge commit message. Should be required on FE"}} :- [:map
|
||||
[:commit-message {:optional true} [:string {:min 1}]]]]
|
||||
(let [ws (api/check-404 (t2/select-one [:model/Workspace :id :name] :id ws-id))
|
||||
ws-transform (api/check-404 (t2/select-one :model/WorkspaceTransform :workspace_id ws-id :ref_id tx-id))
|
||||
ws-merge-id (t2/insert-returning-pk!
|
||||
:model/WorkspaceMerge
|
||||
{:workspace_id (:id ws)
|
||||
:workspace_name (:name ws)
|
||||
:commit_message commit-message
|
||||
:creator_id api/*current-user-id*})
|
||||
{:keys [error] :as result} (ws.merge/merge-transform! {:ws-transform ws-transform
|
||||
:workspace ws
|
||||
:workspace-merge-id ws-merge-id
|
||||
:merging-user-id api/*current-user-id*
|
||||
:commit-message commit-message})]
|
||||
(if error
|
||||
(throw (ex-info "Failed to merge transform."
|
||||
(-> result
|
||||
(dissoc :error)
|
||||
(assoc :status-code 500))
|
||||
error))
|
||||
result)))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/workspace/` routes."
|
||||
(api.macros/ns-handler *ns* api/+check-superuser +auth))
|
||||
182
enterprise/backend/src/metabase_enterprise/workspaces/common.clj
Normal file
@@ -0,0 +1,182 @@
|
||||
(ns metabase-enterprise.workspaces.common
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.transforms.interface :as transforms.i]
|
||||
[metabase-enterprise.workspaces.dag :as ws.dag]
|
||||
[metabase-enterprise.workspaces.isolation :as ws.isolation]
|
||||
[metabase-enterprise.workspaces.models.workspace-log :as ws.log]
|
||||
[metabase-enterprise.workspaces.util :as ws.u]
|
||||
[metabase.api-keys.core :as api-key]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.quick-task :as quick-task]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;; should be encapsulated in our dag namespace, or dependency module
|
||||
(defn check-no-card-dependencies!
|
||||
"Check that transforms don't depend on cards. Throws 400 if they do."
|
||||
[transform-ids]
|
||||
(when-let [card-ids (seq (ws.dag/unsupported-dependency? transform-ids))]
|
||||
(api/check-400 false
|
||||
(format "Cannot add transforms that depend on saved questions (cards). Found dependencies on card IDs: %s"
|
||||
(pr-str (vec card-ids))))))
|
||||
|
||||
(defn- extract-suffix-number
|
||||
"Extract the numeric suffix from a workspace name like 'Foo (3)', or nil if no valid suffix."
|
||||
[ws-name base-name]
|
||||
(when (= (str/replace ws-name #"\s*\(\d+\)$" "") base-name)
|
||||
(when-let [[_ n] (re-find #"\((\d+)\)$" ws-name)]
|
||||
(parse-long n))))
|
||||
|
||||
(defn- generate-unique-workspace-name
|
||||
"Generate a unique workspace name by appending (N) if the name already exists.
|
||||
|
||||
Strategy:
|
||||
1. If the base name doesn't exist, use it as-is
|
||||
2. If it exists, strip any existing (N) suffix and find the highest N among similar names
|
||||
3. Return the base name with (N+1) appended
|
||||
|
||||
Note: This function has a potential race condition between checking existence and insertion.
|
||||
Callers should handle unique constraint violations by retrying."
|
||||
[base-name]
|
||||
(when (str/blank? base-name)
|
||||
(throw (ex-info "Workspace name cannot be empty" {:status-code 400})))
|
||||
(if-not (t2/exists? :model/Workspace :name base-name)
|
||||
base-name
|
||||
(let [stripped-name (str/replace base-name #"\s*\(\d+\)$" "")
|
||||
existing (t2/select-fn-set :name :model/Workspace
|
||||
:name [:like (str stripped-name " (%)")])
|
||||
numbers (keep #(extract-suffix-number % stripped-name) existing)
|
||||
next-num (inc (apply max 0 numbers))]
|
||||
(str stripped-name " (" next-num ")"))))
|
||||
|
||||
;; TODO: Generate new metabase user for the workspace
|
||||
(defn- create-workspace-container!
|
||||
"Create the workspace and its related collection, user, and api key."
|
||||
[creator-id db-id workspace-name]
|
||||
;; TODO (Chris 2025-11-19) Unsure API key name is unique, and remove this (insecure) workaround.
|
||||
(let [api-key (let [key-name (format "API key for Workspace %s" workspace-name)]
|
||||
(or (t2/select-one :model/ApiKey :name key-name)
|
||||
(api-key/create-api-key-with-new-user! {:key-name key-name})))
|
||||
;; TODO (Chris 2025-11-19) Associate the api-key user with the workspace as well.
|
||||
ws (t2/insert-returning-instance! :model/Workspace
|
||||
{:name workspace-name
|
||||
:creator_id creator-id
|
||||
:database_id db-id
|
||||
:api_key_id (:id api-key)
|
||||
:execution_user (:user_id api-key)
|
||||
:base_status :empty
|
||||
:db_status :uninitialized})
|
||||
coll (t2/insert-returning-instance! :model/Collection
|
||||
{:name (format "Collection for Workspace %s" workspace-name)
|
||||
:namespace "workspace"
|
||||
:workspace_id (:id ws)})
|
||||
ws (assoc ws :collection_id (:id coll))]
|
||||
;; Set the backlink from the workspace to the collection inside it and set the schema.
|
||||
(t2/update! :model/Workspace (:id ws) {:collection_id (:id coll)})
|
||||
;; TODO (Sanya 2025-11-18) - not sure how to transfer this api key to agent
|
||||
#_(log/infof "Generated API key for workspace: %s" (u.secret/expose (:unmasked_key api-key)))
|
||||
ws))
|
||||
|
||||
(defn- unique-constraint-violation?
|
||||
"Check if an exception is due to a unique constraint violation."
|
||||
[e]
|
||||
(let [msg (str (ex-message e) (some-> (ex-cause e) ex-message))]
|
||||
(or (str/includes? msg "unique")
|
||||
(str/includes? msg "duplicate")
|
||||
(str/includes? msg "UNIQUE"))))
|
||||
|
||||
(defn- run-workspace-setup!
|
||||
"Background job: runs isolation, grants. Updates db_status to :ready when done."
|
||||
[{ws-id :id :as workspace} database]
|
||||
(try
|
||||
(ws.log/track! ws-id :workspace-setup
|
||||
(let [isolation-details (ws.log/track! ws-id :database-isolation
|
||||
(ws.isolation/ensure-database-isolation! workspace database))]
|
||||
(t2/update! :model/Workspace ws-id (merge (select-keys isolation-details [:schema :database_details])
|
||||
{:db_status :ready}))))
|
||||
(catch Exception e
|
||||
(log/error e "Failed to setup workspace")
|
||||
(t2/update! :model/Workspace ws-id {:db_status :broken})
|
||||
(throw e))))
|
||||
|
||||
(defn initialize-workspace!
|
||||
"Initialize an uninitialized workspace with the given database_id.
|
||||
Updates database_id (if different from provisional), sets schema, creates isolation resources async,
|
||||
and transitions db_status to :pending. Returns the updated workspace with schema set."
|
||||
[workspace database-id]
|
||||
(let [database (t2/select-one :model/Database database-id)
|
||||
schema (ws.u/isolation-namespace-name workspace)
|
||||
res (t2/update! :model/Workspace {:id (:id workspace)
|
||||
:db_status :uninitialized}
|
||||
{:database_id database-id
|
||||
:schema schema
|
||||
:db_status :pending})]
|
||||
(when (zero? res)
|
||||
(let [new-db-id (t2/select-one-fn :database_id :model/Workspace (:id workspace))]
|
||||
(when (not= database-id new-db-id)
|
||||
(throw (ex-info "Workspace has been initialized already with a different database"
|
||||
{:requested-db-id database-id
|
||||
:actual-db-id new-db-id})))))
|
||||
(let [ws (t2/select-one :model/Workspace (:id workspace))]
|
||||
;; TODO allow this to be fully async as part of BOT-746
|
||||
(try
|
||||
@(quick-task/submit-task! #(run-workspace-setup! ws database))
|
||||
(catch Exception e
|
||||
(log/error e "Failed to initialize workspace")))
|
||||
;; Querying again to get the database_details
|
||||
(t2/select-one :model/Workspace (:id workspace)))))
|
||||
|
||||
(defn- create-uninitialized-workspace!
|
||||
"Create a workspace with a provisional database_id but no isolation resources.
|
||||
Retries on unique constraint violations for workspace name."
|
||||
[creator-id db-id ws-name max-retries]
|
||||
(loop [attempt 1]
|
||||
(let [unique-name (generate-unique-workspace-name ws-name)
|
||||
{:keys [retry
|
||||
workspace]} (try
|
||||
{:workspace (create-workspace-container! creator-id db-id unique-name)}
|
||||
(catch Exception e
|
||||
(if (and (< attempt max-retries) (unique-constraint-violation? e))
|
||||
{:retry true}
|
||||
(throw e))))]
|
||||
(if retry
|
||||
(recur (inc attempt))
|
||||
workspace))))
|
||||
|
||||
(defn create-workspace!
|
||||
"Create workspace."
|
||||
[creator-id {ws-name-maybe :name
|
||||
db-id :database_id}]
|
||||
(let [ws-name (or ws-name-maybe (ws.u/generate-name))]
|
||||
(create-uninitialized-workspace! creator-id db-id ws-name 5)))
|
||||
|
||||
(defn add-to-changeset!
|
||||
"Add the given transform to the workspace changeset.
|
||||
If workspace db_status is uninitialized, initializes it with the transform's target database.
|
||||
If workspace base_status is empty, transitions it to active."
|
||||
[creator-id workspace entity-type global-id body]
|
||||
(ws.u/assert-transform! entity-type)
|
||||
;; Initialize workspace if uninitialized (outside transaction so async task can see committed data)
|
||||
(let [workspace (if (= :uninitialized (:db_status workspace))
|
||||
(let [target-db-id (transforms.i/target-db-id body)]
|
||||
(api/check-400 target-db-id "Transform must have a target database")
|
||||
(initialize-workspace! workspace target-db-id))
|
||||
workspace)]
|
||||
(t2/with-transaction [_]
|
||||
(let [workspace-id (:id workspace)
|
||||
workspace-db-id (:database_id workspace)
|
||||
body (assoc-in body [:target :database] workspace-db-id)
|
||||
transform (t2/insert-returning-instance!
|
||||
:model/WorkspaceTransform
|
||||
(assoc (select-keys body [:name :description :source :target])
|
||||
:ref_id (ws.u/generate-ref-id)
|
||||
:creator_id creator-id
|
||||
:global_id global-id
|
||||
:workspace_id workspace-id))]
|
||||
;; Transition base_status from :empty to :active when first transform is added,
|
||||
;; and mark workspace as stale (new transform = graph needs recalculation)
|
||||
(t2/update! :model/Workspace workspace-id
|
||||
(cond-> {:analysis_stale true}
|
||||
(= :empty (:base_status workspace)) (assoc :base_status :active)))
|
||||
transform))))
|
||||
@@ -0,0 +1,2 @@
|
||||
;; For now, we do not anticipate any non-API usage of this module.
|
||||
(ns metabase-enterprise.workspaces.core)
|
||||
236
enterprise/backend/src/metabase_enterprise/workspaces/dag.clj
Normal file
@@ -0,0 +1,236 @@
|
||||
;; TODO consider renaming this to dataflow
|
||||
(ns metabase-enterprise.workspaces.dag
|
||||
"Used to do graph computations with respect to dataflow within a workspace.
|
||||
|
||||
This namespace should encapsulate all coupling to the [[metabase-enterprise.dependencies]] module, including through
|
||||
database artifacts.
|
||||
|
||||
It is purely concerned with calculation, any side effects wanted as a result of the analysis should happen outside."
|
||||
(:require
|
||||
[metabase-enterprise.workspaces.util :as ws.u]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]
|
||||
[toucan2.realize :as t2.realize]))
|
||||
|
||||
;; Is this working with appdb-ids or ref-ids?
|
||||
(defn unsupported-dependency?
|
||||
"Return a map of the entities which have unsupported dependencies, or nil if there are none."
|
||||
[{transform-ids :transforms :as _entity-map}]
|
||||
;; TODO filter out those that don't have a card dependency
|
||||
(when (seq transform-ids)
|
||||
{:transforms transform-ids}))
|
||||
|
||||
;; TODO (chris 2025/12/15) Should we leverage data-authority table metadata to speed things up?
|
||||
|
||||
(defn- table-id->coord [id]
|
||||
(t2/select-one [:model/Table :id :schema [:name :table] [:db_id :db]] id))
|
||||
|
||||
(defn- target->coord [{:keys [database schema name] :as _target}]
|
||||
{:db database
|
||||
:schema schema
|
||||
:table name})
|
||||
|
||||
(defn- global-parents
|
||||
"Dependencies analyzed by global hooks - may be incomplete. For example, excluding transforms that have not been run."
|
||||
[ws-id node-type id]
|
||||
;; Note: from_entity_type is transformed to a keyword by the Dependency model
|
||||
(t2/select-fn-vec (fn [{id :to_entity_id entity-type :to_entity_type}]
|
||||
(case entity-type
|
||||
:card {:node-type :external-card, :id id}
|
||||
:table {:node-type :table, :id (table-id->coord id)}
|
||||
:transform {:node-type :external-transform, :id id}))
|
||||
[:model/Dependency :to_entity_type :to_entity_id]
|
||||
:from_entity_type node-type
|
||||
:from_entity_id id
|
||||
;; Exclude transforms which are being overridden in the workspace.
|
||||
;; Note: use string "transform" in SQL WHERE clause (db stores strings)
|
||||
{:where [:not [:and
|
||||
[:= "transform" :to_entity_type]
|
||||
[:exists {:select [1]
|
||||
:from [[:workspace_transform :wt]]
|
||||
:where [:and
|
||||
[:= :wt.workspace_id ws-id]
|
||||
[:= :wt.global_id :to_entity_id]]}]]]}))
|
||||
|
||||
;; Workaround for change in how Dependencies table works (inlining of transform dependencies)
|
||||
(defn- global-tx-parents
|
||||
"Dependencies analyzed by global hooks - may be incomplete. For example, excluding transforms that have not been run."
|
||||
[id]
|
||||
;; Note: from_entity_type is transformed to a keyword by the Dependency model
|
||||
(mapv
|
||||
(fn [{:keys [node-type id] :as node}]
|
||||
(if (= :external-transform node-type)
|
||||
{:node-type :table, :id (target->coord (t2/select-one-fn :target [:model/Transform :target] id))}
|
||||
node))
|
||||
(t2/select-fn-vec (fn [{id :to_entity_id entity-type :to_entity_type}]
|
||||
(case entity-type
|
||||
:card {:node-type :external-card, :id id}
|
||||
:table {:node-type :table, :id (table-id->coord id)}
|
||||
:transform {:node-type :external-transform, :id id}))
|
||||
[:model/Dependency :to_entity_type :to_entity_id]
|
||||
:from_entity_type "transform"
|
||||
:from_entity_id id)))
|
||||
|
||||
(defn- ws-transform-parents [ws-id ref-id]
|
||||
;; We assume there are no card dependencies yet
|
||||
(t2/select-fn-vec (fn [table-coord]
|
||||
{:node-type :table, :id (t2.realize/realize table-coord)})
|
||||
[:model/WorkspaceInput [:db_id :db] :schema :table [:table_id :id]]
|
||||
:workspace_id ws-id
|
||||
:id [:in {:select [:to_entity_id]
|
||||
:from [:workspace_dependency]
|
||||
:where [:and
|
||||
[:= "transform" :from_entity_type]
|
||||
[:= ref-id :from_entity_id]
|
||||
[:= "input" :to_entity_type]]}]))
|
||||
|
||||
(defn- table-producers [ws-id id-or-coord]
|
||||
;; Work with either logical co-ords or an id
|
||||
(let [{:keys [db schema table id]} (if (map? id-or-coord) id-or-coord (table-id->coord id-or-coord))
|
||||
tx-ref-id (t2/select-one-fn :ref_id [:model/WorkspaceOutput :ref_id]
|
||||
{:where [:and
|
||||
[:= :workspace_id ws-id]
|
||||
[:or
|
||||
(when id
|
||||
[:= :global_table_id id])
|
||||
[:and
|
||||
[:= :db_id db]
|
||||
[:= :global_schema schema]
|
||||
[:= :global_table table]]]]})]
|
||||
(if tx-ref-id
|
||||
;; If there is a workspace transform that targets this table, ignore any global transform that also targets it.
|
||||
[{:node-type :workspace-transform :id tx-ref-id}]
|
||||
(global-parents ws-id "table" id))))
|
||||
|
||||
(defn- table? [{:keys [node-type]}] (= :table node-type))
|
||||
|
||||
(defn- node-parents [ws-id {:keys [node-type id]}]
|
||||
(case node-type
|
||||
:workspace-transform (ws-transform-parents ws-id id)
|
||||
:external-transform (global-tx-parents id)
|
||||
:external-card (global-parents ws-id "card" id)
|
||||
:table (table-producers ws-id id)))
|
||||
|
||||
(defn- node->allowed-parents
|
||||
[pred deps node]
|
||||
(if-not (pred node)
|
||||
[node]
|
||||
;; Collapse to the things that *produce* node. If it is a root node, leave it in.
|
||||
(or (seq (mapcat (partial node->allowed-parents pred deps) (deps node)))
|
||||
[node])))
|
||||
|
||||
(defn- collapse
|
||||
"Remove nodes that satisfy pred, in-lining their transitive dependencies instead."
|
||||
[pred deps]
|
||||
(u/for-map [[child parents] deps
|
||||
:when (not (pred child))]
|
||||
[child (mapcat (partial node->allowed-parents pred deps) parents)]))
|
||||
|
||||
(defn- render-graph [entities parents deps & {:keys [table? table-sort unwrap-table]
|
||||
:or {table? table?
|
||||
table-sort (juxt :db :schema :table)
|
||||
unwrap-table :id}}]
|
||||
(let [table-nodes (filter table? entities)
|
||||
;; Any table that has a parent in the subgraph is an output
|
||||
outputs (filter deps table-nodes)
|
||||
;; Anything other parent table is an input
|
||||
inputs (->> entities (mapcat parents) (filter table?) (remove (set outputs)) distinct)
|
||||
entities (->> (ws.u/toposort-dfs deps) (remove table?))
|
||||
deps+input (reduce
|
||||
(fn [deps [c parents]]
|
||||
(if-not (deps c)
|
||||
deps
|
||||
(update deps c into parents)))
|
||||
deps
|
||||
parents)]
|
||||
{:inputs (sort-by table-sort (map unwrap-table inputs))
|
||||
:outputs (sort-by table-sort (map unwrap-table outputs))
|
||||
:entities entities
|
||||
;; collapse tables out, directly connecting transforms? smaller graphs are easier for humans to read
|
||||
:dependencies (collapse table? deps+input)}))
|
||||
|
||||
(defn- path-induced-subgraph*
|
||||
"Implementation for [[path-induced-subgraph]] that takes the lookup functions as arguments. Useful for testing."
|
||||
[init-nodes fns]
|
||||
(loop [members (set init-nodes)
|
||||
cache {}
|
||||
;; Association list sets from nodes to their direct dependencies
|
||||
deps (u/for-map [node init-nodes] [node #{}])
|
||||
;; Paths are vectors sorted from child to parent
|
||||
[path & paths] (for [node members] [node])]
|
||||
(if-not path
|
||||
;; Finished walking all paths, render the result.
|
||||
(render-graph members cache deps fns)
|
||||
;; Look-up from cache first
|
||||
(let [_ (when (some #(not= 1 (val %)) (frequencies path))
|
||||
(throw (ex-info "Cycle detected" {:path path})))
|
||||
head (peek path)
|
||||
parents-fn (:node-parents fns)
|
||||
parents (cache head (parents-fn head))
|
||||
cache (assoc cache head parents)
|
||||
continue (when parents (remove members parents))]
|
||||
(if (not= parents continue)
|
||||
;; At least one parent is in the enclosed subgraph, so this entire path is as well.
|
||||
(let [add-dep (fn [deps [child parent]]
|
||||
(update deps child (fnil conj #{}) parent))
|
||||
;; Break up the path into adjacent child-parent pairs.
|
||||
pairs (concat (partition 2 1 path)
|
||||
(map (fn [end] [(peek path) end]) (filter members parents)))
|
||||
;; Since everything along this path will now be added to deps already, we can truncate the paths.
|
||||
next-paths (into paths (for [c continue] [(peek path) c]))]
|
||||
(recur (into members path) cache (reduce add-dep deps pairs) next-paths))
|
||||
;; We have not reached another member of the enclosed subgraph yet, so keep extending.
|
||||
(recur members cache deps (into paths (for [c continue] (conj path c)))))))))
|
||||
|
||||
;;;; Public API
|
||||
|
||||
(defn path-induced-subgraph
|
||||
"Given a map of entity types to IDs, compute the path-induced subgraph.
|
||||
`entities-by-type` is a map like {:transform [1 2 3]}.
|
||||
|
||||
Returns a map with:
|
||||
- :inputs - tables that the subgraph directly depends on, that are not themselves produced by the subgraph.
|
||||
ordered lexically by db+schema+table, to be stable across metabase instances, and visibly sorted.
|
||||
- :outputs - outputs of both the changeset transforms, and the external transforms enclosed by the changeset.
|
||||
both the external names, and the internal (isolated) names. sorted by the external names.
|
||||
point back to their relevant transform.
|
||||
|
||||
- :entities - the full list of entities, both in the changeset, and those that are enclosed. topo-sorted.
|
||||
- :dependencies - association list for the subgraph, with keys and values both in topological order"
|
||||
[ws-id changeset]
|
||||
(ws.u/assert-transforms! changeset)
|
||||
(if (empty? changeset)
|
||||
{:inputs []
|
||||
:outputs []
|
||||
:entities []
|
||||
:dependencies {}}
|
||||
(let [tx-nodes (for [{:keys [entity-type id]} changeset]
|
||||
(case entity-type
|
||||
:transform {:node-type :workspace-transform, :id id}))
|
||||
outputs (when (seq tx-nodes)
|
||||
(t2/select-fn-vec (fn [row]
|
||||
{:node-type :table, :id (t2.realize/realize row)})
|
||||
[:model/WorkspaceOutput
|
||||
[:db_id :db]
|
||||
[:global_schema :schema]
|
||||
[:global_table :table]
|
||||
[:global_table_id :id]]
|
||||
:ref_id [:in (map :id tx-nodes)]))
|
||||
init-nodes (concat tx-nodes outputs)
|
||||
fns {:node-parents (partial node-parents ws-id)
|
||||
:table? table?}]
|
||||
(path-induced-subgraph* init-nodes fns))))
|
||||
|
||||
;; source-table ---------> checked-out-transform
|
||||
;; \____external-transform__/
|
||||
;;
|
||||
;; should external-transform be enclosed? answer: no, because it won't become stale from changes in the workspace
|
||||
;;
|
||||
;; source-table ----> model ---> checked-out-transform
|
||||
;;
|
||||
;; this time we *need* the model to be serialized, because we need to read it in a fresh instance.
|
||||
;; it doesn't need to be remapped or "run" independently though, so does it make sense to call it enclosed?
|
||||
;;
|
||||
;; enclosed: between changeset only? or including the model in the example above?
|
||||
;; ancestors: as far back as you can go
|
||||
;;
|
||||
@@ -0,0 +1,399 @@
|
||||
(ns metabase-enterprise.workspaces.dependencies
|
||||
"Workspace-local dependency tracking.
|
||||
|
||||
Unlike the global `dependency` table which links to `table_id` (requiring the table to exist),
|
||||
workspace dependencies use three tables that support logical references:
|
||||
|
||||
- `workspace_input` - external tables transforms consume (may not exist yet)
|
||||
- `workspace_output` - tables transforms produce
|
||||
- `workspace_dependency` - edges: transform → input (external) or transform → output (internal)
|
||||
|
||||
## Example: Internal Dependency (Transform B depends on Transform A's output)
|
||||
|
||||
Transform A produces `analytics.orders_summary`:
|
||||
```
|
||||
{:ref_id \"happy-dolphin-a1b2\"
|
||||
:target {:database 1 :schema \"analytics\" :name \"orders_summary\"}}
|
||||
```
|
||||
|
||||
Transform B reads from `analytics.orders_summary`:
|
||||
```
|
||||
{:ref_id \"brave-lion-g7h8\"
|
||||
:source {:type \"query\" :query \"SELECT * FROM analytics.orders_summary\"}
|
||||
:target {:database 1 :schema \"analytics\" :name \"orders_report\"}}
|
||||
```
|
||||
|
||||
After processing both transforms:
|
||||
|
||||
workspace_output:
|
||||
| id | ref_id | schema | table |
|
||||
|-----|--------------------|-----------|----------------|
|
||||
| 101 | happy-dolphin-a1b2 | analytics | orders_summary |
|
||||
| 104 | brave-lion-g7h8 | analytics | orders_report |
|
||||
|
||||
workspace_dependency:
|
||||
| from_entity_type | from_entity_id | to_entity_type | to_entity_id | meaning |
|
||||
|------------------|--------------------| ---------------|--------------|-------------------------|
|
||||
| transform | brave-lion-g7h8 | output | 101 | B depends on A's output |
|
||||
|
||||
Transform B's dependency points to `output:101` (Transform A's output), NOT to a new `input`.
|
||||
This is because `analytics.orders_summary` matches an existing `workspace_output` record.
|
||||
|
||||
If Transform A also depended on an external table ORDERS, we'd also have:
|
||||
|
||||
workspace_input:
|
||||
| id | schema | table | table_id |
|
||||
|-----|--------|--------|----------|
|
||||
| 201 | PUBLIC | ORDERS | 42 |
|
||||
|
||||
workspace_dependency:
|
||||
| from_entity_type | from_entity_id | to_entity_type | to_entity_id | meaning |
|
||||
|------------------|--------------------| ---------------|--------------|------------------------------|
|
||||
| transform | happy-dolphin-a1b2 | input | 201 | A depends on external ORDERS |
|
||||
| transform | brave-lion-g7h8 | output | 101 | B depends on A's output |"
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.workspaces.models.workspace-dependency]
|
||||
[metabase-enterprise.workspaces.models.workspace-input]
|
||||
[metabase-enterprise.workspaces.models.workspace-output]
|
||||
[metabase-enterprise.workspaces.util :as ws.u]
|
||||
[metabase.app-db.core :as app-db]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.sql :as driver.sql]
|
||||
;; TODO (chris 2025/12/17) I solemnly declare that we will clean up this coupling nightmare for table normalization
|
||||
^{:clj-kondo/ignore [:metabase/modules]}
|
||||
[metabase.driver.sql.normalize :as sql.normalize]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.performance :as perf]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; ---------------------------------------- Schemas ----------------------------------------
|
||||
|
||||
(mr/def ::table-ref
|
||||
"A logical reference to a table by schema and name. Includes table_id if the table exists."
|
||||
[:map
|
||||
[:db_id :int]
|
||||
[:schema [:maybe :string]]
|
||||
[:table :string]
|
||||
[:table_id {:optional true} [:maybe :int]]])
|
||||
|
||||
(mr/def ::output-ref
|
||||
"A reference to an output table (transform target)."
|
||||
[:map
|
||||
[:db_id :int]
|
||||
[:schema [:maybe :string]]
|
||||
[:table :string]])
|
||||
|
||||
(mr/def ::analysis
|
||||
"The result of analyzing an entity's dependencies."
|
||||
[:map
|
||||
[:output ::output-ref]
|
||||
[:inputs [:sequential ::table-ref]]])
|
||||
|
||||
;;; ---------------------------------------- Batch table lookups ----------------------------------------
|
||||
|
||||
(defn- batch-table-refs-from-ids
|
||||
"Look up table metadata for multiple table IDs in a single query.
|
||||
Returns a map of table-id -> {:db_id :schema :table :table_id}."
|
||||
[table-ids]
|
||||
(when (seq table-ids)
|
||||
(t2/select-fn->fn :id
|
||||
(fn [{:keys [id db_id schema name]}]
|
||||
{:db_id db_id
|
||||
:schema schema
|
||||
:table name
|
||||
:table_id id})
|
||||
[:model/Table :id :db_id :schema :name]
|
||||
:id [:in table-ids])))
|
||||
|
||||
(defn- batch-lookup-table-ids
|
||||
"Look up table_ids for multiple logical table references in a single query.
|
||||
Uses the same matching logic as find-table-or-transform: if parsed schema is nil,
|
||||
it matches against the driver's default schema.
|
||||
Returns a map of [db_id search-schema table] -> table_id where search-schema
|
||||
is the schema used for lookup (defaulted if nil)."
|
||||
[driver db-id table-refs]
|
||||
(when (seq table-refs)
|
||||
(let [default-schema (driver.sql/default-schema driver)
|
||||
;; Include default-schema in filter so nil refs can match that too
|
||||
nil-schema? (some (comp nil? :schema) table-refs)
|
||||
schemas-to-query (into #{} (map #(or (:schema %) default-schema)) table-refs)
|
||||
db-table-lookup (t2/select-fn->fn (juxt :schema :name) :id
|
||||
[:model/Table :id :schema :name]
|
||||
:db_id db-id
|
||||
:name [:in (map :table table-refs)]
|
||||
{:where (if nil-schema?
|
||||
[:or [:= :schema nil] [:in :schema schemas-to-query]]
|
||||
[:in :schema schemas-to-query])})]
|
||||
(into {}
|
||||
(keep (fn [{:keys [schema table]}]
|
||||
(let [table-id (or (get db-table-lookup [schema table])
|
||||
(when (nil? schema)
|
||||
(get db-table-lookup [default-schema table])))]
|
||||
(when table-id
|
||||
[[db-id schema table] table-id]))))
|
||||
table-refs))))
|
||||
|
||||
(defn- output-from-target
|
||||
"Extract output table info from a transform's :target field."
|
||||
[{:keys [database schema name] :as _target}]
|
||||
{:db_id database
|
||||
:schema schema
|
||||
:table name})
|
||||
|
||||
;;; ---------------------------------------- Input extraction ----------------------------------------
|
||||
|
||||
(defn- inputs-from-mbql-query
|
||||
"Extract table refs from an MBQL query. Tables are looked up by ID in batch."
|
||||
[query]
|
||||
(let [table-ids (-> #{}
|
||||
(into (lib/all-source-table-ids query))
|
||||
(into (lib/all-implicitly-joined-table-ids query)))
|
||||
table-refs (batch-table-refs-from-ids table-ids)]
|
||||
(u/keepv table-refs table-ids)))
|
||||
|
||||
(defn- inputs-from-native-query
|
||||
"Extract table refs from a native query. Uses driver/native-query-table-refs to get
|
||||
schema+table directly, then batch looks up table_ids for tables that exist.
|
||||
Uses the same default schema logic as find-table-or-transform."
|
||||
[query db-id]
|
||||
(let [driver (:engine (lib.metadata/database query))
|
||||
parsed-refs (driver/native-query-table-refs driver query)
|
||||
;; Batch lookup existing table IDs (handles default schema logic)
|
||||
table-id-map (batch-lookup-table-ids driver db-id parsed-refs)]
|
||||
(perf/for [{:keys [schema table]} parsed-refs
|
||||
:let [table-id (get table-id-map [db-id schema table])]]
|
||||
(cond-> {:db_id db-id
|
||||
:schema schema
|
||||
:table table}
|
||||
table-id (assoc :table_id table-id)))))
|
||||
|
||||
(defn- inputs-from-query
|
||||
"Extract table refs from a query (MBQL or native)."
|
||||
[query db-id]
|
||||
(if (lib/native-only-query? query)
|
||||
(inputs-from-native-query query db-id)
|
||||
(inputs-from-mbql-query query)))
|
||||
|
||||
(defn- inputs-from-python-transform
|
||||
"Extract table refs from a python transform's source-tables.
|
||||
Python transforms require tables to exist (they map name -> table_id). Batch lookup."
|
||||
[source-tables]
|
||||
(let [table-ids (set (vals source-tables))
|
||||
table-refs (batch-table-refs-from-ids table-ids)]
|
||||
(u/keepv table-refs (vals source-tables))))
|
||||
|
||||
(mu/defn analyze-entity :- ::analysis
|
||||
"Analyze a workspace entity to find its dependencies.
|
||||
|
||||
Arguments:
|
||||
- entity-type: keyword, must be :transform (asserted)
|
||||
- entity: the workspace transform map with :source and :target
|
||||
|
||||
Returns:
|
||||
{:output {:db_id int :schema string :table string}
|
||||
:inputs [{:db_id int :schema string :table string} ...]}"
|
||||
[entity-type :- :keyword
|
||||
entity :- :map]
|
||||
(ws.u/assert-transform! entity-type)
|
||||
(let [{{:keys [query source-tables], source-type :type} :source
|
||||
target :target} entity
|
||||
db-id (:database target)
|
||||
inputs (cond
|
||||
(= (keyword source-type) :query)
|
||||
(inputs-from-query query db-id)
|
||||
|
||||
(= (keyword source-type) :python)
|
||||
(inputs-from-python-transform source-tables)
|
||||
|
||||
:else
|
||||
(do (log/warnf "Don't know how to analyze deps of transform with source type '%s'" source-type)
|
||||
[]))]
|
||||
{:output (output-from-target target)
|
||||
:inputs inputs}))
|
||||
|
||||
;;; ---------------------------------------- Write helpers ----------------------------------------
|
||||
|
||||
(defn- upsert-workspace-output!
|
||||
"Upsert a workspace_output record for the transform's target table.
|
||||
Stores both global (original) and isolated (workspace-specific) table identifiers.
|
||||
Returns the workspace_output id."
|
||||
[workspace-id ref-id isolated-schema {:keys [db_id schema table]} normalize-sql]
|
||||
(app-db/update-or-insert! :model/WorkspaceOutput
|
||||
{:workspace_id workspace-id
|
||||
:ref_id ref-id}
|
||||
(fn [existing]
|
||||
(let [isolated-table (ws.u/isolated-table-name schema table)
|
||||
qry-table-id (fn [schema table]
|
||||
(t2/select-one-fn :id [:model/Table :id]
|
||||
:db_id db_id
|
||||
:schema (normalize-sql schema)
|
||||
:name (normalize-sql table)))
|
||||
id-if-match (fn [schema-key schema table-key table id-key]
|
||||
(when (and (= schema (get existing schema-key))
|
||||
(= table (get existing table-key)))
|
||||
(get existing id-key)))
|
||||
id-or-fallback (fn [schema-key schema table-key table id-key]
|
||||
(or (id-if-match schema-key schema table-key table id-key)
|
||||
(qry-table-id schema table)))]
|
||||
{:db_id db_id
|
||||
:global_schema schema
|
||||
:global_table table
|
||||
:global_table_id (id-or-fallback
|
||||
:global_schema schema
|
||||
:global_table table
|
||||
:global_table_id)
|
||||
:isolated_schema isolated-schema
|
||||
:isolated_table isolated-table
|
||||
:isolated_table_id (id-or-fallback
|
||||
:isolated_schema isolated-schema
|
||||
:isolated_table isolated-table
|
||||
:isolated_table_id)}))))
|
||||
|
||||
(defn- build-output-lookup
|
||||
"Build a lookup map for workspace outputs: [db_id global_schema global_table] -> output_id.
|
||||
Single query to fetch all outputs for the workspace."
|
||||
[workspace-id]
|
||||
(t2/select-fn->fn (juxt :db_id :global_schema :global_table) :id
|
||||
:model/WorkspaceOutput
|
||||
:workspace_id workspace-id))
|
||||
|
||||
(defn- build-input-lookup
|
||||
"Build a lookup map for workspace inputs: [db_id schema table] -> {:id :table_id}.
|
||||
Single query to fetch all inputs for the workspace."
|
||||
[workspace-id]
|
||||
(t2/select-fn->fn (juxt :db_id :schema :table)
|
||||
#(select-keys % [:id :table_id])
|
||||
[:model/WorkspaceInput :id :db_id :schema :table :table_id]
|
||||
:workspace_id workspace-id))
|
||||
|
||||
(defn- normalize-input-schema
|
||||
"Normalize an input's schema: replace nil with the driver's default schema.
|
||||
This ensures consistent comparison with output schemas which are always explicit."
|
||||
[default-schema input]
|
||||
(update input :schema #(or % default-schema)))
|
||||
|
||||
(defn- ensure-workspace-inputs!
|
||||
"Ensure all external inputs exist in workspace_input table.
|
||||
Uses batch lookup to avoid N+1. Returns map of [db_id schema table] -> input_id.
|
||||
Inputs should already have normalized schemas (nil replaced with driver default)."
|
||||
[workspace-id inputs existing-input-lookup]
|
||||
(let [{existing true new-inputs false}
|
||||
(group-by (fn [{:keys [db_id schema table]}]
|
||||
(contains? existing-input-lookup [db_id schema table]))
|
||||
inputs)
|
||||
|
||||
;; TODO: This N+1 section could be optimized into one batch update using HoneySQL
|
||||
_ (doseq [{:keys [db_id schema table table_id]} existing]
|
||||
(let [existing-record (get existing-input-lookup [db_id schema table])]
|
||||
(when (and existing-record
|
||||
(not= (:table_id existing-record) table_id))
|
||||
(t2/update! :model/WorkspaceInput (:id existing-record) {:table_id table_id}))))
|
||||
|
||||
new-input-records (when (seq new-inputs)
|
||||
(t2/insert-returning-instances!
|
||||
:model/WorkspaceInput
|
||||
(for [{:keys [db_id schema table table_id]} new-inputs]
|
||||
{:workspace_id workspace-id
|
||||
:db_id db_id
|
||||
:schema schema
|
||||
:table table
|
||||
:table_id table_id})))
|
||||
|
||||
new-input-lookup (into {} (map (fn [{:keys [id db_id schema table]}]
|
||||
[[db_id schema table] id])
|
||||
new-input-records))]
|
||||
(merge (into {} (map (fn [[k v]] [k (:id v)]) existing-input-lookup))
|
||||
new-input-lookup)))
|
||||
|
||||
(defn- create-dependency-edges!
|
||||
"Batch create workspace_dependency edges."
|
||||
[workspace-id ref-id dep-specs]
|
||||
(when (seq dep-specs)
|
||||
(t2/insert! :model/WorkspaceDependency
|
||||
(for [{:keys [to_entity_type to_entity_id]} dep-specs]
|
||||
{:workspace_id workspace-id
|
||||
:from_entity_type :transform
|
||||
:from_entity_id ref-id
|
||||
:to_entity_type to_entity_type
|
||||
:to_entity_id to_entity_id}))))
|
||||
|
||||
(defn- delete-stale-edges!
|
||||
"Delete dependency edges that are no longer present.
|
||||
Uses single delete with OR conditions for batch deletion."
|
||||
[workspace-id ref-id stale-edges]
|
||||
(when (seq stale-edges)
|
||||
(doseq [{:keys [to_entity_type to_entity_id]} stale-edges]
|
||||
;; TODO: Could optimize further with raw SQL DELETE ... WHERE (type, id) IN (...)
|
||||
(t2/delete! :model/WorkspaceDependency
|
||||
:workspace_id workspace-id
|
||||
:from_entity_type :transform
|
||||
:from_entity_id ref-id
|
||||
:to_entity_type to_entity_type
|
||||
:to_entity_id to_entity_id))))
|
||||
|
||||
(defn- current-edge-specs
|
||||
"Get the current dependency edge specs for a transform."
|
||||
[workspace-id ref-id]
|
||||
(into #{}
|
||||
(map #(select-keys % [:to_entity_type :to_entity_id]))
|
||||
(t2/select [:model/WorkspaceDependency :to_entity_type :to_entity_id]
|
||||
:workspace_id workspace-id
|
||||
:from_entity_type :transform
|
||||
:from_entity_id ref-id)))
|
||||
|
||||
(mu/defn write-dependencies! :- :nil
|
||||
"Persist dependency analysis for a workspace entity.
|
||||
|
||||
Arguments:
|
||||
- workspace-id: int
|
||||
- isolated-schema: string, the workspace's isolated schema name
|
||||
- entity-type: keyword, must be :transform
|
||||
- ref-id: string, the workspace_transform.ref_id
|
||||
- analysis: result from analyze-entity
|
||||
|
||||
Side effects:
|
||||
- Upserts workspace_output for this transform (with both global and isolated identifiers)
|
||||
- Upserts workspace_input for each external table dependency
|
||||
- Creates workspace_dependency edges
|
||||
- Deletes stale edges"
|
||||
[workspace-id :- :int
|
||||
isolated-schema :- :string
|
||||
entity-type :- :keyword
|
||||
ref-id :- :string
|
||||
{:keys [output inputs]} :- ::analysis]
|
||||
(ws.u/assert-transform! entity-type)
|
||||
(t2/with-transaction [_conn]
|
||||
(let [output-lookup (build-output-lookup workspace-id)
|
||||
existing-input-lookup (build-input-lookup workspace-id)
|
||||
current-edges (current-edge-specs workspace-id ref-id)
|
||||
driver (t2/select-one-fn :engine [:model/Database :engine]
|
||||
:id [:in {:select [:database_id]
|
||||
:from [:workspace]
|
||||
:where [:= :id workspace-id]}])
|
||||
normalize (partial sql.normalize/normalize-name driver)]
|
||||
(upsert-workspace-output! workspace-id ref-id isolated-schema output normalize)
|
||||
(let [{internal-inputs true external-inputs false} (group-by (fn [{:keys [db_id schema table]}]
|
||||
(contains? output-lookup [db_id schema table]))
|
||||
inputs)
|
||||
default-schema (driver.sql/default-schema driver)
|
||||
;; Normalize external inputs so lookups use consistent schema keys
|
||||
normalized-external-inputs (map (partial normalize-input-schema default-schema) external-inputs)
|
||||
input-id-lookup (ensure-workspace-inputs! workspace-id normalized-external-inputs existing-input-lookup)
|
||||
new-dep-specs (concat
|
||||
(for [{:keys [db_id schema table]} internal-inputs]
|
||||
{:to_entity_type :output
|
||||
:to_entity_id (get output-lookup [db_id schema table])})
|
||||
(for [{:keys [db_id schema table]} normalized-external-inputs]
|
||||
{:to_entity_type :input
|
||||
:to_entity_id (get input-id-lookup [db_id schema table])}))
|
||||
new-dep-specs-set (set new-dep-specs)
|
||||
edges-to-create (set/difference new-dep-specs-set current-edges)
|
||||
edges-to-delete (set/difference current-edges new-dep-specs-set)]
|
||||
(create-dependency-edges! workspace-id ref-id edges-to-create)
|
||||
(delete-stale-edges! workspace-id ref-id edges-to-delete)))))
|
||||
@@ -0,0 +1,32 @@
|
||||
(ns metabase-enterprise.workspaces.events
|
||||
"Event handlers to mark workspaces as stale when global transforms change."
|
||||
(:require
|
||||
[metabase.events.core :as events]
|
||||
[metabase.util.log :as log]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;; When a global transform is created, updated, or deleted, mark all workspaces as stale.
|
||||
;; This is a conservative approach - we could be more selective by tracking which workspaces
|
||||
;; reference which transforms, but the complexity of doing so would be brittle.
|
||||
|
||||
(defn- mark-all-workspaces-stale!
|
||||
"Mark all workspaces as needing graph recalculation."
|
||||
[]
|
||||
(let [count (t2/update! :model/Workspace {} {:analysis_stale true})]
|
||||
(when (pos? count)
|
||||
(log/debugf "Marked %d workspaces as analysis stale due to global transform change" count))))
|
||||
|
||||
;; These events are published by metabase-enterprise.transforms.api when transforms are
|
||||
;; created, updated, or deleted. We subscribe to mark workspaces stale.
|
||||
|
||||
(derive ::workspace-staleness :metabase/event)
|
||||
(derive :event/transform-create ::workspace-staleness)
|
||||
(derive :event/transform-update ::workspace-staleness)
|
||||
(derive :event/transform-delete ::workspace-staleness)
|
||||
|
||||
(methodical/defmethod events/publish-event! ::workspace-staleness
|
||||
[_ _event]
|
||||
;; Mark all workspaces as stale when any global transform changes.
|
||||
;; The graph will be recalculated lazily the next time we need it.
|
||||
(mark-all-workspaces-stale!))
|
||||
@@ -0,0 +1,120 @@
|
||||
(ns metabase-enterprise.workspaces.execute
|
||||
"This namespace is concerned with executing non-AppDB transforms, with optional reference re-mappings.
|
||||
|
||||
It currently lives inside the workspace module, but eventually will become part of the transform module, so it should
|
||||
not make any assumptions about being run within a workspace, having the input / output semantics of a workspace, and
|
||||
especially it should not touch any of the Workspace entities inside AppDb, or any of the other workspace namespaces.
|
||||
|
||||
For now, it uses AppDB as a side-channel with the transforms module, but in future this module should be COMPLETELY
|
||||
decoupled from AppDb."
|
||||
(:require
|
||||
[macaw.core :as macaw]
|
||||
[metabase-enterprise.transforms.core :as transforms]
|
||||
[metabase-enterprise.transforms.execute :as transforms.execute]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- execution-results
|
||||
"Extract execution metadata from transform_run and target table info.
|
||||
Must be called within the transaction before rollback."
|
||||
[{xf-id :id :keys [target]}]
|
||||
(let [run (t2/select-one :model/TransformRun :transform_id xf-id)]
|
||||
(merge
|
||||
(select-keys run [:status :start_time :end_time :message])
|
||||
{:table {:name (:name target)
|
||||
:schema (:schema target)}})))
|
||||
|
||||
(defn- remap-python-source
|
||||
"Remap source-tables in a Python transform source to point to isolated tables.
|
||||
Handles both legacy integer format and new map format (from PR #66934)."
|
||||
[table-mapping source]
|
||||
(letfn [(remap [{:keys [database_id schema table table_id] :as table-ref}]
|
||||
(if-let [{:keys [id db-id schema table]} (or (table-mapping table-ref)
|
||||
(some-> table_id table-mapping)
|
||||
(when table
|
||||
(table-mapping [database_id schema table])))]
|
||||
;; Prefer table ID when available; map format won't work until PR #66934 is merged:
|
||||
;; https://github.com/metabase/metabase/pull/66934
|
||||
(or id {:database_id db-id, :schema schema, :table table})
|
||||
;; Leave it un-mapped if we don't have an override.
|
||||
table-ref))]
|
||||
(update source :source-tables update-vals remap)))
|
||||
|
||||
(defn- remap-sql-source [table-mapping source]
|
||||
(let [remapping (reduce
|
||||
(fn [remapping [[_ source-schema source-table] {target-schema :schema, target-table :table}]]
|
||||
(assoc-in remapping [:tables {:schema source-schema
|
||||
:table source-table}]
|
||||
{:schema target-schema
|
||||
:table target-table}))
|
||||
{:schemas {}
|
||||
:tables {}}
|
||||
;; Strip out the numeric keys (table ids)
|
||||
(filter (comp vector? key) table-mapping))]
|
||||
;; We may need to set other options, like the case insensitivity (driver dependent)
|
||||
(update-in source [:query :stages 0 :native] #(macaw/replace-names % remapping {:allow-unused? true}))))
|
||||
|
||||
(defn- remap-mbql-source [_table-mapping _field-map source]
|
||||
(throw (ex-info "Remapping MBQL queries is not supported yet" {:source source})))
|
||||
|
||||
(defn- remap-source [table-map field-map source-type source]
|
||||
(case source-type
|
||||
:mbql (remap-mbql-source table-map field-map source)
|
||||
;; TODO make sure it's actually a SQL dialect though..
|
||||
:native (remap-sql-source table-map source)
|
||||
:python (remap-python-source table-map source)))
|
||||
|
||||
;; You might prefer a multi-method? I certainly would.
|
||||
|
||||
(defn- remap-target [table-map target-fallback {d :database, s :schema, t :name :as target}]
|
||||
(if-let [replacement (or (table-map [d s t]) (target-fallback [d s t]))]
|
||||
;; Always fallback to tables for re-mapped outputs, regardless of the type used in the original target.
|
||||
{:type (:type replacement "table")
|
||||
:database (:db-id replacement)
|
||||
:schema (:schema replacement)
|
||||
:name (:table replacement)}
|
||||
target))
|
||||
|
||||
(def ^:private no-mapping {})
|
||||
|
||||
(defn run-transform-with-remapping
|
||||
"Execute a given collection with the given table and field re-mappings.
|
||||
|
||||
This is used by Workspaces to re-route the output of each transform to a non-production table, and
|
||||
to re-write their queries where these outputs transitively become inputs to other transforms.
|
||||
|
||||
Returns an ::ws.t/execution-result map with status, timing, and table metadata.
|
||||
|
||||
-------
|
||||
|
||||
NOTE: currently execution is done using transaction-rollback pattern, as a short-term hack.
|
||||
|
||||
1. Creating temporary Transform/TransformRun records in a transaction
|
||||
2. Executing using existing transform infrastructure
|
||||
3. Scraping metadata (execution stats + table schema)
|
||||
4. Rolling back the transaction (no app DB records persist)
|
||||
|
||||
The warehouse DB changes (actual table data) DO persist in the isolated schema."
|
||||
[{:keys [source target] :as transform} remapping]
|
||||
(t2/with-transaction [_conn]
|
||||
(let [s-type (transforms/transform-source-type source)
|
||||
table-mapping (:tables remapping no-mapping)
|
||||
target-fallback (:target-fallback remapping no-mapping)
|
||||
field-mapping (:fields remapping no-mapping)
|
||||
new-xf (-> (select-keys transform [:name :description])
|
||||
(assoc :creator_id api/*current-user-id*
|
||||
:source (remap-source table-mapping field-mapping s-type source)
|
||||
:target (remap-target table-mapping target-fallback target)))
|
||||
_ (assert (:target new-xf) "Target mapping must not be nil")
|
||||
temp-xf (t2/insert-returning-instance! :model/Transform new-xf)]
|
||||
(try
|
||||
(transforms.execute/execute! temp-xf {:run-method :manual})
|
||||
(catch Exception _e
|
||||
;; Execution failed - the TransformRun record has been updated with failure status and message
|
||||
nil))
|
||||
;; Return execution results whether succeeded or failed
|
||||
(u/prog1 (execution-results temp-xf)
|
||||
(t2/delete! :model/Transform (:id temp-xf))))))
|
||||
418
enterprise/backend/src/metabase_enterprise/workspaces/impl.clj
Normal file
@@ -0,0 +1,418 @@
|
||||
(ns metabase-enterprise.workspaces.impl
|
||||
"Glue code connecting workspace subsystems (dependencies, isolation)."
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.workspaces.dag :as ws.dag]
|
||||
[metabase-enterprise.workspaces.dependencies :as ws.deps]
|
||||
[metabase-enterprise.workspaces.execute :as ws.execute]
|
||||
[metabase-enterprise.workspaces.isolation :as ws.isolation]
|
||||
[metabase-enterprise.workspaces.util :as ws.u]
|
||||
[metabase.app-db.core :as app-db]
|
||||
[metabase.driver.sql :as driver.sql]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- query-ungranted-external-inputs
|
||||
"Query for external inputs in a workspace that haven't been granted access yet.
|
||||
External inputs are tables that are not shadowed by any output.
|
||||
Returns seq of WorkspaceInput records where access_granted is false."
|
||||
[workspace-id]
|
||||
;; NOTE: Could optimize with table_id join if workspace_output gets that column,
|
||||
;; but not worth it given the small number of rows per workspace.
|
||||
(t2/select :model/WorkspaceInput
|
||||
:workspace_id workspace-id
|
||||
{:where [:and
|
||||
[:= :workspace_input.access_granted false]
|
||||
[:not [:exists {:select [1]
|
||||
:from [[:workspace_output :wo]]
|
||||
:where [:and
|
||||
[:= :wo.workspace_id :workspace_input.workspace_id]
|
||||
[:= :wo.db_id :workspace_input.db_id]
|
||||
[:or
|
||||
[:and [:= :wo.global_schema nil] [:= :workspace_input.schema nil]]
|
||||
[:= :wo.global_schema :workspace_input.schema]]
|
||||
[:= :wo.global_table :workspace_input.table]]}]]]}))
|
||||
|
||||
(defn- external-input->table
|
||||
[{:keys [schema table]}]
|
||||
{:schema schema
|
||||
:name table})
|
||||
|
||||
(defn sync-grant-accesses!
|
||||
"Grant read access to external input tables for a workspace that haven't been granted yet.
|
||||
External inputs are tables that are read by transforms but not produced by any transform in the workspace.
|
||||
This should be called after adding transforms to a workspace or when re-initializing workspace isolation
|
||||
(e.g., after unarchiving)."
|
||||
[{workspace-id :id :as workspace}]
|
||||
(let [ungranted-inputs (query-ungranted-external-inputs workspace-id)]
|
||||
(if-not (:database_details workspace)
|
||||
;; TODO (chris 2025/12/15) we will want to make this strict before merging to master
|
||||
#_(throw (ex-info "No database details, unable to grant read only access to the service account." {}))
|
||||
(log/warn "No database details, unable to grant read only access to the service account.")
|
||||
(when (seq ungranted-inputs)
|
||||
(let [database (t2/select-one :model/Database :id (:database_id workspace))
|
||||
tables (mapv external-input->table ungranted-inputs)]
|
||||
(try
|
||||
(ws.isolation/grant-read-access-to-tables! database workspace tables)
|
||||
;; Mark inputs as granted after successful grant
|
||||
(t2/update! :model/WorkspaceInput {:id [:in (map :id ungranted-inputs)]}
|
||||
{:access_granted true})
|
||||
(catch Exception e
|
||||
(log/warn e "Error granting RO table permissions"))))))))
|
||||
|
||||
(defn- build-remapping [workspace]
|
||||
;; Build table remapping from stored WorkspaceOutput and WorkspaceOutputExternal data.
|
||||
;; Maps [db_id global_schema global_table] -> {:db-id :schema :table :id} for isolated tables.
|
||||
;; Also maps global_table_id -> same. This is more convenient and reliable for MBQL queries and Python transforms.
|
||||
;; Also maps [db_id nil global_table] for tables in the default schema, so unqualified SQL references work.
|
||||
;; This is used to remap queries, sources and targets to reflect the "isolated" tables used to seal the Workspace.
|
||||
(let [outputs (t2/select [:model/WorkspaceOutput
|
||||
:db_id :global_schema :global_table :global_table_id
|
||||
:isolated_schema :isolated_table :isolated_table_id]
|
||||
:workspace_id (:id workspace))
|
||||
external-outputs (t2/select [:model/WorkspaceOutputExternal
|
||||
:db_id :global_schema :global_table :global_table_id
|
||||
:isolated_schema :isolated_table :isolated_table_id]
|
||||
:workspace_id (:id workspace))
|
||||
all-outputs (concat outputs external-outputs)
|
||||
;; Get default schema for each database involved
|
||||
db-ids (into #{} (map :db_id) all-outputs)
|
||||
db-id->default (when (seq db-ids)
|
||||
(t2/select-fn->fn :id #(driver.sql/default-schema (:engine %))
|
||||
[:model/Database :id :engine]
|
||||
:id [:in db-ids]))
|
||||
table-map (reduce
|
||||
(fn [m {:keys [db_id global_schema global_table global_table_id
|
||||
isolated_schema isolated_table isolated_table_id]}]
|
||||
(let [replacement {:db-id db_id
|
||||
:schema isolated_schema
|
||||
:table isolated_table
|
||||
:id isolated_table_id}
|
||||
default-schema (get db-id->default db_id)]
|
||||
(cond-> (assoc m [db_id global_schema global_table] replacement)
|
||||
global_table_id (assoc global_table_id replacement)
|
||||
;; Add nil-schema entry for tables in the default schema
|
||||
(= global_schema default-schema) (assoc [db_id nil global_table] replacement))))
|
||||
{}
|
||||
all-outputs)]
|
||||
{:tables table-map
|
||||
;; We never want to write to any global tables, so remap on-the-fly if we hit an un-mapped target.
|
||||
:target-fallback (fn [[d s t]]
|
||||
(log/warn "Missing remapping for" {:db d :schema s, :table s})
|
||||
{:db-id d, :schema (:schema workspace), :table (ws.u/isolated-table-name s t), :id nil})
|
||||
;; We won't need the field-map until we support MBQL.
|
||||
:fields nil}))
|
||||
|
||||
(defn- backfill-isolated-table-id!
|
||||
"Backfill workspace_output.isolated_table_id FK in the case where we just created the table for the first time."
|
||||
[ref-id]
|
||||
(when-let [table-id (:id (t2/query-one
|
||||
{:from [[:workspace_output :wo]]
|
||||
:join [[:metabase_table :t] [:and
|
||||
[:= :t.db_id :wo.db_id]
|
||||
[:= :t.schema :wo.isolated_schema]
|
||||
[:= :t.name :wo.isolated_table]]]
|
||||
:select [:t.id]
|
||||
:where [:and
|
||||
[:= :wo.ref_id ref-id]
|
||||
[:or [:= :wo.isolated_table_id nil] [:not= :t.id :wo.isolated_table_id]]]}))]
|
||||
(t2/update! :model/WorkspaceOutput {:ref_id ref-id} {:isolated_table_id table-id})))
|
||||
|
||||
(defn- backfill-external-isolated-table-id!
|
||||
"Backfill workspace_output_external.isolated_table_id FK after running an enclosed external transform."
|
||||
[transform-id]
|
||||
(when-let [table-id (:id (t2/query-one
|
||||
{:from [[:workspace_output_external :woe]]
|
||||
:join [[:metabase_table :t] [:and
|
||||
[:= :t.db_id :woe.db_id]
|
||||
[:= :t.schema :woe.isolated_schema]
|
||||
[:= :t.name :woe.isolated_table]]]
|
||||
:select [:t.id]
|
||||
:where [:and
|
||||
[:= :woe.transform_id transform-id]
|
||||
[:or [:= :woe.isolated_table_id nil] [:not= :t.id :woe.isolated_table_id]]]}))]
|
||||
(t2/update! :model/WorkspaceOutputExternal {:transform_id transform-id} {:isolated_table_id table-id})))
|
||||
|
||||
(defn run-transform!
|
||||
"Execute the given workspace transform or enclosed external transform."
|
||||
([workspace transform]
|
||||
(run-transform! workspace transform (build-remapping workspace)))
|
||||
([workspace transform remapping]
|
||||
(let [ref-id (:ref_id transform)
|
||||
external-id (:id transform)
|
||||
result (ws.isolation/with-workspace-isolation workspace (ws.execute/run-transform-with-remapping transform remapping))]
|
||||
(when ref-id
|
||||
(t2/update! :model/WorkspaceTransform ref-id {:last_run_at (:end_time result)
|
||||
:last_run_message (:message result)}))
|
||||
(when (= :succeeded (:status result))
|
||||
(if ref-id
|
||||
;; Workspace transform
|
||||
(backfill-isolated-table-id! ref-id)
|
||||
;; External transform (enclosed in workspace)
|
||||
(backfill-external-isolated-table-id! external-id)))
|
||||
result)))
|
||||
|
||||
;;;; ---------------------------------------- External Transform Sync ----------------------------------------
|
||||
|
||||
(defn- extract-external-transform-ids
|
||||
"Extract IDs of external transforms from graph entities."
|
||||
[entities]
|
||||
(into [] (comp (filter #(= :external-transform (:node-type %)))
|
||||
(map :id))
|
||||
entities))
|
||||
|
||||
(defn- sync-external-outputs!
|
||||
"Sync workspace_output_external table based on enclosed external transforms in the graph.
|
||||
Deletes obsolete rows, upserts current ones, and backfills table IDs where possible."
|
||||
[workspace-id isolated-schema entities]
|
||||
(let [external-tx-ids (extract-external-transform-ids entities)
|
||||
existing-rows (when (seq external-tx-ids)
|
||||
(t2/select-fn->fn :transform_id identity
|
||||
:model/WorkspaceOutputExternal
|
||||
:workspace_id workspace-id))
|
||||
existing-tx-ids (set (keys existing-rows))]
|
||||
;; Delete rows for transforms no longer in the graph
|
||||
(let [obsolete-ids (set/difference existing-tx-ids (set external-tx-ids))]
|
||||
(when (seq obsolete-ids)
|
||||
(t2/delete! :model/WorkspaceOutputExternal
|
||||
:workspace_id workspace-id
|
||||
:transform_id [:in obsolete-ids])))
|
||||
;; Upsert rows for current external transforms
|
||||
(when (seq external-tx-ids)
|
||||
(let [transforms (t2/select [:model/Transform :id :target] :id [:in external-tx-ids])]
|
||||
(doseq [{tx-id :id, {:keys [database schema name]} :target} transforms]
|
||||
(let [isolated-table (ws.u/isolated-table-name schema name)
|
||||
existing (get existing-rows tx-id)
|
||||
;; Try to find table IDs - reuse existing if schema/table match
|
||||
global-table-id (or (when (and existing
|
||||
(= schema (:global_schema existing))
|
||||
(= name (:global_table existing)))
|
||||
(:global_table_id existing))
|
||||
(t2/select-one-fn :id [:model/Table :id]
|
||||
:db_id database :schema schema :name name))
|
||||
isolated-table-id (or (when (and existing
|
||||
(= isolated-schema (:isolated_schema existing))
|
||||
(= isolated-table (:isolated_table existing)))
|
||||
(:isolated_table_id existing))
|
||||
(t2/select-one-fn :id [:model/Table :id]
|
||||
:db_id database :schema isolated-schema :name isolated-table))]
|
||||
(if existing
|
||||
(t2/update! :model/WorkspaceOutputExternal (:id existing)
|
||||
{:db_id database
|
||||
:global_schema schema
|
||||
:global_table name
|
||||
:global_table_id global-table-id
|
||||
:isolated_schema isolated-schema
|
||||
:isolated_table isolated-table
|
||||
:isolated_table_id isolated-table-id})
|
||||
(t2/insert! :model/WorkspaceOutputExternal
|
||||
{:workspace_id workspace-id
|
||||
:transform_id tx-id
|
||||
:db_id database
|
||||
:global_schema schema
|
||||
:global_table name
|
||||
:global_table_id global-table-id
|
||||
:isolated_schema isolated-schema
|
||||
:isolated_table isolated-table
|
||||
:isolated_table_id isolated-table-id}))))))))
|
||||
|
||||
(defn- normalize-table-schema
|
||||
"Normalize a table's schema: replace nil with the driver's default schema.
|
||||
Takes a map of db_id -> default-schema for lookup."
|
||||
[db-id->default-schema {:keys [db_id schema] :as table}]
|
||||
(if (some? schema)
|
||||
table
|
||||
(assoc table :schema (get db-id->default-schema db_id))))
|
||||
|
||||
(defn- sync-external-inputs!
|
||||
"Sync workspace_input_external table based on enclosed external transforms in the graph.
|
||||
Tracks external tables consumed by enclosed transforms that aren't outputs of other transforms.
|
||||
Inputs are deduplicated per workspace. Normalizes nil schemas to driver defaults."
|
||||
[workspace-id entities]
|
||||
(let [external-tx-ids (extract-external-transform-ids entities)]
|
||||
(when (seq external-tx-ids)
|
||||
;; Query global Dependency table for all inputs of external transforms
|
||||
(let [input-tables (t2/select [:model/Dependency :to_entity_id]
|
||||
:from_entity_type :transform
|
||||
:from_entity_id [:in external-tx-ids]
|
||||
:to_entity_type :table)
|
||||
input-table-ids (into #{} (map :to_entity_id) input-tables)
|
||||
;; Get workspace output table IDs (both workspace and external outputs)
|
||||
workspace-output-ids (t2/select-fn-set :global_table_id
|
||||
[:model/WorkspaceOutput :global_table_id]
|
||||
:workspace_id workspace-id
|
||||
{:where [:not= :global_table_id nil]})
|
||||
external-output-ids (t2/select-fn-set :global_table_id
|
||||
[:model/WorkspaceOutputExternal :global_table_id]
|
||||
:workspace_id workspace-id
|
||||
{:where [:not= :global_table_id nil]})
|
||||
all-output-ids (set/union workspace-output-ids external-output-ids)
|
||||
;; Filter to only true external inputs (not outputs of transforms in graph)
|
||||
external-input-ids (set/difference input-table-ids all-output-ids)]
|
||||
(when (seq external-input-ids)
|
||||
;; Get table details for external inputs
|
||||
(let [tables (t2/select [:model/Table :id :db_id :schema :name] :id [:in external-input-ids])
|
||||
;; Build default schema lookup for each database involved
|
||||
db-ids (into #{} (map :db_id) tables)
|
||||
db-id->default-schema (when (seq db-ids)
|
||||
(into {}
|
||||
(map (fn [{:keys [id engine]}]
|
||||
[id (driver.sql/default-schema engine)]))
|
||||
(t2/select [:model/Database :id :engine] :id [:in db-ids])))
|
||||
;; Normalize schemas: nil -> default schema for the table's database
|
||||
normalized-tables (map (partial normalize-table-schema db-id->default-schema) tables)
|
||||
new-inputs (into {}
|
||||
(map (fn [{:keys [id db_id schema name]}]
|
||||
[[db_id schema name] {:table_id id}]))
|
||||
normalized-tables)
|
||||
;; Get existing external inputs
|
||||
existing-inputs (t2/select-fn->fn (juxt :db_id :schema :table)
|
||||
#(select-keys % [:id :table_id])
|
||||
[:model/WorkspaceInputExternal :id :db_id :schema :table :table_id]
|
||||
:workspace_id workspace-id)
|
||||
existing-keys (set (keys existing-inputs))
|
||||
new-keys (set (keys new-inputs))
|
||||
obsolete-keys (set/difference existing-keys new-keys)
|
||||
missing-keys (set/difference new-keys existing-keys)]
|
||||
;; Delete obsolete rows
|
||||
(doseq [[db_id schema table] obsolete-keys]
|
||||
(t2/delete! :model/WorkspaceInputExternal
|
||||
:workspace_id workspace-id
|
||||
:db_id db_id
|
||||
:schema schema
|
||||
:table table))
|
||||
;; Insert missing rows
|
||||
(when (seq missing-keys)
|
||||
(t2/insert! :model/WorkspaceInputExternal
|
||||
(for [[db_id schema table] missing-keys]
|
||||
{:workspace_id workspace-id
|
||||
:db_id db_id
|
||||
:schema schema
|
||||
:table table
|
||||
:table_id (:table_id (get new-inputs [db_id schema table]))
|
||||
:access_granted false})))))))))
|
||||
|
||||
;;;; ---------------------------------------- Lazy Graph Calculation ----------------------------------------
|
||||
|
||||
(defn- calculate-graph!
|
||||
"Calculate the dependency graph for a workspace.
|
||||
Returns the graph without caching - caller is responsible for caching."
|
||||
[ws-id]
|
||||
(ws.dag/path-induced-subgraph ws-id (t2/select-fn-vec (fn [{:keys [ref_id]}] {:entity-type :transform :id ref_id})
|
||||
[:model/WorkspaceTransform :ref_id]
|
||||
:workspace_id ws-id)))
|
||||
|
||||
(defn analyze-transform-if-stale!
|
||||
"Re-analyze dependencies for a given transform, if necessary. Marks it as not stale after analysis."
|
||||
[{ws-id :id, isolated-schema :schema :as workspace} transform]
|
||||
(when (:analysis_stale transform)
|
||||
(let [analysis (ws.deps/analyze-entity :transform transform)]
|
||||
(ws.deps/write-dependencies! ws-id isolated-schema :transform (:ref_id transform) analysis))
|
||||
(t2/update! :model/WorkspaceTransform (:ref_id transform) {:analysis_stale false})
|
||||
(sync-grant-accesses! workspace)))
|
||||
|
||||
(defn analyze-stale-transforms!
|
||||
"Re-analyze dependencies for any workspace transforms that are stale.
|
||||
Marks them as not stale after analysis. Returns true if any transforms were analyzed."
|
||||
[{ws-id :id, isolated-schema :schema :as workspace}]
|
||||
(let [stale-transforms (t2/select :model/WorkspaceTransform
|
||||
:workspace_id ws-id
|
||||
:analysis_stale true)]
|
||||
(doseq [transform stale-transforms]
|
||||
(let [analysis (ws.deps/analyze-entity :transform transform)]
|
||||
(ws.deps/write-dependencies! ws-id isolated-schema :transform (:ref_id transform) analysis))
|
||||
(t2/update! :model/WorkspaceTransform (:ref_id transform) {:analysis_stale false}))
|
||||
;; Grant read access to any new external inputs
|
||||
(when (seq stale-transforms)
|
||||
(sync-grant-accesses! workspace))
|
||||
(boolean (seq stale-transforms))))
|
||||
|
||||
(defn- upsert-workspace-graph!
|
||||
"Insert or update the cached graph for a workspace."
|
||||
[ws-id graph]
|
||||
(app-db/update-or-insert! :model/WorkspaceGraph {:workspace_id ws-id} (fn [_] {:workspace_id ws-id, :graph graph})))
|
||||
|
||||
(defn- calculate-and-cache-graph!
|
||||
"Calculate the graph, cache it, sync external inputs/outputs, and mark workspace as not stale."
|
||||
[{ws-id :id, isolated-schema :schema :as workspace}]
|
||||
(t2/with-transaction [_conn]
|
||||
;; First, re-analyze any stale transforms
|
||||
(analyze-stale-transforms! workspace)
|
||||
(let [graph (calculate-graph! ws-id)]
|
||||
;; Persist our work
|
||||
(sync-external-outputs! ws-id isolated-schema (:entities graph))
|
||||
(sync-external-inputs! ws-id (:entities graph))
|
||||
(upsert-workspace-graph! ws-id graph)
|
||||
;; Mark workspace as not stale
|
||||
(t2/update! :model/Workspace ws-id {:analysis_stale false})
|
||||
graph)))
|
||||
|
||||
(defn get-or-calculate-graph
|
||||
"Return the dependency graph for a workspace. Uses cached graph if workspace is not stale,
|
||||
otherwise recalculates it.
|
||||
Also syncs workspace_output_external and workspace_input_external tables when recalculating."
|
||||
[{ws-id :id, analysis-stale :analysis_stale :as workspace}]
|
||||
(if-not analysis-stale
|
||||
;; Return cached graph from workspace_graph table
|
||||
(when-let [cached (t2/select-one :model/WorkspaceGraph :workspace_id ws-id)]
|
||||
(:graph cached))
|
||||
;; Recalculate and cache
|
||||
(calculate-and-cache-graph! workspace)))
|
||||
|
||||
(defn mark-workspace-stale!
|
||||
"Mark a workspace as needing graph recalculation."
|
||||
[workspace-id]
|
||||
(t2/update! :model/Workspace workspace-id {:analysis_stale true}))
|
||||
|
||||
(defn- transforms-to-execute
|
||||
"Given a workspace and an optional filter, return the global and workspace definitions to run, in the correct order."
|
||||
[{ws-id :id :as workspace} & {:keys [stale-only?]}]
|
||||
;; 1. Depending on what we end up storing in this field, we might not be considering stale ancestors.
|
||||
;; 2. For now, we never set this field to false, so we'll always run everything, even with the flag.
|
||||
;; Why is there all this weird code then? To avoid unused references.
|
||||
(let [stale-clause (if stale-only? {:where [:= :stale true]} {})
|
||||
entities (:entities (get-or-calculate-graph workspace))
|
||||
type->ids (u/group-by :node-type :id entities)
|
||||
id->tx (merge
|
||||
{}
|
||||
(when-let [ids (seq (type->ids :external-transform))]
|
||||
(t2/select-fn->fn :id identity
|
||||
[:model/Transform :id :name :source :target]
|
||||
:id [:in ids]
|
||||
stale-clause))
|
||||
(when-let [ref-ids (seq (type->ids :workspace-transform))]
|
||||
(t2/select-fn->fn :ref_id identity
|
||||
[:model/WorkspaceTransform :ref_id :name :source :target]
|
||||
:workspace_id ws-id
|
||||
:ref_id [:in ref-ids]
|
||||
stale-clause)))]
|
||||
(keep (comp id->tx :id) entities)))
|
||||
|
||||
(defn- id->str [ref-id-or-id]
|
||||
(if (string? ref-id-or-id)
|
||||
ref-id-or-id
|
||||
(str "global-id:" ref-id-or-id)))
|
||||
|
||||
(defn execute-workspace!
|
||||
"Execute all the transforms within a given workspace."
|
||||
[workspace & {:keys [stale-only?] :or {stale-only? false}}]
|
||||
(let [ws-id (:id workspace)
|
||||
remapping (build-remapping workspace)]
|
||||
(reduce
|
||||
(fn [acc {external-id :id ref-id :ref_id :as transform}]
|
||||
(let [node-type (if external-id :external-transform :workspace-transform)
|
||||
id-str (id->str (or external-id ref-id))]
|
||||
(try
|
||||
;; Perhaps we want to return some of the metadata from this as well?
|
||||
(if (= :succeeded (:status (run-transform! workspace transform remapping)))
|
||||
(update acc :succeeded conj id-str)
|
||||
;; Perhaps the status might indicate it never ran?
|
||||
(update acc :failed conj id-str))
|
||||
(catch Exception e
|
||||
(log/error e "Failed to execute transform" {:workspace-id ws-id :node-type node-type :id id-str})
|
||||
(update acc :failed conj id-str)))))
|
||||
{:succeeded []
|
||||
:failed []
|
||||
:not_run []}
|
||||
(transforms-to-execute workspace {:stale-only stale-only?}))))
|
||||
@@ -0,0 +1,8 @@
|
||||
(ns metabase-enterprise.workspaces.init
|
||||
"Initialize workspace models. Driver workspace isolation methods are now
|
||||
defined directly in the driver files."
|
||||
(:require
|
||||
[metabase-enterprise.workspaces.events]
|
||||
[metabase-enterprise.workspaces.models.workspace]
|
||||
[metabase-enterprise.workspaces.models.workspace-graph]
|
||||
[metabase-enterprise.workspaces.models.workspace-transform]))
|
||||
@@ -0,0 +1,59 @@
|
||||
(ns metabase-enterprise.workspaces.isolation
|
||||
"Workspace database isolation functions.
|
||||
|
||||
The actual driver-specific implementations are now in the driver files:
|
||||
- metabase.driver.h2
|
||||
- metabase.driver.postgres
|
||||
- metabase.driver.redshift
|
||||
- metabase.driver.snowflake
|
||||
- metabase.driver.sqlserver
|
||||
- metabase.driver.clickhouse
|
||||
- metabase.driver.bigquery-cloud-sdk
|
||||
|
||||
This namespace provides the public API for workspace isolation."
|
||||
(:require
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.util :as driver.u]))
|
||||
|
||||
;;;; Delegation to driver methods
|
||||
;; The actual multimethod implementations are now in the individual driver files.
|
||||
;; These functions dispatch through the driver multimethods.
|
||||
|
||||
(defn init-workspace-database-isolation!
|
||||
"Create database isolation for a workspace. Return the database details.
|
||||
Delegates to driver/init-workspace-isolation!"
|
||||
[database workspace]
|
||||
(driver/init-workspace-isolation! (driver.u/database->driver database) database workspace))
|
||||
|
||||
(defn grant-read-access-to-tables!
|
||||
"Grant read access to these tables.
|
||||
Delegates to driver/grant-workspace-read-access!"
|
||||
[database workspace tables]
|
||||
(driver/grant-workspace-read-access! (driver.u/database->driver database) database workspace tables))
|
||||
|
||||
(defn destroy-workspace-isolation!
|
||||
"Destroy all database resources created for workspace isolation.
|
||||
This includes dropping tables, schemas/databases, users, roles, and logins.
|
||||
Fails fast on first error. Should be called when deleting a workspace.
|
||||
Delegates to driver/destroy-workspace-isolation!"
|
||||
[database workspace]
|
||||
(driver/destroy-workspace-isolation! (driver.u/database->driver database) database workspace))
|
||||
|
||||
;;;; Public API
|
||||
|
||||
(defn ensure-database-isolation!
|
||||
"Wrapper around the driver method, to make migrations easier in future."
|
||||
[workspace database]
|
||||
(init-workspace-database-isolation! database workspace))
|
||||
|
||||
(defn do-with-workspace-isolation
|
||||
"Impl of* with-workspace-isolation*."
|
||||
[workspace thunk]
|
||||
(driver/with-swapped-connection-details (:database_id workspace)
|
||||
(:database_details workspace)
|
||||
(thunk)))
|
||||
|
||||
(defmacro with-workspace-isolation
|
||||
"Execute body with necessary isolation."
|
||||
[workspace & body]
|
||||
`(do-with-workspace-isolation ~workspace (fn [] ~@body)))
|
||||
129
enterprise/backend/src/metabase_enterprise/workspaces/merge.clj
Normal file
@@ -0,0 +1,129 @@
|
||||
(ns metabase-enterprise.workspaces.merge
|
||||
"Update global entities based on the definitions within a workspace.
|
||||
All functions assumes that validation of the dependencies AFTER merge is done BEFORE calling any of these methods."
|
||||
(:require
|
||||
[metabase-enterprise.transforms.api :as transforms.api]
|
||||
[metabase-enterprise.workspaces.models.workspace-merge]
|
||||
[metabase-enterprise.workspaces.models.workspace-merge-transform]
|
||||
[metabase-enterprise.workspaces.types :as ws.types]
|
||||
[metabase.util.malli :as mu]
|
||||
[toucan2.core :as t2])
|
||||
(:import (java.sql Connection)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- create-merge-transform-history!
|
||||
"Create a workspace_merge_transform record to track this merge operation.
|
||||
Does nothing for :noop operations (archived transforms that don't exist globally)
|
||||
or :delete operations (history would be CASCADE deleted with the transform anyway)."
|
||||
[op merge-map]
|
||||
(when (#{:create :update} op)
|
||||
(t2/insert! :model/WorkspaceMergeTransform merge-map)))
|
||||
|
||||
;; TODO (crisptrutski 2025/12/10): When there are more entity types support, this should update those too.
|
||||
(mu/defn merge-transform! :- [:map
|
||||
[:op [:enum :create :delete :noop :update]]
|
||||
[:ref_id ::ws.types/ref-id]
|
||||
[:global_id {:optional true} [:maybe ::ws.types/appdb-id]]
|
||||
[:error {:optional true} :any]]
|
||||
"Make the given transform in the Changeset public, i.e. create or update the relevant model/Transform entities.
|
||||
This should also clear it out from the Changset, as it no longer has any changes.
|
||||
|
||||
Creates a WorkspaceMergeTransform history record.
|
||||
- :ws-transform - The workspace transform to merge
|
||||
- :workspace - The workspace map (must have :id and :name keys)
|
||||
- :workspace-merge-id - ID of parent WorkspaceMerge (caller must create this)
|
||||
- :merging-user-id - user performing the merge
|
||||
- :commit-message - description of the merge"
|
||||
[{:keys [ws-transform workspace workspace-merge-id merging-user-id commit-message]}]
|
||||
;; Problems this may run into:
|
||||
;; It will recalculate and validate the dag greedily as it inserts each items, and this might fail of temporary conflicts.
|
||||
;; Some of these conflicts could be solved by ordering things smartly, but in the general case (e.g. reversing a
|
||||
;; chain) this will not be possible - we would need to defer all validation and calculation, which is a bit scary.
|
||||
;; Let's just focus on the happy path for now though, as this is a fundamental problem and unrelated to the design.
|
||||
(let [{:keys [global_id ref_id archived_at]} ws-transform
|
||||
{:keys [error] :as result}
|
||||
(cond (and global_id archived_at)
|
||||
(merge
|
||||
{:op :delete :global_id global_id :ref_id ref_id}
|
||||
(try
|
||||
(transforms.api/delete-transform! (t2/select-one :model/Transform :id global_id))
|
||||
nil
|
||||
(catch Throwable e
|
||||
{:error e})))
|
||||
|
||||
global_id
|
||||
(merge
|
||||
{:op :update :global_id global_id :ref_id ref_id}
|
||||
(try
|
||||
(transforms.api/update-transform!
|
||||
global_id (select-keys ws-transform [:name :description :source :target]))
|
||||
nil
|
||||
(catch Throwable e
|
||||
{:error e})))
|
||||
|
||||
archived_at
|
||||
{:op :noop :global_id nil :ref_id ref_id}
|
||||
|
||||
:else
|
||||
(merge
|
||||
{:op :create :global_id nil :ref_id ref_id}
|
||||
(try
|
||||
{:global_id (:id (transforms.api/create-transform!
|
||||
(select-keys ws-transform [:name :description :source :target])
|
||||
(:creator_id ws-transform)))}
|
||||
(catch Throwable e
|
||||
{:error e}))))]
|
||||
|
||||
(when-not error
|
||||
(t2/delete! :model/WorkspaceTransform :ref_id ref_id)
|
||||
(create-merge-transform-history!
|
||||
(:op result)
|
||||
{:workspace_merge_id workspace-merge-id
|
||||
:workspace_id (:id workspace)
|
||||
:workspace_name (:name workspace)
|
||||
:transform_id (:global_id result)
|
||||
:commit_message commit-message
|
||||
:merging_user_id merging-user-id}))
|
||||
|
||||
result))
|
||||
|
||||
;; TODO (crisptrutski 2025-12-XX): When there are more entity types support, this should update those too.
|
||||
(defn merge-workspace!
|
||||
"Make all the transforms in the Changeset public, i.e. create or update the relevant model/Transform entities.
|
||||
This should also clear the entire Changeset, as it no longer has any changes.
|
||||
|
||||
- workspace: The workspace map (must have :id and :name keys)
|
||||
- merging-user-id: user performing the merge
|
||||
- commit-message: description of the merge"
|
||||
[workspace merging-user-id commit-message]
|
||||
;; Perhaps we want to solve the N+1?
|
||||
;; This will require reworking the lifecycle for validating and recalculating dependencies for the transforms.
|
||||
(t2/with-transaction [tx]
|
||||
(let [savepoint (.setSavepoint ^Connection tx)
|
||||
ws-merge-id (t2/insert-returning-pk!
|
||||
:model/WorkspaceMerge
|
||||
{:workspace_id (:id workspace)
|
||||
:workspace_name (:name workspace)
|
||||
:commit_message commit-message
|
||||
:creator_id merging-user-id})
|
||||
result (reduce
|
||||
(fn [acc ws-transform]
|
||||
(let [{:keys [error] :as result} (merge-transform! {:ws-transform ws-transform
|
||||
:workspace workspace
|
||||
:workspace-merge-id ws-merge-id
|
||||
:merging-user-id merging-user-id
|
||||
:commit-message commit-message})]
|
||||
(if error
|
||||
(reduced (-> acc
|
||||
(update :errors conj result)
|
||||
(assoc-in [:merged :transforms] [])
|
||||
#_(assoc :short_circuit true)))
|
||||
(update-in acc [:merged :transforms] conj result))))
|
||||
{:merged {:transforms []}
|
||||
:errors []
|
||||
#_#_:short_circuit false}
|
||||
(t2/select :model/WorkspaceTransform :workspace_id (:id workspace)))]
|
||||
(when (seq (:errors result))
|
||||
(.rollback ^Connection tx savepoint))
|
||||
result)))
|
||||
@@ -0,0 +1,98 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace
|
||||
(:require
|
||||
[metabase-enterprise.workspaces.impl :as ws.impl]
|
||||
[metabase-enterprise.workspaces.isolation :as ws.isolation]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util.log :as log]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Workspace [_model] :workspace)
|
||||
|
||||
(def ^:private base-statuses
|
||||
"Set of valid base_status values, representing the synchronous lifecycle."
|
||||
#{:empty :active :archived})
|
||||
|
||||
(def ^:private db-statuses
|
||||
"Set of valid db_status values, representing the status of the data warehouse."
|
||||
#{:uninitialized :pending :ready :broken})
|
||||
|
||||
(t2/deftransforms :model/Workspace
|
||||
{:database_details mi/transform-encrypted-json
|
||||
:base_status (mi/transform-validator mi/transform-keyword (partial mi/assert-enum base-statuses))
|
||||
:db_status (mi/transform-validator mi/transform-keyword (partial mi/assert-enum db-statuses))})
|
||||
|
||||
(defn computed-status
|
||||
"Compute backwards-compatible status from base_status and db_status.
|
||||
Returns :archived if workspace is archived, otherwise returns db_status."
|
||||
[{:keys [base_status db_status]}]
|
||||
(case base_status
|
||||
:empty :uninitialized
|
||||
:archived :archived
|
||||
db_status))
|
||||
|
||||
(doto :model/Workspace
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(defn archive!
|
||||
"Archive a workspace. Destroys database isolation resources (best-effort), revokes access grants,
|
||||
sets base_status to :archived and db_status to :uninitialized.
|
||||
Cleanup failures are logged but don't block archiving - the workspace can be unarchived to retry cleanup,
|
||||
or deleted once the underlying permission issues are resolved."
|
||||
[{workspace-id :id :as workspace}]
|
||||
(let [database (t2/select-one :model/Database :id (:database_id workspace))
|
||||
;; Best-effort cleanup - don't block archiving on cleanup failures
|
||||
;; Only destroy isolation if workspace was initialized (not uninitialized db_status)
|
||||
cleaned? (when (not= :uninitialized (:db_status workspace))
|
||||
(try
|
||||
(ws.isolation/destroy-workspace-isolation! database workspace)
|
||||
true
|
||||
(catch Exception e
|
||||
(log/warnf e "Failed to cleanup isolation resources for workspace %s, proceeding with archive" workspace-id)
|
||||
false)))]
|
||||
;; Mark all inputs as un-granted since the user *may* have been dropped (even if there were some failures)
|
||||
(t2/update! :model/WorkspaceInput {:workspace_id workspace-id}
|
||||
{:access_granted false})
|
||||
;; Mark all analysis as stale, as we will need to recreate grants.
|
||||
(t2/update! :model/WorkspaceTransform {:workspace_id workspace-id} {:analysis_stale true})
|
||||
(t2/update! :model/Workspace workspace-id {:base_status :archived
|
||||
:db_status (if cleaned? :uninitialized :broken)
|
||||
:analysis_stale true})))
|
||||
|
||||
(defn unarchive!
|
||||
"Unarchive a workspace. If workspace has transforms, re-initializes database isolation resources,
|
||||
re-grants read access to source tables, and sets base_status to :active with db_status :ready.
|
||||
If workspace is empty (no transforms), sets base_status to :empty and leaves db_status as :uninitialized."
|
||||
[workspace]
|
||||
(let [workspace-id (:id workspace)
|
||||
has-transforms? (t2/exists? :model/WorkspaceTransform :workspace_id workspace-id)]
|
||||
(t2/delete! :model/WorkspaceGraph :workspace_id workspace-id)
|
||||
(if has-transforms?
|
||||
;; Workspace has transforms - re-initialize database isolation
|
||||
(let [database (t2/select-one :model/Database (:database_id workspace))
|
||||
isolation-result (ws.isolation/ensure-database-isolation! workspace database)
|
||||
_ (t2/update! :model/Workspace workspace-id
|
||||
(merge {:base_status :active
|
||||
:db_status :ready
|
||||
:analysis_stale true}
|
||||
(select-keys isolation-result [:schema :database_details])))
|
||||
;; Re-fetch workspace to get updated database_details and schema for granting permissions
|
||||
updated-ws (t2/select-one :model/Workspace workspace-id)]
|
||||
;; Re-grant read access to source tables (inputs were marked un-granted during archive)
|
||||
(ws.impl/sync-grant-accesses! updated-ws))
|
||||
;; Workspace is empty - just set to empty status
|
||||
(t2/update! :model/Workspace workspace-id {:base_status :empty}))))
|
||||
|
||||
(defn delete!
|
||||
"Delete a workspace. Destroys database isolation resources and deletes the workspace."
|
||||
[workspace]
|
||||
;; Only destroy isolation if workspace was initialized
|
||||
(when (not= :uninitialized (:db_status workspace))
|
||||
(when-let [database (t2/select-one :model/Database :id (:database_id workspace))]
|
||||
(try
|
||||
(ws.isolation/destroy-workspace-isolation! database workspace)
|
||||
(catch Exception _))
|
||||
(t2/update! :model/WorkspaceInput {:workspace_id (:id workspace)}
|
||||
{:access_granted false})))
|
||||
(t2/delete! :model/Workspace (:id workspace)))
|
||||
@@ -0,0 +1,17 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-dependency
|
||||
"Model for WorkspaceDependency - edges between workspace transforms and their dependencies."
|
||||
(:require
|
||||
[metabase.models.interface :as mi]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceDependency [_model] :workspace_dependency)
|
||||
|
||||
(doto :model/WorkspaceDependency
|
||||
(derive :metabase/model)
|
||||
(derive :hook/created-at-timestamped?))
|
||||
|
||||
(t2/deftransforms :model/WorkspaceDependency
|
||||
{:from_entity_type mi/transform-keyword
|
||||
:to_entity_type mi/transform-keyword
|
||||
:from_entity_id mi/transform-trim})
|
||||
@@ -0,0 +1,50 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-graph
|
||||
"Model for cached workspace dependency graphs."
|
||||
(:require
|
||||
[metabase.models.interface :as mi]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceGraph [_model] :workspace_graph)
|
||||
|
||||
;; The graph structure contains :dependencies with map keys that need special handling.
|
||||
;; We use a custom transform that converts map keys to/from association lists.
|
||||
|
||||
(defn- graph-out
|
||||
"Transform graph from DB (JSON string) to Clojure data.
|
||||
Converts :dependencies association list back to a map with proper keys."
|
||||
[graph-json]
|
||||
(when graph-json
|
||||
(let [graph (mi/json-out-with-keywordization graph-json)]
|
||||
(-> graph
|
||||
(update :dependencies (fn [assoc-list]
|
||||
(into {} (map (fn [[k vs]]
|
||||
[(update k :node-type keyword)
|
||||
(mapv #(update % :node-type keyword) vs)])
|
||||
assoc-list))))
|
||||
(update :entities (fn [entities]
|
||||
(mapv #(update % :node-type keyword) entities)))
|
||||
(update :inputs #(or % []))
|
||||
(update :outputs #(or % []))))))
|
||||
|
||||
(defn- graph-in
|
||||
"Transform graph from Clojure data to DB (JSON string).
|
||||
Converts :dependencies map to association list since JSON can't have map keys."
|
||||
[graph]
|
||||
(when graph
|
||||
(-> graph
|
||||
(update :dependencies (fn [deps]
|
||||
(mapv (fn [[k vs]]
|
||||
[(select-keys k [:node-type :id])
|
||||
(mapv #(select-keys % [:node-type :id]) vs)])
|
||||
deps)))
|
||||
(update :entities (fn [entities]
|
||||
(mapv #(select-keys % [:node-type :id]) entities)))
|
||||
mi/json-in)))
|
||||
|
||||
(t2/deftransforms :model/WorkspaceGraph
|
||||
{:graph {:in graph-in :out graph-out}})
|
||||
|
||||
(doto :model/WorkspaceGraph
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
@@ -0,0 +1,11 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-input
|
||||
"Model for WorkspaceInput - external tables that workspace transforms consume."
|
||||
(:require
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceInput [_model] :workspace_input)
|
||||
|
||||
(doto :model/WorkspaceInput
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
@@ -0,0 +1,11 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-input-external
|
||||
"Model for WorkspaceInputExternal - external tables that enclosed external transforms consume."
|
||||
(:require
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceInputExternal [_model] :workspace_input_external)
|
||||
|
||||
(doto :model/WorkspaceInputExternal
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
@@ -0,0 +1,70 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-log
|
||||
(:require
|
||||
[java-time.api :as t]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util.i18n :refer [deferred-tru]]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceLog [_model] :workspace_log)
|
||||
|
||||
(doto :model/WorkspaceLog
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(t2/deftransforms :model/WorkspaceLog
|
||||
{:task mi/transform-keyword
|
||||
:status mi/transform-keyword})
|
||||
|
||||
(defn- task->description
|
||||
"Return a human-readable description for a workspace task."
|
||||
[task]
|
||||
(case task
|
||||
:workspace-setup (deferred-tru "Setting up the workspace")
|
||||
:database-isolation (deferred-tru "Provisioning database isolation")
|
||||
:mirror-entities (deferred-tru "Mirroring entities")
|
||||
:grant-read-access (deferred-tru "Granting permissions")
|
||||
(name task)))
|
||||
|
||||
(t2/define-after-select :model/WorkspaceLog
|
||||
[{:keys [task] :as log}]
|
||||
(assoc log :description (task->description task)))
|
||||
|
||||
(defn start!
|
||||
"Create a log entry for a workspace setup task. Returns the created log."
|
||||
[workspace-id task]
|
||||
(t2/insert-returning-instance! :model/WorkspaceLog
|
||||
{:workspace_id workspace-id
|
||||
:task task
|
||||
:started_at (t/offset-date-time)
|
||||
:status :started}))
|
||||
|
||||
(defn- complete!
|
||||
[status log-id & [message]]
|
||||
(assert (#{:success :failure} status) "It can only be either success or failure")
|
||||
(let [data (cond-> {:completed_at (t/offset-date-time)
|
||||
:status status}
|
||||
message (assoc :message message))]
|
||||
(t2/update! :model/WorkspaceLog log-id data)))
|
||||
|
||||
(defn success!
|
||||
"Task was completed successfully"
|
||||
[log-id & [message]]
|
||||
(complete! :success log-id message))
|
||||
|
||||
(defn failure!
|
||||
"Task has failed"
|
||||
[log-id & [message]]
|
||||
(complete! :failure log-id message))
|
||||
|
||||
(defmacro track!
|
||||
"Wrap body with automatic logging: starts a log entry, marks success on completion, failure on exception."
|
||||
[workspace-id task & body]
|
||||
`(let [log# (start! ~workspace-id ~task)]
|
||||
(try
|
||||
(let [result# (do ~@body)]
|
||||
(success! (:id log#))
|
||||
result#)
|
||||
(catch Exception e#
|
||||
(failure! (:id log#) (ex-message e#))
|
||||
(throw e#)))))
|
||||
@@ -0,0 +1,14 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-merge
|
||||
"Model for WorkspaceMerge - tracks batch merge events with commit messages.
|
||||
Survives workspace deletion through inlined workspace_name."
|
||||
(:require
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; -------------------------------------------------- Model Setup --------------------------------------------------
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceMerge [_model] :workspace_merge)
|
||||
|
||||
(doto :model/WorkspaceMerge
|
||||
(derive :metabase/model)
|
||||
(derive :hook/created-at-timestamped?))
|
||||
@@ -0,0 +1,15 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-merge-transform
|
||||
"Model for WorkspaceMergeTransform - tracks individual transform merges.
|
||||
Linked to WorkspaceMerge for batch merges, standalone for single-transform merges.
|
||||
Survives workspace deletion through inlined workspace_name."
|
||||
(:require
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; -------------------------------------------------- Model Setup --------------------------------------------------
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceMergeTransform [_model] :workspace_merge_transform)
|
||||
|
||||
(doto :model/WorkspaceMergeTransform
|
||||
(derive :metabase/model)
|
||||
(derive :hook/created-at-timestamped?))
|
||||
@@ -0,0 +1,15 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-output
|
||||
"Model for WorkspaceOutput - tables that workspace transforms produce."
|
||||
(:require
|
||||
[metabase.models.interface :as mi]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceOutput [_model] :workspace_output)
|
||||
|
||||
(doto :model/WorkspaceOutput
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(t2/deftransforms :model/WorkspaceOutput
|
||||
{:ref_id mi/transform-trim})
|
||||
@@ -0,0 +1,11 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-output-external
|
||||
"Model for WorkspaceOutputExternal - tables that enclosed external transforms produce."
|
||||
(:require
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceOutputExternal [_model] :workspace_output_external)
|
||||
|
||||
(doto :model/WorkspaceOutputExternal
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
@@ -0,0 +1,49 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace-transform
|
||||
"Model for WorkspaceTransform - holds the changeset of transforms being created
|
||||
and edited within a workspace."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[medley.core :as m]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.models.interface :as mi]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkspaceTransform [_model] :workspace_transform)
|
||||
|
||||
(methodical/defmethod t2/primary-keys :model/WorkspaceTransform [_model] [:ref_id])
|
||||
|
||||
(doto :model/WorkspaceTransform
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?)
|
||||
(derive :hook/entity-id))
|
||||
|
||||
;; TODO (chris 2025/12/11) we need to share a bunch of stuff with transforms, i think we'll need to reorganize modules
|
||||
;; suggestion: add a transforms-interfaces module which both transforms and workspaces depend on.
|
||||
|
||||
(defn- transform-source-out-DUPLICATED [m]
|
||||
(-> m
|
||||
mi/json-out-without-keywordization
|
||||
(update-keys keyword)
|
||||
(m/update-existing :query lib-be/normalize-query)
|
||||
(m/update-existing :type keyword)
|
||||
(m/update-existing :source-incremental-strategy #(update-keys % keyword))))
|
||||
|
||||
(defn- transform-source-in-DUPLICATED [m]
|
||||
(-> m
|
||||
(m/update-existing :query (comp lib/prepare-for-serialization lib-be/normalize-query))
|
||||
mi/json-in))
|
||||
|
||||
(t2/deftransforms :model/WorkspaceTransform
|
||||
{:ref_id {:in identity :out str/trim}
|
||||
:source {:out transform-source-out-DUPLICATED, :in transform-source-in-DUPLICATED}
|
||||
:target mi/transform-json})
|
||||
|
||||
(t2/define-before-insert :model/WorkspaceTransform
|
||||
[instance]
|
||||
;; Ref id can not be added here due to mysql t2 bug
|
||||
(when-not (string? (not-empty (:ref_id instance)))
|
||||
(throw (ex-info "ref_id required for WorkspaceTransform insertion."
|
||||
{:instance instance})))
|
||||
instance)
|
||||
161
enterprise/backend/src/metabase_enterprise/workspaces/types.clj
Normal file
@@ -0,0 +1,161 @@
|
||||
(ns metabase-enterprise.workspaces.types
|
||||
(:require
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(mr/def ::appdb-id ms/PositiveInt)
|
||||
|
||||
(mr/def ::ref-id [:string {:min 1 :api/regex #".+"}])
|
||||
|
||||
(def entity-types
|
||||
"The kinds of entities we can store within a Workspace."
|
||||
[{:name "Transform"
|
||||
:key :transform
|
||||
:group :transforms
|
||||
:model :model/Transform}])
|
||||
|
||||
(mr/def ::entity-type (into [:enum] (map :key) entity-types))
|
||||
|
||||
(mr/def ::entity-grouping (into [:enum] (map :group) entity-types))
|
||||
|
||||
;; Map like {:transforms [1 2 3]}
|
||||
(mr/def ::entity-map
|
||||
[:map-of ::entity-grouping [:sequential ms/PositiveInt]])
|
||||
|
||||
(mr/def ::execution-result
|
||||
[:map
|
||||
[:status [:enum :succeeded :failed]]
|
||||
[:start_time {:optional true} [:maybe some?]]
|
||||
[:end_time {:optional true} [:maybe some?]]
|
||||
[:message {:optional true} [:maybe :string]]
|
||||
[:table [:map
|
||||
[:name :string]
|
||||
[:schema {:optional true} [:maybe :string]]]]])
|
||||
|
||||
;;; ---------------------------------------- Graph/Problem Types ----------------------------------------
|
||||
|
||||
;; Node types used in workspace graphs and problem detection
|
||||
(mr/def ::node-type [:enum :input-table :external-transform :workspace-transform])
|
||||
|
||||
(mr/def ::table-coord
|
||||
"Logical reference to a table by database, schema, and name."
|
||||
[:map
|
||||
[:db_id ::appdb-id]
|
||||
[:schema [:maybe :string]]
|
||||
[:table :string]])
|
||||
|
||||
(mr/def ::node-ref
|
||||
"Reference to a node in the graph or a related entity."
|
||||
[:map
|
||||
[:type ::node-type]
|
||||
[:id [:or ::appdb-id ::ref-id]]
|
||||
[:name {:optional true} :string]])
|
||||
|
||||
;;; ---------------------------------------- Problem Types ----------------------------------------
|
||||
|
||||
;; Problem type definitions with metadata for FE display and merge-blocking behavior
|
||||
;; See validation.clj docstring for full documentation table
|
||||
|
||||
(def problem-types
|
||||
"All problem types with their metadata.
|
||||
|
||||
Each entry has:
|
||||
- :description - human-readable explanation
|
||||
- :severity - :error, :warning, or :info
|
||||
- :block-merge - whether this problem should prevent merging
|
||||
- :static? - true if determinable from metadata alone, false if requires runtime info
|
||||
|
||||
## Categories
|
||||
|
||||
Dependency-based (about output state) - prefix indicates who depends on the output:
|
||||
- unused-* : nothing depends on this output
|
||||
- internal-downstream-* : transforms within the workspace graph depend on this
|
||||
- external-downstream-* : transforms outside the workspace depend on this
|
||||
|
||||
Structural (about graph shape) - prefix indicates scope of the issue:
|
||||
- internal-* : between workspace transforms only
|
||||
- external-* : involves global transforms (upstream or downstream)"
|
||||
{;; No dependents (informational)
|
||||
:unused/not-run {:description "Output hasn't been created yet, but nothing depends on it"
|
||||
:severity :info
|
||||
:block-merge false
|
||||
:static? true}
|
||||
:unused/stale {:description "Output is stale (needs re-run), but nothing depends on it"
|
||||
:severity :info
|
||||
:block-merge false
|
||||
:static? true}
|
||||
:unused/failed {:description "Transform failed on last run, but nothing depends on it"
|
||||
:severity :error
|
||||
:block-merge false
|
||||
:static? false}
|
||||
|
||||
;; Internal dependents (blocks workspace progress)
|
||||
:internal-downstream/not-run {:description "Output hasn't been created yet, other workspace transforms need it"
|
||||
:severity :warning
|
||||
:block-merge true
|
||||
:static? true}
|
||||
:internal-downstream/stale {:description "Output is stale, other workspace transforms need fresh data"
|
||||
:severity :warning
|
||||
:block-merge true
|
||||
:static? true}
|
||||
:internal-downstream/failed {:description "Transform failed on last run, other workspace transforms need it"
|
||||
:severity :error
|
||||
:block-merge true
|
||||
:static? false}
|
||||
|
||||
;; External dependents (would affect things after merge)
|
||||
:external-downstream/not-run {:description "Output hasn't been created yet, external transforms depend on it"
|
||||
:severity :warning
|
||||
:block-merge true
|
||||
:static? true}
|
||||
:external-downstream/stale {:description "Output is stale, external transforms are using outdated data"
|
||||
:severity :warning
|
||||
:block-merge true
|
||||
:static? true}
|
||||
:external-downstream/removed-field {:description "Field was removed that external transforms reference"
|
||||
:severity :error
|
||||
:block-merge true
|
||||
:static? true}
|
||||
:external-downstream/removed-table {:description "Table was removed, external transforms will stop receiving updates"
|
||||
:severity :warning
|
||||
:block-merge false
|
||||
:static? true}
|
||||
|
||||
;; Structural issues (conflicts/cycles)
|
||||
:internal/target-conflict {:description "Multiple workspace transforms target the same table"
|
||||
:severity :error
|
||||
:block-merge true
|
||||
:static? true}
|
||||
:internal/cycle {:description "Circular dependency between workspace transforms"
|
||||
:severity :error
|
||||
:block-merge true
|
||||
:static? true}
|
||||
:external/target-conflict {:description "Workspace transform conflicts with an existing global transform's target"
|
||||
:severity :error
|
||||
:block-merge true
|
||||
:static? true}
|
||||
:external/cycle {:description "Workspace changes would create a circular dependency with global transforms"
|
||||
:severity :error
|
||||
:block-merge true
|
||||
:static? true}})
|
||||
|
||||
(mr/def ::problem-type (into [:enum] (keys problem-types)))
|
||||
|
||||
(mr/def ::severity [:enum :error :warning :info])
|
||||
|
||||
;; Categories derived from problem-type namespaces
|
||||
(mr/def ::problem-category
|
||||
(into [:enum] (distinct (map (comp keyword namespace) (keys problem-types)))))
|
||||
|
||||
;; Problem names derived from problem-type names
|
||||
(mr/def ::problem-name
|
||||
(into [:enum] (distinct (map (comp keyword name) (keys problem-types)))))
|
||||
|
||||
(mr/def ::problem
|
||||
"A problem detected during workspace validation."
|
||||
[:map
|
||||
[:category ::problem-category]
|
||||
[:problem ::problem-name]
|
||||
[:severity ::severity]
|
||||
[:block_merge :boolean]
|
||||
[:data :map]])
|
||||
167
enterprise/backend/src/metabase_enterprise/workspaces/util.clj
Normal file
@@ -0,0 +1,167 @@
|
||||
(ns metabase-enterprise.workspaces.util
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase.system.core :as system]))
|
||||
|
||||
(defn assert-transform!
|
||||
"Test whether we support the given entity type within workspaces yet.
|
||||
Named for the only case we support currently, to make call sites assumptions more obvious."
|
||||
[entity-type]
|
||||
(when (not= "transform" (name entity-type))
|
||||
(throw (ex-info "Only transform entity type is supported"
|
||||
{:status-code 400
|
||||
:entity-type entity-type}))))
|
||||
|
||||
(defn assert-transforms!
|
||||
"Test that only supported types are given in the given list.
|
||||
Named for the only case we support currently, to make call sites assumptions more obvious."
|
||||
[entities]
|
||||
(when-let [other-types (seq (remove #{:transform} (map :entity-type entities)))]
|
||||
(throw (ex-info "Only transform entities are currently supported"
|
||||
{:status-code 400
|
||||
:unsupported-types other-types}))))
|
||||
|
||||
(defn- toposort-visit [node child->parents visited result]
|
||||
(cond
|
||||
(visited node) [visited result]
|
||||
:else (let [parents (child->parents node [])
|
||||
[visited' result'] (reduce (fn [[v r] p]
|
||||
(toposort-visit p child->parents v r))
|
||||
[(conj visited node) result]
|
||||
parents)]
|
||||
[visited' (conj result' node)])))
|
||||
|
||||
(defn toposort-dfs
|
||||
"Perform a topological sort using depth-first search.
|
||||
Takes a map from child nodes to their parent nodes (dependencies).
|
||||
Returns nodes in topological order (dependencies before dependents)."
|
||||
[child->parents]
|
||||
;; TODO (Chris 2025-11-20): Detect cycles and throw an error. (In practice inputs will never be cyclic, but still.)
|
||||
(let [all-nodes (set (keys child->parents))]
|
||||
(loop [visited #{}
|
||||
result []
|
||||
remaining all-nodes]
|
||||
(if (empty? remaining)
|
||||
result
|
||||
(let [node (first remaining)
|
||||
[visited' result'] (toposort-visit node child->parents visited result)]
|
||||
(recur visited' result' (disj remaining node)))))))
|
||||
|
||||
;;; Naming
|
||||
|
||||
;; re-using https://github.com/metabase/metabase/pull/61887/commits/c92e4a9cc451c61a13fef19ed9d6107873b17f07
|
||||
;; (original ws isolation code)
|
||||
(defn- instance-uuid-slug
|
||||
"Create a slug from the site UUID, taking the first character of each section."
|
||||
[site-uuid-string]
|
||||
(->> (str/split site-uuid-string #"-")
|
||||
(map first)
|
||||
(apply str)))
|
||||
|
||||
;; WARNING: Changing this prefix requires backwards compatibility handling for existing workspaces.
|
||||
;; The prefix is used to identify isolation namespaces in the database, and existing workspaces
|
||||
;; will have namespaces created with the current prefix.
|
||||
(def ^:private isolated-prefix "mb__isolation")
|
||||
|
||||
(defn isolation-namespace-name
|
||||
"Generate namespace/database name for workspace isolation following mb__isolation_<slug>_<workspace-id> pattern.
|
||||
Uses 'namespace' as the generic term that maps to 'schema' in Postgres, 'database' in ClickHouse, etc."
|
||||
[workspace]
|
||||
(assert (some? (:id workspace)) "Workspace must have an :id")
|
||||
(let [instance-slug (instance-uuid-slug (str (system/site-uuid)))
|
||||
clean-workspace-id (str/replace (str (:id workspace)) #"[^a-zA-Z0-9]" "_")]
|
||||
(format "%s_%s_%s" isolated-prefix instance-slug clean-workspace-id)))
|
||||
|
||||
(defn isolated-table-name
|
||||
"Generate name for a table mirroring transform target table in the isolated database namespace.
|
||||
Returns schema__name when schema is present, or __name when schema is nil (to distinguish from global tables)."
|
||||
[schema name]
|
||||
;; TODO: This naming scheme is not guaranteed to give a unique name, even if it's likely
|
||||
;; to always be unique in practice. Consider adding a hash suffix or conflict detection
|
||||
;; before merging to master.
|
||||
(if schema
|
||||
(format "%s__%s" schema name)
|
||||
(format "__%s" name)))
|
||||
|
||||
(defn isolation-user-name
|
||||
"Generate username for workspace isolation."
|
||||
[workspace]
|
||||
(let [instance-slug (instance-uuid-slug (str (system/site-uuid)))]
|
||||
(format "%s_%s_%s" isolated-prefix instance-slug (:id workspace))))
|
||||
|
||||
(def ^:private password-char-sets
|
||||
"Character sets for password generation. Cycles through these to ensure representation from each."
|
||||
["ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
"abcdefghjkmnpqrstuvwxyz"
|
||||
"123456789"
|
||||
"!#$%&*+-="])
|
||||
|
||||
(defn random-isolated-password
|
||||
"Generate a random password suitable for most database engines.
|
||||
Ensures the password contains characters from all sets (uppercase, lowercase, digits, special)
|
||||
by cycling through the character sets. Result is shuffled for randomness."
|
||||
[]
|
||||
(->> (cycle password-char-sets)
|
||||
(take (+ 32 (rand-int 32)))
|
||||
(map rand-nth)
|
||||
shuffle
|
||||
(apply str)))
|
||||
|
||||
;;; -------------------------------------------------- Cute Names --------------------------------------------------
|
||||
|
||||
(def ^:private adjectives
|
||||
"Adjectives for generating cute ref IDs, inspired by Docker's naming scheme."
|
||||
["admiring" "adoring" "affectionate" "agitated" "amazing" "angry" "awesome"
|
||||
"beautiful" "blissful" "bold" "boring" "brave" "busy" "charming" "clever"
|
||||
"cool" "compassionate" "competent" "condescending" "confident" "cranky"
|
||||
"crazy" "dazzling" "determined" "distracted" "dreamy" "eager" "ecstatic"
|
||||
"elastic" "elated" "elegant" "eloquent" "epic" "exciting" "fervent"
|
||||
"festive" "flamboyant" "focused" "friendly" "frosty" "funny" "gallant"
|
||||
"gifted" "goofy" "gracious" "great" "happy" "hardcore" "heuristic" "hopeful"
|
||||
"hungry" "infallible" "inspiring" "intelligent" "interesting" "jolly"
|
||||
"jovial" "keen" "kind" "laughing" "loving" "lucid" "magical" "modest"
|
||||
"musing" "mystifying" "naughty" "nervous" "nice" "nifty" "nostalgic"
|
||||
"objective" "optimistic" "peaceful" "pedantic" "pensive" "practical"
|
||||
"priceless" "quirky" "quizzical" "recursing" "relaxed" "reverent" "romantic"
|
||||
"sad" "serene" "sharp" "silly" "sleepy" "stoic" "strange" "stupefied"
|
||||
"suspicious" "sweet" "tender" "thirsty" "trusting" "unruffled" "upbeat"
|
||||
"vibrant" "vigilant" "vigorous" "wizardly" "wonderful" "xenodochial" "youthful"
|
||||
"zealous" "zen"])
|
||||
|
||||
(def ^:private animals
|
||||
"Animals for generating cute ref IDs."
|
||||
["albatross" "alligator" "alpaca" "ant" "anteater" "antelope" "armadillo"
|
||||
"baboon" "badger" "bat" "bear" "beaver" "bee" "bison" "boar" "buffalo"
|
||||
"butterfly" "camel" "capybara" "caribou" "cat" "caterpillar" "cheetah"
|
||||
"chicken" "chimpanzee" "chinchilla" "cobra" "condor" "cormorant" "coyote"
|
||||
"crab" "crane" "crocodile" "crow" "deer" "dingo" "dog" "dolphin" "donkey"
|
||||
"dove" "dragonfly" "duck" "eagle" "echidna" "eel" "elephant" "elk" "emu"
|
||||
"falcon" "ferret" "finch" "firefly" "flamingo" "fox" "frog" "gazelle"
|
||||
"gerbil" "giraffe" "goat" "goose" "gopher" "gorilla" "grasshopper" "grouse"
|
||||
"hamster" "hare" "hawk" "hedgehog" "heron" "hippopotamus" "hornet" "horse"
|
||||
"hummingbird" "hyena" "ibex" "iguana" "impala" "jackal" "jaguar" "jellyfish"
|
||||
"kangaroo" "kingfisher" "kiwi" "koala" "komodo" "kookaburra" "lemur"
|
||||
"leopard" "lion" "lizard" "llama" "lobster" "lynx" "magpie" "mallard"
|
||||
"manatee" "mandrill" "marmot" "meerkat" "mink" "mole" "mongoose" "monkey"
|
||||
"moose" "moth" "mouse" "mule" "narwhal" "newt" "nightingale" "ocelot"
|
||||
"octopus" "okapi" "opossum" "orangutan" "orca" "oryx" "osprey" "ostrich"
|
||||
"otter" "owl" "ox" "panda" "panther" "parrot" "partridge" "peacock"
|
||||
"pelican" "penguin" "pheasant" "pig" "pigeon" "platypus" "pony" "porcupine"
|
||||
"porpoise" "puma" "quail" "rabbit" "raccoon" "ram" "raven" "reindeer"
|
||||
"rhinoceros" "salamander" "salmon" "sardine" "scorpion" "seahorse" "seal"
|
||||
"shark" "sheep" "shrew" "skunk" "sloth" "snail" "snake" "sparrow" "spider"
|
||||
"squid" "squirrel" "starfish" "stingray" "stork" "swallow" "swan" "tapir"
|
||||
"tiger" "toad" "toucan" "turkey" "turtle" "viper" "vulture" "wallaby"
|
||||
"walrus" "warthog" "wasp" "weasel" "whale" "wolf" "wolverine" "wombat"
|
||||
"woodpecker" "yak" "zebra"])
|
||||
|
||||
(defn generate-name
|
||||
"Generate a cute adjective-animal style name. Making sure it's unique is your job."
|
||||
[]
|
||||
(str (rand-nth adjectives) "-" (rand-nth animals)))
|
||||
|
||||
(defn generate-ref-id
|
||||
"Generate a cute adjective-animal style ref ID with a random suffix.
|
||||
Format: adjective-animal-xxxx (where xxxx is a random 4-char hex suffix)"
|
||||
[]
|
||||
(str (generate-name) "-" (format "%04x" (rand-int 65536))))
|
||||
@@ -0,0 +1,279 @@
|
||||
(ns metabase-enterprise.workspaces.validation
|
||||
"Validation of workspace changes against downstream dependencies.
|
||||
|
||||
ASSUMPTION: All transforms in the workspace have been run, and their outputs are not stale.
|
||||
This validation checks the *current* isolated table schema against external transform requirements.
|
||||
|
||||
## Problem Types
|
||||
|
||||
See `metabase-enterprise.workspaces.types/problem-types` for the full list of problem types
|
||||
with their descriptions, severity levels, and merge-blocking behavior.
|
||||
|
||||
## Currently Implemented
|
||||
|
||||
| Category | Problem | Description |
|
||||
|---------------------|---------------|----------------------------------------------------------------|
|
||||
| unused | not-run | Output hasn't been created, nothing depends on it |
|
||||
| internal-downstream | not-run | Output hasn't been created, other workspace transforms need it |
|
||||
| external-downstream | not-run | Output hasn't been created, external transforms need it |
|
||||
| external-downstream | removed-field | Field was removed that external transforms reference |"
|
||||
(:require
|
||||
[metabase-enterprise.workspaces.types :as ws.t]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata.protocols :as lib.metadata.protocols]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;;; ---------------------------------------- Metadata Provider Override ----------------------------------------
|
||||
|
||||
(defn- make-field-override-provider
|
||||
"Create a metadata provider that overrides a table's fields with a different set of fields.
|
||||
Used to simulate what external transforms would see after a merge.
|
||||
|
||||
- `base-provider`: the underlying metadata provider
|
||||
- `target-table-id`: the global table ID whose fields we're overriding
|
||||
- `replacement-fields`: the fields from the isolated table (what fields will exist after merge)"
|
||||
[base-provider target-table-id replacement-fields]
|
||||
(let [replacement-field-names (into #{} (map :name) replacement-fields)
|
||||
;; TODO we probably want to do one query for *all* the tables we care about, earlier in our validation.
|
||||
table-field-id? (t2/select-fn-set :id [:model/Field :id] :table_id target-table-id)]
|
||||
(reify
|
||||
lib.metadata.protocols/MetadataProvider
|
||||
(database [_this]
|
||||
(lib.metadata.protocols/database base-provider))
|
||||
(metadatas [_this metadata-spec]
|
||||
;; Yes, for some reason :id is actually a set of ids
|
||||
(let [{spec-type :lib/type spec-field-ids :id} metadata-spec]
|
||||
(if (and (= :metadata/column spec-type) (some table-field-id? spec-field-ids))
|
||||
;; Return replacement fields with :active based on whether they exist
|
||||
(let [original-fields (lib.metadata.protocols/metadatas base-provider metadata-spec)]
|
||||
(map (fn [field]
|
||||
(if (contains? replacement-field-names (:name field))
|
||||
field
|
||||
(assoc field :active false)))
|
||||
original-fields))
|
||||
;; Delegate everything else
|
||||
(lib.metadata.protocols/metadatas base-provider metadata-spec))))
|
||||
(setting [_this setting-key]
|
||||
(lib.metadata.protocols/setting base-provider setting-key))
|
||||
|
||||
lib.metadata.protocols/CachedMetadataProvider
|
||||
(cached-metadatas [_this metadata-type metadata-ids]
|
||||
(lib.metadata.protocols/cached-metadatas base-provider metadata-type metadata-ids))
|
||||
(store-metadata! [_this a-metadata]
|
||||
(lib.metadata.protocols/store-metadata! base-provider a-metadata))
|
||||
(cached-value [_this k not-found]
|
||||
(lib.metadata.protocols/cached-value base-provider k not-found))
|
||||
(cache-value! [_this k v]
|
||||
(lib.metadata.protocols/cache-value! base-provider k v))
|
||||
(has-cache? [_this]
|
||||
(lib.metadata.protocols/has-cache? base-provider))
|
||||
(clear-cache! [_this]
|
||||
(lib.metadata.protocols/clear-cache! base-provider)))))
|
||||
|
||||
;;;; ---------------------------------------- External Transform Discovery ----------------------------------------
|
||||
|
||||
;; TODO we could join these extra fields onto the graph before calling into this namespace, and save coupling
|
||||
(defn- workspace-outputs
|
||||
"Get all workspace outputs with their global and isolated table info.
|
||||
Includes both workspace transform outputs and enclosed external transform outputs."
|
||||
[workspace-id]
|
||||
(let [ws-outputs (t2/select [:model/WorkspaceOutput
|
||||
:id :ref_id :db_id
|
||||
:global_schema :global_table :global_table_id
|
||||
:isolated_schema :isolated_table :isolated_table_id]
|
||||
:workspace_id workspace-id)
|
||||
;; External outputs use transform_id (int) instead of ref_id (string)
|
||||
;; Map transform_id to ref_id for compatibility with the rest of the validation code
|
||||
ext-outputs (map #(assoc % :ref_id (:transform_id %) :external? true)
|
||||
(t2/select [:model/WorkspaceOutputExternal
|
||||
:id :transform_id :db_id
|
||||
:global_schema :global_table :global_table_id
|
||||
:isolated_schema :isolated_table :isolated_table_id]
|
||||
:workspace_id workspace-id))]
|
||||
(concat ws-outputs ext-outputs)))
|
||||
|
||||
(defn- ids-for-type [entities desired-node-type]
|
||||
(for [{:keys [node-type id]} entities, :when (= node-type desired-node-type)] id))
|
||||
|
||||
(defn- checked-out-transform-ids
|
||||
"Get the global IDs of transforms that are checked out into this workspace.
|
||||
Extracts workspace-transform ref_ids from the graph and looks up their global_ids."
|
||||
[{:keys [entities] :as _graph}]
|
||||
(if-let [ref-ids (seq (ids-for-type entities :workspace-transform))]
|
||||
;; TODO if we keep track of the global_id properties when building the graph, we can save this query.
|
||||
(t2/select-fn-set :global_id
|
||||
[:model/WorkspaceTransform :global_id]
|
||||
:ref_id [:in ref-ids]
|
||||
{:where [:not= :global_id nil]})
|
||||
#{}))
|
||||
|
||||
(defn- enclosed-transform-ids
|
||||
"Get the IDs of external transforms that are enclosed by the workspace graph.
|
||||
These transforms run within the workspace, so they don't need further static validation."
|
||||
[{:keys [entities] :as _graph}]
|
||||
(ids-for-type entities :external-transform))
|
||||
|
||||
(def ^:private table? #{:table})
|
||||
|
||||
(defn- internal-dependents
|
||||
"Build a map of non-table ids to the non-table ids that directly depend on it (internal to workspace).
|
||||
This is essentially an inversion of the dependencies map, with some filtering."
|
||||
[{:keys [dependencies] :as _graph}]
|
||||
;; :dependencies is {child #{parents...}}, we need to invert to {parent #{children...}}
|
||||
(reduce (fn [acc [{child-type :node-type, child-id :id} parents]]
|
||||
(if (table? child-type)
|
||||
acc
|
||||
(reduce (fn [acc {parent-type :node-type, parent-id :id}]
|
||||
(if (table? parent-type)
|
||||
acc
|
||||
(update acc parent-id (fnil conj #{}) child-id)))
|
||||
acc
|
||||
parents)))
|
||||
{}
|
||||
dependencies))
|
||||
|
||||
(defn- external-transform-dependents
|
||||
"Get external transforms that depend on a given table (by global_table_id).
|
||||
Excludes transforms that are checked out or enclosed in the workspace."
|
||||
[global-table-id checked-out-ids enclosed-ids]
|
||||
(when global-table-id
|
||||
(let [dependent-transform-ids (t2/select-fn-set :from_entity_id
|
||||
[:model/Dependency :from_entity_id]
|
||||
:to_entity_type :table
|
||||
:to_entity_id global-table-id
|
||||
:from_entity_type :transform)
|
||||
;; Remove checked out and enclosed transforms
|
||||
external-ids (-> dependent-transform-ids
|
||||
(disj nil)
|
||||
(as-> ids (apply disj ids checked-out-ids))
|
||||
(as-> ids (apply disj ids enclosed-ids)))]
|
||||
(when (seq external-ids)
|
||||
(t2/select [:model/Transform :id :name :source] :id [:in external-ids])))))
|
||||
|
||||
;;;; ---------------------------------------- Validation Logic ----------------------------------------
|
||||
|
||||
(defn- validate-transform-against-fields
|
||||
"Check if a transform would break given a modified set of fields.
|
||||
Returns bad refs if the transform's query references fields that don't exist in replacement-fields."
|
||||
[transform global-table-id replacement-fields]
|
||||
(let [db-id (get-in transform [:source :query :database])]
|
||||
(when db-id
|
||||
(let [base-provider (lib-be/application-database-metadata-provider db-id)
|
||||
override-provider (make-field-override-provider base-provider global-table-id replacement-fields)
|
||||
query (lib/query override-provider (get-in transform [:source :query]))]
|
||||
(lib/find-bad-refs query)))))
|
||||
|
||||
(defn- get-table-fields
|
||||
"Get all active fields for a table by ID."
|
||||
[table-id]
|
||||
(when table-id
|
||||
(t2/select [:model/Field :id :name :base_type :semantic_type]
|
||||
:table_id table-id
|
||||
:active true)))
|
||||
|
||||
(defn- make-problem
|
||||
"Construct a problem map with type metadata from ws.t/problem-types.
|
||||
Splits the namespaced problem-type into :category and :problem keys."
|
||||
[problem-type data]
|
||||
(let [{:keys [severity block-merge description]} (get ws.t/problem-types problem-type)]
|
||||
{:category (keyword (namespace problem-type))
|
||||
:problem (keyword (name problem-type))
|
||||
:severity severity
|
||||
:block_merge block-merge
|
||||
:description description
|
||||
:data data}))
|
||||
|
||||
(defn- problems-for-output
|
||||
"Find problems related to a single workspace output.
|
||||
Returns a sequence of problem maps."
|
||||
[output checked-out-ids enclosed-ids internal-dependents-map]
|
||||
(let [{:keys [ref_id db_id global_schema global_table global_table_id
|
||||
isolated_table_id external?]} output
|
||||
;; For external outputs, ref_id is actually the transform_id (an int)
|
||||
transform-type (if external? :external-transform :workspace-transform)
|
||||
external-transforms (external-transform-dependents global_table_id checked-out-ids enclosed-ids)
|
||||
has-external-dependents? (seq external-transforms)
|
||||
internal-dependents (get internal-dependents-map ref_id)
|
||||
has-internal-dependents? (seq internal-dependents)
|
||||
table-coord {:db_id db_id :schema global_schema :table global_table}]
|
||||
|
||||
(cond
|
||||
;; Case 1: Isolated table doesn't exist yet
|
||||
(nil? isolated_table_id)
|
||||
(cond
|
||||
;; External transforms depend on this output that doesn't exist
|
||||
has-external-dependents?
|
||||
[(make-problem :external-downstream/not-run
|
||||
{:output table-coord
|
||||
:transform {:type transform-type
|
||||
:id ref_id}
|
||||
:dependents (mapv (fn [{:keys [id name]}]
|
||||
{:type :external-transform
|
||||
:id id
|
||||
:name name})
|
||||
external-transforms)})]
|
||||
;; Internal workspace transforms depend on this output that doesn't exist
|
||||
has-internal-dependents?
|
||||
[(make-problem :internal-downstream/not-run
|
||||
{:output table-coord
|
||||
:transform {:type transform-type
|
||||
:id ref_id}
|
||||
:dependents (mapv (fn [dep-ref-id]
|
||||
{:type :workspace-transform
|
||||
:id dep-ref-id})
|
||||
internal-dependents)})]
|
||||
;; No dependents - just informational
|
||||
:else
|
||||
[(make-problem :unused/not-run
|
||||
{:output table-coord
|
||||
:transform {:type transform-type
|
||||
:id ref_id}})])
|
||||
|
||||
;; Case 2: Isolated table exists - check for field breakages
|
||||
has-external-dependents?
|
||||
(let [isolated-fields (get-table-fields isolated_table_id)]
|
||||
(into []
|
||||
(mapcat (fn [transform]
|
||||
(when-let [bad-refs (validate-transform-against-fields
|
||||
transform global_table_id isolated-fields)]
|
||||
[(make-problem :external-downstream/removed-field
|
||||
{:output table-coord
|
||||
:transform {:type :external-transform
|
||||
:id (:id transform)
|
||||
:name (:name transform)}
|
||||
:bad-refs bad-refs})])))
|
||||
external-transforms))
|
||||
|
||||
;; Case 3: No problems
|
||||
:else [])))
|
||||
|
||||
;;;; ---------------------------------------- Public API ----------------------------------------
|
||||
|
||||
(defn find-downstream-problems
|
||||
"Find problems that would affect transforms outside the workspace after merge.
|
||||
|
||||
Takes the workspace graph (from `ws.dag/path-induced-subgraph`) as an argument.
|
||||
|
||||
Returns a sequence of problem maps, each with:
|
||||
- :category - the problem category (e.g. :unused, :internal-downstream, :external-downstream)
|
||||
- :problem - the specific problem (e.g. :not-run, :removed-field)
|
||||
- :severity - :error, :warning, or :info
|
||||
- :block-merge - whether this problem should prevent merging
|
||||
- :data - further data, depending on type
|
||||
|
||||
Currently implemented problem types (category + problem):
|
||||
- :unused/not-run - output table hasn't been created (informational, no dependents)
|
||||
- :internal-downstream/not-run - output table hasn't been created, other workspace transforms need it
|
||||
- :external-downstream/not-run - output table hasn't been created, but external transforms depend on it
|
||||
- :external-downstream/removed-field - a field was removed that external transforms need"
|
||||
[workspace-id graph]
|
||||
(let [outputs (workspace-outputs workspace-id)
|
||||
checked-out-ids (checked-out-transform-ids graph)
|
||||
enclosed-ids (enclosed-transform-ids graph)
|
||||
dependents (internal-dependents graph)]
|
||||
(into []
|
||||
(mapcat #(problems-for-output % checked-out-ids enclosed-ids dependents))
|
||||
outputs)))
|
||||
@@ -57,7 +57,8 @@
|
||||
:collection-cleanup
|
||||
:database-routing
|
||||
:tenants
|
||||
:cloud-custom-smtp}
|
||||
:cloud-custom-smtp
|
||||
:workspaces}
|
||||
(is (= {:advanced_permissions true
|
||||
:attached_dwh true
|
||||
:audit_app true
|
||||
@@ -109,5 +110,6 @@
|
||||
:cloud_custom_smtp true
|
||||
:etl_connections false
|
||||
:etl_connections_pg false
|
||||
:dependencies false}
|
||||
:dependencies false
|
||||
:workspaces true}
|
||||
(:token-features (mt/user-http-request :crowberto :get 200 "session/properties"))))))
|
||||
|
||||
@@ -181,6 +181,20 @@
|
||||
:table #{checkins-id venues-id}}
|
||||
(calculation/upstream-deps:transform transform)))))))
|
||||
|
||||
(deftest ^:parallel upstream-deps-python-transform-test
|
||||
(mt/with-premium-features #{:transforms}
|
||||
(let [products-id (mt/id :products)
|
||||
orders-id (mt/id :orders)]
|
||||
(mt/with-temp [:model/Transform transform {:name "Test Transform"
|
||||
:source {:type :python
|
||||
:source-tables {"PRODUCTS" products-id
|
||||
"ORDERS" orders-id}
|
||||
:body "..."}
|
||||
:target {:schema "PUBLIC"
|
||||
:name "test_output"}}]
|
||||
(is (= {:table #{products-id orders-id}}
|
||||
(calculation/upstream-deps:transform transform)))))))
|
||||
|
||||
(deftest ^:parallel upstream-deps-card-native-with-parameter-source-test
|
||||
(let [mp (mt/metadata-provider)
|
||||
products-id (mt/id :products)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
(ns metabase-enterprise.dependencies.findings-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.dependencies.findings :as deps.findings]
|
||||
[metabase-enterprise.dependencies.models.analysis-finding :as models.analysis-finding]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- backfill-all-entity-analyses! []
|
||||
(doseq [model [:card :transform :segment]]
|
||||
(while (pos? (deps.findings/analyze-batch! model 100)))))
|
||||
|
||||
(deftest ^:sequential can-analyze-entity-batches-test
|
||||
(backfill-all-entity-analyses!)
|
||||
(let [mp (mt/metadata-provider)
|
||||
products-id (mt/id :products)
|
||||
orders-id (mt/id :orders)
|
||||
products (lib.metadata/table mp products-id)
|
||||
orders (lib.metadata/table mp orders-id)]
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query (lib/query mp products)}
|
||||
:model/Card {other-card-id :id} {:dataset_query (lib/query mp orders)}]
|
||||
(is (= 2 (deps.findings/analyze-batch! :card 2)))
|
||||
(is (= #{models.analysis-finding/*current-analysis-finding-version*}
|
||||
(t2/select-fn-set :analysis_version
|
||||
:model/AnalysisFinding
|
||||
:analyzed_entity_id [:in [card-id other-card-id]]
|
||||
:analyzed_entity_type :card)))))))
|
||||
|
||||
(deftest ^:sequential does-not-repeatedly-analyze-entities-test
|
||||
(backfill-all-entity-analyses!)
|
||||
(let [mp (mt/metadata-provider)
|
||||
products-id (mt/id :products)
|
||||
orders-id (mt/id :orders)
|
||||
products (lib.metadata/table mp products-id)
|
||||
orders (lib.metadata/table mp orders-id)]
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/Card _ {:dataset_query (lib/query mp products)}
|
||||
:model/Card _ {:dataset_query (lib/query mp orders)}]
|
||||
(is (= 2 (deps.findings/analyze-batch! :card 2)))
|
||||
(is (= 0 (deps.findings/analyze-batch! :card 2)))))))
|
||||
|
||||
(deftest ^:sequential re-analyze-entities-when-analysis-version-bumped-test
|
||||
(backfill-all-entity-analyses!)
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(is (= 0 (deps.findings/analyze-batch! :card 2)))
|
||||
(binding [models.analysis-finding/*current-analysis-finding-version* (inc models.analysis-finding/*current-analysis-finding-version*)]
|
||||
;; can't check the actual cards analyzed here because this could be any starting card in the data
|
||||
(is (= 2 (deps.findings/analyze-batch! :card 2))))))
|
||||
@@ -0,0 +1,2 @@
|
||||
3:"litellm.ServiceUnavailableError: litellm.MidStreamFallbackError: litellm.APIConnectionError: APIConnectionError: OpenrouterException - Connection closed. Original exception: APIConnectionError: litellm.APIConnectionError: APIConnectionError: OpenrouterException - Connection closed."
|
||||
d:{"finishReason":"error","usage":{"promptTokens":0,"completionTokens":0}}
|
||||
@@ -107,7 +107,19 @@
|
||||
:model/ConnectionImpersonation
|
||||
:model/CloudMigration
|
||||
:model/Comment
|
||||
:model/CommentReaction})
|
||||
:model/CommentReaction
|
||||
;; TODO (lbrdnk 2025/12/17): I've added rest of the workspace models here as Workspace was present. I believe
|
||||
;; going forward all of that will be available for export. We should revisit this later in the project.
|
||||
:model/Workspace
|
||||
:model/WorkspaceDependency
|
||||
:model/WorkspaceInput
|
||||
:model/WorkspaceInputExternal
|
||||
:model/WorkspaceLog
|
||||
:model/WorkspaceMerge
|
||||
:model/WorkspaceMergeTransform
|
||||
:model/WorkspaceOutput
|
||||
:model/WorkspaceOutputExternal
|
||||
:model/WorkspaceTransform})
|
||||
|
||||
(deftest ^:parallel comprehensive-entity-id-test
|
||||
(let [entity-id-models (->> (v2.entity-ids/toucan-models)
|
||||
|
||||
@@ -65,9 +65,11 @@
|
||||
(testing "GET /api/ee/transform-job/:id/transforms"
|
||||
(mt/with-premium-features #{:transforms}
|
||||
(let [crowberto-id (mt/user->id :crowberto)]
|
||||
(mt/with-temp [:model/Transform {transform1-id :id} {:name "tr1" :creator_id crowberto-id}
|
||||
:model/Transform {transform2-id :id} {:name "tr2" :creator_id crowberto-id}
|
||||
:model/Transform _transform3 {}
|
||||
(mt/with-temp [:model/Transform {transform1-id :id} {:name "tr1" :creator_id crowberto-id
|
||||
:target {:database (mt/id)}}
|
||||
:model/Transform {transform2-id :id} {:name "tr2" :creator_id crowberto-id
|
||||
:target {:database (mt/id)}}
|
||||
:model/Transform _transform3 {:target {:database (mt/id)}}
|
||||
:model/TransformTag {tag-id :id} {}
|
||||
:model/TransformTransformTag _ {:transform_id transform1-id :tag_id tag-id :position 0}
|
||||
:model/TransformTransformTag _ {:transform_id transform2-id :tag_id tag-id :position 0}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
with-transform-cleanup!]]
|
||||
[metabase-enterprise.transforms.util :as transforms.util]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.lib-be.metadata.jvm :as lib.metadata.jvm]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.query-processor :as qp]
|
||||
@@ -278,6 +279,139 @@
|
||||
(is (=? [{:id t2-id}]
|
||||
(mt/user-http-request :crowberto :get 200 "ee/transform" :tag_ids [tag2-id])))))))))
|
||||
|
||||
(deftest filter-transforms-by-type-test
|
||||
(testing "should be able to filter transforms by source type"
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
|
||||
(mt/with-premium-features #{:transforms :transforms-python}
|
||||
(let [mp (mt/metadata-provider)
|
||||
nq (lib/native-query mp "select 1")]
|
||||
(mt/with-temp [:model/Transform {native-transform-id :id}
|
||||
{:name "Native Transform"
|
||||
:source {:type "query"
|
||||
:query nq}
|
||||
:target {:type "table"
|
||||
:name (str "test_native_" (u/generate-nano-id))}}
|
||||
:model/Transform {python-transform-id :id}
|
||||
{:name "Python Transform"
|
||||
:source {:type "python"
|
||||
:body "print('hello')"
|
||||
:source-tables {}}
|
||||
:target {:type "table"
|
||||
:name (str "test_python_" (u/generate-nano-id))
|
||||
:database (mt/id)}}]
|
||||
(testing "filter by native type"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "ee/transform" :type ["native"])]
|
||||
(is (some #(= native-transform-id (:id %)) results))
|
||||
(is (not (some #(= python-transform-id (:id %)) results)))))
|
||||
(testing "filter by python type"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "ee/transform" :type ["python"])]
|
||||
(is (some #(= python-transform-id (:id %)) results))
|
||||
(is (not (some #(= native-transform-id (:id %)) results)))))
|
||||
(testing "filter by both types returns all"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "ee/transform" :type ["native" "python"])]
|
||||
(is (some #(= native-transform-id (:id %)) results))
|
||||
(is (some #(= python-transform-id (:id %)) results))))))))))
|
||||
|
||||
(deftest filter-transforms-by-database-id-test
|
||||
(testing "should be able to filter transforms by database_id"
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
|
||||
(mt/with-premium-features #{:transforms}
|
||||
(mt/with-temp [:model/Database {other-db-id :id} {:engine :h2 :details {}}
|
||||
:model/Transform {transform1-id :id} {:name "Transform for main DB"
|
||||
:source {:type "query"
|
||||
:query {:database (mt/id)
|
||||
:type "native"
|
||||
:native {:query "SELECT 1"
|
||||
:template-tags {}}}}
|
||||
:target_db_id (mt/id)
|
||||
:target {:type "table"
|
||||
:name (str "test_main_" (u/generate-nano-id))}}
|
||||
:model/Transform {transform2-id :id} {:name "Transform for other DB"
|
||||
:source {:type "query"
|
||||
:query {:database other-db-id
|
||||
:type "native"
|
||||
:native {:query "SELECT 2"
|
||||
:template-tags {}}}}
|
||||
:target_db_id other-db-id
|
||||
:target {:type "table"
|
||||
:name (str "test_other_" (u/generate-nano-id))}}]
|
||||
(testing "filter by main database id"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "ee/transform" :database_id (mt/id))]
|
||||
(is (some #(= transform1-id (:id %)) results))
|
||||
(is (not (some #(= transform2-id (:id %)) results)))))
|
||||
(testing "filter by other database id"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "ee/transform" :database_id other-db-id)]
|
||||
(is (some #(= transform2-id (:id %)) results))
|
||||
(is (not (some #(= transform1-id (:id %)) results)))))
|
||||
(testing "filter by non-existent database returns empty for our transforms"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "ee/transform" :database_id 999999)]
|
||||
(is (not (some #(= transform1-id (:id %)) results)))
|
||||
(is (not (some #(= transform2-id (:id %)) results))))))))))
|
||||
|
||||
(deftest filter-transforms-by-database-id-target-test
|
||||
(testing "should match transforms where target database matches"
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
|
||||
(mt/with-premium-features #{:transforms :transforms-python}
|
||||
(mt/with-temp [:model/Database {target-db-id :id} {:engine :h2 :details {}}
|
||||
:model/Transform {transform-id :id} {:name "Python Transform with target DB"
|
||||
:source {:type "python"
|
||||
:body "print('hello')"
|
||||
:source-tables {}}
|
||||
:target_db_id target-db-id
|
||||
:target {:type "table"
|
||||
:name (str "test_target_" (u/generate-nano-id))
|
||||
:database target-db-id}}]
|
||||
(testing "filter by target database id"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "ee/transform" :database_id target-db-id)]
|
||||
(is (some #(= transform-id (:id %)) results)))))))))
|
||||
|
||||
(deftest filter-transforms-combined-test
|
||||
(testing "should be able to combine database_id and type filters"
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
|
||||
(mt/with-premium-features #{:transforms :transforms-python}
|
||||
(let [nq (lib/native-query (mt/metadata-provider) "select 1")]
|
||||
(mt/with-temp [:model/Database {other-db-id :id} {:engine :h2 :details {}}
|
||||
:model/Transform {query-main-id :id}
|
||||
{:name "Query on main DB"
|
||||
:source_type :native
|
||||
:source {:type "query"
|
||||
:query nq}
|
||||
:target {:type "table"
|
||||
:name (str "test_qm_" (u/generate-nano-id))}}
|
||||
:model/Transform {python-main-id :id}
|
||||
{:name "Python on main DB"
|
||||
:source_type :python
|
||||
:source {:type "python"
|
||||
:body "print('hello')"
|
||||
:source-tables {}}
|
||||
:target {:type "table"
|
||||
:name (str "test_pm_" (u/generate-nano-id))
|
||||
:database (mt/id)}}
|
||||
:model/Transform {query-other-id :id}
|
||||
{:name "Query on other DB"
|
||||
:source_type :native
|
||||
:source {:type "query"
|
||||
:query (lib/native-query
|
||||
(lib.metadata.jvm/application-database-metadata-provider other-db-id)
|
||||
"select 2")}
|
||||
:target {:type "table"
|
||||
:name (str "test_qo_" (u/generate-nano-id))
|
||||
:database other-db-id}}]
|
||||
(testing "filter by main database and query type"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "ee/transform"
|
||||
:database_id (mt/id)
|
||||
:type ["native"])]
|
||||
(is (some #(= query-main-id (:id %)) results))
|
||||
(is (not (some #(= python-main-id (:id %)) results)))
|
||||
(is (not (some #(= query-other-id (:id %)) results)))))
|
||||
(testing "filter by main database and python type"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "ee/transform"
|
||||
:database_id (mt/id)
|
||||
:type ["python"])]
|
||||
(is (some #(= python-main-id (:id %)) results))
|
||||
(is (not (some #(= query-main-id (:id %)) results)))
|
||||
(is (not (some #(= query-other-id (:id %)) results)))))))))))
|
||||
|
||||
(deftest get-transforms-test
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
|
||||
(mt/with-premium-features #{:transforms}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[clojure.test :refer :all]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.sql :as driver.sql]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.data.interface :as tx]
|
||||
[metabase.util :as u]
|
||||
@@ -15,13 +16,16 @@
|
||||
|
||||
(defn drop-target!
|
||||
"Drop transform target `target` and clean up its metadata.
|
||||
`target` can be a string or a map. If `target` is a string, type :table is assumed."
|
||||
`target` can be a string or a map. If `target` is a string, type :table is assumed.
|
||||
If no schema is provided, uses the driver's default schema."
|
||||
[target]
|
||||
(let [target (if (map? target)
|
||||
target
|
||||
;; assume this is just a plain table name
|
||||
{:type :table, :name target})
|
||||
driver driver/*driver*]
|
||||
(let [driver driver/*driver*
|
||||
target (cond-> (if (map? target)
|
||||
target
|
||||
;; assume this is just a plain table name
|
||||
{:type :table, :name target})
|
||||
(nil? (:schema target))
|
||||
(assoc :schema (driver.sql/default-schema driver)))]
|
||||
(binding [api/*is-superuser?* true
|
||||
api/*current-user-id* (mt/user->id :crowberto)]
|
||||
;; Drop the actual table/view from the database
|
||||
|
||||
@@ -108,3 +108,44 @@
|
||||
(catch Exception _e
|
||||
;; Ignore cleanup errors
|
||||
nil)))))))))
|
||||
|
||||
;;; ------------------------------------------------------------
|
||||
;;; Filter xf tests
|
||||
;;; ------------------------------------------------------------
|
||||
|
||||
(deftest ^:parallel matching-timestamp?-test
|
||||
(testing "matching-timestamp? checks if a timestamp falls within a date range [start, end)"
|
||||
(let [matching-timestamp? #'transforms.util/matching-timestamp?
|
||||
field-path [:start_time]
|
||||
range-jan-feb {:start "2024-01-01T00:00:00Z" :end "2024-02-01T00:00:00Z"}
|
||||
range-start-only {:start "2024-01-01T00:00:00Z" :end nil}
|
||||
range-end-only {:start nil :end "2024-02-01T00:00:00Z"}]
|
||||
|
||||
(testing "with both start and end bounds"
|
||||
(are [expected timestamp]
|
||||
(= expected (matching-timestamp? {:start_time timestamp} field-path range-jan-feb))
|
||||
nil nil ; missing field returns nil
|
||||
true "2024-01-15T12:00:00Z" ; timestamp in middle of range
|
||||
false "2023-12-15T12:00:00Z" ; timestamp before range
|
||||
false "2024-02-15T12:00:00Z" ; timestamp after range
|
||||
true "2024-01-01T00:00:00Z" ; start boundary is inclusive
|
||||
true "2024-02-01T00:00:00Z")) ; end boundary is inclusive too 🤷
|
||||
|
||||
(testing "with only start bound"
|
||||
(are [expected timestamp]
|
||||
(= expected (matching-timestamp? {:start_time timestamp} field-path range-start-only))
|
||||
true "2024-01-15T12:00:00Z" ; timestamp after start
|
||||
true "2024-02-15T12:00:00Z" ; any timestamp after start
|
||||
false "2023-12-15T12:00:00Z")) ; timestamp before start
|
||||
|
||||
(testing "with only end bound"
|
||||
(are [expected timestamp]
|
||||
(= expected (matching-timestamp? {:start_time timestamp} field-path range-end-only))
|
||||
true "2024-01-15T12:00:00Z" ; timestamp before end
|
||||
true "2023-12-15T12:00:00Z" ; any timestamp before end
|
||||
false "2024-02-15T12:00:00Z")) ; timestamp after end
|
||||
|
||||
(testing "returns nil when field value is missing"
|
||||
(are [job] (nil? (matching-timestamp? job field-path range-jan-feb))
|
||||
{}
|
||||
{:other "value"})))))
|
||||
|
||||
1725
enterprise/backend/test/metabase_enterprise/workspaces/api_test.clj
Normal file
@@ -0,0 +1,113 @@
|
||||
(ns metabase-enterprise.workspaces.dag-abstract
|
||||
"Abstract graph solver using shorthand notation for testing.
|
||||
|
||||
Shorthand uses keywords like :x1, :t2, :m3 where:
|
||||
- :x<n> is a transform
|
||||
- :t<n> is a table (source/generated)
|
||||
- :m<n> is a model (card)"
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[flatland.ordered.map :as ordered-map]
|
||||
[metabase-enterprise.workspaces.util :as ws.u]
|
||||
[metabase.util :as u]))
|
||||
|
||||
;;;; Shorthand notation helpers
|
||||
|
||||
(defn- related [t id]
|
||||
(keyword (str (name t) (subs (name id) 1))))
|
||||
|
||||
(def ^:private x->t (partial related :t))
|
||||
|
||||
(defn is?
|
||||
"Check if id is of type t (e.g., (is? :x :x1) => true)."
|
||||
[t id]
|
||||
(= (name t) (str (first (name id)))))
|
||||
|
||||
(defn- lexico [id]
|
||||
(let [n (name id)]
|
||||
[(parse-long (subs n 1))
|
||||
({\x 1 \t 2 \m 3} (first n))]))
|
||||
|
||||
(defn expand-shorthand
|
||||
"As shorthand, we can use transforms in the place of their induced tables.
|
||||
This function expands the graph to insert the induced tables, and replace the corresponding references."
|
||||
[dependencies]
|
||||
(let [xs (set (filter #(is? :x %) (concat (keys dependencies) (mapcat val dependencies))))]
|
||||
(into (sorted-map-by #(compare (lexico %1) (lexico %2)))
|
||||
(merge (zipmap (map x->t xs) (map vector xs))
|
||||
(into {} (filter (comp #(is? :t %) key)) dependencies)
|
||||
(update-vals
|
||||
(into {} (remove (comp #(is? :t %) key)) dependencies)
|
||||
(fn [deps] (->> deps
|
||||
(map (fn [id]
|
||||
(if (is? :x id)
|
||||
(x->t id)
|
||||
id)))
|
||||
(sort-by lexico)
|
||||
(vec))))))))
|
||||
|
||||
;;;; Graph traversal helpers
|
||||
|
||||
(defn- children [dependencies]
|
||||
(reduce
|
||||
(fn [acc [child parents]]
|
||||
(reduce (fn [acc p] (update acc p u/conjv child)) acc parents))
|
||||
(sorted-map-by #(compare (lexico %1) (lexico %2)))
|
||||
dependencies))
|
||||
|
||||
(defn- ->ts [ids]
|
||||
(into #{}
|
||||
(keep (fn [id]
|
||||
(cond (is? :t id) id
|
||||
(is? :x id) (x->t id))))
|
||||
ids))
|
||||
|
||||
;;;; Toposort
|
||||
|
||||
(defn- toposort-map [child->parents]
|
||||
(let [child->parents (if (map? child->parents) child->parents (into {} child->parents))]
|
||||
(into (ordered-map/ordered-map)
|
||||
(keep (fn [n] (when-let [deps (get child->parents n)] [n deps])))
|
||||
(ws.u/toposort-dfs child->parents))))
|
||||
|
||||
;;;; Abstract path-induced subgraph solver
|
||||
|
||||
(def ^:private max-iterations 100)
|
||||
|
||||
(defn path-induced-subgraph
|
||||
"Abstract solver for path-induced subgraph using shorthand notation."
|
||||
[{:keys [check-outs dependencies] :as _graph}]
|
||||
(let [->parents dependencies
|
||||
->children (children dependencies)]
|
||||
(loop [iteration 1
|
||||
below check-outs
|
||||
above check-outs
|
||||
seen-down? check-outs
|
||||
seen-up? check-outs
|
||||
to-descend check-outs
|
||||
to-ascend check-outs]
|
||||
(if (and (empty? to-descend) (empty? to-ascend))
|
||||
(let [nodes (set/intersection above below)
|
||||
entities (remove #(is? :t %) nodes)
|
||||
tables (sort-by lexico (filter #(is? :t %) nodes))]
|
||||
{:check-outs (sort-by lexico check-outs)
|
||||
:inputs (sort-by lexico (remove nodes (->ts (mapcat ->parents check-outs))))
|
||||
:tables tables
|
||||
:transforms (sort-by lexico (filter #(is? :x %) nodes))
|
||||
:entities (sort-by lexico entities)
|
||||
:dependencies (apply dissoc
|
||||
(toposort-map
|
||||
(for [n nodes]
|
||||
[n (vec (filter nodes (get dependencies n)))]))
|
||||
tables)})
|
||||
(if (> iteration max-iterations)
|
||||
{:error "max iterations exceeded"}
|
||||
(let [next-down (into #{} (comp (mapcat ->children) (remove seen-down?)) to-descend)
|
||||
next-up (into #{} (comp (mapcat ->parents) (remove seen-up?)) to-ascend)]
|
||||
(recur (inc iteration)
|
||||
(into below next-down)
|
||||
(into above next-up)
|
||||
(into seen-down? next-down)
|
||||
(into seen-up? next-up)
|
||||
next-down
|
||||
next-up)))))))
|
||||
@@ -0,0 +1,313 @@
|
||||
(ns metabase-enterprise.workspaces.dag-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[flatland.ordered.map :as ordered-map]
|
||||
[metabase-enterprise.workspaces.dag :as ws.dag]
|
||||
[metabase-enterprise.workspaces.dag-abstract :as dag-abstract]
|
||||
[metabase-enterprise.workspaces.impl :as ws.impl]
|
||||
[metabase-enterprise.workspaces.test-util :as ws.tu]
|
||||
[metabase.app-db.core :as app-db]
|
||||
[metabase.driver.sql.normalize :as sql.normalize]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures
|
||||
:once
|
||||
(fn [f]
|
||||
(mt/with-premium-features [:dependencies :transforms :workspaces]
|
||||
(mt/with-model-cleanup [:model/Dependency :model/Transform :model/Table]
|
||||
(f)))))
|
||||
|
||||
;;;; Example graphs for testing
|
||||
|
||||
(def ^:private example-graph
|
||||
{:x3 [:x1 :t2]
|
||||
:x4 [:x3]
|
||||
:m6 [:x4 :t5]
|
||||
:m10 [:t9]
|
||||
:x11 [:m10]
|
||||
:m12 [:x11]
|
||||
:m13 [:x11 :m12]})
|
||||
|
||||
;;;; Test data helpers
|
||||
|
||||
(defn- transform? [kw] (= \x (first (name kw))))
|
||||
(defn- table? [kw] (= \t (first (name kw))))
|
||||
(defn- kw->id [kw] (parse-long (subs (name kw) 1)))
|
||||
|
||||
(defn- create-test-graph!
|
||||
"Create test transforms and tables from shorthand notation, returning id mappings.
|
||||
|
||||
Shorthand uses keywords like :x1, :t2, :m3 where:
|
||||
- :x<n> is a transform
|
||||
- :t<n> is a table (source/generated)
|
||||
- :m<n> is a model (card)
|
||||
|
||||
Returns a map from shorthand id to real database id, e.g. {:x1 123, :t2 456}
|
||||
|
||||
NOTE: Caller should wrap in mt/with-model-cleanup for proper cleanup."
|
||||
[dependencies]
|
||||
(let [schema (str/replace (str (random-uuid)) "-" "_")
|
||||
all-ids (set (concat (keys dependencies)
|
||||
(mapcat val dependencies)))
|
||||
transforms (filter transform? all-ids)
|
||||
driver (t2/select-one-fn :engine [:model/Database :engine] (mt/id))
|
||||
normalize #(sql.normalize/normalize-name driver %)
|
||||
tables (filter table? all-ids)
|
||||
table-ids (u/for-map [t tables]
|
||||
[t (t2/insert-returning-pk! :model/Table
|
||||
{:db_id (mt/id)
|
||||
:schema (some-> schema normalize)
|
||||
:name (normalize (str "test_table_" (kw->id t)))
|
||||
:active true})])
|
||||
;; Create transforms that reference their input tables
|
||||
tx-ids (u/for-map [tx transforms]
|
||||
(let [parent-tables (->> (get dependencies tx []) (filter table?) (map table-ids))
|
||||
id (t2/insert-returning-pk! :model/Transform
|
||||
{:name (str "Test " (name tx))
|
||||
:source {:type :query
|
||||
:query (if (seq parent-tables)
|
||||
#_{:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table (first parent-tables)
|
||||
:joins (for [pt (rest parent-tables)]
|
||||
{:source-table pt
|
||||
:condition [:= 1 1]})}}
|
||||
{:database (mt/id)
|
||||
:type :native
|
||||
:native {:query (->> (t2/select-fn-vec #(str (:schema %) "." (:name %))
|
||||
[:model/Table :schema :name]
|
||||
:id [:in parent-tables])
|
||||
(str/join ", ")
|
||||
(str "SELECT * FROM "))}}
|
||||
{:database (mt/id)
|
||||
:type :native
|
||||
:native {:query "SELECT 1"}})}
|
||||
:target {:type "table"
|
||||
:database (mt/id)
|
||||
:schema schema
|
||||
:name (str "test_table_" (kw->id tx))}})]
|
||||
[tx id]))]
|
||||
|
||||
;; This is a workaround for the dependency between transforms and their output only being inserted on run.
|
||||
(doseq [[tx-kw tx-id] tx-ids]
|
||||
(app-db/update-or-insert! :model/Dependency
|
||||
{:to_entity_type "transform"
|
||||
:to_entity_id tx-id
|
||||
:from_entity_type "table"
|
||||
:from_entity_id (table-ids (keyword (str "t" (kw->id tx-kw))))}))
|
||||
|
||||
(merge tx-ids table-ids)))
|
||||
|
||||
(defn- translate-result
|
||||
"Translate result from real IDs back to shorthand notation for easier comparison."
|
||||
[{:keys [inputs outputs entities dependencies] :as _result} id-map]
|
||||
(let [reverse-map (u/for-map [[k v] id-map] [v k])
|
||||
table->kw (comp reverse-map :id)
|
||||
node->kw (fn [{:keys [node-type id]}]
|
||||
(reverse-map (case node-type
|
||||
:table (:id id)
|
||||
:external-transform id
|
||||
:workspace-transform (t2/select-one-fn :global_id [:model/WorkspaceTransform :global_id] :ref_id id))))]
|
||||
{:inputs (into #{} (map table->kw) inputs)
|
||||
:outputs (into #{} (map table->kw) outputs)
|
||||
:entities (into #{} (map node->kw) entities)
|
||||
:dependencies (u/for-map [[child parents] dependencies]
|
||||
[(node->kw child) (into #{} (map node->kw parents))])}))
|
||||
|
||||
;;;; Tests
|
||||
|
||||
(deftest path-induced-subgraph-shorthand-test
|
||||
(testing "graph built from shorthand matches abstract solver"
|
||||
(let [shorthand {:x2 [:t1]}
|
||||
id-map (create-test-graph! (dag-abstract/expand-shorthand shorthand))
|
||||
gtx (t2/select-one :model/Transform (id-map :x2))]
|
||||
;; TODO make this nice and declarative too
|
||||
(mt/with-temp [:model/Workspace ws {:name "Test Workspace", :database_id (mt/id)}
|
||||
:model/WorkspaceTransform wtx (merge
|
||||
(select-keys gtx [:name :source :target])
|
||||
{:workspace_id (:id ws)
|
||||
:global_id (:id gtx)})]
|
||||
(ws.tu/analyze-workspace! (:id ws))
|
||||
(let [entity {:entity-type :transform, :id (:ref_id wtx)}
|
||||
result (ws.dag/path-induced-subgraph (:id ws) [entity])
|
||||
translated (translate-result result id-map)]
|
||||
(is (=? {:inputs #{:t1}
|
||||
:outputs #{:t2}
|
||||
:entities #{:x2}
|
||||
:dependencies {:x2 #{:t1}}}
|
||||
translated)))))))
|
||||
|
||||
(deftest path-induced-subgraph-larger-test
|
||||
(testing "graph built from shorthand matches abstract solver"
|
||||
;; check-outs x2, x4
|
||||
(let [shorthand {:x1 [:t0]
|
||||
:x2 [:x1, :t10]
|
||||
:x3 [:x2, :t8]
|
||||
:x4 [:x3]
|
||||
:x5 [:x2, :x4, :t9]}
|
||||
id-map (create-test-graph! (dag-abstract/expand-shorthand shorthand))
|
||||
gtx-2 (t2/select-one :model/Transform (id-map :x2))
|
||||
gtx-4 (t2/select-one :model/Transform (id-map :x4))
|
||||
fork (fn [ws gtx]
|
||||
(merge
|
||||
(select-keys gtx [:name :source :target])
|
||||
{:workspace_id (:id ws)
|
||||
:global_id (:id gtx)}))]
|
||||
;; TODO make a convenience method / method for doing these checkouts too
|
||||
(mt/with-temp [:model/Workspace ws {:name "Test Workspace", :database_id (mt/id)}
|
||||
:model/WorkspaceTransform wtx-2 (fork ws gtx-2)
|
||||
:model/WorkspaceTransform wtx-4 (fork ws gtx-4)]
|
||||
(ws.tu/analyze-workspace! (:id ws))
|
||||
(let [entities (for [wtx [wtx-2 wtx-4]]
|
||||
{:entity-type :transform, :id (:ref_id wtx)})
|
||||
result (ws.dag/path-induced-subgraph (:id ws) entities)
|
||||
translated (translate-result result id-map)]
|
||||
(is (=? {:inputs #{:t1 :t8 :t10}
|
||||
:outputs #{:t2 :t3 :t4}
|
||||
:entities #{:x2 :x3 :x4}
|
||||
:dependencies {:x2 #{:t1 :t10}
|
||||
:x3 #{:x2 :t8}
|
||||
:x4 #{:x3}}}
|
||||
translated)))))))
|
||||
|
||||
(deftest expand-solver-test
|
||||
(testing "expand-shorthand inserts interstitial nodes for transform output tables"
|
||||
(is (= {:t1 [:x1]
|
||||
:x3 [:t1 :t2]
|
||||
:t3 [:x3]
|
||||
:x4 [:t3]
|
||||
:t4 [:x4]
|
||||
:m6 [:t4 :t5]
|
||||
:m10 [:t9]
|
||||
:x11 [:m10]
|
||||
:t11 [:x11]
|
||||
:m12 [:t11]
|
||||
:m13 [:t11 :m12]}
|
||||
(dag-abstract/expand-shorthand example-graph)))))
|
||||
|
||||
(deftest abstract-path-induced-subgraph-test
|
||||
(testing "path-induced-subgraph computes correct result for example graph"
|
||||
(is (= {:check-outs [:x3 :m6 :m10 :m13]
|
||||
:inputs [:t1 :t2 :t5 :t9]
|
||||
:tables [:t3 :t4 :t11]
|
||||
:transforms [:x3 :x4 :x11]
|
||||
:entities [:x3 :x4 :m6 :m10 :x11 :m12 :m13]
|
||||
:dependencies (ordered-map/ordered-map
|
||||
:m10 []
|
||||
:x11 [:m10]
|
||||
:m12 [:t11]
|
||||
:m13 [:t11 :m12]
|
||||
:x3 []
|
||||
:x4 [:t3]
|
||||
:m6 [:t4])}
|
||||
(dag-abstract/path-induced-subgraph
|
||||
{:check-outs #{:x3 :m6 :m10 :m13}
|
||||
:dependencies (dag-abstract/expand-shorthand example-graph)})))))
|
||||
|
||||
;;;; Card dependency detection tests
|
||||
|
||||
(deftest unsupported-dependency-no-transforms-test
|
||||
(testing "empty input returns nil"
|
||||
(is (nil? (ws.dag/unsupported-dependency? {})))
|
||||
(is (nil? (ws.dag/unsupported-dependency? {:transforms []})))))
|
||||
|
||||
(deftest unsupported-dependency-mbql-card-test
|
||||
#_(testing "transform with card dependencies are unsupported"
|
||||
(let [tx-with-no-dependencies 1
|
||||
;; These tests transforms must seem pretty redundant - but it's intentional: we may relax them one case at
|
||||
;; at time in the future.
|
||||
;; ----------------------
|
||||
;; We don't allow MBQL card dependencies, as we do not support re-mapping their references on execution.
|
||||
;; This is only a problem if the card depends on a table that is shadowed in the isolated schema, but we
|
||||
;; want to keep the semantics simple.
|
||||
tx-with-direct-mbql-card-dependency 2
|
||||
;; Even a transitive dependency would need to be remapped.
|
||||
tx-with-indirect-mbql-card-dependency 3
|
||||
;; In fact, we don't allow *any* card dependency for two reasons:
|
||||
;; 1. We don't yet support reference re-mapping across entity references.
|
||||
;; 2. It's an anti-pattern to build transforms on models, the relationship is intended to be the other way
|
||||
;; around.
|
||||
tx-with-sql-card-dependency 4]
|
||||
(is (= nil (ws.dag/unsupported-dependency? {:transforms [1]})))
|
||||
(is (= {:transforms [2 3 4]} (ws.dag/unsupported-dependency? {:transforms [1 2 3 4]}))))))
|
||||
|
||||
(deftest collapse-test
|
||||
(is (= {:x1 [:x2 :x3]
|
||||
:x2 [:t3]
|
||||
:x3 [:x5]
|
||||
:x4 []
|
||||
:x5 []}
|
||||
(#'ws.dag/collapse
|
||||
table?
|
||||
{:x1 [:t1 :t2]
|
||||
:t1 [:x2]
|
||||
:t2 [:x3]
|
||||
:x2 [:t3]
|
||||
:x3 [:t4]
|
||||
:t4 [:x5]
|
||||
:t5 [:x4]
|
||||
:x4 []
|
||||
:x5 []}))))
|
||||
|
||||
(defn tx->table [kw]
|
||||
(when (transform? kw)
|
||||
(keyword (str "t" (kw->id kw)))))
|
||||
|
||||
(defn- solve-in-memory [init-nodes graph]
|
||||
(let [tx-nodes (filter transform? init-nodes)
|
||||
tables (map tx->table tx-nodes)]
|
||||
(#'ws.dag/path-induced-subgraph*
|
||||
;; Include all changeset targets in the init-nodes
|
||||
(distinct (into init-nodes tables))
|
||||
{:node-parents (dag-abstract/expand-shorthand graph)
|
||||
:table? table?
|
||||
:table-sort kw->id
|
||||
:unwrap-table identity})))
|
||||
|
||||
(defn- chain->deps [chain]
|
||||
(reduce
|
||||
(fn [deps [from to]]
|
||||
(assoc deps from [to]))
|
||||
{}
|
||||
(partition 2 1 (reverse chain))))
|
||||
|
||||
(deftest in-memory-path-induced-subgraph-test
|
||||
(testing "singleton"
|
||||
(is (= {:inputs [:t1]
|
||||
:outputs [:t2]
|
||||
:entities [:x2]
|
||||
:dependencies {:x2 [:t1]}}
|
||||
(solve-in-memory [:x2] {:x2 [:t1]}))))
|
||||
|
||||
(testing "encloses middle of a chain"
|
||||
(is (= {:inputs [:t1]
|
||||
:outputs [:t2 :t3 :t4]
|
||||
:entities [:x2 :x3 :x4]
|
||||
:dependencies {:x2 [:t1]
|
||||
:x3 [:x2]
|
||||
:x4 [:x3]}}
|
||||
(solve-in-memory [:x2 :x4] (chain->deps [:x1 :x2 :x3 :x4 :x5])))))
|
||||
|
||||
(testing "larger graph"
|
||||
(is (= {:inputs [:t1 :t2 :t5 :t9]
|
||||
:outputs [:t3 :t4 :t11]
|
||||
:entities [:m10 :x11 :m12 :m13 :x3 :x4 :m6]
|
||||
:dependencies {:m10 [:t9]
|
||||
:m12 [:x11]
|
||||
:m13 [:m12 :x11]
|
||||
:m6 [:x4 :t5]
|
||||
:x11 [:m10]
|
||||
:x3 [:t2 :t1]
|
||||
:x4 [:x3]}}
|
||||
(solve-in-memory
|
||||
[:x3 :m6 :m10 :m13]
|
||||
{:x3 [:x1 :t2]
|
||||
:x4 [:x3]
|
||||
:m6 [:x4 :t5]
|
||||
:m10 [:t9]
|
||||
:x11 [:m10]
|
||||
:m12 [:x11]
|
||||
:m13 [:x11 :m12]})))))
|
||||
@@ -0,0 +1,318 @@
|
||||
(ns metabase-enterprise.workspaces.dependencies-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.workspaces.dependencies :as ws.deps]
|
||||
[metabase-enterprise.workspaces.test-util :as ws.tu]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(ws.tu/ws-fixtures!)
|
||||
|
||||
;;; ---------------------------------------- analyze-entity tests ----------------------------------------
|
||||
|
||||
(deftest ^:parallel analyze-entity-query-transform-test
|
||||
(testing "analyze-entity extracts dependencies from a query transform"
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (lib/query mp (lib.metadata/table mp (mt/id :orders)))
|
||||
transform {:source {:type "query"
|
||||
:query query}
|
||||
:target {:database (mt/id)
|
||||
:schema "public"
|
||||
:name "orders_with_products"}}
|
||||
result (ws.deps/analyze-entity :transform transform)]
|
||||
(testing "output is extracted from target"
|
||||
(is (= {:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "orders_with_products"}
|
||||
(:output result))))
|
||||
(testing "inputs contain the source table with table_id (since table exists)"
|
||||
(let [inputs (:inputs result)
|
||||
table-names (set (map :table inputs))]
|
||||
(is (= 1 (count inputs)))
|
||||
(is (contains? table-names (mt/format-name :orders)))
|
||||
;; MBQL queries have table_id since the tables must exist
|
||||
(is (every? #(and (:db_id %)
|
||||
(:table %)
|
||||
(:table_id %))
|
||||
inputs)))))))
|
||||
|
||||
(deftest ^:parallel analyze-entity-native-query-existing-table-test
|
||||
(testing "analyze-entity extracts dependencies from native query referencing existing table"
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (lib/native-query mp "SELECT * FROM ORDERS WHERE total > 100")
|
||||
transform {:source {:type "query"
|
||||
:query query}
|
||||
:target {:database (mt/id)
|
||||
:schema "analytics"
|
||||
:name "high_value_orders"}}
|
||||
result (ws.deps/analyze-entity :transform transform)]
|
||||
(testing "output is extracted from target"
|
||||
(is (= {:db_id (mt/id)
|
||||
:schema "analytics"
|
||||
:table "high_value_orders"}
|
||||
(:output result))))
|
||||
(testing "inputs include table_id since ORDERS table exists"
|
||||
(let [inputs (:inputs result)]
|
||||
;; Native query parsing should detect ORDERS table reference and look up table_id
|
||||
(is (pos? (count inputs)))
|
||||
(is (every? #(and (:db_id %)
|
||||
(:table %)
|
||||
(:table_id %))
|
||||
inputs)))))))
|
||||
|
||||
(deftest ^:parallel analyze-entity-native-query-nonexistent-table-test
|
||||
(testing "analyze-entity handles native query referencing non-existent table"
|
||||
(let [mp (mt/metadata-provider)
|
||||
;; Reference a table that doesn't exist in the database
|
||||
query (lib/native-query mp "SELECT * FROM future_sales_data WHERE region = 'US'")
|
||||
transform {:source {:type "query"
|
||||
:query query}
|
||||
:target {:database (mt/id)
|
||||
:schema "analytics"
|
||||
:name "regional_sales"}}
|
||||
result (ws.deps/analyze-entity :transform transform)]
|
||||
(testing "output is extracted from target"
|
||||
(is (= {:db_id (mt/id)
|
||||
:schema "analytics"
|
||||
:table "regional_sales"}
|
||||
(:output result))))
|
||||
(testing "inputs have logical reference but no table_id (table doesn't exist)"
|
||||
(let [inputs (:inputs result)]
|
||||
;; Should still parse the table reference even though table doesn't exist
|
||||
(is (pos? (count inputs)))
|
||||
;; Has db_id and table name but NOT table_id
|
||||
(is (every? #(and (:db_id %)
|
||||
(:table %))
|
||||
inputs))
|
||||
;; Verify table_id is nil or missing for non-existent tables
|
||||
(is (every? #(nil? (:table_id %)) inputs)))))))
|
||||
|
||||
(deftest ^:parallel analyze-entity-python-transform-test
|
||||
(testing "analyze-entity extracts dependencies from a python transform"
|
||||
(let [transform {:source {:type "python"
|
||||
:source-tables {"orders" (mt/id :orders)
|
||||
"products" (mt/id :products)}
|
||||
:body "def transform(orders, products): return orders"}
|
||||
:target {:database (mt/id)
|
||||
:schema "public"
|
||||
:name "python_result"}}
|
||||
result (ws.deps/analyze-entity :transform transform)]
|
||||
(testing "output is extracted from target"
|
||||
(is (= {:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "python_result"}
|
||||
(:output result))))
|
||||
(testing "inputs contain the source tables with table_ids"
|
||||
(let [inputs (:inputs result)
|
||||
table-names (set (map :table inputs))]
|
||||
(is (= 2 (count inputs)))
|
||||
(is (contains? table-names (mt/format-name :orders)))
|
||||
(is (contains? table-names (mt/format-name :products)))
|
||||
;; Python transforms have table_ids since source-tables maps to existing table IDs
|
||||
(is (every? :table_id inputs)))))))
|
||||
|
||||
(deftest analyze-entity-asserts-transform-type-test
|
||||
(testing "analyze-entity asserts entity-type is :transform"
|
||||
(is (thrown-with-msg? Exception #"Only transform entity type is supported"
|
||||
(ws.deps/analyze-entity :card {})))))
|
||||
|
||||
;;; ---------------------------------------- write-dependencies! tests ----------------------------------------
|
||||
|
||||
(deftest write-dependencies-creates-output-test
|
||||
(testing "write-dependencies! creates workspace_output record"
|
||||
(mt/with-temp [:model/Workspace workspace {:name "Test Workspace"
|
||||
:database_id (mt/id)
|
||||
:schema "test_isolated_schema"}
|
||||
:model/WorkspaceTransform wt {:workspace_id (:id workspace)
|
||||
:name "Test Transform"
|
||||
:source {:type "query" :query {}}
|
||||
:target {:database (mt/id)
|
||||
:schema "public"
|
||||
:name "test_output"}}]
|
||||
(let [analysis {:output {:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "test_output"}
|
||||
:inputs []}]
|
||||
(ws.deps/write-dependencies! (:id workspace) "test_isolated_schema" :transform (:ref_id wt) analysis)
|
||||
(let [output (t2/select-one :model/WorkspaceOutput
|
||||
:workspace_id (:id workspace)
|
||||
:ref_id (:ref_id wt))]
|
||||
(is (some? output))
|
||||
(is (= (mt/id) (:db_id output)))
|
||||
(is (= "public" (:global_schema output)))
|
||||
(is (= "test_output" (:global_table output)))
|
||||
(is (= "test_isolated_schema" (:isolated_schema output)))
|
||||
(is (= "public__test_output" (:isolated_table output))))))))
|
||||
|
||||
(deftest write-dependencies-creates-inputs-test
|
||||
(testing "write-dependencies! creates workspace_input records for external dependencies"
|
||||
(let [iso-schema "test_isolated_schema"]
|
||||
(mt/with-temp [:model/Workspace workspace {:name "Test Workspace"
|
||||
:database_id (mt/id)
|
||||
:schema iso-schema}
|
||||
:model/WorkspaceTransform wt {:workspace_id (:id workspace)
|
||||
:name "Test Transform"
|
||||
:source {:type "query" :query {}}
|
||||
:target {:database (mt/id)
|
||||
:schema "public"
|
||||
:name "test_output"}}]
|
||||
(let [orders-table (t2/select-one [:model/Table :db_id :schema :name] :id (mt/id :orders))
|
||||
analysis {:output {:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "test_output"}
|
||||
:inputs [{:db_id (:db_id orders-table)
|
||||
:schema (:schema orders-table)
|
||||
:table (:name orders-table)}]}]
|
||||
(ws.deps/write-dependencies! (:id workspace) iso-schema :transform (:ref_id wt) analysis)
|
||||
(let [input (t2/select-one :model/WorkspaceInput
|
||||
:workspace_id (:id workspace)
|
||||
:table (:name orders-table))]
|
||||
(is (some? input))
|
||||
(is (= (:db_id orders-table) (:db_id input)))
|
||||
(is (= (:name orders-table) (:table input)))))))))
|
||||
|
||||
(deftest write-dependencies-creates-edges-test
|
||||
(testing "write-dependencies! creates workspace_dependency edges"
|
||||
(let [iso-schema "test_isolated_schema"]
|
||||
(mt/with-temp [:model/Workspace workspace {:name "Test Workspace"
|
||||
:database_id (mt/id)
|
||||
:schema iso-schema}
|
||||
:model/WorkspaceTransform wt {:workspace_id (:id workspace)
|
||||
:name "Test Transform"
|
||||
:source {:type "query" :query {}}
|
||||
:target {:database (mt/id)
|
||||
:schema "public"
|
||||
:name "test_output"}}]
|
||||
(let [orders-table (t2/select-one [:model/Table :db_id :schema :name] :id (mt/id :orders))
|
||||
analysis {:output {:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "test_output"}
|
||||
:inputs [{:db_id (:db_id orders-table)
|
||||
:schema (:schema orders-table)
|
||||
:table (:name orders-table)}]}]
|
||||
(ws.deps/write-dependencies! (:id workspace) iso-schema :transform (:ref_id wt) analysis)
|
||||
(let [edges (t2/select :model/WorkspaceDependency
|
||||
:workspace_id (:id workspace)
|
||||
:from_entity_type :transform
|
||||
:from_entity_id (:ref_id wt))]
|
||||
(is (= 1 (count edges)))
|
||||
(is (= :input (:to_entity_type (first edges))))))))))
|
||||
|
||||
(deftest write-dependencies-internal-dependency-test
|
||||
(testing "write-dependencies! links to workspace_output for internal dependencies"
|
||||
(mt/with-temp [:model/Workspace workspace {:name "Test Workspace"
|
||||
:database_id (mt/id)
|
||||
:schema "test_isolated_schema"}
|
||||
:model/WorkspaceTransform wt1 {:workspace_id (:id workspace)
|
||||
:name "Upstream Transform"
|
||||
:source {:type "query" :query {}}
|
||||
:target {:database (mt/id)
|
||||
:schema "public"
|
||||
:name "upstream_output"}}
|
||||
:model/WorkspaceTransform wt2 {:workspace_id (:id workspace)
|
||||
:name "Downstream Transform"
|
||||
:source {:type "query" :query {}}
|
||||
:target {:database (mt/id)
|
||||
:schema "public"
|
||||
:name "downstream_output"}}]
|
||||
;; First, write dependencies for the upstream transform (creates the output)
|
||||
(ws.deps/write-dependencies! (:id workspace) "test_isolated_schema" :transform (:ref_id wt1)
|
||||
{:output {:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "upstream_output"}
|
||||
:inputs []})
|
||||
|
||||
;; Now write dependencies for downstream that depends on upstream's output
|
||||
(ws.deps/write-dependencies! (:id workspace) "test_isolated_schema" :transform (:ref_id wt2)
|
||||
{:output {:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "downstream_output"}
|
||||
:inputs [{:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "upstream_output"}]})
|
||||
|
||||
(let [edges (t2/select :model/WorkspaceDependency
|
||||
:workspace_id (:id workspace)
|
||||
:from_entity_type :transform
|
||||
:from_entity_id (:ref_id wt2))]
|
||||
(testing "creates edge to output (internal dependency)"
|
||||
(is (= 1 (count edges)))
|
||||
(is (= :output (:to_entity_type (first edges)))))
|
||||
(testing "no workspace_input created for internal dependency"
|
||||
(is (not (t2/exists? :model/WorkspaceInput
|
||||
:workspace_id (:id workspace)
|
||||
:table "upstream_output"))))))))
|
||||
|
||||
(deftest write-dependencies-updates-on-change-test
|
||||
(testing "write-dependencies! updates records and removes stale edges"
|
||||
(mt/with-temp [:model/Workspace workspace {:name "Test Workspace"
|
||||
:database_id (mt/id)
|
||||
:schema "test_isolated_schema"}
|
||||
:model/WorkspaceTransform wt {:workspace_id (:id workspace)
|
||||
:name "Test Transform"
|
||||
:source {:type "query" :query {}}
|
||||
:target {:database (mt/id)
|
||||
:schema "public"
|
||||
:name "test_output"}}]
|
||||
(let [orders-table (t2/select-one [:model/Table :db_id :schema :name] :id (mt/id :orders))
|
||||
products-table (t2/select-one [:model/Table :db_id :schema :name] :id (mt/id :products))]
|
||||
;; First write with orders dependency
|
||||
(ws.deps/write-dependencies! (:id workspace) "test_isolated_schema" :transform (:ref_id wt)
|
||||
{:output {:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "test_output"}
|
||||
:inputs [{:db_id (:db_id orders-table)
|
||||
:schema (:schema orders-table)
|
||||
:table (:name orders-table)}]})
|
||||
(is (= 1 (t2/count :model/WorkspaceDependency :workspace_id (:id workspace))))
|
||||
|
||||
;; Update to depend on products instead
|
||||
(ws.deps/write-dependencies! (:id workspace) "test_isolated_schema" :transform (:ref_id wt)
|
||||
{:output {:db_id (mt/id)
|
||||
:schema "public"
|
||||
:table "test_output"}
|
||||
:inputs [{:db_id (:db_id products-table)
|
||||
:schema (:schema products-table)
|
||||
:table (:name products-table)}]})
|
||||
|
||||
(testing "old edge is removed"
|
||||
(is (= 1 (t2/count :model/WorkspaceDependency :workspace_id (:id workspace)))))
|
||||
(testing "new input is created"
|
||||
(is (t2/exists? :model/WorkspaceInput
|
||||
:workspace_id (:id workspace)
|
||||
:table (:name products-table))))))))
|
||||
|
||||
;;; ---------------------------------------- Integration test ----------------------------------------
|
||||
|
||||
(deftest analyze-and-write-integration-test
|
||||
(testing "Full flow: analyze a real transform and write its dependencies"
|
||||
(let [mp (mt/metadata-provider)
|
||||
query (lib/query mp (lib.metadata/table mp (mt/id :orders)))]
|
||||
(mt/with-temp [:model/Workspace workspace {:name "Integration Test Workspace"
|
||||
:database_id (mt/id)
|
||||
:schema "test_isolated_schema"}
|
||||
:model/WorkspaceTransform wt {:workspace_id (:id workspace)
|
||||
:name "Orders Analysis"
|
||||
:source {:type "query" :query {}}
|
||||
:target {:database (mt/id)
|
||||
:schema "analytics"
|
||||
:name "orders_analysis"}}]
|
||||
;; Create the transform entity with the proper query (not from DB)
|
||||
(let [transform-entity {:source {:type "query"
|
||||
:query query}
|
||||
:target {:database (mt/id)
|
||||
:schema "analytics"
|
||||
:name "orders_analysis"}}
|
||||
analysis (ws.deps/analyze-entity :transform transform-entity)]
|
||||
(ws.deps/write-dependencies! (:id workspace) "test_isolated_schema" :transform (:ref_id wt) analysis)
|
||||
|
||||
(testing "output record created"
|
||||
(is (= 1 (t2/count :model/WorkspaceOutput :workspace_id (:id workspace)))))
|
||||
(testing "input record created for ORDERS table"
|
||||
(is (= 1 (t2/count :model/WorkspaceInput :workspace_id (:id workspace)))))
|
||||
(testing "dependency edge created"
|
||||
(is (= 1 (t2/count :model/WorkspaceDependency :workspace_id (:id workspace))))))))))
|
||||
@@ -0,0 +1,20 @@
|
||||
(ns ^:mb/driver-tests metabase-enterprise.workspaces.drivers-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest drivers-support-workspaces-has-to-support-db-swapping-test
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :workspace)
|
||||
(let [db-swapped? (fn []
|
||||
(try
|
||||
(mt/run-mbql-query venues {:limit 1})
|
||||
false
|
||||
(catch Exception _e
|
||||
true)))]
|
||||
(testing "db is swapped if being executed inside a macros"
|
||||
(driver/with-swapped-connection-details (mt/id) {:user "unicorn"}
|
||||
(is (db-swapped?))))
|
||||
|
||||
(testing "sanity check that it's not swapped outside of the macros"
|
||||
(is (not (db-swapped?)))))))
|
||||
@@ -0,0 +1,85 @@
|
||||
(ns ^:mb/driver-tests metabase-enterprise.workspaces.e2e-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.transforms.execute :as transforms.execute]
|
||||
[metabase-enterprise.transforms.test-util :as transforms.tu]
|
||||
[metabase-enterprise.workspaces.common :as ws.common]
|
||||
[metabase-enterprise.workspaces.impl :as ws.impl]
|
||||
[metabase-enterprise.workspaces.isolation :as ws.isolation]
|
||||
[metabase-enterprise.workspaces.test-util :as ws.tu]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.sql.query-processor :as sql.qp]
|
||||
[metabase.query-processor.preprocess :as qp.preprocess]
|
||||
^{:clj-kondo/ignore [:deprecated-namespace]}
|
||||
[metabase.query-processor.store :as qp.store]
|
||||
[metabase.sync.core :as sync]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- execute-workspace-transform!
|
||||
"Execute a transform within workspace isolation context. For testing purposes."
|
||||
[workspace ws-transform opts]
|
||||
(ws.isolation/with-workspace-isolation workspace
|
||||
(let [result (atom nil)]
|
||||
(t2/with-transaction [_tx]
|
||||
(let [transform (t2/insert-returning-instance!
|
||||
:model/Transform
|
||||
(select-keys ws-transform [:name :description :source :target]))]
|
||||
(reset! result (transforms.execute/execute! transform opts))
|
||||
;; rather abort transaction
|
||||
(t2/delete! :model/Transform (:id transform))))
|
||||
@result)))
|
||||
|
||||
(defn- mbql->native [query]
|
||||
(qp.store/with-metadata-provider (mt/id)
|
||||
(sql.qp/mbql->native driver/*driver* (qp.preprocess/preprocess query))))
|
||||
|
||||
(deftest isolation-e2e-test
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :workspace)
|
||||
(transforms.tu/with-transform-cleanup! [output-table-name (str "test_table_1_" (mt/random-name))]
|
||||
(mt/with-model-cleanup [:model/Transform :model/Workspace]
|
||||
(mt/with-temp [:model/Transform {transform-id :id} {:name "Transform 1"
|
||||
:source {:type "query"
|
||||
:query (mt/native-query (mbql->native (mt/mbql-query orders {:limit 3})))}
|
||||
:target {:type "table"
|
||||
:name output-table-name
|
||||
:database (mt/id)
|
||||
:schema (t2/select-one-fn :schema :model/Table (mt/id :orders))}}]
|
||||
(ws.tu/with-workspaces! [workspace {:name (mt/random-name)
|
||||
:database_id (mt/id)}]
|
||||
(ws.common/add-to-changeset! (mt/user->id :crowberto) workspace
|
||||
:transform transform-id
|
||||
(t2/select-one :model/Transform transform-id))
|
||||
(let [_workspace (u/poll {:thunk #(t2/select-one :model/Workspace (:id workspace))
|
||||
:done? #(= :ready (:status %))
|
||||
:timeout-ms 5000})
|
||||
isolated-transform (t2/select-one :model/WorkspaceTransform :workspace_id (:id workspace))
|
||||
executed-transform (mt/with-current-user (mt/user->id :crowberto)
|
||||
(ws.impl/run-transform! workspace isolated-transform))
|
||||
output-table (-> executed-transform :table)
|
||||
table-name (:name output-table)
|
||||
schema (:schema output-table)]
|
||||
(testing "execute the transform with the original query is fine and the output is in an isolated schema"
|
||||
(u/poll {:thunk #(t2/select-one :model/Table :name table-name :schema schema)
|
||||
:done? some?
|
||||
:timeout-ms 1000})
|
||||
;; TODO: the table is synced but there are no fields even though the table and fields exist in the db
|
||||
;; ... even sync'ing it again doesn't seem to help ;_;
|
||||
(sync/sync-table! (t2/select-one :model/Table :name table-name :schema schema))
|
||||
#_(transforms.tu/wait-for-table (:name output-table) 1000)
|
||||
(is (str/starts-with? (:schema output-table) "mb__isolation"))
|
||||
#_(is (= 1 (count (transforms.tu/table-rows table-name)))))
|
||||
|
||||
(testing "changing the query without granting access will fail"
|
||||
(t2/update! :model/WorkspaceTransform
|
||||
{:ref_id (:ref_id isolated-transform)}
|
||||
{:source {:type "query"
|
||||
:query (mt/native-query (mbql->native (mt/mbql-query venues {:limit 1})))}})
|
||||
(is (thrown-with-msg?
|
||||
Exception
|
||||
#"ERROR: permission denied for table.*"
|
||||
(execute-workspace-transform! workspace
|
||||
(t2/select-one :model/WorkspaceTransform :ref_id (:ref_id isolated-transform))
|
||||
{:run-method :manual})))))))))))
|
||||