philtre 0.0.0 → 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +6 -5
- data/.travis.yml +7 -0
- data/README.md +169 -12
- data/Rakefile +8 -0
- data/TODO +0 -0
- data/lib/philtre.rb +59 -2
- data/lib/philtre/core_extensions.rb +31 -0
- data/lib/philtre/empty_expression.rb +9 -0
- data/lib/philtre/filter.rb +232 -0
- data/lib/philtre/grinder.rb +195 -0
- data/lib/philtre/place_holder.rb +41 -0
- data/lib/philtre/predicate_dsl.rb +25 -0
- data/lib/philtre/predicate_splitter.rb +40 -0
- data/lib/philtre/predicates.rb +109 -0
- data/lib/philtre/sequel_extensions.rb +30 -0
- data/lib/philtre/version.rb +2 -2
- data/philtre.gemspec +17 -10
- data/spec/dataset_spec.rb +57 -0
- data/spec/filter_spec.rb +502 -0
- data/spec/grinder_spec.rb +180 -0
- data/spec/predicate_splitter_spec.rb +54 -0
- data/tasks/console.rake +10 -0
- metadata +112 -8
@@ -0,0 +1,195 @@
|
|
1
|
+
require 'ripar'
|
2
|
+
|
3
|
+
require 'philtre/filter.rb'
|
4
|
+
require 'philtre/place_holder.rb'
|
5
|
+
require 'philtre/empty_expression.rb'
|
6
|
+
|
7
|
+
# Using the expressions in the filter, transform a dataset with
|
8
|
+
# placeholders into a real dataset with expressions, for example:
|
9
|
+
#
|
10
|
+
# ds = Personage.filter( :brief.lieu, :title.lieu ).order( :age.lieu )
|
11
|
+
# g = Grinder.new( Philtre.new(title: 'Grand High Poobah', :order => :age.desc ) )
|
12
|
+
# nds = g.transform( ds )
|
13
|
+
# nds.sql
|
14
|
+
#
|
15
|
+
# => SELECT * FROM "personages" WHERE (("title" = 'Grand High Poobah'))
|
16
|
+
#
|
17
|
+
# In a sense, this is a means to defining SQL functions with
|
18
|
+
# optional keyword arguments.
|
19
|
+
class Philtre::Grinder < Sequel::ASTTransformer
|
20
|
+
# filter must respond to expr_for( key, sql_field = nil ), expr_hash and order_hash
|
21
|
+
def initialize( filter = Philtre::Filter.new )
|
22
|
+
@filter = filter
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_reader :filter
|
26
|
+
|
27
|
+
# pass in a dataset containing PlaceHolder expressions.
|
28
|
+
# you'll get back a modified dataset with the filter values
|
29
|
+
# filled in.
|
30
|
+
def transform( dataset, apply_unknown: true )
|
31
|
+
@unknown = []
|
32
|
+
@places = {}
|
33
|
+
@subsets = []
|
34
|
+
|
35
|
+
# handy for debugging
|
36
|
+
@original_dataset = dataset
|
37
|
+
|
38
|
+
# the transformed dataset with placeholders that
|
39
|
+
# exist in filter replaced. unknown might have values
|
40
|
+
# after this.
|
41
|
+
t_dataset = super(dataset)
|
42
|
+
unknown_placeholders
|
43
|
+
|
44
|
+
if unknown.any?
|
45
|
+
if apply_unknown
|
46
|
+
# now filter by whatever predicates are left over
|
47
|
+
# ie those not in the incoming dataset. Leftover
|
48
|
+
# order parameters will overwrite existing ones
|
49
|
+
# that are not protected by an outer select.
|
50
|
+
filter.subset( *unknown ).apply t_dataset
|
51
|
+
else
|
52
|
+
raise "unknown values #{unknown.inspect} for\n#{dataset.sql}"
|
53
|
+
end
|
54
|
+
else
|
55
|
+
t_dataset
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
alias [] transform
|
60
|
+
|
61
|
+
# Grouped hash of place holders in the original dataset from the last transform.
|
62
|
+
# Only has values after transform has been called.
|
63
|
+
def places
|
64
|
+
@places || raise("Call transform to find place holders.")
|
65
|
+
end
|
66
|
+
|
67
|
+
# collection of values in the filter that were not found as placeholders
|
68
|
+
# in the original dataset.
|
69
|
+
def unknown
|
70
|
+
@unknown || raise("Call transform to find placeholders not provided by the filter.")
|
71
|
+
end
|
72
|
+
|
73
|
+
protected
|
74
|
+
|
75
|
+
# Set of all keys that are not in the placeholders
|
76
|
+
def extra_keys( keys )
|
77
|
+
incoming = keys
|
78
|
+
existing = places.flat_map{|subset, placeholders| placeholders.keys}
|
79
|
+
|
80
|
+
# find the elements in incoming that are not in existing
|
81
|
+
incoming - existing & incoming
|
82
|
+
end
|
83
|
+
|
84
|
+
def unknown_placeholders
|
85
|
+
unknown.concat extra_keys( filter.expr_hash.keys )
|
86
|
+
unknown.concat extra_keys( filter.order_hash.keys )
|
87
|
+
end
|
88
|
+
|
89
|
+
def context_places
|
90
|
+
@places ||= {}
|
91
|
+
@places[subset] ||= {}
|
92
|
+
end
|
93
|
+
|
94
|
+
# TODO rename subset to clause
|
95
|
+
def subset
|
96
|
+
@subsets.last || :none
|
97
|
+
end
|
98
|
+
|
99
|
+
def subset_stack
|
100
|
+
@subsets ||= []
|
101
|
+
end
|
102
|
+
|
103
|
+
def push_subset( latest_subset, &block )
|
104
|
+
subset_stack.push latest_subset
|
105
|
+
rv = yield
|
106
|
+
subset_stack.pop
|
107
|
+
rv
|
108
|
+
end
|
109
|
+
|
110
|
+
# Override the ASTTransformer method, which is where the work
|
111
|
+
# is done to transform the dataset containing placeholders into
|
112
|
+
# a dataset containing a proper SQL statement.
|
113
|
+
# Yes, this is in fact every OO purist's worst nightware - a Giant Switch Statement.
|
114
|
+
def v( obj )
|
115
|
+
case obj
|
116
|
+
when Sequel::Dataset
|
117
|
+
# transform empty expressions to false (or nil, but false is more debuggable)
|
118
|
+
# can't use nil for all kinds of expressions because nil mean NULL for
|
119
|
+
# most of the Sequel::SQL expressions.
|
120
|
+
obj.clone Hash[ v(obj.opts).map{|k,val| [k, val.is_a?(Philtre::EmptyExpression) ? false : val]} ]
|
121
|
+
|
122
|
+
# for Sequel::Models
|
123
|
+
when ->(obj){obj.respond_to? :dataset}
|
124
|
+
v obj.dataset
|
125
|
+
|
126
|
+
# for other things that are convertible to dataset
|
127
|
+
when ->(obj){obj.respond_to? :to_dataset}
|
128
|
+
v obj.to_dataset
|
129
|
+
|
130
|
+
# Keep the context for place holders,
|
131
|
+
# so we know what kind of expression to insert later.
|
132
|
+
# Each of :where, :order, :having, :select etc will come as a hash.
|
133
|
+
# There are some other top-level options too.
|
134
|
+
when Hash
|
135
|
+
rv = {}
|
136
|
+
obj.each do |key, val|
|
137
|
+
push_subset key do
|
138
|
+
rv[v(key)] = v(val)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
rv
|
142
|
+
|
143
|
+
when Philtre::PlaceHolder
|
144
|
+
# get the expression for the placeholder.
|
145
|
+
# use the placeholder's field name if given
|
146
|
+
expr =
|
147
|
+
case subset
|
148
|
+
# substitute a comparison or some other predicate
|
149
|
+
when :where, :having
|
150
|
+
filter.expr_for obj.name, obj.sql_field
|
151
|
+
|
152
|
+
# substitute an order by expression
|
153
|
+
when :order
|
154
|
+
filter.order_for obj.name
|
155
|
+
|
156
|
+
# Substitute the field name only if it has a value.
|
157
|
+
# nil when the name doesn't have a value. This way,
|
158
|
+
# select :some_field.lieu
|
159
|
+
# is left out when some_field does not have a value.
|
160
|
+
when :select
|
161
|
+
if filter[obj.name]
|
162
|
+
obj.sql_field || obj.name
|
163
|
+
end
|
164
|
+
|
165
|
+
else
|
166
|
+
raise "don't understand subset #{subset}"
|
167
|
+
end
|
168
|
+
|
169
|
+
# keep it, just in case
|
170
|
+
context_places[obj.name] = expr
|
171
|
+
|
172
|
+
# transform
|
173
|
+
expr || Philtre::EmptyExpression.new
|
174
|
+
|
175
|
+
when Array
|
176
|
+
# sometimes things are already an empty array, in which
|
177
|
+
# case just leave them alone.
|
178
|
+
return super if obj.empty?
|
179
|
+
|
180
|
+
# collect expressions, some may be empty.
|
181
|
+
exprs = super.reject{|e| e.is_a? Philtre::EmptyExpression}
|
182
|
+
|
183
|
+
# an empty array of expressions must be translated
|
184
|
+
# to an empty expression at this point.
|
185
|
+
exprs.empty? ? Philtre::EmptyExpression.new : exprs
|
186
|
+
|
187
|
+
when Sequel::SQL::ComplexExpression
|
188
|
+
# use the Array case above, otherwise copy the expression itself
|
189
|
+
v( obj.args ).empty? ? Philtre::EmptyExpression.new : super
|
190
|
+
|
191
|
+
else
|
192
|
+
super
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Philtre
|
2
|
+
class PlaceHolder < Sequel::SQL::Expression
|
3
|
+
# name is what gets replaced by the operation and correspondingly named value in the filter
|
4
|
+
# sql_field is the name of the field that the operation will compare the named value to.
|
5
|
+
def initialize( name, sql_field = nil, bt = caller )
|
6
|
+
# backtrace
|
7
|
+
@bt = bt
|
8
|
+
|
9
|
+
@name = name
|
10
|
+
@sql_field = sql_field
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :bt
|
14
|
+
attr_reader :name
|
15
|
+
attr_reader :sql_field
|
16
|
+
|
17
|
+
# this is inserted into the generated SQL from a dataset that
|
18
|
+
# contains PlaceHolder instances.
|
19
|
+
def to_s_append( ds, s )
|
20
|
+
s << '$' << name.to_s
|
21
|
+
s << ':' << sql_field.to_s if sql_field
|
22
|
+
s << '/*' << small_source << '*/'
|
23
|
+
end
|
24
|
+
|
25
|
+
def source
|
26
|
+
bt[1]
|
27
|
+
end
|
28
|
+
|
29
|
+
def small_source
|
30
|
+
source.split('/').last(2).join('/').split(':')[0..1].join(':')
|
31
|
+
end
|
32
|
+
|
33
|
+
def inspect
|
34
|
+
"#<#{self.class} #{name}:#{sql_field} @#{source}>"
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_s
|
38
|
+
"#{name}:#{sql_field} @#{small_source}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Philtre
|
2
|
+
# This is a specialised module that also understands a simple
|
3
|
+
# DSL for creating predicates as methods.
|
4
|
+
#
|
5
|
+
# This is how the DSL works:
|
6
|
+
# each meth is a predicate, args is a set of alternatives
|
7
|
+
# and the block must return something convertible to a Sequel.expr
|
8
|
+
# to create the expression for that predicate.
|
9
|
+
class PredicateDsl < Module
|
10
|
+
def initialize( &bloc )
|
11
|
+
if bloc
|
12
|
+
if bloc.arity == 0
|
13
|
+
module_eval &bloc
|
14
|
+
else
|
15
|
+
bloc.call self
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def method_missing(meth, *args, &bloc)
|
21
|
+
define_method meth, &bloc
|
22
|
+
args.each{|arg| send :alias_method, arg, meth }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'fastandand'
|
2
|
+
|
3
|
+
module Philtre
|
4
|
+
# This is to split things like birth_year_gt into
|
5
|
+
# - birth_year (the field)
|
6
|
+
# - gt (the predicate)
|
7
|
+
# Yes, there are side effects.
|
8
|
+
# === is provided so it can be used in case statements
|
9
|
+
# (which doesn't really work cos they're backwards).
|
10
|
+
class PredicateSplitter
|
11
|
+
def initialize( key, value )
|
12
|
+
@key, @value = key, value
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :key, :value
|
16
|
+
|
17
|
+
# split suffix from the key and store the two values as name and op
|
18
|
+
# return truthy if successful
|
19
|
+
def split_key( suffix )
|
20
|
+
rv = @key =~ /(.*?)_?(#{suffix})$/
|
21
|
+
@field, @op = $1, $2
|
22
|
+
rv
|
23
|
+
end
|
24
|
+
|
25
|
+
alias_method :===, :split_key
|
26
|
+
alias_method :=~, :split_key
|
27
|
+
|
28
|
+
# return name if the split was successful, or fall back to key
|
29
|
+
# which is handy when none of the predicates match and so key
|
30
|
+
# is probably just a field name.
|
31
|
+
def field
|
32
|
+
(@field || @key).andand.to_sym
|
33
|
+
end
|
34
|
+
|
35
|
+
# the operator, or predicate
|
36
|
+
def op
|
37
|
+
@op.andand.to_sym
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
module Philtre
|
2
|
+
# Container for methods which return Sequel::SQL::Expression or something
|
3
|
+
# that can become one through Sequel.expr, eg {year: 2013}
|
4
|
+
#
|
5
|
+
# Reminder: they're defined as methods so we can benefit from
|
6
|
+
# using them inside this class to define other predicates.
|
7
|
+
#
|
8
|
+
# This can be extended in all the usual ruby ways: by including a module,
|
9
|
+
# reopening the class. Also using extend_with which takes a PredicateDsl block.
|
10
|
+
class Predicates
|
11
|
+
def initialize( &bloc )
|
12
|
+
extend_with &bloc
|
13
|
+
end
|
14
|
+
|
15
|
+
# pass a block that can contain a combination of def meth_name() or the
|
16
|
+
# DSL defined by PredicateDsl.
|
17
|
+
def extend_with( &bloc )
|
18
|
+
extend PredicateDsl.new(&bloc)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Convert a field_predicate (field_op format), a value and a SQL field
|
22
|
+
# name using the set of predicates, to something convertible to a Sequel
|
23
|
+
# expression.
|
24
|
+
#
|
25
|
+
# field_predicate:: is a key from the parameter hash. Usually name_pred, eg birth_year_gt
|
26
|
+
# value:: is the value from the parameter hash. Could be a collection.
|
27
|
+
# field:: is the name of the SQL field to use, or nil where it would default to key without its predicate.
|
28
|
+
def define_name_predicate( field_predicate, value, field = nil )
|
29
|
+
splitter = PredicateSplitter.new field_predicate, value
|
30
|
+
|
31
|
+
# the default / else / fall-through is just an equality
|
32
|
+
default_proc = ->{:eq}
|
33
|
+
|
34
|
+
# find a better predicate, if there is one
|
35
|
+
suffix = predicate_names.find default_proc do |suffix|
|
36
|
+
splitter =~ suffix
|
37
|
+
end
|
38
|
+
|
39
|
+
# wrap the field name first, to infect the expressions so all the
|
40
|
+
# operators work.
|
41
|
+
field_name = Sequel.expr(field || splitter.field)
|
42
|
+
|
43
|
+
define_singleton_method field_predicate do |value|
|
44
|
+
send suffix, field_name, value
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
protected :define_name_predicate
|
49
|
+
|
50
|
+
# TODO this should probably also be method_missing?
|
51
|
+
# field is only used once for any given field_predicate
|
52
|
+
def call( field_predicate, value, field = nil )
|
53
|
+
unless respond_to? field_predicate
|
54
|
+
define_name_predicate field_predicate, value, field
|
55
|
+
end
|
56
|
+
send field_predicate, value
|
57
|
+
end
|
58
|
+
|
59
|
+
# The main interface from Filter#to_expr
|
60
|
+
alias [] call
|
61
|
+
|
62
|
+
def predicate_names
|
63
|
+
DefaultPredicates.instance_methods
|
64
|
+
end
|
65
|
+
|
66
|
+
# Define the set of default predicates.
|
67
|
+
DefaultPredicates = PredicateDsl.new do
|
68
|
+
# longer suffixes first, so they match first in define_name_predicate
|
69
|
+
not_eq {|expr, val| ~Sequel.expr( expr => val) }
|
70
|
+
|
71
|
+
def not_like( expr, val )
|
72
|
+
Sequel.~(like expr, val)
|
73
|
+
end
|
74
|
+
|
75
|
+
matches( :like ) {|expr, val| Sequel.expr( expr => /#{val}/i) }
|
76
|
+
|
77
|
+
def not_blank(expr, val)
|
78
|
+
Sequel.~(blank expr, val)
|
79
|
+
end
|
80
|
+
|
81
|
+
def blank(expr, _)
|
82
|
+
is_nil = Sequel.expr(expr => nil)
|
83
|
+
is_empty = Sequel.expr(expr => '')
|
84
|
+
Sequel.| is_nil, is_empty
|
85
|
+
end
|
86
|
+
|
87
|
+
# and now the shorter suffixes
|
88
|
+
eq {|expr, val| Sequel.expr( expr => val) }
|
89
|
+
gt {|expr, val| expr > val }
|
90
|
+
gte( :gteq ) {|expr, val| expr >= val }
|
91
|
+
lt {|expr, val| expr < val }
|
92
|
+
lte( :lteq ) {|expr, val| expr <= val }
|
93
|
+
|
94
|
+
# more complex predicates
|
95
|
+
def like_all( expr, arg )
|
96
|
+
exprs = Array(arg).map {|value| like expr, value }
|
97
|
+
Sequel.& *exprs
|
98
|
+
end
|
99
|
+
|
100
|
+
def like_any( expr, arg )
|
101
|
+
exprs = Array(arg).map {|value| like expr, value }
|
102
|
+
Sequel.| *exprs
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# make the available to Predicates instances.
|
107
|
+
include DefaultPredicates
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# TODO docs for this
|
2
|
+
class Sequel::Dataset
|
3
|
+
include Ripar
|
4
|
+
|
5
|
+
# make the roller understand dataset method
|
6
|
+
def roller
|
7
|
+
rv = super
|
8
|
+
class << rv
|
9
|
+
def to_dataset; riven end
|
10
|
+
end
|
11
|
+
rv
|
12
|
+
end
|
13
|
+
|
14
|
+
# roll the block and return the resulting dataset immediately
|
15
|
+
def rolled( &blk )
|
16
|
+
roller.rive &blk
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class Sequel::Dataset
|
21
|
+
# filter must respond_to expr_hash and order_hash
|
22
|
+
# will optionally yield a Grinder instance to the block
|
23
|
+
def grind( filter = Philtre::Filter.new, apply_unknown: true )
|
24
|
+
grinder = Philtre::Grinder.new filter
|
25
|
+
t_dataset = grinder.transform self, apply_unknown: apply_unknown
|
26
|
+
# only yield after the transform, so the grinder has the place holders
|
27
|
+
yield grinder if block_given?
|
28
|
+
t_dataset
|
29
|
+
end
|
30
|
+
end
|
data/lib/philtre/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
module Philtre
|
2
|
-
VERSION = "0.0.
|
1
|
+
module Philtre #:nodoc:
|
2
|
+
VERSION = "0.0.1"
|
3
3
|
end
|