Skip to content

Commit 9b979e7

Browse files
Merge pull request #31 from infinum/feature/dynamic-sorts
2 parents 6a3d680 + aad5ee7 commit 9b979e7

File tree

10 files changed

+303
-18
lines changed

10 files changed

+303
-18
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,38 @@ But since we're devout followers of the SOLID principles, we can define a sort c
142142
which returns the sorted collection. Under the hood the sort class is initialized with the current scope and the
143143
direction parameter.
144144
145+
#### Dynamic sorting (prefix-based)
146+
147+
Sometimes you want to allow sorting by a dynamic subset of attributes that share a common prefix (e.g., JSON/JSONB keys, translated columns, join records). You can register a dynamic sort by attribute prefix using `dynamically_sorts_by`.
148+
149+
- The configured prefix is matched against each parsed sort attribute.
150+
- The prefix is stripped and only the dynamic part is passed to your sort handler.
151+
- You can provide either a lambda/proc or a class. The callable receives `(collection, dynamic_attribute, direction)`.
152+
153+
Example with a lambda (PostgreSQL JSONB text value):
154+
155+
```ruby
156+
# Allows sorting by any key in the `data` column: e.g. sort=-data.name,data.created_at
157+
dynamically_sorts_by :'data.', ->(collection, attribute, direction) {
158+
# attribute is the part after the prefix, e.g. "name" or "created_at"
159+
quoted_attribute = ActiveRecord::Base.connection.quote(attribute)
160+
collection.order(Arel.sql("(data->>#{quoted_attribute}) #{direction}"))
161+
}
162+
```
163+
164+
Example with a sort class (PostgreSQL JSONB text value):
165+
166+
```ruby
167+
class DataSort < Jsonapi::QueryBuilder::DynamicSort
168+
def results
169+
quoted_attribute = ActiveRecord::Base.connection.quote(dynamic_attribute)
170+
collection.order(Arel.sql("(data->>#{quoted_attribute}) #{direction}"))
171+
end
172+
end
173+
174+
dynamically_sorts_by :'data.', DataSort
175+
```
176+
145177
### Filtering
146178
147179
#### Simple exact match filters

lib/jsonapi/query_builder.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
require "jsonapi/query_builder/base_query"
1010
require "jsonapi/query_builder/base_filter"
1111
require "jsonapi/query_builder/base_sort"
12+
require "jsonapi/query_builder/dynamic_sort"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
require "jsonapi/query_builder/base_sort"
4+
5+
module Jsonapi
6+
module QueryBuilder
7+
class DynamicSort < BaseSort
8+
attr_reader :dynamic_attribute
9+
10+
# @param [ActiveRecord::Relation] collection
11+
# @param [Symbol] dynamic_attribute, which attribute to dynamically sort by
12+
# @param [Symbol] direction of the ordering, one of :asc or :desc
13+
def initialize(collection, dynamic_attribute, direction = :asc)
14+
super(collection, direction)
15+
@dynamic_attribute = dynamic_attribute
16+
end
17+
18+
# @return [ActiveRecord::Relation] Collection with order applied
19+
def results
20+
raise NotImplementedError, "#{self.class} should implement #results"
21+
end
22+
end
23+
end
24+
end

lib/jsonapi/query_builder/mixins/sort.rb

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22

33
require "jsonapi/query_builder/mixins/sort/param"
4+
require "jsonapi/query_builder/mixins/sort/static"
5+
require "jsonapi/query_builder/mixins/sort/dynamic"
46
require "jsonapi/query_builder/errors/unpermitted_sort_parameters"
57

68
module Jsonapi
@@ -16,8 +18,14 @@ def _unique_sort_attributes
1618
@_unique_sort_attributes || [id: :asc]
1719
end
1820

21+
# @return [Hash<Symbol, Jsonapi::QueryBuilder::Mixins::Sort::Static>] Supported sorts
1922
def supported_sorts
20-
@supported_sorts || {}
23+
@supported_sorts ||= {}
24+
end
25+
26+
# @return [Array<Jsonapi::QueryBuilder::Mixins::Sort::Dynamic>] Supported dynamic sorts
27+
def supported_dynamic_sorts
28+
@supported_dynamic_sorts ||= []
2129
end
2230

2331
# Ensures deterministic ordering. Defaults to :id in ascending direction.
@@ -43,8 +51,14 @@ def default_sort(options)
4351
# @param [Symbol] attribute The "sortable" attribute
4452
# @param [proc, Class] sort A proc or a sort class, defaults to a simple order(attribute => direction)
4553
def sorts_by(attribute, sort = nil)
46-
sort ||= ->(collection, direction) { collection.order(attribute => direction) }
47-
@supported_sorts = {**supported_sorts, attribute => sort}
54+
supported_sorts[attribute] = Sort::Static.new(attribute, sort)
55+
end
56+
57+
# Registers an attribute prefix that can be dynamically used for sorting. Attribute prefix is stripped from parsed sort parameter and passed to the sort proc or class.
58+
# @param [Symbol] attribute_prefix The "sortable" attribute prefix, e.g. `:'data.'` for sorting by `data.name` and `data.created_at`
59+
# @param [proc, Class] sort A proc or a sort class, defaults to a simple order(attribute => direction)
60+
def dynamically_sorts_by(attribute_prefix, sort)
61+
supported_dynamic_sorts << Sort::Dynamic.new(attribute_prefix, sort)
4862
end
4963
end
5064

@@ -76,7 +90,9 @@ def sort_params
7690
end
7791

7892
def ensure_permitted_sort_params!(sort_params)
79-
unpermitted_parameters = sort_params.map(&:attribute).map(&:to_sym) - self.class.supported_sorts.keys
93+
unpermitted_parameters = sort_params.map(&:attribute).filter do |attribute|
94+
!self.class.supported_sorts.key?(attribute.to_sym) && self.class.supported_dynamic_sorts.none? { |dynamic_sort| dynamic_sort.matches?(attribute) }
95+
end
8096
return if unpermitted_parameters.size.zero?
8197

8298
raise Errors::UnpermittedSortParameters, unpermitted_parameters
@@ -87,13 +103,11 @@ def add_order_attributes(collection, sort_params)
87103
return sort_by_default(collection) if sort_params.blank?
88104

89105
sort_params.reduce(collection) do |sorted_collection, sort_param|
90-
sort = self.class.supported_sorts.fetch(sort_param.attribute.to_sym)
91-
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
106+
sort = self.class.supported_sorts.fetch(sort_param.attribute.to_sym) do
107+
self.class.supported_dynamic_sorts.find { |dynamic_sort| dynamic_sort.matches?(sort_param.attribute) }
96108
end
109+
110+
sort.results(sorted_collection, sort_param)
97111
end
98112
end
99113

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
module Jsonapi
4+
module QueryBuilder
5+
module Mixins
6+
module Sort
7+
class Dynamic
8+
attr_reader :attribute_prefix, :sort
9+
10+
def initialize(attribute_prefix, sort)
11+
@attribute_prefix = attribute_prefix.to_s
12+
@sort = sort
13+
end
14+
15+
def matches?(sort_attribute)
16+
sort_attribute.to_s.start_with?(attribute_prefix)
17+
end
18+
19+
def results(collection, sort_param)
20+
dynamic_attribute = sort_param.attribute.delete_prefix(attribute_prefix)
21+
if sort.respond_to?(:call)
22+
sort.call(collection, dynamic_attribute, sort_param.direction)
23+
else
24+
sort.new(collection, dynamic_attribute, sort_param.direction).results
25+
end
26+
end
27+
end
28+
end
29+
end
30+
end
31+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module Jsonapi
4+
module QueryBuilder
5+
module Mixins
6+
module Sort
7+
class Static
8+
attr_reader :attribute, :sort
9+
10+
def initialize(attribute, sort)
11+
@attribute = attribute
12+
@sort = sort || ->(collection, direction) { collection.order(attribute => direction) }
13+
end
14+
15+
def results(collection, sort_param)
16+
if sort.respond_to?(:call)
17+
sort.call(collection, sort_param.direction)
18+
else
19+
sort.new(collection, sort_param.direction).results
20+
end
21+
end
22+
end
23+
end
24+
end
25+
end
26+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Jsonapi::QueryBuilder::DynamicSort do
4+
let(:sort_class) { Class.new(described_class) }
5+
6+
before do
7+
stub_const "FakeSort", sort_class
8+
end
9+
10+
it "defaults to ascending sort direction" do
11+
expect(FakeSort.new(instance_double("collection"), "attribute")).to have_attributes(direction: :asc)
12+
end
13+
14+
context "with required interface methods" do
15+
it "raises an error for results method" do
16+
expect { FakeSort.new(instance_double("collection"), "attribute", :desc).results }.to raise_error(
17+
NotImplementedError, "FakeSort should implement #results"
18+
)
19+
end
20+
end
21+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Jsonapi::QueryBuilder::Mixins::Sort::Dynamic do
4+
subject(:dynamic_sort) { described_class.new(:"data.", sort) }
5+
6+
let(:collection) { instance_double("collection") }
7+
let(:sort) { ->(collection, attribute, direction) { collection.order(attribute => direction) } }
8+
let(:sort_param) { Jsonapi::QueryBuilder::Mixins::Sort::Param.new("-data.description") }
9+
10+
before do
11+
allow(collection).to receive(:order).and_return(collection)
12+
end
13+
14+
describe "#matches?" do
15+
context "when sort attribute starts with configured prefix" do
16+
it "returns true" do
17+
expect(dynamic_sort.matches?(:"data.description")).to be true
18+
end
19+
end
20+
21+
context "when sort attribute does not match" do
22+
it "returns false" do
23+
expect(dynamic_sort.matches?(:description)).to be false
24+
end
25+
end
26+
end
27+
28+
describe "#results" do
29+
context "when sort is a Proc" do
30+
it "calls the provided proc" do
31+
dynamic_sort.results(collection, sort_param)
32+
33+
expect(collection).to have_received(:order).with("description" => :desc)
34+
end
35+
end
36+
37+
context "when sort is a class" do
38+
let(:sort_class_instance) { instance_double("SortClass", results: collection) }
39+
let(:sort) { SortClass }
40+
41+
before do
42+
class_double("SortClass", new: sort_class_instance).as_stubbed_const
43+
end
44+
45+
it "uses the provided sort class", :aggregate_failures do
46+
dynamic_sort.results(collection, sort_param)
47+
48+
expect(SortClass).to have_received(:new).with(collection, "description", :desc)
49+
expect(sort_class_instance).to have_received(:results)
50+
end
51+
end
52+
end
53+
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Jsonapi::QueryBuilder::Mixins::Sort::Static do
4+
subject(:static_sort) { described_class.new(:description, sort) }
5+
6+
let(:collection) { instance_double("collection") }
7+
8+
let(:sort_param) { Jsonapi::QueryBuilder::Mixins::Sort::Param.new("-description") }
9+
10+
before do
11+
allow(collection).to receive(:order).and_return(collection)
12+
end
13+
14+
describe "#results" do
15+
context "when sort is not given" do
16+
let(:sort) { nil }
17+
18+
it "defaults to ordering collection by attribute name" do
19+
static_sort.results(collection, sort_param)
20+
21+
expect(collection).to have_received(:order).with(description: :desc)
22+
end
23+
end
24+
25+
context "when sort is a Proc" do
26+
let(:sort) { ->(collection, direction) { collection.order(foobar: direction) } }
27+
28+
it "calls the provided proc" do
29+
static_sort.results(collection, sort_param)
30+
31+
expect(collection).to have_received(:order).with(foobar: :desc)
32+
end
33+
end
34+
35+
context "when sort is a class" do
36+
let(:sort_class_instance) { instance_double("SortClass", results: collection) }
37+
let(:sort) { SortClass }
38+
39+
before do
40+
class_double("SortClass", new: sort_class_instance).as_stubbed_const
41+
end
42+
43+
it "uses the provided sort class", :aggregate_failures do
44+
static_sort.results(collection, sort_param)
45+
46+
expect(SortClass).to have_received(:new).with(collection, :desc)
47+
expect(sort_class_instance).to have_received(:results)
48+
end
49+
end
50+
end
51+
end

0 commit comments

Comments
 (0)