graphiti-activegraph 1.1.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: c387f138fe40ab7a304c5d587a0281258e2bf4e8304d7e0f0967295adeeef6d7
4
- data.tar.gz: 6154b76796fef327e488b722d1075b05f448be9fd499e4b8525e58b3544399d0
3
+ metadata.gz: 64aa5789f68595122f2d8113a497fa4f822f262bed07cac85da0474cb1ed7d51
4
+ data.tar.gz: f95a5b41c60d54f43e245c0b0279577b69325fb777e41c526f376e7fed40f1a3
5
5
  SHA512:
6
- metadata.gz: 16cf1d53448e132be9dd3c45dc755f933f434114dc13d7414b3670becaf97c4696ba0c57e3f10075f984b60516d1286d3ccee01bad8baa4bc2bad1143ea1d1f4
7
- data.tar.gz: 69a336f08b80474be83a0d0111e74a7cfbed451e5dc3ca2dba41318e3212c0e10565f799be327fdc9a26eed111b7ef1a7a46d90f24e85151a4f0d1e719b909d9
6
+ metadata.gz: c2d65e132c567d4fcb814389e199a3b4c4c13d53f83f9604d57ddc98b7dd05573b8527eecc67e78294de9cc9e178ef7ab287ad571626f5854efcc8db30de32a2
7
+ data.tar.gz: 6fdd6b5a5ec1be222a0905bf1877761c1b81342e0d48f19d662e5ae014c3819938e5a72aaf159e65c86b7a58d173fd2e3d65f66071734e7cc90d9e4d83ea08ed
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ 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
+
15
+ ## [1.2.0] - 2025-05-20
16
+
17
+ ### Added
18
+
19
+ - **Graphiti Compatibility**: Added support for Graphiti versions up to 1.8.x, fixing previously broken sideloading behavior.
20
+
21
+ ### Fixed
22
+
23
+ - **Serializer**: Resolved an issue where polymorphic resources were not functioning correctly in serializers.
24
+
10
25
  ## [1.1.0] - 2025-05-2
11
26
 
12
27
  ### 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
 
@@ -17,7 +17,7 @@ Gem::Specification.new do |spec|
17
17
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
18
18
  spec.require_paths = ['lib']
19
19
 
20
- spec.add_dependency 'graphiti', '>= 1.6.4'
20
+ spec.add_dependency 'graphiti', '>= 1.6.4', '<= 1.8.1'
21
21
  spec.add_dependency 'activegraph', '>= 12.0.0.beta.5'
22
22
  spec.add_dependency 'parslet', '>= 2.0.0'
23
23
  spec.add_dependency 'activegraph-extensions', '>= 0.1.0'
@@ -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)
@@ -84,6 +85,10 @@ module Graphiti
84
85
  {}
85
86
  end
86
87
 
88
+ def all_models
89
+ polymorphic? ? self.class.children.map(&:model) : [model]
90
+ end
91
+
87
92
  private
88
93
 
89
94
  def scoping_class
@@ -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
@@ -5,7 +5,7 @@ module Graphiti::ActiveGraph
5
5
  module SortingAliases
6
6
  def with_vars_for_sort
7
7
  [] unless add_extra_vars_to_query?
8
- (deep_sort_keys + sort_keys) & resource.extra_attributes.keys
8
+ (deep_sort_keys + sort_keys) & resource.extra_attributes.keys - graphiti_query_vars.map(&:to_sym)
9
9
  end
10
10
 
11
11
  def add_extra_vars_to_query?
@@ -19,6 +19,16 @@ module Graphiti::ActiveGraph
19
19
  def sort_keys
20
20
  query.sorts.collect(&:keys).flatten
21
21
  end
22
+
23
+ def query
24
+ @opts[:query_obj]
25
+ end
26
+
27
+ private
28
+
29
+ def graphiti_query_vars
30
+ Graphiti.context.fetch(:with_vars, [])
31
+ end
22
32
  end
23
33
  end
24
34
  end
@@ -0,0 +1,15 @@
1
+ module Graphiti::ActiveGraph
2
+ module Serializer
3
+ def jsonapi_resource_class
4
+ if polymorphic?
5
+ "#{jsonapi_type.to_s.singularize.camelize}Resource".constantize
6
+ else
7
+ @resource.class
8
+ end
9
+ end
10
+
11
+ def resource_class_name
12
+ @_type.to_s.singularize.camelize
13
+ end
14
+ end
15
+ end
@@ -19,38 +19,101 @@ module Graphiti::ActiveGraph
19
19
  end
20
20
 
21
21
  def resolve
22
- super.tap { |results| preload_extra_fields(results) }
22
+ resolve_with_callbacks.tap { |results| preload_extra_fields(results) }
23
23
  end
24
24
 
25
25
  private
26
26
 
27
- def preload_extra_fields(results)
28
- requested_extra_fields.each do |extra_field_name|
29
- next unless preload_extra_field?(extra_field_name)
30
-
31
- result_map = fetch_preloaded_data(extra_field_name, results)
32
- assign_preloaded_data(results, extra_field_name, result_map)
27
+ def resolve_with_callbacks
28
+ if @query.zero_results?
29
+ []
30
+ else
31
+ resolved = broadcast_data { |payload|
32
+ @object = @resource.before_resolve(@object, @query)
33
+ payload[:results] = @resource.resolve(@object)
34
+ payload[:results]
35
+ }
36
+ resolved.compact!
37
+ assign_serializer(resolved)
38
+ yield resolved if block_given?
39
+ @opts[:after_resolve]&.call(resolved)
40
+ resolve_sideloads(resolved) unless @query.sideloads.empty?
41
+ resolved
33
42
  end
34
43
  end
35
44
 
36
- def requested_extra_fields
37
- @query.extra_fields[@resource.type] || []
45
+ def preload_extra_fields(results)
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)
49
+
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
54
+ end
38
55
  end
39
56
 
40
- def fetch_preloaded_data(extra_field_name, results)
41
- @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)
42
59
  end
43
60
 
44
61
  def assign_preloaded_data(results, extra_field_name, result_map)
45
62
  results.each { |r| r.public_send("#{extra_field_name}=", result_map[r.id]) }
46
63
  end
47
64
 
48
- def preload_extra_field?(extra_field_name)
49
- @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
50
85
  end
51
86
 
52
87
  def default_preload_method(extra_field_name)
53
88
  "#{PRELOAD_METHOD_PREFIX}#{extra_field_name}"
54
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
55
118
  end
56
119
  end
@@ -1,5 +1,5 @@
1
1
  module Graphiti
2
2
  module ActiveGraph
3
- VERSION = '1.1.0'
3
+ VERSION = '1.3.0'
4
4
  end
5
5
  end
@@ -24,6 +24,7 @@ Graphiti::Resource::Persistence.prepend Graphiti::ActiveGraph::Resources::Persis
24
24
  Graphiti::Resource::Interface::ClassMethods.prepend Graphiti::ActiveGraph::Resources::Interface::ClassMethods
25
25
  require 'graphiti'
26
26
  Graphiti::Scoping::Filter.prepend Graphiti::ActiveGraph::Scoping::Filter
27
+ Graphiti::Serializer.prepend Graphiti::ActiveGraph::Serializer
27
28
  Graphiti::Util::SerializerRelationship.prepend Graphiti::ActiveGraph::Util::SerializerRelationship
28
29
  Graphiti::Util::SerializerAttribute.prepend Graphiti::ActiveGraph::Util::SerializerAttribute
29
30
  Graphiti::Util::RelationshipPayload.prepend Graphiti::ActiveGraph::Util::RelationshipPayload
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.1.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-02 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
@@ -16,6 +16,9 @@ dependencies:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: 1.6.4
19
+ - - "<="
20
+ - !ruby/object:Gem::Version
21
+ version: 1.8.1
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -23,6 +26,9 @@ dependencies:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: 1.6.4
29
+ - - "<="
30
+ - !ruby/object:Gem::Version
31
+ version: 1.8.1
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: activegraph
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -208,11 +214,13 @@ files:
208
214
  - lib/graphiti/active_graph/scoping/filter.rb
209
215
  - lib/graphiti/active_graph/scoping/filterable.rb
210
216
  - lib/graphiti/active_graph/scoping/include.rb
217
+ - lib/graphiti/active_graph/scoping/internal/extra_field_normalizer.rb
211
218
  - lib/graphiti/active_graph/scoping/internal/include_normalizer.rb
212
219
  - lib/graphiti/active_graph/scoping/internal/path_descriptor.rb
213
220
  - lib/graphiti/active_graph/scoping/internal/sort_normalizer.rb
214
221
  - lib/graphiti/active_graph/scoping/internal/sorting_aliases.rb
215
222
  - lib/graphiti/active_graph/scoping/internal/sparse_fields_eagerloading.rb
223
+ - lib/graphiti/active_graph/serializer.rb
216
224
  - lib/graphiti/active_graph/sideload_resolve.rb
217
225
  - lib/graphiti/active_graph/util/parsers/rel_chain.rb
218
226
  - lib/graphiti/active_graph/util/relationship_payload.rb