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 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