Compare commits
298 Commits
python-sec
...
dashboards
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78adec4d9c | ||
|
|
92c8f942fb | ||
|
|
ddcc13edd1 | ||
|
|
38a5d7b604 | ||
|
|
938600d341 | ||
|
|
1469864d71 | ||
|
|
777980d11e | ||
|
|
0a63009b9e | ||
|
|
609a58ad6d | ||
|
|
e202541ac8 | ||
|
|
7e54e25766 | ||
|
|
9b903edd5e | ||
|
|
ea1cdf4917 | ||
|
|
c58e26c8e2 | ||
|
|
99d1bd8cdf | ||
|
|
57999716ce | ||
|
|
57f64aded5 | ||
|
|
8d204c712e | ||
|
|
2cbece56c3 | ||
|
|
c8c515462a | ||
|
|
4ed9e32676 | ||
|
|
3a02474632 | ||
|
|
20875923d7 | ||
|
|
2b2cd76b2f | ||
|
|
6a78c448cf | ||
|
|
81fa467312 | ||
|
|
7b604076bc | ||
|
|
8f8b2b762a | ||
|
|
db2d21ef20 | ||
|
|
c2a163701b | ||
|
|
2a9df86ff0 | ||
|
|
b2502af083 | ||
|
|
4cd66a1b6e | ||
|
|
eb9a0acb6f | ||
|
|
bc6f659963 | ||
|
|
93fe76a5c6 | ||
|
|
993672c878 | ||
|
|
2421a24f31 | ||
|
|
2688cf4cfb | ||
|
|
4ac312e62c | ||
|
|
32a67d3633 | ||
|
|
e343d5ebbe | ||
|
|
49020716b5 | ||
|
|
e406c0c35c | ||
|
|
3cd8c2f2cd | ||
|
|
59f53c16db | ||
|
|
cac18c4db2 | ||
|
|
dde1cd6b95 | ||
|
|
e4b0d120e5 | ||
|
|
56fac7fa11 | ||
|
|
6826b70279 | ||
|
|
dae088f934 | ||
|
|
983bf8b002 | ||
|
|
709a494ad4 | ||
|
|
29a5c1d804 | ||
|
|
d2a4f43bfa | ||
|
|
9f04d4525c | ||
|
|
6a98142d10 | ||
|
|
6e075b3fee | ||
|
|
f4f2fae807 | ||
|
|
4b1783d724 | ||
|
|
9bb863e36f | ||
|
|
18b558507c | ||
|
|
60af228ce8 | ||
|
|
4ebe289c9e | ||
|
|
b96378dd1d | ||
|
|
80a1ee7544 | ||
|
|
190b660861 | ||
|
|
69a0182908 | ||
|
|
096c943179 | ||
|
|
3250dbbe3b | ||
|
|
eb27ad00a2 | ||
|
|
e5d50e6e41 | ||
|
|
e876cc59ff | ||
|
|
04f22bce1c | ||
|
|
fe38c6dbfb | ||
|
|
ab8d8e1a85 | ||
|
|
38edca362b | ||
|
|
481d5c7dd7 | ||
|
|
bd407a7a23 | ||
|
|
81d235e1f9 | ||
|
|
a6e8e815e1 | ||
|
|
20bc080cbe | ||
|
|
9489190f9b | ||
|
|
481b92c289 | ||
|
|
adcc8e3e5c | ||
|
|
d9fff0a2e7 | ||
|
|
ebe192cc51 | ||
|
|
fbe0f1a83b | ||
|
|
5f100521aa | ||
|
|
dcc2e5b777 | ||
|
|
3152844182 | ||
|
|
6cd4d53aaa | ||
|
|
de157d98f1 | ||
|
|
64a914bb6c | ||
|
|
b392c931ef | ||
|
|
57dce27409 | ||
|
|
7bbf5f082e | ||
|
|
6a90502eaa | ||
|
|
be07b8fe0f | ||
|
|
802765a6b4 | ||
|
|
8e4d85d37b | ||
|
|
ae17a6f937 | ||
|
|
cafc5540c0 | ||
|
|
ccc6c717d2 | ||
|
|
944cefd0b5 | ||
|
|
8bd2e87737 | ||
|
|
9927a4abcb | ||
|
|
b087de7b13 | ||
|
|
3760109621 | ||
|
|
c44514f19e | ||
|
|
d197607055 | ||
|
|
5b4b835a45 | ||
|
|
9cb6173c3f | ||
|
|
04fb2f80b8 | ||
|
|
434e4c77b4 | ||
|
|
fa86a2f729 | ||
|
|
3e8cc7d241 | ||
|
|
9672377eff | ||
|
|
2d25ff6801 | ||
|
|
23c32a9679 | ||
|
|
192a3f8cab | ||
|
|
d48e2e167f | ||
|
|
0fc449bb3f | ||
|
|
0ccfcf9368 | ||
|
|
f920a0ad6b | ||
|
|
ad03eb5e71 | ||
|
|
968ab66658 | ||
|
|
531c0a2ec0 | ||
|
|
361b089ae6 | ||
|
|
f868deee6b | ||
|
|
e24dd51f77 | ||
|
|
a8c3bf86bf | ||
|
|
f77ee2c518 | ||
|
|
126768e0b4 | ||
|
|
a0088a70e2 | ||
|
|
64860019bc | ||
|
|
b899d8cd97 | ||
|
|
fc0f79e339 | ||
|
|
60050841a2 | ||
|
|
bc8d0d0423 | ||
|
|
75de8e0572 | ||
|
|
9b1198ce7b | ||
|
|
f1cb9c64fd | ||
|
|
80af50a637 | ||
|
|
0ac69e8f89 | ||
|
|
2b3a76cf5b | ||
|
|
a4acab9eff | ||
|
|
8dd09eaf20 | ||
|
|
085b060c4c | ||
|
|
fb34fc1654 | ||
|
|
ca9f37c05f | ||
|
|
2ebdcb6139 | ||
|
|
1300e4fe2d | ||
|
|
ee5c71754a | ||
|
|
e0790dda10 | ||
|
|
ca30b7d361 | ||
|
|
6697c05014 | ||
|
|
56e70074d0 | ||
|
|
7e0b5e85cd | ||
|
|
e7154969aa | ||
|
|
471e76be64 | ||
|
|
25908d5f96 | ||
|
|
42f5d6cc13 | ||
|
|
e3a9edcb7a | ||
|
|
cf1c863b2b | ||
|
|
f7db036c73 | ||
|
|
49cb9edfa7 | ||
|
|
0fd583ee05 | ||
|
|
a33fee2435 | ||
|
|
de107e5639 | ||
|
|
339ce9b24c | ||
|
|
07b6dcd9ea | ||
|
|
560a3b67f0 | ||
|
|
d8ae013339 | ||
|
|
ad06c09272 | ||
|
|
b0d38931d0 | ||
|
|
30f997054b | ||
|
|
bc73878e2a | ||
|
|
2bdaa1ca68 | ||
|
|
949633567d | ||
|
|
ee68edecae | ||
|
|
6b055d5133 | ||
|
|
c39e42692c | ||
|
|
34dc6533b2 | ||
|
|
46bf926ee3 | ||
|
|
54389ff03a | ||
|
|
13a76e983e | ||
|
|
34b84f3151 | ||
|
|
d65e9c8ec4 | ||
|
|
29263dad18 | ||
|
|
b70431b8f6 | ||
|
|
57c7b6e15c | ||
|
|
9b0a44d385 | ||
|
|
c0bec3f32e | ||
|
|
2510bae3c2 | ||
|
|
1dad0e5c1e | ||
|
|
a9be96bbd2 | ||
|
|
1625842d80 | ||
|
|
9af2a858cb | ||
|
|
942fcbd2d0 | ||
|
|
67566c475b | ||
|
|
e1bf8c6582 | ||
|
|
069d773c94 | ||
|
|
82029ecfbb | ||
|
|
7070fac7a6 | ||
|
|
2653b9d303 | ||
|
|
addf6fda01 | ||
|
|
4a2651851b | ||
|
|
2f80bab68f | ||
|
|
a136a66af7 | ||
|
|
aab38f089a | ||
|
|
8dd1b8c4cc | ||
|
|
d8e3b30a83 | ||
|
|
6b9a0c0277 | ||
|
|
774cc91a4e | ||
|
|
0d185aa777 | ||
|
|
4434205bc0 | ||
|
|
9fc8a2724d | ||
|
|
94a5098f74 | ||
|
|
5c788efbe1 | ||
|
|
dc9bbc95d5 | ||
|
|
29e408bf72 | ||
|
|
9398a2f092 | ||
|
|
bed66ab743 | ||
|
|
ed931e2404 | ||
|
|
afa3d6d70c | ||
|
|
d616d269f9 | ||
|
|
ea1ef9c283 | ||
|
|
639fcf2a3e | ||
|
|
89bf939860 | ||
|
|
c635152b92 | ||
|
|
7e8d562358 | ||
|
|
320aab30bc | ||
|
|
968cf8c336 | ||
|
|
f4f22cb733 | ||
|
|
48239abdd3 | ||
|
|
2c40e5f217 | ||
|
|
38550465eb | ||
|
|
8f73866c87 | ||
|
|
d1625d7384 | ||
|
|
e22c93ed4c | ||
|
|
a229d08410 | ||
|
|
35aec6c7cb | ||
|
|
58c4abdd10 | ||
|
|
08e9272440 | ||
|
|
5c9303e6e3 | ||
|
|
554c6388b3 | ||
|
|
f21d0de52e | ||
|
|
8dc61e4eb0 | ||
|
|
270fcf4299 | ||
|
|
77cfe40954 | ||
|
|
6c4f743f38 | ||
|
|
65a5a67624 | ||
|
|
abaac8c3f7 | ||
|
|
e1406025a9 | ||
|
|
3312f915ee | ||
|
|
3809c909f0 | ||
|
|
388f208bb2 | ||
|
|
55ef1e8392 | ||
|
|
576535eaf7 | ||
|
|
78e3b5d282 | ||
|
|
ff96dabfcf | ||
|
|
3dece4143a | ||
|
|
092674a6b8 | ||
|
|
4d46a3beb4 | ||
|
|
edccda93bf | ||
|
|
2d10da8267 | ||
|
|
64d22caf90 | ||
|
|
c09d983f2c | ||
|
|
503c35172d | ||
|
|
57906d9ddc | ||
|
|
0e2fdcdfef | ||
|
|
83f6814775 | ||
|
|
1bb21b1ad7 | ||
|
|
fb62d65095 | ||
|
|
ad1423767a | ||
|
|
ef661cc1d1 | ||
|
|
be445608ce | ||
|
|
075ee44a6c | ||
|
|
407e6dd824 | ||
|
|
5b86da8b8b | ||
|
|
39d9982ae4 | ||
|
|
530b985bc4 | ||
|
|
ab8afe34b1 | ||
|
|
26b4d85352 | ||
|
|
558a2f91b2 | ||
|
|
6b9381b859 | ||
|
|
0cfa725615 | ||
|
|
7d74867058 | ||
|
|
bf3a3e9e00 | ||
|
|
bcca5c2329 | ||
|
|
25c142a7aa | ||
|
|
2d53706de7 | ||
|
|
ae6f82e0ee | ||
|
|
219685e26f | ||
|
|
1f892946c9 | ||
|
|
cdacf75b02 |
@@ -1922,6 +1922,19 @@
|
||||
util
|
||||
enterprise/serialization}}
|
||||
|
||||
enterprise/representations
|
||||
{:team "Querying"
|
||||
:api #{metabase-enterprise.representations.core
|
||||
metabase-enterprise.representations.api
|
||||
metabase-enterprise.representations.init}
|
||||
:uses #{api
|
||||
collections
|
||||
config
|
||||
driver
|
||||
lib
|
||||
models
|
||||
util}}
|
||||
|
||||
enterprise/sandbox
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.sandbox.api.routes
|
||||
|
||||
25
.github/workflows/backend.yml
vendored
25
.github/workflows/backend.yml
vendored
@@ -19,6 +19,11 @@ jobs:
|
||||
name: Clj-Kondo
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Run clj-kondo
|
||||
@@ -31,6 +36,11 @@ jobs:
|
||||
name: Eastwood
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- run: clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood
|
||||
@@ -102,6 +112,11 @@ jobs:
|
||||
}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare front-end environment
|
||||
if: ${{ matrix.job.build-static-viz == 'true' }}
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
@@ -178,6 +193,11 @@ jobs:
|
||||
name: "Check Java ${{ matrix.java-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare backend
|
||||
uses: ./.github/actions/prepare-backend
|
||||
# TODO -- we should probably also check WITHOUT ee -- do a check with only OSS namespaces on the classpath
|
||||
@@ -191,6 +211,11 @@ jobs:
|
||||
name: Cljfmt
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Run cljfmt
|
||||
|
||||
5
.github/workflows/build-scripts.yml
vendored
5
.github/workflows/build-scripts.yml
vendored
@@ -13,6 +13,11 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare back-end environment
|
||||
timeout-minutes: 5
|
||||
uses: ./.github/actions/prepare-backend
|
||||
|
||||
@@ -160,6 +160,11 @@ jobs:
|
||||
SKIP_LICENSES: ${{ github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
with:
|
||||
|
||||
5
.github/workflows/i18n.yml
vendored
5
.github/workflows/i18n.yml
vendored
@@ -34,6 +34,11 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
|
||||
6
.github/workflows/loki.yml
vendored
6
.github/workflows/loki.yml
vendored
@@ -42,6 +42,12 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
|
||||
- name: Prepare frontend environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
|
||||
|
||||
33
.github/workflows/representations.yml
vendored
Normal file
33
.github/workflows/representations.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Representation Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-backend
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
representation-tests:
|
||||
timeout-minutes: 10
|
||||
name: Representation Tests
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
with:
|
||||
m2-cache-key: 'representation-tests'
|
||||
- name: Run test
|
||||
run: clojure -X:dev:ci:test:ee:ee-dev :only '"enterprise/backend/test/metabase_enterprise/representations/"'
|
||||
5
.github/workflows/uberjar.yml
vendored
5
.github/workflows/uberjar.yml
vendored
@@ -127,6 +127,11 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.commit }}
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: metabase/representations
|
||||
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
|
||||
path: representations
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
with:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -163,3 +163,6 @@ CLAUDE.local.md
|
||||
|
||||
/logs
|
||||
/.clojure-mcp
|
||||
|
||||
# Private dependency (checked out locally and in CI)
|
||||
/representations/
|
||||
|
||||
43
bb.edn
43
bb.edn
@@ -1,7 +1,7 @@
|
||||
{:min-bb-version "1.12.208"
|
||||
;; we put path as bin, and everything is in the ./mage subdirectory,
|
||||
;; so the namespaces are mage.cli, mage.format, etc.
|
||||
:paths ["mage/src" "mage/test" "bin/lint-migrations-file/src"]
|
||||
:paths ["mage/src" "mage/test" "bin/lint-migrations-file/src" "representation"]
|
||||
:deps {org.babashka/json {:mvn/version "0.1.6"}
|
||||
metosin/malli {:mvn/version "0.17.0"}
|
||||
table/table {:mvn/version "0.5.0"}
|
||||
@@ -249,8 +249,7 @@
|
||||
(apply readability/check arguments))}
|
||||
|
||||
;; Uncomment this if you want to play with the example calculator task:
|
||||
#_#_
|
||||
-example-calculator
|
||||
#_#_-example-calculator
|
||||
{;; `:doc` is a short docstring for the task, will be listed in `./bin/mage -h` and other help info.
|
||||
;; Keep these short, they need to fit on one line in the output from `./bin/mage -h`.
|
||||
:doc "The example calculator's task docstring"
|
||||
@@ -309,4 +308,40 @@
|
||||
:requires [[mage.escherize.fix-whitespace :as fix-whitespace]]
|
||||
:examples [["mage -escherize-fix-whitespace '**/*.clj'" "Removes all lines like ' ' from all clj files"]]
|
||||
:arg-schema [:sequential [:string {:description "Glob pattern to match files"}]]
|
||||
:task (task! (fix-whitespace/run-clean parsed))}}}
|
||||
:task (task! (fix-whitespace/run-clean parsed))}
|
||||
|
||||
export
|
||||
{:doc "Export collections from Metabase instance to local filesystem"
|
||||
:requires [[representation.cli :as repr-cli]
|
||||
[representation.export :as repr-export]]
|
||||
:task (let [opts [[nil "--mb-instance-url URL" "Metabase instance URL"]
|
||||
[nil "--mb-instance-api-key KEY" "Metabase API key"]
|
||||
[nil "--manifest PATH" "Path to manifest.yml file"
|
||||
:default "manifest.yml"]
|
||||
[nil "--collection NAMES" "Comma-separated collection names"]]
|
||||
parsed (repr-cli/parse! opts)]
|
||||
(repr-export/run parsed))}
|
||||
|
||||
import
|
||||
{:doc "Import collections from local filesystem to Metabase instance"
|
||||
:requires [[representation.cli :as repr-cli]
|
||||
[representation.import :as repr-import]]
|
||||
:task (let [opts [[nil "--mb-instance-url URL" "Metabase instance URL"]
|
||||
[nil "--mb-instance-api-key KEY" "Metabase API key"]
|
||||
[nil "--manifest PATH" "Path to manifest.yml file"
|
||||
:default "manifest.yml"]
|
||||
[nil "--collection NAMES" "Comma-separated collection names"]]
|
||||
parsed (repr-cli/parse! opts)]
|
||||
(repr-import/run parsed))}
|
||||
|
||||
lint
|
||||
{:doc "Validate collections without importing to Metabase"
|
||||
:requires [[representation.cli :as repr-cli]
|
||||
[representation.lint :as repr-lint]]
|
||||
:task (let [opts [[nil "--mb-instance-url URL" "Metabase instance URL"]
|
||||
[nil "--mb-instance-api-key KEY" "Metabase API key"]
|
||||
[nil "--manifest PATH" "Path to manifest.yml file"
|
||||
:default "manifest.yml"]
|
||||
[nil "--collection NAMES" "Comma-separated collection names"]]
|
||||
parsed (repr-cli/parse! opts)]
|
||||
(repr-lint/run parsed))}}}
|
||||
|
||||
1
deps.edn
1
deps.edn
@@ -89,6 +89,7 @@
|
||||
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
|
||||
:git/sha "4998a1fdd9d3713d7034d04509e9212204773bf2"
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
io.github.metabase/representations {:local/root "representations"}
|
||||
;; TODO: replace back to normal artifact
|
||||
io.github.metabase/macaw {:mvn/version "0.2.29"} ; Parse native SQL queries
|
||||
io.github.resilience4j/resilience4j-retry ^:antq/exclude ; 2.x do not support Java 8
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
[metabase-enterprise.permission-debug.api]
|
||||
[metabase-enterprise.public-sharing.api]
|
||||
[metabase-enterprise.remote-sync.api]
|
||||
[metabase-enterprise.representations.api]
|
||||
[metabase-enterprise.sandbox.api.routes]
|
||||
[metabase-enterprise.scim.routes]
|
||||
[metabase-enterprise.semantic-search.api]
|
||||
@@ -123,6 +124,7 @@
|
||||
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
|
||||
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
|
||||
"/public" (api.routes.common/+public-exceptions metabase-enterprise.public-sharing.api/routes)
|
||||
"/representation" metabase-enterprise.representations.api/routes
|
||||
"/transforms-python" (premium-handler metabase-enterprise.transforms-python.api/routes :transforms-python)
|
||||
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
|
||||
"/semantic-search" (premium-handler metabase-enterprise.semantic-search.api/routes :semantic-search)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
[metabase-enterprise.llm.init]
|
||||
[metabase-enterprise.metabot-v3.init]
|
||||
[metabase-enterprise.remote-sync.init]
|
||||
[metabase-enterprise.representations.init]
|
||||
[metabase-enterprise.scim.init]
|
||||
[metabase-enterprise.semantic-search.init]
|
||||
[metabase-enterprise.sso.init]
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
(ns metabase-enterprise.representations.api
|
||||
(:require
|
||||
[cheshire.core :as json]
|
||||
[clojure.pprint :refer [pprint]]
|
||||
[metabase-enterprise.representations.common :as common]
|
||||
[metabase-enterprise.representations.core :as rep]
|
||||
[metabase-enterprise.representations.dependencies :as deps]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.init]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.util.log :as log]
|
||||
[representations.core :as rep-core]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(api.macros/defendpoint :get "/database/:id"
|
||||
"Download a yaml representation of a database."
|
||||
[{:keys [id]}
|
||||
_query-params
|
||||
_body-params
|
||||
_request]
|
||||
(let [id (Long/parseLong id)
|
||||
database (api/check-404 (t2/select-one :model/Database :id id))
|
||||
rep (rep/export database)]
|
||||
(try
|
||||
(rep/normalize-representation rep)
|
||||
(catch Exception e
|
||||
(log/error e "Does not validate.")))
|
||||
(rep-yaml/generate-string rep)))
|
||||
|
||||
(api.macros/defendpoint :post "/validate"
|
||||
"Validate a YAML representation string and return validation errors if any.
|
||||
Returns an empty string if validation succeeds, or the ex-data as a string if it fails."
|
||||
[_path-params
|
||||
_query-params
|
||||
_body-params
|
||||
request]
|
||||
(try
|
||||
(let [yaml-string (slurp (:body request))
|
||||
representation (rep-yaml/parse-string yaml-string)]
|
||||
(rep/normalize-representation representation)
|
||||
"") ; Return empty string on success
|
||||
(catch Exception e
|
||||
(with-out-str
|
||||
(println (ex-message e))
|
||||
(pprint (ex-data e))))))
|
||||
|
||||
(api.macros/defendpoint :get "/collection/:collection-id"
|
||||
"Export all representations in a collection as a single yaml file"
|
||||
[{:keys [collection-id]} :- :any
|
||||
_
|
||||
_
|
||||
_]
|
||||
(let [collection-id (Long/parseLong collection-id)]
|
||||
(rep-yaml/generate-string (export/export-entire-collection collection-id))))
|
||||
|
||||
(api.macros/defendpoint :put "/collection/:collection-id"
|
||||
"Import into a collection. Will delete everything in the collection first."
|
||||
[{:keys [collection-id]} :- :any
|
||||
_
|
||||
_
|
||||
request]
|
||||
;; TODO: remove everything from the collection, maybe put it into an archive collection
|
||||
(let [yaml-string (slurp (:body request))
|
||||
collection (rep-yaml/parse-string yaml-string)
|
||||
representations (import/collection-representations collection)
|
||||
representations (v0-common/order-representations representations)
|
||||
representations (map rep-read/parse representations)
|
||||
errors (deps/check-dependencies representations)]
|
||||
(rep-yaml/generate-string errors)))
|
||||
|
||||
(api.macros/defendpoint :post "/collection/validate"
|
||||
"Validate a collection. Will return empty yml map when it's nothing."
|
||||
[_ :- :any
|
||||
_
|
||||
_
|
||||
request]
|
||||
;; TODO: remove everything from the collection, maybe put it into an archive collection
|
||||
(let [yaml-string (slurp (:body request))
|
||||
collection (rep-yaml/parse-string yaml-string)
|
||||
representations (import/collection-representations collection)
|
||||
representations (v0-common/order-representations representations)
|
||||
representations (map rep-read/parse representations)
|
||||
errors (deps/check-dependencies representations)]
|
||||
(rep-yaml/generate-string errors)))
|
||||
|
||||
(api.macros/defendpoint :post "/collection/:collection-id/export"
|
||||
"Export all representations in a collection to the local filesystem"
|
||||
[{:keys [collection-id]} :- :any
|
||||
_query-params
|
||||
_body-params
|
||||
_request]
|
||||
(export/export-collection-representations (Long/parseLong collection-id))
|
||||
"Ok")
|
||||
|
||||
(api.macros/defendpoint :post "/collection/:collection-id/import"
|
||||
"Import all representations from the local filesystem into a collection"
|
||||
[{:keys [collection-id]} :- :any
|
||||
_query-params
|
||||
_body-params
|
||||
_request]
|
||||
(rep/import-collection-representations (Long/parseLong collection-id))
|
||||
"Ok")
|
||||
|
||||
(api.macros/defendpoint :post "/transform/export"
|
||||
"Export all transform representations to the local filesystem"
|
||||
[_path-params
|
||||
_query-params
|
||||
_body-params
|
||||
_request]
|
||||
(export/export-transform-representations)
|
||||
"Ok")
|
||||
|
||||
(api.macros/defendpoint :post "/transform/import"
|
||||
"Import all transform representations from the local filesystemto Metabase"
|
||||
[_path-params
|
||||
_query-params
|
||||
_body-params
|
||||
_request]
|
||||
(import/import-transform-representations)
|
||||
"Ok")
|
||||
|
||||
(api.macros/defendpoint :post "/collection/import"
|
||||
"Import a collection from a YAML bundle in the request body. Creates a new collection based on the name in the bundle."
|
||||
[_path-params
|
||||
_query-params
|
||||
_body-params
|
||||
request]
|
||||
(let [yaml-string (slurp (:body request))
|
||||
result (import/import-collection-yaml yaml-string)]
|
||||
{:collection-id (:id result)
|
||||
:status "ok"}))
|
||||
|
||||
(defn- reduce-tables [export-set]
|
||||
(cond-> export-set
|
||||
(> (count export-set) 1)
|
||||
(export/reduce-tables)))
|
||||
|
||||
(api.macros/defendpoint :get "/export-set/:type/:id"
|
||||
"Export an entity and its transitive dependencies as a JSON of YAMLs.
|
||||
Type can be: question, model, metric, transform, collection, database, snippet."
|
||||
[{:keys [type id]}
|
||||
_query-params
|
||||
_body-params
|
||||
_request]
|
||||
(let [id (Long/parseLong id)
|
||||
type-keyword (keyword type)
|
||||
model (common/toucan-model {:version rep-core/latest-version
|
||||
:type type-keyword})
|
||||
entity (api/check-404 (t2/select-one model :id id))
|
||||
representation (export/export-entity entity)
|
||||
export-set (-> [representation]
|
||||
(export/export-set)
|
||||
(reduce-tables)
|
||||
(v0-common/order-representations)
|
||||
(reverse)
|
||||
(export/rename-refs export/ref-from-name export/standard-ref-strategies export/add-sequence-number))
|
||||
export-set (mapv #(dissoc % :entity_id :entity-id) export-set)
|
||||
clean-yamls (mapv v0-common/cleanup-delete-before-output export-set)
|
||||
export-yamls (mapv rep-yaml/generate-string clean-yamls)]
|
||||
(json/generate-string {:yamls export-yamls})))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/representations` routes."
|
||||
(handlers/routes
|
||||
(api.macros/ns-handler *ns* +auth)))
|
||||
@@ -0,0 +1,9 @@
|
||||
(ns metabase-enterprise.representations.common
|
||||
(:require
|
||||
[metabase-enterprise.representations.v0.core :as v0]))
|
||||
|
||||
(defn toucan-model
|
||||
"Given a representation, returns the internal Toucan model keyword we will use when importing to our app db."
|
||||
[& {:keys [version type]}]
|
||||
(case version
|
||||
:v0 (v0/toucan-model type)))
|
||||
@@ -0,0 +1,73 @@
|
||||
(ns metabase-enterprise.representations.core
|
||||
"Core functionality for the representations module that enables human-writable
|
||||
formats for Metabase entities for version control and programmatic management."
|
||||
(:require
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[representations.read :as rep-read]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Validate representation structure
|
||||
|
||||
(defn normalize-representation
|
||||
"Validates a representation against its schema based on the type field.
|
||||
|
||||
The type field can be either:
|
||||
- Simple: 'question', 'collection' (defaults to v0)
|
||||
- Versioned: 'v0/question', 'v1/collection' (explicit version)
|
||||
|
||||
The schemas themselves expect simple types, so we strip the version
|
||||
before validation if present.
|
||||
|
||||
Handles both string and keyword keys from YAML parsing.
|
||||
Applies JSON decoders to convert strings to appropriate types (e.g., \"scheduled\" -> :scheduled).
|
||||
|
||||
Throws an exception if validation fails.
|
||||
Returns the decoded and validated representation if validation passes."
|
||||
[representation]
|
||||
(rep-read/parse representation))
|
||||
|
||||
;;;;;;;;;;;
|
||||
;; Import
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a validated representation into data suitable for creating/updating an entity.
|
||||
Returns a map with keys matching the Toucan model fields.
|
||||
Does NOT insert into the database - just transforms the data.
|
||||
Delegates to the multimethod yaml->toucan for extensibility."
|
||||
([valid-representation]
|
||||
(import/yaml->toucan valid-representation nil))
|
||||
([valid-representation ref-index]
|
||||
(import/yaml->toucan valid-representation ref-index)))
|
||||
|
||||
(defn persist!
|
||||
"Persist the representation with t2"
|
||||
([representation]
|
||||
(persist! representation (v0-common/map-entity-index {})))
|
||||
([representation ref-index]
|
||||
(when-let [validated (normalize-representation representation)]
|
||||
(import/persist! validated ref-index))))
|
||||
|
||||
;; =============================================================== ;;
|
||||
|
||||
(defn export
|
||||
"Export a Metabase entity to its human-readable representation.
|
||||
Delegates to the multimethod export-entity for extensibility."
|
||||
[t2-model]
|
||||
(export/export-entity t2-model))
|
||||
|
||||
(defn export-collection-representations
|
||||
"Export a collection and all its contents to YAML files.
|
||||
Delegates to export/export-collection-representations."
|
||||
([id]
|
||||
(export/export-collection-representations id))
|
||||
([id path]
|
||||
(export/export-collection-representations id path)))
|
||||
|
||||
(defn import-collection-representations
|
||||
"Because I didn't potemkin yet."
|
||||
[id]
|
||||
(import/import-collection-representations id))
|
||||
@@ -0,0 +1,31 @@
|
||||
(ns metabase-enterprise.representations.dependencies
|
||||
(:require
|
||||
[metabase-enterprise.dependencies.core :as dependencies]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn check-dependencies
|
||||
"Check dependencies of the representations, which should be in order from least dependent to most."
|
||||
[representations]
|
||||
(let [index (reduce (fn [index [id rep]]
|
||||
(assoc index (:ref rep) (-> (if (= :v0/database (:type rep))
|
||||
(import/persist! rep index)
|
||||
(-> (import/yaml->toucan rep index)
|
||||
(assoc :id (+ Integer/MAX_VALUE id))))
|
||||
(assoc ::type (:type rep)))))
|
||||
{}
|
||||
(map vector (range) representations))
|
||||
entities (reduce #(merge-with concat %1 %2)
|
||||
{}
|
||||
(map (fn [entity]
|
||||
{(case (::type entity)
|
||||
:v0/question :card
|
||||
:v0/collection :collection
|
||||
:model/Transform :transform
|
||||
:model/Snippet :snippet
|
||||
:v0/database :database)
|
||||
[(dissoc entity ::type)]})
|
||||
(vals index)))
|
||||
;;entities (dissoc entities :database)
|
||||
]
|
||||
(dependencies/errors-from-proposed-edits (dissoc entities :database :collection))))
|
||||
@@ -0,0 +1,44 @@
|
||||
(ns metabase-enterprise.representations.dev-helpers
|
||||
"TOTALLY TEMPORARY HACKS.
|
||||
Helper functions for experimenting with representations in the REPL to check
|
||||
that what we export matches what we'd want to use on import."
|
||||
(:require
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn export-card
|
||||
"Export a card (model/question) by ID to a YAML file.
|
||||
|
||||
Usage: (export-card 123 \"/tmp/my-model.yml\")"
|
||||
[card-id filepath]
|
||||
(let [card (t2/select-one :model/Card :id card-id)
|
||||
representation (export/export-entity card)
|
||||
yaml-str (rep-yaml/generate-string representation)]
|
||||
(spit filepath yaml-str)
|
||||
(println "Exported card" card-id "to" filepath)))
|
||||
|
||||
(defn import-card
|
||||
"Import a card from a YAML file, updating the specified card ID.
|
||||
|
||||
Usage: (import-card \"/tmp/my-model.yml\" 123)"
|
||||
[filepath card-id]
|
||||
(let [yaml-str (slurp filepath)
|
||||
representation (rep-yaml/parse-string yaml-str)
|
||||
sample-db (t2/select-one :model/Database :name "Sample Database")
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(str "database-" (:id sample-db)) sample-db})]
|
||||
(import/update! representation card-id ref-index)))
|
||||
|
||||
(defn import-card-string
|
||||
"Import a card from a YAML string, updating the specified card ID.
|
||||
|
||||
Usage: (import-card-string \"_type: model\\nname: My Model\\n...\" 123)"
|
||||
[yaml-str card-id]
|
||||
(let [representation (rep-yaml/parse-string yaml-str)
|
||||
sample-db (t2/select-one :model/Database :name "Sample Database")
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(str "database-" (:id sample-db)) sample-db})]
|
||||
(import/update! representation card-id ref-index)))
|
||||
@@ -0,0 +1,261 @@
|
||||
(ns metabase-enterprise.representations.export
|
||||
"Export functionality for Metabase entities to human-readable representations"
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[clojure.walk :as walk]
|
||||
[metabase-enterprise.representations.common :as common]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.core :as v0]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.collections.api :as coll.api]
|
||||
[metabase.util :as mu]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
[java.io File]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn export-entity
|
||||
"Export a Metabase entity to its human-readable representation format.
|
||||
Dispatches on [model type] for Cards, [model nil] for other entities."
|
||||
[t2-entity]
|
||||
;; v0 because that's the latest version
|
||||
(v0/export-entity t2-entity))
|
||||
|
||||
(defn- read-from-ref [version ref]
|
||||
(let [[type id] (str/split ref #"-" 2)]
|
||||
(-> (common/toucan-model {:version version :type (keyword type)})
|
||||
(t2/select-one :id (Long/parseLong id))
|
||||
(export-entity))))
|
||||
|
||||
(defn export-set
|
||||
"Returns a transitive set of ref-dependencies"
|
||||
[representations]
|
||||
(loop [acc (set representations)
|
||||
prev #{}]
|
||||
(if-some [new (seq (set/difference acc prev))]
|
||||
(let [reps (for [rep new
|
||||
:let [version (:version rep)]
|
||||
refs (v0-common/refs rep)]
|
||||
(read-from-ref version refs))]
|
||||
(recur (into acc reps) acc))
|
||||
acc)))
|
||||
|
||||
(defn- munge-name
|
||||
"Convert a name to a filesystem-safe format.
|
||||
- Preserves Unicode letters (including accented characters and CJK)
|
||||
- Preserves numbers
|
||||
- Replaces all other characters (spaces, punctuation, emojis, etc.) with hyphens
|
||||
- Collapses multiple hyphens into one
|
||||
- Removes leading and trailing hyphens
|
||||
- Converts to lowercase"
|
||||
[name]
|
||||
(-> name
|
||||
(str/trim)
|
||||
;; Replace any sequence of non-letter, non-number characters with a single hyphen
|
||||
;; This preserves accented characters (é, ñ, ü) and CJK characters
|
||||
;; while replacing emojis, punctuation, and spaces with hyphens
|
||||
(str/replace #"[^\p{L}\p{N}]+" "-")
|
||||
;; Remove leading and trailing hyphens
|
||||
(str/replace #"^-+|-+$" "")
|
||||
(mu/lower-case-en)))
|
||||
|
||||
(defn ref-from-name
|
||||
[reps]
|
||||
(map #(assoc % ::proposed-ref (munge-name (:display_name %))) reps))
|
||||
|
||||
(defn add-type
|
||||
[reps]
|
||||
(map #(update % ::proposed-ref str "-" (name (:type %))) reps))
|
||||
|
||||
(defn add-sequence-number
|
||||
[reps]
|
||||
(map-indexed (fn [i rep]
|
||||
(update rep ::proposed-ref str "-" i))
|
||||
reps))
|
||||
|
||||
(def standard-ref-strategies
|
||||
[add-type])
|
||||
|
||||
(defn- rename-refs-map
|
||||
[representations initial-gen-ref middle-gen-refs final-gen-ref]
|
||||
(loop [gen-refs middle-gen-refs
|
||||
acc (into #{} (initial-gen-ref representations))]
|
||||
(let [gen-ref (or (first gen-refs) final-gen-ref)
|
||||
new-ref-to-reps (group-by ::proposed-ref acc)
|
||||
acc' (reduce (fn [acc [to-remove to-add]]
|
||||
(into (apply disj acc to-remove) to-add))
|
||||
acc (for [[_ref reps] new-ref-to-reps
|
||||
:when (> (count reps) 1)]
|
||||
[reps (gen-ref reps)]))]
|
||||
(if (= acc' acc) ;; base case
|
||||
(into {} (map (juxt :name ::proposed-ref)) acc')
|
||||
(recur (rest gen-refs) acc')))))
|
||||
|
||||
(defn rename-refs
|
||||
"Rename a set of representations based on a sequence of ref strategy functions `gen-refs`.
|
||||
|
||||
Each `gen-ref` takes a sequence of representations and returns a new sequence with proposed refs."
|
||||
[representations initial-gen-ref middle-gen-refs final-gen-ref]
|
||||
(let [ref-map (rename-refs-map representations initial-gen-ref middle-gen-refs final-gen-ref)
|
||||
renamed (mapv #(update % :name ref-map) representations)
|
||||
ref:ref-map (into {} (map (fn [[old new]]
|
||||
[(str "ref:" old) (str "ref:" new)]))
|
||||
ref-map)]
|
||||
(->> renamed
|
||||
(walk/postwalk-replace ref:ref-map)
|
||||
(mapv #(dissoc % ::proposed-ref)))))
|
||||
|
||||
(defn- schema->table-index [schema]
|
||||
(let [tables (:tables schema)
|
||||
index (into {} (map (juxt :name identity)) tables)]
|
||||
(assoc schema :tables index)))
|
||||
|
||||
(defn- table-index->schema [schema]
|
||||
(update schema :tables (comp vec #(sort-by :name %) vals)))
|
||||
|
||||
(defn- db->schema-index [db]
|
||||
(let [schemas (:schemas db)
|
||||
schemas (map schema->table-index schemas)
|
||||
index (into {} (map (juxt :name identity)) schemas)]
|
||||
(assoc db :schemas index)))
|
||||
|
||||
(defn- schema-index->db [db]
|
||||
(update db :schemas (comp vec #(sort-by :name %) #(map table-index->schema %) vals)))
|
||||
|
||||
(defn- limit-db-to-schemas [db schemas]
|
||||
(update db :schemas select-keys schemas))
|
||||
|
||||
(defn- limit-schema-to-tables [schema tables]
|
||||
(update schema :tables select-keys tables))
|
||||
|
||||
(defn reduce-tables
|
||||
"Include only schemas and tables in databases referenced by queries in the export set."
|
||||
[export-set]
|
||||
(let [tables (into #{} (mapcat v0-common/table-refs) export-set)
|
||||
referred-to-databases (-> #{}
|
||||
(into (comp (map :database)
|
||||
(map v0-common/unref))
|
||||
tables)
|
||||
(into (comp (map :database)
|
||||
(filter some?)
|
||||
(map v0-common/unref))
|
||||
export-set))
|
||||
db-split (group-by #(= :database (:type %)) export-set)
|
||||
databases (get db-split true)
|
||||
;; turn them into a convenient form
|
||||
databases (map db->schema-index databases)
|
||||
non-databases (get db-split false)
|
||||
;; remove databases that are not referred to
|
||||
databases (filter #(contains? referred-to-databases (:name %)) databases)
|
||||
tables-by-db (group-by (comp v0-common/unref :database) tables)
|
||||
databases (map (fn [db]
|
||||
(let [tables (get tables-by-db (:name db))
|
||||
by-schema (group-by :schema tables)
|
||||
schemas (keys by-schema)
|
||||
;; remove schemas that are not referred to
|
||||
database (limit-db-to-schemas db schemas)
|
||||
;; remove tables that are not referred to
|
||||
database (reduce (fn [database [schema tables]]
|
||||
(let [tables (into #{} (map :table) tables)]
|
||||
(update-in database [:schemas schema] limit-schema-to-tables tables)))
|
||||
database by-schema)]
|
||||
database))
|
||||
databases)
|
||||
;; turn them back into regular form
|
||||
databases (map schema-index->db databases)]
|
||||
(-> []
|
||||
(into databases)
|
||||
(into non-databases))))
|
||||
|
||||
(defn- child->database-ids
|
||||
"Extract database IDs from a child entity"
|
||||
[child]
|
||||
(when-let [db-id (:database_id child)]
|
||||
[db-id]))
|
||||
|
||||
(defn model->card-type
|
||||
"Given a model, returns a card-type."
|
||||
[model]
|
||||
(let [kw (keyword (:model model))]
|
||||
(get {:dataset :model :card :question} kw kw)))
|
||||
|
||||
(defn export-collection-representations
|
||||
"Export the stuff."
|
||||
([id] (export-collection-representations id v0-common/representations-export-dir))
|
||||
([id path]
|
||||
(let [collection (t2/select-one :model/Collection :id id)
|
||||
coll-dir (str path (v0-common/file-sys-name id (:name collection) "/"))
|
||||
refs-dir (str coll-dir "refs/")
|
||||
children (-> collection
|
||||
(coll.api/collection-children {:show-dashboard-questions? true :archived? false})
|
||||
:data)
|
||||
database-ids (into #{} (mapcat child->database-ids) children)]
|
||||
(.mkdirs (File. refs-dir))
|
||||
(doseq [db-id database-ids]
|
||||
(try
|
||||
(let [database (t2/select-one :model/Database :id db-id)
|
||||
db-yaml (-> database export-entity rep-yaml/generate-string)
|
||||
file-name (v0-common/file-sys-name db-id (:name database) ".database.yml")]
|
||||
(spit (str refs-dir file-name) db-yaml))
|
||||
(catch Exception e
|
||||
(log/errorf e "Unable to export database with id %s" db-id))))
|
||||
(doseq [child children]
|
||||
(let [child-id (:id child)
|
||||
model-type (model->card-type child)]
|
||||
(if (= model-type :collection)
|
||||
(export-collection-representations child-id coll-dir)
|
||||
(try
|
||||
(let [card (t2/select-one :model/Card :id child-id :type model-type)
|
||||
entity-yaml (-> card export-entity rep-yaml/generate-string)
|
||||
suffix (format ".%s.yml" (name model-type))
|
||||
card-file-name (v0-common/file-sys-name child-id (:entity_id child) suffix)]
|
||||
(spit (str coll-dir card-file-name) entity-yaml))
|
||||
(catch Exception e
|
||||
(log/errorf e "Unable to export representation of type %s with id %s" model-type child-id)))))))))
|
||||
|
||||
(defn export-entire-collection
|
||||
"Generate a list of yaml representations to export"
|
||||
[id]
|
||||
(let [collection (t2/select-one :model/Collection :id id)
|
||||
children (-> collection
|
||||
(coll.api/collection-children {:show-dashboard-questions? true :archived? false})
|
||||
:data)
|
||||
database-ids (into #{} (mapcat child->database-ids) children)
|
||||
child-reps (for [child children]
|
||||
(let [child-id (:id child)
|
||||
model-type (model->card-type child)]
|
||||
(if (= model-type :collection)
|
||||
(export-entire-collection child-id)
|
||||
(-> (t2/select-one :model/Card :id child-id :type model-type)
|
||||
export-entity))))]
|
||||
(assoc (export-entity collection)
|
||||
:children (map #(dissoc % :databases) child-reps)
|
||||
:databases (concat
|
||||
(for [db-id database-ids]
|
||||
(-> (t2/select-one :model/Database :id db-id)
|
||||
export-entity))
|
||||
(mapcat :databases child-reps)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;
|
||||
;; Transforms ;;
|
||||
|
||||
(defn export-transform-representations
|
||||
"Exports transform representations to the associated local dir"
|
||||
([] (export-transform-representations v0-common/representations-export-dir))
|
||||
([path]
|
||||
(let [trans-dir "transforms/"
|
||||
transforms (t2/select :model/Transform)]
|
||||
(.mkdirs (File. (str path trans-dir)))
|
||||
(doseq [transform transforms]
|
||||
(let [transform-id (:id transform)]
|
||||
(try
|
||||
(let [entity-yaml (->> (t2/select-one :model/Transform :id transform-id)
|
||||
export-entity
|
||||
(rep-yaml/generate-string))
|
||||
file-name (v0-common/file-sys-name transform-id (:name transform) ".yaml")]
|
||||
(spit (str path trans-dir file-name) entity-yaml))
|
||||
(catch Exception e
|
||||
(log/errorf e "Unable to export representation of type transform with id %s" transform-id))))))))
|
||||
@@ -0,0 +1,197 @@
|
||||
(ns metabase-enterprise.representations.import
|
||||
"Import functionality for Metabase entities from human-readable representations"
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.representations.common :as common]
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.core :as v0-core]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.util.log :as log]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn persist!
|
||||
"Ingest a validated representation and create/update the entity in the database."
|
||||
[representation ref-index]
|
||||
(case (:version representation)
|
||||
:v0 (v0-core/persist! representation ref-index)))
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a validated representation into data suitable for creating/updating an entity.
|
||||
Returns a map with keys matching the Toucan model fields.
|
||||
Does NOT insert into the database - just transforms the data."
|
||||
[representation ref-index]
|
||||
(case (:version representation)
|
||||
:v0 (v0-core/yaml->toucan representation ref-index)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Representation Normalization ;;
|
||||
|
||||
(defn insert!
|
||||
"Insert a representation as a new entity in the database."
|
||||
[representation ref-index]
|
||||
(let [representation (rep-read/parse representation)]
|
||||
(case (:version representation)
|
||||
:v0 (v0-core/insert! representation ref-index))))
|
||||
|
||||
(defn update!
|
||||
"Update an existing entity from a representation."
|
||||
[representation id ref-index]
|
||||
(let [representation (rep-read/parse representation)]
|
||||
(case (:version representation)
|
||||
:v0 (v0-core/update! representation id ref-index))))
|
||||
|
||||
(defn- file->collection-id
|
||||
[^java.io.File file]
|
||||
(let [id
|
||||
(-> (.getParent file)
|
||||
(str/split #"/")
|
||||
last
|
||||
(str/split #"_")
|
||||
first
|
||||
(Long/parseLong))]
|
||||
id))
|
||||
|
||||
(defn import-yaml
|
||||
"Parse a YAML representation file and return the data structure.
|
||||
Returns nil if the file cannot be parsed."
|
||||
[file]
|
||||
(try
|
||||
(rep-yaml/from-file file)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to parse YAML file" file)
|
||||
nil)))
|
||||
|
||||
(defn- prepare-yaml
|
||||
[^java.io.File file]
|
||||
(when (or (str/ends-with? (.getName file) ".yml")
|
||||
(str/ends-with? (.getName file) ".yaml"))
|
||||
(-> (import-yaml file)
|
||||
rep-read/parse
|
||||
(assoc :collection (file->collection-id file)))))
|
||||
|
||||
(defn- yaml-files
|
||||
"Returns imported reference models from the yaml files in dir, in safe persistence order."
|
||||
[dir]
|
||||
(->> (io/file dir)
|
||||
file-seq
|
||||
(keep prepare-yaml)
|
||||
(v0-common/order-representations)))
|
||||
|
||||
(defn persist-dir!
|
||||
"Given a dir containing yaml representations, sorts them topologically, then persists them in order."
|
||||
[dir]
|
||||
(let [representations (yaml-files dir)]
|
||||
(reduce (fn [index representation]
|
||||
(try
|
||||
(let [persisted (persist! representation index)]
|
||||
(assoc index (:name representation) persisted))
|
||||
(catch Exception e
|
||||
(log/warn "Failed to persist an entity!"
|
||||
(ex-info (format "Entity failed to persist for ref %s. Removing from index." (:name representation))
|
||||
{:entity representation
|
||||
:ref-index index}
|
||||
e))
|
||||
(dissoc index (:name representation)))))
|
||||
{}
|
||||
representations)))
|
||||
|
||||
(defn collection-representations [collection-yaml]
|
||||
(concat
|
||||
[(dissoc collection-yaml :children :databases)]
|
||||
(:databases collection-yaml)
|
||||
;; recursive call
|
||||
(mapcat collection-representations (:children collection-yaml))))
|
||||
|
||||
(defn import-collection-representations
|
||||
"Import a whole collection from the associated local directory"
|
||||
[id]
|
||||
(let [collection (t2/select-one :model/Collection :id id)
|
||||
coll-dir (v0-common/file-sys-name id (:name collection) "/")
|
||||
coll-path (str v0-common/representations-export-dir coll-dir)]
|
||||
(persist-dir! coll-path)))
|
||||
|
||||
(defn- flatten-collection-children
|
||||
"Flatten collection children and databases into individual representations (excluding the top-level collection itself)."
|
||||
[bundle]
|
||||
(let [{:keys [children databases]} bundle
|
||||
child-reps (mapcat (fn [child]
|
||||
(if (= (:type child) "v0/collection")
|
||||
(concat [child] (flatten-collection-children child))
|
||||
[child]))
|
||||
children)]
|
||||
(concat databases child-reps)))
|
||||
|
||||
(defn- order-collections
|
||||
"Order collections by location depth (parents before children)."
|
||||
[collections]
|
||||
(sort-by (fn [coll]
|
||||
(let [location (or (:location coll) "/")]
|
||||
(count (filter #(= % \/) location))))
|
||||
collections))
|
||||
|
||||
(defn import-collection-yaml
|
||||
"Import a collection from a YAML string containing the full bundle.
|
||||
Creates collections first (ordered by parent-child), then other entities."
|
||||
[yaml-string]
|
||||
(let [bundle (-> yaml-string rep-yaml/parse-string rep-read/parse)
|
||||
collection-rep (dissoc bundle :children :databases)
|
||||
collection-name (:name collection-rep)
|
||||
;; Select ONLY top-level collections with the name:
|
||||
existing-collections (t2/select :model/Collection :name collection-name :location "/")]
|
||||
(cond (> (count existing-collections) 1)
|
||||
(throw (ex-info (str "Multiple collections found with name: " collection-name)
|
||||
{:collection-name collection-name
|
||||
:count (count existing-collections)}))
|
||||
(= (count existing-collections) 1)
|
||||
(let [existing (first existing-collections)
|
||||
archived-name (str collection-name " (archived)")]
|
||||
(log/info "Renaming existing collection" collection-name "to" archived-name)
|
||||
(t2/update! :model/Collection (:id existing) {:name archived-name})))
|
||||
(let [new-collection (persist! collection-rep {})
|
||||
collection-id (:id new-collection)
|
||||
representations (flatten-collection-children bundle)
|
||||
normalized (map rep-read/parse representations)
|
||||
ordered (v0-common/order-representations normalized)]
|
||||
(reduce (fn [index entity]
|
||||
(try
|
||||
(let [persisted (persist! (assoc entity :collection collection-id)
|
||||
(v0-common/map-entity-index index))]
|
||||
(assoc index (:name entity) persisted))
|
||||
(catch Exception e
|
||||
(log/warn e (format "Failed to persist entity with ref %s" (:name entity)))
|
||||
(dissoc index (:name entity)))))
|
||||
{}
|
||||
ordered)
|
||||
new-collection)))
|
||||
|
||||
;;;;;;;;;;;;;;;;
|
||||
;; Transforms ;;
|
||||
|
||||
(defn import-transform-representations
|
||||
"Import all transforms from the associated local directory"
|
||||
[]
|
||||
(let [trans-dir "transforms/"
|
||||
trans-path (str v0-common/representations-export-dir trans-dir)]
|
||||
(doseq [^java.io.File file (file-seq (io/file trans-path))]
|
||||
(when (.isFile file)
|
||||
(try
|
||||
(let [valid-repr (-> (slurp file)
|
||||
(rep-yaml/parse-string)
|
||||
(rep-read/parse))
|
||||
id (-> (:name valid-repr)
|
||||
(str/split #"-")
|
||||
(last)
|
||||
(Long/parseLong))
|
||||
toucan-model (yaml->toucan valid-repr nil)
|
||||
update-transform (assoc toucan-model
|
||||
:id id
|
||||
:updated_at (java.time.OffsetDateTime/now))]
|
||||
(t2/update! :model/Transform id update-transform))
|
||||
(catch Exception e
|
||||
(log/errorf e "Failed to ingest representation file %s" (.getName file))))))))
|
||||
@@ -0,0 +1,5 @@
|
||||
(ns metabase-enterprise.representations.init
|
||||
"Initialization namespace for the representations module."
|
||||
(:require
|
||||
[metabase-enterprise.representations.core]
|
||||
[metabase-enterprise.representations.v0.init]))
|
||||
@@ -0,0 +1,49 @@
|
||||
(ns metabase-enterprise.representations.lookup
|
||||
(:require
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn lookup-by-id
|
||||
"Find and return a toucan entity given the representation type and id."
|
||||
[toucan-type id]
|
||||
(when (int? id)
|
||||
(t2/select-one toucan-type :id id)))
|
||||
|
||||
(defn lookup-by-entity-id
|
||||
"Find and return a toucan entity given the representation type and entity id."
|
||||
[toucan-type entity-id]
|
||||
(when (string? entity-id)
|
||||
(t2/select-one toucan-type :entity_id entity-id)))
|
||||
|
||||
(defn lookup-by-name
|
||||
"Find and return a toucan entity given the representation type and its name.
|
||||
|
||||
Will throw an exception if multiple entities are found with the same name."
|
||||
[toucan-type name]
|
||||
(when (string? name)
|
||||
(let [entities (t2/select toucan-type :name name)]
|
||||
(if (= 1 (count entities))
|
||||
(first entities)
|
||||
(throw (ex-info (str "Multiple entities of type " toucan-type " with name '" name "'.")
|
||||
{:toucan-type toucan-type
|
||||
:name name
|
||||
:entities entities}))))))
|
||||
|
||||
(defn lookup-database-id
|
||||
"Looks up a database-id from some reference to a database (ref, id, or entity-id)."
|
||||
[ref-index database]
|
||||
(v0-common/ensure-not-nil
|
||||
(cond
|
||||
(integer? database)
|
||||
database
|
||||
|
||||
(v0-common/ref? database)
|
||||
(-> (v0-common/lookup-entity ref-index database)
|
||||
(v0-common/ensure-correct-type :database)
|
||||
:id)
|
||||
|
||||
(string? database)
|
||||
(-> (or (lookup-by-name :model/Database database)
|
||||
(lookup-by-entity-id :model/Database database))
|
||||
(v0-common/ensure-correct-type :database)
|
||||
:id))))
|
||||
@@ -0,0 +1,60 @@
|
||||
(ns metabase-enterprise.representations.toucan.core
|
||||
(:require [metabase.api.common :as api]
|
||||
[metabase.config.core :as config]))
|
||||
|
||||
(defmulti with-toucan-defaults
|
||||
"Adds default, required values to a toucan representation."
|
||||
{:arglists '([t2-model toucan-entity])}
|
||||
(fn [t2-model _toucan-entity]
|
||||
t2-model))
|
||||
|
||||
(defn- with-question-defaults
|
||||
[question-entity]
|
||||
(->> question-entity
|
||||
(merge {:description ""
|
||||
:visualization_settings {}
|
||||
:display :table
|
||||
:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
|
||||
|
||||
(defn- with-model-defaults
|
||||
[model-entity]
|
||||
(->> model-entity
|
||||
(merge {:description ""
|
||||
:visualization_settings {}
|
||||
:display :table
|
||||
:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
|
||||
|
||||
(defn- with-metric-defaults
|
||||
[metric-entity]
|
||||
(->> metric-entity
|
||||
(merge {:description ""
|
||||
:visualization_settings {}
|
||||
:display :table
|
||||
:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
|
||||
|
||||
(defmethod with-toucan-defaults :model/Card
|
||||
[_t2-model card-entity]
|
||||
(case (:type card-entity)
|
||||
:question (with-question-defaults card-entity)
|
||||
:model (with-model-defaults card-entity)
|
||||
:metric (with-metric-defaults card-entity)))
|
||||
|
||||
(defmethod with-toucan-defaults :model/Collection
|
||||
[_t2-model collection-entity]
|
||||
collection-entity)
|
||||
|
||||
(defmethod with-toucan-defaults :model/Document
|
||||
[_t2-model document-entity]
|
||||
(->> document-entity
|
||||
(merge {:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
|
||||
|
||||
(defmethod with-toucan-defaults :model/NativeQuerySnippet
|
||||
[_t2-model snippet-entity]
|
||||
(->> snippet-entity
|
||||
(merge {:description ""
|
||||
:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
|
||||
|
||||
(defmethod with-toucan-defaults :model/Transform
|
||||
[_t2-model transform-entity]
|
||||
(->> transform-entity
|
||||
(merge {:description ""})))
|
||||
@@ -0,0 +1,6 @@
|
||||
(ns metabase-enterprise.representations.v0.card
|
||||
(:require
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]))
|
||||
|
||||
(defmethod v0-common/representation-type :model/Card [t2-card]
|
||||
(:type t2-card))
|
||||
@@ -0,0 +1,86 @@
|
||||
(ns metabase-enterprise.representations.v0.collection
|
||||
(:require
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.collections.api :as coll.api]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def toucan-model
|
||||
"The toucan model keyword associated with collection representations"
|
||||
:model/Collection)
|
||||
|
||||
(defmethod v0-common/representation-type :model/Collection [_entity]
|
||||
:collection)
|
||||
|
||||
(defn- model->card-type ; TODO: DRY -- repeated in export.clj
|
||||
"Given a model, returns a card-type."
|
||||
[model]
|
||||
(let [kw (keyword (:model model))]
|
||||
(get {:dataset :model :card :question} kw kw)))
|
||||
|
||||
(defn- model->url
|
||||
"Given a model, return a url."
|
||||
[model]
|
||||
(format "/api/ee/representation/%s/%s" (name (model->card-type model)) (:id model)))
|
||||
|
||||
(defn children
|
||||
"Returns the URLS of children of a collection."
|
||||
[collection]
|
||||
(->> (coll.api/collection-children collection {:show-dashboard-questions? true
|
||||
:archived? false})
|
||||
:data
|
||||
(mapv model->url)))
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 collection representation to Toucan-compatible data."
|
||||
[{collection-name :display_name
|
||||
:keys [name description collection] :as _representation}
|
||||
_ref-index]
|
||||
(let [parent-id (when collection
|
||||
(if (number? collection)
|
||||
collection
|
||||
(:id collection)))
|
||||
location (if parent-id
|
||||
(str "/" parent-id "/")
|
||||
"/")]
|
||||
(u/remove-nils
|
||||
{:name (or collection-name name)
|
||||
:description description
|
||||
:location location})))
|
||||
|
||||
(defn- insert-collection!
|
||||
[new-collection]
|
||||
(log/debug "Creating new collection" (:name new-collection))
|
||||
(t2/insert-returning-instance! :model/Collection new-collection))
|
||||
|
||||
(defn- archive-and-persist!
|
||||
[existing-collection new-collection]
|
||||
(let [collection-name (:name existing-collection)
|
||||
archived-name (str collection-name " (archived)")]
|
||||
(log/info "Renaming existing collection" collection-name "to" archived-name)
|
||||
(t2/update! :model/Collection (:id existing-collection) {:name archived-name})
|
||||
(insert-collection! new-collection)))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 collection representation by creating or updating it in the database."
|
||||
[representation ref-index]
|
||||
(let [new-collection (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/Collection))
|
||||
collection-name (:name new-collection)
|
||||
existing (t2/select-one :model/Collection :name collection-name :location "/")]
|
||||
(if existing
|
||||
(archive-and-persist! existing new-collection)
|
||||
(insert-collection! new-collection))))
|
||||
|
||||
(defn export-collection
|
||||
"Export a Collection Toucan entity to a v0 collection representation."
|
||||
[t2-collection]
|
||||
(-> {:type :collection
|
||||
:version :v0
|
||||
:name (format "%s-%s" "collection" (:id t2-collection))
|
||||
:display_name (:name t2-collection)
|
||||
:description (:description t2-collection)
|
||||
:children (children t2-collection)} ;; todo: where's the other collection exporter?
|
||||
u/remove-nils))
|
||||
@@ -0,0 +1,245 @@
|
||||
(ns metabase-enterprise.representations.v0.common
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[clojure.walk :as walk]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defmulti representation-type
|
||||
"Returns the representation type (e.g., :question, :model, :metric) for a toucan entity.
|
||||
|
||||
Dispatches on the toucan model type."
|
||||
{:arglists '[[entity]]}
|
||||
t2/model)
|
||||
|
||||
(defmethod representation-type :default [entity]
|
||||
(throw (ex-info (str "Unknown entity type: " (t2/model entity))
|
||||
{:entity entity})))
|
||||
|
||||
(defn entity-id
|
||||
"Generates an entity-id stably from ref and collection-ref."
|
||||
[ref collection-ref]
|
||||
(-> (str collection-ref "/" ref)
|
||||
hash
|
||||
str
|
||||
u/generate-nano-id))
|
||||
|
||||
(defn generate-entity-id
|
||||
"Generate a stable entity-id from the representation's collection-ref and its own ref."
|
||||
[representation]
|
||||
(entity-id (:name representation) (:collection representation)))
|
||||
|
||||
(defn add-entity-id-from-ref-hash
|
||||
"Add a stable entity-id to a representation based on its ref and collection ref."
|
||||
[representation]
|
||||
(assoc representation :entity-id (generate-entity-id representation)))
|
||||
|
||||
(defn add-entity-id-random
|
||||
"Add an unstable entity-id to a representation. It should be random so that it never collides."
|
||||
[representation]
|
||||
(assoc representation :entity-id (u/generate-nano-id)))
|
||||
|
||||
(defn remove-entity-id
|
||||
"Remove the entity id from a representation (if it has one)."
|
||||
[representation]
|
||||
(dissoc representation :entity-id))
|
||||
|
||||
(defn find-collection-id
|
||||
"Find collection ID by name or ref. Returns nil if not found."
|
||||
[collection-ref]
|
||||
(when collection-ref
|
||||
(or
|
||||
(when (integer? collection-ref) collection-ref)
|
||||
;; Try to find by slug or name
|
||||
(t2/select-one-pk :model/Collection :slug collection-ref)
|
||||
(t2/select-one-pk :model/Collection :name collection-ref))))
|
||||
|
||||
(defn ref?
|
||||
"Is it a ref?"
|
||||
[x]
|
||||
(and (string? x)
|
||||
(str/starts-with? x "ref:")))
|
||||
|
||||
(defn unref
|
||||
"Unmake that ref."
|
||||
[x]
|
||||
(when (ref? x)
|
||||
;; "ref:"
|
||||
(subs x 4)))
|
||||
|
||||
(defn refs
|
||||
"Returns all refs present in the entity-map, recursively walking to discover them."
|
||||
[representation]
|
||||
(let [v (volatile! [])]
|
||||
(walk/postwalk (fn [node]
|
||||
(when (ref? node)
|
||||
(vswap! v conj node))
|
||||
node)
|
||||
representation)
|
||||
(set (map unref @v))))
|
||||
|
||||
(defn ->ref
|
||||
"Constructs a ref with the shape \"ref:<type>-<id>\""
|
||||
[id type]
|
||||
(format "ref:%s-%s" (name type) id))
|
||||
|
||||
(defn env-var?
|
||||
"Is it an env var?
|
||||
|
||||
Env vars are useful for storing credentials for databases in representations. They are expanded during import."
|
||||
[s]
|
||||
(and (string? s)
|
||||
(str/starts-with? s "env:")))
|
||||
|
||||
(defn un-env-var
|
||||
"Turn env var string into an env var name."
|
||||
[s]
|
||||
(when (env-var? s)
|
||||
;; "env:"
|
||||
(subs s 4)))
|
||||
|
||||
(defn hydrate-env-vars
|
||||
"Given a representation, hydrate all env vars with their values looked up from the environment."
|
||||
[representation]
|
||||
(walk/postwalk (fn [x]
|
||||
(if (env-var? x)
|
||||
(System/getenv (un-env-var x))
|
||||
x))
|
||||
representation))
|
||||
|
||||
(def representations-export-dir
|
||||
"The dir where POC import/export representations are stored."
|
||||
"local/representations/")
|
||||
|
||||
(defn file-sys-name
|
||||
"Creates a suitable filename."
|
||||
[id name suffix]
|
||||
(str id "_" (str/replace (u/lower-case-en name) " " "_") suffix))
|
||||
|
||||
(defn table-ref?
|
||||
"Is this a table ref?"
|
||||
[x]
|
||||
(and (map? x)
|
||||
(contains? x :schema)
|
||||
(contains? x :table)
|
||||
(not (contains? x :field))))
|
||||
|
||||
(defn field-ref?
|
||||
"Is this a field ref?"
|
||||
[x]
|
||||
(and (map? x)
|
||||
(contains? x :schema)
|
||||
(contains? x :table)
|
||||
(contains? x :field)))
|
||||
|
||||
(defn table-refs
|
||||
"Returns all table refs present in the representation, recursively walking to discover them."
|
||||
[representation]
|
||||
(let [v (volatile! #{})]
|
||||
(walk/postwalk (fn [node]
|
||||
(when (or (table-ref? node)
|
||||
(field-ref? node))
|
||||
(vswap! v conj (cond-> (dissoc node :field)
|
||||
(not (contains? node :database))
|
||||
(assoc :database (:database representation)))))
|
||||
node)
|
||||
representation)
|
||||
@v))
|
||||
|
||||
(defprotocol EntityLookup
|
||||
(lookup-entity [this ref])
|
||||
(lookup-id [this ref]))
|
||||
|
||||
(defrecord MapEntityIndex [idx]
|
||||
EntityLookup
|
||||
(lookup-entity [_this ref]
|
||||
(get idx (unref ref)))
|
||||
(lookup-id [this ref]
|
||||
(:id (lookup-entity this ref))))
|
||||
|
||||
(defn map-entity-index
|
||||
"Create a new index from a map of ref -> toucan entity."
|
||||
[mp]
|
||||
(->MapEntityIndex mp))
|
||||
|
||||
(defn ensure-not-nil [entity]
|
||||
(when (nil? entity)
|
||||
(throw (ex-info "Entity not found." {})))
|
||||
entity)
|
||||
|
||||
(defn ensure-correct-type
|
||||
"Ensures the entity is of the expected representation type. Throws if not."
|
||||
[entity expected-type]
|
||||
(when (and entity
|
||||
(not= expected-type (representation-type entity)))
|
||||
(throw (ex-info (str "Entity is not the correct type. Expected: " expected-type "; Actual: " (representation-type entity))
|
||||
{:entity entity
|
||||
:expected-type expected-type
|
||||
:actual-type (representation-type entity)})))
|
||||
entity)
|
||||
|
||||
(defn entity->ref
|
||||
"Get the internal ref for a toucan entity."
|
||||
[t2-entity]
|
||||
(->ref (:id t2-entity) (representation-type t2-entity)))
|
||||
|
||||
(defn id-model->ref
|
||||
"Get the internal ref for a toucan model and id."
|
||||
[id model]
|
||||
(entity->ref (t2/select-one model id)))
|
||||
|
||||
(defn ensure-correct-model-type [entity expected-type]
|
||||
(when (not= expected-type (t2/model entity))
|
||||
(throw (ex-info (str "Entity is not the correct model type. Expected: " expected-type "; Actual: " (t2/model entity))
|
||||
{:entity entity
|
||||
:expected-type expected-type
|
||||
:actual-type (t2/model entity)})))
|
||||
entity)
|
||||
|
||||
(defn replace-refs-everywhere
|
||||
"Replace all refs with whatever is in the ref-index."
|
||||
[data ref-index]
|
||||
(walk/postwalk
|
||||
(fn [node]
|
||||
(if (ref? node)
|
||||
(lookup-id ref-index node)
|
||||
node))
|
||||
data))
|
||||
|
||||
(defn cleanup-delete-before-output
|
||||
"Delete a special key used to keep structured data around until export."
|
||||
[data]
|
||||
(walk/postwalk
|
||||
(fn [node]
|
||||
(if (map? node)
|
||||
(dissoc node ::delete-before-output)
|
||||
node))
|
||||
data))
|
||||
|
||||
(defn order-representations
|
||||
"Order representations topologically by ref dependency"
|
||||
[representations]
|
||||
(loop [iterations (count representations) ;; should take at most one iteration per representation
|
||||
acc []
|
||||
remaining (set representations)]
|
||||
(cond
|
||||
(empty? remaining) ;; we're done!
|
||||
acc
|
||||
|
||||
(neg? iterations) ;; we've used too many iterations, probably in a cycle
|
||||
(throw (ex-info "Used too many iterations. Cycle?"
|
||||
{:representations representations}))
|
||||
|
||||
:else
|
||||
(let [done (set (map :name acc))
|
||||
ready (filter #(set/subset? (refs %) done)
|
||||
remaining)
|
||||
acc' (into acc ready)
|
||||
next-remaining (set/difference remaining (set acc'))]
|
||||
(when (= remaining next-remaining)
|
||||
(throw (ex-info "No progress made. Cycle?"
|
||||
{:representations representations})))
|
||||
(recur (dec iterations) acc' next-remaining)))))
|
||||
@@ -0,0 +1,98 @@
|
||||
(ns metabase-enterprise.representations.v0.core
|
||||
(:require
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.card :as v0-card]
|
||||
[metabase-enterprise.representations.v0.collection :as v0-coll]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.dashboard :as v0-dashboard]
|
||||
[metabase-enterprise.representations.v0.database :as v0-database]
|
||||
[metabase-enterprise.representations.v0.document :as v0-document]
|
||||
[metabase-enterprise.representations.v0.metric :as v0-metric]
|
||||
[metabase-enterprise.representations.v0.model :as v0-model]
|
||||
[metabase-enterprise.representations.v0.question :as v0-question]
|
||||
[metabase-enterprise.representations.v0.snippet :as v0-snippet]
|
||||
[metabase-enterprise.representations.v0.transform :as v0-transform]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn toucan-model
|
||||
[representation-type]
|
||||
(case representation-type
|
||||
:collection v0-coll/toucan-model
|
||||
:dashboard v0-dashboard/toucan-model
|
||||
:database v0-database/toucan-model
|
||||
:document v0-document/toucan-model
|
||||
:metric v0-metric/toucan-model
|
||||
:model v0-model/toucan-model
|
||||
:question v0-question/toucan-model
|
||||
:snippet v0-snippet/toucan-model
|
||||
:transform v0-transform/toucan-model))
|
||||
|
||||
(defn export-entity
|
||||
"Export the Toucan entity to a v0 representation.
|
||||
|
||||
resolve is a function that takes id and toucan model name and returns a ref"
|
||||
[t2-entity]
|
||||
(case (v0-common/representation-type t2-entity)
|
||||
:collection (v0-coll/export-collection t2-entity)
|
||||
:dashboard (v0-dashboard/export-dashboard t2-entity)
|
||||
:database (v0-database/export-database t2-entity)
|
||||
:document (v0-document/export-document t2-entity)
|
||||
:metric (v0-metric/export-metric t2-entity)
|
||||
:model (v0-model/export-model t2-entity)
|
||||
:question (v0-question/export-question t2-entity)
|
||||
:snippet (v0-snippet/export-snippet t2-entity)
|
||||
:transform (v0-transform/export-transform t2-entity)))
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 representation into data suitable for creating/updating an entity."
|
||||
[representation ref-index]
|
||||
(case (:type representation)
|
||||
:collection (v0-coll/yaml->toucan representation ref-index)
|
||||
:dashboard (v0-dashboard/yaml->toucan representation ref-index)
|
||||
:database (v0-database/yaml->toucan representation ref-index)
|
||||
:document (v0-document/yaml->toucan representation ref-index)
|
||||
:metric (v0-metric/yaml->toucan representation ref-index)
|
||||
:model (v0-model/yaml->toucan representation ref-index)
|
||||
:question (v0-question/yaml->toucan representation ref-index)
|
||||
:snippet (v0-snippet/yaml->toucan representation ref-index)
|
||||
:transform (v0-transform/yaml->toucan representation ref-index)))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 representation by creating or updating the entity in the database."
|
||||
[representation ref-index]
|
||||
(case (:type representation)
|
||||
:collection (v0-coll/persist! representation ref-index)
|
||||
:dashboard (v0-dashboard/persist! representation ref-index)
|
||||
:database (v0-database/persist! representation ref-index)
|
||||
:document (v0-document/persist! representation ref-index)
|
||||
:metric (v0-metric/persist! representation ref-index)
|
||||
:model (v0-model/persist! representation ref-index)
|
||||
:question (v0-question/persist! representation ref-index)
|
||||
:snippet (v0-snippet/persist! representation ref-index)
|
||||
:transform (v0-transform/persist! representation ref-index)))
|
||||
|
||||
(defn insert!
|
||||
"Insert a v0 representation as a new entity."
|
||||
[representation ref-index]
|
||||
(case (:type representation)
|
||||
:transform (v0-transform/insert! representation ref-index)
|
||||
:dashboard (v0-dashboard/insert! representation ref-index)
|
||||
;; default
|
||||
(let [model (toucan-model (:type representation))
|
||||
toucan (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults model))]
|
||||
(t2/insert-returning-instance! model toucan))))
|
||||
|
||||
(defn update!
|
||||
"Update an existing v0 entity from a representation."
|
||||
[representation id ref-index]
|
||||
(case (:type representation)
|
||||
:transform (v0-transform/update! representation id ref-index)
|
||||
:dashboard (v0-dashboard/update! representation id ref-index)
|
||||
;; default
|
||||
(let [model (toucan-model (:type representation))
|
||||
toucan (yaml->toucan representation ref-index)]
|
||||
(t2/update! model id toucan)
|
||||
(t2/select-one model :id id))))
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
(ns metabase-enterprise.representations.v0.dashboard
|
||||
"Dashboard representations for v0 format.
|
||||
|
||||
ASSUMPTIONS AND DESIGN DECISIONS:
|
||||
|
||||
1. Dashboard Structure:
|
||||
- Dashboards contain multiple DashboardCards (report_dashboardcard)
|
||||
- DashboardCards can be organized into Tabs (dashboard_tab)
|
||||
- Each DashboardCard references a Card (question/model) or can be a text card
|
||||
|
||||
2. Parameter Handling:
|
||||
- Dashboard-level parameters are stored in the :parameters field
|
||||
- Each DashboardCard can have parameter_mappings linking dashboard params to card params
|
||||
- Parameter mappings are complex nested structures that need careful preservation
|
||||
|
||||
3. Card References:
|
||||
- DashboardCards reference Cards via :card_id
|
||||
- We convert these to refs (e.g., 'question-123') for export
|
||||
- Text cards have :card_id nil and :visualization_settings containing markdown
|
||||
|
||||
4. Series Handling:
|
||||
- DashboardCards can have additional cards shown as series
|
||||
- Series are stored as :series in visualization_settings
|
||||
- Each series card needs to be tracked as a dependency
|
||||
|
||||
5. Visualization Settings:
|
||||
- Complex nested maps containing chart configuration
|
||||
- May contain references to other cards (in series)
|
||||
- Preserved as-is except for card ID -> ref conversions
|
||||
|
||||
6. Tab Support:
|
||||
- Tabs organize cards into logical groupings
|
||||
- Each tab has an ID, name, and position
|
||||
- DashboardCards reference their tab via :dashboard_tab_id
|
||||
|
||||
7. Auto-Apply Filters:
|
||||
- The :auto_apply_filters field controls whether filters apply automatically
|
||||
- Preserved during export/import
|
||||
|
||||
DIFFICULTIES AND CHALLENGES:
|
||||
|
||||
1. Circular Dependencies:
|
||||
- Dashboards reference Cards, Cards can reference Dashboards (via dashboard_id)
|
||||
- We only track Dashboard -> Card dependencies, not the reverse
|
||||
|
||||
2. Archived Cards:
|
||||
- DashboardCards might reference archived cards
|
||||
- Need to decide: fail on archived cards or skip them?
|
||||
- Current approach: include archived card refs but mark with comment
|
||||
|
||||
3. Parameter Mapping Complexity:
|
||||
- Parameter mappings have variable structure (can be null, maps, or arrays)
|
||||
- Different mapping types for different field types (category, location, etc.)
|
||||
- Preserved as-is but may be fragile across versions
|
||||
|
||||
4. Visualization Settings Size:
|
||||
- Can be very large JSON blobs
|
||||
- May contain computed/derived values
|
||||
- Risk of storing stale cached data
|
||||
|
||||
5. Position and Size:
|
||||
- Cards have col, row, size_x, size_y for grid layout
|
||||
- These are viewport-specific and might not translate across systems
|
||||
- Preserved but users may need to adjust layouts after import
|
||||
|
||||
6. Dashboard Filters vs Card Filters:
|
||||
- Dashboard parameters can be connected to card filters
|
||||
- The connection is through parameter_mappings which use parameter_id + card_id
|
||||
- Parameter IDs might change on import, requiring remapping"
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def toucan-model
|
||||
"The toucan model keyword associated with dashboard representations"
|
||||
:model/Dashboard)
|
||||
|
||||
(defmethod v0-common/representation-type :model/Dashboard [_entity]
|
||||
:dashboard)
|
||||
|
||||
;; -- Import --
|
||||
|
||||
(defn- resolve-card-ref
|
||||
"Resolve a card reference to an actual card ID.
|
||||
Card refs can be in the format 'question-123' or 'model-456'."
|
||||
[card-ref ref-index]
|
||||
(when card-ref
|
||||
(let [resolved (v0-common/replace-refs-everywhere card-ref ref-index)]
|
||||
(if (number? resolved)
|
||||
resolved
|
||||
;; If it's still a string after replacement, it might be unresolvable
|
||||
(log/warn "Could not resolve card reference:" card-ref)))))
|
||||
|
||||
(defn- import-parameter-mappings
|
||||
"Import parameter mappings, resolving any card references.
|
||||
Parameter mappings link dashboard parameters to card parameters."
|
||||
[parameter-mappings ref-index]
|
||||
(when parameter-mappings
|
||||
;; Parameter mappings are complex structures that may contain card references
|
||||
;; We preserve them as-is since they're mostly parameter IDs and field references
|
||||
(v0-common/replace-refs-everywhere parameter-mappings ref-index)))
|
||||
|
||||
(defn- import-series
|
||||
"Import series from visualization settings, resolving card references.
|
||||
Series are additional cards shown on the same visualization."
|
||||
[series ref-index]
|
||||
(when series
|
||||
(mapv (fn [series-card]
|
||||
;; Each series entry might have a :card_id that needs resolution
|
||||
(if-let [card-ref (:card_id series-card)]
|
||||
(assoc series-card :card_id (resolve-card-ref card-ref ref-index))
|
||||
series-card))
|
||||
series)))
|
||||
|
||||
(defn- import-visualization-settings
|
||||
"Import visualization settings, resolving any embedded card references.
|
||||
Visualization settings can contain series (additional cards) that need resolution."
|
||||
[viz-settings ref-index]
|
||||
(when viz-settings
|
||||
(let [viz-settings (v0-common/replace-refs-everywhere viz-settings ref-index)]
|
||||
;; Special handling for series if present
|
||||
(if-let [series (:series viz-settings)]
|
||||
(assoc viz-settings :series (import-series series ref-index))
|
||||
viz-settings))))
|
||||
|
||||
(defn- import-dashcard
|
||||
"Convert a representation dashcard to Toucan-compatible data.
|
||||
Resolves card references and parameter mappings."
|
||||
[dashcard ref-index]
|
||||
(-> dashcard
|
||||
(update :card_id resolve-card-ref ref-index)
|
||||
(update :parameter_mappings import-parameter-mappings ref-index)
|
||||
(update :visualization_settings import-visualization-settings ref-index)
|
||||
;; Remove the temporary dashboard_id reference - it will be set when inserting
|
||||
(dissoc :dashboard_id)
|
||||
u/remove-nils))
|
||||
|
||||
(defn- import-tabs
|
||||
"Convert representation tabs to Toucan-compatible data.
|
||||
Tabs organize cards into logical groupings."
|
||||
[tabs]
|
||||
(when tabs
|
||||
(mapv (fn [tab]
|
||||
(-> tab
|
||||
;; Remove the temporary dashboard_id - it will be set when inserting
|
||||
(dissoc :dashboard_id)
|
||||
(select-keys [:name :position])
|
||||
u/remove-nils))
|
||||
tabs)))
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 dashboard representation to Toucan-compatible data.
|
||||
|
||||
NOTE: This returns the dashboard data only. DashboardCards and Tabs
|
||||
must be created separately after the dashboard is persisted, since they
|
||||
require the dashboard ID."
|
||||
[{:keys [name display_name description parameters
|
||||
embeddable embedding_parameters width] :as _representation}
|
||||
ref-index]
|
||||
;; ASSUMPTION: We only create the dashboard entity here
|
||||
;; DashboardCards and Tabs are handled in persist! after we have a dashboard ID
|
||||
(-> {:name (or display_name name)
|
||||
:description description
|
||||
:parameters parameters
|
||||
:embeddable embeddable ;; optional
|
||||
:embedding_params embedding_parameters ;; optional
|
||||
:width width} ;; optional
|
||||
u/remove-nils))
|
||||
|
||||
(defn- create-or-update-tabs!
|
||||
"Create or update tabs for a dashboard.
|
||||
|
||||
DIFFICULTY: Tab IDs change between systems, so DashboardCards reference tabs by position.
|
||||
We need to create tabs first, then map old positions to new IDs."
|
||||
[dashboard-id tabs]
|
||||
(when (seq tabs)
|
||||
;; First, delete existing tabs
|
||||
(t2/delete! :model/DashboardTab :dashboard_id dashboard-id)
|
||||
|
||||
;; Create new tabs and build a position->id map
|
||||
(let [created-tabs (for [tab tabs]
|
||||
(first (t2/insert-returning-instances!
|
||||
:model/DashboardTab
|
||||
(assoc tab :dashboard_id dashboard-id))))]
|
||||
;; Return a map of position -> new tab ID
|
||||
(into {} (map (fn [tab] [(:position tab) (:id tab)]) created-tabs)))))
|
||||
|
||||
(defn- create-or-update-dashcards!
|
||||
"Create or update dashcards for a dashboard.
|
||||
|
||||
CHALLENGE: DashboardCards reference tabs by ID, but tab IDs change between systems.
|
||||
We use the position->tab-id map to remap tab references."
|
||||
[dashboard-id dashcards tab-position->id]
|
||||
(when (seq dashcards)
|
||||
;; Delete existing dashcards
|
||||
(t2/delete! :model/DashboardCard :dashboard_id dashboard-id)
|
||||
|
||||
;; Create new dashcards
|
||||
(doseq [dashcard dashcards]
|
||||
(let [;; Remap dashboard_tab_id using position if we have tabs
|
||||
dashboard-tab-id (when-let [tab-pos (:dashboard_tab_id dashcard)]
|
||||
;; ASSUMPTION: dashboard_tab_id in representation is actually position
|
||||
;; This might need adjustment based on actual export format
|
||||
(get tab-position->id tab-pos))
|
||||
dashcard-data (-> dashcard
|
||||
(assoc :dashboard_id dashboard-id)
|
||||
(assoc :dashboard_tab_id dashboard-tab-id)
|
||||
u/remove-nils)]
|
||||
;; DIFFICULTY: If card_id is nil, this is a text card
|
||||
;; Text cards store their content in visualization_settings
|
||||
(when (or (:card_id dashcard-data)
|
||||
(get-in dashcard-data [:visualization_settings :text]))
|
||||
(t2/insert! :model/DashboardCard dashcard-data))))))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 dashboard representation by creating or updating it in the database.
|
||||
|
||||
COMPLEXITY: Dashboards have three related entities that must be created in order:
|
||||
1. Dashboard (parent)
|
||||
2. DashboardTabs (children, needed for tab IDs)
|
||||
3. DashboardCards (children, reference tabs by ID)"
|
||||
[representation ref-index]
|
||||
(let [dashboard-data (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/Dashboard))
|
||||
entity-id (:entity_id dashboard-data)
|
||||
existing (when entity-id
|
||||
(t2/select-one :model/Dashboard :entity_id entity-id))
|
||||
|
||||
;; Create or update the dashboard
|
||||
dashboard (if existing
|
||||
(do
|
||||
(log/info "Updating existing dashboard" (:name dashboard-data)
|
||||
"with ref" (:name representation))
|
||||
(t2/update! :model/Dashboard (:id existing) (dissoc dashboard-data :entity_id))
|
||||
(t2/select-one :model/Dashboard :id (:id existing)))
|
||||
(do
|
||||
(log/info "Creating new dashboard" (:name dashboard-data))
|
||||
(first (t2/insert-returning-instances! :model/Dashboard dashboard-data))))
|
||||
|
||||
dashboard-id (:id dashboard)
|
||||
|
||||
;; Import tabs first (to get their IDs)
|
||||
tabs (import-tabs (:tabs representation))
|
||||
tab-position->id (create-or-update-tabs! dashboard-id tabs)
|
||||
|
||||
;; Import and create dashcards (using tab IDs from above)
|
||||
dashcards (map #(import-dashcard % ref-index) (:dashcards representation))
|
||||
_ (create-or-update-dashcards! dashboard-id dashcards tab-position->id)]
|
||||
|
||||
dashboard))
|
||||
|
||||
(defn- set-up-tabs
|
||||
[dashboard-id tab-representations ref-index]
|
||||
(let [tab-instances (t2/insert-returning-instances! :model/DashboardTab
|
||||
(for [[pos tab-rep] (map vector (range) tab-representations)]
|
||||
{:dashboard_id dashboard-id
|
||||
:name (:name tab-rep)
|
||||
:position pos}))]
|
||||
(t2/insert! :model/DashboardCard
|
||||
(for [[tab-rep tab-inst] (map vector tab-representations tab-instances)
|
||||
dashcard-rep (:cards tab-rep)]
|
||||
{:size_x (:width dashcard-rep)
|
||||
:size_y (:height dashcard-rep)
|
||||
:row (:row dashcard-rep)
|
||||
:col (:column dashcard-rep)
|
||||
:card_id (some->> (:card dashcard-rep)
|
||||
(v0-common/lookup-entity ref-index))
|
||||
:dashboard_id dashboard-id
|
||||
:dashboard_tab_id (:id tab-inst)
|
||||
:parameter_mappings (:parameter_mappings dashcard-rep)
|
||||
:visualization_settings ()}))))
|
||||
|
||||
(defn insert!
|
||||
[representation ref-index]
|
||||
(let [representation (rep-read/parse representation)]
|
||||
(assert (= :dashboard (:type representation)))
|
||||
(let [toucan (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/Dashboard))
|
||||
dashboard (t2/insert-returning-instance! :model/Dashboard toucan)]
|
||||
(set-up-tabs (:id dashboard) (:tabs representation) ref-index)
|
||||
;;todo (eric 2025-11-10): hydrate?
|
||||
)))
|
||||
|
||||
(defn update!
|
||||
[representation id ref-index]
|
||||
(let [representation (rep-read/parse representation)]
|
||||
(assert (= :dashboard (:type representation)))
|
||||
(let [toucan (yaml->toucan representation ref-index)]
|
||||
(t2/update! :model/Dashboard id (dissoc toucan :entity_id))
|
||||
(t2/delete! :model/DashboardTab :dashboard_id id)
|
||||
(t2/delete! :model/DashboardCard :dashboard_id id)
|
||||
(set-up-tabs id (:tabs representation) ref-index)
|
||||
;;todo (eric 2025-11-10): hydrate?
|
||||
)))
|
||||
|
||||
;; -- Export --
|
||||
|
||||
(defn- series-card-ref [series-card]
|
||||
(u/update-if-exists series-card :card_id #(v0-common/id-model->ref % :model/Card)))
|
||||
|
||||
(defn- vis-settings-refs [settings]
|
||||
(when settings
|
||||
(u/update-if-exists settings :series #(mapv series-card-ref %))))
|
||||
|
||||
(defn- export-dashcard
|
||||
"Export a DashboardCard to representation format.
|
||||
Converts card_id to a card reference (e.g., 'ref:question-123')."
|
||||
[dashcard]
|
||||
(let [card-ref (when (:card_id dashcard)
|
||||
(v0-common/id-model->ref (:card_id dashcard) :model/Card))]
|
||||
(-> (ordered-map
|
||||
:card card-ref ;; nil for text cards
|
||||
:row (:row dashcard)
|
||||
:column (:col dashcard)
|
||||
:width (:size_x dashcard)
|
||||
:height (:size_y dashcard)
|
||||
:dashboard_tab_id (:dashboard_tab_id dashcard)
|
||||
:parameter_mappings (:parameter_mappings dashcard)
|
||||
:visualization_settings (vis-settings-refs (:visualization_settings dashcard)))
|
||||
u/remove-nils)))
|
||||
|
||||
(defn- export-tab
|
||||
"Export a DashboardTab to representation format. Takes a toucan entity for the tab and toucan entities for its cards."
|
||||
[t2-tab t2-cards]
|
||||
(-> (ordered-map
|
||||
:name (:name t2-tab)
|
||||
:cards (mapv export-dashcard t2-cards))
|
||||
u/remove-nils))
|
||||
|
||||
(defn export-dashboard
|
||||
"Export a Dashboard Toucan entity to a v0 dashboard representation."
|
||||
[t2-dashboard]
|
||||
(let [dashboard-ref (v0-common/entity->ref t2-dashboard)
|
||||
|
||||
;; Fetch related tabs and cards
|
||||
tabs (t2/select :model/DashboardTab :dashboard_id (:id t2-dashboard)
|
||||
{:order-by [[:position :asc]]})
|
||||
dashcards (t2/select :model/DashboardCard :dashboard_id (:id t2-dashboard)
|
||||
{:order-by [[:row :asc] [:col :asc]]})
|
||||
cards-by-tab (group-by :dashboard_tab_id dashcards)]
|
||||
|
||||
(-> (ordered-map
|
||||
:name dashboard-ref
|
||||
:type :dashboard
|
||||
:version :v0
|
||||
:display_name (:name t2-dashboard)
|
||||
:description (:description t2-dashboard)
|
||||
:parameters (:parameters t2-dashboard)
|
||||
:embeddable (:enable_embedding t2-dashboard)
|
||||
:embedding_parameters (:embedding_params t2-dashboard)
|
||||
:width (:width t2-dashboard)
|
||||
:tabs (mapv #(export-tab % (get cards-by-tab (:id %))) tabs))
|
||||
u/remove-nils)))
|
||||
@@ -0,0 +1,117 @@
|
||||
(ns metabase-enterprise.representations.v0.database
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def toucan-model
|
||||
"The toucan model keyword associated with database representations"
|
||||
:model/Database)
|
||||
|
||||
(defmethod v0-common/representation-type :model/Database [_entity]
|
||||
:database)
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 database representation to Toucan-compatible data."
|
||||
[representation _ref-index]
|
||||
(-> representation
|
||||
(set/rename-keys {:connection_details :details
|
||||
:display_name :name})
|
||||
(select-keys [:name :engine :description :details :schemas])
|
||||
(v0-common/hydrate-env-vars)
|
||||
(u/remove-nils)))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 database representation by matching it to an existing database."
|
||||
[representation ref-index]
|
||||
(let [representation (yaml->toucan representation ref-index)]
|
||||
(if-some [existing (t2/select-one :model/Database
|
||||
:name (:name representation)
|
||||
:engine (:engine representation))]
|
||||
(do
|
||||
(log/info "Found existing database" (:name representation) "with name" (:name representation))
|
||||
existing)
|
||||
(throw (ex-info (str "Could not match database by name: " (:name representation)
|
||||
" and engine: " (:engine representation))
|
||||
{:representation representation})))))
|
||||
|
||||
;;; ------------------------------------ Export Functions ------------------------------------
|
||||
|
||||
(defn- get-fk-reference
|
||||
"Get the foreign key reference in TABLE.COLUMN format"
|
||||
[fk-target-field-id]
|
||||
(when fk-target-field-id
|
||||
(when-let [target-field (t2/select-one [:model/Field :name :table_id] :id fk-target-field-id)]
|
||||
(when-let [target-table-name (t2/select-one-fn :name :model/Table :id (:table_id target-field))]
|
||||
(str target-table-name "." (:name target-field))))))
|
||||
|
||||
(defn- process-field
|
||||
[field]
|
||||
(let [fk-ref (when (:fk_target_field_id field)
|
||||
(get-fk-reference (:fk_target_field_id field)))]
|
||||
(cond-> {:name (:name field)
|
||||
:type (or (:database_type field)
|
||||
(name (or (:base_type field) :unknown)))}
|
||||
(:description field) (assoc :description (:description field))
|
||||
(= (:database_required field) false) (assoc :nullable true)
|
||||
(:pk field) (assoc :pk true)
|
||||
fk-ref (assoc :fk fk-ref))))
|
||||
|
||||
(defn- get-table-columns
|
||||
"Get all columns for a table with their metadata"
|
||||
[table-id]
|
||||
(->> (t2/select :model/Field :table_id table-id :active true)
|
||||
(mapv process-field)))
|
||||
|
||||
(defn- process-schema-tables
|
||||
[[schema-name tables]]
|
||||
{:name (or schema-name "PUBLIC")
|
||||
:tables (vec (for [table tables]
|
||||
(cond-> {:name (:name table)}
|
||||
(:description table) (assoc :description (:description table))
|
||||
:always (assoc :columns (get-table-columns (:id table))))))})
|
||||
|
||||
(defn- get-database-schemas
|
||||
"Get all schemas, tables, and columns for a database"
|
||||
[database-id]
|
||||
(->> (t2/select :model/Table :db_id database-id :active true)
|
||||
(group-by :schema)
|
||||
(mapv process-schema-tables)))
|
||||
|
||||
(defn- sanitize-connection-details
|
||||
"Remove sensitive fields from connection details using Metabase's official list"
|
||||
[details ref]
|
||||
(when details
|
||||
(reduce (fn [m k]
|
||||
(if (contains? m k)
|
||||
(assoc m k (-> (format "%s_%s" ref (name k))
|
||||
(str/replace #"-" "_")
|
||||
(u/upper-case-en)
|
||||
(->> #_env-name
|
||||
(str "env:"))))
|
||||
m))
|
||||
details
|
||||
driver.u/default-sensitive-fields)))
|
||||
|
||||
(defn export-database
|
||||
"Export a Database Toucan entity to a v0 database representation."
|
||||
[database]
|
||||
(let [ref (v0-common/unref (v0-common/->ref (:id database) :database))]
|
||||
(-> (ordered-map
|
||||
:name ref
|
||||
:type :database
|
||||
:version :v0
|
||||
:engine (:engine database)
|
||||
:display_name (:name database)
|
||||
:description (not-empty (:description database))
|
||||
:connection_details (some-> (:details database)
|
||||
(sanitize-connection-details ref))
|
||||
:schemas (get-database-schemas (:id database)))
|
||||
u/remove-nils)))
|
||||
@@ -0,0 +1,128 @@
|
||||
(ns metabase-enterprise.representations.v0.document
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.java.shell :as sh]
|
||||
[clojure.string :as str]
|
||||
[clojure.walk :as walk]
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; ------------------------------------ Main Schema ------------------------------------
|
||||
|
||||
(def toucan-model
|
||||
"The toucan model keyword associated with document representations"
|
||||
:model/Document)
|
||||
|
||||
(defmethod v0-common/representation-type :model/Document [_entity]
|
||||
:document)
|
||||
|
||||
(defn- markdown->yaml [md]
|
||||
(let [script (str (io/file (io/resource "representations/markdown-to-prosemirror.mjs")))
|
||||
result (->> md (sh/sh "node" script :in))]
|
||||
;; Always log errors if present
|
||||
(when (seq (:err result))
|
||||
(log/warn "Error from prosemirror-to-markdown:" (:err result)))
|
||||
;; Throw if we got an error and no output
|
||||
(when (not (zero? (:exit result)))
|
||||
(throw (ex-info (str "Error converting markdown to prosemirror: " (:err result))
|
||||
{:md md
|
||||
:error (:err result)
|
||||
:exit-code (:exit result)})))
|
||||
;; Throw if we got no output at all (even without explicit error)
|
||||
(when-not (seq (:out result))
|
||||
(throw (ex-info "Markdown to prosemirror conversion produced no output"
|
||||
{:md md
|
||||
:stderr (:err result)
|
||||
:exit-code (:exit result)})))
|
||||
(str/trim (:out result))))
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 document representation to Toucan-compatible data."
|
||||
[{document-name :display_name
|
||||
:keys [name content _content_type]}
|
||||
ref-index]
|
||||
(let [yaml-content content
|
||||
yaml-content (v0-common/replace-refs-everywhere yaml-content ref-index)]
|
||||
{:name (or document-name name)
|
||||
:document (json/encode yaml-content)
|
||||
:content_type "application/json+vnd.prose-mirror"}))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 document representation by creating or updating it in the database."
|
||||
[representation ref-index]
|
||||
(let [document-data (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/Document))
|
||||
entity-id (:entity_id document-data)
|
||||
existing (when entity-id
|
||||
(t2/select-one :model/Document :entity_id entity-id))]
|
||||
(if existing
|
||||
(do
|
||||
(log/info "Updating existing document" (:name document-data) "with name" (:name representation))
|
||||
(t2/update! :model/Document (:id existing) (dissoc document-data :entity_id))
|
||||
(t2/select-one :model/Document :id (:id existing)))
|
||||
(do
|
||||
(log/info "Creating new document" (:name document-data))
|
||||
(first (t2/insert-returning-instances! :model/Document document-data))))))
|
||||
|
||||
(defn- patch-refs-for-export
|
||||
[yaml]
|
||||
(walk/postwalk
|
||||
(fn [node]
|
||||
(cond-> node
|
||||
(and (map? node)
|
||||
(= "cardEmbed" (:type node)))
|
||||
(update-in [:attrs :id] v0-common/id-model->ref :model/Card)))
|
||||
yaml))
|
||||
|
||||
(defn- edn->markdown
|
||||
[edn]
|
||||
(let [script (str (io/file (io/resource "representations/prosemirror-to-markdown.mjs")))
|
||||
result (->> edn
|
||||
(json/encode)
|
||||
(sh/sh "node" script :in))]
|
||||
;; Always log errors if present
|
||||
(when (seq (:err result))
|
||||
(log/warn "Error from prosemirror-to-markdown:" (:err result)))
|
||||
;; Throw if we got an error and no output
|
||||
(when (not (zero? (:exit result)))
|
||||
(throw (ex-info (str "Error converting prosemirror to markdown: " (:err result))
|
||||
{:edn edn
|
||||
:error (:err result)
|
||||
:exit-code (:exit result)})))
|
||||
;; Throw if we got no output at all (even without explicit error)
|
||||
(when-not (seq (:out result))
|
||||
(throw (ex-info "Prosemirror to markdown conversion produced no output"
|
||||
{:edn edn
|
||||
:stderr (:err result)
|
||||
:exit-code (:exit result)})))
|
||||
(str/trim (:out result))))
|
||||
|
||||
(defn export-document
|
||||
"Export a Document Toucan entity to a v0 document representation."
|
||||
[document]
|
||||
(let [document-ref (v0-common/unref (v0-common/->ref (:id document) :document))
|
||||
document (patch-refs-for-export document)
|
||||
doc-content (let [doc (:document document)]
|
||||
(cond
|
||||
(nil? doc)
|
||||
(throw (ex-info "Document content is nil - document field not loaded correctly"
|
||||
{:document-id (:id document)
|
||||
:document-keys (keys document)}))
|
||||
|
||||
(string? doc)
|
||||
(json/decode doc keyword)
|
||||
|
||||
:else
|
||||
doc))]
|
||||
(cond-> {:display_name (:name document)
|
||||
:type :document
|
||||
:version :v0
|
||||
:name document-ref
|
||||
:content doc-content
|
||||
:content_type :prosemirror}
|
||||
:always
|
||||
u/remove-nils)))
|
||||
@@ -0,0 +1,11 @@
|
||||
(ns metabase-enterprise.representations.v0.init
|
||||
(:require
|
||||
[metabase-enterprise.representations.v0.card]
|
||||
[metabase-enterprise.representations.v0.collection]
|
||||
[metabase-enterprise.representations.v0.database]
|
||||
[metabase-enterprise.representations.v0.document]
|
||||
[metabase-enterprise.representations.v0.metric]
|
||||
[metabase-enterprise.representations.v0.model]
|
||||
[metabase-enterprise.representations.v0.question]
|
||||
[metabase-enterprise.representations.v0.snippet]
|
||||
[metabase-enterprise.representations.v0.transform]))
|
||||
@@ -0,0 +1,121 @@
|
||||
(ns metabase-enterprise.representations.v0.mbql
|
||||
"MBQL transformations for import/export of representations."
|
||||
(:require
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.query :as lib.query]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;; ============================================================================
|
||||
;; Import: Convert representation refs to Metabase IDs
|
||||
;; ============================================================================
|
||||
|
||||
(defn- resolve-source-card
|
||||
[card-ref ref-index _mp]
|
||||
(when (v0-common/ref? card-ref)
|
||||
(-> ref-index
|
||||
(v0-common/lookup-id card-ref)
|
||||
(v0-common/ensure-not-nil))))
|
||||
|
||||
(defn- resolve-source-table
|
||||
"Resolves refs in the raw MBQL map, replacing them with IDs for import."
|
||||
[source-table _ref-index mp]
|
||||
(when (v0-common/table-ref? source-table)
|
||||
(->> (lib.metadata/tables mp)
|
||||
(filter #(and (= (:schema source-table) (:schema %))
|
||||
(= (:table source-table) (:name %))))
|
||||
first
|
||||
:id
|
||||
v0-common/ensure-not-nil)))
|
||||
|
||||
(defn- resolve-field
|
||||
[field-ref ref-index mp]
|
||||
(when (v0-common/field-ref? field-ref)
|
||||
(let [table-id (resolve-source-table (dissoc field-ref :field) ref-index mp)]
|
||||
(->> (lib.metadata/fields mp table-id)
|
||||
(filter #(= (:field field-ref) (:name %)))
|
||||
first
|
||||
:id
|
||||
v0-common/ensure-not-nil))))
|
||||
|
||||
(defrecord V0ImportVisitor []
|
||||
lib/QueryVisitor
|
||||
(visit-database-id [_ _mp ref-index database-ref]
|
||||
(when (v0-common/ref? database-ref)
|
||||
(lookup/lookup-database-id ref-index database-ref)))
|
||||
(visit-table-id [_ mp ref-index table-ref]
|
||||
(when (v0-common/table-ref? table-ref)
|
||||
(resolve-source-table table-ref ref-index mp)))
|
||||
(visit-card-id [_ mp ref-index card-ref]
|
||||
(when (v0-common/ref? card-ref)
|
||||
(resolve-source-card card-ref ref-index mp)))
|
||||
(visit-field-id [_ mp ref-index field-ref]
|
||||
(when (v0-common/field-ref? field-ref)
|
||||
(resolve-field field-ref ref-index mp))))
|
||||
|
||||
(def ^:private import-visitor (->V0ImportVisitor))
|
||||
|
||||
(defn import-dataset-query
|
||||
"Returns Metabase's dataset_query format, given a representation.
|
||||
Converts representation format to Metabase's internal dataset_query structure."
|
||||
[{:keys [query database]} ref-index]
|
||||
(let [db-id (lookup/lookup-database-id ref-index database)
|
||||
mp (lib-be/application-database-metadata-provider db-id)
|
||||
query' (if (string? query)
|
||||
(lib/native-query mp query)
|
||||
(mu/disable-enforcement
|
||||
(lib.query/query-with-stages mp query)))]
|
||||
(lib/import-walk query' import-visitor ref-index)))
|
||||
|
||||
;; ============================================================================
|
||||
;; Export: Convert Metabase IDs to representation refs
|
||||
;; ============================================================================
|
||||
|
||||
(defn- table-ref
|
||||
"Convert a table ID to a representation ref map."
|
||||
[mp table-id]
|
||||
(when-some [t (lib.metadata/table mp table-id)]
|
||||
(-> {:schema (:schema t)
|
||||
:table (:name t)}
|
||||
|
||||
u/remove-nils)))
|
||||
|
||||
(defn- field-ref
|
||||
"Convert a field id to a representation ref map."
|
||||
[mp field-id]
|
||||
(let [field (lib.metadata/field mp field-id)
|
||||
tr (table-ref mp (:table-id field))]
|
||||
(assoc tr :field (:name field))))
|
||||
|
||||
(defrecord V0MBQLExportVisitor []
|
||||
lib/QueryVisitor
|
||||
(visit-database-id [_ _mp _context database-id]
|
||||
(when (int? database-id)
|
||||
(v0-common/->ref database-id :database)))
|
||||
(visit-table-id [_ mp _context table-id]
|
||||
(when (int? table-id)
|
||||
(table-ref mp table-id)))
|
||||
(visit-card-id [_ mp _context card-id]
|
||||
(when-some [card (lib.metadata/card mp card-id)]
|
||||
(v0-common/->ref (:id card) (:type card))))
|
||||
(visit-field-id [_ mp _context field-id]
|
||||
(when (int? field-id)
|
||||
(field-ref mp field-id))))
|
||||
|
||||
(def ^:private export-visitor (->V0MBQLExportVisitor))
|
||||
|
||||
(defn export-dataset-query
|
||||
"Export a dataset query to representation compatible format.
|
||||
Will have database (as a ref) and query."
|
||||
[query]
|
||||
(let [patched-query (lib/export-walk query export-visitor nil)]
|
||||
{:database (:database patched-query)
|
||||
:query (if (lib/native-only-query? query)
|
||||
(lib/raw-native-query query)
|
||||
(:stages patched-query))}))
|
||||
@@ -0,0 +1,79 @@
|
||||
(ns metabase-enterprise.representations.v0.metric
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.card]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[representations.schema.v0.column :as rep-v0-column]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def toucan-model
|
||||
"The toucan model keyword associated with metric representations"
|
||||
:model/Card)
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 metric representation to Toucan-compatible data."
|
||||
[{metric-name :display_name
|
||||
:keys [name description database collection columns] :as representation}
|
||||
ref-index]
|
||||
(let [database-id (lookup/lookup-database-id ref-index database)
|
||||
;; TODO: once we've cleaned up mbql stuff, this explicit lookup should be superfluous.
|
||||
;; Just pull it off of the dataset-query
|
||||
dataset-query (-> (assoc representation :database database-id)
|
||||
(v0-mbql/import-dataset-query ref-index))]
|
||||
(-> {;; Core fields
|
||||
:name (or metric-name name)
|
||||
:description description
|
||||
:dataset_query dataset-query
|
||||
:database_id database-id
|
||||
:query_type (if (lib/native-only-query? dataset-query) :native :query)
|
||||
:type :metric
|
||||
:result_metadata columns
|
||||
:collection_id (v0-common/find-collection-id collection)}
|
||||
u/remove-nils)))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 metric representation by creating or updating it in the database."
|
||||
[representation ref-index]
|
||||
(let [metric-data (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/Card))
|
||||
;; Generate stable entity_id from ref and collection
|
||||
entity-id (v0-common/generate-entity-id representation)
|
||||
existing (when entity-id
|
||||
(t2/select-one :model/Card :entity_id entity-id))]
|
||||
(if existing
|
||||
(do
|
||||
(log/info "Updating existing metric" (:name metric-data) "with name" (:name representation))
|
||||
(t2/update! :model/Card (:id existing) (dissoc metric-data :entity_id))
|
||||
(t2/select-one :model/Card :id (:id existing)))
|
||||
(do
|
||||
(log/info "Creating new metric" (:name metric-data))
|
||||
(let [metric-data-with-creator (-> metric-data
|
||||
(assoc :creator_id (or api/*current-user-id*
|
||||
config/internal-mb-user-id))
|
||||
(assoc :entity_id entity-id))]
|
||||
(first (t2/insert-returning-instances! :model/Card metric-data-with-creator)))))))
|
||||
|
||||
;;; -- Export --
|
||||
|
||||
(defn export-metric
|
||||
"Export a Metric Card Toucan entity to a v0 metric representation."
|
||||
[card]
|
||||
(-> (ordered-map
|
||||
:name (v0-common/unref (v0-common/->ref (:id card) :metric))
|
||||
:type (:type card)
|
||||
:version :v0
|
||||
:display_name (:name card)
|
||||
:description (:description card)
|
||||
:columns (into []
|
||||
(map #(select-keys % rep-v0-column/column-keys))
|
||||
(:result_metadata card)))
|
||||
(merge (v0-mbql/export-dataset-query (:dataset_query card)))
|
||||
u/remove-nils))
|
||||
@@ -0,0 +1,118 @@
|
||||
(ns metabase-enterprise.representations.v0.model
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.card]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.queries.models.card.metadata :as card.metadata]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private user-editable-settings
|
||||
[:column_title :text_align :text_wrapping :view_as :link_text :link_url
|
||||
:show_mini_bar :number_style :currency :currency_style
|
||||
:date_style :date_separator :date_abbreviate :time_enabled :time_style])
|
||||
|
||||
;;; ------------------------------------ Main Model Schema ------------------------------------
|
||||
|
||||
(defn- columns->result-metadata
|
||||
"Merge required inferred-metadata into the yml column metadata for user-defined fields."
|
||||
[user-metadata inferred-metadata]
|
||||
(mapv (fn [user-column inferred]
|
||||
(assoc user-column :base-type (:base-type inferred)))
|
||||
user-metadata
|
||||
inferred-metadata))
|
||||
|
||||
(def toucan-model
|
||||
"The toucan model keyword associated with model representations"
|
||||
:model/Card)
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 model representation to Toucan-compatible data."
|
||||
[{model-name :display_name
|
||||
:keys [_type name description database collection columns] :as representation}
|
||||
ref-index]
|
||||
(let [database-id (lookup/lookup-database-id ref-index database)
|
||||
dataset-query (-> (assoc representation :database database-id)
|
||||
(v0-mbql/import-dataset-query ref-index))
|
||||
metadata-provider (lib-be/application-database-metadata-provider database-id)
|
||||
normalized-query (card.metadata/normalize-dataset-query dataset-query)
|
||||
mlv2-query (lib/query metadata-provider normalized-query)
|
||||
inferred-metadata (lib/returned-columns mlv2-query)]
|
||||
(-> {:name (or model-name name)
|
||||
:description description
|
||||
:dataset_query dataset-query
|
||||
:database_id database-id
|
||||
:query_type (if (lib/native-only-query? dataset-query) :native :query)
|
||||
:type :model
|
||||
:result_metadata (when columns
|
||||
(columns->result-metadata columns inferred-metadata))
|
||||
:collection_id (v0-common/find-collection-id collection)}
|
||||
u/remove-nils)))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 model representation by creating or updating it in the database."
|
||||
[representation ref-index]
|
||||
(let [model-data (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/Card))
|
||||
entity-id (:entity_id model-data)
|
||||
existing (when entity-id
|
||||
(t2/select-one :model/Card :entity_id entity-id))]
|
||||
(if existing
|
||||
(do
|
||||
(log/info "Updating existing model" (:name model-data) "with name" (:name representation))
|
||||
(t2/update! :model/Card (:id existing) (dissoc model-data :entity_id))
|
||||
(t2/select-one :model/Card :id (:id existing)))
|
||||
(do
|
||||
(log/info "Creating new model" (:name model-data))
|
||||
(let [model-data-with-creator (-> model-data
|
||||
(assoc :creator_id (or api/*current-user-id*
|
||||
config/internal-mb-user-id))
|
||||
(assoc :entity_id entity-id))]
|
||||
(first (t2/insert-returning-instances! :model/Card model-data-with-creator)))))))
|
||||
|
||||
;;; -- Export --
|
||||
|
||||
(defn- extract-user-editable-settings
|
||||
"Extract user-editable settings from a column's settings map.
|
||||
Returns only the fields that users should be able to edit in YAML."
|
||||
[settings]
|
||||
(when settings
|
||||
(not-empty
|
||||
(select-keys settings user-editable-settings))))
|
||||
|
||||
(defn- extract-user-editable-column-metadata
|
||||
"Extract user-editable metadata from a result_metadata column entry.
|
||||
Returns a map with :name and user-editable fields only."
|
||||
[result-column-metadata]
|
||||
(let [base {:name (:name result-column-metadata)}
|
||||
editable (not-empty
|
||||
(select-keys result-column-metadata [:display_name :description :semantic_type
|
||||
:visibility_type :fk_target_field_id]))
|
||||
settings (extract-user-editable-settings (:settings result-column-metadata))]
|
||||
(cond-> base
|
||||
editable (merge editable)
|
||||
settings (assoc :settings settings))))
|
||||
|
||||
(defn export-model
|
||||
"Export a Model Card Toucan entity to a v0 model representation."
|
||||
[card]
|
||||
(let [card-ref (v0-common/unref (v0-common/->ref (:id card) :model))
|
||||
columns (when-let [result-metadata (:result_metadata card)]
|
||||
(seq (mapv extract-user-editable-column-metadata result-metadata)))]
|
||||
(-> (ordered-map
|
||||
{:name card-ref
|
||||
:type (:type card)
|
||||
:version :v0
|
||||
:display_name (:name card)
|
||||
:description (:description card)})
|
||||
(merge (v0-mbql/export-dataset-query (:dataset_query card)))
|
||||
(assoc :columns columns)
|
||||
u/remove-nils)))
|
||||
@@ -0,0 +1,63 @@
|
||||
(ns metabase-enterprise.representations.v0.question
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.card]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def toucan-model
|
||||
"The toucan model keyword associated with question representations"
|
||||
:model/Card)
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 question representation to Toucan-compatible data."
|
||||
[representation ref-index]
|
||||
(let [database-id (lookup/lookup-database-id ref-index (:database representation))
|
||||
query (v0-mbql/import-dataset-query representation ref-index)]
|
||||
(-> {:name (or (:display_name representation)
|
||||
(:name representation))
|
||||
:description (:description representation)
|
||||
:display (:display representation)
|
||||
:dataset_query query
|
||||
:database_id database-id
|
||||
:query_type (if (lib/native-only-query? query) :native :query)
|
||||
:type :question}
|
||||
u/remove-nils)))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 question representation by creating or updating it in the database."
|
||||
[representation ref-index]
|
||||
(let [question-data (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/Card))
|
||||
entity-id (:entity_id question-data)
|
||||
existing (when entity-id
|
||||
(t2/select-one :model/Card :entity_id entity-id))]
|
||||
(if existing
|
||||
(do
|
||||
(log/info "Updating existing question" (:name question-data) "with ref" (:name representation))
|
||||
(t2/update! :model/Card (:id existing) (dissoc question-data :entity_id))
|
||||
(t2/select-one :model/Card :id (:id existing)))
|
||||
(do
|
||||
(log/info "Creating new question" (:name question-data))
|
||||
(first (t2/insert-returning-instances! :model/Card question-data))))))
|
||||
|
||||
;; -- Export --
|
||||
|
||||
(defn export-question
|
||||
"Export a Question Card Toucan entity to a v0 question representation."
|
||||
[card]
|
||||
(let [card-ref (v0-common/unref (v0-common/->ref (:id card) :question))]
|
||||
(-> (ordered-map
|
||||
:name card-ref
|
||||
:type (:type card)
|
||||
:version :v0
|
||||
:display_name (:name card)
|
||||
:description (:description card))
|
||||
(merge (v0-mbql/export-dataset-query (:dataset_query card)))
|
||||
u/remove-nils)))
|
||||
@@ -0,0 +1,70 @@
|
||||
(ns metabase-enterprise.representations.v0.snippet
|
||||
(:require
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.lib.native :as lib.native]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defmethod v0-common/representation-type :model/NativeQuerySnippet [_entity]
|
||||
:snippet)
|
||||
|
||||
(def toucan-model
|
||||
"The toucan model keyword associated with snippet representations"
|
||||
:model/NativeQuerySnippet)
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 snippet representation to Toucan-compatible data."
|
||||
[{:keys [name display_name description sql collection entity-id] :as representation}
|
||||
_ref-index]
|
||||
(-> {:name (or display_name name)
|
||||
:description description
|
||||
:content sql
|
||||
:collection_id (v0-common/find-collection-id collection)
|
||||
:template_tags (lib.native/recognize-template-tags sql)}
|
||||
u/remove-nils))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 snippet representation by creating or updating it in the database."
|
||||
[representation ref-index]
|
||||
(let [snippet-data (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
|
||||
entity-id (:entity_id snippet-data)
|
||||
existing (when entity-id (t2/select-one :model/NativeQuerySnippet :entity_id entity-id))]
|
||||
(if existing
|
||||
(do
|
||||
(log/info "Updating existing snippet" (:name snippet-data) "with name" (:name representation))
|
||||
(t2/update! :model/NativeQuerySnippet (:id existing) (dissoc snippet-data :entity_id))
|
||||
(t2/select-one :model/NativeQuerySnippet :id (:id existing)))
|
||||
(do
|
||||
(log/info "Creating new snippet" (:name snippet-data))
|
||||
(first (t2/insert-returning-instances! :model/NativeQuerySnippet snippet-data))))))
|
||||
|
||||
(defn- template-tag-ref
|
||||
"Given a template tag map, return its ref string."
|
||||
[template-tag]
|
||||
(case (:type template-tag)
|
||||
:snippet (v0-common/->ref (:snippet-id template-tag) :snippet)
|
||||
:card (v0-common/->ref (:card-id template-tag) :card)
|
||||
(throw (ex-info (str "Unknown template tag type " (:type template-tag))
|
||||
{:template-tag template-tag}))))
|
||||
|
||||
(defn export-snippet
|
||||
"Export a NativeQuerySnippet Toucan entity to a v0 snippet representation."
|
||||
[snippet]
|
||||
(let [snippet-ref (v0-common/unref (v0-common/->ref (:id snippet) :snippet))
|
||||
template-tags (into {}
|
||||
(comp
|
||||
(filter (fn [[_ v]] (not= (:type v) :text)))
|
||||
(map (fn [[k v]] [k (template-tag-ref v)])))
|
||||
(:template_tags snippet))]
|
||||
{:name snippet-ref
|
||||
:type :snippet
|
||||
:version :v0
|
||||
:display_name (:name snippet)
|
||||
:description (:description snippet)
|
||||
:sql (:content snippet)
|
||||
:template_tags template-tags}))
|
||||
@@ -0,0 +1,121 @@
|
||||
(ns metabase-enterprise.representations.v0.transform
|
||||
"The v0 transform representation namespace."
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defmethod v0-common/representation-type :model/Transform [_entity]
|
||||
:transform)
|
||||
|
||||
(def toucan-model
|
||||
"The toucan model keyword associated with transform representations"
|
||||
:model/Transform)
|
||||
|
||||
(defn yaml->toucan
|
||||
"Convert a v0 transform representation to Toucan-compatible data."
|
||||
[{:keys [database] :as representation}
|
||||
ref-index]
|
||||
(let [database-id (-> ref-index
|
||||
(v0-common/lookup-entity database)
|
||||
(v0-common/ensure-correct-type :database)
|
||||
(or (lookup/lookup-by-name :model/Database database))
|
||||
(or (lookup/lookup-by-id :model/Database database))
|
||||
:id
|
||||
(v0-common/ensure-not-nil))
|
||||
query (v0-mbql/import-dataset-query representation ref-index)]
|
||||
(-> {:database database-id
|
||||
:name (or (:display_name representation)
|
||||
(:name representation))
|
||||
:description (:description representation)
|
||||
:source {:type "query"
|
||||
:query query}
|
||||
:target {:type "table"
|
||||
:schema (-> representation :target_table :schema)
|
||||
:name (-> representation :target_table :table)}}
|
||||
u/remove-nils)))
|
||||
|
||||
(defn- set-up-tags [transform-id tags]
|
||||
(when (seq tags)
|
||||
(let [existing-tags (t2/select :model/TransformTag :name [:in tags])
|
||||
missing-tags (reduce disj (set tags) (map :name existing-tags))
|
||||
new-tags (t2/insert-returning-instances! :model/TransformTag (for [tag missing-tags] {:name tag}))
|
||||
by-name (into {} (map (juxt :name identity)) (concat existing-tags new-tags))]
|
||||
(t2/insert! :model/TransformTransformTag (for [[i tag] (map vector (range) tags)]
|
||||
{:transform_id transform-id
|
||||
:tag_id (-> tag by-name :id)
|
||||
:position i})))))
|
||||
|
||||
(defn insert!
|
||||
"Insert a v0 transform as a new entity, handling tags as well"
|
||||
[representation ref-index]
|
||||
(let [representation (rep-read/parse representation)]
|
||||
(assert (= :transform (:type representation)))
|
||||
(let [toucan (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/Transform))
|
||||
transform (t2/insert-returning-instance! :model/Transform toucan)]
|
||||
(set-up-tags (:id transform) (:tags representation))
|
||||
(t2/hydrate transform :transform_tag_names))))
|
||||
|
||||
(defn update!
|
||||
"Update an existing v0 transform from a representation."
|
||||
[representation id ref-index]
|
||||
(let [representation (rep-read/parse representation)]
|
||||
(assert (= :transform (:type representation)))
|
||||
(let [toucan (yaml->toucan representation ref-index)]
|
||||
(t2/update! :model/Transform id (dissoc toucan :entity_id))
|
||||
(t2/delete! :model/TransformTransformTag :transform_id id)
|
||||
(set-up-tags id (:tags representation))
|
||||
(t2/hydrate (t2/select-one :model/Transform :id id) :transform_tag_names))))
|
||||
|
||||
(defn persist!
|
||||
"Persist a v0 transform representation by creating or updating it in the database."
|
||||
[representation ref-index]
|
||||
(let [transform-data (->> (yaml->toucan representation ref-index)
|
||||
(rep-t2/with-toucan-defaults :model/Transform))
|
||||
entity-id (:entity_id transform-data)
|
||||
existing (when entity-id
|
||||
(t2/select-one :model/Transform :entity_id entity-id))]
|
||||
(if existing
|
||||
(do
|
||||
(log/info "Updating existing transform" (:name transform-data) "with name" (:name representation))
|
||||
(t2/update! :model/Transform (:id existing) (dissoc transform-data :entity_id))
|
||||
(t2/delete! :model/TransformTransformTag :transform_id (:id existing))
|
||||
(set-up-tags (:id existing) (:tags representation))
|
||||
(t2/hydrate (t2/select-one :model/Transform :id (:id existing)) :transform_tag_names))
|
||||
(do
|
||||
(log/info "Creating new transform" (:name transform-data))
|
||||
(let [transform (t2/insert-returning-instance! :model/Transform transform-data)]
|
||||
(set-up-tags (:id transform) (:tags representation))
|
||||
(t2/hydrate transform :transform_tag_names))))))
|
||||
|
||||
;; EXPORT
|
||||
|
||||
(defn export-transform
|
||||
"Export a Transform Toucan entity to a v0 transform representation."
|
||||
[transform]
|
||||
(let [query (v0-mbql/export-dataset-query (-> transform :source :query))]
|
||||
(cond-> (ordered-map
|
||||
:name (v0-common/unref (v0-common/->ref (:id transform) :transform))
|
||||
:type :transform
|
||||
:version :v0
|
||||
:tags (:tags transform)
|
||||
:display_name (:name transform)
|
||||
:description (:description transform)
|
||||
:database (:database query))
|
||||
|
||||
(#{"table" :table} (-> transform :target :type))
|
||||
(assoc :target_table {:schema (-> transform :target :schema)
|
||||
:table (-> transform :target :name)})
|
||||
|
||||
:always
|
||||
(assoc :query (:query query))
|
||||
|
||||
:always
|
||||
u/remove-nils)))
|
||||
@@ -0,0 +1,27 @@
|
||||
(ns metabase-enterprise.representations.yaml
|
||||
"Centralized YAML utilities for representations with consistent formatting options."
|
||||
(:require
|
||||
[metabase.util.yaml :as yaml]))
|
||||
|
||||
(def ^:private yaml-options
|
||||
"Standard YAML formatting options for all representations."
|
||||
{:dumper-options
|
||||
{:flow-style :block
|
||||
:indent 2
|
||||
:indicator-indent 2
|
||||
:indent-with-indicator true}})
|
||||
|
||||
(defn generate-string
|
||||
"Generate YAML string from EDN data with standard representations formatting."
|
||||
[data]
|
||||
(yaml/generate-string data yaml-options))
|
||||
|
||||
(defn parse-string
|
||||
"Parse YAML string to EDN data."
|
||||
[s]
|
||||
(yaml/parse-string s))
|
||||
|
||||
(defn from-file
|
||||
"Read and parse YAML file to EDN data."
|
||||
[filename]
|
||||
(yaml/from-file filename))
|
||||
@@ -110,6 +110,23 @@
|
||||
tag-associations)]
|
||||
(for [transform transforms] (assoc transform :tag_ids (vec (get transform-id->tag-ids (:id transform) [])))))))
|
||||
|
||||
(mi/define-batched-hydration-method transform-tag-names
|
||||
:transform_tag_names
|
||||
"Add tag_ids to a transform, preserving the order defined by position"
|
||||
[transforms]
|
||||
(if-not (seq transforms)
|
||||
transforms
|
||||
(let [transforms (t2/hydrate transforms :transform_tag_ids)
|
||||
tag-ids (set (mapcat :tag_ids transforms))
|
||||
tags (when (seq tag-ids)
|
||||
(t2/select [:model/TransformTag :id :name]
|
||||
:id [:in tag-ids]))
|
||||
tag-index (into {} (for [{:keys [id name]} tags] [id (str name)]))]
|
||||
(for [transform transforms]
|
||||
(cond-> (dissoc transform :tag_ids)
|
||||
(seq (:tag_ids transform))
|
||||
(assoc :tags (mapv tag-index (:tag_ids transform))))))))
|
||||
|
||||
(t2/define-after-insert :model/Transform [transform]
|
||||
(events/publish-event! :event/create-transform {:object transform})
|
||||
transform)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
(ns metabase-enterprise.representations.core-test
|
||||
(:require
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[metabase-enterprise.representations.core :as rep]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.model]
|
||||
[metabase-enterprise.representations.v0.question]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[representations.read :as rep-read]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(deftest check-its-running
|
||||
(is true))
|
||||
|
||||
(deftest export-import-test
|
||||
(testing "Export database and question with refs, then import in order"
|
||||
(mt/with-temp [:model/Database database {:name "Test Database for Refs"
|
||||
:engine :h2
|
||||
:details {}}]
|
||||
(let [db-id (:id database)
|
||||
db-export (rep/export database)
|
||||
db-ref (:name db-export)]
|
||||
(mt/with-temp [:model/Card question {:name "Test Question with Ref"
|
||||
:type :question
|
||||
:database_id db-id
|
||||
:dataset_query {:database db-id
|
||||
:type :native
|
||||
:native {:query "SELECT 1"}}}]
|
||||
(let [question-export (rep/export question)]
|
||||
(is (= (str "ref:database-" db-id) (:database question-export))
|
||||
"Question export should reference database with ref")
|
||||
(is (= db-ref (str "database-" db-id))
|
||||
"Database export should have correct ref")
|
||||
(let [db-yaml (rep-yaml/generate-string db-export)
|
||||
question-yaml (rep-yaml/generate-string question-export)
|
||||
db-rep (rep-yaml/parse-string db-yaml)
|
||||
question-rep (rep-yaml/parse-string question-yaml)]
|
||||
(is (rep-read/parse db-rep)
|
||||
"Database representation should validate")
|
||||
(is (rep-read/parse question-rep)
|
||||
"Question representation should validate")
|
||||
(let [persisted-db (rep/persist! db-rep nil)
|
||||
ref-index (v0-common/map-entity-index {db-ref persisted-db})
|
||||
persisted-question (rep/persist! question-rep ref-index)]
|
||||
(is persisted-db "Database should persist successfully")
|
||||
(is persisted-question "Question should persist successfully")
|
||||
(is (= (:id persisted-db) (:database_id persisted-question))
|
||||
"Imported question should reference imported database")
|
||||
(let [re-exported (rep/export persisted-question)
|
||||
re-exported-yaml (rep-yaml/generate-string re-exported)
|
||||
re-exported-rep (rep-yaml/parse-string re-exported-yaml)]
|
||||
(is (=? (dissoc question-rep :name :entity-id) re-exported-rep)
|
||||
"Re-exported representation should match original"))))))))))
|
||||
|
||||
;; TODO: Reproduce test coverage but incorporating shipping changes
|
||||
|
||||
#_(deftest mbql-question-roundtrip-test
|
||||
(testing "Export and import MBQL-based question with separate MBQL data"
|
||||
(mt/with-temp [:model/Card question {:name "MBQL Question Test"
|
||||
:type :question
|
||||
:database_id (mt/id)
|
||||
:dataset_query (mt/mbql-query users {:limit 10})}]
|
||||
(let [card-export (rep/export question)
|
||||
card-yaml (yaml/generate-string card-export)
|
||||
card-rep (yaml/parse-string card-yaml)]
|
||||
|
||||
(is (rep-read/parse card-rep)
|
||||
"MBQL question export should validate")
|
||||
|
||||
(is (string? (:mbql_query card-rep))
|
||||
"Export should have mbql_query as ref string")
|
||||
|
||||
(let [mbql-export (export/export-mbql-data question)
|
||||
mbql-yaml (-> mbql-export export/export-entity yaml/generate-string)
|
||||
mbql-rep (yaml/parse-string mbql-yaml)
|
||||
|
||||
db (mt/db)
|
||||
db-ref (str "database-" (mt/id))
|
||||
mbql-ref (:ref mbql-rep)
|
||||
|
||||
mbql-data (rep/persist! mbql-rep nil)
|
||||
ref-index {db-ref db
|
||||
mbql-ref mbql-data}
|
||||
|
||||
persisted (rep/persist! card-rep ref-index)]
|
||||
|
||||
(is mbql-data "MBQL data should persist to ref-index")
|
||||
(is persisted "MBQL question should persist successfully")
|
||||
|
||||
(let [re-exported-card (rep/export persisted)
|
||||
re-exported-card-yaml (yaml/generate-string re-exported-card)
|
||||
re-exported-card-rep (yaml/parse-string re-exported-card-yaml)]
|
||||
|
||||
(is (=? (dissoc card-rep :ref) (dissoc re-exported-card-rep :ref))
|
||||
"Re-exported MBQL question should match original")))))))
|
||||
|
||||
#_(deftest mbql-model-roundtrip-test
|
||||
(testing "Export and import MBQL-based model with separate MBQL data and result_metadata"
|
||||
(mt/with-temp [:model/Card model {:name "MBQL Model Test"
|
||||
:type :model
|
||||
:database_id (mt/id)
|
||||
:dataset_query (mt/mbql-query users {:limit 5})
|
||||
:result_metadata [{:name "ID"
|
||||
:display_name "ID"
|
||||
:base_type :type/Integer}
|
||||
{:name "NAME"
|
||||
:display_name "Name"
|
||||
:base_type :type/Text}]}]
|
||||
(let [card-export (rep/export model)
|
||||
card-yaml (yaml/generate-string card-export)
|
||||
card-rep (yaml/parse-string card-yaml)]
|
||||
|
||||
(is (rep-read/parse card-rep)
|
||||
"MBQL model export should validate")
|
||||
|
||||
(is (string? (:mbql_query card-rep))
|
||||
"Export should have mbql_query as ref string")
|
||||
|
||||
(let [mbql-export (export/export-mbql-data model)
|
||||
mbql-yaml (-> mbql-export export/export-entity yaml/generate-string)
|
||||
mbql-rep (yaml/parse-string mbql-yaml)
|
||||
|
||||
db (mt/db)
|
||||
db-ref (str "database-" (mt/id))
|
||||
mbql-ref (:ref mbql-rep)
|
||||
|
||||
mbql-data (rep/persist! mbql-rep nil)
|
||||
ref-index {db-ref db
|
||||
mbql-ref mbql-data}
|
||||
|
||||
persisted (rep/persist! card-rep ref-index)]
|
||||
|
||||
(is mbql-data "MBQL data should persist to ref-index")
|
||||
(is (:result_metadata mbql-data) "MBQL data should contain result_metadata")
|
||||
(is persisted "MBQL model should persist successfully")
|
||||
|
||||
(let [re-exported-card (rep/export persisted)
|
||||
re-exported-card-yaml (yaml/generate-string re-exported-card)
|
||||
re-exported-card-rep (yaml/parse-string re-exported-card-yaml)]
|
||||
|
||||
(is (=? (-> card-rep (dissoc :ref :mbql_query))
|
||||
(-> re-exported-card-rep (dissoc :ref :mbql_query)))
|
||||
"Re-exported MBQL model should match original (excluding refs)")
|
||||
(is (string? (:mbql_query re-exported-card-rep))
|
||||
"Re-exported model should still have MBQL ref")))))))
|
||||
|
||||
#_(deftest collection-export-with-mbql-files-test
|
||||
(testing "Collection export creates separate .mbql.yml files for MBQL cards"
|
||||
(mt/with-temp [:model/Collection collection {:name "Test Collection"}]
|
||||
(let [coll-id (:id collection)]
|
||||
(mt/with-temp [:model/Card _question {:name "MBQL Question"
|
||||
:type :question
|
||||
:database_id (mt/id)
|
||||
:collection_id coll-id
|
||||
:dataset_query (mt/mbql-query venues {:limit 10})}
|
||||
:model/Card _model {:name "MBQL Model"
|
||||
:type :model
|
||||
:database_id (mt/id)
|
||||
:collection_id coll-id
|
||||
:dataset_query (mt/mbql-query users {:limit 5})}
|
||||
:model/Card _native-q {:name "Native Question"
|
||||
:type :question
|
||||
:database_id (mt/id)
|
||||
:collection_id coll-id
|
||||
:dataset_query {:database (mt/id)
|
||||
:type :native
|
||||
:native {:query "SELECT 1"}}}]
|
||||
;; Export the collection with user context
|
||||
(mt/with-test-user :rasta
|
||||
(let [export-dir (str "test_resources/representations/v0/test-export-" (random-uuid) "/")
|
||||
_ (rep/export-collection-representations coll-id export-dir)
|
||||
coll-dir (str export-dir (v0-common/file-sys-name coll-id (:name collection) "/"))
|
||||
files (-> (io/file coll-dir)
|
||||
.listFiles
|
||||
seq
|
||||
(->> (map #(.getName ^java.io.File %)))
|
||||
set)
|
||||
yaml-files (filter #(re-matches #".*\.yml" %) files)
|
||||
mbql-files (filter #(re-matches #".*\.mbql\.yml" %) files)
|
||||
db-files (filter #(re-matches #".*\.database\.yml" %) files)]
|
||||
(testing "Correct number of each file type"
|
||||
(is (= 6 (count yaml-files))
|
||||
"Should have 6 YAML files (1 database, 3 cards, 2 MBQL files)")
|
||||
(is (= 2 (count mbql-files))
|
||||
"Should have 2 MBQL files (question + model, not native)")
|
||||
(is (= 1 (count db-files))
|
||||
"Should have 1 database file"))
|
||||
;; Cleanup - recursively delete directory
|
||||
(letfn [(delete-dir [^java.io.File file]
|
||||
(when (.isDirectory file)
|
||||
(doseq [child (.listFiles file)]
|
||||
(delete-dir child)))
|
||||
(.delete file))]
|
||||
(delete-dir (io/file export-dir))))))))))
|
||||
@@ -0,0 +1,105 @@
|
||||
(ns metabase-enterprise.representations.export-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(deftest export-entity-all-entities
|
||||
(doseq [model [:model/Card :model/Database :model/Transform :model/Collection :model/NativeQuerySnippet]
|
||||
entity (t2/select model)]
|
||||
(mt/with-test-user :crowberto
|
||||
(is (export/export-entity entity)))))
|
||||
|
||||
(deftest rename-refs-empty
|
||||
(is (= [] (export/rename-refs []
|
||||
export/ref-from-name
|
||||
export/standard-ref-strategies
|
||||
export/add-sequence-number))))
|
||||
|
||||
(deftest rename-refs-one
|
||||
(is (= [{:name "xyz"}]
|
||||
(export/rename-refs [{:name "abc"}]
|
||||
(fn [reps] (map #(assoc % ::export/proposed-ref "xyz") reps))
|
||||
[]
|
||||
export/add-sequence-number))))
|
||||
|
||||
(defn- unique-names?
|
||||
[representations]
|
||||
(= (count representations)
|
||||
(count (into #{} (map :name) representations))))
|
||||
|
||||
(deftest hard-one
|
||||
(is (unique-names? (export/rename-refs [{:name "1" :display_name "b-question-1" :type :question}
|
||||
{:name "2" :display_name "b" :type :question}
|
||||
{:name "3" :display_name "b" :type :question}]
|
||||
export/ref-from-name
|
||||
export/standard-ref-strategies
|
||||
export/add-sequence-number))))
|
||||
|
||||
(deftest hard-one-2
|
||||
(let [reps [{:name "1" :display_name "b" :type :question :database "ref:2"}
|
||||
{:name "2" :display_name "b" :type :database}]
|
||||
reps' (export/rename-refs reps
|
||||
export/ref-from-name
|
||||
export/standard-ref-strategies
|
||||
export/add-sequence-number)]
|
||||
(is (= (->> reps'
|
||||
(filter #(= :question (:type %)))
|
||||
:database
|
||||
v0-common/unref)
|
||||
(->> reps'
|
||||
(filter #(= :database (:type %)))
|
||||
:name)))))
|
||||
|
||||
(deftest munge-name-english-test
|
||||
(testing "Basic English names"
|
||||
(is (= "hello-world" (#'export/munge-name "Hello World")))
|
||||
(is (= "my-dashboard" (#'export/munge-name "My Dashboard")))
|
||||
(is (= "user-report-2024" (#'export/munge-name "User Report 2024")))
|
||||
(is (= "sales-data" (#'export/munge-name "Sales_Data")))
|
||||
(is (= "trim-spaces" (#'export/munge-name " Trim Spaces ")))))
|
||||
|
||||
(deftest munge-name-accented-characters-test
|
||||
(testing "Accented characters should be preserved"
|
||||
(is (= "café" (#'export/munge-name "Café")))
|
||||
(is (= "naïve" (#'export/munge-name "Naïve")))
|
||||
(is (= "résumé" (#'export/munge-name "Résumé")))
|
||||
(is (= "señor" (#'export/munge-name "Señor")))
|
||||
(is (= "zürich" (#'export/munge-name "Zürich")))
|
||||
(is (= "crème-brûlée" (#'export/munge-name "Crème Brûlée")))))
|
||||
|
||||
(deftest munge-name-emoji-test
|
||||
(testing "Emojis should be replaced with hyphens"
|
||||
(is (= "hello-world" (#'export/munge-name "Hello 😀 World")))
|
||||
(is (= "report-2024" (#'export/munge-name "Report 📊 2024")))
|
||||
(is (= "dashboard" (#'export/munge-name "🎯 Dashboard 🎯")))
|
||||
(is (= "sales-data" (#'export/munge-name "Sales 💰 Data")))
|
||||
(is (= "check-this-out" (#'export/munge-name "Check ✅ This ✅ Out")))))
|
||||
|
||||
(deftest munge-name-cjk-test
|
||||
(testing "Chinese characters should not be replaced with hyphens"
|
||||
(is (= "用户报告" (#'export/munge-name "用户报告")))
|
||||
(is (= "销售数据-2024" (#'export/munge-name "销售数据 2024")))
|
||||
(is (= "我的仪表板" (#'export/munge-name "我的仪表板"))))
|
||||
|
||||
(testing "Japanese characters should not be replaced with hyphens"
|
||||
(is (= "ユーザーレポート" (#'export/munge-name "ユーザーレポート")))
|
||||
(is (= "売上データ" (#'export/munge-name "売上データ")))
|
||||
(is (= "私のダッシュボード" (#'export/munge-name "私のダッシュボード"))))
|
||||
|
||||
(testing "Korean characters should not be replaced with hyphens"
|
||||
(is (= "사용자-보고서" (#'export/munge-name "사용자 보고서")))
|
||||
(is (= "판매-데이터" (#'export/munge-name "판매 데이터")))
|
||||
(is (= "내-대시보드" (#'export/munge-name "내 대시보드")))))
|
||||
|
||||
(deftest munge-name-mixed-test
|
||||
(testing "Mixed scripts and special characters"
|
||||
(is (= "hello-世界" (#'export/munge-name "Hello 世界")))
|
||||
(is (= "café-日本" (#'export/munge-name "Café 日本")))
|
||||
(is (= "report-2024" (#'export/munge-name "Report!!! 2024")))
|
||||
(is (= "my-dashboard" (#'export/munge-name "My---Dashboard")))))
|
||||
@@ -0,0 +1,8 @@
|
||||
(ns metabase-enterprise.representations.import-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.test.fixtures :as fixtures]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
@@ -0,0 +1,72 @@
|
||||
(ns metabase-enterprise.representations.lookup-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest lookup-by-id-test
|
||||
(doseq [model [:model/Card
|
||||
:model/Database
|
||||
:model/Transform
|
||||
:model/Collection
|
||||
:model/NativeQuerySnippet]
|
||||
entity (t2/select model)]
|
||||
(let [id (:id entity)]
|
||||
(is (= id (:id (lookup/lookup-by-id model id)))))))
|
||||
|
||||
(deftest lookup-by-id-string-test
|
||||
(testing "lookup-by-id handles string id gracefully (returns nil)"
|
||||
(doseq [model [:model/Card
|
||||
:model/Database
|
||||
:model/Transform
|
||||
:model/Collection
|
||||
:model/NativeQuerySnippet]
|
||||
entity (t2/select model)]
|
||||
(let [id (str (:id entity))]
|
||||
(is (nil? (lookup/lookup-by-id model id)))))))
|
||||
|
||||
(deftest lookup-by-entity-id-test
|
||||
(doseq [model [:model/Card
|
||||
:model/Transform
|
||||
:model/Collection
|
||||
:model/NativeQuerySnippet
|
||||
;; Databases do not have entity ids
|
||||
]
|
||||
entity (t2/select model)]
|
||||
(let [entity-id (:entity_id entity)]
|
||||
(is (= (:id entity) (:id (lookup/lookup-by-entity-id model entity-id)))))))
|
||||
|
||||
;; Wat?
|
||||
#_(deftest lookup-by-entity-id-long-test
|
||||
(testing "lookup-by-entity-id handles long entity-id gracefully (returns nil)"
|
||||
(doseq [model [:model/Card
|
||||
:model/Transform
|
||||
:model/Collection
|
||||
:model/NativeQuerySnippet]]
|
||||
(let [entity-id (rand-int 1000)]
|
||||
(is (nil? (lookup/lookup-by-entity-id model entity-id)))))))
|
||||
|
||||
(deftest lookup-by-name-test
|
||||
(doseq [model [:model/Card
|
||||
:model/Transform
|
||||
:model/Collection
|
||||
:model/NativeQuerySnippet
|
||||
:model/Database]]
|
||||
(let [entities (t2/select model)]
|
||||
(doseq [[name group] (group-by :name entities)]
|
||||
(if (= 1 (count group))
|
||||
(let [entity (first group)]
|
||||
(is (= (:id entity) (:id (lookup/lookup-by-name model name)))))
|
||||
(is (thrown? clojure.lang.ExceptionInfo
|
||||
(lookup/lookup-by-name model name))))))))
|
||||
|
||||
(deftest lookup-by-name-non-string-test
|
||||
(testing "lookup-by-name handles non-string name gracefully (returns nil)"
|
||||
(doseq [model [:model/Card
|
||||
:model/Transform
|
||||
:model/Collection
|
||||
:model/NativeQuerySnippet
|
||||
:model/Database]
|
||||
entity (t2/select model)]
|
||||
(let [name-as-keyword (keyword (:name entity))]
|
||||
(is (nil? (lookup/lookup-by-name model name-as-keyword)))))))
|
||||
@@ -0,0 +1,78 @@
|
||||
(ns metabase-enterprise.representations.v0.collection-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.yaml :as yaml]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(comment
|
||||
(deftest validate-exported-collections-test
|
||||
(testing "Exported collections validate against schema"
|
||||
(mt/with-temp [:model/Collection collection {:name "Test Collection"
|
||||
:description "A test collection"}]
|
||||
(let [edn (export/export-entity collection)
|
||||
yaml-str (yaml/generate-string edn)
|
||||
rep (yaml/parse-string yaml-str)]
|
||||
(is (rep-read/parse rep))))))
|
||||
|
||||
(deftest export-collection-test
|
||||
(testing "Collection exports with correct structure"
|
||||
(mt/with-temp [:model/Collection collection {:name "Test Collection"
|
||||
:description "A test collection"}]
|
||||
(let [exported (export/export-entity collection)]
|
||||
(is (= :collection (:type exported)))
|
||||
(is (= :v0 (:version exported)))
|
||||
(is (= "Test Collection" (:name exported)))
|
||||
(is (= "A test collection" (:description exported)))
|
||||
(is (:ref exported))
|
||||
(is (string? (:ref exported)))))))
|
||||
|
||||
(deftest export-nested-collection-test
|
||||
(testing "Nested collection exports with parent reference"
|
||||
(mt/with-temp [:model/Collection parent {:name "Parent Collection"}
|
||||
:model/Collection child {:name "Child Collection"
|
||||
:location (format "/%d/" (:id parent))}]
|
||||
(let [exported (export/export-entity child)]
|
||||
(is (= :collection (:type exported)))
|
||||
(is (= "Child Collection" (:name exported)))
|
||||
(is (:collection exported))
|
||||
(is (string? (:collection exported)))))))
|
||||
|
||||
(deftest import-collection-test
|
||||
(testing "Collection can be imported from representation"
|
||||
(let [rep {:type :collection
|
||||
:version :v0
|
||||
:ref "test-collection"
|
||||
:name "Imported Collection"
|
||||
:description "A collection imported from YAML"}
|
||||
toucan-model (import/yaml->toucan rep nil)]
|
||||
(is (= "Imported Collection" (:name toucan-model)))
|
||||
(is (= "A collection imported from YAML" (:description toucan-model))))))
|
||||
|
||||
(deftest roundtrip-collection-test
|
||||
(testing "Collection export → import → export preserves data"
|
||||
(mt/with-temp [:model/Collection collection {:name "Roundtrip Test"
|
||||
:description "Testing roundtrip"}]
|
||||
(let [export-1 (export/export-entity collection)
|
||||
yaml-str (yaml/generate-string export-1)
|
||||
rep (yaml/parse-string yaml-str)
|
||||
normalized (rep-read/parse rep)
|
||||
imported (import/yaml->toucan normalized nil)
|
||||
;; Persist and reload
|
||||
persisted (mt/with-test-user :crowberto
|
||||
(import/persist! normalized nil))
|
||||
export-2 (export/export-entity persisted)]
|
||||
(is (= (:name export-1) (:name export-2)))
|
||||
(is (= (:description export-1) (:description export-2)))
|
||||
(is (= (:type export-1) (:type export-2))))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Collection)]
|
||||
(is (= :collection (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,177 @@
|
||||
(ns metabase-enterprise.representations.v0.common-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.core :as v0]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;; Use this bad boy for testing where you want to parse a ref that looks like ref:question-45 to have it return 45
|
||||
(defrecord ParseRefEntityIndex []
|
||||
v0-common/EntityLookup
|
||||
(lookup-id [_this ref]
|
||||
(let [ur (v0-common/unref ref)
|
||||
[_type id] (str/split ur #"-")]
|
||||
(Long/parseLong id)))
|
||||
(lookup-entity [_this ref]
|
||||
(let [ur (v0-common/unref ref)
|
||||
[type id] (str/split ur #"-")
|
||||
id (Long/parseLong id)]
|
||||
(t2/select-one (v0/toucan-model type) :id id))))
|
||||
|
||||
(deftest map-entity-index-lookup-success-test
|
||||
(testing "MapEntityIndex successfully looks up entity by ref"
|
||||
(let [card (t2/instance :model/Card {:id 123 :name "Test Question" :type :question})
|
||||
idx (v0-common/map-entity-index {"question-123" card})
|
||||
result (v0-common/lookup-id idx "ref:question-123")]
|
||||
(is (= 123 result)))))
|
||||
|
||||
(deftest map-entity-index-lookup-ref-not-found-test
|
||||
(testing "MapEntityIndex throws exception when ref not found"
|
||||
(let [card (t2/instance :model/Card {:id 123 :name "Test Card" :type :question})
|
||||
idx (v0-common/map-entity-index {"card-123" card})]
|
||||
(is (nil? (v0-common/lookup-id idx "ref:card-999"))))))
|
||||
|
||||
(deftest map-entity-index-strips-ref-prefix-test
|
||||
(testing "MapEntityIndex handles ref: prefix correctly"
|
||||
(let [card (t2/instance :model/Card {:id 456 :name "Another Card" :type :question})
|
||||
idx (v0-common/map-entity-index {"card-456" card})]
|
||||
(is (= 456 (v0-common/lookup-id idx "ref:card-456"))))))
|
||||
|
||||
(deftest parse-ref-entity-index-success-test
|
||||
(testing "ParseRefEntityIndex successfully parses ref and returns ID as Long"
|
||||
(let [idx (->ParseRefEntityIndex)]
|
||||
(is (= 123 (v0-common/lookup-id idx "ref:database-123")))
|
||||
(is (= 456 (v0-common/lookup-id idx "ref:question-456")))
|
||||
(is (= 789 (v0-common/lookup-id idx "ref:model-789")))
|
||||
(is (instance? Long (v0-common/lookup-id idx "ref:database-123"))))))
|
||||
|
||||
(deftest parse-ref-entity-index-malformed-ref-test
|
||||
(testing "ParseRefEntityIndex throws exception for malformed refs"
|
||||
(let [idx (->ParseRefEntityIndex)]
|
||||
(testing "ref without dash separator"
|
||||
(is (thrown? NumberFormatException
|
||||
(v0-common/lookup-id idx "ref:database123"))))
|
||||
(testing "ref with non-numeric ID"
|
||||
(is (thrown? NumberFormatException
|
||||
(v0-common/lookup-id idx "ref:database-abc")))))))
|
||||
|
||||
(deftest entity-lookup-protocol-satisfaction-test
|
||||
(testing "Both implementations satisfy EntityLookup protocol"
|
||||
(let [map-idx (v0-common/map-entity-index {})
|
||||
parse-idx (->ParseRefEntityIndex)]
|
||||
(is (satisfies? v0-common/EntityLookup map-idx))
|
||||
(is (satisfies? v0-common/EntityLookup parse-idx)))))
|
||||
|
||||
(deftest order-representations-self-reference-test
|
||||
(testing "order-representations throws exception when representation refers to itself"
|
||||
(let [reps [{:name "a" :database "ref:a"}]
|
||||
result-future (future (try
|
||||
(v0-common/order-representations reps)
|
||||
(catch Exception e e)))
|
||||
result (deref result-future 100 ::timeout)]
|
||||
(future-cancel result-future)
|
||||
(is (instance? Exception result)
|
||||
"Expected exception to be thrown")
|
||||
(is (not= ::timeout result) "order-representations took longer than 100ms, likely infinite loop"))))
|
||||
|
||||
(deftest order-representations-circular-reference-test
|
||||
(testing "order-representations throws exception when representations have circular dependency"
|
||||
(let [reps [{:name "a" :database "ref:b"}
|
||||
{:name "b" :database "ref:a"}]
|
||||
result-future (future (try
|
||||
(v0-common/order-representations reps)
|
||||
(catch Exception e e)))
|
||||
result (deref result-future 100 ::timeout)]
|
||||
(future-cancel result-future)
|
||||
(is (instance? Exception result)
|
||||
"Expected exception to be thrown")
|
||||
(is (not= ::timeout result) "order-representations took longer than 100ms, likely infinite loop"))))
|
||||
|
||||
(deftest order-representations-empty-test
|
||||
(testing "order-representations handles empty list"
|
||||
(let [result-future (future (try
|
||||
(v0-common/order-representations [])
|
||||
(catch Exception e e)))
|
||||
result (deref result-future 100 ::timeout)]
|
||||
(future-cancel result-future)
|
||||
(is (= [] result)))))
|
||||
|
||||
(deftest order-representations-chain-test
|
||||
(testing "order-representations orders chain correctly"
|
||||
(let [reps [{:name "a" :type :question :table-ref "ref:b"}
|
||||
{:name "b" :type :model :database "ref:d"}
|
||||
{:name "c" :type :model :database "ref:d"}
|
||||
{:name "d" :type :database}]
|
||||
result-future (future (try
|
||||
(v0-common/order-representations reps)
|
||||
(catch Exception e e)))
|
||||
ordered (deref result-future 100 ::timeout)
|
||||
refs (mapv :name ordered)]
|
||||
(future-cancel result-future)
|
||||
(is (= ["d" "c" "b" "a"] refs)))))
|
||||
|
||||
(deftest order-representations-test
|
||||
(let [x [{:name "database-1",
|
||||
:type :database,
|
||||
:version :v0,
|
||||
:engine :h2,
|
||||
:display_name "Sample Database",
|
||||
:description "Some example data for you to play around with.",
|
||||
:connection_details
|
||||
{:db "file:/Users/ericnormand/code/metabase/plugins/sample-database.db;USER=GUEST;PASSWORD=guest"},
|
||||
:schemas
|
||||
[{:name "PUBLIC",
|
||||
:tables
|
||||
[{:name "ORDERS",
|
||||
:description "Confirmed Sample Company orders for a product, from a user.",
|
||||
:columns
|
||||
[{:name "QUANTITY", :type "INTEGER", :description "Number of products bought.", :nullable true}
|
||||
{:name "DISCOUNT", :type "DOUBLE PRECISION", :description "Discount amount.", :nullable true}
|
||||
{:name "TOTAL", :type "DOUBLE PRECISION", :description "The total billed amount.", :nullable true}
|
||||
{:name "ID",
|
||||
:type "BIGINT",
|
||||
:description
|
||||
"This is a unique ID for the product. It is also called the “Invoice number” or “Confirmation number” in customer facing emails and screens.",
|
||||
:nullable true}
|
||||
{:name "SUBTOTAL",
|
||||
:type "DOUBLE PRECISION",
|
||||
:description
|
||||
"The raw, pre-tax cost of the order. Note that this might be different in the future from the product price due to promotions, credits, etc.",
|
||||
:nullable true}
|
||||
{:name "USER_ID",
|
||||
:type "INTEGER",
|
||||
:description
|
||||
"The id of the user who made this order. Note that in some cases where an order was created on behalf of a customer who phoned the order in, this might be the employee who handled the request.",
|
||||
:nullable true,
|
||||
:fk "PEOPLE.ID"}
|
||||
{:name "CREATED_AT",
|
||||
:type "TIMESTAMP",
|
||||
:description "The date and time an order was submitted.",
|
||||
:nullable true}
|
||||
{:name "PRODUCT_ID",
|
||||
:type "INTEGER",
|
||||
:description "The product ID. This is an internal identifier for the product, NOT the SKU.",
|
||||
:nullable true,
|
||||
:fk "PRODUCTS.ID"}
|
||||
{:name "TAX",
|
||||
:type "DOUBLE PRECISION",
|
||||
:description
|
||||
"This is the amount of local and federal taxes that are collected on the purchase. Note that other governmental fees on some products are not included here, but instead are accounted for in the subtotal.",
|
||||
:nullable true}]}]}]}
|
||||
{:name "transform-26",
|
||||
:type :transform,
|
||||
:version :v0,
|
||||
:entity_id "q1zWv69ebx6nF4Pq0OGBE",
|
||||
:display_name "Orders count by month (transform)",
|
||||
:description "",
|
||||
:database "ref:database-1",
|
||||
:target_table {:schema "PUBLIC", :table "ORDERS_COUNT"},
|
||||
:lib_query
|
||||
[{:lib/type :mbql.stage/mbql,
|
||||
:source-table {:database "ref:database-1", :schema "PUBLIC", :table "ORDERS"},
|
||||
:aggregation [[:count {:lib/uuid "8b980f80-1b7e-4f46-9c85-72f7d89d96bf"}]],
|
||||
:breakout [{:database "ref:database-1", :schema "PUBLIC", :table "ORDERS", :field "CREATED_AT"}]}]}]]
|
||||
(is (v0-common/order-representations x))))
|
||||
@@ -0,0 +1,172 @@
|
||||
(ns metabase-enterprise.representations.v0.dashboard-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.dashboard :as v0-dashboard]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(deftest export-dashboard-test
|
||||
(testing "Exporting a dashboard produces valid representation"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Test Collection"}
|
||||
:model/Card {card-id :id} {:type :question
|
||||
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
|
||||
:model/Dashboard {dashboard-id :id} {:name "Test Dashboard"
|
||||
:collection_id collection-id
|
||||
:parameters []}
|
||||
:model/DashboardCard {_dashcard-id :id} {:dashboard_id dashboard-id
|
||||
:card_id card-id
|
||||
:row 0
|
||||
:col 0
|
||||
:size_x 4
|
||||
:size_y 4}]
|
||||
(let [exported (export/export-entity (t2/select-one :model/Dashboard :id dashboard-id))]
|
||||
(testing "has required fields"
|
||||
(is (= :dashboard (:type exported)))
|
||||
(is (= :v0 (:version exported)))
|
||||
(is (= "Test Dashboard" (:display_name exported)))
|
||||
(is (some? (:name exported))))
|
||||
|
||||
(testing "includes dashcards"
|
||||
(is (seq (:dashcards exported)))
|
||||
(is (= 1 (count (:dashcards exported)))))
|
||||
|
||||
(testing "dashcard has card reference"
|
||||
(let [dashcard (first (:dashcards exported))
|
||||
card-ref (:card_id dashcard)]
|
||||
(is (some? card-ref))
|
||||
(is (string? card-ref))
|
||||
(is (re-matches #"ref:question-\d+" card-ref))))))))
|
||||
|
||||
(deftest export-import-dashboard-roundtrip-test
|
||||
(testing "Export then import roundtrip for dashboard"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Dashboard Collection"}
|
||||
:model/Card {card-id :id} {:type :question
|
||||
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
|
||||
:model/Dashboard {dashboard-id :id}
|
||||
{:name "My Dashboard"
|
||||
:collection_id collection-id
|
||||
:parameters []
|
||||
:description "A test dashboard"}
|
||||
:model/DashboardCard {dashcard-id :id}
|
||||
{:dashboard_id dashboard-id
|
||||
:card_id card-id
|
||||
:row 0
|
||||
:col 0
|
||||
:size_x 4
|
||||
:size_y 4}]
|
||||
(let [dashboard (t2/select-one :model/Dashboard :id dashboard-id)
|
||||
dashboard-edn (export/export-entity dashboard)
|
||||
dashboard-yaml (rep-yaml/generate-string dashboard-edn)
|
||||
dashboard-rep (rep-yaml/parse-string dashboard-yaml)
|
||||
collection (t2/select-one :model/Collection :id collection-id)
|
||||
card (t2/select-one :model/Card :id card-id)
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(v0-common/unref (v0-common/->ref collection-id :collection))
|
||||
collection
|
||||
(v0-common/unref (v0-common/->ref card-id :question))
|
||||
card})
|
||||
|
||||
;; Persist the imported dashboard
|
||||
_ (import/update! dashboard-rep dashboard-id ref-index)
|
||||
imported-dashboard (t2/select-one :model/Dashboard :id dashboard-id)
|
||||
|
||||
;; Export again and compare
|
||||
edn2 (export/export-entity imported-dashboard)
|
||||
yaml2 (rep-yaml/generate-string edn2)
|
||||
rep2 (rep-yaml/parse-string yaml2)]
|
||||
|
||||
(testing "roundtrip preserves dashboard name"
|
||||
(is (= "My Dashboard" (:display_name rep2))))
|
||||
|
||||
(testing "roundtrip preserves dashboard type"
|
||||
(is (= "dashboard" (name (:type rep2)))))
|
||||
|
||||
(testing "roundtrip preserves dashcards"
|
||||
(is (seq (:dashcards rep2)))
|
||||
(is (= 1 (count (:dashcards rep2)))))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(testing "representation-type multimethod returns :dashboard"
|
||||
(mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "Type Test Dashboard"
|
||||
:parameters []}]
|
||||
(let [dashboard (t2/select-one :model/Dashboard :id dashboard-id)]
|
||||
(is (= :dashboard (v0-common/representation-type dashboard)))))))
|
||||
|
||||
(deftest dashboard-with-tabs-test
|
||||
(testing "Dashboard with tabs exports and imports correctly"
|
||||
(mt/with-temp [:model/Card {card-id :id} {:type :question
|
||||
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
|
||||
:model/Dashboard {dashboard-id :id} {:name "Tabbed Dashboard"
|
||||
:parameters []}
|
||||
:model/DashboardTab {tab1-id :id} {:dashboard_id dashboard-id
|
||||
:name "Tab 1"
|
||||
:position 0}
|
||||
:model/DashboardTab {tab2-id :id} {:dashboard_id dashboard-id
|
||||
:name "Tab 2"
|
||||
:position 1}
|
||||
:model/DashboardCard {_dashcard-id :id} {:dashboard_id dashboard-id
|
||||
:card_id card-id
|
||||
:dashboard_tab_id tab1-id
|
||||
:row 0
|
||||
:col 0
|
||||
:size_x 4
|
||||
:size_y 4}]
|
||||
(let [exported (export/export-entity (t2/select-one :model/Dashboard :id dashboard-id))]
|
||||
(testing "exports tabs"
|
||||
(is (seq (:tabs exported)))
|
||||
(is (= 2 (count (:tabs exported)))))
|
||||
|
||||
(testing "tab structure is correct"
|
||||
(let [tab1 (first (:tabs exported))]
|
||||
(is (some? (:name tab1)))
|
||||
(is (number? (:position tab1)))))
|
||||
|
||||
(testing "dashcard references tab"
|
||||
(let [dashcard (first (:dashcards exported))]
|
||||
(is (some? (:dashboard_tab_id dashcard)))))))))
|
||||
|
||||
(deftest dashboard-card-embed-refs-test
|
||||
(testing "Dashboard with embedded cards creates proper references"
|
||||
(mt/with-temp [:model/Card {card-id :id} {:type :question
|
||||
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
|
||||
:model/Dashboard {dashboard-id :id} {:name "Test Dashboard"
|
||||
:parameters []}
|
||||
:model/DashboardCard {_dashcard-id :id} {:dashboard_id dashboard-id
|
||||
:card_id card-id
|
||||
:row 0
|
||||
:col 0
|
||||
:size_x 4
|
||||
:size_y 4}]
|
||||
(let [rep (export/export-entity (t2/select-one :model/Dashboard :id dashboard-id))
|
||||
refs (v0-common/refs rep)]
|
||||
(testing "dashboard references the embedded card"
|
||||
(is (contains? refs (str "question-" card-id))))))))
|
||||
|
||||
(deftest text-dashcard-test
|
||||
(testing "Text-only dashcard (no card_id) exports correctly"
|
||||
(mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "Dashboard with Text"
|
||||
:parameters []}
|
||||
:model/DashboardCard {_dashcard-id :id} {:dashboard_id dashboard-id
|
||||
:card_id nil
|
||||
:row 0
|
||||
:col 0
|
||||
:size_x 4
|
||||
:size_y 2
|
||||
:visualization_settings {:text "Hello, World!"}}]
|
||||
(let [exported (export/export-entity (t2/select-one :model/Dashboard :id dashboard-id))]
|
||||
(testing "includes text dashcard"
|
||||
(is (seq (:dashcards exported)))
|
||||
(is (= 1 (count (:dashcards exported)))))
|
||||
|
||||
(testing "text dashcard has nil card_id"
|
||||
(let [dashcard (first (:dashcards exported))]
|
||||
(is (nil? (:card_id dashcard)))
|
||||
(is (some? (get-in dashcard [:visualization_settings :text])))))))))
|
||||
@@ -0,0 +1,47 @@
|
||||
(ns metabase-enterprise.representations.v0.database-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.core :as rep]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(deftest export-import
|
||||
(testing "Testing export then import roundtrip"
|
||||
(mt/with-temp [:model/Database database {:engine :postgres
|
||||
:name "abc"
|
||||
:details "{}"}]
|
||||
(let [edn (rep/export database)
|
||||
yaml (rep-yaml/generate-string edn)
|
||||
rep (rep-yaml/parse-string yaml)
|
||||
rep (rep-read/parse rep)
|
||||
database (rep/persist! rep)
|
||||
database (t2/select-one :model/Database :id (:id database))
|
||||
edn (rep/export database)
|
||||
yaml (rep-yaml/generate-string edn)
|
||||
rep2 (rep-yaml/parse-string yaml)
|
||||
|
||||
rep2 (rep-read/parse rep2)]
|
||||
(is (=? (dissoc rep :name) rep2)))))
|
||||
|
||||
(testing "Testing export then import roundtrip, import doesn't change details"
|
||||
(mt/with-temp [:model/Database database {:engine :postgres
|
||||
:name "abc"
|
||||
:description "MY DB"}]
|
||||
(let [edn (rep/export database)
|
||||
edn (assoc edn :description "CHANGE")
|
||||
yaml (rep-yaml/generate-string edn)
|
||||
rep (rep-yaml/parse-string yaml)
|
||||
rep (rep-read/parse rep)
|
||||
database2 (rep/persist! rep)
|
||||
database2 (t2/select-one :model/Database :id (:id database2))]
|
||||
(is (=? database database2))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Database)]
|
||||
(is (= :database (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,103 @@
|
||||
(ns metabase-enterprise.representations.v0.document-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.core :as rep]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.document :as v0-document]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
#_(deftest markdown-prosemirror-roundtrip
|
||||
(let [md "# Hello"
|
||||
pm (#'v0-document/markdown->yaml md)
|
||||
edn (rep-yaml/parse-string pm)
|
||||
md2 (#'v0-document/edn->markdown edn)
|
||||
pm2 (#'v0-document/markdown->yaml md2)
|
||||
edn2 (rep-yaml/parse-string pm2)]
|
||||
(is (= md md2))
|
||||
(is (= edn edn2))))
|
||||
|
||||
(deftest export-document-test
|
||||
(testing "Exporting a document produces valid EDN"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Test Collection"}
|
||||
:model/Document {document-id :id} {:name "Test Document"
|
||||
:collection_id collection-id
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Hello, world!"}]}]}}]
|
||||
(let [exported (export/export-entity (t2/select-one :model/Document :id document-id))]
|
||||
(testing "has required fields"
|
||||
(is (= :document (:type exported)))
|
||||
(is (= :v0 (:version exported)))
|
||||
(is (= "Test Document" (:display_name exported)))
|
||||
(is (some? (:name exported)))
|
||||
(is (= :prosemirror (:content_type exported))))))))
|
||||
|
||||
(deftest export-import-singleton-test
|
||||
(testing "Export then import roundtrip for document"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Document Collection"}
|
||||
:model/Document {document-id :id}
|
||||
{:name "My Document"
|
||||
:collection_id collection-id
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Test content"}]}
|
||||
{:type "heading"
|
||||
:attrs {:level 2}
|
||||
:content [{:type "text"
|
||||
:text "Section Title"}]}]}}]
|
||||
(let [document (t2/select-one :model/Document :id document-id)
|
||||
document-edn (export/export-entity document)
|
||||
document-yaml (rep-yaml/generate-string document-edn)
|
||||
document-rep (rep-yaml/parse-string document-yaml)
|
||||
collection (t2/select-one :model/Collection :id collection-id)
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(v0-common/unref (v0-common/->ref collection-id :collection))
|
||||
collection})
|
||||
|
||||
;; Persist the imported document
|
||||
_ (import/update! document-rep document-id ref-index)
|
||||
imported-document (t2/select-one :model/Document :id document-id)
|
||||
|
||||
;; Export again and compare
|
||||
edn2 (export/export-entity imported-document)
|
||||
yaml2 (rep-yaml/generate-string edn2)
|
||||
rep2 (rep-yaml/parse-string yaml2)]
|
||||
|
||||
(testing "roundtrip preserves document name"
|
||||
(is (= "My Document" (:display_name rep2))))
|
||||
|
||||
(testing "roundtrip preserves document type"
|
||||
(is (= "document" (name (:type rep2)))))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(testing "representation-type multimethod returns :document"
|
||||
(mt/with-temp [:model/Document {document-id :id} {:name "Type Test Document"
|
||||
:document {:type "doc"
|
||||
:content []}}]
|
||||
(let [document (t2/select-one :model/Document :id document-id)]
|
||||
(is (= :document (v0-common/representation-type document)))))))
|
||||
|
||||
(deftest document-card-embed-refs
|
||||
(mt/with-temp [:model/Card card {:type :question
|
||||
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
|
||||
:model/Document document
|
||||
{:name "My Document"
|
||||
:document {:type "resizeNode"
|
||||
:attrs {:height 442
|
||||
:minHeight 280}
|
||||
:content [{:type "cardEmbed"
|
||||
:attrs {:id (:id card)}}]}}]
|
||||
(let [rep (export/export-entity document)
|
||||
refs (v0-common/refs rep)]
|
||||
(is (contains? refs (str "question-" (:id card)))))))
|
||||
@@ -0,0 +1,35 @@
|
||||
(ns metabase-enterprise.representations.v0.export-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.init]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest export-group-test
|
||||
(testing "We get the question and database when exporting the question"
|
||||
(mt/with-temp [:model/Card question {:type :question
|
||||
:dataset_query (mt/native-query {:query "select 1"})}]
|
||||
(let [database (t2/select-one :model/Database :id (mt/id))
|
||||
qrep (export/export-entity question)
|
||||
drep (export/export-entity database)
|
||||
set (export/export-set #{qrep})]
|
||||
(is (= (:id database)
|
||||
(:database_id question)))
|
||||
(is (= 2 (count set)))
|
||||
(is (= #{qrep drep}
|
||||
set)))))
|
||||
|
||||
;; TODO: needs to be updated given Cam's big MBQL5 PRs
|
||||
#_(testing "We get the question, the question it refers to, and database when exporting the question"
|
||||
(mt/with-temp [:model/Card question1 {:type :question
|
||||
:dataset_query (mt/native-query {:query "select 1"})}
|
||||
:model/Card question2 {:type :question
|
||||
:dataset_query (mt/mbql-query nil {:source-table (str "card__" (:id question1))})}]
|
||||
(let [database (t2/select-one :model/Database :id (mt/id))
|
||||
qrep1 (export/export-entity question1)
|
||||
qrep2 (export/export-entity question2)
|
||||
drep (export/export-entity database)
|
||||
set (export/export-set #{qrep2})]
|
||||
(is (= 3 (count set)))
|
||||
(is (= #{qrep2 drep qrep1} set))))))
|
||||
@@ -0,0 +1,32 @@
|
||||
(ns metabase-enterprise.representations.v0.mbql-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(deftest export-dataset-query-test
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card card {:dataset_query (lib/query mp (lib.metadata/table mp (mt/id :users)))}]
|
||||
(doseq [query [(lib/query mp (lib.metadata/table mp (mt/id :users)))
|
||||
(lib/query mp (lib.metadata/card mp (:id card)))
|
||||
(lib/native-query mp "select 1")]]
|
||||
(let [exported-query (v0-mbql/export-dataset-query query)
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(v0-common/unref (:database exported-query))
|
||||
(t2/instance :model/Database :id (:database query))
|
||||
(v0-common/unref (v0-common/entity->ref card))
|
||||
card})]
|
||||
(is (:database exported-query))
|
||||
(is (v0-mbql/import-dataset-query exported-query ref-index))))))
|
||||
;; make sure we can get the query of any existing cards
|
||||
(doseq [card (t2/select :model/Card)]
|
||||
(let [query (v0-mbql/export-dataset-query (:dataset_query card))]
|
||||
(is (:database query))
|
||||
(is (:query query)))))
|
||||
@@ -0,0 +1,45 @@
|
||||
(ns metabase-enterprise.representations.v0.metric-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.core :as rep]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(def valid-examples ["test_resources/representations/v0/select-1.metric.yml"])
|
||||
|
||||
(deftest validate-example-yamls
|
||||
(testing "Testing valid examples"
|
||||
(doseq [filename valid-examples]
|
||||
(testing (str "Validating: " filename)
|
||||
(let [rep (rep-yaml/from-file filename)]
|
||||
(is (rep-read/parse rep)))))))
|
||||
|
||||
(deftest validate-exported-metrics
|
||||
(doseq [query [(mt/native-query {:query "select 1"})
|
||||
(mt/mbql-query users)]]
|
||||
(mt/with-temp [:model/Card metric {:type :metric
|
||||
:dataset_query query}]
|
||||
(let [edn (rep/export metric)
|
||||
;; convert to yaml and read back in to convert keywords to strings, etc
|
||||
yaml (rep-yaml/generate-string edn)
|
||||
rep (rep-yaml/parse-string yaml)]
|
||||
(is (rep-read/parse rep))))))
|
||||
|
||||
(deftest can-import
|
||||
(doseq [filename valid-examples]
|
||||
(testing (str "Importing: " filename)
|
||||
(let [rep (rep-yaml/from-file filename)
|
||||
ref-index (-> {(v0-common/unref (:database rep))
|
||||
(t2/select-one :model/Database (mt/id))}
|
||||
(v0-common/map-entity-index))]
|
||||
(is (rep/persist! rep ref-index))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Card :type :metric)]
|
||||
(is (= :metric (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,176 @@
|
||||
(ns metabase-enterprise.representations.v0.model-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.core :as rep]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.yaml :as yaml]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(deftest validate-example-yamls
|
||||
(testing "Testing valid examples"
|
||||
(doseq [filename
|
||||
["test_resources/representations/v0/product-performance.model.yml"
|
||||
"test_resources/representations/v0/collection-8/sales-data.model.yml"]]
|
||||
(testing (str "Validating: " filename)
|
||||
(let [rep (yaml/from-file filename)]
|
||||
(is (rep-read/parse rep)))))))
|
||||
|
||||
(deftest validate-exported-models
|
||||
(doseq [query [(mt/native-query {:query "select 1"})
|
||||
(mt/mbql-query users)]]
|
||||
(mt/with-temp [:model/Card model {:type :model
|
||||
:dataset_query query}]
|
||||
(let [edn (rep/export model)
|
||||
;; convert to yaml and read back in to convert keywords to strings, etc
|
||||
yaml (yaml/generate-string edn)
|
||||
rep (yaml/parse-string yaml)]
|
||||
(is (rep-read/parse rep))))))
|
||||
|
||||
;; TODO: replace old stale tests
|
||||
|
||||
;; TODO: Need to load sample database first
|
||||
;; TODO: And NOW it's failing because there's more than one "Sample Database"
|
||||
;; Clean up after we decide on testing framework for lookup-by-name
|
||||
#_(deftest can-import
|
||||
(doseq [filename ["test_resources/representations/v0/product-performance.model.yml"
|
||||
#_"test_resources/representations/v0/collection-8/sales-data-enriched.model.yml"
|
||||
#_"test_resources/representations/v0/collection-8/sales-data.model.yml"]]
|
||||
(testing (str "Importing: " filename)
|
||||
(let [rep (yaml/from-file filename)]
|
||||
(is (rep/persist! (rep/normalize-representation rep)))))))
|
||||
|
||||
;; Dependent on MBQL work (QUE-2667)
|
||||
#_(deftest export-import-roundtrip-test
|
||||
(testing "Testing export then import roundtrip for models"
|
||||
(doseq [query [(mt/native-query {:query "select 1"})
|
||||
(mt/mbql-query users)]]
|
||||
(mt/with-temp [:model/Card model {:type :model
|
||||
:dataset_query query}]
|
||||
(let [card-edn (rep/export model)
|
||||
card-yaml (yaml/generate-string card-edn)
|
||||
card-rep (yaml/parse-string card-yaml)
|
||||
card-rep (rep/normalize-representation card-rep)
|
||||
;; Build ref-index with database and MBQL data (if present)
|
||||
ref-index {(v0-common/unref (:database card-edn)) (t2/select-one :model/Database (mt/id))}
|
||||
_ (clojure.pprint/pprint card-rep)
|
||||
model (rep/persist! card-rep ref-index)
|
||||
model (t2/select-one :model/Card :id (:id model))
|
||||
edn (rep/export model)
|
||||
yaml (yaml/generate-string edn)
|
||||
rep2 (yaml/parse-string yaml)
|
||||
rep2 (rep/normalize-representation rep2)]
|
||||
;; For models with MBQL, the mbql_query ref will differ due to new IDs
|
||||
;; So we compare structure excluding :name and :mbql_query
|
||||
(is (=? (dissoc card-rep :name :mbql_query) (dissoc rep2 :name :mbql_query))))))))
|
||||
|
||||
(deftest export-mbql-model-includes-columns-test
|
||||
(testing "MBQL models export user-editable column metadata"
|
||||
(mt/with-temp [:model/Card model {:type :model
|
||||
:dataset_query (mt/mbql-query orders)}]
|
||||
(let [exported (export/export-entity model)]
|
||||
(testing "Exported representation has columns field"
|
||||
(is (contains? exported :columns))
|
||||
(is (seq (:columns exported))))
|
||||
(testing "Each column has required name field"
|
||||
(doseq [col (:columns exported)]
|
||||
(is (:name col))))
|
||||
(testing "Columns contain only user-editable fields"
|
||||
(doseq [col (:columns exported)]
|
||||
(is (nil? (:id col)))
|
||||
(is (nil? (:table_id col)))
|
||||
(is (nil? (:fingerprint col)))
|
||||
(is (nil? (:field_ref col)))
|
||||
(is (nil? (:base_type col)))
|
||||
(is (nil? (:effective_type col)))))))))
|
||||
|
||||
#_(deftest import-user-columns-override-base-metadata-test
|
||||
(testing "User-edited columns in model.yml override base metadata from mbql.yml"
|
||||
(mt/with-temp [:model/Card model {:type :model
|
||||
:dataset_query (mt/mbql-query orders)}]
|
||||
(let [mbql-data (export/export-mbql-data model)
|
||||
base-metadata (:result_metadata mbql-data)
|
||||
base-subtotal (first (filter #(= "SUBTOTAL" (:name %)) base-metadata))
|
||||
original-display-name (:display_name base-subtotal)
|
||||
user-columns [{:name "SUBTOTAL"
|
||||
:display_name "Order Total (Pre-Tax)"
|
||||
:description "The subtotal amount before taxes are applied"
|
||||
:semantic_type :type/Currency
|
||||
:settings {:text_align "right" :currency "USD"}}]
|
||||
model-rep {:type :v0/model
|
||||
:name "test-model"
|
||||
:display_name "Test Model"
|
||||
:database (str "ref:database-" (mt/id))
|
||||
:mbql_query (str "ref:" (:name mbql-data))
|
||||
:columns user-columns}
|
||||
ref-index {(:name mbql-data) mbql-data
|
||||
(str "database-" (mt/id)) (t2/select-one :model/Database (mt/id))}
|
||||
imported (import/yaml->toucan model-rep ref-index)
|
||||
imported-metadata (:result_metadata imported)
|
||||
imported-subtotal (first (filter #(= "SUBTOTAL" (:name %)) imported-metadata))]
|
||||
(testing "User's display_name overrides base"
|
||||
(is (= "Order Total (Pre-Tax)" (:display_name imported-subtotal)))
|
||||
(is (not= original-display-name (:display_name imported-subtotal))))
|
||||
(testing "User's description is added"
|
||||
(is (= "The subtotal amount before taxes are applied"
|
||||
(:description imported-subtotal))))
|
||||
(testing "User's semantic_type overrides base"
|
||||
(is (= :type/Currency (:semantic_type imported-subtotal))))
|
||||
(testing "User's settings are merged with base settings"
|
||||
(is (= "right" (get-in imported-subtotal [:settings :text_align])))
|
||||
(is (= "USD" (get-in imported-subtotal [:settings :currency]))))
|
||||
(testing "Base metadata fields are preserved"
|
||||
(is (:id imported-subtotal) "Should preserve internal id")
|
||||
(is (:field_ref imported-subtotal) "Should preserve field_ref")
|
||||
(is (:base_type imported-subtotal) "Should preserve base_type"))))))
|
||||
#_(deftest roundtrip-preserves-user-edits-test
|
||||
(testing "Export → Edit → Import → Export cycle preserves user edits"
|
||||
(mt/with-temp [:model/Card model {:type :model
|
||||
:dataset_query (mt/mbql-query orders)}]
|
||||
(let [export-1 (export/export-entity model)
|
||||
mbql-data-1 (export/export-mbql-data model)
|
||||
original-subtotal-name (-> export-1 :columns
|
||||
(->> (filter #(= "SUBTOTAL" (:name %))))
|
||||
first :display_name)
|
||||
edited-rep (-> export-1
|
||||
(assoc :type :v0/model)
|
||||
(update :columns
|
||||
(fn [cols]
|
||||
(mapv (fn [col]
|
||||
(if (= "SUBTOTAL" (:name col))
|
||||
(assoc col
|
||||
:display_name "Pre-Tax Total"
|
||||
:description "Total before tax")
|
||||
col))
|
||||
cols))))
|
||||
ref-index-1 {(:name mbql-data-1) mbql-data-1
|
||||
(v0-common/unref (:database edited-rep)) (t2/select-one :model/Database (mt/id))}
|
||||
imported (import/yaml->toucan edited-rep ref-index-1)
|
||||
persisted (binding [api/*current-user-id* (mt/user->id :crowberto)]
|
||||
(import/persist! edited-rep ref-index-1))
|
||||
export-2 (export/export-entity persisted)
|
||||
final-subtotal (-> export-2 :columns
|
||||
(->> (filter #(= "SUBTOTAL" (:name %))))
|
||||
first)]
|
||||
(testing "Original export has original display name"
|
||||
(is original-subtotal-name))
|
||||
(testing "After import, model has edited values in result_metadata"
|
||||
(let [imported-subtotal (first (filter #(= "SUBTOTAL" (:name %))
|
||||
(:result_metadata imported)))]
|
||||
(is (= "Pre-Tax Total" (:display_name imported-subtotal)))
|
||||
(is (= "Total before tax" (:description imported-subtotal)))))
|
||||
(testing "Second export preserves the user edits"
|
||||
(is (= "Pre-Tax Total" (:display_name final-subtotal)))
|
||||
(is (= "Total before tax" (:description final-subtotal))))
|
||||
(testing "User edits persist through the full cycle"
|
||||
(is (not= original-subtotal-name (:display_name final-subtotal))
|
||||
"Final export should have edited name, not original"))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Card :type :model)]
|
||||
(is (= :model (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,124 @@
|
||||
(ns metabase-enterprise.representations.v0.question-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.core :as rep]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(def singleton-yamls ["test_resources/representations/v0/singletons/monthly-revenue.question.yml"])
|
||||
|
||||
(deftest validate-example-yamls
|
||||
(testing "Testing valid examples"
|
||||
(doseq [filename singleton-yamls]
|
||||
(testing (str "Validating: " filename)
|
||||
(let [rep (rep-yaml/from-file filename)]
|
||||
(is (rep/normalize-representation rep))))))
|
||||
(testing "Testing invalid examples"
|
||||
(doseq [filename
|
||||
["test_resources/representations/v0/invalid.question.yml"]]
|
||||
(testing (str "Validating: " filename)
|
||||
(let [rep (rep-yaml/from-file filename)]
|
||||
(is (thrown? clojure.lang.ExceptionInfo (rep-read/parse rep))))))))
|
||||
|
||||
(deftest validate-exported-questions
|
||||
(doseq [query [(mt/native-query {:query "select 1"})
|
||||
(mt/mbql-query users)]]
|
||||
(mt/with-temp [:model/Card question {:type :question
|
||||
:dataset_query query}]
|
||||
(let [edn (rep/export question)
|
||||
;; convert to yaml and read back in to convert keywords to strings, etc
|
||||
yaml (rep-yaml/generate-string edn)
|
||||
rep (rep-yaml/parse-string yaml)]
|
||||
(is (rep-read/parse rep))))))
|
||||
|
||||
(deftest can-import
|
||||
(let [filename "test_resources/representations/v0/monthly-revenue.question.yml"
|
||||
rep (rep-yaml/from-file filename)
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(v0-common/unref (:database rep))
|
||||
(t2/select-one :model/Database (mt/id))})]
|
||||
(is (rep/persist! rep ref-index))))
|
||||
|
||||
(deftest database-import-test
|
||||
(mt/with-temp [:model/Card question {:type :question
|
||||
:dataset_query (mt/mbql-query users)}]
|
||||
(let [edn (rep/export question)
|
||||
export-set (export/export-set [edn])
|
||||
export-set (export/reduce-tables export-set)
|
||||
db-ref (v0-common/unref (:database edn))
|
||||
idx (into {} (map (juxt :name identity)) export-set)
|
||||
database (get idx db-ref)]
|
||||
(is (= 2 (count export-set)))
|
||||
(is (= :database (:type database)))
|
||||
(let [database-tables (set (for [schema (:schemas database)
|
||||
table (:tables schema)]
|
||||
[(:name schema) (:name table)]))]
|
||||
(is (= #{["PUBLIC" "USERS"]} database-tables))))))
|
||||
|
||||
;; TODO: this test is fundamentally broken, trying to force the use of ID 1:
|
||||
;; This fails in db lookup
|
||||
#_(deftest import-export-singleton-test
|
||||
(testing "Testing import then export roundtrip with IDs"
|
||||
(doseq [filename singleton-yamls]
|
||||
(testing (str "Importing-Exporting: " filename)
|
||||
(let [rep (-> (import/import-yaml filename)
|
||||
(assoc :database (v0-common/->ref 1 :database)))
|
||||
persisted (rep/persist! rep
|
||||
(v0-common/map-entity-index
|
||||
{(v0-common/unref (v0-common/->ref 1 :database))
|
||||
(t2/select-one :model/Database :id 1)}))]
|
||||
(is persisted)
|
||||
(let [question (t2/select-one :model/Card :id (:id persisted))
|
||||
edn (export/export-entity question)
|
||||
yaml (rep-yaml/generate-string edn)
|
||||
rep2 (rep-yaml/parse-string yaml)]
|
||||
(is (=? (dissoc rep :ref) rep2)
|
||||
"Exported representation should match imported (except autogenerated ref)")))))))
|
||||
|
||||
(deftest export-import-singleton-test
|
||||
(testing "Testing export then import roundtrip"
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card card {:type :question
|
||||
:dataset_query (lib/native-query mp "select 2")}]
|
||||
(doseq [query [(mt/native-query {:query "select 1"})
|
||||
(mt/mbql-query users)
|
||||
(lib/native-query (mt/metadata-provider) "select 1")
|
||||
(lib/query mp (lib.metadata/table mp (mt/id :orders)))
|
||||
(lib/query mp (lib.metadata/card mp (:id card)))]]
|
||||
(mt/with-temp [:model/Card {qid :id} {:type :question
|
||||
:dataset_query query}]
|
||||
(let [question (t2/select-one :model/Card :id qid)
|
||||
card-edn (export/export-entity question)
|
||||
card-yaml (rep-yaml/generate-string card-edn)
|
||||
card-rep (rep-yaml/parse-string card-yaml)
|
||||
card-rep (rep-read/parse card-rep)
|
||||
|
||||
;; Build ref-index with database
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(v0-common/unref (:database card-edn))
|
||||
(t2/select-one :model/Database (mt/id))
|
||||
|
||||
(v0-common/unref (v0-common/entity->ref card))
|
||||
card})
|
||||
|
||||
question (import/insert! card-rep ref-index)
|
||||
question (t2/select-one :model/Card :id (:id question))
|
||||
edn (export/export-entity question)
|
||||
yaml (rep-yaml/generate-string edn)
|
||||
rep2 (rep-yaml/parse-string yaml)
|
||||
rep2 (rep-read/parse rep2)]
|
||||
(is (=? (dissoc card-rep :name :entity-id) rep2)))))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Card :type :question)]
|
||||
(is (= :question (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,217 @@
|
||||
(ns metabase-enterprise.representations.v0.snippet-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(defn- run-snippet-export-import-test! [name description content exp-tags]
|
||||
(mt/with-temp [:model/NativeQuerySnippet {snippet-id :id :as snippet} {:name name
|
||||
:description description
|
||||
:content content}]
|
||||
(try
|
||||
(let [representation (export/export-entity snippet)
|
||||
toucan-model (->> (import/yaml->toucan representation nil)
|
||||
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
|
||||
_ (t2/delete! :model/NativeQuerySnippet :id snippet-id)
|
||||
persisted-snippet (import/persist! representation nil)]
|
||||
(is (= {:name (str "snippet-" snippet-id),
|
||||
:type :snippet,
|
||||
:version :v0,
|
||||
:display_name name,
|
||||
:description description,
|
||||
:sql content,
|
||||
:template_tags {}}
|
||||
representation))
|
||||
(is (=? {:name name,
|
||||
:description description,
|
||||
:content content,
|
||||
:creator_id 13371338,
|
||||
:template_tags exp-tags}
|
||||
toucan-model))
|
||||
(is (=? {:description description,
|
||||
:archived false,
|
||||
:dependency_analysis_version 0,
|
||||
:content content,
|
||||
:name name,
|
||||
:creator_id 13371338,
|
||||
:id (inc snippet-id),
|
||||
:template_tags exp-tags}
|
||||
persisted-snippet)))
|
||||
(finally
|
||||
(t2/delete! :model/NativeQuerySnippet :id (inc snippet-id))))))
|
||||
|
||||
(deftest snippet-export-import-entity-test
|
||||
(testing "snippet with template tag is exported correctly"
|
||||
(run-snippet-export-import-test! "snippet one" "the first snippet" "foo = 1" {}))
|
||||
(testing "snippet with variable template tag is exported correctly"
|
||||
(run-snippet-export-import-test! "foo snippet" "a test snippet" "foo = {{ val }}" {"val" {:type :text :name "val" :display-name "Val"}}))
|
||||
(testing "snippet with card template tag is exported correctly"
|
||||
(let [mp (mt/metadata-provider)
|
||||
venues (lib.metadata/table mp (mt/id :venues))]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:name "card one"
|
||||
:type :question
|
||||
:dataset_query (lib/query mp venues)}
|
||||
:model/NativeQuerySnippet {snippet-id :id :as snippet} {:name "foo snippet"
|
||||
:description "a test snippet"
|
||||
:content (str "select * from {{ #" card-id "-card-one }} where id = 1")}]
|
||||
(try
|
||||
(let [representation (export/export-entity snippet)
|
||||
toucan-model (->> (import/yaml->toucan representation nil)
|
||||
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
|
||||
_ (t2/delete! :model/NativeQuerySnippet :id snippet-id)
|
||||
persisted-snippet (import/persist! representation nil)]
|
||||
(is (= {:name (str "snippet-" snippet-id),
|
||||
:type :snippet,
|
||||
:version :v0,
|
||||
:display_name "foo snippet",
|
||||
:description "a test snippet",
|
||||
:sql (str "select * from {{ #" card-id "-card-one }} where id = 1"),
|
||||
:template_tags {(str "#" card-id "-card-one") (str "ref:card-" card-id)}}
|
||||
representation))
|
||||
(is (=? {:name "foo snippet",
|
||||
:description "a test snippet",
|
||||
:content (str "select * from {{ #" card-id "-card-one }} where id = 1"),
|
||||
:creator_id 13371338,
|
||||
:template_tags {(str "#" card-id "-card-one")
|
||||
{:type :card,
|
||||
:name (str "#" card-id "-card-one"),
|
||||
:card-id card-id,
|
||||
:display-name (str "#" card-id " Card One")}}}
|
||||
toucan-model))
|
||||
(is (=? {:description "a test snippet",
|
||||
:archived false,
|
||||
:dependency_analysis_version 0,
|
||||
:content (str "select * from {{ #" card-id "-card-one }} where id = 1"),
|
||||
:name "foo snippet",
|
||||
:creator_id 13371338,
|
||||
:id (inc snippet-id),
|
||||
:template_tags {(str "#" card-id "-card-one")
|
||||
{:type :card,
|
||||
:name (str "#" card-id "-card-one"),
|
||||
:card-id card-id,
|
||||
:display-name (str "#" card-id " Card One")}}}
|
||||
persisted-snippet)))
|
||||
(finally
|
||||
(t2/delete! :model/NativeQuerySnippet :id (inc snippet-id)))))))
|
||||
(testing "snippet with snippet template tag is exported correctly"
|
||||
(mt/with-temp [:model/NativeQuerySnippet {bar-snippet-id :id} {:name "bar snippet"
|
||||
:content "bar = 2"}
|
||||
:model/NativeQuerySnippet {snippet-id :id :as snippet} {:name "foo snippet"
|
||||
:description "a test snippet"
|
||||
:content "foo = 1 and {{ snippet: bar snippet}}"}]
|
||||
(try (let [representation (export/export-entity snippet)
|
||||
toucan-model (->> (import/yaml->toucan representation nil)
|
||||
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
|
||||
_ (t2/delete! :model/NativeQuerySnippet :id snippet-id)
|
||||
persisted-snippet (import/persist! representation nil)]
|
||||
(is (= {:name (str "snippet-" snippet-id),
|
||||
:type :snippet,
|
||||
:version :v0,
|
||||
:display_name "foo snippet",
|
||||
:description "a test snippet",
|
||||
:sql "foo = 1 and {{ snippet: bar snippet}}",
|
||||
:template_tags {"snippet: bar snippet" (str "ref:snippet-" bar-snippet-id)}}
|
||||
(export/export-entity snippet)))
|
||||
(is (=? {:name "foo snippet",
|
||||
:description "a test snippet",
|
||||
:content "foo = 1 and {{ snippet: bar snippet}}",
|
||||
:creator_id 13371338,
|
||||
:template_tags {"snippet: bar snippet"
|
||||
{:type :snippet,
|
||||
:name "snippet: bar snippet",
|
||||
:snippet-name "bar snippet",
|
||||
:display-name "Snippet: Bar Snippet"}}}
|
||||
toucan-model))
|
||||
(is (=? {:description "a test snippet",
|
||||
:archived false,
|
||||
:dependency_analysis_version 0,
|
||||
:content "foo = 1 and {{ snippet: bar snippet}}",
|
||||
:name "foo snippet",
|
||||
:creator_id 13371338,
|
||||
:id (inc snippet-id),
|
||||
:template_tags {"snippet: bar snippet"
|
||||
{:type :snippet,
|
||||
:name "snippet: bar snippet",
|
||||
:snippet-name "bar snippet",
|
||||
:snippet-id bar-snippet-id
|
||||
:display-name "Snippet: Bar Snippet"}}}
|
||||
persisted-snippet)))
|
||||
(finally
|
||||
(t2/delete! :model/NativeQuerySnippet :id (inc snippet-id))))))
|
||||
(testing "snippet with multiple template tags is exported correctly"
|
||||
(let [mp (mt/metadata-provider)
|
||||
venues (lib.metadata/table mp (mt/id :venues))]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:name "card one"
|
||||
:type :question
|
||||
:dataset_query (lib/query mp venues)}
|
||||
:model/NativeQuerySnippet {bar-snippet-id :id} {:name "bar snippet"
|
||||
:content "bar = 2"}
|
||||
:model/NativeQuerySnippet {snippet-id :id :as snippet} {:name "foo snippet"
|
||||
:description "a test snippet"
|
||||
:content (str "foo = {{ val }} and {{ snippet: bar snippet}} and (select id from {{ #" card-id "-card-one }} where id = 1)")}]
|
||||
(try (let [representation (export/export-entity snippet)
|
||||
toucan-model (->> (import/yaml->toucan representation nil)
|
||||
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
|
||||
_ (t2/delete! :model/NativeQuerySnippet :id snippet-id)
|
||||
persisted-snippet (import/persist! representation nil)]
|
||||
(is (= {:name (str "snippet-" snippet-id),
|
||||
:type :snippet,
|
||||
:version :v0,
|
||||
:display_name "foo snippet",
|
||||
:description "a test snippet",
|
||||
:sql (str "foo = {{ val }} and {{ snippet: bar snippet}} and (select id from {{ #" card-id "-card-one }} where id = 1)"),
|
||||
:template_tags {(str "#" card-id "-card-one") (str "ref:card-" card-id)
|
||||
"snippet: bar snippet" (str "ref:snippet-" bar-snippet-id)}}
|
||||
(export/export-entity snippet)))
|
||||
(is (=? {:name "foo snippet",
|
||||
:description "a test snippet",
|
||||
:content (str "foo = {{ val }} and {{ snippet: bar snippet}} and (select id from {{ #" card-id "-card-one }} where id = 1)"),
|
||||
:creator_id 13371338,
|
||||
:template_tags {"snippet: bar snippet"
|
||||
{:type :snippet,
|
||||
:name "snippet: bar snippet",
|
||||
:snippet-name "bar snippet",
|
||||
:display-name "Snippet: Bar Snippet"}
|
||||
(str "#" card-id "-card-one")
|
||||
{:type :card,
|
||||
:name (str "#" card-id "-card-one"),
|
||||
:card-id card-id,
|
||||
:display-name (str "#" card-id " Card One")}
|
||||
"val" {:type :text :name "val" :display-name "Val"}}}
|
||||
toucan-model))
|
||||
(is (=? {:description "a test snippet",
|
||||
:archived false,
|
||||
:dependency_analysis_version 0,
|
||||
:content (str "foo = {{ val }} and {{ snippet: bar snippet}} and (select id from {{ #" card-id "-card-one }} where id = 1)"),
|
||||
:collection_id nil,
|
||||
:name "foo snippet",
|
||||
:creator_id 13371338,
|
||||
:id (inc snippet-id),
|
||||
:template_tags {"snippet: bar snippet"
|
||||
{:type :snippet,
|
||||
:name "snippet: bar snippet",
|
||||
:snippet-name "bar snippet",
|
||||
:snippet-id bar-snippet-id
|
||||
:display-name "Snippet: Bar Snippet"}
|
||||
(str "#" card-id "-card-one")
|
||||
{:type :card,
|
||||
:name (str "#" card-id "-card-one"),
|
||||
:card-id card-id,
|
||||
:display-name (str "#" card-id " Card One")}
|
||||
"val" {:type :text :name "val" :display-name "Val"}}}
|
||||
persisted-snippet)))
|
||||
(finally
|
||||
(t2/delete! :model/NativeQuerySnippet :id (inc snippet-id))))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/NativeQuerySnippet)]
|
||||
(is (= :snippet (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,102 @@
|
||||
(ns metabase-enterprise.representations.v0.transform-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.core :as rep]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.yaml :as yaml]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[representations.read :as rep-read]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(def good-yamls ["test_resources/representations/v0/orders-count.transform.yml"
|
||||
"test_resources/representations/v0/orders-count-existing.transform.yml"])
|
||||
|
||||
(deftest validate-example-yamls
|
||||
(testing "Testing valid examples"
|
||||
(doseq [filename good-yamls]
|
||||
(testing (str "Validating: " filename)
|
||||
(let [rep (yaml/from-file filename)]
|
||||
(is (rep-read/parse rep))))))
|
||||
(testing "Testing invalid examples"
|
||||
(doseq [filename
|
||||
[]]
|
||||
(testing (str "Validating: " filename)
|
||||
(let [rep (yaml/from-file filename)]
|
||||
(is (thrown? clojure.lang.ExceptionInfo (rep-read/parse rep))))))))
|
||||
|
||||
;; TODO: test passes but I suspect this is also incorrectly using model/Transform:
|
||||
(deftest validate-exported-transforms
|
||||
(doseq [query [(mt/native-query {:query "select 1"})
|
||||
(mt/mbql-query users)]]
|
||||
(mt/with-temp [:model/Transform transform {:name "TEST"
|
||||
:source {:type :query
|
||||
:query query}
|
||||
:target {:type "table"
|
||||
:name "output_table"
|
||||
:schema "output_schema"}}]
|
||||
(let [edn (rep/export transform)
|
||||
;; convert to yaml and read back in to convert keywords to strings, etc
|
||||
yaml (yaml/generate-string edn)
|
||||
rep (yaml/parse-string yaml)]
|
||||
(is (rep-read/parse rep))))))
|
||||
|
||||
;; Update schema transformations to toucan (and possibly the yamls)
|
||||
#_(deftest can-import
|
||||
(doseq [filename good-yamls]
|
||||
(let [rep (yaml/from-file filename)
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(v0-common/unref (:database rep))
|
||||
(t2/select-one :model/Database (mt/id))})]
|
||||
(is (rep/persist! rep ref-index)))))
|
||||
|
||||
;; TODO: fix ID usage here, db lookup fails
|
||||
#_(deftest import-export
|
||||
(testing "Testing import then export roundtrip"
|
||||
(doseq [filename good-yamls]
|
||||
(testing (str "Importing-Exporting: " filename)
|
||||
(let [rep (import/import-yaml filename)
|
||||
rep (walk/postwalk-replace {(:database rep)
|
||||
(v0-common/->ref 1 :database)}
|
||||
rep)
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(v0-common/unref (v0-common/->ref 1 :database))
|
||||
(t2/select-one :model/Database :id 1)})
|
||||
persisted (rep/persist! rep ref-index)]
|
||||
(is persisted)
|
||||
(let [edn (rep/export persisted)
|
||||
yaml (yaml/generate-string edn)
|
||||
rep2 (yaml/parse-string yaml)]
|
||||
(is (=? (dissoc rep :name :database) rep2))))))))
|
||||
|
||||
;; TODO: update with latest transform schema in db
|
||||
#_(deftest export-import
|
||||
(testing "Testing export then import roundtrip"
|
||||
(doseq [query [(mt/native-query {:query "select 1"})
|
||||
(mt/mbql-query users)]]
|
||||
(mt/with-temp [:model/Transform transform {:name "TRANSFORM"
|
||||
:source {:type "query"
|
||||
:database (mt/id)
|
||||
:query query}
|
||||
:target {:type "table"
|
||||
:schema "PUBLIC"
|
||||
:name "SOME_TABLE"}}]
|
||||
(let [edn (rep/export transform)
|
||||
yaml (yaml/generate-string edn)
|
||||
rep (yaml/parse-string yaml)
|
||||
rep (rep-read/parse rep)
|
||||
ref-index (v0-common/map-entity-index
|
||||
{(v0-common/unref (:database edn)) (t2/select-one :model/Database (mt/id))})
|
||||
transform (rep/persist! rep ref-index)
|
||||
transform (t2/select-one :model/Transform :id (:id transform))
|
||||
edn (rep/export transform)
|
||||
yaml (yaml/generate-string edn)
|
||||
rep2 (yaml/parse-string yaml)
|
||||
rep2 (rep-read/parse rep2)]
|
||||
(is (=? (dissoc rep :name) rep2)))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Transform)]
|
||||
(is (= :transform (v0-common/representation-type entity)))))
|
||||
@@ -8,6 +8,7 @@ import DateTime, {
|
||||
import { useHasTokenFeature, useSetting } from "metabase/common/hooks";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { isWithinIframe } from "metabase/lib/dom";
|
||||
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import {
|
||||
@@ -66,6 +67,8 @@ export const DocumentHeader = ({
|
||||
onArchive,
|
||||
hasComments = false,
|
||||
}: DocumentHeaderProps) => {
|
||||
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
|
||||
useState(false);
|
||||
const isPublicSharingEnabled = useSetting("enable-public-sharing");
|
||||
const hasDocumentsFeature = useHasTokenFeature("documents");
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
@@ -145,6 +148,14 @@ export const DocumentHeader = ({
|
||||
</Box>
|
||||
)}
|
||||
</Transition>
|
||||
{!isNewDocument && document && (
|
||||
<Button
|
||||
onClick={() => setIsRepresentationsModalOpen(true)}
|
||||
data-hide-on-print
|
||||
>
|
||||
{t`Representations`}
|
||||
</Button>
|
||||
)}
|
||||
{!isNewDocument && hasComments && !isWithinIframe() && (
|
||||
<Tooltip label={t`Show all comments`}>
|
||||
<Box>
|
||||
@@ -259,6 +270,14 @@ export const DocumentHeader = ({
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{document && (
|
||||
<RepresentationsModal
|
||||
opened={isRepresentationsModalOpen}
|
||||
onClose={() => setIsRepresentationsModalOpen(false)}
|
||||
entityId={document.id}
|
||||
entityType="document"
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { BrowserCrumbs } from "metabase/common/components/BrowserCrumbs";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
|
||||
import { Button, Group } from "metabase/ui";
|
||||
import type { Transform } from "metabase-types/api";
|
||||
|
||||
type HeaderSectionProps = {
|
||||
@@ -9,12 +12,26 @@ type HeaderSectionProps = {
|
||||
};
|
||||
|
||||
export function HeaderSection({ transform }: HeaderSectionProps) {
|
||||
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
|
||||
useState(false);
|
||||
|
||||
return (
|
||||
<BrowserCrumbs
|
||||
crumbs={[
|
||||
{ title: t`Transforms`, to: Urls.transformList() },
|
||||
{ title: transform.name, to: Urls.transform(transform.id) },
|
||||
]}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<BrowserCrumbs
|
||||
crumbs={[
|
||||
{ title: t`Transforms`, to: Urls.transformList() },
|
||||
{ title: transform.name, to: Urls.transform(transform.id) },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => setIsRepresentationsModalOpen(true)}>
|
||||
{t`Representations`}
|
||||
</Button>
|
||||
<RepresentationsModal
|
||||
opened={isRepresentationsModalOpen}
|
||||
onClose={() => setIsRepresentationsModalOpen(false)}
|
||||
entityId={transform.id}
|
||||
entityType="transform"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { isSyncInProgress } from "metabase/lib/syncing";
|
||||
import { browseDatabase } from "metabase/lib/urls";
|
||||
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
|
||||
import { Button, Flex, Icon } from "metabase/ui";
|
||||
import type { Database } from "metabase-types/api";
|
||||
|
||||
@@ -11,30 +13,48 @@ interface Props {
|
||||
}
|
||||
|
||||
export const HeaderLinkGroup = ({ database }: Props) => {
|
||||
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
|
||||
useState(false);
|
||||
const isSyncing = isSyncInProgress(database);
|
||||
|
||||
return (
|
||||
<Flex gap="0.5rem">
|
||||
<Button
|
||||
component={Link}
|
||||
fw="bold"
|
||||
to={`/admin/permissions/data/database/${database.id}`}
|
||||
variant="subtle"
|
||||
>
|
||||
{t`Manage permissions`}
|
||||
</Button>
|
||||
<Button
|
||||
component={isSyncing ? undefined : Link}
|
||||
disabled={isSyncing}
|
||||
fw="bold"
|
||||
rightSection={<Icon name="external" />}
|
||||
target="_blank"
|
||||
title={isSyncing ? t`Sync in progress` : undefined}
|
||||
to={browseDatabase(database)}
|
||||
variant="subtle"
|
||||
>
|
||||
{t`Browse data`}
|
||||
</Button>
|
||||
</Flex>
|
||||
<>
|
||||
<Flex gap="0.5rem">
|
||||
<Button
|
||||
fw="bold"
|
||||
onClick={() => setIsRepresentationsModalOpen(true)}
|
||||
variant="subtle"
|
||||
data-testid="representations-button"
|
||||
>
|
||||
{t`Representations`}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
fw="bold"
|
||||
to={`/admin/permissions/data/database/${database.id}`}
|
||||
variant="subtle"
|
||||
>
|
||||
{t`Manage permissions`}
|
||||
</Button>
|
||||
<Button
|
||||
component={isSyncing ? undefined : Link}
|
||||
disabled={isSyncing}
|
||||
fw="bold"
|
||||
rightSection={<Icon name="external" />}
|
||||
target="_blank"
|
||||
title={isSyncing ? t`Sync in progress` : undefined}
|
||||
to={browseDatabase(database)}
|
||||
variant="subtle"
|
||||
>
|
||||
{t`Browse data`}
|
||||
</Button>
|
||||
</Flex>
|
||||
<RepresentationsModal
|
||||
opened={isRepresentationsModalOpen}
|
||||
onClose={() => setIsRepresentationsModalOpen(false)}
|
||||
entityId={database.id}
|
||||
entityType="database"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
17
frontend/src/metabase/api/representations.ts
Normal file
17
frontend/src/metabase/api/representations.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Api } from "./api";
|
||||
|
||||
export const representationsApi = Api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getExportSet: builder.query<
|
||||
{ yamls: string[] },
|
||||
{ type: string; id: number }
|
||||
>({
|
||||
query: ({ type, id }) => ({
|
||||
method: "GET",
|
||||
url: `/api/ee/representation/export-set/${type}/${id}`,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetExportSetQuery } = representationsApi;
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { withRouter } from "react-router";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
isInstanceAnalyticsCollection,
|
||||
isTrashedCollection,
|
||||
} from "metabase/collections/utils";
|
||||
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
|
||||
import { Button } from "metabase/ui";
|
||||
import type { Collection } from "metabase-types/api";
|
||||
|
||||
import { CollectionMenu } from "../CollectionMenu";
|
||||
@@ -40,6 +44,9 @@ const CollectionHeader = ({
|
||||
canUpload,
|
||||
uploadsEnabled,
|
||||
}: CollectionHeaderProps): JSX.Element => {
|
||||
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const isTrash = isTrashedCollection(collection);
|
||||
const showUploadButton =
|
||||
collection.can_write && (canUpload || !uploadsEnabled);
|
||||
@@ -77,6 +84,9 @@ const CollectionHeader = ({
|
||||
onCreateBookmark={onCreateBookmark}
|
||||
onDeleteBookmark={onDeleteBookmark}
|
||||
/>
|
||||
<Button onClick={() => setIsRepresentationsModalOpen(true)}>
|
||||
{t`Representations`}
|
||||
</Button>
|
||||
<CollectionInfoSidebarToggle
|
||||
collection={collection}
|
||||
onUpdateCollection={onUpdateCollection}
|
||||
@@ -90,6 +100,12 @@ const CollectionHeader = ({
|
||||
)}
|
||||
</HeaderActions>
|
||||
)}
|
||||
<RepresentationsModal
|
||||
opened={isRepresentationsModalOpen}
|
||||
onClose={() => setIsRepresentationsModalOpen(false)}
|
||||
entityId={collection.id}
|
||||
entityType="collection"
|
||||
/>
|
||||
</HeaderRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import cx from "classnames";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
@@ -13,6 +13,7 @@ import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWit
|
||||
import { canExploreResults } from "metabase/query_builder/components/view/ViewHeader/utils";
|
||||
import type { QueryModalType } from "metabase/query_builder/constants";
|
||||
import { MODAL_TYPES } from "metabase/query_builder/constants";
|
||||
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
|
||||
import { Box, Button, Flex, Tooltip } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
@@ -96,6 +97,9 @@ export function ViewTitleHeaderRightSide({
|
||||
isShowingQuestionInfoSidebar,
|
||||
isObjectDetail,
|
||||
}: ViewTitleHeaderRightSideProps): React.JSX.Element {
|
||||
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const isShowingNotebook = queryBuilderMode === "notebook";
|
||||
const { isEditable } = Lib.queryDisplayInfo(question.query());
|
||||
|
||||
@@ -160,6 +164,15 @@ export function ViewTitleHeaderRightSide({
|
||||
className={ViewTitleHeaderS.ViewHeaderActionPanel}
|
||||
data-testid="qb-header-action-panel"
|
||||
>
|
||||
{isSaved && (
|
||||
<Button
|
||||
className={cx(CS.hide, CS.smShow)}
|
||||
onClick={() => setIsRepresentationsModalOpen(true)}
|
||||
data-testid="representations-button"
|
||||
>
|
||||
{t`Representations`}
|
||||
</Button>
|
||||
)}
|
||||
{FilterHeaderButton.shouldRender({
|
||||
question,
|
||||
queryBuilderMode,
|
||||
@@ -264,6 +277,12 @@ export function ViewTitleHeaderRightSide({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<RepresentationsModal
|
||||
opened={isRepresentationsModalOpen}
|
||||
onClose={() => setIsRepresentationsModalOpen(false)}
|
||||
entityId={question.id()}
|
||||
entityType={question.type()}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
118
frontend/src/metabase/representations/RepresentationsModal.tsx
Normal file
118
frontend/src/metabase/representations/RepresentationsModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useGetExportSetQuery } from "metabase/api/representations";
|
||||
import {
|
||||
Code,
|
||||
Collapse,
|
||||
Group,
|
||||
Icon,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
import type { CardType } from "metabase-types/api";
|
||||
|
||||
type EntityType = CardType | "transform" | "document" | "collection";
|
||||
|
||||
interface RepresentationsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
entityId: number | null;
|
||||
entityType: EntityType;
|
||||
}
|
||||
|
||||
function DependencyItem({ yaml, index }: { yaml: string; index: number }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Extract the first few lines to show type, name, and ref
|
||||
const lines = yaml.split("\n");
|
||||
const typeLine = lines.find((line) => line.startsWith("type:"));
|
||||
const nameLine = lines.find((line) => line.startsWith("name:"));
|
||||
const refLine = lines.find((line) => line.startsWith("ref:"));
|
||||
|
||||
const type = typeLine?.split(":")[1]?.trim() || "Unknown";
|
||||
const name = nameLine?.split(":")[1]?.trim() || `Dependency ${index + 1}`;
|
||||
const ref = refLine?.split(":")[1]?.trim();
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group
|
||||
onClick={() => setIsOpen((x) => !x)}
|
||||
style={{ cursor: "pointer", userSelect: "none" }}
|
||||
>
|
||||
<Icon name={isOpen ? "chevrondown" : "chevronright"} size={16} />
|
||||
<Text fw="bold">
|
||||
{type}: {name} {ref && `(${ref})`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Collapse in={isOpen}>
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
maxHeight: "600px",
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{yaml}
|
||||
</Code>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepresentationsModal({
|
||||
opened,
|
||||
onClose,
|
||||
entityId,
|
||||
entityType,
|
||||
}: RepresentationsModalProps) {
|
||||
const { data, isLoading, error } = useGetExportSetQuery(
|
||||
{ type: entityType, id: entityId! },
|
||||
{ skip: !opened || entityId === null },
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Representation" size="xl">
|
||||
<Stack>
|
||||
{isLoading && (
|
||||
<Stack align="center" p="xl">
|
||||
<Loader size="lg" />
|
||||
<Text c="text-medium">Loading representation...</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text c="error">
|
||||
{t`Error loading representation: ${(error as Error).message}`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
maxHeight: "600px",
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{data.yamls[0]}
|
||||
</Code>
|
||||
{data.yamls.length > 1 && (
|
||||
<>
|
||||
<Text fw="bold" mt="md">{t`Dependencies`}</Text>
|
||||
{data.yamls.slice(1).map((yaml, index) => (
|
||||
<DependencyItem key={index} yaml={yaml} index={index} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
382
resources/representations/markdown-to-prosemirror.mjs
Normal file
382
resources/representations/markdown-to-prosemirror.mjs
Normal file
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Markdown to ProseMirror parser using prosemirror-markdown
|
||||
*
|
||||
* Usage:
|
||||
* node parse-markdown.mjs <markdown-file>
|
||||
* node parse-markdown.mjs < input.md
|
||||
* echo "# Test" | node parse-markdown.mjs
|
||||
* node parse-markdown.mjs --verbose < input.md
|
||||
*
|
||||
* Output: ProseMirror JSON to stdout
|
||||
*
|
||||
* Options:
|
||||
* --verbose Show diagnostic messages on stderr
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { MarkdownParser, defaultMarkdownParser, schema as baseSchema } from 'prosemirror-markdown';
|
||||
import { Schema } from 'prosemirror-model';
|
||||
|
||||
// Extend the base schema with cardEmbed, resizeNode, and flexContainer
|
||||
const schema = new Schema({
|
||||
nodes: baseSchema.spec.nodes
|
||||
.addBefore('image', 'cardEmbed', {
|
||||
attrs: {
|
||||
id: { default: null }, // Can be number or string (ref:foo)
|
||||
name: { default: null }
|
||||
},
|
||||
group: 'block',
|
||||
parseDOM: [{
|
||||
tag: 'div[data-card-embed]',
|
||||
getAttrs: (dom) => {
|
||||
const idStr = dom.getAttribute('data-id');
|
||||
// Keep ref: as string, parse numeric strings to numbers
|
||||
const id = idStr && idStr.startsWith('ref:') ? idStr : parseInt(idStr);
|
||||
return {
|
||||
id,
|
||||
name: dom.getAttribute('data-name')
|
||||
};
|
||||
}
|
||||
}],
|
||||
toDOM: (node) => ['div', {
|
||||
'data-card-embed': '',
|
||||
'data-id': node.attrs.id,
|
||||
'data-name': node.attrs.name
|
||||
}]
|
||||
})
|
||||
.addBefore('image', 'flexContainer', {
|
||||
attrs: {
|
||||
columnWidths: { default: [50, 50] }
|
||||
},
|
||||
content: 'cardEmbed{2}',
|
||||
parseDOM: [{
|
||||
tag: 'div[data-flex-container]',
|
||||
getAttrs: (dom) => {
|
||||
const widths = dom.getAttribute('data-column-widths');
|
||||
return {
|
||||
columnWidths: widths ? JSON.parse(widths) : [50, 50]
|
||||
};
|
||||
}
|
||||
}],
|
||||
toDOM: (node) => ['div', {
|
||||
'data-flex-container': '',
|
||||
'data-column-widths': JSON.stringify(node.attrs.columnWidths)
|
||||
}]
|
||||
})
|
||||
.addBefore('image', 'resizeNode', {
|
||||
attrs: {
|
||||
height: { default: 442 },
|
||||
minHeight: { default: 280 }
|
||||
},
|
||||
content: '(cardEmbed | flexContainer)',
|
||||
group: 'block',
|
||||
parseDOM: [{
|
||||
tag: 'div[data-resize-node]',
|
||||
getAttrs: (dom) => ({
|
||||
height: parseInt(dom.getAttribute('data-height')) || 442,
|
||||
minHeight: parseInt(dom.getAttribute('data-min-height')) || 280
|
||||
})
|
||||
}],
|
||||
toDOM: (node) => ['div', {
|
||||
'data-resize-node': '',
|
||||
'data-height': node.attrs.height,
|
||||
'data-min-height': node.attrs.minHeight
|
||||
}]
|
||||
}),
|
||||
marks: baseSchema.spec.marks
|
||||
});
|
||||
|
||||
// Custom markdown-it plugin to parse card embeds
|
||||
function cardEmbedPlugin(md) {
|
||||
md.block.ruler.before('fence', 'card_embed', (state, startLine, endLine, silent) => {
|
||||
const pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
const lineText = state.src.slice(pos, max);
|
||||
|
||||
// Match: {% card id=123 %} or {% card id=ref:my-chart %} with optional name
|
||||
const match = lineText.match(/^\{%\s*card\s+id=(ref:[a-zA-Z0-9_-]+|\d+)(?:\s+name="([^"]*)")?\s*%\}$/);
|
||||
|
||||
if (!match) return false;
|
||||
if (silent) return true;
|
||||
|
||||
const token = state.push('card_embed', '', 0);
|
||||
token.attrs = [
|
||||
['id', match[1]], // Keep as-is (string for ref:, string for numeric)
|
||||
['name', match[2] || null]
|
||||
];
|
||||
token.block = true;
|
||||
token.map = [startLine, startLine + 1];
|
||||
|
||||
state.line = startLine + 1;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Custom markdown-it plugin to parse row containers
|
||||
function rowPlugin(md) {
|
||||
md.block.ruler.before('fence', 'row_container', (state, startLine, endLine, silent) => {
|
||||
const pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
const lineText = state.src.slice(pos, max);
|
||||
|
||||
// Match: {% row %} or {% row widths="75:25" %}
|
||||
const match = lineText.match(/^\{%\s*row(?:\s+widths="([^"]+)")?\s*%\}$/);
|
||||
|
||||
if (!match) return false;
|
||||
if (silent) return true;
|
||||
|
||||
const widths = match[1];
|
||||
let nextLine = startLine + 1;
|
||||
const cards = [];
|
||||
|
||||
// Find all cards until {% endrow %}
|
||||
while (nextLine < endLine) {
|
||||
const nextPos = state.bMarks[nextLine] + state.tShift[nextLine];
|
||||
const nextMax = state.eMarks[nextLine];
|
||||
const nextLineText = state.src.slice(nextPos, nextMax);
|
||||
|
||||
// Check for endrow
|
||||
if (nextLineText.match(/^\{%\s*endrow\s*%\}$/)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for card
|
||||
const cardMatch = nextLineText.match(/^\{%\s*card\s+id=(ref:[a-zA-Z0-9_-]+|\d+)(?:\s+name="([^"]*)")?\s*%\}$/);
|
||||
if (cardMatch) {
|
||||
cards.push({
|
||||
id: cardMatch[1], // Keep as-is (string for ref:, string for numeric)
|
||||
name: cardMatch[2] || null
|
||||
});
|
||||
}
|
||||
|
||||
nextLine++;
|
||||
}
|
||||
|
||||
if (cards.length !== 2 || nextLine >= endLine) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create resize_node_open token
|
||||
const resizeToken = state.push('resize_node_open', '', 1);
|
||||
resizeToken.block = true;
|
||||
|
||||
// Create flex_container_open token
|
||||
const flexToken = state.push('flex_container_open', '', 1);
|
||||
flexToken.block = true;
|
||||
|
||||
// Parse widths if provided
|
||||
if (widths) {
|
||||
const parts = widths.split(':').map(w => parseFloat(w.trim()));
|
||||
if (parts.length === 2) {
|
||||
flexToken.attrs = [['columnWidths', JSON.stringify(parts)]];
|
||||
}
|
||||
}
|
||||
|
||||
// Add card tokens
|
||||
for (const card of cards) {
|
||||
const cardToken = state.push('card_embed', '', 0);
|
||||
cardToken.attrs = [
|
||||
['id', card.id],
|
||||
['name', card.name]
|
||||
];
|
||||
cardToken.block = true;
|
||||
}
|
||||
|
||||
// Close flex_container
|
||||
state.push('flex_container_close', '', -1);
|
||||
|
||||
// Close resize_node
|
||||
state.push('resize_node_close', '', -1);
|
||||
|
||||
state.line = nextLine + 1; // Skip past {% endrow %}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Post-process tokens to wrap standalone card_embed in resize_node
|
||||
function wrapStandaloneCards(tokens) {
|
||||
const result = [];
|
||||
let inFlexContainer = false;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
|
||||
// Track if we're inside a flex container
|
||||
if (token.type === 'flex_container_open') {
|
||||
inFlexContainer = true;
|
||||
result.push(token);
|
||||
} else if (token.type === 'flex_container_close') {
|
||||
inFlexContainer = false;
|
||||
result.push(token);
|
||||
} else if (token.type === 'card_embed' && !inFlexContainer) {
|
||||
// Only wrap standalone cards (not inside flex container)
|
||||
const resizeOpen = new token.constructor('resize_node_open', '', 1);
|
||||
resizeOpen.block = true;
|
||||
result.push(resizeOpen);
|
||||
|
||||
result.push(token);
|
||||
|
||||
const resizeClose = new token.constructor('resize_node_close', '', -1);
|
||||
resizeClose.block = true;
|
||||
result.push(resizeClose);
|
||||
} else {
|
||||
result.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Create custom markdown-it instance with plugins
|
||||
const md = new MarkdownIt('commonmark', {
|
||||
html: false,
|
||||
linkify: false
|
||||
}).use(rowPlugin).use(cardEmbedPlugin);
|
||||
|
||||
// Override parse to post-process tokens
|
||||
const originalParse = md.parse.bind(md);
|
||||
md.parse = function(src, env) {
|
||||
const tokens = originalParse(src, env);
|
||||
return wrapStandaloneCards(tokens);
|
||||
};
|
||||
|
||||
// Create custom parser with token handlers
|
||||
const customParser = new MarkdownParser(
|
||||
schema,
|
||||
md,
|
||||
{
|
||||
...defaultMarkdownParser.tokens,
|
||||
card_embed: {
|
||||
node: 'cardEmbed',
|
||||
getAttrs: (tok) => {
|
||||
const idStr = tok.attrGet('id');
|
||||
// Keep ref: as string, parse numeric strings to numbers
|
||||
const id = idStr.startsWith('ref:') ? idStr : parseInt(idStr);
|
||||
return {
|
||||
id,
|
||||
name: tok.attrGet('name')
|
||||
};
|
||||
}
|
||||
},
|
||||
resize_node: {
|
||||
block: 'resizeNode',
|
||||
getAttrs: (tok) => ({
|
||||
height: 442,
|
||||
minHeight: 280
|
||||
})
|
||||
},
|
||||
flex_container: {
|
||||
block: 'flexContainer',
|
||||
getAttrs: (tok) => {
|
||||
const widths = tok.attrGet('columnWidths');
|
||||
return {
|
||||
columnWidths: widths ? JSON.parse(widths) : [50, 50]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Transform ProseMirror node/mark types to Tiptap naming convention
|
||||
function transformToTiptapFormat(node) {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return node;
|
||||
}
|
||||
|
||||
// Map node types from ProseMirror defaults to Tiptap names
|
||||
const typeMap = {
|
||||
'strong': 'bold',
|
||||
'em': 'italic',
|
||||
'code_block': 'codeBlock',
|
||||
'bullet_list': 'bulletList',
|
||||
'ordered_list': 'orderedList',
|
||||
'list_item': 'listItem',
|
||||
'horizontal_rule': 'horizontalRule',
|
||||
'hard_break': 'hardBreak'
|
||||
};
|
||||
|
||||
const transformedNode = { ...node };
|
||||
|
||||
// Update node type if it needs mapping
|
||||
if (transformedNode.type && typeMap[transformedNode.type]) {
|
||||
transformedNode.type = typeMap[transformedNode.type];
|
||||
}
|
||||
|
||||
// Transform marks
|
||||
if (transformedNode.marks) {
|
||||
transformedNode.marks = transformedNode.marks.map(mark => ({
|
||||
...mark,
|
||||
type: typeMap[mark.type] || mark.type
|
||||
}));
|
||||
}
|
||||
|
||||
// Recursively transform content
|
||||
if (transformedNode.content && Array.isArray(transformedNode.content)) {
|
||||
transformedNode.content = transformedNode.content.map(transformToTiptapFormat);
|
||||
}
|
||||
|
||||
return transformedNode;
|
||||
}
|
||||
|
||||
export function parseMarkdown(markdownText) {
|
||||
// Use the custom parser
|
||||
const doc = customParser.parse(markdownText);
|
||||
|
||||
// Convert to JSON
|
||||
const pmDoc = doc.toJSON();
|
||||
|
||||
// Transform to Tiptap format
|
||||
return transformToTiptapFormat(pmDoc);
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let markdownText;
|
||||
let verbose = false;
|
||||
|
||||
// Check for --verbose flag
|
||||
const verboseIndex = args.indexOf('--verbose');
|
||||
if (verboseIndex !== -1) {
|
||||
verbose = true;
|
||||
args.splice(verboseIndex, 1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Read from stdin if no file provided or if '-' is specified
|
||||
if (args.length === 0 || args[0] === '-') {
|
||||
if (verbose) {
|
||||
console.error('Reading from stdin...');
|
||||
}
|
||||
markdownText = await readStdin();
|
||||
} else {
|
||||
const filePath = args[0];
|
||||
if (verbose) {
|
||||
console.error(`Reading from file: ${filePath}`);
|
||||
}
|
||||
markdownText = readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
const pmDoc = parseMarkdown(markdownText);
|
||||
|
||||
// Output JSON to stdout (always)
|
||||
console.log(JSON.stringify(pmDoc, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Error parsing markdown:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Only run main if this file is being executed directly (not imported)
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
293
resources/representations/prosemirror-to-markdown.mjs
Normal file
293
resources/representations/prosemirror-to-markdown.mjs
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ProseMirror to Markdown serializer - Standalone version
|
||||
* This script converts ProseMirror JSON to Markdown without external dependencies
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
// Simple markdown serializer that handles ProseMirror JSON structure
|
||||
function serializeProseMirrorToMarkdown(doc) {
|
||||
if (!doc || doc.type !== "doc") {
|
||||
throw new Error("Invalid ProseMirror document structure");
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
|
||||
function serializeNode(node) {
|
||||
const type = node.type;
|
||||
|
||||
switch (type) {
|
||||
case "paragraph":
|
||||
lines.push(serializeInlineContent(node.content || []));
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
case "heading":
|
||||
const level = node.attrs?.level || 1;
|
||||
const prefix = "#".repeat(level);
|
||||
lines.push(`${prefix} ${serializeInlineContent(node.content || [])}`);
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
case "codeBlock":
|
||||
lines.push("```");
|
||||
if (node.content) {
|
||||
node.content.forEach((child) => {
|
||||
if (child.type === "text") {
|
||||
lines.push(child.text || "");
|
||||
}
|
||||
});
|
||||
}
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
case "blockquote":
|
||||
if (node.content) {
|
||||
const quotedLines = [];
|
||||
node.content.forEach((child) => {
|
||||
const saved = lines.length;
|
||||
serializeNode(child);
|
||||
// Take the lines that were just added
|
||||
while (lines.length > saved) {
|
||||
quotedLines.push(lines.pop());
|
||||
}
|
||||
});
|
||||
quotedLines.reverse().forEach((line) => {
|
||||
lines.push(line ? `> ${line}` : ">");
|
||||
});
|
||||
}
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
case "bulletList":
|
||||
if (node.content) {
|
||||
node.content.forEach((item) => serializeListItem(item, "-"));
|
||||
}
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
case "orderedList":
|
||||
const start = node.attrs?.order || 1;
|
||||
if (node.content) {
|
||||
node.content.forEach((item, index) => {
|
||||
serializeListItem(item, `${start + index}.`);
|
||||
});
|
||||
}
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
case "horizontalRule":
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
case "hardBreak":
|
||||
lines.push(" ");
|
||||
break;
|
||||
|
||||
case "cardEmbed":
|
||||
const { id, name } = node.attrs || {};
|
||||
if (name) {
|
||||
lines.push(`{% card id=${id} name="${name}" %}`);
|
||||
} else {
|
||||
lines.push(`{% card id=${id} %}`);
|
||||
}
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
case "resizeNode":
|
||||
// Check if it contains a flexContainer
|
||||
const child = node.content?.[0];
|
||||
if (child && child.type === "flexContainer") {
|
||||
serializeFlexContainer(child);
|
||||
} else if (child) {
|
||||
serializeNode(child);
|
||||
}
|
||||
break;
|
||||
|
||||
case "flexContainer":
|
||||
serializeFlexContainer(node);
|
||||
break;
|
||||
|
||||
default:
|
||||
// For unknown nodes, try to serialize their content
|
||||
if (node.content) {
|
||||
node.content.forEach((child) => serializeNode(child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeFlexContainer(node) {
|
||||
const { columnWidths } = node.attrs || { columnWidths: [50, 50] };
|
||||
const hasCustomWidths =
|
||||
columnWidths && (columnWidths[0] !== 50 || columnWidths[1] !== 50);
|
||||
|
||||
if (hasCustomWidths) {
|
||||
const [w1, w2] = columnWidths;
|
||||
lines.push(`{% row widths="${w1.toFixed(2)}:${w2.toFixed(2)}" %}`);
|
||||
} else {
|
||||
lines.push(`{% row %}`);
|
||||
}
|
||||
|
||||
// Serialize the cards inside
|
||||
if (node.content) {
|
||||
node.content.forEach((card) => {
|
||||
const { id, name } = card.attrs || {};
|
||||
if (name) {
|
||||
lines.push(`{% card id=${id} name="${name}" %}`);
|
||||
} else {
|
||||
lines.push(`{% card id=${id} %}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
lines.push(`{% endrow %}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
function serializeListItem(item, bullet) {
|
||||
if (!item.content) return;
|
||||
|
||||
// Get the first paragraph's content
|
||||
const firstPara = item.content[0];
|
||||
if (firstPara && firstPara.type === "paragraph") {
|
||||
lines.push(`${bullet} ${serializeInlineContent(firstPara.content || [])}`);
|
||||
}
|
||||
|
||||
// Handle nested blocks
|
||||
for (let i = 1; i < item.content.length; i++) {
|
||||
const child = item.content[i];
|
||||
const saved = lines.length;
|
||||
serializeNode(child);
|
||||
// Indent nested content
|
||||
while (lines.length > saved) {
|
||||
const line = lines.pop();
|
||||
lines.push(line ? ` ${line}` : "");
|
||||
}
|
||||
lines.reverse();
|
||||
for (let j = saved; j < lines.length; j++) {
|
||||
const temp = lines[j];
|
||||
lines[j] = lines[lines.length - 1 - (j - saved)];
|
||||
lines[lines.length - 1 - (j - saved)] = temp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeInlineContent(content) {
|
||||
return content
|
||||
.map((node) => {
|
||||
if (node.type === "text") {
|
||||
return applyMarks(node.text || "", node.marks || []);
|
||||
} else if (node.type === "hardBreak") {
|
||||
return " \n";
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function applyMarks(text, marks) {
|
||||
let result = text;
|
||||
|
||||
// Apply marks in reverse order for proper nesting
|
||||
for (let i = marks.length - 1; i >= 0; i--) {
|
||||
const mark = marks[i];
|
||||
switch (mark.type) {
|
||||
case "bold":
|
||||
case "strong":
|
||||
result = `**${result}**`;
|
||||
break;
|
||||
case "italic":
|
||||
case "em":
|
||||
result = `*${result}*`;
|
||||
break;
|
||||
case "code":
|
||||
result = `\`${result}\``;
|
||||
break;
|
||||
case "link":
|
||||
const href = mark.attrs?.href || "";
|
||||
const title = mark.attrs?.title;
|
||||
if (title) {
|
||||
result = `[${result}](${href} "${title}")`;
|
||||
} else {
|
||||
result = `[${result}](${href})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Serialize all top-level nodes
|
||||
if (doc.content) {
|
||||
doc.content.forEach((node) => serializeNode(node));
|
||||
}
|
||||
|
||||
// Remove trailing empty lines and join
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
export function serializeProseMirror(jsonText) {
|
||||
const pmJson = JSON.parse(jsonText);
|
||||
return serializeProseMirrorToMarkdown(pmJson);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let jsonText;
|
||||
let verbose = false;
|
||||
|
||||
// Check for --verbose flag
|
||||
const verboseIndex = args.indexOf("--verbose");
|
||||
if (verboseIndex !== -1) {
|
||||
verbose = true;
|
||||
args.splice(verboseIndex, 1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Read from stdin if no file provided or if '-' is specified
|
||||
if (args.length === 0 || args[0] === "-") {
|
||||
if (verbose) {
|
||||
console.error("Reading from stdin...");
|
||||
}
|
||||
jsonText = await readStdin();
|
||||
} else {
|
||||
const filePath = args[0];
|
||||
if (verbose) {
|
||||
console.error(`Reading from file: ${filePath}`);
|
||||
}
|
||||
jsonText = readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
const markdown = serializeProseMirror(jsonText);
|
||||
|
||||
// Output markdown to stdout
|
||||
console.log(markdown);
|
||||
} catch (error) {
|
||||
console.error("Error serializing ProseMirror:", error.message);
|
||||
if (verbose) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Only run main if this file is being executed directly (not imported)
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
@@ -951,7 +951,7 @@
|
||||
res
|
||||
limit-res)))
|
||||
|
||||
(mu/defn- collection-children
|
||||
(mu/defn collection-children
|
||||
"Fetch a sequence of 'child' objects belonging to a Collection, filtered using `options`."
|
||||
[{collection-namespace :namespace, :as collection} :- collection/CollectionWithLocationAndIDOrRoot
|
||||
{:keys [models], :as options} :- CollectionChildrenOptions]
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
[metabase.lib.page]
|
||||
[metabase.lib.parameters]
|
||||
[metabase.lib.parse :as lib.parse]
|
||||
[metabase.lib.portable :as lib.portable]
|
||||
[metabase.lib.query :as lib.query]
|
||||
[metabase.lib.ref :as lib.ref]
|
||||
[metabase.lib.remove-replace :as lib.remove-replace]
|
||||
@@ -418,6 +419,10 @@
|
||||
update-parameter-target-dimension-options]
|
||||
[lib.parse
|
||||
parse]
|
||||
[lib.portable
|
||||
export-walk
|
||||
import-walk
|
||||
QueryVisitor]
|
||||
[lib.query
|
||||
->query
|
||||
can-preview
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(ns metabase.lib.metadata.protocols
|
||||
(ns metabase.lib.metadata.protocols
|
||||
(:require
|
||||
#?@(:clj [[potemkin :as p]])
|
||||
[medley.core :as m]
|
||||
|
||||
77
src/metabase/lib/portable.cljc
Normal file
77
src/metabase/lib/portable.cljc
Normal file
@@ -0,0 +1,77 @@
|
||||
(ns metabase.lib.portable
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.schema :as lib.schema]
|
||||
[metabase.lib.util.match :as lib.util.match]
|
||||
[metabase.lib.walk :as lib.walk]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]))
|
||||
|
||||
(defprotocol QueryVisitor
|
||||
(visit-database-id [this mp context database-id])
|
||||
(visit-table-id [this mp context source-table-id])
|
||||
(visit-card-id [this mp context source-card-id])
|
||||
(visit-field-id [this mp context field-id]))
|
||||
|
||||
(defn query-visitor?
|
||||
"Does x implement the ExportVisitor protocol?"
|
||||
[x]
|
||||
#?(:clj (extends? QueryVisitor (class x))
|
||||
:cljs (satisfies? QueryVisitor x)))
|
||||
|
||||
(mr/def ::query-visitor
|
||||
"Schema for things that satisfy the [[metabase.lib.portable/QueryVisitor]] protocol."
|
||||
[:fn
|
||||
{:error/message "Valid QueryVisitor"}
|
||||
#'query-visitor?])
|
||||
|
||||
(defn- update-field [mp k f]
|
||||
(m/assoc-some mp k (some-> (get mp k) f)))
|
||||
|
||||
(defn- walk-database [query context visitor]
|
||||
(let [mp (lib.metadata/->metadata-provider query)]
|
||||
(update-field query :database (partial visit-database-id visitor mp context))))
|
||||
|
||||
(defn- walk-stages [query context visitor]
|
||||
(let [mp (lib.metadata/->metadata-provider query)
|
||||
st (partial visit-table-id visitor mp context)
|
||||
sc (partial visit-card-id visitor mp context)]
|
||||
(mu/disable-enforcement
|
||||
(lib.walk/walk-stages
|
||||
query
|
||||
(fn [_q _pt stage]
|
||||
(-> stage
|
||||
(update-field :source-table st)
|
||||
(update-field :source-card sc)))))))
|
||||
|
||||
(defn- walk-clauses [query context visitor]
|
||||
(let [mp (lib.metadata/->metadata-provider query)]
|
||||
(mu/disable-enforcement
|
||||
(lib.walk/walk-clauses
|
||||
query
|
||||
(fn [_q _pt _sjp clause]
|
||||
(lib.util.match/match-lite clause
|
||||
[:field opts id]
|
||||
(when-some [field-ref (visit-field-id visitor mp context id)]
|
||||
[:field opts field-ref])))))))
|
||||
|
||||
(mu/defn export-walk
|
||||
"Walk a query, replacing database ids, source-table ids, source card ids, and field ids using the QueryVisitor protocol. The QueryVisitor methods should take an id and return the exported form (or nil to do nothing)."
|
||||
[query :- ::lib.schema/query
|
||||
export-visitor :- ::query-visitor
|
||||
context :- :any]
|
||||
(-> query
|
||||
(walk-database context export-visitor)
|
||||
(walk-stages context export-visitor)
|
||||
(walk-clauses context export-visitor)))
|
||||
|
||||
(mu/defn import-walk :- ::lib.schema/query
|
||||
"Walk a query, replacing database refs, table refds, card refs, and field refs using the QueryVisitor protocol. The QueryVisitor methods should take whatever was exported and return the appropriate id. This function does not check the query schema on input (it won't pass), but does check the schema of the return value, so make sure it gets patched up right!"
|
||||
[query :- :any ;; something similar to a query with bits changed
|
||||
import-visitor :- ::query-visitor
|
||||
context :- :any]
|
||||
(-> query
|
||||
(walk-database context import-visitor)
|
||||
(walk-stages context import-visitor)
|
||||
(walk-clauses context import-visitor)))
|
||||
@@ -0,0 +1,50 @@
|
||||
type: question
|
||||
ref: pokemon-cards-limited
|
||||
name: Pokemon Cards Limited
|
||||
description: Limited selection of Pokemon card fields
|
||||
database: Postgres
|
||||
mbql_query:
|
||||
database: 2
|
||||
type: query
|
||||
query:
|
||||
source-table: "card__113"
|
||||
fields:
|
||||
- - field
|
||||
- _mb_row_id
|
||||
- base-type: type/BigInteger
|
||||
- - field
|
||||
- id
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- number
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- name
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- image_small
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- supertype
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- subtype1
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- subtype2
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- type1
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- type2
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- hp
|
||||
- base-type: type/BigInteger
|
||||
- - field
|
||||
- evolves_from
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- evolves_to
|
||||
- base-type: type/Text
|
||||
@@ -0,0 +1,10 @@
|
||||
type: question
|
||||
ref: pokemon-cards
|
||||
name: Pokemon Cards
|
||||
description: Complete Pokemon card collection data
|
||||
database: Postgres
|
||||
mbql_query:
|
||||
database: 2
|
||||
type: query
|
||||
query:
|
||||
source-table: 35
|
||||
@@ -0,0 +1,28 @@
|
||||
type: question
|
||||
ref: sales-data-2025-with-ratings
|
||||
name: Sales Data 2025 with Ratings
|
||||
description: Sales data from 2025 model with product ratings
|
||||
database: Sample Database
|
||||
mbql_query:
|
||||
database: 1
|
||||
type: query
|
||||
query:
|
||||
source-table: "card__119"
|
||||
joins:
|
||||
- fields:
|
||||
- - field
|
||||
- 16
|
||||
- base-type: type/Float
|
||||
join-alias: "Products - Products → Category"
|
||||
strategy: left-join
|
||||
alias: "Products - Products → Category"
|
||||
condition:
|
||||
- "="
|
||||
- - field
|
||||
- Products__CATEGORY
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- 18
|
||||
- base-type: type/Text
|
||||
join-alias: "Products - Products → Category"
|
||||
source-table: 3
|
||||
@@ -0,0 +1,46 @@
|
||||
type: v0/model
|
||||
name: sales-data-2025
|
||||
display_name: Sales Data 2025
|
||||
description: Monthly sales analysis for 2025 with product categories and financial metrics
|
||||
database: Sample Database
|
||||
mbql_query:
|
||||
database: 1
|
||||
type: query
|
||||
query:
|
||||
source-table: "card__117"
|
||||
filter:
|
||||
- time-interval
|
||||
- - field
|
||||
- CREATED_AT
|
||||
- base-type: type/DateTime
|
||||
inherited-temporal-unit: month
|
||||
- -12
|
||||
- month
|
||||
columns:
|
||||
- name: category
|
||||
display_name: Products → Category
|
||||
description: "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget"
|
||||
base_type: type/Text
|
||||
semantic_type: type/Category
|
||||
|
||||
- name: created_at_month
|
||||
display_name: Created At Month
|
||||
description: The date and time an order was submitted
|
||||
base_type: type/DateTime
|
||||
semantic_type: type/CreationTimestamp
|
||||
|
||||
- name: avg_total
|
||||
display_name: Average of Total
|
||||
description: Average total price
|
||||
base_type: type/Float
|
||||
|
||||
- name: avg_quantity
|
||||
display_name: Average of Quantity
|
||||
description: Average quantity ordered
|
||||
base_type: type/Float
|
||||
semantic_type: type/Quantity
|
||||
|
||||
- name: avg_tax
|
||||
display_name: Average of Tax
|
||||
description: Average tax amount
|
||||
base_type: type/Float
|
||||
@@ -0,0 +1,91 @@
|
||||
# Vastly overcomplicated, I know, but experimental
|
||||
|
||||
type: model
|
||||
version: v0
|
||||
name: sales-data-experiment
|
||||
display_name: Sales Data
|
||||
description: Monthly sales analysis with product categories and financial metrics
|
||||
database: Sample Database
|
||||
query:
|
||||
database: 1
|
||||
type: query
|
||||
query:
|
||||
source-table: "card__117"
|
||||
columns:
|
||||
- name: category
|
||||
display_name: Product
|
||||
description: "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget"
|
||||
base_type: type/Text
|
||||
semantic_type: type/Category
|
||||
# Additional fields from reference-model.json
|
||||
database_type: CHARACTER VARYING
|
||||
table_id: 3
|
||||
qp/implicit-field?: true
|
||||
lib/deduplicated-name: CATEGORY
|
||||
lib/original-name: CATEGORY
|
||||
lib/breakout?: false
|
||||
lib/card-id: 117
|
||||
lib/source: source/previous-stage
|
||||
lib/source-column-alias: Products__CATEGORY
|
||||
lib/original-display-name: Category
|
||||
source: fields
|
||||
lib/original-join-alias: Products
|
||||
field_ref: ["field", 18, null]
|
||||
effective_type: type/Text
|
||||
active: true
|
||||
id: 18
|
||||
lib/desired-column-alias: Products__CATEGORY
|
||||
position: 3
|
||||
visibility_type: normal
|
||||
fingerprint:
|
||||
global:
|
||||
distinct-count: 4
|
||||
nil%: 0
|
||||
type:
|
||||
type/Text:
|
||||
percent-json: 0
|
||||
percent-url: 0
|
||||
percent-email: 0
|
||||
percent-state: 0
|
||||
average-length: 6.375
|
||||
source_alias: Products
|
||||
|
||||
- name: created_at_month
|
||||
display_name: Month of Creation
|
||||
description: The date and time an order was submitted
|
||||
base_type: type/DateTime
|
||||
semantic_type: type/CreationDate
|
||||
# Additional fields from reference-model.json
|
||||
database_type: TIMESTAMP
|
||||
table_id: 2
|
||||
qp/implicit-field?: true
|
||||
# lib/deduplicated-name: CREATED_AT # YES with id
|
||||
# lib/source-column-alias: CREATED_AT # YES with id
|
||||
id: 13 # <-- REQUIRED FOR NAME
|
||||
# lib/desired-column-alias: CREATED_AT # YES with id
|
||||
|
||||
- name: avg_price
|
||||
display_name: Price
|
||||
description: Average price for products in this category
|
||||
base_type: type/Float
|
||||
semantic_type: type/Currency
|
||||
currency: USD
|
||||
# Additional fields from reference-model.json
|
||||
lib/deduplicated-name: avg
|
||||
|
||||
- name: avg_quantity
|
||||
display_name: Quantity
|
||||
description: Average quantity ordered
|
||||
base_type: type/Float
|
||||
semantic_type: type/Quantity
|
||||
lib/desired-column-alias: avg_2
|
||||
|
||||
- name: avg_tax
|
||||
display_name: Tax
|
||||
description: Average tax amount
|
||||
base_type: type/Float
|
||||
semantic_type: type/Currency
|
||||
currency: USD
|
||||
# lib/deduplicated-name: avg_3 # <--- This would work too
|
||||
# lib/desired-column-alias: avg_3 # <--- This would work too
|
||||
lib/source-column-alias: avg_3
|
||||
@@ -0,0 +1,43 @@
|
||||
type: model
|
||||
version: v0
|
||||
name: sales-data
|
||||
display_name: Sales Data
|
||||
description: Monthly sales analysis with product categories and financial metrics
|
||||
database: Sample Database
|
||||
query:
|
||||
database: 1
|
||||
type: query
|
||||
query:
|
||||
source-table: "card__117"
|
||||
columns:
|
||||
- name: category
|
||||
display_name: Product
|
||||
description: "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget"
|
||||
base_type: type/Text
|
||||
semantic_type: type/Category
|
||||
|
||||
- name: created_at_month
|
||||
display_name: Month of Creation
|
||||
description: The date and time an order was submitted
|
||||
base_type: type/DateTime
|
||||
semantic_type: type/CreationDate
|
||||
|
||||
- name: avg_price
|
||||
display_name: Price
|
||||
description: Average price for products in this category
|
||||
base_type: type/Float
|
||||
semantic_type: type/Currency
|
||||
currency: USD
|
||||
|
||||
- name: avg_quantity
|
||||
display_name: Quantity
|
||||
description: Average quantity ordered
|
||||
base_type: type/Float
|
||||
semantic_type: type/Quantity
|
||||
|
||||
- name: avg_tax
|
||||
display_name: Tax
|
||||
description: Average tax amount
|
||||
base_type: type/Float
|
||||
semantic_type: type/Currency
|
||||
currency: USD
|
||||
@@ -0,0 +1,46 @@
|
||||
type: question
|
||||
ref: sales-data
|
||||
name: Sales Data
|
||||
description: Sales analysis with product categories and monthly averages
|
||||
database: Sample Database
|
||||
mbql_query:
|
||||
database: 1
|
||||
type: query
|
||||
query:
|
||||
source-table: 2
|
||||
joins:
|
||||
- fields: all
|
||||
strategy: left-join
|
||||
alias: Products
|
||||
condition:
|
||||
- "="
|
||||
- - field
|
||||
- 14
|
||||
- base-type: type/Integer
|
||||
- - field
|
||||
- 8
|
||||
- base-type: type/BigInteger
|
||||
join-alias: Products
|
||||
source-table: 3
|
||||
breakout:
|
||||
- - field
|
||||
- 18
|
||||
- base-type: type/Text
|
||||
join-alias: Products
|
||||
- - field
|
||||
- 13
|
||||
- base-type: type/DateTime
|
||||
temporal-unit: month
|
||||
aggregation:
|
||||
- - avg
|
||||
- - field
|
||||
- 5
|
||||
- base-type: type/Float
|
||||
- - avg
|
||||
- - field
|
||||
- 2
|
||||
- base-type: type/Integer
|
||||
- - avg
|
||||
- - field
|
||||
- 6
|
||||
- base-type: type/Float
|
||||
@@ -0,0 +1,51 @@
|
||||
type: v0/model
|
||||
name: trainers-facts
|
||||
display_name: Trainers Facts Model
|
||||
description: Aggregated trainer statistics by subtypes
|
||||
database: Postgres
|
||||
mbql_query:
|
||||
database: 2
|
||||
type: query
|
||||
query:
|
||||
source-table: "card__115"
|
||||
breakout:
|
||||
- - field
|
||||
- subtype1
|
||||
- base-type: type/Text
|
||||
- - field
|
||||
- subtype2
|
||||
- base-type: type/Text
|
||||
aggregation:
|
||||
- - count
|
||||
- - avg
|
||||
- - field
|
||||
- hp
|
||||
- base-type: type/BigInteger
|
||||
filter:
|
||||
- not-empty
|
||||
- - field
|
||||
- subtype2
|
||||
- base-type: type/Text
|
||||
columns:
|
||||
- name: subtype1
|
||||
display_name: Subtype1
|
||||
description: Primary subtype category
|
||||
base_type: type/Text
|
||||
semantic_type: type/Category
|
||||
|
||||
- name: subtype2
|
||||
display_name: Subtype2
|
||||
description: Secondary subtype category
|
||||
base_type: type/Text
|
||||
semantic_type: type/Category
|
||||
|
||||
- name: count
|
||||
display_name: Count
|
||||
description: Number of records in this category
|
||||
base_type: type/BigInteger
|
||||
semantic_type: type/Quantity
|
||||
|
||||
- name: avg_hp
|
||||
display_name: Average of Hp
|
||||
description: Average HP value for this category
|
||||
base_type: type/Decimal
|
||||
@@ -0,0 +1,16 @@
|
||||
type: question
|
||||
ref: trainers
|
||||
name: Trainers
|
||||
description: Pokemon cards filtered to show only Trainer type cards
|
||||
database: Postgres
|
||||
mbql_query:
|
||||
database: 2
|
||||
type: query
|
||||
query:
|
||||
source-table: "card__114"
|
||||
filter:
|
||||
- "="
|
||||
- - field
|
||||
- supertype
|
||||
- base-type: type/Text
|
||||
- "Trainer"
|
||||
5
test_resources/representations/v0/collection.yml
Normal file
5
test_resources/representations/v0/collection.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
# REQUIRED file that defines the collection for this representations directory
|
||||
type: collection
|
||||
ref: analytics-reports
|
||||
name: Analytics Reports
|
||||
description: Monthly and quarterly analytics reports with key business metrics
|
||||
10
test_resources/representations/v0/invalid.question.yml
Normal file
10
test_resources/representations/v0/invalid.question.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
type: question
|
||||
version: v0
|
||||
ref: invalid-question
|
||||
name: 12
|
||||
description: 10
|
||||
query: |
|
||||
SELECT
|
||||
*
|
||||
FROM orders
|
||||
database: Sample Database
|
||||
@@ -0,0 +1,10 @@
|
||||
type: question
|
||||
version: v0
|
||||
name: monthly-revenue
|
||||
display_name: Monthly Revenue Report
|
||||
description: Tracks total revenue by month for the current year
|
||||
query: |
|
||||
SELECT
|
||||
*
|
||||
FROM orders
|
||||
database: ref:sample-database
|
||||
@@ -0,0 +1,44 @@
|
||||
display_name: Number of Orders
|
||||
type: metric
|
||||
version: v0
|
||||
name: metric-19
|
||||
description: Canonical metric for number of orders placed
|
||||
query:
|
||||
aggregation:
|
||||
- - count
|
||||
breakout:
|
||||
- database: ref:database-1
|
||||
schema: PUBLIC
|
||||
table: ORDERS
|
||||
field: CREATED_AT
|
||||
filter:
|
||||
- time-interval
|
||||
- database: ref:database-1
|
||||
schema: PUBLIC
|
||||
table: ORDERS
|
||||
field: CREATED_AT
|
||||
- -3
|
||||
- year
|
||||
- include-current: true
|
||||
source-table:
|
||||
database: ref:database-1
|
||||
schema: PUBLIC
|
||||
table: ORDERS
|
||||
database: ref:database-1
|
||||
columns:
|
||||
- description: The date and time an order was submitted.
|
||||
semantic_type: type/CreationTimestamp
|
||||
name: CREATED_AT
|
||||
effective_type: type/DateTime
|
||||
id: 13
|
||||
visibility_type: normal
|
||||
display_name: 'Created At: Month'
|
||||
base_type: type/DateTime
|
||||
- base_type: type/BigInteger
|
||||
display_name: Count
|
||||
effective_type: type/BigInteger
|
||||
field_ref:
|
||||
- aggregation
|
||||
- 0
|
||||
name: count
|
||||
semantic_type: type/Quantity
|
||||
124
test_resources/representations/v0/orders+people.model.yml
Normal file
124
test_resources/representations/v0/orders+people.model.yml
Normal file
@@ -0,0 +1,124 @@
|
||||
display_name: Orders + People
|
||||
type: model
|
||||
name: model-1
|
||||
entity-id: _GiVL6zYmsnBb1oqLCp4u
|
||||
description: Sample orders joined with products
|
||||
mbql_query:
|
||||
expressions:
|
||||
Age:
|
||||
- datetime-diff
|
||||
- database: ref:database-1
|
||||
schema: PUBLIC
|
||||
table: PEOPLE
|
||||
field: BIRTH_DATE
|
||||
- - now
|
||||
- year
|
||||
joins:
|
||||
- alias: People - User
|
||||
condition:
|
||||
- =
|
||||
- database: ref:database-1
|
||||
schema: PUBLIC
|
||||
table: ORDERS
|
||||
field: USER_ID
|
||||
- database: ref:database-1
|
||||
schema: PUBLIC
|
||||
table: PEOPLE
|
||||
field: ID
|
||||
fields:
|
||||
- database: ref:database-1
|
||||
schema: PUBLIC
|
||||
table: PEOPLE
|
||||
field: EMAIL
|
||||
- database: ref:database-1
|
||||
schema: PUBLIC
|
||||
table: PEOPLE
|
||||
field: STATE
|
||||
source-table: 1
|
||||
strategy: left-join
|
||||
source-table:
|
||||
database: ref:database-1
|
||||
schema: PUBLIC
|
||||
table: ORDERS
|
||||
database: ref:database-1
|
||||
columns:
|
||||
- name: ID
|
||||
display_name: ID
|
||||
description: This is a unique ID for the product. It is also called the “Invoice number” or “Confirmation number” in customer facing emails and screens.
|
||||
semantic_type: type/PK
|
||||
visibility_type: normal
|
||||
fk_target_field_id: null
|
||||
- name: USER_ID
|
||||
display_name: User ID
|
||||
description: The id of the user who made this order. Note that in some cases where an order was created on behalf of a customer who phoned the order in, this might be the employee who handled the request.
|
||||
semantic_type: type/FK
|
||||
visibility_type: normal
|
||||
fk_target_field_id: 4
|
||||
- name: PRODUCT_ID
|
||||
display_name: Product ID
|
||||
description: The product ID. This is an internal identifier for the product, NOT the SKU.
|
||||
semantic_type: type/FK
|
||||
visibility_type: normal
|
||||
fk_target_field_id: 8
|
||||
- name: SUBTOTAL
|
||||
display_name: Subtotal
|
||||
description: The raw, pre-tax cost of the order. Note that this might be different in the future from the product price due to promotions, credits, etc.
|
||||
semantic_type: type/Currency
|
||||
visibility_type: normal
|
||||
fk_target_field_id: null
|
||||
settings:
|
||||
view_as: null
|
||||
- name: TAX
|
||||
display_name: Tax
|
||||
description: This is the amount of local and federal taxes that are collected on the purchase. Note that other governmental fees on some products are not included here, but instead are accounted for in the subtotal.
|
||||
semantic_type: type/Currency
|
||||
visibility_type: normal
|
||||
fk_target_field_id: null
|
||||
- name: TOTAL
|
||||
display_name: Total
|
||||
description: The total billed amount.
|
||||
semantic_type: type/Currency
|
||||
visibility_type: normal
|
||||
fk_target_field_id: null
|
||||
settings:
|
||||
currency: USD
|
||||
- name: DISCOUNT
|
||||
display_name: Discount
|
||||
description: Discount amount.
|
||||
semantic_type: type/Discount
|
||||
visibility_type: normal
|
||||
fk_target_field_id: null
|
||||
- name: CREATED_AT
|
||||
display_name: Created At
|
||||
description: The date and time an order was submitted.
|
||||
semantic_type: type/CreationTimestamp
|
||||
visibility_type: normal
|
||||
fk_target_field_id: null
|
||||
settings:
|
||||
time_enabled: milliseconds
|
||||
- name: QUANTITY
|
||||
display_name: Quantity
|
||||
description: Number of products bought.
|
||||
semantic_type: type/Quantity
|
||||
visibility_type: normal
|
||||
fk_target_field_id: null
|
||||
settings:
|
||||
show_mini_bar: false
|
||||
- name: Age
|
||||
display_name: Age
|
||||
description: Age of the customer in years.
|
||||
semantic_type: type/Quantity
|
||||
- name: EMAIL
|
||||
display_name: Customer Email
|
||||
description: The contact email for the account.
|
||||
semantic_type: type/Email
|
||||
visibility_type: normal
|
||||
fk_target_field_id: null
|
||||
settings:
|
||||
link_text: ''
|
||||
- name: STATE
|
||||
display_name: State
|
||||
description: The state or province of the account’s billing address.
|
||||
semantic_type: type/State
|
||||
visibility_type: normal
|
||||
fk_target_field_id: null
|
||||
@@ -0,0 +1,13 @@
|
||||
tags: [monthly, daily]
|
||||
name: transform-4
|
||||
display_name: Orders count by month (transform)
|
||||
target_table: {schema: PUBLIC, table: ORDERS_COUNT}
|
||||
type: transform
|
||||
version: v0
|
||||
query:
|
||||
source-table: {database: 'ref:database-53', schema: PUBLIC, table: ORDERS}
|
||||
aggregation:
|
||||
- [count]
|
||||
breakout:
|
||||
- {database: 'ref:database-53', schema: PUBLIC, table: ORDERS, field: CREATED_AT}
|
||||
database: ref:database-53
|
||||
13
test_resources/representations/v0/orders-count.transform.yml
Normal file
13
test_resources/representations/v0/orders-count.transform.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
tags: [monthly, daily]
|
||||
name: transform-4
|
||||
display_name: Orders count by month (transform)
|
||||
target_table: {schema: PUBLIC, table: ORDERS_COUNT}
|
||||
type: transform
|
||||
version: v0
|
||||
query:
|
||||
source-table: {database: 'ref:database-53', schema: PUBLIC, table: ORDERS}
|
||||
aggregation:
|
||||
- [count]
|
||||
breakout:
|
||||
- {database: 'ref:database-53', schema: PUBLIC, table: ORDERS, field: CREATED_AT}
|
||||
database: ref:database-53
|
||||
22
test_resources/representations/v0/pokemon-type.transform.yml
Normal file
22
test_resources/representations/v0/pokemon-type.transform.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
type: transform
|
||||
ref: pokemon-type
|
||||
name: Pokemon Type Summary (Transform)
|
||||
description: Aggregated statistics by Pokemon type combos
|
||||
database: Postgres
|
||||
source:
|
||||
query: |
|
||||
SELECT
|
||||
type_1,
|
||||
type_2,
|
||||
COUNT(*) AS combination_count,
|
||||
AVG(avg) AS overall_avg,
|
||||
AVG(avg_2) AS secondary_avg,
|
||||
AVG(avg_3) AS tertiary_avg,
|
||||
MAX(avg) AS max_primary_stat
|
||||
FROM public.pokemon_type1_type2
|
||||
WHERE type_1 IS NOT NULL
|
||||
GROUP BY type_1, type_2
|
||||
ORDER BY combination_count DESC
|
||||
target:
|
||||
table: pokemon_type_summary
|
||||
schema: public
|
||||
159
test_resources/representations/v0/product-performance.model.yml
Normal file
159
test_resources/representations/v0/product-performance.model.yml
Normal file
@@ -0,0 +1,159 @@
|
||||
type: model
|
||||
version: v0
|
||||
name: product-performance
|
||||
display_name: Product Performance Model
|
||||
description: Product analytics model showing sales performance, inventory metrics, and ratings
|
||||
database: Sample Database
|
||||
query: |
|
||||
SELECT
|
||||
p.ID AS "product_id",
|
||||
p.TITLE AS "product_name",
|
||||
p.CATEGORY AS "category",
|
||||
p.EAN AS "product_code",
|
||||
p.VENDOR AS "vendor",
|
||||
p.PRICE AS "price",
|
||||
p.RATING AS "rating",
|
||||
p.CREATED_AT AS "product_created",
|
||||
COUNT(DISTINCT o.ID) AS "order_count",
|
||||
COUNT(DISTINCT o.USER_ID) AS "unique_buyers",
|
||||
SUM(o.QUANTITY) AS "total_units_sold",
|
||||
SUM(o.SUBTOTAL) AS "gross_revenue",
|
||||
SUM(o.TOTAL) AS "net_revenue",
|
||||
AVG(o.DISCOUNT) AS "avg_discount",
|
||||
AVG(o.TAX) AS "avg_tax",
|
||||
MAX(o.CREATED_AT) AS "last_order_date",
|
||||
MIN(o.CREATED_AT) AS "first_order_date",
|
||||
CASE
|
||||
WHEN SUM(o.SUBTOTAL) > 10000 THEN 'Star Product'
|
||||
WHEN SUM(o.SUBTOTAL) > 5000 THEN 'High Performer'
|
||||
WHEN SUM(o.SUBTOTAL) > 1000 THEN 'Standard'
|
||||
WHEN SUM(o.SUBTOTAL) > 0 THEN 'Low Performer'
|
||||
ELSE 'No Sales'
|
||||
END AS "performance_tier",
|
||||
CASE
|
||||
WHEN p.RATING >= 4.5 THEN 'Excellent'
|
||||
WHEN p.RATING >= 4.0 THEN 'Good'
|
||||
WHEN p.RATING >= 3.0 THEN 'Average'
|
||||
WHEN p.RATING IS NOT NULL THEN 'Poor'
|
||||
ELSE 'Not Rated'
|
||||
END AS "rating_category"
|
||||
FROM PRODUCTS p
|
||||
LEFT JOIN ORDERS o ON p.ID = o.PRODUCT_ID
|
||||
GROUP BY p.ID, p.TITLE, p.CATEGORY, p.EAN, p.VENDOR, p.PRICE, p.RATING, p.CREATED_AT
|
||||
columns:
|
||||
- name: product_id
|
||||
display_name: Product ID
|
||||
description: Unique identifier for each product
|
||||
base_type: type/BigInteger
|
||||
semantic_type: type/PK
|
||||
|
||||
- name: product_name
|
||||
display_name: Product Name
|
||||
description: Full product title
|
||||
base_type: type/Text
|
||||
semantic_type: type/Name
|
||||
|
||||
- name: category
|
||||
display_name: Product Category
|
||||
description: Product categorization
|
||||
base_type: type/Text
|
||||
semantic_type: type/Category
|
||||
|
||||
- name: product_code
|
||||
display_name: EAN/Barcode
|
||||
description: European Article Number or product barcode
|
||||
base_type: type/Text
|
||||
|
||||
- name: vendor
|
||||
display_name: Vendor/Supplier
|
||||
description: Company that supplies this product
|
||||
base_type: type/Text
|
||||
semantic_type: type/Company
|
||||
|
||||
- name: price
|
||||
display_name: List Price
|
||||
description: Current selling price of the product
|
||||
base_type: type/Float
|
||||
semantic_type: type/Price
|
||||
currency: USD
|
||||
|
||||
- name: rating
|
||||
display_name: Customer Rating
|
||||
description: Average customer rating (1-5 scale)
|
||||
base_type: type/Float
|
||||
semantic_type: type/Score
|
||||
|
||||
- name: product_created
|
||||
display_name: Product Launch Date
|
||||
description: When the product was added to catalog
|
||||
base_type: type/DateTime
|
||||
semantic_type: type/CreationTimestamp
|
||||
|
||||
- name: order_count
|
||||
display_name: Total Orders
|
||||
description: Number of orders containing this product
|
||||
base_type: type/Integer
|
||||
semantic_type: type/Quantity
|
||||
|
||||
- name: unique_buyers
|
||||
display_name: Unique Customers
|
||||
description: Number of different customers who purchased this product
|
||||
base_type: type/Integer
|
||||
semantic_type: type/Quantity
|
||||
|
||||
- name: total_units_sold
|
||||
display_name: Units Sold
|
||||
description: Total quantity of this product sold
|
||||
base_type: type/Integer
|
||||
semantic_type: type/Quantity
|
||||
|
||||
- name: gross_revenue
|
||||
display_name: Gross Revenue
|
||||
description: Total revenue before taxes and discounts
|
||||
base_type: type/Float
|
||||
semantic_type: type/Currency
|
||||
currency: USD
|
||||
|
||||
- name: net_revenue
|
||||
display_name: Net Revenue
|
||||
description: Total revenue after taxes and discounts
|
||||
base_type: type/Float
|
||||
semantic_type: type/Currency
|
||||
currency: USD
|
||||
|
||||
- name: avg_discount
|
||||
display_name: Average Discount
|
||||
description: Average discount applied to this product
|
||||
base_type: type/Float
|
||||
semantic_type: type/Discount
|
||||
currency: USD
|
||||
|
||||
- name: avg_tax
|
||||
display_name: Average Tax
|
||||
description: Average tax amount on this product
|
||||
base_type: type/Float
|
||||
semantic_type: type/Currency
|
||||
currency: USD
|
||||
|
||||
- name: last_order_date
|
||||
display_name: Last Sale Date
|
||||
description: Most recent order date for this product
|
||||
base_type: type/DateTime
|
||||
semantic_type: type/UpdatedTimestamp
|
||||
|
||||
- name: first_order_date
|
||||
display_name: First Sale Date
|
||||
description: Earliest order date for this product
|
||||
base_type: type/DateTime
|
||||
|
||||
- name: performance_tier
|
||||
display_name: Performance Tier
|
||||
description: Product classification based on revenue (Star Product, High Performer, Standard, Low Performer, No Sales)
|
||||
base_type: type/Text
|
||||
semantic_type: type/Category
|
||||
|
||||
- name: rating_category
|
||||
display_name: Rating Category
|
||||
description: Categorized rating (Excellent, Good, Average, Poor, Not Rated)
|
||||
base_type: type/Text
|
||||
semantic_type: type/Category
|
||||
@@ -0,0 +1,285 @@
|
||||
type: database
|
||||
name: sample-database
|
||||
display_name: Sample Database
|
||||
engine: h2
|
||||
description: Some example data for you to play around with.
|
||||
connection_details:
|
||||
db: file:plugins/sample-database.db;USER=GUEST;PASSWORD=guest
|
||||
schemas:
|
||||
- name: PUBLIC
|
||||
tables:
|
||||
- name: ACCOUNTS
|
||||
description: Information on customer accounts registered with Piespace. Each account represents a new organization signing up for on-demand pies.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
- name: TRIAL_CONVERTED
|
||||
type: BOOLEAN
|
||||
nullable: true
|
||||
- name: LEGACY_PLAN
|
||||
type: BOOLEAN
|
||||
nullable: true
|
||||
- name: SEATS
|
||||
type: INTEGER
|
||||
nullable: true
|
||||
- name: EMAIL
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: LAST_NAME
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: CANCELED_AT
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: SOURCE
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: FIRST_NAME
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: TRIAL_ENDS_AT
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: PLAN
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: ACTIVE_SUBSCRIPTION
|
||||
type: BOOLEAN
|
||||
nullable: true
|
||||
- name: LONGITUDE
|
||||
type: DOUBLE PRECISION
|
||||
nullable: true
|
||||
- name: COUNTRY
|
||||
type: CHARACTER
|
||||
nullable: true
|
||||
- name: LATITUDE
|
||||
type: DOUBLE PRECISION
|
||||
nullable: true
|
||||
- name: ANALYTIC_EVENTS
|
||||
description: Piespace does some anonymous analytics tracking on how users interact with their platform. They’ve only had time to implement a few events, but you know how it is. Pies come first.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
- name: EVENT
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: TIMESTAMP
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: ACCOUNT_ID
|
||||
type: BIGINT
|
||||
nullable: true
|
||||
fk: ACCOUNTS.ID
|
||||
- name: PAGE_URL
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: BUTTON_LABEL
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: FEEDBACK
|
||||
description: With each order of pies sent out, Piespace includes a place for customers to submit feedback and review their order.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
- name: ACCOUNT_ID
|
||||
type: BIGINT
|
||||
nullable: true
|
||||
fk: ACCOUNTS.ID
|
||||
- name: DATE_RECEIVED
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: RATING_MAPPED
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: EMAIL
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: RATING
|
||||
type: SMALLINT
|
||||
nullable: true
|
||||
- name: BODY
|
||||
type: CHARACTER LARGE OBJECT
|
||||
nullable: true
|
||||
- name: INVOICES
|
||||
description: Confirmed payments from Piespace’s customers. Most accounts pay for their pie subscription on a monthly basis.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
- name: PLAN
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: EXPECTED_INVOICE
|
||||
type: BOOLEAN
|
||||
nullable: true
|
||||
- name: ACCOUNT_ID
|
||||
type: BIGINT
|
||||
nullable: true
|
||||
fk: ACCOUNTS.ID
|
||||
- name: DATE_RECEIVED
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: PAYMENT
|
||||
type: DOUBLE PRECISION
|
||||
nullable: true
|
||||
- name: ORDERS
|
||||
description: Confirmed Sample Company orders for a product, from a user.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
description: This is a unique ID for the product. It is also called the “Invoice number” or “Confirmation number” in customer facing emails and screens.
|
||||
nullable: true
|
||||
- name: PRODUCT_ID
|
||||
type: INTEGER
|
||||
description: The product ID. This is an internal identifier for the product, NOT the SKU.
|
||||
nullable: true
|
||||
fk: PRODUCTS.ID
|
||||
- name: QUANTITY
|
||||
type: INTEGER
|
||||
description: Number of products bought.
|
||||
nullable: true
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: The date and time an order was submitted.
|
||||
nullable: true
|
||||
- name: DISCOUNT
|
||||
type: DOUBLE PRECISION
|
||||
description: Discount amount.
|
||||
nullable: true
|
||||
- name: USER_ID
|
||||
type: INTEGER
|
||||
description: The id of the user who made this order. Note that in some cases where an order was created on behalf of a customer who phoned the order in, this might be the employee who handled the request.
|
||||
nullable: true
|
||||
fk: PEOPLE.ID
|
||||
- name: TAX
|
||||
type: DOUBLE PRECISION
|
||||
description: This is the amount of local and federal taxes that are collected on the purchase. Note that other governmental fees on some products are not included here, but instead are accounted for in the subtotal.
|
||||
nullable: true
|
||||
- name: TOTAL
|
||||
type: DOUBLE PRECISION
|
||||
description: The total billed amount.
|
||||
nullable: true
|
||||
- name: SUBTOTAL
|
||||
type: DOUBLE PRECISION
|
||||
description: The raw, pre-tax cost of the order. Note that this might be different in the future from the product price due to promotions, credits, etc.
|
||||
nullable: true
|
||||
- name: PEOPLE
|
||||
description: Information on the user accounts registered with Sample Company.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
description: A unique identifier given to each user.
|
||||
nullable: true
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: The date the user record was created. Also referred to as the user’s "join date"
|
||||
nullable: true
|
||||
- name: PASSWORD
|
||||
type: CHARACTER VARYING
|
||||
description: This is the salted password of the user. It should not be visible
|
||||
nullable: true
|
||||
- name: EMAIL
|
||||
type: CHARACTER VARYING
|
||||
description: The contact email for the account.
|
||||
nullable: true
|
||||
- name: BIRTH_DATE
|
||||
type: DATE
|
||||
description: The date of birth of the user
|
||||
nullable: true
|
||||
- name: LONGITUDE
|
||||
type: DOUBLE PRECISION
|
||||
description: This is the longitude of the user on sign-up. It might be updated in the future to the last seen location.
|
||||
nullable: true
|
||||
- name: LATITUDE
|
||||
type: DOUBLE PRECISION
|
||||
description: This is the latitude of the user on sign-up. It might be updated in the future to the last seen location.
|
||||
nullable: true
|
||||
- name: SOURCE
|
||||
type: CHARACTER VARYING
|
||||
description: 'The channel through which we acquired this user. Valid values include: Affiliate, Facebook, Google, Organic and Twitter'
|
||||
nullable: true
|
||||
- name: ZIP
|
||||
type: CHARACTER
|
||||
description: The postal code of the account’s billing address
|
||||
nullable: true
|
||||
- name: STATE
|
||||
type: CHARACTER
|
||||
description: The state or province of the account’s billing address
|
||||
nullable: true
|
||||
- name: NAME
|
||||
type: CHARACTER VARYING
|
||||
description: The name of the user who owns an account
|
||||
nullable: true
|
||||
- name: CITY
|
||||
type: CHARACTER VARYING
|
||||
description: The city of the account’s billing address
|
||||
nullable: true
|
||||
- name: ADDRESS
|
||||
type: CHARACTER VARYING
|
||||
description: The street address of the account’s billing address
|
||||
nullable: true
|
||||
- name: PRODUCTS
|
||||
description: Includes a catalog of all the products ever sold by the famed Sample Company.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
description: The numerical product number. Only used internally. All external communication should use the title or EAN.
|
||||
nullable: true
|
||||
- name: PRICE
|
||||
type: DOUBLE PRECISION
|
||||
description: The list price of the product. Note that this is not always the price the product sold for due to discounts, promotions, etc.
|
||||
nullable: true
|
||||
- name: RATING
|
||||
type: DOUBLE PRECISION
|
||||
description: The average rating users have given the product. This ranges from 1 - 5
|
||||
nullable: true
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: The date the product was added to our catalog.
|
||||
nullable: true
|
||||
- name: CATEGORY
|
||||
type: CHARACTER VARYING
|
||||
description: 'The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget'
|
||||
nullable: true
|
||||
- name: EAN
|
||||
type: CHARACTER
|
||||
description: The international article number. A 13 digit number uniquely identifying the product.
|
||||
nullable: true
|
||||
- name: TITLE
|
||||
type: CHARACTER VARYING
|
||||
description: The name of the product as it should be displayed to customers.
|
||||
nullable: true
|
||||
- name: VENDOR
|
||||
type: CHARACTER VARYING
|
||||
description: The source of the product.
|
||||
nullable: true
|
||||
- name: REVIEWS
|
||||
description: Reviews that Sample Company customers have left on our products.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
description: A unique internal identifier for the review. Should not be used externally.
|
||||
nullable: true
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: The day and time a review was written by a user.
|
||||
nullable: true
|
||||
- name: BODY
|
||||
type: CHARACTER VARYING
|
||||
description: The review the user left. Limited to 2000 characters.
|
||||
nullable: true
|
||||
- name: PRODUCT_ID
|
||||
type: INTEGER
|
||||
description: The product the review was for
|
||||
nullable: true
|
||||
fk: PRODUCTS.ID
|
||||
- name: RATING
|
||||
type: SMALLINT
|
||||
description: The rating (on a scale of 1-5) the user left.
|
||||
nullable: true
|
||||
- name: REVIEWER
|
||||
type: CHARACTER VARYING
|
||||
description: The user who left the review
|
||||
nullable: true
|
||||
@@ -0,0 +1,91 @@
|
||||
type: database
|
||||
name: sample-db
|
||||
display_name: Sample Database
|
||||
engine: postgres
|
||||
description: Main application database with orders and customer data
|
||||
connection_details:
|
||||
host: localhost
|
||||
port: 5432
|
||||
dbname: sample_data
|
||||
user: metabase_user
|
||||
ssl: true
|
||||
# Note: password should be provided via environment variable or secrets management
|
||||
schemas:
|
||||
- name: PUBLIC
|
||||
tables:
|
||||
- name: ORDERS
|
||||
description: Customer order transactions
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
pk: true
|
||||
description: Order unique identifier
|
||||
- name: USER_ID
|
||||
type: BIGINT
|
||||
fk: PEOPLE.ID
|
||||
description: Reference to customer
|
||||
- name: PRODUCT_ID
|
||||
type: INTEGER
|
||||
fk: PRODUCTS.ID
|
||||
description: Reference to product
|
||||
- name: SUBTOTAL
|
||||
type: DECIMAL(10,2)
|
||||
description: Order amount before tax
|
||||
- name: TAX
|
||||
type: DECIMAL(10,2)
|
||||
description: Tax amount
|
||||
- name: TOTAL
|
||||
type: DECIMAL(10,2)
|
||||
description: Total order amount
|
||||
- name: DISCOUNT
|
||||
type: DECIMAL(10,2)
|
||||
nullable: true
|
||||
description: Discount applied
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: Order creation timestamp
|
||||
- name: QUANTITY
|
||||
type: INTEGER
|
||||
description: Number of items ordered
|
||||
- name: PEOPLE
|
||||
description: Customer information
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
pk: true
|
||||
description: Customer unique identifier
|
||||
- name: EMAIL
|
||||
type: VARCHAR(255)
|
||||
description: Customer email address
|
||||
- name: NAME
|
||||
type: VARCHAR(255)
|
||||
description: Customer full name
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: Account creation date
|
||||
- name: PRODUCTS
|
||||
description: Product catalog
|
||||
columns:
|
||||
- name: ID
|
||||
type: INTEGER
|
||||
pk: true
|
||||
description: Product unique identifier
|
||||
- name: TITLE
|
||||
type: VARCHAR(255)
|
||||
description: Product name
|
||||
- name: CATEGORY
|
||||
type: VARCHAR(100)
|
||||
description: Product category
|
||||
- name: VENDOR
|
||||
type: VARCHAR(100)
|
||||
description: Product vendor/manufacturer
|
||||
- name: PRICE
|
||||
type: DECIMAL(10,2)
|
||||
description: Product unit price
|
||||
- name: RATING
|
||||
type: DECIMAL(3,2)
|
||||
nullable: true
|
||||
description: Average product rating
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: Product listing date
|
||||
6
test_resources/representations/v0/select-1.metric.yml
Normal file
6
test_resources/representations/v0/select-1.metric.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
display_name: RTDDSRUAEFJLNYWASIFS
|
||||
type: metric
|
||||
version: v0
|
||||
name: metric-126
|
||||
query: select 1
|
||||
database: ref:database-3
|
||||
6
test_resources/representations/v0/select-1.model.yml
Normal file
6
test_resources/representations/v0/select-1.model.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
display_name: DJWCFCOREPMVWZWXSQLS
|
||||
type: model
|
||||
name: model-119
|
||||
entity-id: qMQj_zy85cRhyX33xEfaZ
|
||||
query: select 1
|
||||
database: ref:database-3
|
||||
@@ -0,0 +1,285 @@
|
||||
type: v0/database
|
||||
name: database-1
|
||||
display_name: Sample Database
|
||||
engine: h2
|
||||
description: Some example data for you to play around with.
|
||||
connection_details:
|
||||
db: file:plugins/sample-database.db;USER=GUEST;PASSWORD=guest
|
||||
schemas:
|
||||
- name: PUBLIC
|
||||
tables:
|
||||
- name: PEOPLE
|
||||
description: Information on the user accounts registered with Sample Company.
|
||||
columns:
|
||||
- name: STATE
|
||||
type: CHARACTER
|
||||
description: The state or province of the account’s billing address
|
||||
nullable: true
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
description: A unique identifier given to each user.
|
||||
nullable: true
|
||||
- name: EMAIL
|
||||
type: CHARACTER VARYING
|
||||
description: The contact email for the account.
|
||||
nullable: true
|
||||
- name: BIRTH_DATE
|
||||
type: DATE
|
||||
description: The date of birth of the user
|
||||
nullable: true
|
||||
- name: SOURCE
|
||||
type: CHARACTER VARYING
|
||||
description: 'The channel through which we acquired this user. Valid values include: Affiliate, Facebook, Google, Organic and Twitter'
|
||||
nullable: true
|
||||
- name: NAME
|
||||
type: CHARACTER VARYING
|
||||
description: The name of the user who owns an account
|
||||
nullable: true
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: The date the user record was created. Also referred to as the user’s "join date"
|
||||
nullable: true
|
||||
- name: ADDRESS
|
||||
type: CHARACTER VARYING
|
||||
description: The street address of the account’s billing address
|
||||
nullable: true
|
||||
- name: LATITUDE
|
||||
type: DOUBLE PRECISION
|
||||
description: This is the latitude of the user on sign-up. It might be updated in the future to the last seen location.
|
||||
nullable: true
|
||||
- name: CITY
|
||||
type: CHARACTER VARYING
|
||||
description: The city of the account’s billing address
|
||||
nullable: true
|
||||
- name: PASSWORD
|
||||
type: CHARACTER VARYING
|
||||
description: This is the salted password of the user. It should not be visible
|
||||
nullable: true
|
||||
- name: LONGITUDE
|
||||
type: DOUBLE PRECISION
|
||||
description: This is the longitude of the user on sign-up. It might be updated in the future to the last seen location.
|
||||
nullable: true
|
||||
- name: ZIP
|
||||
type: CHARACTER
|
||||
description: The postal code of the account’s billing address
|
||||
nullable: true
|
||||
- name: ORDERS
|
||||
description: Confirmed Sample Company orders for a product, from a user.
|
||||
columns:
|
||||
- name: QUANTITY
|
||||
type: INTEGER
|
||||
description: Number of products bought.
|
||||
nullable: true
|
||||
- name: DISCOUNT
|
||||
type: DOUBLE PRECISION
|
||||
description: Discount amount.
|
||||
nullable: true
|
||||
- name: TOTAL
|
||||
type: DOUBLE PRECISION
|
||||
description: The total billed amount.
|
||||
nullable: true
|
||||
- name: TAX
|
||||
type: DOUBLE PRECISION
|
||||
description: This is the amount of local and federal taxes that are collected on the purchase. Note that other governmental fees on some products are not included here, but instead are accounted for in the subtotal.
|
||||
nullable: true
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
description: This is a unique ID for the product. It is also called the “Invoice number” or “Confirmation number” in customer facing emails and screens.
|
||||
nullable: true
|
||||
- name: SUBTOTAL
|
||||
type: DOUBLE PRECISION
|
||||
description: The raw, pre-tax cost of the order. Note that this might be different in the future from the product price due to promotions, credits, etc.
|
||||
nullable: true
|
||||
- name: USER_ID
|
||||
type: INTEGER
|
||||
description: The id of the user who made this order. Note that in some cases where an order was created on behalf of a customer who phoned the order in, this might be the employee who handled the request.
|
||||
nullable: true
|
||||
fk: PEOPLE.ID
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: The date and time an order was submitted.
|
||||
nullable: true
|
||||
- name: PRODUCT_ID
|
||||
type: INTEGER
|
||||
description: The product ID. This is an internal identifier for the product, NOT the SKU.
|
||||
nullable: true
|
||||
fk: PRODUCTS.ID
|
||||
- name: PRODUCTS
|
||||
description: Includes a catalog of all the products ever sold by the famed Sample Company.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
description: The numerical product number. Only used internally. All external communication should use the title or EAN.
|
||||
nullable: true
|
||||
- name: EAN
|
||||
type: CHARACTER
|
||||
description: The international article number. A 13 digit number uniquely identifying the product.
|
||||
nullable: true
|
||||
- name: RATING
|
||||
type: DOUBLE PRECISION
|
||||
description: The average rating users have given the product. This ranges from 1 - 5
|
||||
nullable: true
|
||||
- name: TITLE
|
||||
type: CHARACTER VARYING
|
||||
description: The name of the product as it should be displayed to customers.
|
||||
nullable: true
|
||||
- name: CATEGORY
|
||||
type: CHARACTER VARYING
|
||||
description: 'The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget'
|
||||
nullable: true
|
||||
- name: VENDOR
|
||||
type: CHARACTER VARYING
|
||||
description: The source of the product.
|
||||
nullable: true
|
||||
- name: PRICE
|
||||
type: DOUBLE PRECISION
|
||||
description: The list price of the product. Note that this is not always the price the product sold for due to discounts, promotions, etc.
|
||||
nullable: true
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: The date the product was added to our catalog.
|
||||
nullable: true
|
||||
- name: REVIEWS
|
||||
description: Reviews that Sample Company customers have left on our products.
|
||||
columns:
|
||||
- name: RATING
|
||||
type: SMALLINT
|
||||
description: The rating (on a scale of 1-5) the user left.
|
||||
nullable: true
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
description: The day and time a review was written by a user.
|
||||
nullable: true
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
description: A unique internal identifier for the review. Should not be used externally.
|
||||
nullable: true
|
||||
- name: PRODUCT_ID
|
||||
type: INTEGER
|
||||
description: The product the review was for
|
||||
nullable: true
|
||||
fk: PRODUCTS.ID
|
||||
- name: REVIEWER
|
||||
type: CHARACTER VARYING
|
||||
description: The user who left the review
|
||||
nullable: true
|
||||
- name: BODY
|
||||
type: CHARACTER VARYING
|
||||
description: The review the user left. Limited to 2000 characters.
|
||||
nullable: true
|
||||
- name: FEEDBACK
|
||||
description: With each order of pies sent out, Piespace includes a place for customers to submit feedback and review their order.
|
||||
columns:
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
- name: RATING
|
||||
type: SMALLINT
|
||||
nullable: true
|
||||
- name: RATING_MAPPED
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: ACCOUNT_ID
|
||||
type: BIGINT
|
||||
nullable: true
|
||||
fk: ACCOUNTS.ID
|
||||
- name: EMAIL
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: DATE_RECEIVED
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: BODY
|
||||
type: CHARACTER LARGE OBJECT
|
||||
nullable: true
|
||||
- name: ACCOUNTS
|
||||
description: Information on customer accounts registered with Piespace. Each account represents a new organization signing up for on-demand pies.
|
||||
columns:
|
||||
- name: LONGITUDE
|
||||
type: DOUBLE PRECISION
|
||||
nullable: true
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
- name: CREATED_AT
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: SEATS
|
||||
type: INTEGER
|
||||
nullable: true
|
||||
- name: LAST_NAME
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: FIRST_NAME
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: TRIAL_CONVERTED
|
||||
type: BOOLEAN
|
||||
nullable: true
|
||||
- name: CANCELED_AT
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: TRIAL_ENDS_AT
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: SOURCE
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: ACTIVE_SUBSCRIPTION
|
||||
type: BOOLEAN
|
||||
nullable: true
|
||||
- name: PLAN
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: LEGACY_PLAN
|
||||
type: BOOLEAN
|
||||
nullable: true
|
||||
- name: COUNTRY
|
||||
type: CHARACTER
|
||||
nullable: true
|
||||
- name: LATITUDE
|
||||
type: DOUBLE PRECISION
|
||||
nullable: true
|
||||
- name: EMAIL
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: ANALYTIC_EVENTS
|
||||
description: Piespace does some anonymous analytics tracking on how users interact with their platform. They’ve only had time to implement a few events, but you know how it is. Pies come first.
|
||||
columns:
|
||||
- name: BUTTON_LABEL
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: EVENT
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: TIMESTAMP
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
- name: ACCOUNT_ID
|
||||
type: BIGINT
|
||||
nullable: true
|
||||
fk: ACCOUNTS.ID
|
||||
- name: PAGE_URL
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
- name: INVOICES
|
||||
description: Confirmed payments from Piespace’s customers. Most accounts pay for their pie subscription on a monthly basis.
|
||||
columns:
|
||||
- name: PLAN
|
||||
type: CHARACTER VARYING
|
||||
nullable: true
|
||||
- name: PAYMENT
|
||||
type: DOUBLE PRECISION
|
||||
nullable: true
|
||||
- name: ACCOUNT_ID
|
||||
type: BIGINT
|
||||
nullable: true
|
||||
fk: ACCOUNTS.ID
|
||||
- name: ID
|
||||
type: BIGINT
|
||||
- name: EXPECTED_INVOICE
|
||||
type: BOOLEAN
|
||||
nullable: true
|
||||
- name: DATE_RECEIVED
|
||||
type: TIMESTAMP
|
||||
nullable: true
|
||||
@@ -0,0 +1,3 @@
|
||||
type: v0/mbql
|
||||
ref: mbql-question-870
|
||||
data: cXVlcnk6CiAgc291cmNlLXRhYmxlOgogICAgcmVmL2RhdGFiYXNlOiByZWY6ZGF0YWJhc2UtMQogICAgcmVmL3NjaGVtYTogUFVCTElDCiAgICByZWYvdGFibGU6IE9SREVSUwogIGpvaW5zOgogICAgLSBmaWVsZHM6IGFsbAogICAgICBzdHJhdGVneTogbGVmdC1qb2luCiAgICAgIGFsaWFzOiBQcm9kdWN0cwogICAgICBjb25kaXRpb246CiAgICAgICAgLSA9CiAgICAgICAgLSByZWYvZGF0YWJhc2U6IHJlZjpkYXRhYmFzZS0xCiAgICAgICAgICByZWYvc2NoZW1hOiBQVUJMSUMKICAgICAgICAgIHJlZi90YWJsZTogT1JERVJTCiAgICAgICAgICByZWYvZmllbGQ6IFBST0RVQ1RfSUQKICAgICAgICAtIHJlZi9kYXRhYmFzZTogcmVmOmRhdGFiYXNlLTEKICAgICAgICAgIHJlZi9zY2hlbWE6IFBVQkxJQwogICAgICAgICAgcmVmL3RhYmxlOiBQUk9EVUNUUwogICAgICAgICAgcmVmL2ZpZWxkOiBJRAogICAgICBzb3VyY2UtdGFibGU6IDMKICBhZ2dyZWdhdGlvbjoKICAgIC0gICAtIGF2ZwogICAgICAgIC0gcmVmL2RhdGFiYXNlOiByZWY6ZGF0YWJhc2UtMQogICAgICAgICAgcmVmL3NjaGVtYTogUFVCTElDCiAgICAgICAgICByZWYvdGFibGU6IE9SREVSUwogICAgICAgICAgcmVmL2ZpZWxkOiBUT1RBTAogICAgLSAgIC0gYXZnCiAgICAgICAgLSByZWYvZGF0YWJhc2U6IHJlZjpkYXRhYmFzZS0xCiAgICAgICAgICByZWYvc2NoZW1hOiBQVUJMSUMKICAgICAgICAgIHJlZi90YWJsZTogT1JERVJTCiAgICAgICAgICByZWYvZmllbGQ6IFFVQU5USVRZCiAgICAtICAgLSBhdmcKICAgICAgICAtIHJlZi9kYXRhYmFzZTogcmVmOmRhdGFiYXNlLTEKICAgICAgICAgIHJlZi9zY2hlbWE6IFBVQkxJQwogICAgICAgICAgcmVmL3RhYmxlOiBPUkRFUlMKICAgICAgICAgIHJlZi9maWVsZDogVEFYCiAgYnJlYWtvdXQ6CiAgICAtIHJlZi9kYXRhYmFzZTogcmVmOmRhdGFiYXNlLTEKICAgICAgcmVmL3NjaGVtYTogUFVCTElDCiAgICAgIHJlZi90YWJsZTogUFJPRFVDVFMKICAgICAgcmVmL2ZpZWxkOiBDQVRFR09SWQogICAgLSByZWYvZGF0YWJhc2U6IHJlZjpkYXRhYmFzZS0xCiAgICAgIHJlZi9zY2hlbWE6IFBVQkxJQwogICAgICByZWYvdGFibGU6IE9SREVSUwogICAgICByZWYvZmllbGQ6IENSRUFURURfQVQK
|
||||
@@ -0,0 +1,7 @@
|
||||
name: Sales Data
|
||||
type: question
|
||||
ref: question-870
|
||||
entity-id: ZkB8pnBqduzPlZ45RbVTt
|
||||
description: 'Orders + Products, Average of Total, Average of Quantity, and Average of Tax, Grouped by Products → Category and Created At: Month'
|
||||
mbql_query: ref:mbql-question-870
|
||||
database: ref:database-1
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
||||
display_name: Sales Data
|
||||
type: model
|
||||
name: model-871
|
||||
entity-id: YGJnB1FNaVqmXiJwtzDrK
|
||||
mbql_query: ref:mbql-model-871
|
||||
database: ref:database-1
|
||||
columns:
|
||||
- name: CATEGORY
|
||||
display_name: Product
|
||||
description: 'The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget'
|
||||
semantic_type: type/Category
|
||||
visibility_type: normal
|
||||
- name: CREATED_AT
|
||||
display_name: 'Month of Creation: Month'
|
||||
description: The date and time an order was submitted.
|
||||
semantic_type: type/CreationDate
|
||||
visibility_type: normal
|
||||
- name: avg
|
||||
display_name: Price
|
||||
semantic_type: type/Currency
|
||||
- name: avg_2
|
||||
display_name: Quantity
|
||||
semantic_type: type/Quantity
|
||||
- name: avg_3
|
||||
display_name: Tax
|
||||
semantic_type: type/Currency
|
||||
35
test_resources/representations/v0/simple.document.yml
Normal file
35
test_resources/representations/v0/simple.document.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
display_name: This is my document
|
||||
type: document
|
||||
version: v0
|
||||
name: document-1
|
||||
entity-id: -refHqV7RAXzw6ji-POfh
|
||||
content:
|
||||
type: doc
|
||||
content:
|
||||
- type: heading
|
||||
attrs:
|
||||
level: 1
|
||||
_id: 45cfcc03-9c4f-df83-b091-071a301b839b
|
||||
content:
|
||||
- type: text
|
||||
text: Let's make a document.
|
||||
- type: paragraph
|
||||
attrs:
|
||||
_id: 048f343b-6957-1a17-6fa2-026fad799e42
|
||||
content:
|
||||
- type: text
|
||||
text: 'Here is a question:'
|
||||
- type: resizeNode
|
||||
attrs:
|
||||
height: 442
|
||||
minHeight: 280
|
||||
content:
|
||||
- type: cardEmbed
|
||||
attrs:
|
||||
id: 325
|
||||
name: null
|
||||
_id: ab365220-b387-d3c6-992a-e6034057ddd5
|
||||
- type: paragraph
|
||||
attrs:
|
||||
_id: b7085b94-6ea4-b138-4030-01f36f8526e9
|
||||
content_type: application/json+vnd.prose-mirror
|
||||
@@ -0,0 +1,10 @@
|
||||
type: question
|
||||
version: v0
|
||||
name: monthly-revenue
|
||||
display_name: Monthly Revenue Report
|
||||
description: Tracks total revenue by month for the current year
|
||||
query: |
|
||||
SELECT
|
||||
*
|
||||
FROM orders
|
||||
database: ref:sample-database
|
||||
Reference in New Issue
Block a user