jpie 3.7.0 → 3.8.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.
@@ -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
 
@@ -8,10 +8,14 @@ module JSONAPI
8
8
  private
9
9
 
10
10
  def includes_to_hash(paths)
11
- hash = paths.each_with_object({}) do |path, h|
12
- path.split(".").reduce(h) { |cur, part| cur[part.to_sym] ||= {} }
11
+ filter_includable(includes_to_tree(paths), model_class)
12
+ end
13
+
14
+ # Nests dotted include paths into one tree, collapsing shared prefixes.
15
+ def includes_to_tree(paths)
16
+ paths.each_with_object({}) do |path, tree|
17
+ path.split(".").reduce(tree) { |node, part| node[part.to_sym] ||= {} }
13
18
  end
14
- filter_includable(hash, model_class)
15
19
  end
16
20
 
17
21
  # Preloads associations from the include param so the serializer avoids N+1 queries
@@ -32,12 +36,27 @@ module JSONAPI
32
36
  def filter_includable(hash, klass)
33
37
  hash.each_with_object({}) do |(key, value), filtered|
34
38
  assoc = klass.reflect_on_association(key)
35
- next unless assoc
36
-
37
- filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
39
+ if assoc
40
+ filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
41
+ elsif (attachment_assoc = attachment_association_name(klass, key))
42
+ # An attachment include carries no AR reflection under its own name
43
+ # (has_many_attached :files defines files_attachments, not files).
44
+ # Preload the association pair ActiveStorage defines for it, so the
45
+ # serializer reads blobs without one probe query per record.
46
+ filtered[attachment_assoc] = { blob: {} }
47
+ end
38
48
  end
39
49
  end
40
50
 
51
+ def attachment_association_name(klass, key)
52
+ return nil unless defined?(::ActiveStorage) && klass.respond_to?(:reflect_on_attachment)
53
+
54
+ reflection = klass.reflect_on_attachment(key)
55
+ return nil unless reflection
56
+
57
+ reflection.macro == :has_one_attached ? :"#{key}_attachment" : :"#{key}_attachments"
58
+ end
59
+
41
60
  def preload_required?(hash, klass)
42
61
  hash.any? do |key, value|
43
62
  assoc = klass.reflect_on_association(key)
@@ -67,18 +86,45 @@ module JSONAPI
67
86
  assoc.polymorphic? || (value.present? && hash_contains_polymorphic?(value, assoc.klass))
68
87
  end
69
88
 
89
+ # Applies each included resource's own `records` preloads to the records
90
+ # of that resource's class. Walks the whole include tree: a resource
91
+ # reached at depth 3 gets its preloads just as one reached at depth 1.
92
+ #
93
+ # The tree collapses shared prefixes, so a prefix is visited once however
94
+ # many paths run through it. Walking costs no association queries — every
95
+ # hop reads targets that `scope_with_includes` already loaded. The only
96
+ # queries are the batched resource-default preloads themselves, each of
97
+ # which replaces one query per record during serialization.
70
98
  def preload_included_resource_associations(resources, includes)
71
99
  return if includes.empty? || resources.empty?
72
100
 
73
- includes.each do |include_path|
74
- association_name = include_path.split(".").first.to_sym
75
- assoc_reflection = model_class.reflect_on_association(association_name)
76
- next unless assoc_reflection
101
+ preload_resource_tree(resources, model_class, includes_to_tree(includes))
102
+ end
103
+
104
+ def preload_resource_tree(records, klass, tree)
105
+ tree.each do |association_name, subtree|
106
+ reflection = klass.reflect_on_association(association_name)
107
+ next unless reflection
77
108
 
78
- targets = collect_include_targets(resources, association_name)
109
+ targets = collect_include_targets(records, association_name)
79
110
  next if targets.empty?
80
111
 
81
- apply_preloads_for_targets(targets, assoc_reflection.polymorphic?)
112
+ apply_preloads_for_targets(targets, reflection.polymorphic?)
113
+ next if subtree.empty?
114
+
115
+ descend_resource_tree(targets, reflection, subtree)
116
+ end
117
+ end
118
+
119
+ # A polymorphic hop has no single target class, so continue once per
120
+ # class actually present in the loaded targets.
121
+ def descend_resource_tree(targets, reflection, subtree)
122
+ if reflection.polymorphic?
123
+ targets.group_by(&:class).each do |target_class, records|
124
+ preload_resource_tree(records, target_class, subtree)
125
+ end
126
+ else
127
+ preload_resource_tree(targets, reflection.klass, subtree)
82
128
  end
83
129
  end
84
130
 
@@ -7,8 +7,10 @@ module JSONAPI
7
7
  include IncludePreloading
8
8
 
9
9
  def serialize_resource(resource)
10
- JSONAPI::Serializer.new(resource, authorization_context: self).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,28 +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, authorization_context: self).to_hash(include: includes, fields:,
46
- 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:)
47
53
  end
48
54
 
49
- def collect_included(result, all_included, processed)
50
- (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|
51
92
  add_unique_included(inc, all_included, processed)
52
93
  end
94
+ all_included
53
95
  end
54
96
 
55
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,51 +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
- base_scope = ResourceLoader.find_for_model(related_klass).records
11
- authorized_scope = apply_include_authorization(base_scope, related_klass)
12
-
13
- # When the authorization_scope hook narrows the relation (joins/subqueries a
14
- # where-hash can't express), the eager-loaded set — preloaded through acts_as_tenant
15
- # alone — must be re-checked against the authorized scope in the database, so a
16
- # relationship never surfaces a record the related endpoint denies. An unchanged
17
- # scope keeps the in-memory, query-free records-only filtering.
18
- return filter_by_query(loaded_array, authorized_scope) if narrowed?(base_scope, authorized_scope)
19
- return loaded_array if base_scope.where_clause.empty?
20
-
21
- filter_by_where_hash(loaded_array, base_scope, related_klass)
22
- end
23
-
24
- def narrowed?(base_scope, authorized_scope)
25
- authorized_scope.to_sql != base_scope.to_sql
26
- end
27
-
28
- def filter_by_where_hash(loaded_array, resource_scope, related_klass)
29
- hash = where_values_hash_for_scope(resource_scope, related_klass)
30
- return filter_by_query(loaded_array, resource_scope) if hash.blank?
31
-
32
- loaded_array.select { |r| record_matches_where_hash?(r, hash) }
33
- end
34
-
35
- def where_values_hash_for_scope(resource_scope, related_klass)
36
- resource_scope.where_values_hash(related_klass.table_name)
37
- rescue StandardError
38
- {}
39
- end
40
-
41
- def record_matches_where_hash?(record, hash)
42
- hash.all? do |attr, val|
43
- r_val = record.read_attribute(attr)
44
- val.is_a?(Array) ? val.include?(r_val) : r_val == val
45
- end
46
- end
47
-
48
- def filter_by_query(loaded_array, resource_scope)
49
- valid_ids = resource_scope.where(id: loaded_array.filter_map(&:id)).pluck(:id).to_set
50
- loaded_array.select { |r| valid_ids.include?(r.id) }
16
+ include_filter_cache.filter(loaded_array, related_klass)
51
17
  end
52
18
  end
53
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
@@ -116,11 +112,7 @@ module JSONAPI
116
112
  end
117
113
 
118
114
  def get_active_storage_records(current_record, association_name)
119
- attachment = current_record.public_send(association_name)
120
- return [] unless attachment.respond_to?(:attached?) && attachment.attached?
121
- return attachment.blobs.to_a if attachment.is_a?(::ActiveStorage::Attached::Many)
122
-
123
- [attachment.blob].compact
115
+ JSONAPI::ActiveStorage::Serialization.blobs_for(association_name, current_record)
124
116
  end
125
117
 
126
118
  def serialize_and_process_record(related_record, path_to_record, ctx, parent_record: nil, association_name: nil)
@@ -128,7 +120,7 @@ module JSONAPI
128
120
 
129
121
  requested = include_paths_to_relationship_names(ctx.all_includes, path_to_record)
130
122
  serializer = self.class.new(related_record, parent_record:, association_name:,
131
- authorization_context:,)
123
+ authorization_context:, include_filter_cache:,)
132
124
  ctx.included_records << serializer.serialize_record(ctx.fields, requested_relationships: requested)
133
125
  ctx.processed.add(build_record_key(related_record))
134
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"
@@ -35,23 +36,28 @@ module JSONAPI
35
36
  end
36
37
 
37
38
  def initialize(record, definition: nil, base_definition: nil, parent_record: nil, association_name: nil,
38
- authorization_context: nil)
39
+ authorization_context: nil, include_filter_cache: nil)
39
40
  @record = record
40
41
  @definition = definition || ResourceLoader.find_for_model(record.class)
41
42
  @base_definition = base_definition
42
43
  @parent_record = parent_record
43
44
  @association_name = association_name
44
45
  @authorization_context = authorization_context
46
+ @include_filter_cache = include_filter_cache
45
47
  @sti_subclass = nil
46
48
  end
47
49
 
48
- 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)
49
55
  include_paths = normalize_include_paths(include)
50
56
  top_level_relationships = include_paths_to_relationship_names(include_paths, "")
51
57
  {
52
58
  jsonapi: jsonapi_object,
53
59
  data: serialize_record(fields, requested_relationships: top_level_relationships),
54
- included: serialize_included(include_paths, fields),
60
+ included: serialize_included(include_paths, fields, context: include_context),
55
61
  meta: document_meta,
56
62
  }.compact
57
63
  end
@@ -71,6 +77,12 @@ module JSONAPI
71
77
 
72
78
  attr_reader :record, :definition, :parent_record, :association_name, :authorization_context
73
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
85
+
74
86
  def base_definition
75
87
  @base_definition ||= definition
76
88
  end