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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 4e7ebc6f6d2d25c12725e30ad189728f27a843da
4
- data.tar.gz: 954a8ba030a5ed3c04e8e7f814ee242387c346d5
3
+ metadata.gz: 581e7addf0188f66a5a341f6dd205e3eb6b93a7e
4
+ data.tar.gz: 58a47bc162546a980faa4f17f5b28e75b0b86e9c
5
5
  SHA512:
6
- metadata.gz: 5fe7d82fceac966a6803e9568dda95fc56bafe63106d183e63444591a9ce3511739a7cd1ddb82820573690ca0079194223503875fa1a4154df54151fa9c1a662
7
- data.tar.gz: d54f471e8819fd0ae883182c703b88f509d6590ba06e6817e8030540df5e4b7fb7dc122cf22be1888ae4f4ee69d072b6d5ff6b92dcb77998309feed638c823e8
6
+ metadata.gz: 9a7cb668887750dfc955373d5cdc2a1f54ca06b56e9322217c29fbc3a7f4d0b598d142e7e25cfb91f80d8cb4e4642b3196b748d32a499c03edd4541bfcd2e41b
7
+ data.tar.gz: 86d1ef5d892b136cafbd8e0ac63ef45e799ee268fc93900e11c52409ac5e0cae5db221184915b6db70bba07125a60c39114ecf426467ef3cf9086ef930a1ebf8
data/.gitignore CHANGED
@@ -15,8 +15,9 @@ spec/reports
15
15
  test/tmp
16
16
  test/version_tmp
17
17
  tmp
18
- *.bundle
19
- *.so
20
- *.o
21
- *.a
22
- mkmf.log
18
+ .ruby-gemset
19
+ .ruby-version
20
+ *.sublime-project
21
+ *.sublime-workspace
22
+ .floo
23
+ .flooignore
@@ -0,0 +1,7 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.0.0
4
+ - 2.1.0
5
+ - 2.1.1
6
+ - 2.1.2
7
+ script: bundle exec rspec spec
data/README.md CHANGED
@@ -1,12 +1,9 @@
1
- # Philtre
1
+ # philtre [![Gem Version](https://badge.fury.io/rb/philtre.png)](http://badge.fury.io/rb/philtre)
2
2
 
3
- Coming Soon...
3
+ It's the [Sequel](http://sequel.jeremyevans.net) equivalent for Ransack, Metasearch, Searchlogic. If
4
+ this doesn't make you fall in love, I don't know what will :-p
4
5
 
5
- It's the Sequel equivalent for Ransack, Metasearch, Searchlogic.
6
- If this doesn't make you fall in love, I don't know what will.
7
-
8
- Yeah, I know. Corny naming. Filter => Philtre. But it lets you mix things up
9
- and something awesome comes out the other side.
6
+ See philtre-rails for rails integration.
10
7
 
11
8
  ## Installation
12
9
 
@@ -14,6 +11,10 @@ Add this line to your application's Gemfile:
14
11
 
15
12
  gem 'philtre'
16
13
 
14
+ Or for all the rails integration goodies
15
+
16
+ gem 'philtre-rails'
17
+
17
18
  And then execute:
18
19
 
19
20
  $ bundle
@@ -22,15 +23,171 @@ Or install it yourself as:
22
23
 
23
24
  $ gem install philtre
24
25
 
25
- ## Usage
26
+ ## Basic Usage
27
+
28
+ Parse the predicates on the end of field names, and modify a Sequel::Dataset
29
+ to retrieve matching rows.
30
+
31
+ So, using a fairly standard rails-style parameter hash:
32
+
33
+ ``` ruby
34
+ filter_parameters = {
35
+ birth_year: ['2012', '2011'],
36
+ title: 'bar',
37
+ order: ['title', 'name_asc', 'birth_year_desc'],
38
+ }
39
+
40
+ # This would normally be a real Sequel::Dataset
41
+ personages_dataset = Sequel.mock[:personages]
42
+
43
+ philtre = Philtre.new( filter_parameters ).apply( personages_dataset ).sql
44
+ ```
45
+
46
+ should result in (formatting added here for clarity)
47
+
48
+ ``` SQL
49
+ SELECT *
50
+ FROM "personages"
51
+ WHERE
52
+ (("birth_year" IN ('2012', '2011'))
53
+ AND
54
+ ("title" = 'bar'))
55
+ ORDER BY ("title" ASC, "name" ASC, "date" DESC)
56
+ ```
57
+
58
+ ## Predicates
59
+
60
+ ```{title: 'sir'}``` is fine when you want to match on string equality. But
61
+ there are all kinds of other things you need to do. For example
62
+
63
+ ```{title_like: 'sir', age_gt: 10}``` is for a where clause ```title ~* 'sir' and age > 10```
64
+
65
+ There are a range of predefined predicates, mostly borrowed from the other search gems:
66
+
67
+ ```
68
+ gt
69
+ gte, gteq
70
+ lt
71
+ lte, lteq
72
+ eq
73
+ not_eq
74
+ matches, like
75
+ not_blank
76
+ like_all
77
+ like_any
78
+ ```
79
+
80
+ ## Custom Predicates
81
+
82
+ There are two ways:
83
+
84
+ 1) You can also define your own by creating a Filter with a block:
85
+
86
+ ``` ruby
87
+ philtre = Philtre.new filter_parameters do
88
+ def tagged_by_id(tag_ids)
89
+ Tag.db[:projects_tags]
90
+ .select(:personage_id)
91
+ .filter(tag_id: tag_ids, :project_id => :personage__id )
92
+ .exists
93
+ end
94
+
95
+ def really_fancy(tag_ids)
96
+ # do some really fancy SQL here
97
+ end
98
+
99
+ # etc...
100
+ end
101
+ ```
102
+
103
+ Now you can pass the filter_parameter hash ```{tagged_by_id: 45}```.
104
+
105
+ The result of a predicate block should be a ```Sequel::SQL::Expression``` (ie
106
+ one of Sequel's hash expressions in the simplest case) which will work instead
107
+ of its named placeholder. That is, if the placeholder is inside a SELECT
108
+ clause it worked work to give in an ORDER BY.
109
+
110
+ 2) You could also inherit from ```Philtre::Filter``` and override
111
+ ```#predicates```. And optionally override ```Philtre.new``` (which is just a
112
+ factory method on ```module Philtre```) to return the instance of your class.
113
+
114
+ ## Advanced usage
115
+
116
+ There is also the ```Philtre::Grinder``` class which can insert placeholders into
117
+ your ```Sequel::Dataset``` definition, and then substitute those once it has the
118
+ parameter hash. Effectively this makes it a SQL macro engine.
119
+
120
+ Why so complicated? Well, it's really handy when you need to use aggregate
121
+ queries, and apply different values in the parameter hash to where clauses
122
+ inside and the outside of the aggregation. For example, give me a list of all
123
+ stores in a particular region who share of total sales was more than some
124
+ percentage. Yes, you can also use window functions to deal with that
125
+ particular query.
126
+
127
+ ``` ruby
128
+ # This would normally be a real Sequel::Dataset
129
+ stores_dataset = Sequel.mock[:stores]
130
+
131
+ # parameterise it with placeholders
132
+ parameterised_dataset = stores_dataset.filter( :region.lieu, :sales_gt.lieu, :manager.lieu )
133
+
134
+ filter_parameters = {
135
+ region: 'The Bundus',
136
+ sales_gt: 10,
137
+ order: ['store_name', 'sales_desc'],
138
+ }
139
+
140
+ # generate the SQL you need
141
+ parameterised_dataset.grind( Philtre.new( filter_parameters ) ).sql
142
+ ```
143
+
144
+ will result in
145
+
146
+ ``` SQL
147
+ SELECT *
148
+ FROM stores
149
+ WHERE ((region = 'The Bundus') AND (sales > 10))
150
+ ```
151
+
152
+ Notice that the manager part of the where clause is absent because
153
+ filter_parameters didn't have a manager key.
154
+
155
+ Look at the sql generated by parameterised_dataset and you'll see the placeholders
156
+ marked by SQL comments, so you can debug the Giant SQL Statement more easily. You
157
+ might also want to find a command-line SQL pretty printer (eg ``fsqlf```) and use it to produce
158
+ readable SQL instead of a very long hard-to-read string.
159
+
160
+ If you don't like the monkey-patching of Symbol with #lieu, you can use
161
+ several other ways to generate the placeholders. ```Philtre::PlaceHolder.new```
162
+ is canonical in that all the other possibilities use it.
163
+
164
+ ## Highly Advanced Usage
165
+
166
+ Sometimes method chaining gets ugly. So you can say
167
+
168
+ ``` ruby
169
+ store_id_range = 20..90
170
+ parameterised_dataset = stores_dataset.rolled do
171
+ where :region.lieu, :sales_gt.lieu, :manager.lieu
172
+ where store_id: store_id_range
173
+ select_append db[:products].join(:stores, :store_id => :id ).select(:product_name)
174
+ end
175
+ ```
176
+
177
+ Notice that values outside the block are accessible inside, _without_
178
+ the need for a block parameter. This uses Ripar under the cover and indirects
179
+ the binding lookup, so may result in errors that you won't expect.
180
+
181
+ ## Specs
182
+
183
+ Nothing fancy. Just:
26
184
 
27
- filter = Philtre.new( hash )
28
- dataset = filter.apply YourModel.dataset
185
+ $ rspec spec
29
186
 
30
187
  ## Contributing
31
188
 
32
- 1. Fork it ( https://github.com/djellemah/philtre/fork )
189
+ 1. Fork it ( http://github.com/djellemah/philtre/fork )
33
190
  2. Create your feature branch (`git checkout -b my-new-feature`)
34
191
  3. Commit your changes (`git commit -am 'Add some feature'`)
35
192
  4. Push to the branch (`git push origin my-new-feature`)
36
- 5. Create a new Pull Request
193
+ 5. Create new Pull Request
data/Rakefile CHANGED
@@ -1,2 +1,10 @@
1
1
  require "bundler/gem_tasks"
2
2
 
3
+ require 'pathname'
4
+
5
+ SELF_PATH = Pathname(__FILE__).parent
6
+
7
+ $: << SELF_PATH + 'lib'
8
+ load SELF_PATH + 'lib/philtre/version.rb'
9
+
10
+ load SELF_PATH + 'tasks/console.rake'
data/TODO ADDED
File without changes
@@ -1,5 +1,62 @@
1
- require "philtre/version"
1
+ require 'philtre/filter.rb'
2
+ require 'philtre/grinder.rb'
2
3
 
4
+ # The high-level interface to Philtre. There are several ways
5
+ # to use it:
6
+ # 1. Philtre.new
7
+ # philtre = Philtre.new name: 'Moustafa'
8
+ # 1. Philtre
9
+ # philtre = Philtre dataset: some_dataset, age_gt: 21
10
+ # philtre = Philtre dataset: some_dataset, with {age_gt: 21}
11
+ # 1. Philtre.filter
12
+ # philtre = Philtre.filter dataset: some_dataset, name: 'Moustafa', age_gt: 21
13
+ # philtre = Philtre.filter dataset: some_dataset, with: {name: 'Moustafa', age_gt: 21}
3
14
  module Philtre
4
- # Your code goes here...
15
+ # Just a factory method that calls Filter.new
16
+ #
17
+ # philtre = Philtre.new params[:filter]
18
+ def self.new( *filter_parameters, &blk )
19
+ Filter.new *filter_parameters, &blk
20
+ end
21
+
22
+ # This is the high-level, easy-to-read smalltalk-style interface
23
+ # params:
24
+ # - dataset is a Sequel::Model or a Sequel::Dataset
25
+ # - with is the param hash (optional, or just use hash-style args)
26
+ #
27
+ # for x-ample, in rails you could do
28
+ #
29
+ # @personages = Philtre.filter dataset: Personage, with: params[:filter]
30
+ #
31
+ # or even
32
+ #
33
+ # @personages = Philtre.filter dataset: Personage, name: 'Dylan', age_gt: 21, age_lt: 67
34
+ #
35
+ def self.filter( dataset: nil, with: {}, **kwargs )
36
+ new(with.merge kwargs).apply(dataset)
37
+ end
38
+
39
+ # Create a grinder with the parameters, and
40
+ # use it on the dataset. Return the result.
41
+ #
42
+ # dataset should have placeholders, otherwise calling this
43
+ # method just warms your cpu.
44
+ def self.grind( dataset: nil, with: {}, **kwargs )
45
+ filter = new(with.merge kwargs)
46
+ Philtre::Grinder.new(filter).transform(dataset)
47
+ end
48
+ end
49
+
50
+ require 'philtre/core_extensions.rb'
51
+
52
+ # And this is the even higher-level smalltalk-style interface
53
+ #
54
+ # Philtre dataset: Personage, with: params[:filter]
55
+ module Kernel
56
+ private
57
+ def Philtre( dataset: nil, with: {}, **kwargs )
58
+ Philtre.filter dataset: dataset, with: with, **kwargs
59
+ end
60
+
61
+ alias philtre Philtre
5
62
  end
@@ -0,0 +1,31 @@
1
+ # several ways to create placeholders in Sequel statements
2
+ module Kernel
3
+ private
4
+ def PlaceHolder( name, sql_field = nil, bt = caller )
5
+ Philtre::PlaceHolder.new name, sql_field, bt = caller
6
+ end
7
+
8
+ alias_method :Lieu, :PlaceHolder
9
+ end
10
+
11
+ class Symbol
12
+ def lieu( sql_field = nil )
13
+ Lieu self, sql_field, caller
14
+ end
15
+
16
+ def place_holder( sql_field = nil )
17
+ PlaceHolder self, sql_field, caller
18
+ end
19
+ end
20
+
21
+ unless Hash.instance_methods.include? :slice
22
+ class Hash
23
+ # return a hash containing only the specified keys
24
+ def slice( *other_keys )
25
+ other_keys.inject(Hash.new) do |hash, key|
26
+ hash[key] = self[key] if has_key?( key )
27
+ hash
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,9 @@
1
+ module Philtre
2
+ # used when transforming to unaltered or partially
3
+ # altered datasets
4
+ class EmptyExpression < Sequel::SQL::Expression
5
+ # sometimes this is returned in place of an empty array
6
+ def empty?; true; end
7
+ def to_s_append( ds, s ); end
8
+ end
9
+ end
@@ -0,0 +1,232 @@
1
+ require 'sequel'
2
+
3
+ Sequel.extension :blank
4
+
5
+ require 'philtre/predicate_splitter'
6
+ require 'philtre/predicate_dsl'
7
+ require 'philtre/predicates'
8
+
9
+ module Philtre
10
+ # Parse the predicates on the end of field names, and round-trip the search fields
11
+ # between incoming params, controller and views.
12
+ # So,
13
+ #
14
+ # filter_parameters = {
15
+ # birth_year: ['2012', '2011'],
16
+ # title_like: 'sir',
17
+ # order: ['title', 'name_asc', 'birth_year_desc'],
18
+ # }
19
+ #
20
+ # Philtre.new( filter_parameters ).apply( Personage.dataset ).sql
21
+ #
22
+ # should result in
23
+ #
24
+ # SELECT * FROM "personages" WHERE (("birth_year" IN ('2012', '2011')) AND ("title" ~* 'bar')) ORDER BY ("title" ASC, "name" ASC, "date" DESC)
25
+ #
26
+ # TODO pass a predicates: parameter in here to specify a predicates object.
27
+ class Filter
28
+ def initialize( filter_parameters = nil, &custom_predicate_block )
29
+ # This must be a new instance of Hash, because sometimes
30
+ # HashWithIndifferentAccess is passed in, which breaks things in here.
31
+ # Don't use symbolize_keys because that creates a dependency on ActiveSupport
32
+ @filter_parameters =
33
+ if filter_parameters
34
+ # preserve 2.0 compatibility
35
+ filter_parameters.inject({}){|ha,(k,v)| ha[k.to_sym] = v; ha}
36
+ else
37
+ {}
38
+ end
39
+
40
+ if block_given?
41
+ predicates.extend_with &custom_predicate_block
42
+ end
43
+ end
44
+
45
+ attr_reader :filter_parameters
46
+
47
+ def empty?; filter_parameters.empty? end
48
+
49
+ # return a modified dataset containing all the predicates
50
+ def call( dataset )
51
+ # mainly for Sequel::Model
52
+ dataset = dataset.dataset if dataset.respond_to? :dataset
53
+
54
+ # clone here so later order! calls don't mess with a Model's default dataset
55
+ dataset = expressions.inject(dataset.clone) do |dataset, filter_expr|
56
+ dataset.filter( filter_expr )
57
+ end
58
+
59
+ # preserve existing order if we don't have one.
60
+ if order_clause.empty?
61
+ dataset
62
+ else
63
+ # There might be multiple orderings in the order_clause
64
+ dataset.order *order_clause
65
+ end
66
+ end
67
+
68
+ alias apply call
69
+
70
+ # Values in the parameter list which are not blank, and not
71
+ # an ordering. That is, parameters which will be used to generate
72
+ # the filter expression.
73
+ def valued_parameters
74
+ filter_parameters.select do |key,value|
75
+ key.to_sym != :order && (value.is_a?(Array) || !value.blank?)
76
+ end
77
+ end
78
+
79
+ # The set of expressions from the filter_parameters with values.
80
+ def expressions
81
+ valued_parameters.map do |key, value|
82
+ to_expr(key, value)
83
+ end
84
+ end
85
+
86
+ def self.predicates
87
+ @predicates ||= Predicates.new
88
+ end
89
+
90
+ # Hash of predicate names to blocks. One way to get custom predicates is
91
+ # to subclass filter and override this.
92
+ def predicates
93
+ # don't mess with the class' minimal set
94
+ @predicates ||= self.class.predicates.clone
95
+ end
96
+
97
+ attr_writer :predicates
98
+
99
+ def order_expr( order_predicate )
100
+ return if order_predicate.blank?
101
+
102
+ splitter = PredicateSplitter.new( order_predicate, nil )
103
+ case
104
+ when splitter === :asc
105
+ Sequel.asc splitter.field
106
+ when splitter === :desc
107
+ Sequel.desc splitter.field
108
+ else
109
+ Sequel.asc splitter.field
110
+ end
111
+ end
112
+
113
+ def order_for( order_field )
114
+ order_hash[order_field]
115
+ end
116
+
117
+ # return a possibly empty array of Sequel order expressions
118
+ def order_clause
119
+ @order_clause ||= order_expressions.map{|e| e.last}
120
+ end
121
+
122
+ # Associative array (not a Hash) of names to order expressions
123
+ # TODO this should just be a hash
124
+ def order_expressions
125
+ @order_expressions ||=
126
+ [filter_parameters[:order]].flatten.map do |order_predicate|
127
+ next if order_predicate.blank?
128
+ expr = order_expr order_predicate
129
+ [expr.expression, expr]
130
+ end.compact
131
+ end
132
+
133
+ def order_hash
134
+ @order_hash ||= Hash[ order_expressions ]
135
+ end
136
+
137
+ # turn a filter_parameter key => value into a Sequel::SQL::Expression subclass
138
+ # field will be the field name ultimately used in the expression. Defaults to key.
139
+ def to_expr( key, value, field = nil )
140
+ Sequel.expr( predicates[key, value, field] )
141
+ end
142
+
143
+ # turn the expression at predicate into a Sequel expression with
144
+ # field, having the value for predicate. Will be nil if the
145
+ # predicate has no value in valued_parameters.
146
+ # Will always be a Sequel::SQL::Expression.
147
+ def expr_for( predicate, field = nil )
148
+ unless (value = valued_parameters[predicate]).blank?
149
+ to_expr( predicate, value, field )
150
+ end
151
+ end
152
+
153
+ # for use in forms
154
+ def to_h(all=false)
155
+ filter_parameters.select{|k,v| all || !v.blank?}
156
+ end
157
+
158
+ attr_writer :filter_parameters
159
+ protected :filter_parameters=
160
+
161
+ # deallocate any cached lazies
162
+ def initialize_copy( *args )
163
+ super
164
+ @order_expressions = nil
165
+ @order_hash = nil
166
+ @order_clause = nil
167
+ end
168
+
169
+ def clone( extra_parameters = {} )
170
+ new_filter = super()
171
+
172
+ # and explicitly clone these because they may well be modified
173
+ new_filter.filter_parameters = filter_parameters.clone
174
+ new_filter.predicates = predicates.clone
175
+
176
+ extra_parameters.each do |key,value|
177
+ new_filter[key] = value
178
+ end
179
+
180
+ new_filter
181
+ end
182
+
183
+ # return a new filter including only the specified filter parameters/predicates.
184
+ # NOTE predicates are not the same as field names.
185
+ # args to select_block are the same as to filter_parameters, ie it's a Hash
186
+ # TODO should use clone
187
+ def subset( *keys, &select_block )
188
+ subset_params =
189
+ if block_given?
190
+ filter_parameters.select &select_block
191
+ else
192
+ filter_parameters.slice( *keys )
193
+ end
194
+ subset = self.class.new( subset_params )
195
+ subset.predicates = predicates.clone
196
+ subset
197
+ end
198
+
199
+ # return a subset of filter parameters/predicates,
200
+ # but leave this object without the matching keys.
201
+ # NOTE does not operate on field names.
202
+ def extract!( *keys, &select_block )
203
+ rv = subset( *keys, &select_block )
204
+ rv.to_h.keys.each do |key|
205
+ filter_parameters.delete( key )
206
+ end
207
+ rv
208
+ end
209
+
210
+ # hash of keys to expressions, but only where
211
+ # there are values.
212
+ def expr_hash
213
+ vary = valued_parameters.map do |key, value|
214
+ [ key, to_expr(key, value) ]
215
+ end
216
+
217
+ Hash[ vary ]
218
+ end
219
+
220
+ # easier access for filter_parameters
221
+ # return nil for nil and '' and []
222
+ def []( key )
223
+ rv = filter_parameters[key]
224
+ rv unless rv.blank?
225
+ end
226
+
227
+ # easier access for filter_parameters
228
+ def []=(key, value)
229
+ filter_parameters[key] = value
230
+ end
231
+ end
232
+ end