parse-stack-next 4.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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,445 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "set"
5
+
6
+ module Parse
7
+ # Exception raised when N+1 query is detected in strict mode
8
+ class NPlusOneQueryError < StandardError
9
+ attr_reader :source_class, :association, :target_class, :count, :location
10
+
11
+ def initialize(source_class, association, target_class, count, location = nil)
12
+ @source_class = source_class
13
+ @association = association
14
+ @target_class = target_class
15
+ @count = count
16
+ @location = location
17
+
18
+ message = "N+1 query detected on #{source_class}.#{association} " \
19
+ "(#{count} separate fetches for #{target_class})"
20
+ message += " at #{location}" if location
21
+ message += ". Use `.includes(:#{association})` to eager-load this association."
22
+ super(message)
23
+ end
24
+ end
25
+
26
+ # Detects N+1 query patterns when accessing associations.
27
+ #
28
+ # N+1 queries occur when you load a collection of objects and then
29
+ # access an association on each object individually, triggering a
30
+ # separate query for each. This is inefficient and can be avoided
31
+ # by using includes() to eager-load the associations.
32
+ #
33
+ # @example Detecting N+1 queries (warn mode - default)
34
+ # Parse.n_plus_one_mode = :warn
35
+ #
36
+ # songs = Song.all(limit: 100)
37
+ # songs.each do |song|
38
+ # song.artist.name # Warning: N+1 query detected on Song.artist
39
+ # end
40
+ #
41
+ # @example Strict mode for CI/tests
42
+ # Parse.n_plus_one_mode = :raise
43
+ #
44
+ # songs = Song.all(limit: 100)
45
+ # songs.each do |song|
46
+ # song.artist.name # Raises Parse::NPlusOneQueryError
47
+ # end
48
+ #
49
+ # @example Avoiding N+1 with includes
50
+ # songs = Song.all(limit: 100, includes: [:artist])
51
+ # songs.each do |song|
52
+ # song.artist.name # No warning - artist was eager-loaded
53
+ # end
54
+ #
55
+ class NPlusOneDetector
56
+ # Default time window in seconds to track related fetches
57
+ DEFAULT_DETECTION_WINDOW = 2.0
58
+
59
+ # Default minimum number of fetches to trigger a warning
60
+ DEFAULT_FETCH_THRESHOLD = 3
61
+
62
+ # Default cleanup interval in seconds
63
+ DEFAULT_CLEANUP_INTERVAL = 60.0
64
+
65
+ # Thread-local storage key for tracking
66
+ TRACKING_KEY = :parse_n_plus_one_tracking
67
+
68
+ # Thread-local key for last cleanup time
69
+ CLEANUP_KEY = :parse_n_plus_one_last_cleanup
70
+
71
+ # Thread-local key for source registry (maps object_id to source info)
72
+ SOURCE_REGISTRY_KEY = :parse_n_plus_one_source_registry
73
+
74
+ # Valid modes for N+1 detection
75
+ VALID_MODES = [:warn, :raise, :ignore].freeze
76
+
77
+ # Thread-local key for mode
78
+ MODE_KEY = :parse_n_plus_one_mode
79
+
80
+ class << self
81
+ # Configurable thresholds
82
+ # @return [Float] time window in seconds to track related fetches
83
+ attr_writer :detection_window
84
+
85
+ # @return [Integer] minimum number of fetches to trigger a warning
86
+ attr_writer :fetch_threshold
87
+
88
+ # @return [Float] how often to run cleanup in seconds
89
+ attr_writer :cleanup_interval
90
+
91
+ def detection_window
92
+ @detection_window || DEFAULT_DETECTION_WINDOW
93
+ end
94
+
95
+ def fetch_threshold
96
+ @fetch_threshold || DEFAULT_FETCH_THRESHOLD
97
+ end
98
+
99
+ def cleanup_interval
100
+ @cleanup_interval || DEFAULT_CLEANUP_INTERVAL
101
+ end
102
+
103
+ # Register a source (class and association) for a pointer object.
104
+ # This uses the object's Ruby object_id as a key in a thread-local registry,
105
+ # avoiding the need to set instance variables on foreign objects.
106
+ #
107
+ # @param pointer [Parse::Pointer] the pointer object
108
+ # @param source_class [String] the class where the pointer was accessed
109
+ # @param association [Symbol] the association name
110
+ def register_source(pointer, source_class:, association:)
111
+ return unless pointer && enabled?
112
+ registry = get_source_registry
113
+ registry[pointer.object_id] = {
114
+ source_class: source_class,
115
+ association: association,
116
+ registered_at: Time.now.to_f,
117
+ }
118
+ end
119
+
120
+ # Look up the source info for a pointer object.
121
+ #
122
+ # @param pointer [Parse::Pointer] the pointer object
123
+ # @return [Hash, nil] the source info or nil if not found
124
+ def lookup_source(pointer)
125
+ return nil unless pointer
126
+ registry = get_source_registry
127
+ registry[pointer.object_id]
128
+ end
129
+
130
+ # Clear the source registry (called during reset)
131
+ def clear_source_registry!
132
+ Thread.current[SOURCE_REGISTRY_KEY] = nil
133
+ end
134
+
135
+ # Get the current N+1 detection mode
136
+ # @return [Symbol] :warn, :raise, or :ignore
137
+ def mode
138
+ Thread.current[MODE_KEY] || :ignore
139
+ end
140
+
141
+ # Set the N+1 detection mode
142
+ # @param value [Symbol] :warn, :raise, or :ignore
143
+ # @raise [ArgumentError] if an invalid mode is provided
144
+ def mode=(value)
145
+ value = value.to_sym if value.respond_to?(:to_sym)
146
+ unless VALID_MODES.include?(value)
147
+ raise ArgumentError, "Invalid N+1 mode: #{value.inspect}. Valid modes: #{VALID_MODES.join(", ")}"
148
+ end
149
+ Thread.current[MODE_KEY] = value
150
+ reset! if value == :ignore
151
+ end
152
+
153
+ # Whether N+1 detection is enabled (not in ignore mode)
154
+ # @return [Boolean]
155
+ def enabled?
156
+ mode != :ignore
157
+ end
158
+
159
+ # Enable or disable N+1 detection for the current thread
160
+ # @param value [Boolean] true enables :warn mode, false sets :ignore mode
161
+ def enabled=(value)
162
+ self.mode = value ? :warn : :ignore
163
+ end
164
+
165
+ # Reset all tracking data
166
+ def reset!
167
+ Thread.current[TRACKING_KEY] = nil
168
+ clear_source_registry!
169
+ end
170
+
171
+ # Track an autofetch event for N+1 detection.
172
+ #
173
+ # @param source_class [String] the class name where the fetch originated
174
+ # @param association [Symbol] the association being accessed
175
+ # @param target_class [String] the class being fetched
176
+ # @param object_id [String] the ID of the object being fetched
177
+ def track_autofetch(source_class:, association:, target_class:, object_id:)
178
+ return unless enabled?
179
+
180
+ tracking = get_tracking
181
+ key = "#{source_class}.#{association}"
182
+ now = Time.now.to_f
183
+
184
+ # Periodically clean up stale tracking entries to prevent memory leaks
185
+ # in long-running threads (e.g., Puma, Sidekiq thread pools)
186
+ cleanup_stale_entries(tracking, now)
187
+
188
+ # Initialize or update tracking for this association
189
+ tracking[key] ||= { fetches: [], warned: false, target_class: target_class }
190
+ data = tracking[key]
191
+
192
+ # Remove stale entries outside the detection window
193
+ data[:fetches] = data[:fetches].select { |t| now - t < detection_window }
194
+
195
+ # Add this fetch
196
+ data[:fetches] << now
197
+
198
+ # Check if we've exceeded the threshold and haven't warned yet
199
+ if data[:fetches].size >= fetch_threshold && !data[:warned]
200
+ data[:warned] = true
201
+ emit_warning(source_class, association, target_class, data[:fetches].size)
202
+ end
203
+ end
204
+
205
+ # Emit an N+1 warning or raise an error based on the current mode.
206
+ #
207
+ # @param source_class [String] the class where the N+1 originated
208
+ # @param association [Symbol] the association causing the N+1
209
+ # @param target_class [String] the class being fetched repeatedly
210
+ # @param count [Integer] the number of fetches detected
211
+ def emit_warning(source_class, association, target_class, count)
212
+ location = find_user_code_location
213
+
214
+ # Call registered callbacks regardless of mode
215
+ callbacks.each { |cb| cb.call(source_class, association, target_class, count, location) }
216
+
217
+ case mode
218
+ when :raise
219
+ raise NPlusOneQueryError.new(source_class, association, target_class, count, location)
220
+ when :warn
221
+ message = "[Parse::N+1] Warning: N+1 query detected on #{source_class}.#{association} " \
222
+ "(#{count} separate fetches for #{target_class})"
223
+
224
+ if location
225
+ message += "\n Location: #{location}"
226
+ end
227
+
228
+ message += "\n Suggestion: Use `.includes(:#{association})` to eager-load this association"
229
+
230
+ # Output warning
231
+ if logger
232
+ logger.warn(message)
233
+ else
234
+ warn(message)
235
+ end
236
+ # :ignore mode does nothing (but callbacks still run)
237
+ end
238
+ end
239
+
240
+ # Register a callback to be called when N+1 is detected.
241
+ # Useful for custom logging or metrics.
242
+ #
243
+ # @yield [source_class, association, target_class, count, location]
244
+ def on_n_plus_one(&block)
245
+ callbacks << block if block_given?
246
+ end
247
+
248
+ # Clear all registered callbacks
249
+ def clear_callbacks!
250
+ @callbacks = []
251
+ end
252
+
253
+ # Get registered callbacks
254
+ # @return [Array<Proc>]
255
+ def callbacks
256
+ @callbacks ||= []
257
+ end
258
+
259
+ # Set a custom logger
260
+ # @param value [Logger, nil]
261
+ attr_writer :logger
262
+
263
+ # Get the configured logger
264
+ # @return [Logger, nil]
265
+ def logger
266
+ @logger
267
+ end
268
+
269
+ # Get summary statistics of detected N+1 patterns
270
+ # @return [Hash] summary of N+1 detections
271
+ def summary
272
+ tracking = get_tracking
273
+ {
274
+ patterns_detected: tracking.count { |_, v| v[:warned] },
275
+ associations: tracking.map { |k, v| { pattern: k, fetches: v[:fetches].size, warned: v[:warned] } },
276
+ }
277
+ end
278
+
279
+ private
280
+
281
+ def get_tracking
282
+ Thread.current[TRACKING_KEY] ||= {}
283
+ end
284
+
285
+ # Clean up stale tracking entries to prevent memory leaks in thread pools.
286
+ # Removes entries that have no recent fetches and have already warned.
287
+ # Runs at most once per cleanup_interval to minimize overhead.
288
+ def cleanup_stale_entries(tracking, now)
289
+ last_cleanup = Thread.current[CLEANUP_KEY] || 0
290
+ return if now - last_cleanup < cleanup_interval
291
+
292
+ Thread.current[CLEANUP_KEY] = now
293
+
294
+ # Remove entries that are stale (no recent fetches) and have already warned
295
+ tracking.delete_if do |_key, data|
296
+ # Clean up old timestamps first
297
+ data[:fetches] = data[:fetches].select { |t| now - t < detection_window }
298
+ # Remove if empty and already warned (pattern is stale)
299
+ data[:fetches].empty? && data[:warned]
300
+ end
301
+
302
+ # Also clean up stale source registry entries
303
+ cleanup_source_registry(now)
304
+ end
305
+
306
+ def get_source_registry
307
+ Thread.current[SOURCE_REGISTRY_KEY] ||= {}
308
+ end
309
+
310
+ # Clean up old source registry entries to prevent memory leaks.
311
+ # Removes entries older than the detection window.
312
+ def cleanup_source_registry(now)
313
+ registry = get_source_registry
314
+ registry.delete_if do |_object_id, data|
315
+ now - data[:registered_at] > detection_window
316
+ end
317
+ end
318
+
319
+ # Find the location in user code where the N+1 originated.
320
+ # Filters out parse-stack internal frames to show relevant user code.
321
+ def find_user_code_location
322
+ caller_locations.each do |loc|
323
+ path = loc.path.to_s
324
+ # Skip internal parse-stack code
325
+ next if path.include?("/lib/parse/")
326
+ next if path.include?("/gems/")
327
+ next if path.include?("ruby/") || path.include?("<internal")
328
+
329
+ return "#{loc.path}:#{loc.lineno} in `#{loc.label}`"
330
+ end
331
+ nil
332
+ end
333
+ end
334
+ end
335
+
336
+ # Module-level configuration for N+1 detection
337
+ class << self
338
+ # Set the N+1 detection mode.
339
+ #
340
+ # @example Different modes
341
+ # Parse.n_plus_one_mode = :warn # Log warnings (default when enabled)
342
+ # Parse.n_plus_one_mode = :raise # Raise NPlusOneQueryError (for CI/tests)
343
+ # Parse.n_plus_one_mode = :ignore # Disable detection
344
+ #
345
+ # @param value [Symbol] :warn, :raise, or :ignore
346
+ def n_plus_one_mode=(value)
347
+ NPlusOneDetector.mode = value
348
+ end
349
+
350
+ # Get the current N+1 detection mode.
351
+ # @return [Symbol] :warn, :raise, or :ignore
352
+ def n_plus_one_mode
353
+ NPlusOneDetector.mode
354
+ end
355
+
356
+ # Enable or disable N+1 query detection.
357
+ # When enabled, warnings are emitted when N+1 patterns are detected.
358
+ # For more control, use {#n_plus_one_mode=} instead.
359
+ #
360
+ # @example Enable N+1 detection
361
+ # Parse.warn_on_n_plus_one = true
362
+ #
363
+ # @param value [Boolean] true enables :warn mode, false sets :ignore mode
364
+ def warn_on_n_plus_one=(value)
365
+ NPlusOneDetector.enabled = value
366
+ end
367
+
368
+ # Check if N+1 detection is enabled.
369
+ # @return [Boolean]
370
+ def warn_on_n_plus_one
371
+ NPlusOneDetector.enabled?
372
+ end
373
+
374
+ # Alias for compatibility
375
+ alias_method :warn_on_n_plus_one?, :warn_on_n_plus_one
376
+
377
+ # Register a callback for N+1 detection events.
378
+ # Useful for custom logging or metrics collection.
379
+ # Callbacks are called regardless of mode (even in :ignore mode).
380
+ #
381
+ # @example Track N+1 patterns
382
+ # Parse.on_n_plus_one do |source, assoc, target, count, location|
383
+ # MyMetrics.increment("n_plus_one.#{source}.#{assoc}")
384
+ # end
385
+ #
386
+ # @yield [source_class, association, target_class, count, location]
387
+ def on_n_plus_one(&block)
388
+ NPlusOneDetector.on_n_plus_one(&block)
389
+ end
390
+
391
+ # Clear N+1 detection callbacks
392
+ def clear_n_plus_one_callbacks!
393
+ NPlusOneDetector.clear_callbacks!
394
+ end
395
+
396
+ # Reset N+1 detection tracking
397
+ def reset_n_plus_one_tracking!
398
+ NPlusOneDetector.reset!
399
+ end
400
+
401
+ # Get N+1 detection summary
402
+ # @return [Hash]
403
+ def n_plus_one_summary
404
+ NPlusOneDetector.summary
405
+ end
406
+
407
+ # Configure N+1 detection thresholds.
408
+ #
409
+ # @example Configure thresholds
410
+ # Parse.configure_n_plus_one do |config|
411
+ # config.detection_window = 5.0 # 5 seconds
412
+ # config.fetch_threshold = 5 # 5 fetches to trigger
413
+ # config.cleanup_interval = 120.0 # cleanup every 2 minutes
414
+ # end
415
+ #
416
+ # @yield [NPlusOneDetector] the detector class for configuration
417
+ def configure_n_plus_one
418
+ yield NPlusOneDetector if block_given?
419
+ end
420
+
421
+ # Set the N+1 detection window (time in seconds to track related fetches)
422
+ # @param value [Float]
423
+ def n_plus_one_detection_window=(value)
424
+ NPlusOneDetector.detection_window = value
425
+ end
426
+
427
+ # Get the N+1 detection window
428
+ # @return [Float]
429
+ def n_plus_one_detection_window
430
+ NPlusOneDetector.detection_window
431
+ end
432
+
433
+ # Set the N+1 fetch threshold (minimum fetches to trigger warning)
434
+ # @param value [Integer]
435
+ def n_plus_one_fetch_threshold=(value)
436
+ NPlusOneDetector.fetch_threshold = value
437
+ end
438
+
439
+ # Get the N+1 fetch threshold
440
+ # @return [Integer]
441
+ def n_plus_one_fetch_threshold
442
+ NPlusOneDetector.fetch_threshold
443
+ end
444
+ end
445
+ end
@@ -0,0 +1,104 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "active_support"
5
+ require "active_support/inflector"
6
+
7
+ module Parse
8
+
9
+ # An operation is the core part of {Parse::Constraint} when performing
10
+ # queries. It contains an operand (the Parse field) and an operator (the Parse
11
+ # operation). These combined with a value, provide you with a constraint.
12
+ #
13
+ # All operation registrations add methods to the Symbol class.
14
+ class Operation
15
+
16
+ # @!attribute operand
17
+ # The field in Parse for this operation.
18
+ # @return [Symbol]
19
+ attr_accessor :operand
20
+
21
+ # @!attribute operator
22
+ # The type of Parse operation.
23
+ # @return [Symbol]
24
+ attr_accessor :operator
25
+
26
+ class << self
27
+ # @return [Hash] a hash containing all supported Parse operations mapped
28
+ # to their {Parse::Constraint} subclass.
29
+ attr_writer :operators
30
+
31
+ def operators
32
+ @operators ||= {}
33
+ end
34
+ end
35
+
36
+ # Whether this operation is defined properly.
37
+ def valid?
38
+ !(@operand.nil? || @operator.nil? || handler.nil?)
39
+ end
40
+
41
+ # @return [Parse::Constraint] the constraint class designed to handle
42
+ # this operator.
43
+ def handler
44
+ Operation.operators[@operator] unless @operator.nil?
45
+ end
46
+
47
+ # MongoDB operators that are blocked in field names to prevent injection.
48
+ BLOCKED_FIELD_OPERATORS = %w[$where $function $accumulator $expr].freeze
49
+
50
+ # Create a new operation.
51
+ # @param field [Symbol] the name of the Parse field
52
+ # @param op [Symbol] the operator name (ex. :eq, :lt)
53
+ # @raise [ArgumentError] if the field name contains a blocked MongoDB operator.
54
+ def initialize(field, op)
55
+ self.operand = field.to_sym
56
+ self.operand = :objectId if operand == :id
57
+ validate_field_name!(operand)
58
+ self.operator = op.to_sym
59
+ end
60
+
61
+ private
62
+
63
+ # Validates that a field name does not contain MongoDB operators that could
64
+ # allow code execution or injection attacks.
65
+ def validate_field_name!(field)
66
+ field_str = field.to_s
67
+ if field_str.start_with?("$") || field_str.include?(".$")
68
+ blocked = BLOCKED_FIELD_OPERATORS.find { |op| field_str.include?(op) }
69
+ if blocked || field_str.start_with?("$")
70
+ raise ArgumentError, "Field name cannot contain MongoDB operators: #{field_str}"
71
+ end
72
+ end
73
+ end
74
+
75
+ public
76
+
77
+ # @!visibility private
78
+ def inspect
79
+ "#{operator.inspect}(#{operand.inspect})"
80
+ end
81
+
82
+ # Create a new constraint based on the handler that had
83
+ # been registered with this operation.
84
+ # @param value [Object] a value to pass to the constraint subclass.
85
+ # @return [Parse::Constraint] a constraint with this operation and value.
86
+ def constraint(value = nil)
87
+ handler.new(self, value)
88
+ end
89
+
90
+ # Register a new symbol operator method mapped to a specific {Parse::Constraint}.
91
+ def self.register(op, klass)
92
+ Operation.operators[op.to_sym] = klass
93
+ # Some operator names (e.g. :size) collide with existing Symbol methods.
94
+ # The override is intentional - the query DSL repurposes these for
95
+ # constraint building. Remove the prior definition so define_method
96
+ # does not emit "method redefined" under ruby -W.
97
+ Symbol.send(:remove_method, op) if Symbol.method_defined?(op, false)
98
+ Symbol.send :define_method, op do |value = nil|
99
+ operation = Operation.new self, op
100
+ value.nil? ? operation : operation.constraint(value)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,66 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ # This class adds support for describing ordering for Parse queries. You can
6
+ # either order by ascending (asc) or descending (desc) order.
7
+ #
8
+ # Ordering is implemented similarly to constraints in which we add
9
+ # special methods to the Symbol class. The developer can then pass one
10
+ # or an array of fields (as symbols) and call the particular ordering
11
+ # polarity (ex. _:name.asc_ would create a Parse::Order where we want
12
+ # things to be sortd by the name field in ascending order)
13
+ # For more information about the query design pattern from DataMapper
14
+ # that inspired this, see http://datamapper.org/docs/find.html'
15
+ # @example
16
+ # :name.asc # => Parse::Order by ascending :name
17
+ # :like_count.desc # => Parse::Order by descending :like_count
18
+ #
19
+ class Order
20
+ # The Parse operators to indicate ordering direction.
21
+ ORDERING = { asc: "", desc: "-" }.freeze
22
+
23
+ # @!attribute [rw] field
24
+ # @return [Symbol] the name of the field
25
+ attr_reader :field
26
+
27
+ # @!attribute [rw] direction
28
+ # The direction of the sorting. This is either `:asc` or `:desc`.
29
+ # @return [Symbol]
30
+ attr_accessor :direction
31
+
32
+ def initialize(field, order = :asc)
33
+ @field = field.to_sym || :objectId
34
+ @direction = order
35
+ end
36
+
37
+ def field=(f)
38
+ @field = f.to_sym
39
+ end
40
+
41
+ # @return [String] the sort direction
42
+ def polarity
43
+ ORDERING[@direction] || ORDERING[:asc]
44
+ end # polarity
45
+
46
+ # @return [String] the ordering as a string
47
+ def to_s
48
+ "" if @field.nil?
49
+ polarity + @field.to_s
50
+ end
51
+
52
+ # @!visibility private
53
+ def inspect
54
+ "#{@direction.to_s}(#{@field.inspect})"
55
+ end
56
+ end # Order
57
+ end
58
+
59
+ # Extension to add all the operator instance methods to the Symbol classe
60
+ class Symbol
61
+ Parse::Order::ORDERING.keys.each do |sym|
62
+ define_method(sym) do
63
+ Parse::Order.new self, sym
64
+ end
65
+ end # each
66
+ end