Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,38 @@ But since we're devout followers of the SOLID principles, we can define a sort c
which returns the sorted collection. Under the hood the sort class is initialized with the current scope and the
direction parameter.

#### Dynamic sorting (prefix-based)

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`.

- The configured prefix is matched against each parsed sort attribute.
- The prefix is stripped and only the dynamic part is passed to your sort handler.
- You can provide either a lambda/proc or a class. The callable receives `(collection, dynamic_attribute, direction)`.

Example with a lambda (PostgreSQL JSONB text value):

```ruby
# Allows sorting by any key in the `data` column: e.g. sort=-data.name,data.created_at
dynamically_sorts_by :'data.', ->(collection, attribute, direction) {
# attribute is the part after the prefix, e.g. "name" or "created_at"
quoted_attribute = ActiveRecord::Base.connection.quote(attribute)
collection.order(Arel.sql("(data->>#{quoted_attribute}) #{direction}"))
}
```

Example with a sort class (PostgreSQL JSONB text value):

```ruby
class DataSort < Jsonapi::QueryBuilder::DynamicSort
def results
quoted_attribute = ActiveRecord::Base.connection.quote(dynamic_attribute)
collection.order(Arel.sql("(data->>#{quoted_attribute}) #{direction}"))
end
end

dynamically_sorts_by :'data.', DataSort
```

### Filtering

#### Simple exact match filters
Expand Down
1 change: 1 addition & 0 deletions lib/jsonapi/query_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
require "jsonapi/query_builder/base_query"
require "jsonapi/query_builder/base_filter"
require "jsonapi/query_builder/base_sort"
require "jsonapi/query_builder/dynamic_sort"
24 changes: 24 additions & 0 deletions lib/jsonapi/query_builder/dynamic_sort.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require "jsonapi/query_builder/base_sort"

module Jsonapi
module QueryBuilder
class DynamicSort < BaseSort
attr_reader :dynamic_attribute

# @param [ActiveRecord::Relation] collection
# @param [Symbol] dynamic_attribute, which attribute to dynamically sort by
# @param [Symbol] direction of the ordering, one of :asc or :desc
def initialize(collection, dynamic_attribute, direction = :asc)
super(collection, direction)
@dynamic_attribute = dynamic_attribute
end

# @return [ActiveRecord::Relation] Collection with order applied
def results
raise NotImplementedError, "#{self.class} should implement #results"
end
end
end
end
34 changes: 24 additions & 10 deletions lib/jsonapi/query_builder/mixins/sort.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

require "jsonapi/query_builder/mixins/sort/param"
require "jsonapi/query_builder/mixins/sort/static"
require "jsonapi/query_builder/mixins/sort/dynamic"
require "jsonapi/query_builder/errors/unpermitted_sort_parameters"

module Jsonapi
Expand All @@ -16,8 +18,14 @@ def _unique_sort_attributes
@_unique_sort_attributes || [id: :asc]
end

# @return [Hash<Symbol, Jsonapi::QueryBuilder::Mixins::Sort::Static>] Supported sorts
def supported_sorts
@supported_sorts || {}
@supported_sorts ||= {}
end

# @return [Array<Jsonapi::QueryBuilder::Mixins::Sort::Dynamic>] Supported dynamic sorts
def supported_dynamic_sorts
@supported_dynamic_sorts ||= []
end

# Ensures deterministic ordering. Defaults to :id in ascending direction.
Expand All @@ -43,8 +51,14 @@ def default_sort(options)
# @param [Symbol] attribute The "sortable" attribute
# @param [proc, Class] sort A proc or a sort class, defaults to a simple order(attribute => direction)
def sorts_by(attribute, sort = nil)
sort ||= ->(collection, direction) { collection.order(attribute => direction) }
@supported_sorts = {**supported_sorts, attribute => sort}
supported_sorts[attribute] = Sort::Static.new(attribute, sort)
end

# 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.
# @param [Symbol] attribute_prefix The "sortable" attribute prefix, e.g. `:'data.'` for sorting by `data.name` and `data.created_at`
# @param [proc, Class] sort A proc or a sort class, defaults to a simple order(attribute => direction)
def dynamically_sorts_by(attribute_prefix, sort)
supported_dynamic_sorts << Sort::Dynamic.new(attribute_prefix, sort)
end
end

Expand Down Expand Up @@ -76,7 +90,9 @@ def sort_params
end

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

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

sort_params.reduce(collection) do |sorted_collection, sort_param|
sort = self.class.supported_sorts.fetch(sort_param.attribute.to_sym)

if sort.respond_to?(:call)
sort.call(sorted_collection, sort_param.direction)
else
sort.new(sorted_collection, sort_param.direction).results
sort = self.class.supported_sorts.fetch(sort_param.attribute.to_sym) do
self.class.supported_dynamic_sorts.find { |dynamic_sort| dynamic_sort.matches?(sort_param.attribute) }
end

sort.results(sorted_collection, sort_param)
end
end

Expand Down
31 changes: 31 additions & 0 deletions lib/jsonapi/query_builder/mixins/sort/dynamic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module Jsonapi
module QueryBuilder
module Mixins
module Sort
class Dynamic
attr_reader :attribute_prefix, :sort

def initialize(attribute_prefix, sort)
@attribute_prefix = attribute_prefix.to_s
@sort = sort
end

def matches?(sort_attribute)
sort_attribute.to_s.start_with?(attribute_prefix)
end

def results(collection, sort_param)
dynamic_attribute = sort_param.attribute.delete_prefix(attribute_prefix)
if sort.respond_to?(:call)
sort.call(collection, dynamic_attribute, sort_param.direction)
else
sort.new(collection, dynamic_attribute, sort_param.direction).results
end
end
end
end
end
end
end
26 changes: 26 additions & 0 deletions lib/jsonapi/query_builder/mixins/sort/static.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module Jsonapi
module QueryBuilder
module Mixins
module Sort
class Static
attr_reader :attribute, :sort

def initialize(attribute, sort)
@attribute = attribute
@sort = sort || ->(collection, direction) { collection.order(attribute => direction) }
end

def results(collection, sort_param)
if sort.respond_to?(:call)
sort.call(collection, sort_param.direction)
else
sort.new(collection, sort_param.direction).results
end
end
end
end
end
end
end
21 changes: 21 additions & 0 deletions spec/jsonapi/query_builder/dynamic_sort_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

RSpec.describe Jsonapi::QueryBuilder::DynamicSort do
let(:sort_class) { Class.new(described_class) }

before do
stub_const "FakeSort", sort_class
end

it "defaults to ascending sort direction" do
expect(FakeSort.new(instance_double("collection"), "attribute")).to have_attributes(direction: :asc)
end

context "with required interface methods" do
it "raises an error for results method" do
expect { FakeSort.new(instance_double("collection"), "attribute", :desc).results }.to raise_error(
NotImplementedError, "FakeSort should implement #results"
)
end
end
end
53 changes: 53 additions & 0 deletions spec/jsonapi/query_builder/mixins/sort/dynamic_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

RSpec.describe Jsonapi::QueryBuilder::Mixins::Sort::Dynamic do
subject(:dynamic_sort) { described_class.new(:"data.", sort) }

let(:collection) { instance_double("collection") }
let(:sort) { ->(collection, attribute, direction) { collection.order(attribute => direction) } }
let(:sort_param) { Jsonapi::QueryBuilder::Mixins::Sort::Param.new("-data.description") }

before do
allow(collection).to receive(:order).and_return(collection)
end

describe "#matches?" do
context "when sort attribute starts with configured prefix" do
it "returns true" do
expect(dynamic_sort.matches?(:"data.description")).to be true
end
end

context "when sort attribute does not match" do
it "returns false" do
expect(dynamic_sort.matches?(:description)).to be false
end
end
end

describe "#results" do
context "when sort is a Proc" do
it "calls the provided proc" do
dynamic_sort.results(collection, sort_param)

expect(collection).to have_received(:order).with("description" => :desc)
end
end

context "when sort is a class" do
let(:sort_class_instance) { instance_double("SortClass", results: collection) }
let(:sort) { SortClass }

before do
class_double("SortClass", new: sort_class_instance).as_stubbed_const
end

it "uses the provided sort class", :aggregate_failures do
dynamic_sort.results(collection, sort_param)

expect(SortClass).to have_received(:new).with(collection, "description", :desc)
expect(sort_class_instance).to have_received(:results)
end
end
end
end
51 changes: 51 additions & 0 deletions spec/jsonapi/query_builder/mixins/sort/static_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

RSpec.describe Jsonapi::QueryBuilder::Mixins::Sort::Static do
subject(:static_sort) { described_class.new(:description, sort) }

let(:collection) { instance_double("collection") }

let(:sort_param) { Jsonapi::QueryBuilder::Mixins::Sort::Param.new("-description") }

before do
allow(collection).to receive(:order).and_return(collection)
end

describe "#results" do
context "when sort is not given" do
let(:sort) { nil }

it "defaults to ordering collection by attribute name" do
static_sort.results(collection, sort_param)

expect(collection).to have_received(:order).with(description: :desc)
end
end

context "when sort is a Proc" do
let(:sort) { ->(collection, direction) { collection.order(foobar: direction) } }

it "calls the provided proc" do
static_sort.results(collection, sort_param)

expect(collection).to have_received(:order).with(foobar: :desc)
end
end

context "when sort is a class" do
let(:sort_class_instance) { instance_double("SortClass", results: collection) }
let(:sort) { SortClass }

before do
class_double("SortClass", new: sort_class_instance).as_stubbed_const
end

it "uses the provided sort class", :aggregate_failures do
static_sort.results(collection, sort_param)

expect(SortClass).to have_received(:new).with(collection, :desc)
expect(sort_class_instance).to have_received(:results)
end
end
end
end
Loading