zen-query 1.0.0

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 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: []