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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +42 -0
- data/graphiti-activegraph.gemspec +1 -1
- data/lib/graphiti/active_graph/resource.rb +6 -1
- data/lib/graphiti/active_graph/scoping/include.rb +7 -2
- data/lib/graphiti/active_graph/scoping/internal/extra_field_normalizer.rb +76 -0
- data/lib/graphiti/active_graph/scoping/internal/sorting_aliases.rb +11 -1
- data/lib/graphiti/active_graph/serializer.rb +15 -0
- data/lib/graphiti/active_graph/sideload_resolve.rb +76 -13
- data/lib/graphiti/active_graph/version.rb +1 -1
- data/lib/graphiti-activegraph.rb +1 -0
- metadata +10 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 64aa5789f68595122f2d8113a497fa4f822f262bed07cac85da0474cb1ed7d51
|
4
|
+
data.tar.gz: f95a5b41c60d54f43e245c0b0279577b69325fb777e41c526f376e7fed40f1a3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
22
|
+
resolve_with_callbacks.tap { |results| preload_extra_fields(results) }
|
23
23
|
end
|
24
24
|
|
25
25
|
private
|
26
26
|
|
27
|
-
def
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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
|
37
|
-
@query.extra_fields
|
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
|
-
|
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
|
-
|
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
|
data/lib/graphiti-activegraph.rb
CHANGED
@@ -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.
|
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-
|
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
|