jpie 3.5.0 → 3.8.1

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.
@@ -38,7 +38,8 @@ module JSONAPI
38
38
 
39
39
  related_model_class = type_value.constantize
40
40
  related_resource_class = JSONAPI::ResourceLoader.find_for_model(related_model_class)
41
- record = related_resource_class.records.find(id_value)
41
+ base = apply_relationship_authorization(related_resource_class.records, related_model_class)
42
+ record = base.find(id_value)
42
43
 
43
44
  association.collection? ? Array(record) : record
44
45
  end
@@ -52,17 +53,31 @@ module JSONAPI
52
53
  def fetch_polymorphic_has_many_through_resource_records(association)
53
54
  association_instance = @resource.association(@relationship_name)
54
55
  related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
55
- related_resource_class.records.merge(association_instance.scope)
56
+ base = apply_relationship_authorization(related_resource_class.records, association.klass)
57
+ association_instance.scope.merge(base)
56
58
  end
57
59
 
58
60
  def fetch_non_polymorphic_related_through_resource_records(association)
59
61
  association_instance = @resource.association(@relationship_name)
60
62
  related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
61
- scope = related_resource_class.records.merge(association_instance.scope)
63
+ base = apply_relationship_authorization(related_resource_class.records, association.klass)
64
+ scope = association_instance.scope.merge(base)
62
65
 
63
66
  association.collection? ? scope : scope.first
64
67
  end
65
68
 
69
+ # A relationships response (`/:type/:id/relationships/:rel`) lists the related
70
+ # records identified by linkage. Route the related base scope through the
71
+ # configured authorization_scope hook, exactly as the primary collection and
72
+ # `?include=` sideloads already do, so linkage never names a record the reader
73
+ # cannot fetch. No configured hook leaves the scope untouched.
74
+ def apply_relationship_authorization(scope, model_class)
75
+ handler = JSONAPI.configuration.authorization_scope
76
+ return scope unless handler
77
+
78
+ handler.call(controller: self, scope:, action: :index, model_class:)
79
+ end
80
+
66
81
  def serialize_related(related, association)
67
82
  return serialize_collection_relationship(related, association) if association.collection?
68
83
 
@@ -32,12 +32,27 @@ module JSONAPI
32
32
  def filter_includable(hash, klass)
33
33
  hash.each_with_object({}) do |(key, value), filtered|
34
34
  assoc = klass.reflect_on_association(key)
35
- next unless assoc
36
-
37
- filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
35
+ if assoc
36
+ filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
37
+ elsif (attachment_assoc = attachment_association_name(klass, key))
38
+ # An attachment include carries no AR reflection under its own name
39
+ # (has_many_attached :files defines files_attachments, not files).
40
+ # Preload the association pair ActiveStorage defines for it, so the
41
+ # serializer reads blobs without one probe query per record.
42
+ filtered[attachment_assoc] = { blob: {} }
43
+ end
38
44
  end
39
45
  end
40
46
 
47
+ def attachment_association_name(klass, key)
48
+ return nil unless defined?(::ActiveStorage) && klass.respond_to?(:reflect_on_attachment)
49
+
50
+ reflection = klass.reflect_on_attachment(key)
51
+ return nil unless reflection
52
+
53
+ reflection.macro == :has_one_attached ? :"#{key}_attachment" : :"#{key}_attachments"
54
+ end
55
+
41
56
  def preload_required?(hash, klass)
42
57
  hash.any? do |key, value|
43
58
  assoc = klass.reflect_on_association(key)
@@ -7,8 +7,10 @@ module JSONAPI
7
7
  include IncludePreloading
8
8
 
9
9
  def serialize_resource(resource)
10
- JSONAPI::Serializer.new(resource).to_hash(
11
- include: parse_include_param,
10
+ includes = parse_include_param
11
+ cache = build_include_filter_cache([resource], includes)
12
+ JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache).to_hash(
13
+ include: includes,
12
14
  fields: parse_fields_param,
13
15
  document_meta: jsonapi_document_meta,
14
16
  )
@@ -28,27 +30,68 @@ module JSONAPI
28
30
 
29
31
  private
30
32
 
33
+ # One IncludeContext spans the whole page, so a record reached from
34
+ # several primaries serializes once, not once per primary. The final
35
+ # type-id pass keeps the response contract: it drops the rare collision
36
+ # where two model classes serialize to the same type and id.
31
37
  def serialize_resources_with_includes(resources, includes, fields)
32
- all_included = []
33
- processed = Set.new
38
+ shared_context = JSONAPI::Serialization::IncludeContext.new(
39
+ fields: fields, included_records: [], processed: Set.new, all_includes: nil,
40
+ )
41
+ cache = build_include_filter_cache(resources, includes)
34
42
 
35
43
  data = resources.map do |r|
36
- result = serialize_single(r, includes, fields)
37
- collect_included(result, all_included, processed)
38
- result[:data]
44
+ serialize_single(r, includes, fields, shared_context, cache)[:data]
39
45
  end
40
46
 
41
- [data, all_included]
47
+ [data, dedupe_included(shared_context.included_records)]
42
48
  end
43
49
 
44
- def serialize_single(resource, includes, fields)
45
- JSONAPI::Serializer.new(resource).to_hash(include: includes, fields:, document_meta: nil)
50
+ def serialize_single(resource, includes, fields, include_context = nil, cache = nil)
51
+ JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache)
52
+ .to_hash(include: includes, fields:, document_meta: nil, include_context:)
46
53
  end
47
54
 
48
- def collect_included(result, all_included, processed)
49
- (result[:included] || []).each do |inc|
55
+ # One filter cache per request, warmed from the loaded include tree:
56
+ # every included record gets its scope verdict in one query per class,
57
+ # and the serializers answer their per-visit checks from memory.
58
+ def build_include_filter_cache(resources, includes)
59
+ cache = JSONAPI::Serialization::IncludeFilterCache.new(authorization_context: self)
60
+ includes.each do |path|
61
+ current = resources
62
+ path.split(".").each do |part|
63
+ current = warm_include_level(cache, current, part.to_sym)
64
+ break if current.empty?
65
+ end
66
+ end
67
+ cache
68
+ end
69
+
70
+ def warm_include_level(cache, records, association_name)
71
+ targets = records.flat_map { |record| loaded_association_targets(record, association_name) }
72
+ targets.group_by(&:class).each { |klass, recs| cache.warm(klass, recs.filter_map(&:id)) }
73
+ targets
74
+ end
75
+
76
+ # Attachment names carry no AR reflection, and unloaded associations go
77
+ # through the scoped-relation fallback instead of filtering: both skip.
78
+ def loaded_association_targets(record, association_name)
79
+ return [] unless record.class.reflect_on_association(association_name)
80
+
81
+ association = record.association(association_name)
82
+ return [] unless association.loaded?
83
+
84
+ target = association.target
85
+ target.respond_to?(:to_a) ? target.to_a.compact : Array(target).compact
86
+ end
87
+
88
+ def dedupe_included(included_records)
89
+ all_included = []
90
+ processed = Set.new
91
+ included_records.each do |inc|
50
92
  add_unique_included(inc, all_included, processed)
51
93
  end
94
+ all_included
52
95
  end
53
96
 
54
97
  def add_unique_included(inc, all_included, processed)
@@ -100,8 +100,13 @@ module JSONAPI
100
100
  # before app controllers are available, causing FrozenError or NameError
101
101
  # 3. We register with reloader.to_prepare for code reloading in development
102
102
  config.after_initialize do |app|
103
- # Register for code reloading in development
103
+ # Register for code reloading in development. Clear the name-keyed
104
+ # resolution caches first so reloaded classes re-resolve; applications
105
+ # that pre-populate ResourceLoader caches in their own to_prepare blocks
106
+ # run after this and re-apply their entries.
104
107
  app.reloader.to_prepare do
108
+ JSONAPI::ResourceLoader.reset_cache!
109
+ JSONAPI::TypeConversion.reset_cache!
105
110
  Railtie.setup_base_controllers
106
111
  end
107
112
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/map"
4
+
3
5
  module JSONAPI
4
6
  class ResourceLoader
5
7
  class MissingResourceClass < JSONAPI::Error
@@ -16,7 +18,30 @@ module JSONAPI
16
18
  end
17
19
  end
18
20
 
21
+ # Resolution runs the inflector and safe_constantize on every call, and the
22
+ # serializer calls it once per record, per identifier, and per include-hop
23
+ # visit. Cache by name, never by class object, so a reloaded class cannot be
24
+ # retained. Only successful resolutions are cached. The railtie clears both
25
+ # caches on code reload.
26
+ @find_cache = Concurrent::Map.new
27
+ @model_cache = Concurrent::Map.new
28
+
29
+ def self.reset_cache!
30
+ @find_cache.clear
31
+ @model_cache.clear
32
+ end
33
+
19
34
  def self.find(resource_type, namespace: nil)
35
+ key = "#{namespace}|#{resource_type}|#{JSONAPI.configuration.namespace_fallback ? 1 : 0}"
36
+ cached = @find_cache[key]
37
+ return cached if cached
38
+
39
+ klass = resolve(find_candidates(resource_type, namespace)) ||
40
+ raise(MissingResourceClass.new(resource_type, namespace:))
41
+ @find_cache[key] = klass
42
+ end
43
+
44
+ def self.find_candidates(resource_type, namespace)
20
45
  candidates = []
21
46
 
22
47
  # Namespaced resource, e.g. "widgets" with namespace "api/v1" → API::V1::WidgetResource
@@ -28,16 +53,24 @@ module JSONAPI
28
53
  nil,)
29
54
  end
30
55
 
31
- resolve(candidates) || raise(MissingResourceClass.new(resource_type, namespace:))
56
+ candidates
32
57
  end
33
58
 
34
59
  def self.find_for_model(model_class, namespace: nil)
35
60
  return ActiveStorageBlobResource if active_storage_blob?(model_class)
36
61
 
62
+ # Key format "::ModelName" is public-ish: applications pre-populate
63
+ # @model_cache with these keys to pin STI subclass resources.
64
+ key = namespace ? "#{namespace}|::#{model_class.name}" : "::#{model_class.name}"
65
+ cached = @model_cache[key]
66
+ return cached if cached
67
+
37
68
  effective_namespace = namespace || extract_namespace_from_model(model_class)
38
69
  candidates = model_candidates(model_class, effective_namespace)
39
70
 
40
- resolve(candidates) || raise(MissingResourceClass.new(model_class.name, namespace: effective_namespace))
71
+ klass = resolve(candidates) ||
72
+ raise(MissingResourceClass.new(model_class.name, namespace: effective_namespace))
73
+ @model_cache[key] = klass
41
74
  end
42
75
 
43
76
  def self.build_resource_class_name(resource_type, namespace)
@@ -8,6 +8,7 @@ module JSONAPI
8
8
  controller ||= detect_controller(resource_name, namespace)
9
9
  defaults = build_jsonapi_defaults(defaults, resource_name, namespace)
10
10
  options[:only] = :index if sti
11
+ options = without_form_actions(options)
11
12
 
12
13
  JSONAPI::ResourceLoader.find(resource_name, namespace:)
13
14
  define_resource_routes(resource, controller, defaults, options, &)
@@ -20,6 +21,15 @@ module JSONAPI
20
21
 
21
22
  private
22
23
 
24
+ # `new` and `edit` render the HTML forms of a scaffold. A JSON:API endpoint
25
+ # answers neither, so the routes only pad the route table and any document
26
+ # generated from it. A caller that names `only:` keeps full control.
27
+ def without_form_actions(options)
28
+ return options if options.key?(:only)
29
+
30
+ options.merge(except: Array(options[:except]) | %i[new edit])
31
+ end
32
+
23
33
  def extract_namespace_from_scope
24
34
  @scope[:module]&.to_s.presence
25
35
  end
@@ -3,39 +3,17 @@
3
3
  module JSONAPI
4
4
  module Serialization
5
5
  module IncludeFiltering
6
+ # When the authorization_scope hook or the resource's records scope
7
+ # narrows the relation, the eager-loaded set — preloaded through
8
+ # acts_as_tenant alone — must be re-checked against the narrowed scope,
9
+ # so a relationship never surfaces a record the related endpoint
10
+ # denies. The request-scoped cache memoizes the scopes and the per-id
11
+ # verdicts, so each record is vetted at most once per request.
6
12
  def filter_loaded_records(association, related_klass)
7
13
  loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
8
14
  return [] if loaded_array.empty?
9
15
 
10
- resource_scope = ResourceLoader.find_for_model(related_klass).records
11
- return loaded_array if resource_scope.where_clause.empty?
12
-
13
- filter_by_where_hash(loaded_array, resource_scope, related_klass)
14
- end
15
-
16
- def filter_by_where_hash(loaded_array, resource_scope, related_klass)
17
- hash = where_values_hash_for_scope(resource_scope, related_klass)
18
- return filter_by_query(loaded_array, resource_scope) if hash.blank?
19
-
20
- loaded_array.select { |r| record_matches_where_hash?(r, hash) }
21
- end
22
-
23
- def where_values_hash_for_scope(resource_scope, related_klass)
24
- resource_scope.where_values_hash(related_klass.table_name)
25
- rescue StandardError
26
- {}
27
- end
28
-
29
- def record_matches_where_hash?(record, hash)
30
- hash.all? do |attr, val|
31
- r_val = record.read_attribute(attr)
32
- val.is_a?(Array) ? val.include?(r_val) : r_val == val
33
- end
34
- end
35
-
36
- def filter_by_query(loaded_array, resource_scope)
37
- valid_ids = resource_scope.where(id: loaded_array.filter_map(&:id)).pluck(:id).to_set
38
- loaded_array.select { |r| valid_ids.include?(r.id) }
16
+ include_filter_cache.filter(loaded_array, related_klass)
39
17
  end
40
18
  end
41
19
  end
@@ -13,22 +13,18 @@ module JSONAPI
13
13
  include IncludePathHelpers
14
14
  include IncludeFiltering
15
15
 
16
- def serialize_included(includes, fields = {})
16
+ def serialize_included(includes, fields = {}, context: nil)
17
17
  all_includes = normalize_include_paths(includes)
18
- return [] if all_includes.empty?
18
+ return context&.included_records || [] if all_includes.empty?
19
19
 
20
- ctx = build_include_context(fields, all_includes)
20
+ ctx = context || build_include_context(fields, all_includes)
21
+ ctx.all_includes ||= all_includes
21
22
  all_includes.each { |path| serialize_include_path(record, path, ctx, path_from_root: "") }
22
23
  ctx.included_records
23
24
  end
24
25
 
25
26
  def build_include_context(fields, all_includes)
26
- IncludeContext.new(
27
- fields: fields,
28
- included_records: [],
29
- processed: Set.new,
30
- all_includes: all_includes,
31
- )
27
+ IncludeContext.new(fields:, included_records: [], processed: Set.new, all_includes:)
32
28
  end
33
29
 
34
30
  private
@@ -96,22 +92,35 @@ module JSONAPI
96
92
 
97
93
  def build_scoped_relation(related_klass, association)
98
94
  related_base_scope = ResourceLoader.find_for_model(related_klass).records
99
- association.scope.merge(related_base_scope)
95
+ association.scope.merge(apply_include_authorization(related_base_scope, related_klass))
100
96
  end
101
97
 
102
- def get_active_storage_records(current_record, association_name)
103
- attachment = current_record.public_send(association_name)
104
- return [] unless attachment.respond_to?(:attached?) && attachment.attached?
105
- return attachment.blobs.to_a if attachment.is_a?(::ActiveStorage::Attached::Many)
98
+ # A sideloaded `?include=` is loaded through acts_as_tenant alone, bypassing the
99
+ # related resource's authorization_scope — so a relationship can reach records the
100
+ # related resource's own collection endpoint denies (e.g. an include that surfaces
101
+ # rows a Pundit scope hides). Route the related resource's base records through the
102
+ # configured authorization_scope hook, exactly as the primary collection does, so an
103
+ # included record can never escape the reader's authorization. No context (serializer
104
+ # used outside a request) or no configured hook leaves the scope untouched.
105
+ def apply_include_authorization(scope, model_class)
106
+ return scope unless authorization_context
107
+
108
+ handler = JSONAPI.configuration.authorization_scope
109
+ return scope unless handler
106
110
 
107
- [attachment.blob].compact
111
+ handler.call(controller: authorization_context, scope:, action: :index, model_class:)
112
+ end
113
+
114
+ def get_active_storage_records(current_record, association_name)
115
+ JSONAPI::ActiveStorage::Serialization.blobs_for(association_name, current_record)
108
116
  end
109
117
 
110
118
  def serialize_and_process_record(related_record, path_to_record, ctx, parent_record: nil, association_name: nil)
111
119
  return if ctx.processed.include?(build_record_key(related_record))
112
120
 
113
121
  requested = include_paths_to_relationship_names(ctx.all_includes, path_to_record)
114
- serializer = self.class.new(related_record, parent_record:, association_name:)
122
+ serializer = self.class.new(related_record, parent_record:, association_name:,
123
+ authorization_context:, include_filter_cache:,)
115
124
  ctx.included_records << serializer.serialize_record(ctx.fields, requested_relationships: requested)
116
125
  ctx.processed.add(build_record_key(related_record))
117
126
  end
@@ -44,16 +44,19 @@ module JSONAPI
44
44
 
45
45
  def default_timestamp_meta
46
46
  {}.tap do |meta|
47
- meta[:created_at] = format_timestamp(:created_at)
48
- meta[:updated_at] = format_timestamp(:updated_at)
47
+ meta[:created_at] = timestamp(:created_at)
48
+ meta[:updated_at] = timestamp(:updated_at)
49
49
  end.compact
50
50
  end
51
51
 
52
- def format_timestamp(attr)
52
+ # Returns the raw Time so the JSON encoder formats it. Formatting here with
53
+ # `iso8601` drops the sub-second digits the encoder keeps, which puts two
54
+ # spellings of one instant in a single document whenever a resource also
55
+ # exposes the timestamp as an attribute.
56
+ def timestamp(attr)
53
57
  return unless record.respond_to?(attr)
54
58
 
55
- value = record.public_send(attr)
56
- value&.iso8601
59
+ record.public_send(attr)
57
60
  end
58
61
  end
59
62
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Serialization
5
+ # Request-scoped memoization for include filtering.
6
+ #
7
+ # The include walk re-checks the related resource's scope (and the
8
+ # authorization hook) against the loaded records once per parent record,
9
+ # per hop, per include path. Within one request those verdicts cannot
10
+ # change: the scopes are functions of the related class and the
11
+ # controller. So compute each class's filter data once, and vet each
12
+ # record id at most once. Pure memoization — the allowed set is
13
+ # identical, id for id, to the per-visit filtering it replaces.
14
+ class IncludeFilterCache
15
+ Entry = Struct.new(:base_scope, :authorized_scope, :narrowed, :where_clause_empty, :where_hash,
16
+ keyword_init: true,)
17
+
18
+ def initialize(authorization_context: nil)
19
+ @authorization_context = authorization_context
20
+ @entries = {}
21
+ @verdicts = {}
22
+ end
23
+
24
+ # Filters loaded records with the same rules as the per-visit filtering:
25
+ # a narrowed authorization scope vets ids in the database; a resource
26
+ # scope with no where clause keeps everything; a where-hash-expressible
27
+ # scope matches in memory; anything else vets ids in the database
28
+ # against the resource scope.
29
+ def filter(records, klass)
30
+ entry = entry_for(klass)
31
+ return filter_by_ids(records, klass, :authorized, entry.authorized_scope) if entry.narrowed
32
+ return records if entry.where_clause_empty
33
+ return filter_by_ids(records, klass, :base, entry.base_scope) if entry.where_hash.blank?
34
+
35
+ records.select { |record| matches_where_hash?(record, entry.where_hash) }
36
+ end
37
+
38
+ # Vets ids ahead of the walk, one query per class, so the per-visit
39
+ # checks answer from memory. Safe to skip: #filter queries lazily.
40
+ def warm(klass, ids)
41
+ entry = entry_for(klass)
42
+ if entry.narrowed
43
+ vet_ids(klass, :authorized, entry.authorized_scope, ids)
44
+ elsif !entry.where_clause_empty && entry.where_hash.blank?
45
+ vet_ids(klass, :base, entry.base_scope, ids)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def entry_for(klass)
52
+ @entries[klass] ||= build_entry(klass)
53
+ end
54
+
55
+ def build_entry(klass)
56
+ base_scope = ResourceLoader.find_for_model(klass).records
57
+ authorized_scope = apply_authorization(base_scope, klass)
58
+ Entry.new(
59
+ base_scope: base_scope,
60
+ authorized_scope: authorized_scope,
61
+ narrowed: authorized_scope.to_sql != base_scope.to_sql,
62
+ where_clause_empty: base_scope.where_clause.empty?,
63
+ where_hash: where_hash_for(base_scope, klass),
64
+ )
65
+ end
66
+
67
+ # Mirrors the serializer's include authorization: no context (serializer
68
+ # used outside a request) or no configured hook leaves the scope untouched.
69
+ def apply_authorization(scope, klass)
70
+ return scope unless @authorization_context
71
+
72
+ handler = JSONAPI.configuration.authorization_scope
73
+ return scope unless handler
74
+
75
+ handler.call(controller: @authorization_context, scope:, action: :index, model_class: klass)
76
+ end
77
+
78
+ def where_hash_for(scope, klass)
79
+ scope.where_values_hash(klass.table_name)
80
+ rescue StandardError
81
+ {}
82
+ end
83
+
84
+ def matches_where_hash?(record, hash)
85
+ hash.all? do |attr, val|
86
+ r_val = record.read_attribute(attr)
87
+ val.is_a?(Array) ? val.include?(r_val) : r_val == val
88
+ end
89
+ end
90
+
91
+ def filter_by_ids(records, klass, kind, scope)
92
+ allowed = vet_ids(klass, kind, scope, records.filter_map(&:id))
93
+ records.select { |record| allowed.include?(record.id) }
94
+ end
95
+
96
+ # One verdict per id per (class, scope kind). Queries only ids that have
97
+ # no verdict yet; repeats answer from memory.
98
+ def vet_ids(klass, kind, scope, ids)
99
+ verdicts = (@verdicts[[klass, kind]] ||= {})
100
+ record_new_verdicts(verdicts, scope, ids)
101
+ ids.each_with_object(Set.new) { |id, set| set << id if verdicts[id] }
102
+ end
103
+
104
+ def record_new_verdicts(verdicts, scope, ids)
105
+ unseen = ids.reject { |id| verdicts.key?(id) }
106
+ return if unseen.empty?
107
+
108
+ valid = scope.where(id: unseen).pluck(:id).to_set
109
+ unseen.each { |id| verdicts[id] = valid.include?(id) }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "include_filter_cache"
3
4
  require_relative "concerns/attributes_serialization"
4
5
  require_relative "concerns/relationships_serialization"
5
6
  require_relative "concerns/links_serialization"
@@ -34,22 +35,29 @@ module JSONAPI
34
35
  @jsonapi_object = nil
35
36
  end
36
37
 
37
- def initialize(record, definition: nil, base_definition: nil, parent_record: nil, association_name: nil)
38
+ def initialize(record, definition: nil, base_definition: nil, parent_record: nil, association_name: nil,
39
+ authorization_context: nil, include_filter_cache: nil)
38
40
  @record = record
39
41
  @definition = definition || ResourceLoader.find_for_model(record.class)
40
42
  @base_definition = base_definition
41
43
  @parent_record = parent_record
42
44
  @association_name = association_name
45
+ @authorization_context = authorization_context
46
+ @include_filter_cache = include_filter_cache
43
47
  @sti_subclass = nil
44
48
  end
45
49
 
46
- def to_hash(include: [], fields: {}, document_meta: nil)
50
+ # include_context shares one IncludeContext across a whole collection: a
51
+ # record reached from several primaries then serializes once instead of
52
+ # once per primary. Callers that pass it read the accumulated records from
53
+ # the context, not from each result's :included.
54
+ def to_hash(include: [], fields: {}, document_meta: nil, include_context: nil)
47
55
  include_paths = normalize_include_paths(include)
48
56
  top_level_relationships = include_paths_to_relationship_names(include_paths, "")
49
57
  {
50
58
  jsonapi: jsonapi_object,
51
59
  data: serialize_record(fields, requested_relationships: top_level_relationships),
52
- included: serialize_included(include_paths, fields),
60
+ included: serialize_included(include_paths, fields, context: include_context),
53
61
  meta: document_meta,
54
62
  }.compact
55
63
  end
@@ -67,7 +75,13 @@ module JSONAPI
67
75
 
68
76
  private
69
77
 
70
- attr_reader :record, :definition, :parent_record, :association_name
78
+ attr_reader :record, :definition, :parent_record, :association_name, :authorization_context
79
+
80
+ # Shared per request when the controller passes one in; a standalone
81
+ # serializer builds its own, which still dedupes within its own walk.
82
+ def include_filter_cache
83
+ @include_filter_cache ||= Serialization::IncludeFilterCache.new(authorization_context: authorization_context)
84
+ end
71
85
 
72
86
  def base_definition
73
87
  @base_definition ||= definition
@@ -1,7 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/map"
4
+
3
5
  module JSONAPI
4
6
  module TypeConversion
7
+ # Type names derive only from a class name plus a resolved format, so the
8
+ # serializer re-computes identical strings once per record and per
9
+ # relationship identifier. Cache frozen results keyed by name and format.
10
+ # The railtie clears the cache on code reload.
11
+ @type_name_cache = Concurrent::Map.new
12
+
13
+ def self.reset_cache!
14
+ @type_name_cache.clear
15
+ end
16
+
17
+ def self.type_name_cache
18
+ @type_name_cache
19
+ end
20
+
5
21
  module_function
6
22
 
7
23
  def type_to_class_name(type, namespace: nil)
@@ -19,18 +35,20 @@ module JSONAPI
19
35
 
20
36
  def model_type_name(model_class, format: nil)
21
37
  format ||= JSONAPI.configuration.namespace_type_format
38
+ name = model_class.name
39
+ return format_type_name(name.underscore.pluralize, format) if name.nil?
22
40
 
23
- full_name = model_class.name.underscore.pluralize
24
-
25
- format_type_name(full_name, format)
41
+ cache = TypeConversion.type_name_cache
42
+ cache["m|#{name}|#{format}"] ||= format_type_name(name.underscore.pluralize, format).freeze
26
43
  end
27
44
 
28
45
  def resource_type_name(definition_class, format: nil)
29
46
  format ||= resolve_type_format(definition_class)
47
+ name = definition_class.name
48
+ return format_type_name(name.sub(/Resource$/, "").underscore.pluralize, format) if name.nil?
30
49
 
31
- full_name = definition_class.name.sub(/Resource$/, "").underscore.pluralize
32
-
33
- format_type_name(full_name, format)
50
+ cache = TypeConversion.type_name_cache
51
+ cache["r|#{name}|#{format}"] ||= format_type_name(name.sub(/Resource$/, "").underscore.pluralize, format).freeze
34
52
  end
35
53
 
36
54
  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.5.0"
4
+ VERSION = "3.8.1"
5
5
  end