jpie 1.5.1 → 2.0.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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +22 -1
- data/lib/json_api/configuration.rb +36 -11
- data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +6 -7
- data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +2 -3
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +42 -1
- data/lib/json_api/controllers/concerns/relationships/updating.rb +1 -5
- data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +0 -8
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +3 -7
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +3 -3
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +42 -25
- data/lib/json_api/controllers/concerns/resource_actions.rb +6 -10
- data/lib/json_api/controllers/relationships_controller.rb +2 -3
- data/lib/json_api/errors/parameter_not_allowed.rb +0 -5
- data/lib/json_api/rack/n1_warning.rb +72 -0
- data/lib/json_api/railtie.rb +8 -3
- data/lib/json_api/resources/resource.rb +0 -4
- data/lib/json_api/serialization/concerns/include_filtering.rb +42 -0
- data/lib/json_api/serialization/concerns/includes_serialization.rb +9 -11
- data/lib/json_api/serialization/concerns/links_serialization.rb +12 -4
- data/lib/json_api/serialization/concerns/relationship_processing.rb +1 -5
- data/lib/json_api/support/collection_query.rb +1 -2
- data/lib/json_api/support/concerns/condition_building.rb +5 -15
- data/lib/json_api/support/concerns/nested_filters.rb +1 -1
- data/lib/json_api/support/concerns/polymorphic_filters.rb +1 -1
- data/lib/json_api/support/concerns/regular_filters.rb +3 -21
- data/lib/json_api/support/filter_parsing.rb +18 -0
- data/lib/json_api/support/param_helpers.rb +4 -0
- data/lib/json_api/support/query_counter.rb +32 -0
- data/lib/json_api/support/relationship_guard.rb +1 -1
- data/lib/json_api/support/resource_identifier.rb +2 -1
- data/lib/json_api/support/responders.rb +1 -1
- data/lib/json_api/version.rb +1 -1
- data/lib/json_api.rb +3 -1
- metadata +5 -5
- data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +0 -80
- data/lib/json_api/resources/concerns/eager_load_dsl.rb +0 -50
- data/lib/json_api/resources/concerns/preload_dsl.rb +0 -49
- data/lib/json_api/support/response_helpers.rb +0 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a598f58a7c0f80730e852f069276ea9d3066d3c7622a8faec599f1b58039f0e
|
|
4
|
+
data.tar.gz: 33292a3d2e2b98c09fb9868972b2936738ebf00b6ec0a1b7baf46ec51b017369
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b3830b35c1fc6690c755c5f90ad7568ffc6c57d7b8ad0516fdf368f476a15223a0489694acb5ac288d2c5f8f9f8889c154f5e0f09915dfac276b876ed437858
|
|
7
|
+
data.tar.gz: 0cdc51e099411e4f27342783e3e47bf5e34ff983308758fa904819009df8f23364521a7d1b32a634827fdcba7f4302501202ef4f0c193d119e97b9f9daaf7990
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# JPie
|
|
2
2
|
|
|
3
3
|
A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API controllers for producing and consuming JSON:API resources.
|
|
4
4
|
|
|
@@ -659,6 +659,27 @@ JSONAPI.configure do |config|
|
|
|
659
659
|
end
|
|
660
660
|
```
|
|
661
661
|
|
|
662
|
+
### N+1 Query Warning
|
|
663
|
+
|
|
664
|
+
When clients omit the `include` parameter for nested relationships, the API may execute N+1 queries. The gem detects high query counts and:
|
|
665
|
+
|
|
666
|
+
1. **Logs** a warning to `Rails.logger` with path, include param, and query count
|
|
667
|
+
2. **Adds response headers** so clients can detect and fix their requests:
|
|
668
|
+
- `X-JPie-Query-Count` – number of DB queries executed
|
|
669
|
+
- `Server-Timing` – W3C standard header with query count in `desc`
|
|
670
|
+
- `X-JPie-Performance-Warning` – when count exceeds threshold, suggests adding `include` param
|
|
671
|
+
|
|
672
|
+
Configure in an initializer:
|
|
673
|
+
|
|
674
|
+
```ruby
|
|
675
|
+
JSONAPI.configure do |config|
|
|
676
|
+
config.n1_query_threshold = 20 # Warn when queries exceed this (default: 20)
|
|
677
|
+
config.n1_warning_enabled = true # Set false to disable (default: true)
|
|
678
|
+
end
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
Clients can read `X-JPie-Query-Count` or `Server-Timing` to monitor performance and add appropriate `include` values when the warning header appears.
|
|
682
|
+
|
|
662
683
|
### Base Controller Class
|
|
663
684
|
|
|
664
685
|
By default, `JSONAPI::BaseController` inherits from `ActionController::API`. You can configure it to inherit from a different base class (e.g., `ActionController::Base` or a custom base controller):
|
|
@@ -4,19 +4,11 @@ module JSONAPI
|
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :default_page_size, :max_page_size, :jsonapi_meta, :authorization_handler,
|
|
6
6
|
:authorization_scope, :document_meta_resolver,
|
|
7
|
-
:namespace_type_format, :namespace_model_mapping, :namespace_fallback
|
|
7
|
+
:namespace_type_format, :namespace_model_mapping, :namespace_fallback,
|
|
8
|
+
:n1_query_threshold, :n1_warning_enabled
|
|
8
9
|
|
|
9
10
|
def initialize
|
|
10
|
-
|
|
11
|
-
@max_page_size = 100
|
|
12
|
-
@jsonapi_meta = nil
|
|
13
|
-
@authorization_handler = nil
|
|
14
|
-
@authorization_scope = nil
|
|
15
|
-
@document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
|
|
16
|
-
@base_controller_class = "ActionController::API"
|
|
17
|
-
@namespace_type_format = :flat
|
|
18
|
-
@namespace_model_mapping = :same_namespace
|
|
19
|
-
@namespace_fallback = true
|
|
11
|
+
set_defaults
|
|
20
12
|
end
|
|
21
13
|
|
|
22
14
|
def base_controller_class=(value)
|
|
@@ -49,6 +41,39 @@ module JSONAPI
|
|
|
49
41
|
def base_controller_overridden?
|
|
50
42
|
@base_controller_class != "ActionController::API"
|
|
51
43
|
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def set_defaults
|
|
48
|
+
set_pagination_defaults
|
|
49
|
+
set_n1_defaults
|
|
50
|
+
set_nil_defaults
|
|
51
|
+
@document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
|
|
52
|
+
@base_controller_class = "ActionController::API"
|
|
53
|
+
set_namespace_defaults
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def set_pagination_defaults
|
|
57
|
+
@default_page_size = 25
|
|
58
|
+
@max_page_size = 100
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def set_n1_defaults
|
|
62
|
+
@n1_query_threshold = 20
|
|
63
|
+
@n1_warning_enabled = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_nil_defaults
|
|
67
|
+
@jsonapi_meta = nil
|
|
68
|
+
@authorization_handler = nil
|
|
69
|
+
@authorization_scope = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def set_namespace_defaults
|
|
73
|
+
@namespace_type_format = :flat
|
|
74
|
+
@namespace_model_mapping = :same_namespace
|
|
75
|
+
@namespace_fallback = true
|
|
76
|
+
end
|
|
52
77
|
end
|
|
53
78
|
|
|
54
79
|
def self.configuration
|
|
@@ -7,17 +7,16 @@ module JSONAPI
|
|
|
7
7
|
|
|
8
8
|
protected
|
|
9
9
|
|
|
10
|
+
def render_not_found_error(title:, detail:)
|
|
11
|
+
render_jsonapi_error(status: 404, title:, detail:)
|
|
12
|
+
end
|
|
13
|
+
|
|
10
14
|
def render_resource_not_found_error(message)
|
|
11
|
-
|
|
12
|
-
status: 404,
|
|
13
|
-
title: "Resource Not Found",
|
|
14
|
-
detail: message,
|
|
15
|
-
)
|
|
15
|
+
render_not_found_error(title: "Resource Not Found", detail: message)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def render_model_not_found_error(error)
|
|
19
|
-
|
|
20
|
-
status: 404,
|
|
19
|
+
render_not_found_error(
|
|
21
20
|
title: "Resource Not Found",
|
|
22
21
|
detail: "Model class for '#{@resource_name}' not found: #{error.message}",
|
|
23
22
|
)
|
|
@@ -30,10 +30,9 @@ module JSONAPI
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def set_resource
|
|
33
|
-
@resource = @
|
|
33
|
+
@resource = @resource_class.records.find(params[:id])
|
|
34
34
|
rescue ActiveRecord::RecordNotFound
|
|
35
|
-
|
|
36
|
-
status: 404,
|
|
35
|
+
render_not_found_error(
|
|
37
36
|
title: "Record Not Found",
|
|
38
37
|
detail: "Could not find #{@resource_name} with id '#{params[:id]}'",
|
|
39
38
|
)
|
|
@@ -16,12 +16,53 @@ module JSONAPI
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def fetch_related_data(association)
|
|
19
|
-
related =
|
|
19
|
+
related = fetch_related_through_resource_records(association)
|
|
20
20
|
return related unless association.collection? && related.respond_to?(:order)
|
|
21
21
|
|
|
22
22
|
apply_sorting_to_relationship(related, association)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def fetch_related_through_resource_records(association)
|
|
26
|
+
if association.polymorphic?
|
|
27
|
+
fetch_polymorphic_related_through_resource_records(association)
|
|
28
|
+
else
|
|
29
|
+
fetch_non_polymorphic_related_through_resource_records(association)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fetch_polymorphic_related_through_resource_records(association)
|
|
34
|
+
type_value = @resource.read_attribute(association.foreign_type)
|
|
35
|
+
id_value = @resource.read_attribute(association.foreign_key)
|
|
36
|
+
|
|
37
|
+
return fetch_blank_polymorphic(association) if type_value.blank? || id_value.blank?
|
|
38
|
+
|
|
39
|
+
related_model_class = type_value.constantize
|
|
40
|
+
related_resource_class = JSONAPI::ResourceLoader.find_for_model(related_model_class)
|
|
41
|
+
record = related_resource_class.records.find(id_value)
|
|
42
|
+
|
|
43
|
+
association.collection? ? Array(record) : record
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch_blank_polymorphic(association)
|
|
47
|
+
return nil unless association.collection?
|
|
48
|
+
|
|
49
|
+
fetch_polymorphic_has_many_through_resource_records(association)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fetch_polymorphic_has_many_through_resource_records(association)
|
|
53
|
+
association_instance = @resource.association(@relationship_name)
|
|
54
|
+
related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
|
|
55
|
+
related_resource_class.records.merge(association_instance.scope)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch_non_polymorphic_related_through_resource_records(association)
|
|
59
|
+
association_instance = @resource.association(@relationship_name)
|
|
60
|
+
related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
|
|
61
|
+
scope = related_resource_class.records.merge(association_instance.scope)
|
|
62
|
+
|
|
63
|
+
association.collection? ? scope : scope.first
|
|
64
|
+
end
|
|
65
|
+
|
|
25
66
|
def serialize_related(related, association)
|
|
26
67
|
return serialize_collection_relationship(related, association) if association.collection?
|
|
27
68
|
|
|
@@ -24,7 +24,7 @@ module JSONAPI
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def update_to_many_relationship(association, relationship_data)
|
|
27
|
-
if relationship_data.nil? || empty_array?(relationship_data)
|
|
27
|
+
if relationship_data.nil? || ParamHelpers.empty_array?(relationship_data)
|
|
28
28
|
@resource.public_send("#{@relationship_name}=", [])
|
|
29
29
|
return
|
|
30
30
|
end
|
|
@@ -35,10 +35,6 @@ module JSONAPI
|
|
|
35
35
|
@resource.public_send("#{@relationship_name}=", related_resources)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def empty_array?(data)
|
|
39
|
-
data.is_a?(Array) && data.empty?
|
|
40
|
-
end
|
|
41
|
-
|
|
42
38
|
def resolve_related_resources(relationship_data, association)
|
|
43
39
|
relationship_data.map { |identifier| find_related_resource(identifier, association) }
|
|
44
40
|
end
|
|
@@ -61,14 +61,6 @@ module JSONAPI
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
def render_update_error(error)
|
|
65
|
-
if error.is_a?(ActiveSupport::MessageVerifier::InvalidSignature)
|
|
66
|
-
render_signed_id_error(error)
|
|
67
|
-
else
|
|
68
|
-
render_invalid_relationship_error(error)
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
64
|
def render_signed_id_error(error)
|
|
73
65
|
render_jsonapi_error(
|
|
74
66
|
status: 400,
|
|
@@ -4,6 +4,7 @@ module JSONAPI
|
|
|
4
4
|
module ResourceActions
|
|
5
5
|
module FilterValidation
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
|
+
include JSONAPI::Support::FilterParsing
|
|
7
8
|
|
|
8
9
|
def validate_filter_param
|
|
9
10
|
filters = parse_filter_param
|
|
@@ -69,13 +70,8 @@ module JSONAPI
|
|
|
69
70
|
def polymorphic_filter_valid?(parts)
|
|
70
71
|
return false if parts.empty? || parts.length > 1
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
%w[id type].include?(
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def parse_column_filter(name)
|
|
77
|
-
m = name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
|
|
78
|
-
m ? { column: m[1], operator: m[2].to_sym } : nil
|
|
73
|
+
col_filter = parse_column_filter(parts.first)
|
|
74
|
+
%w[id type].include?(col_filter ? col_filter[:column] : parts.first)
|
|
79
75
|
end
|
|
80
76
|
|
|
81
77
|
def render_filter_errors(invalid)
|
|
@@ -28,12 +28,12 @@ module JSONAPI
|
|
|
28
28
|
def load_resource_record
|
|
29
29
|
return unless params[:id] && @resource_class
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
scope = scope_with_includes(@resource_class.records)
|
|
32
|
+
@resource = scope.find(params[:id])
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def render_record_not_found
|
|
35
|
-
|
|
36
|
-
status: 404,
|
|
36
|
+
render_not_found_error(
|
|
37
37
|
title: "Record Not Found",
|
|
38
38
|
detail: "Could not find #{@resource_name} with id '#{params[:id]}'",
|
|
39
39
|
)
|
|
@@ -6,31 +6,26 @@ module JSONAPI
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
def serialize_resource(resource)
|
|
9
|
-
run_preload_hook([resource])
|
|
10
9
|
JSONAPI::Serializer.new(resource).to_hash(
|
|
11
10
|
include: parse_include_param,
|
|
12
11
|
fields: parse_fields_param,
|
|
13
12
|
document_meta: jsonapi_document_meta,
|
|
14
13
|
)
|
|
15
|
-
ensure
|
|
16
|
-
clear_preload_data
|
|
17
14
|
end
|
|
18
15
|
|
|
19
16
|
def serialize_collection(resources)
|
|
17
|
+
includes = parse_include_param
|
|
18
|
+
fields = parse_fields_param
|
|
19
|
+
resources = scope_with_includes(resources)
|
|
20
20
|
resources_array = resources.to_a
|
|
21
|
-
run_preload_hook(resources_array)
|
|
22
21
|
|
|
23
|
-
data, all_included = serialize_resources_with_includes(resources_array)
|
|
22
|
+
data, all_included = serialize_resources_with_includes(resources_array, includes, fields)
|
|
24
23
|
build_collection_response(data, all_included)
|
|
25
|
-
ensure
|
|
26
|
-
clear_preload_data
|
|
27
24
|
end
|
|
28
25
|
|
|
29
26
|
private
|
|
30
27
|
|
|
31
|
-
def serialize_resources_with_includes(resources)
|
|
32
|
-
includes = parse_include_param
|
|
33
|
-
fields = parse_fields_param
|
|
28
|
+
def serialize_resources_with_includes(resources, includes, fields)
|
|
34
29
|
all_included = []
|
|
35
30
|
processed = Set.new
|
|
36
31
|
|
|
@@ -61,30 +56,52 @@ module JSONAPI
|
|
|
61
56
|
processed.add(key)
|
|
62
57
|
end
|
|
63
58
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
def includes_to_hash(paths)
|
|
60
|
+
hash = paths.each_with_object({}) do |path, h|
|
|
61
|
+
path.split(".").reduce(h) { |cur, part| cur[part.to_sym] ||= {} }
|
|
62
|
+
end
|
|
63
|
+
filter_includable(hash, model_class)
|
|
64
|
+
end
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
# Preloads associations from the include param so the serializer avoids N+1 queries
|
|
67
|
+
# when walking include paths (association.loaded? is true). Used by both show and index.
|
|
68
|
+
def scope_with_includes(scope)
|
|
69
|
+
includes = parse_include_param
|
|
70
|
+
return scope unless includes.any?
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
inc_hash = includes_to_hash(includes)
|
|
73
|
+
hash_contains_polymorphic?(inc_hash, model_class) ? scope.preload(inc_hash) : scope.includes(inc_hash)
|
|
73
74
|
end
|
|
74
75
|
|
|
75
|
-
def
|
|
76
|
-
|
|
76
|
+
def filter_includable(hash, klass)
|
|
77
|
+
hash.each_with_object({}) do |(key, value), filtered|
|
|
78
|
+
assoc = klass.reflect_on_association(key)
|
|
79
|
+
next unless assoc
|
|
77
80
|
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def hash_contains_polymorphic?(hash, klass)
|
|
86
|
+
hash.any? { |key, value| polymorphic_in_hash_entry?(key, value, klass) }
|
|
80
87
|
end
|
|
81
88
|
|
|
82
|
-
def
|
|
83
|
-
|
|
89
|
+
def polymorphic_in_hash_entry?(key, value, klass)
|
|
90
|
+
assoc = klass.reflect_on_association(key)
|
|
91
|
+
return false unless assoc
|
|
92
|
+
|
|
93
|
+
assoc.polymorphic? || (value.present? && hash_contains_polymorphic?(value, assoc.klass))
|
|
84
94
|
end
|
|
85
95
|
|
|
86
|
-
def
|
|
87
|
-
{
|
|
96
|
+
def build_collection_response(data, all_included)
|
|
97
|
+
result = { jsonapi: JSONAPI::Serializer.jsonapi_object, data: }
|
|
98
|
+
result[:included] = all_included if all_included.any?
|
|
99
|
+
|
|
100
|
+
pagination_meta = @pagination_applied ? build_pagination_meta : {}
|
|
101
|
+
result[:links] = build_pagination_links if @pagination_applied
|
|
102
|
+
result[:meta] = jsonapi_document_meta(pagination_meta)
|
|
103
|
+
|
|
104
|
+
result
|
|
88
105
|
end
|
|
89
106
|
end
|
|
90
107
|
end
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "resource_actions/filter_validation"
|
|
4
4
|
require_relative "resource_actions/field_validation"
|
|
5
|
-
require_relative "resource_actions/preloading"
|
|
6
5
|
require_relative "resource_actions/serialization"
|
|
7
6
|
require_relative "resource_actions/pagination"
|
|
8
7
|
require_relative "resource_actions/type_validation"
|
|
@@ -15,7 +14,6 @@ module JSONAPI
|
|
|
15
14
|
include ActiveStorageSupport
|
|
16
15
|
include FilterValidation
|
|
17
16
|
include FieldValidation
|
|
18
|
-
include Preloading
|
|
19
17
|
include Serialization
|
|
20
18
|
include Pagination
|
|
21
19
|
include TypeValidation
|
|
@@ -30,11 +28,10 @@ module JSONAPI
|
|
|
30
28
|
before_action :validate_include_param, only: %i[index show]
|
|
31
29
|
before_action :validate_resource_type!, only: %i[create update]
|
|
32
30
|
before_action :validate_resource_id!, only: [:update]
|
|
33
|
-
before_action :preload_includes, only: %i[index show]
|
|
34
31
|
end
|
|
35
32
|
|
|
36
33
|
def index
|
|
37
|
-
scope = apply_authorization_scope(@
|
|
34
|
+
scope = apply_authorization_scope(@resource_class.records, action: :index)
|
|
38
35
|
query = build_query(scope)
|
|
39
36
|
@total_count = query.total_count
|
|
40
37
|
@pagination_applied = query.pagination_applied
|
|
@@ -42,9 +39,8 @@ module JSONAPI
|
|
|
42
39
|
end
|
|
43
40
|
|
|
44
41
|
def show
|
|
45
|
-
resource
|
|
46
|
-
|
|
47
|
-
render json: serialize_resource(resource), status: :ok
|
|
42
|
+
authorize_resource_action!(@resource, action: :show)
|
|
43
|
+
render json: serialize_resource(@resource), status: :ok
|
|
48
44
|
end
|
|
49
45
|
|
|
50
46
|
def create
|
|
@@ -54,7 +50,7 @@ module JSONAPI
|
|
|
54
50
|
save_created(resource)
|
|
55
51
|
rescue ArgumentError => e
|
|
56
52
|
render_create_error(e)
|
|
57
|
-
rescue JSONAPI::
|
|
53
|
+
rescue JSONAPI::Errors::ParameterNotAllowed, ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
58
54
|
handle_create_exception(e)
|
|
59
55
|
end
|
|
60
56
|
|
|
@@ -66,7 +62,7 @@ module JSONAPI
|
|
|
66
62
|
|
|
67
63
|
def handle_create_exception(error)
|
|
68
64
|
case error
|
|
69
|
-
when JSONAPI::
|
|
65
|
+
when JSONAPI::Errors::ParameterNotAllowed then render_parameter_not_allowed_error(error)
|
|
70
66
|
when ActiveSupport::MessageVerifier::InvalidSignature then render_signed_id_error(error)
|
|
71
67
|
end
|
|
72
68
|
end
|
|
@@ -77,7 +73,7 @@ module JSONAPI
|
|
|
77
73
|
save_updated(params_hash, attachments)
|
|
78
74
|
rescue ArgumentError => e
|
|
79
75
|
render_invalid_relationship_error(e)
|
|
80
|
-
rescue JSONAPI::
|
|
76
|
+
rescue JSONAPI::Errors::ParameterNotAllowed => e
|
|
81
77
|
render_parameter_not_allowed_error(e)
|
|
82
78
|
rescue ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
83
79
|
render_signed_id_error(e)
|
|
@@ -26,7 +26,6 @@ module JSONAPI
|
|
|
26
26
|
before_action :set_relationship_name
|
|
27
27
|
before_action :validate_relationship_exists
|
|
28
28
|
before_action :validate_sort_param, only: [:show]
|
|
29
|
-
skip_before_action :validate_resource_type!, :validate_resource_id!
|
|
30
29
|
|
|
31
30
|
def show
|
|
32
31
|
authorize_resource_action!(@resource, action: :show, context: { relationship: @relationship_name })
|
|
@@ -40,7 +39,7 @@ module JSONAPI
|
|
|
40
39
|
save_and_render_relationship(relationship_data)
|
|
41
40
|
rescue ArgumentError => e
|
|
42
41
|
render_invalid_relationship_error(e)
|
|
43
|
-
rescue JSONAPI::
|
|
42
|
+
rescue JSONAPI::Errors::ParameterNotAllowed => e
|
|
44
43
|
render_parameter_not_allowed_error(e)
|
|
45
44
|
end
|
|
46
45
|
|
|
@@ -53,7 +52,7 @@ module JSONAPI
|
|
|
53
52
|
finalize_relationship_removal(relationship_data)
|
|
54
53
|
rescue ArgumentError => e
|
|
55
54
|
render_invalid_relationship_error(e)
|
|
56
|
-
rescue JSONAPI::
|
|
55
|
+
rescue JSONAPI::Errors::ParameterNotAllowed => e
|
|
57
56
|
render_parameter_not_allowed_error(e)
|
|
58
57
|
end
|
|
59
58
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Rack
|
|
5
|
+
class N1Warning
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
return @app.call(env) unless JSONAPI.configuration.n1_warning_enabled
|
|
12
|
+
return @app.call(env) unless jsonapi_request?(env)
|
|
13
|
+
|
|
14
|
+
result = nil
|
|
15
|
+
query_count = JSONAPI::QueryCounter.count_queries { result = @app.call(env) }
|
|
16
|
+
status, headers, body = result
|
|
17
|
+
headers = add_n1_headers(headers, query_count, env)
|
|
18
|
+
[status, headers, body]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def add_n1_headers(headers, query_count, env)
|
|
24
|
+
headers = headers.dup
|
|
25
|
+
headers["X-JPie-Query-Count"] = query_count.to_s
|
|
26
|
+
headers["Server-Timing"] = append_server_timing(headers["Server-Timing"].to_s, query_count)
|
|
27
|
+
add_perf_warning_if_needed!(headers, query_count, env)
|
|
28
|
+
headers
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add_perf_warning_if_needed!(headers, query_count, env)
|
|
32
|
+
threshold = JSONAPI.configuration.n1_query_threshold
|
|
33
|
+
return unless query_count > threshold
|
|
34
|
+
|
|
35
|
+
warning_msg = n1_warning_message(query_count, threshold)
|
|
36
|
+
headers["X-JPie-Performance-Warning"] = warning_msg
|
|
37
|
+
headers["Server-Timing"] = [headers["Server-Timing"], perf_warning_metric(warning_msg)].join(", ")
|
|
38
|
+
log_n1_warning(env, query_count, threshold)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def append_server_timing(existing, query_count)
|
|
42
|
+
db_metric = "db;desc=\"#{query_count} queries\""
|
|
43
|
+
[existing, db_metric].reject(&:empty?).join(", ")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def n1_warning_message(query_count, threshold)
|
|
47
|
+
"N+1 possible (#{query_count} queries, threshold #{threshold}) - " \
|
|
48
|
+
"add include param for nested relationships"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def perf_warning_metric(warning_msg)
|
|
52
|
+
safe_desc = warning_msg.tr('"', "'")
|
|
53
|
+
"perf-warning;desc=\"#{safe_desc}\""
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def jsonapi_request?(env)
|
|
57
|
+
env["HTTP_ACCEPT"].to_s.include?("application/vnd.api+json")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def log_n1_warning(env, count, threshold)
|
|
61
|
+
return unless defined?(Rails) && Rails.logger
|
|
62
|
+
|
|
63
|
+
req = ActionDispatch::Request.new(env)
|
|
64
|
+
Rails.logger.warn(
|
|
65
|
+
"[JPie] N+1 query warning: #{count} queries (threshold #{threshold}) " \
|
|
66
|
+
"path=#{req.path} include=#{req.params[:include].inspect} " \
|
|
67
|
+
"resource=#{req.params[:resource_type]}",
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/json_api/railtie.rb
CHANGED
|
@@ -16,14 +16,19 @@ module JSONAPI
|
|
|
16
16
|
Mime::Type.register "application/vnd.api+json", :jsonapi
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
initializer "json_api.n1_warning_middleware", after: "action_dispatch.configure" do |app|
|
|
20
|
+
if app.config.respond_to?(:server_timing) && app.config.server_timing
|
|
21
|
+
app.config.middleware.insert_after ActionDispatch::ServerTiming, JSONAPI::Rack::N1Warning
|
|
22
|
+
else
|
|
23
|
+
app.config.middleware.use JSONAPI::Rack::N1Warning
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
19
27
|
initializer "json_api.routes" do |_app|
|
|
20
28
|
require "json_api/routing"
|
|
21
29
|
ActionDispatch::Routing::Mapper.include JSONAPI::Routing
|
|
22
30
|
end
|
|
23
31
|
|
|
24
|
-
# Removed eager_load_namespaces registration - JSONAPI module doesn't implement eager_load!
|
|
25
|
-
# Controllers and resources are autoloaded via Zeitwerk
|
|
26
|
-
|
|
27
32
|
initializer "json_api.parameter_parsing", after: "action_dispatch.configure" do |_app|
|
|
28
33
|
ActionDispatch::Request.parameter_parsers[:jsonapi] = lambda do |raw_post|
|
|
29
34
|
ActiveSupport::JSON.decode(raw_post)
|
|
@@ -6,8 +6,6 @@ require_relative "concerns/sortable_fields_dsl"
|
|
|
6
6
|
require_relative "concerns/relationships_dsl"
|
|
7
7
|
require_relative "concerns/meta_dsl"
|
|
8
8
|
require_relative "concerns/model_class_helpers"
|
|
9
|
-
require_relative "concerns/eager_load_dsl"
|
|
10
|
-
require_relative "concerns/preload_dsl"
|
|
11
9
|
|
|
12
10
|
module JSONAPI
|
|
13
11
|
class Resource
|
|
@@ -17,8 +15,6 @@ module JSONAPI
|
|
|
17
15
|
include Resources::RelationshipsDsl
|
|
18
16
|
include Resources::MetaDsl
|
|
19
17
|
include Resources::ModelClassHelpers
|
|
20
|
-
include Resources::EagerLoadDsl
|
|
21
|
-
include Resources::PreloadDsl
|
|
22
18
|
|
|
23
19
|
class << self
|
|
24
20
|
attr_accessor :type_format
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Serialization
|
|
5
|
+
module IncludeFiltering
|
|
6
|
+
def filter_loaded_records(association, related_klass)
|
|
7
|
+
loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
|
|
8
|
+
return [] if loaded_array.empty?
|
|
9
|
+
|
|
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) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "include_filtering"
|
|
4
|
+
|
|
3
5
|
module JSONAPI
|
|
4
6
|
module Serialization
|
|
5
7
|
IncludeContext = Struct.new(:fields, :included_records, :processed, :all_includes, keyword_init: true)
|
|
@@ -9,6 +11,7 @@ module JSONAPI
|
|
|
9
11
|
|
|
10
12
|
module IncludesSerialization
|
|
11
13
|
include IncludePathHelpers
|
|
14
|
+
include IncludeFiltering
|
|
12
15
|
|
|
13
16
|
def serialize_included(includes, fields = {})
|
|
14
17
|
all_includes = normalize_include_paths(includes)
|
|
@@ -84,21 +87,16 @@ module JSONAPI
|
|
|
84
87
|
related_klass = association.klass
|
|
85
88
|
return [] if related_klass.blank?
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
|
|
93
|
-
loaded_ids = loaded_array.filter_map(&:id)
|
|
94
|
-
return [] if loaded_ids.empty?
|
|
95
|
-
|
|
96
|
-
scope.where(id: loaded_ids).to_a
|
|
90
|
+
if association.loaded?
|
|
91
|
+
filter_loaded_records(association, related_klass)
|
|
92
|
+
else
|
|
93
|
+
build_scoped_relation(related_klass, association).to_a
|
|
94
|
+
end
|
|
97
95
|
end
|
|
98
96
|
|
|
99
97
|
def build_scoped_relation(related_klass, association)
|
|
100
98
|
related_base_scope = ResourceLoader.find_for_model(related_klass).records
|
|
101
|
-
|
|
99
|
+
association.scope.merge(related_base_scope)
|
|
102
100
|
end
|
|
103
101
|
|
|
104
102
|
def get_active_storage_records(current_record, association_name)
|
|
@@ -22,17 +22,25 @@ module JSONAPI
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def variant_or_blob_path
|
|
25
|
-
return rails_blob_path unless
|
|
25
|
+
return rails_blob_path unless image_blob_with_parent?
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
rel_def = RelationshipHelpers.find_relationship_definition(parent_definition, association_name)
|
|
29
|
-
variant_opts = rel_def&.dig(:options, :variant)
|
|
27
|
+
variant_opts = variant_options_for_parent_association
|
|
30
28
|
return rails_blob_path unless variant_opts.present?
|
|
31
29
|
|
|
32
30
|
representation = record.representation(**variant_opts.symbolize_keys)
|
|
33
31
|
Rails.application.routes.url_helpers.rails_representation_path(representation, only_path: true)
|
|
34
32
|
end
|
|
35
33
|
|
|
34
|
+
def image_blob_with_parent?
|
|
35
|
+
parent_record && association_name && record.content_type&.start_with?("image/")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def variant_options_for_parent_association
|
|
39
|
+
parent_definition = ResourceLoader.find_for_model(parent_record.class)
|
|
40
|
+
rel_def = RelationshipHelpers.find_relationship_definition(parent_definition, association_name)
|
|
41
|
+
rel_def&.dig(:options, :variant)
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
def rails_blob_path
|
|
37
45
|
Rails.application.routes.url_helpers.rails_blob_path(record, only_path: true)
|
|
38
46
|
end
|
|
@@ -11,7 +11,7 @@ module JSONAPI
|
|
|
11
11
|
ensure_relationship_writable!(association_name)
|
|
12
12
|
|
|
13
13
|
return handle_null_relationship(attrs, param_name, association_name) if data.nil?
|
|
14
|
-
return handle_empty_array_relationship(attrs, param_name, association_name) if empty_array?(data)
|
|
14
|
+
return handle_empty_array_relationship(attrs, param_name, association_name) if ParamHelpers.empty_array?(data)
|
|
15
15
|
|
|
16
16
|
validate_relationship_data_format!(data, association_name)
|
|
17
17
|
process_relationship_data(attrs, association_name, param_name, data)
|
|
@@ -27,10 +27,6 @@ module JSONAPI
|
|
|
27
27
|
value_hash[:data]
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def empty_array?(data)
|
|
31
|
-
data.is_a?(Array) && data.empty?
|
|
32
|
-
end
|
|
33
|
-
|
|
34
30
|
def handle_null_relationship(attrs, param_name, association_name)
|
|
35
31
|
if active_storage_attachment?(association_name)
|
|
36
32
|
attrs[association_name.to_s] = nil
|
|
@@ -44,8 +44,7 @@ module JSONAPI
|
|
|
44
44
|
return unless pagination_applied
|
|
45
45
|
|
|
46
46
|
has_virtual_sort = sort_params.any? { |sort_field| virtual_attribute_sort?(sort_field) }
|
|
47
|
-
@total_count = @scope.count
|
|
48
|
-
@total_count = @scope.count if has_virtual_sort && @scope.is_a?(Array)
|
|
47
|
+
@total_count = @scope.count if !has_virtual_sort || @scope.is_a?(Array)
|
|
49
48
|
end
|
|
50
49
|
|
|
51
50
|
def apply_filtering
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
module JSONAPI
|
|
4
4
|
module Support
|
|
5
5
|
module ConditionBuilding
|
|
6
|
+
include Support::FilterParsing
|
|
7
|
+
|
|
6
8
|
private
|
|
7
9
|
|
|
8
|
-
def build_condition(column, value, operator)
|
|
9
|
-
|
|
10
|
+
def build_condition(model, column, value, operator)
|
|
11
|
+
attr = model.arel_table[column.name]
|
|
12
|
+
build_operator_condition(attr, value, operator)
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def normalize_filter_value_for_model(model, column, raw_value)
|
|
@@ -19,15 +22,6 @@ module JSONAPI
|
|
|
19
22
|
type.cast(value)
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
def build_condition_for_model(model, column, value, operator)
|
|
23
|
-
build_arel_condition(model, column, value, operator)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def build_arel_condition(model, column, value, operator)
|
|
27
|
-
attr = model.arel_table[column.name]
|
|
28
|
-
build_operator_condition(attr, value, operator)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
25
|
def build_operator_condition(attr, value, operator)
|
|
32
26
|
case operator
|
|
33
27
|
when :eq then attr.eq(value)
|
|
@@ -48,10 +42,6 @@ module JSONAPI
|
|
|
48
42
|
def apply_condition(scope, condition)
|
|
49
43
|
scope.where(condition)
|
|
50
44
|
end
|
|
51
|
-
|
|
52
|
-
def empty_filter_value?(filter_value)
|
|
53
|
-
filter_value.respond_to?(:empty?) ? filter_value.empty? : filter_value.nil?
|
|
54
|
-
end
|
|
55
45
|
end
|
|
56
46
|
end
|
|
57
47
|
end
|
|
@@ -97,7 +97,7 @@ module JSONAPI
|
|
|
97
97
|
value = normalize_filter_value_for_model(target_model, column, filter_value)
|
|
98
98
|
return nil unless value
|
|
99
99
|
|
|
100
|
-
condition =
|
|
100
|
+
condition = build_condition(target_model, column, value, column_filter[:operator])
|
|
101
101
|
condition ? apply_condition(scope, condition) : nil
|
|
102
102
|
end
|
|
103
103
|
|
|
@@ -51,7 +51,7 @@ module JSONAPI
|
|
|
51
51
|
value = normalize_filter_value_for_model(model_class, context[:fk_column], attr_value)
|
|
52
52
|
return scope unless value
|
|
53
53
|
|
|
54
|
-
condition =
|
|
54
|
+
condition = build_condition(model_class, context[:fk_column], value, column_filter[:operator])
|
|
55
55
|
condition ? apply_condition(scope, condition) : scope
|
|
56
56
|
end
|
|
57
57
|
|
|
@@ -17,7 +17,7 @@ module JSONAPI
|
|
|
17
17
|
private
|
|
18
18
|
|
|
19
19
|
def apply_regular_filter(scope, filter_name, filter_value)
|
|
20
|
-
return scope if
|
|
20
|
+
return scope if empty_filter_value?(filter_value)
|
|
21
21
|
|
|
22
22
|
column_filter = parse_column_filter(filter_name)
|
|
23
23
|
if column_filter
|
|
@@ -27,16 +27,6 @@ module JSONAPI
|
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def parse_column_filter(filter_name)
|
|
31
|
-
match = filter_name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
|
|
32
|
-
return nil unless match
|
|
33
|
-
|
|
34
|
-
column_name = match[1]
|
|
35
|
-
operator = match[2].to_sym
|
|
36
|
-
|
|
37
|
-
{ column: column_name, operator: }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
30
|
def apply_column_filter(scope, column_filter, raw_value)
|
|
41
31
|
condition = build_column_condition(column_filter, raw_value)
|
|
42
32
|
condition ? apply_condition(scope, condition) : scope
|
|
@@ -49,10 +39,10 @@ module JSONAPI
|
|
|
49
39
|
column = model_class.column_for_attribute(column_filter[:column])
|
|
50
40
|
return nil unless column
|
|
51
41
|
|
|
52
|
-
value =
|
|
42
|
+
value = normalize_filter_value_for_model(model_class, column, raw_value)
|
|
53
43
|
return nil if value.nil?
|
|
54
44
|
|
|
55
|
-
build_condition(column, value, column_filter[:operator])
|
|
45
|
+
build_condition(model_class, column, value, column_filter[:operator])
|
|
56
46
|
end
|
|
57
47
|
|
|
58
48
|
def log_filter_error(column_filter, operator, error)
|
|
@@ -61,14 +51,6 @@ module JSONAPI
|
|
|
61
51
|
Rails.logger.warn("Filter error for #{column_filter[:column]}_#{operator}: #{error.class} - #{error.message}")
|
|
62
52
|
end
|
|
63
53
|
|
|
64
|
-
def normalize_filter_value(column, raw_value)
|
|
65
|
-
value = raw_value.is_a?(Array) ? raw_value.first : raw_value
|
|
66
|
-
return nil if value.nil?
|
|
67
|
-
|
|
68
|
-
type = model_class.type_for_attribute(column.name)
|
|
69
|
-
type.cast(value)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
54
|
def apply_scope_fallback(scope, filter_name, filter_value)
|
|
73
55
|
return scope unless model_class.respond_to?(filter_name.to_sym)
|
|
74
56
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Support
|
|
5
|
+
module FilterParsing
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse_column_filter(filter_name)
|
|
9
|
+
m = filter_name.to_s.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
|
|
10
|
+
m ? { column: m[1], operator: m[2].to_sym } : nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def empty_filter_value?(filter_value)
|
|
14
|
+
filter_value.respond_to?(:empty?) ? filter_value.empty? : filter_value.nil?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module QueryCounter
|
|
5
|
+
SKIP_PATTERN = /\A(\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE\s+SAVEPOINT)\b)/i
|
|
6
|
+
SCHEMA_PATTERN = /\A(\s*(CREATE|ALTER|DROP)\s+(TABLE|INDEX|DATABASE))/i
|
|
7
|
+
PRAGMA_PATTERN = /\APRAGMA\b/i
|
|
8
|
+
SQLITE_MASTER_PATTERN = /sqlite_master|sqlite_temp_master/i
|
|
9
|
+
|
|
10
|
+
def self.count(&)
|
|
11
|
+
queries = []
|
|
12
|
+
counter = ->(_name, _start, _finish, _id, payload) { count_sql(queries, payload) }
|
|
13
|
+
ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &)
|
|
14
|
+
queries
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.count_queries(&)
|
|
18
|
+
count(&).size
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.count_sql(queries, payload)
|
|
22
|
+
sql = payload[:sql]
|
|
23
|
+
return if sql.nil?
|
|
24
|
+
return if SKIP_PATTERN.match?(sql)
|
|
25
|
+
return if SCHEMA_PATTERN.match?(sql)
|
|
26
|
+
return if PRAGMA_PATTERN.match?(sql)
|
|
27
|
+
return if SQLITE_MASTER_PATTERN.match?(sql)
|
|
28
|
+
|
|
29
|
+
queries << sql
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -58,7 +58,8 @@ module JSONAPI
|
|
|
58
58
|
|
|
59
59
|
def find_related_record(type, id, association, is_polymorphic)
|
|
60
60
|
related_model_class = resolve_related_model_class(type, association, is_polymorphic)
|
|
61
|
-
|
|
61
|
+
resource_class = JSONAPI::ResourceLoader.find_for_model(related_model_class)
|
|
62
|
+
resource_class.records.find(id)
|
|
62
63
|
rescue ActiveRecord::RecordNotFound
|
|
63
64
|
raise ArgumentError, "Related resource not found: #{type} with id #{id}"
|
|
64
65
|
rescue NameError
|
|
@@ -94,7 +94,7 @@ module JSONAPI
|
|
|
94
94
|
def status_code_for(status)
|
|
95
95
|
return status if status.is_a?(String) && status.match?(/\A\d+\z/)
|
|
96
96
|
|
|
97
|
-
Rack::Utils::SYMBOL_TO_STATUS_CODE.fetch(status, status).to_s
|
|
97
|
+
::Rack::Utils::SYMBOL_TO_STATUS_CODE.fetch(status, status).to_s
|
|
98
98
|
end
|
|
99
99
|
end
|
|
100
100
|
end
|
data/lib/json_api/version.rb
CHANGED
data/lib/json_api.rb
CHANGED
|
@@ -33,13 +33,15 @@ require "json_api/active_storage/detection"
|
|
|
33
33
|
require "json_api/active_storage/serialization"
|
|
34
34
|
require "json_api/active_storage/deserialization"
|
|
35
35
|
require "json_api/support/active_storage_support"
|
|
36
|
+
require "json_api/support/filter_parsing"
|
|
36
37
|
require "json_api/support/collection_query"
|
|
38
|
+
require "json_api/support/query_counter"
|
|
39
|
+
require "json_api/rack/n1_warning"
|
|
37
40
|
require "json_api/routing"
|
|
38
41
|
require "json_api/support/responders"
|
|
39
42
|
require "json_api/support/instrumentation"
|
|
40
43
|
require "json_api/serialization/serializer"
|
|
41
44
|
require "json_api/serialization/deserializer"
|
|
42
|
-
require "json_api/support/response_helpers"
|
|
43
45
|
require "json_api/controllers/concerns/controller_helpers"
|
|
44
46
|
require "json_api/controllers/concerns/resource_actions"
|
|
45
47
|
require "json_api/controllers/base_controller"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jpie
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Emil Kampp
|
|
@@ -95,21 +95,19 @@ files:
|
|
|
95
95
|
- lib/json_api/controllers/concerns/resource_actions/field_validation.rb
|
|
96
96
|
- lib/json_api/controllers/concerns/resource_actions/filter_validation.rb
|
|
97
97
|
- lib/json_api/controllers/concerns/resource_actions/pagination.rb
|
|
98
|
-
- lib/json_api/controllers/concerns/resource_actions/preloading.rb
|
|
99
98
|
- lib/json_api/controllers/concerns/resource_actions/resource_loading.rb
|
|
100
99
|
- lib/json_api/controllers/concerns/resource_actions/serialization.rb
|
|
101
100
|
- lib/json_api/controllers/concerns/resource_actions/type_validation.rb
|
|
102
101
|
- lib/json_api/controllers/relationships_controller.rb
|
|
103
102
|
- lib/json_api/controllers/resources_controller.rb
|
|
104
103
|
- lib/json_api/errors/parameter_not_allowed.rb
|
|
104
|
+
- lib/json_api/rack/n1_warning.rb
|
|
105
105
|
- lib/json_api/railtie.rb
|
|
106
106
|
- lib/json_api/resources/active_storage_blob_resource.rb
|
|
107
107
|
- lib/json_api/resources/concerns/attributes_dsl.rb
|
|
108
|
-
- lib/json_api/resources/concerns/eager_load_dsl.rb
|
|
109
108
|
- lib/json_api/resources/concerns/filters_dsl.rb
|
|
110
109
|
- lib/json_api/resources/concerns/meta_dsl.rb
|
|
111
110
|
- lib/json_api/resources/concerns/model_class_helpers.rb
|
|
112
|
-
- lib/json_api/resources/concerns/preload_dsl.rb
|
|
113
111
|
- lib/json_api/resources/concerns/relationships_dsl.rb
|
|
114
112
|
- lib/json_api/resources/concerns/sortable_fields_dsl.rb
|
|
115
113
|
- lib/json_api/resources/resource.rb
|
|
@@ -118,6 +116,7 @@ files:
|
|
|
118
116
|
- lib/json_api/serialization/concerns/attributes_deserialization.rb
|
|
119
117
|
- lib/json_api/serialization/concerns/attributes_serialization.rb
|
|
120
118
|
- lib/json_api/serialization/concerns/deserialization_helpers.rb
|
|
119
|
+
- lib/json_api/serialization/concerns/include_filtering.rb
|
|
121
120
|
- lib/json_api/serialization/concerns/includes_serialization.rb
|
|
122
121
|
- lib/json_api/serialization/concerns/links_serialization.rb
|
|
123
122
|
- lib/json_api/serialization/concerns/meta_serialization.rb
|
|
@@ -136,13 +135,14 @@ files:
|
|
|
136
135
|
- lib/json_api/support/concerns/polymorphic_filters.rb
|
|
137
136
|
- lib/json_api/support/concerns/regular_filters.rb
|
|
138
137
|
- lib/json_api/support/concerns/sorting.rb
|
|
138
|
+
- lib/json_api/support/filter_parsing.rb
|
|
139
139
|
- lib/json_api/support/instrumentation.rb
|
|
140
140
|
- lib/json_api/support/param_helpers.rb
|
|
141
|
+
- lib/json_api/support/query_counter.rb
|
|
141
142
|
- lib/json_api/support/relationship_guard.rb
|
|
142
143
|
- lib/json_api/support/relationship_helpers.rb
|
|
143
144
|
- lib/json_api/support/resource_identifier.rb
|
|
144
145
|
- lib/json_api/support/responders.rb
|
|
145
|
-
- lib/json_api/support/response_helpers.rb
|
|
146
146
|
- lib/json_api/support/sort_parsing.rb
|
|
147
147
|
- lib/json_api/support/type_conversion.rb
|
|
148
148
|
- lib/json_api/testing.rb
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module JSONAPI
|
|
4
|
-
module ResourceActions
|
|
5
|
-
module Preloading
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
|
-
|
|
8
|
-
def preload_includes
|
|
9
|
-
includes_hash = build_combined_includes_hash
|
|
10
|
-
return if includes_hash.empty?
|
|
11
|
-
|
|
12
|
-
preload_resources(includes_hash)
|
|
13
|
-
rescue ActiveRecord::RecordNotFound
|
|
14
|
-
# Will be handled by set_resource
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
private
|
|
18
|
-
|
|
19
|
-
def build_combined_includes_hash
|
|
20
|
-
param_includes = parse_include_param
|
|
21
|
-
dsl_includes = resource_eager_load_associations
|
|
22
|
-
|
|
23
|
-
combined = dsl_includes.map(&:to_s)
|
|
24
|
-
combined.concat(param_includes)
|
|
25
|
-
combined.uniq!
|
|
26
|
-
|
|
27
|
-
build_includes_hash(combined)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def resource_eager_load_associations
|
|
31
|
-
return [] unless resource_class.respond_to?(:eager_load_associations)
|
|
32
|
-
|
|
33
|
-
resource_class.eager_load_associations
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def build_includes_hash(includes)
|
|
37
|
-
includes.each_with_object({}) do |path, hash|
|
|
38
|
-
merge_include_path(hash, path)
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def merge_include_path(hash, path)
|
|
43
|
-
parts = path.to_s.split(".")
|
|
44
|
-
current = hash
|
|
45
|
-
|
|
46
|
-
parts.each_with_index do |part, i|
|
|
47
|
-
sym = part.to_sym
|
|
48
|
-
current[sym] ||= {}
|
|
49
|
-
current = current[sym] if i < parts.length - 1
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def preload_resources(includes_hash)
|
|
54
|
-
filtered = filter_includes_for_preload(includes_hash)
|
|
55
|
-
|
|
56
|
-
case action_name
|
|
57
|
-
when "index" then @preloaded_resources = preload_collection(filtered)
|
|
58
|
-
when "show" then @preloaded_resource = preload_single(filtered)
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def filter_includes_for_preload(hash)
|
|
63
|
-
filtered = filter_active_storage_from_includes(hash, model_class)
|
|
64
|
-
filter_polymorphic_from_includes(filtered, model_class)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def preload_collection(includes)
|
|
68
|
-
return resource_class.records if includes.empty?
|
|
69
|
-
|
|
70
|
-
resource_class.records.includes(includes)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def preload_single(includes)
|
|
74
|
-
return resource_class.records.find(params[:id]) if includes.empty?
|
|
75
|
-
|
|
76
|
-
resource_class.records.includes(includes).find(params[:id])
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module JSONAPI
|
|
4
|
-
module Resources
|
|
5
|
-
module EagerLoadDsl
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
|
-
|
|
8
|
-
class_methods do
|
|
9
|
-
# Declare associations to always eager-load for this resource
|
|
10
|
-
# @param associations [Array<Symbol>] list of association names to eager-load
|
|
11
|
-
def eager_load(*associations)
|
|
12
|
-
@declared_eager_loads ||= []
|
|
13
|
-
@declared_eager_loads.concat(associations.map(&:to_sym))
|
|
14
|
-
reset_eager_load_associations!
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# Get all eager-load associations including inherited ones
|
|
18
|
-
# @return [Array<Symbol>] frozen array of association names
|
|
19
|
-
def eager_load_associations
|
|
20
|
-
return @eager_load_associations if defined?(@eager_load_associations)
|
|
21
|
-
|
|
22
|
-
@eager_load_associations = build_eager_load_associations.freeze
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def reset_eager_load_associations!
|
|
26
|
-
remove_instance_variable(:@eager_load_associations) if defined?(@eager_load_associations)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
module EagerLoadHelperMethods
|
|
31
|
-
def build_eager_load_associations
|
|
32
|
-
own_associations = @declared_eager_loads || []
|
|
33
|
-
inherited = inherit_eager_load_associations
|
|
34
|
-
(inherited + own_associations).uniq
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def inherit_eager_load_associations
|
|
38
|
-
return [] unless superclass.respond_to?(:eager_load_associations)
|
|
39
|
-
return [] if superclass == JSONAPI::Resource
|
|
40
|
-
|
|
41
|
-
superclass.eager_load_associations
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
included do
|
|
46
|
-
extend EagerLoadHelperMethods
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module JSONAPI
|
|
4
|
-
module Resources
|
|
5
|
-
module PreloadDsl
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
|
-
|
|
8
|
-
class_methods do
|
|
9
|
-
# Override this method in resource subclasses to preload data before serialization.
|
|
10
|
-
# This is called once with all records before serialization starts.
|
|
11
|
-
# Use this to batch-load data needed by meta methods to avoid N+1 queries.
|
|
12
|
-
#
|
|
13
|
-
# @param records [Array<ActiveRecord::Base>] the records that will be serialized
|
|
14
|
-
# @param context [Hash] optional context (e.g., current user, request params)
|
|
15
|
-
# @return [Hash] a hash of preloaded data keyed by record id
|
|
16
|
-
#
|
|
17
|
-
# @example
|
|
18
|
-
# class WorkstreamResource < ApplicationResource
|
|
19
|
-
# def self.preload_for_serialization(records, context = {})
|
|
20
|
-
# stats = Preloaders::StatsPreloader.call(records: records)
|
|
21
|
-
# { stats: stats }
|
|
22
|
-
# end
|
|
23
|
-
#
|
|
24
|
-
# def meta(...)
|
|
25
|
-
# preloaded = self.class.preloaded_data[record.id]
|
|
26
|
-
# super.merge(loading: preloaded[:stats])
|
|
27
|
-
# end
|
|
28
|
-
# end
|
|
29
|
-
def preload_for_serialization(_records, _context = {})
|
|
30
|
-
# Default implementation does nothing - override in subclasses
|
|
31
|
-
{}
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Storage for preloaded data (request-scoped via Thread.current)
|
|
35
|
-
def preloaded_data
|
|
36
|
-
Thread.current["#{name}_preloaded_data"] ||= {}
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def preloaded_data=(data)
|
|
40
|
-
Thread.current["#{name}_preloaded_data"] = data
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def clear_preloaded_data!
|
|
44
|
-
Thread.current["#{name}_preloaded_data"] = nil
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|