jsonapi-query_builder 0.1.6.pre → 0.2.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 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