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.
- checksums.yaml +4 -4
- data/.rubocop.yml +58 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -1
- data/PERFORMANCE_BASELINE.md +458 -99
- data/README.md +3 -3
- data/Rakefile +5 -0
- data/lib/json_api/active_storage/serialization.rb +23 -6
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +18 -3
- data/lib/json_api/controllers/concerns/resource_actions/include_preloading.rb +58 -12
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +55 -13
- data/lib/json_api/railtie.rb +6 -1
- data/lib/json_api/resources/resource_loader.rb +35 -2
- data/lib/json_api/routing.rb +10 -0
- data/lib/json_api/serialization/concerns/include_filtering.rb +7 -41
- data/lib/json_api/serialization/concerns/includes_serialization.rb +7 -15
- data/lib/json_api/serialization/concerns/meta_serialization.rb +8 -5
- data/lib/json_api/serialization/include_filter_cache.rb +113 -0
- data/lib/json_api/serialization/serializer.rb +15 -3
- data/lib/json_api/support/type_conversion.rb +24 -6
- data/lib/json_api/version.rb +1 -1
- metadata +2 -1
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
109
|
+
targets = collect_include_targets(records, association_name)
|
|
79
110
|
next if targets.empty?
|
|
80
111
|
|
|
81
|
-
apply_preloads_for_targets(targets,
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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)
|
data/lib/json_api/railtie.rb
CHANGED
|
@@ -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
|
-
|
|
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) ||
|
|
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)
|
data/lib/json_api/routing.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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] =
|
|
48
|
-
meta[: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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|