metabase_query_sync 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 020bfbb2b8958b1f6f3ed8067628bfb4942ddc8a751abc787b797c562b1500d8
4
- data.tar.gz: ed21dd175d50d34dec40ae0fac3c29b65da668cba7d80c23bcb93d4b527d0a0d
3
+ metadata.gz: 196f3dcb53519b8a0dbbdab05d3a237181dc5e381e83e0a9627fe41e0ffa0024
4
+ data.tar.gz: e0c49e7710a5fbd18cb51ba1749700ed4e74503c6c0d3f63f786383380fbfa56
5
5
  SHA512:
6
- metadata.gz: 6b41d731113fea1a55c85875b762a777d5ce77a27dc6efe9f5088ce22febbbc5a5beccc4cf12260a782a6b4e67fddcc7961941d3be1b747fee5ab747c5ca753a
7
- data.tar.gz: 6b646e18d35d39a5da020fa43b3f0d9dbc9e9782fcb440a98371960f8866e659b266ad559f7710acc12de0bc21c9fb7e6176e0fcd09a6cc35c102be5c6ca0634
6
+ metadata.gz: e548655a3ad94a845b5966e248ba4376d0b98676fdd2afd13e94cf84b72fb3cd263df7d13147ea3382255df1b6643e92662ba0842996af04edf4cb667820f8bb
7
+ data.tar.gz: 4fc3a03c4c4e434982d0832d37ad0062c4322a90352636696ddced4f343501d56dcd4a492f97936b74cca5c9a796e88b7ef937e8b921fc7b2e73efb95a7bfc84
data/README.md CHANGED
@@ -31,7 +31,7 @@ Build files with `.query.yaml` or `.pulse.yaml` suffix and sync those files up t
31
31
  name: Low Volume Orders
32
32
  sql: 'select * from orders'
33
33
  database: Local DB # must match name of database in metabase
34
- pulse: Hourly # must match local pulse name field, throws exception if no pulse is found with that name
34
+ pulse: hourly # must match local pulse id (name of file), throws exception if no pulse is found with that name
35
35
  ```
36
36
 
37
37
  ```yaml
@@ -50,48 +50,97 @@ alerts:
50
50
  day: mon # first 3 character of day only needed if weekly
51
51
  ```
52
52
 
53
+ ### Understanding Ids
54
+
55
+ Every metabase model defined in a file has an id which defaults to the name of the file minus the query/pulse.yaml suffix. An id can be explicitly set in the file if wanted. All references within files to other files must use the id of the item to reference.
56
+
57
+ Given the following folder layout:
58
+
59
+ ```
60
+ queries/
61
+ low-volume.query.yaml
62
+ catalog/
63
+ products-missing-images.query.yaml
64
+ hourly.pulse.yaml
65
+ ```
66
+
67
+ The generated ids would be:
68
+
69
+ ```
70
+ low-volume
71
+ catalog/products-missing-images
72
+ hourly
73
+ ```
74
+
53
75
  ### Running the Sync
54
76
 
55
77
  Then using the metabase-query-sync cli tool, you can sync those files directly into metabase:
56
78
 
57
79
  ```bash
58
80
  Command:
59
- metabase-query-sync sync
81
+ metabase-query-sync s
60
82
 
61
83
  Usage:
62
- metabase-query-sync sync ROOT_COLLECTION_ID PATH
84
+ metabase-query-sync s ROOT_COLLECTION_ID [PATHS]
63
85
 
64
86
  Description:
65
87
  Sync queries/pulses to your metabase root collection
66
88
 
67
89
  Arguments:
68
90
  ROOT_COLLECTION_ID # REQUIRED The root collection id to sync all items under.
69
- PATH # REQUIRED The path to metabase item files to sync from.
91
+ PATHS # The paths to metabase item files to sync from. Support for scoped paths with custom_name:/path/to/folder is supported as well to ensure each imported item is scoped with custom_name.
70
92
 
71
93
  Options:
72
94
  --[no-]dry-run, -d # Perform a dry run and do not actually sync to the metabase instance., default: false
73
95
  --host=VALUE, -H VALUE # Metabase Host, if not set, will read from env at METABASE_QUERY_SYNC_HOST
74
96
  --user=VALUE, -u VALUE # Metabase User, if not set, will read from env at METABASE_QUERY_SYNC_USER
75
97
  --pass=VALUE, -p VALUE # Metabase Password, if not set, will read from env at METABASE_QUERY_SYNC_PASS
98
+ --config-file=VALUE, -f VALUE # explicit path to .metabase-query-sync.erb.yaml file in case its not in the working directory
76
99
  --help, -h # Print this help
77
100
  ```
78
101
 
102
+ ### Using .metabase-query-sync.erb.yaml
103
+
104
+ It's nice to configure the different paths to search for at once instead of configuring into the command each time, and for that, we support setting defalt config in a file named `.metabase-query-sync.yaml` which should be in the same working directory of the command executation.
105
+
106
+ Here's an example file:
107
+
108
+ ```yaml
109
+ credentials:
110
+ host: http://metabase:3000
111
+ user: ragboyjr@icloud.com
112
+ pass: <%= ENV["METABASE_PASS"] %>
113
+ paths:
114
+ - 'sales:app/sales/queries'
115
+ - 'catalog:app/catalog/queries'
116
+ ```
117
+
118
+ ### Results in Metabase
119
+
120
+ Navigating to metabase under the root collection provided, should show the synced queries and pulses!
121
+
122
+ ![All Synced Items](docs/img/readme-1-everything.png)
123
+ ![Card](docs/img/readme-1-card.png)
124
+ ![Pulse](docs/img/readme-1-pulse.png)
125
+
79
126
  ## Development
80
127
 
81
128
  - Install gems with `bundle install`
82
129
  - Run tests with `bundle exec rspec`
83
130
 
84
- ### TODO
85
-
86
- - Support Collections and Syncing with collections
87
- - Matching IR vs MetabaseApi items should go off of the file name + collection id instead of just the name
88
131
 
89
- ## Debugging with Metabase
132
+ ### Debugging with Metabase
90
133
 
91
134
  To setup the local data source for metabase, run `make db`.
92
135
 
93
136
  Starting the metabase docker container should automatically initialize an empty metabase installation with the main admin user account (ragboyjr@icloud.com / password123).
94
137
 
138
+ ## Roadmap
139
+
140
+ - Support syncing from multiple paths with id prefixes
141
+ - e.g. /path-to-sales-queries:sales /path-to-payment-queries:payment
142
+ - Support `.metabase-query-sync.erb.yaml` configuration file
143
+
95
144
  ## Contributing
96
145
 
97
146
  Bug reports and pull requests are welcome on GitHub at https://github.com/ragboyjr/metabase-query-sync. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/ragboyjr/metabase-query-sync/blob/master/CODE_OF_CONDUCT.md).
@@ -14,16 +14,20 @@ module MetabaseQuerySync
14
14
  desc 'Sync queries/pulses to your metabase root collection'
15
15
 
16
16
  argument :root_collection_id, type: :integer, required: true, desc: 'The root collection id to sync all items under.'
17
- argument :path, type: :string, required: true, desc: 'The path to metabase item files to sync from.'
17
+ argument :paths, type: :array, required: false, desc: 'The paths to metabase item files to sync from. Support for scoped paths with custom_name:/path/to/folder is supported as well to ensure each imported item is scoped with custom_name.'
18
18
  option :dry_run, type: :boolean, default: false, aliases: ['-d'], desc: 'Perform a dry run and do not actually sync to the metabase instance.'
19
19
  option :host, type: :string, aliases: ['-H'], desc: 'Metabase Host, if not set, will read from env at METABASE_QUERY_SYNC_HOST'
20
20
  option :user, type: :string, aliases: ['-u'], desc: 'Metabase User, if not set, will read from env at METABASE_QUERY_SYNC_USER'
21
21
  option :pass, type: :string, aliases: ['-p'], desc: 'Metabase Password, if not set, will read from env at METABASE_QUERY_SYNC_PASS'
22
+ option :config_file, type: :string, aliases: ['-f'], desc: 'explicit path to .metabase-query-sync.erb.yaml file in case its not in the working directory'
22
23
 
23
- def call(root_collection_id:, path:, dry_run: false, host: nil, user: nil, pass: nil)
24
- config = MetabaseQuerySync::Config.new(
25
- credentials: MetabaseQuerySync::MetabaseCredentials.from_env(host: host, user: user, pass: pass),
26
- path: path,
24
+ def call(root_collection_id:, paths: nil, dry_run: false, host: nil, user: nil, pass: nil, config_file: nil, **)
25
+ config = MetabaseQuerySync::Config.from_file(
26
+ config_file || File.join(Dir.pwd, '.metabase-query-sync.erb.yaml'),
27
+ paths: paths,
28
+ host: host,
29
+ user: user,
30
+ pass: pass,
27
31
  )
28
32
  sync = MetabaseQuerySync::Sync.from_config(config, Logger.new(STDOUT))
29
33
  sync.(MetabaseQuerySync::SyncRequest.new(root_collection_id: root_collection_id.to_i, dry_run: dry_run))
@@ -1,12 +1,39 @@
1
+ require 'dry-schema'
2
+ require 'yaml'
3
+ require 'erb'
4
+
1
5
  module MetabaseQuerySync
2
6
  class Config
3
- attr_reader :credentials, :path
7
+ attr_reader :credentials, :paths
4
8
 
5
9
  # @param credentials [MetabaseQuerySync::MetabaseCredentials]
6
- # @param path [String]
7
- def initialize(credentials:, path:)
10
+ # @param paths [Array<String>]
11
+ def initialize(credentials:, paths:)
8
12
  @credentials = credentials
9
- @path = path
13
+ @paths = paths
14
+ end
15
+
16
+ def self.from_file(path, paths: [], host: nil, user: nil, pass: nil)
17
+ if File.exists? path
18
+ data = YAML.load(ERB.new(File.read(path)).result)
19
+ result = Dry::Schema.JSON do
20
+ required(:paths).value(array[:string], min_size?: 1)
21
+ required(:credentials).hash do
22
+ required(:host).filled(:string)
23
+ required(:user).filled(:string)
24
+ required(:pass).filled(:string)
25
+ end
26
+ end.(data)
27
+ raise "Invalid data provided in config file: #{result.errors.to_h}" if result.failure?
28
+ end
29
+ new(
30
+ credentials: MetabaseCredentials.new(
31
+ host: host || data["credentials"]["host"],
32
+ user: user || data["credentials"]["user"],
33
+ pass: pass || data["credentials"]["pass"]
34
+ ),
35
+ paths: paths.empty? ? data["paths"] : paths
36
+ )
10
37
  end
11
38
  end
12
39
  end
@@ -36,27 +36,17 @@ module MetabaseQuerySync::IR
36
36
  end)
37
37
  end
38
38
 
39
- # @return [Query, nil]
40
- def query_by_name(name)
41
- queries.filter { |query| strcmp(query.name, name) }.first
42
- end
43
-
44
- # @return [Pulse, nil]
45
- def pulse_by_name(name)
46
- pulses.filter { |pulse| strcmp(pulse.name, name) }.first
47
- end
48
-
49
39
  # @return [Array<Query>]
50
- def queries_by_pulse(pulse_name)
51
- queries.filter { |query| strcmp(query.pulse, pulse_name) }
40
+ def queries_by_pulse(pulse_id)
41
+ queries.filter { |query| query.pulse == pulse_id }
52
42
  end
53
43
 
54
44
  private
55
45
 
56
46
  def assert_traversal
57
- pulse_names = pulses.map(&:name).map(&:downcase).to_set
47
+ pulse_ids = pulses.map(&:id).to_set
58
48
  queries.each do |q|
59
- raise "No pulse (#{q.pulse}) found for query (#{q.name})" unless pulse_names === q.pulse.downcase
49
+ raise "No pulse (#{q.pulse}) found for query (#{q.name})" unless pulse_ids === q.pulse
60
50
  end
61
51
  end
62
52
 
@@ -22,11 +22,13 @@ module MetabaseQuerySync::IR
22
22
  attribute :schedule, Schedule
23
23
  end
24
24
 
25
+ attribute :id, string
25
26
  attribute :name, string
26
27
  attribute :skip_if_empty, bool.default(true)
27
28
  attribute :alerts, array.of(Alert)
28
29
 
29
30
  validate_with_schema do
31
+ required(:id).filled(:string)
30
32
  required(:name).filled(:string)
31
33
  optional(:skip_if_empty).value(:bool)
32
34
  required(:alerts).value(:array, min_size?: 1).each do
@@ -2,6 +2,7 @@ require 'dry-schema'
2
2
 
3
3
  module MetabaseQuerySync::IR
4
4
  class Query < Model
5
+ attribute :id, string
5
6
  attribute :name, string
6
7
  attribute :description, string.optional.default(nil)
7
8
  attribute :sql, string
@@ -10,6 +11,7 @@ module MetabaseQuerySync::IR
10
11
  attribute :collection, string.optional.default(nil)
11
12
 
12
13
  validate_with_schema do
14
+ required(:id).filled(:string)
13
15
  required(:name).filled(:string)
14
16
  required(:sql).filled(:string)
15
17
  required(:database).filled(:string)
@@ -65,16 +65,6 @@ module MetabaseQuerySync
65
65
  collections.empty? && cards.empty? && pulses.empty?
66
66
  end
67
67
 
68
- # @return [MetabaseApi::Pulse, nil]
69
- def pulse_by_name(name)
70
- pulses.filter { |p| p.name.downcase == name.downcase }.first
71
- end
72
-
73
- # @return [MetabaseApi::Card, nil]
74
- def card_by_name(name)
75
- cards.filter { |c| c.name.downcase == name.downcase }.first
76
- end
77
-
78
68
  def database_by_name(name)
79
69
  databases.filter { |d| d.name.downcase == name.downcase }.first
80
70
  end
@@ -1,20 +1,50 @@
1
+ require 'logger'
1
2
  require 'yaml'
2
3
 
3
4
  class MetabaseQuerySync::ReadIR
4
5
  class FromFiles < self
5
- def initialize(path)
6
- @path = path
6
+ def initialize(path, logger = nil)
7
+ @paths = path.is_a?(Array) ? path : [path]
8
+ @logger = logger || Logger.new(IO::NULL)
9
+
10
+ raise 'Paths must not be empty when reading from files' if @paths.empty?
7
11
  end
8
12
 
9
13
  def call
10
14
  MetabaseQuerySync::IR::Graph.from_items(
11
- # @type [String] f
12
- Dir[File.join(@path, "**/*.{query,pulse}.yaml")].map do |f|
13
- data = YAML.load_file(f)
14
- next MetabaseQuerySync::IR::Query.from_h(data) if f.end_with? 'query.yaml'
15
- next MetabaseQuerySync::IR::Pulse.from_h(data) if f.end_with? 'pulse.yaml'
16
- end
15
+ @paths.flat_map { |p| ir_items_from_path(p) }
17
16
  )
18
17
  end
18
+
19
+ private
20
+
21
+ # @param path [String]
22
+ def ir_items_from_path(path)
23
+ (scope, path) = split_path(path)
24
+ @logger.info "Reading IR Items from path (#{path}) and scope (#{scope})"
25
+
26
+ # @type [String] f
27
+ Dir[File.join(path, "**/*.{query,pulse}.yaml")].map do |f|
28
+ data = YAML.load_file(f)
29
+ next MetabaseQuerySync::IR::Query.from_h(prefix_id(scope, {"id" => id_from_file(path, f)}.merge(data))) if f.end_with? 'query.yaml'
30
+ next MetabaseQuerySync::IR::Pulse.from_h(prefix_id(scope, {"id" => id_from_file(path, f)}.merge(data))) if f.end_with? 'pulse.yaml'
31
+ end
32
+ end
33
+
34
+ # @param path [String]
35
+ def split_path(path)
36
+ path.include?(':') ? path.split(':', 2) : [nil, path]
37
+ end
38
+
39
+ # @param file_path [String]
40
+ def id_from_file(base_path, file_path)
41
+ file_path.gsub(/^#{Regexp.quote(File.join(base_path, ''))}(.+)\.(query|pulse)\.yaml$/, '\1')
42
+ end
43
+
44
+ # @param attributes [Hash]
45
+ def prefix_id(scope, attributes)
46
+ return attributes unless scope
47
+ attributes.merge({"id" => File.join(scope, attributes["id"])})
48
+ end
19
49
  end
20
50
  end
@@ -12,7 +12,7 @@ module MetabaseQuerySync
12
12
 
13
13
  # @param config [Config]
14
14
  def self.from_config(config, logger = nil)
15
- new(ReadIR::FromFiles.new(config.path), MetabaseApi::FaradayMetabaseApi.from_metabase_credentials(config.credentials), logger)
15
+ new(ReadIR::FromFiles.new(config.paths, logger), MetabaseApi::FaradayMetabaseApi.from_metabase_credentials(config.credentials), logger)
16
16
  end
17
17
 
18
18
  # @param sync_req [SyncRequest]
@@ -48,7 +48,7 @@ module MetabaseQuerySync
48
48
  # @param metabase_state [MetabaseState]
49
49
  def delete_pulses(graph, metabase_state)
50
50
  metabase_state.pulses
51
- .filter { |pulse| graph.pulse_by_name(pulse.name) == nil }
51
+ .filter { |pulse| find_graph_pulse(graph, id(pulse)) == nil }
52
52
  .map { |pulse| MetabaseApi::PutPulseRequest.from_pulse(pulse).new(archived: true) }
53
53
  end
54
54
 
@@ -56,7 +56,7 @@ module MetabaseQuerySync
56
56
  # @param metabase_state [MetabaseState]
57
57
  def delete_cards(graph, metabase_state)
58
58
  metabase_state.cards
59
- .filter { |card| graph.query_by_name(card.name) == nil }
59
+ .filter { |card| find_graph_query(graph, id(card)) == nil }
60
60
  .map { |card| MetabaseApi::PutCardRequest.from_card(card).new(archived: true) }
61
61
  end
62
62
 
@@ -66,11 +66,12 @@ module MetabaseQuerySync
66
66
  def add_cards(graph, metabase_state, root_collection_id)
67
67
  graph.queries
68
68
  .map do |q|
69
- [q, metabase_state.card_by_name(q.name)]
69
+ [q, find_api_card(metabase_state, id(q))]
70
70
  end
71
71
  .filter do |(q, card)|
72
72
  next true unless card
73
73
  card.dataset_query.native.query != q.sql ||
74
+ card.name != api_item_name(q) ||
74
75
  card.database_id != metabase_state.database_by_name(q.database)&.id ||
75
76
  card.description != q.description
76
77
  end
@@ -81,7 +82,7 @@ module MetabaseQuerySync
81
82
  id: card&.id,
82
83
  sql: q.sql,
83
84
  database_id: metabase_state.database_by_name(q.database)&.id,
84
- name: q.name,
85
+ name: api_item_name(q),
85
86
  description: q.description,
86
87
  collection_id: root_collection_id,
87
88
  )
@@ -94,11 +95,11 @@ module MetabaseQuerySync
94
95
  def add_pulses(graph, metabase_state, root_collection_id)
95
96
  graph.pulses
96
97
  .map do |pulse|
97
- api_pulse = metabase_state.pulse_by_name(pulse.name)
98
+ api_pulse = find_api_pulse(metabase_state, id(pulse))
98
99
  pulse_cards = graph
99
- .queries_by_pulse(pulse.name)
100
+ .queries_by_pulse(pulse.id)
100
101
  .flat_map do |query|
101
- card = metabase_state.card_by_name(query.name)
102
+ card = find_api_card(metabase_state, id(query))
102
103
  card ? [card] : []
103
104
  end
104
105
  .map { |card| MetabaseApi::Pulse::Card.new(id: card.id) }
@@ -125,12 +126,14 @@ module MetabaseQuerySync
125
126
  end
126
127
  .filter do |(pulse, api_pulse, pulse_cards, pulse_channels)|
127
128
  next true unless api_pulse
128
- api_pulse.cards != pulse_cards || api_pulse.channels != pulse_channels
129
+ api_pulse.cards != pulse_cards ||
130
+ api_pulse.channels != pulse_channels ||
131
+ api_pulse.name != api_item_name(pulse)
129
132
  end
130
133
  .map do |(pulse, api_pulse, pulse_cards, pulse_channels)|
131
134
  MetabaseApi::PutPulseRequest.new(
132
135
  id: api_pulse&.id,
133
- name: pulse.name,
136
+ name: "#{pulse.id}:#{pulse.name}",
134
137
  cards: pulse_cards,
135
138
  channels: pulse_channels,
136
139
  collection_id: root_collection_id,
@@ -163,5 +166,52 @@ module MetabaseQuerySync
163
166
  end
164
167
  end
165
168
  end
169
+
170
+ # @param item [IR::Model]
171
+ # @return [String]
172
+ def api_item_name(item)
173
+ "#{item.id}:#{item.name}"
174
+ end
175
+
176
+ # @param graph [IR::Graph]
177
+ # @param query_id [String]
178
+ # @return [IR::Query, nil]
179
+ def find_graph_query(graph, query_id)
180
+ graph.queries.filter { |q| id(q) == query_id }.first
181
+ end
182
+
183
+ # @param graph [IR::Graph]
184
+ # @param pulse_id [String]
185
+ # @return [IR::Pulse, nil]
186
+ def find_graph_pulse(graph, pulse_id)
187
+ graph.pulses.filter { |p| id(p) == pulse_id }.first
188
+ end
189
+
190
+ # @param metabase_state [MetabaseState]
191
+ # @param card_id [String]
192
+ # @return [MetabaseApi::Card, nil]
193
+ def find_api_card(metabase_state, card_id)
194
+ metabase_state.cards.filter { |c| id(c) == card_id }.first
195
+ end
196
+
197
+ # @param metabase_state [MetabaseState]
198
+ # @param pulse_id [String]
199
+ # @return [MetabaseApi::Pulse, nil]
200
+ def find_api_pulse(metabase_state, pulse_id)
201
+ metabase_state.pulses.filter { |p| id(p) == pulse_id }.first
202
+ end
203
+
204
+ # gets the normalized id from the given object to be used for comparisons
205
+ # @return [String]
206
+ def id(object)
207
+ case object
208
+ when IR::Model
209
+ object.id
210
+ when MetabaseApi::Model
211
+ object.name[/^([^:]+):/, 1] # metabase api names are constructed with #{IR id}:#{IR name}
212
+ else
213
+ raise "Unexpected object (#{object.class}) provided."
214
+ end
215
+ end
166
216
  end
167
217
  end
@@ -1,3 +1,3 @@
1
1
  module MetabaseQuerySync
2
- VERSION = '0.1.1'
2
+ VERSION = '0.2.0'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metabase_query_sync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - RJ Garcia
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-02-28 00:00:00.000000000 Z
11
+ date: 2021-03-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk