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.
@@ -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
@@ -1,3 +1,3 @@
1
- module Philtre
2
- VERSION = "0.0.0"
1
+ module Philtre #:nodoc:
2
+ VERSION = "0.0.1"
3
3
  end