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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdb7cd53b04d123a7fbcefd2e1cde0809eaa0289dfe1731cf283302db3ad260f
4
- data.tar.gz: 8685564a715a8cb677b73a4d55b3762d94fadccbac03adbd28d9166943c98df2
3
+ metadata.gz: 7cb5c150e4da0f0608aa5b97534e2239b8b80900054c984ca2aed3c24e747198
4
+ data.tar.gz: 83558900f75be4e9a4b8ea5997f7551963ee1406359b9fd30903d39fe9ad78bb
5
5
  SHA512:
6
- metadata.gz: 9c47e7c40d0ddcfecb1c8ed69ab089e16445e87a5a2a1c838d9ad041a9f21067ddbfd1bb55ca382ff1e933b03e325f66cc07a3e3d69cec191caca058324d77ea
7
- data.tar.gz: 8ab015ac55076351ac1a02510a6f3c9ad60ed9fd48de5f7261c2f3280ef97218ff9a872a1662d2d4fae542f7dc1d174c88be0bb2dde1d4604b6e032a66f468c7
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. For a namespaced model, both name
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
- invalid = filters.keys.reject { |n| filter_path_valid?(n.to_s) }
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,
@@ -105,6 +105,7 @@ module JSONAPI
105
105
  scope,
106
106
  definition: @resource_class,
107
107
  model_class: model_class,
108
+ namespace: jsonapi_namespace,
108
109
  filter_params: parse_filter_param,
109
110
  sort_params: parse_sort_param,
110
111
  page_params: parse_page_param,
@@ -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
- parts = filter_name.split(".")
21
- return scope if parts.length < 2
20
+ *relationship_chain, leaf_filter = filter_name.split(".")
21
+ return scope if relationship_chain.empty?
22
22
 
23
- relationship_chain = parts[0..-2]
24
- leaf_filter = parts.last
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
- 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?
27
+ if target.association
52
28
  attributes = { leaf_filter => filter_value }
53
- return { scope: apply_polymorphic_nested_filters(scope, association, relationship_name, attributes),
54
- early_return: true, }
29
+ return apply_polymorphic_nested_filters(scope, target.association, relationship_chain.last, attributes)
55
30
  end
56
31
 
57
- next_definition = JSONAPI::Resource.resource_for_model(association.klass)
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, result, relationship_chain:, leaf_filter:, filter_value:)
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, result[:model], result[:definition], leaf_filter, filter_value)
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, target_resource, filter_name, filter_value)
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, target_resource, filter_name, filter_value) ||
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, _target_resource, filter_name, filter_value)
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 { |a, b| compare_by_fields(a, b, virtual_fields) }
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "4.1.1.pre.202609300315"
4
+ VERSION = "4.1.2"
5
5
  end
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.1.pre.202609300315
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