zen-query 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 50b08ec0ae8f0289575e0741873b57ec885f9d6c06fcd0dd6f31909127102585
4
+ data.tar.gz: 00aa3acdcbf0b33c4128bd420e90f95b260e4166963fba4d248e17508fc11a2c
5
+ SHA512:
6
+ metadata.gz: 45044327d4848b4250a6f8603f19110ed1dcaf4ede3d333b5171425a6f7679f0369674f17ca1f0afec812b260583ea60ed64b39c6bf1aa5bf63c6aa60bdd53c4
7
+ data.tar.gz: bff2db15804f328d7f5e2c7f59d1b180443449ff78b7a9cf304ceb6310e794043fc0c8ec001b603b90200b9a2c827496c92f002d5927dc346c5d7feec45dd4c7
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ .rspec_status
12
+ .ruby-version
13
+ .ruby-gemset
14
+
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,36 @@
1
+ AllCops:
2
+ NewCops: disable
3
+ TargetRubyVersion: 2.4
4
+
5
+ Layout/MultilineMethodCallIndentation:
6
+ EnforcedStyle: indented_relative_to_receiver
7
+
8
+ Style/StringLiterals:
9
+ EnforcedStyle: double_quotes
10
+
11
+ Style/AccessModifierDeclarations:
12
+ EnforcedStyle: group
13
+
14
+ Style/Documentation:
15
+ Enabled: false
16
+
17
+ Style/LambdaCall:
18
+ Enabled: false
19
+
20
+ Style/ClassAndModuleChildren:
21
+ Enabled: false
22
+
23
+ Style/DoubleNegation:
24
+ Enabled: false
25
+
26
+ Metrics/MethodLength:
27
+ Max: 20
28
+
29
+ Metrics/BlockLength:
30
+ Exclude:
31
+ - 'spec/**/*.rb'
32
+ - 'zen-query.gemspec'
33
+
34
+ Layout/LineLength:
35
+ Exclude:
36
+ - 'spec/**/*.rb'
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.4.0
4
+ before_install: gem install bundler -v 2.1.0
data/CHANGELOG ADDED
@@ -0,0 +1,21 @@
1
+ === master
2
+
3
+ Improve :index option support, add query_by! and sift_by! methods
4
+
5
+ === 1.0.3
6
+
7
+ Explicitly apply query blocks in definition order
8
+
9
+ === 1.0.2
10
+
11
+ Remove base_scope application on sifted instance
12
+
13
+ === 1.0.1
14
+
15
+ Fix base scope application on sifted instance
16
+
17
+ Add [github release] badge to README
18
+
19
+ Update README, add CHANGELOG
20
+
21
+ === 1.0.0
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Artem Kuzko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,428 @@
1
+ # Zen::Query
2
+
3
+ Param-based scope (relation, dataset) generation.
4
+
5
+ [![build status](https://secure.travis-ci.org/akuzko/zen-query.png)](http://travis-ci.org/akuzko/zen-query)
6
+ [![github release](https://img.shields.io/github/release/akuzko/zen-query.svg)](https://github.com/akuzko/zen-query/releases)
7
+
8
+ ---
9
+
10
+ This gem provides a `Zen::Query` class with a declarative and convenient API
11
+ to build scopes (ActiveRecord relations or arbitrary objects) dynamically, based
12
+ on parameters passed to query object on initialization.
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'zen-query'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ $ bundle
25
+
26
+ Or install it yourself as:
27
+
28
+ $ gem install zen-query
29
+
30
+ ## Usage
31
+
32
+ Despite the fact `zen-query` was intended to help building ActiveRecord relations
33
+ via scopes or query methods, it's usage is not limited to ActiveRecord cases and
34
+ may be used with any arbitrary classes and objects. In fact, for development and
35
+ testing, `OpenStruct` instance is used as a generic subject. However, ActiveRecord
36
+ examples should illustrate gem's usage in the best way.
37
+
38
+ For most examples in this README, `scope` method is used as accessor to
39
+ current subject value. This behavior is easily achieved via `Query.alias_subject_name(:scope)`
40
+ method call.
41
+
42
+ ### API
43
+
44
+ `zen-query` provides `Zen::Query` class, descendants of which should declare
45
+ scope manipulations using `query_by`, `sift_by` and other class methods bellow.
46
+
47
+ #### Class Methods
48
+
49
+ - `query_by(*presence_fields, **value_fields, &block)` declares a scope-generation query
50
+ block that will be executed if, and only if all values of query params at the keys of
51
+ `presence_fields` are present in activesupport's definition of presence and all `value_fields`
52
+ are present in query params as is. The block is executed in context of query
53
+ object. All values of specified params are yielded to the block. If the block
54
+ returns a non-nil value, it becomes a new scope for subsequent processing. Of course,
55
+ there can be multiple `query_by` block definitions. Methods accepts additional options:
56
+ - `:index` - allows to specify order of query block applications. By default all query
57
+ blocks have index of 0. This option also accepts special values `:first` and `:last` for
58
+ more convenient usage. Queries with the same value of `:index` option are applied in
59
+ order of declaration.
60
+ - `:if` - specifies condition according to which query should be applied. If Symbol
61
+ or String is passed, calls corresponding method. If Proc is passed, it is executed
62
+ in context of query object. Note that this is optional condition, and does not
63
+ overwrite original param-based condition for a query block that should always be met.
64
+ - `:unless` - the same as `:if` option, but with reversed boolean check.
65
+
66
+ - `query_by!(*fields, &block)` declares scope-generation block that is always executed
67
+ (unless `:if` and/or `:unless` options are used). All values in params at `fields` keys are
68
+ yielded to the block. As `query_by`, accepts `:index`, `:if` and `:unless` options.
69
+
70
+ - `query(&block)` declares scope-generation block that is always executed (unless `:if`
71
+ and/or `:unless` options are used). As `query_by`, accepts `:index`, `:if` and `:unless`
72
+ options.
73
+
74
+ *Examples:*
75
+
76
+ ```ruby
77
+ # executes block only when params[:department_id] is non-empty:
78
+ query_by(:department_id) { |id| scope.where(department_id: id) }
79
+
80
+ # executes block only when params[:only_active] == 'true':
81
+ query_by(only_active: 'true') { scope.active }
82
+
83
+ # executes block only when *both* params[:first_name] and params[:last_name]
84
+ # are present:
85
+ query_by(:first_name, :last_name) do |first_name, last_name|
86
+ scope.where(first_name: first_name, last_name: last_name)
87
+ end
88
+
89
+ # if query block returns nil, scope will remain intact:
90
+ query { scope.active if only_active? }
91
+
92
+ # conditional example:
93
+ query(if: :include_inactive?) { scope.with_inactive }
94
+
95
+ def include_inactive?
96
+ company.settings.include_inactive?
97
+ end
98
+ ```
99
+
100
+ - `sift_by(*presence_fields, **value_fields, &block)` method is used to hoist sets of
101
+ query definitions that should be applied if, and only if, all specified values
102
+ match criteria in the same way as in `query_by` method. Just like `query_by` method,
103
+ values of specified fields are yielded to the block. Accepts the same options as
104
+ it's `query_by` counterpart. Such `sift_by` definitions may be nested in any depth.
105
+
106
+ - `sift_by!(*fields, &block)` declares a sifter block that is always applied (unless
107
+ `:if` and/or `:unless` options are used). All values in params at specified `fields`
108
+ are yielded to the block.
109
+
110
+ - `sifter` alias for `sift_by`. Results in a more readable construct when a single
111
+ presence field is passed. For example, `sifter(:paginated)`.
112
+
113
+ *Examples:*
114
+
115
+ ```ruby
116
+ sift_by(:search_value, :search_type) do |value|
117
+ # definitions in this block will be applied only if *both* params[:search_value]
118
+ # and params[:search_type] are present
119
+
120
+ search_value = "%#{value}%"
121
+
122
+ query_by(search_type: 'name') { scope.name_like(value) }
123
+ query_by(search_type: 'email') { scope.where("users.email LIKE ?", search_value) }
124
+ end
125
+
126
+ sifter :paginated do
127
+ query_by(:page, :per_page) do |page, per|
128
+ scope.page(page).per(per)
129
+ end
130
+ end
131
+
132
+ def paginated_records
133
+ resolve(:paginated)
134
+ end
135
+ ```
136
+
137
+ - `subject(&block)` method is used to define a base subject as a starting point
138
+ of subject-generating process. Note that `subject` will not be evaluated if
139
+ query is initialized with a given subject.
140
+
141
+ *Examples:*
142
+
143
+ ```ruby
144
+ subject { User.all }
145
+ ```
146
+
147
+ - `defaults(&block)` method is used to declare default query params that are
148
+ reverse merged with params passed on query initialization. When used in `sift_by`
149
+ block, hashes are merged altogether. Accepts a `block`, it's return value
150
+ will be evaluated and merged on query object instantiation, allowing to have
151
+ dynamic default params values.
152
+
153
+ *Examples:*
154
+
155
+ ```ruby
156
+ defaults { { later_than: 1.week.ago } }
157
+
158
+ sifter :paginated do
159
+ # sifter defaults are merged with higher-level defaults:
160
+ defaults { { page: 1, per_page: 25 } }
161
+ end
162
+ ```
163
+
164
+ - `guard(message = nil, &block)` defines a guard instance method block (see instance methods
165
+ bellow). All such blocks are executed before query object resolves scope via
166
+ `resolve_scope` method. Optional `message` may be supplied to provide more informative
167
+ error message.
168
+
169
+ *Examples:*
170
+
171
+ ```ruby
172
+ sift_by(:sort_col, :sort_dir) do |scol, sdir|
173
+ # will raise Zen::Query::GuardViolationError on scope resolution if
174
+ # params[:sort_dir] is not 'asc' or 'desc'
175
+ guard(':sort_dir should be "asc" or "desc"') do
176
+ sdir.downcase.in?(%w(asc desc))
177
+ end
178
+
179
+ query { scope.order(scol => sdir) }
180
+ end
181
+ ```
182
+
183
+ - `raise_on_guard_violation(value)` allows to specify whether or not exception should be raised
184
+ whenever any guard block is violated during scope resolution. When set to `false`, in case
185
+ of any violation, `resolve` will return `nil`, and query will have `violation` property
186
+ set with value corresponding to the message of violated block. Default option value is `true`.
187
+
188
+ *Examples:*
189
+
190
+ ```ruby
191
+ raise_on_guard_violation false
192
+
193
+ sift_by(:sort_col, :sort_dir) do |scol, sdir|
194
+ guard(':sort_dir should be "asc" or "desc"') do
195
+ sdir.downcase.in?(%w(asc desc))
196
+ end
197
+
198
+ query { scope.order(scol => sdir) }
199
+ end
200
+ ```
201
+
202
+ ```ruby
203
+ query = UsersQuery.new(sort_col: 'id', sort_dir: 'there')
204
+ query.resolve # => nil
205
+ query.violation # => ":sort_dir should be \"asc\" or \"desc\""
206
+ ```
207
+
208
+ - `attributes(*attribute_names)` allows to specify additional attributes that can be passed
209
+ to query object on initialization. For each given attribute name, reader method is generated.
210
+
211
+ #### Instance Methods
212
+
213
+ - `initialize(params: {}, subject: nil, **attributes)` initializes a query with
214
+ `params`, an optional subject and attributes. If subject is aliased, corresponding
215
+ key should be used instead. The rest of attributes are only accepted if they were
216
+ declared via `attributes` class method call.
217
+
218
+ *Examples:*
219
+
220
+ ```ruby
221
+ query = UsersQuery.new(params: query_params, company: company)
222
+ ```
223
+
224
+ - `params` returns a parameters passed in initialization, reverse merged with query
225
+ defaults.
226
+
227
+ - `subject` "current" subject of query object. For an initialized query object corresponds
228
+ to base subject. Primary usage is to call this method in `query_by` blocks and return
229
+ it's mutated version corresponding to passed `query_by` arguments.
230
+
231
+ Can be aliased to more suitable name with `Query.alias_subject_name` class method.
232
+
233
+ - `guard(&block)` executes a passed `block`. If this execution returns falsy value,
234
+ `GuardViolationError` is raised. You can use this method to ensure safety of param
235
+ values interpolation to a SQL string in a `query_by` block for example.
236
+
237
+ *Examples:*
238
+
239
+ ```ruby
240
+ query_by(:sort_col, :sort_dir) do |scol, sdir|
241
+ # will raise Zen::Query::GuardViolationError on scope resolution if
242
+ # params[:sort_dir] is not 'asc' or 'desc'
243
+ guard { sdir.downcase.in?(%w(asc desc)) }
244
+
245
+ scope.order(scol => sdir)
246
+ end
247
+ ```
248
+
249
+ - `resolve(*presence_keys, override_params = {})` returns a resulting scope
250
+ generated by all queries and sifted queries that fit to query params applied to
251
+ base scope. Optionally, additional params may be passed to override the ones passed on
252
+ initialization. For convinience, you may pass list of keys that should be resolved
253
+ to `true` with params (for example, `resolve(:with_projects)` instead of
254
+ `resolve(with_projects: true)`). It's the main `Query` instance method that
255
+ returns the sole purpose of it's instances.
256
+
257
+ *Examples:*
258
+
259
+ ```ruby
260
+ defaults { { only_active: true } }
261
+
262
+ subject { company.users }
263
+
264
+ query_by(:only_active) { subject.active }
265
+
266
+ sifter :with_departments do
267
+ query { subject.joins(:departments) }
268
+
269
+ query_by(:department_name) do |name|
270
+ subject.where(departments: { name: name })
271
+ end
272
+ end
273
+
274
+ def users
275
+ @users ||= resolve
276
+ end
277
+
278
+ # you can use options to overwrite defaults:
279
+ def all_users
280
+ resolve(only_active: false)
281
+ end
282
+
283
+ # or to apply a sifter with additional params:
284
+ def managers
285
+ resolve(:with_departments, department_name: 'managers')
286
+ end
287
+ ```
288
+
289
+ ### Composite usage example with ActiveRecord Relation as a subject, aliased as `:relation`
290
+
291
+ ```ruby
292
+ class UserQuery < Zen::Query
293
+ alias_subject_name :relation
294
+
295
+ attributes :company
296
+
297
+ defaults { { only_active: true } }
298
+
299
+ relation { company.users }
300
+
301
+ query_by(:only_active) { relation.active }
302
+
303
+ query_by(:birthdate) { |date| relation.by_birtdate(date) }
304
+
305
+ query_by :name do |name|
306
+ relation.where("CONCAT(first_name, ' ', last_name) LIKE :name", name: "%#{name}%")
307
+ end
308
+
309
+ sift_by :sort_column, :sort_direction do |scol, sdir|
310
+ guard { sdir.to_s.downcase.in?(%w(asc desc)) }
311
+
312
+ query { relation.order(scol => sdir) }
313
+
314
+ query_by(sort_column: 'name') do
315
+ relation.reorder("CONCAT(first_name, ' ', last_name) #{sdir}")
316
+ end
317
+ end
318
+
319
+ sifter :with_projects do
320
+ query { relation.joins(:projects) }
321
+
322
+ query_by :project_name do |name|
323
+ scope.where(projects: { name: name })
324
+ end
325
+ end
326
+
327
+ def users
328
+ @users ||= resolve
329
+ end
330
+
331
+ def project_users
332
+ @project_users ||= resolve(:with_projects)
333
+ end
334
+ end
335
+
336
+ params = { name: 'John', sort_column: 'name', sort_direction: 'DESC', project_name: 'ExampleApp' }
337
+
338
+ query = UserQuery.new(params: params, company: some_company)
339
+
340
+ query.project_users # => this is the same as:
341
+ # some_company.users
342
+ # .active
343
+ # .joins(:projects)
344
+ # .where("CONCAT(first_name, ' ', last_name) LIKE ?", "%John%")
345
+ # .where(projects: { name: 'ExampleApp' })
346
+ # .order("CONCAT(first_name, ' ', last_name) DESC")
347
+ ```
348
+
349
+ ### Hints and Tips
350
+
351
+ - Keep in mind that query classes are just plain Ruby classes. All `sifter`,
352
+ `query_by` and `guard` declarations are inherited, as well as default params
353
+ declared by `defaults` method. Thus, you can define a BaseQuery with common
354
+ definitions as a base class for queries in your application. Or you can define
355
+ query API blocks in some module's `included` callback to share common definitions
356
+ via module inclusion.
357
+
358
+ - Being plain Ruby classes also means you can easily extend default functionality
359
+ for your needs. For example, if you're querying ActiveRecord relations, and your
360
+ primary use case looks like
361
+
362
+ ```ruby
363
+ query_by(:some_field_id) { |id| scope.where(some_field_id: id) }
364
+ ```
365
+ you can do the following to make things more DRY:
366
+
367
+ ```ruby
368
+ class ApplicationQuery < Zen::Query
369
+ def self.query_by(*fields, &block)
370
+ block ||= default_query_block(fields)
371
+ super(*fields, &block)
372
+ end
373
+
374
+ def self.default_query_block(fields)
375
+ ->(*values){ scope.where(Hash[fields.zip(values)]) }
376
+ end
377
+ private_class_method :default_query_block
378
+ end
379
+ ```
380
+
381
+ and then you can simply call
382
+
383
+ ```ruby
384
+ class UsersQuery < ApplicationQuery
385
+ base_scope { company.users }
386
+
387
+ query_by :first_name
388
+ query_by :last_name
389
+ query_by :city, :street_address
390
+ end
391
+ ```
392
+
393
+ Or you can go a little further and declare a class method
394
+
395
+ ```ruby
396
+ class ApplicationQuery
397
+ def self.query_by_fields(*fields)
398
+ fields.each do |field|
399
+ query_by field
400
+ end
401
+ end
402
+ end
403
+ ```
404
+
405
+ and then
406
+
407
+ ```ruby
408
+ class UserQuery < ApplicationQuery
409
+ query_by_fields :first_name, :last_name, :department_id
410
+ end
411
+ ```
412
+
413
+ ## Development
414
+
415
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
416
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
417
+ prompt that will allow you to experiment.
418
+
419
+ ## Contributing
420
+
421
+ Bug reports and pull requests are welcome on GitHub at https://github.com/akuzko/zen-query.
422
+
423
+
424
+ ## License
425
+
426
+ The gem is available as open source under the terms of the
427
+ [MIT License](http://opensource.org/licenses/MIT).
428
+
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "zen/query"
6
+
7
+ require "ostruct"
8
+
9
+ class Query < Zen::Query
10
+ raise_on_guard_violation(false)
11
+
12
+ alias_subject_name(:scope)
13
+
14
+ subject { OpenStruct.new }
15
+
16
+ defaults { { time: Time.now, barbak: "barbak" } }
17
+
18
+ query_by :foo do |foo|
19
+ guard('wrong value for :foo param. try "foo"') { foo == "foo" }
20
+
21
+ scope.tap { scope.foo = foo }
22
+ end
23
+
24
+ query_by bar: "bar" do
25
+ scope.tap { scope.bar = "bar" }
26
+ end
27
+
28
+ sift_by baz: "baz" do |baz|
29
+ defaults { { bakbar: "bakbar" } }
30
+
31
+ guard { upcase(baz) == "BAZ" }
32
+
33
+ query { scope.tap { scope.baz = "baz" } }
34
+
35
+ query_by :bak do |bak|
36
+ scope.tap { scope.bak = bak }
37
+ end
38
+
39
+ query_by :barbak do |barbak|
40
+ scope.tap { scope.barbak = barbak }
41
+ end
42
+
43
+ sift_by :nested_baz do |nested_baz|
44
+ query_by :bakbar do |bakbar|
45
+ scope.tap { scope.bakbar = [baz, nested_baz, bakbar].join("-") }
46
+ end
47
+ end
48
+ end
49
+
50
+ sift_by :other_baz do |other|
51
+ query { scope.tap { scope.other_baz = other } }
52
+ end
53
+
54
+ query(if: :condition?) { scope.tap { scope.by_instance_condition = true } }
55
+ query(unless: :bad_condition?) { scope.tap { scope.not_bad_condition = true } }
56
+
57
+ def condition?
58
+ !!params[:condition]
59
+ end
60
+
61
+ def bad_condition?
62
+ !condition?
63
+ end
64
+
65
+ def upcase(str)
66
+ str.upcase
67
+ end
68
+ end
69
+
70
+ # q = Query.new(params: { foo: "foo", bar: "bar", baz: "baz", bak: "bak", nested_baz: "nb", other_baz: "ob" })
71
+
72
+ require "pry"
73
+ Pry.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/zen/query.rb ADDED
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query/api_block"
4
+ require_relative "query/api_methods"
5
+ require_relative "query/attributes"
6
+
7
+ module Zen
8
+ class Query # rubocop:disable Metrics/ClassLength
9
+ UndefinedSubjectError = Class.new(StandardError)
10
+ GuardViolationError = Class.new(ArgumentError)
11
+
12
+ extend ApiMethods
13
+ include Attributes
14
+
15
+ raise_on_guard_violation(true)
16
+
17
+ attr_reader :params, :violation
18
+
19
+ def self.inherited(subclass) # rubocop:disable Metrics/AbcSize
20
+ subclass.raise_on_guard_violation(raise_on_guard_violation?)
21
+ subclass.query_blocks.replace(query_blocks.dup)
22
+ subclass.sift_blocks.replace(sift_blocks.dup)
23
+ subclass.guard_blocks.replace(guard_blocks.dup)
24
+ subclass.subject(&subject)
25
+ subclass.defaults(&defaults) unless defaults.nil?
26
+ super
27
+ end
28
+
29
+ def initialize(params: {}, **attrs)
30
+ @params = klass.fetch_defaults.merge(params)
31
+ @subject = attrs.delete(self.class.subject_name)
32
+ @base_params = @params
33
+ super
34
+ end
35
+
36
+ def subject
37
+ @subject ||= base_subject
38
+ end
39
+
40
+ def base_subject
41
+ subject =
42
+ klass
43
+ .ancestors
44
+ .select { |mod| mod.respond_to?(:subject) }
45
+ .map(&:subject)
46
+ .compact
47
+ .first
48
+ &.call
49
+
50
+ raise UndefinedSubjectError, "failed to build subject. Have you missed subject definition?" if subject.nil?
51
+
52
+ subject
53
+ end
54
+
55
+ def resolve(*args)
56
+ @violation = nil
57
+ arg_params = args.pop if args.last.is_a?(Hash)
58
+ return sifted_instance.resolve! if arg_params.nil? && args.empty?
59
+
60
+ clone_with_params(trues(args).merge(arg_params || {})).resolve
61
+ rescue GuardViolationError => e
62
+ @violation = e.message
63
+ raise if self.class.raise_on_guard_violation?
64
+ end
65
+
66
+ def klass
67
+ sifted? ? singleton_class : self.class
68
+ end
69
+
70
+ protected
71
+
72
+ attr_writer :subject, :params
73
+ attr_accessor :block
74
+ attr_reader :attrs
75
+
76
+ def sifted_instance
77
+ blocks = klass.sift_blocks.select { |block| block.fits?(self) }
78
+
79
+ blocks.empty? ? self : sifted_instance_for(blocks)
80
+ end
81
+
82
+ def resolve!
83
+ guard_all
84
+ klass.sorted_query_blocks.reduce(subject) do |subject, block|
85
+ clone_with_subject(subject, block).apply_block!.subject
86
+ end
87
+ end
88
+
89
+ def apply_block!
90
+ if block&.fits?(self)
91
+ subject = instance_exec(*block.values_for(params), &block.block)
92
+ @subject = subject unless subject.nil?
93
+ end
94
+ self
95
+ end
96
+
97
+ def sifted!(query, blocks) # rubocop:disable Metrics/AbcSize
98
+ singleton_class.query_blocks.replace(query.klass.query_blocks.dup)
99
+ singleton_class.guard_blocks.replace(query.klass.guard_blocks.dup)
100
+ singleton_class.subject(&query.klass.subject)
101
+ blocks.each do |block|
102
+ singleton_class.instance_exec(*block.values_for(params), &block.block)
103
+ end
104
+ params.replace(singleton_class.fetch_defaults.merge(params))
105
+ @sifted = true
106
+ end
107
+
108
+ def sifted?
109
+ !!@sifted
110
+ end
111
+
112
+ def clone_with_subject(subject, block = nil)
113
+ clone.tap do |query|
114
+ query.subject = subject
115
+ query.block = block
116
+ end
117
+ end
118
+
119
+ def clone_with_params(other_params)
120
+ dup.tap do |query|
121
+ query.params = @base_params.merge(other_params)
122
+ query.remove_instance_variable("@sifted") if query.instance_variable_defined?("@sifted")
123
+ query.remove_instance_variable("@subject") if query.instance_variable_defined?("@subject")
124
+ end
125
+ end
126
+
127
+ def clone_sifted_with(blocks)
128
+ dup.tap do |query|
129
+ query.sifted!(self, blocks)
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def guard_all
136
+ klass.guard_blocks.each { |message, block| guard(message, &block) }
137
+ end
138
+
139
+ def guard(message = nil, &block)
140
+ return if instance_exec(&block)
141
+
142
+ violation = message || "guard block violated on #{block.source_location.join(':')}"
143
+
144
+ raise GuardViolationError, violation
145
+ end
146
+
147
+ def sifted_instance_for(blocks)
148
+ clone_sifted_with(blocks).sifted_instance
149
+ end
150
+
151
+ def trues(keys)
152
+ keys.each_with_object({}) do |key, hash|
153
+ hash[key] = true
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zen
4
+ class Query
5
+ class ApiBlock
6
+ OPTION_KEYS = %i[index if unless].freeze
7
+ private_constant :OPTION_KEYS
8
+
9
+ attr_reader :presence_fields, :value_fields, :block, :force, :options
10
+
11
+ def initialize(presence_fields:, value_fields:, block:, force: false)
12
+ @options = extract_options!(value_fields)
13
+
14
+ @presence_fields = presence_fields
15
+ @value_fields = value_fields
16
+ @block = block
17
+ @force = force
18
+ end
19
+
20
+ def fits?(query)
21
+ return false unless conditions_met_by?(query)
22
+ return true if force
23
+
24
+ (presence_fields.empty? && value_fields.empty?) ||
25
+ values_for(query.params).all? { |value| present?(value) }
26
+ end
27
+
28
+ def values_for(params)
29
+ params.values_at(*presence_fields) + valued_values_for(params)
30
+ end
31
+
32
+ def present?(value)
33
+ value.respond_to?(:empty?) ? !value.empty? : !!value
34
+ end
35
+
36
+ def index
37
+ case options[:index]
38
+ when :first then -Float::INFINITY
39
+ when :last then Float::INFINITY
40
+ when Numeric then options[:index]
41
+ else 0
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def extract_options!(fields)
48
+ fields.keys.each_with_object({}) do |key, options|
49
+ options[key] = fields.delete(key) if OPTION_KEYS.include?(key)
50
+ end
51
+ end
52
+
53
+ def valued_values_for(params)
54
+ value_fields.map do |field, required_value|
55
+ params[field] == required_value && required_value
56
+ end
57
+ end
58
+
59
+ def conditions_met_by?(query)
60
+ condition_met?(query, :if) && condition_met?(query, :unless)
61
+ end
62
+
63
+ def condition_met?(query, key)
64
+ return true unless options.key?(key)
65
+
66
+ condition = options[key]
67
+
68
+ value =
69
+ case condition
70
+ when String, Symbol then query.send(condition)
71
+ when Proc then query.instance_exec(&condition)
72
+ else condition
73
+ end
74
+
75
+ key == :if ? value : !value
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zen
4
+ class Query
5
+ module ApiMethods
6
+ def alias_subject_name(name)
7
+ @subject_name = name
8
+
9
+ define_singleton_method(name) { |&block| subject(&block) }
10
+
11
+ alias_method(name, :subject)
12
+ end
13
+
14
+ def subject_name
15
+ @subject_name || :subject
16
+ end
17
+
18
+ def raise_on_guard_violation(value)
19
+ @raise_on_guard_violation = !!value
20
+ end
21
+
22
+ def raise_on_guard_violation?
23
+ @raise_on_guard_violation
24
+ end
25
+
26
+ def subject(&block)
27
+ return @subject_block unless block_given?
28
+
29
+ @subject_block = block
30
+ end
31
+
32
+ def defaults(&block)
33
+ return @defaults_block unless block_given?
34
+
35
+ @defaults_block = block
36
+ end
37
+
38
+ def fetch_defaults
39
+ ancestors
40
+ .select { |mod| mod.respond_to?(:defaults) }
41
+ .map(&:defaults)
42
+ .compact
43
+ .reduce({}) { |result, block| result.merge!(block.call) }
44
+ end
45
+
46
+ def sift_by(*presence_fields, **value_fields, &block)
47
+ sift_blocks.push(
48
+ Query::ApiBlock.new(
49
+ presence_fields: presence_fields,
50
+ value_fields: value_fields,
51
+ block: block
52
+ )
53
+ )
54
+ end
55
+
56
+ def query_by(*presence_fields, **value_fields, &block)
57
+ query_blocks.push(
58
+ Query::ApiBlock.new(
59
+ presence_fields: presence_fields,
60
+ value_fields: value_fields,
61
+ block: block
62
+ )
63
+ )
64
+ end
65
+
66
+ alias sifter sift_by
67
+ alias query query_by
68
+
69
+ def sift_by!(*presence_fields, &block)
70
+ sift_blocks.push(
71
+ Query::ApiBlock.new(
72
+ presence_fields: presence_fields,
73
+ value_fields: {},
74
+ block: block,
75
+ force: true
76
+ )
77
+ )
78
+ end
79
+
80
+ def query_by!(*presence_fields, &block)
81
+ query_blocks.push(
82
+ Query::ApiBlock.new(
83
+ presence_fields: presence_fields,
84
+ value_fields: {},
85
+ block: block,
86
+ force: true
87
+ )
88
+ )
89
+ end
90
+
91
+ alias sifter! sift_by!
92
+ alias query! query_by!
93
+
94
+ def guard(message = nil, &block)
95
+ guard_blocks.push([message, block])
96
+ end
97
+
98
+ def sift_blocks
99
+ @sift_blocks ||= []
100
+ end
101
+
102
+ def query_blocks
103
+ @query_blocks ||= []
104
+ end
105
+
106
+ def guard_blocks
107
+ @guard_blocks ||= []
108
+ end
109
+
110
+ def sorted_query_blocks
111
+ query_blocks.sort do |a, b|
112
+ if a.index == b.index
113
+ query_blocks.index(a) <=> query_blocks.index(b)
114
+ else
115
+ a.index <=> b.index
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zen
4
+ class Query
5
+ module Attributes
6
+ module ClassMethods
7
+ def inherited(query_class)
8
+ query_class.const_set(:AttributeMethods, Module.new)
9
+ query_class.send(:include, query_class::AttributeMethods)
10
+ query_class.attributes_list.replace(attributes_list.dup)
11
+ super
12
+ end
13
+
14
+ def attribute_methods
15
+ const_get(:AttributeMethods)
16
+ end
17
+
18
+ def attributes(*attrs)
19
+ attributes_list.concat(attrs)
20
+
21
+ attrs.each do |name|
22
+ attribute_methods.send(:define_method, name) { @attributes[name] }
23
+ end
24
+ end
25
+
26
+ def attributes_list
27
+ @attributes_list ||= []
28
+ end
29
+ end
30
+
31
+ def self.included(target)
32
+ target.extend(ClassMethods)
33
+ end
34
+
35
+ def initialize(**attrs)
36
+ attributes = attrs.dup
37
+ attributes.delete(:params)
38
+ assert_valid_attributes!(attributes)
39
+ @attributes = attributes
40
+ end
41
+
42
+ private
43
+
44
+ def assert_valid_attributes!(attrs)
45
+ unknown_attrs = attrs.keys - self.class.attributes_list
46
+
47
+ raise(ArgumentError, "Unknown attributes #{unknown_attrs.inspect}") if unknown_attrs.any?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zen
4
+ class Query
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
data/zen-query.gemspec ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/zen/query/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "zen-query"
7
+ spec.version = Zen::Query::VERSION
8
+ spec.authors = ["Artem Kuzko"]
9
+ spec.email = ["a.kuzko@gmail.com"]
10
+
11
+ spec.summary = "Builds a params-sifted scope"
12
+ spec.description = 'Zen::Query class provides a way to dynamically
13
+ apply scopes or ActiveRecord (or any other ORM) query methods based on passed
14
+ params with a declarative and convenient API'
15
+ spec.homepage = "https://github.com/akuzko/zen-query"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
18
+
19
+ spec.metadata["allowed_push_host"] = "https://rubygems.org/"
20
+
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://github.com/akuzko/zen-query.git"
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_development_dependency "bundler", ">= 2.1.0"
34
+ spec.add_development_dependency "pry"
35
+ spec.add_development_dependency "pry-nav"
36
+ spec.add_development_dependency "rake", "~> 13.0"
37
+ spec.add_development_dependency "rspec", "~> 3.0"
38
+ spec.add_development_dependency "rspec-its", "~> 1.2"
39
+ spec.add_development_dependency "rubocop", "~> 0.80"
40
+ end
metadata ADDED
@@ -0,0 +1,164 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zen-query
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Artem Kuzko
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-05-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.1.0
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 2.1.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry-nav
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec-its
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.2'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.2'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.80'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.80'
111
+ description: |-
112
+ Zen::Query class provides a way to dynamically
113
+ apply scopes or ActiveRecord (or any other ORM) query methods based on passed
114
+ params with a declarative and convenient API
115
+ email:
116
+ - a.kuzko@gmail.com
117
+ executables: []
118
+ extensions: []
119
+ extra_rdoc_files: []
120
+ files:
121
+ - ".gitignore"
122
+ - ".rspec"
123
+ - ".rubocop.yml"
124
+ - ".travis.yml"
125
+ - CHANGELOG
126
+ - Gemfile
127
+ - LICENSE.txt
128
+ - README.md
129
+ - Rakefile
130
+ - bin/console
131
+ - bin/setup
132
+ - lib/zen/query.rb
133
+ - lib/zen/query/api_block.rb
134
+ - lib/zen/query/api_methods.rb
135
+ - lib/zen/query/attributes.rb
136
+ - lib/zen/query/version.rb
137
+ - zen-query.gemspec
138
+ homepage: https://github.com/akuzko/zen-query
139
+ licenses:
140
+ - MIT
141
+ metadata:
142
+ allowed_push_host: https://rubygems.org/
143
+ homepage_uri: https://github.com/akuzko/zen-query
144
+ source_code_uri: https://github.com/akuzko/zen-query.git
145
+ post_install_message:
146
+ rdoc_options: []
147
+ require_paths:
148
+ - lib
149
+ required_ruby_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: 2.4.0
154
+ required_rubygems_version: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ requirements: []
160
+ rubygems_version: 3.1.6
161
+ signing_key:
162
+ specification_version: 4
163
+ summary: Builds a params-sifted scope
164
+ test_files: []