jpie 4.1.1 → 4.1.3
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 +49 -0
- data/README.md +4 -0
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +4 -58
- data/lib/json_api/support/collection_query.rb +8 -1
- data/lib/json_api/support/concerns/nested_filters.rb +25 -56
- data/lib/json_api/support/concerns/polymorphic_filters.rb +19 -11
- 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: e210d214edcde88211f1fad82af9fa2dfcc2bb67053e8592d7a7d271995d99b3
|
|
4
|
+
data.tar.gz: e8e81eacff5269a4f66cadc93ee496b9b7746f7016f9a68a48cdffb21fc3d64e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1725977eb3a852b9b131453bbca28d25ab5495faf06862b439bf65572455be2d1a97cbf8de18ad65e855d3e51c349e6ac06af3644201436fc7d00b52919ae6bd
|
|
7
|
+
data.tar.gz: f17962764401488d7986f9da451d26bc3a68a037135713169ad76b2b7be9ec86533b66e17c2d0c3e0fa36d290d67575b03818675fbb3e07f22ac89c8d2fc3916
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,55 @@ at release time.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.1.3] - 2026-10-10
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Filter a polymorphic hop on the model that owns it.** A path such as
|
|
15
|
+
`filter[user.profile.id_eq]` on `/posts` passed validation and answered 500.
|
|
16
|
+
`PolymorphicFilters` read the foreign key and type columns from the root
|
|
17
|
+
table of the query, and `posts` holds no `profile_id`. A root table that
|
|
18
|
+
holds a column of the same name answered 200 with rows filtered on the wrong
|
|
19
|
+
table. The filter now joins the hops before the polymorphic one and reads
|
|
20
|
+
the columns of the owner's table. A polymorphic hop on the root model
|
|
21
|
+
behaves as before. Regression spec:
|
|
22
|
+
`nested_filter_polymorphic_hop_below_root_spec.rb`.
|
|
23
|
+
- **Ignore an empty nested filter value.** `filter[comments.author.email_eq]=`
|
|
24
|
+
added the inner join before it checked the value, so the answer held only
|
|
25
|
+
the posts that have a comment. `filter[profile.type]=` matched the empty
|
|
26
|
+
string and answered no rows. An empty nested value now adds no join and no
|
|
27
|
+
condition, as an empty top-level filter does. A nested filter that adds no
|
|
28
|
+
condition adds no join either. Regression spec:
|
|
29
|
+
`nested_filter_empty_value_spec.rb`.
|
|
30
|
+
|
|
31
|
+
## [4.1.2] - 2026-10-10
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Check each filter in the query, not only in the controller.**
|
|
36
|
+
`CollectionQuery` applied each filter name it received. Only
|
|
37
|
+
`validate_filter_param`, a controller `before_action`, refused a filter no
|
|
38
|
+
resource declares. A caller that built the query without that validation
|
|
39
|
+
reached the public class methods of a model through `public_send`. A spec
|
|
40
|
+
shows `where` take a raw SQL string that way. The query now takes the walk
|
|
41
|
+
filter validation takes, in the new `JSONAPI::Support::FilterPath`. A
|
|
42
|
+
resource declares each hop in `permitted_filters_through`, and the last
|
|
43
|
+
resource declares the leaf in `permitted_filters`. The query ignores a filter
|
|
44
|
+
that fails the walk: it adds no join, no condition and no model call. A
|
|
45
|
+
request that passes filter validation gets the same rows as before. Specs:
|
|
46
|
+
`collection_query_spec.rb`.
|
|
47
|
+
- **End the order of every paged read with the primary key.** A paged
|
|
48
|
+
collection read carried no `ORDER BY` unless the request named a sort, and a
|
|
49
|
+
sort carried no tie-break. A database promises no order beyond the one it is
|
|
50
|
+
given, so a write between two page reads moved a row to another page: the
|
|
51
|
+
walk returned one row twice and never returned another. `Support::Sort` now
|
|
52
|
+
appends the model's primary key to the order of a paged read, and the
|
|
53
|
+
in-memory sort for virtual fields keeps the database order for rows that tie.
|
|
54
|
+
A read with no `page` parameter is unchanged. A paged scope must keep the
|
|
55
|
+
primary key in its select list: PostgreSQL refuses the order on a scope that
|
|
56
|
+
selects `DISTINCT` columns without the key, or groups by another column.
|
|
57
|
+
Regression spec: `paged_read_unstable_order_spec.rb`.
|
|
58
|
+
|
|
10
59
|
## [4.1.1] - 2026-10-10
|
|
11
60
|
|
|
12
61
|
### Fixed
|
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:
|
|
@@ -4,74 +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 = filter_hop_resource(rel_mod)
|
|
49
|
-
return false unless rel_res
|
|
50
|
-
|
|
51
|
-
check_filter_parts(parts, rel_res, rel_mod)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# The request namespace answers each hop, the lookup an include hop
|
|
55
|
-
# takes. A flat resource is another namespace's surface, with filters
|
|
56
|
-
# this one never declared.
|
|
57
|
-
def filter_hop_resource(model)
|
|
58
|
-
JSONAPI::ResourceLoader.find_for_model(model, namespace: jsonapi_namespace)
|
|
59
|
-
rescue JSONAPI::ResourceLoader::MissingResourceClass
|
|
60
|
-
nil
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def filter_rel_allowed?(res_cls, rel)
|
|
64
|
-
permitted = res_cls.permitted_filters_through
|
|
65
|
-
permitted.include?(rel.to_sym) || permitted.map(&:to_s).include?(rel.to_s)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def polymorphic_filter_valid?(parts)
|
|
69
|
-
return false if parts.empty? || parts.length > 1
|
|
70
|
-
|
|
71
|
-
col_filter = parse_column_filter(parts.first)
|
|
72
|
-
%w[id type].include?(col_filter ? col_filter[:column] : parts.first)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
21
|
def render_filter_errors(invalid)
|
|
76
22
|
render_parameter_errors(
|
|
77
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"
|
|
@@ -30,7 +31,7 @@ module JSONAPI
|
|
|
30
31
|
|
|
31
32
|
def execute
|
|
32
33
|
@scope = apply_filtering
|
|
33
|
-
@sort = Support::Sort.new(sort_params:, resource_class: definition)
|
|
34
|
+
@sort = Support::Sort.new(sort_params:, resource_class: definition, tie_break: pagination_applied)
|
|
34
35
|
compute_total_count_if_needed
|
|
35
36
|
@scope = @sort.apply(@scope)
|
|
36
37
|
@scope = apply_pagination
|
|
@@ -51,5 +52,11 @@ module JSONAPI
|
|
|
51
52
|
scope = apply_nested_relationship_filters(@scope)
|
|
52
53
|
apply_regular_filters(scope)
|
|
53
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
|
|
54
61
|
end
|
|
55
62
|
end
|
|
@@ -17,61 +17,27 @@ module JSONAPI
|
|
|
17
17
|
private
|
|
18
18
|
|
|
19
19
|
def apply_filter_for_path(scope, filter_name, filter_value)
|
|
20
|
-
|
|
21
|
-
return scope if
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
result = traverse_relationship_chain(scope, relationship_chain, leaf_filter, filter_value)
|
|
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?
|
|
52
|
-
attributes = { leaf_filter => filter_value }
|
|
53
|
-
return { scope: apply_polymorphic_nested_filters(scope, association, relationship_name, attributes),
|
|
54
|
-
early_return: true, }
|
|
55
|
-
end
|
|
20
|
+
*relationship_chain, leaf_filter = filter_name.split(".")
|
|
21
|
+
return scope if relationship_chain.empty?
|
|
22
|
+
# An empty value filters nothing, so it adds no join. An inner join
|
|
23
|
+
# drops each row that has no related record.
|
|
24
|
+
return scope if empty_filter_value?(filter_value)
|
|
56
25
|
|
|
57
|
-
|
|
58
|
-
|
|
26
|
+
# The walk filter validation takes. A path it refuses filters nothing.
|
|
27
|
+
target = filter_path.resolve(filter_name)
|
|
28
|
+
return scope unless target
|
|
59
29
|
|
|
60
|
-
|
|
30
|
+
# The foreign key and type columns of a polymorphic hop sit on the model
|
|
31
|
+
# that owns the association. Only the hops before it need a join.
|
|
32
|
+
joined = join_chain(scope, target.association ? relationship_chain[0..-2] : relationship_chain)
|
|
33
|
+
filtered = apply_filter_on_target(joined, target, leaf_filter, filter_value)
|
|
34
|
+
# A filter that adds no condition adds no join either.
|
|
35
|
+
filtered.equal?(joined) ? scope : filtered
|
|
61
36
|
end
|
|
62
37
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
JSONAPI::ResourceLoader.find_for_model(model, namespace:)
|
|
67
|
-
rescue JSONAPI::ResourceLoader::MissingResourceClass
|
|
68
|
-
nil
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def apply_joined_filter(scope, result, relationship_chain:, leaf_filter:, filter_value:)
|
|
72
|
-
join_hash = build_join_hash_for_chain(relationship_chain)
|
|
73
|
-
scope = scope.joins(join_hash) if join_hash.present?
|
|
74
|
-
apply_filter_on_model(scope, result[:model], result[:definition], leaf_filter, filter_value)
|
|
38
|
+
def join_chain(scope, chain)
|
|
39
|
+
join_hash = build_join_hash_for_chain(chain)
|
|
40
|
+
join_hash.present? ? scope.joins(join_hash) : scope
|
|
75
41
|
end
|
|
76
42
|
|
|
77
43
|
def build_join_hash_for_chain(chain)
|
|
@@ -86,15 +52,18 @@ module JSONAPI
|
|
|
86
52
|
end
|
|
87
53
|
end
|
|
88
54
|
|
|
89
|
-
def
|
|
90
|
-
|
|
55
|
+
def apply_filter_on_target(scope, target, filter_name, filter_value)
|
|
56
|
+
if target.association
|
|
57
|
+
attributes = { filter_name => filter_value }
|
|
58
|
+
return apply_polymorphic_nested_filters(scope, target.association, target.model, attributes)
|
|
59
|
+
end
|
|
91
60
|
|
|
92
|
-
apply_column_filter(scope,
|
|
93
|
-
apply_scope_method_filter(scope,
|
|
61
|
+
apply_column_filter(scope, target.model, filter_name, filter_value) ||
|
|
62
|
+
apply_scope_method_filter(scope, target.model, filter_name, filter_value) ||
|
|
94
63
|
scope
|
|
95
64
|
end
|
|
96
65
|
|
|
97
|
-
def apply_scope_method_filter(scope, target_model,
|
|
66
|
+
def apply_scope_method_filter(scope, target_model, filter_name, filter_value)
|
|
98
67
|
return nil unless target_model.respond_to?(filter_name.to_sym)
|
|
99
68
|
|
|
100
69
|
try_scope_method(scope, target_model, filter_name, filter_value)
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module JSONAPI
|
|
4
4
|
module Support
|
|
5
5
|
module PolymorphicFilters
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
# `owner` is the model that holds the association. Its table carries the
|
|
7
|
+
# foreign key and type columns, and the scope must already join it.
|
|
8
|
+
def apply_polymorphic_nested_filters(scope, association, owner, attributes)
|
|
9
|
+
context = build_polymorphic_filter_context(association, owner, attributes)
|
|
8
10
|
|
|
9
11
|
attributes.each do |attr_name, attr_value|
|
|
10
12
|
scope = apply_polymorphic_attribute_filter(scope, attr_name, attr_value, context)
|
|
@@ -15,18 +17,18 @@ module JSONAPI
|
|
|
15
17
|
|
|
16
18
|
private
|
|
17
19
|
|
|
18
|
-
def build_polymorphic_filter_context(association, attributes)
|
|
20
|
+
def build_polymorphic_filter_context(association, owner, attributes)
|
|
19
21
|
{
|
|
22
|
+
owner:,
|
|
20
23
|
foreign_key: association.foreign_key,
|
|
21
24
|
foreign_type: association.foreign_type,
|
|
22
|
-
fk_column:
|
|
25
|
+
fk_column: owner.column_for_attribute(association.foreign_key),
|
|
23
26
|
type_value: attributes["type"] || attributes["type_eq"],
|
|
24
27
|
class_name: association.options[:class_name],
|
|
25
28
|
}
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
def apply_polymorphic_attribute_filter(scope, attr_name, attr_value, context)
|
|
29
|
-
return scope if empty_filter_value?(attr_value)
|
|
30
32
|
return scope if attr_name == "type"
|
|
31
33
|
|
|
32
34
|
scope = apply_polymorphic_id_filter(scope, attr_name, attr_value, context)
|
|
@@ -38,7 +40,7 @@ module JSONAPI
|
|
|
38
40
|
return apply_polymorphic_fk_filter(scope, attr_value, column_filter, context) if polymorphic_fk_filter?(
|
|
39
41
|
column_filter, context,
|
|
40
42
|
)
|
|
41
|
-
return scope
|
|
43
|
+
return where_owner_column(scope, context, :foreign_key, attr_value) if attr_name == "id"
|
|
42
44
|
|
|
43
45
|
scope
|
|
44
46
|
end
|
|
@@ -48,17 +50,17 @@ module JSONAPI
|
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def apply_polymorphic_fk_filter(scope, attr_value, column_filter, context)
|
|
51
|
-
value = normalize_filter_value_for_model(
|
|
53
|
+
value = normalize_filter_value_for_model(context[:owner], context[:fk_column], attr_value)
|
|
52
54
|
return scope unless value
|
|
53
55
|
|
|
54
|
-
condition = build_condition(
|
|
56
|
+
condition = build_condition(context[:owner], context[:fk_column], value, column_filter[:operator])
|
|
55
57
|
condition ? apply_condition(scope, condition) : scope
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def apply_polymorphic_type_filter(scope, context)
|
|
59
61
|
return scope unless context[:foreign_type]
|
|
60
|
-
return scope
|
|
61
|
-
return scope
|
|
62
|
+
return where_owner_column(scope, context, :foreign_type, context[:class_name]) if context[:class_name]
|
|
63
|
+
return where_owner_column(scope, context, :foreign_type, context[:type_value]) if context[:type_value]
|
|
62
64
|
|
|
63
65
|
scope
|
|
64
66
|
end
|
|
@@ -68,7 +70,13 @@ module JSONAPI
|
|
|
68
70
|
return scope if context[:class_name]
|
|
69
71
|
return scope unless context[:type_value]
|
|
70
72
|
|
|
71
|
-
scope
|
|
73
|
+
where_owner_column(scope, context, :foreign_type, context[:type_value])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Names the owner's table, so the condition reads the joined table when
|
|
77
|
+
# the owner sits below the root of the query.
|
|
78
|
+
def where_owner_column(scope, context, column_key, value)
|
|
79
|
+
scope.where(context[:owner].table_name => { context[column_key] => value })
|
|
72
80
|
end
|
|
73
81
|
end
|
|
74
82
|
end
|
|
@@ -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.3
|
|
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
|