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,406 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "model"
5
+ require_relative "geopoint"
6
+
7
+ module Parse
8
+
9
+ # This class manages the Polygon data type that Parse Server provides to
10
+ # store geographic shapes. To define a Polygon property, use the `:polygon`
11
+ # data type. A polygon must contain at least three distinct vertices.
12
+ # Each coordinate pair is in `[latitude, longitude]` order (Parse-style),
13
+ # matching {Parse::GeoPoint} and the Parse REST wire format.
14
+ #
15
+ # @example
16
+ # class Region < Parse::Object
17
+ # property :area, :polygon
18
+ # end
19
+ #
20
+ # # Three accepted constructor forms
21
+ # triangle = Parse::Polygon.new [[0, 0], [0, 1], [1, 0]]
22
+ # triangle = Parse::Polygon.new [
23
+ # Parse::GeoPoint.new(0, 0),
24
+ # Parse::GeoPoint.new(0, 1),
25
+ # Parse::GeoPoint.new(1, 0),
26
+ # ]
27
+ # copy = Parse::Polygon.new(triangle)
28
+ #
29
+ # region = Region.new
30
+ # region.area = triangle
31
+ # region.save
32
+ #
33
+ # The ring is not auto-closed by this class; Parse Server will close it
34
+ # on persist. Equality (`==`) is element-wise, so an open ring and the
35
+ # same ring with its first point repeated at the end compare as different.
36
+ class Polygon < Model
37
+ include Enumerable
38
+
39
+ # The default attributes in a Parse Polygon hash. The values are type
40
+ # hints used by the serializer; the keys are the serialized field names.
41
+ ATTRIBUTES = { __type: :string, coordinates: :array }.freeze
42
+
43
+ # The minimum number of distinct vertices required by Parse Server.
44
+ MIN_VERTICES = 3
45
+
46
+ # @return [Parse::Model::TYPE_POLYGON]
47
+ def self.parse_class; TYPE_POLYGON; end
48
+ # @return [Parse::Model::TYPE_POLYGON]
49
+ def parse_class; self.class.parse_class; end
50
+
51
+ alias_method :__type, :parse_class
52
+
53
+ # @return [Array<Array<Float>>] the polygon ring as an array of [lat, lng] pairs.
54
+ attr_reader :coordinates
55
+
56
+ # The initializer accepts an array of [lat, lng] pairs, an array of
57
+ # {Parse::GeoPoint} objects, or another {Parse::Polygon}.
58
+ # @example
59
+ # Parse::Polygon.new [[0, 0], [0, 1], [1, 0]]
60
+ # Parse::Polygon.new [Parse::GeoPoint.new(0, 0), Parse::GeoPoint.new(0, 1), Parse::GeoPoint.new(1, 0)]
61
+ # Parse::Polygon.new(other_polygon)
62
+ #
63
+ # @param value [Array, Parse::Polygon, Hash] the polygon coordinates.
64
+ def initialize(value = nil)
65
+ @coordinates = []
66
+ self.coordinates = value unless value.nil?
67
+ end
68
+
69
+ # Convenience factory accepting vertices as positional arguments.
70
+ # Each argument may be a {Parse::GeoPoint} or a `[lat, lng]` pair.
71
+ # @example
72
+ # Parse::Polygon.from_points([0, 0], [0, 1], [1, 0])
73
+ # Parse::Polygon.from_points(gp1, gp2, gp3)
74
+ # @return [Parse::Polygon]
75
+ def self.from_points(*points)
76
+ new(points)
77
+ end
78
+
79
+ # Build a {Parse::Polygon} from a GeoJSON `Polygon` geometry object.
80
+ # GeoJSON uses `[longitude, latitude]` axis order and wraps the ring
81
+ # one level deeper than Parse's wire format; this method performs both
82
+ # transformations. Accepts a closed or open outer ring; the closing
83
+ # vertex (when present and equal to the first) is preserved.
84
+ # Only the outer ring is consumed — GeoJSON inner rings (holes) are
85
+ # silently dropped because Parse Server's Polygon type does not
86
+ # support holes.
87
+ # @example
88
+ # Parse::Polygon.from_geojson("type" => "Polygon", "coordinates" => [[[-117.6, 32.8], [-117.5, 32.8], [-117.5, 32.9], [-117.6, 32.8]]])
89
+ # @param geojson [Hash] a GeoJSON Polygon geometry object.
90
+ # @return [Parse::Polygon]
91
+ # @raise [ArgumentError] if the input is not a valid GeoJSON Polygon.
92
+ def self.from_geojson(geojson)
93
+ raise ArgumentError, "[Parse::Polygon] from_geojson expects a Hash." unless geojson.is_a?(Hash)
94
+ hash = geojson.respond_to?(:symbolize_keys) ? geojson.symbolize_keys : geojson
95
+ type = hash[:type] || hash["type"]
96
+ rings = hash[:coordinates] || hash["coordinates"]
97
+ unless type.to_s == "Polygon" && rings.is_a?(Array) && rings.first.is_a?(Array)
98
+ raise ArgumentError, "[Parse::Polygon] from_geojson expects a GeoJSON Polygon with a nested coordinates array."
99
+ end
100
+ outer = rings.first
101
+ pairs = outer.map do |(lng, lat)|
102
+ raise ArgumentError, "[Parse::Polygon] GeoJSON ring entries must be [lng, lat] numeric pairs." \
103
+ unless lng.is_a?(Numeric) && lat.is_a?(Numeric)
104
+ [lat.to_f, lng.to_f]
105
+ end
106
+ new(pairs)
107
+ end
108
+
109
+ # Deep-copy the internal coordinate array so `dup` / `clone` produce a
110
+ # polygon whose mutation does not affect the original.
111
+ # @!visibility private
112
+ def initialize_copy(other)
113
+ super
114
+ @coordinates = other.coordinates.map(&:dup)
115
+ end
116
+
117
+ # Set the polygon coordinates. Accepts:
118
+ # - an Array of [lat, lng] pairs
119
+ # - an Array of {Parse::GeoPoint} objects
120
+ # - a Hash with a `coordinates` key (the Parse REST wire shape)
121
+ # - another {Parse::Polygon}
122
+ def coordinates=(value)
123
+ coords =
124
+ case value
125
+ when Parse::Polygon
126
+ # Duplicate so external mutation of the source doesn't leak in.
127
+ value.coordinates.map { |pair| pair.dup }
128
+ when Hash
129
+ hash = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
130
+ normalize_array(hash[:coordinates] || hash["coordinates"] || [])
131
+ when Array
132
+ normalize_array(value)
133
+ else
134
+ raise ArgumentError, "[Parse::Polygon] Cannot build polygon from #{value.class}: " \
135
+ "expected Array of [lat,lng] pairs, Array of Parse::GeoPoint, or Parse::Polygon."
136
+ end
137
+
138
+ @coordinates = coords
139
+ _validate
140
+ @coordinates
141
+ end
142
+
143
+ # @return [Hash] the attribute hint hash used by the JSON serializer.
144
+ def attributes
145
+ ATTRIBUTES
146
+ end
147
+
148
+ # @return [Array<Array<Float>>] the coordinates in `[[lat, lng], ...]` form.
149
+ def to_a
150
+ @coordinates.map(&:dup)
151
+ end
152
+
153
+ # @return [Hash] the Parse REST wire representation of this polygon.
154
+ def as_json(*_args)
155
+ { __type: parse_class, coordinates: @coordinates.map(&:dup) }
156
+ end
157
+
158
+ # @return [Array<Parse::GeoPoint>] the vertices as GeoPoint objects.
159
+ def geo_points
160
+ @coordinates.map { |(lat, lng)| Parse::GeoPoint.new(lat, lng) }
161
+ end
162
+
163
+ # Yield each vertex as a {Parse::GeoPoint}. Including {Enumerable} gives
164
+ # `map`, `select`, `to_a`, etc. for free.
165
+ # @yieldparam point [Parse::GeoPoint]
166
+ # @return [Enumerator] if no block is given.
167
+ def each(&block)
168
+ return enum_for(:each) unless block_given?
169
+ @coordinates.each { |(lat, lng)| yield Parse::GeoPoint.new(lat, lng) }
170
+ self
171
+ end
172
+
173
+ # The axis-aligned bounding box of the polygon as `[[min_lat, min_lng],
174
+ # [max_lat, max_lng]]`. Returns `nil` for an empty polygon.
175
+ # @return [Array<Array<Float>>, nil]
176
+ def bounds
177
+ return nil if @coordinates.empty?
178
+ lats = @coordinates.map(&:first)
179
+ lngs = @coordinates.map(&:last)
180
+ [[lats.min, lngs.min], [lats.max, lngs.max]]
181
+ end
182
+
183
+ # Planar area in degrees-squared, computed via the shoelace formula. This
184
+ # is a Cartesian approximation and is useful for relative comparison only.
185
+ # For surface-area in square meters use a proper geodesic library.
186
+ # @return [Float] non-negative planar area.
187
+ def area
188
+ return 0.0 if @coordinates.length < MIN_VERTICES
189
+ sum = 0.0
190
+ n = @coordinates.length
191
+ n.times do |i|
192
+ lat_i, lng_i = @coordinates[i]
193
+ lat_j, lng_j = @coordinates[(i + 1) % n]
194
+ sum += (lng_i * lat_j) - (lng_j * lat_i)
195
+ end
196
+ (sum.abs / 2.0)
197
+ end
198
+
199
+ # Shoelace-weighted polygon centroid in `[lat, lng]` form. Falls back to
200
+ # the vertex average when the polygon has zero area (e.g. a degenerate
201
+ # ring of collinear points). Returns `nil` for an empty polygon.
202
+ # @return [Array<Float>, nil]
203
+ def centroid
204
+ return nil if @coordinates.empty?
205
+ n = @coordinates.length
206
+ return @coordinates.first.dup if n == 1
207
+
208
+ sum_a = 0.0
209
+ sum_lat = 0.0
210
+ sum_lng = 0.0
211
+ n.times do |i|
212
+ lat_i, lng_i = @coordinates[i]
213
+ lat_j, lng_j = @coordinates[(i + 1) % n]
214
+ cross = (lng_i * lat_j) - (lng_j * lat_i)
215
+ sum_a += cross
216
+ sum_lat += (lat_i + lat_j) * cross
217
+ sum_lng += (lng_i + lng_j) * cross
218
+ end
219
+
220
+ if sum_a.abs < 1e-12
221
+ # Degenerate ring — fall back to vertex average so callers always
222
+ # get a usable point.
223
+ lat = @coordinates.map(&:first).sum / n
224
+ lng = @coordinates.map(&:last).sum / n
225
+ return [lat, lng]
226
+ end
227
+
228
+ factor = 1.0 / (3.0 * sum_a)
229
+ [sum_lat * factor, sum_lng * factor]
230
+ end
231
+
232
+ # GeoJSON (RFC 7946) representation of this polygon. GeoJSON requires
233
+ # `[longitude, latitude]` axis order (the inverse of Parse) and a closed
234
+ # ring nested one level deeper than Parse's wire format. This method
235
+ # performs both transformations so the result drops directly into
236
+ # Leaflet, Mapbox, PostGIS, and other standard GIS tools.
237
+ # @example
238
+ # polygon.to_geojson
239
+ # # => {"type" => "Polygon", "coordinates" => [[[lng, lat], [lng, lat], ...]]}
240
+ # @return [Hash] a GeoJSON `Polygon` geometry object.
241
+ def to_geojson
242
+ ring = @coordinates.map { |(lat, lng)| [lng, lat] }
243
+ # GeoJSON requires the ring to be explicitly closed.
244
+ ring << ring.first.dup if !ring.empty? && ring.first != ring.last
245
+ { "type" => "Polygon", "coordinates" => [ring] }
246
+ end
247
+
248
+ # Well-Known Text representation (`POLYGON((lng lat, lng lat, ...))`).
249
+ # The output uses `longitude latitude` axis order — matching the OGC
250
+ # WKT spec — and includes the closing vertex if not already present.
251
+ # @return [String] the WKT string, suitable for PostGIS `ST_GeomFromText`.
252
+ def to_wkt
253
+ return "POLYGON EMPTY" if @coordinates.empty?
254
+ ring = @coordinates.map { |(lat, lng)| [lng, lat] }
255
+ ring << ring.first.dup if ring.first != ring.last
256
+ "POLYGON((#{ring.map { |(lng, lat)| "#{lng} #{lat}" }.join(", ")}))"
257
+ end
258
+
259
+ # Element-wise equality. Two polygons are equal if their coordinate
260
+ # arrays match exactly. An open ring and its closed form are NOT equal,
261
+ # matching the JS SDK.
262
+ def ==(other)
263
+ return false unless other.is_a?(Parse::Polygon)
264
+ @coordinates == other.coordinates
265
+ end
266
+
267
+ # Client-side ray-casting point-in-polygon test. Mirrors
268
+ # `Parse.Polygon#containsPoint` in the JS SDK. Boundary behavior is
269
+ # not guaranteed (a point exactly on an edge may return either result).
270
+ # @param point [Parse::GeoPoint, Array<Numeric>] the point to test.
271
+ # @return [Boolean]
272
+ def contains_point?(point)
273
+ lat, lng =
274
+ case point
275
+ when Parse::GeoPoint then [point.latitude, point.longitude]
276
+ when Array then [point[0].to_f, point[1].to_f]
277
+ else
278
+ raise ArgumentError, "[Parse::Polygon] contains_point? expects a Parse::GeoPoint or [lat,lng] Array."
279
+ end
280
+
281
+ ring = @coordinates
282
+ return false if ring.size < MIN_VERTICES
283
+
284
+ inside = false
285
+ j = ring.size - 1
286
+ (0...ring.size).each do |i|
287
+ lat_i, lng_i = ring[i]
288
+ lat_j, lng_j = ring[j]
289
+ intersect = ((lng_i > lng) != (lng_j > lng)) &&
290
+ (lat < (lat_j - lat_i) * (lng - lng_i) / ((lng_j - lng_i).nonzero? || 1e-12) + lat_i)
291
+ inside = !inside if intersect
292
+ j = i
293
+ end
294
+ inside
295
+ end
296
+
297
+ # Returns `true` when the outer ring is wound counter-clockwise
298
+ # (as required by RFC 7946 / GeoJSON for exterior rings, and by
299
+ # MongoDB 8+ / Atlas for polygons used in `$geoWithin` and
300
+ # `$geoIntersects` against `2dsphere` indexes). Uses the shoelace
301
+ # signed-area test with longitude on the x-axis and latitude on the
302
+ # y-axis. Degenerate rings (fewer than {MIN_VERTICES} vertices)
303
+ # return `true` because winding is undefined.
304
+ # @return [Boolean]
305
+ def counter_clockwise?
306
+ n = @coordinates.length
307
+ return true if n < MIN_VERTICES
308
+ sum = 0.0
309
+ n.times do |i|
310
+ lat_i, lng_i = @coordinates[i]
311
+ lat_j, lng_j = @coordinates[(i + 1) % n]
312
+ sum += (lng_i * lat_j) - (lng_j * lat_i)
313
+ end
314
+ sum > 0
315
+ end
316
+
317
+ # Reverses the coordinate ring in place if it is currently wound
318
+ # clockwise so the polygon satisfies the RFC 7946 / MongoDB 8+
319
+ # counter-clockwise outer-ring requirement. Returns `self` so calls
320
+ # chain. Idempotent: calling on an already-CCW polygon is a no-op.
321
+ # @return [Parse::Polygon]
322
+ def ensure_counter_clockwise!
323
+ @coordinates.reverse! unless counter_clockwise?
324
+ self
325
+ end
326
+
327
+ # @!visibility private
328
+ def inspect
329
+ "#<Polygon #{@coordinates.inspect}>"
330
+ end
331
+
332
+ private
333
+
334
+ # @!visibility private
335
+ def normalize_array(array)
336
+ raise ArgumentError, "[Parse::Polygon] coordinates must be an Array" unless array.is_a?(Array)
337
+
338
+ array.map do |entry|
339
+ case entry
340
+ when Parse::GeoPoint
341
+ finite_pair!(entry.latitude.to_f, entry.longitude.to_f)
342
+ when Array
343
+ unless entry.length == 2 && entry[0].is_a?(Numeric) && entry[1].is_a?(Numeric)
344
+ raise ArgumentError, "[Parse::Polygon] each coordinate must be a 2-element [lat,lng] numeric pair."
345
+ end
346
+ finite_pair!(entry[0].to_f, entry[1].to_f)
347
+ when Hash
348
+ hash = entry.respond_to?(:symbolize_keys) ? entry.symbolize_keys : entry
349
+ lat = hash[:latitude] || hash[:lat] || hash["latitude"] || hash["lat"]
350
+ lng = hash[:longitude] || hash[:lng] || hash["longitude"] || hash["lng"]
351
+ raise ArgumentError, "[Parse::Polygon] coordinate hash needs latitude/longitude." if lat.nil? || lng.nil?
352
+ unless lat.is_a?(Numeric) && lng.is_a?(Numeric)
353
+ raise ArgumentError, "[Parse::Polygon] coordinate hash latitude/longitude must be numeric."
354
+ end
355
+ finite_pair!(lat.to_f, lng.to_f)
356
+ else
357
+ raise ArgumentError, "[Parse::Polygon] unsupported coordinate entry #{entry.inspect}."
358
+ end
359
+ end
360
+ end
361
+
362
+ # @!visibility private
363
+ # Reject NaN / Infinity at the door. `Float::NAN.is_a?(Numeric)` is
364
+ # true (`NaN.between?(...)` returns false silently) so the earlier
365
+ # type check is insufficient — a polygon containing NaN gets accepted
366
+ # and then errors mid-pipeline when MongoDB tries to build the
367
+ # `2dsphere` index, cascading transaction-level failures.
368
+ def finite_pair!(lat, lng)
369
+ unless lat.finite? && lng.finite?
370
+ raise ArgumentError, "[Parse::Polygon] coordinates must be finite numerics; got [#{lat}, #{lng}]."
371
+ end
372
+ [lat, lng]
373
+ end
374
+
375
+ # @!visibility private
376
+ def _validate
377
+ distinct = @coordinates.uniq
378
+ if distinct.length < MIN_VERTICES
379
+ warn "[Parse::Polygon] Polygon has #{distinct.length} distinct vertices; Parse Server requires at least #{MIN_VERTICES}."
380
+ end
381
+
382
+ # TRACK-QUERY-5: out-of-range lat/lng previously warned; now
383
+ # raises. MongoDB's `2dsphere` index rejects polygons with lat
384
+ # outside [-90, 90] or lng outside [-180, 180] at index-rebuild
385
+ # time; failing fast at construction prevents a tenant-wide
386
+ # write failure later.
387
+ @coordinates.each do |(lat, lng)|
388
+ unless lat.nil? || lat.between?(Parse::GeoPoint::LAT_MIN, Parse::GeoPoint::LAT_MAX)
389
+ raise ArgumentError, "[Parse::Polygon] Latitude (#{lat}) is not between " \
390
+ "#{Parse::GeoPoint::LAT_MIN} and #{Parse::GeoPoint::LAT_MAX}."
391
+ end
392
+ unless lng.nil? || lng.between?(Parse::GeoPoint::LNG_MIN, Parse::GeoPoint::LNG_MAX)
393
+ raise ArgumentError, "[Parse::Polygon] Longitude (#{lng}) is not between " \
394
+ "#{Parse::GeoPoint::LNG_MIN} and #{Parse::GeoPoint::LNG_MAX}."
395
+ end
396
+ end
397
+
398
+ if @coordinates.length >= MIN_VERTICES && !counter_clockwise?
399
+ warn "[Parse::Polygon] Outer ring is wound clockwise. MongoDB 8+ and " \
400
+ "Atlas reject CW outer rings for 2dsphere $geoWithin/$geoIntersects " \
401
+ "queries; call #ensure_counter_clockwise! before persisting or " \
402
+ "querying against a 2dsphere index."
403
+ end
404
+ end
405
+ end
406
+ end