Compare commits
333 Commits
microvm-ex
...
representa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b584b35cd9 | ||
|
|
a36cda7ffd | ||
|
|
151f143233 | ||
|
|
e440239b79 | ||
|
|
0f41ae3925 | ||
|
|
d4344c308e | ||
|
|
ae23f598f0 | ||
|
|
2d6624319a | ||
|
|
f51192d3db | ||
|
|
23754f9e95 | ||
|
|
ae65686d30 | ||
|
|
f26f6a18cd | ||
|
|
4b33138799 | ||
|
|
d3bad99de1 | ||
|
|
a576039ae0 | ||
|
|
619e2fa17e | ||
|
|
9116c09afe | ||
|
|
47b0340836 | ||
|
|
eda624973b | ||
|
|
0c0634dbc1 | ||
|
|
8a1139118f | ||
|
|
8bb448b8fc | ||
|
|
99e16ef825 | ||
|
|
8b44711794 | ||
|
|
c35be5ea97 | ||
|
|
04f1e58c3a | ||
|
|
20657a3b0b | ||
|
|
174a118a6c | ||
|
|
4d6693adae | ||
|
|
91d2191c93 | ||
|
|
86dcae0d73 | ||
|
|
ea48c27d72 | ||
|
|
13cb16df11 | ||
|
|
89e585ac23 | ||
|
|
7c8078bd09 | ||
|
|
8e37784337 | ||
|
|
b0443a4805 | ||
|
|
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 |
@@ -2098,6 +2098,21 @@
|
||||
util
|
||||
enterprise/serialization}}
|
||||
|
||||
enterprise/representations
|
||||
{:team "Querying"
|
||||
:api #{metabase-enterprise.representations.core
|
||||
metabase-enterprise.representations.api
|
||||
metabase-enterprise.representations.init}
|
||||
:uses #{api
|
||||
collections
|
||||
collections-rest
|
||||
config
|
||||
driver
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
util}}
|
||||
|
||||
enterprise/sandbox
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.sandbox.api.routes
|
||||
|
||||
5
bb.edn
5
bb.edn
@@ -275,8 +275,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"
|
||||
@@ -335,4 +334,4 @@
|
||||
:requires [[mage.escherize.fix-whitespace :as fix-whitespace]]
|
||||
:examples [["mage -escherize-fix-whitespace '**/*.clj{,c,s}'" "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))}}}
|
||||
|
||||
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 "ffc4edc482c057cd9412c62f018a41e9140c618b"
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
io.github.metabase/representations {:mvn/version "0.1.13"}
|
||||
;; 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
|
||||
|
||||
@@ -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,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,81 @@
|
||||
(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 insert!
|
||||
"Insert a representation into the App DB as a new entity."
|
||||
([representation]
|
||||
(import/insert! representation (v0-common/map-entity-index {})))
|
||||
([representation ref-index]
|
||||
(import/insert! representation ref-index)))
|
||||
|
||||
(defn update!
|
||||
"Update an existing representation into the App DB given its PK id."
|
||||
([representation id]
|
||||
(import/update! representation id (v0-common/map-entity-index {})))
|
||||
([representation id ref-index]
|
||||
(import/update! representation id 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-nested
|
||||
"Export a collection and all its contents."
|
||||
([collection-id]
|
||||
(export/export-entire-collection collection-id)))
|
||||
|
||||
(defn export-set
|
||||
"Export the transitive closure of the dependencies."
|
||||
([representations]
|
||||
(export/export-set representations)))
|
||||
|
||||
(defn order-representations
|
||||
"Topologically sort the representations."
|
||||
([representations]
|
||||
(v0-common/order-representations representations)))
|
||||
@@ -0,0 +1,212 @@
|
||||
(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.collections-rest.api :as coll.api]
|
||||
[metabase.util :as mu]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(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. Requires basic refs (use before rename-refs)."
|
||||
[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
|
||||
"Generate a reference name for each representation based on its display name.
|
||||
Munges the display name to create a valid reference identifier."
|
||||
[reps]
|
||||
(map #(assoc % ::proposed-ref (munge-name (:display_name %))) reps))
|
||||
|
||||
(defn add-type
|
||||
"Append the entity type to each representation's proposed reference.
|
||||
This helps distinguish entities with similar names but different types."
|
||||
[reps]
|
||||
(map #(update % ::proposed-ref str "-" (name (:type %))) reps))
|
||||
|
||||
(defn add-sequence-number
|
||||
"Append a sequence number to each representation's proposed reference.
|
||||
This ensures uniqueness when other strategies don't provide sufficient disambiguation."
|
||||
[reps]
|
||||
(map-indexed (fn [i rep]
|
||||
(update rep ::proposed-ref str "-" i))
|
||||
reps))
|
||||
|
||||
(def standard-ref-strategies
|
||||
"Default strategies for generating unique references for exported representations.
|
||||
Applied in order to disambiguate entities with conflicting reference names."
|
||||
[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]
|
||||
(rename-refs representations ref-from-name standard-ref-strategies add-sequence-number))
|
||||
([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-entire-collection
|
||||
"Generate a list of yaml representations to export. Probably only works on cards and sub-collections."
|
||||
[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)))))
|
||||
@@ -0,0 +1,74 @@
|
||||
(ns metabase-enterprise.representations.import
|
||||
"Import functionality for Metabase entities from human-readable representations"
|
||||
(:require
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.core :as v0-core]
|
||||
[representations.read :as rep-read]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(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))))
|
||||
|
||||
;; Collections
|
||||
|
||||
(defn- collection-representations
|
||||
"Extract all representations from a collection YAML, including the collection itself,
|
||||
its databases, and recursively all child collections."
|
||||
[collection-yaml]
|
||||
(concat
|
||||
[(dissoc collection-yaml :children :databases)]
|
||||
(:databases collection-yaml)
|
||||
;; recursive call
|
||||
(mapcat collection-representations (:children collection-yaml))))
|
||||
|
||||
(defn- persist-collection-tree
|
||||
"Persist the collection down through a nested collection tree."
|
||||
([collection-yaml]
|
||||
(persist-collection-tree collection-yaml nil))
|
||||
([collection-yaml parent-ref]
|
||||
(let [this-ref (str "ref:" (:name collection-yaml))]
|
||||
(-> collection-yaml
|
||||
(assoc :collection parent-ref)
|
||||
(update :children (fn [children]
|
||||
(mapv #(persist-collection-tree % this-ref) children)))))))
|
||||
|
||||
(defn prepare-collection-tree-for-import
|
||||
"Prepare a collection tree (as outputted from [[export/export-entire-collection]] for [[insert-all!]]."
|
||||
[collection-yaml]
|
||||
(-> collection-yaml
|
||||
persist-collection-tree
|
||||
collection-representations))
|
||||
|
||||
(defn insert-all!
|
||||
"Insert a collection of representations."
|
||||
[representations]
|
||||
(loop [representations (v0-common/order-representations representations)
|
||||
index {}]
|
||||
(if (seq representations)
|
||||
(let [rep (first representations)
|
||||
instance (insert! rep (v0-common/map-entity-index index))]
|
||||
(recur (rest representations) (assoc index (:name rep) instance)))
|
||||
index)))
|
||||
@@ -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,52 @@
|
||||
(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)]
|
||||
(cond
|
||||
(= 1 (count entities))
|
||||
(first entities)
|
||||
|
||||
(< 1 (count entities))
|
||||
(throw (ex-info (str "Multiple entities of type " toucan-type " with name '" name "' found in App DB.")
|
||||
{: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,68 @@
|
||||
(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 {: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 {: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 {: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 ""})))
|
||||
|
||||
(defmethod with-toucan-defaults :model/Timeline
|
||||
[_t2-model timeline-entity]
|
||||
(->> timeline-entity
|
||||
(merge {:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
|
||||
|
||||
(defmethod with-toucan-defaults :model/TimelineEvent
|
||||
[_t2-model event-entity]
|
||||
(->> event-entity
|
||||
(merge {:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
|
||||
@@ -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,58 @@
|
||||
(ns metabase-enterprise.representations.v0.collection
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.collections-rest.api :as coll.api]
|
||||
[metabase.util :as u]))
|
||||
|
||||
(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 (when collection
|
||||
(v0-common/lookup-entity ref-index collection))
|
||||
location (if parent
|
||||
(str (:location parent) (:id parent) "/")
|
||||
"/")]
|
||||
(u/remove-nils
|
||||
{:name (or collection-name name)
|
||||
:description description
|
||||
:location location})))
|
||||
|
||||
(defn export-collection
|
||||
"Export a Collection Toucan entity to a v0 collection representation."
|
||||
[t2-collection]
|
||||
(-> (ordered-map
|
||||
{:name (v0-common/unref (v0-common/->ref (:id t2-collection) :collection))
|
||||
:type :collection
|
||||
:version :v0
|
||||
:display_name (:name t2-collection)
|
||||
:description (:description t2-collection)})
|
||||
u/remove-nils))
|
||||
@@ -0,0 +1,251 @@
|
||||
(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
|
||||
"Validates that an entity is not nil. Throws an exception if nil, otherwise returns the entity."
|
||||
[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 by fetching the entity from the database.
|
||||
Returns a reference string in the format expected by the representation system."
|
||||
[id model]
|
||||
(entity->ref (t2/select-one model id)))
|
||||
|
||||
(defn ensure-correct-model-type
|
||||
"Validates that an entity has the expected Toucan model type.
|
||||
Throws an exception if the model type doesn't match, otherwise returns the entity."
|
||||
[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,77 @@
|
||||
(ns metabase-enterprise.representations.v0.core
|
||||
(:require
|
||||
[metabase-enterprise.representations.toucan.core :as rep-t2]
|
||||
[metabase-enterprise.representations.v0.collection :as v0-coll]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[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
|
||||
"Returns the Toucan model keyword for a given v0 representation type.
|
||||
Maps representation types (e.g., :collection, :database) to their corresponding Toucan models."
|
||||
[representation-type]
|
||||
(case representation-type
|
||||
:collection v0-coll/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)
|
||||
: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)
|
||||
: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 insert!
|
||||
"Insert a v0 representation as a new entity."
|
||||
[representation ref-index]
|
||||
(case (:type representation)
|
||||
:transform (v0-transform/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)
|
||||
;; 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,102 @@
|
||||
(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]
|
||||
[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)))
|
||||
|
||||
;;; ------------------------------------ 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,62 @@
|
||||
(ns metabase-enterprise.representations.v0.document
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
;;; ------------------------------------ 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 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- 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 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,136 @@
|
||||
(ns metabase-enterprise.representations.v0.mbql
|
||||
"MBQL transformations for import/export of representations."
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[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.normalize :as lib.normalize]
|
||||
[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- normalize-field-references [query]
|
||||
(walk/postwalk (fn [node]
|
||||
(if (and (vector? node)
|
||||
(= "field" (first node)))
|
||||
(update node 0 keyword)
|
||||
node))
|
||||
query))
|
||||
|
||||
(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)
|
||||
;; normalize and import-walk have trouble with fields as we use them with strings and our own version of the
|
||||
;; field-ref, so manually convert "field" to :field
|
||||
query (normalize-field-references query)
|
||||
query' (if (string? query)
|
||||
(lib/native-query mp query)
|
||||
(mu/disable-enforcement
|
||||
(lib.normalize/normalize
|
||||
(lib.query/query-with-stages mp query))))]
|
||||
(mu/disable-enforcement
|
||||
(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,51 @@
|
||||
(ns metabase-enterprise.representations.v0.metric
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[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]
|
||||
[representations.schema.v0.column :as rep-v0-column]))
|
||||
|
||||
(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 columns] :as representation}
|
||||
ref-index]
|
||||
(let [database-id (lookup/lookup-database-id ref-index database)
|
||||
dataset-query (v0-mbql/import-dataset-query representation ref-index)
|
||||
collection-id (when (:collection representation)
|
||||
(v0-common/lookup-id ref-index (:collection representation)))]
|
||||
(-> {;; 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 collection-id}
|
||||
u/remove-nils)))
|
||||
|
||||
;;; -- 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,98 @@
|
||||
(ns metabase-enterprise.representations.v0.model
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[metabase-enterprise.representations.v0.card]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.util :as u]))
|
||||
|
||||
(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]
|
||||
(let [name->base-type (into {} (for [md inferred-metadata]
|
||||
[(:lib/original-name md)
|
||||
(:base-type md)]))]
|
||||
(mapv (fn [user-column]
|
||||
(assoc user-column :base_type (get name->base-type (:name user-column))))
|
||||
user-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 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 (lib-be/normalize-query dataset-query)
|
||||
mlv2-query (lib/query metadata-provider normalized-query)
|
||||
inferred-metadata (lib/returned-columns mlv2-query)
|
||||
collection-id (when (:collection representation)
|
||||
(v0-common/lookup-id ref-index (:collection representation)))]
|
||||
(-> {: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
|
||||
:collection_id collection-id
|
||||
:result_metadata (when columns
|
||||
(columns->result-metadata columns inferred-metadata))}
|
||||
u/remove-nils)))
|
||||
|
||||
;;; -- 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) ;; put columns last
|
||||
u/remove-nils)))
|
||||
@@ -0,0 +1,46 @@
|
||||
(ns metabase-enterprise.representations.v0.question
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[metabase-enterprise.representations.lookup :as lookup]
|
||||
[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]))
|
||||
|
||||
(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)
|
||||
collection-id (when (:collection representation)
|
||||
(v0-common/lookup-id ref-index (:collection representation)))]
|
||||
(-> {:name (or (:display_name representation)
|
||||
(:name representation))
|
||||
:description (:description representation)
|
||||
:display (:display representation)
|
||||
:dataset_query query
|
||||
:database_id database-id
|
||||
:collection_id collection-id
|
||||
:query_type (if (lib/native-only-query? query) :native :query)
|
||||
:type :question}
|
||||
u/remove-nils)))
|
||||
|
||||
;; -- 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,51 @@
|
||||
(ns metabase-enterprise.representations.v0.snippet
|
||||
(:require
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.util :as u]))
|
||||
|
||||
(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]}
|
||||
_ref-index]
|
||||
(-> {:name (or display_name name)
|
||||
:description description
|
||||
:content sql
|
||||
:collection_id (v0-common/find-collection-id collection)
|
||||
:template_tags (lib/recognize-template-tags sql)}
|
||||
u/remove-nils))
|
||||
|
||||
(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,90 @@
|
||||
(ns metabase-enterprise.representations.v0.transform
|
||||
"The v0 transform representation namespace."
|
||||
(:require
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[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]
|
||||
[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."
|
||||
[representation
|
||||
ref-index]
|
||||
(let [query (v0-mbql/import-dataset-query representation ref-index)]
|
||||
(-> {: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))))
|
||||
|
||||
;; 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,33 @@
|
||||
(ns metabase-enterprise.representations.yaml
|
||||
"Centralized YAML utilities for representations with consistent formatting options."
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[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]
|
||||
(->> s
|
||||
yaml/parse-string
|
||||
(walk/postwalk (fn [node]
|
||||
(if (map? node)
|
||||
(into {} node)
|
||||
node)))))
|
||||
|
||||
(defn from-file
|
||||
"Read and parse YAML file to EDN data."
|
||||
[filename]
|
||||
(yaml/from-file filename))
|
||||
@@ -100,6 +100,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))))))))
|
||||
|
||||
(methodical/defmethod t2/batched-hydrate [:model/Transform :creator]
|
||||
"Add creator (user) to a transform"
|
||||
[_model _k transforms]
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
(ns metabase-enterprise.representations.export-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.core :as core]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.v0.common :as v0-common]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(defn- through-yaml [representation]
|
||||
(-> representation
|
||||
rep-yaml/generate-string
|
||||
rep-yaml/parse-string))
|
||||
|
||||
(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
|
||||
(let [rep (through-yaml (export/export-entity entity))]
|
||||
(is rep)
|
||||
(is (core/normalize-representation rep))))))
|
||||
|
||||
(deftest rename-refs-empty-test
|
||||
(is (= [] (export/rename-refs []
|
||||
export/ref-from-name
|
||||
export/standard-ref-strategies
|
||||
export/add-sequence-number))))
|
||||
|
||||
(deftest rename-refs-one-test
|
||||
(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-rename-test
|
||||
(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-rename-test
|
||||
(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,47 @@
|
||||
(ns metabase-enterprise.representations.import-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.representations.export :as export]
|
||||
[metabase-enterprise.representations.import :as import]
|
||||
[metabase-enterprise.representations.v0.common-test :as ct]
|
||||
[metabase-enterprise.representations.yaml :as rep-yaml]
|
||||
[metabase.collections.models.collection :as coll]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(defn- through-yaml [representation]
|
||||
(-> representation
|
||||
rep-yaml/generate-string
|
||||
rep-yaml/parse-string))
|
||||
|
||||
(deftest export-insert-all-entities
|
||||
(doseq [model [:model/Card :model/Transform :model/Collection :model/NativeQuerySnippet]
|
||||
entity (t2/select model)]
|
||||
(mt/with-test-user :crowberto
|
||||
(let [rep (export/export-entity entity)
|
||||
rep (through-yaml rep)
|
||||
instance (import/insert! rep (ct/->ParseRefEntityIndex))]
|
||||
(try
|
||||
(let [rep2 (export/export-entity instance)
|
||||
rep2 (through-yaml rep2)]
|
||||
(is (=? (dissoc rep :name :query)
|
||||
(dissoc rep2 :name :query))))
|
||||
(finally
|
||||
(t2/delete! model (:id instance))))))))
|
||||
|
||||
(deftest export-update-all-entities
|
||||
(doseq [model [:model/Card :model/Transform :model/Collection :model/NativeQuerySnippet]
|
||||
entity (t2/select model)]
|
||||
(when-not (and (= model :model/Collection)
|
||||
(coll/is-trash? entity))
|
||||
(mt/with-test-user :crowberto
|
||||
(let [rep (export/export-entity entity)
|
||||
rep (through-yaml rep)
|
||||
instance (import/update! rep (:id entity) (ct/->ParseRefEntityIndex))
|
||||
rep2 (export/export-entity instance)
|
||||
rep2 (through-yaml rep2)]
|
||||
(is (=? (dissoc rep :name :query)
|
||||
(dissoc rep2 :name :query))))))))
|
||||
@@ -0,0 +1,62 @@
|
||||
(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)))))))
|
||||
|
||||
(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,82 @@
|
||||
(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))
|
||||
|
||||
(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" (:display_name exported)))
|
||||
(is (= "A test collection" (:description exported)))))))
|
||||
|
||||
(deftest import-collection-test
|
||||
(testing "Collection can be imported from representation"
|
||||
(let [rep {:type :collection
|
||||
:version :v0
|
||||
:name "test-collection"
|
||||
:display_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)
|
||||
;; Persist and reload
|
||||
persisted (mt/with-test-user :crowberto
|
||||
(import/insert! normalized (v0-common/map-entity-index {})))
|
||||
export-2 (export/export-entity persisted)]
|
||||
(is (= (:display_name export-1) (:display_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)))))
|
||||
|
||||
(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))}]
|
||||
(mt/with-test-user :crowberto
|
||||
(let [exported (export/export-entire-collection (:id parent))
|
||||
ready (import/prepare-collection-tree-for-import exported)
|
||||
refs (import/insert-all! ready)]
|
||||
(is (= 2 (count ready)))
|
||||
(is refs)
|
||||
(let [[p c] (sort (keys refs))
|
||||
parent1 (get refs p)
|
||||
child1 (get refs c)]
|
||||
(is (= (str "/" (:id parent1) "/")
|
||||
(:location child1)))
|
||||
(is (= (:name parent) (:name parent1)))
|
||||
(is (= (:name child) (:name child1)))))))))
|
||||
@@ -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 (keyword 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,34 @@
|
||||
(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"
|
||||
(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)]
|
||||
(is (=? {:name (str "database-" (:id database)),
|
||||
:type :database,
|
||||
:version :v0,
|
||||
:engine "postgres",
|
||||
:display_name "abc",
|
||||
:connection_details {},
|
||||
:schemas []}
|
||||
rep))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Database)]
|
||||
(is (= :database (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,100 @@
|
||||
(ns metabase-enterprise.representations.v0.document-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 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,57 @@
|
||||
(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.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 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
|
||||
(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 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))
|
||||
instance (rep/insert! rep ref-index)]
|
||||
(try
|
||||
(is instance)
|
||||
(finally
|
||||
(t2/delete! :model/Card (:id instance))))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Card :type :metric)]
|
||||
(is (= :metric (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,57 @@
|
||||
(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))))))
|
||||
|
||||
(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 representation-type-test
|
||||
(doseq [entity (t2/select :model/Card :type :model)]
|
||||
(is (= :model (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,151 @@
|
||||
(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))})
|
||||
instance (rep/insert! rep ref-index)]
|
||||
(try
|
||||
(is instance)
|
||||
(finally
|
||||
(t2/delete! :model/Card (:id instance))))))
|
||||
|
||||
(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))))))
|
||||
|
||||
(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)]
|
||||
(try
|
||||
(let [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)))
|
||||
(finally
|
||||
(t2/delete! :model/Card (:id question)))))))))))
|
||||
|
||||
(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 [ref-index (v0-common/map-entity-index {db-ref database})
|
||||
persisted-question (rep/insert! question-rep ref-index)]
|
||||
(try
|
||||
(is persisted-question "Question should persist successfully")
|
||||
(is (= (:id database) (: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"))
|
||||
(finally
|
||||
(t2/delete! :model/Card (:id persisted-question))))))))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Card :type :question)]
|
||||
(is (= :question (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,227 @@
|
||||
(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}]
|
||||
(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/insert! representation (v0-common/map-entity-index {}))]
|
||||
(try
|
||||
(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-basic-test
|
||||
(testing "snippet with template tag is exported correctly"
|
||||
(run-snippet-export-import-test! "snippet one" "the first snippet" "foo = 1" {})))
|
||||
|
||||
(deftest snippet-export-import-variable-template-tag-test
|
||||
(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"}})))
|
||||
|
||||
(deftest snippet-export-import-card-template-tag-test
|
||||
(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")}]
|
||||
(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/insert! representation (v0-common/map-entity-index {}))]
|
||||
(try
|
||||
(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)))))))))
|
||||
|
||||
(deftest snippet-export-import-snippet-template-tag-test
|
||||
(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}}"}]
|
||||
(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/insert! representation (v0-common/map-entity-index {}))]
|
||||
(try
|
||||
(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))))))))
|
||||
|
||||
(deftest snippet-export-import-multiple-template-tags-test
|
||||
(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)")}]
|
||||
(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/insert! representation (v0-common/map-entity-index {}))]
|
||||
(try
|
||||
(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 (:id persisted-snippet)))))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/NativeQuerySnippet)]
|
||||
(is (= :snippet (v0-common/representation-type entity)))))
|
||||
@@ -0,0 +1,47 @@
|
||||
(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))))))))
|
||||
|
||||
(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))))))
|
||||
|
||||
(deftest representation-type-test
|
||||
(doseq [entity (t2/select :model/Transform)]
|
||||
(is (= :transform (v0-common/representation-type entity)))))
|
||||
@@ -975,7 +975,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]
|
||||
@@ -419,6 +420,10 @@
|
||||
update-parameter-target-dimension-options]
|
||||
[lib.parse
|
||||
parse]
|
||||
[lib.portable
|
||||
export-walk
|
||||
import-walk
|
||||
QueryVisitor]
|
||||
[lib.query
|
||||
->query
|
||||
can-preview
|
||||
|
||||
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 QueryVisitor 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