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.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- 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
|