graphiti-activegraph 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: 96124388fd7da0f681746c33d199a96431291363f8ec3acd1a1ef2533fba7e3b
4
- data.tar.gz: ba13ae0e3229a1e31a3e575d8d7b1e8c502a58d6d774ab3326b1936138e09d5c
3
+ metadata.gz: 64aa5789f68595122f2d8113a497fa4f822f262bed07cac85da0474cb1ed7d51
4
+ data.tar.gz: f95a5b41c60d54f43e245c0b0279577b69325fb777e41c526f376e7fed40f1a3
5
5
  SHA512:
6
- metadata.gz: 9985ef640559045360fc73f8dc41daf8ce09b43d92010cddff64bec9967e031cea3d806683865793c0dcca9f3e3092539cee81501b9bcc06e0a6f1bf4b0f1104
7
- data.tar.gz: 711c568f287e85dc68d39fcf09b87425c7e8f1c61e20ce656578d1eee12bee57a2c62258a30f9df4e20380d9cebebb020c1951e557ff61d1eb82944dd0eeae64
6
+ metadata.gz: c2d65e132c567d4fcb814389e199a3b4c4c13d53f83f9604d57ddc98b7dd05573b8527eecc67e78294de9cc9e178ef7ab287ad571626f5854efcc8db30de32a2
7
+ data.tar.gz: 6fdd6b5a5ec1be222a0905bf1877761c1b81342e0d48f19d662e5ae014c3819938e5a72aaf159e65c86b7a58d173fd2e3d65f66071734e7cc90d9e4d83ea08ed
data/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2025-10-13
11
+ ### Added
12
+ - **Extra fields preloading in main query**: Support preloading associations for `extra_fields` for main and sideloaded records. Adds `preload` option to `extra_attribute` in resource classes. Example: `extra_attribute :full_post_title, :string, preload: :author`. (PR #45)
13
+ - **Extra fields preloading in separate query**: Collect IDs from the main query result and run a single separate query to load and calculate `extra_fields` values for all records. does not support preloading for deep sideloads. (PR #47)
14
+
10
15
  ## [1.2.0] - 2025-05-20
11
16
 
12
17
  ### Added
data/README.md CHANGED
@@ -65,6 +65,48 @@ Control the links in response payload via request query params:
65
65
  - `pagination_links=true|false` — toggle top-level pagination links
66
66
  - `links=true|false` — toggle links inside each resource object
67
67
 
68
+ #### Preload Extra Attribute Associations via `preload:` option
69
+ You can declare an extra attribute on your resource and specify an association to preload using the `preload:` option.
70
+ Example:
71
+ ```ruby
72
+ extra_attribute :full_post_title, :string, preload: :author
73
+ ```
74
+ Check [spec/active_graph/scoping/internal/extra_field_normalizer_spec.rb](https://github.com/mrhardikjoshi/graphiti-activegraph/blob/master/spec/active_graph/scoping/internal/extra_field_normalizer_spec.rb) for examples of usage `preload:` option.
75
+
76
+ #### Preload Extra Fields via Model Preload Method
77
+ You can define a custom preload method with prefix `preload_` in your model (e.g., `preload_posts_number` for the posts_number extra field) that fetches values for the extra attribute.
78
+ When you request an extra field (e.g., `posts_number`) in your query, graphiti-activegraph will call this method, passing all relevant record IDs, and assign the returned values to each record’s extra attribute.
79
+ This works for both top-level results and sideloaded records of the matching resource type.
80
+
81
+ ##### Usage example
82
+ ```ruby
83
+ class Comment
84
+ # Allows assignment of the extra field value by the preloader
85
+ attr_writer :author_activity
86
+
87
+ def author_activity
88
+ @author_activity ||= author.comments.count + author.posts.count
89
+ end
90
+
91
+ # Preload method which fetches values for the extra_attribute
92
+ def self.preload_author_activity(comment_ids)
93
+ where(id: comment_ids).with_associations(author: [:posts, :comments]).to_h do |comment|
94
+ author = comment.author
95
+ [comment.id, author.posts.count + author.comments.count]
96
+ end
97
+ end
98
+ end
99
+
100
+ class CommentResource < Graphiti::ActiveGraph::Resource
101
+ extra_attribute :author_activity, :integer
102
+ end
103
+ ```
104
+
105
+ **Note:**
106
+ Currently, this feature does not support preloading for deep sideloads such as `posts.comment.author*`. Deeply sideloaded records will not appear in the array of relevant records for preload, and thus will not have extra fields assigned.
107
+
108
+ Check [spec/support/factory_bot_setup.rb](https://github.com/mrhardikjoshi/graphiti-activegraph/blob/master/spec/support/factory_bot_setup.rb) and [spec/active_graph/sideload_resolve_spec.rb](https://github.com/mrhardikjoshi/graphiti-activegraph/blob/master/spec/active_graph/sideload_resolve_spec.rb) for examples of usage.
109
+
68
110
  ## Contributing
69
111
  Bug reports and pull requests are welcome on GitHub at https://github.com/mrhardikjoshi/graphiti-activegraph. This project is intended to be a safe, welcoming space for collaboration.
70
112
 
@@ -45,8 +45,9 @@ module Graphiti
45
45
 
46
46
  def handle_includes(scope, includes, sorts, **opts)
47
47
  includes_str = JSONAPI::IncludeDirective.new(includes, retain_rel_limit: true).to_string.split(',')
48
+ extra_includes_str = opts.delete(:extra_fields_includes) || []
48
49
  options = opts.merge(max_page_size:).merge!(authorize_scope_params)
49
- scope.with_ordered_associations(includes_str, sorts, options)
50
+ scope.with_ordered_associations(includes_str.union(extra_includes_str), sorts, options)
50
51
  end
51
52
 
52
53
  def sideload_name_arr(query)
@@ -10,9 +10,10 @@ module Graphiti::ActiveGraph
10
10
  end
11
11
 
12
12
  def apply_standard_scope
13
- return scope if normalized_includes.empty?
13
+ return scope if normalized_includes.empty? && extra_fields_includes.empty?
14
+
14
15
  self.scope = resource.handle_includes(scope, normalized_includes, normalized_sorts,
15
- with_vars: with_vars_for_sort, paginate: paginate?)
16
+ extra_fields_includes:, with_vars: with_vars_for_sort, paginate: paginate?)
16
17
  end
17
18
 
18
19
  private
@@ -23,6 +24,10 @@ module Graphiti::ActiveGraph
23
24
  @opts[:query_obj]
24
25
  end
25
26
 
27
+ def extra_fields_includes
28
+ @extra_fields_includes ||= Internal::ExtraFieldNormalizer.new(@query_hash[:extra_fields]).normalize(resource, normalized_includes)
29
+ end
30
+
26
31
  def paginate?
27
32
  Graphiti::Scoping::Paginate.new(@resource, @query_hash, scope, @opts).apply?
28
33
  end
@@ -0,0 +1,76 @@
1
+ module Graphiti::ActiveGraph
2
+ module Scoping
3
+ module Internal
4
+ class ExtraFieldNormalizer
5
+
6
+ def initialize(extra_fields)
7
+ @extra_fields = extra_fields
8
+ @extra_includes = []
9
+ end
10
+
11
+ def normalize(resource, normalized_includes)
12
+ return [] if @extra_fields.blank?
13
+
14
+ process_extra_fields_for_assoc(resource, [], '')
15
+ collect_extra_field_paths(resource, normalized_includes) unless normalized_includes.blank?
16
+ @extra_includes.uniq
17
+ end
18
+
19
+ private
20
+
21
+ def collect_extra_field_paths(resource, normalized_includes, parent_path = [])
22
+ normalized_includes.each do |assoc, nested_assoc|
23
+ assoc_resource = fetch_assoc_resource(resource, assoc)
24
+ next unless assoc_resource
25
+
26
+ process_extra_fields_for_assoc(assoc_resource, parent_path, assoc)
27
+ collect_extra_field_paths(assoc_resource, nested_assoc, parent_path + [assoc.to_s]) unless nested_assoc.empty?
28
+ end
29
+ end
30
+
31
+ def fetch_assoc_resource(resource, assoc)
32
+ rel_name = Util::Transformers::RelationParam.new(assoc).rel_name_sym
33
+ resource.class&.sideload_resource_class(rel_name)&.new
34
+ end
35
+
36
+ def process_extra_fields_for_assoc(assoc_resource, parent_path, assoc)
37
+ return unless @extra_fields.key?(assoc_resource.type)
38
+
39
+ Array(@extra_fields[assoc_resource.type]).each do |extra_field|
40
+ add_preload_paths_for_extra_field(extra_field_config(assoc_resource, extra_field), parent_path, assoc)
41
+ end
42
+ end
43
+
44
+ def extra_field_config(assoc_resource, extra_field)
45
+ assoc_resource.class&.config&.dig(:extra_attributes, extra_field)
46
+ end
47
+
48
+ def add_preload_paths_for_extra_field(config, parent_path, assoc)
49
+ return unless config && config[:preload].present?
50
+
51
+ flatten_preload_hash(config[:preload]).each do |preload|
52
+ @extra_includes << construct_preload_path(parent_path, assoc, preload)
53
+ end
54
+ end
55
+
56
+ def flatten_preload_hash(preload, prefix = [])
57
+ case preload
58
+ when Hash
59
+ preload.flat_map { |k, v| flatten_preload_hash(v, prefix + [k.to_s]) }
60
+ when Array
61
+ preload.flat_map { |v| flatten_preload_hash(v, prefix) }
62
+ else
63
+ value = preload.to_s
64
+ return [] if value.empty?
65
+
66
+ [(prefix + [value]).join('.')]
67
+ end
68
+ end
69
+
70
+ def construct_preload_path(parent_path, assoc, preload)
71
+ (parent_path + [assoc.to_s, preload.to_s]).compact_blank.join('.')
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -43,32 +43,77 @@ module Graphiti::ActiveGraph
43
43
  end
44
44
 
45
45
  def preload_extra_fields(results)
46
- requested_extra_fields.each do |extra_field_name|
47
- next unless preload_extra_field?(extra_field_name)
46
+ @query.extra_fields.each do |type, extra_field_names|
47
+ extra_field_names.each do |name|
48
+ next unless preload_extra_field?(type, name)
48
49
 
49
- result_map = fetch_preloaded_data(extra_field_name, results)
50
- assign_preloaded_data(results, extra_field_name, result_map)
50
+ records_for_preload = collect_records_for_preload(type, results)
51
+ result_map = fetch_preloaded_data(type, name, records_for_preload)
52
+ assign_preloaded_data(records_for_preload, name, result_map)
53
+ end
51
54
  end
52
55
  end
53
56
 
54
- def requested_extra_fields
55
- @query.extra_fields[@resource.type] || []
56
- end
57
-
58
- def fetch_preloaded_data(extra_field_name, results)
59
- @resource.model.public_send(default_preload_method(extra_field_name), results.pluck(:id))
57
+ def fetch_preloaded_data(type, extra_field_name, results)
58
+ resource_for_preload(type).model.public_send(default_preload_method(extra_field_name), results.pluck(:id).uniq)
60
59
  end
61
60
 
62
61
  def assign_preloaded_data(results, extra_field_name, result_map)
63
62
  results.each { |r| r.public_send("#{extra_field_name}=", result_map[r.id]) }
64
63
  end
65
64
 
66
- def preload_extra_field?(extra_field_name)
67
- @resource.extra_attribute?(extra_field_name) && @resource.model.respond_to?(default_preload_method(extra_field_name))
65
+ def preload_extra_field?(type, extra_field_name)
66
+ resource = resource_for_preload(type)
67
+ resource && resource.extra_attribute?(extra_field_name) && resource.model.respond_to?(default_preload_method(extra_field_name))
68
+ end
69
+
70
+ def resource_for_preload(type)
71
+ return @resource if type == @resource.type
72
+
73
+ find_resource_in_included_associations(type) unless @query.sideloads.empty?
74
+ end
75
+
76
+ def find_resource_in_included_associations(type, sideload_query = @query)
77
+ sideload_query.sideloads.values.each do |sideload|
78
+ return sideload.resource if sideload.resource.type == type
79
+
80
+ resource = find_resource_in_included_associations(type, sideload)
81
+ return resource if resource
82
+ end
83
+
84
+ nil
68
85
  end
69
86
 
70
87
  def default_preload_method(extra_field_name)
71
88
  "#{PRELOAD_METHOD_PREFIX}#{extra_field_name}"
72
89
  end
90
+
91
+ def collect_records_for_preload(type, results)
92
+ base_records = resource_matches_type?(@resource, type) ? Array(results) : []
93
+ sideloaded_records = collect_sideloaded_records(Array(results), @query, type)
94
+ (base_records + sideloaded_records).flatten.compact.uniq
95
+ end
96
+
97
+ def collect_sideloaded_records(source_records, sideload_query, type)
98
+ return [] if source_records.empty? || sideload_query.sideloads.empty?
99
+
100
+ sideload_query.sideloads.flat_map do |sideload_name, nested_query|
101
+ associated_records = collect_associated_records(source_records, sideload_name)
102
+ matched_records = resource_matches_type?(nested_query.resource, type) ? associated_records : []
103
+ matched_records + collect_sideloaded_records(associated_records, nested_query, type)
104
+ end
105
+ end
106
+
107
+ def resource_matches_type?(resource, type)
108
+ resource&.type == type
109
+ end
110
+
111
+ def collect_associated_records(source_records, sideload_name)
112
+ source_records.flat_map do |parent|
113
+ next [] unless parent.respond_to?(sideload_name)
114
+
115
+ Array(parent.public_send(sideload_name)).compact
116
+ end
117
+ end
73
118
  end
74
119
  end
@@ -1,5 +1,5 @@
1
1
  module Graphiti
2
2
  module ActiveGraph
3
- VERSION = '1.2.0'
3
+ VERSION = '1.3.0'
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphiti-activegraph
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
  - Hardik Joshi
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-05-20 00:00:00.000000000 Z
10
+ date: 2025-10-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: graphiti
@@ -214,6 +214,7 @@ files:
214
214
  - lib/graphiti/active_graph/scoping/filter.rb
215
215
  - lib/graphiti/active_graph/scoping/filterable.rb
216
216
  - lib/graphiti/active_graph/scoping/include.rb
217
+ - lib/graphiti/active_graph/scoping/internal/extra_field_normalizer.rb
217
218
  - lib/graphiti/active_graph/scoping/internal/include_normalizer.rb
218
219
  - lib/graphiti/active_graph/scoping/internal/path_descriptor.rb
219
220
  - lib/graphiti/active_graph/scoping/internal/sort_normalizer.rb