jpie 4.1.5 → 4.1.6

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: 618cb164f0b64934d5cd9a0c6e871b726bcc6b40f4add24c3204bea490a0cd5c
4
- data.tar.gz: f4acc02e6f57760deae01dc2d4c49acb29fb5273acefb3eac290f7519e3f53d4
3
+ metadata.gz: 88daaadec0328fdb557e2376e20763a99dcefb98ba869e5d94230bda9c055b88
4
+ data.tar.gz: 20effbb785465921ecd1aa325c23d7207c6db317948f66c643483a999ae01372
5
5
  SHA512:
6
- metadata.gz: b83a3b08c429dc700144718b112c578bb9c67243a05a2400ef7fdd9055f614e50852e4819bd63c6897e04cf168eee09e1f7d2b606da39340b9dc6acd2e723b8a
7
- data.tar.gz: 79f17c229521c0c99541e9787e298e9e00268996759ca8a54f67fec95f212f3cd7f124ea672d8c9da65f65546c6ad02794c971b49a8a8fef7b874883eb601760
6
+ metadata.gz: 3643d03bdf7acec6532241e282379e7b9bd3fe6f3dffad7b5d753ba23ce642d0b1c0ebe30962c03cde53a38d7a747acae589d002d702d5243c4112f80bda980a
7
+ data.tar.gz: 7dbaa3e703ecc13f432a54341d39f357a860896efef6b078572be24e78258658bb343c0a49eb1b3ad0600362c296ab4d620943da21d1bc7ff766b8ed33b27b1e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ at release time.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.1.6] - 2026-11-13
11
+
12
+ ### Fixed
13
+
14
+ - **Keep a primary record out of `included`.** A primary record that an include
15
+ reached again was serialized a second time, with its meta and computed
16
+ attributes, and sent in `included`. JSON:API allows one resource object for
17
+ each type and id in a document. Every primary now counts as seen before the
18
+ include walk starts. The walk still goes past it to the records behind it.
19
+
20
+ ### Added
21
+
22
+ - **`Resource.included_preloads` to give an included record lighter preloads.**
23
+ The include walk ran the full `records` preloads of a resource on every
24
+ record that another resource included. An included record serializes only
25
+ the relationships its include path names, so a deep preload for the
26
+ resource's own response (a conversation's messages, for example) loaded rows
27
+ the response never read. `included_preloads` returns the preloads of
28
+ `records` by default, so nothing changes until a resource overrides it.
29
+ Regression spec: `included_preloads_spec.rb`.
30
+
31
+ ### Changed
32
+
33
+ - **Compare the query values, not two SQL strings, to see if the hook narrowed
34
+ an include scope.** The include filter cache built the SQL of the resource
35
+ scope and of the authorized scope for each included class, in each request.
36
+ It now compares the values of the two relations and builds no SQL. A narrowed
37
+ scope still reads as narrowed. A scope the hook leaves unchanged still reads
38
+ as not narrowed. The documents do not change. On the Kiln test app, the
39
+ compare allocates 740 objects for each class before and 18 after.
40
+ - A sparse fieldset now limits which attributes the serializer reads. It read every attribute of every record and cut the fieldset out afterwards, so a resource method outside the fieldset ran for every row.
41
+
10
42
  ## [4.1.5] - 2026-11-11
11
43
 
12
44
  ### Fixed
@@ -103,8 +103,7 @@ module JSONAPI
103
103
  end
104
104
 
105
105
  def resource_preload_values(klass)
106
- scope = ResourceLoader.find_for_model(klass, namespace: jsonapi_namespace).records
107
- scope.preload_values + scope.includes_values
106
+ ResourceLoader.find_for_model(klass, namespace: jsonapi_namespace).included_preloads
108
107
  end
109
108
  end
110
109
  end
@@ -41,6 +41,9 @@ module JSONAPI
41
41
  shared_context = JSONAPI::Serialization::IncludeContext.new(
42
42
  fields: fields, included_records: [], processed: Set.new, all_includes: nil,
43
43
  )
44
+ # A primary that an include reaches again belongs in `data` alone. Mark every primary
45
+ # before the first walk, so no walk serializes it a second time.
46
+ resources.each { |r| shared_context.processed.add([r.class, r.id]) }
44
47
  cache = build_include_filter_cache(resources, includes)
45
48
 
46
49
  data = resources.map do |r|
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Resources
5
+ module IncludedPreloads
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ # The preloads the include walk runs on this resource's records when
10
+ # another resource reaches them through `?include=`. By default they
11
+ # are the preloads of `records`. Override it when `records` preloads
12
+ # more than an included record needs. An included record serializes
13
+ # only the relationships its include path names, so a deep preload
14
+ # that feeds the resource's own response is wasted there.
15
+ def included_preloads
16
+ scope = records
17
+ scope.preload_values + scope.includes_values
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -6,6 +6,7 @@ require_relative "concerns/sortable_fields_dsl"
6
6
  require_relative "concerns/relationships_dsl"
7
7
  require_relative "concerns/meta_dsl"
8
8
  require_relative "concerns/model_class_helpers"
9
+ require_relative "concerns/included_preloads"
9
10
 
10
11
  module JSONAPI
11
12
  class Resource
@@ -15,6 +16,7 @@ module JSONAPI
15
16
  include Resources::RelationshipsDsl
16
17
  include Resources::MetaDsl
17
18
  include Resources::ModelClassHelpers
19
+ include Resources::IncludedPreloads
18
20
 
19
21
  class << self
20
22
  attr_accessor :type_format
@@ -7,10 +7,7 @@ module JSONAPI
7
7
  type_fields = extract_type_fields(fields)
8
8
  return {} if type_fields.empty? && fields.any?
9
9
 
10
- attributes = build_attributes_hash
11
- return attributes if type_fields.empty?
12
-
13
- attributes.slice(*type_fields.map(&:to_sym))
10
+ build_attributes_hash(type_fields.map(&:to_sym))
14
11
  end
15
12
 
16
13
  private
@@ -19,11 +16,15 @@ module JSONAPI
19
16
  fields[record_type.to_sym] || []
20
17
  end
21
18
 
22
- def build_attributes_hash
19
+ # A sparse fieldset names the attributes to send, so only those are read. Building
20
+ # every attribute and slicing afterwards paid for each unrequested resource method on
21
+ # every row. The keys keep the order of the fieldset, as the slice did.
22
+ def build_attributes_hash(requested)
23
23
  permitted_attrs = definition.permitted_attributes
24
+ wanted = requested.empty? ? permitted_attrs : requested.select { |attr_sym| permitted_attrs.include?(attr_sym) }
24
25
  attributes = {}
25
26
 
26
- permitted_attrs.each do |attr_sym|
27
+ wanted.each do |attr_sym|
27
28
  attributes[attr_sym] = get_attribute_value(definition_instance, attr_sym)
28
29
  end
29
30
 
@@ -28,6 +28,7 @@ module JSONAPI
28
28
 
29
29
  ctx = context || build_include_context(fields, all_includes)
30
30
  ctx.all_includes ||= all_includes
31
+ ctx.processed.add(build_record_key(record))
31
32
  all_includes.each { |path| serialize_include_path(record, path_walk_for(path, ctx), 0, ctx) }
32
33
  ctx.included_records
33
34
  end
@@ -75,12 +75,23 @@ module JSONAPI
75
75
  Entry.new(
76
76
  base_scope: base_scope,
77
77
  authorized_scope: authorized_scope,
78
- narrowed: authorized_scope.to_sql != base_scope.to_sql,
78
+ narrowed: narrowed?(authorized_scope, base_scope),
79
79
  where_clause_empty: base_scope.where_clause.empty?,
80
80
  where_hash: where_hash_for(base_scope, klass),
81
81
  )
82
82
  end
83
83
 
84
+ # A scope the hook leaves as it found it keeps the same query values, so the
85
+ # compare reads them and builds no SQL. Building both statements for every
86
+ # included class cost thousands of objects per request. Two scopes whose
87
+ # values differ but whose SQL matches read as narrowed. That only adds a
88
+ # vetting query: the verdicts come from the same SQL.
89
+ def narrowed?(authorized_scope, base_scope)
90
+ return false if authorized_scope.equal?(base_scope)
91
+
92
+ authorized_scope.values != base_scope.values
93
+ end
94
+
84
95
  # Mirrors the serializer's include authorization: no context (serializer
85
96
  # used outside a request) or no configured hook leaves the scope untouched.
86
97
  def apply_authorization(scope, klass)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "4.1.5"
4
+ VERSION = "4.1.6"
5
5
  end
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.5
4
+ version: 4.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp
@@ -135,6 +135,7 @@ files:
135
135
  - lib/json_api/resources/active_storage_blob_resource.rb
136
136
  - lib/json_api/resources/concerns/attributes_dsl.rb
137
137
  - lib/json_api/resources/concerns/filters_dsl.rb
138
+ - lib/json_api/resources/concerns/included_preloads.rb
138
139
  - lib/json_api/resources/concerns/meta_dsl.rb
139
140
  - lib/json_api/resources/concerns/model_class_helpers.rb
140
141
  - lib/json_api/resources/concerns/relationships_dsl.rb