graphiform 0.4.1 → 0.5.1

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: 19e01a0f9ea8fdc89179e49ae39577309e7c0db74ba008322c67aa0ca9f00a64
4
+ data.tar.gz: 5aeb7bf5df444d7bb20609dbf3bfb9653245418361c6c1a91d8186d9bad8b205
5
5
  SHA512:
6
- metadata.gz: 1ec2b1bc0fc661c2f1353b62ccf8d689c8919722a4364a5239d01e7fa35760ee17a11eb9f9a2cdda197a50cf1be3fb58559ac7d684f8a5f0d1a07bd76a3b1646
7
- data.tar.gz: 057d78c54bbe196ba1e6fb37dfbc43c2334d58683def817f2352e61361943b5843aaed46f40e2f774f28d34c33b8db56ca8786ae78550304bf5883c6c78a055e
6
+ metadata.gz: 5e128eb6bedb679a8895ad62c5f8bcfefa683e8021f93d64362f4935e6ed22556f1968c97267714d4bdeb6d21020ca76168391b3f5aea4196d009dbf00e6ec15
7
+ data.tar.gz: 35e63ac4f74add2bb905d01d38d6b377dadd5719d0ca1d80153ae4ef65a0149882c0c703d69df36e24a62b4e39f23662050c7799f549bb2f70886cdadaf7339a
@@ -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,69 @@ 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 graphiform_pending_mutex
329
+ @graphiform_pending_mutex ||= Mutex.new
330
+ end
331
+
332
+ def flush_pending_filters!
333
+ return if @graphiform_pending_filters.nil? || @graphiform_pending_filters.empty?
334
+
335
+ pending = graphiform_pending_mutex.synchronize do
336
+ drained = @graphiform_pending_filters
337
+ @graphiform_pending_filters = []
338
+ drained
339
+ end
340
+ pending.each do |(name, identifier, options)|
341
+ graphql_add_scopes_to_filter(name, identifier, **options)
342
+ end
343
+ end
344
+
345
+ def flush_pending_sorts!
346
+ return if @graphiform_pending_sorts.nil? || @graphiform_pending_sorts.empty?
347
+
348
+ pending = graphiform_pending_mutex.synchronize do
349
+ drained = @graphiform_pending_sorts
350
+ @graphiform_pending_sorts = []
351
+ drained
352
+ end
353
+ pending.each do |(name, identifier, options)|
354
+ graphql_field_to_sort(name, identifier, **options)
355
+ end
356
+ end
357
+
358
+ def flush_pending_groupings!
359
+ return if @graphiform_pending_groupings.nil? || @graphiform_pending_groupings.empty?
360
+
361
+ pending = graphiform_pending_mutex.synchronize do
362
+ drained = @graphiform_pending_groupings
363
+ @graphiform_pending_groupings = []
364
+ drained
365
+ end
366
+ pending.each do |(name, identifier, options)|
367
+ graphql_field_to_grouping(name, identifier, **options)
368
+ end
369
+ end
370
+ # ----------------------------------------------------------------------
371
+
276
372
  private
277
373
 
278
374
  def demodulized_name
@@ -280,4 +376,4 @@ module Graphiform
280
376
  end
281
377
  end
282
378
  end
283
- end
379
+ 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,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+ require 'monitor'
5
+
3
6
  module Graphiform
4
7
  module Helpers
5
8
  def self.logger
@@ -9,6 +12,68 @@ module Graphiform
9
12
  @logger
10
13
  end
11
14
 
15
+ # --- Name normalization & per-class registries -------------------------
16
+ #
17
+ # Replaces the O(n) `arguments.keys.any? { equal_graphql_names?(...) }`
18
+ # scan (and its repeated string allocations) with an O(1) Set lookup.
19
+
20
+ NAME_NORMALIZE_CACHE = {}
21
+ NAME_NORMALIZE_MUTEX = Mutex.new
22
+
23
+ # Canonicalize a name the same way graphql-ruby presents it externally,
24
+ # so `:my_field`, `"my_field"`, `"myField"`, `"MyField"` all collide.
25
+ def self.normalize_graphql_name(name)
26
+ key = name.is_a?(Symbol) ? name : name.to_s
27
+ cached = NAME_NORMALIZE_CACHE[key]
28
+ return cached if cached
29
+
30
+ NAME_NORMALIZE_MUTEX.synchronize do
31
+ NAME_NORMALIZE_CACHE[key] ||= key.to_s.camelize(:lower).freeze
32
+ end
33
+ end
34
+
35
+ # Fetch (and lazily seed) the registered-names Set for a generated
36
+ # GraphQL class. Seeding from existing `arguments` / `fields` makes this
37
+ # safe even when classes are pre-populated (e.g. `OR`/`AND` on filters,
38
+ # or manual user-defined args).
39
+ def self.tracked_names(klass)
40
+ set = klass.instance_variable_get(:@graphiform_names)
41
+ return set if set
42
+
43
+ set = Set.new
44
+
45
+ if klass.respond_to?(:own_arguments)
46
+ own_args = klass.instance_variable_get(:@own_arguments) || {}
47
+ set.merge(own_args.each_key.map { |k| normalize_graphql_name(k) })
48
+ end
49
+
50
+ if klass.respond_to?(:fields)
51
+ own_fields = klass.instance_variable_get(:@own_fields) || {}
52
+ set.merge(own_fields.each_key.map { |k| normalize_graphql_name(k) })
53
+ end
54
+
55
+ klass.instance_variable_set(:@graphiform_names, set)
56
+ end
57
+
58
+ # Guard helper: yield (which should add the field/argument) only if the
59
+ # name isn't already present. Returns true when the block ran.
60
+ def self.add_unless_exists(klass, name)
61
+ normalized = normalize_graphql_name(name)
62
+ set = tracked_names(klass)
63
+ return false if set.include?(normalized)
64
+
65
+ mutex = (klass.instance_variable_get(:@graphiform_names_mutex) ||
66
+ klass.instance_variable_set(:@graphiform_names_mutex, Monitor.new))
67
+ mutex.synchronize do
68
+ return false if set.include?(normalized)
69
+
70
+ yield
71
+ set << normalized
72
+ end
73
+ true
74
+ end
75
+ # -----------------------------------------------------------------------
76
+
12
77
  def self.graphql_type(active_record_type)
13
78
  is_array = active_record_type.is_a? Array
14
79
  active_record_type = is_array ? active_record_type[0] : active_record_type
@@ -27,23 +92,22 @@ module Graphiform
27
92
  val.ancestors.include?(GraphQL::Schema::Resolver)
28
93
  end
29
94
 
95
+ CONST_LOCKS = {}
96
+ CONST_LOCKS_MUTEX = Mutex.new
97
+
30
98
  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
99
+ return mod.const_get(const) if mod.const_defined?(const, false)
39
100
 
40
- val = yield
41
- mod.const_set(const, val)
42
- val
43
- end
101
+ key = [mod, const.to_s]
102
+ lock = CONST_LOCKS_MUTEX.synchronize { CONST_LOCKS[key] ||= Monitor.new }
103
+
104
+ lock.synchronize do
105
+ return mod.const_get(const) if mod.const_defined?(const, false)
44
106
 
45
- def self.equal_graphql_names?(key, name)
46
- key.downcase == name.to_s.camelize.downcase || key.downcase == name.to_s.downcase
107
+ val = yield
108
+ mod.const_set(const, val)
109
+ val
110
+ end
47
111
  end
48
112
 
49
113
  def self.full_const_name(name)
@@ -54,25 +118,21 @@ module Graphiform
54
118
  end
55
119
 
56
120
  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?
121
+ return false unless association_def.present?
122
+ return false unless association_def.klass.respond_to?(method)
123
+
124
+ target = association_def.klass.send(method)
125
+ return false unless target.respond_to?(:arguments)
126
+
127
+ own_args = target.instance_variable_get(:@own_arguments) || {}
128
+ !own_args.empty?
61
129
  end
62
130
 
63
- def self.dataloader_support?(dataloader, association_def, keys)
64
- (
65
- association_def.present? &&
131
+ def self.dataloader_support?(dataloader, association_def)
132
+ association_def.present? &&
66
133
  !association_def.polymorphic? &&
67
- !association_def.through_reflection? &&
68
134
  !association_def.inverse_of&.polymorphic? &&
69
- (
70
- !association_def.scope ||
71
- association_def.scope.arity.zero?
72
- ) &&
73
- !keys.is_a?(Array) &&
74
135
  !dataloader.is_a?(GraphQL::Dataloader::NullDataloader)
75
- )
76
136
  end
77
137
  end
78
- end
138
+ 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.1'.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,14 @@
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - jayce.pulsipher
8
- autorequire:
8
+ - zack.denkers
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-21 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -16,42 +16,54 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 6.1.0.rc1
19
+ version: '7.1'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '8.2'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
- version: 6.1.0.rc1
29
+ version: '7.1'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '8.2'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: graphql
29
35
  requirement: !ruby/object:Gem::Requirement
30
36
  requirements:
31
- - - "~>"
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 2.5.4
40
+ - - "<"
32
41
  - !ruby/object:Gem::Version
33
- version: '1.8'
42
+ version: '2.7'
34
43
  type: :runtime
35
44
  prerelease: false
36
45
  version_requirements: !ruby/object:Gem::Requirement
37
46
  requirements:
38
- - - "~>"
47
+ - - ">="
39
48
  - !ruby/object:Gem::Version
40
- version: '1.8'
49
+ version: 2.5.4
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.7'
41
53
  - !ruby/object:Gem::Dependency
42
54
  name: scopiform
43
55
  requirement: !ruby/object:Gem::Requirement
44
56
  requirements:
45
57
  - - ">="
46
58
  - !ruby/object:Gem::Version
47
- version: 0.3.0
59
+ version: 0.4.1
48
60
  type: :runtime
49
61
  prerelease: false
50
62
  version_requirements: !ruby/object:Gem::Requirement
51
63
  requirements:
52
64
  - - ">="
53
65
  - !ruby/object:Gem::Version
54
- version: 0.3.0
66
+ version: 0.4.1
55
67
  - !ruby/object:Gem::Dependency
56
68
  name: appraisal
57
69
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +78,34 @@ dependencies:
66
78
  - - ">="
67
79
  - !ruby/object:Gem::Version
68
80
  version: '0'
81
+ - !ruby/object:Gem::Dependency
82
+ name: minitest
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '5.20'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '5.20'
95
+ - !ruby/object:Gem::Dependency
96
+ name: rake
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
69
109
  - !ruby/object:Gem::Dependency
70
110
  name: spy
71
111
  requirement: !ruby/object:Gem::Requirement
@@ -108,9 +148,9 @@ dependencies:
108
148
  - - ">="
109
149
  - !ruby/object:Gem::Version
110
150
  version: '0'
111
- description:
112
151
  email:
113
152
  - jayce.pulsipher@3-form.com
153
+ - zack.denkers@3-form.com
114
154
  executables: []
115
155
  extensions: []
116
156
  extra_rdoc_files: []
@@ -120,10 +160,11 @@ files:
120
160
  - Rakefile
121
161
  - lib/graphiform.rb
122
162
  - lib/graphiform/active_record_helpers.rb
123
- - lib/graphiform/association_source.rb
124
163
  - lib/graphiform/core.rb
125
164
  - lib/graphiform/fields.rb
126
165
  - lib/graphiform/helpers.rb
166
+ - lib/graphiform/preloader_source.rb
167
+ - lib/graphiform/scope_composer.rb
127
168
  - lib/graphiform/skeleton.rb
128
169
  - lib/graphiform/sort_enum.rb
129
170
  - lib/graphiform/version.rb
@@ -132,7 +173,6 @@ homepage: https://github.com/3-form/graphiform
132
173
  licenses:
133
174
  - MIT
134
175
  metadata: {}
135
- post_install_message:
136
176
  rdoc_options: []
137
177
  require_paths:
138
178
  - lib
@@ -140,15 +180,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
140
180
  requirements:
141
181
  - - ">="
142
182
  - !ruby/object:Gem::Version
143
- version: '0'
183
+ version: '3.1'
144
184
  required_rubygems_version: !ruby/object:Gem::Requirement
145
185
  requirements:
146
186
  - - ">="
147
187
  - !ruby/object:Gem::Version
148
188
  version: '0'
149
189
  requirements: []
150
- rubygems_version: 3.3.5
151
- signing_key:
190
+ rubygems_version: 3.6.9
152
191
  specification_version: 4
153
192
  summary: Generate GraphQL types, inputs, resolvers, queries, and connections based
154
193
  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