jsonapi-query_builder 0.1.6.pre → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edcba945298b8eadf8c9eb952ee3fed13ba3c8c822afa15dea5126102b689584
4
- data.tar.gz: acb2b63e334270e21874c4985467c29953faa2abda3af6065aac26b0963767af
3
+ metadata.gz: 51c9f4a8cc7fb70d6ca5a535cdc3386e97231960b915b02af356126b57d80609
4
+ data.tar.gz: 9a1684797aa2b39a67c66226da08c0adf326133e4d51190a78f34817e4fc288b
5
5
  SHA512:
6
- metadata.gz: d80d0e5ac1d07327c1f8737eb5b52439022914238a650ee926175c9f2e3b6f1bf0abecb15812e3e8f8241f8d607a5c2a8b9094169a1ba2e869363a4ecc588a0e
7
- data.tar.gz: a17154de4293836988b82af81fef9e2a925f9a153d5ba1ae161178e75ad7b6571a0c5f38b2f0f3b96661f001ec3442191b70cdc85754489ebbd80cee78a4bb21
6
+ metadata.gz: 70121ffb0d3419bfddff71778b6d4214e4de00c9f61f0bbd272ff6ab5f0a9e848f04c37124cc911371909326fa1f1a41cf1e401f2cd4dcc56e309cc323e2b9fd
7
+ data.tar.gz: ed296c71cf3bfdc1b46ee995fad2c2de965b38bafad1cad00f8571e55e09a83f18627b36360e231f68e9015b211fcefde2686ba8ef41bdce8c4060dc6fc32506
@@ -0,0 +1,31 @@
1
+ name: lint
2
+
3
+ on:
4
+ push:
5
+ branches: [ master ]
6
+ pull_request:
7
+ branches: [ master ]
8
+
9
+ jobs:
10
+ standardrb:
11
+ runs-on: ubuntu-18.04
12
+
13
+ steps:
14
+ - uses: actions/checkout@v2
15
+ - uses: ruby/setup-ruby@v1
16
+ with:
17
+ bundler-cache: true
18
+ - name: standardrb
19
+ run: bundle exec standardrb
20
+
21
+ rubocop-rspec:
22
+ runs-on: ubuntu-18.04
23
+
24
+ steps:
25
+ - uses: actions/checkout@v2
26
+ - uses: ruby/setup-ruby@v1
27
+ with:
28
+ bundler-cache: true
29
+
30
+ - name: rubocop
31
+ run: bundle exec rubocop --only RSpec
@@ -0,0 +1,23 @@
1
+ name: spec
2
+
3
+ on:
4
+ push:
5
+ branches: [ master ]
6
+ pull_request:
7
+ branches: [ master ]
8
+
9
+ jobs:
10
+ rake-spec:
11
+ runs-on: ubuntu-18.04
12
+ strategy:
13
+ matrix:
14
+ ruby: [2.5, 2.6, 2.7, 3.0]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v2
18
+ - uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: ${{ matrix.ruby }}
21
+ bundler-cache: true
22
+ - name: rake spec
23
+ run: bundle exec rake spec
data/.gitignore CHANGED
@@ -9,5 +9,4 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
- Gemfile.lock
13
12
  *.gem
data/.rubocop.yml CHANGED
@@ -4,5 +4,8 @@ require:
4
4
  RSpec/NestedGroups:
5
5
  Max: 4
6
6
 
7
+ RSpec/MultipleMemoizedHelpers:
8
+ Max: 7
9
+
7
10
  AllCops:
8
11
  NewCops: enable
data/CHANGELOG.md CHANGED
@@ -0,0 +1,16 @@
1
+ # Change log
2
+
3
+ ## 0.2.0 (2021-09-29)
4
+ Added support for Kaminari and Keyset pagination strategies in addition to Pagy.
5
+
6
+ - [#21](https://github.com/infinum/jsonapi-query_builder/pull/21): Extract paginators.
7
+
8
+ ## 0.1.9 (2021-05-07)
9
+
10
+ - [#18](https://github.com/infinum/jsonapi-query_builder/pull/18): Remove Ruby `to` version.
11
+ - [#9](https://github.com/infinum/jsonapi-query_builder/pull/9): added github actions
12
+
13
+ ## 0.1.8 (2021-01-25)
14
+
15
+ - [#8](https://github.com/infinum/jsonapi-query_builder/pull/8): add support for ruby 3.0
16
+
data/Gemfile.lock ADDED
@@ -0,0 +1,135 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ jsonapi-query_builder (0.2.0)
5
+ activerecord (>= 5)
6
+ pagy (~> 3.5)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ actionview (6.1.4.1)
12
+ activesupport (= 6.1.4.1)
13
+ builder (~> 3.1)
14
+ erubi (~> 1.4)
15
+ rails-dom-testing (~> 2.0)
16
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
17
+ activemodel (6.1.4.1)
18
+ activesupport (= 6.1.4.1)
19
+ activerecord (6.1.4.1)
20
+ activemodel (= 6.1.4.1)
21
+ activesupport (= 6.1.4.1)
22
+ activesupport (6.1.4.1)
23
+ concurrent-ruby (~> 1.0, >= 1.0.2)
24
+ i18n (>= 1.6, < 2)
25
+ minitest (>= 5.1)
26
+ tzinfo (~> 2.0)
27
+ zeitwerk (~> 2.3)
28
+ ast (2.4.2)
29
+ builder (3.2.4)
30
+ coderay (1.1.3)
31
+ concurrent-ruby (1.1.9)
32
+ crass (1.0.6)
33
+ diff-lcs (1.4.4)
34
+ erubi (1.10.0)
35
+ i18n (1.8.10)
36
+ concurrent-ruby (~> 1.0)
37
+ kaminari (1.2.1)
38
+ activesupport (>= 4.1.0)
39
+ kaminari-actionview (= 1.2.1)
40
+ kaminari-activerecord (= 1.2.1)
41
+ kaminari-core (= 1.2.1)
42
+ kaminari-actionview (1.2.1)
43
+ actionview
44
+ kaminari-core (= 1.2.1)
45
+ kaminari-activerecord (1.2.1)
46
+ activerecord
47
+ kaminari-core (= 1.2.1)
48
+ kaminari-core (1.2.1)
49
+ lefthook (0.7.6)
50
+ loofah (2.12.0)
51
+ crass (~> 1.0.2)
52
+ nokogiri (>= 1.5.9)
53
+ method_source (1.0.0)
54
+ mini_portile2 (2.6.1)
55
+ minitest (5.14.4)
56
+ nokogiri (1.12.5)
57
+ mini_portile2 (~> 2.6.1)
58
+ racc (~> 1.4)
59
+ pagy (3.14.0)
60
+ parallel (1.21.0)
61
+ parser (3.0.2.0)
62
+ ast (~> 2.4.1)
63
+ pry (0.14.1)
64
+ coderay (~> 1.1)
65
+ method_source (~> 1.0)
66
+ racc (1.5.2)
67
+ rails-dom-testing (2.0.3)
68
+ activesupport (>= 4.2.0)
69
+ nokogiri (>= 1.6)
70
+ rails-html-sanitizer (1.4.2)
71
+ loofah (~> 2.3)
72
+ rainbow (3.0.0)
73
+ rake (13.0.6)
74
+ regexp_parser (2.1.1)
75
+ rexml (3.2.5)
76
+ rspec (3.10.0)
77
+ rspec-core (~> 3.10.0)
78
+ rspec-expectations (~> 3.10.0)
79
+ rspec-mocks (~> 3.10.0)
80
+ rspec-core (3.10.1)
81
+ rspec-support (~> 3.10.0)
82
+ rspec-expectations (3.10.1)
83
+ diff-lcs (>= 1.2.0, < 2.0)
84
+ rspec-support (~> 3.10.0)
85
+ rspec-mocks (3.10.2)
86
+ diff-lcs (>= 1.2.0, < 2.0)
87
+ rspec-support (~> 3.10.0)
88
+ rspec-support (3.10.2)
89
+ rubocop (1.20.0)
90
+ parallel (~> 1.10)
91
+ parser (>= 3.0.0.0)
92
+ rainbow (>= 2.2.2, < 4.0)
93
+ regexp_parser (>= 1.8, < 3.0)
94
+ rexml
95
+ rubocop-ast (>= 1.9.1, < 2.0)
96
+ ruby-progressbar (~> 1.7)
97
+ unicode-display_width (>= 1.4.0, < 3.0)
98
+ rubocop-ast (1.12.0)
99
+ parser (>= 3.0.1.1)
100
+ rubocop-performance (1.11.5)
101
+ rubocop (>= 1.7.0, < 2.0)
102
+ rubocop-ast (>= 0.4.0)
103
+ rubocop-rspec (2.5.0)
104
+ rubocop (~> 1.19)
105
+ ruby-progressbar (1.11.0)
106
+ sqlite3 (1.4.2)
107
+ standard (1.3.0)
108
+ rubocop (= 1.20.0)
109
+ rubocop-performance (= 1.11.5)
110
+ standardrb (1.0.0)
111
+ standard
112
+ tzinfo (2.0.4)
113
+ concurrent-ruby (~> 1.0)
114
+ unicode-display_width (2.1.0)
115
+ zeitwerk (2.4.2)
116
+
117
+ PLATFORMS
118
+ ruby
119
+
120
+ DEPENDENCIES
121
+ activerecord
122
+ bundler (~> 2.0)
123
+ jsonapi-query_builder!
124
+ kaminari (~> 1.2)
125
+ lefthook
126
+ pry
127
+ rake (~> 13.0)
128
+ rspec (~> 3.0)
129
+ rubocop-rspec
130
+ sqlite3
131
+ standard
132
+ standardrb
133
+
134
+ BUNDLED WITH
135
+ 2.1.4
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Jsonapi::QueryBuilder
1
+ # Jsonapi::QueryBuilder ![lint](https://github.com/infinum/jsonapi-query_builder/workflows/lint/badge.svg)![spec](https://github.com/infinum/jsonapi-query_builder/workflows/spec/badge.svg)
2
2
 
3
3
  `Jsonapi::QueryBuilder` serves the purpose of adding the json api query related SQL conditions to the already scoped
4
4
  collection, usually used in controller index actions.
@@ -27,9 +27,14 @@ Or install it yourself as:
27
27
 
28
28
  ```ruby
29
29
  class UserQuery < Jsonapi::QueryBuilder::BaseQuery
30
+ ## pagination
31
+ paginator Jsonapi::QueryBuilder::Paginator::Pagy # default paginator
32
+
30
33
  ## sorting
31
34
  default_sort created_at: :desc
32
- sorts_by :first_name, :last_name, :email
35
+ sorts_by :last_name
36
+ sorts_by :first_name, ->(collection, direction) { collection.order(name: direction) }
37
+ sorts_by :email, EmailSort
33
38
 
34
39
  ## filtering
35
40
  filters_by :first_name
@@ -54,6 +59,29 @@ current user permissions, or for any other type of scoping. It's only responsibi
54
59
  querying. Use `pundit` or similar for policy scoping, custom query objects for other scoping, and then pass the scoped
55
60
  collection to the `Jsonapi::QueryBuilder::BaseQuery` object.
56
61
 
62
+ ### Pagination
63
+ Pagination support is configurable using the `paginator` method to define the paginator. It defaults to the `Pagy`
64
+ paginator, a lightweight and fast paginator. Other paginators currently supported are `Kaminari` and an implementation
65
+ of keyset pagination. Before using these paginators we need to explicitly require the gems in our Gemfile and the
66
+ paginator file in question.
67
+ Additionally one can implement it's own paginator by inheriting from `Jsonapi::QueryBuilder::Paginator::BasePaginator`.
68
+ The minimum required implementation is a `#paginate` method that receives page params and returns a page of the
69
+ collection. It can return the pagination details as the second item of the returned array, that can be used in the
70
+ serializer for pagination metadata.
71
+ #### Using the Kaminari Paginator
72
+ ```ruby
73
+ require "jsonapi/query_builder/paginator/kaminari"
74
+
75
+ paginator Jsonapi::QueryBuilder::Paginator::Kaminari
76
+ ```
77
+
78
+ #### Using the Keyset Paginator
79
+ ```ruby
80
+ require "jsonapi/query_builder/paginator/keyset"
81
+
82
+ paginator Jsonapi::QueryBuilder::Paginator::Keyset
83
+ ```
84
+
57
85
  ### Sorting
58
86
  #### Ensuring deterministic results
59
87
  Sorting has a fallback to an unique attribute which defaults to the `id` attribute. This ensures deterministic paginated
@@ -71,14 +99,26 @@ are passed directly to the underlying active record relation, so the usual order
71
99
  ```ruby
72
100
  default_sort created_at: :desc
73
101
  ```
74
- #### Enabling sorting for attributes
102
+ #### Enabling simple sorting for attributes
75
103
  `sorts_by` denotes which attributes can be used for sorting. Sorting parameters are usually parsed from the
76
- `json:api` sort query parameter in order they are given. So `sort=-first_name,email` would translate to
77
- `{ first_name: :desc, email: :asc }`
104
+ `json:api` sort query parameter in the order they are given. So `sort=-first_name,email` would translate to
105
+ `{ first_name: :desc, email: :asc }`
106
+ ```ruby
107
+ sorts_by :first_name
108
+ sorts_by :email
109
+ ```
110
+ #### Sorting with lambdas
111
+ `sorts_by` also supports passing a lambda to implement a custom order or reorder function. The parameters passed to the
112
+ lamdba are collection and the direction of the order, which is either `:desc` or `:asc`.
78
113
  ```ruby
79
- sorts_by :first_name, :email
114
+ sorts_by :first_name, ->(collection, direction) { collection.order(name: direction) }
80
115
  ```
81
116
 
117
+ #### Sorting with sort classes
118
+ But since we're devout followers of the SOLID principles, we can define a sort class that responds to
119
+ `#results` method, which returns the sorted collection. Under the hood the sort class is initialized with
120
+ the current scope and the direction parameter.
121
+
82
122
  ### Filtering
83
123
 
84
124
  #### Simple exact match filters
@@ -94,10 +134,11 @@ filters_by :email, ->(collection, query) { collection.where('email ilike ?', "%#
94
134
  ```
95
135
 
96
136
  #### Filter classes
97
- But since we're devout followers of the SOLID principles, we can define a filter class that responds to
98
- `#results` method, which returns the filtered collection results. Under the hood the filter class is initialized with
99
- the current scope and the query parameter is passed to the call method. This is great if you're using query objects for
100
- ActiveRecord scopes, you can easily use them to filter as well.
137
+ We can define a filter class that responds to `#results` method, which returns the filtered collection results. Under
138
+ the hood the filter class is initialized with the current scope and the query parameter. However, if the object responds
139
+ to a `call` method it sends the current scope and the query parameter to that instead. This is great if you're using
140
+ query objects for ActiveRecord scopes, you can easily use them to filter with as well.
141
+
101
142
  ```ruby
102
143
  filters_by :type, TypeFilter
103
144
  ```
@@ -150,9 +191,12 @@ filters_by :type, TypeFilter, if: :correct_type?
150
191
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can
151
192
  also run `bin/console` for an interactive prompt that will allow you to experiment.
152
193
 
194
+ We're using `standardrb` and `lefthook`. You can install lefthook hooks via `lefthook install`. It will run linters and
195
+ standardrb checks before commits, and a bundle audit + whole spec suite before push.
196
+
153
197
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the
154
- version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version,
155
- push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
198
+ version number in `version.rb`, and then run `LEFTHOOK=0 bundle exec rake release`, which will create a git tag for the
199
+ version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
156
200
 
157
201
  ## Contributing
158
202
 
@@ -36,15 +36,20 @@ Gem::Specification.new do |spec|
36
36
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
37
37
  spec.require_paths = ["lib"]
38
38
 
39
- spec.required_ruby_version = "~> 2.5"
39
+ spec.required_ruby_version = ">= 2.5"
40
40
 
41
- spec.add_runtime_dependency "activerecord", ">= 5", "<= 6.1"
41
+ spec.add_runtime_dependency "activerecord", ">= 5"
42
42
  spec.add_runtime_dependency "pagy", "~> 3.5"
43
43
 
44
44
  spec.add_development_dependency "bundler", "~> 2.0"
45
45
  spec.add_development_dependency "rake", "~> 13.0"
46
46
  spec.add_development_dependency "rspec", "~> 3.0"
47
47
  spec.add_development_dependency "standardrb"
48
+ spec.add_development_dependency "standard"
48
49
  spec.add_development_dependency "rubocop-rspec"
49
50
  spec.add_development_dependency "lefthook"
51
+ spec.add_development_dependency "kaminari", "~> 1.2"
52
+ spec.add_development_dependency "activerecord"
53
+ spec.add_development_dependency "sqlite3"
54
+ spec.add_development_dependency "pry"
50
55
  end
data/lefthook.yml CHANGED
@@ -2,13 +2,13 @@ lint:
2
2
  commands: &lint
3
3
  lint-frozen-strings:
4
4
  glob: "*.rb"
5
- run: bundle exec rubocop {staged_files} --only Style/FrozenStringLiteralComment,Layout/EmptyLineAfterMagicComment --format quiet --auto-correct
5
+ run: bundle exec rubocop --only Style/FrozenStringLiteralComment,Layout/EmptyLineAfterMagicComment --format quiet --auto-correct
6
6
 
7
7
  rubocop:
8
8
  commands: &rubocop
9
9
  rubocop-rspec:
10
10
  glob: "spec/*"
11
- run: bundle exec rubocop {staged_files} --only RSpec --format quiet
11
+ run: bundle exec rubocop --only RSpec --format quiet
12
12
 
13
13
  pre-commit:
14
14
  parallel: true
@@ -5,11 +5,14 @@ module Jsonapi
5
5
  class BaseFilter
6
6
  attr_reader :collection, :query
7
7
 
8
+ # @param [ActiveRecord::Relation] collection
9
+ # @param [String] query the query value used for filtering
8
10
  def initialize(collection, query)
9
11
  @collection = collection
10
12
  @query = query
11
13
  end
12
14
 
15
+ # @return [ActiveRecord::Relation] Collection with the filter applied
13
16
  def results
14
17
  raise NotImplementedError, "#{self.class} should implement #results"
15
18
  end
@@ -1,41 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "jsonapi/query_builder/mixins/filtering"
3
+ require "jsonapi/query_builder/mixins/filter"
4
4
  require "jsonapi/query_builder/mixins/include"
5
5
  require "jsonapi/query_builder/mixins/paginate"
6
6
  require "jsonapi/query_builder/mixins/sort"
7
7
 
8
+ require "jsonapi/query_builder/paginator"
9
+
8
10
  module Jsonapi
9
11
  module QueryBuilder
10
12
  class BaseQuery
11
- include Mixins::Filtering
13
+ include Mixins::Filter
12
14
  include Mixins::Include
13
15
  include Mixins::Paginate
14
16
  include Mixins::Sort
15
17
 
16
- attr_reader :collection, :params
18
+ attr_accessor :collection, :params
17
19
 
20
+ # @param [ActiveRecord::Relation] collection
21
+ # @param [Hash] params Json:Api query parameters
18
22
  def initialize(collection, params)
19
23
  @collection = collection
20
24
  @params = params.deep_symbolize_keys
21
25
  end
22
26
 
27
+ # @return [ActiveRecord::Relation] A collection with eager loaded relationships based on include params, filtered,
28
+ # ordered and lastly, paginated.
29
+ # @note Pagination details are saved to an instance variable and can be accessed via the #pagination_details attribute reader
23
30
  def results
24
31
  collection
25
- .yield_self(&method(:sort))
26
32
  .yield_self(&method(:add_includes))
33
+ .yield_self(&method(:sort))
27
34
  .yield_self(&method(:filter))
28
35
  .yield_self(&method(:paginate))
29
36
  end
30
37
 
38
+ # @param [integer, string] id
39
+ # @return [Object]
40
+ # @raise [ActiveRecord::RecordNotFound] if the record by the id is not found
31
41
  def find(id)
32
42
  find_by! id: id
33
43
  end
34
44
 
45
+ # Finds the record by the id parameter the class is instantiated with
46
+ # @return (see #find)
47
+ # @raise (see #find)
35
48
  def record
36
49
  find_by! id: params[:id]
37
50
  end
38
51
 
52
+ # @param [Hash] kwargs Attributes with required values
53
+ # @return (see #find)
54
+ # @raise [ActiveRecord::RecordNotFound] if the record by the arguments is not found
39
55
  def find_by!(**kwargs)
40
56
  add_includes(collection).find_by!(kwargs)
41
57
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonapi
4
+ module QueryBuilder
5
+ class BaseSort
6
+ attr_reader :collection, :direction
7
+
8
+ # @param [ActiveRecord::Relation] collection
9
+ # @param [Symbol] direction of the ordering, one of :asc or :desc
10
+ def initialize(collection, direction)
11
+ @collection = collection
12
+ @direction = direction
13
+ end
14
+
15
+ # @return [ActiveRecord::Relation] Collection with order applied
16
+ def results
17
+ raise NotImplementedError, "#{self.class} should implement #results"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonapi
4
+ module QueryBuilder
5
+ module Errors
6
+ class UnpermittedSortParameters < ArgumentError
7
+ def initialize(unpermitted_parameters)
8
+ super [
9
+ unpermitted_parameters.to_sentence,
10
+ unpermitted_parameters.count == 1 ? "is not a" : "are not",
11
+ "permitted sort attribute".pluralize(unpermitted_parameters.count)
12
+ ].join(" ")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonapi
4
+ module QueryBuilder
5
+ module Mixins
6
+ module Filter
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def supported_filters
11
+ @supported_filters || {}
12
+ end
13
+
14
+ # Registers an attribute to filter by. Filters are applied in the order they are registered, if they are
15
+ # applicable, so in-memory filters should be registered last.
16
+ #
17
+ # @param [Symbol] attribute The attribute which can be used to filter by
18
+ # @param [proc, Class] filter A proc or a filter class, defaults to a simple where(attribute => query)
19
+ # @param [Hash] options Additional filter options
20
+ # @option options [Symbol] :query_parameter used to override the filter query parameter name
21
+ # @option options [Boolean] :allow_nil changes the filter conditional to allow explicit checks for an
22
+ # attribute null value
23
+ # @option options [proc, Symbol] :if Define the conditional for applying the filter. If passed a symbol, a
24
+ # method with that name is invoked on the instantiated filter object
25
+ # @option options [proc, Symbol] :unless Define the conditional for applying the filter. If passed a symbol, a
26
+ # method with that name is invoked on the instantiated filter object
27
+ #
28
+ # @example Change the query parameter name
29
+ # filters_by :first_name, query_parameter: 'name'
30
+ # # => collection.where(first_name: params.dig(:filter, :name)) if params.dig(:filter, :name).present?
31
+ #
32
+ # @example Allow checks for null values
33
+ # filters_by :first_name, allow_nil: true
34
+ # # => collection.where(first_name: params.dig(:filter, :first_name)) if params[:filter]&.key?(:first_name)
35
+ #
36
+ # @example Change the filter condition
37
+ # filters_by :first_name, if: ->(query) { query.length >= 2 }
38
+ # # => collection.where(first_name: params.dig(:filter, :first_name)) if params.dig(:filter, :first_name) >= 2
39
+ # filters_by :first_name, unless: ->(query) { query.length < 2 }
40
+ # # => collection.where(first_name: params.dig(:filter, :first_name)) unless params.dig(:filter, :first_name) < 2
41
+ # filters_by :type, TypeFilter, if: :correct_type?
42
+ # # => TypeFilter.new(collection, query).yield_self { |filter| filter.results if filter.correct_type? }
43
+ def filters_by(attribute, filter = nil, **options)
44
+ filter ||= ->(collection, query) { collection.where(attribute => query) }
45
+ @supported_filters = {**supported_filters, attribute => [filter, options]}
46
+ end
47
+ end
48
+
49
+ # Filters the passed relation with the default filter params (parsed from the queries params) or with explicitly
50
+ # passed filter parameters.
51
+ # Iterates through registered filters so that the filter application order is settable from the backend side
52
+ # instead of being dependent on the query order from the clients. If the filter condition for the filter
53
+ # strategy is met, then the filter is applied to the collection. If the strategy responds to a call method it
54
+ # calls it with the collection and parameter's parsed sort direction, otherwise it instantiates the filter class
55
+ # with the collection and the parameter's query value and calls for the results.
56
+ # @param [ActiveRecord::Relation] collection
57
+ # @param [Object] filter_params Optional explicit filter params
58
+ # @return [ActiveRecord::Relation, Array] An AR relation is returned unless filters need to resort to in-memory
59
+ # filtering strategy, then an array is returned.
60
+ def filter(collection, filter_params = send(:filter_params))
61
+ self.class.supported_filters.reduce(collection) do |filtered_collection, supported_filter|
62
+ filter, options = serialize_filter(supported_filter, collection: filtered_collection, params: filter_params)
63
+
64
+ next filtered_collection unless options[:conditions].all? { |type, condition|
65
+ check_condition(condition, type, filter: filter, query: options[:query])
66
+ }
67
+
68
+ filter.respond_to?(:call) ? filter.call(filtered_collection, options[:query]) : filter.results
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def filter_params
75
+ params[:filter] || {}
76
+ end
77
+
78
+ def serialize_filter(supported_filter, collection:, params:)
79
+ attribute, (filter, options) = *supported_filter
80
+
81
+ options[:query_parameter] = options[:query_parameter]&.to_sym || attribute
82
+ options[:query] = params[options[:query_parameter]].presence
83
+ options[:conditions] = options.slice(:if, :unless).presence ||
84
+ {if: options[:query].present? || options[:allow_nil] && params.key?(options[:query_parameter])}
85
+
86
+ filter = filter.new(collection, options[:query]) unless filter.respond_to?(:call)
87
+
88
+ [filter, options]
89
+ end
90
+
91
+ def check_condition(condition, type, **opts)
92
+ (type == :if) == case condition
93
+ when Proc
94
+ condition.call(opts[:query])
95
+ when Symbol
96
+ opts[:filter].send(condition)
97
+ else
98
+ condition
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -4,6 +4,11 @@ module Jsonapi
4
4
  module QueryBuilder
5
5
  module Mixins
6
6
  module Include
7
+ # Eager loads the relationships that will be included in the response, based on the Json:Api
8
+ # include query parameter.
9
+ # @param [ActiveRecord::Relation] collection
10
+ # @param [Object] include_params Optional explicit include params
11
+ # @return [ActiveRecord::Relation] Collection with eager loaded included relations
7
12
  def add_includes(collection, include_params = send(:include_params))
8
13
  collection.includes(formatted_include_params(include_params))
9
14
  end
@@ -4,14 +4,30 @@ module Jsonapi
4
4
  module QueryBuilder
5
5
  module Mixins
6
6
  module Paginate
7
- include Pagy::Backend
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ # Sets the paginator used to page the results. Defaults to Pagy
11
+ #
12
+ # @param [Jsonapi::QueryBuilder::Paginator::BasePaginator] paginator A subclass of BasePaginator
13
+ def paginator(paginator)
14
+ @paginator = paginator
15
+ end
16
+
17
+ def _paginator
18
+ @paginator || Paginator::Pagy
19
+ end
20
+ end
8
21
 
9
22
  attr_reader :pagination_details
10
23
 
24
+ # Paginates the collection and returns the requested page. Also sets the pagination details that can be used for
25
+ # displaying metadata in the Json:Api response.
26
+ # @param [ActiveRecord::Relation] collection
27
+ # @param [Object] page_params Optional explicit pagination params
28
+ # @return [ActiveRecord::Relation] Paged collection
11
29
  def paginate(collection, page_params = send(:page_params))
12
- @pagination_details, records = pagy collection, page: page_params[:number],
13
- items: page_params[:size],
14
- outset: page_params[:offset]
30
+ records, @pagination_details = self.class._paginator.new(collection).paginate(page_params)
15
31
 
16
32
  records
17
33
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonapi
4
+ module QueryBuilder
5
+ module Mixins
6
+ module Sort
7
+ class Param
8
+ attr_reader :descending, :attribute
9
+
10
+ def initialize(param)
11
+ @descending, @attribute = deserialize(param)
12
+ end
13
+
14
+ class << self
15
+ def deserialize_params(sort_params)
16
+ (sort_params || "").split(",").map(&method(:new))
17
+ end
18
+ end
19
+
20
+ def deserialize(param)
21
+ _, descending, attribute = param.strip.match(/^(?<descending>-)?(?<attribute>.*)$/).to_a
22
+
23
+ [descending, attribute]
24
+ end
25
+
26
+ def serialize
27
+ [descending, attribute].compact.join
28
+ end
29
+
30
+ def direction
31
+ descending.present? ? :desc : :asc
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "jsonapi/query_builder/mixins/sort/param"
4
+ require "jsonapi/query_builder/errors/unpermitted_sort_parameters"
5
+
3
6
  module Jsonapi
4
7
  module QueryBuilder
5
8
  module Mixins
6
9
  module Sort
7
10
  extend ActiveSupport::Concern
8
11
 
9
- UnpermittedSortParameters = Class.new ArgumentError
10
-
11
12
  class_methods do
12
13
  attr_reader :_default_sort
13
14
 
@@ -15,28 +16,57 @@ module Jsonapi
15
16
  @_unique_sort_attributes || [id: :asc]
16
17
  end
17
18
 
18
- def _sort_attributes
19
- @_sort_attributes || []
19
+ def supported_sorts
20
+ @supported_sorts || {}
20
21
  end
21
22
 
23
+ # Ensures deterministic ordering. Defaults to :id in ascending direction.
24
+ # @param [Array<Symbol, String, Hash>] attributes An array of attributes or a hash with the attribute and it's
25
+ # order direction.
22
26
  def unique_sort_attributes(*attributes)
23
27
  @_unique_sort_attributes = attributes
24
28
  end
29
+
25
30
  alias_method :unique_sort_attribute, :unique_sort_attributes
26
31
 
32
+ # The :default_sort: can be set to sort by any field like `created_at` timestamp or similar. It is only used
33
+ # if no sort parameter is set, unlike the `unique_sort_attribute` which is always appended as the last sort
34
+ # attribute. The parameters are passed directly to the underlying active record relation, so the usual
35
+ # ordering options are possible.
36
+ # @param [Symbol, Hash] options A default sort attribute or a Hash with the attribute and it's order direction.
27
37
  def default_sort(options)
28
38
  @_default_sort = options
29
39
  end
30
40
 
31
- def sorts_by(*attributes)
32
- @_sort_attributes = _sort_attributes + attributes
41
+ # Registers attribute that can be used for sorting. Sorting parameters are usually parsed from the `json:api`
42
+ # sort query parameter in the order they are given.
43
+ # @param [Symbol] attribute The "sortable" attribute
44
+ # @param [proc, Class] sort A proc or a sort class, defaults to a simple order(attribute => direction)
45
+ def sorts_by(attribute, sort = nil)
46
+ sort ||= ->(collection, direction) { collection.order(attribute => direction) }
47
+ @supported_sorts = {**supported_sorts, attribute => sort}
33
48
  end
34
49
  end
35
50
 
51
+ # Sorts the passed relation with the default sort params (parsed from the queries params) or with explicitly
52
+ # passed sort parameters.
53
+ # Parses each sort parameter and looks for the sorting strategy for it, if the strategy responds to a call
54
+ # method it calls it with the collection and parameter's parsed sort direction, otherwise it instantiates the
55
+ # sort class with the collection and the parameter's parsed sort direction and calls for the results. Finally it
56
+ # adds the unique sort attributes to enforce deterministic results. If sort params are blank, it adds the
57
+ # default sort attributes before setting the unique sort attributes.
58
+ # @param [ActiveRecord::Relation] collection
59
+ # @param [Object] sort_params Optional explicit sort params
60
+ # @return [ActiveRecord::Relation] Sorted relation
61
+ # @raise [Jsonapi::QueryBuilder::Errors::UnpermittedSortParameters] if not all sort parameters are
62
+ # permitted
36
63
  def sort(collection, sort_params = send(:sort_params))
64
+ sort_params = Param.deserialize_params(sort_params)
65
+ ensure_permitted_sort_params!(sort_params) if sort_params
66
+
37
67
  collection
38
- .reorder(sort_params.nil? ? self.class._default_sort : formatted_sort_params(sort_params))
39
- .tap(&method(:add_unique_order_attributes))
68
+ .yield_self { |c| add_order_attributes(c, sort_params) }
69
+ .yield_self(&method(:add_unique_order_attributes))
40
70
  end
41
71
 
42
72
  private
@@ -45,27 +75,30 @@ module Jsonapi
45
75
  params[:sort]
46
76
  end
47
77
 
48
- def add_unique_order_attributes(collection)
49
- collection.order(*self.class._unique_sort_attributes)
50
- end
78
+ def ensure_permitted_sort_params!(sort_params)
79
+ unpermitted_parameters = sort_params.map(&:attribute).map(&:to_sym) - self.class.supported_sorts.keys
80
+ return if unpermitted_parameters.size.zero?
51
81
 
52
- def formatted_sort_params(sort_params)
53
- sort_params
54
- .split(",")
55
- .map(&:strip)
56
- .to_h { |attribute| attribute.start_with?("-") ? [attribute[1..-1], :desc] : [attribute, :asc] }
57
- .symbolize_keys
58
- .tap(&method(:ensure_permitted_sort_params!))
82
+ raise Errors::UnpermittedSortParameters, unpermitted_parameters
59
83
  end
60
84
 
61
- def ensure_permitted_sort_params!(sort_params)
62
- return if (unpermitted_parameters = sort_params.keys - self.class._sort_attributes.map(&:to_sym)).size.zero?
85
+ def add_order_attributes(collection, sort_params)
86
+ return collection if self.class._default_sort.nil? && sort_params.blank?
87
+ return collection.order(self.class._default_sort) if sort_params.blank?
88
+
89
+ sort_params.reduce(collection) do |sorted_collection, sort_param|
90
+ sort = self.class.supported_sorts.fetch(sort_param.attribute.to_sym)
63
91
 
64
- raise UnpermittedSortParameters, [
65
- unpermitted_parameters.to_sentence,
66
- unpermitted_parameters.count == 1 ? "is not a" : "are not",
67
- "permitted sort attribute".pluralize(unpermitted_parameters.count)
68
- ].join(" ")
92
+ if sort.respond_to?(:call)
93
+ sort.call(sorted_collection, sort_param.direction)
94
+ else
95
+ sort.new(sorted_collection, sort_param.direction).results
96
+ end
97
+ end
98
+ end
99
+
100
+ def add_unique_order_attributes(collection)
101
+ collection.order(*self.class._unique_sort_attributes)
69
102
  end
70
103
  end
71
104
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonapi
4
+ module QueryBuilder
5
+ module Paginator
6
+ class BasePaginator
7
+ attr_reader :collection
8
+
9
+ # @param [ActiveRecord::Relation] collection
10
+ def initialize(collection)
11
+ @collection = collection
12
+ end
13
+
14
+ # @param [Hash] page_params
15
+ # @return [[ActiveRecord::Relation, Hash]] Records and pagination details
16
+ def paginate(page_params)
17
+ raise NotImplementedError, "#{self.class} should implement ##{__method__}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kaminari"
4
+
5
+ module Jsonapi
6
+ module QueryBuilder
7
+ module Paginator
8
+ class Kaminari < BasePaginator
9
+ def paginate(page_params)
10
+ paged_collection = collection
11
+ .page(page_params[:number])
12
+ .per(page_params[:size])
13
+ .padding(page_params[:offset])
14
+
15
+ [paged_collection, pagination_details(paged_collection, page_params)]
16
+ end
17
+
18
+ private
19
+
20
+ def pagination_details(collection, page_params)
21
+ {
22
+ number: collection.current_page,
23
+ size: collection.limit_value,
24
+ offset: page_params[:offset],
25
+ total: collection.total_count,
26
+ total_pages: collection.total_pages,
27
+ next_page: collection.next_page,
28
+ prev_page: collection.prev_page
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module Jsonapi
6
+ module QueryBuilder
7
+ module Paginator
8
+ class Keyset < BasePaginator
9
+ DEFAULT_DIRECTION = :after
10
+ DEFAULT_LIMIT = 25
11
+
12
+ def paginate(page_params)
13
+ page_params = extract_pagination_params(page_params)
14
+ records = apply_pagination(collection, page_params)
15
+
16
+ [records, page_params]
17
+ end
18
+
19
+ private
20
+
21
+ def extract_pagination_params(params)
22
+ {
23
+ column: params.fetch(:column, nil),
24
+ position: params.fetch(:position, nil),
25
+ direction: params.fetch(:direction, DEFAULT_DIRECTION),
26
+ limit: params.fetch(:limit, DEFAULT_LIMIT)
27
+ }
28
+ end
29
+
30
+ def apply_pagination(collection, pagination_params)
31
+ column = pagination_params[:column]
32
+ position = pagination_params[:position]
33
+ direction = pagination_params[:direction]
34
+ limit = pagination_params[:limit]
35
+
36
+ return collection unless column
37
+
38
+ collection = apply_order(collection, column, direction)
39
+ collection = collection.limit(limit.to_i)
40
+
41
+ return collection unless position
42
+
43
+ apply_filter(collection, column, position, direction)
44
+ end
45
+
46
+ def apply_order(collection, column, direction)
47
+ if direction.to_sym == DEFAULT_DIRECTION
48
+ collection.reorder(collection.arel_table[column].asc)
49
+ else
50
+ collection.reorder(collection.arel_table[column].desc)
51
+ end
52
+ end
53
+
54
+ def apply_filter(collection, column, position, direction)
55
+ if direction.to_sym == DEFAULT_DIRECTION
56
+ collection.where(collection.arel_table[column].gt(position))
57
+ else
58
+ collection.where(collection.arel_table[column].lt(position))
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pagy"
4
+ require "pagy/extras/items"
5
+
6
+ module Jsonapi
7
+ module QueryBuilder
8
+ module Paginator
9
+ class Pagy < BasePaginator
10
+ include ::Pagy::Backend
11
+
12
+ def paginate(page_params)
13
+ @params = {page: page_params}
14
+
15
+ pagination_details, records = pagy collection, page: page_params[:number],
16
+ items: page_params[:size],
17
+ outset: page_params[:offset]
18
+ [records, pagination_details]
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :params
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jsonapi/query_builder/paginator/base_paginator"
4
+ require "jsonapi/query_builder/paginator/pagy"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jsonapi
4
4
  module QueryBuilder
5
- VERSION = "0.1.6.pre"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -4,9 +4,8 @@ require "active_support/concern"
4
4
  require "active_support/core_ext/array/conversions"
5
5
  require "active_support/core_ext/hash/keys"
6
6
  require "active_support/core_ext/string/inflections"
7
- require "pagy"
8
- require "pagy/extras/items"
9
7
 
10
8
  require "jsonapi/query_builder/version"
11
9
  require "jsonapi/query_builder/base_query"
12
10
  require "jsonapi/query_builder/base_filter"
11
+ require "jsonapi/query_builder/base_sort"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jsonapi-query_builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6.pre
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jure Cindro
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-09-01 00:00:00.000000000 Z
11
+ date: 2021-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -17,9 +17,6 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '5'
20
- - - "<="
21
- - !ruby/object:Gem::Version
22
- version: '6.1'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
@@ -27,9 +24,6 @@ dependencies:
27
24
  - - ">="
28
25
  - !ruby/object:Gem::Version
29
26
  version: '5'
30
- - - "<="
31
- - !ruby/object:Gem::Version
32
- version: '6.1'
33
27
  - !ruby/object:Gem::Dependency
34
28
  name: pagy
35
29
  requirement: !ruby/object:Gem::Requirement
@@ -100,6 +94,20 @@ dependencies:
100
94
  - - ">="
101
95
  - !ruby/object:Gem::Version
102
96
  version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: standard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
103
111
  - !ruby/object:Gem::Dependency
104
112
  name: rubocop-rspec
105
113
  requirement: !ruby/object:Gem::Requirement
@@ -128,6 +136,62 @@ dependencies:
128
136
  - - ">="
129
137
  - !ruby/object:Gem::Version
130
138
  version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: kaminari
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '1.2'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '1.2'
153
+ - !ruby/object:Gem::Dependency
154
+ name: activerecord
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: sqlite3
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ - !ruby/object:Gem::Dependency
182
+ name: pry
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
131
195
  description: |
132
196
  `Jsonapi::QueryBuilder` serves the purpose of adding the json api query related SQL conditions to the already scoped collection, usually used in controller index actions.
133
197
 
@@ -138,12 +202,15 @@ executables: []
138
202
  extensions: []
139
203
  extra_rdoc_files: []
140
204
  files:
205
+ - ".github/workflows/lint.yml"
206
+ - ".github/workflows/spec.yml"
141
207
  - ".gitignore"
142
208
  - ".rspec"
143
209
  - ".rubocop.yml"
144
210
  - ".ruby-version"
145
211
  - CHANGELOG.md
146
212
  - Gemfile
213
+ - Gemfile.lock
147
214
  - LICENSE.txt
148
215
  - README.md
149
216
  - Rakefile
@@ -154,10 +221,18 @@ files:
154
221
  - lib/jsonapi/query_builder.rb
155
222
  - lib/jsonapi/query_builder/base_filter.rb
156
223
  - lib/jsonapi/query_builder/base_query.rb
157
- - lib/jsonapi/query_builder/mixins/filtering.rb
224
+ - lib/jsonapi/query_builder/base_sort.rb
225
+ - lib/jsonapi/query_builder/errors/unpermitted_sort_parameters.rb
226
+ - lib/jsonapi/query_builder/mixins/filter.rb
158
227
  - lib/jsonapi/query_builder/mixins/include.rb
159
228
  - lib/jsonapi/query_builder/mixins/paginate.rb
160
229
  - lib/jsonapi/query_builder/mixins/sort.rb
230
+ - lib/jsonapi/query_builder/mixins/sort/param.rb
231
+ - lib/jsonapi/query_builder/paginator.rb
232
+ - lib/jsonapi/query_builder/paginator/base_paginator.rb
233
+ - lib/jsonapi/query_builder/paginator/kaminari.rb
234
+ - lib/jsonapi/query_builder/paginator/keyset.rb
235
+ - lib/jsonapi/query_builder/paginator/pagy.rb
161
236
  - lib/jsonapi/query_builder/version.rb
162
237
  homepage: https://github.com/infinum/jsonapi-query_builder
163
238
  licenses:
@@ -172,14 +247,14 @@ require_paths:
172
247
  - lib
173
248
  required_ruby_version: !ruby/object:Gem::Requirement
174
249
  requirements:
175
- - - "~>"
250
+ - - ">="
176
251
  - !ruby/object:Gem::Version
177
252
  version: '2.5'
178
253
  required_rubygems_version: !ruby/object:Gem::Requirement
179
254
  requirements:
180
- - - ">"
255
+ - - ">="
181
256
  - !ruby/object:Gem::Version
182
- version: 1.3.1
257
+ version: '0'
183
258
  requirements: []
184
259
  rubygems_version: 3.0.3
185
260
  signing_key:
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Jsonapi
4
- module QueryBuilder
5
- module Mixins
6
- module Filtering
7
- extend ActiveSupport::Concern
8
-
9
- class_methods do
10
- def supported_filters
11
- @supported_filters || {}
12
- end
13
-
14
- def filters_by(attribute, filter = nil, **options)
15
- filter ||= ->(collection, query) { collection.where(attribute => query) }
16
- @supported_filters = {**supported_filters, attribute => [filter, options]}
17
- end
18
- end
19
-
20
- def filter(collection, filter_params = send(:filter_params))
21
- self.class.supported_filters.reduce(collection) do |filtered_collection, supported_filter|
22
- filter, options = serialize_filter(supported_filter, collection: filtered_collection, params: filter_params)
23
-
24
- next filtered_collection unless options[:conditions].all? { |type, condition|
25
- check_condition(condition, type, filter: filter, query: options[:query])
26
- }
27
-
28
- filter.respond_to?(:call) ? filter.call(filtered_collection, options[:query]) : filter.results
29
- end
30
- end
31
-
32
- private
33
-
34
- def filter_params
35
- params[:filter] || {}
36
- end
37
-
38
- def serialize_filter(supported_filter, collection:, params:)
39
- attribute, (filter, options) = *supported_filter
40
-
41
- options[:query_parameter] = options[:query_parameter]&.to_sym || attribute
42
- options[:query] = params[options[:query_parameter]].presence
43
- options[:conditions] = options.slice(:if, :unless).presence ||
44
- {if: options[:query].present? || options[:allow_nil] && params.key?(options[:query_parameter])}
45
-
46
- filter = filter.new(collection, options[:query]) unless filter.respond_to?(:call)
47
-
48
- [filter, options]
49
- end
50
-
51
- def check_condition(condition, type, **opts)
52
- (type == :if) == case condition
53
- when Proc
54
- condition.call(opts[:query])
55
- when Symbol
56
- opts[:filter].send(condition)
57
- else
58
- condition
59
- end
60
- end
61
- end
62
- end
63
- end
64
- end