Compare commits

...

333 Commits

Author SHA1 Message Date
Eric Normand
b584b35cd9 Better tests 2025-12-01 15:19:51 -06:00
Eric Normand
a36cda7ffd Merge branch 'master' into representations 2025-12-01 15:19:00 -06:00
Eric Normand
151f143233 Whitespace 2025-12-01 13:42:15 -06:00
Eric Normand
e440239b79 Move question test 2025-12-01 13:40:24 -06:00
Eric Normand
0f41ae3925 Remove stray whitespace 2025-12-01 13:35:25 -06:00
Eric Normand
d4344c308e Use representations build from clojars 2025-12-01 13:33:17 -06:00
Eric Normand
ae23f598f0 Remove special representations workflow 2025-11-25 15:01:42 -06:00
Eric Normand
2d6624319a Test collection tree import/export 2025-11-25 09:43:28 -06:00
Eric Normand
f51192d3db Collection tests! 2025-11-24 14:33:43 -06:00
Eric Normand
23754f9e95 Tests pass, kondo passes, cljfmt done 2025-11-24 14:18:16 -06:00
Eric Normand
ae65686d30 Fix tests 2025-11-21 15:49:57 -06:00
Eric Normand
f26f6a18cd core test pass 2025-11-21 15:25:43 -06:00
Eric Normand
4b33138799 Better database tests 2025-11-21 15:22:03 -06:00
Eric Normand
d3bad99de1 Whitespace 2025-11-21 15:21:51 -06:00
Eric Normand
a576039ae0 Remove persist! 2025-11-21 14:48:18 -06:00
Eric Normand
619e2fa17e Remove non-existent imports 2025-11-21 14:39:47 -06:00
Eric Normand
9116c09afe Get rid of frontend changes 2025-11-21 14:38:58 -06:00
Eric Normand
47b0340836 Merge branch 'master' into representations 2025-11-21 14:23:32 -06:00
Eric Normand
eda624973b Remove old stuff and simplify core 2025-11-21 14:21:48 -06:00
Eric Normand
0c0634dbc1 Module deps for kondo 2025-11-21 13:54:13 -06:00
Eric Normand
8a1139118f Remove transform-specific import 2025-11-21 13:53:57 -06:00
Eric Normand
8bb448b8fc People keep moving namespaces 2025-11-21 13:53:46 -06:00
Eric Normand
99e16ef825 Fixups after merge 2025-11-21 13:53:25 -06:00
Eric Normand
8b44711794 Don't throw if not found 2025-11-21 13:52:57 -06:00
Eric Normand
c35be5ea97 Remove unused transforms export 2025-11-21 13:52:43 -06:00
Eric Normand
04f1e58c3a Delete some unused files. 2025-11-21 13:50:55 -06:00
Eric Normand
20657a3b0b Remove dependencies "integration" (unused) 2025-11-19 11:34:10 -06:00
Eric Normand
174a118a6c Claude fixed the kondo warnings 2025-11-19 11:23:53 -06:00
Eric Normand
4d6693adae Typo 2025-11-19 10:27:24 -06:00
Eric Normand
91d2191c93 Merge branch 'master' into representations 2025-11-19 10:17:57 -06:00
Riley Thompson
86dcae0d73 clean up pulse ui 2025-11-13 19:07:01 -05:00
Riley Thompson
ea48c27d72 use refs with pulses 2025-11-13 19:02:23 -05:00
Riley Thompson
13cb16df11 initial pulse impl 2025-11-13 16:57:35 -05:00
Riley Thompson
89e585ac23 finish timeline tests 2025-11-13 15:30:05 -05:00
Riley Thompson
7c8078bd09 Merge branch 'master' into representations 2025-11-10 18:13:21 -05:00
Riley Thompson
8e37784337 update timelines 2025-11-10 17:52:06 -05:00
Riley Thompson
b0443a4805 timeline impl 2025-11-10 17:13:20 -05:00
Eric Normand
ddcc13edd1 Simplify portable query visitor 2025-11-07 16:53:26 -06:00
Eric Normand
38a5d7b604 Simplify portable query visitor protocol 2025-11-07 16:49:36 -06:00
Eric Normand
938600d341 Fix mbql tests 2025-11-07 11:17:18 -06:00
Eric Normand
1469864d71 Question tests pass again 2025-11-07 11:07:20 -06:00
Eric Normand
777980d11e Forgot the new file 2025-11-07 10:40:52 -06:00
Eric Normand
0a63009b9e lib.portable and representations integration 2025-11-07 10:37:56 -06:00
Eric Normand
609a58ad6d Tiny cleanup 2025-11-04 15:48:39 -06:00
Eric Normand
e202541ac8 Don't double ref: 2025-11-04 15:27:12 -06:00
Eric Normand
7e54e25766 Better 2025-11-04 15:09:27 -06:00
Eric Normand
9b903edd5e document 2025-11-04 14:33:50 -06:00
Eric Normand
ea1cdf4917 Checkpoint 2025-11-04 10:35:49 -06:00
Eric Normand
c58e26c8e2 Add resolve to export 2025-11-03 17:28:03 -06:00
Timothy Dean
99d1bd8cdf Use lookup/lookup-database-id in more places 2025-11-03 14:42:52 -07:00
Timothy Dean
57999716ce Playing with model metadata 2025-11-03 14:05:31 -07:00
Eric Normand
57f64aded5 Hook rename refs into api 2025-11-03 13:34:16 -06:00
Eric Normand
8d204c712e Whitespace 2025-11-03 13:28:25 -06:00
Eric Normand
2cbece56c3 Nice set of name tests with a munge-name implementation 2025-11-03 13:01:15 -06:00
Timothy Dean
c8c515462a Fix bad var reference 2025-11-03 10:53:40 -07:00
Timothy Dean
4ed9e32676 Delete unused and stale yml files 2025-10-31 16:48:46 -06:00
Timothy Dean
3a02474632 Delete bin/* scripts 2025-10-31 16:44:03 -06:00
Timothy Dean
20875923d7 Delete example-manifest.yml 2025-10-31 16:36:36 -06:00
Timothy Dean
2b2cd76b2f Delete ./representation babashka stuff 2025-10-31 16:35:28 -06:00
Eric Normand
6a78c448cf Oops bad merge 2025-10-31 16:24:46 -05:00
Eric Normand
81fa467312 Export ref renaming 2025-10-31 16:18:40 -05:00
Eric Normand
7b604076bc move more stuff for order-reprsentations 2025-10-31 16:18:00 -05:00
Timothy Dean
8f8b2b762a Bugfixes in insert! and update! 2025-10-31 14:24:33 -06:00
Timothy Dean
db2d21ef20 Fix v0.common-test errors 2025-10-31 14:19:49 -06:00
Timothy Dean
c2a163701b Step 3, multimethods gone and circular dependencies resolved. 2025-10-31 14:16:53 -06:00
Timothy Dean
2a9df86ff0 Step 2 of cleanup, removing import dependency from v0.* representation nses 2025-10-31 13:45:31 -06:00
Timothy Dean
b2502af083 Step 1 of a series of big cleanup commits 2025-10-31 13:35:06 -06:00
Eric Normand
4cd66a1b6e move order-representations into v0-common 2025-10-31 11:37:48 -05:00
Eric Normand
eb9a0acb6f Oops 2025-10-31 10:22:09 -05:00
Timothy Dean
bc6f659963 m. Remove ref: initial from workflows importing metabase/representations 2025-10-30 16:09:09 -06:00
Timothy Dean
93fe76a5c6 More temporary stuff, getting verify-i18n-files running 2025-10-30 16:06:35 -06:00
Timothy Dean
993672c878 Merge remote-tracking branch 'origin/master' into representations 2025-10-30 15:58:00 -06:00
Timothy Dean
2421a24f31 More temporary boilerplate 2025-10-30 15:57:41 -06:00
Timothy Dean
2688cf4cfb Build artifact with metabase/representations 2025-10-30 15:53:20 -06:00
Timothy Dean
4ac312e62c Missed a file 2025-10-30 15:50:16 -06:00
Timothy Dean
32a67d3633 Experimental. Delete copy of metabase/representations, use PAT to check out private repo 2025-10-30 15:46:02 -06:00
Eric Normand
e343d5ebbe Default db. Fix insert/update. Default to name if display_name is not avilable. 2025-10-30 14:49:36 -05:00
Eric Normand
49020716b5 Multify insert! and update! 2025-10-30 13:38:03 -05:00
Timothy Dean
e406c0c35c Merge remote-tracking branch 'origin/master' into representations 2025-10-29 17:10:05 -06:00
Timothy Dean
3cd8c2f2cd Fix core-test 2025-10-29 17:02:35 -06:00
Timothy Dean
59f53c16db The big switchover to metabase/representations for schemas 2025-10-29 16:57:55 -06:00
Timothy Dean
cac18c4db2 Copy in metabase/representations 2025-10-29 16:56:34 -06:00
Timothy Dean
dde1cd6b95 Copy from metabase/representations 2025-10-29 15:19:08 -06:00
Timothy Dean
e4b0d120e5 Copy 2b8f5d6 from metabase/representations 2025-10-29 14:31:00 -06:00
Eric Normand
56fac7fa11 Transforms order and better export/import 2025-10-29 15:23:27 -05:00
Eric Normand
6826b70279 Import test 2025-10-29 14:01:13 -05:00
Eric Normand
dae088f934 Databases maintain key order 2025-10-29 12:37:59 -05:00
Eric Normand
983bf8b002 Maintain model key order 2025-10-29 12:35:22 -05:00
Eric Normand
709a494ad4 :ref->:name in export 2025-10-29 12:32:20 -05:00
Eric Normand
29a5c1d804 Metric maintains order of keys 2025-10-29 12:32:05 -05:00
Eric Normand
d2a4f43bfa Maintain order of question keys 2025-10-29 12:31:54 -05:00
Eric Normand
9f04d4525c Docstring 2025-10-29 12:17:00 -05:00
Timothy Dean
6a98142d10 name -> display_name, ref -> name 2025-10-29 11:16:04 -06:00
Eric Normand
6e075b3fee MBQL5 fields appear to work 2025-10-29 12:04:58 -05:00
Eric Normand
f4f2fae807 Also use database: in referred to databases 2025-10-29 11:57:25 -05:00
Eric Normand
4b1783d724 ALways use query key 2025-10-29 11:57:02 -05:00
Eric Normand
9bb863e36f Reduce tables on export 2025-10-29 11:00:19 -05:00
Eric Normand
18b558507c Reduce database tables on export 2025-10-29 10:52:54 -05:00
Eric Normand
60af228ce8 Don't convert to markdown for now 2025-10-29 10:52:38 -05:00
Eric Normand
4ebe289c9e Represetnations button on database admin screen 2025-10-29 10:27:37 -05:00
Eric Normand
b96378dd1d Try deps free version 2025-10-29 09:57:35 -05:00
Eric Normand
80a1ee7544 Change to tiptap 2025-10-29 09:51:35 -05:00
Eric Normand
190b660861 Refs for documents 2025-10-28 16:39:25 -05:00
Eric Normand
69a0182908 Can you handle the whitespace? 2025-10-28 16:13:20 -05:00
Eric Normand
096c943179 Better document export 2025-10-28 15:53:53 -05:00
Eric Normand
3250dbbe3b Document prosemirror to markdown 2025-10-28 15:53:53 -05:00
Timothy Dean
eb27ad00a2 Copy changes from metabase/representations 2025-10-28 12:03:41 -06:00
Timothy Dean
e5d50e6e41 Add :local/root to deps.edn for copy of metabase/representations 2025-10-28 11:33:39 -06:00
Timothy Dean
e876cc59ff Copy in metabase/representations 2025-10-28 11:19:52 -06:00
Timothy Dean
04f22bce1c Revert "Attempt to bring in metabase/representations as submodule"
This reverts commit 38edca362b.
2025-10-28 11:12:19 -06:00
Timothy Dean
fe38c6dbfb Revert "Maybe github CI can use submodules: true"
This reverts commit ab8d8e1a85.
2025-10-28 11:12:05 -06:00
Timothy Dean
ab8d8e1a85 Maybe github CI can use submodules: true 2025-10-28 11:06:40 -06:00
Timothy Dean
38edca362b Attempt to bring in metabase/representations as submodule 2025-10-28 11:00:11 -06:00
Eric Normand
481d5c7dd7 Fix card question 2025-10-28 11:47:55 -05:00
Eric Normand
bd407a7a23 Use native-only-query? 2025-10-28 11:17:34 -05:00
Eric Normand
81d235e1f9 Docstring 2025-10-28 10:35:48 -05:00
Eric Normand
a6e8e815e1 Correct env var problem 2025-10-28 10:34:21 -05:00
Eric Normand
20bc080cbe Source cards and native queris 2025-10-28 10:34:21 -05:00
Timothy Dean
9489190f9b Merge remote-tracking branch 'origin/master' into representations 2025-10-28 09:21:47 -06:00
Timothy Dean
481b92c289 Remove whitespace 2025-10-28 09:21:03 -06:00
Timothy Dean
adcc8e3e5c Merge remote-tracking branch 'origin/master' into representations 2025-10-28 09:17:36 -06:00
Eric Normand
d9fff0a2e7 Documents and collections representations 2025-10-27 18:38:41 -05:00
Eric Normand
ebe192cc51 Yes, transforms too. 2025-10-27 18:10:05 -05:00
Eric Normand
fbe0f1a83b Better UI, more cards 2025-10-27 18:10:05 -05:00
Timothy Dean
5f100521aa More use of lookup-database-id 2025-10-27 15:59:37 -06:00
Timothy Dean
dcc2e5b777 disable test, add comments 2025-10-27 14:58:26 -06:00
Timothy Dean
3152844182 Transforms code is stale and broken 2025-10-27 14:52:32 -06:00
Timothy Dean
6cd4d53aaa Fix typo in snippet-test and add uses of with-toucan-defaults 2025-10-27 14:42:53 -06:00
Timothy Dean
de157d98f1 Use v0-common/map-entity-index in more places 2025-10-27 14:37:16 -06:00
Timothy Dean
64a914bb6c Getting tests up to date 2025-10-27 14:22:59 -06:00
Timothy Dean
b392c931ef Delete stale test 2025-10-27 14:13:27 -06:00
Timothy Dean
57dce27409 Fix bug in new lookup-id impl for maps, update common-test 2025-10-27 14:10:05 -06:00
Timothy Dean
7bbf5f082e Update stale reference to old with-toucan-defaults 2025-10-27 14:00:25 -06:00
Timothy Dean
6a90502eaa Fix CI issue: create lookup/lookup-database-id 2025-10-27 13:51:02 -06:00
Timothy Dean
be07b8fe0f Remove metabase/representations dependency so CI can run 2025-10-27 13:41:19 -06:00
Timothy Dean
802765a6b4 Fix with-toucan-defaults, add resolve-database-id, get more tests passing 2025-10-27 13:40:28 -06:00
Eric Normand
8e4d85d37b Get a modal. It works. Could be better. 2025-10-27 14:12:20 -05:00
Timothy Dean
ae17a6f937 Fix compilation error in representations.export 2025-10-27 11:41:01 -06:00
Timothy Dean
cafc5540c0 Add metabase/representations dependency 2025-10-27 11:41:01 -06:00
Eric Normand
ccc6c717d2 Missing yamls 2025-10-27 12:26:17 -05:00
Timothy Dean
944cefd0b5 Merge remote-tracking branch 'origin/master' into representations 2025-10-27 11:05:48 -06:00
Eric Normand
8bd2e87737 WIP document 2025-10-24 14:25:59 -05:00
Eric Normand
9927a4abcb Add with-toucan-defaults 2025-10-23 14:37:20 -05:00
Eric Normand
b087de7b13 Guard against infinite loops in order-representations 2025-10-23 12:23:37 -05:00
Eric Normand
3760109621 Robust tests against faulty cycle detection 2025-10-22 16:59:44 -05:00
Eric Normand
c44514f19e Cleanup and test 2025-10-22 16:50:44 -05:00
Eric Normand
d197607055 Add a first implementation of a RefIndex 2025-10-22 15:32:37 -05:00
Eric Normand
5b4b835a45 Move representation-type to common 2025-10-22 15:17:09 -05:00
Eric Normand
9cb6173c3f Cycle detection in order-representations 2025-10-22 14:44:28 -05:00
Eric Normand
04fb2f80b8 Cleanup 2025-10-22 12:47:34 -05:00
Eric Normand
434e4c77b4 Whitespace 2025-10-22 12:35:46 -05:00
Eric Normand
fa86a2f729 Convert type->model to multimethod 2025-10-22 12:35:22 -05:00
Eric Normand
3e8cc7d241 Add docstrings in lookup 2025-10-22 12:01:12 -05:00
Eric Normand
9672377eff Add some todos for migration in comments 2025-10-22 12:00:52 -05:00
Eric Normand
2d25ff6801 More testing and polishing 2025-10-20 15:10:03 -05:00
Eric Normand
23c32a9679 More lookup functions 2025-10-20 13:37:11 -05:00
Eric Normand
192a3f8cab Good tests for exporting based on types 2025-10-20 13:21:01 -05:00
Eric Normand
d48e2e167f Test lookup and type conversion 2025-10-20 13:07:07 -05:00
Eric Normand
0fc449bb3f First pass at better import (WIP) 2025-10-14 13:55:33 +02:00
Eric Normand
0ccfcf9368 At leasts the tests pass 2025-10-10 23:06:49 -05:00
Eric Normand
f920a0ad6b Work with lib in transforms??? 2025-10-10 22:59:52 -05:00
Eric Normand
ad03eb5e71 Fix transform tests 2025-10-10 22:54:28 -05:00
Eric Normand
968ab66658 Get lib questions working (with tests) 2025-10-10 22:41:56 -05:00
Timothy Dean
531c0a2ec0 Tweaking mbql.clj 2025-10-10 13:46:38 -06:00
Timothy Dean
361b089ae6 Fix nesting bug in mbql.clj, re-enable a models test 2025-10-10 13:13:50 -06:00
Timothy Dean
f868deee6b Models and their metadata working much better 2025-10-10 12:55:18 -06:00
Timothy Dean
e24dd51f77 Land bugfixes in flow we're not using any longer, but it's related to the collections.yml ticket 2025-10-10 12:54:41 -06:00
Eric Normand
a8c3bf86bf Missed a reference 2025-10-10 12:31:11 -05:00
Eric Normand
f77ee2c518 Oops cyclical dep 2025-10-10 12:27:42 -05:00
Eric Normand
126768e0b4 Cleanup mbql stuff 2025-10-10 12:27:08 -05:00
Riley Thompson
a0088a70e2 Merge branch 'representations' of https://github.com/metabase/metabase into representations 2025-10-10 13:24:43 -04:00
Riley Thompson
64860019bc add snippet import tests 2025-10-10 13:24:33 -04:00
Eric Normand
b899d8cd97 Update test 2025-10-10 11:23:54 -05:00
Eric Normand
fc0f79e339 Export only schemas and tables that are referred to 2025-10-10 11:22:55 -05:00
Riley Thompson
60050841a2 snippet export test 2025-10-09 18:35:32 -04:00
Eric Normand
bc8d0d0423 Metrics import/export tests 2025-10-09 15:02:19 -05:00
Riley Thompson
75de8e0572 remove static serve code from 3312f91 and 89bf939 2025-10-09 15:21:57 -04:00
Riley Thompson
9b1198ce7b remove frontend code 2025-10-09 14:50:58 -04:00
Riley Thompson
f1cb9c64fd use refs directory for db representations 2025-10-09 11:43:48 -04:00
Riley Thompson
80af50a637 replace content with sql in snippet representation 2025-10-09 11:29:22 -04:00
Riley Thompson
0ac69e8f89 Merge branch 'representations' of https://github.com/metabase/metabase into representations 2025-10-08 16:33:14 -04:00
Riley Thompson
2b3a76cf5b use lib native to build template tags 2025-10-08 16:33:09 -04:00
Eric Normand
a4acab9eff Dependency check API and beginnings of import 2025-10-08 13:32:22 -05:00
Riley Thompson
8dd09eaf20 Merge branch 'representations' of https://github.com/metabase/metabase into representations 2025-10-08 14:06:53 -04:00
Riley Thompson
085b060c4c add template tags to sql snippetes yaml->toucan 2025-10-08 14:06:43 -04:00
Timothy Dean
fb34fc1654 babashka import: manually fix a few issues, but the code organization is pretty bad 2025-10-08 11:00:46 -06:00
Riley Thompson
ca9f37c05f replace versioned :type key with separate :version and :type keys 2025-10-08 12:16:47 -04:00
Timothy Dean
2ebdcb6139 Init commit for representation.bb 2025-10-07 19:00:23 -06:00
Riley Thompson
1300e4fe2d Merge branch 'representations' of https://github.com/metabase/metabase into representations 2025-10-07 18:30:55 -04:00
Riley Thompson
ee5c71754a sql snippet representations 2025-10-07 18:30:49 -04:00
Eric Normand
e0790dda10 Structural change for better recursion 2025-10-07 16:21:41 -05:00
Eric Normand
ca30b7d361 Export entire collection (API) 2025-10-07 16:18:08 -05:00
Eric Normand
6697c05014 Can check question dependencies 2025-10-07 15:39:54 -05:00
Eric Normand
56e70074d0 Merge branch 'representations' of github.com:metabase/metabase into representations 2025-10-07 12:15:01 -05:00
Eric Normand
7e0b5e85cd Don't use ref/ namespace 2025-10-07 12:14:34 -05:00
Timothy Dean
e7154969aa Delete a ton of code and experimental feature work. 2025-10-07 10:46:25 -06:00
Timothy Dean
471e76be64 Whitespace 2025-10-06 13:01:42 -06:00
Timothy Dean
25908d5f96 Fix core-test 2025-10-06 12:53:41 -06:00
Timothy Dean
42f5d6cc13 Merge remote-tracking branch 'origin/master' into representations 2025-10-06 12:44:46 -06:00
Timothy Dean
e3a9edcb7a Fix question-test 2025-10-06 12:41:56 -06:00
Timothy Dean
cf1c863b2b Fix YAML dumper-options, filenames, misc. 2025-10-06 12:19:26 -06:00
Timothy Dean
f7db036c73 Add latest export examples 2025-10-06 12:18:09 -06:00
Timothy Dean
49cb9edfa7 Fix topological sort for base64-encoded mbql 2025-10-06 11:10:36 -06:00
Timothy Dean
0fd583ee05 Non-cleaned-up, but new tests and metadata overrides for models. Mix of hand and vibe code, cleanup needed 2025-10-04 00:57:43 -06:00
Timothy Dean
a33fee2435 MBQL separated as base64-encoded blob 2025-10-03 23:33:53 -06:00
Timothy Dean
de107e5639 Add forgotten file. 2025-10-03 17:20:59 -06:00
Timothy Dean
339ce9b24c export-import-with-refs-test 2025-10-03 17:12:09 -06:00
Timothy Dean
07b6dcd9ea export-with-refs and export-with-ids 2025-10-03 16:56:57 -06:00
Timothy Dean
560a3b67f0 Cleanup, about to refactor to allow bimodal exports 2025-10-03 15:39:08 -06:00
Eric Normand
d8ae013339 Ooops! 2025-10-03 15:58:36 -05:00
Eric Normand
ad06c09272 Transforms work! 2025-10-03 15:35:38 -05:00
Timothy Dean
b0d38931d0 Some fixes, mostly around models 2025-10-03 12:15:50 -06:00
Eric Normand
30f997054b Make question tests pass 2025-10-01 16:51:44 -05:00
Eric Normand
bc73878e2a Fix kondo and remove bad docstring 2025-10-01 14:07:41 -05:00
Eric Normand
2bdaa1ca68 Make export-set take a collection of reps 2025-10-01 14:07:23 -05:00
Eric Normand
949633567d Whitespace in cond 2025-10-01 14:06:57 -05:00
Eric Normand
ee68edecae Fix question test 2025-10-01 14:06:45 -05:00
Eric Normand
6b055d5133 Use u/remove-nils instead of custom one 2025-10-01 13:36:55 -05:00
Timothy Dean
c39e42692c Fix circular dependencies 2025-09-30 23:55:11 -06:00
Timothy Dean
34dc6533b2 Cleaning up a little, and resolving 104 kondo warnings 2025-09-30 22:52:08 -06:00
Timothy Dean
46bf926ee3 Get import/export buttons working more nicely (for database.yml and question.yml only) 2025-09-30 16:13:12 -06:00
Riley Thompson
54389ff03a fix collection script 2025-09-30 17:10:01 -04:00
Riley Thompson
13a76e983e Merge branch 'master' into representations 2025-09-30 17:04:15 -04:00
Riley Thompson
34b84f3151 download and upload transform represenations 2025-09-30 17:02:04 -04:00
Timothy Dean
d65e9c8ec4 Merge duplicated work, get batch-imports working for at least questions and databases.
Eric and I duplicated some work. This merges our efforts. This also builds in ref patching on import and export and provides an `import-dir!` method that #worksonmymachine.

Co-authored-by: Eric Normand <ericwnormand@gmail.com>
2025-09-29 14:23:59 -06:00
Timothy Dean
29263dad18 Fix circular dependency, clean up some code 2025-09-26 15:15:13 -06:00
Eric Normand
b70431b8f6 Match existing database by name or do nothing. 2025-09-26 12:47:46 -05:00
Eric Normand
57c7b6e15c Database api and better yaml format 2025-09-26 11:45:03 -05:00
Eric Normand
9b0a44d385 Update database export
Env vars for passwords and other secure details
2025-09-26 11:41:56 -05:00
Eric Normand
c0bec3f32e Remove question 2025-09-26 11:41:38 -05:00
Eric Normand
2510bae3c2 Tests 2025-09-26 11:09:46 -05:00
Eric Normand
1dad0e5c1e Import ordering 2025-09-25 15:25:27 -05:00
Eric Normand
a9be96bbd2 Merge branch 'representations' of github.com:metabase/metabase into representations 2025-09-25 13:23:18 -05:00
Eric Normand
1625842d80 Whitespace 2025-09-25 13:21:02 -05:00
Eric Normand
9af2a858cb Export databases. 2025-09-25 13:19:18 -05:00
Eric Normand
942fcbd2d0 Remove prints 2025-09-25 13:17:56 -05:00
Timothy Dean
67566c475b m. 2025-09-24 17:58:23 -06:00
Timothy Dean
e1bf8c6582 Basic database export functionality 2025-09-24 17:56:06 -06:00
Timothy Dean
069d773c94 Make collection-8 test_resources dir mirroring /collection/8-serialization-v0 2025-09-24 15:47:25 -06:00
Eric Normand
82029ecfbb We're not ready for import-export 2025-09-24 14:26:24 -05:00
Riley Thompson
7070fac7a6 add frontend 2025-09-24 14:15:42 -04:00
Timothy Dean
2653b9d303 Merge remote-tracking branch 'origin/master' into representations 2025-09-24 11:39:14 -06:00
Riley Thompson
addf6fda01 export and import collection representation endpoints 2025-09-24 13:00:51 -04:00
Timothy Dean
4a2651851b representations.core: remove unnecessary decoder code 2025-09-24 10:34:09 -06:00
Timothy Dean
2f80bab68f Fix transform schema 2025-09-24 10:21:03 -06:00
Timothy Dean
a136a66af7 Just delete run-trigger 2025-09-23 17:02:05 -06:00
Timothy Dean
aab38f089a Transform improvements 2025-09-23 16:51:09 -06:00
Eric Normand
8dd1b8c4cc import/export tests for questions 2025-09-23 16:02:37 -05:00
Eric Normand
d8e3b30a83 Add transforms export 2025-09-23 14:35:42 -05:00
Eric Normand
6b9a0c0277 Add roundtrip tests 2025-09-23 14:31:12 -05:00
Timothy Dean
774cc91a4e Transforms schema tweaks, ingestion. Persist method not well tested 2025-09-23 10:22:30 -06:00
Eric Normand
0d185aa777 Export metrics 2025-09-23 10:10:22 -05:00
Eric Normand
4434205bc0 Add model test 2025-09-23 09:34:19 -05:00
Timothy Dean
9fc8a2724d Add validate-representation script 2025-09-22 14:05:04 -06:00
Timothy Dean
94a5098f74 Add load-representation script 2025-09-22 13:44:14 -06:00
Timothy Dean
5c788efbe1 Questions and models from serialization-v0 collection 2025-09-22 12:47:58 -06:00
Eric Normand
dc9bbc95d5 More question tests 2025-09-22 11:39:47 -05:00
Eric Normand
29e408bf72 Merge branch 'master' into representations 2025-09-22 10:06:21 -05:00
Timothy Dean
9398a2f092 Push experimental model yml file 2025-09-20 01:45:06 -05:00
Timothy Dean
bed66ab743 Fix yarn dev 2025-09-20 01:39:08 -05:00
Timothy Dean
ed931e2404 Playing with model metadata 2025-09-20 01:38:13 -05:00
Timothy Dean
afa3d6d70c Fix the /load endpoint 2025-09-20 01:37:15 -05:00
dan sutton
d616d269f9 fixup tests
need db to run db tests. and validation checks against schema
2025-09-19 17:36:31 -05:00
Eric Normand
ea1ef9c283 Comment 2025-09-19 16:57:54 -05:00
Eric Normand
639fcf2a3e Add question tests 2025-09-19 16:54:31 -05:00
dan sutton
89bf939860 referencing questions in static assets
```
❯ tree /tmp/behold
/tmp/behold
├── c-1-card196164.card.yml
├── c-2-card196324.card.yml
├── sub-collection
│   └── c-3-card12345.card.yml
└── with-references
    ├── question-122.yml
    └── question-125.yml

3 directories, 5 files
```

the with-references directory is as follows:

```yaml
name: kondo_lints_question
type: question
ref: question-122
mbql_query:
  type: query
  database: pg
  query:
    source-table:
    - pg
    - public
    - kondo_lints
    fields:
    - - field
      - - pg
        - public
        - kondo_lints
        - id
      - base-type: type/Integer
    - - field
      - - pg
        - public
        - kondo_lints
        - filename
      - base-type: type/Text
    - - field
      - - pg
        - public
        - kondo_lints
        - line
      - base-type: type/Integer
    - - field
      - - pg
        - public
        - kondo_lints
        - status
      - base-type: type/Text
    - - field
      - - pg
        - public
        - kondo_lints
        - error
      - base-type: type/Text
    - - field
      - - pg
        - public
        - kondo_lints
        - category
      - base-type: type/Text
database: pg
```

and 125:

```yaml
name: kondo_lints_question, Filtered by Status is error
type: question
ref: question-125
mbql_query:
  database: pg
  type: query
  query:
    source-table:
      ref: question-122
    filter:
    - =
    - - field
      - status
      - base-type: type/Text
    - error
database: pg
```

It _references_ another static, non-app-db question

```clojure
core=> (:dataset_query (fetch :question -5))
{:database 3,
 :type "query",
 :query {:source-table "card__-4", ;; <----- card -4
         :filter ["="
                  ["field" "status" {:base-type "type/Text"}]
                  "error"]}}
core=> (->> (qp/process-query (:dataset_query (fetch :question -5)))
            :data :rows (take 2))
([12933
  "shared/src/metabase/mbql/normalize.cljc"
  594
  "error"
  "Recur can only be used in tail position."
  "shared/src/metabase/mbql/normalize.cljc"]
 [12934
  "shared/src/metabase/mbql/normalize.cljc"
  594
  "error"
  "Unresolved symbol"
  "shared/src/metabase/mbql/normalize.cljc"])
```
2025-09-19 16:38:05 -05:00
Eric Normand
c635152b92 Fixes 2025-09-19 15:50:30 -05:00
Eric Normand
7e8d562358 Move export functions 2025-09-19 14:25:21 -05:00
Eric Normand
320aab30bc whitespace 2025-09-19 14:25:16 -05:00
Eric Normand
968cf8c336 Add a tiny test 2025-09-19 13:37:15 -05:00
Eric Normand
f4f22cb733 Revert all disablings 2025-09-19 13:33:30 -05:00
Timothy Dean
48239abdd3 Add an MBQL model.yml file 2025-09-19 12:24:33 -06:00
Eric Normand
2c40e5f217 kick ci 2025-09-19 13:22:56 -05:00
Eric Normand
38550465eb disable frontend tests? 2025-09-19 13:20:19 -05:00
Eric Normand
8f73866c87 Disable frontend tests? 2025-09-19 13:19:20 -05:00
Eric Normand
d1625d7384 Skip frontend? 2025-09-19 13:18:11 -05:00
Eric Normand
e22c93ed4c try skip 2025-09-19 13:11:23 -05:00
Eric Normand
a229d08410 remove stuff from run-tests.yml 2025-09-19 13:07:49 -05:00
Eric Normand
35aec6c7cb Revert "Delete e2e tests files"
This reverts commit 58c4abdd10.
2025-09-19 13:05:51 -05:00
Eric Normand
58c4abdd10 Delete e2e tests files 2025-09-19 13:04:02 -05:00
Eric Normand
08e9272440 Get representation tests to run???? 2025-09-19 12:51:38 -05:00
Eric Normand
5c9303e6e3 Attempt to disable tests 2025-09-19 12:49:47 -05:00
Eric Normand
554c6388b3 Remove more tests 2025-09-19 12:41:13 -05:00
Eric Normand
f21d0de52e Disable backend test runs 2025-09-19 12:11:19 -05:00
Eric Normand
8dc61e4eb0 Disable more tests 2025-09-19 12:08:10 -05:00
Timothy Dean
270fcf4299 Reorganize code again based on huddle discussion 2025-09-19 11:06:43 -06:00
Eric Normand
77cfe40954 kick ci 2025-09-19 11:58:19 -05:00
Eric Normand
6c4f743f38 Attempt: Disable driver tests and add representation tests runner 2025-09-19 11:56:39 -05:00
Eric Normand
65a5a67624 wip 2025-09-19 11:06:15 -05:00
Timothy Dean
abaac8c3f7 m. Rename 2025-09-19 09:20:07 -06:00
Timothy Dean
e1406025a9 Provide a wrapper for yaml->toucan operations 2025-09-19 09:18:41 -06:00
dan sutton
3312f915ee Static serving
given

```
❯ tree /tmp/swaperoo
/tmp/swaperoo
├── c-1-card196164.card.yml
├── c-2-card196324.card.yml
└── sub-collection
    └── c-3-card12345.card.yml

2 directories, 3 files
```

the running

```
clj -M:dev:ee:ee-dev:drivers:drivers-dev:socket -m metabase.core.bootstrap static --directory /tmp/swaperoo
```

Will serve the assets from /tmp/swaperoo.
It does this by loading them in MEMORY and assigning them sequential
negative ids. These are not loaded into the app-db.

```
❯ tree /tmp/swaperoo
/tmp/swaperoo  (collection -1: swaperoo)
├── c-1-card196164.card.yml (card -1)
├── c-2-card196324.card.yml (card -2)
└── sub-collection (collection -2: sub-collection)
    └── c-3-card12345.card.yml (card -3)

```
2025-09-19 09:32:02 -05:00
Timothy Dean
3809c909f0 Create an invalid example yml file 2025-09-18 16:44:09 -06:00
Timothy Dean
388f208bb2 Tweak model.yml 2025-09-18 16:28:12 -06:00
Eric Normand
55ef1e8392 card->question 2025-09-18 17:14:56 -05:00
Eric Normand
576535eaf7 dataset->model 2025-09-18 17:09:20 -05:00
Timothy Dean
78e3b5d282 Wire up model ingestion 2025-09-18 15:50:51 -06:00
Timothy Dean
ff96dabfcf Slightly improved /validate endpoint 2025-09-18 15:35:02 -06:00
Timothy Dean
3dece4143a WIP model ingestion, not wired up yet 2025-09-18 15:28:52 -06:00
Eric Normand
092674a6b8 string instead of keyword 2025-09-18 16:22:21 -05:00
dan sutton
4d46a3beb4 clean up representation -> card in memory representation 2025-09-18 16:21:33 -05:00
Eric Normand
edccda93bf Add bb script to export a collection 2025-09-18 16:18:05 -05:00
Timothy Dean
2d10da8267 Stop hardcoding :native on question ingestion 2025-09-18 15:11:16 -06:00
Timothy Dean
64d22caf90 Move creator-id and entity-id determination into representation->question-data 2025-09-18 15:00:24 -06:00
Eric Normand
c09d983f2c Add collection endpoint 2025-09-18 15:50:49 -05:00
Eric Normand
503c35172d Merge branch 'master' into representations 2025-09-18 15:02:12 -05:00
Eric Normand
57906d9ddc kick ci 2025-09-18 14:59:28 -05:00
Eric Normand
0e2fdcdfef Merge branch 'representations' of github.com:metabase/metabase into representations 2025-09-18 13:55:55 -05:00
Eric Normand
83f6814775 kick ci 2025-09-18 13:55:37 -05:00
Timothy Dean
1bb21b1ad7 Add /validate endpoint to API 2025-09-18 11:46:51 -06:00
Timothy Dean
fb62d65095 m. 2025-09-18 11:37:10 -06:00
Timothy Dean
ad1423767a Better example model.yml 2025-09-18 11:36:14 -06:00
Timothy Dean
ef661cc1d1 Add example model yml with some schema 2025-09-18 11:29:51 -06:00
Timothy Dean
be445608ce Create representations.ingestion.common 2025-09-18 11:23:48 -06:00
Eric Normand
075ee44a6c Forgot this file 2025-09-18 12:05:25 -05:00
Eric Normand
407e6dd824 Export models
Note: I copied the export for questions, including the logic and schema. I don't know if there's something else in
models (metadata?) that I am not capturing.
2025-09-18 12:04:35 -05:00
Eric Normand
5b86da8b8b Export mbql questions 2025-09-18 11:01:06 -05:00
Eric Normand
39d9982ae4 API for exporting a native query question.
GET /api/ee/representation/question/92
2025-09-18 10:38:10 -05:00
Timothy Dean
530b985bc4 tweak commented out dev utils 2025-09-17 17:35:24 -06:00
Timothy Dean
ab8afe34b1 Delete 'card' as a named entity, reorganize namespaces as cleanup 2025-09-17 17:31:45 -06:00
Eric Normand
26b4d85352 API route
curl -X POST -H "Content-Type: text/yaml" -H "X-API-Key: mb_5Qj3t1s5NYcXNq1jB3wIebP3rAhLcmmgkSBp8YTvXOU=" --data-binary @test_resources/representations/v0/monthly-revenue.question.yml localhost:3002/api/ee/representation/load/5
2025-09-17 17:30:44 -05:00
Timothy Dean
558a2f91b2 On card ingestion, update existing card if ref matches 2025-09-17 16:24:56 -06:00
Timothy Dean
6b9381b859 Tweak collection schema 2025-09-17 16:15:48 -06:00
Timothy Dean
0cfa725615 Hook up card ingestion 2025-09-17 15:50:36 -06:00
Timothy Dean
7d74867058 Ingest one example question.yml file and insert it into the app-db 2025-09-17 15:42:20 -06:00
Timothy Dean
bf3a3e9e00 WIP: ingest & validate yml against schemas 2025-09-17 12:30:48 -06:00
dan sutton
bcca5c2329 first pass at writing representations
Interesting things:
- using serdes mbql export. this puts nano-ids, so we walk over each card, get mapping of nano-id to id, then replace
with refs. So the ingestion will need to do the opposite.

  ;; do we like entity type in filename?
  ;; walk over all, identity which depend on other refs. so first, get all refs
  ;; then walk over each dataset_query, looking for any refs that are reffed
  ;; then do topological sort, inserting cards without deps and keeping a map of ref to inserted id
  ;; update existing refs to ids (note source-tables are "card__<id>"
  ;; insert and continue

```clojure
core=> (write-em "/tmp" 8)
.
..nil
```

Very dump to write dots but this is exploratory, baby.

example:

(note, rows in yaml are actually commented out but git would remove them)
```yaml
❯ tree custom
custom
├── c-115-card1150883.card.yml
├── c-116-card1151398.card.yml
└── c-117-card1150868.card.yml

1 directory, 3 files

/tmp on ☁️  metabase-query
❯ cat custom/*
name: Orders and weird column
version: 1-card
type: card
ref: c-115-card1150883
dataset_query:
  database: Sample Database
  type: query
  query:
    source-table:
    - Sample Database
    - PUBLIC
    - ORDERS
    expressions:
      sum-of-weird:
      - +
      - - field
        - - Sample Database
          - PUBLIC
          - ORDERS
          - TAX
        - base-type: type/Float
      - - field
        - - Sample Database
          - PUBLIC
          - ORDERS
          - TOTAL
        - base-type: type/Float

 [1 1 14 37.648145389078365 2.07 39.718145389078366 nil "2025-02-11T21:40:27.892Z" 2 41.788145389078366 "Hudson Borer" "Awesome Concrete Shoes"]
 [2 1 123 110.93145648834248 6.1 117.03145648834247 nil "2024-05-15T08:04:04.58Z" 3 123.13145648834247 "Hudson Borer" "Mediocre Wooden Bench"]
 [3 1 105 52.723521442619514 2.9 49.206842233769756 6.416679208849759 "2025-12-06T22:22:48.544Z" 2 52.106842233769754 "Hudson Borer" "Fantastic Wool Shirt"]
 [4 1 94 109.21864156655383 6.01 115.22864156655383 nil "2025-08-22T16:30:42.392Z" 6 121.23864156655384 "Hudson Borer" "Awesome Bronze Plate"]
 [5 1 132 127.88197029833711 7.03 134.9119702983371 nil "2024-10-10T03:34:47.309Z" 5 141.9419702983371 "Hudson Borer" "Sleek Steel Table"]
 [6 1 60 29.80214751859149 1.64 31.44214751859149 nil "2025-11-06T16:38:50.134Z" 3 33.08214751859149 "Hudson Borer" "Rustic Paper Car"]
 [7 1 55 95.77128575934437 5.27 101.04128575934436 nil "2024-09-11T11:22:26.521Z" 5 106.31128575934436 "Hudson Borer" "Mediocre Rubber Shoes"]
 [8 1 65 68.22769726470014 3.75 63.32374433449318 8.653952930206964 "2025-06-17T02:37:41.693Z" 7 67.07374433449317 "Hudson Borer" "Ergonomic Silk Table"]
 [9 1 184 77.3982748679465 4.26 78.06353271268733 3.594742155259162 "2023-05-03T16:00:54.923Z" 3 82.32353271268734 "Hudson Borer" "Practical Granite Plate"]
 [10 1 6 97.43621265344382 5.36 102.79621265344382 nil "2026-01-17T01:44:37.233Z" 2 108.15621265344382 "Hudson Borer" "Small Marble Hat"]
name: Orders and weird column, Filtered by sum-of-weird is greater than 10
version: 1-card
type: card
ref: c-116-card1151398
dataset_query:
  database: Sample Database
  type: query
  query:
    source-table: c-115-card1150883
    filter:
    - '>'
    - - field
      - sum-of-weird
      - base-type: type/Float
    - 10

 [1 1 14 37.648145389078365 2.07 39.718145389078366 nil "2025-02-11T21:40:27.892Z" 2 41.788145389078366 "Hudson Borer" "Awesome Concrete Shoes"]
 [2 1 123 110.93145648834248 6.1 117.03145648834247 nil "2024-05-15T08:04:04.58Z" 3 123.13145648834247 "Hudson Borer" "Mediocre Wooden Bench"]
 [3 1 105 52.723521442619514 2.9 49.206842233769756 6.416679208849759 "2025-12-06T22:22:48.544Z" 2 52.106842233769754 "Hudson Borer" "Fantastic Wool Shirt"]
 [4 1 94 109.21864156655383 6.01 115.22864156655383 nil "2025-08-22T16:30:42.392Z" 6 121.23864156655384 "Hudson Borer" "Awesome Bronze Plate"]
 [5 1 132 127.88197029833711 7.03 134.9119702983371 nil "2024-10-10T03:34:47.309Z" 5 141.9419702983371 "Hudson Borer" "Sleek Steel Table"]
 [6 1 60 29.80214751859149 1.64 31.44214751859149 nil "2025-11-06T16:38:50.134Z" 3 33.08214751859149 "Hudson Borer" "Rustic Paper Car"]
 [7 1 55 95.77128575934437 5.27 101.04128575934436 nil "2024-09-11T11:22:26.521Z" 5 106.31128575934436 "Hudson Borer" "Mediocre Rubber Shoes"]
 [8 1 65 68.22769726470014 3.75 63.32374433449318 8.653952930206964 "2025-06-17T02:37:41.693Z" 7 67.07374433449317 "Hudson Borer" "Ergonomic Silk Table"]
 [9 1 184 77.3982748679465 4.26 78.06353271268733 3.594742155259162 "2023-05-03T16:00:54.923Z" 3 82.32353271268734 "Hudson Borer" "Practical Granite Plate"]
 [10 1 6 97.43621265344382 5.36 102.79621265344382 nil "2026-01-17T01:44:37.233Z" 2 108.15621265344382 "Hudson Borer" "Small Marble Hat"]
name: 5 products
version: 1-card
type: card
ref: c-117-card1150868
dataset_query:
  database: Sample Database
  type: native
  native:
    template-tags:
      category_filter:
        widget-type: string/=
        display-name: Category Filter
        type: dimension
        name: category_filter
        id: b5cca8e2-d397-4195-9957-1f69fda66d95
        dimension:
        - field
        - - Sample Database
          - PUBLIC
          - PRODUCTS
          - CATEGORY
        - null
    query: select * from products where {{category_filter}} limit 5

 [1 "1018947080336" "Rustic Paper Wallet" "Gizmo" "Swaniawski, Casper and Hilll" 29.463261130679875 4.6 "2023-07-19T19:44:56.582Z"]
 [2 "7663515285824" "Small Marble Shoes" "Doohickey" "Balistreri-Ankunding" 70.07989613071763 0.0 "2025-04-11T08:49:35.932Z"]
 [3 "4966277046676" "Synergistic Granite Chair" "Doohickey" "Murray, Watsica and Wunsch" 35.388744881539054 4.0 "2024-09-08T22:03:20.239Z"]
 [4 "4134502155718" "Enormous Aluminum Shirt" "Doohickey" "Regan Bradtke and Sons" 73.99178100854834 3.0 "2024-03-06T02:53:09.937Z"]
 [5 "5499736705597" "Enormous Marble Wallet" "Gadget" "Price, Schultz and Daniel" 82.7450976850356 4.0 "2022-10-03T01:47:39.147Z"]
```
2025-09-16 16:49:54 -05:00
Timothy Dean
25c142a7aa v0 scaffolding collection.yml 2025-09-16 15:43:43 -06:00
Timothy Dean
2d53706de7 v0 database scaffolding 2025-09-16 15:36:14 -06:00
Timothy Dean
ae6f82e0ee Scaffolding: clj schemas 2025-09-16 15:16:19 -06:00
Timothy Dean
219685e26f Scaffolding 2025-09-16 15:01:02 -06:00
Timothy Dean
1f892946c9 Scaffolding 2025-09-16 14:53:43 -06:00
Timothy Dean
cdacf75b02 Init commit 2025-09-16 13:59:53 -06:00
72 changed files with 4452 additions and 4 deletions

View File

@@ -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
View File

@@ -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))}}}

View File

@@ -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

View File

@@ -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]

View File

@@ -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)))

View File

@@ -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)))

View File

@@ -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)))))

View File

@@ -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)))

View File

@@ -0,0 +1,5 @@
(ns metabase-enterprise.representations.init
"Initialization namespace for the representations module."
(:require
[metabase-enterprise.representations.core]
[metabase-enterprise.representations.v0.init]))

View File

@@ -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))))

View File

@@ -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)})))

View File

@@ -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))

View File

@@ -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))

View File

@@ -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)))))

View File

@@ -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))))

View File

@@ -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)))

View File

@@ -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)))

View File

@@ -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]))

View File

@@ -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))}))

View File

@@ -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))

View File

@@ -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)))

View File

@@ -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)))

View File

@@ -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}))

View File

@@ -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)))

View File

@@ -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))

View File

@@ -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]

View File

@@ -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")))))

View File

@@ -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))))))))

View File

@@ -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)))))))

View File

@@ -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)))))))))

View File

@@ -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))))

View File

@@ -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)))))

View File

@@ -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)))))))

View File

@@ -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))))))

View File

@@ -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)))))

View File

@@ -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)))))

View File

@@ -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)))))

View File

@@ -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)))))

View File

@@ -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)))))

View File

@@ -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)))))

View File

@@ -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]

View File

@@ -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

View 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)))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View 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

View File

@@ -0,0 +1,10 @@
type: question
version: v0
ref: invalid-question
name: 12
description: 10
query: |
SELECT
*
FROM orders
database: Sample Database

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,6 @@
display_name: RTDDSRUAEFJLNYWASIFS
type: metric
version: v0
name: metric-126
query: select 1
database: ref:database-3

View File

@@ -0,0 +1,6 @@
display_name: DJWCFCOREPMVWZWXSQLS
type: model
name: model-119
entity-id: qMQj_zy85cRhyX33xEfaZ
query: select 1
database: ref:database-3

View File

@@ -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

View File

@@ -0,0 +1,3 @@
type: v0/mbql
ref: mbql-question-870
data: cXVlcnk6CiAgc291cmNlLXRhYmxlOgogICAgcmVmL2RhdGFiYXNlOiByZWY6ZGF0YWJhc2UtMQogICAgcmVmL3NjaGVtYTogUFVCTElDCiAgICByZWYvdGFibGU6IE9SREVSUwogIGpvaW5zOgogICAgLSBmaWVsZHM6IGFsbAogICAgICBzdHJhdGVneTogbGVmdC1qb2luCiAgICAgIGFsaWFzOiBQcm9kdWN0cwogICAgICBjb25kaXRpb246CiAgICAgICAgLSA9CiAgICAgICAgLSByZWYvZGF0YWJhc2U6IHJlZjpkYXRhYmFzZS0xCiAgICAgICAgICByZWYvc2NoZW1hOiBQVUJMSUMKICAgICAgICAgIHJlZi90YWJsZTogT1JERVJTCiAgICAgICAgICByZWYvZmllbGQ6IFBST0RVQ1RfSUQKICAgICAgICAtIHJlZi9kYXRhYmFzZTogcmVmOmRhdGFiYXNlLTEKICAgICAgICAgIHJlZi9zY2hlbWE6IFBVQkxJQwogICAgICAgICAgcmVmL3RhYmxlOiBQUk9EVUNUUwogICAgICAgICAgcmVmL2ZpZWxkOiBJRAogICAgICBzb3VyY2UtdGFibGU6IDMKICBhZ2dyZWdhdGlvbjoKICAgIC0gICAtIGF2ZwogICAgICAgIC0gcmVmL2RhdGFiYXNlOiByZWY6ZGF0YWJhc2UtMQogICAgICAgICAgcmVmL3NjaGVtYTogUFVCTElDCiAgICAgICAgICByZWYvdGFibGU6IE9SREVSUwogICAgICAgICAgcmVmL2ZpZWxkOiBUT1RBTAogICAgLSAgIC0gYXZnCiAgICAgICAgLSByZWYvZGF0YWJhc2U6IHJlZjpkYXRhYmFzZS0xCiAgICAgICAgICByZWYvc2NoZW1hOiBQVUJMSUMKICAgICAgICAgIHJlZi90YWJsZTogT1JERVJTCiAgICAgICAgICByZWYvZmllbGQ6IFFVQU5USVRZCiAgICAtICAgLSBhdmcKICAgICAgICAtIHJlZi9kYXRhYmFzZTogcmVmOmRhdGFiYXNlLTEKICAgICAgICAgIHJlZi9zY2hlbWE6IFBVQkxJQwogICAgICAgICAgcmVmL3RhYmxlOiBPUkRFUlMKICAgICAgICAgIHJlZi9maWVsZDogVEFYCiAgYnJlYWtvdXQ6CiAgICAtIHJlZi9kYXRhYmFzZTogcmVmOmRhdGFiYXNlLTEKICAgICAgcmVmL3NjaGVtYTogUFVCTElDCiAgICAgIHJlZi90YWJsZTogUFJPRFVDVFMKICAgICAgcmVmL2ZpZWxkOiBDQVRFR09SWQogICAgLSByZWYvZGF0YWJhc2U6IHJlZjpkYXRhYmFzZS0xCiAgICAgIHJlZi9zY2hlbWE6IFBVQkxJQwogICAgICByZWYvdGFibGU6IE9SREVSUwogICAgICByZWYvZmllbGQ6IENSRUFURURfQVQK

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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