graphiform 0.4.1 → 0.5.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: 0fe59e03d0206ec1a77e8c8528e5950fbc31129bba16f8a3a7fe8019cf7f6961
4
- data.tar.gz: cb4292984369688038ba1b6b4c60900c5bca472be376106442e2425f5b035e44
3
+ metadata.gz: 68d73b56aab3ca718e3b8c8521ef9e03bc4872913a8d6fe2c5b05498611495ae
4
+ data.tar.gz: f605a09353957a99960460be3c071b2f9d3558e6df61edf5b29f367c7517a117
5
5
  SHA512:
6
- metadata.gz: 1ec2b1bc0fc661c2f1353b62ccf8d689c8919722a4364a5239d01e7fa35760ee17a11eb9f9a2cdda197a50cf1be3fb58559ac7d684f8a5f0d1a07bd76a3b1646
7
- data.tar.gz: 057d78c54bbe196ba1e6fb37dfbc43c2334d58683def817f2352e61361943b5843aaed46f40e2f774f28d34c33b8db56ca8786ae78550304bf5883c6c78a055e
6
+ metadata.gz: 255499f8b9e7742cfbaf9f9cc1e7fe5df3002887e35dc527326dde57288678a1d8bf88923d1a6e6e5a6607b333badf78a90cc7ebc364c99c27365ed74bdaefa3
7
+ data.tar.gz: e793c187d70413324c6c59b557dea0f7d46e346d688ee33681f5c9f4b690e0d14568861b61fb969916ee9596062e65a9a5fd2847442ee1104ab303ab0b103abb
@@ -3,7 +3,8 @@
3
3
  require 'active_support/concern'
4
4
 
5
5
  require 'graphiform/helpers'
6
- require 'graphiform/association_source'
6
+ require 'graphiform/preloader_source'
7
+ require 'graphiform/scope_composer'
7
8
 
8
9
  module Graphiform
9
10
  module Core
@@ -43,49 +44,87 @@ module Graphiform
43
44
  def graphql_filter
44
45
  unless defined? @filter
45
46
  local_demodulized_name = demodulized_name
47
+ model_class = self
46
48
  @filter = Helpers.get_const_or_create(local_demodulized_name, ::Inputs::Filters) do
47
49
  Class.new(::Inputs::Filters::BaseFilter) do
48
50
  graphql_name "#{local_demodulized_name}Filter"
51
+
52
+ define_singleton_method(:arguments) do |context = nil|
53
+ model_class.send(:flush_pending_filters!)
54
+ super(context)
55
+ end
56
+
57
+ define_singleton_method(:own_arguments) do
58
+ model_class.send(:flush_pending_filters!)
59
+ super()
60
+ end
49
61
  end
50
62
  end
51
63
  @filter.class_eval do
52
64
  argument_class Graphiform.configuration[:argument_class] if Graphiform.configuration[:argument_class].present?
53
- argument 'OR', [self], required: false
54
- argument 'AND', [self], required: false
55
65
  end
66
+ Helpers.add_unless_exists(@filter, 'OR') { @filter.class_eval { argument 'OR', [self], required: false } }
67
+ Helpers.add_unless_exists(@filter, 'AND') { @filter.class_eval { argument 'AND', [self], required: false } }
56
68
  end
57
69
 
70
+ flush_pending_filters!
58
71
  @filter
59
72
  end
60
73
 
61
74
  def graphql_sort
62
75
  unless defined? @graphql_sort
63
76
  local_demodulized_name = demodulized_name
77
+ model_class = self
64
78
  @graphql_sort = Helpers.get_const_or_create(local_demodulized_name, ::Inputs::Sorts) do
65
79
  Class.new(::Inputs::Sorts::BaseSort) do
66
80
  graphql_name "#{local_demodulized_name}Sort"
81
+
82
+ define_singleton_method(:arguments) do |context = nil|
83
+ model_class.send(:flush_pending_sorts!)
84
+ super(context)
85
+ end
86
+
87
+ define_singleton_method(:own_arguments) do
88
+ model_class.send(:flush_pending_sorts!)
89
+ super()
90
+ end
67
91
  end
68
92
  end
69
93
  @graphql_sort.class_eval do
70
94
  argument_class Graphiform.configuration[:argument_class] if Graphiform.configuration[:argument_class].present?
71
95
  end
72
96
  end
97
+
98
+ flush_pending_sorts!
73
99
  @graphql_sort
74
100
  end
75
101
 
76
102
  def graphql_grouping
77
103
  unless defined? @graphql_grouping
78
104
  local_demodulized_name = demodulized_name
105
+ model_class = self
79
106
  @graphql_grouping = Helpers.get_const_or_create(local_demodulized_name, ::Inputs::Groupings) do
80
107
  Class.new(::Inputs::Groupings::BaseGrouping) do
81
108
  graphql_name "#{local_demodulized_name}Grouping"
82
109
  argument_class Graphiform.configuration[:argument_class] if Graphiform.configuration[:argument_class].present?
110
+
111
+ define_singleton_method(:arguments) do |context = nil|
112
+ model_class.send(:flush_pending_groupings!)
113
+ super(context)
114
+ end
115
+
116
+ define_singleton_method(:own_arguments) do
117
+ model_class.send(:flush_pending_groupings!)
118
+ super()
119
+ end
83
120
  end
84
121
  end
85
122
  @graphql_grouping.class_eval do
86
123
  argument_class Graphiform.configuration[:argument_class] if Graphiform.configuration[:argument_class].present?
87
124
  end
88
125
  end
126
+
127
+ flush_pending_groupings!
89
128
  @graphql_grouping
90
129
  end
91
130
 
@@ -137,11 +176,7 @@ module Graphiform
137
176
  end
138
177
 
139
178
  def apply_built_ins(where: nil, sort: nil, group: nil, **)
140
- @value = @value.apply_filters(where.to_h) if where.present? && @value.respond_to?(:apply_filters)
141
- @value = @value.apply_sorts(sort.to_h) if sort.present? && @value.respond_to?(:apply_sorts)
142
- @value = @value.apply_groupings(group.to_h) if group.present? && @value.respond_to?(:apply_groupings)
143
-
144
- @value
179
+ @value = Graphiform::ScopeComposer.compose(@value, where: where, sort: sort, group: group)
145
180
  end
146
181
 
147
182
  # Default resolver - meant to be overridden
@@ -164,8 +199,8 @@ module Graphiform
164
199
  end
165
200
 
166
201
  argument :where, local_graphql_filter, required: false
167
- argument :sort, local_graphql_sort, required: false unless local_graphql_sort.arguments.empty?
168
- argument :group, local_graphql_grouping, required: false unless local_graphql_grouping.arguments.empty?
202
+ argument :sort, local_graphql_sort, required: false unless local_graphql_sort.own_arguments.empty?
203
+ argument :group, local_graphql_grouping, required: false unless local_graphql_grouping.own_arguments.empty?
169
204
  end
170
205
  end
171
206
 
@@ -201,7 +236,7 @@ module Graphiform
201
236
  end
202
237
  end
203
238
 
204
- def graphql_create_resolver(method_name, resolver_type = graphql_type, read_prepare: nil, read_resolve: nil, null: true, skip_dataloader: false, case_sensitive: Graphiform.configuration[:case_sensitive], **)
239
+ def graphql_create_resolver(method_name, resolver_type = graphql_type, read_prepare: nil, read_resolve: nil, null: true, skip_dataloader: false, **)
205
240
  Class.new(graphql_base_resolver) do
206
241
  type resolver_type, null: null
207
242
 
@@ -212,7 +247,7 @@ module Graphiform
212
247
 
213
248
  skip_dataloader ||=
214
249
  !association_def ||
215
- !Helpers.dataloader_support?(dataloader, association_def, association_def.join_foreign_key) ||
250
+ !Helpers.dataloader_support?(dataloader, association_def) ||
216
251
  read_resolve ||
217
252
  read_prepare ||
218
253
  args[:group]
@@ -226,35 +261,33 @@ module Graphiform
226
261
  else
227
262
  dataloader
228
263
  .with(
229
- AssociationSource,
230
- association_def.klass,
231
- association_def.join_primary_key,
264
+ Graphiform::PreloaderSource,
265
+ association_def.name,
266
+ klass: association_def.klass,
232
267
  scope: association_def.scope,
233
268
  where: args[:where],
234
269
  sort: args[:sort],
235
- multi: true,
236
- case_sensitive: case_sensitive,
237
- )
238
- .load(
239
- @value.public_send(association_def.join_foreign_key)
240
270
  )
271
+ .load(@value)
241
272
  end
242
273
  end
243
274
  end
244
275
  end
245
276
 
246
- def graphql_create_association_resolver(association_def, resolver_type, null: true, skip_dataloader: false, case_sensitive: nil, **)
277
+ def graphql_create_association_resolver(association_def, resolver_type, null: true, skip_dataloader: false, **)
247
278
  Class.new(::Resolvers::BaseResolver) do
248
279
  type resolver_type, null: null
249
280
 
250
281
  define_method :resolve do |*|
251
-
252
- skip_dataloader ||= !Helpers.dataloader_support?(dataloader, association_def, association_def.join_foreign_key)
253
-
282
+ skip_dataloader ||= !Helpers.dataloader_support?(dataloader, association_def)
254
283
  return object.public_send(association_def.name) if skip_dataloader
255
284
 
256
- value = object.public_send(association_def.join_foreign_key)
257
- dataloader.with(AssociationSource, association_def.klass, association_def.join_primary_key, scope: association_def.scope, case_sensitive: case_sensitive).load(value)
285
+ dataloader.with(
286
+ Graphiform::PreloaderSource,
287
+ association_def.name,
288
+ klass: association_def.klass,
289
+ scope: association_def.scope
290
+ ).load(object)
258
291
  end
259
292
  end
260
293
  end
@@ -273,6 +306,56 @@ module Graphiform
273
306
  end
274
307
  end
275
308
 
309
+ # --- Pending queues for lazy filter/sort/grouping wiring -------------
310
+ #
311
+ # graphql_readable_field pushes entries here instead of immediately
312
+ # invoking add_scope_def_to_filter / graphql_field_to_sort /
313
+ # graphql_field_to_grouping. The queues are drained the first time
314
+ # the corresponding builder is accessed.
315
+
316
+ def graphiform_pending_filters
317
+ @graphiform_pending_filters ||= []
318
+ end
319
+
320
+ def graphiform_pending_sorts
321
+ @graphiform_pending_sorts ||= []
322
+ end
323
+
324
+ def graphiform_pending_groupings
325
+ @graphiform_pending_groupings ||= []
326
+ end
327
+
328
+ def flush_pending_filters!
329
+ return if @graphiform_pending_filters.nil? || @graphiform_pending_filters.empty?
330
+
331
+ pending = @graphiform_pending_filters
332
+ @graphiform_pending_filters = []
333
+ pending.each do |(name, identifier, options)|
334
+ graphql_add_scopes_to_filter(name, identifier, **options)
335
+ end
336
+ end
337
+
338
+ def flush_pending_sorts!
339
+ return if @graphiform_pending_sorts.nil? || @graphiform_pending_sorts.empty?
340
+
341
+ pending = @graphiform_pending_sorts
342
+ @graphiform_pending_sorts = []
343
+ pending.each do |(name, identifier, options)|
344
+ graphql_field_to_sort(name, identifier, **options)
345
+ end
346
+ end
347
+
348
+ def flush_pending_groupings!
349
+ return if @graphiform_pending_groupings.nil? || @graphiform_pending_groupings.empty?
350
+
351
+ pending = @graphiform_pending_groupings
352
+ @graphiform_pending_groupings = []
353
+ pending.each do |(name, identifier, options)|
354
+ graphql_field_to_grouping(name, identifier, **options)
355
+ end
356
+ end
357
+ # ----------------------------------------------------------------------
358
+
276
359
  private
277
360
 
278
361
  def demodulized_name
@@ -280,4 +363,4 @@ module Graphiform
280
363
  end
281
364
  end
282
365
  end
283
- end
366
+ end
@@ -22,9 +22,11 @@ module Graphiform
22
22
  graphql_add_association_field(name, association_def, read_prepare: read_prepare, null: null, as: as, **options) if association_def.present?
23
23
  graphql_add_method_field(name, read_prepare: read_prepare, null: null, as: as, **options) unless column_def.present? || association_def.present?
24
24
 
25
- graphql_add_scopes_to_filter(name, identifier, **options)
26
- graphql_field_to_sort(name, identifier, **options)
27
- graphql_field_to_grouping(name, identifier, **options)
25
+ # Defer filter/sort/grouping wiring — flushed on first access to
26
+ # graphql_filter / graphql_sort / graphql_grouping.
27
+ graphiform_pending_filters << [name, identifier, options]
28
+ graphiform_pending_sorts << [name, identifier, options]
29
+ graphiform_pending_groupings << [name, identifier, options]
28
30
  end
29
31
 
30
32
  def graphql_writable_field(
@@ -34,7 +36,6 @@ module Graphiform
34
36
  write_prepare: nil,
35
37
  prepare: nil,
36
38
  description: nil,
37
- default_value: ::GraphQL::Schema::Argument::NO_DEFAULT,
38
39
  as: nil,
39
40
  **args
40
41
  )
@@ -49,19 +50,19 @@ module Graphiform
49
50
 
50
51
  prepare = write_prepare || prepare
51
52
 
52
- graphql_input.class_eval do
53
- argument(
54
- argument_name,
55
- argument_type,
56
- required: required,
57
- prepare: prepare,
58
- description: description,
59
- default_value: default_value,
60
- as: as,
61
- method_access: false,
62
- **args
63
- )
64
- end unless graphql_input.arguments.keys.any? { |key| Helpers.equal_graphql_names?(key, argument_name) }
53
+ Helpers.add_unless_exists(graphql_input, argument_name) do
54
+ graphql_input.class_eval do
55
+ argument(
56
+ argument_name,
57
+ argument_type,
58
+ required: required,
59
+ prepare: prepare,
60
+ description: description,
61
+ as: as,
62
+ **args
63
+ )
64
+ end
65
+ end
65
66
  end
66
67
 
67
68
  def graphql_field(
@@ -152,16 +153,17 @@ module Graphiform
152
153
  argument_name = "#{argument_prefix}#{argument_attribute}#{argument_suffix}".underscore
153
154
  scope_name = scope_def.name
154
155
 
155
- graphql_filter.class_eval do
156
- argument(
157
- argument_name,
158
- argument_type,
159
- required: false,
160
- as: scope_name,
161
- method_access: false,
162
- **options
163
- )
164
- end unless graphql_filter.arguments.keys.any? { |key| Helpers.equal_graphql_names?(key, argument_name) }
156
+ Helpers.add_unless_exists(graphql_filter, argument_name) do
157
+ graphql_filter.class_eval do
158
+ argument(
159
+ argument_name,
160
+ argument_type,
161
+ required: false,
162
+ as: scope_name,
163
+ **options
164
+ )
165
+ end
166
+ end
165
167
  end
166
168
 
167
169
  def graphql_field_to_sort(name, as, **options)
@@ -175,16 +177,17 @@ module Graphiform
175
177
 
176
178
  local_graphql_sort = graphql_sort
177
179
 
178
- local_graphql_sort.class_eval do
179
- argument(
180
- name,
181
- type,
182
- required: false,
183
- as: as,
184
- method_access: false,
185
- **options
186
- )
187
- end unless local_graphql_sort.arguments.keys.any? { |key| Helpers.equal_graphql_names?(key, name) }
180
+ Helpers.add_unless_exists(local_graphql_sort, name) do
181
+ local_graphql_sort.class_eval do
182
+ argument(
183
+ name,
184
+ type,
185
+ required: false,
186
+ as: as,
187
+ **options
188
+ )
189
+ end
190
+ end
188
191
  end
189
192
 
190
193
  def graphql_field_to_grouping(name, as, **options)
@@ -198,16 +201,17 @@ module Graphiform
198
201
 
199
202
  local_graphql_grouping = graphql_grouping
200
203
 
201
- local_graphql_grouping.class_eval do
202
- argument(
203
- name,
204
- type,
205
- required: false,
206
- as: as,
207
- method_access: false,
208
- **options
209
- )
210
- end unless local_graphql_grouping.arguments.keys.any? { |key| Helpers.equal_graphql_names?(key, name) }
204
+ Helpers.add_unless_exists(local_graphql_grouping, name) do
205
+ local_graphql_grouping.class_eval do
206
+ argument(
207
+ name,
208
+ type,
209
+ required: false,
210
+ as: as,
211
+ **options
212
+ )
213
+ end
214
+ end
211
215
  end
212
216
 
213
217
  def graphql_add_field_to_type(
@@ -239,21 +243,23 @@ module Graphiform
239
243
  field_options[:null] = null
240
244
  end
241
245
 
242
- graphql_type.class_eval do
243
- added_field = field(field_name, **field_options, **options)
246
+ Helpers.add_unless_exists(graphql_type, field_name) do
247
+ graphql_type.class_eval do
248
+ added_field = field(field_name, **field_options, **options)
244
249
 
245
- if read_prepare || read_resolve
246
- define_method(
247
- added_field.method_sym,
248
- lambda do
249
- value = read_resolve ? instance_exec(object, context, &read_resolve) : object.public_send(added_field.method_sym)
250
- value = instance_exec(value, context, &read_prepare) if read_prepare
250
+ if read_prepare || read_resolve
251
+ define_method(
252
+ added_field.method_sym,
253
+ lambda do
254
+ value = read_resolve ? instance_exec(object, context, &read_resolve) : object.public_send(added_field.method_sym)
255
+ value = instance_exec(value, context, &read_prepare) if read_prepare
251
256
 
252
- value
253
- end
254
- )
257
+ value
258
+ end
259
+ )
260
+ end
255
261
  end
256
- end unless graphql_type.fields.keys.any? { |key| Helpers.equal_graphql_names?(key, field_name) }
262
+ end
257
263
  end
258
264
 
259
265
  def graphql_add_column_field(field_name, column_def, type: nil, null: nil, as: nil, **options)
@@ -277,7 +283,6 @@ module Graphiform
277
283
  read_prepare: nil,
278
284
  read_resolve: nil,
279
285
  skip_dataloader: false,
280
- case_sensitive: Graphiform.configuration[:case_sensitive],
281
286
  **options
282
287
  )
283
288
  unless association_def.klass.respond_to?(:graphql_type)
@@ -304,8 +309,7 @@ module Graphiform
304
309
  read_prepare: read_prepare,
305
310
  read_resolve: read_resolve,
306
311
  null: false,
307
- skip_dataloader: true,
308
- case_sensitive: case_sensitive
312
+ skip_dataloader: true
309
313
  ),
310
314
  false,
311
315
  **options
@@ -321,15 +325,13 @@ module Graphiform
321
325
  read_prepare: read_prepare,
322
326
  read_resolve: read_resolve,
323
327
  null: false,
324
- skip_dataloader: skip_dataloader,
325
- case_sensitive: case_sensitive
328
+ skip_dataloader: skip_dataloader
326
329
  )
327
330
  else
328
331
  klass.graphql_create_association_resolver(
329
332
  association_def,
330
333
  klass.graphql_type,
331
- skip_dataloader: skip_dataloader,
332
- case_sensitive: case_sensitive
334
+ skip_dataloader: skip_dataloader
333
335
  )
334
336
  end
335
337
  )
@@ -354,4 +356,4 @@ module Graphiform
354
356
  end
355
357
  end
356
358
  end
357
- end
359
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module Graphiform
4
6
  module Helpers
5
7
  def self.logger
@@ -9,6 +11,62 @@ module Graphiform
9
11
  @logger
10
12
  end
11
13
 
14
+ # --- Name normalization & per-class registries -------------------------
15
+ #
16
+ # Replaces the O(n) `arguments.keys.any? { equal_graphql_names?(...) }`
17
+ # scan (and its repeated string allocations) with an O(1) Set lookup.
18
+
19
+ NAME_NORMALIZE_CACHE = {}
20
+ NAME_NORMALIZE_MUTEX = Mutex.new
21
+
22
+ # Canonicalize a name the same way graphql-ruby presents it externally,
23
+ # so `:my_field`, `"my_field"`, `"myField"`, `"MyField"` all collide.
24
+ def self.normalize_graphql_name(name)
25
+ key = name.is_a?(Symbol) ? name : name.to_s
26
+ cached = NAME_NORMALIZE_CACHE[key]
27
+ return cached if cached
28
+
29
+ NAME_NORMALIZE_MUTEX.synchronize do
30
+ NAME_NORMALIZE_CACHE[key] ||= key.to_s.camelize(:lower).freeze
31
+ end
32
+ end
33
+
34
+ # Fetch (and lazily seed) the registered-names Set for a generated
35
+ # GraphQL class. Seeding from existing `arguments` / `fields` makes this
36
+ # safe even when classes are pre-populated (e.g. `OR`/`AND` on filters,
37
+ # or manual user-defined args).
38
+ def self.tracked_names(klass)
39
+ set = klass.instance_variable_get(:@graphiform_names)
40
+ return set if set
41
+
42
+ set = Set.new
43
+
44
+ if klass.respond_to?(:own_arguments)
45
+ own_args = klass.instance_variable_get(:@own_arguments) || {}
46
+ set.merge(own_args.each_key.map { |k| normalize_graphql_name(k) })
47
+ end
48
+
49
+ if klass.respond_to?(:fields)
50
+ own_fields = klass.instance_variable_get(:@own_fields) || {}
51
+ set.merge(own_fields.each_key.map { |k| normalize_graphql_name(k) })
52
+ end
53
+
54
+ klass.instance_variable_set(:@graphiform_names, set)
55
+ end
56
+
57
+ # Guard helper: yield (which should add the field/argument) only if the
58
+ # name isn't already present. Returns true when the block ran.
59
+ def self.add_unless_exists(klass, name)
60
+ normalized = normalize_graphql_name(name)
61
+ set = tracked_names(klass)
62
+ return false if set.include?(normalized)
63
+
64
+ yield
65
+ set << normalized
66
+ true
67
+ end
68
+ # -----------------------------------------------------------------------
69
+
12
70
  def self.graphql_type(active_record_type)
13
71
  is_array = active_record_type.is_a? Array
14
72
  active_record_type = is_array ? active_record_type[0] : active_record_type
@@ -28,24 +86,13 @@ module Graphiform
28
86
  end
29
87
 
30
88
  def self.get_const_or_create(const, mod = Object)
31
- new_full_const_name = full_const_name("#{mod}::#{const}")
32
- new_full_const_name.constantize
33
- Object.const_get(new_full_const_name)
34
- rescue NameError => e
35
- unless full_const_name(e.missing_name) == new_full_const_name.to_s
36
- logger.warn "Failed to load #{e.missing_name} when loading constant #{new_full_const_name}"
37
- return Object.const_get(new_full_const_name)
38
- end
39
-
89
+ return mod.const_get(const) if mod.const_defined?(const, false)
90
+
40
91
  val = yield
41
92
  mod.const_set(const, val)
42
93
  val
43
94
  end
44
95
 
45
- def self.equal_graphql_names?(key, name)
46
- key.downcase == name.to_s.camelize.downcase || key.downcase == name.to_s.downcase
47
- end
48
-
49
96
  def self.full_const_name(name)
50
97
  name = "Object#{name}" if name.starts_with?('::')
51
98
  name = "Object::#{name}" unless name.starts_with?('Object::')
@@ -54,25 +101,21 @@ module Graphiform
54
101
  end
55
102
 
56
103
  def self.association_arguments_valid?(association_def, method)
57
- association_def.present? &&
58
- association_def.klass.respond_to?(method) &&
59
- association_def.klass.send(method).respond_to?(:arguments) &&
60
- !association_def.klass.send(method).arguments.empty?
104
+ return false unless association_def.present?
105
+ return false unless association_def.klass.respond_to?(method)
106
+
107
+ target = association_def.klass.send(method)
108
+ return false unless target.respond_to?(:arguments)
109
+
110
+ own_args = target.instance_variable_get(:@own_arguments) || {}
111
+ !own_args.empty?
61
112
  end
62
113
 
63
- def self.dataloader_support?(dataloader, association_def, keys)
64
- (
65
- association_def.present? &&
114
+ def self.dataloader_support?(dataloader, association_def)
115
+ association_def.present? &&
66
116
  !association_def.polymorphic? &&
67
- !association_def.through_reflection? &&
68
117
  !association_def.inverse_of&.polymorphic? &&
69
- (
70
- !association_def.scope ||
71
- association_def.scope.arity.zero?
72
- ) &&
73
- !keys.is_a?(Array) &&
74
118
  !dataloader.is_a?(GraphQL::Dataloader::NullDataloader)
75
- )
76
119
  end
77
120
  end
78
- end
121
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+ require 'graphql'
3
+ require 'graphql/dataloader'
4
+ require 'graphql/dataloader/source'
5
+ require 'graphql/dataloader/active_record_association_source'
6
+ require 'graphiform/scope_composer'
7
+
8
+ module Graphiform
9
+ # Loads ActiveRecord associations through the dataloader, with optional
10
+ # Scopiform-style `where`/`sort`/`includes`/`scope` composition applied to
11
+ # the relation handed to ActiveRecord::Associations::Preloader.
12
+ #
13
+ # Inherits batching semantics (including `scope.to_sql`-based batch keys),
14
+ # `assoc.loaded?` short-circuiting, polymorphic handling, and cross-source
15
+ # cache reuse from GraphQL::Dataloader::ActiveRecordAssociationSource.
16
+ #
17
+ # Usage:
18
+ #
19
+ # dataloader.with(
20
+ # Graphiform::PreloaderSource,
21
+ # :posts,
22
+ # klass: User.reflect_on_association(:posts).klass,
23
+ # scope: association_reflection.scope,
24
+ # where: args[:where],
25
+ # sort: args[:sort],
26
+ # ).load(user_record)
27
+ class PreloaderSource < ::GraphQL::Dataloader::ActiveRecordAssociationSource
28
+ # @param association_name [Symbol] the association on the parent record(s)
29
+ # @param klass [Class, nil] target model class used to build the
30
+ # composed scope. Required when any of `scope`/`where`/`sort`/`includes`
31
+ # is given; may be nil for bare preloads.
32
+ # @param scope [Proc, nil]
33
+ # @param where [Hash, nil]
34
+ # @param sort [Hash, nil]
35
+ # @param includes [Symbol, Array, Hash, nil]
36
+ def initialize(association_name, klass: nil, scope: nil, where: nil, sort: nil, includes: nil)
37
+ effective_scope = build_scope(klass, scope: scope, where: where, sort: sort, includes: includes)
38
+ super(association_name, effective_scope)
39
+ end
40
+
41
+ # Mirror initialize's scope composition so the batch key (which upstream
42
+ # derives from the scope via `scope.to_sql`) is consistent across loads
43
+ # that should batch together.
44
+ def self.batch_key_for(association_name, klass: nil, scope: nil, where: nil, sort: nil, includes: nil)
45
+ effective_scope = build_scope(klass, scope: scope, where: where, sort: sort, includes: includes)
46
+ super(association_name, effective_scope)
47
+ end
48
+
49
+ def self.build_scope(klass, scope: nil, where: nil, sort: nil, includes: nil)
50
+ return nil unless klass
51
+ return nil unless ScopeComposer.customized?(scope: scope, where: where, sort: sort, includes: includes)
52
+
53
+ ScopeComposer.compose(klass.all, scope: scope, where: where, sort: sort, includes: includes)
54
+ end
55
+
56
+ # Instance-level helper kept for symmetry / overrideability.
57
+ def build_scope(klass, **kwargs)
58
+ self.class.build_scope(klass, **kwargs)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphiform
4
+ # Builds an ActiveRecord relation by applying Graphiform/Scopiform-style
5
+ # customizations on top of a base relation.
6
+ #
7
+ # Used by both PreloaderSource (to compose the `scope:` handed to
8
+ # ActiveRecord::Associations::Preloader) and Core#apply_built_ins (to apply
9
+ # the same chain to top-level resolver relations).
10
+ module ScopeComposer
11
+ module_function
12
+
13
+ # @param relation [ActiveRecord::Relation] starting relation (e.g. `Model.all`)
14
+ # @param scope [Proc, nil] optional scope block, `instance_exec`'d on the relation
15
+ # @param where [Hash, nil] Scopiform filter hash, applied via `apply_filters`
16
+ # @param sort [Hash, nil] Scopiform sort hash, applied via `apply_sorts`
17
+ # @param includes [Symbol, Array, Hash, nil] eager-load spec passed to `includes`
18
+ # @return [ActiveRecord::Relation]
19
+ def compose(relation, scope: nil, where: nil, sort: nil, group: nil, includes: nil)
20
+ relation = relation.merge(relation.instance_exec(&scope)) if scope.respond_to?(:call)
21
+ relation = relation.includes(includes) if includes.present? && relation.respond_to?(:includes)
22
+ relation = relation.apply_filters(where.to_h) if where.present? && relation.respond_to?(:apply_filters)
23
+ relation = relation.apply_sorts(sort.to_h) if sort.present? && relation.respond_to?(:apply_sorts)
24
+ relation = relation.apply_groupings(group.to_h) if group.present? && relation.respond_to?(:apply_groupings)
25
+ relation
26
+ end
27
+
28
+ # True when any customization would actually modify the base relation.
29
+ # Used by PreloaderSource to decide whether to hand a scope to upstream
30
+ # (which disables result caching) or pass nil (which preserves it).
31
+ def customized?(scope: nil, where: nil, sort: nil, group: nil, includes: nil)
32
+ scope.respond_to?(:call) || where.present? || sort.present? || group.present? || includes.present?
33
+ end
34
+ end
35
+ end
@@ -88,5 +88,6 @@ module Graphiform
88
88
  Helpers.get_const_or_create('BaseEnum', ::Enums) do
89
89
  Class.new(::GraphQL::Schema::Enum)
90
90
  end
91
+
91
92
  end
92
93
  end
@@ -1,3 +1,3 @@
1
1
  module Graphiform
2
- VERSION = '0.4.1'.freeze
2
+ VERSION = '0.5.0'.freeze
3
3
  end
data/lib/graphiform.rb CHANGED
@@ -6,6 +6,8 @@ require 'graphiform/active_record_helpers'
6
6
  require 'graphiform/core'
7
7
  require 'graphiform/fields'
8
8
  require 'graphiform/sort_enum'
9
+ require 'graphiform/scope_composer'
10
+ require 'graphiform/preloader_source'
9
11
  class GraphiformConfigurationError < StandardError; end
10
12
 
11
13
  module Graphiform
metadata CHANGED
@@ -1,14 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphiform
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - jayce.pulsipher
8
- autorequire:
8
+ - zack.denkers
9
+ autorequire:
9
10
  bindir: bin
10
11
  cert_chain: []
11
- date: 2025-05-21 00:00:00.000000000 Z
12
+ date: 2026-05-19 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  name: activerecord
@@ -16,42 +17,54 @@ dependencies:
16
17
  requirements:
17
18
  - - ">="
18
19
  - !ruby/object:Gem::Version
19
- version: 6.1.0.rc1
20
+ version: '7.1'
21
+ - - "<"
22
+ - !ruby/object:Gem::Version
23
+ version: '8.2'
20
24
  type: :runtime
21
25
  prerelease: false
22
26
  version_requirements: !ruby/object:Gem::Requirement
23
27
  requirements:
24
28
  - - ">="
25
29
  - !ruby/object:Gem::Version
26
- version: 6.1.0.rc1
30
+ version: '7.1'
31
+ - - "<"
32
+ - !ruby/object:Gem::Version
33
+ version: '8.2'
27
34
  - !ruby/object:Gem::Dependency
28
35
  name: graphql
29
36
  requirement: !ruby/object:Gem::Requirement
30
37
  requirements:
31
- - - "~>"
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 2.5.4
41
+ - - "<"
32
42
  - !ruby/object:Gem::Version
33
- version: '1.8'
43
+ version: '2.7'
34
44
  type: :runtime
35
45
  prerelease: false
36
46
  version_requirements: !ruby/object:Gem::Requirement
37
47
  requirements:
38
- - - "~>"
48
+ - - ">="
39
49
  - !ruby/object:Gem::Version
40
- version: '1.8'
50
+ version: 2.5.4
51
+ - - "<"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.7'
41
54
  - !ruby/object:Gem::Dependency
42
55
  name: scopiform
43
56
  requirement: !ruby/object:Gem::Requirement
44
57
  requirements:
45
58
  - - ">="
46
59
  - !ruby/object:Gem::Version
47
- version: 0.3.0
60
+ version: 0.4.1
48
61
  type: :runtime
49
62
  prerelease: false
50
63
  version_requirements: !ruby/object:Gem::Requirement
51
64
  requirements:
52
65
  - - ">="
53
66
  - !ruby/object:Gem::Version
54
- version: 0.3.0
67
+ version: 0.4.1
55
68
  - !ruby/object:Gem::Dependency
56
69
  name: appraisal
57
70
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +79,34 @@ dependencies:
66
79
  - - ">="
67
80
  - !ruby/object:Gem::Version
68
81
  version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: minitest
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '5.20'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '5.20'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rake
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
69
110
  - !ruby/object:Gem::Dependency
70
111
  name: spy
71
112
  requirement: !ruby/object:Gem::Requirement
@@ -108,9 +149,10 @@ dependencies:
108
149
  - - ">="
109
150
  - !ruby/object:Gem::Version
110
151
  version: '0'
111
- description:
152
+ description:
112
153
  email:
113
154
  - jayce.pulsipher@3-form.com
155
+ - zack.denkers@3-form.com
114
156
  executables: []
115
157
  extensions: []
116
158
  extra_rdoc_files: []
@@ -120,10 +162,11 @@ files:
120
162
  - Rakefile
121
163
  - lib/graphiform.rb
122
164
  - lib/graphiform/active_record_helpers.rb
123
- - lib/graphiform/association_source.rb
124
165
  - lib/graphiform/core.rb
125
166
  - lib/graphiform/fields.rb
126
167
  - lib/graphiform/helpers.rb
168
+ - lib/graphiform/preloader_source.rb
169
+ - lib/graphiform/scope_composer.rb
127
170
  - lib/graphiform/skeleton.rb
128
171
  - lib/graphiform/sort_enum.rb
129
172
  - lib/graphiform/version.rb
@@ -132,7 +175,7 @@ homepage: https://github.com/3-form/graphiform
132
175
  licenses:
133
176
  - MIT
134
177
  metadata: {}
135
- post_install_message:
178
+ post_install_message:
136
179
  rdoc_options: []
137
180
  require_paths:
138
181
  - lib
@@ -140,15 +183,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
140
183
  requirements:
141
184
  - - ">="
142
185
  - !ruby/object:Gem::Version
143
- version: '0'
186
+ version: '3.1'
144
187
  required_rubygems_version: !ruby/object:Gem::Requirement
145
188
  requirements:
146
189
  - - ">="
147
190
  - !ruby/object:Gem::Version
148
191
  version: '0'
149
192
  requirements: []
150
- rubygems_version: 3.3.5
151
- signing_key:
193
+ rubygems_version: 3.3.27
194
+ signing_key:
152
195
  specification_version: 4
153
196
  summary: Generate GraphQL types, inputs, resolvers, queries, and connections based
154
197
  off whitelisted column and association definitions
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Graphiform
4
- class AssociationSource < GraphQL::Dataloader::Source
5
- def initialize(model, attribute, **options)
6
- super()
7
-
8
- @model = model
9
- @attribute = attribute
10
- @options = options
11
- end
12
-
13
- def fetch(values)
14
- normalized_values = normalize_values(values)
15
- records = query(normalized_values.uniq).to_a
16
- results(normalized_values, records)
17
- end
18
-
19
- def query(values)
20
- query = @model
21
- query = query.merge(@model.instance_exec(&@options[:scope])) if @options[:scope].present?
22
- query = query.where(@attribute => values)
23
-
24
- query = query.includes(@options[:includes]) if @options[:includes].present? && query.respond_to?(:includes)
25
- query = query.apply_filters(@options[:where].to_h) if @options[:where].present? && query.respond_to?(:apply_filters)
26
- query = query.apply_sorts(@options[:sort].to_h) if @options[:sort].present? && query.respond_to?(:apply_sorts)
27
-
28
- query
29
- end
30
-
31
- def normalize_value(value)
32
- value = value.downcase if !@options[:case_sensitive] && value.is_a?(String)
33
-
34
- value
35
- end
36
-
37
- def normalize_values(values)
38
- type_for_attribute = @model.type_for_attribute(@attribute) if @model.respond_to?(:type_for_attribute)
39
- values.map do |value|
40
- value = type_for_attribute.cast(value) if type_for_attribute.present?
41
- normalize_value(value)
42
- end
43
- end
44
-
45
- def results(values, records)
46
- record_attributes = records.map { |record| normalize_value(record[@attribute]) }
47
- values.map do |value|
48
- if @options[:multi]
49
- indexes = record_attributes.each_index.select { |index| record_attributes[index] == value }
50
- indexes.map { |index| index && records[index] }
51
- else
52
- index = record_attributes.index(value)
53
- index && records[index]
54
- end
55
- end
56
- end
57
- end
58
- end