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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +938 -0
  5. data/lib/prato/configuration.rb +99 -0
  6. data/lib/prato/internal/active_record_version.rb +24 -0
  7. data/lib/prato/internal/join_helper.rb +48 -0
  8. data/lib/prato/internal/join_helper_legacy.rb +171 -0
  9. data/lib/prato/internal/lazy_loader_cache.rb +25 -0
  10. data/lib/prato/internal/pipeline/filtering.rb +277 -0
  11. data/lib/prato/internal/pipeline/pagination.rb +30 -0
  12. data/lib/prato/internal/pipeline/serializer.rb +87 -0
  13. data/lib/prato/internal/pipeline/sorting.rb +78 -0
  14. data/lib/prato/internal/query_executor.rb +105 -0
  15. data/lib/prato/internal/query_state.rb +90 -0
  16. data/lib/prato/internal/specification.rb +101 -0
  17. data/lib/prato/internal/specification_builder.rb +361 -0
  18. data/lib/prato/internal/sql_support.rb +118 -0
  19. data/lib/prato/query/and_filter.rb +13 -0
  20. data/lib/prato/query/default_parser.rb +148 -0
  21. data/lib/prato/query/field_resolver.rb +23 -0
  22. data/lib/prato/query/filter.rb +15 -0
  23. data/lib/prato/query/or_filter.rb +13 -0
  24. data/lib/prato/query/parameters.rb +17 -0
  25. data/lib/prato/query/sort.rb +14 -0
  26. data/lib/prato/table.rb +39 -0
  27. data/lib/prato/table_builder.rb +40 -0
  28. data/lib/prato/types/aggregate_column.rb +93 -0
  29. data/lib/prato/types/association_column.rb +37 -0
  30. data/lib/prato/types/direct_column.rb +27 -0
  31. data/lib/prato/types/expression_column.rb +38 -0
  32. data/lib/prato/types/ruby_column.rb +31 -0
  33. data/lib/prato/version.rb +5 -0
  34. data/lib/prato.rb +66 -0
  35. metadata +96 -0
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prato
4
+ class Configuration
5
+ class << self
6
+ def config
7
+ @config ||= Configuration.new
8
+ end
9
+
10
+ def configure
11
+ yield(config) if block_given?
12
+ config
13
+ end
14
+
15
+ def with_settings(
16
+ base = nil,
17
+ key_transformation: nil,
18
+ on_invalid_input: nil,
19
+ parameter_parser: nil,
20
+ default_page_size: nil,
21
+ maximum_page_size: nil,
22
+ default_queryable: nil,
23
+ default_ruby_column_queryable: nil
24
+ )
25
+ copy = (base || config).dup
26
+ copy.key_transformation = key_transformation if key_transformation
27
+ copy.on_invalid_input = on_invalid_input if on_invalid_input
28
+ copy.parameter_parser = parameter_parser if parameter_parser
29
+ copy.default_page_size = default_page_size if default_page_size
30
+ copy.maximum_page_size = maximum_page_size if maximum_page_size
31
+ copy.default_queryable = default_queryable if default_queryable
32
+ copy.default_ruby_column_queryable = default_ruby_column_queryable if default_ruby_column_queryable
33
+ copy
34
+ end
35
+ end
36
+
37
+ attr_accessor :default_page_size,
38
+ :maximum_page_size
39
+
40
+ attr_reader :key_transformation,
41
+ :on_invalid_input,
42
+ :parameter_parser,
43
+ :default_queryable,
44
+ :default_ruby_column_queryable
45
+
46
+ def initialize
47
+ @key_transformation = :camelCase
48
+ @on_invalid_input = :empty
49
+ @parameter_parser = Prato::Query::DefaultParser.new
50
+ @default_page_size = 20
51
+ @maximum_page_size = 100
52
+ @default_queryable = :all
53
+ @default_ruby_column_queryable = :none
54
+ end
55
+
56
+ KEY_TRANSFORMATION_OPTIONS = [:camelCase, :snake_case, :none].freeze
57
+ def key_transformation=(value)
58
+ raise ArgumentError unless KEY_TRANSFORMATION_OPTIONS.include?(value)
59
+
60
+ @key_transformation = value
61
+ end
62
+
63
+ INVALID_INPUT_OPTIONS = [:empty, :raise].freeze
64
+ def on_invalid_input=(value)
65
+ raise ArgumentError unless INVALID_INPUT_OPTIONS.include?(value)
66
+
67
+ @on_invalid_input = value
68
+ end
69
+
70
+ def parameter_parser=(parser)
71
+ unless parser.respond_to?(:parse_parameters)
72
+ raise ArgumentError,
73
+ "parameter_parser must respond to .parse_parameters. Got #{parser.inspect}"
74
+ end
75
+
76
+ @parameter_parser = parser
77
+ end
78
+
79
+ VALID_QUERYABLE = [:all, :none, :filter, :sort].freeze
80
+
81
+ def default_queryable=(value)
82
+ @default_queryable = validate_queryable!(value, option_name: "default_queryable")
83
+ end
84
+
85
+ def default_ruby_column_queryable=(value)
86
+ @default_ruby_column_queryable = validate_queryable!(value, option_name: "default_ruby_column_queryable")
87
+ end
88
+
89
+ private
90
+
91
+ def validate_queryable!(value, option_name: "queryable")
92
+ unless VALID_QUERYABLE.include?(value)
93
+ raise ArgumentError, "#{option_name} must be one of #{VALID_QUERYABLE.map(&:inspect).join(", ")}"
94
+ end
95
+
96
+ value
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ require "active_record/version"
3
+ module Prato
4
+ module Internal
5
+ module ActiveRecordVersion
6
+ extend self
7
+
8
+ LEGACY_CUTOFF = Gem::Version.new("5.2").freeze
9
+ MINIMUM_AREL_DESC_VERSION = Gem::Version.new("6.0").freeze
10
+
11
+ def version
12
+ @version ||= Gem::Version.new(ActiveRecord::VERSION::STRING)
13
+ end
14
+
15
+ def legacy?
16
+ version < LEGACY_CUTOFF
17
+ end
18
+
19
+ def supports_arel_desc?
20
+ version >= MINIMUM_AREL_DESC_VERSION
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prato
4
+ module Internal
5
+ module JoinHelper
6
+ extend self
7
+
8
+ def ensure_join(scope, column, left_outer: false)
9
+ return scope unless column.is_a?(Types::AssociationColumn)
10
+
11
+ join_hash = join_hash_for(column.association_path)
12
+ left_outer ? scope.left_joins(join_hash) : scope.joins(join_hash)
13
+ end
14
+
15
+ def ensure_left_joins(scope, association_paths)
16
+ return scope if association_paths.empty?
17
+
18
+ scope.left_joins(*join_hashes_for(association_paths))
19
+ end
20
+
21
+ private
22
+
23
+ def join_hash_for(path)
24
+ return path.first if path.length == 1
25
+
26
+ path.reverse.reduce { |inner, outer| { outer => inner } }
27
+ end
28
+
29
+ def join_hashes_for(paths)
30
+ result = {}
31
+
32
+ paths.each do |path|
33
+ current = result
34
+ path.each do |assoc|
35
+ current[assoc] ||= {}
36
+ current = current[assoc]
37
+ end
38
+ end
39
+
40
+ simplify_join_hash(result)
41
+ end
42
+
43
+ def simplify_join_hash(hash)
44
+ hash.map { |key, value| value.empty? ? key : { key => simplify_join_hash(value) } }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails 5.0 and 5.1ºs joins work in a different way, so we need to handle them...manually
4
+ module Prato
5
+ module Internal
6
+ module JoinHelper
7
+ extend self
8
+
9
+ def ensure_join(scope, column, left_outer: false)
10
+ return scope unless column.is_a?(Types::AssociationColumn)
11
+
12
+ join_hash = join_hash_for(column.association_path)
13
+ left_outer ? scope.left_joins(join_hash) : scope.joins(join_hash)
14
+ end
15
+
16
+ def ensure_left_joins(scope, association_paths)
17
+ return scope if association_paths.empty?
18
+
19
+ scope = freeze_existing_joins(scope)
20
+
21
+ association_paths.uniq.sort_by(&:length).each do |path|
22
+ next if join_path_resolved?(scope, path)
23
+
24
+ prefix_length = deepest_joined_prefix_length(scope, path)
25
+ next if prefix_length == path.length
26
+
27
+ parent_path = path[0...prefix_length]
28
+ parent_table = parent_path.empty? ? scope.model.arel_table : SqlSupport.table_for(scope, parent_path)
29
+ parent_model = model_for_path(scope.model, parent_path)
30
+ suffix = path[prefix_length..-1]
31
+
32
+ scope = append_left_join_suffix(scope, parent_table, parent_model, suffix)
33
+ end
34
+
35
+ scope
36
+ end
37
+
38
+ private
39
+
40
+ # Freeze pre-existing association joins into concrete join nodes before
41
+ # adding suffix joins so Rails 5 cannot rename earlier aliases.
42
+ def freeze_existing_joins(scope)
43
+ frozen_scope = scope.spawn
44
+ frozen_scope.joins_values = scope.arel.join_sources.dup
45
+ frozen_scope.left_outer_joins_values = []
46
+ frozen_scope.bind_values = scope.arel.bind_values.dup
47
+ frozen_scope
48
+ end
49
+
50
+ def append_left_join_suffix(scope, parent_table, parent_model, suffix)
51
+ node = build_join_node(parent_model, suffix)
52
+ alias_tracker = build_alias_tracker(scope)
53
+
54
+ assign_tables!(node, parent_table, alias_tracker)
55
+
56
+ collect_join_infos(node, parent_table, parent_model).each do |info|
57
+ scope = scope.joins(*info.joins)
58
+ scope.bind_values += info.binds
59
+ end
60
+
61
+ scope
62
+ end
63
+
64
+ def join_path_resolved?(scope, path)
65
+ SqlSupport.table_for(scope, path)
66
+ true
67
+ rescue ArgumentError
68
+ false
69
+ end
70
+
71
+ def deepest_joined_prefix_length(scope, path)
72
+ path.length.downto(0).find { |length| join_path_resolved?(scope, path[0...length]) } || 0
73
+ end
74
+
75
+ def model_for_path(base_model, path)
76
+ path.reduce(base_model) do |model, assoc_name|
77
+ reflection = model.reflect_on_association(assoc_name)
78
+ raise ArgumentError, "Unknown association '#{assoc_name}' on #{model}" unless reflection
79
+
80
+ reflection.klass
81
+ end
82
+ end
83
+
84
+ def build_join_node(model, path)
85
+ reflection = model.reflect_on_association(path.first)
86
+ raise ArgumentError, "Unknown association '#{path.first}' on #{model}" unless reflection
87
+
88
+ children = path.length > 1 ? [build_join_node(reflection.klass, path[1..-1])] : []
89
+ ActiveRecord::Associations::JoinDependency::JoinAssociation.new(reflection, children)
90
+ end
91
+
92
+ def assign_tables!(node, parent_table, alias_tracker)
93
+ node.tables = node.reflection.chain.map do |reflection|
94
+ aliased_table_for(
95
+ alias_tracker,
96
+ reflection,
97
+ table_alias_name(reflection, parent_table.table_name, reflection != node.reflection)
98
+ )
99
+ end
100
+
101
+ node.children.each { |child| assign_tables!(child, node.table, alias_tracker) }
102
+ end
103
+
104
+ def build_alias_tracker(scope)
105
+ method = ActiveRecord::Associations::AliasTracker.method(:create_with_joins)
106
+
107
+ if method.parameters.length == 3
108
+ method.call(scope.model.connection, scope.model.table_name, scope.joins_values)
109
+ else
110
+ method.call(scope.model.connection, scope.model.table_name, scope.joins_values, scope.model.type_caster)
111
+ end
112
+ end
113
+
114
+ def aliased_table_for(alias_tracker, reflection, aliased_name)
115
+ method = alias_tracker.method(:aliased_table_for)
116
+
117
+ if method.parameters.length == 3
118
+ method.call(reflection.table_name, aliased_name, reflection.klass.type_caster)
119
+ else
120
+ method.call(reflection.table_name, aliased_name)
121
+ end
122
+ end
123
+
124
+ def collect_join_infos(node, parent_table, parent_model)
125
+ infos = [build_join_info(node, parent_table, parent_model)]
126
+
127
+ node.children.each do |child|
128
+ infos.concat(collect_join_infos(child, node.table, node.base_klass))
129
+ end
130
+
131
+ infos
132
+ end
133
+
134
+ def build_join_info(node, parent_table, parent_model)
135
+ method = node.method(:join_constraints)
136
+
137
+ if method.parameters.length == 5
138
+ method.call(
139
+ parent_table,
140
+ parent_model,
141
+ Arel::Nodes::OuterJoin,
142
+ node.tables,
143
+ node.reflection.chain
144
+ )
145
+ else
146
+ method.call(
147
+ parent_table,
148
+ parent_model,
149
+ node,
150
+ Arel::Nodes::OuterJoin,
151
+ node.tables,
152
+ node.reflection.scope_chain,
153
+ node.reflection.chain
154
+ )
155
+ end
156
+ end
157
+
158
+ def table_alias_name(reflection, parent_table_name, join)
159
+ name = "#{reflection.plural_name}_#{parent_table_name}"
160
+ name << "_join" if join
161
+ name
162
+ end
163
+
164
+ def join_hash_for(path)
165
+ return path.first if path.length == 1
166
+
167
+ path.reverse.reduce { |inner, outer| { outer => inner } }
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prato
4
+ module Internal
5
+ class LazyLoaderCache < Hash
6
+ def initialize(records)
7
+ super(records)
8
+
9
+ @records = records
10
+ end
11
+
12
+ def [](key)
13
+ value = super
14
+
15
+ if value.is_a?(Proc)
16
+ result = value.call(@records, self)
17
+ self[key] = result # memoize the result
18
+ result
19
+ else
20
+ value
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prato
4
+ module Internal
5
+ module Pipeline
6
+ module Filtering
7
+ extend self
8
+
9
+ def filter_query(query_state, spec, raw_filters)
10
+ return query_state if raw_filters.nil?
11
+
12
+ sql_filters, ruby_filters = classify_filters(spec, Array(raw_filters))
13
+
14
+ filtered_query_1 = apply_sql_filters(query_state, spec, sql_filters)
15
+ filtered_query_2 = apply_ruby_filters(filtered_query_1, spec, ruby_filters)
16
+
17
+ filtered_query_2
18
+ end
19
+
20
+ private
21
+
22
+ def classify_filters(spec, filters)
23
+ flatten_ands(filters).partition { |f| all_sql?(spec, f) }
24
+ end
25
+
26
+ def flatten_ands(filters)
27
+ filters.flat_map do |f|
28
+ case f
29
+ when Query::AndFilter then flatten_ands(f.filters)
30
+ else [f]
31
+ end
32
+ end
33
+ end
34
+
35
+ def all_sql?(spec, filter)
36
+ case filter
37
+ when Query::Filter
38
+ !spec.columns[filter.field].is_a?(Types::RubyColumn)
39
+ when Query::AndFilter, Query::OrFilter
40
+ filter.filters.all? { |f| all_sql?(spec, f) }
41
+ end
42
+ end
43
+
44
+ def apply_sql_filters(query_state, spec, filters)
45
+ filters.reduce(query_state) do |qs, filter|
46
+ normalized = normalize_sql_filter_tree(spec, filter)
47
+ apply_sql_filter(qs, spec, normalized)
48
+ end
49
+ end
50
+
51
+ # We normalize some SQL filters so that the SQL statements are simpler
52
+ def normalize_sql_filter_tree(spec, filter)
53
+ case filter
54
+ when Query::Filter
55
+ normalize_sql_leaf_filter(spec, filter)
56
+ when Query::AndFilter
57
+ Query::AndFilter.new(filter.filters.map { |f| normalize_sql_filter_tree(spec, f) })
58
+ when Query::OrFilter
59
+ Query::OrFilter.new(filter.filters.map { |f| normalize_sql_filter_tree(spec, f) })
60
+ end
61
+ end
62
+
63
+ NIL_ARRAY = [nil].freeze
64
+ NEGATIVE_ASSC_OPS = %i[not_eq not_in not_contains not_icontains].freeze
65
+ def normalize_sql_leaf_filter(spec, filter)
66
+ operator = filter.operator
67
+ value = filter.value
68
+
69
+ # Rule 1: nil values → presence operators
70
+ if operator == :eq && value.nil?
71
+ return Query::Filter.new(filter.field, :not_present, nil)
72
+ end
73
+ if operator == :not_eq && value.nil?
74
+ return Query::Filter.new(filter.field, :present, nil)
75
+ end
76
+
77
+ if operator == :in && value == [nil]
78
+ return Query::Filter.new(filter.field, :not_present, nil)
79
+ end
80
+
81
+ if operator == :not_in && value == [nil]
82
+ return Query::Filter.new(filter.field, :present, nil)
83
+ end
84
+
85
+ # Rule 2: negative operators on association columns (non-nil values) → OrFilter with not_present
86
+ if NEGATIVE_ASSC_OPS.include?(operator) && !value.nil? && spec.columns[filter.field].is_a?(Types::AssociationColumn)
87
+ return Query::OrFilter.new([
88
+ filter,
89
+ Query::Filter.new(filter.field, :not_present, nil)
90
+ ])
91
+ end
92
+
93
+ filter
94
+ end
95
+
96
+ def apply_sql_filter(query_state, spec, filter)
97
+ case filter
98
+ when Query::Filter
99
+ column = spec.columns[filter.field]
100
+ scope = ensure_joins(query_state.dataset, column, filter.operator)
101
+
102
+ if custom_filter?(column)
103
+ result = column.filter.call(scope, filter.operator, filter.value)
104
+ return query_state.with_dataset(result) unless result.nil?
105
+ end
106
+
107
+ condition = build_operator_condition(column.sql_node_for(scope), filter.operator, filter.value)
108
+ query_state.with_dataset(scope.where(condition))
109
+ when Query::AndFilter
110
+ filter.filters.reduce(query_state) { |qs, child| apply_sql_filter(qs, spec, child) }
111
+ when Query::OrFilter
112
+ scope = ensure_left_joins_for_filters(query_state.dataset, spec, filter.filters)
113
+ condition = build_sql_condition(scope, spec, filter)
114
+ query_state.with_dataset(scope.where(condition))
115
+ end
116
+ end
117
+
118
+ def build_sql_condition(scope, spec, filter)
119
+ case filter
120
+ when Query::Filter
121
+ column = spec.columns[filter.field]
122
+
123
+ if custom_filter?(column)
124
+ result = column.filter.call(scope, filter.operator, filter.value)
125
+ return result.where_clause.ast unless result.nil?
126
+ end
127
+
128
+ build_operator_condition(column.sql_node_for(scope), filter.operator, filter.value)
129
+ when Query::AndFilter
130
+ filter.filters.map { |child| build_sql_condition(scope, spec, child) }
131
+ .reduce { |a, b| a.and(b) }
132
+ when Query::OrFilter
133
+ filter.filters.map { |child| build_sql_condition(scope, spec, child) }
134
+ .reduce { |a, b| a.or(b) }
135
+ end
136
+ end
137
+
138
+ def build_operator_condition(arel_node, operator, value)
139
+ case operator
140
+ when :eq then arel_node.eq(value)
141
+ when :not_eq then arel_node.not_eq(value)
142
+ when :lt then arel_node.lt(value)
143
+ when :lte then arel_node.lteq(value)
144
+ when :gt then arel_node.gt(value)
145
+ when :gte then arel_node.gteq(value)
146
+ when :present then arel_node.not_eq(nil)
147
+ when :not_present then arel_node.eq(nil)
148
+ when :in then arel_node.in(Array(value))
149
+ when :not_in then arel_node.not_in(Array(value))
150
+ when :contains then arel_node.matches(like_pattern(value))
151
+ when :not_contains then arel_node.does_not_match(like_pattern(value))
152
+ when :icontains then arel_node.matches(like_pattern(value), nil, false)
153
+ when :not_icontains then arel_node.does_not_match(like_pattern(value), nil, false)
154
+ when :between then arel_node.gteq(value[0]).and(arel_node.lteq(value[1]))
155
+ when :not_between then arel_node.lt(value[0]).or(arel_node.gt(value[1]))
156
+ when :between_exclusive then arel_node.gt(value[0]).and(arel_node.lt(value[1]))
157
+ when :not_between_exclusive then arel_node.lteq(value[0]).or(arel_node.gteq(value[1]))
158
+ else
159
+ raise ArgumentError, "Unknown filter operator: #{operator.inspect}"
160
+ end
161
+ end
162
+
163
+ def ensure_joins(scope, column, operator)
164
+ Internal::JoinHelper.ensure_join(scope, column, left_outer: operator == :not_present)
165
+ end
166
+
167
+ def ensure_left_joins_for_filters(scope, spec, filters)
168
+ filters.each do |filter|
169
+ case filter
170
+ when Query::Filter
171
+ column = spec.columns[filter.field]
172
+ scope = Internal::JoinHelper.ensure_join(scope, column, left_outer: true)
173
+ when Query::AndFilter, Query::OrFilter
174
+ scope = ensure_left_joins_for_filters(scope, spec, filter.filters)
175
+ end
176
+ end
177
+ scope
178
+ end
179
+
180
+ ###################################################################
181
+ # RUBY FILTERS
182
+ ###################################################################
183
+
184
+ def apply_ruby_filters(query_state, spec, filters)
185
+ return query_state if filters.empty?
186
+
187
+ records, ruby_data = query_state.materialized_dataset(spec)
188
+
189
+ filtered = records.select do |record|
190
+ filters.all? { |f| evaluate_ruby_filter(record, ruby_data, spec, f) }
191
+ end
192
+
193
+ query_state.with_dataset(filtered)
194
+ end
195
+
196
+ def evaluate_ruby_filter(record, ruby_data, spec, filter)
197
+ case filter
198
+ when Query::Filter
199
+ column = spec.columns[filter.field]
200
+ actual = column.extract_value(record, ruby_data)
201
+
202
+ if custom_filter?(column)
203
+ result = column.filter.call(actual, filter.operator, filter.value)
204
+ return result unless result.nil?
205
+ end
206
+
207
+ compare_value(actual, filter.operator, filter.value)
208
+ when Query::AndFilter
209
+ filter.filters.all? { |child| evaluate_ruby_filter(record, ruby_data, spec, child) }
210
+ when Query::OrFilter
211
+ filter.filters.any? { |child| evaluate_ruby_filter(record, ruby_data, spec, child) }
212
+ end
213
+ end
214
+
215
+ def compare_value(actual, operator, expected)
216
+ case operator
217
+ when :eq then actual == expected
218
+ when :not_eq then actual != expected
219
+ when :lt then !actual.nil? && actual < expected
220
+ when :lte then !actual.nil? && actual <= expected
221
+ when :gt then !actual.nil? && actual > expected
222
+ when :gte then !actual.nil? && actual >= expected
223
+ when :present then !actual.nil?
224
+ when :not_present then actual.nil?
225
+ when :in then Array(expected).include?(actual)
226
+ when :not_in then !Array(expected).include?(actual)
227
+ when :contains then actual.to_s.include?(expected.to_s)
228
+ when :not_contains then !actual.to_s.include?(expected.to_s)
229
+ when :icontains then icontains_match?(actual, expected)
230
+ when :not_icontains then !icontains_match?(actual, expected)
231
+ when :between then !actual.nil? && actual >= expected[0] && actual <= expected[1]
232
+ when :not_between then !actual.nil? && (actual < expected[0] || actual > expected[1])
233
+ when :between_exclusive then !actual.nil? && actual > expected[0] && actual < expected[1]
234
+ when :not_between_exclusive then !actual.nil? && (actual <= expected[0] || actual >= expected[1])
235
+ else
236
+ raise ArgumentError, "Unknown filter operator: #{operator.inspect}"
237
+ end
238
+ end
239
+
240
+ def filter_fields(filters)
241
+ filters.flat_map do |filter|
242
+ case filter
243
+ when Query::Filter
244
+ [filter.field]
245
+ when Query::AndFilter, Query::OrFilter
246
+ filter_fields(filter.filters)
247
+ else
248
+ []
249
+ end
250
+ end
251
+ end
252
+
253
+ def custom_filter?(column)
254
+ column.filter.is_a?(Proc)
255
+ end
256
+
257
+ if ActiveRecordVersion.legacy?
258
+ def sanitize_like(value)
259
+ ActiveRecord::Base.send(:sanitize_sql_like, value.to_s)
260
+ end
261
+ else
262
+ def sanitize_like(value)
263
+ ActiveRecord::Base.sanitize_sql_like(value.to_s)
264
+ end
265
+ end
266
+
267
+ def like_pattern(value)
268
+ "%#{sanitize_like(value)}%"
269
+ end
270
+
271
+ def icontains_match?(actual, expected)
272
+ actual.to_s.downcase.include?(expected.to_s.downcase)
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prato
4
+ module Internal
5
+ module Pipeline
6
+ module Pagination
7
+ extend self
8
+
9
+ def paginate_query(query_state, config, raw_page, raw_per_page)
10
+ page = raw_page || 1
11
+ per_page = raw_per_page || config.default_page_size
12
+ if per_page > config.maximum_page_size
13
+ per_page = config.maximum_page_size
14
+ end
15
+
16
+ dataset = query_state.dataset
17
+ offset = (page - 1) * per_page
18
+
19
+ paginated_dataset = if query_state.unmaterialized?
20
+ dataset.offset(offset).limit(per_page)
21
+ else
22
+ dataset.slice(offset, per_page) || []
23
+ end
24
+
25
+ query_state.with_dataset(paginated_dataset)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end