any_query 0.1.1

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 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