Compare commits

...

298 Commits

Author SHA1 Message Date
Eric Normand
78adec4d9c More WIP 2025-11-17 10:39:33 -06:00
Eric Normand
92c8f942fb Dashboard WIP 2025-11-11 10:17:05 -06: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
97 changed files with 6956 additions and 35 deletions

View File

@@ -1922,6 +1922,19 @@
util
enterprise/serialization}}
enterprise/representations
{:team "Querying"
:api #{metabase-enterprise.representations.core
metabase-enterprise.representations.api
metabase-enterprise.representations.init}
:uses #{api
collections
config
driver
lib
models
util}}
enterprise/sandbox
{:team "UX West"
:api #{metabase-enterprise.sandbox.api.routes

View File

@@ -19,6 +19,11 @@ jobs:
name: Clj-Kondo
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Run clj-kondo
@@ -31,6 +36,11 @@ jobs:
name: Eastwood
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- run: clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood
@@ -102,6 +112,11 @@ jobs:
}}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare front-end environment
if: ${{ matrix.job.build-static-viz == 'true' }}
uses: ./.github/actions/prepare-frontend
@@ -178,6 +193,11 @@ jobs:
name: "Check Java ${{ matrix.java-version }}"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare backend
uses: ./.github/actions/prepare-backend
# TODO -- we should probably also check WITHOUT ee -- do a check with only OSS namespaces on the classpath
@@ -191,6 +211,11 @@ jobs:
name: Cljfmt
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Run cljfmt

View File

@@ -13,6 +13,11 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare back-end environment
timeout-minutes: 5
uses: ./.github/actions/prepare-backend

View File

@@ -160,6 +160,11 @@ jobs:
SKIP_LICENSES: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
with:

View File

@@ -34,6 +34,11 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment

View File

@@ -42,6 +42,12 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare frontend environment
uses: ./.github/actions/prepare-frontend

33
.github/workflows/representations.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Representation Tests
on:
push:
pull_request:
workflow_call:
inputs:
skip:
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-backend
cancel-in-progress: true
jobs:
representation-tests:
timeout-minutes: 10
name: Representation Tests
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
with:
m2-cache-key: 'representation-tests'
- name: Run test
run: clojure -X:dev:ci:test:ee:ee-dev :only '"enterprise/backend/test/metabase_enterprise/representations/"'

View File

@@ -127,6 +127,11 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.commit }}
- uses: actions/checkout@v4
with:
repository: metabase/representations
token: ${{ secrets.REPRESENTATIONS_GITHUB_PTA }}
path: representations
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
with:

3
.gitignore vendored
View File

@@ -163,3 +163,6 @@ CLAUDE.local.md
/logs
/.clojure-mcp
# Private dependency (checked out locally and in CI)
/representations/

43
bb.edn
View File

@@ -1,7 +1,7 @@
{:min-bb-version "1.12.208"
;; we put path as bin, and everything is in the ./mage subdirectory,
;; so the namespaces are mage.cli, mage.format, etc.
:paths ["mage/src" "mage/test" "bin/lint-migrations-file/src"]
:paths ["mage/src" "mage/test" "bin/lint-migrations-file/src" "representation"]
:deps {org.babashka/json {:mvn/version "0.1.6"}
metosin/malli {:mvn/version "0.17.0"}
table/table {:mvn/version "0.5.0"}
@@ -249,8 +249,7 @@
(apply readability/check arguments))}
;; Uncomment this if you want to play with the example calculator task:
#_#_
-example-calculator
#_#_-example-calculator
{;; `:doc` is a short docstring for the task, will be listed in `./bin/mage -h` and other help info.
;; Keep these short, they need to fit on one line in the output from `./bin/mage -h`.
:doc "The example calculator's task docstring"
@@ -309,4 +308,40 @@
:requires [[mage.escherize.fix-whitespace :as fix-whitespace]]
:examples [["mage -escherize-fix-whitespace '**/*.clj'" "Removes all lines like ' ' from all clj files"]]
:arg-schema [:sequential [:string {:description "Glob pattern to match files"}]]
:task (task! (fix-whitespace/run-clean parsed))}}}
:task (task! (fix-whitespace/run-clean parsed))}
export
{:doc "Export collections from Metabase instance to local filesystem"
:requires [[representation.cli :as repr-cli]
[representation.export :as repr-export]]
:task (let [opts [[nil "--mb-instance-url URL" "Metabase instance URL"]
[nil "--mb-instance-api-key KEY" "Metabase API key"]
[nil "--manifest PATH" "Path to manifest.yml file"
:default "manifest.yml"]
[nil "--collection NAMES" "Comma-separated collection names"]]
parsed (repr-cli/parse! opts)]
(repr-export/run parsed))}
import
{:doc "Import collections from local filesystem to Metabase instance"
:requires [[representation.cli :as repr-cli]
[representation.import :as repr-import]]
:task (let [opts [[nil "--mb-instance-url URL" "Metabase instance URL"]
[nil "--mb-instance-api-key KEY" "Metabase API key"]
[nil "--manifest PATH" "Path to manifest.yml file"
:default "manifest.yml"]
[nil "--collection NAMES" "Comma-separated collection names"]]
parsed (repr-cli/parse! opts)]
(repr-import/run parsed))}
lint
{:doc "Validate collections without importing to Metabase"
:requires [[representation.cli :as repr-cli]
[representation.lint :as repr-lint]]
:task (let [opts [[nil "--mb-instance-url URL" "Metabase instance URL"]
[nil "--mb-instance-api-key KEY" "Metabase API key"]
[nil "--manifest PATH" "Path to manifest.yml file"
:default "manifest.yml"]
[nil "--collection NAMES" "Comma-separated collection names"]]
parsed (repr-cli/parse! opts)]
(repr-lint/run parsed))}}}

View File

@@ -89,6 +89,7 @@
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
:git/sha "4998a1fdd9d3713d7034d04509e9212204773bf2"
:git/url "https://github.com/eerohele/pp"}
io.github.metabase/representations {:local/root "representations"}
;; TODO: replace back to normal artifact
io.github.metabase/macaw {:mvn/version "0.2.29"} ; Parse native SQL queries
io.github.resilience4j/resilience4j-retry ^:antq/exclude ; 2.x do not support Java 8

View File

@@ -31,6 +31,7 @@
[metabase-enterprise.permission-debug.api]
[metabase-enterprise.public-sharing.api]
[metabase-enterprise.remote-sync.api]
[metabase-enterprise.representations.api]
[metabase-enterprise.sandbox.api.routes]
[metabase-enterprise.scim.routes]
[metabase-enterprise.semantic-search.api]
@@ -123,6 +124,7 @@
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
"/public" (api.routes.common/+public-exceptions metabase-enterprise.public-sharing.api/routes)
"/representation" metabase-enterprise.representations.api/routes
"/transforms-python" (premium-handler metabase-enterprise.transforms-python.api/routes :transforms-python)
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
"/semantic-search" (premium-handler metabase-enterprise.semantic-search.api/routes :semantic-search)

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,174 @@
(ns metabase-enterprise.representations.api
(:require
[cheshire.core :as json]
[clojure.pprint :refer [pprint]]
[metabase-enterprise.representations.common :as common]
[metabase-enterprise.representations.core :as rep]
[metabase-enterprise.representations.dependencies :as deps]
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.import :as import]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.init]
[metabase-enterprise.representations.yaml :as rep-yaml]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.util.log :as log]
[representations.core :as rep-core]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(api.macros/defendpoint :get "/database/:id"
"Download a yaml representation of a database."
[{:keys [id]}
_query-params
_body-params
_request]
(let [id (Long/parseLong id)
database (api/check-404 (t2/select-one :model/Database :id id))
rep (rep/export database)]
(try
(rep/normalize-representation rep)
(catch Exception e
(log/error e "Does not validate.")))
(rep-yaml/generate-string rep)))
(api.macros/defendpoint :post "/validate"
"Validate a YAML representation string and return validation errors if any.
Returns an empty string if validation succeeds, or the ex-data as a string if it fails."
[_path-params
_query-params
_body-params
request]
(try
(let [yaml-string (slurp (:body request))
representation (rep-yaml/parse-string yaml-string)]
(rep/normalize-representation representation)
"") ; Return empty string on success
(catch Exception e
(with-out-str
(println (ex-message e))
(pprint (ex-data e))))))
(api.macros/defendpoint :get "/collection/:collection-id"
"Export all representations in a collection as a single yaml file"
[{:keys [collection-id]} :- :any
_
_
_]
(let [collection-id (Long/parseLong collection-id)]
(rep-yaml/generate-string (export/export-entire-collection collection-id))))
(api.macros/defendpoint :put "/collection/:collection-id"
"Import into a collection. Will delete everything in the collection first."
[{:keys [collection-id]} :- :any
_
_
request]
;; TODO: remove everything from the collection, maybe put it into an archive collection
(let [yaml-string (slurp (:body request))
collection (rep-yaml/parse-string yaml-string)
representations (import/collection-representations collection)
representations (v0-common/order-representations representations)
representations (map rep-read/parse representations)
errors (deps/check-dependencies representations)]
(rep-yaml/generate-string errors)))
(api.macros/defendpoint :post "/collection/validate"
"Validate a collection. Will return empty yml map when it's nothing."
[_ :- :any
_
_
request]
;; TODO: remove everything from the collection, maybe put it into an archive collection
(let [yaml-string (slurp (:body request))
collection (rep-yaml/parse-string yaml-string)
representations (import/collection-representations collection)
representations (v0-common/order-representations representations)
representations (map rep-read/parse representations)
errors (deps/check-dependencies representations)]
(rep-yaml/generate-string errors)))
(api.macros/defendpoint :post "/collection/:collection-id/export"
"Export all representations in a collection to the local filesystem"
[{:keys [collection-id]} :- :any
_query-params
_body-params
_request]
(export/export-collection-representations (Long/parseLong collection-id))
"Ok")
(api.macros/defendpoint :post "/collection/:collection-id/import"
"Import all representations from the local filesystem into a collection"
[{:keys [collection-id]} :- :any
_query-params
_body-params
_request]
(rep/import-collection-representations (Long/parseLong collection-id))
"Ok")
(api.macros/defendpoint :post "/transform/export"
"Export all transform representations to the local filesystem"
[_path-params
_query-params
_body-params
_request]
(export/export-transform-representations)
"Ok")
(api.macros/defendpoint :post "/transform/import"
"Import all transform representations from the local filesystemto Metabase"
[_path-params
_query-params
_body-params
_request]
(import/import-transform-representations)
"Ok")
(api.macros/defendpoint :post "/collection/import"
"Import a collection from a YAML bundle in the request body. Creates a new collection based on the name in the bundle."
[_path-params
_query-params
_body-params
request]
(let [yaml-string (slurp (:body request))
result (import/import-collection-yaml yaml-string)]
{:collection-id (:id result)
:status "ok"}))
(defn- reduce-tables [export-set]
(cond-> export-set
(> (count export-set) 1)
(export/reduce-tables)))
(api.macros/defendpoint :get "/export-set/:type/:id"
"Export an entity and its transitive dependencies as a JSON of YAMLs.
Type can be: question, model, metric, transform, collection, database, snippet."
[{:keys [type id]}
_query-params
_body-params
_request]
(let [id (Long/parseLong id)
type-keyword (keyword type)
model (common/toucan-model {:version rep-core/latest-version
:type type-keyword})
entity (api/check-404 (t2/select-one model :id id))
representation (export/export-entity entity)
export-set (-> [representation]
(export/export-set)
(reduce-tables)
(v0-common/order-representations)
(reverse)
(export/rename-refs export/ref-from-name export/standard-ref-strategies export/add-sequence-number))
export-set (mapv #(dissoc % :entity_id :entity-id) export-set)
clean-yamls (mapv v0-common/cleanup-delete-before-output export-set)
export-yamls (mapv rep-yaml/generate-string clean-yamls)]
(json/generate-string {:yamls export-yamls})))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/representations` routes."
(handlers/routes
(api.macros/ns-handler *ns* +auth)))

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,73 @@
(ns metabase-enterprise.representations.core
"Core functionality for the representations module that enables human-writable
formats for Metabase entities for version control and programmatic management."
(:require
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.import :as import]
[metabase-enterprise.representations.v0.common :as v0-common]
[representations.read :as rep-read]))
(set! *warn-on-reflection* true)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Validate representation structure
(defn normalize-representation
"Validates a representation against its schema based on the type field.
The type field can be either:
- Simple: 'question', 'collection' (defaults to v0)
- Versioned: 'v0/question', 'v1/collection' (explicit version)
The schemas themselves expect simple types, so we strip the version
before validation if present.
Handles both string and keyword keys from YAML parsing.
Applies JSON decoders to convert strings to appropriate types (e.g., \"scheduled\" -> :scheduled).
Throws an exception if validation fails.
Returns the decoded and validated representation if validation passes."
[representation]
(rep-read/parse representation))
;;;;;;;;;;;
;; Import
(defn yaml->toucan
"Convert a validated representation into data suitable for creating/updating an entity.
Returns a map with keys matching the Toucan model fields.
Does NOT insert into the database - just transforms the data.
Delegates to the multimethod yaml->toucan for extensibility."
([valid-representation]
(import/yaml->toucan valid-representation nil))
([valid-representation ref-index]
(import/yaml->toucan valid-representation ref-index)))
(defn persist!
"Persist the representation with t2"
([representation]
(persist! representation (v0-common/map-entity-index {})))
([representation ref-index]
(when-let [validated (normalize-representation representation)]
(import/persist! validated ref-index))))
;; =============================================================== ;;
(defn export
"Export a Metabase entity to its human-readable representation.
Delegates to the multimethod export-entity for extensibility."
[t2-model]
(export/export-entity t2-model))
(defn export-collection-representations
"Export a collection and all its contents to YAML files.
Delegates to export/export-collection-representations."
([id]
(export/export-collection-representations id))
([id path]
(export/export-collection-representations id path)))
(defn import-collection-representations
"Because I didn't potemkin yet."
[id]
(import/import-collection-representations id))

View File

@@ -0,0 +1,31 @@
(ns metabase-enterprise.representations.dependencies
(:require
[metabase-enterprise.dependencies.core :as dependencies]
[metabase-enterprise.representations.import :as import]
[toucan2.core :as t2]))
(defn check-dependencies
"Check dependencies of the representations, which should be in order from least dependent to most."
[representations]
(let [index (reduce (fn [index [id rep]]
(assoc index (:ref rep) (-> (if (= :v0/database (:type rep))
(import/persist! rep index)
(-> (import/yaml->toucan rep index)
(assoc :id (+ Integer/MAX_VALUE id))))
(assoc ::type (:type rep)))))
{}
(map vector (range) representations))
entities (reduce #(merge-with concat %1 %2)
{}
(map (fn [entity]
{(case (::type entity)
:v0/question :card
:v0/collection :collection
:model/Transform :transform
:model/Snippet :snippet
:v0/database :database)
[(dissoc entity ::type)]})
(vals index)))
;;entities (dissoc entities :database)
]
(dependencies/errors-from-proposed-edits (dissoc entities :database :collection))))

View File

@@ -0,0 +1,44 @@
(ns metabase-enterprise.representations.dev-helpers
"TOTALLY TEMPORARY HACKS.
Helper functions for experimenting with representations in the REPL to check
that what we export matches what we'd want to use on import."
(:require
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.import :as import]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.yaml :as rep-yaml]
[toucan2.core :as t2]))
(defn export-card
"Export a card (model/question) by ID to a YAML file.
Usage: (export-card 123 \"/tmp/my-model.yml\")"
[card-id filepath]
(let [card (t2/select-one :model/Card :id card-id)
representation (export/export-entity card)
yaml-str (rep-yaml/generate-string representation)]
(spit filepath yaml-str)
(println "Exported card" card-id "to" filepath)))
(defn import-card
"Import a card from a YAML file, updating the specified card ID.
Usage: (import-card \"/tmp/my-model.yml\" 123)"
[filepath card-id]
(let [yaml-str (slurp filepath)
representation (rep-yaml/parse-string yaml-str)
sample-db (t2/select-one :model/Database :name "Sample Database")
ref-index (v0-common/map-entity-index
{(str "database-" (:id sample-db)) sample-db})]
(import/update! representation card-id ref-index)))
(defn import-card-string
"Import a card from a YAML string, updating the specified card ID.
Usage: (import-card-string \"_type: model\\nname: My Model\\n...\" 123)"
[yaml-str card-id]
(let [representation (rep-yaml/parse-string yaml-str)
sample-db (t2/select-one :model/Database :name "Sample Database")
ref-index (v0-common/map-entity-index
{(str "database-" (:id sample-db)) sample-db})]
(import/update! representation card-id ref-index)))

View File

@@ -0,0 +1,261 @@
(ns metabase-enterprise.representations.export
"Export functionality for Metabase entities to human-readable representations"
(:require
[clojure.set :as set]
[clojure.string :as str]
[clojure.walk :as walk]
[metabase-enterprise.representations.common :as common]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.core :as v0]
[metabase-enterprise.representations.yaml :as rep-yaml]
[metabase.collections.api :as coll.api]
[metabase.util :as mu]
[metabase.util.log :as log]
[toucan2.core :as t2])
(:import
[java.io File]))
(set! *warn-on-reflection* true)
(defn export-entity
"Export a Metabase entity to its human-readable representation format.
Dispatches on [model type] for Cards, [model nil] for other entities."
[t2-entity]
;; v0 because that's the latest version
(v0/export-entity t2-entity))
(defn- read-from-ref [version ref]
(let [[type id] (str/split ref #"-" 2)]
(-> (common/toucan-model {:version version :type (keyword type)})
(t2/select-one :id (Long/parseLong id))
(export-entity))))
(defn export-set
"Returns a transitive set of ref-dependencies"
[representations]
(loop [acc (set representations)
prev #{}]
(if-some [new (seq (set/difference acc prev))]
(let [reps (for [rep new
:let [version (:version rep)]
refs (v0-common/refs rep)]
(read-from-ref version refs))]
(recur (into acc reps) acc))
acc)))
(defn- munge-name
"Convert a name to a filesystem-safe format.
- Preserves Unicode letters (including accented characters and CJK)
- Preserves numbers
- Replaces all other characters (spaces, punctuation, emojis, etc.) with hyphens
- Collapses multiple hyphens into one
- Removes leading and trailing hyphens
- Converts to lowercase"
[name]
(-> name
(str/trim)
;; Replace any sequence of non-letter, non-number characters with a single hyphen
;; This preserves accented characters (é, ñ, ü) and CJK characters
;; while replacing emojis, punctuation, and spaces with hyphens
(str/replace #"[^\p{L}\p{N}]+" "-")
;; Remove leading and trailing hyphens
(str/replace #"^-+|-+$" "")
(mu/lower-case-en)))
(defn ref-from-name
[reps]
(map #(assoc % ::proposed-ref (munge-name (:display_name %))) reps))
(defn add-type
[reps]
(map #(update % ::proposed-ref str "-" (name (:type %))) reps))
(defn add-sequence-number
[reps]
(map-indexed (fn [i rep]
(update rep ::proposed-ref str "-" i))
reps))
(def standard-ref-strategies
[add-type])
(defn- rename-refs-map
[representations initial-gen-ref middle-gen-refs final-gen-ref]
(loop [gen-refs middle-gen-refs
acc (into #{} (initial-gen-ref representations))]
(let [gen-ref (or (first gen-refs) final-gen-ref)
new-ref-to-reps (group-by ::proposed-ref acc)
acc' (reduce (fn [acc [to-remove to-add]]
(into (apply disj acc to-remove) to-add))
acc (for [[_ref reps] new-ref-to-reps
:when (> (count reps) 1)]
[reps (gen-ref reps)]))]
(if (= acc' acc) ;; base case
(into {} (map (juxt :name ::proposed-ref)) acc')
(recur (rest gen-refs) acc')))))
(defn rename-refs
"Rename a set of representations based on a sequence of ref strategy functions `gen-refs`.
Each `gen-ref` takes a sequence of representations and returns a new sequence with proposed refs."
[representations initial-gen-ref middle-gen-refs final-gen-ref]
(let [ref-map (rename-refs-map representations initial-gen-ref middle-gen-refs final-gen-ref)
renamed (mapv #(update % :name ref-map) representations)
ref:ref-map (into {} (map (fn [[old new]]
[(str "ref:" old) (str "ref:" new)]))
ref-map)]
(->> renamed
(walk/postwalk-replace ref:ref-map)
(mapv #(dissoc % ::proposed-ref)))))
(defn- schema->table-index [schema]
(let [tables (:tables schema)
index (into {} (map (juxt :name identity)) tables)]
(assoc schema :tables index)))
(defn- table-index->schema [schema]
(update schema :tables (comp vec #(sort-by :name %) vals)))
(defn- db->schema-index [db]
(let [schemas (:schemas db)
schemas (map schema->table-index schemas)
index (into {} (map (juxt :name identity)) schemas)]
(assoc db :schemas index)))
(defn- schema-index->db [db]
(update db :schemas (comp vec #(sort-by :name %) #(map table-index->schema %) vals)))
(defn- limit-db-to-schemas [db schemas]
(update db :schemas select-keys schemas))
(defn- limit-schema-to-tables [schema tables]
(update schema :tables select-keys tables))
(defn reduce-tables
"Include only schemas and tables in databases referenced by queries in the export set."
[export-set]
(let [tables (into #{} (mapcat v0-common/table-refs) export-set)
referred-to-databases (-> #{}
(into (comp (map :database)
(map v0-common/unref))
tables)
(into (comp (map :database)
(filter some?)
(map v0-common/unref))
export-set))
db-split (group-by #(= :database (:type %)) export-set)
databases (get db-split true)
;; turn them into a convenient form
databases (map db->schema-index databases)
non-databases (get db-split false)
;; remove databases that are not referred to
databases (filter #(contains? referred-to-databases (:name %)) databases)
tables-by-db (group-by (comp v0-common/unref :database) tables)
databases (map (fn [db]
(let [tables (get tables-by-db (:name db))
by-schema (group-by :schema tables)
schemas (keys by-schema)
;; remove schemas that are not referred to
database (limit-db-to-schemas db schemas)
;; remove tables that are not referred to
database (reduce (fn [database [schema tables]]
(let [tables (into #{} (map :table) tables)]
(update-in database [:schemas schema] limit-schema-to-tables tables)))
database by-schema)]
database))
databases)
;; turn them back into regular form
databases (map schema-index->db databases)]
(-> []
(into databases)
(into non-databases))))
(defn- child->database-ids
"Extract database IDs from a child entity"
[child]
(when-let [db-id (:database_id child)]
[db-id]))
(defn model->card-type
"Given a model, returns a card-type."
[model]
(let [kw (keyword (:model model))]
(get {:dataset :model :card :question} kw kw)))
(defn export-collection-representations
"Export the stuff."
([id] (export-collection-representations id v0-common/representations-export-dir))
([id path]
(let [collection (t2/select-one :model/Collection :id id)
coll-dir (str path (v0-common/file-sys-name id (:name collection) "/"))
refs-dir (str coll-dir "refs/")
children (-> collection
(coll.api/collection-children {:show-dashboard-questions? true :archived? false})
:data)
database-ids (into #{} (mapcat child->database-ids) children)]
(.mkdirs (File. refs-dir))
(doseq [db-id database-ids]
(try
(let [database (t2/select-one :model/Database :id db-id)
db-yaml (-> database export-entity rep-yaml/generate-string)
file-name (v0-common/file-sys-name db-id (:name database) ".database.yml")]
(spit (str refs-dir file-name) db-yaml))
(catch Exception e
(log/errorf e "Unable to export database with id %s" db-id))))
(doseq [child children]
(let [child-id (:id child)
model-type (model->card-type child)]
(if (= model-type :collection)
(export-collection-representations child-id coll-dir)
(try
(let [card (t2/select-one :model/Card :id child-id :type model-type)
entity-yaml (-> card export-entity rep-yaml/generate-string)
suffix (format ".%s.yml" (name model-type))
card-file-name (v0-common/file-sys-name child-id (:entity_id child) suffix)]
(spit (str coll-dir card-file-name) entity-yaml))
(catch Exception e
(log/errorf e "Unable to export representation of type %s with id %s" model-type child-id)))))))))
(defn export-entire-collection
"Generate a list of yaml representations to export"
[id]
(let [collection (t2/select-one :model/Collection :id id)
children (-> collection
(coll.api/collection-children {:show-dashboard-questions? true :archived? false})
:data)
database-ids (into #{} (mapcat child->database-ids) children)
child-reps (for [child children]
(let [child-id (:id child)
model-type (model->card-type child)]
(if (= model-type :collection)
(export-entire-collection child-id)
(-> (t2/select-one :model/Card :id child-id :type model-type)
export-entity))))]
(assoc (export-entity collection)
:children (map #(dissoc % :databases) child-reps)
:databases (concat
(for [db-id database-ids]
(-> (t2/select-one :model/Database :id db-id)
export-entity))
(mapcat :databases child-reps)))))
;;;;;;;;;;;;;;;;
;; Transforms ;;
(defn export-transform-representations
"Exports transform representations to the associated local dir"
([] (export-transform-representations v0-common/representations-export-dir))
([path]
(let [trans-dir "transforms/"
transforms (t2/select :model/Transform)]
(.mkdirs (File. (str path trans-dir)))
(doseq [transform transforms]
(let [transform-id (:id transform)]
(try
(let [entity-yaml (->> (t2/select-one :model/Transform :id transform-id)
export-entity
(rep-yaml/generate-string))
file-name (v0-common/file-sys-name transform-id (:name transform) ".yaml")]
(spit (str path trans-dir file-name) entity-yaml))
(catch Exception e
(log/errorf e "Unable to export representation of type transform with id %s" transform-id))))))))

View File

@@ -0,0 +1,197 @@
(ns metabase-enterprise.representations.import
"Import functionality for Metabase entities from human-readable representations"
(:require
[clojure.java.io :as io]
[clojure.set :as set]
[clojure.string :as str]
[metabase-enterprise.representations.common :as common]
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.core :as v0-core]
[metabase-enterprise.representations.yaml :as rep-yaml]
[metabase.util.log :as log]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn persist!
"Ingest a validated representation and create/update the entity in the database."
[representation ref-index]
(case (:version representation)
:v0 (v0-core/persist! representation ref-index)))
(defn yaml->toucan
"Convert a validated representation into data suitable for creating/updating an entity.
Returns a map with keys matching the Toucan model fields.
Does NOT insert into the database - just transforms the data."
[representation ref-index]
(case (:version representation)
:v0 (v0-core/yaml->toucan representation ref-index)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Representation Normalization ;;
(defn insert!
"Insert a representation as a new entity in the database."
[representation ref-index]
(let [representation (rep-read/parse representation)]
(case (:version representation)
:v0 (v0-core/insert! representation ref-index))))
(defn update!
"Update an existing entity from a representation."
[representation id ref-index]
(let [representation (rep-read/parse representation)]
(case (:version representation)
:v0 (v0-core/update! representation id ref-index))))
(defn- file->collection-id
[^java.io.File file]
(let [id
(-> (.getParent file)
(str/split #"/")
last
(str/split #"_")
first
(Long/parseLong))]
id))
(defn import-yaml
"Parse a YAML representation file and return the data structure.
Returns nil if the file cannot be parsed."
[file]
(try
(rep-yaml/from-file file)
(catch Exception e
(log/error e "Failed to parse YAML file" file)
nil)))
(defn- prepare-yaml
[^java.io.File file]
(when (or (str/ends-with? (.getName file) ".yml")
(str/ends-with? (.getName file) ".yaml"))
(-> (import-yaml file)
rep-read/parse
(assoc :collection (file->collection-id file)))))
(defn- yaml-files
"Returns imported reference models from the yaml files in dir, in safe persistence order."
[dir]
(->> (io/file dir)
file-seq
(keep prepare-yaml)
(v0-common/order-representations)))
(defn persist-dir!
"Given a dir containing yaml representations, sorts them topologically, then persists them in order."
[dir]
(let [representations (yaml-files dir)]
(reduce (fn [index representation]
(try
(let [persisted (persist! representation index)]
(assoc index (:name representation) persisted))
(catch Exception e
(log/warn "Failed to persist an entity!"
(ex-info (format "Entity failed to persist for ref %s. Removing from index." (:name representation))
{:entity representation
:ref-index index}
e))
(dissoc index (:name representation)))))
{}
representations)))
(defn collection-representations [collection-yaml]
(concat
[(dissoc collection-yaml :children :databases)]
(:databases collection-yaml)
;; recursive call
(mapcat collection-representations (:children collection-yaml))))
(defn import-collection-representations
"Import a whole collection from the associated local directory"
[id]
(let [collection (t2/select-one :model/Collection :id id)
coll-dir (v0-common/file-sys-name id (:name collection) "/")
coll-path (str v0-common/representations-export-dir coll-dir)]
(persist-dir! coll-path)))
(defn- flatten-collection-children
"Flatten collection children and databases into individual representations (excluding the top-level collection itself)."
[bundle]
(let [{:keys [children databases]} bundle
child-reps (mapcat (fn [child]
(if (= (:type child) "v0/collection")
(concat [child] (flatten-collection-children child))
[child]))
children)]
(concat databases child-reps)))
(defn- order-collections
"Order collections by location depth (parents before children)."
[collections]
(sort-by (fn [coll]
(let [location (or (:location coll) "/")]
(count (filter #(= % \/) location))))
collections))
(defn import-collection-yaml
"Import a collection from a YAML string containing the full bundle.
Creates collections first (ordered by parent-child), then other entities."
[yaml-string]
(let [bundle (-> yaml-string rep-yaml/parse-string rep-read/parse)
collection-rep (dissoc bundle :children :databases)
collection-name (:name collection-rep)
;; Select ONLY top-level collections with the name:
existing-collections (t2/select :model/Collection :name collection-name :location "/")]
(cond (> (count existing-collections) 1)
(throw (ex-info (str "Multiple collections found with name: " collection-name)
{:collection-name collection-name
:count (count existing-collections)}))
(= (count existing-collections) 1)
(let [existing (first existing-collections)
archived-name (str collection-name " (archived)")]
(log/info "Renaming existing collection" collection-name "to" archived-name)
(t2/update! :model/Collection (:id existing) {:name archived-name})))
(let [new-collection (persist! collection-rep {})
collection-id (:id new-collection)
representations (flatten-collection-children bundle)
normalized (map rep-read/parse representations)
ordered (v0-common/order-representations normalized)]
(reduce (fn [index entity]
(try
(let [persisted (persist! (assoc entity :collection collection-id)
(v0-common/map-entity-index index))]
(assoc index (:name entity) persisted))
(catch Exception e
(log/warn e (format "Failed to persist entity with ref %s" (:name entity)))
(dissoc index (:name entity)))))
{}
ordered)
new-collection)))
;;;;;;;;;;;;;;;;
;; Transforms ;;
(defn import-transform-representations
"Import all transforms from the associated local directory"
[]
(let [trans-dir "transforms/"
trans-path (str v0-common/representations-export-dir trans-dir)]
(doseq [^java.io.File file (file-seq (io/file trans-path))]
(when (.isFile file)
(try
(let [valid-repr (-> (slurp file)
(rep-yaml/parse-string)
(rep-read/parse))
id (-> (:name valid-repr)
(str/split #"-")
(last)
(Long/parseLong))
toucan-model (yaml->toucan valid-repr nil)
update-transform (assoc toucan-model
:id id
:updated_at (java.time.OffsetDateTime/now))]
(t2/update! :model/Transform id update-transform))
(catch Exception e
(log/errorf e "Failed to ingest representation file %s" (.getName file))))))))

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,49 @@
(ns metabase-enterprise.representations.lookup
(:require
[metabase-enterprise.representations.v0.common :as v0-common]
[toucan2.core :as t2]))
(defn lookup-by-id
"Find and return a toucan entity given the representation type and id."
[toucan-type id]
(when (int? id)
(t2/select-one toucan-type :id id)))
(defn lookup-by-entity-id
"Find and return a toucan entity given the representation type and entity id."
[toucan-type entity-id]
(when (string? entity-id)
(t2/select-one toucan-type :entity_id entity-id)))
(defn lookup-by-name
"Find and return a toucan entity given the representation type and its name.
Will throw an exception if multiple entities are found with the same name."
[toucan-type name]
(when (string? name)
(let [entities (t2/select toucan-type :name name)]
(if (= 1 (count entities))
(first entities)
(throw (ex-info (str "Multiple entities of type " toucan-type " with name '" name "'.")
{:toucan-type toucan-type
:name name
:entities entities}))))))
(defn lookup-database-id
"Looks up a database-id from some reference to a database (ref, id, or entity-id)."
[ref-index database]
(v0-common/ensure-not-nil
(cond
(integer? database)
database
(v0-common/ref? database)
(-> (v0-common/lookup-entity ref-index database)
(v0-common/ensure-correct-type :database)
:id)
(string? database)
(-> (or (lookup-by-name :model/Database database)
(lookup-by-entity-id :model/Database database))
(v0-common/ensure-correct-type :database)
:id))))

View File

@@ -0,0 +1,60 @@
(ns metabase-enterprise.representations.toucan.core
(:require [metabase.api.common :as api]
[metabase.config.core :as config]))
(defmulti with-toucan-defaults
"Adds default, required values to a toucan representation."
{:arglists '([t2-model toucan-entity])}
(fn [t2-model _toucan-entity]
t2-model))
(defn- with-question-defaults
[question-entity]
(->> question-entity
(merge {:description ""
:visualization_settings {}
:display :table
:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
(defn- with-model-defaults
[model-entity]
(->> model-entity
(merge {:description ""
:visualization_settings {}
:display :table
:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
(defn- with-metric-defaults
[metric-entity]
(->> metric-entity
(merge {:description ""
:visualization_settings {}
:display :table
:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
(defmethod with-toucan-defaults :model/Card
[_t2-model card-entity]
(case (:type card-entity)
:question (with-question-defaults card-entity)
:model (with-model-defaults card-entity)
:metric (with-metric-defaults card-entity)))
(defmethod with-toucan-defaults :model/Collection
[_t2-model collection-entity]
collection-entity)
(defmethod with-toucan-defaults :model/Document
[_t2-model document-entity]
(->> document-entity
(merge {:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
(defmethod with-toucan-defaults :model/NativeQuerySnippet
[_t2-model snippet-entity]
(->> snippet-entity
(merge {:description ""
:creator_id (or api/*current-user-id* config/internal-mb-user-id)})))
(defmethod with-toucan-defaults :model/Transform
[_t2-model transform-entity]
(->> transform-entity
(merge {:description ""})))

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,86 @@
(ns metabase-enterprise.representations.v0.collection
(:require
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase.collections.api :as coll.api]
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(def toucan-model
"The toucan model keyword associated with collection representations"
:model/Collection)
(defmethod v0-common/representation-type :model/Collection [_entity]
:collection)
(defn- model->card-type ; TODO: DRY -- repeated in export.clj
"Given a model, returns a card-type."
[model]
(let [kw (keyword (:model model))]
(get {:dataset :model :card :question} kw kw)))
(defn- model->url
"Given a model, return a url."
[model]
(format "/api/ee/representation/%s/%s" (name (model->card-type model)) (:id model)))
(defn children
"Returns the URLS of children of a collection."
[collection]
(->> (coll.api/collection-children collection {:show-dashboard-questions? true
:archived? false})
:data
(mapv model->url)))
(defn yaml->toucan
"Convert a v0 collection representation to Toucan-compatible data."
[{collection-name :display_name
:keys [name description collection] :as _representation}
_ref-index]
(let [parent-id (when collection
(if (number? collection)
collection
(:id collection)))
location (if parent-id
(str "/" parent-id "/")
"/")]
(u/remove-nils
{:name (or collection-name name)
:description description
:location location})))
(defn- insert-collection!
[new-collection]
(log/debug "Creating new collection" (:name new-collection))
(t2/insert-returning-instance! :model/Collection new-collection))
(defn- archive-and-persist!
[existing-collection new-collection]
(let [collection-name (:name existing-collection)
archived-name (str collection-name " (archived)")]
(log/info "Renaming existing collection" collection-name "to" archived-name)
(t2/update! :model/Collection (:id existing-collection) {:name archived-name})
(insert-collection! new-collection)))
(defn persist!
"Persist a v0 collection representation by creating or updating it in the database."
[representation ref-index]
(let [new-collection (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/Collection))
collection-name (:name new-collection)
existing (t2/select-one :model/Collection :name collection-name :location "/")]
(if existing
(archive-and-persist! existing new-collection)
(insert-collection! new-collection))))
(defn export-collection
"Export a Collection Toucan entity to a v0 collection representation."
[t2-collection]
(-> {:type :collection
:version :v0
:name (format "%s-%s" "collection" (:id t2-collection))
:display_name (:name t2-collection)
:description (:description t2-collection)
:children (children t2-collection)} ;; todo: where's the other collection exporter?
u/remove-nils))

View File

@@ -0,0 +1,245 @@
(ns metabase-enterprise.representations.v0.common
(:require
[clojure.set :as set]
[clojure.string :as str]
[clojure.walk :as walk]
[metabase.util :as u]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defmulti representation-type
"Returns the representation type (e.g., :question, :model, :metric) for a toucan entity.
Dispatches on the toucan model type."
{:arglists '[[entity]]}
t2/model)
(defmethod representation-type :default [entity]
(throw (ex-info (str "Unknown entity type: " (t2/model entity))
{:entity entity})))
(defn entity-id
"Generates an entity-id stably from ref and collection-ref."
[ref collection-ref]
(-> (str collection-ref "/" ref)
hash
str
u/generate-nano-id))
(defn generate-entity-id
"Generate a stable entity-id from the representation's collection-ref and its own ref."
[representation]
(entity-id (:name representation) (:collection representation)))
(defn add-entity-id-from-ref-hash
"Add a stable entity-id to a representation based on its ref and collection ref."
[representation]
(assoc representation :entity-id (generate-entity-id representation)))
(defn add-entity-id-random
"Add an unstable entity-id to a representation. It should be random so that it never collides."
[representation]
(assoc representation :entity-id (u/generate-nano-id)))
(defn remove-entity-id
"Remove the entity id from a representation (if it has one)."
[representation]
(dissoc representation :entity-id))
(defn find-collection-id
"Find collection ID by name or ref. Returns nil if not found."
[collection-ref]
(when collection-ref
(or
(when (integer? collection-ref) collection-ref)
;; Try to find by slug or name
(t2/select-one-pk :model/Collection :slug collection-ref)
(t2/select-one-pk :model/Collection :name collection-ref))))
(defn ref?
"Is it a ref?"
[x]
(and (string? x)
(str/starts-with? x "ref:")))
(defn unref
"Unmake that ref."
[x]
(when (ref? x)
;; "ref:"
(subs x 4)))
(defn refs
"Returns all refs present in the entity-map, recursively walking to discover them."
[representation]
(let [v (volatile! [])]
(walk/postwalk (fn [node]
(when (ref? node)
(vswap! v conj node))
node)
representation)
(set (map unref @v))))
(defn ->ref
"Constructs a ref with the shape \"ref:<type>-<id>\""
[id type]
(format "ref:%s-%s" (name type) id))
(defn env-var?
"Is it an env var?
Env vars are useful for storing credentials for databases in representations. They are expanded during import."
[s]
(and (string? s)
(str/starts-with? s "env:")))
(defn un-env-var
"Turn env var string into an env var name."
[s]
(when (env-var? s)
;; "env:"
(subs s 4)))
(defn hydrate-env-vars
"Given a representation, hydrate all env vars with their values looked up from the environment."
[representation]
(walk/postwalk (fn [x]
(if (env-var? x)
(System/getenv (un-env-var x))
x))
representation))
(def representations-export-dir
"The dir where POC import/export representations are stored."
"local/representations/")
(defn file-sys-name
"Creates a suitable filename."
[id name suffix]
(str id "_" (str/replace (u/lower-case-en name) " " "_") suffix))
(defn table-ref?
"Is this a table ref?"
[x]
(and (map? x)
(contains? x :schema)
(contains? x :table)
(not (contains? x :field))))
(defn field-ref?
"Is this a field ref?"
[x]
(and (map? x)
(contains? x :schema)
(contains? x :table)
(contains? x :field)))
(defn table-refs
"Returns all table refs present in the representation, recursively walking to discover them."
[representation]
(let [v (volatile! #{})]
(walk/postwalk (fn [node]
(when (or (table-ref? node)
(field-ref? node))
(vswap! v conj (cond-> (dissoc node :field)
(not (contains? node :database))
(assoc :database (:database representation)))))
node)
representation)
@v))
(defprotocol EntityLookup
(lookup-entity [this ref])
(lookup-id [this ref]))
(defrecord MapEntityIndex [idx]
EntityLookup
(lookup-entity [_this ref]
(get idx (unref ref)))
(lookup-id [this ref]
(:id (lookup-entity this ref))))
(defn map-entity-index
"Create a new index from a map of ref -> toucan entity."
[mp]
(->MapEntityIndex mp))
(defn ensure-not-nil [entity]
(when (nil? entity)
(throw (ex-info "Entity not found." {})))
entity)
(defn ensure-correct-type
"Ensures the entity is of the expected representation type. Throws if not."
[entity expected-type]
(when (and entity
(not= expected-type (representation-type entity)))
(throw (ex-info (str "Entity is not the correct type. Expected: " expected-type "; Actual: " (representation-type entity))
{:entity entity
:expected-type expected-type
:actual-type (representation-type entity)})))
entity)
(defn entity->ref
"Get the internal ref for a toucan entity."
[t2-entity]
(->ref (:id t2-entity) (representation-type t2-entity)))
(defn id-model->ref
"Get the internal ref for a toucan model and id."
[id model]
(entity->ref (t2/select-one model id)))
(defn ensure-correct-model-type [entity expected-type]
(when (not= expected-type (t2/model entity))
(throw (ex-info (str "Entity is not the correct model type. Expected: " expected-type "; Actual: " (t2/model entity))
{:entity entity
:expected-type expected-type
:actual-type (t2/model entity)})))
entity)
(defn replace-refs-everywhere
"Replace all refs with whatever is in the ref-index."
[data ref-index]
(walk/postwalk
(fn [node]
(if (ref? node)
(lookup-id ref-index node)
node))
data))
(defn cleanup-delete-before-output
"Delete a special key used to keep structured data around until export."
[data]
(walk/postwalk
(fn [node]
(if (map? node)
(dissoc node ::delete-before-output)
node))
data))
(defn order-representations
"Order representations topologically by ref dependency"
[representations]
(loop [iterations (count representations) ;; should take at most one iteration per representation
acc []
remaining (set representations)]
(cond
(empty? remaining) ;; we're done!
acc
(neg? iterations) ;; we've used too many iterations, probably in a cycle
(throw (ex-info "Used too many iterations. Cycle?"
{:representations representations}))
:else
(let [done (set (map :name acc))
ready (filter #(set/subset? (refs %) done)
remaining)
acc' (into acc ready)
next-remaining (set/difference remaining (set acc'))]
(when (= remaining next-remaining)
(throw (ex-info "No progress made. Cycle?"
{:representations representations})))
(recur (dec iterations) acc' next-remaining)))))

View File

@@ -0,0 +1,98 @@
(ns metabase-enterprise.representations.v0.core
(:require
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.card :as v0-card]
[metabase-enterprise.representations.v0.collection :as v0-coll]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.dashboard :as v0-dashboard]
[metabase-enterprise.representations.v0.database :as v0-database]
[metabase-enterprise.representations.v0.document :as v0-document]
[metabase-enterprise.representations.v0.metric :as v0-metric]
[metabase-enterprise.representations.v0.model :as v0-model]
[metabase-enterprise.representations.v0.question :as v0-question]
[metabase-enterprise.representations.v0.snippet :as v0-snippet]
[metabase-enterprise.representations.v0.transform :as v0-transform]
[toucan2.core :as t2]))
(defn toucan-model
[representation-type]
(case representation-type
:collection v0-coll/toucan-model
:dashboard v0-dashboard/toucan-model
:database v0-database/toucan-model
:document v0-document/toucan-model
:metric v0-metric/toucan-model
:model v0-model/toucan-model
:question v0-question/toucan-model
:snippet v0-snippet/toucan-model
:transform v0-transform/toucan-model))
(defn export-entity
"Export the Toucan entity to a v0 representation.
resolve is a function that takes id and toucan model name and returns a ref"
[t2-entity]
(case (v0-common/representation-type t2-entity)
:collection (v0-coll/export-collection t2-entity)
:dashboard (v0-dashboard/export-dashboard t2-entity)
:database (v0-database/export-database t2-entity)
:document (v0-document/export-document t2-entity)
:metric (v0-metric/export-metric t2-entity)
:model (v0-model/export-model t2-entity)
:question (v0-question/export-question t2-entity)
:snippet (v0-snippet/export-snippet t2-entity)
:transform (v0-transform/export-transform t2-entity)))
(defn yaml->toucan
"Convert a v0 representation into data suitable for creating/updating an entity."
[representation ref-index]
(case (:type representation)
:collection (v0-coll/yaml->toucan representation ref-index)
:dashboard (v0-dashboard/yaml->toucan representation ref-index)
:database (v0-database/yaml->toucan representation ref-index)
:document (v0-document/yaml->toucan representation ref-index)
:metric (v0-metric/yaml->toucan representation ref-index)
:model (v0-model/yaml->toucan representation ref-index)
:question (v0-question/yaml->toucan representation ref-index)
:snippet (v0-snippet/yaml->toucan representation ref-index)
:transform (v0-transform/yaml->toucan representation ref-index)))
(defn persist!
"Persist a v0 representation by creating or updating the entity in the database."
[representation ref-index]
(case (:type representation)
:collection (v0-coll/persist! representation ref-index)
:dashboard (v0-dashboard/persist! representation ref-index)
:database (v0-database/persist! representation ref-index)
:document (v0-document/persist! representation ref-index)
:metric (v0-metric/persist! representation ref-index)
:model (v0-model/persist! representation ref-index)
:question (v0-question/persist! representation ref-index)
:snippet (v0-snippet/persist! representation ref-index)
:transform (v0-transform/persist! representation ref-index)))
(defn insert!
"Insert a v0 representation as a new entity."
[representation ref-index]
(case (:type representation)
:transform (v0-transform/insert! representation ref-index)
:dashboard (v0-dashboard/insert! representation ref-index)
;; default
(let [model (toucan-model (:type representation))
toucan (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults model))]
(t2/insert-returning-instance! model toucan))))
(defn update!
"Update an existing v0 entity from a representation."
[representation id ref-index]
(case (:type representation)
:transform (v0-transform/update! representation id ref-index)
:dashboard (v0-dashboard/update! representation id ref-index)
;; default
(let [model (toucan-model (:type representation))
toucan (yaml->toucan representation ref-index)]
(t2/update! model id toucan)
(t2/select-one model :id id))))

View File

@@ -0,0 +1,359 @@
(ns metabase-enterprise.representations.v0.dashboard
"Dashboard representations for v0 format.
ASSUMPTIONS AND DESIGN DECISIONS:
1. Dashboard Structure:
- Dashboards contain multiple DashboardCards (report_dashboardcard)
- DashboardCards can be organized into Tabs (dashboard_tab)
- Each DashboardCard references a Card (question/model) or can be a text card
2. Parameter Handling:
- Dashboard-level parameters are stored in the :parameters field
- Each DashboardCard can have parameter_mappings linking dashboard params to card params
- Parameter mappings are complex nested structures that need careful preservation
3. Card References:
- DashboardCards reference Cards via :card_id
- We convert these to refs (e.g., 'question-123') for export
- Text cards have :card_id nil and :visualization_settings containing markdown
4. Series Handling:
- DashboardCards can have additional cards shown as series
- Series are stored as :series in visualization_settings
- Each series card needs to be tracked as a dependency
5. Visualization Settings:
- Complex nested maps containing chart configuration
- May contain references to other cards (in series)
- Preserved as-is except for card ID -> ref conversions
6. Tab Support:
- Tabs organize cards into logical groupings
- Each tab has an ID, name, and position
- DashboardCards reference their tab via :dashboard_tab_id
7. Auto-Apply Filters:
- The :auto_apply_filters field controls whether filters apply automatically
- Preserved during export/import
DIFFICULTIES AND CHALLENGES:
1. Circular Dependencies:
- Dashboards reference Cards, Cards can reference Dashboards (via dashboard_id)
- We only track Dashboard -> Card dependencies, not the reverse
2. Archived Cards:
- DashboardCards might reference archived cards
- Need to decide: fail on archived cards or skip them?
- Current approach: include archived card refs but mark with comment
3. Parameter Mapping Complexity:
- Parameter mappings have variable structure (can be null, maps, or arrays)
- Different mapping types for different field types (category, location, etc.)
- Preserved as-is but may be fragile across versions
4. Visualization Settings Size:
- Can be very large JSON blobs
- May contain computed/derived values
- Risk of storing stale cached data
5. Position and Size:
- Cards have col, row, size_x, size_y for grid layout
- These are viewport-specific and might not translate across systems
- Preserved but users may need to adjust layouts after import
6. Dashboard Filters vs Card Filters:
- Dashboard parameters can be connected to card filters
- The connection is through parameter_mappings which use parameter_id + card_id
- Parameter IDs might change on import, requiring remapping"
(:require
[flatland.ordered.map :refer [ordered-map]]
[metabase-enterprise.representations.lookup :as lookup]
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase.util :as u]
[metabase.util.log :as log]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(def toucan-model
"The toucan model keyword associated with dashboard representations"
:model/Dashboard)
(defmethod v0-common/representation-type :model/Dashboard [_entity]
:dashboard)
;; -- Import --
(defn- resolve-card-ref
"Resolve a card reference to an actual card ID.
Card refs can be in the format 'question-123' or 'model-456'."
[card-ref ref-index]
(when card-ref
(let [resolved (v0-common/replace-refs-everywhere card-ref ref-index)]
(if (number? resolved)
resolved
;; If it's still a string after replacement, it might be unresolvable
(log/warn "Could not resolve card reference:" card-ref)))))
(defn- import-parameter-mappings
"Import parameter mappings, resolving any card references.
Parameter mappings link dashboard parameters to card parameters."
[parameter-mappings ref-index]
(when parameter-mappings
;; Parameter mappings are complex structures that may contain card references
;; We preserve them as-is since they're mostly parameter IDs and field references
(v0-common/replace-refs-everywhere parameter-mappings ref-index)))
(defn- import-series
"Import series from visualization settings, resolving card references.
Series are additional cards shown on the same visualization."
[series ref-index]
(when series
(mapv (fn [series-card]
;; Each series entry might have a :card_id that needs resolution
(if-let [card-ref (:card_id series-card)]
(assoc series-card :card_id (resolve-card-ref card-ref ref-index))
series-card))
series)))
(defn- import-visualization-settings
"Import visualization settings, resolving any embedded card references.
Visualization settings can contain series (additional cards) that need resolution."
[viz-settings ref-index]
(when viz-settings
(let [viz-settings (v0-common/replace-refs-everywhere viz-settings ref-index)]
;; Special handling for series if present
(if-let [series (:series viz-settings)]
(assoc viz-settings :series (import-series series ref-index))
viz-settings))))
(defn- import-dashcard
"Convert a representation dashcard to Toucan-compatible data.
Resolves card references and parameter mappings."
[dashcard ref-index]
(-> dashcard
(update :card_id resolve-card-ref ref-index)
(update :parameter_mappings import-parameter-mappings ref-index)
(update :visualization_settings import-visualization-settings ref-index)
;; Remove the temporary dashboard_id reference - it will be set when inserting
(dissoc :dashboard_id)
u/remove-nils))
(defn- import-tabs
"Convert representation tabs to Toucan-compatible data.
Tabs organize cards into logical groupings."
[tabs]
(when tabs
(mapv (fn [tab]
(-> tab
;; Remove the temporary dashboard_id - it will be set when inserting
(dissoc :dashboard_id)
(select-keys [:name :position])
u/remove-nils))
tabs)))
(defn yaml->toucan
"Convert a v0 dashboard representation to Toucan-compatible data.
NOTE: This returns the dashboard data only. DashboardCards and Tabs
must be created separately after the dashboard is persisted, since they
require the dashboard ID."
[{:keys [name display_name description parameters
embeddable embedding_parameters width] :as _representation}
ref-index]
;; ASSUMPTION: We only create the dashboard entity here
;; DashboardCards and Tabs are handled in persist! after we have a dashboard ID
(-> {:name (or display_name name)
:description description
:parameters parameters
:embeddable embeddable ;; optional
:embedding_params embedding_parameters ;; optional
:width width} ;; optional
u/remove-nils))
(defn- create-or-update-tabs!
"Create or update tabs for a dashboard.
DIFFICULTY: Tab IDs change between systems, so DashboardCards reference tabs by position.
We need to create tabs first, then map old positions to new IDs."
[dashboard-id tabs]
(when (seq tabs)
;; First, delete existing tabs
(t2/delete! :model/DashboardTab :dashboard_id dashboard-id)
;; Create new tabs and build a position->id map
(let [created-tabs (for [tab tabs]
(first (t2/insert-returning-instances!
:model/DashboardTab
(assoc tab :dashboard_id dashboard-id))))]
;; Return a map of position -> new tab ID
(into {} (map (fn [tab] [(:position tab) (:id tab)]) created-tabs)))))
(defn- create-or-update-dashcards!
"Create or update dashcards for a dashboard.
CHALLENGE: DashboardCards reference tabs by ID, but tab IDs change between systems.
We use the position->tab-id map to remap tab references."
[dashboard-id dashcards tab-position->id]
(when (seq dashcards)
;; Delete existing dashcards
(t2/delete! :model/DashboardCard :dashboard_id dashboard-id)
;; Create new dashcards
(doseq [dashcard dashcards]
(let [;; Remap dashboard_tab_id using position if we have tabs
dashboard-tab-id (when-let [tab-pos (:dashboard_tab_id dashcard)]
;; ASSUMPTION: dashboard_tab_id in representation is actually position
;; This might need adjustment based on actual export format
(get tab-position->id tab-pos))
dashcard-data (-> dashcard
(assoc :dashboard_id dashboard-id)
(assoc :dashboard_tab_id dashboard-tab-id)
u/remove-nils)]
;; DIFFICULTY: If card_id is nil, this is a text card
;; Text cards store their content in visualization_settings
(when (or (:card_id dashcard-data)
(get-in dashcard-data [:visualization_settings :text]))
(t2/insert! :model/DashboardCard dashcard-data))))))
(defn persist!
"Persist a v0 dashboard representation by creating or updating it in the database.
COMPLEXITY: Dashboards have three related entities that must be created in order:
1. Dashboard (parent)
2. DashboardTabs (children, needed for tab IDs)
3. DashboardCards (children, reference tabs by ID)"
[representation ref-index]
(let [dashboard-data (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/Dashboard))
entity-id (:entity_id dashboard-data)
existing (when entity-id
(t2/select-one :model/Dashboard :entity_id entity-id))
;; Create or update the dashboard
dashboard (if existing
(do
(log/info "Updating existing dashboard" (:name dashboard-data)
"with ref" (:name representation))
(t2/update! :model/Dashboard (:id existing) (dissoc dashboard-data :entity_id))
(t2/select-one :model/Dashboard :id (:id existing)))
(do
(log/info "Creating new dashboard" (:name dashboard-data))
(first (t2/insert-returning-instances! :model/Dashboard dashboard-data))))
dashboard-id (:id dashboard)
;; Import tabs first (to get their IDs)
tabs (import-tabs (:tabs representation))
tab-position->id (create-or-update-tabs! dashboard-id tabs)
;; Import and create dashcards (using tab IDs from above)
dashcards (map #(import-dashcard % ref-index) (:dashcards representation))
_ (create-or-update-dashcards! dashboard-id dashcards tab-position->id)]
dashboard))
(defn- set-up-tabs
[dashboard-id tab-representations ref-index]
(let [tab-instances (t2/insert-returning-instances! :model/DashboardTab
(for [[pos tab-rep] (map vector (range) tab-representations)]
{:dashboard_id dashboard-id
:name (:name tab-rep)
:position pos}))]
(t2/insert! :model/DashboardCard
(for [[tab-rep tab-inst] (map vector tab-representations tab-instances)
dashcard-rep (:cards tab-rep)]
{:size_x (:width dashcard-rep)
:size_y (:height dashcard-rep)
:row (:row dashcard-rep)
:col (:column dashcard-rep)
:card_id (some->> (:card dashcard-rep)
(v0-common/lookup-entity ref-index))
:dashboard_id dashboard-id
:dashboard_tab_id (:id tab-inst)
:parameter_mappings (:parameter_mappings dashcard-rep)
:visualization_settings ()}))))
(defn insert!
[representation ref-index]
(let [representation (rep-read/parse representation)]
(assert (= :dashboard (:type representation)))
(let [toucan (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/Dashboard))
dashboard (t2/insert-returning-instance! :model/Dashboard toucan)]
(set-up-tabs (:id dashboard) (:tabs representation) ref-index)
;;todo (eric 2025-11-10): hydrate?
)))
(defn update!
[representation id ref-index]
(let [representation (rep-read/parse representation)]
(assert (= :dashboard (:type representation)))
(let [toucan (yaml->toucan representation ref-index)]
(t2/update! :model/Dashboard id (dissoc toucan :entity_id))
(t2/delete! :model/DashboardTab :dashboard_id id)
(t2/delete! :model/DashboardCard :dashboard_id id)
(set-up-tabs id (:tabs representation) ref-index)
;;todo (eric 2025-11-10): hydrate?
)))
;; -- Export --
(defn- series-card-ref [series-card]
(u/update-if-exists series-card :card_id #(v0-common/id-model->ref % :model/Card)))
(defn- vis-settings-refs [settings]
(when settings
(u/update-if-exists settings :series #(mapv series-card-ref %))))
(defn- export-dashcard
"Export a DashboardCard to representation format.
Converts card_id to a card reference (e.g., 'ref:question-123')."
[dashcard]
(let [card-ref (when (:card_id dashcard)
(v0-common/id-model->ref (:card_id dashcard) :model/Card))]
(-> (ordered-map
:card card-ref ;; nil for text cards
:row (:row dashcard)
:column (:col dashcard)
:width (:size_x dashcard)
:height (:size_y dashcard)
:dashboard_tab_id (:dashboard_tab_id dashcard)
:parameter_mappings (:parameter_mappings dashcard)
:visualization_settings (vis-settings-refs (:visualization_settings dashcard)))
u/remove-nils)))
(defn- export-tab
"Export a DashboardTab to representation format. Takes a toucan entity for the tab and toucan entities for its cards."
[t2-tab t2-cards]
(-> (ordered-map
:name (:name t2-tab)
:cards (mapv export-dashcard t2-cards))
u/remove-nils))
(defn export-dashboard
"Export a Dashboard Toucan entity to a v0 dashboard representation."
[t2-dashboard]
(let [dashboard-ref (v0-common/entity->ref t2-dashboard)
;; Fetch related tabs and cards
tabs (t2/select :model/DashboardTab :dashboard_id (:id t2-dashboard)
{:order-by [[:position :asc]]})
dashcards (t2/select :model/DashboardCard :dashboard_id (:id t2-dashboard)
{:order-by [[:row :asc] [:col :asc]]})
cards-by-tab (group-by :dashboard_tab_id dashcards)]
(-> (ordered-map
:name dashboard-ref
:type :dashboard
:version :v0
:display_name (:name t2-dashboard)
:description (:description t2-dashboard)
:parameters (:parameters t2-dashboard)
:embeddable (:enable_embedding t2-dashboard)
:embedding_parameters (:embedding_params t2-dashboard)
:width (:width t2-dashboard)
:tabs (mapv #(export-tab % (get cards-by-tab (:id %))) tabs))
u/remove-nils)))

View File

@@ -0,0 +1,117 @@
(ns metabase-enterprise.representations.v0.database
(:require
[clojure.set :as set]
[clojure.string :as str]
[flatland.ordered.map :refer [ordered-map]]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase.driver.util :as driver.u]
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(def toucan-model
"The toucan model keyword associated with database representations"
:model/Database)
(defmethod v0-common/representation-type :model/Database [_entity]
:database)
(defn yaml->toucan
"Convert a v0 database representation to Toucan-compatible data."
[representation _ref-index]
(-> representation
(set/rename-keys {:connection_details :details
:display_name :name})
(select-keys [:name :engine :description :details :schemas])
(v0-common/hydrate-env-vars)
(u/remove-nils)))
(defn persist!
"Persist a v0 database representation by matching it to an existing database."
[representation ref-index]
(let [representation (yaml->toucan representation ref-index)]
(if-some [existing (t2/select-one :model/Database
:name (:name representation)
:engine (:engine representation))]
(do
(log/info "Found existing database" (:name representation) "with name" (:name representation))
existing)
(throw (ex-info (str "Could not match database by name: " (:name representation)
" and engine: " (:engine representation))
{:representation representation})))))
;;; ------------------------------------ Export Functions ------------------------------------
(defn- get-fk-reference
"Get the foreign key reference in TABLE.COLUMN format"
[fk-target-field-id]
(when fk-target-field-id
(when-let [target-field (t2/select-one [:model/Field :name :table_id] :id fk-target-field-id)]
(when-let [target-table-name (t2/select-one-fn :name :model/Table :id (:table_id target-field))]
(str target-table-name "." (:name target-field))))))
(defn- process-field
[field]
(let [fk-ref (when (:fk_target_field_id field)
(get-fk-reference (:fk_target_field_id field)))]
(cond-> {:name (:name field)
:type (or (:database_type field)
(name (or (:base_type field) :unknown)))}
(:description field) (assoc :description (:description field))
(= (:database_required field) false) (assoc :nullable true)
(:pk field) (assoc :pk true)
fk-ref (assoc :fk fk-ref))))
(defn- get-table-columns
"Get all columns for a table with their metadata"
[table-id]
(->> (t2/select :model/Field :table_id table-id :active true)
(mapv process-field)))
(defn- process-schema-tables
[[schema-name tables]]
{:name (or schema-name "PUBLIC")
:tables (vec (for [table tables]
(cond-> {:name (:name table)}
(:description table) (assoc :description (:description table))
:always (assoc :columns (get-table-columns (:id table))))))})
(defn- get-database-schemas
"Get all schemas, tables, and columns for a database"
[database-id]
(->> (t2/select :model/Table :db_id database-id :active true)
(group-by :schema)
(mapv process-schema-tables)))
(defn- sanitize-connection-details
"Remove sensitive fields from connection details using Metabase's official list"
[details ref]
(when details
(reduce (fn [m k]
(if (contains? m k)
(assoc m k (-> (format "%s_%s" ref (name k))
(str/replace #"-" "_")
(u/upper-case-en)
(->> #_env-name
(str "env:"))))
m))
details
driver.u/default-sensitive-fields)))
(defn export-database
"Export a Database Toucan entity to a v0 database representation."
[database]
(let [ref (v0-common/unref (v0-common/->ref (:id database) :database))]
(-> (ordered-map
:name ref
:type :database
:version :v0
:engine (:engine database)
:display_name (:name database)
:description (not-empty (:description database))
:connection_details (some-> (:details database)
(sanitize-connection-details ref))
:schemas (get-database-schemas (:id database)))
u/remove-nils)))

View File

@@ -0,0 +1,128 @@
(ns metabase-enterprise.representations.v0.document
(:require
[clojure.java.io :as io]
[clojure.java.shell :as sh]
[clojure.string :as str]
[clojure.walk :as walk]
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[toucan2.core :as t2]))
;;; ------------------------------------ Main Schema ------------------------------------
(def toucan-model
"The toucan model keyword associated with document representations"
:model/Document)
(defmethod v0-common/representation-type :model/Document [_entity]
:document)
(defn- markdown->yaml [md]
(let [script (str (io/file (io/resource "representations/markdown-to-prosemirror.mjs")))
result (->> md (sh/sh "node" script :in))]
;; Always log errors if present
(when (seq (:err result))
(log/warn "Error from prosemirror-to-markdown:" (:err result)))
;; Throw if we got an error and no output
(when (not (zero? (:exit result)))
(throw (ex-info (str "Error converting markdown to prosemirror: " (:err result))
{:md md
:error (:err result)
:exit-code (:exit result)})))
;; Throw if we got no output at all (even without explicit error)
(when-not (seq (:out result))
(throw (ex-info "Markdown to prosemirror conversion produced no output"
{:md md
:stderr (:err result)
:exit-code (:exit result)})))
(str/trim (:out result))))
(defn yaml->toucan
"Convert a v0 document representation to Toucan-compatible data."
[{document-name :display_name
:keys [name content _content_type]}
ref-index]
(let [yaml-content content
yaml-content (v0-common/replace-refs-everywhere yaml-content ref-index)]
{:name (or document-name name)
:document (json/encode yaml-content)
:content_type "application/json+vnd.prose-mirror"}))
(defn persist!
"Persist a v0 document representation by creating or updating it in the database."
[representation ref-index]
(let [document-data (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/Document))
entity-id (:entity_id document-data)
existing (when entity-id
(t2/select-one :model/Document :entity_id entity-id))]
(if existing
(do
(log/info "Updating existing document" (:name document-data) "with name" (:name representation))
(t2/update! :model/Document (:id existing) (dissoc document-data :entity_id))
(t2/select-one :model/Document :id (:id existing)))
(do
(log/info "Creating new document" (:name document-data))
(first (t2/insert-returning-instances! :model/Document document-data))))))
(defn- patch-refs-for-export
[yaml]
(walk/postwalk
(fn [node]
(cond-> node
(and (map? node)
(= "cardEmbed" (:type node)))
(update-in [:attrs :id] v0-common/id-model->ref :model/Card)))
yaml))
(defn- edn->markdown
[edn]
(let [script (str (io/file (io/resource "representations/prosemirror-to-markdown.mjs")))
result (->> edn
(json/encode)
(sh/sh "node" script :in))]
;; Always log errors if present
(when (seq (:err result))
(log/warn "Error from prosemirror-to-markdown:" (:err result)))
;; Throw if we got an error and no output
(when (not (zero? (:exit result)))
(throw (ex-info (str "Error converting prosemirror to markdown: " (:err result))
{:edn edn
:error (:err result)
:exit-code (:exit result)})))
;; Throw if we got no output at all (even without explicit error)
(when-not (seq (:out result))
(throw (ex-info "Prosemirror to markdown conversion produced no output"
{:edn edn
:stderr (:err result)
:exit-code (:exit result)})))
(str/trim (:out result))))
(defn export-document
"Export a Document Toucan entity to a v0 document representation."
[document]
(let [document-ref (v0-common/unref (v0-common/->ref (:id document) :document))
document (patch-refs-for-export document)
doc-content (let [doc (:document document)]
(cond
(nil? doc)
(throw (ex-info "Document content is nil - document field not loaded correctly"
{:document-id (:id document)
:document-keys (keys document)}))
(string? doc)
(json/decode doc keyword)
:else
doc))]
(cond-> {:display_name (:name document)
:type :document
:version :v0
:name document-ref
:content doc-content
:content_type :prosemirror}
:always
u/remove-nils)))

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,121 @@
(ns metabase-enterprise.representations.v0.mbql
"MBQL transformations for import/export of representations."
(:require
[metabase-enterprise.representations.lookup :as lookup]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.query :as lib.query]
[metabase.util :as u]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
;; ============================================================================
;; Import: Convert representation refs to Metabase IDs
;; ============================================================================
(defn- resolve-source-card
[card-ref ref-index _mp]
(when (v0-common/ref? card-ref)
(-> ref-index
(v0-common/lookup-id card-ref)
(v0-common/ensure-not-nil))))
(defn- resolve-source-table
"Resolves refs in the raw MBQL map, replacing them with IDs for import."
[source-table _ref-index mp]
(when (v0-common/table-ref? source-table)
(->> (lib.metadata/tables mp)
(filter #(and (= (:schema source-table) (:schema %))
(= (:table source-table) (:name %))))
first
:id
v0-common/ensure-not-nil)))
(defn- resolve-field
[field-ref ref-index mp]
(when (v0-common/field-ref? field-ref)
(let [table-id (resolve-source-table (dissoc field-ref :field) ref-index mp)]
(->> (lib.metadata/fields mp table-id)
(filter #(= (:field field-ref) (:name %)))
first
:id
v0-common/ensure-not-nil))))
(defrecord V0ImportVisitor []
lib/QueryVisitor
(visit-database-id [_ _mp ref-index database-ref]
(when (v0-common/ref? database-ref)
(lookup/lookup-database-id ref-index database-ref)))
(visit-table-id [_ mp ref-index table-ref]
(when (v0-common/table-ref? table-ref)
(resolve-source-table table-ref ref-index mp)))
(visit-card-id [_ mp ref-index card-ref]
(when (v0-common/ref? card-ref)
(resolve-source-card card-ref ref-index mp)))
(visit-field-id [_ mp ref-index field-ref]
(when (v0-common/field-ref? field-ref)
(resolve-field field-ref ref-index mp))))
(def ^:private import-visitor (->V0ImportVisitor))
(defn import-dataset-query
"Returns Metabase's dataset_query format, given a representation.
Converts representation format to Metabase's internal dataset_query structure."
[{:keys [query database]} ref-index]
(let [db-id (lookup/lookup-database-id ref-index database)
mp (lib-be/application-database-metadata-provider db-id)
query' (if (string? query)
(lib/native-query mp query)
(mu/disable-enforcement
(lib.query/query-with-stages mp query)))]
(lib/import-walk query' import-visitor ref-index)))
;; ============================================================================
;; Export: Convert Metabase IDs to representation refs
;; ============================================================================
(defn- table-ref
"Convert a table ID to a representation ref map."
[mp table-id]
(when-some [t (lib.metadata/table mp table-id)]
(-> {:schema (:schema t)
:table (:name t)}
u/remove-nils)))
(defn- field-ref
"Convert a field id to a representation ref map."
[mp field-id]
(let [field (lib.metadata/field mp field-id)
tr (table-ref mp (:table-id field))]
(assoc tr :field (:name field))))
(defrecord V0MBQLExportVisitor []
lib/QueryVisitor
(visit-database-id [_ _mp _context database-id]
(when (int? database-id)
(v0-common/->ref database-id :database)))
(visit-table-id [_ mp _context table-id]
(when (int? table-id)
(table-ref mp table-id)))
(visit-card-id [_ mp _context card-id]
(when-some [card (lib.metadata/card mp card-id)]
(v0-common/->ref (:id card) (:type card))))
(visit-field-id [_ mp _context field-id]
(when (int? field-id)
(field-ref mp field-id))))
(def ^:private export-visitor (->V0MBQLExportVisitor))
(defn export-dataset-query
"Export a dataset query to representation compatible format.
Will have database (as a ref) and query."
[query]
(let [patched-query (lib/export-walk query export-visitor nil)]
{:database (:database patched-query)
:query (if (lib/native-only-query? query)
(lib/raw-native-query query)
(:stages patched-query))}))

View File

@@ -0,0 +1,79 @@
(ns metabase-enterprise.representations.v0.metric
(:require
[flatland.ordered.map :refer [ordered-map]]
[metabase-enterprise.representations.lookup :as lookup]
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.card]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
[metabase.api.common :as api]
[metabase.config.core :as config]
[metabase.lib.core :as lib]
[metabase.util :as u]
[metabase.util.log :as log]
[representations.schema.v0.column :as rep-v0-column]
[toucan2.core :as t2]))
(def toucan-model
"The toucan model keyword associated with metric representations"
:model/Card)
(defn yaml->toucan
"Convert a v0 metric representation to Toucan-compatible data."
[{metric-name :display_name
:keys [name description database collection columns] :as representation}
ref-index]
(let [database-id (lookup/lookup-database-id ref-index database)
;; TODO: once we've cleaned up mbql stuff, this explicit lookup should be superfluous.
;; Just pull it off of the dataset-query
dataset-query (-> (assoc representation :database database-id)
(v0-mbql/import-dataset-query ref-index))]
(-> {;; Core fields
:name (or metric-name name)
:description description
:dataset_query dataset-query
:database_id database-id
:query_type (if (lib/native-only-query? dataset-query) :native :query)
:type :metric
:result_metadata columns
:collection_id (v0-common/find-collection-id collection)}
u/remove-nils)))
(defn persist!
"Persist a v0 metric representation by creating or updating it in the database."
[representation ref-index]
(let [metric-data (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/Card))
;; Generate stable entity_id from ref and collection
entity-id (v0-common/generate-entity-id representation)
existing (when entity-id
(t2/select-one :model/Card :entity_id entity-id))]
(if existing
(do
(log/info "Updating existing metric" (:name metric-data) "with name" (:name representation))
(t2/update! :model/Card (:id existing) (dissoc metric-data :entity_id))
(t2/select-one :model/Card :id (:id existing)))
(do
(log/info "Creating new metric" (:name metric-data))
(let [metric-data-with-creator (-> metric-data
(assoc :creator_id (or api/*current-user-id*
config/internal-mb-user-id))
(assoc :entity_id entity-id))]
(first (t2/insert-returning-instances! :model/Card metric-data-with-creator)))))))
;;; -- Export --
(defn export-metric
"Export a Metric Card Toucan entity to a v0 metric representation."
[card]
(-> (ordered-map
:name (v0-common/unref (v0-common/->ref (:id card) :metric))
:type (:type card)
:version :v0
:display_name (:name card)
:description (:description card)
:columns (into []
(map #(select-keys % rep-v0-column/column-keys))
(:result_metadata card)))
(merge (v0-mbql/export-dataset-query (:dataset_query card)))
u/remove-nils))

View File

@@ -0,0 +1,118 @@
(ns metabase-enterprise.representations.v0.model
(:require
[flatland.ordered.map :refer [ordered-map]]
[metabase-enterprise.representations.lookup :as lookup]
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.card]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
[metabase.api.common :as api]
[metabase.config.core :as config]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.queries.models.card.metadata :as card.metadata]
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(def ^:private user-editable-settings
[:column_title :text_align :text_wrapping :view_as :link_text :link_url
:show_mini_bar :number_style :currency :currency_style
:date_style :date_separator :date_abbreviate :time_enabled :time_style])
;;; ------------------------------------ Main Model Schema ------------------------------------
(defn- columns->result-metadata
"Merge required inferred-metadata into the yml column metadata for user-defined fields."
[user-metadata inferred-metadata]
(mapv (fn [user-column inferred]
(assoc user-column :base-type (:base-type inferred)))
user-metadata
inferred-metadata))
(def toucan-model
"The toucan model keyword associated with model representations"
:model/Card)
(defn yaml->toucan
"Convert a v0 model representation to Toucan-compatible data."
[{model-name :display_name
:keys [_type name description database collection columns] :as representation}
ref-index]
(let [database-id (lookup/lookup-database-id ref-index database)
dataset-query (-> (assoc representation :database database-id)
(v0-mbql/import-dataset-query ref-index))
metadata-provider (lib-be/application-database-metadata-provider database-id)
normalized-query (card.metadata/normalize-dataset-query dataset-query)
mlv2-query (lib/query metadata-provider normalized-query)
inferred-metadata (lib/returned-columns mlv2-query)]
(-> {:name (or model-name name)
:description description
:dataset_query dataset-query
:database_id database-id
:query_type (if (lib/native-only-query? dataset-query) :native :query)
:type :model
:result_metadata (when columns
(columns->result-metadata columns inferred-metadata))
:collection_id (v0-common/find-collection-id collection)}
u/remove-nils)))
(defn persist!
"Persist a v0 model representation by creating or updating it in the database."
[representation ref-index]
(let [model-data (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/Card))
entity-id (:entity_id model-data)
existing (when entity-id
(t2/select-one :model/Card :entity_id entity-id))]
(if existing
(do
(log/info "Updating existing model" (:name model-data) "with name" (:name representation))
(t2/update! :model/Card (:id existing) (dissoc model-data :entity_id))
(t2/select-one :model/Card :id (:id existing)))
(do
(log/info "Creating new model" (:name model-data))
(let [model-data-with-creator (-> model-data
(assoc :creator_id (or api/*current-user-id*
config/internal-mb-user-id))
(assoc :entity_id entity-id))]
(first (t2/insert-returning-instances! :model/Card model-data-with-creator)))))))
;;; -- Export --
(defn- extract-user-editable-settings
"Extract user-editable settings from a column's settings map.
Returns only the fields that users should be able to edit in YAML."
[settings]
(when settings
(not-empty
(select-keys settings user-editable-settings))))
(defn- extract-user-editable-column-metadata
"Extract user-editable metadata from a result_metadata column entry.
Returns a map with :name and user-editable fields only."
[result-column-metadata]
(let [base {:name (:name result-column-metadata)}
editable (not-empty
(select-keys result-column-metadata [:display_name :description :semantic_type
:visibility_type :fk_target_field_id]))
settings (extract-user-editable-settings (:settings result-column-metadata))]
(cond-> base
editable (merge editable)
settings (assoc :settings settings))))
(defn export-model
"Export a Model Card Toucan entity to a v0 model representation."
[card]
(let [card-ref (v0-common/unref (v0-common/->ref (:id card) :model))
columns (when-let [result-metadata (:result_metadata card)]
(seq (mapv extract-user-editable-column-metadata result-metadata)))]
(-> (ordered-map
{:name card-ref
:type (:type card)
:version :v0
:display_name (:name card)
:description (:description card)})
(merge (v0-mbql/export-dataset-query (:dataset_query card)))
(assoc :columns columns)
u/remove-nils)))

View File

@@ -0,0 +1,63 @@
(ns metabase-enterprise.representations.v0.question
(:require
[flatland.ordered.map :refer [ordered-map]]
[metabase-enterprise.representations.lookup :as lookup]
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.card]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
[metabase.lib.core :as lib]
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(def toucan-model
"The toucan model keyword associated with question representations"
:model/Card)
(defn yaml->toucan
"Convert a v0 question representation to Toucan-compatible data."
[representation ref-index]
(let [database-id (lookup/lookup-database-id ref-index (:database representation))
query (v0-mbql/import-dataset-query representation ref-index)]
(-> {:name (or (:display_name representation)
(:name representation))
:description (:description representation)
:display (:display representation)
:dataset_query query
:database_id database-id
:query_type (if (lib/native-only-query? query) :native :query)
:type :question}
u/remove-nils)))
(defn persist!
"Persist a v0 question representation by creating or updating it in the database."
[representation ref-index]
(let [question-data (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/Card))
entity-id (:entity_id question-data)
existing (when entity-id
(t2/select-one :model/Card :entity_id entity-id))]
(if existing
(do
(log/info "Updating existing question" (:name question-data) "with ref" (:name representation))
(t2/update! :model/Card (:id existing) (dissoc question-data :entity_id))
(t2/select-one :model/Card :id (:id existing)))
(do
(log/info "Creating new question" (:name question-data))
(first (t2/insert-returning-instances! :model/Card question-data))))))
;; -- Export --
(defn export-question
"Export a Question Card Toucan entity to a v0 question representation."
[card]
(let [card-ref (v0-common/unref (v0-common/->ref (:id card) :question))]
(-> (ordered-map
:name card-ref
:type (:type card)
:version :v0
:display_name (:name card)
:description (:description card))
(merge (v0-mbql/export-dataset-query (:dataset_query card)))
u/remove-nils)))

View File

@@ -0,0 +1,70 @@
(ns metabase-enterprise.representations.v0.snippet
(:require
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase.lib.native :as lib.native]
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defmethod v0-common/representation-type :model/NativeQuerySnippet [_entity]
:snippet)
(def toucan-model
"The toucan model keyword associated with snippet representations"
:model/NativeQuerySnippet)
(defn yaml->toucan
"Convert a v0 snippet representation to Toucan-compatible data."
[{:keys [name display_name description sql collection entity-id] :as representation}
_ref-index]
(-> {:name (or display_name name)
:description description
:content sql
:collection_id (v0-common/find-collection-id collection)
:template_tags (lib.native/recognize-template-tags sql)}
u/remove-nils))
(defn persist!
"Persist a v0 snippet representation by creating or updating it in the database."
[representation ref-index]
(let [snippet-data (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
entity-id (:entity_id snippet-data)
existing (when entity-id (t2/select-one :model/NativeQuerySnippet :entity_id entity-id))]
(if existing
(do
(log/info "Updating existing snippet" (:name snippet-data) "with name" (:name representation))
(t2/update! :model/NativeQuerySnippet (:id existing) (dissoc snippet-data :entity_id))
(t2/select-one :model/NativeQuerySnippet :id (:id existing)))
(do
(log/info "Creating new snippet" (:name snippet-data))
(first (t2/insert-returning-instances! :model/NativeQuerySnippet snippet-data))))))
(defn- template-tag-ref
"Given a template tag map, return its ref string."
[template-tag]
(case (:type template-tag)
:snippet (v0-common/->ref (:snippet-id template-tag) :snippet)
:card (v0-common/->ref (:card-id template-tag) :card)
(throw (ex-info (str "Unknown template tag type " (:type template-tag))
{:template-tag template-tag}))))
(defn export-snippet
"Export a NativeQuerySnippet Toucan entity to a v0 snippet representation."
[snippet]
(let [snippet-ref (v0-common/unref (v0-common/->ref (:id snippet) :snippet))
template-tags (into {}
(comp
(filter (fn [[_ v]] (not= (:type v) :text)))
(map (fn [[k v]] [k (template-tag-ref v)])))
(:template_tags snippet))]
{:name snippet-ref
:type :snippet
:version :v0
:display_name (:name snippet)
:description (:description snippet)
:sql (:content snippet)
:template_tags template-tags}))

View File

@@ -0,0 +1,121 @@
(ns metabase-enterprise.representations.v0.transform
"The v0 transform representation namespace."
(:require
[flatland.ordered.map :refer [ordered-map]]
[metabase-enterprise.representations.lookup :as lookup]
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.mbql :as v0-mbql]
[metabase.util :as u]
[metabase.util.log :as log]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(defmethod v0-common/representation-type :model/Transform [_entity]
:transform)
(def toucan-model
"The toucan model keyword associated with transform representations"
:model/Transform)
(defn yaml->toucan
"Convert a v0 transform representation to Toucan-compatible data."
[{:keys [database] :as representation}
ref-index]
(let [database-id (-> ref-index
(v0-common/lookup-entity database)
(v0-common/ensure-correct-type :database)
(or (lookup/lookup-by-name :model/Database database))
(or (lookup/lookup-by-id :model/Database database))
:id
(v0-common/ensure-not-nil))
query (v0-mbql/import-dataset-query representation ref-index)]
(-> {:database database-id
:name (or (:display_name representation)
(:name representation))
:description (:description representation)
:source {:type "query"
:query query}
:target {:type "table"
:schema (-> representation :target_table :schema)
:name (-> representation :target_table :table)}}
u/remove-nils)))
(defn- set-up-tags [transform-id tags]
(when (seq tags)
(let [existing-tags (t2/select :model/TransformTag :name [:in tags])
missing-tags (reduce disj (set tags) (map :name existing-tags))
new-tags (t2/insert-returning-instances! :model/TransformTag (for [tag missing-tags] {:name tag}))
by-name (into {} (map (juxt :name identity)) (concat existing-tags new-tags))]
(t2/insert! :model/TransformTransformTag (for [[i tag] (map vector (range) tags)]
{:transform_id transform-id
:tag_id (-> tag by-name :id)
:position i})))))
(defn insert!
"Insert a v0 transform as a new entity, handling tags as well"
[representation ref-index]
(let [representation (rep-read/parse representation)]
(assert (= :transform (:type representation)))
(let [toucan (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/Transform))
transform (t2/insert-returning-instance! :model/Transform toucan)]
(set-up-tags (:id transform) (:tags representation))
(t2/hydrate transform :transform_tag_names))))
(defn update!
"Update an existing v0 transform from a representation."
[representation id ref-index]
(let [representation (rep-read/parse representation)]
(assert (= :transform (:type representation)))
(let [toucan (yaml->toucan representation ref-index)]
(t2/update! :model/Transform id (dissoc toucan :entity_id))
(t2/delete! :model/TransformTransformTag :transform_id id)
(set-up-tags id (:tags representation))
(t2/hydrate (t2/select-one :model/Transform :id id) :transform_tag_names))))
(defn persist!
"Persist a v0 transform representation by creating or updating it in the database."
[representation ref-index]
(let [transform-data (->> (yaml->toucan representation ref-index)
(rep-t2/with-toucan-defaults :model/Transform))
entity-id (:entity_id transform-data)
existing (when entity-id
(t2/select-one :model/Transform :entity_id entity-id))]
(if existing
(do
(log/info "Updating existing transform" (:name transform-data) "with name" (:name representation))
(t2/update! :model/Transform (:id existing) (dissoc transform-data :entity_id))
(t2/delete! :model/TransformTransformTag :transform_id (:id existing))
(set-up-tags (:id existing) (:tags representation))
(t2/hydrate (t2/select-one :model/Transform :id (:id existing)) :transform_tag_names))
(do
(log/info "Creating new transform" (:name transform-data))
(let [transform (t2/insert-returning-instance! :model/Transform transform-data)]
(set-up-tags (:id transform) (:tags representation))
(t2/hydrate transform :transform_tag_names))))))
;; EXPORT
(defn export-transform
"Export a Transform Toucan entity to a v0 transform representation."
[transform]
(let [query (v0-mbql/export-dataset-query (-> transform :source :query))]
(cond-> (ordered-map
:name (v0-common/unref (v0-common/->ref (:id transform) :transform))
:type :transform
:version :v0
:tags (:tags transform)
:display_name (:name transform)
:description (:description transform)
:database (:database query))
(#{"table" :table} (-> transform :target :type))
(assoc :target_table {:schema (-> transform :target :schema)
:table (-> transform :target :name)})
:always
(assoc :query (:query query))
:always
u/remove-nils)))

View File

@@ -0,0 +1,27 @@
(ns metabase-enterprise.representations.yaml
"Centralized YAML utilities for representations with consistent formatting options."
(:require
[metabase.util.yaml :as yaml]))
(def ^:private yaml-options
"Standard YAML formatting options for all representations."
{:dumper-options
{:flow-style :block
:indent 2
:indicator-indent 2
:indent-with-indicator true}})
(defn generate-string
"Generate YAML string from EDN data with standard representations formatting."
[data]
(yaml/generate-string data yaml-options))
(defn parse-string
"Parse YAML string to EDN data."
[s]
(yaml/parse-string s))
(defn from-file
"Read and parse YAML file to EDN data."
[filename]
(yaml/from-file filename))

View File

@@ -110,6 +110,23 @@
tag-associations)]
(for [transform transforms] (assoc transform :tag_ids (vec (get transform-id->tag-ids (:id transform) [])))))))
(mi/define-batched-hydration-method transform-tag-names
:transform_tag_names
"Add tag_ids to a transform, preserving the order defined by position"
[transforms]
(if-not (seq transforms)
transforms
(let [transforms (t2/hydrate transforms :transform_tag_ids)
tag-ids (set (mapcat :tag_ids transforms))
tags (when (seq tag-ids)
(t2/select [:model/TransformTag :id :name]
:id [:in tag-ids]))
tag-index (into {} (for [{:keys [id name]} tags] [id (str name)]))]
(for [transform transforms]
(cond-> (dissoc transform :tag_ids)
(seq (:tag_ids transform))
(assoc :tags (mapv tag-index (:tag_ids transform))))))))
(t2/define-after-insert :model/Transform [transform]
(events/publish-event! :event/create-transform {:object transform})
transform)

View File

@@ -0,0 +1,199 @@
(ns metabase-enterprise.representations.core-test
(:require
[clojure.test :refer [deftest is testing use-fixtures]]
[metabase-enterprise.representations.core :as rep]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.model]
[metabase-enterprise.representations.v0.question]
[metabase-enterprise.representations.yaml :as rep-yaml]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[representations.read :as rep-read]))
(set! *warn-on-reflection* true)
(use-fixtures :once (fixtures/initialize :db))
(deftest check-its-running
(is true))
(deftest export-import-test
(testing "Export database and question with refs, then import in order"
(mt/with-temp [:model/Database database {:name "Test Database for Refs"
:engine :h2
:details {}}]
(let [db-id (:id database)
db-export (rep/export database)
db-ref (:name db-export)]
(mt/with-temp [:model/Card question {:name "Test Question with Ref"
:type :question
:database_id db-id
:dataset_query {:database db-id
:type :native
:native {:query "SELECT 1"}}}]
(let [question-export (rep/export question)]
(is (= (str "ref:database-" db-id) (:database question-export))
"Question export should reference database with ref")
(is (= db-ref (str "database-" db-id))
"Database export should have correct ref")
(let [db-yaml (rep-yaml/generate-string db-export)
question-yaml (rep-yaml/generate-string question-export)
db-rep (rep-yaml/parse-string db-yaml)
question-rep (rep-yaml/parse-string question-yaml)]
(is (rep-read/parse db-rep)
"Database representation should validate")
(is (rep-read/parse question-rep)
"Question representation should validate")
(let [persisted-db (rep/persist! db-rep nil)
ref-index (v0-common/map-entity-index {db-ref persisted-db})
persisted-question (rep/persist! question-rep ref-index)]
(is persisted-db "Database should persist successfully")
(is persisted-question "Question should persist successfully")
(is (= (:id persisted-db) (:database_id persisted-question))
"Imported question should reference imported database")
(let [re-exported (rep/export persisted-question)
re-exported-yaml (rep-yaml/generate-string re-exported)
re-exported-rep (rep-yaml/parse-string re-exported-yaml)]
(is (=? (dissoc question-rep :name :entity-id) re-exported-rep)
"Re-exported representation should match original"))))))))))
;; TODO: Reproduce test coverage but incorporating shipping changes
#_(deftest mbql-question-roundtrip-test
(testing "Export and import MBQL-based question with separate MBQL data"
(mt/with-temp [:model/Card question {:name "MBQL Question Test"
:type :question
:database_id (mt/id)
:dataset_query (mt/mbql-query users {:limit 10})}]
(let [card-export (rep/export question)
card-yaml (yaml/generate-string card-export)
card-rep (yaml/parse-string card-yaml)]
(is (rep-read/parse card-rep)
"MBQL question export should validate")
(is (string? (:mbql_query card-rep))
"Export should have mbql_query as ref string")
(let [mbql-export (export/export-mbql-data question)
mbql-yaml (-> mbql-export export/export-entity yaml/generate-string)
mbql-rep (yaml/parse-string mbql-yaml)
db (mt/db)
db-ref (str "database-" (mt/id))
mbql-ref (:ref mbql-rep)
mbql-data (rep/persist! mbql-rep nil)
ref-index {db-ref db
mbql-ref mbql-data}
persisted (rep/persist! card-rep ref-index)]
(is mbql-data "MBQL data should persist to ref-index")
(is persisted "MBQL question should persist successfully")
(let [re-exported-card (rep/export persisted)
re-exported-card-yaml (yaml/generate-string re-exported-card)
re-exported-card-rep (yaml/parse-string re-exported-card-yaml)]
(is (=? (dissoc card-rep :ref) (dissoc re-exported-card-rep :ref))
"Re-exported MBQL question should match original")))))))
#_(deftest mbql-model-roundtrip-test
(testing "Export and import MBQL-based model with separate MBQL data and result_metadata"
(mt/with-temp [:model/Card model {:name "MBQL Model Test"
:type :model
:database_id (mt/id)
:dataset_query (mt/mbql-query users {:limit 5})
:result_metadata [{:name "ID"
:display_name "ID"
:base_type :type/Integer}
{:name "NAME"
:display_name "Name"
:base_type :type/Text}]}]
(let [card-export (rep/export model)
card-yaml (yaml/generate-string card-export)
card-rep (yaml/parse-string card-yaml)]
(is (rep-read/parse card-rep)
"MBQL model export should validate")
(is (string? (:mbql_query card-rep))
"Export should have mbql_query as ref string")
(let [mbql-export (export/export-mbql-data model)
mbql-yaml (-> mbql-export export/export-entity yaml/generate-string)
mbql-rep (yaml/parse-string mbql-yaml)
db (mt/db)
db-ref (str "database-" (mt/id))
mbql-ref (:ref mbql-rep)
mbql-data (rep/persist! mbql-rep nil)
ref-index {db-ref db
mbql-ref mbql-data}
persisted (rep/persist! card-rep ref-index)]
(is mbql-data "MBQL data should persist to ref-index")
(is (:result_metadata mbql-data) "MBQL data should contain result_metadata")
(is persisted "MBQL model should persist successfully")
(let [re-exported-card (rep/export persisted)
re-exported-card-yaml (yaml/generate-string re-exported-card)
re-exported-card-rep (yaml/parse-string re-exported-card-yaml)]
(is (=? (-> card-rep (dissoc :ref :mbql_query))
(-> re-exported-card-rep (dissoc :ref :mbql_query)))
"Re-exported MBQL model should match original (excluding refs)")
(is (string? (:mbql_query re-exported-card-rep))
"Re-exported model should still have MBQL ref")))))))
#_(deftest collection-export-with-mbql-files-test
(testing "Collection export creates separate .mbql.yml files for MBQL cards"
(mt/with-temp [:model/Collection collection {:name "Test Collection"}]
(let [coll-id (:id collection)]
(mt/with-temp [:model/Card _question {:name "MBQL Question"
:type :question
:database_id (mt/id)
:collection_id coll-id
:dataset_query (mt/mbql-query venues {:limit 10})}
:model/Card _model {:name "MBQL Model"
:type :model
:database_id (mt/id)
:collection_id coll-id
:dataset_query (mt/mbql-query users {:limit 5})}
:model/Card _native-q {:name "Native Question"
:type :question
:database_id (mt/id)
:collection_id coll-id
:dataset_query {:database (mt/id)
:type :native
:native {:query "SELECT 1"}}}]
;; Export the collection with user context
(mt/with-test-user :rasta
(let [export-dir (str "test_resources/representations/v0/test-export-" (random-uuid) "/")
_ (rep/export-collection-representations coll-id export-dir)
coll-dir (str export-dir (v0-common/file-sys-name coll-id (:name collection) "/"))
files (-> (io/file coll-dir)
.listFiles
seq
(->> (map #(.getName ^java.io.File %)))
set)
yaml-files (filter #(re-matches #".*\.yml" %) files)
mbql-files (filter #(re-matches #".*\.mbql\.yml" %) files)
db-files (filter #(re-matches #".*\.database\.yml" %) files)]
(testing "Correct number of each file type"
(is (= 6 (count yaml-files))
"Should have 6 YAML files (1 database, 3 cards, 2 MBQL files)")
(is (= 2 (count mbql-files))
"Should have 2 MBQL files (question + model, not native)")
(is (= 1 (count db-files))
"Should have 1 database file"))
;; Cleanup - recursively delete directory
(letfn [(delete-dir [^java.io.File file]
(when (.isDirectory file)
(doseq [child (.listFiles file)]
(delete-dir child)))
(.delete file))]
(delete-dir (io/file export-dir))))))))))

View File

@@ -0,0 +1,105 @@
(ns metabase-enterprise.representations.export-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(deftest export-entity-all-entities
(doseq [model [:model/Card :model/Database :model/Transform :model/Collection :model/NativeQuerySnippet]
entity (t2/select model)]
(mt/with-test-user :crowberto
(is (export/export-entity entity)))))
(deftest rename-refs-empty
(is (= [] (export/rename-refs []
export/ref-from-name
export/standard-ref-strategies
export/add-sequence-number))))
(deftest rename-refs-one
(is (= [{:name "xyz"}]
(export/rename-refs [{:name "abc"}]
(fn [reps] (map #(assoc % ::export/proposed-ref "xyz") reps))
[]
export/add-sequence-number))))
(defn- unique-names?
[representations]
(= (count representations)
(count (into #{} (map :name) representations))))
(deftest hard-one
(is (unique-names? (export/rename-refs [{:name "1" :display_name "b-question-1" :type :question}
{:name "2" :display_name "b" :type :question}
{:name "3" :display_name "b" :type :question}]
export/ref-from-name
export/standard-ref-strategies
export/add-sequence-number))))
(deftest hard-one-2
(let [reps [{:name "1" :display_name "b" :type :question :database "ref:2"}
{:name "2" :display_name "b" :type :database}]
reps' (export/rename-refs reps
export/ref-from-name
export/standard-ref-strategies
export/add-sequence-number)]
(is (= (->> reps'
(filter #(= :question (:type %)))
:database
v0-common/unref)
(->> reps'
(filter #(= :database (:type %)))
:name)))))
(deftest munge-name-english-test
(testing "Basic English names"
(is (= "hello-world" (#'export/munge-name "Hello World")))
(is (= "my-dashboard" (#'export/munge-name "My Dashboard")))
(is (= "user-report-2024" (#'export/munge-name "User Report 2024")))
(is (= "sales-data" (#'export/munge-name "Sales_Data")))
(is (= "trim-spaces" (#'export/munge-name " Trim Spaces ")))))
(deftest munge-name-accented-characters-test
(testing "Accented characters should be preserved"
(is (= "café" (#'export/munge-name "Café")))
(is (= "naïve" (#'export/munge-name "Naïve")))
(is (= "résumé" (#'export/munge-name "Résumé")))
(is (= "señor" (#'export/munge-name "Señor")))
(is (= "zürich" (#'export/munge-name "Zürich")))
(is (= "crème-brûlée" (#'export/munge-name "Crème Brûlée")))))
(deftest munge-name-emoji-test
(testing "Emojis should be replaced with hyphens"
(is (= "hello-world" (#'export/munge-name "Hello 😀 World")))
(is (= "report-2024" (#'export/munge-name "Report 📊 2024")))
(is (= "dashboard" (#'export/munge-name "🎯 Dashboard 🎯")))
(is (= "sales-data" (#'export/munge-name "Sales 💰 Data")))
(is (= "check-this-out" (#'export/munge-name "Check ✅ This ✅ Out")))))
(deftest munge-name-cjk-test
(testing "Chinese characters should not be replaced with hyphens"
(is (= "用户报告" (#'export/munge-name "用户报告")))
(is (= "销售数据-2024" (#'export/munge-name "销售数据 2024")))
(is (= "我的仪表板" (#'export/munge-name "我的仪表板"))))
(testing "Japanese characters should not be replaced with hyphens"
(is (= "ユーザーレポート" (#'export/munge-name "ユーザーレポート")))
(is (= "売上データ" (#'export/munge-name "売上データ")))
(is (= "私のダッシュボード" (#'export/munge-name "私のダッシュボード"))))
(testing "Korean characters should not be replaced with hyphens"
(is (= "사용자-보고서" (#'export/munge-name "사용자 보고서")))
(is (= "판매-데이터" (#'export/munge-name "판매 데이터")))
(is (= "내-대시보드" (#'export/munge-name "내 대시보드")))))
(deftest munge-name-mixed-test
(testing "Mixed scripts and special characters"
(is (= "hello-世界" (#'export/munge-name "Hello 世界")))
(is (= "café-日本" (#'export/munge-name "Café 日本")))
(is (= "report-2024" (#'export/munge-name "Report!!! 2024")))
(is (= "my-dashboard" (#'export/munge-name "My---Dashboard")))))

View File

@@ -0,0 +1,8 @@
(ns metabase-enterprise.representations.import-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.import :as import]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase.test.fixtures :as fixtures]))
(use-fixtures :once (fixtures/initialize :db))

View File

@@ -0,0 +1,72 @@
(ns metabase-enterprise.representations.lookup-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.lookup :as lookup]
[toucan2.core :as t2]))
(deftest lookup-by-id-test
(doseq [model [:model/Card
:model/Database
:model/Transform
:model/Collection
:model/NativeQuerySnippet]
entity (t2/select model)]
(let [id (:id entity)]
(is (= id (:id (lookup/lookup-by-id model id)))))))
(deftest lookup-by-id-string-test
(testing "lookup-by-id handles string id gracefully (returns nil)"
(doseq [model [:model/Card
:model/Database
:model/Transform
:model/Collection
:model/NativeQuerySnippet]
entity (t2/select model)]
(let [id (str (:id entity))]
(is (nil? (lookup/lookup-by-id model id)))))))
(deftest lookup-by-entity-id-test
(doseq [model [:model/Card
:model/Transform
:model/Collection
:model/NativeQuerySnippet
;; Databases do not have entity ids
]
entity (t2/select model)]
(let [entity-id (:entity_id entity)]
(is (= (:id entity) (:id (lookup/lookup-by-entity-id model entity-id)))))))
;; Wat?
#_(deftest lookup-by-entity-id-long-test
(testing "lookup-by-entity-id handles long entity-id gracefully (returns nil)"
(doseq [model [:model/Card
:model/Transform
:model/Collection
:model/NativeQuerySnippet]]
(let [entity-id (rand-int 1000)]
(is (nil? (lookup/lookup-by-entity-id model entity-id)))))))
(deftest lookup-by-name-test
(doseq [model [:model/Card
:model/Transform
:model/Collection
:model/NativeQuerySnippet
:model/Database]]
(let [entities (t2/select model)]
(doseq [[name group] (group-by :name entities)]
(if (= 1 (count group))
(let [entity (first group)]
(is (= (:id entity) (:id (lookup/lookup-by-name model name)))))
(is (thrown? clojure.lang.ExceptionInfo
(lookup/lookup-by-name model name))))))))
(deftest lookup-by-name-non-string-test
(testing "lookup-by-name handles non-string name gracefully (returns nil)"
(doseq [model [:model/Card
:model/Transform
:model/Collection
:model/NativeQuerySnippet
:model/Database]
entity (t2/select model)]
(let [name-as-keyword (keyword (:name entity))]
(is (nil? (lookup/lookup-by-name model name-as-keyword)))))))

View File

@@ -0,0 +1,78 @@
(ns metabase-enterprise.representations.v0.collection-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.import :as import]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.yaml :as yaml]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(comment
(deftest validate-exported-collections-test
(testing "Exported collections validate against schema"
(mt/with-temp [:model/Collection collection {:name "Test Collection"
:description "A test collection"}]
(let [edn (export/export-entity collection)
yaml-str (yaml/generate-string edn)
rep (yaml/parse-string yaml-str)]
(is (rep-read/parse rep))))))
(deftest export-collection-test
(testing "Collection exports with correct structure"
(mt/with-temp [:model/Collection collection {:name "Test Collection"
:description "A test collection"}]
(let [exported (export/export-entity collection)]
(is (= :collection (:type exported)))
(is (= :v0 (:version exported)))
(is (= "Test Collection" (:name exported)))
(is (= "A test collection" (:description exported)))
(is (:ref exported))
(is (string? (:ref exported)))))))
(deftest export-nested-collection-test
(testing "Nested collection exports with parent reference"
(mt/with-temp [:model/Collection parent {:name "Parent Collection"}
:model/Collection child {:name "Child Collection"
:location (format "/%d/" (:id parent))}]
(let [exported (export/export-entity child)]
(is (= :collection (:type exported)))
(is (= "Child Collection" (:name exported)))
(is (:collection exported))
(is (string? (:collection exported)))))))
(deftest import-collection-test
(testing "Collection can be imported from representation"
(let [rep {:type :collection
:version :v0
:ref "test-collection"
:name "Imported Collection"
:description "A collection imported from YAML"}
toucan-model (import/yaml->toucan rep nil)]
(is (= "Imported Collection" (:name toucan-model)))
(is (= "A collection imported from YAML" (:description toucan-model))))))
(deftest roundtrip-collection-test
(testing "Collection export → import → export preserves data"
(mt/with-temp [:model/Collection collection {:name "Roundtrip Test"
:description "Testing roundtrip"}]
(let [export-1 (export/export-entity collection)
yaml-str (yaml/generate-string export-1)
rep (yaml/parse-string yaml-str)
normalized (rep-read/parse rep)
imported (import/yaml->toucan normalized nil)
;; Persist and reload
persisted (mt/with-test-user :crowberto
(import/persist! normalized nil))
export-2 (export/export-entity persisted)]
(is (= (:name export-1) (:name export-2)))
(is (= (:description export-1) (:description export-2)))
(is (= (:type export-1) (:type export-2))))))))
(deftest representation-type-test
(doseq [entity (t2/select :model/Collection)]
(is (= :collection (v0-common/representation-type entity)))))

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 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,172 @@
(ns metabase-enterprise.representations.v0.dashboard-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.import :as import]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.dashboard :as v0-dashboard]
[metabase-enterprise.representations.yaml :as rep-yaml]
[metabase.lib.core :as lib]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(deftest export-dashboard-test
(testing "Exporting a dashboard produces valid representation"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Test Collection"}
:model/Card {card-id :id} {:type :question
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
:model/Dashboard {dashboard-id :id} {:name "Test Dashboard"
:collection_id collection-id
:parameters []}
:model/DashboardCard {_dashcard-id :id} {:dashboard_id dashboard-id
:card_id card-id
:row 0
:col 0
:size_x 4
:size_y 4}]
(let [exported (export/export-entity (t2/select-one :model/Dashboard :id dashboard-id))]
(testing "has required fields"
(is (= :dashboard (:type exported)))
(is (= :v0 (:version exported)))
(is (= "Test Dashboard" (:display_name exported)))
(is (some? (:name exported))))
(testing "includes dashcards"
(is (seq (:dashcards exported)))
(is (= 1 (count (:dashcards exported)))))
(testing "dashcard has card reference"
(let [dashcard (first (:dashcards exported))
card-ref (:card_id dashcard)]
(is (some? card-ref))
(is (string? card-ref))
(is (re-matches #"ref:question-\d+" card-ref))))))))
(deftest export-import-dashboard-roundtrip-test
(testing "Export then import roundtrip for dashboard"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Dashboard Collection"}
:model/Card {card-id :id} {:type :question
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
:model/Dashboard {dashboard-id :id}
{:name "My Dashboard"
:collection_id collection-id
:parameters []
:description "A test dashboard"}
:model/DashboardCard {dashcard-id :id}
{:dashboard_id dashboard-id
:card_id card-id
:row 0
:col 0
:size_x 4
:size_y 4}]
(let [dashboard (t2/select-one :model/Dashboard :id dashboard-id)
dashboard-edn (export/export-entity dashboard)
dashboard-yaml (rep-yaml/generate-string dashboard-edn)
dashboard-rep (rep-yaml/parse-string dashboard-yaml)
collection (t2/select-one :model/Collection :id collection-id)
card (t2/select-one :model/Card :id card-id)
ref-index (v0-common/map-entity-index
{(v0-common/unref (v0-common/->ref collection-id :collection))
collection
(v0-common/unref (v0-common/->ref card-id :question))
card})
;; Persist the imported dashboard
_ (import/update! dashboard-rep dashboard-id ref-index)
imported-dashboard (t2/select-one :model/Dashboard :id dashboard-id)
;; Export again and compare
edn2 (export/export-entity imported-dashboard)
yaml2 (rep-yaml/generate-string edn2)
rep2 (rep-yaml/parse-string yaml2)]
(testing "roundtrip preserves dashboard name"
(is (= "My Dashboard" (:display_name rep2))))
(testing "roundtrip preserves dashboard type"
(is (= "dashboard" (name (:type rep2)))))
(testing "roundtrip preserves dashcards"
(is (seq (:dashcards rep2)))
(is (= 1 (count (:dashcards rep2)))))))))
(deftest representation-type-test
(testing "representation-type multimethod returns :dashboard"
(mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "Type Test Dashboard"
:parameters []}]
(let [dashboard (t2/select-one :model/Dashboard :id dashboard-id)]
(is (= :dashboard (v0-common/representation-type dashboard)))))))
(deftest dashboard-with-tabs-test
(testing "Dashboard with tabs exports and imports correctly"
(mt/with-temp [:model/Card {card-id :id} {:type :question
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
:model/Dashboard {dashboard-id :id} {:name "Tabbed Dashboard"
:parameters []}
:model/DashboardTab {tab1-id :id} {:dashboard_id dashboard-id
:name "Tab 1"
:position 0}
:model/DashboardTab {tab2-id :id} {:dashboard_id dashboard-id
:name "Tab 2"
:position 1}
:model/DashboardCard {_dashcard-id :id} {:dashboard_id dashboard-id
:card_id card-id
:dashboard_tab_id tab1-id
:row 0
:col 0
:size_x 4
:size_y 4}]
(let [exported (export/export-entity (t2/select-one :model/Dashboard :id dashboard-id))]
(testing "exports tabs"
(is (seq (:tabs exported)))
(is (= 2 (count (:tabs exported)))))
(testing "tab structure is correct"
(let [tab1 (first (:tabs exported))]
(is (some? (:name tab1)))
(is (number? (:position tab1)))))
(testing "dashcard references tab"
(let [dashcard (first (:dashcards exported))]
(is (some? (:dashboard_tab_id dashcard)))))))))
(deftest dashboard-card-embed-refs-test
(testing "Dashboard with embedded cards creates proper references"
(mt/with-temp [:model/Card {card-id :id} {:type :question
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
:model/Dashboard {dashboard-id :id} {:name "Test Dashboard"
:parameters []}
:model/DashboardCard {_dashcard-id :id} {:dashboard_id dashboard-id
:card_id card-id
:row 0
:col 0
:size_x 4
:size_y 4}]
(let [rep (export/export-entity (t2/select-one :model/Dashboard :id dashboard-id))
refs (v0-common/refs rep)]
(testing "dashboard references the embedded card"
(is (contains? refs (str "question-" card-id))))))))
(deftest text-dashcard-test
(testing "Text-only dashcard (no card_id) exports correctly"
(mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "Dashboard with Text"
:parameters []}
:model/DashboardCard {_dashcard-id :id} {:dashboard_id dashboard-id
:card_id nil
:row 0
:col 0
:size_x 4
:size_y 2
:visualization_settings {:text "Hello, World!"}}]
(let [exported (export/export-entity (t2/select-one :model/Dashboard :id dashboard-id))]
(testing "includes text dashcard"
(is (seq (:dashcards exported)))
(is (= 1 (count (:dashcards exported)))))
(testing "text dashcard has nil card_id"
(let [dashcard (first (:dashcards exported))]
(is (nil? (:card_id dashcard)))
(is (some? (get-in dashcard [:visualization_settings :text])))))))))

View File

@@ -0,0 +1,47 @@
(ns metabase-enterprise.representations.v0.database-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.core :as rep]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.yaml :as rep-yaml]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(deftest export-import
(testing "Testing export then import roundtrip"
(mt/with-temp [:model/Database database {:engine :postgres
:name "abc"
:details "{}"}]
(let [edn (rep/export database)
yaml (rep-yaml/generate-string edn)
rep (rep-yaml/parse-string yaml)
rep (rep-read/parse rep)
database (rep/persist! rep)
database (t2/select-one :model/Database :id (:id database))
edn (rep/export database)
yaml (rep-yaml/generate-string edn)
rep2 (rep-yaml/parse-string yaml)
rep2 (rep-read/parse rep2)]
(is (=? (dissoc rep :name) rep2)))))
(testing "Testing export then import roundtrip, import doesn't change details"
(mt/with-temp [:model/Database database {:engine :postgres
:name "abc"
:description "MY DB"}]
(let [edn (rep/export database)
edn (assoc edn :description "CHANGE")
yaml (rep-yaml/generate-string edn)
rep (rep-yaml/parse-string yaml)
rep (rep-read/parse rep)
database2 (rep/persist! rep)
database2 (t2/select-one :model/Database :id (:id database2))]
(is (=? database database2))))))
(deftest representation-type-test
(doseq [entity (t2/select :model/Database)]
(is (= :database (v0-common/representation-type entity)))))

View File

@@ -0,0 +1,103 @@
(ns metabase-enterprise.representations.v0.document-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.representations.core :as rep]
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.import :as import]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.v0.document :as v0-document]
[metabase-enterprise.representations.yaml :as rep-yaml]
[metabase.lib.core :as lib]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
#_(deftest markdown-prosemirror-roundtrip
(let [md "# Hello"
pm (#'v0-document/markdown->yaml md)
edn (rep-yaml/parse-string pm)
md2 (#'v0-document/edn->markdown edn)
pm2 (#'v0-document/markdown->yaml md2)
edn2 (rep-yaml/parse-string pm2)]
(is (= md md2))
(is (= edn edn2))))
(deftest export-document-test
(testing "Exporting a document produces valid EDN"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Test Collection"}
:model/Document {document-id :id} {:name "Test Document"
:collection_id collection-id
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello, world!"}]}]}}]
(let [exported (export/export-entity (t2/select-one :model/Document :id document-id))]
(testing "has required fields"
(is (= :document (:type exported)))
(is (= :v0 (:version exported)))
(is (= "Test Document" (:display_name exported)))
(is (some? (:name exported)))
(is (= :prosemirror (:content_type exported))))))))
(deftest export-import-singleton-test
(testing "Export then import roundtrip for document"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Document Collection"}
:model/Document {document-id :id}
{:name "My Document"
:collection_id collection-id
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Test content"}]}
{:type "heading"
:attrs {:level 2}
:content [{:type "text"
:text "Section Title"}]}]}}]
(let [document (t2/select-one :model/Document :id document-id)
document-edn (export/export-entity document)
document-yaml (rep-yaml/generate-string document-edn)
document-rep (rep-yaml/parse-string document-yaml)
collection (t2/select-one :model/Collection :id collection-id)
ref-index (v0-common/map-entity-index
{(v0-common/unref (v0-common/->ref collection-id :collection))
collection})
;; Persist the imported document
_ (import/update! document-rep document-id ref-index)
imported-document (t2/select-one :model/Document :id document-id)
;; Export again and compare
edn2 (export/export-entity imported-document)
yaml2 (rep-yaml/generate-string edn2)
rep2 (rep-yaml/parse-string yaml2)]
(testing "roundtrip preserves document name"
(is (= "My Document" (:display_name rep2))))
(testing "roundtrip preserves document type"
(is (= "document" (name (:type rep2)))))))))
(deftest representation-type-test
(testing "representation-type multimethod returns :document"
(mt/with-temp [:model/Document {document-id :id} {:name "Type Test Document"
:document {:type "doc"
:content []}}]
(let [document (t2/select-one :model/Document :id document-id)]
(is (= :document (v0-common/representation-type document)))))))
(deftest document-card-embed-refs
(mt/with-temp [:model/Card card {:type :question
:dataset_query (lib/native-query (mt/metadata-provider) "select 1")}
:model/Document document
{:name "My Document"
:document {:type "resizeNode"
:attrs {:height 442
:minHeight 280}
:content [{:type "cardEmbed"
:attrs {:id (:id card)}}]}}]
(let [rep (export/export-entity document)
refs (v0-common/refs rep)]
(is (contains? refs (str "question-" (:id card)))))))

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,45 @@
(ns metabase-enterprise.representations.v0.metric-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.core :as rep]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.yaml :as rep-yaml]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(def valid-examples ["test_resources/representations/v0/select-1.metric.yml"])
(deftest validate-example-yamls
(testing "Testing valid examples"
(doseq [filename valid-examples]
(testing (str "Validating: " filename)
(let [rep (rep-yaml/from-file filename)]
(is (rep-read/parse rep)))))))
(deftest validate-exported-metrics
(doseq [query [(mt/native-query {:query "select 1"})
(mt/mbql-query users)]]
(mt/with-temp [:model/Card metric {:type :metric
:dataset_query query}]
(let [edn (rep/export metric)
;; convert to yaml and read back in to convert keywords to strings, etc
yaml (rep-yaml/generate-string edn)
rep (rep-yaml/parse-string yaml)]
(is (rep-read/parse rep))))))
(deftest can-import
(doseq [filename valid-examples]
(testing (str "Importing: " filename)
(let [rep (rep-yaml/from-file filename)
ref-index (-> {(v0-common/unref (:database rep))
(t2/select-one :model/Database (mt/id))}
(v0-common/map-entity-index))]
(is (rep/persist! rep ref-index))))))
(deftest representation-type-test
(doseq [entity (t2/select :model/Card :type :metric)]
(is (= :metric (v0-common/representation-type entity)))))

View File

@@ -0,0 +1,176 @@
(ns metabase-enterprise.representations.v0.model-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.core :as rep]
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.yaml :as yaml]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(deftest validate-example-yamls
(testing "Testing valid examples"
(doseq [filename
["test_resources/representations/v0/product-performance.model.yml"
"test_resources/representations/v0/collection-8/sales-data.model.yml"]]
(testing (str "Validating: " filename)
(let [rep (yaml/from-file filename)]
(is (rep-read/parse rep)))))))
(deftest validate-exported-models
(doseq [query [(mt/native-query {:query "select 1"})
(mt/mbql-query users)]]
(mt/with-temp [:model/Card model {:type :model
:dataset_query query}]
(let [edn (rep/export model)
;; convert to yaml and read back in to convert keywords to strings, etc
yaml (yaml/generate-string edn)
rep (yaml/parse-string yaml)]
(is (rep-read/parse rep))))))
;; TODO: replace old stale tests
;; TODO: Need to load sample database first
;; TODO: And NOW it's failing because there's more than one "Sample Database"
;; Clean up after we decide on testing framework for lookup-by-name
#_(deftest can-import
(doseq [filename ["test_resources/representations/v0/product-performance.model.yml"
#_"test_resources/representations/v0/collection-8/sales-data-enriched.model.yml"
#_"test_resources/representations/v0/collection-8/sales-data.model.yml"]]
(testing (str "Importing: " filename)
(let [rep (yaml/from-file filename)]
(is (rep/persist! (rep/normalize-representation rep)))))))
;; Dependent on MBQL work (QUE-2667)
#_(deftest export-import-roundtrip-test
(testing "Testing export then import roundtrip for models"
(doseq [query [(mt/native-query {:query "select 1"})
(mt/mbql-query users)]]
(mt/with-temp [:model/Card model {:type :model
:dataset_query query}]
(let [card-edn (rep/export model)
card-yaml (yaml/generate-string card-edn)
card-rep (yaml/parse-string card-yaml)
card-rep (rep/normalize-representation card-rep)
;; Build ref-index with database and MBQL data (if present)
ref-index {(v0-common/unref (:database card-edn)) (t2/select-one :model/Database (mt/id))}
_ (clojure.pprint/pprint card-rep)
model (rep/persist! card-rep ref-index)
model (t2/select-one :model/Card :id (:id model))
edn (rep/export model)
yaml (yaml/generate-string edn)
rep2 (yaml/parse-string yaml)
rep2 (rep/normalize-representation rep2)]
;; For models with MBQL, the mbql_query ref will differ due to new IDs
;; So we compare structure excluding :name and :mbql_query
(is (=? (dissoc card-rep :name :mbql_query) (dissoc rep2 :name :mbql_query))))))))
(deftest export-mbql-model-includes-columns-test
(testing "MBQL models export user-editable column metadata"
(mt/with-temp [:model/Card model {:type :model
:dataset_query (mt/mbql-query orders)}]
(let [exported (export/export-entity model)]
(testing "Exported representation has columns field"
(is (contains? exported :columns))
(is (seq (:columns exported))))
(testing "Each column has required name field"
(doseq [col (:columns exported)]
(is (:name col))))
(testing "Columns contain only user-editable fields"
(doseq [col (:columns exported)]
(is (nil? (:id col)))
(is (nil? (:table_id col)))
(is (nil? (:fingerprint col)))
(is (nil? (:field_ref col)))
(is (nil? (:base_type col)))
(is (nil? (:effective_type col)))))))))
#_(deftest import-user-columns-override-base-metadata-test
(testing "User-edited columns in model.yml override base metadata from mbql.yml"
(mt/with-temp [:model/Card model {:type :model
:dataset_query (mt/mbql-query orders)}]
(let [mbql-data (export/export-mbql-data model)
base-metadata (:result_metadata mbql-data)
base-subtotal (first (filter #(= "SUBTOTAL" (:name %)) base-metadata))
original-display-name (:display_name base-subtotal)
user-columns [{:name "SUBTOTAL"
:display_name "Order Total (Pre-Tax)"
:description "The subtotal amount before taxes are applied"
:semantic_type :type/Currency
:settings {:text_align "right" :currency "USD"}}]
model-rep {:type :v0/model
:name "test-model"
:display_name "Test Model"
:database (str "ref:database-" (mt/id))
:mbql_query (str "ref:" (:name mbql-data))
:columns user-columns}
ref-index {(:name mbql-data) mbql-data
(str "database-" (mt/id)) (t2/select-one :model/Database (mt/id))}
imported (import/yaml->toucan model-rep ref-index)
imported-metadata (:result_metadata imported)
imported-subtotal (first (filter #(= "SUBTOTAL" (:name %)) imported-metadata))]
(testing "User's display_name overrides base"
(is (= "Order Total (Pre-Tax)" (:display_name imported-subtotal)))
(is (not= original-display-name (:display_name imported-subtotal))))
(testing "User's description is added"
(is (= "The subtotal amount before taxes are applied"
(:description imported-subtotal))))
(testing "User's semantic_type overrides base"
(is (= :type/Currency (:semantic_type imported-subtotal))))
(testing "User's settings are merged with base settings"
(is (= "right" (get-in imported-subtotal [:settings :text_align])))
(is (= "USD" (get-in imported-subtotal [:settings :currency]))))
(testing "Base metadata fields are preserved"
(is (:id imported-subtotal) "Should preserve internal id")
(is (:field_ref imported-subtotal) "Should preserve field_ref")
(is (:base_type imported-subtotal) "Should preserve base_type"))))))
#_(deftest roundtrip-preserves-user-edits-test
(testing "Export → Edit → Import → Export cycle preserves user edits"
(mt/with-temp [:model/Card model {:type :model
:dataset_query (mt/mbql-query orders)}]
(let [export-1 (export/export-entity model)
mbql-data-1 (export/export-mbql-data model)
original-subtotal-name (-> export-1 :columns
(->> (filter #(= "SUBTOTAL" (:name %))))
first :display_name)
edited-rep (-> export-1
(assoc :type :v0/model)
(update :columns
(fn [cols]
(mapv (fn [col]
(if (= "SUBTOTAL" (:name col))
(assoc col
:display_name "Pre-Tax Total"
:description "Total before tax")
col))
cols))))
ref-index-1 {(:name mbql-data-1) mbql-data-1
(v0-common/unref (:database edited-rep)) (t2/select-one :model/Database (mt/id))}
imported (import/yaml->toucan edited-rep ref-index-1)
persisted (binding [api/*current-user-id* (mt/user->id :crowberto)]
(import/persist! edited-rep ref-index-1))
export-2 (export/export-entity persisted)
final-subtotal (-> export-2 :columns
(->> (filter #(= "SUBTOTAL" (:name %))))
first)]
(testing "Original export has original display name"
(is original-subtotal-name))
(testing "After import, model has edited values in result_metadata"
(let [imported-subtotal (first (filter #(= "SUBTOTAL" (:name %))
(:result_metadata imported)))]
(is (= "Pre-Tax Total" (:display_name imported-subtotal)))
(is (= "Total before tax" (:description imported-subtotal)))))
(testing "Second export preserves the user edits"
(is (= "Pre-Tax Total" (:display_name final-subtotal)))
(is (= "Total before tax" (:description final-subtotal))))
(testing "User edits persist through the full cycle"
(is (not= original-subtotal-name (:display_name final-subtotal))
"Final export should have edited name, not original"))))))
(deftest representation-type-test
(doseq [entity (t2/select :model/Card :type :model)]
(is (= :model (v0-common/representation-type entity)))))

View File

@@ -0,0 +1,124 @@
(ns metabase-enterprise.representations.v0.question-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.core :as rep]
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.import :as import]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.yaml :as rep-yaml]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(def singleton-yamls ["test_resources/representations/v0/singletons/monthly-revenue.question.yml"])
(deftest validate-example-yamls
(testing "Testing valid examples"
(doseq [filename singleton-yamls]
(testing (str "Validating: " filename)
(let [rep (rep-yaml/from-file filename)]
(is (rep/normalize-representation rep))))))
(testing "Testing invalid examples"
(doseq [filename
["test_resources/representations/v0/invalid.question.yml"]]
(testing (str "Validating: " filename)
(let [rep (rep-yaml/from-file filename)]
(is (thrown? clojure.lang.ExceptionInfo (rep-read/parse rep))))))))
(deftest validate-exported-questions
(doseq [query [(mt/native-query {:query "select 1"})
(mt/mbql-query users)]]
(mt/with-temp [:model/Card question {:type :question
:dataset_query query}]
(let [edn (rep/export question)
;; convert to yaml and read back in to convert keywords to strings, etc
yaml (rep-yaml/generate-string edn)
rep (rep-yaml/parse-string yaml)]
(is (rep-read/parse rep))))))
(deftest can-import
(let [filename "test_resources/representations/v0/monthly-revenue.question.yml"
rep (rep-yaml/from-file filename)
ref-index (v0-common/map-entity-index
{(v0-common/unref (:database rep))
(t2/select-one :model/Database (mt/id))})]
(is (rep/persist! rep ref-index))))
(deftest database-import-test
(mt/with-temp [:model/Card question {:type :question
:dataset_query (mt/mbql-query users)}]
(let [edn (rep/export question)
export-set (export/export-set [edn])
export-set (export/reduce-tables export-set)
db-ref (v0-common/unref (:database edn))
idx (into {} (map (juxt :name identity)) export-set)
database (get idx db-ref)]
(is (= 2 (count export-set)))
(is (= :database (:type database)))
(let [database-tables (set (for [schema (:schemas database)
table (:tables schema)]
[(:name schema) (:name table)]))]
(is (= #{["PUBLIC" "USERS"]} database-tables))))))
;; TODO: this test is fundamentally broken, trying to force the use of ID 1:
;; This fails in db lookup
#_(deftest import-export-singleton-test
(testing "Testing import then export roundtrip with IDs"
(doseq [filename singleton-yamls]
(testing (str "Importing-Exporting: " filename)
(let [rep (-> (import/import-yaml filename)
(assoc :database (v0-common/->ref 1 :database)))
persisted (rep/persist! rep
(v0-common/map-entity-index
{(v0-common/unref (v0-common/->ref 1 :database))
(t2/select-one :model/Database :id 1)}))]
(is persisted)
(let [question (t2/select-one :model/Card :id (:id persisted))
edn (export/export-entity question)
yaml (rep-yaml/generate-string edn)
rep2 (rep-yaml/parse-string yaml)]
(is (=? (dissoc rep :ref) rep2)
"Exported representation should match imported (except autogenerated ref)")))))))
(deftest export-import-singleton-test
(testing "Testing export then import roundtrip"
(let [mp (mt/metadata-provider)]
(mt/with-temp [:model/Card card {:type :question
:dataset_query (lib/native-query mp "select 2")}]
(doseq [query [(mt/native-query {:query "select 1"})
(mt/mbql-query users)
(lib/native-query (mt/metadata-provider) "select 1")
(lib/query mp (lib.metadata/table mp (mt/id :orders)))
(lib/query mp (lib.metadata/card mp (:id card)))]]
(mt/with-temp [:model/Card {qid :id} {:type :question
:dataset_query query}]
(let [question (t2/select-one :model/Card :id qid)
card-edn (export/export-entity question)
card-yaml (rep-yaml/generate-string card-edn)
card-rep (rep-yaml/parse-string card-yaml)
card-rep (rep-read/parse card-rep)
;; Build ref-index with database
ref-index (v0-common/map-entity-index
{(v0-common/unref (:database card-edn))
(t2/select-one :model/Database (mt/id))
(v0-common/unref (v0-common/entity->ref card))
card})
question (import/insert! card-rep ref-index)
question (t2/select-one :model/Card :id (:id question))
edn (export/export-entity question)
yaml (rep-yaml/generate-string edn)
rep2 (rep-yaml/parse-string yaml)
rep2 (rep-read/parse rep2)]
(is (=? (dissoc card-rep :name :entity-id) rep2)))))))))
(deftest representation-type-test
(doseq [entity (t2/select :model/Card :type :question)]
(is (= :question (v0-common/representation-type entity)))))

View File

@@ -0,0 +1,217 @@
(ns metabase-enterprise.representations.v0.snippet-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.export :as export]
[metabase-enterprise.representations.import :as import]
[metabase-enterprise.representations.toucan.core :as rep-t2]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(defn- run-snippet-export-import-test! [name description content exp-tags]
(mt/with-temp [:model/NativeQuerySnippet {snippet-id :id :as snippet} {:name name
:description description
:content content}]
(try
(let [representation (export/export-entity snippet)
toucan-model (->> (import/yaml->toucan representation nil)
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
_ (t2/delete! :model/NativeQuerySnippet :id snippet-id)
persisted-snippet (import/persist! representation nil)]
(is (= {:name (str "snippet-" snippet-id),
:type :snippet,
:version :v0,
:display_name name,
:description description,
:sql content,
:template_tags {}}
representation))
(is (=? {:name name,
:description description,
:content content,
:creator_id 13371338,
:template_tags exp-tags}
toucan-model))
(is (=? {:description description,
:archived false,
:dependency_analysis_version 0,
:content content,
:name name,
:creator_id 13371338,
:id (inc snippet-id),
:template_tags exp-tags}
persisted-snippet)))
(finally
(t2/delete! :model/NativeQuerySnippet :id (inc snippet-id))))))
(deftest snippet-export-import-entity-test
(testing "snippet with template tag is exported correctly"
(run-snippet-export-import-test! "snippet one" "the first snippet" "foo = 1" {}))
(testing "snippet with variable template tag is exported correctly"
(run-snippet-export-import-test! "foo snippet" "a test snippet" "foo = {{ val }}" {"val" {:type :text :name "val" :display-name "Val"}}))
(testing "snippet with card template tag is exported correctly"
(let [mp (mt/metadata-provider)
venues (lib.metadata/table mp (mt/id :venues))]
(mt/with-temp [:model/Card {card-id :id} {:name "card one"
:type :question
:dataset_query (lib/query mp venues)}
:model/NativeQuerySnippet {snippet-id :id :as snippet} {:name "foo snippet"
:description "a test snippet"
:content (str "select * from {{ #" card-id "-card-one }} where id = 1")}]
(try
(let [representation (export/export-entity snippet)
toucan-model (->> (import/yaml->toucan representation nil)
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
_ (t2/delete! :model/NativeQuerySnippet :id snippet-id)
persisted-snippet (import/persist! representation nil)]
(is (= {:name (str "snippet-" snippet-id),
:type :snippet,
:version :v0,
:display_name "foo snippet",
:description "a test snippet",
:sql (str "select * from {{ #" card-id "-card-one }} where id = 1"),
:template_tags {(str "#" card-id "-card-one") (str "ref:card-" card-id)}}
representation))
(is (=? {:name "foo snippet",
:description "a test snippet",
:content (str "select * from {{ #" card-id "-card-one }} where id = 1"),
:creator_id 13371338,
:template_tags {(str "#" card-id "-card-one")
{:type :card,
:name (str "#" card-id "-card-one"),
:card-id card-id,
:display-name (str "#" card-id " Card One")}}}
toucan-model))
(is (=? {:description "a test snippet",
:archived false,
:dependency_analysis_version 0,
:content (str "select * from {{ #" card-id "-card-one }} where id = 1"),
:name "foo snippet",
:creator_id 13371338,
:id (inc snippet-id),
:template_tags {(str "#" card-id "-card-one")
{:type :card,
:name (str "#" card-id "-card-one"),
:card-id card-id,
:display-name (str "#" card-id " Card One")}}}
persisted-snippet)))
(finally
(t2/delete! :model/NativeQuerySnippet :id (inc snippet-id)))))))
(testing "snippet with snippet template tag is exported correctly"
(mt/with-temp [:model/NativeQuerySnippet {bar-snippet-id :id} {:name "bar snippet"
:content "bar = 2"}
:model/NativeQuerySnippet {snippet-id :id :as snippet} {:name "foo snippet"
:description "a test snippet"
:content "foo = 1 and {{ snippet: bar snippet}}"}]
(try (let [representation (export/export-entity snippet)
toucan-model (->> (import/yaml->toucan representation nil)
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
_ (t2/delete! :model/NativeQuerySnippet :id snippet-id)
persisted-snippet (import/persist! representation nil)]
(is (= {:name (str "snippet-" snippet-id),
:type :snippet,
:version :v0,
:display_name "foo snippet",
:description "a test snippet",
:sql "foo = 1 and {{ snippet: bar snippet}}",
:template_tags {"snippet: bar snippet" (str "ref:snippet-" bar-snippet-id)}}
(export/export-entity snippet)))
(is (=? {:name "foo snippet",
:description "a test snippet",
:content "foo = 1 and {{ snippet: bar snippet}}",
:creator_id 13371338,
:template_tags {"snippet: bar snippet"
{:type :snippet,
:name "snippet: bar snippet",
:snippet-name "bar snippet",
:display-name "Snippet: Bar Snippet"}}}
toucan-model))
(is (=? {:description "a test snippet",
:archived false,
:dependency_analysis_version 0,
:content "foo = 1 and {{ snippet: bar snippet}}",
:name "foo snippet",
:creator_id 13371338,
:id (inc snippet-id),
:template_tags {"snippet: bar snippet"
{:type :snippet,
:name "snippet: bar snippet",
:snippet-name "bar snippet",
:snippet-id bar-snippet-id
:display-name "Snippet: Bar Snippet"}}}
persisted-snippet)))
(finally
(t2/delete! :model/NativeQuerySnippet :id (inc snippet-id))))))
(testing "snippet with multiple template tags is exported correctly"
(let [mp (mt/metadata-provider)
venues (lib.metadata/table mp (mt/id :venues))]
(mt/with-temp [:model/Card {card-id :id} {:name "card one"
:type :question
:dataset_query (lib/query mp venues)}
:model/NativeQuerySnippet {bar-snippet-id :id} {:name "bar snippet"
:content "bar = 2"}
:model/NativeQuerySnippet {snippet-id :id :as snippet} {:name "foo snippet"
:description "a test snippet"
:content (str "foo = {{ val }} and {{ snippet: bar snippet}} and (select id from {{ #" card-id "-card-one }} where id = 1)")}]
(try (let [representation (export/export-entity snippet)
toucan-model (->> (import/yaml->toucan representation nil)
(rep-t2/with-toucan-defaults :model/NativeQuerySnippet))
_ (t2/delete! :model/NativeQuerySnippet :id snippet-id)
persisted-snippet (import/persist! representation nil)]
(is (= {:name (str "snippet-" snippet-id),
:type :snippet,
:version :v0,
:display_name "foo snippet",
:description "a test snippet",
:sql (str "foo = {{ val }} and {{ snippet: bar snippet}} and (select id from {{ #" card-id "-card-one }} where id = 1)"),
:template_tags {(str "#" card-id "-card-one") (str "ref:card-" card-id)
"snippet: bar snippet" (str "ref:snippet-" bar-snippet-id)}}
(export/export-entity snippet)))
(is (=? {:name "foo snippet",
:description "a test snippet",
:content (str "foo = {{ val }} and {{ snippet: bar snippet}} and (select id from {{ #" card-id "-card-one }} where id = 1)"),
:creator_id 13371338,
:template_tags {"snippet: bar snippet"
{:type :snippet,
:name "snippet: bar snippet",
:snippet-name "bar snippet",
:display-name "Snippet: Bar Snippet"}
(str "#" card-id "-card-one")
{:type :card,
:name (str "#" card-id "-card-one"),
:card-id card-id,
:display-name (str "#" card-id " Card One")}
"val" {:type :text :name "val" :display-name "Val"}}}
toucan-model))
(is (=? {:description "a test snippet",
:archived false,
:dependency_analysis_version 0,
:content (str "foo = {{ val }} and {{ snippet: bar snippet}} and (select id from {{ #" card-id "-card-one }} where id = 1)"),
:collection_id nil,
:name "foo snippet",
:creator_id 13371338,
:id (inc snippet-id),
:template_tags {"snippet: bar snippet"
{:type :snippet,
:name "snippet: bar snippet",
:snippet-name "bar snippet",
:snippet-id bar-snippet-id
:display-name "Snippet: Bar Snippet"}
(str "#" card-id "-card-one")
{:type :card,
:name (str "#" card-id "-card-one"),
:card-id card-id,
:display-name (str "#" card-id " Card One")}
"val" {:type :text :name "val" :display-name "Val"}}}
persisted-snippet)))
(finally
(t2/delete! :model/NativeQuerySnippet :id (inc snippet-id))))))))
(deftest representation-type-test
(doseq [entity (t2/select :model/NativeQuerySnippet)]
(is (= :snippet (v0-common/representation-type entity)))))

View File

@@ -0,0 +1,102 @@
(ns metabase-enterprise.representations.v0.transform-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.representations.core :as rep]
[metabase-enterprise.representations.v0.common :as v0-common]
[metabase-enterprise.representations.yaml :as yaml]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[representations.read :as rep-read]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(def good-yamls ["test_resources/representations/v0/orders-count.transform.yml"
"test_resources/representations/v0/orders-count-existing.transform.yml"])
(deftest validate-example-yamls
(testing "Testing valid examples"
(doseq [filename good-yamls]
(testing (str "Validating: " filename)
(let [rep (yaml/from-file filename)]
(is (rep-read/parse rep))))))
(testing "Testing invalid examples"
(doseq [filename
[]]
(testing (str "Validating: " filename)
(let [rep (yaml/from-file filename)]
(is (thrown? clojure.lang.ExceptionInfo (rep-read/parse rep))))))))
;; TODO: test passes but I suspect this is also incorrectly using model/Transform:
(deftest validate-exported-transforms
(doseq [query [(mt/native-query {:query "select 1"})
(mt/mbql-query users)]]
(mt/with-temp [:model/Transform transform {:name "TEST"
:source {:type :query
:query query}
:target {:type "table"
:name "output_table"
:schema "output_schema"}}]
(let [edn (rep/export transform)
;; convert to yaml and read back in to convert keywords to strings, etc
yaml (yaml/generate-string edn)
rep (yaml/parse-string yaml)]
(is (rep-read/parse rep))))))
;; Update schema transformations to toucan (and possibly the yamls)
#_(deftest can-import
(doseq [filename good-yamls]
(let [rep (yaml/from-file filename)
ref-index (v0-common/map-entity-index
{(v0-common/unref (:database rep))
(t2/select-one :model/Database (mt/id))})]
(is (rep/persist! rep ref-index)))))
;; TODO: fix ID usage here, db lookup fails
#_(deftest import-export
(testing "Testing import then export roundtrip"
(doseq [filename good-yamls]
(testing (str "Importing-Exporting: " filename)
(let [rep (import/import-yaml filename)
rep (walk/postwalk-replace {(:database rep)
(v0-common/->ref 1 :database)}
rep)
ref-index (v0-common/map-entity-index
{(v0-common/unref (v0-common/->ref 1 :database))
(t2/select-one :model/Database :id 1)})
persisted (rep/persist! rep ref-index)]
(is persisted)
(let [edn (rep/export persisted)
yaml (yaml/generate-string edn)
rep2 (yaml/parse-string yaml)]
(is (=? (dissoc rep :name :database) rep2))))))))
;; TODO: update with latest transform schema in db
#_(deftest export-import
(testing "Testing export then import roundtrip"
(doseq [query [(mt/native-query {:query "select 1"})
(mt/mbql-query users)]]
(mt/with-temp [:model/Transform transform {:name "TRANSFORM"
:source {:type "query"
:database (mt/id)
:query query}
:target {:type "table"
:schema "PUBLIC"
:name "SOME_TABLE"}}]
(let [edn (rep/export transform)
yaml (yaml/generate-string edn)
rep (yaml/parse-string yaml)
rep (rep-read/parse rep)
ref-index (v0-common/map-entity-index
{(v0-common/unref (:database edn)) (t2/select-one :model/Database (mt/id))})
transform (rep/persist! rep ref-index)
transform (t2/select-one :model/Transform :id (:id transform))
edn (rep/export transform)
yaml (yaml/generate-string edn)
rep2 (yaml/parse-string yaml)
rep2 (rep-read/parse rep2)]
(is (=? (dissoc rep :name) rep2)))))))
(deftest representation-type-test
(doseq [entity (t2/select :model/Transform)]
(is (= :transform (v0-common/representation-type entity)))))

View File

@@ -8,6 +8,7 @@ import DateTime, {
import { useHasTokenFeature, useSetting } from "metabase/common/hooks";
import CS from "metabase/css/core/index.css";
import { isWithinIframe } from "metabase/lib/dom";
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
import { useSelector } from "metabase/lib/redux";
import { getUserIsAdmin } from "metabase/selectors/user";
import {
@@ -66,6 +67,8 @@ export const DocumentHeader = ({
onArchive,
hasComments = false,
}: DocumentHeaderProps) => {
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
useState(false);
const isPublicSharingEnabled = useSetting("enable-public-sharing");
const hasDocumentsFeature = useHasTokenFeature("documents");
const isAdmin = useSelector(getUserIsAdmin);
@@ -145,6 +148,14 @@ export const DocumentHeader = ({
</Box>
)}
</Transition>
{!isNewDocument && document && (
<Button
onClick={() => setIsRepresentationsModalOpen(true)}
data-hide-on-print
>
{t`Representations`}
</Button>
)}
{!isNewDocument && hasComments && !isWithinIframe() && (
<Tooltip label={t`Show all comments`}>
<Box>
@@ -259,6 +270,14 @@ export const DocumentHeader = ({
/>
)}
</Flex>
{document && (
<RepresentationsModal
opened={isRepresentationsModalOpen}
onClose={() => setIsRepresentationsModalOpen(false)}
entityId={document.id}
entityType="document"
/>
)}
</Flex>
);
};

View File

@@ -1,7 +1,10 @@
import { useState } from "react";
import { t } from "ttag";
import { BrowserCrumbs } from "metabase/common/components/BrowserCrumbs";
import * as Urls from "metabase/lib/urls";
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
import { Button, Group } from "metabase/ui";
import type { Transform } from "metabase-types/api";
type HeaderSectionProps = {
@@ -9,12 +12,26 @@ type HeaderSectionProps = {
};
export function HeaderSection({ transform }: HeaderSectionProps) {
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
useState(false);
return (
<BrowserCrumbs
crumbs={[
{ title: t`Transforms`, to: Urls.transformList() },
{ title: transform.name, to: Urls.transform(transform.id) },
]}
/>
<Group justify="space-between">
<BrowserCrumbs
crumbs={[
{ title: t`Transforms`, to: Urls.transformList() },
{ title: transform.name, to: Urls.transform(transform.id) },
]}
/>
<Button onClick={() => setIsRepresentationsModalOpen(true)}>
{t`Representations`}
</Button>
<RepresentationsModal
opened={isRepresentationsModalOpen}
onClose={() => setIsRepresentationsModalOpen(false)}
entityId={transform.id}
entityType="transform"
/>
</Group>
);
}

View File

@@ -1,8 +1,10 @@
import { useState } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import { isSyncInProgress } from "metabase/lib/syncing";
import { browseDatabase } from "metabase/lib/urls";
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
import { Button, Flex, Icon } from "metabase/ui";
import type { Database } from "metabase-types/api";
@@ -11,30 +13,48 @@ interface Props {
}
export const HeaderLinkGroup = ({ database }: Props) => {
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
useState(false);
const isSyncing = isSyncInProgress(database);
return (
<Flex gap="0.5rem">
<Button
component={Link}
fw="bold"
to={`/admin/permissions/data/database/${database.id}`}
variant="subtle"
>
{t`Manage permissions`}
</Button>
<Button
component={isSyncing ? undefined : Link}
disabled={isSyncing}
fw="bold"
rightSection={<Icon name="external" />}
target="_blank"
title={isSyncing ? t`Sync in progress` : undefined}
to={browseDatabase(database)}
variant="subtle"
>
{t`Browse data`}
</Button>
</Flex>
<>
<Flex gap="0.5rem">
<Button
fw="bold"
onClick={() => setIsRepresentationsModalOpen(true)}
variant="subtle"
data-testid="representations-button"
>
{t`Representations`}
</Button>
<Button
component={Link}
fw="bold"
to={`/admin/permissions/data/database/${database.id}`}
variant="subtle"
>
{t`Manage permissions`}
</Button>
<Button
component={isSyncing ? undefined : Link}
disabled={isSyncing}
fw="bold"
rightSection={<Icon name="external" />}
target="_blank"
title={isSyncing ? t`Sync in progress` : undefined}
to={browseDatabase(database)}
variant="subtle"
>
{t`Browse data`}
</Button>
</Flex>
<RepresentationsModal
opened={isRepresentationsModalOpen}
onClose={() => setIsRepresentationsModalOpen(false)}
entityId={database.id}
entityType="database"
/>
</>
);
};

View File

@@ -0,0 +1,17 @@
import { Api } from "./api";
export const representationsApi = Api.injectEndpoints({
endpoints: (builder) => ({
getExportSet: builder.query<
{ yamls: string[] },
{ type: string; id: number }
>({
query: ({ type, id }) => ({
method: "GET",
url: `/api/ee/representation/export-set/${type}/${id}`,
}),
}),
}),
});
export const { useGetExportSetQuery } = representationsApi;

View File

@@ -1,9 +1,13 @@
import { useState } from "react";
import { withRouter } from "react-router";
import { t } from "ttag";
import {
isInstanceAnalyticsCollection,
isTrashedCollection,
} from "metabase/collections/utils";
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
import { Button } from "metabase/ui";
import type { Collection } from "metabase-types/api";
import { CollectionMenu } from "../CollectionMenu";
@@ -40,6 +44,9 @@ const CollectionHeader = ({
canUpload,
uploadsEnabled,
}: CollectionHeaderProps): JSX.Element => {
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
useState(false);
const isTrash = isTrashedCollection(collection);
const showUploadButton =
collection.can_write && (canUpload || !uploadsEnabled);
@@ -77,6 +84,9 @@ const CollectionHeader = ({
onCreateBookmark={onCreateBookmark}
onDeleteBookmark={onDeleteBookmark}
/>
<Button onClick={() => setIsRepresentationsModalOpen(true)}>
{t`Representations`}
</Button>
<CollectionInfoSidebarToggle
collection={collection}
onUpdateCollection={onUpdateCollection}
@@ -90,6 +100,12 @@ const CollectionHeader = ({
)}
</HeaderActions>
)}
<RepresentationsModal
opened={isRepresentationsModalOpen}
onClose={() => setIsRepresentationsModalOpen(false)}
entityId={collection.id}
entityType="collection"
/>
</HeaderRoot>
);
};

View File

@@ -1,6 +1,6 @@
import cx from "classnames";
import type React from "react";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { t } from "ttag";
import CS from "metabase/css/core/index.css";
@@ -13,6 +13,7 @@ import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWit
import { canExploreResults } from "metabase/query_builder/components/view/ViewHeader/utils";
import type { QueryModalType } from "metabase/query_builder/constants";
import { MODAL_TYPES } from "metabase/query_builder/constants";
import { RepresentationsModal } from "metabase/representations/RepresentationsModal";
import { Box, Button, Flex, Tooltip } from "metabase/ui";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
@@ -96,6 +97,9 @@ export function ViewTitleHeaderRightSide({
isShowingQuestionInfoSidebar,
isObjectDetail,
}: ViewTitleHeaderRightSideProps): React.JSX.Element {
const [isRepresentationsModalOpen, setIsRepresentationsModalOpen] =
useState(false);
const isShowingNotebook = queryBuilderMode === "notebook";
const { isEditable } = Lib.queryDisplayInfo(question.query());
@@ -160,6 +164,15 @@ export function ViewTitleHeaderRightSide({
className={ViewTitleHeaderS.ViewHeaderActionPanel}
data-testid="qb-header-action-panel"
>
{isSaved && (
<Button
className={cx(CS.hide, CS.smShow)}
onClick={() => setIsRepresentationsModalOpen(true)}
data-testid="representations-button"
>
{t`Representations`}
</Button>
)}
{FilterHeaderButton.shouldRender({
question,
queryBuilderMode,
@@ -264,6 +277,12 @@ export function ViewTitleHeaderRightSide({
</Button>
</Tooltip>
)}
<RepresentationsModal
opened={isRepresentationsModalOpen}
onClose={() => setIsRepresentationsModalOpen(false)}
entityId={question.id()}
entityType={question.type()}
/>
</Flex>
);
}

View File

@@ -0,0 +1,118 @@
import { useState } from "react";
import { t } from "ttag";
import { useGetExportSetQuery } from "metabase/api/representations";
import {
Code,
Collapse,
Group,
Icon,
Loader,
Modal,
Stack,
Text,
} from "metabase/ui";
import type { CardType } from "metabase-types/api";
type EntityType = CardType | "transform" | "document" | "collection";
interface RepresentationsModalProps {
opened: boolean;
onClose: () => void;
entityId: number | null;
entityType: EntityType;
}
function DependencyItem({ yaml, index }: { yaml: string; index: number }) {
const [isOpen, setIsOpen] = useState(false);
// Extract the first few lines to show type, name, and ref
const lines = yaml.split("\n");
const typeLine = lines.find((line) => line.startsWith("type:"));
const nameLine = lines.find((line) => line.startsWith("name:"));
const refLine = lines.find((line) => line.startsWith("ref:"));
const type = typeLine?.split(":")[1]?.trim() || "Unknown";
const name = nameLine?.split(":")[1]?.trim() || `Dependency ${index + 1}`;
const ref = refLine?.split(":")[1]?.trim();
return (
<Stack gap="xs">
<Group
onClick={() => setIsOpen((x) => !x)}
style={{ cursor: "pointer", userSelect: "none" }}
>
<Icon name={isOpen ? "chevrondown" : "chevronright"} size={16} />
<Text fw="bold">
{type}: {name} {ref && `(${ref})`}
</Text>
</Group>
<Collapse in={isOpen}>
<Code
block
style={{
maxHeight: "600px",
overflow: "auto",
whiteSpace: "pre-wrap",
}}
>
{yaml}
</Code>
</Collapse>
</Stack>
);
}
export function RepresentationsModal({
opened,
onClose,
entityId,
entityType,
}: RepresentationsModalProps) {
const { data, isLoading, error } = useGetExportSetQuery(
{ type: entityType, id: entityId! },
{ skip: !opened || entityId === null },
);
return (
<Modal opened={opened} onClose={onClose} title="Representation" size="xl">
<Stack>
{isLoading && (
<Stack align="center" p="xl">
<Loader size="lg" />
<Text c="text-medium">Loading representation...</Text>
</Stack>
)}
{error && (
<Text c="error">
{t`Error loading representation: ${(error as Error).message}`}
</Text>
)}
{data && (
<>
<Code
block
style={{
maxHeight: "600px",
overflow: "auto",
whiteSpace: "pre-wrap",
}}
>
{data.yamls[0]}
</Code>
{data.yamls.length > 1 && (
<>
<Text fw="bold" mt="md">{t`Dependencies`}</Text>
{data.yamls.slice(1).map((yaml, index) => (
<DependencyItem key={index} yaml={yaml} index={index} />
))}
</>
)}
</>
)}
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,382 @@
#!/usr/bin/env node
/**
* Markdown to ProseMirror parser using prosemirror-markdown
*
* Usage:
* node parse-markdown.mjs <markdown-file>
* node parse-markdown.mjs < input.md
* echo "# Test" | node parse-markdown.mjs
* node parse-markdown.mjs --verbose < input.md
*
* Output: ProseMirror JSON to stdout
*
* Options:
* --verbose Show diagnostic messages on stderr
*/
import { readFileSync } from 'fs';
import MarkdownIt from 'markdown-it';
import { MarkdownParser, defaultMarkdownParser, schema as baseSchema } from 'prosemirror-markdown';
import { Schema } from 'prosemirror-model';
// Extend the base schema with cardEmbed, resizeNode, and flexContainer
const schema = new Schema({
nodes: baseSchema.spec.nodes
.addBefore('image', 'cardEmbed', {
attrs: {
id: { default: null }, // Can be number or string (ref:foo)
name: { default: null }
},
group: 'block',
parseDOM: [{
tag: 'div[data-card-embed]',
getAttrs: (dom) => {
const idStr = dom.getAttribute('data-id');
// Keep ref: as string, parse numeric strings to numbers
const id = idStr && idStr.startsWith('ref:') ? idStr : parseInt(idStr);
return {
id,
name: dom.getAttribute('data-name')
};
}
}],
toDOM: (node) => ['div', {
'data-card-embed': '',
'data-id': node.attrs.id,
'data-name': node.attrs.name
}]
})
.addBefore('image', 'flexContainer', {
attrs: {
columnWidths: { default: [50, 50] }
},
content: 'cardEmbed{2}',
parseDOM: [{
tag: 'div[data-flex-container]',
getAttrs: (dom) => {
const widths = dom.getAttribute('data-column-widths');
return {
columnWidths: widths ? JSON.parse(widths) : [50, 50]
};
}
}],
toDOM: (node) => ['div', {
'data-flex-container': '',
'data-column-widths': JSON.stringify(node.attrs.columnWidths)
}]
})
.addBefore('image', 'resizeNode', {
attrs: {
height: { default: 442 },
minHeight: { default: 280 }
},
content: '(cardEmbed | flexContainer)',
group: 'block',
parseDOM: [{
tag: 'div[data-resize-node]',
getAttrs: (dom) => ({
height: parseInt(dom.getAttribute('data-height')) || 442,
minHeight: parseInt(dom.getAttribute('data-min-height')) || 280
})
}],
toDOM: (node) => ['div', {
'data-resize-node': '',
'data-height': node.attrs.height,
'data-min-height': node.attrs.minHeight
}]
}),
marks: baseSchema.spec.marks
});
// Custom markdown-it plugin to parse card embeds
function cardEmbedPlugin(md) {
md.block.ruler.before('fence', 'card_embed', (state, startLine, endLine, silent) => {
const pos = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineText = state.src.slice(pos, max);
// Match: {% card id=123 %} or {% card id=ref:my-chart %} with optional name
const match = lineText.match(/^\{%\s*card\s+id=(ref:[a-zA-Z0-9_-]+|\d+)(?:\s+name="([^"]*)")?\s*%\}$/);
if (!match) return false;
if (silent) return true;
const token = state.push('card_embed', '', 0);
token.attrs = [
['id', match[1]], // Keep as-is (string for ref:, string for numeric)
['name', match[2] || null]
];
token.block = true;
token.map = [startLine, startLine + 1];
state.line = startLine + 1;
return true;
});
}
// Custom markdown-it plugin to parse row containers
function rowPlugin(md) {
md.block.ruler.before('fence', 'row_container', (state, startLine, endLine, silent) => {
const pos = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineText = state.src.slice(pos, max);
// Match: {% row %} or {% row widths="75:25" %}
const match = lineText.match(/^\{%\s*row(?:\s+widths="([^"]+)")?\s*%\}$/);
if (!match) return false;
if (silent) return true;
const widths = match[1];
let nextLine = startLine + 1;
const cards = [];
// Find all cards until {% endrow %}
while (nextLine < endLine) {
const nextPos = state.bMarks[nextLine] + state.tShift[nextLine];
const nextMax = state.eMarks[nextLine];
const nextLineText = state.src.slice(nextPos, nextMax);
// Check for endrow
if (nextLineText.match(/^\{%\s*endrow\s*%\}$/)) {
break;
}
// Check for card
const cardMatch = nextLineText.match(/^\{%\s*card\s+id=(ref:[a-zA-Z0-9_-]+|\d+)(?:\s+name="([^"]*)")?\s*%\}$/);
if (cardMatch) {
cards.push({
id: cardMatch[1], // Keep as-is (string for ref:, string for numeric)
name: cardMatch[2] || null
});
}
nextLine++;
}
if (cards.length !== 2 || nextLine >= endLine) {
return false;
}
// Create resize_node_open token
const resizeToken = state.push('resize_node_open', '', 1);
resizeToken.block = true;
// Create flex_container_open token
const flexToken = state.push('flex_container_open', '', 1);
flexToken.block = true;
// Parse widths if provided
if (widths) {
const parts = widths.split(':').map(w => parseFloat(w.trim()));
if (parts.length === 2) {
flexToken.attrs = [['columnWidths', JSON.stringify(parts)]];
}
}
// Add card tokens
for (const card of cards) {
const cardToken = state.push('card_embed', '', 0);
cardToken.attrs = [
['id', card.id],
['name', card.name]
];
cardToken.block = true;
}
// Close flex_container
state.push('flex_container_close', '', -1);
// Close resize_node
state.push('resize_node_close', '', -1);
state.line = nextLine + 1; // Skip past {% endrow %}
return true;
});
}
// Post-process tokens to wrap standalone card_embed in resize_node
function wrapStandaloneCards(tokens) {
const result = [];
let inFlexContainer = false;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
// Track if we're inside a flex container
if (token.type === 'flex_container_open') {
inFlexContainer = true;
result.push(token);
} else if (token.type === 'flex_container_close') {
inFlexContainer = false;
result.push(token);
} else if (token.type === 'card_embed' && !inFlexContainer) {
// Only wrap standalone cards (not inside flex container)
const resizeOpen = new token.constructor('resize_node_open', '', 1);
resizeOpen.block = true;
result.push(resizeOpen);
result.push(token);
const resizeClose = new token.constructor('resize_node_close', '', -1);
resizeClose.block = true;
result.push(resizeClose);
} else {
result.push(token);
}
}
return result;
}
// Create custom markdown-it instance with plugins
const md = new MarkdownIt('commonmark', {
html: false,
linkify: false
}).use(rowPlugin).use(cardEmbedPlugin);
// Override parse to post-process tokens
const originalParse = md.parse.bind(md);
md.parse = function(src, env) {
const tokens = originalParse(src, env);
return wrapStandaloneCards(tokens);
};
// Create custom parser with token handlers
const customParser = new MarkdownParser(
schema,
md,
{
...defaultMarkdownParser.tokens,
card_embed: {
node: 'cardEmbed',
getAttrs: (tok) => {
const idStr = tok.attrGet('id');
// Keep ref: as string, parse numeric strings to numbers
const id = idStr.startsWith('ref:') ? idStr : parseInt(idStr);
return {
id,
name: tok.attrGet('name')
};
}
},
resize_node: {
block: 'resizeNode',
getAttrs: (tok) => ({
height: 442,
minHeight: 280
})
},
flex_container: {
block: 'flexContainer',
getAttrs: (tok) => {
const widths = tok.attrGet('columnWidths');
return {
columnWidths: widths ? JSON.parse(widths) : [50, 50]
};
}
}
}
);
// Transform ProseMirror node/mark types to Tiptap naming convention
function transformToTiptapFormat(node) {
if (!node || typeof node !== 'object') {
return node;
}
// Map node types from ProseMirror defaults to Tiptap names
const typeMap = {
'strong': 'bold',
'em': 'italic',
'code_block': 'codeBlock',
'bullet_list': 'bulletList',
'ordered_list': 'orderedList',
'list_item': 'listItem',
'horizontal_rule': 'horizontalRule',
'hard_break': 'hardBreak'
};
const transformedNode = { ...node };
// Update node type if it needs mapping
if (transformedNode.type && typeMap[transformedNode.type]) {
transformedNode.type = typeMap[transformedNode.type];
}
// Transform marks
if (transformedNode.marks) {
transformedNode.marks = transformedNode.marks.map(mark => ({
...mark,
type: typeMap[mark.type] || mark.type
}));
}
// Recursively transform content
if (transformedNode.content && Array.isArray(transformedNode.content)) {
transformedNode.content = transformedNode.content.map(transformToTiptapFormat);
}
return transformedNode;
}
export function parseMarkdown(markdownText) {
// Use the custom parser
const doc = customParser.parse(markdownText);
// Convert to JSON
const pmDoc = doc.toJSON();
// Transform to Tiptap format
return transformToTiptapFormat(pmDoc);
}
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
}
async function main() {
const args = process.argv.slice(2);
let markdownText;
let verbose = false;
// Check for --verbose flag
const verboseIndex = args.indexOf('--verbose');
if (verboseIndex !== -1) {
verbose = true;
args.splice(verboseIndex, 1);
}
try {
// Read from stdin if no file provided or if '-' is specified
if (args.length === 0 || args[0] === '-') {
if (verbose) {
console.error('Reading from stdin...');
}
markdownText = await readStdin();
} else {
const filePath = args[0];
if (verbose) {
console.error(`Reading from file: ${filePath}`);
}
markdownText = readFileSync(filePath, 'utf8');
}
const pmDoc = parseMarkdown(markdownText);
// Output JSON to stdout (always)
console.log(JSON.stringify(pmDoc, null, 2));
} catch (error) {
console.error('Error parsing markdown:', error.message);
process.exit(1);
}
}
// Only run main if this file is being executed directly (not imported)
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env node
/**
* ProseMirror to Markdown serializer - Standalone version
* This script converts ProseMirror JSON to Markdown without external dependencies
*/
import { readFileSync } from "fs";
// Simple markdown serializer that handles ProseMirror JSON structure
function serializeProseMirrorToMarkdown(doc) {
if (!doc || doc.type !== "doc") {
throw new Error("Invalid ProseMirror document structure");
}
const lines = [];
function serializeNode(node) {
const type = node.type;
switch (type) {
case "paragraph":
lines.push(serializeInlineContent(node.content || []));
lines.push("");
break;
case "heading":
const level = node.attrs?.level || 1;
const prefix = "#".repeat(level);
lines.push(`${prefix} ${serializeInlineContent(node.content || [])}`);
lines.push("");
break;
case "codeBlock":
lines.push("```");
if (node.content) {
node.content.forEach((child) => {
if (child.type === "text") {
lines.push(child.text || "");
}
});
}
lines.push("```");
lines.push("");
break;
case "blockquote":
if (node.content) {
const quotedLines = [];
node.content.forEach((child) => {
const saved = lines.length;
serializeNode(child);
// Take the lines that were just added
while (lines.length > saved) {
quotedLines.push(lines.pop());
}
});
quotedLines.reverse().forEach((line) => {
lines.push(line ? `> ${line}` : ">");
});
}
lines.push("");
break;
case "bulletList":
if (node.content) {
node.content.forEach((item) => serializeListItem(item, "-"));
}
lines.push("");
break;
case "orderedList":
const start = node.attrs?.order || 1;
if (node.content) {
node.content.forEach((item, index) => {
serializeListItem(item, `${start + index}.`);
});
}
lines.push("");
break;
case "horizontalRule":
lines.push("---");
lines.push("");
break;
case "hardBreak":
lines.push(" ");
break;
case "cardEmbed":
const { id, name } = node.attrs || {};
if (name) {
lines.push(`{% card id=${id} name="${name}" %}`);
} else {
lines.push(`{% card id=${id} %}`);
}
lines.push("");
break;
case "resizeNode":
// Check if it contains a flexContainer
const child = node.content?.[0];
if (child && child.type === "flexContainer") {
serializeFlexContainer(child);
} else if (child) {
serializeNode(child);
}
break;
case "flexContainer":
serializeFlexContainer(node);
break;
default:
// For unknown nodes, try to serialize their content
if (node.content) {
node.content.forEach((child) => serializeNode(child));
}
}
}
function serializeFlexContainer(node) {
const { columnWidths } = node.attrs || { columnWidths: [50, 50] };
const hasCustomWidths =
columnWidths && (columnWidths[0] !== 50 || columnWidths[1] !== 50);
if (hasCustomWidths) {
const [w1, w2] = columnWidths;
lines.push(`{% row widths="${w1.toFixed(2)}:${w2.toFixed(2)}" %}`);
} else {
lines.push(`{% row %}`);
}
// Serialize the cards inside
if (node.content) {
node.content.forEach((card) => {
const { id, name } = card.attrs || {};
if (name) {
lines.push(`{% card id=${id} name="${name}" %}`);
} else {
lines.push(`{% card id=${id} %}`);
}
});
}
lines.push(`{% endrow %}`);
lines.push("");
}
function serializeListItem(item, bullet) {
if (!item.content) return;
// Get the first paragraph's content
const firstPara = item.content[0];
if (firstPara && firstPara.type === "paragraph") {
lines.push(`${bullet} ${serializeInlineContent(firstPara.content || [])}`);
}
// Handle nested blocks
for (let i = 1; i < item.content.length; i++) {
const child = item.content[i];
const saved = lines.length;
serializeNode(child);
// Indent nested content
while (lines.length > saved) {
const line = lines.pop();
lines.push(line ? ` ${line}` : "");
}
lines.reverse();
for (let j = saved; j < lines.length; j++) {
const temp = lines[j];
lines[j] = lines[lines.length - 1 - (j - saved)];
lines[lines.length - 1 - (j - saved)] = temp;
}
}
}
function serializeInlineContent(content) {
return content
.map((node) => {
if (node.type === "text") {
return applyMarks(node.text || "", node.marks || []);
} else if (node.type === "hardBreak") {
return " \n";
}
return "";
})
.join("");
}
function applyMarks(text, marks) {
let result = text;
// Apply marks in reverse order for proper nesting
for (let i = marks.length - 1; i >= 0; i--) {
const mark = marks[i];
switch (mark.type) {
case "bold":
case "strong":
result = `**${result}**`;
break;
case "italic":
case "em":
result = `*${result}*`;
break;
case "code":
result = `\`${result}\``;
break;
case "link":
const href = mark.attrs?.href || "";
const title = mark.attrs?.title;
if (title) {
result = `[${result}](${href} "${title}")`;
} else {
result = `[${result}](${href})`;
}
break;
}
}
return result;
}
// Serialize all top-level nodes
if (doc.content) {
doc.content.forEach((node) => serializeNode(node));
}
// Remove trailing empty lines and join
while (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
return lines.join("\n");
}
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
export function serializeProseMirror(jsonText) {
const pmJson = JSON.parse(jsonText);
return serializeProseMirrorToMarkdown(pmJson);
}
async function main() {
const args = process.argv.slice(2);
let jsonText;
let verbose = false;
// Check for --verbose flag
const verboseIndex = args.indexOf("--verbose");
if (verboseIndex !== -1) {
verbose = true;
args.splice(verboseIndex, 1);
}
try {
// Read from stdin if no file provided or if '-' is specified
if (args.length === 0 || args[0] === "-") {
if (verbose) {
console.error("Reading from stdin...");
}
jsonText = await readStdin();
} else {
const filePath = args[0];
if (verbose) {
console.error(`Reading from file: ${filePath}`);
}
jsonText = readFileSync(filePath, "utf8");
}
const markdown = serializeProseMirror(jsonText);
// Output markdown to stdout
console.log(markdown);
} catch (error) {
console.error("Error serializing ProseMirror:", error.message);
if (verbose) {
console.error(error.stack);
}
process.exit(1);
}
}
// Only run main if this file is being executed directly (not imported)
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@@ -951,7 +951,7 @@
res
limit-res)))
(mu/defn- collection-children
(mu/defn collection-children
"Fetch a sequence of 'child' objects belonging to a Collection, filtered using `options`."
[{collection-namespace :namespace, :as collection} :- collection/CollectionWithLocationAndIDOrRoot
{:keys [models], :as options} :- CollectionChildrenOptions]

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]
@@ -418,6 +419,10 @@
update-parameter-target-dimension-options]
[lib.parse
parse]
[lib.portable
export-walk
import-walk
QueryVisitor]
[lib.query
->query
can-preview

View File

@@ -1,4 +1,4 @@
(ns metabase.lib.metadata.protocols
(ns metabase.lib.metadata.protocols
(:require
#?@(:clj [[potemkin :as p]])
[medley.core :as m]

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 ExportVisitor protocol?"
[x]
#?(:clj (extends? QueryVisitor (class x))
:cljs (satisfies? QueryVisitor x)))
(mr/def ::query-visitor
"Schema for things that satisfy the [[metabase.lib.portable/QueryVisitor]] protocol."
[:fn
{:error/message "Valid QueryVisitor"}
#'query-visitor?])
(defn- update-field [mp k f]
(m/assoc-some mp k (some-> (get mp k) f)))
(defn- walk-database [query context visitor]
(let [mp (lib.metadata/->metadata-provider query)]
(update-field query :database (partial visit-database-id visitor mp context))))
(defn- walk-stages [query context visitor]
(let [mp (lib.metadata/->metadata-provider query)
st (partial visit-table-id visitor mp context)
sc (partial visit-card-id visitor mp context)]
(mu/disable-enforcement
(lib.walk/walk-stages
query
(fn [_q _pt stage]
(-> stage
(update-field :source-table st)
(update-field :source-card sc)))))))
(defn- walk-clauses [query context visitor]
(let [mp (lib.metadata/->metadata-provider query)]
(mu/disable-enforcement
(lib.walk/walk-clauses
query
(fn [_q _pt _sjp clause]
(lib.util.match/match-lite clause
[:field opts id]
(when-some [field-ref (visit-field-id visitor mp context id)]
[:field opts field-ref])))))))
(mu/defn export-walk
"Walk a query, replacing database ids, source-table ids, source card ids, and field ids using the QueryVisitor protocol. The QueryVisitor methods should take an id and return the exported form (or nil to do nothing)."
[query :- ::lib.schema/query
export-visitor :- ::query-visitor
context :- :any]
(-> query
(walk-database context export-visitor)
(walk-stages context export-visitor)
(walk-clauses context export-visitor)))
(mu/defn import-walk :- ::lib.schema/query
"Walk a query, replacing database refs, table refds, card refs, and field refs using the QueryVisitor protocol. The QueryVisitor methods should take whatever was exported and return the appropriate id. This function does not check the query schema on input (it won't pass), but does check the schema of the return value, so make sure it gets patched up right!"
[query :- :any ;; something similar to a query with bits changed
import-visitor :- ::query-visitor
context :- :any]
(-> query
(walk-database context import-visitor)
(walk-stages context import-visitor)
(walk-clauses context import-visitor)))

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