jpie 4.1.1.pre.202609300315 → 4.1.2
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 +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +8 -1
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +4 -49
- data/lib/json_api/controllers/concerns/resource_actions.rb +1 -0
- data/lib/json_api/support/collection_query.rb +11 -3
- data/lib/json_api/support/concerns/nested_filters.rb +13 -41
- data/lib/json_api/support/concerns/regular_filters.rb +1 -0
- data/lib/json_api/support/filter_path.rb +84 -0
- data/lib/json_api/support/sort.rb +21 -4
- data/lib/json_api/version.rb +1 -1
- data/lib/json_api.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7cb5c150e4da0f0608aa5b97534e2239b8b80900054c984ca2aed3c24e747198
|
|
4
|
+
data.tar.gz: 83558900f75be4e9a4b8ea5997f7551963ee1406359b9fd30903d39fe9ad78bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1fdbf06edc4419def82b3719d3cd419e84bc76f3ebb9af0e64650c8eea9856d0d0a20c688a8c291c491fddaed0f28a417b506391dc63e3987ab8594a34126f4f
|
|
7
|
+
data.tar.gz: cfa30d1e5be610419135bcd16b13752a9eab3792c8082fe95d4a8cc6272103c6ea62c6315bdfc3e46b68b2d7ffe8467b1231e37980e8872b0f3f3781ba71b3d0
|
data/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,50 @@ at release time.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.1.2] - 2026-10-10
|
|
11
|
+
|
|
10
12
|
### Fixed
|
|
11
13
|
|
|
14
|
+
- **Check each filter in the query, not only in the controller.**
|
|
15
|
+
`CollectionQuery` applied each filter name it received. Only
|
|
16
|
+
`validate_filter_param`, a controller `before_action`, refused a filter no
|
|
17
|
+
resource declares. A caller that built the query without that validation
|
|
18
|
+
reached the public class methods of a model through `public_send`. A spec
|
|
19
|
+
shows `where` take a raw SQL string that way. The query now takes the walk
|
|
20
|
+
filter validation takes, in the new `JSONAPI::Support::FilterPath`. A
|
|
21
|
+
resource declares each hop in `permitted_filters_through`, and the last
|
|
22
|
+
resource declares the leaf in `permitted_filters`. The query ignores a filter
|
|
23
|
+
that fails the walk: it adds no join, no condition and no model call. A
|
|
24
|
+
request that passes filter validation gets the same rows as before. Specs:
|
|
25
|
+
`collection_query_spec.rb`.
|
|
26
|
+
- **End the order of every paged read with the primary key.** A paged
|
|
27
|
+
collection read carried no `ORDER BY` unless the request named a sort, and a
|
|
28
|
+
sort carried no tie-break. A database promises no order beyond the one it is
|
|
29
|
+
given, so a write between two page reads moved a row to another page: the
|
|
30
|
+
walk returned one row twice and never returned another. `Support::Sort` now
|
|
31
|
+
appends the model's primary key to the order of a paged read, and the
|
|
32
|
+
in-memory sort for virtual fields keeps the database order for rows that tie.
|
|
33
|
+
A read with no `page` parameter is unchanged. A paged scope must keep the
|
|
34
|
+
primary key in its select list: PostgreSQL refuses the order on a scope that
|
|
35
|
+
selects `DISTINCT` columns without the key, or groups by another column.
|
|
36
|
+
Regression spec: `paged_read_unstable_order_spec.rb`.
|
|
37
|
+
|
|
38
|
+
## [4.1.1] - 2026-10-10
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **Resolve a nested filter hop in the request namespace.** Filter validation
|
|
43
|
+
and `CollectionQuery` resolved each hop of a path such as
|
|
44
|
+
`filter[comments.author.email_eq]` from the model name alone. A namespaced
|
|
45
|
+
request then judged the path against the flat resources. A public namespace
|
|
46
|
+
accepted every filter and relationship its flat counterparts declare, and
|
|
47
|
+
refused the filters its own resources declare. Each hop now resolves through
|
|
48
|
+
`ResourceLoader.find_for_model(model, namespace:)`, the lookup an `?include=`
|
|
49
|
+
hop takes. A hop the namespace cannot answer is a 400 "Invalid Filter". A
|
|
50
|
+
flat request takes the same lookup, so a hop to an STI subclass now reaches
|
|
51
|
+
its base class's resource. Regression spec:
|
|
52
|
+
`nested_filter_namespace_bypass_spec.rb`.
|
|
53
|
+
|
|
12
54
|
- **Preload an `?include=` hop through the related resource's own scope.** The
|
|
13
55
|
tree preloaded through `acts_as_tenant` alone, so neither the related
|
|
14
56
|
resource's `records` scope nor the `authorization_scope` hook reached the
|
data/README.md
CHANGED
|
@@ -256,6 +256,10 @@ Paginated responses include a `links` object with `self`, `first`, `last`, and c
|
|
|
256
256
|
|
|
257
257
|
Default page size is 25, maximum is 100 (configurable).
|
|
258
258
|
|
|
259
|
+
A paged read always has one total order. JPie ends the `ORDER BY` of every paged query with the model's primary key: an unsorted page orders by the key, and a sorted page uses the key for rows that tie on the sort. A client that reads page after page then gets each row once, even when a row changes between two reads. A read with no `page` parameter gets no order of its own.
|
|
260
|
+
|
|
261
|
+
The key cannot help when a row is created or deleted between two page reads. Every later offset then moves by one row.
|
|
262
|
+
|
|
259
263
|
## Includes
|
|
260
264
|
|
|
261
265
|
Include related resources with the `include` parameter:
|
|
@@ -930,7 +934,10 @@ model is flat.
|
|
|
930
934
|
A namespace serves only what it declares. A namespaced request never resolves a
|
|
931
935
|
flat resource: a route whose namespace carries no resource fails at draw time
|
|
932
936
|
(the app does not boot), and an `?include=` hop the namespace cannot answer is
|
|
933
|
-
a 400 at validation, before anything loads.
|
|
937
|
+
a 400 at validation, before anything loads. A nested filter follows the same
|
|
938
|
+
rule. Each hop of `filter[comments.body_match]` resolves the namespace's
|
|
939
|
+
resource, and the filter passes only where that resource declares it. For a
|
|
940
|
+
namespaced model, both name
|
|
934
941
|
shapes answer — `Pub::ItemResource` and `Pub::DepotItemResource` for
|
|
935
942
|
`Depot::Item` — so the class a `MissingResourceClass` message names is always
|
|
936
943
|
a working declaration. To serve a flat resource inside a
|
|
@@ -4,65 +4,20 @@ module JSONAPI
|
|
|
4
4
|
module ResourceActions
|
|
5
5
|
module FilterValidation
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
|
-
include JSONAPI::Support::FilterParsing
|
|
8
7
|
|
|
9
8
|
def validate_filter_param
|
|
10
9
|
filters = parse_filter_param
|
|
11
10
|
return if filters.empty?
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
path = JSONAPI::Support::FilterPath.new(
|
|
13
|
+
definition: @resource_class, model_class:, namespace: jsonapi_namespace,
|
|
14
|
+
)
|
|
15
|
+
invalid = filters.keys.reject { |name| path.declared?(name) }
|
|
14
16
|
render_filter_errors(invalid) if invalid.any?
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
private
|
|
18
20
|
|
|
19
|
-
def filter_path_valid?(name)
|
|
20
|
-
parts = name.split(".")
|
|
21
|
-
check_filter_parts(parts, @resource_class, model_class)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def check_filter_parts(parts, res_cls, mod_cls)
|
|
25
|
-
return false if parts.empty?
|
|
26
|
-
return single_filter_valid?(parts.first, res_cls) if parts.length == 1
|
|
27
|
-
|
|
28
|
-
check_nested_filter(parts, res_cls, mod_cls)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def single_filter_valid?(name, res_cls)
|
|
32
|
-
res_cls.permitted_filters.map(&:to_s).include?(name)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def check_nested_filter(parts, res_cls, mod_cls)
|
|
36
|
-
rel, *rest = parts
|
|
37
|
-
return false unless filter_rel_allowed?(res_cls, rel)
|
|
38
|
-
|
|
39
|
-
assoc = mod_cls.reflect_on_association(rel.to_sym)
|
|
40
|
-
return false unless assoc
|
|
41
|
-
return polymorphic_filter_valid?(rest) if assoc.polymorphic?
|
|
42
|
-
|
|
43
|
-
check_related_filter(rest, assoc)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def check_related_filter(parts, assoc)
|
|
47
|
-
rel_mod = assoc.klass
|
|
48
|
-
rel_res = JSONAPI::Resource.resource_for_model(rel_mod)
|
|
49
|
-
return false unless rel_res
|
|
50
|
-
|
|
51
|
-
check_filter_parts(parts, rel_res, rel_mod)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def filter_rel_allowed?(res_cls, rel)
|
|
55
|
-
permitted = res_cls.permitted_filters_through
|
|
56
|
-
permitted.include?(rel.to_sym) || permitted.map(&:to_s).include?(rel.to_s)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def polymorphic_filter_valid?(parts)
|
|
60
|
-
return false if parts.empty? || parts.length > 1
|
|
61
|
-
|
|
62
|
-
col_filter = parse_column_filter(parts.first)
|
|
63
|
-
%w[id type].include?(col_filter ? col_filter[:column] : parts.first)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
21
|
def render_filter_errors(invalid)
|
|
67
22
|
render_parameter_errors(
|
|
68
23
|
invalid,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "sort"
|
|
4
|
+
require_relative "filter_path"
|
|
4
5
|
require_relative "concerns/regular_filters"
|
|
5
6
|
require_relative "concerns/nested_filters"
|
|
6
7
|
require_relative "concerns/polymorphic_filters"
|
|
@@ -17,10 +18,11 @@ module JSONAPI
|
|
|
17
18
|
|
|
18
19
|
attr_reader :scope, :total_count, :pagination_applied
|
|
19
20
|
|
|
20
|
-
def initialize(scope, definition:, model_class:, filter_params:, sort_params:, page_params:)
|
|
21
|
+
def initialize(scope, definition:, model_class:, filter_params:, sort_params:, page_params:, namespace: nil)
|
|
21
22
|
@scope = scope
|
|
22
23
|
@definition = definition
|
|
23
24
|
@model_class = model_class
|
|
25
|
+
@namespace = namespace
|
|
24
26
|
@filter_params = filter_params
|
|
25
27
|
@sort_params = sort_params
|
|
26
28
|
@page_params = page_params
|
|
@@ -29,7 +31,7 @@ module JSONAPI
|
|
|
29
31
|
|
|
30
32
|
def execute
|
|
31
33
|
@scope = apply_filtering
|
|
32
|
-
@sort = Support::Sort.new(sort_params:, resource_class: definition)
|
|
34
|
+
@sort = Support::Sort.new(sort_params:, resource_class: definition, tie_break: pagination_applied)
|
|
33
35
|
compute_total_count_if_needed
|
|
34
36
|
@scope = @sort.apply(@scope)
|
|
35
37
|
@scope = apply_pagination
|
|
@@ -38,7 +40,7 @@ module JSONAPI
|
|
|
38
40
|
|
|
39
41
|
private
|
|
40
42
|
|
|
41
|
-
attr_reader :definition, :model_class, :filter_params, :sort_params, :page_params
|
|
43
|
+
attr_reader :definition, :model_class, :namespace, :filter_params, :sort_params, :page_params
|
|
42
44
|
|
|
43
45
|
def compute_total_count_if_needed
|
|
44
46
|
return unless pagination_applied
|
|
@@ -50,5 +52,11 @@ module JSONAPI
|
|
|
50
52
|
scope = apply_nested_relationship_filters(@scope)
|
|
51
53
|
apply_regular_filters(scope)
|
|
52
54
|
end
|
|
55
|
+
|
|
56
|
+
# The query trusts no caller to validate first. It applies a filter only
|
|
57
|
+
# when the resources on its path declare it.
|
|
58
|
+
def filter_path
|
|
59
|
+
@filter_path ||= Support::FilterPath.new(definition:, model_class:, namespace:)
|
|
60
|
+
end
|
|
53
61
|
end
|
|
54
62
|
end
|
|
@@ -17,53 +17,25 @@ module JSONAPI
|
|
|
17
17
|
private
|
|
18
18
|
|
|
19
19
|
def apply_filter_for_path(scope, filter_name, filter_value)
|
|
20
|
-
|
|
21
|
-
return scope if
|
|
20
|
+
*relationship_chain, leaf_filter = filter_name.split(".")
|
|
21
|
+
return scope if relationship_chain.empty?
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
# The walk filter validation takes. A path it refuses filters nothing.
|
|
24
|
+
target = filter_path.resolve(filter_name)
|
|
25
|
+
return scope unless target
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
return result[:scope] if result[:early_return]
|
|
28
|
-
|
|
29
|
-
apply_joined_filter(scope, result, relationship_chain:, leaf_filter:, filter_value:)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def traverse_relationship_chain(scope, relationship_chain, leaf_filter, filter_value)
|
|
33
|
-
current_model = model_class
|
|
34
|
-
current_definition = definition
|
|
35
|
-
|
|
36
|
-
relationship_chain.each do |relationship_name|
|
|
37
|
-
result = process_chain_step(scope, current_model, relationship_name, leaf_filter, filter_value)
|
|
38
|
-
return result if result[:early_return]
|
|
39
|
-
|
|
40
|
-
current_model = result[:model]
|
|
41
|
-
current_definition = result[:definition]
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
{ model: current_model, definition: current_definition, early_return: false }
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def process_chain_step(scope, current_model, relationship_name, leaf_filter, filter_value)
|
|
48
|
-
association = current_model.reflect_on_association(relationship_name.to_sym)
|
|
49
|
-
return { scope:, early_return: true } unless association
|
|
50
|
-
|
|
51
|
-
if association.polymorphic?
|
|
27
|
+
if target.association
|
|
52
28
|
attributes = { leaf_filter => filter_value }
|
|
53
|
-
return
|
|
54
|
-
early_return: true, }
|
|
29
|
+
return apply_polymorphic_nested_filters(scope, target.association, relationship_chain.last, attributes)
|
|
55
30
|
end
|
|
56
31
|
|
|
57
|
-
|
|
58
|
-
return { scope:, early_return: true } unless next_definition
|
|
59
|
-
|
|
60
|
-
{ model: association.klass, definition: next_definition, early_return: false }
|
|
32
|
+
apply_joined_filter(scope, target.model, relationship_chain:, leaf_filter:, filter_value:)
|
|
61
33
|
end
|
|
62
34
|
|
|
63
|
-
def apply_joined_filter(scope,
|
|
35
|
+
def apply_joined_filter(scope, target_model, relationship_chain:, leaf_filter:, filter_value:)
|
|
64
36
|
join_hash = build_join_hash_for_chain(relationship_chain)
|
|
65
37
|
scope = scope.joins(join_hash) if join_hash.present?
|
|
66
|
-
apply_filter_on_model(scope,
|
|
38
|
+
apply_filter_on_model(scope, target_model, leaf_filter, filter_value)
|
|
67
39
|
end
|
|
68
40
|
|
|
69
41
|
def build_join_hash_for_chain(chain)
|
|
@@ -78,15 +50,15 @@ module JSONAPI
|
|
|
78
50
|
end
|
|
79
51
|
end
|
|
80
52
|
|
|
81
|
-
def apply_filter_on_model(scope, target_model,
|
|
53
|
+
def apply_filter_on_model(scope, target_model, filter_name, filter_value)
|
|
82
54
|
return scope if empty_filter_value?(filter_value)
|
|
83
55
|
|
|
84
56
|
apply_column_filter(scope, target_model, filter_name, filter_value) ||
|
|
85
|
-
apply_scope_method_filter(scope, target_model,
|
|
57
|
+
apply_scope_method_filter(scope, target_model, filter_name, filter_value) ||
|
|
86
58
|
scope
|
|
87
59
|
end
|
|
88
60
|
|
|
89
|
-
def apply_scope_method_filter(scope, target_model,
|
|
61
|
+
def apply_scope_method_filter(scope, target_model, filter_name, filter_value)
|
|
90
62
|
return nil unless target_model.respond_to?(filter_name.to_sym)
|
|
91
63
|
|
|
92
64
|
try_scope_method(scope, target_model, filter_name, filter_value)
|
|
@@ -18,6 +18,7 @@ module JSONAPI
|
|
|
18
18
|
|
|
19
19
|
def apply_regular_filter(scope, filter_name, filter_value)
|
|
20
20
|
return scope if empty_filter_value?(filter_value)
|
|
21
|
+
return scope unless filter_path.declared?(filter_name)
|
|
21
22
|
|
|
22
23
|
apply_column_filter(scope, model_class, filter_name, filter_value) ||
|
|
23
24
|
apply_scope_fallback(scope, filter_name, filter_value)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "filter_parsing"
|
|
4
|
+
|
|
5
|
+
module JSONAPI
|
|
6
|
+
module Support
|
|
7
|
+
# Walks a filter path such as `comments.author.email_eq`, for filter
|
|
8
|
+
# validation and for the query. A resource declares each hop as a
|
|
9
|
+
# relationship, and the last resource declares the leaf as a filter. A path
|
|
10
|
+
# that fails either test resolves to nothing.
|
|
11
|
+
class FilterPath
|
|
12
|
+
include FilterParsing
|
|
13
|
+
|
|
14
|
+
# Where a declared path ends. `association` is the polymorphic hop that
|
|
15
|
+
# ends the walk, and nil on every other path.
|
|
16
|
+
Target = Struct.new(:model, :definition, :association)
|
|
17
|
+
|
|
18
|
+
def initialize(definition:, model_class:, namespace:)
|
|
19
|
+
@definition = definition
|
|
20
|
+
@model_class = model_class
|
|
21
|
+
@namespace = namespace
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def declared?(name)
|
|
25
|
+
!resolve(name).nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve(name)
|
|
29
|
+
*relationships, filter = name.to_s.split(".")
|
|
30
|
+
return nil unless filter
|
|
31
|
+
|
|
32
|
+
target = walk(relationships)
|
|
33
|
+
target if target && filter_declared?(target, filter)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def walk(relationships)
|
|
39
|
+
target = Target.new(@model_class, @definition)
|
|
40
|
+
relationships.each do |relationship|
|
|
41
|
+
# A polymorphic hop names no one resource, so no hop follows it.
|
|
42
|
+
target = target.association ? nil : hop(target, relationship)
|
|
43
|
+
return nil unless target
|
|
44
|
+
end
|
|
45
|
+
target
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def hop(target, relationship)
|
|
49
|
+
return nil unless listed?(target.definition.permitted_filters_through, relationship)
|
|
50
|
+
|
|
51
|
+
association = target.model.reflect_on_association(relationship.to_sym)
|
|
52
|
+
return nil unless association
|
|
53
|
+
return Target.new(target.model, target.definition, association) if association.polymorphic?
|
|
54
|
+
|
|
55
|
+
definition = hop_definition(association.klass)
|
|
56
|
+
definition && Target.new(association.klass, definition)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The namespace answers each hop, the lookup an include hop takes. A flat
|
|
60
|
+
# resource is another namespace's surface, with filters this one never
|
|
61
|
+
# declared.
|
|
62
|
+
def hop_definition(model)
|
|
63
|
+
JSONAPI::ResourceLoader.find_for_model(model, namespace: @namespace)
|
|
64
|
+
rescue JSONAPI::ResourceLoader::MissingResourceClass
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def filter_declared?(target, filter)
|
|
69
|
+
return polymorphic_filter?(filter) if target.association
|
|
70
|
+
|
|
71
|
+
listed?(target.definition.permitted_filters, filter)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def polymorphic_filter?(filter)
|
|
75
|
+
column_filter = parse_column_filter(filter)
|
|
76
|
+
%w[id type].include?(column_filter ? column_filter[:column] : filter)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def listed?(names, name)
|
|
80
|
+
names.map(&:to_s).include?(name)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -17,6 +17,10 @@ module JSONAPI
|
|
|
17
17
|
# sort over the materialised result set. This means virtual sorts always
|
|
18
18
|
# take precedence over column/scope sorts regardless of the order the
|
|
19
19
|
# user specified them in.
|
|
20
|
+
#
|
|
21
|
+
# With tie_break, the order ends with the primary key. A paged read needs
|
|
22
|
+
# that: its pages are separate queries, and only a total order makes them
|
|
23
|
+
# agree on which row comes where.
|
|
20
24
|
class Sort
|
|
21
25
|
class InvalidFieldError < JSONAPI::Error
|
|
22
26
|
attr_reader :invalid_fields
|
|
@@ -27,15 +31,15 @@ module JSONAPI
|
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
def initialize(sort_params:, resource_class:)
|
|
34
|
+
def initialize(sort_params:, resource_class:, tie_break: false)
|
|
31
35
|
@fields = build_fields(sort_params, resource_class)
|
|
36
|
+
@tie_break = tie_break
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
def apply(scope)
|
|
35
|
-
return scope if @fields.empty?
|
|
36
|
-
|
|
37
40
|
virtual, db = @fields.partition(&:virtual?)
|
|
38
41
|
scope = db.reduce(scope) { |acc, field| field.apply(acc) }
|
|
42
|
+
scope = break_ties(scope) if @tie_break
|
|
39
43
|
virtual.any? ? apply_virtual_sorting(scope, virtual) : scope
|
|
40
44
|
end
|
|
41
45
|
|
|
@@ -64,8 +68,21 @@ module JSONAPI
|
|
|
64
68
|
raise InvalidFieldError, invalid if invalid.any?
|
|
65
69
|
end
|
|
66
70
|
|
|
71
|
+
# A database places tied rows as it finds them, and an update moves a row.
|
|
72
|
+
# The primary key gives every row one place, so two queries agree on the order.
|
|
73
|
+
def break_ties(scope)
|
|
74
|
+
return scope unless scope.is_a?(ActiveRecord::Relation)
|
|
75
|
+
|
|
76
|
+
scope.order(Array(scope.primary_key).index_with(:asc))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Array#sort does not keep tied items in place, so the position the scope gave breaks the tie.
|
|
67
80
|
def apply_virtual_sorting(scope, virtual_fields)
|
|
68
|
-
scope.to_a.sort
|
|
81
|
+
ranked = scope.to_a.each_with_index.sort do |(record_a, position_a), (record_b, position_b)|
|
|
82
|
+
comparison = compare_by_fields(record_a, record_b, virtual_fields)
|
|
83
|
+
comparison.zero? ? position_a <=> position_b : comparison
|
|
84
|
+
end
|
|
85
|
+
ranked.map(&:first)
|
|
69
86
|
end
|
|
70
87
|
|
|
71
88
|
def compare_by_fields(record_a, record_b, fields)
|
data/lib/json_api/version.rb
CHANGED
data/lib/json_api.rb
CHANGED
|
@@ -37,6 +37,7 @@ require "json_api/active_storage/serialization"
|
|
|
37
37
|
require "json_api/active_storage/deserialization"
|
|
38
38
|
require "json_api/support/active_storage_support"
|
|
39
39
|
require "json_api/support/filter_parsing"
|
|
40
|
+
require "json_api/support/filter_path"
|
|
40
41
|
require "json_api/support/collection_query"
|
|
41
42
|
require "json_api/support/prosopite_instrumentation_logger"
|
|
42
43
|
require "json_api/support/n1_log_subscriber"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jpie
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.1.
|
|
4
|
+
version: 4.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Emil Kampp
|
|
@@ -166,6 +166,7 @@ files:
|
|
|
166
166
|
- lib/json_api/support/concerns/regular_filters.rb
|
|
167
167
|
- lib/json_api/support/correlation_id.rb
|
|
168
168
|
- lib/json_api/support/filter_parsing.rb
|
|
169
|
+
- lib/json_api/support/filter_path.rb
|
|
169
170
|
- lib/json_api/support/header_warning_subscriber.rb
|
|
170
171
|
- lib/json_api/support/instrumentation.rb
|
|
171
172
|
- lib/json_api/support/n1_log_subscriber.rb
|