any_query 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4acc0ba4d4783462b9c1d7b985395b44ff91f857c519252379d0a9f4f2bbcc63
4
+ data.tar.gz: 71b6ed3cbceef45d8a4f696075faa680249e28b995bf3ea1406416c7b0be6334
5
+ SHA512:
6
+ metadata.gz: 519287d516d95e87749cab7de90425622bab52466f5bc08de5c721bdab167222299452da647bb9d6e746ed24f28c1e3f5b3e07470f579326aa6db68450ec8e7f
7
+ data.tar.gz: bfadab83910a87cb40c0948ff5e71a7a4ce634b561e0295ffacaf5cc8919664d4305fdea553f1e724128cf6773b06506b0ca353b8caa902fb12e34a67215b5fd
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyQuery
4
+ module Adapters
5
+ # @api private
6
+ class Base
7
+ def initialize(config)
8
+ @config = config.to_h
9
+ end
10
+
11
+ def load(model, select:, joins:, where:, limit:)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def load_single(model, id, joins)
16
+ load(model, select: [], joins:, where: [{ id: }], limit: 1).first
17
+ end
18
+
19
+ def instantiate_model(model, record)
20
+ instance = model.new
21
+ attrs = instance.instance_variable_get(:@attributes)
22
+ record.each do |key, value|
23
+ attrs.send("#{key}=", value)
24
+ end
25
+ instance
26
+ end
27
+
28
+ def resolve_path(data, path)
29
+ if path.is_a?(Proc)
30
+ path.call(data)
31
+ elsif path.is_a?(Array)
32
+ data.dig(*path)
33
+ else
34
+ data[path]
35
+ end
36
+ rescue StandardError => e
37
+ AnyQuery::Config.logger.error "Failed to resolve path #{path} on #{data.inspect}"
38
+ raise e
39
+ end
40
+
41
+ def fallback_where(data, wheres)
42
+ data.filter do |row|
43
+ wheres.all? do |where|
44
+ where.all? do |key, value|
45
+ resolve_path(row, key) == value
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ def resolve_join(data, join)
52
+ AnyQuery::Config.logger.debug "Joining #{join[:model]} on #{join[:primary_key]} = #{join[:foreign_key]}"
53
+ foreign_keys = data.map { |row| resolve_path(row, join[:foreign_key]) }.compact.uniq
54
+ result = run_external_join(join, foreign_keys)
55
+
56
+ result = group_join_data(result, join)
57
+
58
+ data.each do |row|
59
+ row[join[:into]] = result[resolve_path(row, join[:foreign_key])] || (join[:as] == :list ? [] : nil)
60
+ end
61
+ end
62
+
63
+ def group_join_data(data, join)
64
+ if join[:as] == :list
65
+ data.group_by { |e| resolve_path(e, join[:primary_key]) }
66
+ else
67
+ data.index_by { |e| resolve_path(e, join[:primary_key]) }
68
+ end
69
+ end
70
+
71
+ def run_external_join(join, foreign_keys)
72
+ case join[:strategy]
73
+ when Proc
74
+ join[:strategy].call(foreign_keys)
75
+ when :single
76
+ map_multi_threaded(foreign_keys.uniq) { |key| join[:model].find(key) }
77
+ when :full_scan
78
+ join[:model].to_a
79
+ else
80
+ join[:model].where(id: foreign_keys).to_a
81
+ end
82
+ end
83
+
84
+ def resolve_select(chain, select)
85
+ chain.map do |record|
86
+ select.map do |field|
87
+ resolve_path(record, field)
88
+ end
89
+ end
90
+ end
91
+
92
+ def map_multi_threaded(list, concurrency = 50)
93
+ list.each_slice(concurrency).flat_map do |slice|
94
+ slice
95
+ .map { |data| Thread.new { yield(data) } }
96
+ .each(&:join)
97
+ .map(&:value)
98
+ end
99
+ end
100
+
101
+ def parse_field_type(field, line)
102
+ method_name = "parse_field_type_#{field[:type]}"
103
+
104
+ if respond_to?(method_name)
105
+ send(method_name, field, line)
106
+ else
107
+ line.strip
108
+ end
109
+ rescue StandardError => e
110
+ AnyQuery::Config.logger.error "Failed to parse field \"#{line}\" with type #{field.inspect}: #{e.message}"
111
+ nil
112
+ end
113
+
114
+ def parse_field_type_integer(_, line)
115
+ line.to_i
116
+ end
117
+
118
+ def parse_field_type_date(field, line)
119
+ if field[:format]
120
+ Date.strptime(line.strip, field[:format])
121
+ else
122
+ Date.parse(line)
123
+ end
124
+ end
125
+
126
+ def parse_field_type_datetime(field, line)
127
+ if field[:format]
128
+ DateTime.strptime(line.strip, field[:format])
129
+ else
130
+ DateTime.parse(line)
131
+ end
132
+ end
133
+
134
+ def parse_field_type_float(_, line)
135
+ line.to_f
136
+ end
137
+
138
+ def parse_field_type_decimal(_, line)
139
+ BigDecimal(line)
140
+ end
141
+
142
+ def parse_field_type_string(_, line)
143
+ line.strip
144
+ end
145
+
146
+ def parse_field_type_boolean(_, line)
147
+ line.strip == 'true'
148
+ end
149
+
150
+ # @abstract
151
+ class Config
152
+ def initialize(&block)
153
+ instance_eval(&block)
154
+ end
155
+
156
+ def url(url)
157
+ @url = url
158
+ end
159
+
160
+ def primary_key(key)
161
+ @primary_key = key
162
+ end
163
+
164
+ def to_h
165
+ {
166
+ url: @url,
167
+ primary_key: @primary_key
168
+ }
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+
5
+ module AnyQuery
6
+ module Adapters
7
+ class Csv < Base
8
+ class Config < Base::Config
9
+ def to_h
10
+ {
11
+ url: @url,
12
+ primary_key: @primary_key,
13
+ table: @table
14
+ }
15
+ end
16
+ end
17
+
18
+ def parse_fields(model)
19
+ CSV.foreach(url, headers: true).map do |line|
20
+ result = {}
21
+ model.fields.each do |name, field|
22
+ result[name] = parse_field(field, line[field[:source] || name.to_s])
23
+ end
24
+ result
25
+ end
26
+ end
27
+
28
+ def url
29
+ @config[:url]
30
+ end
31
+
32
+ def load(model, select:, joins:, where:, limit:)
33
+ chain = parse_fields(model)
34
+ chain = fallback_where(chain, where) if where.present?
35
+ chain = chain.first(limit) if limit.present?
36
+ chain = resolve_joins(chain, joins) if joins.present?
37
+
38
+ chain.map! { |row| instantiate_model(model, row) }
39
+ chain = resolve_select(chain, select) if select.present?
40
+
41
+ chain
42
+ end
43
+
44
+ def resolve_joins(data, joins)
45
+ joins.map do |join|
46
+ resolve_join(data, join)
47
+ end
48
+ data
49
+ end
50
+
51
+ def parse_field(field, line)
52
+ result = parse_field_type(field, line)
53
+ if field[:transform]
54
+ field[:transform].call(result)
55
+ else
56
+ result
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyQuery
4
+ module Adapters
5
+ # @api private
6
+ class FixedLength < Base
7
+ # @api private
8
+ class Config < Base::Config
9
+ def to_h
10
+ {
11
+ url: @url,
12
+ primary_key: @primary_key,
13
+ table: @table
14
+ }
15
+ end
16
+ end
17
+
18
+ def initialize(config)
19
+ super(config)
20
+ @file = File.open(url)
21
+ end
22
+
23
+ def parse_fields(model)
24
+ @file.each_line.map do |line|
25
+ result = {}
26
+ last_index = 0
27
+ model.fields.each do |name, field|
28
+ raw_value = line[last_index...(last_index + field[:length])]
29
+ result[name] = parse_field(field, raw_value)
30
+ last_index += field[:length]
31
+ end
32
+ result
33
+ end
34
+ end
35
+
36
+ def url
37
+ @config[:url]
38
+ end
39
+
40
+ def load(model, select:, joins:, where:, limit:)
41
+ @file.rewind
42
+
43
+ chain = parse_fields(model)
44
+ chain = fallback_where(chain, where) if where.present?
45
+ chain = chain.first(limit) if limit.present?
46
+ chain = resolve_joins(chain, joins) if joins.present?
47
+
48
+ chain.map! { |row| instantiate_model(model, row) }
49
+ chain = resolve_select(chain, select) if select.present?
50
+
51
+ chain
52
+ end
53
+
54
+ def resolve_joins(data, joins)
55
+ joins.map do |join|
56
+ resolve_join(data, join)
57
+ end
58
+ data
59
+ end
60
+
61
+ def parse_field(field, line)
62
+ result = parse_field_type(field, line)
63
+ if field[:transform]
64
+ field[:transform].call(result)
65
+ else
66
+ result
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyQuery
4
+ module Adapters
5
+ # @api private
6
+ class Http < Base
7
+ MAX_ITERATIONS = 1000
8
+ # @api private
9
+ class Config < Base::Config
10
+ def endpoint(name, method, path, options = {})
11
+ @endpoints ||= {}
12
+ @endpoints[name] = { method:, path:, options: }
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ url: @url,
18
+ primary_key: @primary_key,
19
+ wrapper: @wrapper,
20
+ endpoints: @endpoints
21
+ }
22
+ end
23
+ end
24
+
25
+ def load(model, select:, joins:, where:, limit:)
26
+ data = run_http_list_query(where)
27
+
28
+ data = resolve_joins(data, joins) if joins.present?
29
+ data = data.first(limit) if limit.present?
30
+
31
+ parse_response(model, select, data)
32
+ end
33
+
34
+ def load_single(model, id, joins)
35
+ data = run_http_single_query(id, {})
36
+
37
+ data = resolve_joins(data, joins) if joins.present?
38
+
39
+ instantiate_model(model, data)
40
+ end
41
+
42
+ def parse_response(model, select, data)
43
+ data = data.map do |record|
44
+ instantiate_model(model, record)
45
+ end
46
+ data = resolve_select(data, select) if select.present?
47
+
48
+ data
49
+ end
50
+
51
+ # FIXME: Use common method
52
+ def load_single_from_list(data)
53
+ data.each_slice(50).flat_map do |slice|
54
+ slice
55
+ .map { |data| Thread.new { run_http_single_query(data[:id], {}) } }
56
+ .each(&:join)
57
+ .map(&:value)
58
+ end
59
+ end
60
+
61
+ def resolve_joins(data, joins)
62
+ data = load_single_from_list(data) if joins.any? { |j| j[:model] == :show }
63
+
64
+ joins.each do |join|
65
+ next if join[:model] == :show
66
+
67
+ resolve_join(data, join)
68
+ end
69
+ data
70
+ end
71
+
72
+ def build_filters(where)
73
+ {
74
+ query: (where || {}).inject({}) do |memo, object|
75
+ memo.merge(object)
76
+ end
77
+ }
78
+ end
79
+
80
+ def run_http_single_query(id, params)
81
+ endpoint = @config[:endpoints][:show]
82
+ url = build_url(endpoint, params, id:)
83
+ params = (endpoint[:options][:default_params] || {}).merge(params)
84
+ AnyQuery::Config.logger.debug "Starting request to #{url} with params #{params.inspect}"
85
+
86
+ data = run_http_request(endpoint, url, params)
87
+ data = data.dig(*endpoint[:options][:wrapper]) if endpoint[:options][:wrapper]
88
+ AnyQuery::Config.logger.debug 'Responded with single record.'
89
+ data
90
+ end
91
+
92
+ def run_http_list_query(raw_params)
93
+ endpoint = @config[:endpoints][:list]
94
+ url = build_url(endpoint, raw_params)
95
+ params = build_filters(raw_params)
96
+ results = Set.new
97
+ previous_response = nil
98
+ MAX_ITERATIONS.times do |i|
99
+ params = merge_params(endpoint, params, i, previous_response)
100
+
101
+ AnyQuery::Config.logger.debug "Starting request to #{url} with params #{params.inspect}"
102
+
103
+ data = run_http_request(endpoint, url, params)
104
+ break if previous_response == data
105
+
106
+ previous_response = data
107
+
108
+ data = unwrap(endpoint, data)
109
+
110
+ AnyQuery::Config.logger.debug "Responded with #{data&.size || 0} records"
111
+ break if !data || data.empty?
112
+
113
+ previous_count = results.size
114
+ results += data
115
+ break if results.size == previous_count
116
+
117
+ break if endpoint.dig(:options, :pagination, :type) == :none
118
+ end
119
+ results.to_a
120
+ end
121
+
122
+ def merge_params(endpoint, params, iteration, previous_response)
123
+ (endpoint[:options][:default_params] || {})
124
+ .deep_merge(params)
125
+ .deep_merge(handle_pagination(endpoint, iteration, previous_response))
126
+ end
127
+
128
+ def build_url(endpoint, params, id: nil)
129
+ output = (@config[:url] + endpoint[:path])
130
+
131
+ output.gsub!('{id}', id.to_s) if id
132
+
133
+ if output.include?('{')
134
+ output.gsub!(/\{([^}]+)\}/) do |match|
135
+ key = Regexp.last_match(1).to_sym
136
+ hash = params.find { |h| h[Regexp.last_match(1).to_sym] }
137
+ hash&.delete(key) || match
138
+ end
139
+ end
140
+
141
+ output
142
+ end
143
+
144
+ def unwrap(endpoint, data)
145
+ data = unwrap_list(endpoint, data)
146
+ unwrap_single(endpoint, data)
147
+ end
148
+
149
+ def unwrap_list(endpoint, data)
150
+ wrapper = endpoint[:options][:wrapper]
151
+ return data unless wrapper
152
+
153
+ if wrapper.is_a?(Proc)
154
+ wrapper.call(data)
155
+ else
156
+ data.dig(*wrapper)
157
+ end
158
+ end
159
+
160
+ def unwrap_single(endpoint, data)
161
+ return data unless endpoint[:options][:single_wrapper]
162
+
163
+ data.map! do |row|
164
+ row.dig(*endpoint[:options][:single_wrapper])
165
+ end
166
+ end
167
+
168
+ def run_http_request(endpoint, url, params)
169
+ response = HTTParty.public_send(endpoint[:method], url, params)
170
+
171
+ raise response.inspect unless response.success?
172
+
173
+ if response.parsed_response.is_a?(Array)
174
+ response.parsed_response.map(&:deep_symbolize_keys)
175
+ else
176
+ response.parsed_response&.deep_symbolize_keys
177
+ end
178
+ end
179
+
180
+ def handle_pagination(endpoint, index, previous_response = nil)
181
+ pagination = endpoint.dig(:options, :pagination) || {}
182
+ method_name = "handle_pagination_#{pagination[:type]}"
183
+ if respond_to?(method_name, true)
184
+ send(method_name, pagination, index, previous_response)
185
+ else
186
+ AnyQuery::Config.logger.warn "Unknown pagination type #{pagination[:type]}"
187
+ { query: { page: index } }
188
+ end
189
+ end
190
+
191
+ def handle_pagination_page(pagination, index, _previous_response)
192
+ starts_from = pagination[:starts_from] || 0
193
+ { query: { (pagination.dig(:params, :number) || :page) => starts_from + index } }
194
+ end
195
+
196
+ def handle_pagination_skip(_pagination, _index, _previous_response)
197
+ raise 'TODO: Implement skip pagination'
198
+ end
199
+
200
+ def handle_pagination_cursor(pagination, _index, previous_response)
201
+ return {} unless previous_response
202
+
203
+ cursor_parameter = pagination.dig(:params, :cursor) || :cursor
204
+ cursor = previous_response[cursor_parameter]
205
+ { query: { (pagination.dig(:params, :cursor) || :cursor) => cursor } }
206
+ end
207
+
208
+ def handle_pagination_none(_pagination, _index, _previous_response)
209
+ {}
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyQuery
4
+ module Adapters
5
+ class Sql < Base
6
+ class Config < Base::Config
7
+ def table(name)
8
+ @table = name
9
+ end
10
+
11
+ def to_h
12
+ {
13
+ url: @url,
14
+ primary_key: @primary_key,
15
+ table: @table
16
+ }
17
+ end
18
+ end
19
+
20
+ def initialize(config)
21
+ super(config)
22
+ ActiveRecord::Base.establish_connection(@config[:url])
23
+ table_name = @config[:table]
24
+
25
+ @rails_model = declare_model!
26
+ @rails_model.table_name = table_name
27
+ @rails_model.inheritance_column = :_sti_disabled
28
+ Object.const_set("AnyQuery#{table_name.classify}", @rails_model)
29
+ end
30
+
31
+ def declare_model!
32
+ Class.new(ActiveRecord::Base) do
33
+ def dig(key, *other)
34
+ data = public_send(key)
35
+ return data if other.empty?
36
+
37
+ return unless data.respond_to?(:dig)
38
+
39
+ data.dig(*other)
40
+ end
41
+ end
42
+ end
43
+
44
+ attr_reader :rails_model
45
+
46
+ def url
47
+ @config[:url]
48
+ end
49
+
50
+ def load(_model, select:, joins:, where:, limit:)
51
+ declare_required_associations!(joins)
52
+ chain = @rails_model.all
53
+ chain = chain.where(*where) if where.present?
54
+ chain = chain.limit(limit) if limit.present?
55
+ chain = resolve_joins(chain, joins) if joins.present?
56
+
57
+ chain = resolve_select(chain, select) if select.present?
58
+
59
+ chain
60
+ end
61
+
62
+ def declare_required_associations!(joins)
63
+ joins&.each do |join|
64
+ next if join[:model]._adapter.url != @config[:url]
65
+
66
+ relation = join_relation_name(join)
67
+
68
+ if join[:as] == :list
69
+ @rails_model.has_many(relation, class_name: join[:model]._adapter.rails_model.to_s,
70
+ foreign_key: join[:foreign_key], primary_key: join[:primary_key])
71
+ else
72
+ @rails_model.belongs_to(relation, class_name: join[:model]._adapter.rails_model.to_s,
73
+ foreign_key: join[:foreign_key], primary_key: join[:primary_key])
74
+ end
75
+ end
76
+ end
77
+
78
+ def resolve_joins(data, joins)
79
+ joins.map do |join|
80
+ if join[:model]._adapter.url == @config[:url]
81
+ relation = join_relation_name(join)
82
+
83
+ data = data.eager_load(relation)
84
+ else
85
+ resolve_join(data, join)
86
+ end
87
+ end
88
+ data
89
+ end
90
+
91
+ def join_relation_name(join)
92
+ if join[:as] == :list
93
+ join[:model].table_name.pluralize.to_sym
94
+ else
95
+ join[:model].table_name.singularize.to_sym
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyQuery
4
+ # @api private
5
+ module Adapters
6
+ extend ActiveSupport::Autoload
7
+ autoload :Base
8
+ autoload :Http
9
+ autoload :Sql
10
+ autoload :FixedLength
11
+ autoload :Csv
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyQuery
4
+ # A class to handle configuration for AnyQuery
5
+ class Config
6
+ attr_writer :logger
7
+
8
+ def self.logger
9
+ @logger ||= Logger.new($stdout)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ module AnyQuery
2
+ class Field
3
+ attr_reader :name, :type, :options
4
+
5
+ def initialize(name, type, options = {}); end
6
+ end
7
+ end