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,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
|