whereable 0.1.2 → 0.2.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 +5 -2
- data/README.md +17 -7
- data/lib/whereable.rb +34 -90
- data/lib/whereable/version.rb +1 -1
- data/lib/whereable_clause.rb +121 -0
- data/lib/whereable_clause.treetop +45 -0
- metadata +6 -5
- data/lib/whereable.treetop +0 -37
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a63f7a79cff3aa76f6c0d05d15bb318476188870cc121e6e7d1d9d44de2e4349
|
4
|
+
data.tar.gz: 58283edffdd9ccbe6283f0c7cba5d52342a0b37460fac7e747393442f3746e52
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a9bd520efc357f0f8391bcfe56af756ca462f107a7a7919c81bb9825e0790eb99499f372a570ddf3d3d8aea18ae503b11b07ef4cd64405a407b160c0bb0ff18d
|
7
|
+
data.tar.gz: af2956f56cb244f79d96bea6301f41a87b8e5bb8ede3df4bf0e71223e7dc71b1dea3e8a53dde6d471d8a74dcbc5f12e5e2af130cee77cc23b585aee48c9f6f2e
|
data/CHANGELOG.md
CHANGED
@@ -1,8 +1,11 @@
|
|
1
1
|
Release History
|
2
2
|
===============
|
3
|
+
# 0.2.0
|
4
|
+
* Add support for IN and BETWEEN
|
5
|
+
|
3
6
|
# 0.1.2
|
4
|
-
*
|
5
|
-
|
7
|
+
* Minor changes to play nice with older versions of Rails
|
8
|
+
* Update gemspec dependencies based on additional testing
|
6
9
|
|
7
10
|
# 0.1.1
|
8
11
|
* Make operators case-insensitive
|
data/README.md
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
# Whereable
|
4
4
|
|
5
5
|
Translates where-like filter syntax into an Arel-based ActiveRecord scope, so you can safely use SQL syntax in Rails controller parameters.
|
6
|
-
|
6
|
+
What it lacks in power, it gains in simplicity and ease of use for API consumers.
|
7
7
|
|
8
8
|
## Installation
|
9
9
|
|
@@ -30,6 +30,8 @@ class User < ActiveRecord::Base
|
|
30
30
|
|
31
31
|
validates :username, presence: true, uniqueness: true
|
32
32
|
|
33
|
+
validates :born_on, presence: true
|
34
|
+
|
33
35
|
enum role: { standard: 0, admin: 1 }
|
34
36
|
end
|
35
37
|
```
|
@@ -37,15 +39,16 @@ With this data:
|
|
37
39
|
``` ruby
|
38
40
|
User.create!(username: 'Morpheus', role: :admin, born_on: '1961-07-30')
|
39
41
|
User.create!(username: 'Neo', role: :standard, born_on: '1964-09-02')
|
42
|
+
User.create!(username: 'Trinity', role: :standard, born_on: '1967-08-21')
|
40
43
|
```
|
41
44
|
Let's assume you're allowing filtered API access to your Users,
|
42
45
|
but using the `#standard` scope to keep admins hidden. So your controller might include:
|
43
46
|
``` ruby
|
44
47
|
User.standard.where(params[:filter])
|
45
48
|
```
|
46
|
-
And your white hat API consumers pass in `filter=born_on < '
|
49
|
+
And your white hat API consumers pass in `filter=born_on < '1967-01-01'`, and …
|
47
50
|
``` ruby
|
48
|
-
User.standard.where("born_on < '
|
51
|
+
User.standard.where("born_on < '1967-01-01'")
|
49
52
|
```
|
50
53
|
returns Neo as expected, so we're all good.
|
51
54
|
|
@@ -65,7 +68,7 @@ User.standard.whereable(params[:filter])
|
|
65
68
|
```
|
66
69
|
And then …
|
67
70
|
``` ruby
|
68
|
-
User.standard.whereable("born_on < '
|
71
|
+
User.standard.whereable("born_on < '1967-01-01'")
|
69
72
|
```
|
70
73
|
returns Neo as before, but …
|
71
74
|
``` ruby
|
@@ -78,9 +81,13 @@ Whereable::FilterInvalid ('Invalid filter at ) or (true')
|
|
78
81
|
|
79
82
|
### Syntax
|
80
83
|
* Supports and/or with nested parentheses as needed
|
81
|
-
* Recognizes these operators: `eq ne gte gt lte lt = != <> >= > <=
|
82
|
-
* Column must be to left of operator, and literal to right
|
83
|
-
* Comparing columns is *not* supported
|
84
|
+
* Recognizes these operators: `eq ne gte gt lte lt = != <> >= > <= <`, plus `IN` and `BETWEEN`
|
85
|
+
* Column must be to left of operator, and literal(s) to right
|
86
|
+
* Comparing columns to each other is *not* supported
|
87
|
+
* `BETWEEN` requires two literals separated by `AND`
|
88
|
+
* Example: `publish_at between '2020-11-01 12:00 EST' and '2020-11-15 23:59:59 EST'`
|
89
|
+
* `IN` requires comma-separated literals in parentheses
|
90
|
+
* Example: `username in (Morpheus, Trinity)`
|
84
91
|
* Quotes are optional unless the literal contains spaces or quotes
|
85
92
|
* Supports double or single quotes, and embedded quotes may be backslash escaped
|
86
93
|
* Also supports the PostgreSQL double-single embedded quote
|
@@ -88,6 +95,9 @@ Whereable::FilterInvalid ('Invalid filter at ) or (true')
|
|
88
95
|
* 👍 `User.whereable('role = admin')`
|
89
96
|
* 👎 `User.whereable('role = 1')`
|
90
97
|
|
98
|
+
### More
|
99
|
+
See the [Wiki](https://github.com/MacksMind/whereable/wiki) for more.
|
100
|
+
|
91
101
|
## Development
|
92
102
|
|
93
103
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/lib/whereable.rb
CHANGED
@@ -4,8 +4,7 @@
|
|
4
4
|
|
5
5
|
require 'whereable/version'
|
6
6
|
require 'active_record'
|
7
|
-
require '
|
8
|
-
require 'whereable.treetop'
|
7
|
+
require 'whereable_clause'
|
9
8
|
|
10
9
|
# Whereable module
|
11
10
|
module Whereable
|
@@ -13,85 +12,6 @@ module Whereable
|
|
13
12
|
|
14
13
|
class FilterInvalid < StandardError; end
|
15
14
|
|
16
|
-
module Disjunction
|
17
|
-
# Hash for OR
|
18
|
-
def to_h
|
19
|
-
return conjunction.to_h if opt.empty?
|
20
|
-
|
21
|
-
{
|
22
|
-
or: [conjunction.to_h] + opt.elements.map { |o| o.conjunction.to_h },
|
23
|
-
}
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
|
-
module Conjunction
|
28
|
-
# Hash for AND
|
29
|
-
def to_h
|
30
|
-
return term.to_h if opt.empty?
|
31
|
-
|
32
|
-
{
|
33
|
-
and: [term.to_h] + opt.elements.map { |o| o.term.to_h },
|
34
|
-
}
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
module NestedExpression
|
39
|
-
# Hash for nested expression
|
40
|
-
delegate :to_h, to: :expression
|
41
|
-
end
|
42
|
-
|
43
|
-
module Condition
|
44
|
-
# Hash for comparison
|
45
|
-
def to_h
|
46
|
-
{
|
47
|
-
operator.to_sym => {
|
48
|
-
column: column.to_s,
|
49
|
-
literal: literal.to_s,
|
50
|
-
},
|
51
|
-
}
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
module Column
|
56
|
-
# String column name
|
57
|
-
def to_s
|
58
|
-
text_value
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
# Operators such as 'eq' that match their Arel equivalent aren't needed here
|
63
|
-
OP_SYMS = {
|
64
|
-
'ne' => :not_eq,
|
65
|
-
'lte' => :lteq,
|
66
|
-
'gte' => :gteq,
|
67
|
-
'=' => :eq,
|
68
|
-
'!=' => :not_eq,
|
69
|
-
'<>' => :not_eq,
|
70
|
-
'<' => :lt,
|
71
|
-
'<=' => :lteq,
|
72
|
-
'>' => :gt,
|
73
|
-
'>=' => :gteq,
|
74
|
-
}.freeze
|
75
|
-
private_constant :OP_SYMS
|
76
|
-
|
77
|
-
module Operator
|
78
|
-
# Arel comparion method
|
79
|
-
def to_sym
|
80
|
-
OP_SYMS[text_value.downcase] || text_value.downcase.to_sym
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
module Literal
|
85
|
-
# Strip enclosing quotes and tranlate embedded quote patterns
|
86
|
-
def to_s
|
87
|
-
text_value
|
88
|
-
.gsub(/\A['"]|['"]\z/, '')
|
89
|
-
.gsub("''", "'")
|
90
|
-
.gsub("\\'", "'")
|
91
|
-
.gsub('\"', '"')
|
92
|
-
end
|
93
|
-
end
|
94
|
-
|
95
15
|
included do
|
96
16
|
scope(
|
97
17
|
:whereable,
|
@@ -108,14 +28,32 @@ module Whereable
|
|
108
28
|
module ClassMethods
|
109
29
|
# Parse filter to hash tree using Treetop PEG
|
110
30
|
def whereable_hash_tree(filter)
|
111
|
-
parser =
|
112
|
-
hash = parser.parse(filter)&.to_h
|
31
|
+
parser = WhereableClauseParser.new
|
32
|
+
hash = parser.parse(filter.strip)&.to_h
|
113
33
|
|
114
34
|
raise FilterInvalid, "Invalid filter at #{filter[parser.max_terminal_failure_index..-1]}" if hash.nil?
|
115
35
|
|
116
36
|
hash
|
117
37
|
end
|
118
38
|
|
39
|
+
# Test column validity
|
40
|
+
def whereable_valid_column(column)
|
41
|
+
raise FilterInvalid, "Invalid column #{column}" unless column_names.include?(column)
|
42
|
+
|
43
|
+
column
|
44
|
+
end
|
45
|
+
|
46
|
+
# Test literal validity
|
47
|
+
def whereable_valid_literal(column, literal)
|
48
|
+
if defined_enums.key?(column)
|
49
|
+
raise(FilterInvalid, "Invalid value #{literal} for #{column}") unless defined_enums[column].key?(literal)
|
50
|
+
|
51
|
+
defined_enums[column][literal]
|
52
|
+
else
|
53
|
+
literal
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
119
57
|
# deparse hash tree to Arel
|
120
58
|
def whereable_deparse(hash)
|
121
59
|
raise FilterInvalid, "Invalid hash #{hash}" if hash.size > 1
|
@@ -130,14 +68,20 @@ module Whereable
|
|
130
68
|
end
|
131
69
|
arel
|
132
70
|
when :eq, :not_eq, :gt, :gteq, :lt, :lteq
|
133
|
-
column = value[:column]
|
134
|
-
literal = value[:literal]
|
135
|
-
raise FilterInvalid, "Invalid column #{column}" unless column_names.include?(column)
|
136
|
-
|
137
|
-
if defined_enums.key?(column)
|
138
|
-
literal = defined_enums[column][literal] || raise(FilterInvalid, "Invalid value #{literal} for #{column}")
|
139
|
-
end
|
71
|
+
column = whereable_valid_column(value[:column])
|
72
|
+
literal = whereable_valid_literal(column, value[:literal])
|
140
73
|
arel_table[column].public_send(key, literal)
|
74
|
+
when :between
|
75
|
+
column = whereable_valid_column(value[:column])
|
76
|
+
literals = value[:literals]
|
77
|
+
raise(FilterInvalid, "Invalid operation for #{column}") if defined_enums.key?(column)
|
78
|
+
|
79
|
+
arel_table[column].between(literals)
|
80
|
+
when :in
|
81
|
+
column = whereable_valid_column(value[:column])
|
82
|
+
literals = value[:literals].map { |l| whereable_valid_literal(column, l) }
|
83
|
+
|
84
|
+
arel_table[column].in(literals)
|
141
85
|
else
|
142
86
|
raise FilterInvalid, "Invalid hash #{hash}"
|
143
87
|
end
|
data/lib/whereable/version.rb
CHANGED
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Mack Earnhardt
|
4
|
+
|
5
|
+
require 'treetop'
|
6
|
+
|
7
|
+
module WhereableClause
|
8
|
+
Treetop.load(File.join(__dir__, 'whereable_clause.treetop'))
|
9
|
+
|
10
|
+
module Disjunction
|
11
|
+
# Hash for OR
|
12
|
+
def to_h
|
13
|
+
return conjunction.to_h if opt.empty?
|
14
|
+
|
15
|
+
{
|
16
|
+
or: [conjunction.to_h] + opt.elements.map { |o| o.conjunction.to_h },
|
17
|
+
}
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
module Conjunction
|
22
|
+
# Hash for AND
|
23
|
+
def to_h
|
24
|
+
return term.to_h if opt.empty?
|
25
|
+
|
26
|
+
{
|
27
|
+
and: [term.to_h] + opt.elements.map { |o| o.term.to_h },
|
28
|
+
}
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
module NestedExpression
|
33
|
+
# Hash for nested expression
|
34
|
+
delegate :to_h, to: :expression
|
35
|
+
end
|
36
|
+
|
37
|
+
module Condition
|
38
|
+
# Hash for comparison
|
39
|
+
def to_h
|
40
|
+
{
|
41
|
+
operator.to_sym => {
|
42
|
+
column: column.to_s,
|
43
|
+
literal: literal.to_s,
|
44
|
+
},
|
45
|
+
}
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
module ConditionBetween
|
50
|
+
# Hash for between operator
|
51
|
+
def to_h
|
52
|
+
{
|
53
|
+
between: {
|
54
|
+
column: column.to_s,
|
55
|
+
literals: left.to_s..right.to_s,
|
56
|
+
},
|
57
|
+
}
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
module ConditionIn
|
62
|
+
# Hash for in operator
|
63
|
+
def to_h
|
64
|
+
if opt.empty?
|
65
|
+
{
|
66
|
+
eq: {
|
67
|
+
column: column.to_s,
|
68
|
+
literal: literal.to_s,
|
69
|
+
},
|
70
|
+
}
|
71
|
+
else
|
72
|
+
{
|
73
|
+
in: {
|
74
|
+
column: column.to_s,
|
75
|
+
literals: [literal.to_s] + opt.elements.map { |o| o.literal.to_s },
|
76
|
+
},
|
77
|
+
}
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
module Column
|
83
|
+
# String column name
|
84
|
+
def to_s
|
85
|
+
text_value
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Operators such as 'eq' that match their Arel equivalent aren't needed here
|
90
|
+
OP_SYMS = {
|
91
|
+
'ne' => :not_eq,
|
92
|
+
'lte' => :lteq,
|
93
|
+
'gte' => :gteq,
|
94
|
+
'=' => :eq,
|
95
|
+
'!=' => :not_eq,
|
96
|
+
'<>' => :not_eq,
|
97
|
+
'<' => :lt,
|
98
|
+
'<=' => :lteq,
|
99
|
+
'>' => :gt,
|
100
|
+
'>=' => :gteq,
|
101
|
+
}.freeze
|
102
|
+
private_constant :OP_SYMS
|
103
|
+
|
104
|
+
module Operator
|
105
|
+
# Arel comparion method
|
106
|
+
def to_sym
|
107
|
+
OP_SYMS[text_value.downcase] || text_value.downcase.to_sym
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
module Literal
|
112
|
+
# Strip enclosing quotes and tranlate embedded quote patterns
|
113
|
+
def to_s
|
114
|
+
text_value
|
115
|
+
.gsub(/\A['"]|['"]\z/, '')
|
116
|
+
.gsub("''", "'")
|
117
|
+
.gsub("\\'", "'")
|
118
|
+
.gsub('\"', '"')
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
grammar WhereableClause
|
2
|
+
rule expression
|
3
|
+
disjunction
|
4
|
+
end
|
5
|
+
|
6
|
+
rule disjunction
|
7
|
+
conjunction opt:( _sp? 'or'i _sp? conjunction )* <Disjunction>
|
8
|
+
end
|
9
|
+
|
10
|
+
rule conjunction
|
11
|
+
term opt:( _sp? 'and'i _sp? term )* <Conjunction>
|
12
|
+
end
|
13
|
+
|
14
|
+
rule term
|
15
|
+
condition / condition_between / condition_in / '(' _sp? expression _sp? ')' <NestedExpression>
|
16
|
+
end
|
17
|
+
|
18
|
+
rule condition
|
19
|
+
column _sp? operator _sp? literal <Condition>
|
20
|
+
end
|
21
|
+
|
22
|
+
rule condition_between
|
23
|
+
column _sp 'between'i _sp left:literal _sp 'and'i _sp right:literal <ConditionBetween>
|
24
|
+
end
|
25
|
+
|
26
|
+
rule condition_in
|
27
|
+
column _sp 'in'i _sp? '(' _sp? literal _sp? opt:( ',' _sp? literal _sp? )* ')' <ConditionIn>
|
28
|
+
end
|
29
|
+
|
30
|
+
rule _sp
|
31
|
+
[\s]+
|
32
|
+
end
|
33
|
+
|
34
|
+
rule column
|
35
|
+
[a-zA-Z0-9_]+ <Column>
|
36
|
+
end
|
37
|
+
|
38
|
+
rule operator
|
39
|
+
( 'eq'i &_sp / 'ne'i &_sp / 'gte'i &_sp / 'gt'i &_sp / 'lte'i &_sp / 'lt'i &_sp / '=' / '!=' / '<>' / '>=' / '>' / '<=' / '<' ) <Operator>
|
40
|
+
end
|
41
|
+
|
42
|
+
rule literal
|
43
|
+
( '"' ( '\"' / [^"] )+ '"' / "'" ( "''" / "\\'" / [^'] )+ "'" / [^\s,)]+ ) <Literal>
|
44
|
+
end
|
45
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: whereable
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mack Earnhardt
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-11-
|
11
|
+
date: 2020-11-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -41,7 +41,7 @@ dependencies:
|
|
41
41
|
description: |
|
42
42
|
Translates where-like filter syntax into an Arel-based ActiveRecord scope,
|
43
43
|
so you can safely use SQL syntax in Rails controller parameters.
|
44
|
-
|
44
|
+
What it lacks in power, it gains in simplicity and ease of use for API consumers.
|
45
45
|
email:
|
46
46
|
- mack@agilereasoning.com
|
47
47
|
executables: []
|
@@ -51,16 +51,17 @@ files:
|
|
51
51
|
- "./CHANGELOG.md"
|
52
52
|
- "./LICENSE.txt"
|
53
53
|
- "./README.md"
|
54
|
-
- "./lib/
|
54
|
+
- "./lib/whereable_clause.treetop"
|
55
55
|
- lib/whereable.rb
|
56
56
|
- lib/whereable/version.rb
|
57
|
+
- lib/whereable_clause.rb
|
57
58
|
homepage: https://github.com/MacksMind/whereable
|
58
59
|
licenses:
|
59
60
|
- MIT
|
60
61
|
metadata:
|
61
62
|
homepage_uri: https://github.com/MacksMind/whereable
|
62
63
|
source_code_uri: https://github.com/MacksMind/whereable
|
63
|
-
changelog_uri: https://github.com/MacksMind/whereable/blob/
|
64
|
+
changelog_uri: https://github.com/MacksMind/whereable/blob/main/CHANGELOG.md
|
64
65
|
post_install_message:
|
65
66
|
rdoc_options: []
|
66
67
|
require_paths:
|
data/lib/whereable.treetop
DELETED
@@ -1,37 +0,0 @@
|
|
1
|
-
grammar Whereable
|
2
|
-
rule expression
|
3
|
-
disjunction
|
4
|
-
end
|
5
|
-
|
6
|
-
rule disjunction
|
7
|
-
conjunction opt:( _? 'or'i _? conjunction )* <Disjunction>
|
8
|
-
end
|
9
|
-
|
10
|
-
rule conjunction
|
11
|
-
term opt:( _? 'and'i _? term )* <Conjunction>
|
12
|
-
end
|
13
|
-
|
14
|
-
rule term
|
15
|
-
condition / '(' _? expression _? ')' <NestedExpression>
|
16
|
-
end
|
17
|
-
|
18
|
-
rule condition
|
19
|
-
column _? operator _? literal <Condition>
|
20
|
-
end
|
21
|
-
|
22
|
-
rule _
|
23
|
-
[\s]+
|
24
|
-
end
|
25
|
-
|
26
|
-
rule column
|
27
|
-
[a-zA-Z0-9_]+ <Column>
|
28
|
-
end
|
29
|
-
|
30
|
-
rule operator
|
31
|
-
( 'eq'i / 'ne'i / 'gte'i / 'gt'i / 'lte'i / 'lt'i / '=' / '!=' / '<>' / '>=' / '>' / '<=' / '<' ) <Operator>
|
32
|
-
end
|
33
|
-
|
34
|
-
rule literal
|
35
|
-
( '"' ( '\"' / [^"] )+ '"' / "'" ( "''" / "\\'" / [^'] )+ "'" / [^\s)]+ ) <Literal>
|
36
|
-
end
|
37
|
-
end
|