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