clearly-query 0.3.1.pre
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/.codeclimate.yml +8 -0
- data/.gitignore +42 -0
- data/.rspec +2 -0
- data/.travis.yml +10 -0
- data/CHANGELOG.md +43 -0
- data/Gemfile +7 -0
- data/Guardfile +19 -0
- data/LICENSE +22 -0
- data/README.md +102 -0
- data/Rakefile +7 -0
- data/SPEC.md +101 -0
- data/bin/guard +16 -0
- data/bin/rake +16 -0
- data/bin/rspec +16 -0
- data/bin/yard +16 -0
- data/clearly-query.gemspec +33 -0
- data/lib/clearly/query.rb +22 -0
- data/lib/clearly/query/cleaner.rb +63 -0
- data/lib/clearly/query/compose/comparison.rb +102 -0
- data/lib/clearly/query/compose/conditions.rb +215 -0
- data/lib/clearly/query/compose/core.rb +75 -0
- data/lib/clearly/query/compose/custom.rb +268 -0
- data/lib/clearly/query/compose/range.rb +114 -0
- data/lib/clearly/query/compose/special.rb +24 -0
- data/lib/clearly/query/compose/subset.rb +115 -0
- data/lib/clearly/query/composer.rb +269 -0
- data/lib/clearly/query/definition.rb +165 -0
- data/lib/clearly/query/errors.rb +27 -0
- data/lib/clearly/query/graph.rb +63 -0
- data/lib/clearly/query/helper.rb +50 -0
- data/lib/clearly/query/validate.rb +296 -0
- data/lib/clearly/query/version.rb +8 -0
- data/spec/lib/clearly/query/cleaner_spec.rb +42 -0
- data/spec/lib/clearly/query/compose/custom_spec.rb +77 -0
- data/spec/lib/clearly/query/composer_query_spec.rb +50 -0
- data/spec/lib/clearly/query/composer_spec.rb +422 -0
- data/spec/lib/clearly/query/definition_spec.rb +23 -0
- data/spec/lib/clearly/query/graph_spec.rb +81 -0
- data/spec/lib/clearly/query/helper_spec.rb +17 -0
- data/spec/lib/clearly/query/version_spec.rb +7 -0
- data/spec/spec_helper.rb +89 -0
- data/spec/support/db/migrate/001_db_create.rb +62 -0
- data/spec/support/models/customer.rb +63 -0
- data/spec/support/models/order.rb +66 -0
- data/spec/support/models/part.rb +63 -0
- data/spec/support/models/product.rb +67 -0
- data/spec/support/shared_setup.rb +13 -0
- data/tmp/.gitkeep +0 -0
- metadata +263 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Clearly
|
|
2
|
+
module Query
|
|
3
|
+
module Compose
|
|
4
|
+
|
|
5
|
+
# Methods for composing queries containing spacial comparisons.
|
|
6
|
+
module Special
|
|
7
|
+
include Clearly::Query::Validate
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Create null comparison node.
|
|
12
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
13
|
+
# @param [Boolean] value
|
|
14
|
+
# @return [Arel::Nodes::Node] condition
|
|
15
|
+
def compose_null_node(node, value)
|
|
16
|
+
validate_node_or_attribute(node)
|
|
17
|
+
validate_boolean(value)
|
|
18
|
+
value ? node.eq(nil) : node.not_eq(nil)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module Clearly
|
|
2
|
+
module Query
|
|
3
|
+
module Compose
|
|
4
|
+
|
|
5
|
+
# Methods for composing subset queries.
|
|
6
|
+
module Subset
|
|
7
|
+
include Clearly::Query::Validate
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Create contains condition.
|
|
12
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
13
|
+
# @param [Object] value
|
|
14
|
+
# @return [Arel::Nodes::Node] condition
|
|
15
|
+
def compose_contains_node(node, value)
|
|
16
|
+
validate_node_or_attribute(node)
|
|
17
|
+
node.matches(like_syntax(value, {start: true, end: true}))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Create not contains condition.
|
|
21
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
22
|
+
# @param [Object] value
|
|
23
|
+
# @return [Arel::Nodes::Node] condition
|
|
24
|
+
def compose_not_contains_node(node, value)
|
|
25
|
+
validate_node_or_attribute(node)
|
|
26
|
+
node.does_not_match(like_syntax(value, {start: true, end: true}))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Create starts_with condition.
|
|
30
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
31
|
+
# @param [Object] value
|
|
32
|
+
# @return [Arel::Nodes::Node] condition
|
|
33
|
+
def compose_starts_with_node(node, value)
|
|
34
|
+
validate_node_or_attribute(node)
|
|
35
|
+
node.matches(like_syntax(value, {start: false, end: true}))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Create not starts_with condition.
|
|
39
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
40
|
+
# @param [Object] value
|
|
41
|
+
# @return [Arel::Nodes::Node] condition
|
|
42
|
+
def compose_not_starts_with_node(node, value)
|
|
43
|
+
validate_node_or_attribute(node)
|
|
44
|
+
node.does_not_match(like_syntax(value, {start: false, end: true}))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Create ends_with condition.
|
|
48
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
49
|
+
# @param [Object] value
|
|
50
|
+
# @return [Arel::Nodes::Node] condition
|
|
51
|
+
def compose_ends_with_node(node, value)
|
|
52
|
+
validate_node_or_attribute(node)
|
|
53
|
+
node.matches(like_syntax(value, {start: true, end: false}))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Create not ends_with condition.
|
|
57
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
58
|
+
# @param [Object] value
|
|
59
|
+
# @return [Arel::Nodes::Node] condition
|
|
60
|
+
def compose_not_ends_with_node(node, value)
|
|
61
|
+
validate_node_or_attribute(node)
|
|
62
|
+
node.does_not_match(like_syntax(value, {start: true, end: false}))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Create IN condition.
|
|
66
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
67
|
+
# @param [Array] values
|
|
68
|
+
# @return [Arel::Nodes::Node] condition
|
|
69
|
+
def compose_in_node(node, values)
|
|
70
|
+
validate_node_or_attribute(node)
|
|
71
|
+
values = [values].flatten
|
|
72
|
+
validate_not_blank(values)
|
|
73
|
+
validate_array(values)
|
|
74
|
+
validate_array_items(values)
|
|
75
|
+
node.in(values)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Create NOT IN condition.
|
|
79
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
80
|
+
# @param [Array] values
|
|
81
|
+
# @return [Arel::Nodes::Node] condition
|
|
82
|
+
def compose_not_in_node(node, values)
|
|
83
|
+
validate_node_or_attribute(node)
|
|
84
|
+
values = [values].flatten
|
|
85
|
+
validate_not_blank(values)
|
|
86
|
+
validate_array(values)
|
|
87
|
+
validate_array_items(values)
|
|
88
|
+
node.not_in(values)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Create regular expression condition.
|
|
92
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
93
|
+
# @param [Object] value
|
|
94
|
+
# @return [Arel::Nodes::Node] condition
|
|
95
|
+
def compose_regex_node(node, value)
|
|
96
|
+
validate_node_or_attribute(node)
|
|
97
|
+
sanitized_value = sanitize_similar_to_value(value)
|
|
98
|
+
Arel::Nodes::Regexp.new(node, Arel::Nodes.build_quoted(sanitized_value))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Create negated regular expression condition.
|
|
102
|
+
# Not available just now, maybe in Arel 6?
|
|
103
|
+
# @param [Arel::Nodes::Node, Arel::Attributes::Attribute, String] node
|
|
104
|
+
# @param [Object] value
|
|
105
|
+
# @return [Arel::Nodes::Node] condition
|
|
106
|
+
def compose_not_regex_node(node, value)
|
|
107
|
+
validate_node_or_attribute(node)
|
|
108
|
+
sanitized_value = sanitize_similar_to_value(value)
|
|
109
|
+
Arel::Nodes::NotRegexp.new(node, Arel::Nodes.build_quoted(sanitized_value))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
module Clearly
|
|
2
|
+
module Query
|
|
3
|
+
|
|
4
|
+
# Class that composes a query from a filter hash.
|
|
5
|
+
class Composer
|
|
6
|
+
include Clearly::Query::Compose::Conditions
|
|
7
|
+
include Clearly::Query::Validate
|
|
8
|
+
|
|
9
|
+
# @return [Array<Clearly::Query::Definition>] available definitions
|
|
10
|
+
attr_reader :definitions
|
|
11
|
+
|
|
12
|
+
# Create an instance of Composer using a set of model query spec definitions.
|
|
13
|
+
# @param [Array<Clearly::Query::Definition>] definitions
|
|
14
|
+
# @return [Clearly::Query::Composer]
|
|
15
|
+
def initialize(definitions)
|
|
16
|
+
validate_not_blank(definitions)
|
|
17
|
+
validate_array(definitions)
|
|
18
|
+
validate_definition_instance(definitions[0])
|
|
19
|
+
validate_array_items(definitions)
|
|
20
|
+
@definitions = definitions
|
|
21
|
+
@table_names = definitions.map { |d| d.table.name }
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Create an instance of Composer from all ActiveRecord models.
|
|
26
|
+
# @return [Clearly::Query::Composer]
|
|
27
|
+
def self.from_active_record
|
|
28
|
+
models = ActiveRecord::Base
|
|
29
|
+
.descendants
|
|
30
|
+
.reject { |d| d.name == 'ActiveRecord::SchemaMigration' }
|
|
31
|
+
.sort { |a, b| a.name <=> b.name }
|
|
32
|
+
.uniq { |d| d.arel_table.name }
|
|
33
|
+
|
|
34
|
+
definitions = models.map do |d|
|
|
35
|
+
if d.name.include?('HABTM_')
|
|
36
|
+
Clearly::Query::Definition.new({table: d.arel_table})
|
|
37
|
+
else
|
|
38
|
+
Clearly::Query::Definition.new({model: d, hash: d.clearly_query_def})
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Composer.new(definitions)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Composes a query from a parsed filter hash.
|
|
46
|
+
# @param [ActiveRecord::Base] model
|
|
47
|
+
# @param [Hash] hash
|
|
48
|
+
# @return [Arel::Nodes::Node, Array<Arel::Nodes::Node>]
|
|
49
|
+
def query(model, hash)
|
|
50
|
+
definition = select_definition_from_model(model)
|
|
51
|
+
# default combiner is :and
|
|
52
|
+
parse_query(definition, :and, hash)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# figure out which model spec to use as the base from the table
|
|
58
|
+
# select from available definitions
|
|
59
|
+
# @param [Arel::Table] table
|
|
60
|
+
# @return [Clearly::Query::Definition]
|
|
61
|
+
def select_definition_from_table(table)
|
|
62
|
+
validate_table(table)
|
|
63
|
+
matches = @definitions.select { |definition| definition.table.name == table.name }
|
|
64
|
+
if matches.size != 1
|
|
65
|
+
fail Clearly::Query::QueryArgumentError, "exactly one definition must match, found '#{matches.size}'"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
matches.first
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# figure out which model spec to use as the base from the model
|
|
72
|
+
# select rom available definitions
|
|
73
|
+
# @param [ActiveRecord::Base] model
|
|
74
|
+
# @return [Clearly::Query::Definition]
|
|
75
|
+
def select_definition_from_model(model)
|
|
76
|
+
validate_model(model)
|
|
77
|
+
select_definition_from_table(model.arel_table)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Parse a filter hash.
|
|
81
|
+
# @param [Clearly::Query::Definition] definition
|
|
82
|
+
# @param [Symbol] query_key
|
|
83
|
+
# @param [Hash] query_value
|
|
84
|
+
# @return [Array<Arel::Nodes::Node>]
|
|
85
|
+
def parse_query(definition, query_key, query_value)
|
|
86
|
+
if query_value.blank? || query_value.size < 1
|
|
87
|
+
msg = "filter hash must have at least 1 entry, got '#{query_value.size}'"
|
|
88
|
+
fail Clearly::Query::QueryArgumentError.new(msg, {hash: query_value})
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
logical_operators = Clearly::Query::Compose::Conditions::OPERATORS_LOGICAL
|
|
92
|
+
|
|
93
|
+
mapped_fields = definition.field_mappings.keys
|
|
94
|
+
standard_fields = definition.all_fields - mapped_fields
|
|
95
|
+
|
|
96
|
+
conditions = []
|
|
97
|
+
|
|
98
|
+
if logical_operators.include?(query_key)
|
|
99
|
+
# first deal with logical operators
|
|
100
|
+
condition = parse_logical_operator(definition, query_key, query_value)
|
|
101
|
+
conditions.push(condition)
|
|
102
|
+
|
|
103
|
+
elsif standard_fields.include?(query_key)
|
|
104
|
+
# then cater for standard fields
|
|
105
|
+
field_conditions = parse_standard_field(definition, query_key, query_value)
|
|
106
|
+
conditions.push(*field_conditions)
|
|
107
|
+
|
|
108
|
+
elsif mapped_fields.include?(query_key)
|
|
109
|
+
# then deal with mapped fields
|
|
110
|
+
field_conditions = parse_mapped_field(definition, query_key, query_value)
|
|
111
|
+
conditions.push(*field_conditions)
|
|
112
|
+
|
|
113
|
+
elsif @table_names.any? { |tn| query_key.to_s.downcase.start_with?(tn) }
|
|
114
|
+
# finally deal with fields from other tables
|
|
115
|
+
field_conditions = parse_custom(definition, query_key, query_value)
|
|
116
|
+
conditions.push(field_conditions)
|
|
117
|
+
else
|
|
118
|
+
fail Clearly::Query::QueryArgumentError.new("unrecognised operator or field '#{query_key}'")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
conditions
|
|
122
|
+
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Parse a logical operator and it's value.
|
|
126
|
+
# @param [Clearly::Query::Definition] definition
|
|
127
|
+
# @param [Symbol] logical_operator
|
|
128
|
+
# @param [Hash] value
|
|
129
|
+
# @return [Arel::Nodes::Node]
|
|
130
|
+
def parse_logical_operator(definition, logical_operator, value)
|
|
131
|
+
validate_definition_instance(definition)
|
|
132
|
+
validate_symbol(logical_operator)
|
|
133
|
+
validate_hash(value)
|
|
134
|
+
conditions = value.map { |key, value| parse_query(definition, key, value) }
|
|
135
|
+
condition_combine(logical_operator, *conditions)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Parse a standard field and it's conditions.
|
|
139
|
+
# @param [Clearly::Query::Definition] definition
|
|
140
|
+
# @param [Symbol] field
|
|
141
|
+
# @param [Hash] value
|
|
142
|
+
# @return [Array<Arel::Nodes::Node>]
|
|
143
|
+
def parse_standard_field(definition, field, value)
|
|
144
|
+
validate_definition_instance(definition)
|
|
145
|
+
validate_symbol(field)
|
|
146
|
+
validate_hash(value)
|
|
147
|
+
value.map do |operator, operation_value|
|
|
148
|
+
condition_components(operator, definition.table, field, definition.all_fields, operation_value)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Parse a mapped field and it's conditions.
|
|
153
|
+
# @param [Clearly::Query::Definition] definition
|
|
154
|
+
# @param [Symbol] field
|
|
155
|
+
# @param [Hash] value
|
|
156
|
+
# @return [Array<Arel::Nodes::Node>]
|
|
157
|
+
def parse_custom(definition, field, value)
|
|
158
|
+
validate_definition_instance(definition)
|
|
159
|
+
validate_symbol(field)
|
|
160
|
+
fail Clearly::Query::QueryArgumentError.new('field name must contain a dot (.)') unless field.to_s.include?('.')
|
|
161
|
+
|
|
162
|
+
validate_hash(value)
|
|
163
|
+
|
|
164
|
+
# extract table and field
|
|
165
|
+
dot_index = field.to_s.index('.')
|
|
166
|
+
|
|
167
|
+
other_table = field[0, dot_index].to_sym
|
|
168
|
+
other_model = other_table.to_s.classify.constantize
|
|
169
|
+
other_field = field[(dot_index + 1)..field.length].to_sym
|
|
170
|
+
|
|
171
|
+
table_names = definition.associations_flat.map { |a| a[:join].table_name.to_sym }
|
|
172
|
+
validate_name(other_table, table_names)
|
|
173
|
+
|
|
174
|
+
models = definition.associations_flat.map { |a| a[:join] }
|
|
175
|
+
validate_association(other_model, models)
|
|
176
|
+
|
|
177
|
+
other_definition = select_definition_from_model(other_model)
|
|
178
|
+
|
|
179
|
+
conditions = parse_standard_field(other_definition, other_field, value)
|
|
180
|
+
subquery(definition, other_definition, conditions)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Parse a mapped field
|
|
184
|
+
# @param [Clearly::Query::Definition] definition
|
|
185
|
+
# @param [Symbol] field mapped field
|
|
186
|
+
# @param [Hash] value
|
|
187
|
+
# @return [Array<Arel::Nodes::Node>]
|
|
188
|
+
def parse_mapped_field(definition, field, value)
|
|
189
|
+
validate_definition_instance(definition)
|
|
190
|
+
mapping = definition.get_field_mapping(field)
|
|
191
|
+
validate_node_or_attribute(mapping)
|
|
192
|
+
validate_hash(value)
|
|
193
|
+
value.map do |operator, operation_value|
|
|
194
|
+
condition_node(operator, mapping, operation_value)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Build a subquery restricting definition to conditions on other_definition.
|
|
199
|
+
# @param [Clearly::Query::Definition] definition
|
|
200
|
+
# @param [Clearly::Query::Definition] other_definition
|
|
201
|
+
# @param [Array<Arel::Nodes::Node>] conditions
|
|
202
|
+
# @return [Array<Arel::Nodes::Node>]
|
|
203
|
+
def subquery(definition, other_definition, conditions)
|
|
204
|
+
validate_definition_instance(definition)
|
|
205
|
+
validate_definition_instance(other_definition)
|
|
206
|
+
[conditions].flatten.each { |c| validate_node_or_attribute(c) }
|
|
207
|
+
|
|
208
|
+
current_model = definition.model
|
|
209
|
+
current_table = definition.table
|
|
210
|
+
current_joins = definition.joins
|
|
211
|
+
|
|
212
|
+
other_table = other_definition.table
|
|
213
|
+
other_model = other_definition.model
|
|
214
|
+
other_joins = other_definition.joins
|
|
215
|
+
|
|
216
|
+
# build an exist subquery to apply conditions that
|
|
217
|
+
# refer to another table
|
|
218
|
+
|
|
219
|
+
subquery = other_definition.table
|
|
220
|
+
|
|
221
|
+
# add conditions to subquery
|
|
222
|
+
[conditions].flatten.each do |c|
|
|
223
|
+
subquery = subquery.where(c)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# add joins that provide other table access to current table
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
which_joins = current_joins
|
|
230
|
+
join_paths_index = nil
|
|
231
|
+
join_path_current_index = nil
|
|
232
|
+
join_path_other_index = nil
|
|
233
|
+
which_joins.each_with_index do |item, index|
|
|
234
|
+
join_path_current_index = item.find_index { |j| j[:join] == current_model }
|
|
235
|
+
join_path_other_index = item.find_index { |j| j[:join] == other_model }
|
|
236
|
+
if !join_path_current_index.nil? && !join_path_other_index.nil?
|
|
237
|
+
join_paths_index = index
|
|
238
|
+
break
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
first_index = [join_path_current_index, join_path_other_index].min
|
|
243
|
+
last_index = [join_path_current_index, join_path_other_index].max
|
|
244
|
+
relevant_joins = which_joins[join_paths_index][first_index..last_index]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
relevant_joins.each do |j|
|
|
248
|
+
join_table = j[:join]
|
|
249
|
+
join_condition = j[:on]
|
|
250
|
+
|
|
251
|
+
# assume this is an arel_table if it doesn't respond to .arel_table
|
|
252
|
+
arel_table = join_table.respond_to?(:arel_table) ? join_table.arel_table : join_table
|
|
253
|
+
|
|
254
|
+
if arel_table.name == other_table.name && !join_condition.nil?
|
|
255
|
+
# add join as condition if this is the main table in the subquery
|
|
256
|
+
subquery = subquery.where(join_condition)
|
|
257
|
+
elsif arel_table.name != other_table.name && !join_condition.nil?
|
|
258
|
+
# add full join if this is not the main table in the subquery
|
|
259
|
+
subquery = subquery.join(arel_table).on(join_condition)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
subquery.project(1).exists
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module Clearly
|
|
2
|
+
module Query
|
|
3
|
+
|
|
4
|
+
# Validates and represents a model query specification definition.
|
|
5
|
+
class Definition
|
|
6
|
+
include Clearly::Query::Compose::Comparison
|
|
7
|
+
include Clearly::Query::Compose::Core
|
|
8
|
+
include Clearly::Query::Compose::Range
|
|
9
|
+
include Clearly::Query::Compose::Subset
|
|
10
|
+
include Clearly::Query::Compose::Special
|
|
11
|
+
include Clearly::Query::Validate
|
|
12
|
+
|
|
13
|
+
# @return [ActiveRecord::Base] active record model for this definition
|
|
14
|
+
attr_reader :model
|
|
15
|
+
|
|
16
|
+
# @return [Arel::Table] arel table for this definition
|
|
17
|
+
attr_reader :table
|
|
18
|
+
|
|
19
|
+
# @return [Array<Symbol>] available model fields
|
|
20
|
+
attr_reader :all_fields
|
|
21
|
+
|
|
22
|
+
# @return [Array<Symbol>] available text model fields
|
|
23
|
+
attr_reader :text_fields
|
|
24
|
+
|
|
25
|
+
# @return [Array<Hash>] mapped model fields
|
|
26
|
+
attr_reader :field_mappings
|
|
27
|
+
|
|
28
|
+
# @return [Array<Hash>] model associations hierarchy
|
|
29
|
+
attr_reader :associations
|
|
30
|
+
|
|
31
|
+
# @return [Array<Hash>] model associations flat array
|
|
32
|
+
attr_reader :associations_flat
|
|
33
|
+
|
|
34
|
+
# @return [Array<Array<Hash>>] associations organised to calculate joins
|
|
35
|
+
attr_reader :joins
|
|
36
|
+
|
|
37
|
+
# @return [Hash] defaults
|
|
38
|
+
attr_reader :defaults
|
|
39
|
+
|
|
40
|
+
# Create a Definition
|
|
41
|
+
# @param [Hash] opts the options to create a message with.
|
|
42
|
+
# @option opts [ActiveRecord::Base] :model (nil) the ActiveRecord model
|
|
43
|
+
# @option opts [Hash] :hash (nil) the model definition hash
|
|
44
|
+
# @option opts [Arel::Table] :table (nil) the arel table
|
|
45
|
+
# @return [Clearly::Query::Definition]
|
|
46
|
+
def initialize(opts)
|
|
47
|
+
opts = {model: nil, hash: nil, table: nil}.merge(opts)
|
|
48
|
+
|
|
49
|
+
# two ways to go: model and hash, or table and joins
|
|
50
|
+
result = nil
|
|
51
|
+
result = create_from_model(opts[:model], opts[:hash]) unless opts[:model].nil?
|
|
52
|
+
result = create_from_table(opts[:table]) if result.nil? && !opts[:table].nil?
|
|
53
|
+
|
|
54
|
+
fail Clearly::Query::QueryArgumentError.new('could not build definition from options') if result.nil?
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Build custom field from model mappings
|
|
59
|
+
# @param [Symbol] column_name
|
|
60
|
+
# @return [Arel::Nodes::Node, Arel::Attributes::Attribute, String]
|
|
61
|
+
def get_field_mapping(column_name)
|
|
62
|
+
value = @field_mappings[column_name]
|
|
63
|
+
if @field_mappings.keys.include?(column_name) && !value.blank?
|
|
64
|
+
value
|
|
65
|
+
else
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Create a Definition from an ActiveRecord model.
|
|
73
|
+
# @param [ActiveRecord::Base] model the ActiveRecord model
|
|
74
|
+
# @param [Hash] hash the model definition hash
|
|
75
|
+
# @return [Clearly::Query::Definition]
|
|
76
|
+
def create_from_model(model, hash)
|
|
77
|
+
validate_model(model)
|
|
78
|
+
validate_definition(hash)
|
|
79
|
+
|
|
80
|
+
@model = model
|
|
81
|
+
@table = relation_table(model)
|
|
82
|
+
|
|
83
|
+
@all_fields = hash[:fields][:valid]
|
|
84
|
+
@text_fields = hash[:fields][:text]
|
|
85
|
+
|
|
86
|
+
mappings = {}
|
|
87
|
+
hash[:fields][:mappings].each { |m| mappings[m[:name]] = m[:value] }
|
|
88
|
+
@field_mappings = mappings
|
|
89
|
+
|
|
90
|
+
@associations = hash[:associations]
|
|
91
|
+
@associations_flat = build_associations_flat(@associations)
|
|
92
|
+
|
|
93
|
+
if @associations.size > 0
|
|
94
|
+
node = {join: model, on: nil, associations: hash[:associations]}
|
|
95
|
+
graph = Clearly::Query::Graph.new(node, :associations)
|
|
96
|
+
@joins = graph.branches
|
|
97
|
+
else
|
|
98
|
+
@joins = []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@defaults = hash[:defaults]
|
|
102
|
+
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Create a Definition for a has and belongs to many table.
|
|
107
|
+
# @param [Arel::Table] table the arel table
|
|
108
|
+
# @return [Clearly::Query::Definition]
|
|
109
|
+
def create_from_table(table)
|
|
110
|
+
validate_table(table)
|
|
111
|
+
|
|
112
|
+
@model = nil
|
|
113
|
+
@table = table
|
|
114
|
+
@all_fields = []
|
|
115
|
+
@text_fields = []
|
|
116
|
+
@field_mappings = []
|
|
117
|
+
@associations = []
|
|
118
|
+
@associations_flat = []
|
|
119
|
+
@joins = []
|
|
120
|
+
@defaults = {}
|
|
121
|
+
|
|
122
|
+
table_name = table.name
|
|
123
|
+
associated_table_names = table_name.split('_')
|
|
124
|
+
|
|
125
|
+
# assumes associated tables primary key is 'id'
|
|
126
|
+
# assumes associated table names are the plural version of HABTM _id columns
|
|
127
|
+
associated_table_names.each do |t|
|
|
128
|
+
arel_table = Arel::Table.new(t.to_sym)
|
|
129
|
+
id_column = "#{t.singularize}_id"
|
|
130
|
+
join = {join: arel_table, on: arel_table[:id].eq(table[id_column]), available: true}
|
|
131
|
+
|
|
132
|
+
@all_fields.push(id_column)
|
|
133
|
+
@associations.push(join)
|
|
134
|
+
@associations_flat.push(join)
|
|
135
|
+
@joins.push([join])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
self
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Create a flat array of joins.
|
|
142
|
+
# @param [Array<Hash>] associations
|
|
143
|
+
# @return [Array<Hash>] associations
|
|
144
|
+
def build_associations_flat(associations)
|
|
145
|
+
joins = []
|
|
146
|
+
|
|
147
|
+
if associations.is_a?(Array)
|
|
148
|
+
more_associations = associations.map { |i| build_associations_flat(i) }
|
|
149
|
+
joins.push(*more_associations.flatten.compact) if more_associations.size > 0
|
|
150
|
+
|
|
151
|
+
elsif associations.is_a?(Hash)
|
|
152
|
+
joins.push(associations.except(:associations))
|
|
153
|
+
|
|
154
|
+
if associations[:associations] && associations[:associations].size > 0
|
|
155
|
+
more_associations = build_associations_flat(associations[:associations])
|
|
156
|
+
joins.push(*more_associations.compact) if more_associations.size > 0
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
joins.uniq
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|