clearly-query 0.3.1.pre → 1.0.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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +73 -19
- data/SPEC.md +22 -21
- data/clearly-query.gemspec +1 -1
- data/lib/clearly/query/compose/conditions.rb +0 -17
- data/lib/clearly/query/composer.rb +70 -13
- data/lib/clearly/query/graph.rb +7 -3
- data/lib/clearly/query/helper.rb +33 -1
- data/lib/clearly/query/validate.rb +5 -27
- data/lib/clearly/query/version.rb +1 -1
- data/spec/lib/clearly/query/compose/custom_spec.rb +11 -1
- data/spec/lib/clearly/query/composer_query_spec.rb +55 -11
- data/spec/lib/clearly/query/composer_spec.rb +53 -26
- data/spec/lib/clearly/query/helper_spec.rb +19 -0
- data/spec/support/models/customer.rb +2 -6
- data/spec/support/models/order.rb +10 -11
- data/spec/support/models/part.rb +2 -6
- data/spec/support/models/product.rb +4 -8
- metadata +5 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8b1fc977200d949ff4d1aa8db533b56d6d987a91
|
4
|
+
data.tar.gz: 0e5dc84af5e9b77647bfcb3ffe6dd77d1daf356b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 83e6b022830e7026f87eebfc3ef8f27a9053127493b8c4aaa327843a2eaeadebd9177f27f81cb91d77a1d1386b27af4f829645e77711f97bfc63c53bb61fba5f
|
7
|
+
data.tar.gz: abcaf272739a41933356e229ff6b4a2d3a5a04ebf90bebd2ecd3bd501c2ab65a9fee76f885fba8abc70ce714e695e0d51e850e34a66cfe96b1c1cffd005aeb5c
|
data/CHANGELOG.md
CHANGED
@@ -6,6 +6,20 @@ and [keeps a change log](http://keepachangelog.com/) (you're reading it!).
|
|
6
6
|
|
7
7
|
## Unreleased
|
8
8
|
|
9
|
+
## Release [v1.0.0](https://github.com/cofiem/clearly-query/releases/tag/v1.0.0) (2015-11-10)
|
10
|
+
|
11
|
+
### Added
|
12
|
+
- Operator to compare all text fields using OR.
|
13
|
+
- Improved tests and coverage.
|
14
|
+
|
15
|
+
### Changed
|
16
|
+
- Two methods for Composer: `#query` to compose an ActiveRecord query and `#conditions` to compose an array of Arel conditions.
|
17
|
+
- Hash cleaner applied within Composer methods.
|
18
|
+
- Graph traversal results are now cached.
|
19
|
+
|
20
|
+
### Fixed
|
21
|
+
- fixed a number of typos in SPEC and README.
|
22
|
+
|
9
23
|
## Release [v0.3.1-pre](https://github.com/cofiem/clearly-query/releases/tag/v0.3.1-pre) (2015-11-01)
|
10
24
|
|
11
25
|
### Added
|
@@ -22,6 +36,16 @@ and [keeps a change log](http://keepachangelog.com/) (you're reading it!).
|
|
22
36
|
- Transported hash filter modules and classes from [baw-server](https://github.com/QutBioacoustics/baw-server)
|
23
37
|
- Created change log
|
24
38
|
|
39
|
+
----
|
40
|
+
|
41
|
+
## Semver Summary
|
42
|
+
|
43
|
+
Given a version number MAJOR.MINOR.PATCH, increment the:
|
44
|
+
|
45
|
+
1. MAJOR version when you make incompatible API changes,
|
46
|
+
1. MINOR version when you add functionality in a backwards-compatible manner, and
|
47
|
+
1. PATCH version when you make backwards-compatible bug fixes.
|
48
|
+
|
25
49
|
## Change log categories
|
26
50
|
|
27
51
|
### Added
|
data/README.md
CHANGED
@@ -2,6 +2,9 @@
|
|
2
2
|
|
3
3
|
A library for constructing an sql query from a hash.
|
4
4
|
|
5
|
+
From a hash, validate, construct, and execute a query or create Arel conditions.
|
6
|
+
There are no assumptions or opinions on what is done with the results from the query.
|
7
|
+
|
5
8
|
Uses [Arel](https://github.com/rails/arel) and [ActiveRecord](https://github.com/rails/rails/tree/master/activerecord).
|
6
9
|
|
7
10
|
## Project Status
|
@@ -13,6 +16,7 @@ Uses [Arel](https://github.com/rails/arel) and [ActiveRecord](https://github.com
|
|
13
16
|
[](https://inch-ci.org/github/cofiem/clearly-query)
|
14
17
|
[](http://www.rubydoc.info/github/cofiem/clearly-query)
|
15
18
|
[](https://gitter.im/cofiem/clearly-query?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
19
|
+
[](https://badge.fury.io/rb/clearly-query)
|
16
20
|
|
17
21
|
## Installation
|
18
22
|
|
@@ -31,7 +35,7 @@ Or install it yourself as:
|
|
31
35
|
## Usage
|
32
36
|
|
33
37
|
There are two main public classes in this gem.
|
34
|
-
The Definition class makes use of
|
38
|
+
The Definition class makes use of settings declared in a model.
|
35
39
|
The Composer converts a hash of options into an Arel query.
|
36
40
|
|
37
41
|
### [Clearly::Query::Definition](./lib/clearly/query/definition.rb)
|
@@ -50,12 +54,12 @@ and
|
|
50
54
|
fields: {
|
51
55
|
valid: [:name, :last_contact_at],
|
52
56
|
text: [:name],
|
53
|
-
mappings: [
|
57
|
+
mappings: [ # these mappings are built in the database, and are only used for comparison, not projection
|
54
58
|
{
|
55
59
|
name: :title,
|
56
60
|
value: Clearly::Query::Helper.string_concat(
|
57
61
|
Customer.arel_table[:name],
|
58
|
-
|
62
|
+
Clearly::Query::Helper.sql_quoted(' title'))
|
59
63
|
}
|
60
64
|
]
|
61
65
|
},
|
@@ -66,37 +70,87 @@ and
|
|
66
70
|
available: true,
|
67
71
|
associations: []
|
68
72
|
}
|
69
|
-
]
|
70
|
-
defaults: {
|
71
|
-
order_by: :created_at,
|
72
|
-
direction: :desc
|
73
|
-
}
|
73
|
+
]
|
74
74
|
}
|
75
75
|
end
|
76
76
|
|
77
|
+
The available specification keys are detailed below.
|
78
|
+
|
79
|
+
All field names that are available to include in a query hash:
|
80
|
+
|
81
|
+
{fields: { valid: [<Symbols>, ...] } }
|
82
|
+
|
83
|
+
All fields that contain text (e.g. `varchar`, `text`) that are available to include in a query hash.
|
84
|
+
This must be a subset (or equal) to the `valid` field array:
|
85
|
+
|
86
|
+
{fields: { text: [<Symbols>, ...] } }
|
87
|
+
|
88
|
+
Field mappings that specify a calculated value:
|
89
|
+
|
90
|
+
{fields: { mappings: [{ name: <Symbol>, value: <Arel::Nodes::Node, String, Arel::Attribute, others...> }, ... ] } }
|
91
|
+
|
92
|
+
Associations between tables, and whether the association is available in queries or not:
|
93
|
+
|
94
|
+
{
|
95
|
+
associations: [
|
96
|
+
{
|
97
|
+
join: <Model or Arel Table>,
|
98
|
+
on: <Arel fragment>,
|
99
|
+
available: <true or false>, # is this association available to be used in queries?
|
100
|
+
associations: [ <further associations for this table>, ... ]
|
101
|
+
}
|
102
|
+
}
|
103
|
+
|
77
104
|
### [Clearly::Query::Composer](./lib/clearly/query/composer.rb)
|
78
105
|
|
79
|
-
|
106
|
+
Use the Composer to Construct an Arel query from a hash of options.
|
80
107
|
See the [query hash specification](SPEC.md) for a comprehensive overview.
|
81
108
|
|
82
|
-
|
109
|
+
There are two ways to do this. Either compose an ActiveRecord query or compose the Arel conditions.
|
83
110
|
|
84
111
|
composer = Clearly::Query::Composer.from_active_record
|
85
112
|
query_hash = {and: {name: {contains: 'test'}}} # from e.g. HTTP request
|
86
|
-
cleaned_query_hash = Clearly::Query::Cleaner.new.do(query_hash)
|
87
113
|
model = Customer
|
88
|
-
|
89
|
-
|
114
|
+
arel_conditions = composer.conditions(model, query_hash)
|
115
|
+
# or
|
116
|
+
query = composer.query(model, query_hash)
|
90
117
|
|
91
|
-
|
118
|
+
### Building custom Arel queries
|
92
119
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
120
|
+
There is also a class to aid in building Arel queries yourself.
|
121
|
+
|
122
|
+
Have a look at the [Clearly::Query::Compose::Custom](./lib/clearly/query/compose/custom.rb) class and the
|
123
|
+
[tests](./spec/lib/clearly/query/compose/custom_spec.rb)
|
124
|
+
for more details.
|
125
|
+
|
126
|
+
## Helper methods and classes
|
127
|
+
|
128
|
+
There are a number of helper methods and classes available to make working with Arel, hashes, and ActiveRecord easier.
|
129
|
+
|
130
|
+
[Clearly::Query::Cleaner](./lib/clearly/query/cleaner.rb) validates a hash to make sure all hash keys are symbols (even in nested hashes and arrays):
|
131
|
+
|
132
|
+
cleaned_query_hash = Clearly::Query::Cleaner.new.do(hash)
|
133
|
+
|
134
|
+
This library uses the custom error `Clearly::Query::QueryArgumentError` (it inherits from `ArgumentError`).
|
135
|
+
|
136
|
+
There are a bunch of validation methods in the `Clearly::Query::Validate` module. Sometimes duck typing is not that great :/
|
137
|
+
|
138
|
+
There is also the `Clearly::Query::Graph` class, currently used only for constructing root to leaf routes for building joins.
|
139
|
+
|
140
|
+
Class methods in the `Clearly::Query::Helper` class provide abstractions over differences in database string concatenation,
|
141
|
+
help construct Arel infix operators, EXISTS clauses, literals, and SQL fragments.
|
142
|
+
These helper methods are mostly a result of obscure or odd functionality.
|
143
|
+
It's a collection of Arel experience that will probably be helpful.
|
98
144
|
|
99
145
|
## More Information about Arel
|
100
146
|
|
101
147
|
- [Using Arel to Compose SQL Queries](http://robots.thoughtbot.com/using-arel-to-compose-sql-queries)
|
102
148
|
- [The definitive guide to Arel, the SQL manager for Ruby](http://jpospisil.com/2014/06/16/the-definitive-guide-to-arel-the-sql-manager-for-ruby.html)
|
149
|
+
|
150
|
+
## Contributing
|
151
|
+
|
152
|
+
1. [Fork this repo](https://github.com/cofiem/clearly-query/fork)
|
153
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
154
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
155
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
156
|
+
5. Create a new [pull request](https://github.com/cofiem/clearly-query/compare)
|
data/SPEC.md
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
# Query Hash Specification
|
2
2
|
|
3
|
-
Inspired by [
|
3
|
+
Inspired by [Elastic Search filters](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-filters.html).
|
4
4
|
|
5
5
|
## Available Filter Operators
|
6
6
|
|
7
|
-
### Combine Operators
|
7
|
+
### Combine / logical Operators
|
8
8
|
|
9
9
|
Operator | Query hash | SQL
|
10
10
|
----------|----------------|---------------------
|
@@ -12,13 +12,14 @@ Inspired by [elastic search filters](http://www.elasticsearch.org/guide/en/elast
|
|
12
12
|
or | {or: { ... }} | WHERE ... OR (...)
|
13
13
|
not | {not: { ... }} | WHERE ... NOT (...)
|
14
14
|
|
15
|
-
|
15
|
+
An implicit `and` operator is used when no logical operator is specified.
|
16
16
|
|
17
17
|
### Filter Operators
|
18
18
|
|
19
|
-
|
20
|
-
Be aware that it is possible operators may be
|
21
|
-
for unicode characters that are beyond the ASCII range'.
|
19
|
+
All filter operators have multiple forms to help with constructing queries that read more 'naturally'.
|
20
|
+
Be aware that it is possible operators may be
|
21
|
+
'case sensitive by default for unicode characters that are beyond the ASCII range'.
|
22
|
+
For example, in [sqlite](https://www.sqlite.org/lang_expr.html).
|
22
23
|
|
23
24
|
#### Comparison Operators
|
24
25
|
|
@@ -41,7 +42,6 @@ Comparison operators are self-explanatory.
|
|
41
42
|
|
42
43
|
There are special operators for `null` comparisons.
|
43
44
|
The only valid values for these operators is `true` or `false`.
|
44
|
-
Any other value is invalid.
|
45
45
|
|
46
46
|
Operator | Query hash | SQL
|
47
47
|
-----------------------|----------------------------|---------------------------------
|
@@ -51,30 +51,31 @@ Any other value is invalid.
|
|
51
51
|
|
52
52
|
##### Range
|
53
53
|
|
54
|
-
A simple range
|
54
|
+
A simple range can be specified from an inclusive lower bound and to an exclusive upper bound.
|
55
55
|
|
56
56
|
Operator | Query hash | SQL
|
57
57
|
------------------------|-----------------------------------------------------|------------------------------------------------------------------
|
58
58
|
range, in_range | {attr: {range: {from: 'value1', to: 'value2'}}} | "table"."attr" >= 'value1' AND "table"."attr" < 'value2'
|
59
|
-
not_range, not_in_range | {attr: {not_range: {from: 'value1', to: 'value2'}}} | ("table"."attr"
|
59
|
+
not_range, not_in_range | {attr: {not_range: {from: 'value1', to: 'value2'}}} | ("table"."attr" < 'value1' OR "table"."attr" >= 'value2')
|
60
60
|
|
61
|
-
A more complex range can be specified using a
|
61
|
+
A more complex range can be specified using a special format which allows for inclusive or exclusive bounds.
|
62
62
|
|
63
|
-
Operator |
|
64
|
-
|
65
|
-
interval | {attr: {interval: '(value1,value2]'}}
|
66
|
-
not_interval | {attr: {
|
63
|
+
Operator | Query hash | SQL
|
64
|
+
-------------|-------------------------------------------|----------------------------------------------------------
|
65
|
+
interval | {attr: {interval: '(value1,value2]'}} | "table"."attr" > 'value1' AND "table"."attr" <= 'value2'
|
66
|
+
not_interval | {attr: {not_interval: '(value1,value2]'}} | ("table"."attr" <= 'value1' OR "table"."attr" > 'value2')
|
67
67
|
|
68
|
-
The `interval` must match the regex `/(\[|\()(.*),(.*)(\)|\])
|
68
|
+
The `interval` must match the regex `/(\[|\()(.*),(.*)(\)|\])/`,
|
69
69
|
where `(` or `)` indicates exclusive and `[` or `]` indicates inclusive.
|
70
70
|
Specifying `[value1,value2]` is equivalent to `BETWEEN value1 AND value2`.
|
71
71
|
|
72
72
|
Any spaces between the brackets will be included in the value.
|
73
73
|
The result of including commas (`,`) in either value is undefined.
|
74
|
+
Use a single comma for separating the two values.
|
74
75
|
|
75
|
-
#####
|
76
|
+
##### Array
|
76
77
|
|
77
|
-
An array of values to match the attribute value
|
78
|
+
An array of values to match the attribute value. Compared using an exact match (which may be case sensitive, depending on the database).
|
78
79
|
|
79
80
|
Operator | Query hash | SQL
|
80
81
|
---------|-------------------------------------|-----------------------------------------------
|
@@ -83,11 +84,11 @@ An array of values to match the attribute value exactly.
|
|
83
84
|
|
84
85
|
##### Contents Match
|
85
86
|
|
86
|
-
|
87
|
-
These comparison operators are case insensitive where possible (
|
88
|
-
It is possible to match
|
87
|
+
A variety of ways to match the contents of model attribute content.
|
88
|
+
These comparison operators are case insensitive where possible (again, depends on the database).
|
89
|
+
It is possible to match the entire content, at the start of the content, at the end, or using a regular expression.
|
89
90
|
|
90
|
-
Regular expression match
|
91
|
+
Regular expression match may not be supported by all databases.
|
91
92
|
|
92
93
|
Operator | Query hash | SQL
|
93
94
|
------------------------------------------------------|-----------------------------------|-----------------------------------------------
|
data/clearly-query.gemspec
CHANGED
@@ -6,7 +6,7 @@ require 'clearly/query/version'
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
7
|
spec.name = 'clearly-query'
|
8
8
|
spec.version = Clearly::Query::VERSION
|
9
|
-
spec.authors = ['
|
9
|
+
spec.authors = ['Mark Cottman-Fields']
|
10
10
|
spec.email = ['cofiem@gmail.com']
|
11
11
|
spec.summary = %q{A library for constructing an sql query from a hash.}
|
12
12
|
spec.description = %q{A library for constructing an sql query from a hash. Uses a strict, yet flexible specification.}
|
@@ -60,23 +60,6 @@ module Clearly
|
|
60
60
|
OPERATORS_REGEX +
|
61
61
|
OPERATORS_SPECIAL
|
62
62
|
|
63
|
-
# Add conditions to a query.
|
64
|
-
# @param [ActiveRecord::Relation] query
|
65
|
-
# @param [Array<Arel::Nodes::Node>, Arel::Nodes::Node] conditions
|
66
|
-
# @return [ActiveRecord::Relation] the modified query
|
67
|
-
def condition_apply(query, conditions)
|
68
|
-
conditions = [conditions].flatten
|
69
|
-
validate_not_blank(conditions)
|
70
|
-
validate_array(conditions)
|
71
|
-
|
72
|
-
conditions.each do |condition|
|
73
|
-
validate_condition(condition)
|
74
|
-
query = query.where(condition)
|
75
|
-
end
|
76
|
-
|
77
|
-
query
|
78
|
-
end
|
79
|
-
|
80
63
|
# Combine multiple conditions.
|
81
64
|
# @param [Symbol] combiner
|
82
65
|
# @param [Arel::Nodes::Node, Array<Arel::Nodes::Node>] conditions
|
@@ -6,6 +6,9 @@ module Clearly
|
|
6
6
|
include Clearly::Query::Compose::Conditions
|
7
7
|
include Clearly::Query::Validate
|
8
8
|
|
9
|
+
# All text fields operator.
|
10
|
+
OPERATOR_ALL_TEXT = :all_text_fields
|
11
|
+
|
9
12
|
# @return [Array<Clearly::Query::Definition>] available definitions
|
10
13
|
attr_reader :definitions
|
11
14
|
|
@@ -26,10 +29,10 @@ module Clearly
|
|
26
29
|
# @return [Clearly::Query::Composer]
|
27
30
|
def self.from_active_record
|
28
31
|
models = ActiveRecord::Base
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
32
|
+
.descendants
|
33
|
+
.reject { |d| d.name == 'ActiveRecord::SchemaMigration' }
|
34
|
+
.sort { |a, b| a.name <=> b.name }
|
35
|
+
.uniq { |d| d.arel_table.name }
|
33
36
|
|
34
37
|
definitions = models.map do |d|
|
35
38
|
if d.name.include?('HABTM_')
|
@@ -45,11 +48,31 @@ module Clearly
|
|
45
48
|
# Composes a query from a parsed filter hash.
|
46
49
|
# @param [ActiveRecord::Base] model
|
47
50
|
# @param [Hash] hash
|
48
|
-
# @return [
|
51
|
+
# @return [ActiveRecord::Relation]
|
49
52
|
def query(model, hash)
|
53
|
+
conditions = conditions(model, hash)
|
54
|
+
query = model.all
|
55
|
+
validate_query(query)
|
56
|
+
conditions.each do |condition|
|
57
|
+
validate_condition(condition)
|
58
|
+
query = query.where(condition)
|
59
|
+
end
|
60
|
+
query
|
61
|
+
end
|
62
|
+
|
63
|
+
# Composes Arel conditions from a parsed filter hash.
|
64
|
+
# @param [ActiveRecord::Base] model
|
65
|
+
# @param [Hash] hash
|
66
|
+
# @return [Array<Arel::Nodes::Node>]
|
67
|
+
def conditions(model, hash)
|
68
|
+
validate_model(model)
|
69
|
+
validate_hash(hash)
|
70
|
+
|
50
71
|
definition = select_definition_from_model(model)
|
72
|
+
cleaned_query_hash = Clearly::Query::Cleaner.new.do(hash)
|
73
|
+
|
51
74
|
# default combiner is :and
|
52
|
-
|
75
|
+
parse_conditions(definition, :and, cleaned_query_hash)
|
53
76
|
end
|
54
77
|
|
55
78
|
private
|
@@ -82,17 +105,15 @@ module Clearly
|
|
82
105
|
# @param [Symbol] query_key
|
83
106
|
# @param [Hash] query_value
|
84
107
|
# @return [Array<Arel::Nodes::Node>]
|
85
|
-
def
|
108
|
+
def parse_conditions(definition, query_key, query_value)
|
86
109
|
if query_value.blank? || query_value.size < 1
|
87
110
|
msg = "filter hash must have at least 1 entry, got '#{query_value.size}'"
|
88
111
|
fail Clearly::Query::QueryArgumentError.new(msg, {hash: query_value})
|
89
112
|
end
|
90
113
|
|
91
114
|
logical_operators = Clearly::Query::Compose::Conditions::OPERATORS_LOGICAL
|
92
|
-
|
93
115
|
mapped_fields = definition.field_mappings.keys
|
94
116
|
standard_fields = definition.all_fields - mapped_fields
|
95
|
-
|
96
117
|
conditions = []
|
97
118
|
|
98
119
|
if logical_operators.include?(query_key)
|
@@ -105,6 +126,11 @@ module Clearly
|
|
105
126
|
field_conditions = parse_standard_field(definition, query_key, query_value)
|
106
127
|
conditions.push(*field_conditions)
|
107
128
|
|
129
|
+
elsif OPERATOR_ALL_TEXT == query_key
|
130
|
+
# build conditions for all text fields combined with or
|
131
|
+
field_condition = parse_all_text_fields(definition, query_value)
|
132
|
+
conditions.push(field_condition)
|
133
|
+
|
108
134
|
elsif mapped_fields.include?(query_key)
|
109
135
|
# then deal with mapped fields
|
110
136
|
field_conditions = parse_mapped_field(definition, query_key, query_value)
|
@@ -114,12 +140,12 @@ module Clearly
|
|
114
140
|
# finally deal with fields from other tables
|
115
141
|
field_conditions = parse_custom(definition, query_key, query_value)
|
116
142
|
conditions.push(field_conditions)
|
143
|
+
|
117
144
|
else
|
118
145
|
fail Clearly::Query::QueryArgumentError.new("unrecognised operator or field '#{query_key}'")
|
119
146
|
end
|
120
147
|
|
121
148
|
conditions
|
122
|
-
|
123
149
|
end
|
124
150
|
|
125
151
|
# Parse a logical operator and it's value.
|
@@ -130,8 +156,9 @@ module Clearly
|
|
130
156
|
def parse_logical_operator(definition, logical_operator, value)
|
131
157
|
validate_definition_instance(definition)
|
132
158
|
validate_symbol(logical_operator)
|
159
|
+
validate_not_blank(value)
|
133
160
|
validate_hash(value)
|
134
|
-
conditions = value.map { |key, value|
|
161
|
+
conditions = value.map { |key, value| parse_conditions(definition, key, value) }
|
135
162
|
condition_combine(logical_operator, *conditions)
|
136
163
|
end
|
137
164
|
|
@@ -143,12 +170,40 @@ module Clearly
|
|
143
170
|
def parse_standard_field(definition, field, value)
|
144
171
|
validate_definition_instance(definition)
|
145
172
|
validate_symbol(field)
|
173
|
+
validate_not_blank(value)
|
146
174
|
validate_hash(value)
|
147
175
|
value.map do |operator, operation_value|
|
148
176
|
condition_components(operator, definition.table, field, definition.all_fields, operation_value)
|
149
177
|
end
|
150
178
|
end
|
151
179
|
|
180
|
+
# Parse the conditions for all text fields.
|
181
|
+
# @param [Clearly::Query::Definition] definition
|
182
|
+
# @param [Hash] value
|
183
|
+
# @return [Array<Arel::Nodes::Node>]
|
184
|
+
def parse_all_text_fields(definition, value)
|
185
|
+
validate_definition_instance(definition)
|
186
|
+
validate_not_blank(value)
|
187
|
+
validate_hash(value)
|
188
|
+
|
189
|
+
# build conditions for all text fields
|
190
|
+
conditions = definition.text_fields.map do |text_field|
|
191
|
+
value.map do |operator, operation_value|
|
192
|
+
# cater for standard fields and mapped fields
|
193
|
+
mapping = definition.get_field_mapping(text_field)
|
194
|
+
if mapping.nil?
|
195
|
+
condition_components(operator, definition.table, text_field, definition.text_fields, operation_value)
|
196
|
+
else
|
197
|
+
validate_node_or_attribute(mapping)
|
198
|
+
condition_node(operator, mapping, operation_value)
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# combine conditions using :or
|
204
|
+
condition_combine(:or, conditions)
|
205
|
+
end
|
206
|
+
|
152
207
|
# Parse a mapped field and it's conditions.
|
153
208
|
# @param [Clearly::Query::Definition] definition
|
154
209
|
# @param [Symbol] field
|
@@ -159,6 +214,7 @@ module Clearly
|
|
159
214
|
validate_symbol(field)
|
160
215
|
fail Clearly::Query::QueryArgumentError.new('field name must contain a dot (.)') unless field.to_s.include?('.')
|
161
216
|
|
217
|
+
validate_not_blank(value)
|
162
218
|
validate_hash(value)
|
163
219
|
|
164
220
|
# extract table and field
|
@@ -189,6 +245,7 @@ module Clearly
|
|
189
245
|
validate_definition_instance(definition)
|
190
246
|
mapping = definition.get_field_mapping(field)
|
191
247
|
validate_node_or_attribute(mapping)
|
248
|
+
validate_not_blank(value)
|
192
249
|
validate_hash(value)
|
193
250
|
value.map do |operator, operation_value|
|
194
251
|
condition_node(operator, mapping, operation_value)
|
@@ -206,12 +263,12 @@ module Clearly
|
|
206
263
|
[conditions].flatten.each { |c| validate_node_or_attribute(c) }
|
207
264
|
|
208
265
|
current_model = definition.model
|
209
|
-
current_table = definition.table
|
266
|
+
#current_table = definition.table
|
210
267
|
current_joins = definition.joins
|
211
268
|
|
212
269
|
other_table = other_definition.table
|
213
270
|
other_model = other_definition.model
|
214
|
-
other_joins = other_definition.joins
|
271
|
+
#other_joins = other_definition.joins
|
215
272
|
|
216
273
|
# build an exist subquery to apply conditions that
|
217
274
|
# refer to another table
|
data/lib/clearly/query/graph.rb
CHANGED
@@ -18,15 +18,19 @@ module Clearly
|
|
18
18
|
def initialize(root_node, child_key)
|
19
19
|
@root_node = root_node
|
20
20
|
@child_key = child_key
|
21
|
+
|
22
|
+
@discovered_nodes = []
|
23
|
+
@paths = []
|
24
|
+
|
21
25
|
self
|
22
26
|
end
|
23
27
|
|
24
28
|
# build an array that contains paths from the root to all leaves
|
25
29
|
# @return [Array] paths from root to leaf
|
26
30
|
def branches
|
27
|
-
@discovered_nodes
|
28
|
-
|
29
|
-
|
31
|
+
if @discovered_nodes.blank? && @paths.blank?
|
32
|
+
traverse_branches(@root_node, nil)
|
33
|
+
end
|
30
34
|
@paths
|
31
35
|
end
|
32
36
|
|
data/lib/clearly/query/helper.rb
CHANGED
@@ -13,7 +13,7 @@ module Clearly
|
|
13
13
|
|
14
14
|
case adapter
|
15
15
|
when 'mysql'
|
16
|
-
|
16
|
+
named_function('concat', args)
|
17
17
|
when 'sqlserver'
|
18
18
|
string_concat_infix('+', *args)
|
19
19
|
when 'postgres'
|
@@ -44,6 +44,38 @@ module Clearly
|
|
44
44
|
result
|
45
45
|
end
|
46
46
|
|
47
|
+
# Construct a SQL literal.
|
48
|
+
# This is useful for sql that is too complex for Arel.
|
49
|
+
# @param [String] value
|
50
|
+
# @return [Arel::Nodes::Node]
|
51
|
+
def sql_literal(value)
|
52
|
+
Arel::Nodes::SqlLiteral.new(value)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Construct a SQL quoted string.
|
56
|
+
# This is used for fragments of SQL.
|
57
|
+
# @param [String] value
|
58
|
+
# @return [Arel::Nodes::Node]
|
59
|
+
def sql_quoted(value)
|
60
|
+
Arel::Nodes.build_quoted(value)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Construct a SQL EXISTS clause.
|
64
|
+
# @param [Arel::Nodes::Node] node
|
65
|
+
# @return [Arel::Nodes::Node]
|
66
|
+
def exists(node)
|
67
|
+
Arel::Nodes::Exists.new(node)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Construct an Arel representation of a SQL function.
|
71
|
+
# @param [String] name
|
72
|
+
# @param [String, Arel::Nodes::Node] expression
|
73
|
+
# @param [String] function_alias
|
74
|
+
# @return [Arel::Nodes::Node]
|
75
|
+
def named_function(name, expression, function_alias = nil)
|
76
|
+
Arel::Nodes::NamedFunction.new(name, expression, function_alias)
|
77
|
+
end
|
78
|
+
|
47
79
|
end
|
48
80
|
end
|
49
81
|
end
|
@@ -4,18 +4,6 @@ module Clearly
|
|
4
4
|
# Provides common validations for composing queries.
|
5
5
|
module Validate
|
6
6
|
|
7
|
-
# Validate query, table, and column values.
|
8
|
-
# @param [Arel::Query] query
|
9
|
-
# @param [Arel::Table] table
|
10
|
-
# @param [Symbol] column_name
|
11
|
-
# @param [Array<Symbol>] allowed
|
12
|
-
# @return [void]
|
13
|
-
def validate_query_table_column(query, table, column_name, allowed)
|
14
|
-
validate_query(query)
|
15
|
-
validate_table(table)
|
16
|
-
validate_name(column_name, allowed)
|
17
|
-
end
|
18
|
-
|
19
7
|
# Validate table and column values.
|
20
8
|
# @param [Arel::Table] table
|
21
9
|
# @param [Symbol] column_name
|
@@ -39,15 +27,6 @@ module Clearly
|
|
39
27
|
fail Clearly::Query::QueryArgumentError, "model must be in '#{models_allowed}', got '#{model}'" unless models_allowed.include?(model)
|
40
28
|
end
|
41
29
|
|
42
|
-
# Validate query and hash values.
|
43
|
-
# @param [ActiveRecord::Relation] query
|
44
|
-
# @param [Hash] hash
|
45
|
-
# @return [void]
|
46
|
-
def validate_query_hash(query, hash)
|
47
|
-
validate_query(query)
|
48
|
-
validate_hash(hash)
|
49
|
-
end
|
50
|
-
|
51
30
|
# Validate table value.
|
52
31
|
# @param [Arel::Table] table
|
53
32
|
# @raise [FilterArgumentError] if table is not an Arel::Table
|
@@ -154,7 +133,6 @@ module Clearly
|
|
154
133
|
# @raise [FilterArgumentError] if value is not a valid Hash.
|
155
134
|
# @return [void]
|
156
135
|
def validate_hash(value)
|
157
|
-
validate_not_blank(value)
|
158
136
|
fail Clearly::Query::QueryArgumentError, "value must be a Hash, got '#{value}'" unless value.is_a?(Hash)
|
159
137
|
end
|
160
138
|
|
@@ -181,7 +159,7 @@ module Clearly
|
|
181
159
|
fail Clearly::Query::QueryArgumentError, "value must be a boolean, got '#{value}'" if !value.is_a?(TrueClass) && !value.is_a?(FalseClass)
|
182
160
|
end
|
183
161
|
|
184
|
-
# Escape wildcards in
|
162
|
+
# Escape wildcards in LIKE value.
|
185
163
|
# @param [String] value
|
186
164
|
# @return [String] sanitized value
|
187
165
|
def sanitize_like_value(value)
|
@@ -247,16 +225,17 @@ module Clearly
|
|
247
225
|
# @param [Hash] value
|
248
226
|
# @return [void]
|
249
227
|
def validate_definition(value)
|
228
|
+
validate_not_blank(value)
|
250
229
|
validate_hash(value)
|
251
230
|
|
252
231
|
# fields
|
232
|
+
validate_not_blank(value[:fields])
|
253
233
|
validate_hash(value[:fields])
|
254
234
|
|
255
235
|
validate_not_blank(value[:fields][:valid])
|
256
236
|
validate_array(value[:fields][:valid])
|
257
237
|
validate_array_items(value[:fields][:valid])
|
258
238
|
|
259
|
-
validate_not_blank(value[:fields][:text])
|
260
239
|
validate_array(value[:fields][:text])
|
261
240
|
validate_array_items(value[:fields][:text])
|
262
241
|
|
@@ -264,6 +243,7 @@ module Clearly
|
|
264
243
|
validate_array(value[:fields][:mappings])
|
265
244
|
|
266
245
|
value[:fields][:mappings].each do |mapping|
|
246
|
+
validate_not_blank(mapping)
|
267
247
|
validate_hash(mapping)
|
268
248
|
validate_symbol(mapping[:name])
|
269
249
|
validate_not_blank(mapping[:value])
|
@@ -271,9 +251,6 @@ module Clearly
|
|
271
251
|
|
272
252
|
# associations
|
273
253
|
validate_spec_association(value[:associations])
|
274
|
-
|
275
|
-
# defaults
|
276
|
-
validate_hash(value[:defaults])
|
277
254
|
end
|
278
255
|
|
279
256
|
# Validate association specification
|
@@ -283,6 +260,7 @@ module Clearly
|
|
283
260
|
validate_array(value)
|
284
261
|
|
285
262
|
value.each do |association|
|
263
|
+
validate_not_blank(association)
|
286
264
|
validate_hash(association)
|
287
265
|
validate_not_blank(association[:join])
|
288
266
|
validate_not_blank(association[:on])
|
@@ -2,15 +2,25 @@ require 'spec_helper'
|
|
2
2
|
|
3
3
|
describe Clearly::Query::Compose::Custom do
|
4
4
|
include_context 'shared_setup'
|
5
|
+
|
6
|
+
# for access to compose_and, relation_none, and relation_all
|
5
7
|
include Clearly::Query::Compose::Core
|
6
8
|
|
7
9
|
it 'can be instantiated' do
|
8
10
|
Clearly::Query::Compose::Custom.new
|
9
11
|
end
|
10
12
|
|
11
|
-
it '
|
13
|
+
it 'constructs sql for select all' do
|
14
|
+
query = self.send(:relation_all, Order).where(customer_id: 10)
|
15
|
+
expect(query.to_sql).to eq('SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" = 10')
|
16
|
+
end
|
12
17
|
|
18
|
+
it 'constructs sql for select none' do
|
19
|
+
query = self.send(:relation_none, Order).where(customer_id: 10)
|
20
|
+
expect(query.to_sql).to eq('')
|
21
|
+
end
|
13
22
|
|
23
|
+
it 'builds expected sql' do
|
14
24
|
custom = Clearly::Query::Compose::Custom.new
|
15
25
|
|
16
26
|
table = product_def.table
|
@@ -2,19 +2,34 @@ require 'spec_helper'
|
|
2
2
|
|
3
3
|
describe Clearly::Query::Composer do
|
4
4
|
include_context 'shared_setup'
|
5
|
-
let(:product_attributes) {
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
5
|
+
let(:product_attributes) {
|
6
|
+
{
|
7
|
+
name: 'plastic cup',
|
8
|
+
code: '000475PC',
|
9
|
+
brand: 'Generic',
|
10
|
+
introduced_at: '2015-01-01 00:00:00',
|
11
|
+
discontinued_at: nil
|
12
|
+
}
|
13
|
+
}
|
14
|
+
|
15
|
+
let(:customer_attributes) {
|
16
|
+
{
|
17
|
+
name: 'first last',
|
18
|
+
last_contact_at: '2015-11-09 10:00:00'
|
19
|
+
}
|
20
|
+
}
|
21
|
+
|
22
|
+
let(:order_attributes) {
|
23
|
+
{
|
24
|
+
|
25
|
+
}
|
26
|
+
}
|
10
27
|
|
11
28
|
it 'finds the only product' do
|
12
29
|
product = Product.create!(product_attributes)
|
13
30
|
query_hash = cleaner.do({name: {contains: 'cup'}})
|
14
|
-
|
15
|
-
expect(result.size).to eq(1)
|
31
|
+
query_ar = composer.query(Product, query_hash)
|
16
32
|
|
17
|
-
query_ar = Product.where(result[0])
|
18
33
|
expect(query_ar.count).to eq(1)
|
19
34
|
|
20
35
|
result_item = query_ar.to_a[0]
|
@@ -34,10 +49,8 @@ describe Clearly::Query::Composer do
|
|
34
49
|
end
|
35
50
|
|
36
51
|
query_hash = cleaner.do({name: {contains: '5'}})
|
37
|
-
|
38
|
-
expect(result.size).to eq(1)
|
52
|
+
query_ar = composer.query(Product, query_hash)
|
39
53
|
|
40
|
-
query_ar = Product.where(result[0])
|
41
54
|
expect(query_ar.count).to eq(1)
|
42
55
|
|
43
56
|
result_item = query_ar.to_a[0]
|
@@ -47,4 +60,35 @@ describe Clearly::Query::Composer do
|
|
47
60
|
expect(result_item.introduced_at).to eq(product_attributes[:introduced_at])
|
48
61
|
expect(result_item.discontinued_at).to eq(product_attributes[:discontinued_at])
|
49
62
|
end
|
63
|
+
|
64
|
+
it 'finds the matching order using mapped field' do
|
65
|
+
customer = Customer.create!(customer_attributes)
|
66
|
+
order_pending = Order.create!(customer: customer)
|
67
|
+
order_shipped = Order.create!(customer: customer, shipped_at: '2015-11-09 11:00:00')
|
68
|
+
|
69
|
+
query_hash = cleaner.do({title: {contains: 'not shipped'}})
|
70
|
+
query_ar = composer.query(Order, query_hash)
|
71
|
+
|
72
|
+
expect(query_ar.count).to eq(1)
|
73
|
+
|
74
|
+
result_item = query_ar.to_a[0]
|
75
|
+
expect(result_item.shipped_at).to eq(order_pending.shipped_at)
|
76
|
+
expect(result_item.customer_id).to eq(order_pending.customer_id)
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'finds the correct order comparing dates' do
|
80
|
+
customer = Customer.create!(customer_attributes)
|
81
|
+
order1 = Order.create!(customer: customer, shipped_at: '2015-11-09 11:00:01')
|
82
|
+
order2 = Order.create!(customer: customer, shipped_at: '2015-11-09 11:00:00')
|
83
|
+
|
84
|
+
query_hash = cleaner.do({shipped_at: {gteq: '2015-11-09 11:00:01'}})
|
85
|
+
query_ar = composer.query(Order, query_hash)
|
86
|
+
|
87
|
+
expect(query_ar.count).to eq(1)
|
88
|
+
|
89
|
+
result_item = query_ar.to_a[0]
|
90
|
+
expect(result_item.shipped_at).to eq(order1.shipped_at)
|
91
|
+
expect(result_item.customer_id).to eq(order1.customer_id)
|
92
|
+
end
|
93
|
+
|
50
94
|
end
|
@@ -17,7 +17,7 @@ describe Clearly::Query::Composer do
|
|
17
17
|
invalid_composer = Clearly::Query::Composer.new([customer_def, customer_def])
|
18
18
|
query = cleaner.do({})
|
19
19
|
expect {
|
20
|
-
invalid_composer.
|
20
|
+
invalid_composer.conditions(Customer, query)
|
21
21
|
}.to raise_error(Clearly::Query::QueryArgumentError, "exactly one definition must match, found '2'")
|
22
22
|
end
|
23
23
|
|
@@ -36,13 +36,13 @@ describe Clearly::Query::Composer do
|
|
36
36
|
it 'is given an empty query' do
|
37
37
|
query = cleaner.do({})
|
38
38
|
expect {
|
39
|
-
composer.
|
39
|
+
composer.conditions(Customer, query)
|
40
40
|
}.to raise_error(Clearly::Query::QueryArgumentError, "filter hash must have at least 1 entry, got '0'")
|
41
41
|
end
|
42
42
|
|
43
43
|
it 'uses a regex operator using sqlite' do
|
44
44
|
expect {
|
45
|
-
conditions = composer.
|
45
|
+
conditions = composer.conditions(Product, {name: {regex: 'test'}})
|
46
46
|
query = Product.all
|
47
47
|
conditions.each { |c| query = query.where(c) }
|
48
48
|
expect(query.to_a).to eq([])
|
@@ -55,7 +55,7 @@ describe Clearly::Query::Composer do
|
|
55
55
|
|
56
56
|
it 'contains an unrecognised filter' do
|
57
57
|
expect {
|
58
|
-
composer.
|
58
|
+
composer.conditions(Customer, {
|
59
59
|
or: {
|
60
60
|
name: {
|
61
61
|
not_a_real_filter: 'Hello'
|
@@ -67,7 +67,7 @@ describe Clearly::Query::Composer do
|
|
67
67
|
|
68
68
|
it 'has no entry' do
|
69
69
|
expect {
|
70
|
-
composer.
|
70
|
+
composer.conditions(Customer, {
|
71
71
|
or: {
|
72
72
|
name: {
|
73
73
|
|
@@ -79,7 +79,7 @@ describe Clearly::Query::Composer do
|
|
79
79
|
|
80
80
|
it 'has not with no entries' do
|
81
81
|
expect {
|
82
|
-
composer.
|
82
|
+
composer.conditions(Customer, {
|
83
83
|
not: {
|
84
84
|
}
|
85
85
|
})
|
@@ -88,7 +88,7 @@ describe Clearly::Query::Composer do
|
|
88
88
|
|
89
89
|
it 'has or with no entries' do
|
90
90
|
expect {
|
91
|
-
composer.
|
91
|
+
composer.conditions(Customer, {
|
92
92
|
or: {
|
93
93
|
}
|
94
94
|
})
|
@@ -97,7 +97,7 @@ describe Clearly::Query::Composer do
|
|
97
97
|
|
98
98
|
it 'has not with more than one field' do
|
99
99
|
expect {
|
100
|
-
composer.
|
100
|
+
composer.conditions(Product, {
|
101
101
|
not: {
|
102
102
|
name: {
|
103
103
|
contains: 'Hello'
|
@@ -112,7 +112,7 @@ describe Clearly::Query::Composer do
|
|
112
112
|
|
113
113
|
it 'has not with more than one filter' do
|
114
114
|
expect {
|
115
|
-
composer.
|
115
|
+
composer.conditions(Product, {
|
116
116
|
not: {
|
117
117
|
name: {
|
118
118
|
contains: 'Hello',
|
@@ -125,7 +125,7 @@ describe Clearly::Query::Composer do
|
|
125
125
|
|
126
126
|
it 'has a combiner that is not recognised with valid filters' do
|
127
127
|
expect {
|
128
|
-
composer.
|
128
|
+
composer.conditions(Product, {
|
129
129
|
not_a_valid_combiner: {
|
130
130
|
name: {
|
131
131
|
contains: 'Hello'
|
@@ -140,7 +140,7 @@ describe Clearly::Query::Composer do
|
|
140
140
|
|
141
141
|
it "has a range missing 'from'" do
|
142
142
|
expect {
|
143
|
-
composer.
|
143
|
+
composer.conditions(Customer, {
|
144
144
|
and: {
|
145
145
|
name: {
|
146
146
|
range: {
|
@@ -154,7 +154,7 @@ describe Clearly::Query::Composer do
|
|
154
154
|
|
155
155
|
it "has a range missing 'to'" do
|
156
156
|
expect {
|
157
|
-
composer.
|
157
|
+
composer.conditions(Product, {
|
158
158
|
and: {
|
159
159
|
code: {
|
160
160
|
range: {
|
@@ -168,7 +168,7 @@ describe Clearly::Query::Composer do
|
|
168
168
|
|
169
169
|
it 'has a range with from/to and interval' do
|
170
170
|
expect {
|
171
|
-
composer.
|
171
|
+
composer.conditions(Customer, {
|
172
172
|
and: {
|
173
173
|
name: {
|
174
174
|
range: {
|
@@ -183,7 +183,7 @@ describe Clearly::Query::Composer do
|
|
183
183
|
|
184
184
|
it 'has a range with no recognised properties' do
|
185
185
|
expect {
|
186
|
-
composer.
|
186
|
+
composer.conditions(Customer, {
|
187
187
|
and: {
|
188
188
|
name: {
|
189
189
|
range: {
|
@@ -197,7 +197,7 @@ describe Clearly::Query::Composer do
|
|
197
197
|
|
198
198
|
it 'has a property that has no filters' do
|
199
199
|
expect {
|
200
|
-
composer.
|
200
|
+
composer.conditions(Customer, {
|
201
201
|
or: {
|
202
202
|
name: {
|
203
203
|
}
|
@@ -216,7 +216,7 @@ describe Clearly::Query::Composer do
|
|
216
216
|
|
217
217
|
expect {
|
218
218
|
query = cleaner.do(filter_params)
|
219
|
-
composer.
|
219
|
+
composer.conditions(Customer, query)
|
220
220
|
}.to raise_error(Clearly::Query::QueryArgumentError, 'array values cannot be hashes')
|
221
221
|
end
|
222
222
|
|
@@ -224,7 +224,7 @@ describe Clearly::Query::Composer do
|
|
224
224
|
filter_params = {"name" => {"inRange" => "(5,6)"}}
|
225
225
|
expect {
|
226
226
|
query = cleaner.do(filter_params)
|
227
|
-
composer.
|
227
|
+
composer.conditions(Customer, query)
|
228
228
|
}.to raise_error(Clearly::Query::QueryArgumentError, "range filter must be {'from': 'value', 'to': 'value'} or {'interval': '(|[.*,.*]|)'} got '(5,6)'")
|
229
229
|
end
|
230
230
|
|
@@ -233,7 +233,7 @@ describe Clearly::Query::Composer do
|
|
233
233
|
context 'succeeds when it' do
|
234
234
|
it 'is given a valid query without combiners' do
|
235
235
|
hash = cleaner.do({name: {contains: 'test'}})
|
236
|
-
conditions = composer.
|
236
|
+
conditions = composer.conditions(Customer, hash)
|
237
237
|
expect(conditions.size).to eq(1)
|
238
238
|
|
239
239
|
# sqlite only supports LIKE
|
@@ -246,7 +246,7 @@ describe Clearly::Query::Composer do
|
|
246
246
|
|
247
247
|
it 'is given a valid query with or combiner' do
|
248
248
|
hash = cleaner.do({or: {name: {contains: 'test'}, code: {eq: 4}}})
|
249
|
-
conditions = composer.
|
249
|
+
conditions = composer.conditions(Product, hash)
|
250
250
|
expect(conditions.size).to eq(1)
|
251
251
|
|
252
252
|
expect(conditions.first.to_sql).to eq("(\"products\".\"name\" LIKE '%test%' OR \"products\".\"code\" = '4')")
|
@@ -258,7 +258,7 @@ describe Clearly::Query::Composer do
|
|
258
258
|
|
259
259
|
it 'is given a valid query with camel cased keys' do
|
260
260
|
hash = cleaner.do({name: {does_not_start_with: 'test'}})
|
261
|
-
conditions = composer.
|
261
|
+
conditions = composer.conditions(Customer, hash)
|
262
262
|
expect(conditions.size).to eq(1)
|
263
263
|
|
264
264
|
expect(conditions.first.to_sql).to eq("\"customers\".\"name\" NOT LIKE 'test%'")
|
@@ -270,7 +270,7 @@ describe Clearly::Query::Composer do
|
|
270
270
|
|
271
271
|
it 'is given a valid range query that excludes the start and includes the end' do
|
272
272
|
hash = cleaner.do({name: {notInRange: {interval: '(2,5]'}}})
|
273
|
-
conditions = composer.
|
273
|
+
conditions = composer.conditions(Customer, hash)
|
274
274
|
expect(conditions.size).to eq(1)
|
275
275
|
|
276
276
|
expect(conditions.first.to_sql).to eq("(\"customers\".\"name\" <= '2' OR \"customers\".\"name\" > '5')")
|
@@ -282,7 +282,7 @@ describe Clearly::Query::Composer do
|
|
282
282
|
|
283
283
|
it 'is given a valid query that uses a table one step away' do
|
284
284
|
hash = cleaner.do({and: {name: {contains: 'test'}, 'orders.shipped_at' => {lt: '2015-10-24'}}})
|
285
|
-
conditions = composer.
|
285
|
+
conditions = composer.conditions(Customer, hash)
|
286
286
|
expect(conditions.size).to eq(1)
|
287
287
|
|
288
288
|
expected = "\"customers\".\"name\" LIKE '%test%' AND EXISTS (SELECT 1 FROM \"orders\" WHERE \"orders\".\"shipped_at\" < '2015-10-24' AND \"orders\".\"customer_id\" = \"customers\".\"id\")"
|
@@ -298,7 +298,7 @@ describe Clearly::Query::Composer do
|
|
298
298
|
# instead of the hash in Definition#parse_table_field
|
299
299
|
|
300
300
|
hash = cleaner.do({and: {name: {contains: 'test'}, 'customers.name' => {lt: '2015-10-24'}}})
|
301
|
-
conditions = composer.
|
301
|
+
conditions = composer.conditions(Part, hash)
|
302
302
|
expect(conditions.size).to eq(1)
|
303
303
|
|
304
304
|
expected = "\"parts\".\"name\" LIKE '%test%' AND EXISTS (SELECT 1 FROM \"customers\" INNER JOIN \"parts_products\" ON \"products\".\"id\" = \"parts_products\".\"product_id\" INNER JOIN \"products\" ON \"products\".\"id\" = \"orders_products\".\"product_id\" INNER JOIN \"orders_products\" ON \"orders\".\"id\" = \"orders_products\".\"order_id\" INNER JOIN \"orders\" ON \"orders\".\"customer_id\" = \"customers\".\"id\" WHERE \"customers\".\"name\" < '2015-10-24' AND \"customers\".\"id\" = \"orders\".\"customer_id\")"
|
@@ -311,10 +311,37 @@ describe Clearly::Query::Composer do
|
|
311
311
|
|
312
312
|
it 'is given a valid query that uses a custom field mapping' do
|
313
313
|
hash = cleaner.do({and: {shipped_at: {lt: '2015-10-24'}, title: {does_not_start_with: 'alice'}}})
|
314
|
-
conditions = composer.
|
314
|
+
conditions = composer.conditions(Order, hash)
|
315
315
|
expect(conditions.size).to eq(1)
|
316
316
|
|
317
|
-
expected = "\"orders\".\"shipped_at\" < '2015-10-24' AND (SELECT \"customers\".\"name\" FROM \"customers\" WHERE \"customers\".\"id\" = \"orders\".\"customer_id\") || ' (' || CASE WHEN \"orders\".\"shipped_at\" IS NULL THEN 'not shipped' ELSE \"orders\".\"shipped_at\" END || ')' NOT LIKE 'alice%'"
|
317
|
+
expected = "\"orders\".\"shipped_at\" < '2015-10-24' AND (SELECT \"customers\".\"name\" FROM \"customers\" WHERE \"customers\".\"id\" = \"orders\".\"customer_id\") || ' (' || (CASE WHEN \"orders\".\"shipped_at\" IS NULL THEN 'not shipped' ELSE \"orders\".\"shipped_at\" END) || ') ' NOT LIKE 'alice%'"
|
318
|
+
expect(conditions.first.to_sql).to eq(expected)
|
319
|
+
|
320
|
+
query = Order.all
|
321
|
+
conditions.each { |c| query = query.where(c) }
|
322
|
+
expect(query.to_a).to eq([])
|
323
|
+
end
|
324
|
+
|
325
|
+
it 'is given a valid query for all text fields' do
|
326
|
+
hash = cleaner.do({and: {all_text_fields: {starts_with: 'a'}}})
|
327
|
+
conditions = composer.conditions(Product, hash)
|
328
|
+
expect(conditions.size).to eq(1)
|
329
|
+
|
330
|
+
expected = "(((\"products\".\"brand\" || ' ' || \"products\".\"name\" || ' (' || \"products\".\"code\" || ')' LIKE 'a%' OR \"products\".\"name\" LIKE 'a%') OR \"products\".\"code\" LIKE 'a%') OR \"products\".\"brand\" LIKE 'a%')"
|
331
|
+
expect(conditions.first.to_sql).to eq(expected)
|
332
|
+
|
333
|
+
query = Product.all
|
334
|
+
conditions.each { |c| query = query.where(c) }
|
335
|
+
expect(query.to_a).to eq([])
|
336
|
+
end
|
337
|
+
|
338
|
+
it 'escapes characters in SQL like' do
|
339
|
+
hash = cleaner.do({and: {shipped_at: {lt: '2015-10-24'}, title: {does_not_start_with: 'a\l_i%c\'e|'}}})
|
340
|
+
conditions = composer.conditions(Order, hash)
|
341
|
+
expect(conditions.size).to eq(1)
|
342
|
+
|
343
|
+
escaped = 'a\\\l\\_i\\%c\'\'e\\|%'
|
344
|
+
expected = "\"orders\".\"shipped_at\" < '2015-10-24' AND (SELECT \"customers\".\"name\" FROM \"customers\" WHERE \"customers\".\"id\" = \"orders\".\"customer_id\") || ' (' || (CASE WHEN \"orders\".\"shipped_at\" IS NULL THEN 'not shipped' ELSE \"orders\".\"shipped_at\" END) || ') ' NOT LIKE '#{escaped}'"
|
318
345
|
expect(conditions.first.to_sql).to eq(expected)
|
319
346
|
|
320
347
|
query = Order.all
|
@@ -355,7 +382,7 @@ describe Clearly::Query::Composer do
|
|
355
382
|
end
|
356
383
|
|
357
384
|
hash = cleaner.do({and: {name: operator_hash}})
|
358
|
-
conditions = composer.
|
385
|
+
conditions = composer.conditions(Product, hash)
|
359
386
|
expect(conditions.size).to eq(1)
|
360
387
|
|
361
388
|
expected = {
|
@@ -14,4 +14,23 @@ describe Clearly::Query::Helper do
|
|
14
14
|
Clearly::Query::Helper.string_concat_infix('+')
|
15
15
|
}.to raise_error(ArgumentError,"string concatenation requires operator and two or more arguments, given '0'")
|
16
16
|
end
|
17
|
+
|
18
|
+
it 'builds a SQL function' do
|
19
|
+
format_date = 'YYYY-MM-DD'
|
20
|
+
format_date_quoted = Arel::Nodes.build_quoted(format_date)
|
21
|
+
table = Order.arel_table
|
22
|
+
column =:shipped_at
|
23
|
+
alias_name = 'as_alias'
|
24
|
+
|
25
|
+
query = Clearly::Query::Helper.named_function('to_char', [table[column], format_date_quoted], alias_name)
|
26
|
+
expect(query.to_sql).to eq("to_char(\"orders\".\"shipped_at\", 'YYYY-MM-DD') AS as_alias")
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'builds a SQL EXISTS condition' do
|
30
|
+
table = Order.arel_table
|
31
|
+
column =:shipped_at
|
32
|
+
|
33
|
+
query = Clearly::Query::Helper.exists(table[column])
|
34
|
+
expect(query.to_sql).to eq("EXISTS (\"orders\".\"shipped_at\")")
|
35
|
+
end
|
17
36
|
end
|
@@ -14,7 +14,7 @@ class Customer < ActiveRecord::Base
|
|
14
14
|
name: :title,
|
15
15
|
value: Clearly::Query::Helper.string_concat(
|
16
16
|
Customer.arel_table[:name],
|
17
|
-
|
17
|
+
Clearly::Query::Helper.sql_quoted(' title'))
|
18
18
|
}
|
19
19
|
]
|
20
20
|
},
|
@@ -53,11 +53,7 @@ class Customer < ActiveRecord::Base
|
|
53
53
|
}
|
54
54
|
]
|
55
55
|
}
|
56
|
-
]
|
57
|
-
defaults: {
|
58
|
-
order_by: :created_at,
|
59
|
-
direction: :desc
|
60
|
-
}
|
56
|
+
]
|
61
57
|
}
|
62
58
|
end
|
63
59
|
end
|
@@ -14,12 +14,15 @@ class Order < ActiveRecord::Base
|
|
14
14
|
{
|
15
15
|
name: :title,
|
16
16
|
value: Clearly::Query::Helper.string_concat(
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
17
|
+
Clearly::Query::Helper.sql_literal(
|
18
|
+
'(' +
|
19
|
+
Customer.arel_table
|
20
|
+
.where(Customer.arel_table[:id].eq(Order.arel_table[:customer_id]))
|
21
|
+
.project(Customer.arel_table[:name]).to_sql + ')'),
|
22
|
+
Clearly::Query::Helper.sql_quoted(' ('),
|
23
|
+
Clearly::Query::Helper.sql_literal('(CASE WHEN "orders"."shipped_at" IS NULL THEN \'not shipped\' ELSE "orders"."shipped_at" END)'),
|
24
|
+
Clearly::Query::Helper.sql_quoted(') ')
|
25
|
+
)
|
23
26
|
}
|
24
27
|
]
|
25
28
|
},
|
@@ -56,11 +59,7 @@ class Order < ActiveRecord::Base
|
|
56
59
|
}
|
57
60
|
]
|
58
61
|
}
|
59
|
-
]
|
60
|
-
defaults: {
|
61
|
-
order_by: :created_at,
|
62
|
-
direction: :desc
|
63
|
-
}
|
62
|
+
]
|
64
63
|
}
|
65
64
|
end
|
66
65
|
end
|
data/spec/support/models/part.rb
CHANGED
@@ -14,7 +14,7 @@ class Part < ActiveRecord::Base
|
|
14
14
|
name: :title,
|
15
15
|
value: Clearly::Query::Helper.string_concat(
|
16
16
|
Part.arel_table[:code],
|
17
|
-
|
17
|
+
Clearly::Query::Helper.sql_quoted(' '),
|
18
18
|
Part.arel_table[:manufacturer])
|
19
19
|
}
|
20
20
|
]
|
@@ -53,11 +53,7 @@ class Part < ActiveRecord::Base
|
|
53
53
|
}
|
54
54
|
]
|
55
55
|
}
|
56
|
-
]
|
57
|
-
defaults: {
|
58
|
-
order_by: :name,
|
59
|
-
direction: :asc
|
60
|
-
}
|
56
|
+
]
|
61
57
|
}
|
62
58
|
end
|
63
59
|
end
|
@@ -15,11 +15,11 @@ class Product < ActiveRecord::Base
|
|
15
15
|
name: :title,
|
16
16
|
value: Clearly::Query::Helper.string_concat(
|
17
17
|
Product.arel_table[:brand],
|
18
|
-
|
18
|
+
Clearly::Query::Helper.sql_quoted(' '),
|
19
19
|
Product.arel_table[:name],
|
20
|
-
|
20
|
+
Clearly::Query::Helper.sql_quoted(' ('),
|
21
21
|
Product.arel_table[:code],
|
22
|
-
|
22
|
+
Clearly::Query::Helper.sql_quoted(')'))
|
23
23
|
}
|
24
24
|
]
|
25
25
|
},
|
@@ -57,11 +57,7 @@ class Product < ActiveRecord::Base
|
|
57
57
|
}
|
58
58
|
]
|
59
59
|
}
|
60
|
-
]
|
61
|
-
defaults: {
|
62
|
-
order_by: :name,
|
63
|
-
direction: :asc
|
64
|
-
}
|
60
|
+
]
|
65
61
|
}
|
66
62
|
end
|
67
63
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: clearly-query
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
|
-
-
|
7
|
+
- Mark Cottman-Fields
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-11-
|
11
|
+
date: 2015-11-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: arel
|
@@ -235,9 +235,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
235
235
|
version: '0'
|
236
236
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
237
237
|
requirements:
|
238
|
-
- - "
|
238
|
+
- - ">="
|
239
239
|
- !ruby/object:Gem::Version
|
240
|
-
version:
|
240
|
+
version: '0'
|
241
241
|
requirements: []
|
242
242
|
rubyforge_project:
|
243
243
|
rubygems_version: 2.4.8
|