advanced_search 0.0.1 → 0.0.2
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/.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
|