metabase_query_sync 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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