philtre 0.0.0 → 0.0.1
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 +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
|