metabase_query_sync 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/bin/metabase-query-sync +5 -0
- data/lib/metabase_query_sync/cli.rb +36 -0
- data/lib/metabase_query_sync/config.rb +12 -0
- data/lib/metabase_query_sync/ir/collection.rb +14 -0
- data/lib/metabase_query_sync/ir/graph.rb +69 -0
- data/lib/metabase_query_sync/ir/model.rb +30 -0
- data/lib/metabase_query_sync/ir/pulse.rb +50 -0
- data/lib/metabase_query_sync/ir/query.rb +22 -0
- data/lib/metabase_query_sync/ir.rb +3 -0
- data/lib/metabase_query_sync/metabase_api/card.rb +33 -0
- data/lib/metabase_query_sync/metabase_api/collection.rb +12 -0
- data/lib/metabase_query_sync/metabase_api/database.rb +8 -0
- data/lib/metabase_query_sync/metabase_api/faraday_metabase_api.rb +118 -0
- data/lib/metabase_query_sync/metabase_api/item.rb +20 -0
- data/lib/metabase_query_sync/metabase_api/model.rb +23 -0
- data/lib/metabase_query_sync/metabase_api/pulse.rb +97 -0
- data/lib/metabase_query_sync/metabase_api/put_card_request.rb +16 -0
- data/lib/metabase_query_sync/metabase_api/put_collection_request.rb +13 -0
- data/lib/metabase_query_sync/metabase_api/put_pulse_request.rb +12 -0
- data/lib/metabase_query_sync/metabase_api/session.rb +7 -0
- data/lib/metabase_query_sync/metabase_api/stub_metabase_api.rb +96 -0
- data/lib/metabase_query_sync/metabase_api.rb +37 -0
- data/lib/metabase_query_sync/metabase_credentials.rb +18 -0
- data/lib/metabase_query_sync/metabase_state.rb +82 -0
- data/lib/metabase_query_sync/read_ir/from_files.rb +20 -0
- data/lib/metabase_query_sync/read_ir.rb +7 -0
- data/lib/metabase_query_sync/sync.rb +167 -0
- data/lib/metabase_query_sync/sync_request.rb +12 -0
- data/lib/metabase_query_sync/types.rb +3 -0
- data/lib/metabase_query_sync/version.rb +3 -0
- data/lib/metabase_query_sync.rb +11 -0
- metadata +176 -0
@@ -0,0 +1,97 @@
|
|
1
|
+
class MetabaseQuerySync::MetabaseApi
|
2
|
+
class Pulse < Model
|
3
|
+
KEY = "pulse"
|
4
|
+
|
5
|
+
class Channel < Model
|
6
|
+
attribute :enabled, MetabaseQuerySync::Types::Strict::Bool.default(true)
|
7
|
+
attribute :schedule_type, MetabaseQuerySync::Types::Strict::String
|
8
|
+
attribute :schedule_day, MetabaseQuerySync::Types::Strict::String.optional
|
9
|
+
attribute :schedule_hour, MetabaseQuerySync::Types::Strict::Integer.optional
|
10
|
+
attribute :schedule_frame, MetabaseQuerySync::Types::Strict::String.optional
|
11
|
+
attribute :channel_type, MetabaseQuerySync::Types::Strict::String.enum('email', 'slack')
|
12
|
+
attribute? :recipients, MetabaseQuerySync::Types::Strict::Array do
|
13
|
+
attribute :email, MetabaseQuerySync::Types::Strict::String
|
14
|
+
end
|
15
|
+
attribute? :details do
|
16
|
+
attribute :channel, MetabaseQuerySync::Types::Strict::String
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.build
|
20
|
+
(yield ChannelBuilder.new).()
|
21
|
+
end
|
22
|
+
|
23
|
+
class ChannelBuilder
|
24
|
+
def initialize
|
25
|
+
@args = {}
|
26
|
+
@class = nil
|
27
|
+
end
|
28
|
+
|
29
|
+
# @param hour [Integer] value between 0 and 23
|
30
|
+
def daily(hour)
|
31
|
+
assert_hour hour
|
32
|
+
@args = @args.merge({schedule_type: 'daily', schedule_day: nil, schedule_frame: nil, schedule_hour: hour})
|
33
|
+
self
|
34
|
+
end
|
35
|
+
|
36
|
+
def assert_hour(hour)
|
37
|
+
raise "invalid hour provided (#{hour})" unless (0..23) === hour
|
38
|
+
end
|
39
|
+
def assert_day(day)
|
40
|
+
raise "invalid day provided (#{day})" unless [:sun, :mon, :tue, :wed, :thu, :fri, :sat] === day
|
41
|
+
end
|
42
|
+
|
43
|
+
# @param hour [Integer] value between 0 and 23
|
44
|
+
# @param day [:sun, :mon, :tue, :wed, :thu, :fri, :sat]
|
45
|
+
def weekly(hour, day)
|
46
|
+
assert_hour hour
|
47
|
+
assert_day day
|
48
|
+
@args = @args.merge({
|
49
|
+
schedule_type: 'weekly',
|
50
|
+
schedule_day: day.to_s,
|
51
|
+
schedule_frame: nil,
|
52
|
+
schedule_hour: hour
|
53
|
+
})
|
54
|
+
self
|
55
|
+
end
|
56
|
+
|
57
|
+
def hourly
|
58
|
+
@args = @args.merge({schedule_type: 'hourly', schedule_day: nil, schedule_frame: nil, schedule_hour: nil})
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
# @param emails [Array<String>]
|
63
|
+
def emails(emails)
|
64
|
+
@args = @args.merge({channel_type: 'email', recipients: emails.map {|e| {email: e}} })
|
65
|
+
self
|
66
|
+
end
|
67
|
+
|
68
|
+
# @param channel [String]
|
69
|
+
def slack(channel)
|
70
|
+
@args = @args.merge({channel_type: 'slack', details: {channel: channel}})
|
71
|
+
self
|
72
|
+
end
|
73
|
+
|
74
|
+
def call()
|
75
|
+
Channel.new(@args)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
class Card < Model
|
81
|
+
attribute :id, MetabaseQuerySync::Types::Strict::Integer
|
82
|
+
attribute :include_csv, MetabaseQuerySync::Types::Strict::Bool.default(false)
|
83
|
+
attribute :include_xls, MetabaseQuerySync::Types::Strict::Bool.default(false)
|
84
|
+
end
|
85
|
+
|
86
|
+
has :id, :archived, :name, :collection_id
|
87
|
+
attribute :cards, MetabaseQuerySync::Types::Strict::Array.of(Pulse::Card)
|
88
|
+
attribute :channels, MetabaseQuerySync::Types::Strict::Array.of(Pulse::Channel)
|
89
|
+
attribute :skip_if_empty, MetabaseQuerySync::Types::Strict::Bool
|
90
|
+
|
91
|
+
# @param put_pulse_request [PutPulseRequest]
|
92
|
+
# @return self
|
93
|
+
def self.from_request(put_pulse_request)
|
94
|
+
new(put_pulse_request.to_h)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class MetabaseQuerySync::MetabaseApi
|
2
|
+
class PutCardRequest < Model
|
3
|
+
has :id, :archived, :name, :description, :collection_id
|
4
|
+
attribute :display, MetabaseQuerySync::Types::Strict::String.default('table'.freeze)
|
5
|
+
attribute :visualization_settings, MetabaseQuerySync::Types::Strict::Hash.default({}.freeze)
|
6
|
+
attribute :dataset_query, Card::DatasetQuery
|
7
|
+
|
8
|
+
def self.native(sql:, database_id:, **kwargs)
|
9
|
+
new(dataset_query: Card::DatasetQuery.native(sql: sql, database_id: database_id), **kwargs)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.from_card(card)
|
13
|
+
new(card.to_h)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class MetabaseQuerySync::MetabaseApi
|
2
|
+
class PutCollectionRequest < Model
|
3
|
+
has :id, :name, :description, :archived
|
4
|
+
attribute :color, MetabaseQuerySync::Types::Strict::String.default('#509EE3'.freeze)
|
5
|
+
attribute :parent_id, MetabaseQuerySync::Types::Strict::Integer.optional.default(nil)
|
6
|
+
|
7
|
+
# TODO: implement collections
|
8
|
+
# # @param collection [Collection]
|
9
|
+
# def self.from_collection(collection)
|
10
|
+
# new(collection.to_h)
|
11
|
+
# end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class MetabaseQuerySync::MetabaseApi
|
2
|
+
class PutPulseRequest < Model
|
3
|
+
has :id, :name, :archived, :collection_id
|
4
|
+
attribute :cards, MetabaseQuerySync::Types::Strict::Array.of(Pulse::Card)
|
5
|
+
attribute :channels, MetabaseQuerySync::Types::Strict::Array.of(Pulse::Channel)
|
6
|
+
attribute :skip_if_empty, MetabaseQuerySync::Types::Strict::Bool
|
7
|
+
|
8
|
+
def self.from_pulse(pulse)
|
9
|
+
new(pulse.to_h)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'dry-monads'
|
2
|
+
|
3
|
+
class MetabaseQuerySync::MetabaseApi
|
4
|
+
class StubMetabaseApi < self
|
5
|
+
include Dry::Monads[:result]
|
6
|
+
|
7
|
+
attr_reader :requests
|
8
|
+
|
9
|
+
def initialize(collections: [], pulses: [], cards: [], databases: [])
|
10
|
+
@collections = collections
|
11
|
+
@pulses = pulses
|
12
|
+
@cards = cards
|
13
|
+
@databases = databases
|
14
|
+
@requests = []
|
15
|
+
end
|
16
|
+
|
17
|
+
def get_collection(id)
|
18
|
+
find_by_id(@collections, id)
|
19
|
+
end
|
20
|
+
|
21
|
+
def get_card(id)
|
22
|
+
find_by_id(@cards, id)
|
23
|
+
end
|
24
|
+
|
25
|
+
def get_databases
|
26
|
+
Success(@databases)
|
27
|
+
end
|
28
|
+
|
29
|
+
def get_pulse(id)
|
30
|
+
find_by_id(@pulses, id)
|
31
|
+
end
|
32
|
+
|
33
|
+
def put_pulse(pulse_request)
|
34
|
+
put_req(Pulse, pulse_request, @pulses) { |pulses| @pulses = pulses }
|
35
|
+
end
|
36
|
+
|
37
|
+
def put_collection(collection_request)
|
38
|
+
@requests << collection_request
|
39
|
+
Success(collection_request)
|
40
|
+
end
|
41
|
+
|
42
|
+
def put_card(card_request)
|
43
|
+
put_req(Card, card_request, @cards) { |cards| @cards = cards }
|
44
|
+
end
|
45
|
+
|
46
|
+
def get_collection_items(collection_id)
|
47
|
+
get_collection(collection_id).bind do
|
48
|
+
Success(@collections.chain(@pulses, @cards).filter do |item|
|
49
|
+
case item
|
50
|
+
when Collection
|
51
|
+
item.parent_id == collection_id
|
52
|
+
when Pulse
|
53
|
+
item.collection_id == collection_id
|
54
|
+
when Card
|
55
|
+
item.collection_id == collection_id
|
56
|
+
else
|
57
|
+
false
|
58
|
+
end
|
59
|
+
end.map do |item|
|
60
|
+
Item.new(id: item.id, name: item.name, description: item.respond_to?(:description) ? item.description : nil, model: case item
|
61
|
+
when Collection
|
62
|
+
Collection::KEY
|
63
|
+
when Pulse
|
64
|
+
Pulse::KEY
|
65
|
+
when Card
|
66
|
+
Card::KEY
|
67
|
+
end)
|
68
|
+
end)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def find_by_id(items, id)
|
75
|
+
item = items.find { |i| i.id == id }
|
76
|
+
item ? Success(item) : Failure(nil)
|
77
|
+
end
|
78
|
+
|
79
|
+
def match_id(id)
|
80
|
+
->(item) { item.id == id }
|
81
|
+
end
|
82
|
+
|
83
|
+
def put_req(klass, req, collection)
|
84
|
+
@requests << req
|
85
|
+
item = klass.from_request(req)
|
86
|
+
if item.id
|
87
|
+
collection = collection.map { |i| i.id == item.id ? item : i }
|
88
|
+
else
|
89
|
+
item = item.new(id: collection.map { |i| i.id }.compact.max.to_i + 1)
|
90
|
+
collection << item
|
91
|
+
end
|
92
|
+
yield collection
|
93
|
+
Success(item)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# Lightweight metabase api interface to be just enough
|
2
|
+
# for this project.
|
3
|
+
class MetabaseQuerySync::MetabaseApi
|
4
|
+
# collections
|
5
|
+
|
6
|
+
def get_collection(id); throw; end
|
7
|
+
def get_collection_items(collection_id); throw; end
|
8
|
+
# @param collection_request [PutCollectionRequest]
|
9
|
+
def put_collection(collection_request); throw; end
|
10
|
+
|
11
|
+
# cards
|
12
|
+
|
13
|
+
def get_card(id); throw; end
|
14
|
+
# @param [PutCardRequest]
|
15
|
+
def put_card(card_request); throw; end
|
16
|
+
def delete_card(card_id); throw; end
|
17
|
+
|
18
|
+
# pulses
|
19
|
+
|
20
|
+
def get_pulse(id); throw; end
|
21
|
+
# @param [PutPulseRequest]
|
22
|
+
def put_pulse(pulse_request); throw; end
|
23
|
+
def delete_pulse(pulse_id); throw; end
|
24
|
+
|
25
|
+
# database
|
26
|
+
|
27
|
+
def get_databases(); throw; end
|
28
|
+
|
29
|
+
# search
|
30
|
+
def search(q, model: nil); throw; end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def throw
|
35
|
+
raise 'not implemented'
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class MetabaseQuerySync::MetabaseCredentials
|
2
|
+
attr_reader :host, :user, :pass
|
3
|
+
|
4
|
+
def initialize(host:, user:, pass:)
|
5
|
+
raise "Metabase credentials for host, user, pass must not be empty)" if host == nil || user == nil || pass == nil
|
6
|
+
@host = host
|
7
|
+
@user = user
|
8
|
+
@pass = pass
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.from_env(host: nil, user: nil, pass: nil, env: ENV)
|
12
|
+
self.new(
|
13
|
+
host: host || env['METABASE_QUERY_SYNC_HOST'],
|
14
|
+
user: user || env['METABASE_QUERY_SYNC_USER'],
|
15
|
+
pass: pass || env['METABASE_QUERY_SYNC_PASS'],
|
16
|
+
)
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'dry-struct'
|
2
|
+
|
3
|
+
# Holds all of the data/state that has been previously synced to metabase
|
4
|
+
module MetabaseQuerySync
|
5
|
+
# @!method collections
|
6
|
+
# @return [Array<MetabaseApi::Collection>]
|
7
|
+
# @!method cards
|
8
|
+
# @return [Array<MetabaseApi::Card>]
|
9
|
+
# @!method pulses
|
10
|
+
# @return [Array<MetabaseApi::Pulse>]
|
11
|
+
# @!method databases
|
12
|
+
# @return [Array<MetabaseApi::Database>]
|
13
|
+
class MetabaseState < Dry::Struct
|
14
|
+
attribute :collections, Types::Strict::Array.of(MetabaseApi::Collection)
|
15
|
+
attribute :cards, Types::Strict::Array.of(MetabaseApi::Card)
|
16
|
+
attribute :pulses, Types::Strict::Array.of(MetabaseApi::Pulse)
|
17
|
+
attribute :databases, Types::Strict::Array.of(MetabaseApi::Database)
|
18
|
+
|
19
|
+
# @param metabase_api [MetabaseApi]
|
20
|
+
# @param root_collection_id [Integer]
|
21
|
+
# @return [MetabaseState]
|
22
|
+
def self.from_metabase_api(metabase_api, root_collection_id)
|
23
|
+
items = metabase_api.get_collection_items(root_collection_id)
|
24
|
+
if items.failure?
|
25
|
+
raise "No root collection (id: #{root_collection_id}) found"
|
26
|
+
end
|
27
|
+
|
28
|
+
acc = items.value!
|
29
|
+
.filter { |i| i.card? || i.pulse? }
|
30
|
+
.map do |item|
|
31
|
+
if item.card?
|
32
|
+
metabase_api.get_card(item.id).value!
|
33
|
+
elsif item.pulse?
|
34
|
+
metabase_api.get_pulse(item.id).value!
|
35
|
+
else
|
36
|
+
raise 'Unexpected item type.'
|
37
|
+
end
|
38
|
+
end
|
39
|
+
.reduce({cards: [], pulses: []}) do |acc, item|
|
40
|
+
case item
|
41
|
+
when MetabaseApi::Card
|
42
|
+
acc[:cards] << item
|
43
|
+
when MetabaseApi::Pulse
|
44
|
+
acc[:pulses] << item
|
45
|
+
else
|
46
|
+
raise 'Unexpected item type.'
|
47
|
+
end
|
48
|
+
acc
|
49
|
+
end
|
50
|
+
|
51
|
+
new(collections: [], cards: acc[:cards], pulses: acc[:pulses], databases: metabase_api.get_databases.value!)
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return self
|
55
|
+
def with_card(card)
|
56
|
+
new(cards: cards.concat([card]))
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return self
|
60
|
+
def with_pulse(pulse)
|
61
|
+
new(pulses: pulses.concat([pulse]))
|
62
|
+
end
|
63
|
+
|
64
|
+
def empty?
|
65
|
+
collections.empty? && cards.empty? && pulses.empty?
|
66
|
+
end
|
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
|
+
def database_by_name(name)
|
79
|
+
databases.filter { |d| d.name.downcase == name.downcase }.first
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
class MetabaseQuerySync::ReadIR
|
4
|
+
class FromFiles < self
|
5
|
+
def initialize(path)
|
6
|
+
@path = path
|
7
|
+
end
|
8
|
+
|
9
|
+
def call
|
10
|
+
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
|
17
|
+
)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,167 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
module MetabaseQuerySync
|
4
|
+
# @!attribute metabase_api
|
5
|
+
# @return [MetabaseApi]
|
6
|
+
class Sync
|
7
|
+
def initialize(read_ir, metabase_api, logger = nil)
|
8
|
+
@read_ir = read_ir
|
9
|
+
@metabase_api = metabase_api
|
10
|
+
@logger = logger || Logger.new(IO::NULL)
|
11
|
+
end
|
12
|
+
|
13
|
+
# @param config [Config]
|
14
|
+
def self.from_config(config, logger = nil)
|
15
|
+
new(ReadIR::FromFiles.new(config.path), MetabaseApi::FaradayMetabaseApi.from_metabase_credentials(config.credentials), logger)
|
16
|
+
end
|
17
|
+
|
18
|
+
# @param sync_req [SyncRequest]
|
19
|
+
def call(sync_req)
|
20
|
+
@logger.info "Starting sync with req: #{sync_req.to_h}"
|
21
|
+
graph = @read_ir.()
|
22
|
+
metabase_state = MetabaseState.from_metabase_api(@metabase_api, sync_req.root_collection_id)
|
23
|
+
|
24
|
+
metabase_state = sync_requests(calc_cards_diff(graph, metabase_state, sync_req.root_collection_id), sync_req.dry_run, metabase_state)
|
25
|
+
metabase_state = sync_requests(calc_pulses_diff(graph, metabase_state, sync_req.root_collection_id), sync_req.dry_run, metabase_state)
|
26
|
+
|
27
|
+
@logger.info "Finished sync"
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def calc_cards_diff(graph, metabase_state, root_collection_id)
|
33
|
+
[].chain(
|
34
|
+
delete_cards(graph, metabase_state),
|
35
|
+
add_cards(graph, metabase_state, root_collection_id)
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
# pulses need to be synced after the cards since pulses need to make use of card ids
|
40
|
+
def calc_pulses_diff(graph, metabase_state, root_collection_id)
|
41
|
+
[].chain(
|
42
|
+
delete_pulses(graph, metabase_state),
|
43
|
+
add_pulses(graph, metabase_state, root_collection_id)
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
# @param graph [IR::Graph]
|
48
|
+
# @param metabase_state [MetabaseState]
|
49
|
+
def delete_pulses(graph, metabase_state)
|
50
|
+
metabase_state.pulses
|
51
|
+
.filter { |pulse| graph.pulse_by_name(pulse.name) == nil }
|
52
|
+
.map { |pulse| MetabaseApi::PutPulseRequest.from_pulse(pulse).new(archived: true) }
|
53
|
+
end
|
54
|
+
|
55
|
+
# @param graph [IR::Graph]
|
56
|
+
# @param metabase_state [MetabaseState]
|
57
|
+
def delete_cards(graph, metabase_state)
|
58
|
+
metabase_state.cards
|
59
|
+
.filter { |card| graph.query_by_name(card.name) == nil }
|
60
|
+
.map { |card| MetabaseApi::PutCardRequest.from_card(card).new(archived: true) }
|
61
|
+
end
|
62
|
+
|
63
|
+
# @param graph [IR::Graph]
|
64
|
+
# @param metabase_state [MetabaseState]
|
65
|
+
# @param root_collection_id [Integer]
|
66
|
+
def add_cards(graph, metabase_state, root_collection_id)
|
67
|
+
graph.queries
|
68
|
+
.map do |q|
|
69
|
+
[q, metabase_state.card_by_name(q.name)]
|
70
|
+
end
|
71
|
+
.filter do |(q, card)|
|
72
|
+
next true unless card
|
73
|
+
card.dataset_query.native.query != q.sql ||
|
74
|
+
card.database_id != metabase_state.database_by_name(q.database)&.id ||
|
75
|
+
card.description != q.description
|
76
|
+
end
|
77
|
+
.map do |(q, card)|
|
78
|
+
database_id = metabase_state.database_by_name(q.database)&.id
|
79
|
+
raise "Database (#{q.database}) not found" if database_id == nil
|
80
|
+
MetabaseApi::PutCardRequest.native(
|
81
|
+
id: card&.id,
|
82
|
+
sql: q.sql,
|
83
|
+
database_id: metabase_state.database_by_name(q.database)&.id,
|
84
|
+
name: q.name,
|
85
|
+
description: q.description,
|
86
|
+
collection_id: root_collection_id,
|
87
|
+
)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# @param graph [IR::Graph]
|
92
|
+
# @param metabase_state [MetabaseState]
|
93
|
+
# # @param root_collection_id [Integer]
|
94
|
+
def add_pulses(graph, metabase_state, root_collection_id)
|
95
|
+
graph.pulses
|
96
|
+
.map do |pulse|
|
97
|
+
api_pulse = metabase_state.pulse_by_name(pulse.name)
|
98
|
+
pulse_cards = graph
|
99
|
+
.queries_by_pulse(pulse.name)
|
100
|
+
.flat_map do |query|
|
101
|
+
card = metabase_state.card_by_name(query.name)
|
102
|
+
card ? [card] : []
|
103
|
+
end
|
104
|
+
.map { |card| MetabaseApi::Pulse::Card.new(id: card.id) }
|
105
|
+
pulse_channels = pulse.alerts.map do |alert|
|
106
|
+
MetabaseApi::Pulse::Channel.build do |c|
|
107
|
+
case alert.type
|
108
|
+
when 'email'
|
109
|
+
c.emails alert.email.emails
|
110
|
+
when 'slack'
|
111
|
+
c.slack alert.slack.channel
|
112
|
+
end
|
113
|
+
|
114
|
+
case alert.schedule.type
|
115
|
+
when 'hourly'
|
116
|
+
c.hourly
|
117
|
+
when 'daily'
|
118
|
+
c.daily(alert.hour)
|
119
|
+
when 'weekly'
|
120
|
+
c.weekly(alert.hour, alert.day)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
[pulse, api_pulse, pulse_cards, pulse_channels]
|
125
|
+
end
|
126
|
+
.filter do |(pulse, api_pulse, pulse_cards, pulse_channels)|
|
127
|
+
next true unless api_pulse
|
128
|
+
api_pulse.cards != pulse_cards || api_pulse.channels != pulse_channels
|
129
|
+
end
|
130
|
+
.map do |(pulse, api_pulse, pulse_cards, pulse_channels)|
|
131
|
+
MetabaseApi::PutPulseRequest.new(
|
132
|
+
id: api_pulse&.id,
|
133
|
+
name: pulse.name,
|
134
|
+
cards: pulse_cards,
|
135
|
+
channels: pulse_channels,
|
136
|
+
collection_id: root_collection_id,
|
137
|
+
skip_if_empty: true,
|
138
|
+
)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# sync requests up to metabase
|
143
|
+
# @param requests [Enumerator]
|
144
|
+
# @param metabase_state [MetabaseState]
|
145
|
+
def sync_requests(requests, dry_run, metabase_state)
|
146
|
+
requests.reduce(metabase_state) do |metabase_state, req|
|
147
|
+
case req
|
148
|
+
when MetabaseApi::PutPulseRequest
|
149
|
+
@logger.info "PutPulseRequest #{req.to_h}"
|
150
|
+
next metabase_state if dry_run
|
151
|
+
@metabase_api.put_pulse(req).fmap do |pulse|
|
152
|
+
metabase_state.with_pulse(pulse)
|
153
|
+
end.value!
|
154
|
+
when MetabaseApi::PutCardRequest
|
155
|
+
@logger.info "PutCardRequest #{req.to_h}"
|
156
|
+
next metabase_state if dry_run
|
157
|
+
@metabase_api.put_card(req).fmap do |card|
|
158
|
+
metabase_state.with_card(card)
|
159
|
+
end.value!
|
160
|
+
else
|
161
|
+
@logger.error "Unhandled Request Type: #{req.class}"
|
162
|
+
metabase_state
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'dry-struct'
|
2
|
+
|
3
|
+
module MetabaseQuerySync
|
4
|
+
# @!method root_collection_id
|
5
|
+
# @return [Integer]
|
6
|
+
# @!method dry_run
|
7
|
+
# @return [Boolean]
|
8
|
+
class SyncRequest < Dry::Struct
|
9
|
+
attribute :root_collection_id, Types::Strict::Integer
|
10
|
+
attribute :dry_run, Types::Strict::Bool.default(false)
|
11
|
+
end
|
12
|
+
end
|