metabase_query_sync 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +105 -0
  4. data/bin/metabase-query-sync +5 -0
  5. data/lib/metabase_query_sync/cli.rb +36 -0
  6. data/lib/metabase_query_sync/config.rb +12 -0
  7. data/lib/metabase_query_sync/ir/collection.rb +14 -0
  8. data/lib/metabase_query_sync/ir/graph.rb +69 -0
  9. data/lib/metabase_query_sync/ir/model.rb +30 -0
  10. data/lib/metabase_query_sync/ir/pulse.rb +50 -0
  11. data/lib/metabase_query_sync/ir/query.rb +22 -0
  12. data/lib/metabase_query_sync/ir.rb +3 -0
  13. data/lib/metabase_query_sync/metabase_api/card.rb +33 -0
  14. data/lib/metabase_query_sync/metabase_api/collection.rb +12 -0
  15. data/lib/metabase_query_sync/metabase_api/database.rb +8 -0
  16. data/lib/metabase_query_sync/metabase_api/faraday_metabase_api.rb +118 -0
  17. data/lib/metabase_query_sync/metabase_api/item.rb +20 -0
  18. data/lib/metabase_query_sync/metabase_api/model.rb +23 -0
  19. data/lib/metabase_query_sync/metabase_api/pulse.rb +97 -0
  20. data/lib/metabase_query_sync/metabase_api/put_card_request.rb +16 -0
  21. data/lib/metabase_query_sync/metabase_api/put_collection_request.rb +13 -0
  22. data/lib/metabase_query_sync/metabase_api/put_pulse_request.rb +12 -0
  23. data/lib/metabase_query_sync/metabase_api/session.rb +7 -0
  24. data/lib/metabase_query_sync/metabase_api/stub_metabase_api.rb +96 -0
  25. data/lib/metabase_query_sync/metabase_api.rb +37 -0
  26. data/lib/metabase_query_sync/metabase_credentials.rb +18 -0
  27. data/lib/metabase_query_sync/metabase_state.rb +82 -0
  28. data/lib/metabase_query_sync/read_ir/from_files.rb +20 -0
  29. data/lib/metabase_query_sync/read_ir.rb +7 -0
  30. data/lib/metabase_query_sync/sync.rb +167 -0
  31. data/lib/metabase_query_sync/sync_request.rb +12 -0
  32. data/lib/metabase_query_sync/types.rb +3 -0
  33. data/lib/metabase_query_sync/version.rb +3 -0
  34. data/lib/metabase_query_sync.rb +11 -0
  35. 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,7 @@
1
+ class MetabaseQuerySync::MetabaseApi
2
+ # @!method id
3
+ # @return [String]
4
+ class Session < Model
5
+ attribute :id, MetabaseQuerySync::Types::Strict::String
6
+ end
7
+ 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,7 @@
1
+ # ReadIR from source and load up instances of an IR root model
2
+ class MetabaseQuerySync::ReadIR
3
+ # @return [MetabaseQuerySync::IR::Graph]
4
+ def call()
5
+ raise 'not implemented.'
6
+ end
7
+ 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
@@ -0,0 +1,3 @@
1
+ module MetabaseQuerySync::Types
2
+ include Dry.Types
3
+ end
@@ -0,0 +1,3 @@
1
+ module MetabaseQuerySync
2
+ VERSION = '0.1.1'
3
+ end
@@ -0,0 +1,11 @@
1
+ require 'zeitwerk'
2
+
3
+ loader = Zeitwerk::Loader.for_gem
4
+ loader.inflector.inflect("ir" => "IR")
5
+ loader.inflector.inflect("read_ir" => "ReadIR")
6
+ loader.inflector.inflect("cli" => "CLI")
7
+ loader.setup
8
+
9
+ module MetabaseQuerySync
10
+
11
+ end