jpie 3.10.1 → 3.11.0

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: aee1da20dd3c81baafa8bcb78801d128f2b59f76be85b12216f96bf35b822344
4
- data.tar.gz: 7de107c0fd9ea459ad0f7015d7b0a6877e88462f81b9900cdf022d14788a8118
3
+ metadata.gz: 3cc59c786e140082618fccd88462e9ea9e3c98d97aa6184752d9d06b0c99d8eb
4
+ data.tar.gz: 4e212599caf45aad70ba6f286a2be35e5d61df0aee6c62e23f3177f827e7bb83
5
5
  SHA512:
6
- metadata.gz: 6639e6a66ac7c37cbf50372b433f9b1be772d6e1bda28cd76edbfb4a15612c6307bb9412894e0c6eede135aa6e0de321c2726cf3eda493381f746153ff6dd4fc
7
- data.tar.gz: 1998a903147f3737344b172459656dbb2638525d0371aac16c6951a8c519334ee4ddae641a7a260d200cd41d1c4ea480202bfd476bb930a1f55a8165d1ce4a07
6
+ metadata.gz: 2360e7d99ce16c2e9a9b485920811cd56f69e6281cd2dfd5b565be44a23c6cea9f9b85f8bd23caabe45b8e8976ff67e650d43bdc3a49ca9de1aab75151dc33db
7
+ data.tar.gz: 29f4244ee319f9a68403f4aeda3f8a497b843c5b67e0cd975704bc5e3f9ac0c4041da86fc008c618ca4552c08b3f167eb6794ae36adc7ad4b14095ec6e3bc7b9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ at release time.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.11.0]
11
+
12
+ ### Changed
13
+
14
+ - Read a model-backed attribute with `has_attribute?` and `read_attribute`.
15
+ The serializer called `ActiveRecord#attributes` three times per attribute per
16
+ record, and each call builds a hash of every column. A record with a fat text
17
+ column paid for that column on all three.
18
+ - Drop the per-record objects in the include walk. Each visited record
19
+ allocated a struct, a hash copy of the path context, and an empty keyword
20
+ hash; the attachment verdict now lifts out of the loop. The relationship
21
+ names an include path asks for are computed once per path, not once per
22
+ record.
23
+ - Precompute each include path's walk once per request. The walk re-split and
24
+ re-joined the path strings at every level for every parent record.
25
+ - Look a relationship definition up through a name-keyed index. The linear
26
+ scan converted every entry's name on every lookup, once per record per hop.
27
+ - Memoize the ActiveStorage attachment verdict per class and name. The
28
+ reflection lookup allocated a string per call, per relationship per record.
29
+ - Resolve a model's resource class and type name through identity-keyed front
30
+ caches. The name-keyed caches allocated their key strings on every call.
31
+ - Serialize a record through one resource instance. The attribute reader and
32
+ the meta reader each built their own, so a resource's memoized reads ran
33
+ twice.
34
+ - Skip the respond_to? probe on a loaded singular association target. The
35
+ probe walked ActiveModel's attribute matcher and allocated a string per
36
+ record.
37
+
10
38
  ## [3.10.1]
11
39
 
12
40
  ### Fixed
@@ -1,17 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/map"
4
+
3
5
  module JSONAPI
4
6
  module ActiveStorage
5
7
  module Detection
8
+ # The reflection lookup allocates a string per call, and the serializer
9
+ # asks per relationship per record. The verdict is a function of the
10
+ # class object and the name, so memoize it. Keys are class objects: the
11
+ # railtie clears the cache on code reload so a reloaded class cannot be
12
+ # retained.
13
+ @attachment_cache = Concurrent::Map.new
14
+
6
15
  module_function
7
16
 
8
17
  def attachment?(association_name, model_class)
9
18
  return false unless defined?(::ActiveStorage)
10
19
 
11
- model_class.respond_to?(:reflect_on_attachment) &&
20
+ cache = @attachment_cache.compute_if_absent(model_class) { Concurrent::Map.new }
21
+ cached = cache[association_name.to_sym]
22
+ return cached unless cached.nil?
23
+
24
+ cache[association_name.to_sym] =
25
+ model_class.respond_to?(:reflect_on_attachment) &&
12
26
  model_class.reflect_on_attachment(association_name.to_sym).present?
13
27
  end
14
28
 
29
+ def reset_cache!
30
+ @attachment_cache.clear
31
+ end
32
+
15
33
  def blob_type?(type)
16
34
  return false unless defined?(::ActiveStorage)
17
35
 
@@ -105,8 +105,8 @@ module JSONAPI
105
105
  # that pre-populate ResourceLoader caches in their own to_prepare blocks
106
106
  # run after this and re-apply their entries.
107
107
  app.reloader.to_prepare do
108
- JSONAPI::ResourceLoader.reset_cache!
109
- JSONAPI::TypeConversion.reset_cache!
108
+ [JSONAPI::ResourceLoader, JSONAPI::TypeConversion,
109
+ JSONAPI::ActiveStorage::Detection,].each(&:reset_cache!)
110
110
  Railtie.setup_base_controllers
111
111
  end
112
112
 
@@ -31,6 +31,7 @@ module JSONAPI
31
31
 
32
32
  def reset_relationship_definitions!
33
33
  remove_instance_variable(:@relationship_definitions) if defined?(@relationship_definitions)
34
+ remove_instance_variable(:@relationship_definition_index) if defined?(@relationship_definition_index)
34
35
  end
35
36
 
36
37
  def relationship_names
@@ -39,6 +40,20 @@ module JSONAPI
39
40
  end
40
41
 
41
42
  module RelationshipHelperMethods
43
+ # Name-keyed lookup over relationship_definitions. The serializer asks
44
+ # for one definition by name once per record per hop; a linear scan
45
+ # with a to_s per entry allocated thousands of strings per request.
46
+ # Both spellings key the same entry, so a lookup converts nothing.
47
+ def relationship_definition_index
48
+ return @relationship_definition_index if defined?(@relationship_definition_index)
49
+
50
+ index = relationship_definitions.each_with_object({}) do |r, h|
51
+ h[r[:name]] = r
52
+ h[r[:name].to_s] = r
53
+ end
54
+ @relationship_definition_index = index.freeze
55
+ end
56
+
42
57
  def register_relationship(name:, type:, meta:, options:)
43
58
  @relationships ||= []
44
59
  @relationships << { name: name.to_sym, type:, meta:, options: }
@@ -25,10 +25,17 @@ module JSONAPI
25
25
  # caches on code reload.
26
26
  @find_cache = Concurrent::Map.new
27
27
  @model_cache = Concurrent::Map.new
28
+ # Identity front cache for find_for_model: the string cache key allocated
29
+ # on every call, and the serializer calls per record and per identifier.
30
+ # Nested by fallback flag and namespace so a configuration flip cannot
31
+ # serve a stale class. Values come through the string-keyed path, so an
32
+ # application that pre-populates @model_cache keeps its pins.
33
+ @model_identity_cache = Concurrent::Map.new
28
34
 
29
35
  def self.reset_cache!
30
36
  @find_cache.clear
31
37
  @model_cache.clear
38
+ @model_identity_cache.clear
32
39
  end
33
40
 
34
41
  def self.find(resource_type, namespace: nil)
@@ -59,6 +66,18 @@ module JSONAPI
59
66
  def self.find_for_model(model_class, namespace: nil)
60
67
  return ActiveStorageBlobResource if active_storage_blob?(model_class)
61
68
 
69
+ bucket = model_identity_bucket(namespace)
70
+ bucket[model_class] || (bucket[model_class] = find_for_model_uncached(model_class, namespace))
71
+ end
72
+
73
+ def self.model_identity_bucket(namespace)
74
+ fallback = @model_identity_cache.compute_if_absent(JSONAPI.configuration.namespace_fallback ? 1 : 0) do
75
+ Concurrent::Map.new
76
+ end
77
+ fallback.compute_if_absent(namespace) { Concurrent::Map.new }
78
+ end
79
+
80
+ def self.find_for_model_uncached(model_class, namespace)
62
81
  key = model_cache_key(model_class, namespace)
63
82
  cached = @model_cache[key]
64
83
  return cached if cached
@@ -20,9 +20,8 @@ module JSONAPI
20
20
  end
21
21
 
22
22
  def build_attributes_hash
23
- permitted_attrs = definition.permitted_attributes.map(&:to_sym)
23
+ permitted_attrs = definition.permitted_attributes
24
24
  attributes = {}
25
- definition_instance = definition.new(record, {})
26
25
 
27
26
  permitted_attrs.each do |attr_sym|
28
27
  attributes[attr_sym] = get_attribute_value(definition_instance, attr_sym)
@@ -34,16 +33,22 @@ module JSONAPI
34
33
  def get_attribute_value(definition_instance, attr_sym)
35
34
  return definition_instance.public_send(attr_sym) if definition_instance.respond_to?(attr_sym, false)
36
35
 
37
- attr_name = attr_sym.to_s
38
- return record.attributes[attr_name] if model_has_attribute?(attr_name)
39
-
40
- nil
36
+ read_model_attribute(attr_sym.to_s)
41
37
  end
42
38
 
43
- def model_has_attribute?(attr_name)
44
- record.respond_to?(:attributes) &&
45
- record.attributes.is_a?(Hash) &&
46
- record.attributes.key?(attr_name)
39
+ # ActiveRecord#attributes builds a hash of every column on every call,
40
+ # and this reader called it three times per attribute per record: twice
41
+ # to ask whether the column exists, once to read it. A record with a fat
42
+ # text column paid for that column on all three. has_attribute? and
43
+ # read_attribute answer from the attribute set itself, one attribute at a
44
+ # time. A model that is not ActiveRecord keeps the hash route.
45
+ def read_model_attribute(attr_name)
46
+ if record.respond_to?(:has_attribute?) && record.respond_to?(:read_attribute)
47
+ return record.has_attribute?(attr_name) ? record.read_attribute(attr_name) : nil
48
+ end
49
+
50
+ attributes = record.attributes if record.respond_to?(:attributes)
51
+ attributes.is_a?(Hash) ? attributes[attr_name] : nil
47
52
  end
48
53
  end
49
54
  end
@@ -10,7 +10,15 @@ module JSONAPI
10
10
  # denies. The request-scoped cache memoizes the scopes and the per-id
11
11
  # verdicts, so each record is vetted at most once per request.
12
12
  def filter_loaded_records(association, related_klass)
13
- loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
13
+ target = association.target
14
+ # A collection target is an Array; a singular target is the record or
15
+ # nil. A respond_to?(:to_a) probe on a record walks ActiveModel's
16
+ # attribute matcher and allocates a string per probe.
17
+ loaded_array = if target.is_a?(Array)
18
+ target
19
+ else
20
+ target.nil? ? [] : [target]
21
+ end
14
22
  return [] if loaded_array.empty?
15
23
 
16
24
  include_filter_cache.filter(loaded_array, related_klass)
@@ -4,10 +4,19 @@ require_relative "include_filtering"
4
4
 
5
5
  module JSONAPI
6
6
  module Serialization
7
- IncludeContext = Struct.new(:fields, :included_records, :processed, :all_includes, keyword_init: true)
8
- PathContext = Struct.new(:path_parts, :path_to_related, :association_name, :current_record, keyword_init: true)
9
- IncludeIteration = Struct.new(:related_record, :path_parts, :path_to_related, :association_name, :current_record,
10
- keyword_init: true,)
7
+ IncludeContext = Struct.new(:fields, :included_records, :processed, :all_includes, :requested, :walks,
8
+ keyword_init: true,) do
9
+ # Per-request memos: the relationship names each include path asks for,
10
+ # and the precomputed walk for each path. They default lazily, so a
11
+ # context built by hand (a caller outside this gem) works without
12
+ # naming them.
13
+ def requested = self[:requested] ||= {}
14
+
15
+ def walks = self[:walks] ||= {}
16
+ end
17
+ # Precomputed shape of one include path: association symbols per depth and
18
+ # the dotted prefix string that names each depth from the root.
19
+ IncludePathWalk = Struct.new(:names, :prefixes, keyword_init: true)
11
20
 
12
21
  module IncludesSerialization
13
22
  include IncludePathHelpers
@@ -19,7 +28,7 @@ module JSONAPI
19
28
 
20
29
  ctx = context || build_include_context(fields, all_includes)
21
30
  ctx.all_includes ||= all_includes
22
- all_includes.each { |path| serialize_include_path(record, path, ctx, path_from_root: "") }
31
+ all_includes.each { |path| serialize_include_path(record, path_walk_for(path, ctx), 0, ctx) }
23
32
  ctx.included_records
24
33
  end
25
34
 
@@ -29,34 +38,45 @@ module JSONAPI
29
38
 
30
39
  private
31
40
 
32
- def serialize_include_path(current_record, include_path, ctx, path_from_root: "")
33
- path_parts = include_path.split(".")
34
- association_name = path_parts.first.to_sym
41
+ # The split, the association symbols, and the prefix strings depend on
42
+ # the path alone, so compute them once per path instead of once per
43
+ # parent record. The old walk re-split and re-joined the path strings at
44
+ # every level for every record it passed.
45
+ def path_walk_for(path, ctx)
46
+ ctx.walks[path] ||= begin
47
+ names = path.split(".").map(&:to_sym)
48
+ prefixes = names.each_with_index.map { |_, i| names[0..i].join(".") }
49
+ IncludePathWalk.new(names: names, prefixes: prefixes)
50
+ end
51
+ end
52
+
53
+ def serialize_include_path(current_record, walk, depth, ctx)
54
+ association_name = walk.names[depth]
35
55
  return unless valid_include_association?(current_record, association_name)
36
56
 
37
- path_to_related = path_from_root.blank? ? path_parts.first : "#{path_from_root}.#{path_parts.first}"
38
- path_ctx = PathContext.new(path_parts:, path_to_related:, association_name:, current_record: current_record)
39
- process_related_records(get_related_records(current_record, association_name), path_ctx, ctx)
57
+ serialize_walk_level(current_record, get_related_records(current_record, association_name),
58
+ walk, depth, ctx,)
40
59
  end
41
60
 
42
- def process_related_records(related_array, path_ctx, ctx)
61
+ # The attachment verdict holds for the whole related array: it reads the
62
+ # association name and the parent class, which do not change inside the
63
+ # loop.
64
+ def serialize_walk_level(current_record, related_array, walk, depth, ctx)
65
+ attachment = self.class.active_storage_attachment?(walk.names[depth], current_record.class)
66
+ nested = depth + 1 < walk.names.size
67
+
43
68
  related_array.each do |related_record|
44
- iter = IncludeIteration.new(related_record: related_record, **path_ctx.to_h)
45
- serialize_related_record(iter, ctx)
69
+ serialize_walk_record(related_record, current_record, walk, depth, ctx, attachment: attachment)
70
+ serialize_include_path(related_record, walk, depth + 1, ctx) if nested
46
71
  end
47
72
  end
48
73
 
49
- def serialize_related_record(iter, ctx)
50
- parent_context = parent_context_for(iter.association_name, iter.current_record)
51
- serialize_and_process_record(iter.related_record, iter.path_to_related, ctx, **parent_context)
52
- serialize_nested_path(iter.related_record, iter.path_parts, iter.path_to_related, ctx)
53
- end
54
-
55
- def parent_context_for(association_name, current_record)
56
- if self.class.active_storage_attachment?(association_name, current_record.class)
57
- { parent_record: current_record, association_name: }
74
+ def serialize_walk_record(related_record, current_record, walk, depth, ctx, attachment:)
75
+ if attachment
76
+ serialize_and_process_record(related_record, walk.prefixes[depth], ctx,
77
+ parent_record: current_record, association_name: walk.names[depth],)
58
78
  else
59
- {}
79
+ serialize_and_process_record(related_record, walk.prefixes[depth], ctx)
60
80
  end
61
81
  end
62
82
 
@@ -122,12 +142,12 @@ module JSONAPI
122
142
  end
123
143
 
124
144
  def serialize_and_process_record(related_record, path_to_record, ctx, parent_record: nil, association_name: nil)
125
- return if ctx.processed.include?(build_record_key(related_record))
145
+ return unless ctx.processed.add?(build_record_key(related_record))
126
146
 
127
- requested = include_paths_to_relationship_names(ctx.all_includes, path_to_record)
147
+ requested = ctx.requested[path_to_record] ||=
148
+ include_paths_to_relationship_names(ctx.all_includes, path_to_record)
128
149
  serializer = child_serializer(related_record, parent_record:, association_name:)
129
150
  ctx.included_records << serializer.serialize_record(ctx.fields, requested_relationships: requested)
130
- ctx.processed.add(build_record_key(related_record))
131
151
  end
132
152
 
133
153
  # An included record serializes with the same request context as its
@@ -138,15 +158,10 @@ module JSONAPI
138
158
  authorization_context:, include_filter_cache:, namespace:,)
139
159
  end
140
160
 
161
+ # A two-slot array beats the string key: no interpolation, no copy of
162
+ # the class name, and the same class-plus-id identity.
141
163
  def build_record_key(related_record)
142
- "#{related_record.class.name}-#{related_record.id}"
143
- end
144
-
145
- def serialize_nested_path(related_record, path_parts, path_from_root, ctx)
146
- return unless path_parts.length > 1
147
-
148
- nested_path = path_parts[1..].join(".")
149
- serialize_include_path(related_record, nested_path, ctx, path_from_root: path_from_root)
164
+ [related_record.class, related_record.id]
150
165
  end
151
166
  end
152
167
  end
@@ -22,8 +22,7 @@ module JSONAPI
22
22
  def fetch_instance_meta
23
23
  return unless definition.method_defined?(:meta)
24
24
 
25
- instance = definition.new(@record, {})
26
- instance.meta
25
+ definition_instance.meta
27
26
  end
28
27
 
29
28
  def fetch_class_meta
@@ -83,6 +83,14 @@ module JSONAPI
83
83
 
84
84
  attr_reader :record, :definition, :parent_record, :association_name, :authorization_context, :namespace
85
85
 
86
+ # One resource instance per serialized record, shared by the attribute
87
+ # reader and the meta reader. The two built separate instances, which
88
+ # doubled the instantiation and kept the resource's own memoized reads
89
+ # from being shared between an attribute and a meta entry.
90
+ def definition_instance
91
+ @definition_instance ||= definition.new(record)
92
+ end
93
+
86
94
  # Shared per request when the controller passes one in; a standalone
87
95
  # serializer builds its own, which still dedupes within its own walk.
88
96
  def include_filter_cache
@@ -63,7 +63,11 @@ module JSONAPI
63
63
  end
64
64
 
65
65
  def find_relationship_definition(definition, relationship_name)
66
- definition.relationship_definitions.find { |r| r[:name].to_s == relationship_name.to_s }
66
+ if definition.respond_to?(:relationship_definition_index)
67
+ definition.relationship_definition_index[relationship_name]
68
+ else
69
+ definition.relationship_definitions.find { |r| r[:name].to_s == relationship_name.to_s }
70
+ end
67
71
  end
68
72
  end
69
73
  end
@@ -73,9 +73,7 @@ module JSONAPI
73
73
  end
74
74
 
75
75
  def polymorphic_association?(definition, relationship_name)
76
- relationship_def = definition.relationship_definitions.find do |r|
77
- r[:name].to_s == relationship_name.to_s
78
- end
76
+ relationship_def = RelationshipHelpers.find_relationship_definition(definition, relationship_name)
79
77
  return false unless relationship_def
80
78
 
81
79
  relationship_def[:options][:polymorphic] == true
@@ -9,15 +9,26 @@ module JSONAPI
9
9
  # relationship identifier. Cache frozen results keyed by name and format.
10
10
  # The railtie clears the cache on code reload.
11
11
  @type_name_cache = Concurrent::Map.new
12
+ # Identity front cache: the name-keyed cache still allocated its key
13
+ # string on every call. Nested by kind and format so a lookup allocates
14
+ # nothing; values come through the name-keyed cache, and both clear
15
+ # together on code reload.
16
+ @class_name_cache = Concurrent::Map.new
12
17
 
13
18
  def self.reset_cache!
14
19
  @type_name_cache.clear
20
+ @class_name_cache.clear
15
21
  end
16
22
 
17
23
  def self.type_name_cache
18
24
  @type_name_cache
19
25
  end
20
26
 
27
+ def self.class_name_bucket(kind, format)
28
+ by_kind = @class_name_cache.compute_if_absent(kind) { Concurrent::Map.new }
29
+ by_kind.compute_if_absent(format) { Concurrent::Map.new }
30
+ end
31
+
21
32
  module_function
22
33
 
23
34
  def type_to_class_name(type, namespace: nil)
@@ -38,8 +49,14 @@ module JSONAPI
38
49
  name = model_class.name
39
50
  return format_type_name(name.underscore.pluralize, format) if name.nil?
40
51
 
41
- cache = TypeConversion.type_name_cache
42
- cache["m|#{name}|#{format}"] ||= format_type_name(name.underscore.pluralize, format).freeze
52
+ bucket = TypeConversion.class_name_bucket(:model, format)
53
+ bucket[model_class] ||= named_type_name("m|#{name}|#{format}", name.underscore.pluralize, format)
54
+ end
55
+
56
+ # The name-keyed cache stays the source of truth so a reloaded class
57
+ # resolves to the same frozen string.
58
+ def named_type_name(key, full_name, format)
59
+ TypeConversion.type_name_cache[key] ||= format_type_name(full_name, format).freeze
43
60
  end
44
61
 
45
62
  def resource_type_name(definition_class, format: nil)
@@ -47,8 +64,9 @@ module JSONAPI
47
64
  name = definition_class.name
48
65
  return format_type_name(name.sub(/Resource$/, "").underscore.pluralize, format) if name.nil?
49
66
 
50
- cache = TypeConversion.type_name_cache
51
- cache["r|#{name}|#{format}"] ||= format_type_name(name.sub(/Resource$/, "").underscore.pluralize, format).freeze
67
+ bucket = TypeConversion.class_name_bucket(:resource, format)
68
+ bucket[definition_class] ||=
69
+ named_type_name("r|#{name}|#{format}", name.sub(/Resource$/, "").underscore.pluralize, format)
52
70
  end
53
71
 
54
72
  def format_type_name(full_name, format)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "3.10.1"
4
+ VERSION = "3.11.0"
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: 3.10.1
4
+ version: 3.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp