From 36e98fcb8394ebce19380877f317dba79c773507 Mon Sep 17 00:00:00 2001 From: Djordje Lacmanovic Date: Mon, 11 Mar 2024 14:18:00 +0100 Subject: [PATCH 1/4] Allow dynamic sorting When sorting certain types of attributes, e.g. JSONB values, it is not possible to know ahead of time what attributes to explicitly expose. Introduce prefix-based dynamic sorting so arbitrary sort attributes can be passed to the sort class/proc. --- lib/jsonapi/query_builder.rb | 1 + lib/jsonapi/query_builder/dynamic_sort.rb | 24 +++++++++ lib/jsonapi/query_builder/mixins/sort.rb | 34 ++++++++---- .../query_builder/mixins/sort/dynamic.rb | 31 +++++++++++ .../query_builder/mixins/sort/static.rb | 26 +++++++++ .../query_builder/dynamic_sort_spec.rb | 21 ++++++++ .../query_builder/mixins/sort/dynamic_spec.rb | 53 +++++++++++++++++++ .../query_builder/mixins/sort/static_spec.rb | 51 ++++++++++++++++++ .../jsonapi/query_builder/mixins/sort_spec.rb | 48 ++++++++++++++--- 9 files changed, 271 insertions(+), 18 deletions(-) create mode 100644 lib/jsonapi/query_builder/dynamic_sort.rb create mode 100644 lib/jsonapi/query_builder/mixins/sort/dynamic.rb create mode 100644 lib/jsonapi/query_builder/mixins/sort/static.rb create mode 100644 spec/jsonapi/query_builder/dynamic_sort_spec.rb create mode 100644 spec/jsonapi/query_builder/mixins/sort/dynamic_spec.rb create mode 100644 spec/jsonapi/query_builder/mixins/sort/static_spec.rb diff --git a/lib/jsonapi/query_builder.rb b/lib/jsonapi/query_builder.rb index e391eb4..2c5da7b 100644 --- a/lib/jsonapi/query_builder.rb +++ b/lib/jsonapi/query_builder.rb @@ -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" diff --git a/lib/jsonapi/query_builder/dynamic_sort.rb b/lib/jsonapi/query_builder/dynamic_sort.rb new file mode 100644 index 0000000..2056393 --- /dev/null +++ b/lib/jsonapi/query_builder/dynamic_sort.rb @@ -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 diff --git a/lib/jsonapi/query_builder/mixins/sort.rb b/lib/jsonapi/query_builder/mixins/sort.rb index a75c7ff..ce23e0c 100644 --- a/lib/jsonapi/query_builder/mixins/sort.rb +++ b/lib/jsonapi/query_builder/mixins/sort.rb @@ -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 @@ -16,8 +18,14 @@ def _unique_sort_attributes @_unique_sort_attributes || [id: :asc] end + # @return [Hash] Supported sorts def supported_sorts - @supported_sorts || {} + @supported_sorts ||= {} + end + + # @return [Array] Supported dynamic sorts + def supported_dynamic_sorts + @supported_dynamic_sorts ||= [] end # Ensures deterministic ordering. Defaults to :id in ascending direction. @@ -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 @@ -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 @@ -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 diff --git a/lib/jsonapi/query_builder/mixins/sort/dynamic.rb b/lib/jsonapi/query_builder/mixins/sort/dynamic.rb new file mode 100644 index 0000000..06910f5 --- /dev/null +++ b/lib/jsonapi/query_builder/mixins/sort/dynamic.rb @@ -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.sub(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 diff --git a/lib/jsonapi/query_builder/mixins/sort/static.rb b/lib/jsonapi/query_builder/mixins/sort/static.rb new file mode 100644 index 0000000..df397fe --- /dev/null +++ b/lib/jsonapi/query_builder/mixins/sort/static.rb @@ -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 diff --git a/spec/jsonapi/query_builder/dynamic_sort_spec.rb b/spec/jsonapi/query_builder/dynamic_sort_spec.rb new file mode 100644 index 0000000..10c8a83 --- /dev/null +++ b/spec/jsonapi/query_builder/dynamic_sort_spec.rb @@ -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 diff --git a/spec/jsonapi/query_builder/mixins/sort/dynamic_spec.rb b/spec/jsonapi/query_builder/mixins/sort/dynamic_spec.rb new file mode 100644 index 0000000..4859862 --- /dev/null +++ b/spec/jsonapi/query_builder/mixins/sort/dynamic_spec.rb @@ -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 diff --git a/spec/jsonapi/query_builder/mixins/sort/static_spec.rb b/spec/jsonapi/query_builder/mixins/sort/static_spec.rb new file mode 100644 index 0000000..0abd77f --- /dev/null +++ b/spec/jsonapi/query_builder/mixins/sort/static_spec.rb @@ -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 diff --git a/spec/jsonapi/query_builder/mixins/sort_spec.rb b/spec/jsonapi/query_builder/mixins/sort_spec.rb index 6236f84..6291b47 100644 --- a/spec/jsonapi/query_builder/mixins/sort_spec.rb +++ b/spec/jsonapi/query_builder/mixins/sort_spec.rb @@ -61,7 +61,9 @@ it "adds a default sort proc" do SortableQuery.sorts_by :first_name - expect(SortableQuery.supported_sorts).to include(first_name: an_instance_of(Proc)) + expect(SortableQuery.supported_sorts) + .to include(first_name: an_instance_of(Jsonapi::QueryBuilder::Mixins::Sort::Static) + .and(have_attributes(attribute: :first_name, sort: Proc))) end it "adds a custom sort" do @@ -69,7 +71,7 @@ SortableQuery.sorts_by :first_name, sort - expect(SortableQuery.supported_sorts).to include(first_name: sort) + expect(SortableQuery.supported_sorts).to include(first_name: have_attributes(sort: sort)) end it "can add multiple different sorts" do @@ -79,6 +81,26 @@ expect(SortableQuery.supported_sorts).to include(:first_name, :last_name) end end + + describe ".dynamically_sorts_by" do + it "registers a supported dynamic sort attribute" do + sort = Class.new(Jsonapi::QueryBuilder::DynamicSort) + SortableQuery.dynamically_sorts_by :address, sort + + expect(SortableQuery.supported_dynamic_sorts) + .to include(an_instance_of(Jsonapi::QueryBuilder::Mixins::Sort::Dynamic) + .and(have_attributes(attribute_prefix: "address", sort: sort))) + end + + it "can add multiple different dynamic sorts" do + sort = Class.new(Jsonapi::QueryBuilder::DynamicSort) + SortableQuery.dynamically_sorts_by :address, sort + SortableQuery.dynamically_sorts_by :email, sort + + expect(SortableQuery.supported_dynamic_sorts).to include(have_attributes(attribute_prefix: "address"), + have_attributes(attribute_prefix: "email")) + end + end end describe "#sort" do @@ -94,6 +116,7 @@ sorts_by :last_name sorts_by :first_name, ->(collection, direction) { collection.order(name: direction) } sorts_by :"address.street", StreetSort + dynamically_sorts_by :"data.", DynamicSort def initialize(collection, params) @collection = collection @@ -103,11 +126,14 @@ def initialize(collection, params) } let(:street_sort_class) { class_double "StreetSort", new: street_sort_instance } let(:street_sort_instance) { instance_double "street_sort", results: collection } + let(:dynamic_sort_class) { class_double "DynamicSort", new: dynamic_sort_instance } + let(:dynamic_sort_instance) { instance_double "dynamic_sort", results: collection } let(:collection) { instance_double "collection" } - let(:params) { {sort: "first_name,-last_name,address.street"} } + let(:params) { {sort: "first_name,-last_name,address.street,data.foobar"} } before do stub_const "StreetSort", street_sort_class + stub_const "DynamicSort", dynamic_sort_class stub_const "SortableQuery", sortable_query_class allow(collection).to receive(:order).and_return(collection) @@ -134,6 +160,13 @@ def initialize(collection, params) expect(street_sort_instance).to have_received(:results) end + it "sorts by present dynamic sort", :aggregate_failures do + sort + + expect(DynamicSort).to have_received(:new).with(collection, "foobar", :asc) + expect(dynamic_sort_instance).to have_received(:results) + end + it "adds the unique sort attribute" do sort @@ -209,7 +242,7 @@ def initialize(collection, params) end context "when one or more of sort params is not permitted" do - let(:params) { {sort: "first_name,email,-birth_date"} } + let(:params) { {sort: "first_name,-data.some_nested_prop,email,-birth_date"} } context "when query does not support nested parameters" do it "raises an unpermitted sort parameters error" do @@ -222,10 +255,9 @@ def initialize(collection, params) end context "when sort params are passed explicitly to #sort" do - subject(:sort) { SortableQuery.new(collection, params).sort(collection, sort_params) } - - let(:params) { {sort: "email"} } - let(:sort_params) { "first_name,-last_name" } + subject(:sort) do + SortableQuery.new(collection, {sort: "email"}).sort(collection, "first_name,-last_name") + end it "overrides with the passed sort string", :aggregate_failures do sort From 8693902fc01e317b41878724d722019e5bca07d5 Mon Sep 17 00:00:00 2001 From: Djordje Lacmanovic Date: Thu, 18 Sep 2025 00:48:05 +0200 Subject: [PATCH 2/4] Update docs for dynamic sorting --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index c0d0697..f32f9b9 100644 --- a/README.md +++ b/README.md @@ -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 From 481f2f5e9bb24987d33ee74f01dca7445a1daae6 Mon Sep 17 00:00:00 2001 From: Djordje Lacmanovic Date: Thu, 18 Sep 2025 10:48:34 +0200 Subject: [PATCH 3/4] Use delete_prefix instead of sub Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/jsonapi/query_builder/mixins/sort/dynamic.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/query_builder/mixins/sort/dynamic.rb b/lib/jsonapi/query_builder/mixins/sort/dynamic.rb index 06910f5..b608383 100644 --- a/lib/jsonapi/query_builder/mixins/sort/dynamic.rb +++ b/lib/jsonapi/query_builder/mixins/sort/dynamic.rb @@ -17,7 +17,7 @@ def matches?(sort_attribute) end def results(collection, sort_param) - dynamic_attribute = sort_param.attribute.sub(attribute_prefix, "") + dynamic_attribute = sort_param.attribute.delete_prefix(attribute_prefix) if sort.respond_to?(:call) sort.call(collection, dynamic_attribute, sort_param.direction) else From aad5ee7c5a9a92246260dec0b583a3fa7d043337 Mon Sep 17 00:00:00 2001 From: Djordje Lacmanovic Date: Thu, 18 Sep 2025 13:12:32 +0200 Subject: [PATCH 4/4] Update sort_spec.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- spec/jsonapi/query_builder/mixins/sort_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/jsonapi/query_builder/mixins/sort_spec.rb b/spec/jsonapi/query_builder/mixins/sort_spec.rb index 6291b47..acbbea6 100644 --- a/spec/jsonapi/query_builder/mixins/sort_spec.rb +++ b/spec/jsonapi/query_builder/mixins/sort_spec.rb @@ -63,7 +63,7 @@ expect(SortableQuery.supported_sorts) .to include(first_name: an_instance_of(Jsonapi::QueryBuilder::Mixins::Sort::Static) - .and(have_attributes(attribute: :first_name, sort: Proc))) + .and(have_attributes(attribute: :first_name, sort: an_instance_of(Proc)))) end it "adds a custom sort" do