advanced_search 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +16 -11
- data/README.md +88 -84
- data/advanced_search.gemspec +5 -3
- data/bin/db/dump.sh +5 -0
- data/bin/rake +3 -0
- data/bin/test.sh +5 -0
- data/lib/advanced_search.rb +9 -4
- data/lib/advanced_search/adapters/mysql2/executor.rb +9 -0
- data/lib/advanced_search/adapters/mysql2/query.rb +9 -0
- data/lib/advanced_search/adapters/mysql2/visitor.rb +9 -0
- data/lib/advanced_search/adapters/pg/executor.rb +24 -0
- data/lib/advanced_search/adapters/pg/query.rb +14 -0
- data/lib/advanced_search/adapters/pg/visitor.rb +75 -0
- data/lib/advanced_search/ast/and.rb +11 -0
- data/lib/advanced_search/ast/base.rb +24 -0
- data/lib/advanced_search/ast/eq.rb +11 -0
- data/lib/advanced_search/ast/id.rb +18 -0
- data/lib/advanced_search/ast/lt.rb +11 -0
- data/lib/advanced_search/ast/or.rb +11 -0
- data/lib/advanced_search/ast/value.rb +17 -0
- data/lib/advanced_search/error.rb +6 -0
- data/lib/advanced_search/sexp/invalid_type.rb +34 -0
- data/lib/advanced_search/sexp/node_builder.rb +59 -0
- data/lib/advanced_search/sexp/s.rb +44 -0
- data/lib/advanced_search/version.rb +5 -1
- metadata +51 -7
- data/Gemfile.lock +0 -35
- data/Rakefile +0 -6
- data/bin/setup +0 -8
- data/lib/advanced_search/junctive.rb +0 -218
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1a8f4bd11b385b3ca93fe87eec974628aa130f52303de760c46b371ff4c1bf27
|
4
|
+
data.tar.gz: 5ff6debc2d699f2f1b0b5fdab32ce3c3e91967abcad46a221b412d9eb9a0e135
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d8cfe5debf530fd434ad5a062e9559479789a847775decb4a2ac80eb4ef06a0bd1517d38e72b11ea6142b2538115063ed0dca415c1c8ccd00f59b901fbd1aa41
|
7
|
+
data.tar.gz: 9c6d1331ef90268f8fbe319488283c9ab5cc3de1a7306583230eb46dd11065065b9826aaf383d7ba42071e725cec69792662c9d2214d683db5bc0b26afe19418
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
@@ -5,17 +5,22 @@ recommendations of [keepachangelog.com](http://keepachangelog.com/).
|
|
5
5
|
|
6
6
|
## Unreleased
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
-
|
8
|
+
- Breaking Changes
|
9
|
+
- None
|
10
|
+
- Added
|
11
|
+
- None
|
12
|
+
- Fixed
|
13
|
+
- None
|
14
|
+
|
15
|
+
## 0.0.2 (2018-10-30)
|
16
|
+
|
17
|
+
- Breaking Changes
|
18
|
+
- Instead of an ActiveRecord adapter, just provide (and test) some examples
|
19
|
+
of using `find_by_sql`
|
20
|
+
- Added
|
21
|
+
- None
|
22
|
+
- Fixed
|
23
|
+
- None
|
19
24
|
|
20
25
|
## 0.0.1 (2018-10-03)
|
21
26
|
|
data/README.md
CHANGED
@@ -1,94 +1,98 @@
|
|
1
1
|
# AdvancedSearch
|
2
2
|
|
3
|
-
|
4
|
-
|
3
|
+
Builds database search queries with complex, nested logical expressions.
|
4
|
+
|
5
|
+
You provide an [abstract syntax tree][1] representing the search, and
|
6
|
+
`AdvancedSearch` builds the search clause of the query (eg. the `where`
|
7
|
+
clause, in SQL).
|
8
|
+
|
9
|
+
| *Good for* | *Not Good For* |
|
10
|
+
| ---------------- | -------------- |
|
11
|
+
| searches | reports |
|
12
|
+
| complex logic expressions | simple "conjunction of all parameters" forms |
|
13
|
+
| natural language | 99% of business search forms |
|
14
|
+
|
15
|
+
Input can be natural language,
|
16
|
+
|
17
|
+
```
|
18
|
+
'age less than 40 and ssn equal to 123-45-6789'
|
19
|
+
```
|
20
|
+
|
21
|
+
or a typical HTML form,
|
22
|
+
|
23
|
+
```
|
24
|
+
{
|
25
|
+
age_lt: 40,
|
26
|
+
ssn_eq: '123-45-6789'
|
27
|
+
}
|
28
|
+
```
|
29
|
+
|
30
|
+
You organize and name your parameters however you want. You convert those
|
31
|
+
parameters into an `AdvancedSearch::AST`:
|
32
|
+
|
33
|
+
```
|
34
|
+
and
|
35
|
+
/ \
|
36
|
+
lt eq
|
37
|
+
/ \ / \
|
38
|
+
age 40 ssn '123-45-6789'
|
39
|
+
```
|
40
|
+
|
41
|
+
A convenient S-expression syntax is provided to help you build your tree:
|
42
|
+
|
43
|
+
```
|
44
|
+
s(:and,
|
45
|
+
s(:lt, s(:id, :age), s(:value, 40),
|
46
|
+
s(:eq, s(:id, :ssn), s(:value, '123-45-6789')
|
47
|
+
)
|
48
|
+
```
|
49
|
+
|
50
|
+
`AdvancedSearch` converts your tree into a parameterized search clause (eg. the
|
51
|
+
`where` clause, in SQL).
|
52
|
+
|
53
|
+
```
|
54
|
+
'where age < $1 and ssn = $2'
|
55
|
+
[40, '123-45-6789']
|
56
|
+
```
|
57
|
+
|
58
|
+
Finally, you do whatever you want with that search clause. Prepare a complete
|
59
|
+
query and execute it using `ActiveRecord`'s [find_by_sql][2] method, perhaps?
|
60
|
+
Maybe execute it directly using a gem like `pg` or `mysql2`? What you do with
|
61
|
+
your query, in the privacy of your own home, is your own business.
|
62
|
+
|
63
|
+
## Complete Example
|
64
|
+
|
65
|
+
There is a complete example using `ActiveRecord`'s [find_by_sql][2] method in
|
66
|
+
the test suite. See:
|
67
|
+
|
68
|
+
```
|
69
|
+
spec/support/activerecord/models
|
70
|
+
spec/support/activerecord/searches/library_catalog.rb
|
71
|
+
spec/activerecord/searches/library_catalog_spec.rb
|
72
|
+
```
|
73
|
+
|
74
|
+
## Adapters
|
75
|
+
|
76
|
+
| *adapter* | *gem* | *status* |
|
77
|
+
| -------------- | -------------- | ------------------- |
|
78
|
+
| `PG` | `pg` | Proof of concept |
|
79
|
+
| `Mysql2` | `mysql2` | Not yet implemented |
|
80
|
+
| `ActiveRecord` | `activerecord` | If only ARel had docs |
|
81
|
+
|
82
|
+
## Design Goals
|
5
83
|
|
6
84
|
- **Simple, not easy.** You'll have to write substantial code to get started,
|
7
85
|
but future changes will be simple.
|
8
86
|
- Requires basic understanding of graph theory, must know what a tree is.
|
9
|
-
- **Agnostic**: Support for specific databases and ORMs is provided via
|
87
|
+
- **Agnostic**: Support for specific databases and ORMs is provided via *adapters*.
|
10
88
|
- **No dependencies**
|
11
89
|
- Unit tests of your search objects do not need to touch database, so they
|
12
90
|
are very fast.
|
13
91
|
|
14
|
-
##
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
class PhonebookSearch
|
22
|
-
def initialize
|
23
|
-
# We will be building a tree, and the head node is an N-ary
|
24
|
-
# conjunction. Conjunction is the most common type of search. Disjunction
|
25
|
-
# is also common.
|
26
|
-
@head = ::AdvancedSearch::Nodes::And.new
|
27
|
-
|
28
|
-
# Later, we'll traverse that tree using a visitor that knows how to build
|
29
|
-
# a query for use with the mysql2 gem. Many other visitors are available.
|
30
|
-
# If you switch databases in the future, you only change the visitor, you
|
31
|
-
# don't change anything else in this class.
|
32
|
-
@visitor = ::AdvancedSearch::Visitors::Mysql2.new
|
33
|
-
end
|
34
|
-
|
35
|
-
# Example params:
|
36
|
-
#
|
37
|
-
# {
|
38
|
-
# age_lt: 40,
|
39
|
-
# ssn_eq: '123-45-6789'
|
40
|
-
# }
|
41
|
-
#
|
42
|
-
# You can organize your parameters, and name them, however you want. A Hash
|
43
|
-
# is common.
|
44
|
-
def search(params)
|
45
|
-
build_tree(params)
|
46
|
-
build_query
|
47
|
-
end
|
48
|
-
|
49
|
-
private
|
50
|
-
|
51
|
-
# Build a tree by dynamically `send`ing the parameter name. Given the params
|
52
|
-
# above, our tree will look like this:
|
53
|
-
#
|
54
|
-
# and
|
55
|
-
# / \
|
56
|
-
# lt eq
|
57
|
-
# / \ / \
|
58
|
-
# age 40 ssn '123-45-6789'
|
59
|
-
#
|
60
|
-
def build_tree(params)
|
61
|
-
params.each { |k, v| send(k, v) }
|
62
|
-
end
|
63
|
-
|
64
|
-
# You write one method like this for each filter on your search form. After
|
65
|
-
# this, our tree looks like:
|
66
|
-
#
|
67
|
-
# and
|
68
|
-
# |
|
69
|
-
# lt
|
70
|
-
# / \
|
71
|
-
# age 40
|
72
|
-
#
|
73
|
-
def age_lt(v)
|
74
|
-
eq = ::AdvancedSearch::Nodes::Lt.new
|
75
|
-
eq.add_edge(::AdvancedSearch::Nodes::Id.new(:age))
|
76
|
-
eq.add_edge(::AdvancedSearch::Nodes::Value.new(v))
|
77
|
-
@head.add_edge(eq)
|
78
|
-
end
|
79
|
-
|
80
|
-
def ssn_eq(v)
|
81
|
-
eq = ::AdvancedSearch::Nodes::Eq.new
|
82
|
-
eq.add_edge(::AdvancedSearch::Nodes::Id.new(:ssn))
|
83
|
-
eq.add_edge(::AdvancedSearch::Nodes::Value.new(v))
|
84
|
-
@head.add_edge(eq)
|
85
|
-
end
|
86
|
-
|
87
|
-
# Returns a query suitable for use with the mysql2 gem, because that's the
|
88
|
-
# visitor we selected above.
|
89
|
-
def build_query
|
90
|
-
@head.accept(@visitor)
|
91
|
-
@visitor.result
|
92
|
-
end
|
93
|
-
end
|
94
|
-
```
|
92
|
+
## Roadmap
|
93
|
+
|
94
|
+
- full-text search
|
95
|
+
- "raw" node?
|
96
|
+
|
97
|
+
[1]: https://en.wikipedia.org/wiki/Abstract_syntax_tree
|
98
|
+
[2]: https://api.rubyonrails.org/classes/ActiveRecord/Querying.html#method-i-find_by_sql
|
data/advanced_search.gemspec
CHANGED
@@ -2,9 +2,9 @@ lib = File.expand_path("../lib", __FILE__)
|
|
2
2
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
3
|
require "advanced_search/version"
|
4
4
|
|
5
|
-
Gem::Specification.new do |spec|
|
5
|
+
::Gem::Specification.new do |spec|
|
6
6
|
spec.name = "advanced_search"
|
7
|
-
spec.version
|
7
|
+
spec.version = ::AdvancedSearch.gem_version
|
8
8
|
spec.licenses = ['AGPL-3.0']
|
9
9
|
spec.authors = ["Jared Beck"]
|
10
10
|
spec.email = ["jared@jaredbeck.com"]
|
@@ -17,7 +17,9 @@ Gem::Specification.new do |spec|
|
|
17
17
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
18
18
|
spec.require_paths = ["lib"]
|
19
19
|
|
20
|
+
spec.add_development_dependency "activerecord", "~> 5.2"
|
20
21
|
spec.add_development_dependency "bundler", "1.15.2"
|
21
|
-
spec.add_development_dependency "
|
22
|
+
spec.add_development_dependency "byebug", "~> 10.0"
|
23
|
+
spec.add_development_dependency "pg", "~> 1.0"
|
22
24
|
spec.add_development_dependency "rspec", "~> 3.0"
|
23
25
|
end
|
data/bin/db/dump.sh
ADDED
data/bin/rake
ADDED
data/bin/test.sh
ADDED
data/lib/advanced_search.rb
CHANGED
@@ -1,6 +1,11 @@
|
|
1
1
|
require "advanced_search/version"
|
2
|
-
require "advanced_search/
|
2
|
+
require "advanced_search/ast/and"
|
3
|
+
require "advanced_search/ast/eq"
|
4
|
+
require "advanced_search/ast/id"
|
5
|
+
require "advanced_search/ast/lt"
|
6
|
+
require "advanced_search/ast/or"
|
7
|
+
require "advanced_search/ast/value"
|
8
|
+
require "advanced_search/sexp/s"
|
3
9
|
|
4
|
-
|
5
|
-
|
6
|
-
end
|
10
|
+
# We don't `require` adapters. They're going to get moved to separate
|
11
|
+
# gems so that library consumers can `require` only what they need.
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'pg'
|
2
|
+
require 'advanced_search/adapters/pg/visitor'
|
3
|
+
|
4
|
+
module AdvancedSearch
|
5
|
+
module Adapters
|
6
|
+
module PG
|
7
|
+
class Executor
|
8
|
+
def initialize(base_query, ast, connection)
|
9
|
+
@base_query = base_query
|
10
|
+
@ast = ast
|
11
|
+
@connection = connection
|
12
|
+
end
|
13
|
+
|
14
|
+
def execute
|
15
|
+
visitor = Visitor.new(:dollars)
|
16
|
+
@ast.accept(visitor)
|
17
|
+
query = visitor.result
|
18
|
+
sql = [@base_query, query.body].join(' where ')
|
19
|
+
@connection.exec_params(sql, query.params)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'advanced_search/adapters/pg/query'
|
2
|
+
|
3
|
+
module AdvancedSearch
|
4
|
+
module Adapters
|
5
|
+
module PG
|
6
|
+
class Visitor
|
7
|
+
def initialize(placeholder_style)
|
8
|
+
@placeholder_style = placeholder_style
|
9
|
+
@sql = []
|
10
|
+
@binds = []
|
11
|
+
end
|
12
|
+
|
13
|
+
def result
|
14
|
+
Query.new(@sql.join(' '), @binds)
|
15
|
+
end
|
16
|
+
|
17
|
+
def visit_and(node)
|
18
|
+
@sql << '('
|
19
|
+
node.edges.each_with_index do |child, i|
|
20
|
+
unless i.zero?
|
21
|
+
@sql << 'and'
|
22
|
+
end
|
23
|
+
child.accept(self)
|
24
|
+
end
|
25
|
+
@sql << ')'
|
26
|
+
end
|
27
|
+
|
28
|
+
def visit_eq(node)
|
29
|
+
node.edges[0].accept(self)
|
30
|
+
@sql << '='
|
31
|
+
node.edges[1].accept(self)
|
32
|
+
end
|
33
|
+
|
34
|
+
def visit_id(node)
|
35
|
+
@sql << node.id
|
36
|
+
end
|
37
|
+
|
38
|
+
def visit_lt(node)
|
39
|
+
node.edges[0].accept(self)
|
40
|
+
@sql << '<'
|
41
|
+
node.edges[1].accept(self)
|
42
|
+
end
|
43
|
+
|
44
|
+
def visit_or(node)
|
45
|
+
@sql << '('
|
46
|
+
node.edges.each_with_index do |child, i|
|
47
|
+
unless i.zero?
|
48
|
+
@sql << 'or'
|
49
|
+
end
|
50
|
+
child.accept(self)
|
51
|
+
end
|
52
|
+
@sql << ')'
|
53
|
+
end
|
54
|
+
|
55
|
+
def visit_value(node)
|
56
|
+
@sql << placeholder(@binds.length + 1)
|
57
|
+
@binds << node.value
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def placeholder(ordinal_number)
|
63
|
+
case @placeholder_style
|
64
|
+
when :question_marks
|
65
|
+
'?'
|
66
|
+
when :dollars
|
67
|
+
format('$%d', ordinal_number)
|
68
|
+
else
|
69
|
+
raise 'Invalid placeholder style'
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module AdvancedSearch
|
2
|
+
module AST
|
3
|
+
class Base
|
4
|
+
def initialize
|
5
|
+
@edges = []
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_reader :edges
|
9
|
+
|
10
|
+
def add_edge(other_node)
|
11
|
+
unless other_node.is_a?(Base)
|
12
|
+
raise(
|
13
|
+
TypeError,
|
14
|
+
format(
|
15
|
+
'Invalid AST edge. Expected AdvancedSearch::AST::Base, got %s',
|
16
|
+
other_node
|
17
|
+
)
|
18
|
+
)
|
19
|
+
end
|
20
|
+
@edges.push(other_node)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require "advanced_search/ast/base"
|
2
|
+
|
3
|
+
module AdvancedSearch
|
4
|
+
module AST
|
5
|
+
# Trusted. Do not construct an `Id` directly from user input.
|
6
|
+
class Id < Base
|
7
|
+
def initialize(id)
|
8
|
+
@id = id
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :id
|
12
|
+
|
13
|
+
def accept(visitor)
|
14
|
+
visitor.visit_id(self)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require "advanced_search/ast/base"
|
2
|
+
|
3
|
+
module AdvancedSearch
|
4
|
+
module AST
|
5
|
+
class Value < Base
|
6
|
+
def initialize(value)
|
7
|
+
@value = value
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_reader :value
|
11
|
+
|
12
|
+
def accept(visitor)
|
13
|
+
visitor.visit_value(self)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require "advanced_search/error"
|
2
|
+
|
3
|
+
module AdvancedSearch
|
4
|
+
module SExp
|
5
|
+
# A nicer presentation of a `NameError`.
|
6
|
+
#
|
7
|
+
# @api public
|
8
|
+
class InvalidType < ::AdvancedSearch::Error
|
9
|
+
# @api private
|
10
|
+
def initialize(type, name_error)
|
11
|
+
@type = type
|
12
|
+
@name_error = name_error
|
13
|
+
end
|
14
|
+
|
15
|
+
# @api public
|
16
|
+
# @return String
|
17
|
+
def message
|
18
|
+
format(
|
19
|
+
'Invalid S-expression type: %s (%s) Valid types are: %s',
|
20
|
+
@type,
|
21
|
+
@name_error.message,
|
22
|
+
valid_types.join(', ')
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# @api private
|
29
|
+
def valid_types
|
30
|
+
AST.constants.reject { |sym| sym == :Base }.map { |sym| sym.to_s.downcase }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require "advanced_search/sexp/invalid_type"
|
2
|
+
|
3
|
+
module AdvancedSearch
|
4
|
+
module SExp
|
5
|
+
# @api private
|
6
|
+
class NodeBuilder
|
7
|
+
# @api private
|
8
|
+
def initialize(type, *args)
|
9
|
+
@type = type
|
10
|
+
@args = args
|
11
|
+
end
|
12
|
+
|
13
|
+
# @api private
|
14
|
+
def build
|
15
|
+
constructor_args, child_nodes = split_args
|
16
|
+
# puts [@type, @args, constructor_arity, constructor_args, child_nodes].inspect
|
17
|
+
node = node_class.new(*constructor_args)
|
18
|
+
add_edges(node, child_nodes)
|
19
|
+
node
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
# @api private
|
25
|
+
def add_edges(node, children)
|
26
|
+
children.each do |i|
|
27
|
+
node.add_edge(i)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# @api private
|
32
|
+
def constructor_arity
|
33
|
+
node_class.allocate.method(:initialize).arity
|
34
|
+
end
|
35
|
+
|
36
|
+
# @api private
|
37
|
+
def node_class
|
38
|
+
@_node_class ||= ::Object.const_get(node_class_path)
|
39
|
+
rescue ::NameError => e
|
40
|
+
raise InvalidType.new(@type, e)
|
41
|
+
end
|
42
|
+
|
43
|
+
# @api private
|
44
|
+
# @return String - absolute constant path
|
45
|
+
def node_class_path
|
46
|
+
format('::AdvancedSearch::AST::%s', @type.to_s.capitalize)
|
47
|
+
end
|
48
|
+
|
49
|
+
# @api private
|
50
|
+
def split_args
|
51
|
+
c_arity = constructor_arity
|
52
|
+
[
|
53
|
+
@args[0, c_arity],
|
54
|
+
@args[c_arity..-1]
|
55
|
+
]
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require "advanced_search/sexp/node_builder"
|
2
|
+
|
3
|
+
module AdvancedSearch
|
4
|
+
module SExp
|
5
|
+
# S-expressions are a concise way to instantiate AST nodes.
|
6
|
+
#
|
7
|
+
# ```
|
8
|
+
# # This ..
|
9
|
+
# eq = ::AdvancedSearch::Nodes::Lt.new
|
10
|
+
# eq.add_edge(::AdvancedSearch::Nodes::Id.new(:age))
|
11
|
+
# eq.add_edge(::AdvancedSearch::Nodes::Value.new(40))
|
12
|
+
#
|
13
|
+
# # .. becomes
|
14
|
+
# include ::AdvancedSearch::SExp::S
|
15
|
+
# eq = s(:lt, s(:id, :age), s(:value, 40))
|
16
|
+
# ```
|
17
|
+
#
|
18
|
+
# You don't have to use S-expressions to use AdvancedSearch, but they are
|
19
|
+
# convenient.
|
20
|
+
#
|
21
|
+
# @api public
|
22
|
+
module S
|
23
|
+
# A concise way to instantiate AST nodes.
|
24
|
+
#
|
25
|
+
# ```
|
26
|
+
# include ::AdvancedSearch::SExp::S
|
27
|
+
# eq = s(:lt, s(:id, :age), s(:value, 40))
|
28
|
+
# ```
|
29
|
+
#
|
30
|
+
# As this method is an important part of the AdvancedSearch API, special
|
31
|
+
# care is taken to produce helpful error messages when given incorrect
|
32
|
+
# arguments.
|
33
|
+
#
|
34
|
+
# @api public
|
35
|
+
# @param type [String, Symbol] - The basename of a descendent of AST::Base.
|
36
|
+
# E.g. to instantiate an `AST:Eq`, use `'eq'` or `:eq`.
|
37
|
+
# @param *args - Arguments for the AST node constructor first. Subsequent
|
38
|
+
# arguments become child nodes.
|
39
|
+
def s(type, *args)
|
40
|
+
NodeBuilder.new(type, *args).build
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: advanced_search
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jared Beck
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-10-
|
11
|
+
date: 2018-10-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5.2'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '5.2'
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: bundler
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -25,7 +39,7 @@ dependencies:
|
|
25
39
|
- !ruby/object:Gem::Version
|
26
40
|
version: 1.15.2
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
42
|
+
name: byebug
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
30
44
|
requirements:
|
31
45
|
- - "~>"
|
@@ -38,6 +52,20 @@ dependencies:
|
|
38
52
|
- - "~>"
|
39
53
|
- !ruby/object:Gem::Version
|
40
54
|
version: '10.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pg
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.0'
|
41
69
|
- !ruby/object:Gem::Dependency
|
42
70
|
name: rspec
|
43
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -65,15 +93,31 @@ files:
|
|
65
93
|
- CHANGELOG.md
|
66
94
|
- CODE_OF_CONDUCT.md
|
67
95
|
- Gemfile
|
68
|
-
- Gemfile.lock
|
69
96
|
- LICENSE
|
70
97
|
- README.md
|
71
|
-
- Rakefile
|
72
98
|
- advanced_search.gemspec
|
73
99
|
- bin/console
|
74
|
-
- bin/
|
100
|
+
- bin/db/dump.sh
|
101
|
+
- bin/rake
|
102
|
+
- bin/test.sh
|
75
103
|
- lib/advanced_search.rb
|
76
|
-
- lib/advanced_search/
|
104
|
+
- lib/advanced_search/adapters/mysql2/executor.rb
|
105
|
+
- lib/advanced_search/adapters/mysql2/query.rb
|
106
|
+
- lib/advanced_search/adapters/mysql2/visitor.rb
|
107
|
+
- lib/advanced_search/adapters/pg/executor.rb
|
108
|
+
- lib/advanced_search/adapters/pg/query.rb
|
109
|
+
- lib/advanced_search/adapters/pg/visitor.rb
|
110
|
+
- lib/advanced_search/ast/and.rb
|
111
|
+
- lib/advanced_search/ast/base.rb
|
112
|
+
- lib/advanced_search/ast/eq.rb
|
113
|
+
- lib/advanced_search/ast/id.rb
|
114
|
+
- lib/advanced_search/ast/lt.rb
|
115
|
+
- lib/advanced_search/ast/or.rb
|
116
|
+
- lib/advanced_search/ast/value.rb
|
117
|
+
- lib/advanced_search/error.rb
|
118
|
+
- lib/advanced_search/sexp/invalid_type.rb
|
119
|
+
- lib/advanced_search/sexp/node_builder.rb
|
120
|
+
- lib/advanced_search/sexp/s.rb
|
77
121
|
- lib/advanced_search/version.rb
|
78
122
|
homepage: https://github.com/jaredbeck/advanced_search
|
79
123
|
licenses:
|
data/Gemfile.lock
DELETED
@@ -1,35 +0,0 @@
|
|
1
|
-
PATH
|
2
|
-
remote: .
|
3
|
-
specs:
|
4
|
-
advanced_search (0.0.1)
|
5
|
-
|
6
|
-
GEM
|
7
|
-
remote: https://rubygems.org/
|
8
|
-
specs:
|
9
|
-
diff-lcs (1.3)
|
10
|
-
rake (10.5.0)
|
11
|
-
rspec (3.8.0)
|
12
|
-
rspec-core (~> 3.8.0)
|
13
|
-
rspec-expectations (~> 3.8.0)
|
14
|
-
rspec-mocks (~> 3.8.0)
|
15
|
-
rspec-core (3.8.0)
|
16
|
-
rspec-support (~> 3.8.0)
|
17
|
-
rspec-expectations (3.8.1)
|
18
|
-
diff-lcs (>= 1.2.0, < 2.0)
|
19
|
-
rspec-support (~> 3.8.0)
|
20
|
-
rspec-mocks (3.8.0)
|
21
|
-
diff-lcs (>= 1.2.0, < 2.0)
|
22
|
-
rspec-support (~> 3.8.0)
|
23
|
-
rspec-support (3.8.0)
|
24
|
-
|
25
|
-
PLATFORMS
|
26
|
-
ruby
|
27
|
-
|
28
|
-
DEPENDENCIES
|
29
|
-
advanced_search!
|
30
|
-
bundler (= 1.15.2)
|
31
|
-
rake (~> 10.0)
|
32
|
-
rspec (~> 3.0)
|
33
|
-
|
34
|
-
BUNDLED WITH
|
35
|
-
1.15.2
|
data/Rakefile
DELETED
data/bin/setup
DELETED
@@ -1,218 +0,0 @@
|
|
1
|
-
# bin/console
|
2
|
-
# irb(main):001:0> AdvancedSearch::Junctive::Client.new(:or).perform(age_lt: 40, ssn_eq: '123-45-6789')
|
3
|
-
# InsecureSql -> age < 40 or ssn = 123-45-6789
|
4
|
-
# BoundSql -> ["age < $1 or ssn = $2", [40, "123-45-6789"]]
|
5
|
-
# BananaSql -> 🍌 < 🍌 or 🍌 = 🍌
|
6
|
-
# => nil
|
7
|
-
|
8
|
-
module AdvancedSearch
|
9
|
-
module Junctive
|
10
|
-
module Nodes
|
11
|
-
class Base
|
12
|
-
def initialize
|
13
|
-
@edges = []
|
14
|
-
end
|
15
|
-
|
16
|
-
def add_edge(other_node)
|
17
|
-
@edges.push(other_node)
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
class And < Base
|
22
|
-
def accept(visitor)
|
23
|
-
@edges.each_with_index do |node, i|
|
24
|
-
unless i.zero?
|
25
|
-
visitor.visit_and
|
26
|
-
end
|
27
|
-
node.accept(visitor)
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
class Eq < Base
|
33
|
-
def accept(visitor)
|
34
|
-
@edges[0].accept(visitor)
|
35
|
-
visitor.visit_eq
|
36
|
-
@edges[1].accept(visitor)
|
37
|
-
end
|
38
|
-
end
|
39
|
-
|
40
|
-
class Id < Base
|
41
|
-
def initialize(id)
|
42
|
-
@id = id
|
43
|
-
end
|
44
|
-
|
45
|
-
attr_reader :id
|
46
|
-
|
47
|
-
def accept(visitor)
|
48
|
-
visitor.visit_id(self)
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
class Lt < Base
|
53
|
-
def accept(visitor)
|
54
|
-
@edges[0].accept(visitor)
|
55
|
-
visitor.visit_lt
|
56
|
-
@edges[1].accept(visitor)
|
57
|
-
end
|
58
|
-
end
|
59
|
-
|
60
|
-
class Or < Base
|
61
|
-
def accept(visitor)
|
62
|
-
@edges.each_with_index do |node, i|
|
63
|
-
unless i.zero?
|
64
|
-
visitor.visit_or
|
65
|
-
end
|
66
|
-
node.accept(visitor)
|
67
|
-
end
|
68
|
-
end
|
69
|
-
end
|
70
|
-
|
71
|
-
class Value < Base
|
72
|
-
def initialize(value)
|
73
|
-
@value = value
|
74
|
-
end
|
75
|
-
|
76
|
-
attr_reader :value
|
77
|
-
|
78
|
-
def accept(visitor)
|
79
|
-
visitor.visit_value(self)
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
module Visitors
|
85
|
-
class Sql
|
86
|
-
def initialize
|
87
|
-
@sql = []
|
88
|
-
end
|
89
|
-
|
90
|
-
def visit_and
|
91
|
-
@sql << 'and'
|
92
|
-
end
|
93
|
-
|
94
|
-
def visit_eq
|
95
|
-
@sql << '='
|
96
|
-
end
|
97
|
-
|
98
|
-
def visit_lt
|
99
|
-
@sql << '<'
|
100
|
-
end
|
101
|
-
|
102
|
-
def visit_or
|
103
|
-
@sql << 'or'
|
104
|
-
end
|
105
|
-
end
|
106
|
-
|
107
|
-
class BoundSql < Sql
|
108
|
-
def initialize
|
109
|
-
super
|
110
|
-
@binds = []
|
111
|
-
end
|
112
|
-
|
113
|
-
def result
|
114
|
-
[@sql.join(' '), @binds]
|
115
|
-
end
|
116
|
-
|
117
|
-
def visit_id(node)
|
118
|
-
@sql << node.id
|
119
|
-
end
|
120
|
-
|
121
|
-
def visit_value(node)
|
122
|
-
@sql << format('$%d', @binds.length + 1)
|
123
|
-
@binds << node.value
|
124
|
-
end
|
125
|
-
end
|
126
|
-
|
127
|
-
# Vulnerable to SQL-injection
|
128
|
-
class InsecureSql < Sql
|
129
|
-
def initialize
|
130
|
-
@sql = []
|
131
|
-
end
|
132
|
-
|
133
|
-
def result
|
134
|
-
@sql.join(' ')
|
135
|
-
end
|
136
|
-
|
137
|
-
def visit_id(node)
|
138
|
-
@sql << node.id
|
139
|
-
end
|
140
|
-
|
141
|
-
def visit_value(node)
|
142
|
-
@sql << node.value
|
143
|
-
end
|
144
|
-
end
|
145
|
-
|
146
|
-
class BananaSql < Sql
|
147
|
-
def initialize
|
148
|
-
@sql = []
|
149
|
-
end
|
150
|
-
|
151
|
-
def result
|
152
|
-
@sql.join(' ')
|
153
|
-
end
|
154
|
-
|
155
|
-
def visit_id(_node)
|
156
|
-
@sql << '🍌'
|
157
|
-
end
|
158
|
-
|
159
|
-
def visit_value(_node)
|
160
|
-
@sql << '🍌'
|
161
|
-
end
|
162
|
-
end
|
163
|
-
end
|
164
|
-
|
165
|
-
class Client
|
166
|
-
# The head of the tree is either a conjunction or disjunction, depending
|
167
|
-
# on `operator`.
|
168
|
-
def initialize(operator)
|
169
|
-
@head = get_head(operator)
|
170
|
-
end
|
171
|
-
|
172
|
-
def perform(params)
|
173
|
-
# Build a graph (tree) by dynamically `send`ing the parameter name.
|
174
|
-
params.each { |k, v| send(k, v) }
|
175
|
-
|
176
|
-
# Now, we have a graph (tree) which we can iterate (DFS) using whichever
|
177
|
-
# visitor we feel like using.
|
178
|
-
traverse(Visitors::InsecureSql)
|
179
|
-
traverse(Visitors::BoundSql)
|
180
|
-
traverse(Visitors::BananaSql)
|
181
|
-
end
|
182
|
-
|
183
|
-
private
|
184
|
-
|
185
|
-
def age_lt(v)
|
186
|
-
eq = Nodes::Lt.new
|
187
|
-
eq.add_edge(Nodes::Id.new(:age))
|
188
|
-
eq.add_edge(Nodes::Value.new(v))
|
189
|
-
@head.add_edge(eq)
|
190
|
-
end
|
191
|
-
|
192
|
-
def get_head(operator)
|
193
|
-
case operator
|
194
|
-
when :and
|
195
|
-
Nodes::And.new
|
196
|
-
when :or
|
197
|
-
Nodes::Or.new
|
198
|
-
else
|
199
|
-
raise 'invalid operator'
|
200
|
-
end
|
201
|
-
end
|
202
|
-
|
203
|
-
def ssn_eq(v)
|
204
|
-
eq = Nodes::Eq.new
|
205
|
-
eq.add_edge(Nodes::Id.new(:ssn))
|
206
|
-
eq.add_edge(Nodes::Value.new(v))
|
207
|
-
@head.add_edge(eq)
|
208
|
-
end
|
209
|
-
|
210
|
-
def traverse(visitor_class)
|
211
|
-
visitor = visitor_class.new
|
212
|
-
@head.accept(visitor)
|
213
|
-
puts format('%20s -> %s', visitor_class.name.split('::').last, visitor.result)
|
214
|
-
nil
|
215
|
-
end
|
216
|
-
end
|
217
|
-
end
|
218
|
-
end
|