jsonapi-query_builder 0.1.4.pre → 0.1.9

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: cfe8817a1796e39ffa45d7ca78ea92fb476b8b114e59f058bf94509996fd72c7
4
- data.tar.gz: b321a790c6cb02112b90cb458e498705ed66227506fc76ae8790a04fdb603e54
3
+ metadata.gz: 8c919f735d39662624a6cbda5429e5b31941578628bfa6adadf26334a0b9a1f3
4
+ data.tar.gz: 3ff9cadb9e8b8cea55447e6b86c6c8f3e16953beda7edee3ff8aac7e230753b8
5
5
  SHA512:
6
- metadata.gz: 1e418da7f6940422f315202f10bc603a138144787b5e97168a6e40b85719e624d2e862fb460dc4eb754e8890ee55ec53ad2e66a75cc27b1427252f1c8e8fd502
7
- data.tar.gz: c6acc4f51df8f314fd2bb650e54160c0c4b549059a16964f8814d103abbd4f0eae1f666f02a59564f9efaf7e960f7e08c21874769bdf0f33ccb86973788f2cff
6
+ metadata.gz: 51d7f79e29b103e6aeac59dee1283b09ae3a9b3dc035099e341a2f9891a41fad1d7e05357198b4b08846226ac0421d195b3d583d9936a326003cfab076645ded
7
+ data.tar.gz: 6f11c9b84669ace622fb6b8d5d8917abeb9ced15ad1dc68e988380e282a7be9aa9d3e73baa317a49212c161c9aa351461c251fd89fd882b5e9c05847626ad698
@@ -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,11 @@
1
+ # Change log
2
+
3
+ ## 0.1.9 (2021-05-07)
4
+
5
+ - [#18](https://github.com/infinum/jsonapi-query_builder/pull/18): Remove Ruby `to` version.
6
+ - [#9](https://github.com/infinum/jsonapi-query_builder/pull/9): added github actions
7
+
8
+ ## 0.1.8 (2021-01-25)
9
+
10
+ - [#8](https://github.com/infinum/jsonapi-query_builder/pull/8): add support for ruby 3.0
11
+
data/Gemfile.lock ADDED
@@ -0,0 +1,92 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ jsonapi-query_builder (0.1.9)
5
+ activerecord (>= 5)
6
+ pagy (~> 3.5)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ activemodel (6.1.3.1)
12
+ activesupport (= 6.1.3.1)
13
+ activerecord (6.1.3.1)
14
+ activemodel (= 6.1.3.1)
15
+ activesupport (= 6.1.3.1)
16
+ activesupport (6.1.3.1)
17
+ concurrent-ruby (~> 1.0, >= 1.0.2)
18
+ i18n (>= 1.6, < 2)
19
+ minitest (>= 5.1)
20
+ tzinfo (~> 2.0)
21
+ zeitwerk (~> 2.3)
22
+ ast (2.4.1)
23
+ concurrent-ruby (1.1.8)
24
+ diff-lcs (1.4.4)
25
+ i18n (1.8.10)
26
+ concurrent-ruby (~> 1.0)
27
+ lefthook (0.7.2)
28
+ minitest (5.14.4)
29
+ pagy (3.11.0)
30
+ parallel (1.20.1)
31
+ parser (3.0.0.0)
32
+ ast (~> 2.4.1)
33
+ rainbow (3.0.0)
34
+ rake (13.0.3)
35
+ regexp_parser (2.0.3)
36
+ rexml (3.2.5)
37
+ rspec (3.10.0)
38
+ rspec-core (~> 3.10.0)
39
+ rspec-expectations (~> 3.10.0)
40
+ rspec-mocks (~> 3.10.0)
41
+ rspec-core (3.10.1)
42
+ rspec-support (~> 3.10.0)
43
+ rspec-expectations (3.10.1)
44
+ diff-lcs (>= 1.2.0, < 2.0)
45
+ rspec-support (~> 3.10.0)
46
+ rspec-mocks (3.10.1)
47
+ diff-lcs (>= 1.2.0, < 2.0)
48
+ rspec-support (~> 3.10.0)
49
+ rspec-support (3.10.1)
50
+ rubocop (1.7.0)
51
+ parallel (~> 1.10)
52
+ parser (>= 2.7.1.5)
53
+ rainbow (>= 2.2.2, < 4.0)
54
+ regexp_parser (>= 1.8, < 3.0)
55
+ rexml
56
+ rubocop-ast (>= 1.2.0, < 2.0)
57
+ ruby-progressbar (~> 1.7)
58
+ unicode-display_width (>= 1.4.0, < 2.0)
59
+ rubocop-ast (1.4.0)
60
+ parser (>= 2.7.1.5)
61
+ rubocop-performance (1.9.2)
62
+ rubocop (>= 0.90.0, < 2.0)
63
+ rubocop-ast (>= 0.4.0)
64
+ rubocop-rspec (2.1.0)
65
+ rubocop (~> 1.0)
66
+ rubocop-ast (>= 1.1.0)
67
+ ruby-progressbar (1.11.0)
68
+ standard (0.11.0)
69
+ rubocop (= 1.7.0)
70
+ rubocop-performance (= 1.9.2)
71
+ standardrb (1.0.0)
72
+ standard
73
+ tzinfo (2.0.4)
74
+ concurrent-ruby (~> 1.0)
75
+ unicode-display_width (1.7.0)
76
+ zeitwerk (2.4.2)
77
+
78
+ PLATFORMS
79
+ ruby
80
+
81
+ DEPENDENCIES
82
+ bundler (~> 2.0)
83
+ jsonapi-query_builder!
84
+ lefthook
85
+ rake (~> 13.0)
86
+ rspec (~> 3.0)
87
+ rubocop-rspec
88
+ standard
89
+ standardrb
90
+
91
+ BUNDLED WITH
92
+ 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.
@@ -29,7 +29,9 @@ Or install it yourself as:
29
29
  class UserQuery < Jsonapi::QueryBuilder::BaseQuery
30
30
  ## sorting
31
31
  default_sort created_at: :desc
32
- sorts_by :first_name, :last_name, :email
32
+ sorts_by :last_name
33
+ sorts_by :first_name, ->(collection, direction) { collection.order(name: direction) }
34
+ sorts_by :email, EmailSort
33
35
 
34
36
  ## filtering
35
37
  filters_by :first_name
@@ -71,13 +73,25 @@ are passed directly to the underlying active record relation, so the usual order
71
73
  ```ruby
72
74
  default_sort created_at: :desc
73
75
  ```
74
- #### Enabling sorting for attributes
76
+ #### Enabling simple sorting for attributes
75
77
  `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 }`
78
+ `json:api` sort query parameter in the order they are given. So `sort=-first_name,email` would translate to
79
+ `{ first_name: :desc, email: :asc }`
78
80
  ```ruby
79
- sorts_by :first_name, :email
81
+ sorts_by :first_name
82
+ sorts_by :email
80
83
  ```
84
+ #### Sorting with lambdas
85
+ `sorts_by` also supports passing a lambda to implement a custom order or reorder function. The parameters passed to the
86
+ lamdba are collection and the direction of the order, which is either `:desc` or `:asc`.
87
+ ```ruby
88
+ sorts_by :first_name, ->(collection, direction) { collection.order(name: direction) }
89
+ ```
90
+
91
+ #### Sorting with sort classes
92
+ But since we're devout followers of the SOLID principles, we can define a sort class that responds to
93
+ `#results` method, which returns the sorted collection. Under the hood the sort class is initialized with
94
+ the current scope and the direction parameter.
81
95
 
82
96
  ### Filtering
83
97
 
@@ -94,10 +108,11 @@ filters_by :email, ->(collection, query) { collection.where('email ilike ?', "%#
94
108
  ```
95
109
 
96
110
  #### 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.
111
+ We can define a filter class that responds to `#results` method, which returns the filtered collection results. Under
112
+ the hood the filter class is initialized with the current scope and the query parameter. However, if the object responds
113
+ to a `call` method it sends the current scope and the query parameter to that instead. This is great if you're using
114
+ query objects for ActiveRecord scopes, you can easily use them to filter with as well.
115
+
101
116
  ```ruby
102
117
  filters_by :type, TypeFilter
103
118
  ```
@@ -150,9 +165,12 @@ filters_by :type, TypeFilter, if: :correct_type?
150
165
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can
151
166
  also run `bin/console` for an interactive prompt that will allow you to experiment.
152
167
 
168
+ We're using `standardrb` and `lefthook`. You can install lefthook hooks via `lefthook install`. It will run linters and
169
+ standardrb checks before commits, and a bundle audit + whole spec suite before push.
170
+
153
171
  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).
172
+ version number in `version.rb`, and then run `LEFTHOOK=0 bundle exec rake release`, which will create a git tag for the
173
+ version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
156
174
 
157
175
  ## Contributing
158
176
 
@@ -36,15 +36,16 @@ 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"
50
51
  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
@@ -10,3 +10,4 @@ require "pagy/extras/items"
10
10
  require "jsonapi/query_builder/version"
11
11
  require "jsonapi/query_builder/base_query"
12
12
  require "jsonapi/query_builder/base_filter"
13
+ require "jsonapi/query_builder/base_sort"
@@ -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,6 +1,6 @@
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"
@@ -8,25 +8,51 @@ require "jsonapi/query_builder/mixins/sort"
8
8
  module Jsonapi
9
9
  module QueryBuilder
10
10
  class BaseQuery
11
- include Mixins::Filtering
11
+ include Mixins::Filter
12
12
  include Mixins::Include
13
13
  include Mixins::Paginate
14
14
  include Mixins::Sort
15
15
 
16
- attr_reader :collection, :params
16
+ attr_accessor :collection, :params
17
17
 
18
+ # @param [ActiveRecord::Relation] collection
19
+ # @param [Hash] params Json:Api query parameters
18
20
  def initialize(collection, params)
19
21
  @collection = collection
20
22
  @params = params.deep_symbolize_keys
21
23
  end
22
24
 
25
+ # @return [ActiveRecord::Relation] A collection with eager loaded relationships based on include params, filtered,
26
+ # ordered and lastly, paginated.
27
+ # @note Pagination details are saved to an instance variable and can be accessed via the #pagination_details attribute reader
23
28
  def results
24
29
  collection
25
- .yield_self(&method(:sort))
26
30
  .yield_self(&method(:add_includes))
31
+ .yield_self(&method(:sort))
27
32
  .yield_self(&method(:filter))
28
33
  .yield_self(&method(:paginate))
29
34
  end
35
+
36
+ # @param [integer, string] id
37
+ # @return [Object]
38
+ # @raise [ActiveRecord::RecordNotFound] if the record by the id is not found
39
+ def find(id)
40
+ find_by! id: id
41
+ end
42
+
43
+ # Finds the record by the id parameter the class is instantiated with
44
+ # @return (see #find)
45
+ # @raise (see #find)
46
+ def record
47
+ find_by! id: params[:id]
48
+ end
49
+
50
+ # @param [Hash] kwargs Attributes with required values
51
+ # @return (see #find)
52
+ # @raise [ActiveRecord::RecordNotFound] if the record by the arguments is not found
53
+ def find_by!(**kwargs)
54
+ add_includes(collection).find_by!(kwargs)
55
+ end
30
56
  end
31
57
  end
32
58
  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,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
@@ -8,6 +8,11 @@ module Jsonapi
8
8
 
9
9
  attr_reader :pagination_details
10
10
 
11
+ # Paginates the collection and returns the requested page. Also sets the pagination details that can be used for
12
+ # displaying metadata in the Json:Api response.
13
+ # @param [ActiveRecord::Relation] collection
14
+ # @param [Object] page_params Optional explicit pagination params
15
+ # @return [ActiveRecord::Relation] Paged collection
11
16
  def paginate(collection, page_params = send(:page_params))
12
17
  @pagination_details, records = pagy collection, page: page_params[:number],
13
18
  items: page_params[:size],
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "jsonapi/query_builder/mixins/sort/param"
4
+
3
5
  module Jsonapi
4
6
  module QueryBuilder
5
7
  module Mixins
@@ -15,28 +17,57 @@ module Jsonapi
15
17
  @_unique_sort_attributes || [id: :asc]
16
18
  end
17
19
 
18
- def _sort_attributes
19
- @_sort_attributes || []
20
+ def supported_sorts
21
+ @supported_sorts || {}
20
22
  end
21
23
 
24
+ # Ensures deterministic ordering. Defaults to :id in ascending direction.
25
+ # @param [Array<Symbol, String, Hash>] attributes An array of attributes or a hash with the attribute and it's
26
+ # order direction.
22
27
  def unique_sort_attributes(*attributes)
23
28
  @_unique_sort_attributes = attributes
24
29
  end
30
+
25
31
  alias_method :unique_sort_attribute, :unique_sort_attributes
26
32
 
33
+ # The :default_sort: can be set to sort by any field like `created_at` timestamp or similar. It is only used
34
+ # if no sort parameter is set, unlike the `unique_sort_attribute` which is always appended as the last sort
35
+ # attribute. The parameters are passed directly to the underlying active record relation, so the usual
36
+ # ordering options are possible.
37
+ # @param [Symbol, Hash] options A default sort attribute or a Hash with the attribute and it's order direction.
27
38
  def default_sort(options)
28
39
  @_default_sort = options
29
40
  end
30
41
 
31
- def sorts_by(*attributes)
32
- @_sort_attributes = _sort_attributes + attributes
42
+ # Registers attribute that can be used for sorting. Sorting parameters are usually parsed from the `json:api`
43
+ # sort query parameter in the order they are given.
44
+ # @param [Symbol] attribute The "sortable" attribute
45
+ # @param [proc, Class] sort A proc or a sort class, defaults to a simple order(attribute => direction)
46
+ def sorts_by(attribute, sort = nil)
47
+ sort ||= ->(collection, direction) { collection.order(attribute => direction) }
48
+ @supported_sorts = {**supported_sorts, attribute => sort}
33
49
  end
34
50
  end
35
51
 
52
+ # Sorts the passed relation with the default sort params (parsed from the queries params) or with explicitly
53
+ # passed sort parameters.
54
+ # Parses each sort parameter and looks for the sorting strategy for it, if the strategy responds to a call
55
+ # method it calls it with the collection and parameter's parsed sort direction, otherwise it instantiates the
56
+ # sort class with the collection and the parameter's parsed sort direction and calls for the results. Finally it
57
+ # adds the unique sort attributes to enforce deterministic results. If sort params are blank, it adds the
58
+ # default sort attributes before setting the unique sort attributes.
59
+ # @param [ActiveRecord::Relation] collection
60
+ # @param [Object] sort_params Optional explicit sort params
61
+ # @return [ActiveRecord::Relation] Sorted relation
62
+ # @raise [Jsonapi::QueryBuilder::Mixins::Sort::UnpermittedSortParameters] if not all sort parameters are
63
+ # permitted
36
64
  def sort(collection, sort_params = send(:sort_params))
65
+ sort_params = Param.deserialize_params(sort_params)
66
+ ensure_permitted_sort_params!(sort_params) if sort_params
67
+
37
68
  collection
38
- .reorder(sort_params.nil? ? self.class._default_sort : formatted_sort_params(sort_params))
39
- .tap(&method(:add_unique_order_attributes))
69
+ .yield_self { |c| add_order_attributes(c, sort_params) }
70
+ .yield_self(&method(:add_unique_order_attributes))
40
71
  end
41
72
 
42
73
  private
@@ -45,21 +76,9 @@ module Jsonapi
45
76
  params[:sort]
46
77
  end
47
78
 
48
- def add_unique_order_attributes(collection)
49
- collection.order(*self.class._unique_sort_attributes)
50
- end
51
-
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!))
59
- end
60
-
61
79
  def ensure_permitted_sort_params!(sort_params)
62
- return if (unpermitted_parameters = sort_params.keys - self.class._sort_attributes.map(&:to_sym)).size.zero?
80
+ unpermitted_parameters = sort_params.map(&:attribute).map(&:to_sym) - self.class.supported_sorts.keys
81
+ return if unpermitted_parameters.size.zero?
63
82
 
64
83
  raise UnpermittedSortParameters, [
65
84
  unpermitted_parameters.to_sentence,
@@ -67,6 +86,25 @@ module Jsonapi
67
86
  "permitted sort attribute".pluralize(unpermitted_parameters.count)
68
87
  ].join(" ")
69
88
  end
89
+
90
+ def add_order_attributes(collection, sort_params)
91
+ return collection if self.class._default_sort.nil? && sort_params.blank?
92
+ return collection.order(self.class._default_sort) if sort_params.blank?
93
+
94
+ sort_params.reduce(collection) do |sorted_collection, sort_param|
95
+ sort = self.class.supported_sorts.fetch(sort_param.attribute.to_sym)
96
+
97
+ if sort.respond_to?(:call)
98
+ sort.call(sorted_collection, sort_param.direction)
99
+ else
100
+ sort.new(sorted_collection, sort_param.direction).results
101
+ end
102
+ end
103
+ end
104
+
105
+ def add_unique_order_attributes(collection)
106
+ collection.order(*self.class._unique_sort_attributes)
107
+ end
70
108
  end
71
109
  end
72
110
  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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jsonapi
4
4
  module QueryBuilder
5
- VERSION = "0.1.4.pre"
5
+ VERSION = "0.1.9"
6
6
  end
7
7
  end
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.4.pre
4
+ version: 0.1.9
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-08-18 00:00:00.000000000 Z
11
+ date: 2021-05-07 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
@@ -138,12 +146,15 @@ executables: []
138
146
  extensions: []
139
147
  extra_rdoc_files: []
140
148
  files:
149
+ - ".github/workflows/lint.yml"
150
+ - ".github/workflows/spec.yml"
141
151
  - ".gitignore"
142
152
  - ".rspec"
143
153
  - ".rubocop.yml"
144
154
  - ".ruby-version"
145
155
  - CHANGELOG.md
146
156
  - Gemfile
157
+ - Gemfile.lock
147
158
  - LICENSE.txt
148
159
  - README.md
149
160
  - Rakefile
@@ -154,10 +165,12 @@ files:
154
165
  - lib/jsonapi/query_builder.rb
155
166
  - lib/jsonapi/query_builder/base_filter.rb
156
167
  - lib/jsonapi/query_builder/base_query.rb
157
- - lib/jsonapi/query_builder/mixins/filtering.rb
168
+ - lib/jsonapi/query_builder/base_sort.rb
169
+ - lib/jsonapi/query_builder/mixins/filter.rb
158
170
  - lib/jsonapi/query_builder/mixins/include.rb
159
171
  - lib/jsonapi/query_builder/mixins/paginate.rb
160
172
  - lib/jsonapi/query_builder/mixins/sort.rb
173
+ - lib/jsonapi/query_builder/mixins/sort/param.rb
161
174
  - lib/jsonapi/query_builder/version.rb
162
175
  homepage: https://github.com/infinum/jsonapi-query_builder
163
176
  licenses:
@@ -172,14 +185,14 @@ require_paths:
172
185
  - lib
173
186
  required_ruby_version: !ruby/object:Gem::Requirement
174
187
  requirements:
175
- - - "~>"
188
+ - - ">="
176
189
  - !ruby/object:Gem::Version
177
190
  version: '2.5'
178
191
  required_rubygems_version: !ruby/object:Gem::Requirement
179
192
  requirements:
180
- - - ">"
193
+ - - ">="
181
194
  - !ruby/object:Gem::Version
182
- version: 1.3.1
195
+ version: '0'
183
196
  requirements: []
184
197
  rubygems_version: 3.0.3
185
198
  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