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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/README.md +22 -1
  4. data/lib/json_api/configuration.rb +36 -11
  5. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +6 -7
  6. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +2 -3
  7. data/lib/json_api/controllers/concerns/relationships/serialization.rb +42 -1
  8. data/lib/json_api/controllers/concerns/relationships/updating.rb +1 -5
  9. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +0 -8
  10. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +3 -7
  11. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +3 -3
  12. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +42 -25
  13. data/lib/json_api/controllers/concerns/resource_actions.rb +6 -10
  14. data/lib/json_api/controllers/relationships_controller.rb +2 -3
  15. data/lib/json_api/errors/parameter_not_allowed.rb +0 -5
  16. data/lib/json_api/rack/n1_warning.rb +72 -0
  17. data/lib/json_api/railtie.rb +8 -3
  18. data/lib/json_api/resources/resource.rb +0 -4
  19. data/lib/json_api/serialization/concerns/include_filtering.rb +42 -0
  20. data/lib/json_api/serialization/concerns/includes_serialization.rb +9 -11
  21. data/lib/json_api/serialization/concerns/links_serialization.rb +12 -4
  22. data/lib/json_api/serialization/concerns/relationship_processing.rb +1 -5
  23. data/lib/json_api/support/collection_query.rb +1 -2
  24. data/lib/json_api/support/concerns/condition_building.rb +5 -15
  25. data/lib/json_api/support/concerns/nested_filters.rb +1 -1
  26. data/lib/json_api/support/concerns/polymorphic_filters.rb +1 -1
  27. data/lib/json_api/support/concerns/regular_filters.rb +3 -21
  28. data/lib/json_api/support/filter_parsing.rb +18 -0
  29. data/lib/json_api/support/param_helpers.rb +4 -0
  30. data/lib/json_api/support/query_counter.rb +32 -0
  31. data/lib/json_api/support/relationship_guard.rb +1 -1
  32. data/lib/json_api/support/resource_identifier.rb +2 -1
  33. data/lib/json_api/support/responders.rb +1 -1
  34. data/lib/json_api/version.rb +1 -1
  35. data/lib/json_api.rb +3 -1
  36. metadata +5 -5
  37. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +0 -80
  38. data/lib/json_api/resources/concerns/eager_load_dsl.rb +0 -50
  39. data/lib/json_api/resources/concerns/preload_dsl.rb +0 -49
  40. 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: be60282bbba362a7ac36dca3370a4733981bce7a949352513244879bc76546a7
4
- data.tar.gz: 6c391beea011b7fd348f8a2439130ba174dd31fa7b8f9f4ea6eaef5c9fd916ee
3
+ metadata.gz: 1a598f58a7c0f80730e852f069276ea9d3066d3c7622a8faec599f1b58039f0e
4
+ data.tar.gz: 33292a3d2e2b98c09fb9868972b2936738ebf00b6ec0a1b7baf46ec51b017369
5
5
  SHA512:
6
- metadata.gz: 13f21d51760a269d54d1bcd46a9be3916506bae8c25af82eaabea8dc7a214d2f3d39b29437ca0703141a794444d13b7ab20ba26ac4f35f4fe3a8ca15207d1630
7
- data.tar.gz: 35e11173e5942bc01059217acf3a5028f5980108b2c76e88b33fe03c9c2ae00a4a6a9c0051328e491a257d83700f02f6a206c7a34406cde968af1a6698ac63fd
6
+ metadata.gz: 5b3830b35c1fc6690c755c5f90ad7568ffc6c57d7b8ad0516fdf368f476a15223a0489694acb5ac288d2c5f8f9f8889c154f5e0f09915dfac276b876ed437858
7
+ data.tar.gz: 0cdc51e099411e4f27342783e3e47bf5e34ff983308758fa904819009df8f23364521a7d1b32a634827fdcba7f4302501202ef4f0c193d119e97b9f9daaf7990
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jpie (1.5.1)
4
+ jpie (2.0.0)
5
5
  actionpack (~> 8.0, >= 8.0.0)
6
6
  rails (~> 8.0, >= 8.0.0)
7
7
 
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # JSONAPI
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
- @default_page_size = 25
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
- render_jsonapi_error(
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
- render_jsonapi_error(
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 = @preloaded_resource || model_class.find(params[:id])
33
+ @resource = @resource_class.records.find(params[:id])
34
34
  rescue ActiveRecord::RecordNotFound
35
- render_jsonapi_error(
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 = @resource.public_send(@relationship_name)
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
- m = parts.first.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
73
- %w[id type].include?(m ? m[1] : parts.first)
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
- @resource = @resource_class.records.find(params[:id])
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
- render_jsonapi_error(
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 build_collection_response(data, all_included)
65
- result = { jsonapi: JSONAPI::Serializer.jsonapi_object, data: }
66
- result[:included] = all_included if all_included.any?
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
- pagination_meta = @pagination_applied ? build_pagination_meta : {}
69
- result[:links] = build_pagination_links if @pagination_applied
70
- result[:meta] = jsonapi_document_meta(pagination_meta)
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
- result
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 run_preload_hook(records)
76
- return unless resource_class.respond_to?(:preload_for_serialization)
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
- preloaded = resource_class.preload_for_serialization(records, jsonapi_context)
79
- resource_class.preloaded_data = preloaded if preloaded.present?
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 clear_preload_data
83
- resource_class.clear_preloaded_data! if resource_class.respond_to?(:clear_preloaded_data!)
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 jsonapi_context
87
- { current_user: respond_to?(:current_user) ? current_user : nil }
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(@preloaded_resources || resource_class.records, action: :index)
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 = @preloaded_resource || @resource
46
- authorize_resource_action!(resource, action: :show)
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::Exceptions::ParameterNotAllowed, ActiveSupport::MessageVerifier::InvalidSignature => e
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::Exceptions::ParameterNotAllowed then render_parameter_not_allowed_error(error)
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::Exceptions::ParameterNotAllowed => e
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::Exceptions::ParameterNotAllowed => e
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::Exceptions::ParameterNotAllowed => e
55
+ rescue JSONAPI::Errors::ParameterNotAllowed => e
57
56
  render_parameter_not_allowed_error(e)
58
57
  end
59
58
 
@@ -11,9 +11,4 @@ module JSONAPI
11
11
  end
12
12
  end
13
13
  end
14
-
15
- # Backward compatibility alias
16
- module Exceptions
17
- ParameterNotAllowed = Errors::ParameterNotAllowed
18
- end
19
14
  end
@@ -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
@@ -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
- scope = build_scoped_relation(related_klass, association)
88
- association.loaded? ? scope_loaded_to_records_scope(association, scope) : scope.to_a
89
- end
90
-
91
- def scope_loaded_to_records_scope(association, scope)
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
- related_base_scope.merge(association.scope)
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 parent_record && association_name && record.content_type&.start_with?("image/")
25
+ return rails_blob_path unless image_blob_with_parent?
26
26
 
27
- parent_definition = ResourceLoader.find_for_model(parent_record.class)
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 unless has_virtual_sort
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
- build_arel_condition(model_class, column, value, operator)
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 = build_condition_for_model(target_model, column, value, column_filter[:operator])
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 = build_condition_for_model(model_class, context[:fk_column], value, column_filter[:operator])
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 filter_value.respond_to?(:empty?) ? filter_value.empty? : filter_value.nil?
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 = normalize_filter_value(column, raw_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
@@ -50,5 +50,9 @@ module JSONAPI
50
50
  def esc(val)
51
51
  CGI.escape(val.to_s)
52
52
  end
53
+
54
+ def empty_array?(data)
55
+ data.is_a?(Array) && data.empty?
56
+ end
53
57
  end
54
58
  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
@@ -10,7 +10,7 @@ module JSONAPI
10
10
  return unless association
11
11
  return unless readonly
12
12
 
13
- raise JSONAPI::Exceptions::ParameterNotAllowed, [error_target]
13
+ raise JSONAPI::Errors::ParameterNotAllowed, [error_target]
14
14
  end
15
15
  end
16
16
  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
- related_model_class.find(id)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "1.5.1"
4
+ VERSION = "2.0.0"
5
5
  end
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: 1.5.1
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
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module ResponseHelpers
5
- # Delegates to Serializer.jsonapi_object for backward compatibility
6
- def self.jsonapi_object
7
- JSONAPI::Serializer.jsonapi_object
8
- end
9
- end
10
- end