prato 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +938 -0
- data/lib/prato/configuration.rb +99 -0
- data/lib/prato/internal/active_record_version.rb +24 -0
- data/lib/prato/internal/join_helper.rb +48 -0
- data/lib/prato/internal/join_helper_legacy.rb +171 -0
- data/lib/prato/internal/lazy_loader_cache.rb +25 -0
- data/lib/prato/internal/pipeline/filtering.rb +277 -0
- data/lib/prato/internal/pipeline/pagination.rb +30 -0
- data/lib/prato/internal/pipeline/serializer.rb +87 -0
- data/lib/prato/internal/pipeline/sorting.rb +78 -0
- data/lib/prato/internal/query_executor.rb +105 -0
- data/lib/prato/internal/query_state.rb +90 -0
- data/lib/prato/internal/specification.rb +101 -0
- data/lib/prato/internal/specification_builder.rb +361 -0
- data/lib/prato/internal/sql_support.rb +118 -0
- data/lib/prato/query/and_filter.rb +13 -0
- data/lib/prato/query/default_parser.rb +148 -0
- data/lib/prato/query/field_resolver.rb +23 -0
- data/lib/prato/query/filter.rb +15 -0
- data/lib/prato/query/or_filter.rb +13 -0
- data/lib/prato/query/parameters.rb +17 -0
- data/lib/prato/query/sort.rb +14 -0
- data/lib/prato/table.rb +39 -0
- data/lib/prato/table_builder.rb +40 -0
- data/lib/prato/types/aggregate_column.rb +93 -0
- data/lib/prato/types/association_column.rb +37 -0
- data/lib/prato/types/direct_column.rb +27 -0
- data/lib/prato/types/expression_column.rb +38 -0
- data/lib/prato/types/ruby_column.rb +31 -0
- data/lib/prato/version.rb +5 -0
- data/lib/prato.rb +66 -0
- metadata +96 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prato
|
|
4
|
+
module Internal
|
|
5
|
+
class SpecificationBuilder
|
|
6
|
+
attr_reader :draft_columns, :ruby_loaders
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@config = Prato::Configuration.config
|
|
10
|
+
@draft_columns = []
|
|
11
|
+
@ruby_loaders = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
AGGREGATE_FUNCTIONS = [:count, :sum, :avg, :min, :max].freeze
|
|
15
|
+
COMMON_RESERVED_KEYWORDS = [:queryable].freeze
|
|
16
|
+
RESERVED_COLUMN_SYMBOLS = ([:format, :expression, :filter] + COMMON_RESERVED_KEYWORDS + AGGREGATE_FUNCTIONS).freeze
|
|
17
|
+
RESERVED_RUBY_COLUMN_SYMBOLS = ([:key, :filter, :includes] + COMMON_RESERVED_KEYWORDS).freeze
|
|
18
|
+
|
|
19
|
+
def inner_column(*args, **kwargs)
|
|
20
|
+
draft = build_draft(args, kwargs)
|
|
21
|
+
@draft_columns << draft
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inner_query_column(*args, **kwargs)
|
|
25
|
+
draft = build_draft(args, kwargs, query_only: true)
|
|
26
|
+
@draft_columns << draft
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def inner_ruby_column(*args, **kwargs, &block)
|
|
30
|
+
name_map, options = extract_name_and_options(args, kwargs, RESERVED_RUBY_COLUMN_SYMBOLS)
|
|
31
|
+
display_name, loader_id = parse_name_map(name_map)
|
|
32
|
+
|
|
33
|
+
key = parse_accessor(options[:key])
|
|
34
|
+
queryable = resolve_queryable_option!(options, VALID_COLUMN_QUERYABLE)
|
|
35
|
+
validate_filter_option!(options[:filter])
|
|
36
|
+
column = ::Prato::Types::RubyColumn.new(loader_id, key: key, filter: options[:filter], includes: options[:includes])
|
|
37
|
+
|
|
38
|
+
@draft_columns << DraftColumn.new(display_name, loader_id, column, queryable: queryable)
|
|
39
|
+
inner_ruby_loader(loader_id, &block) if block_given?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inner_section(id, &block)
|
|
43
|
+
section = SectionBuilder.new
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "No block given to section" unless block_given?
|
|
46
|
+
|
|
47
|
+
section.instance_exec(&block)
|
|
48
|
+
|
|
49
|
+
section.spec.draft_columns.each do |nested_draft|
|
|
50
|
+
new_output_path = [id] + nested_draft.output_paths
|
|
51
|
+
|
|
52
|
+
draft = DraftColumn.new(
|
|
53
|
+
nested_draft.override_name,
|
|
54
|
+
nested_draft.accessor_name,
|
|
55
|
+
nested_draft.column,
|
|
56
|
+
queryable: nested_draft.queryable,
|
|
57
|
+
query_only: nested_draft.query_only,
|
|
58
|
+
output_paths: new_output_path
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@draft_columns << draft
|
|
62
|
+
end
|
|
63
|
+
return if section.spec.ruby_loaders.nil?
|
|
64
|
+
|
|
65
|
+
@ruby_loaders ||= {}
|
|
66
|
+
@ruby_loaders.merge!(section.spec.ruby_loaders)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def inner_ruby_loader(id, includes: nil, &block)
|
|
70
|
+
@ruby_loaders ||= {}
|
|
71
|
+
@ruby_loaders[id] = {
|
|
72
|
+
block: block,
|
|
73
|
+
includes: includes
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inner_config(config)
|
|
78
|
+
@config = config
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build(base_model)
|
|
82
|
+
columns = {}
|
|
83
|
+
visible_fields = []
|
|
84
|
+
filterable_fields = Set.new
|
|
85
|
+
sortable_fields = Set.new
|
|
86
|
+
output_paths = {}
|
|
87
|
+
field_lookup = {}
|
|
88
|
+
|
|
89
|
+
@draft_columns.each do |draft|
|
|
90
|
+
column_output_path = draft.output_paths.map do |path|
|
|
91
|
+
transform_key_part(path, @config.key_transformation)
|
|
92
|
+
end
|
|
93
|
+
internal_column_name = Query::FieldResolver.join(draft.output_paths)
|
|
94
|
+
|
|
95
|
+
if columns.key?(internal_column_name)
|
|
96
|
+
raise ArgumentError,
|
|
97
|
+
"Column '#{draft.name}' (internal id: #{internal_column_name}) has already been defined."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
column = draft.column
|
|
101
|
+
|
|
102
|
+
if column.is_a?(Types::DirectColumn) ||
|
|
103
|
+
column.is_a?(Types::AssociationColumn) ||
|
|
104
|
+
column.is_a?(Types::ExpressionColumn) ||
|
|
105
|
+
column.is_a?(Types::AggregateColumn)
|
|
106
|
+
column.resolve_arel!(base_model, internal_column_name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
filterable = resolve_capability(:filter, draft)
|
|
110
|
+
sortable = resolve_capability(:sort, draft)
|
|
111
|
+
|
|
112
|
+
columns[internal_column_name] = column
|
|
113
|
+
visible_fields << internal_column_name unless draft.query_only
|
|
114
|
+
filterable_fields << internal_column_name if filterable
|
|
115
|
+
sortable_fields << internal_column_name if sortable
|
|
116
|
+
output_paths[internal_column_name] = column_output_path.map(&:to_sym)
|
|
117
|
+
field_lookup[column_output_path.map(&:to_s)] = internal_column_name
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
Specification.new(
|
|
121
|
+
columns: columns,
|
|
122
|
+
visible_fields: visible_fields,
|
|
123
|
+
filterable_fields: filterable_fields,
|
|
124
|
+
sortable_fields: sortable_fields,
|
|
125
|
+
output_paths: output_paths,
|
|
126
|
+
field_lookup: field_lookup,
|
|
127
|
+
ruby_loaders: @ruby_loaders,
|
|
128
|
+
config: @config
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def resolve_capability(capability, draft)
|
|
135
|
+
return true if capability == :filter && !draft.column.filter.nil?
|
|
136
|
+
|
|
137
|
+
queryable = resolve_queryable(draft)
|
|
138
|
+
|
|
139
|
+
queryable == :all || queryable == capability
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve_queryable(draft)
|
|
143
|
+
return draft.queryable if draft.queryable
|
|
144
|
+
return :all if draft.query_only
|
|
145
|
+
|
|
146
|
+
if draft.column.is_a?(Types::RubyColumn)
|
|
147
|
+
@config.default_ruby_column_queryable
|
|
148
|
+
else
|
|
149
|
+
@config.default_queryable
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_ruby_loader!(draft, loaders)
|
|
154
|
+
column = draft.column
|
|
155
|
+
return unless column.is_a?(Prato::Types::RubyColumn)
|
|
156
|
+
|
|
157
|
+
loader_name = column.loader
|
|
158
|
+
|
|
159
|
+
raise ArgumentError, "Ruby column '#{draft.name || column.key}' is missing a loader." if loader_name.nil?
|
|
160
|
+
raise ArgumentError, "No ruby loader registered for '#{loader_name}'." if loaders.nil?
|
|
161
|
+
|
|
162
|
+
loader = loaders[loader_name]
|
|
163
|
+
raise ArgumentError, "No ruby loader registered for '#{loader_name}'." unless loaders.key?(loader_name)
|
|
164
|
+
return if loader.is_a?(Hash) && loader[:block].respond_to?(:call)
|
|
165
|
+
|
|
166
|
+
raise ArgumentError, "Ruby loader '#{loader_name}' must respond to #call."
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
VALID_COLUMN_QUERYABLE = [:all, :none, :filter, :sort].freeze
|
|
170
|
+
VALID_QUERY_COLUMN_QUERYABLE = [:all, :filter, :sort].freeze
|
|
171
|
+
|
|
172
|
+
def build_draft(args, kwargs, query_only: false)
|
|
173
|
+
queryable_options = {}
|
|
174
|
+
queryable_options[:queryable] = kwargs.delete(:queryable) if kwargs.key?(:queryable)
|
|
175
|
+
queryable = resolve_queryable_option!(queryable_options,
|
|
176
|
+
query_only ? VALID_QUERY_COLUMN_QUERYABLE : VALID_COLUMN_QUERYABLE)
|
|
177
|
+
|
|
178
|
+
name_map, options = extract_name_and_options(args, kwargs, RESERVED_COLUMN_SYMBOLS)
|
|
179
|
+
aggregate_function, aggregate_accessor = extract_aggregate(options)
|
|
180
|
+
override_name, accessor = parse_name_map(name_map)
|
|
181
|
+
accessor = parse_accessor(accessor)
|
|
182
|
+
validate_filter_option!(options[:filter])
|
|
183
|
+
|
|
184
|
+
if aggregate_function
|
|
185
|
+
column = ::Prato::Types::AggregateColumn.new(aggregate_function, aggregate_accessor,
|
|
186
|
+
format: options[:format], filter: options[:filter])
|
|
187
|
+
DraftColumn.new(accessor, nil, column, queryable: queryable, query_only: query_only)
|
|
188
|
+
elsif options[:expression]
|
|
189
|
+
column = ::Prato::Types::ExpressionColumn.new(options[:expression], format: options[:format], filter: options[:filter])
|
|
190
|
+
DraftColumn.new(override_name, accessor, column, queryable: queryable, query_only: query_only)
|
|
191
|
+
elsif accessor.is_a?(Array) && accessor.length > 1
|
|
192
|
+
column = ::Prato::Types::AssociationColumn.new(accessor, format: options[:format], filter: options[:filter])
|
|
193
|
+
DraftColumn.new(override_name, accessor, column, queryable: queryable, query_only: query_only)
|
|
194
|
+
else
|
|
195
|
+
column = ::Prato::Types::DirectColumn.new(accessor, format: options[:format], filter: options[:filter])
|
|
196
|
+
DraftColumn.new(override_name, accessor, column, queryable: queryable, query_only: query_only)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def validate_filter_option!(filter)
|
|
201
|
+
return if filter.nil? || filter.is_a?(Proc)
|
|
202
|
+
return if filter.is_a?(Array) && filter.all? { |operator| operator.is_a?(Symbol) }
|
|
203
|
+
|
|
204
|
+
raise ArgumentError, "filter must be nil, an Array of symbols, or a Proc"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def resolve_queryable_option!(options, valid)
|
|
208
|
+
return nil unless options.key?(:queryable)
|
|
209
|
+
|
|
210
|
+
queryable = options[:queryable]
|
|
211
|
+
raise ArgumentError, "queryable: must be a Symbol, got #{queryable.class}" unless queryable.is_a?(Symbol)
|
|
212
|
+
|
|
213
|
+
unless valid.include?(queryable)
|
|
214
|
+
raise ArgumentError, "queryable: must be one of #{valid.map(&:inspect).join(", ")}, got #{queryable.inspect}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
queryable
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def extract_name_and_options(args, kwargs, reserved)
|
|
221
|
+
if args.any?
|
|
222
|
+
[args, kwargs]
|
|
223
|
+
else
|
|
224
|
+
option_keys = kwargs.keys & reserved
|
|
225
|
+
options = kwargs.slice(*option_keys)
|
|
226
|
+
name_map = kwargs.except(*reserved)
|
|
227
|
+
[name_map, options]
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def extract_aggregate(kwargs)
|
|
232
|
+
AGGREGATE_FUNCTIONS.each do |func|
|
|
233
|
+
value = kwargs.delete(func)
|
|
234
|
+
return [func, value] if value
|
|
235
|
+
end
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def parse_name_map(name_map)
|
|
240
|
+
case name_map
|
|
241
|
+
when Symbol
|
|
242
|
+
[nil, name_map]
|
|
243
|
+
when Array
|
|
244
|
+
if name_map.size == 1 && name_map.first.is_a?(Hash)
|
|
245
|
+
parse_name_map(name_map.first)
|
|
246
|
+
elsif name_map.size == 2
|
|
247
|
+
[name_map.first, name_map.second]
|
|
248
|
+
else
|
|
249
|
+
[nil, name_map.first]
|
|
250
|
+
end
|
|
251
|
+
when Hash
|
|
252
|
+
entry = name_map.first
|
|
253
|
+
[entry.first, entry.second]
|
|
254
|
+
else
|
|
255
|
+
raise ArgumentError, "name_map must be a Symbol or Array"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def parse_accessor(value)
|
|
260
|
+
case value
|
|
261
|
+
when Symbol
|
|
262
|
+
value
|
|
263
|
+
when Hash
|
|
264
|
+
flatten_hash_to_array(value)
|
|
265
|
+
else
|
|
266
|
+
value
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def flatten_hash_to_array(hash)
|
|
271
|
+
return [] if hash.empty?
|
|
272
|
+
|
|
273
|
+
key, value = hash.first
|
|
274
|
+
case value
|
|
275
|
+
when Symbol
|
|
276
|
+
[key, value]
|
|
277
|
+
when Hash
|
|
278
|
+
[key] + flatten_hash_to_array(value)
|
|
279
|
+
else
|
|
280
|
+
[key]
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def transform_key_part(part, transformation)
|
|
285
|
+
return part if part.is_a?(String)
|
|
286
|
+
|
|
287
|
+
case transformation
|
|
288
|
+
when :camelCase then to_camel_case(part)
|
|
289
|
+
when :snake_case then to_snake_case(part)
|
|
290
|
+
when :none then part
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def to_snake_case(value)
|
|
295
|
+
s = value.to_s.dup
|
|
296
|
+
s.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
297
|
+
s.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
298
|
+
s.tr!("-", "_")
|
|
299
|
+
s.tr!(" ", "_")
|
|
300
|
+
s.downcase!
|
|
301
|
+
s
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def to_camel_case(value)
|
|
305
|
+
parts = to_snake_case(value).split("_")
|
|
306
|
+
parts.first + parts.drop(1).map(&:capitalize).join
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private_constant :RESERVED_COLUMN_SYMBOLS
|
|
310
|
+
private_constant :RESERVED_RUBY_COLUMN_SYMBOLS
|
|
311
|
+
private_constant :COMMON_RESERVED_KEYWORDS
|
|
312
|
+
private_constant :AGGREGATE_FUNCTIONS
|
|
313
|
+
private_constant :VALID_COLUMN_QUERYABLE
|
|
314
|
+
private_constant :VALID_QUERY_COLUMN_QUERYABLE
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
class DraftColumn
|
|
318
|
+
attr_reader :override_name, :accessor_name, :column, :queryable, :query_only, :output_paths
|
|
319
|
+
|
|
320
|
+
def initialize(override_name, accessor_name, column, queryable: nil, query_only: false,
|
|
321
|
+
output_paths: [])
|
|
322
|
+
@override_name = override_name
|
|
323
|
+
@accessor_name = accessor_name
|
|
324
|
+
@column = column
|
|
325
|
+
@queryable = queryable
|
|
326
|
+
@query_only = query_only
|
|
327
|
+
@output_paths = output_paths.empty? ? [name] : output_paths
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def name
|
|
331
|
+
@override_name || @accessor_name
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
class SectionBuilder
|
|
336
|
+
attr_reader :spec
|
|
337
|
+
|
|
338
|
+
def initialize
|
|
339
|
+
@spec = SpecificationBuilder.new
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def column(*args, **kwargs)
|
|
343
|
+
@spec.inner_column(*args, **kwargs)
|
|
344
|
+
self
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def ruby_column(*args, **kwargs, &block)
|
|
348
|
+
@spec.inner_ruby_column(*args, **kwargs, &block)
|
|
349
|
+
self
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def section(id, &block)
|
|
353
|
+
@spec.inner_section(id, &block)
|
|
354
|
+
self
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
private_constant :DraftColumn
|
|
359
|
+
private_constant :SectionBuilder
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prato
|
|
4
|
+
module Internal
|
|
5
|
+
module SqlSupport
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
def table_for(scope, association_path)
|
|
9
|
+
return scope.model.arel_table if association_path.empty?
|
|
10
|
+
|
|
11
|
+
expanded = expand_through_associations(scope.model, association_path)
|
|
12
|
+
join_sources = scope.arel.join_sources
|
|
13
|
+
|
|
14
|
+
table = walk_join_path(scope.model, scope.model.arel_table, expanded, join_sources)
|
|
15
|
+
|
|
16
|
+
unless table
|
|
17
|
+
raise ArgumentError,
|
|
18
|
+
"Unable to resolve table alias for association path #{association_path.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
table
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def expand_through_associations(model, path)
|
|
27
|
+
expanded = []
|
|
28
|
+
current_model = model
|
|
29
|
+
|
|
30
|
+
path.each do |assoc_name|
|
|
31
|
+
reflection = current_model.reflect_on_association(assoc_name)
|
|
32
|
+
raise ArgumentError, "Unknown association '#{assoc_name}' on #{current_model}" unless reflection
|
|
33
|
+
|
|
34
|
+
expand_reflection(reflection, expanded)
|
|
35
|
+
current_model = reflection.klass
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
expanded
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def expand_reflection(reflection, result)
|
|
42
|
+
if reflection.through_reflection?
|
|
43
|
+
expand_reflection(reflection.through_reflection, result)
|
|
44
|
+
expand_reflection(reflection.source_reflection, result)
|
|
45
|
+
else
|
|
46
|
+
result << reflection.name
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def walk_join_path(model, parent_table, path, join_sources)
|
|
51
|
+
return parent_table if path.empty?
|
|
52
|
+
|
|
53
|
+
assoc_name, *rest = path
|
|
54
|
+
reflection = model.reflect_on_association(assoc_name)
|
|
55
|
+
return nil unless reflection
|
|
56
|
+
|
|
57
|
+
fk = reflection.foreign_key.to_s
|
|
58
|
+
target_table_name = reflection.klass.table_name
|
|
59
|
+
parent_id = table_identifier(parent_table)
|
|
60
|
+
|
|
61
|
+
join_sources.each do |join|
|
|
62
|
+
joined_table = join.left
|
|
63
|
+
next unless base_table_name(joined_table) == target_table_name
|
|
64
|
+
next unless on_has_fk?(join.right.expr, fk)
|
|
65
|
+
next unless on_references?(join.right.expr, parent_id)
|
|
66
|
+
|
|
67
|
+
result = walk_join_path(reflection.klass, joined_table, rest, join_sources)
|
|
68
|
+
return result if result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def base_table_name(table)
|
|
75
|
+
case table
|
|
76
|
+
when Arel::Nodes::TableAlias then table.left.name
|
|
77
|
+
when Arel::Table then table.name
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def table_identifier(table)
|
|
82
|
+
case table
|
|
83
|
+
when Arel::Nodes::TableAlias then table.right.to_s
|
|
84
|
+
when Arel::Table then table.name
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def on_has_fk?(expr, fk)
|
|
89
|
+
each_equality(expr).any? do |eq|
|
|
90
|
+
attr_named?(eq.left, fk) || attr_named?(eq.right, fk)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def on_references?(expr, parent_id)
|
|
95
|
+
each_equality(expr).any? do |eq|
|
|
96
|
+
attr_from_table?(eq.left, parent_id) || attr_from_table?(eq.right, parent_id)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def attr_named?(node, name)
|
|
101
|
+
node.respond_to?(:name) && node.name.to_s == name
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def attr_from_table?(node, table_id)
|
|
105
|
+
node.respond_to?(:relation) && table_identifier(node.relation) == table_id
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def each_equality(expr, &block)
|
|
109
|
+
return enum_for(:each_equality, expr) unless block
|
|
110
|
+
|
|
111
|
+
case expr
|
|
112
|
+
when Arel::Nodes::Equality then yield expr
|
|
113
|
+
when Arel::Nodes::And then expr.children.each { |c| each_equality(c, &block) }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Prato
|
|
6
|
+
module Query
|
|
7
|
+
class DefaultParser
|
|
8
|
+
def parse_parameters(input, field_lookup)
|
|
9
|
+
page = extract_page(input)
|
|
10
|
+
per_page = extract_per_page(input)
|
|
11
|
+
filters = extract_filters(input)
|
|
12
|
+
sorts = extract_sorts(input)
|
|
13
|
+
fields = extract_fields(input)
|
|
14
|
+
|
|
15
|
+
Prato::Query::Parameters.new(
|
|
16
|
+
page: parse_page(page),
|
|
17
|
+
per_page: parse_per_page(per_page),
|
|
18
|
+
filters: parse_filters(filters, field_lookup),
|
|
19
|
+
sorts: parse_sorts(sorts, field_lookup),
|
|
20
|
+
fields: parse_fields(fields, field_lookup)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def extract_page(input)
|
|
25
|
+
hash_access(input, "page")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse_page(raw_value)
|
|
29
|
+
safe_parse_integer(raw_value)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def extract_per_page(input)
|
|
33
|
+
hash_access(input, "per_page")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_per_page(raw_value)
|
|
37
|
+
safe_parse_integer(raw_value)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract_filters(input)
|
|
41
|
+
hash_access(input, "filters")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse_filters(input, field_lookup)
|
|
45
|
+
return nil if input.nil?
|
|
46
|
+
|
|
47
|
+
entries = normalize_entries_to_hash(input)
|
|
48
|
+
filters = parse_filter_entries(entries, field_lookup)
|
|
49
|
+
filters.nil? || filters.empty? ? nil : filters
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_filter_entries(entries, field_lookup, depth = 0)
|
|
53
|
+
raise ArgumentError, "Filter nesting too deep (maximum depth: 10)" if depth == 10
|
|
54
|
+
|
|
55
|
+
Array.wrap(entries).map do |entry|
|
|
56
|
+
if hash_access(entry, "or")
|
|
57
|
+
nested = parse_filter_entries(hash_access(entry, "or"), field_lookup, depth + 1)
|
|
58
|
+
next if nested.nil? || nested.empty?
|
|
59
|
+
|
|
60
|
+
Prato::Query::OrFilter.new(nested)
|
|
61
|
+
elsif hash_access(entry, "and")
|
|
62
|
+
nested = parse_filter_entries(hash_access(entry, "and"), field_lookup, depth + 1)
|
|
63
|
+
next if nested.nil? || nested.empty?
|
|
64
|
+
|
|
65
|
+
Prato::Query::AndFilter.new(nested)
|
|
66
|
+
else
|
|
67
|
+
field = hash_access(entry, "field")
|
|
68
|
+
operator = hash_access(entry, "operator")
|
|
69
|
+
value = hash_access(entry, "value")
|
|
70
|
+
|
|
71
|
+
Prato::Query::Filter.new(
|
|
72
|
+
parse_field(field, field_lookup),
|
|
73
|
+
operator.to_sym,
|
|
74
|
+
value
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extract_sorts(input)
|
|
81
|
+
hash_access(input, "sorts")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_sorts(input, field_lookup)
|
|
85
|
+
return nil if input.nil?
|
|
86
|
+
|
|
87
|
+
entries = normalize_entries_to_hash(input)
|
|
88
|
+
|
|
89
|
+
Array.wrap(entries).map do |entry|
|
|
90
|
+
field = hash_access(entry, "field")
|
|
91
|
+
order = hash_access(entry, "order")
|
|
92
|
+
is_desc = %w[desc descending].include?(order)
|
|
93
|
+
|
|
94
|
+
Prato::Query::Sort.new(parse_field(field, field_lookup), is_desc)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def extract_fields(input)
|
|
99
|
+
hash_access(input, "fields")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_fields(input, field_lookup)
|
|
103
|
+
return nil if input.nil?
|
|
104
|
+
|
|
105
|
+
entries = normalize_entries_to_hash(input)
|
|
106
|
+
|
|
107
|
+
Array.wrap(entries).map do |entry|
|
|
108
|
+
parse_field(entry, field_lookup)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_field(field, field_resolver)
|
|
113
|
+
fields = field.split(".")
|
|
114
|
+
field_resolver.call(fields)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def safe_parse_integer(number)
|
|
118
|
+
return nil if number.nil?
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
Integer(number)
|
|
122
|
+
rescue ArgumentError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def normalize_entries_to_hash(input)
|
|
128
|
+
case input
|
|
129
|
+
when String
|
|
130
|
+
JSON.parse(input)
|
|
131
|
+
when Array, Hash
|
|
132
|
+
input
|
|
133
|
+
else
|
|
134
|
+
raise ArgumentError, "Invalid filters type: #{input.class}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def hash_access(entry, key)
|
|
139
|
+
value = entry[key]
|
|
140
|
+
if value.nil?
|
|
141
|
+
entry[key.to_sym]
|
|
142
|
+
else
|
|
143
|
+
value
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prato
|
|
4
|
+
module Query
|
|
5
|
+
# Module that handles converting user input fields into internal fields.
|
|
6
|
+
module FieldResolver
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
SEPARATOR = "___".freeze
|
|
10
|
+
|
|
11
|
+
def join(parts)
|
|
12
|
+
parts = Array(parts)
|
|
13
|
+
parts.length == 1 ? parts.first.to_sym : parts.map(&:to_s).join(SEPARATOR).to_sym
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resolve_context(field_lookup)
|
|
17
|
+
->(fields) do
|
|
18
|
+
field_lookup[fields]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|