jpie 1.2.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 654010b56dc91dbc6edffc3c54891d7d6cb7eab738f685cbbe0e793aa9d26b13
4
- data.tar.gz: d400665267796d760e1051697a46bb067c414142be5372e1abb2ac1254e8aaa7
3
+ metadata.gz: 1f2984708bbd678f90b58e39d28e853f93b504b933cd15f8af8a995c21eb41e4
4
+ data.tar.gz: 4b6107778dc307c1f155bf013b372dd05db8e0891f1b1a39c8b4ebdc96f4b42c
5
5
  SHA512:
6
- metadata.gz: a4081700e8daa2cd9e4b209d7cf2d1d0464b44d3678fb9095adc995fe5e075ad5fa00b64af8bc7bdb9a53177f64ba74e999e85f61d56c9c82d2908bede76b579
7
- data.tar.gz: 6c786599fbfda71820206e62c17dbbb9ed86d28858ae905293b20a9426e3cf64da76dba69c8ffaa7dfff5193f4e42ba203a09ba635943f8ac6212cea95bdfada
6
+ metadata.gz: 8393c4ba329b198d648f7bb2260777e0c889fde78e8d592ae72cfd9cd48ca97cdacfd0453214011f47cbff7af67c634dba001c888ca1447462f1263460f691c5
7
+ data.tar.gz: f42ea311ec1bea10f6e13d2c30af99eba23369fcc97ffa6a29acdfd2af74b6c109171fee4bed3b880edabc67e2157c950857d5a0fbac85b78bf55c94b57676e1
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jpie (1.2.0)
4
+ jpie (1.3.0)
5
5
  actionpack (~> 8.0, >= 8.0.0)
6
6
  rails (~> 8.0, >= 8.0.0)
7
7
 
@@ -0,0 +1,102 @@
1
+ # jpie Performance Baseline
2
+
3
+ This document outlines the performance metrics for key operations within the `jpie` gem, comparing the initial baseline with the results after implementing all optimizations.
4
+
5
+ ## Performance Comparison
6
+
7
+ | Metric | Original Baseline | After Optimizations | Improvement |
8
+ |--------|-------------------|---------------------|-------------|
9
+ | `resource_loader_find_avg_ms` | 0.0182 | 0.0003 | **60x faster** |
10
+ | `resource_loader_find_for_model_avg_ms` | 0.036 | 0.0003 | **120x faster** |
11
+ | `jsonapi_object_avg_ms` | 0.0003 | 0.0001 | **3x faster** |
12
+ | `serialize_single_user_avg_ms` | 0.2168 | 0.0982 | **2.2x faster** |
13
+ | `serialize_10_users_avg_ms` | 1.4318 | 0.975 | **1.5x faster** |
14
+ | `serialize_single_user_query_count` | 4 | 4 | N/A |
15
+ | `serialize_10_users_query_count` | 40 | 40 | N/A |
16
+ | `serialize_single_user_allocations` | 997 | 977 | **2% reduction** |
17
+ | `serialize_10_users_allocations` | 9934 | 9716 | **2% reduction** |
18
+ | `relationship_definitions_avg_ms` | 0.0036 | 0.0002 | **18x faster** |
19
+ | `resource_instantiation_avg_ms` | 0.0003 | 0.0004 | Similar |
20
+
21
+ ## Implemented Optimizations
22
+
23
+ ### 1. ResourceLoader Caching (Phase 4)
24
+ - Thread-safe caching for `find` and `find_for_model` methods
25
+ - Eliminates repeated `constantize` calls
26
+ - **60-120x improvement** in resource class lookups
27
+
28
+ ### 2. jsonapi_object Memoization (Phase 5)
29
+ - Memoized the static JSON:API object
30
+ - Returns frozen object to prevent mutations
31
+ - **3x improvement** in object generation
32
+
33
+ ### 3. Relationship Definitions Caching (Phase 6)
34
+ - Memoized `relationship_definitions` computation
35
+ - Returns frozen array to prevent mutations
36
+ - **18x improvement** in relationship metadata access
37
+
38
+ ### 4. Optional Count Query (Phase 7)
39
+ - `total_count` only computed when pagination is applied
40
+ - Avoids unnecessary COUNT queries for non-paginated requests
41
+ - Reduces database load
42
+
43
+ ### 5. Eager Loading DSL (Phase 8)
44
+ - New `eager_load` class method for resources
45
+ - Automatically included in preloading without explicit `include` param
46
+ - Helps eliminate N+1 queries at the resource level
47
+
48
+ ### 6. Preload for Serialization Hook (Phase 9)
49
+ - New `preload_for_serialization` class method
50
+ - Called before serialization with all records
51
+ - Enables batch-loading of data needed by `meta` methods
52
+ - Thread-local storage for preloaded data
53
+
54
+ ## Usage Examples
55
+
56
+ ### Eager Loading DSL
57
+
58
+ ```ruby
59
+ class WorkstreamResource < ApplicationResource
60
+ eager_load :conversation, :owners
61
+
62
+ # These associations will be automatically eager-loaded
63
+ # even without an explicit ?include= param
64
+ end
65
+ ```
66
+
67
+ ### Preload for Serialization Hook
68
+
69
+ ```ruby
70
+ class WorkstreamResource < ApplicationResource
71
+ def self.preload_for_serialization(records, context = {})
72
+ # Batch-load stats for all records
73
+ stats = Preloaders::StatsPreloader.call(records: records)
74
+ stats # Store in preloaded_data for access in meta
75
+ end
76
+
77
+ def meta(...)
78
+ stats = self.class.preloaded_data[record.id] || record.loading_stats
79
+ super.merge(loading: stats)
80
+ end
81
+ end
82
+ ```
83
+
84
+ ## Metric Interpretation
85
+
86
+ - **`resource_loader_find_avg_ms`**: Average time to find a resource class by type
87
+ - **`resource_loader_find_for_model_avg_ms`**: Average time to find a resource class by model
88
+ - **`jsonapi_object_avg_ms`**: Average time to generate the static `jsonapi` object
89
+ - **`serialize_single_user_avg_ms`**: Average time to serialize a single `User` record
90
+ - **`serialize_10_users_avg_ms`**: Average time to serialize a collection of 10 `User` records
91
+ - **`serialize_single_user_query_count`**: Number of SQL queries for single `User` serialization
92
+ - **`serialize_10_users_query_count`**: Number of SQL queries for 10 `User` records serialization
93
+ - **`serialize_single_user_allocations`**: Object allocations for single `User` serialization
94
+ - **`serialize_10_users_allocations`**: Object allocations for 10 `User` records serialization
95
+ - **`relationship_definitions_avg_ms`**: Average time to compute relationship definitions
96
+ - **`resource_instantiation_avg_ms`**: Average time to instantiate a resource
97
+
98
+ ## Notes
99
+
100
+ - Query counts remain the same in benchmarks as they test the serializer directly
101
+ - Real-world N+1 reduction comes from the `eager_load` DSL and `preload_for_serialization` hook
102
+ - Allocation reduction is modest but consistent across serialization
@@ -6,10 +6,9 @@ module JSONAPI
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  def preload_includes
9
- includes = parse_include_param
10
- return if includes.empty?
9
+ includes_hash = build_combined_includes_hash
10
+ return if includes_hash.empty?
11
11
 
12
- includes_hash = build_includes_hash(includes)
13
12
  preload_resources(includes_hash)
14
13
  rescue ActiveRecord::RecordNotFound
15
14
  # Will be handled by set_resource
@@ -17,6 +16,23 @@ module JSONAPI
17
16
 
18
17
  private
19
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
+
20
36
  def build_includes_hash(includes)
21
37
  includes.each_with_object({}) do |path, hash|
22
38
  merge_include_path(hash, path)
@@ -24,7 +40,7 @@ module JSONAPI
24
40
  end
25
41
 
26
42
  def merge_include_path(hash, path)
27
- parts = path.split(".")
43
+ parts = path.to_s.split(".")
28
44
  current = hash
29
45
 
30
46
  parts.each_with_index do |part, i|
@@ -6,14 +6,29 @@ module JSONAPI
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  def serialize_resource(resource)
9
+ run_preload_hook([resource])
9
10
  JSONAPI::Serializer.new(resource).to_hash(
10
11
  include: parse_include_param,
11
12
  fields: parse_fields_param,
12
13
  document_meta: jsonapi_document_meta,
13
14
  )
15
+ ensure
16
+ clear_preload_data
14
17
  end
15
18
 
16
19
  def serialize_collection(resources)
20
+ resources_array = resources.to_a
21
+ run_preload_hook(resources_array)
22
+
23
+ data, all_included = serialize_resources_with_includes(resources_array)
24
+ build_collection_response(data, all_included)
25
+ ensure
26
+ clear_preload_data
27
+ end
28
+
29
+ private
30
+
31
+ def serialize_resources_with_includes(resources)
17
32
  includes = parse_include_param
18
33
  fields = parse_fields_param
19
34
  all_included = []
@@ -25,11 +40,9 @@ module JSONAPI
25
40
  result[:data]
26
41
  end
27
42
 
28
- build_collection_response(data, all_included)
43
+ [data, all_included]
29
44
  end
30
45
 
31
- private
32
-
33
46
  def serialize_single(resource, includes, fields)
34
47
  JSONAPI::Serializer.new(resource).to_hash(include: includes, fields:, document_meta: nil)
35
48
  end
@@ -58,6 +71,21 @@ module JSONAPI
58
71
 
59
72
  result
60
73
  end
74
+
75
+ def run_preload_hook(records)
76
+ return unless resource_class.respond_to?(:preload_for_serialization)
77
+
78
+ preloaded = resource_class.preload_for_serialization(records, jsonapi_context)
79
+ resource_class.preloaded_data = preloaded if preloaded.present?
80
+ end
81
+
82
+ def clear_preload_data
83
+ resource_class.clear_preloaded_data! if resource_class.respond_to?(:clear_preloaded_data!)
84
+ end
85
+
86
+ def jsonapi_context
87
+ { current_user: respond_to?(:current_user) ? current_user : nil }
88
+ end
61
89
  end
62
90
  end
63
91
  end
@@ -0,0 +1,50 @@
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
@@ -0,0 +1,49 @@
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
@@ -7,29 +7,30 @@ module JSONAPI
7
7
 
8
8
  class_methods do
9
9
  def has_one(name, meta: nil, **options)
10
- @relationships ||= []
11
10
  detect_polymorphic(name, options)
12
- @relationships << { name: name.to_sym, type: :has_one, meta:, options: }
11
+ register_relationship(name:, type: :has_one, meta:, options:)
13
12
  end
14
13
 
15
14
  def has_many(name, meta: nil, **options)
16
- @relationships ||= []
17
15
  validate_append_only_options!(options)
18
16
  detect_polymorphic(name, options)
19
- @relationships << { name: name.to_sym, type: :has_many, meta:, options: }
17
+ register_relationship(name:, type: :has_many, meta:, options:)
20
18
  end
21
19
 
22
20
  def belongs_to(name, meta: nil, **options)
23
- @relationships ||= []
24
21
  detect_polymorphic(name, options)
25
- @relationships << { name: name.to_sym, type: :belongs_to, meta:, options: }
22
+ register_relationship(name:, type: :belongs_to, meta:, options:)
26
23
  end
27
24
 
28
25
  def relationship_definitions
29
- declared_relationships = instance_variable_defined?(:@relationships)
30
- rels = @relationships || []
31
- rels = superclass.relationship_definitions + rels if should_inherit_relationships?(declared_relationships)
32
- rels.uniq { |r| r[:name] }
26
+ return @relationship_definitions if defined?(@relationship_definitions)
27
+
28
+ rels = build_relationship_definitions
29
+ @relationship_definitions = rels.freeze
30
+ end
31
+
32
+ def reset_relationship_definitions!
33
+ remove_instance_variable(:@relationship_definitions) if defined?(@relationship_definitions)
33
34
  end
34
35
 
35
36
  def relationship_names
@@ -38,6 +39,19 @@ module JSONAPI
38
39
  end
39
40
 
40
41
  module RelationshipHelperMethods
42
+ def register_relationship(name:, type:, meta:, options:)
43
+ @relationships ||= []
44
+ @relationships << { name: name.to_sym, type:, meta:, options: }
45
+ reset_relationship_definitions!
46
+ end
47
+
48
+ def build_relationship_definitions
49
+ declared_relationships = instance_variable_defined?(:@relationships)
50
+ rels = @relationships || []
51
+ rels = superclass.relationship_definitions + rels if should_inherit_relationships?(declared_relationships)
52
+ rels.uniq { |r| r[:name] }
53
+ end
54
+
41
55
  def validate_append_only_options!(options)
42
56
  if options[:append_only] && options[:purge_on_nil] == true
43
57
  raise ArgumentError, "Cannot use append_only: true with purge_on_nil: true"
@@ -6,6 +6,8 @@ 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"
9
11
 
10
12
  module JSONAPI
11
13
  class Resource
@@ -15,6 +17,8 @@ module JSONAPI
15
17
  include Resources::RelationshipsDsl
16
18
  include Resources::MetaDsl
17
19
  include Resources::ModelClassHelpers
20
+ include Resources::EagerLoadDsl
21
+ include Resources::PreloadDsl
18
22
 
19
23
  class << self
20
24
  attr_accessor :type_format
@@ -16,7 +16,25 @@ module JSONAPI
16
16
  end
17
17
  end
18
18
 
19
+ # Thread-safe caches for resource class lookups
20
+ # Using simple Hash with ||= is safe enough - worst case is duplicate computation
21
+ # which is acceptable since the result is always the same Class object
22
+ @find_cache = {}
23
+ @model_cache = {}
24
+
25
+ class << self
26
+ def clear_cache!
27
+ @find_cache = {}
28
+ @model_cache = {}
29
+ end
30
+ end
31
+
19
32
  def self.find(resource_type, namespace: nil)
33
+ cache_key = "#{namespace}::#{resource_type}"
34
+ @find_cache[cache_key] ||= find_uncached(resource_type, namespace:)
35
+ end
36
+
37
+ def self.find_uncached(resource_type, namespace: nil)
20
38
  return find_namespaced(resource_type, namespace) if namespace.present?
21
39
 
22
40
  find_flat(resource_type, namespace)
@@ -48,7 +66,8 @@ module JSONAPI
48
66
  def self.find_for_model(model_class, namespace: nil)
49
67
  return ActiveStorageBlobResource if active_storage_blob?(model_class)
50
68
 
51
- find_resource_for_model(model_class, namespace)
69
+ cache_key = "#{namespace}::#{model_class.name}"
70
+ @model_cache[cache_key] ||= find_resource_for_model(model_class, namespace)
52
71
  end
53
72
 
54
73
  def self.find_resource_for_model(model_class, namespace)
@@ -17,12 +17,22 @@ module JSONAPI
17
17
 
18
18
  JSONAPI_VERSION = "1.1"
19
19
 
20
+ @jsonapi_object = nil
21
+
20
22
  def self.jsonapi_object
23
+ @jsonapi_object ||= build_jsonapi_object.freeze
24
+ end
25
+
26
+ def self.build_jsonapi_object
21
27
  obj = { version: JSONAPI_VERSION }
22
28
  obj[:meta] = JSONAPI.configuration.jsonapi_meta if JSONAPI.configuration.jsonapi_meta
23
29
  obj
24
30
  end
25
31
 
32
+ def self.reset_jsonapi_object!
33
+ @jsonapi_object = nil
34
+ end
35
+
26
36
  def initialize(record, definition: nil, base_definition: nil)
27
37
  @record = record
28
38
  @definition = definition || ResourceLoader.find_for_model(record.class)
@@ -30,10 +30,8 @@ module JSONAPI
30
30
 
31
31
  def execute
32
32
  @scope = apply_filtering
33
- has_virtual_sort = sort_params.any? { |sort_field| virtual_attribute_sort?(sort_field) }
34
- @total_count = @scope.count unless has_virtual_sort
33
+ compute_total_count_if_needed
35
34
  @scope = apply_sorting(@scope)
36
- @total_count = @scope.count if has_virtual_sort && @scope.is_a?(Array)
37
35
  @scope = apply_pagination
38
36
  self
39
37
  end
@@ -42,6 +40,14 @@ module JSONAPI
42
40
 
43
41
  attr_reader :definition, :model_class, :filter_params, :sort_params, :page_params
44
42
 
43
+ def compute_total_count_if_needed
44
+ return unless pagination_applied
45
+
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)
49
+ end
50
+
45
51
  def apply_filtering
46
52
  scope = apply_nested_relationship_filters(@scope)
47
53
  apply_regular_filters(scope)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
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.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp
@@ -64,6 +64,7 @@ files:
64
64
  - ".travis.yml"
65
65
  - Gemfile
66
66
  - Gemfile.lock
67
+ - PERFORMANCE_BASELINE.md
67
68
  - README.md
68
69
  - Rakefile
69
70
  - bin/console
@@ -105,9 +106,11 @@ files:
105
106
  - lib/json_api/railtie.rb
106
107
  - lib/json_api/resources/active_storage_blob_resource.rb
107
108
  - lib/json_api/resources/concerns/attributes_dsl.rb
109
+ - lib/json_api/resources/concerns/eager_load_dsl.rb
108
110
  - lib/json_api/resources/concerns/filters_dsl.rb
109
111
  - lib/json_api/resources/concerns/meta_dsl.rb
110
112
  - lib/json_api/resources/concerns/model_class_helpers.rb
113
+ - lib/json_api/resources/concerns/preload_dsl.rb
111
114
  - lib/json_api/resources/concerns/relationships_dsl.rb
112
115
  - lib/json_api/resources/concerns/sortable_fields_dsl.rb
113
116
  - lib/json_api/resources/resource.rb