parse-stack-next 5.7.5 → 5.8.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +856 -0
  3. data/README.md +15 -4
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +190 -14
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +318 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +290 -17
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. metadata +8 -1
@@ -621,6 +621,13 @@ module Parse
621
621
  ambient = Parse.current_session_token
622
622
  client_opts[:session_token] = ambient if ambient.is_a?(String) && !ambient.empty?
623
623
  end
624
+ # Inside `Parse.with_session(nil)` the caller is anonymous. A worker
625
+ # thread may not see that fiber state, so carry it explicitly rather
626
+ # than letting the worker fall back to the master key.
627
+ if !client_opts.key?(:session_token) && !client_opts.key?(:use_master_key) &&
628
+ Parse.respond_to?(:anonymous_session?) && Parse.anonymous_session?
629
+ client_opts[:use_master_key] = false
630
+ end
624
631
 
625
632
  if type == :batch
626
633
  # use a .in query with the given id as a list
@@ -21,7 +21,7 @@ module Parse
21
21
  result = { type: v.to_s.camelize }
22
22
  # if it is a basic column property, find the right datatype
23
23
  case v
24
- when :integer, :float
24
+ when :integer, :float, :number
25
25
  result[:type] = Parse::Model::TYPE_NUMBER
26
26
  when :geopoint, :geo_point
27
27
  result[:type] = Parse::Model::TYPE_GEOPOINT
@@ -31,8 +31,10 @@ module Parse
31
31
  result = { type: Parse::Model::TYPE_POINTER, targetClass: references[k] }
32
32
  when :acl
33
33
  result[:type] = Parse::Model::ACL
34
- when :timezone, :time_zone
35
- result[:type] = "String" # no TimeZone native in Parse
34
+ when :timezone, :time_zone, :phone, :email
35
+ result[:type] = "String" # no native Parse column type
36
+ when :vector
37
+ result[:type] = "Array" # embeddings are stored as a number array
36
38
  else
37
39
  result[:type] = v.to_s.camelize
38
40
  end
@@ -104,6 +104,12 @@ module Parse
104
104
  type: type_str,
105
105
  }.freeze
106
106
 
107
+ if generated_vector_search_indexes.any? { |e| e[:name] == name_str }
108
+ raise ArgumentError,
109
+ "#{self}.mongo_search_index #{name_str.inspect} is already declared by " \
110
+ "vector_search_index. Each name may have one declaration per class."
111
+ end
112
+
107
113
  existing = mongo_search_index_declarations.find { |d| d[:name] == name_str }
108
114
  if existing
109
115
  # Idempotent redeclaration with identical content — common in
@@ -123,6 +129,63 @@ module Parse
123
129
  declaration
124
130
  end
125
131
 
132
+ # Declare a vectorSearch index whose definition is GENERATED from the
133
+ # model's declarations by {Parse::VectorSearch::IndexDefinition.build}
134
+ # (vector path, dimensions, similarity, quantization, agent_searchable
135
+ # filter fields, agent_tenant_scope field), instead of written by hand.
136
+ #
137
+ # The definition is computed when the migrator plans, not at class
138
+ # load, so `agent_searchable` / `agent_tenant_scope` may be declared in
139
+ # any order. Nothing is applied until {#apply_search_indexes!} runs.
140
+ #
141
+ # @param name [String] the search index name ({INDEX_NAME_PATTERN}).
142
+ # @param field [Symbol, nil] the `:vector` property; may be omitted
143
+ # when the class has exactly one searchable vector.
144
+ # @return [Hash] the registered `{ name:, field: }` entry (frozen).
145
+ # @raise [ArgumentError] on a bad name, or a name already declared
146
+ # with a different field or via {#mongo_search_index}.
147
+ def vector_search_index(name, field: nil)
148
+ name_str = name.to_s
149
+ unless name_str.match?(INDEX_NAME_PATTERN)
150
+ raise ArgumentError,
151
+ "#{self}.vector_search_index name #{name.inspect} must match #{INDEX_NAME_PATTERN.inspect}"
152
+ end
153
+ if mongo_search_index_declarations.any? { |d| d[:name] == name_str }
154
+ raise ArgumentError,
155
+ "#{self}.vector_search_index #{name_str.inspect} is already declared by " \
156
+ "mongo_search_index. Each name may have one declaration per class."
157
+ end
158
+ entry = { name: name_str, field: field&.to_sym }.freeze
159
+ existing = generated_vector_search_indexes.find { |e| e[:name] == name_str }
160
+ if existing
161
+ return existing if existing == entry
162
+ raise ArgumentError,
163
+ "#{self}.vector_search_index #{name_str.inspect} re-declared for a different field."
164
+ end
165
+ generated_vector_search_indexes << entry
166
+ entry
167
+ end
168
+
169
+ # @return [Array<Hash>] `{ name:, field: }` entries registered by
170
+ # {#vector_search_index}.
171
+ def generated_vector_search_indexes
172
+ @generated_vector_search_indexes ||= []
173
+ end
174
+
175
+ # Materialize {#generated_vector_search_indexes} into migrator
176
+ # declarations (`{ name:, definition:, type: "vectorSearch" }`),
177
+ # generating each definition from the current model declarations.
178
+ # @return [Array<Hash>]
179
+ def generated_vector_search_index_declarations
180
+ generated_vector_search_indexes.map do |entry|
181
+ {
182
+ name: entry[:name],
183
+ definition: Parse::VectorSearch::IndexDefinition.build(self, field: entry[:field]),
184
+ type: "vectorSearch",
185
+ }.freeze
186
+ end
187
+ end
188
+
126
189
  # Dry-run reconciliation between declared search indexes and what
127
190
  # exists on Atlas. Delegates to {Parse::Schema::SearchIndexMigrator}.
128
191
  # @return [Hash] see {Parse::Schema::SearchIndexMigrator#plan}
@@ -225,7 +225,7 @@ module Parse
225
225
 
226
226
  raw_hits = Parse::VectorSearch.search(
227
227
  parse_class,
228
- field: resolved_field,
228
+ field: vector_storage_path(resolved_field),
229
229
  query_vector: query_vector,
230
230
  k: k,
231
231
  num_candidates: num_candidates,
@@ -318,7 +318,7 @@ module Parse
318
318
  filter: lex[:filter], fuzzy: lex[:fuzzy],
319
319
  },
320
320
  vector: {
321
- query_vector: qv, field: field_sym, index: vector_index,
321
+ query_vector: qv, field: vector_storage_path(field_sym), index: vector_index,
322
322
  num_candidates: vec[:num_candidates], filter: vec[:filter],
323
323
  vector_filter: vec[:vector_filter],
324
324
  candidate_limit: vec[:candidate_limit],
@@ -332,6 +332,22 @@ module Parse
332
332
  build_hybrid_hits(fused)
333
333
  end
334
334
 
335
+ # The column a `:vector` property is stored under, which is what an
336
+ # Atlas index path and a `$vectorSearch` path must name: the property's
337
+ # `field_map` entry (an explicit `field:` alias, or the lowerCamelCase
338
+ # form of the Ruby name). `property :body_embedding, :vector` is saved
339
+ # as `bodyEmbedding`. Before 5.8 the SDK searched, discovered, and
340
+ # drift-checked indexes by the Ruby name, so a multi-word vector
341
+ # property matched no vectors.
342
+ #
343
+ # @param field [Symbol, String] the `:vector` property's Ruby name.
344
+ # @return [String]
345
+ def vector_storage_path(field)
346
+ sym = field.to_sym
347
+ fmap = respond_to?(:field_map) ? field_map : {}
348
+ (fmap[sym] || sym.to_s.columnize).to_s
349
+ end
350
+
335
351
  private
336
352
 
337
353
  def resolve_vector_field!(field)
@@ -450,7 +466,7 @@ module Parse
450
466
  "#{self}.find_similar: no index: given and Parse::AtlasSearch " \
451
467
  "could not be loaded; pass an explicit index: kwarg."
452
468
  end
453
- idx = Parse::AtlasSearch::IndexCatalog.find_vector_index(parse_class, field: field)
469
+ idx = Parse::AtlasSearch::IndexCatalog.find_vector_index(parse_class, field: vector_storage_path(field))
454
470
  if idx.nil?
455
471
  raise IndexNotResolved,
456
472
  "#{self}.find_similar: no vectorSearch index found covering " \
@@ -473,7 +489,7 @@ module Parse
473
489
  return if Parse::VectorSearch.index_drift_policy == :ignore
474
490
  begin
475
491
  require_relative "../../atlas_search"
476
- idx = Parse::AtlasSearch::IndexCatalog.find_vector_index(parse_class, field: field)
492
+ idx = Parse::AtlasSearch::IndexCatalog.find_vector_index(parse_class, field: vector_storage_path(field))
477
493
  rescue StandardError, LoadError
478
494
  return
479
495
  end
@@ -497,7 +513,9 @@ module Parse
497
513
  # property's declared `dimensions:`.
498
514
  # 2. `similarity` vs the property's declared `similarity:` (only
499
515
  # when both sides declare one).
500
- # 3. When the class registers an `agent_tenant_scope`, the scope
516
+ # 3. `quantization` vs the property's declared `quantization:`
517
+ # (absent on either side means none).
518
+ # 4. When the class registers an `agent_tenant_scope`, the scope
501
519
  # field must appear among the index's `type: "filter"` paths —
502
520
  # otherwise the tenant pre-filter that
503
521
  # {Parse::Retrieval.retrieve} folds into `$vectorSearch.filter`
@@ -530,7 +548,7 @@ module Parse
530
548
  def vector_index_drift_findings(field, idx)
531
549
  defn = idx["latestDefinition"] || idx[:latestDefinition] || {}
532
550
  entries = defn["fields"] || defn[:fields] || []
533
- field_str = field.to_s
551
+ field_str = vector_storage_path(field)
534
552
  vector_entry = entries.find do |f|
535
553
  (f["type"] || f[:type]).to_s == "vector" && (f["path"] || f[:path]).to_s == field_str
536
554
  end
@@ -552,6 +570,17 @@ module Parse
552
570
  "similarity: #{declared_sim.inspect}"
553
571
  end
554
572
 
573
+ # Quantization is compared both ways: an index quantized without a
574
+ # declaration, or a declaration the index does not carry, is drift.
575
+ # An absent value means none on either side.
576
+ declared_q = vector_properties.dig(field.to_sym, :quantization)&.to_s || "none"
577
+ index_q = (vector_entry["quantization"] || vector_entry[:quantization]).to_s
578
+ index_q = "none" if index_q.empty?
579
+ if declared_q != index_q
580
+ findings << "index quantization=#{index_q.inspect} but property declares " \
581
+ "quantization: #{declared_q == "none" ? "none" : declared_q.inspect}"
582
+ end
583
+
555
584
  scope_field = registered_tenant_scope_field
556
585
  if scope_field
557
586
  filter_paths = entries.select { |f| (f["type"] || f[:type]).to_s == "filter" }
@@ -703,7 +703,13 @@ module Parse
703
703
  # @param contents [Object]
704
704
  # @param mime_type [String] Default see default_mime_type
705
705
  def initialize(name, contents = nil, mime_type = nil)
706
- mime_type ||= Parse::File.default_mime_type
706
+ # The default mime type applies only to content this instance will
707
+ # upload. A file hydrated from a server hash or copied from another
708
+ # Parse::File describes an existing upload whose type is not known
709
+ # here, so it keeps an explicit mime_type or the source's, never a
710
+ # guessed "image/jpeg".
711
+ hydrated = name.is_a?(Hash) || name.is_a?(Parse::File)
712
+ mime_type ||= hydrated ? nil : Parse::File.default_mime_type
707
713
 
708
714
  if name.is_a?(String) && name.start_with?("http") #could be url string
709
715
  file = Parse::File.safe_open_url(name)
@@ -717,6 +723,7 @@ module Parse
717
723
  @name = File.basename name.to_path
718
724
  elsif name.is_a?(Parse::File)
719
725
  @name = name.name
726
+ @mime_type = name.mime_type
720
727
  # Route through the single URL normalization point so the copy
721
728
  # gets the same strip + stash treatment as a caller-side
722
729
  # `url=`. Preserve the source's presigned-URL stash
@@ -1088,7 +1095,7 @@ module Parse
1088
1095
  def inspect
1089
1096
  url_state = @url.present? ? "set" : "blank"
1090
1097
  "<Parse::File @name=#{@name.inspect} @mime_type=#{@mime_type.inspect} " \
1091
- "@contents=#{@contents.nil?} @url=#{url_state}>"
1098
+ "@contents=#{!@contents.nil?} @url=#{url_state}>"
1092
1099
  end
1093
1100
 
1094
1101
  # @return [String] the url
@@ -53,29 +53,68 @@ module Parse
53
53
 
54
54
  alias_method :__type, :parse_class
55
55
 
56
- # The initializer can create a GeoPoint with a hash, array or values.
56
+ # The initializer can create a GeoPoint with a hash, array, keyword
57
+ # arguments or values.
57
58
  # @example
58
59
  # san_diego = Parse::GeoPoint.new(32.8233, -117.6542)
59
60
  # san_diego = Parse::GeoPoint.new [32.8233, -117.6542]
60
- # san_diego = Parse::GeoPoint.new { latitude: 32.8233, longitude: -117.6542}
61
+ # san_diego = Parse::GeoPoint.new({ latitude: 32.8233, longitude: -117.6542 })
62
+ # san_diego = Parse::GeoPoint.new(lat: 32.8233, lng: -117.6542)
63
+ # san_diego = Parse::GeoPoint.new(latitude: 32.8233, longitude: -117.6542)
64
+ #
65
+ # Coordinates may be Numerics or numeric Strings. A value that is not a
66
+ # finite number (for example `"abc"`, `nil` inside a pair, NaN) raises
67
+ # ArgumentError rather than silently becoming 0.0, which would place the
68
+ # point at (0, 0) and corrupt geo queries.
61
69
  #
62
70
  # @param latitude [Numeric] The latitude value between LAT_MIN and LAT_MAX.
63
71
  # @param longitude [Numeric] The longitude value between LNG_MIN and LNG_MAX.
64
- def initialize(latitude = nil, longitude = nil)
72
+ # @param coords [Hash] keyword form: `lat:`/`lng:` or `latitude:`/`longitude:`.
73
+ # @raise ArgumentError if a coordinate is not a finite number.
74
+ def initialize(latitude = nil, longitude = nil, **coords)
65
75
  @latitude = @longitude = 0.0
66
- if latitude.is_a?(Hash) || latitude.is_a?(Array)
76
+ if coords.any?
77
+ unless latitude.nil? && longitude.nil?
78
+ raise ArgumentError, "[Parse::GeoPoint] pass either positional or keyword coordinates, not both."
79
+ end
80
+ self.attributes = coords
81
+ elsif latitude.is_a?(Hash) || latitude.is_a?(Array)
67
82
  self.attributes = latitude
68
- elsif latitude.is_a?(Numeric) && longitude.is_a?(Numeric)
69
- @latitude = latitude
70
- @longitude = longitude
71
83
  elsif latitude.is_a?(GeoPoint)
72
84
  @latitude = latitude.latitude
73
85
  @longitude = latitude.longitude
86
+ elsif latitude.is_a?(String) && longitude.nil?
87
+ # "lat,lng" string form.
88
+ parts = latitude.split(",")
89
+ unless parts.length == 2
90
+ raise ArgumentError, "[Parse::GeoPoint] cannot build a GeoPoint from #{latitude.inspect}."
91
+ end
92
+ self.attributes = parts
93
+ elsif !latitude.nil? || !longitude.nil?
94
+ @latitude = self.class.coerce_coordinate(latitude, :latitude)
95
+ @longitude = self.class.coerce_coordinate(longitude, :longitude)
74
96
  end
75
97
 
76
98
  _validate_point
77
99
  end
78
100
 
101
+ # Convert a single coordinate to a Float.
102
+ # @param value [Numeric, String] the coordinate.
103
+ # @param name [Symbol] the coordinate name, for the error message.
104
+ # @return [Float]
105
+ # @raise ArgumentError when the value is not a finite number.
106
+ # @!visibility private
107
+ def self.coerce_coordinate(value, name)
108
+ num = case value
109
+ when Numeric then value.to_f
110
+ when String then Float(value.strip, exception: false)
111
+ end
112
+ unless num.is_a?(Float) && num.finite?
113
+ raise ArgumentError, "[Parse::GeoPoint] #{name} must be a finite number (got #{value.inspect})."
114
+ end
115
+ num
116
+ end
117
+
79
118
  # @!visibility private
80
119
  def _validate_point
81
120
  unless @latitude.nil? || @latitude.between?(LAT_MIN, LAT_MAX)
@@ -134,15 +173,24 @@ module Parse
134
173
  end
135
174
 
136
175
  # Setting lat and lng for an GeoPoint can be done using a hash with the attributes set
137
- # or with an array of two items where the first is the lat and the second is the lng (ex. [32.22,-118.81])
176
+ # or with an array of two items where the first is the lat and the second is the lng (ex. [32.22,-118.81]).
177
+ # Hash keys may be `latitude`/`longitude` or `lat`/`lng`, as Symbols or
178
+ # Strings. A coordinate missing from the hash keeps its current value.
179
+ # @raise ArgumentError when a supplied coordinate is not a finite number,
180
+ # or an Array does not hold exactly two coordinates.
138
181
  def attributes=(h)
139
182
  if h.is_a?(Hash)
140
183
  h = h.symbolize_keys
141
- @latitude = h[:latitude].to_f || h[:lat].to_f || @latitude
142
- @longitude = h[:longitude].to_f || h[:lng].to_f || @longitude
143
- elsif h.is_a?(Array) && h.count == 2
144
- @latitude = h.first.to_f
145
- @longitude = h.last.to_f
184
+ lat = h.key?(:latitude) ? h[:latitude] : h[:lat]
185
+ lng = h.key?(:longitude) ? h[:longitude] : h[:lng]
186
+ @latitude = self.class.coerce_coordinate(lat, :latitude) unless lat.nil?
187
+ @longitude = self.class.coerce_coordinate(lng, :longitude) unless lng.nil?
188
+ elsif h.is_a?(Array)
189
+ unless h.count == 2
190
+ raise ArgumentError, "[Parse::GeoPoint] expects [latitude, longitude] (got #{h.inspect})."
191
+ end
192
+ @latitude = self.class.coerce_coordinate(h.first, :latitude)
193
+ @longitude = self.class.coerce_coordinate(h.last, :longitude)
146
194
  end
147
195
  _validate_point
148
196
  end
@@ -136,10 +136,124 @@ module Parse
136
136
  # the SDK (see Parse::ACLScope `@no_acl_warned`).
137
137
  @model_cache = {}
138
138
  @model_cache_mutex = Mutex.new
139
+ # Class names {find_class} looked up and did not find. A miss otherwise
140
+ # rescans every descendant on each call (a query on a class with no Ruby
141
+ # model hits this on every build). Cleared by {model_registry_changed!}.
142
+ @model_cache_misses = {}
143
+ MODEL_CACHE_MISSES_MAX = 10_000
144
+ # Bumped whenever the set of models, a model's `parse_class`, or a model's
145
+ # declared fields change, so derived per-class caches (such as the
146
+ # query field-alias cache) know to rebuild.
147
+ @model_generation = 0
139
148
 
140
149
  class << self
141
150
  # @!visibility private
142
- attr_reader :model_cache, :model_cache_mutex
151
+ attr_reader :model_cache, :model_cache_mutex, :model_cache_misses
152
+ # @!visibility private
153
+ # Anonymous Parse::Object descendants seen by the last missed
154
+ # {find_class} scan. Guarded by {model_cache_mutex}.
155
+ attr_accessor :model_anonymous_descendants
156
+ # @!visibility private
157
+ # @return [Integer] the current model registry generation.
158
+ attr_reader :model_generation
159
+
160
+ # @!visibility private
161
+ # Record that a model was defined, renamed with `parse_class`, or had a
162
+ # field declared. Clears the {find_class} miss cache and advances
163
+ # {model_generation}.
164
+ def model_registry_changed!
165
+ model_cache_mutex.synchronize do
166
+ # Hits are cleared too: a model redefined after `remove_const`
167
+ # (a code reload, or a test) must replace the old class, whose
168
+ # field_map and references no longer describe the table.
169
+ @model_cache = {}
170
+ @model_cache_misses = {}
171
+ @model_anonymous_descendants = nil
172
+ @model_generation += 1
173
+ end
174
+ end
175
+
176
+ # @!visibility private
177
+ # Whether `klass` is the class its name currently resolves to. A class
178
+ # left behind by `remove_const` (or one whose `name` is overridden to a
179
+ # constant that does not exist) is not live. Never triggers autoload.
180
+ #
181
+ # @param klass [Class]
182
+ # @return [Boolean]
183
+ def live_model?(klass)
184
+ name = klass.name
185
+ return false unless name.is_a?(String) && !name.empty?
186
+ scope = Object
187
+ name.split("::").each do |part|
188
+ return false if scope.autoload?(part)
189
+ return false unless scope.const_defined?(part, false)
190
+ scope = scope.const_get(part, false)
191
+ return false unless scope.is_a?(Module)
192
+ end
193
+ scope.equal?(klass)
194
+ rescue StandardError
195
+ false
196
+ end
197
+
198
+ # @!visibility private
199
+ # The one rule that turns a field name into its column for `klass`.
200
+ # Used by query compilation ({Parse::Query.format_field}) and by the
201
+ # agent field allowlist ({Parse::Agent::MetadataRegistry.wire_field_names}),
202
+ # so the name a policy check approves is the column a query addresses.
203
+ #
204
+ # Precedence:
205
+ # 1. A Ruby property name maps to its declared column (`field_map`).
206
+ # 2. Otherwise a name that is exactly a declared column stays as is.
207
+ # 3. Otherwise nil: the caller applies its default formatting.
208
+ #
209
+ # So with `property :email, field: "contactEmail"` and
210
+ # `property :legacy_email, field: "email"`, the name `email` resolves to
211
+ # `contactEmail`. Model code writes Ruby names (`Klass.query(email: x)`),
212
+ # and the Ruby name must not be redirected to another property's column.
213
+ #
214
+ # @param klass [Class] a model class (anything responding to field_map).
215
+ # @param name [String, Symbol]
216
+ # @return [String, nil] the declared column, or nil when undeclared.
217
+ def wire_name_for(klass, name)
218
+ return nil unless klass.respond_to?(:field_map)
219
+ field_resolution(klass)[name.to_s]
220
+ end
221
+
222
+ # @!visibility private
223
+ # Every name {wire_name_for} resolves for `klass`, as a frozen
224
+ # `{name => column}` Hash. Cached on the class and rebuilt when the
225
+ # model registry generation or the field_map size changes.
226
+ #
227
+ # @param klass [Class]
228
+ # @return [Hash{String => String}]
229
+ def field_resolution(klass)
230
+ fmap = klass.field_map
231
+ cached = klass.instance_variable_get(:@_parse_field_resolution)
232
+ generation = @model_generation
233
+ if cached && cached[0] == generation && cached[1] == fmap.size
234
+ return cached[2]
235
+ end
236
+ map = field_resolution_map(fmap)
237
+ klass.instance_variable_set(:@_parse_field_resolution, [generation, fmap.size, map].freeze)
238
+ map
239
+ end
240
+
241
+ # @!visibility private
242
+ # Build the {wire_name_for} table from a field_map. Declared columns map
243
+ # to themselves; Ruby property names map to their column and win over a
244
+ # declared column spelled the same.
245
+ #
246
+ # @param fmap [Hash{Symbol => Symbol, String}]
247
+ # @return [Hash{String => String}] frozen.
248
+ def field_resolution_map(fmap)
249
+ map = {}
250
+ fmap.each_value do |remote|
251
+ wire = remote.to_s
252
+ map[wire] = wire
253
+ end
254
+ fmap.each { |ruby_name, remote| map[ruby_name.to_s] = remote.to_s }
255
+ map.freeze
256
+ end
143
257
  # @!attribute self.raise_on_save_failure
144
258
  # By default, we return `true` or `false` for save and destroy operations.
145
259
  # If you prefer to have `Parse::Object` raise an exception instead, you
@@ -215,21 +329,58 @@ module Parse
215
329
  # subclasses (e.g. Parse::Object.find_class), so a bare `@model_cache`
216
330
  # would resolve on the subclass singleton — which has no cache. The
217
331
  # cache lives on Parse::Model itself.
332
+ generation = nil
218
333
  Parse::Model.model_cache_mutex.synchronize do
219
334
  cached = Parse::Model.model_cache[str]
220
335
  return cached if cached
336
+ if Parse::Model.model_cache_misses.key?(str)
337
+ # A recorded miss holds until the registry changes, unless an
338
+ # anonymous model seen by the last scan has since been named
339
+ # (`Foo = Class.new(Parse::Object)` fires no hook when the
340
+ # constant is assigned). Without anonymous models this is free.
341
+ anonymous = Parse::Model.model_anonymous_descendants
342
+ return nil if anonymous.nil? || anonymous.none? { |k| (k.name rescue nil) }
343
+ Parse::Model.model_cache_misses.clear
344
+ end
345
+ generation = Parse::Model.model_generation
346
+ end
347
+
348
+ # Scan outside the lock: the liveness check reads constants, and a
349
+ # constant read must never run a model definition (whose `inherited`
350
+ # hook takes this lock) while the lock is held.
351
+ matches = []
352
+ anonymous = []
353
+ Parse::Object.descendants.each do |f|
354
+ anonymous << f if (f.name rescue nil).nil?
355
+ begin
356
+ cls = f.parse_class
357
+ rescue StandardError
358
+ next
359
+ end
360
+ matches << f if cls == str || cls == "_#{str}"
361
+ end
362
+ # Prefer the class the constant currently names over one left behind by
363
+ # `remove_const`; keep descendant order otherwise.
364
+ result = if matches.size > 1
365
+ matches.find { |m| Parse::Model.live_model?(m) } || matches.first
366
+ else
367
+ matches.first
368
+ end
221
369
 
222
- result = Parse::Object.descendants.find do |f|
223
- begin
224
- cls = f.parse_class
225
- rescue StandardError
226
- next false
370
+ Parse::Model.model_cache_mutex.synchronize do
371
+ # Cache only when no model was defined or renamed during the scan.
372
+ if generation == Parse::Model.model_generation
373
+ if result
374
+ Parse::Model.model_cache[str] = result
375
+ else
376
+ misses = Parse::Model.model_cache_misses
377
+ misses.clear if misses.size >= MODEL_CACHE_MISSES_MAX
378
+ misses[str] = true
379
+ Parse::Model.model_anonymous_descendants = anonymous.freeze
227
380
  end
228
- cls == str || cls == "_#{str}"
229
381
  end
230
- Parse::Model.model_cache[str] = result if result
231
- result
232
382
  end
383
+ result
233
384
  end
234
385
 
235
386
  # Whether two Parse class-name strings denote the same class. This is the