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
@@ -387,10 +387,21 @@ module Parse
387
387
  # @return [String] the name of the Parse collection for this model.
388
388
  def parse_class(remoteName = nil)
389
389
  @parse_class ||= model_name.name
390
- @parse_class = remoteName.to_s unless remoteName.nil?
390
+ unless remoteName.nil?
391
+ @parse_class = remoteName.to_s
392
+ Parse::Model.model_registry_changed!
393
+ end
391
394
  @parse_class
392
395
  end
393
396
 
397
+ # @!visibility private
398
+ # A new model can answer a class name {Parse::Model.find_class} already
399
+ # recorded as a miss, so the registry caches are reset.
400
+ def inherited(subclass)
401
+ super
402
+ Parse::Model.model_registry_changed!
403
+ end
404
+
394
405
  # The set of default ACLs to be applied on newly created instances of this class.
395
406
  # The result follows the class's {acl_policy_setting}: the shipped default
396
407
  # policy is `:owner_else_private`, whose fallback half is {Parse::ACL.private}
@@ -1352,6 +1363,13 @@ module Parse
1352
1363
  # the trust signal here.
1353
1364
  trusted = @_trusted_init == true
1354
1365
  @_trusted_init = nil
1366
+ # Accept hash-like input that is not a Hash: Rails strong parameters
1367
+ # (`ActionController::Parameters`) and Struct / OpenStruct values.
1368
+ # Strong parameters raise from `to_h` when unpermitted, so
1369
+ # strong-parameter filtering still applies. Other objects that merely
1370
+ # respond to `to_h` (Set, Range, Array) are ignored as before rather
1371
+ # than converted.
1372
+ opts = opts.to_h if self.class.hash_like_init_input?(opts)
1355
1373
  input_hash = opts.is_a?(Hash) ? opts : nil
1356
1374
  input_had_acl = input_hash && %w[ACL acl].any? do |key|
1357
1375
  input_hash.key?(key) || input_hash.key?(key.to_sym)
@@ -1362,13 +1380,24 @@ module Parse
1362
1380
  if opts.is_a?(String) #then it's the objectId
1363
1381
  @id = opts.to_s
1364
1382
  elsif opts.is_a?(Hash)
1365
- # Pop the `:as` option (also accepts string key) before applying
1366
- # attributes so it is not mistaken for a model property. This holds
1367
- # the caller-supplied owner user for save-time ACL resolution.
1368
- acl_owner_override = opts.delete(:as) || opts.delete("as")
1383
+ # Pop the `as:` option before applying attributes so it is not
1384
+ # mistaken for a model property. It holds the caller-supplied owner
1385
+ # user for save-time ACL resolution, so only a Symbol key written
1386
+ # in code is honored. A String "as" key (form or JSON params, or any
1387
+ # key of a HashWithIndifferentAccess) is removed and ignored: a
1388
+ # request body must not be able to pick the ACL owner, e.g.
1389
+ # `{"as" => "*"}` to make the record public.
1390
+ if opts.key?(:as) || opts.key?("as")
1391
+ symbol_key = !opts.is_a?(ActiveSupport::HashWithIndifferentAccess) && opts.key?(:as)
1392
+ acl_owner_override = symbol_key ? opts[:as] : nil
1393
+ opts = opts.dup
1394
+ opts.delete(:as)
1395
+ opts.delete("as")
1396
+ self.class.validate_acl_owner_option!(acl_owner_override) unless acl_owner_override.nil?
1397
+ end
1369
1398
  #if the objectId is provided we will consider the object pristine
1370
1399
  #and not track dirty items
1371
- dirty_track = opts[Parse::Model::OBJECT_ID] || opts[:objectId] || opts[:id]
1400
+ dirty_track = opts[Parse::Model::OBJECT_ID] || opts[:objectId] || opts[:id] || opts[Parse::Model::ID]
1372
1401
  # Always filter the narrow PROTECTED_INITIALIZE_KEYS set unless
1373
1402
  # the caller is a trusted hydration path. Decoupled from
1374
1403
  # dirty_track so an objectId-bearing hash from a controller,
@@ -1398,11 +1427,18 @@ module Parse
1398
1427
  # self-write-plus-public-read ACL on signup; stamping any value from
1399
1428
  # the SDK side (even `{}`) overrides that and locks the new user out
1400
1429
  # of editing their own profile without the master key.
1430
+ #
1431
+ # A trusted hydration of an existing row (server JSON with an objectId)
1432
+ # is also exempt. A row without an `ACL` key is public on the server,
1433
+ # so stamping the local default would misreport it (usually as
1434
+ # private). Such a row is never ACL-pristine either: the save-time
1435
+ # resolver must not replace the ACL of a record that already exists.
1436
+ server_row = trusted && input_had_id
1401
1437
  acl_was_user_supplied = !self.acl.nil?
1402
- unless self.class.builtin_acl_default_active?
1438
+ unless self.class.builtin_acl_default_active? || server_row
1403
1439
  self.acl = self.class.default_acls.as_json if self.acl.nil?
1404
1440
  end
1405
- @_acl_pristine = !acl_was_user_supplied
1441
+ @_acl_pristine = !acl_was_user_supplied && !server_row
1406
1442
  @_acl_owner_override = acl_owner_override
1407
1443
 
1408
1444
  # Record where our ACL knowledge came from. `acl.nil?` alone is not
@@ -1467,11 +1503,89 @@ module Parse
1467
1503
  Parse::Pointer.new self.parse_class, id
1468
1504
  end
1469
1505
 
1470
- # Determines if this object has been saved to the Parse database. If an object has
1471
- # pending changes, then it is considered to not yet be persisted.
1472
- # @return [Boolean] true if this object has not been saved.
1506
+ # Whether this object exists on the server, in the ActiveModel sense
1507
+ # (`form_with` uses it to choose between create and update). An object
1508
+ # with an objectId is persisted even when it has unsaved changes or was
1509
+ # built without timestamps (a pointer or an id-only instance). Use
1510
+ # {#changed?} to ask whether there are unsaved changes.
1511
+ #
1512
+ # The one exception is an objectId assigned client-side during a create
1513
+ # (`parse_reference precompute: true`, `acl_owner :self`): the object
1514
+ # is not persisted until the create succeeds and returns `createdAt`.
1515
+ #
1516
+ # A destroyed object is not persisted, matching ActiveModel's
1517
+ # `persisted? == !new_record? && !destroyed?` contract.
1518
+ # @return [Boolean] true if this object exists on the server.
1473
1519
  def persisted?
1474
- changed? == false && !(@id.nil? || @created_at.nil? || @updated_at.nil? || @acl.nil?)
1520
+ return false if @id.blank?
1521
+ return false if destroyed?
1522
+ return @created_at.present? if defined?(@_creating_record) && @_creating_record
1523
+ true
1524
+ end
1525
+
1526
+ # Whether this object was deleted from the server by a successful
1527
+ # {#destroy}. A destroyed object keeps its objectId (so `dom_id`,
1528
+ # `to_key` and Turbo Stream removals keep working) but is no longer
1529
+ # {#persisted?}, and {#save} refuses to write it.
1530
+ # @return [Boolean]
1531
+ def destroyed?
1532
+ @_destroyed == true
1533
+ end
1534
+
1535
+ # An ActiveRecord-compatible cache key: `"<collection>/<objectId>"` for an
1536
+ # object with an id, `"<collection>/new"` otherwise. The collection
1537
+ # segment comes from `model_name.cache_key` (for example `"songs"` or
1538
+ # `"music/albums"`), so records of different classes that share an
1539
+ # objectId never collide in a fragment cache.
1540
+ # @return [String]
1541
+ def cache_key
1542
+ prefix = self.class.model_name.cache_key
1543
+ @id.present? ? "#{prefix}/#{@id}" : "#{prefix}/new"
1544
+ end
1545
+
1546
+ # The cache version, derived from `updated_at` in the same
1547
+ # `"%Y%m%d%H%M%S%6N"` form ActiveRecord uses. Returns nil when the
1548
+ # object has no `updated_at` (new or pointer-state objects). Reads the
1549
+ # stored value directly, so it never triggers an autofetch or changes
1550
+ # dirty state.
1551
+ # @return [String, nil]
1552
+ def cache_version
1553
+ value = @updated_at
1554
+ value = Parse::Date.parse(value) if value.is_a?(String)
1555
+ return nil unless value.respond_to?(:to_time)
1556
+ value.to_time.utc.strftime("%Y%m%d%H%M%S%6N")
1557
+ rescue ArgumentError
1558
+ nil
1559
+ end
1560
+
1561
+ # The cache key with the {#cache_version} appended, as in ActiveRecord.
1562
+ # @return [String]
1563
+ def cache_key_with_version
1564
+ version = cache_version
1565
+ version ? "#{cache_key}-#{version}" : cache_key
1566
+ end
1567
+
1568
+ # The current values of this object's properties, keyed by local
1569
+ # attribute name (as Strings).
1570
+ #
1571
+ # {#attributes} returns the property TYPE map (`{"title" => :string}`
1572
+ # style, with Symbol keys). That shape is load-bearing: ActiveModel
1573
+ # serialization reads its keys and then calls each reader, and
1574
+ # {#as_json} depends on it. This method is the value form. It reads the
1575
+ # stored values directly, so it never autofetches a pointer, never
1576
+ # applies defaults, and never changes dirty state; an unfetched field
1577
+ # reads as nil.
1578
+ # @return [Hash{String => Object}]
1579
+ def attribute_values
1580
+ self.class.fields.each_key.with_object({}) do |key, h|
1581
+ ivar = :"@#{key}"
1582
+ next unless self.class.field_map.key?(key)
1583
+ value = instance_variable_defined?(ivar) ? instance_variable_get(ivar) : nil
1584
+ if value.is_a?(String) && self.class.fields[key] == :date
1585
+ value = (Parse::Date.parse(value) rescue value)
1586
+ end
1587
+ h[key.to_s] = value
1588
+ end
1475
1589
  end
1476
1590
 
1477
1591
  # Force reload from the database and replace any local fields with data from
@@ -1618,7 +1732,9 @@ module Parse
1618
1732
  @_fetched_keys = nil
1619
1733
  else
1620
1734
  # Always include :id and convert to symbols
1621
- @_fetched_keys = keys.map { |k| Parse::Query.format_field(k).to_sym }
1735
+ @_fetched_keys = Parse::Query.with_field_aliases(parse_class) do
1736
+ keys.map { |k| Parse::Query.format_field(k).to_sym }
1737
+ end
1622
1738
  @_fetched_keys << :id unless @_fetched_keys.include?(:id)
1623
1739
  @_fetched_keys << :objectId unless @_fetched_keys.include?(:objectId)
1624
1740
  @_fetched_keys.uniq!
@@ -1641,7 +1757,15 @@ module Parse
1641
1757
  key = key.to_sym
1642
1758
  # Base keys are always considered fetched
1643
1759
  return true if Parse::Properties::BASE_KEYS.include?(key)
1644
- return true if key == :acl || key == :ACL
1760
+ # The ACL counts as fetched only when the selective response carried
1761
+ # it. Treating it as always present made `acl` read nil on a partial
1762
+ # fetch, so `(obj.acl || Parse::ACL.new).apply(...)` replaced the
1763
+ # record's real ACL with a single grant on save. Reading it now
1764
+ # autofetches like any other field the partial fetch left out.
1765
+ if key == :acl || key == :ACL
1766
+ return @_fetched_keys.include?(:ACL) || @_fetched_keys.include?(:acl) ||
1767
+ instance_variable_get(:@_authorization_acl_state) != :unknown
1768
+ end
1645
1769
 
1646
1770
  # Check both local key and remote field name
1647
1771
  # Convert remote_key to symbol for consistent comparison
@@ -1704,8 +1828,25 @@ module Parse
1704
1828
  @_nested_fetched_keys[field_name]
1705
1829
  end
1706
1830
 
1831
+ # @!visibility private
1832
+ # Partial-fetch tracking after a successful save. A create leaves a fully
1833
+ # known object. An update returns only `updatedAt`, so fields the partial
1834
+ # fetch never loaded (the ACL among them) are still unknown: keep the
1835
+ # tracking, and count the fields just written as known. Clearing it made
1836
+ # an unloaded `acl` read nil, and a grant added to that nil replaced the
1837
+ # record's existing grants on the next save.
1838
+ # @param was_new [Boolean] whether the save created the object.
1839
+ # @param saved_fields [Array<Symbol>] the fields the save wrote.
1840
+ # @return [void]
1841
+ def _after_save_partial_fetch_state!(was_new, saved_fields)
1842
+ if was_new || !has_selective_keys?
1843
+ clear_partial_fetch_state!
1844
+ return
1845
+ end
1846
+ @_fetched_keys = (@_fetched_keys + Array(saved_fields).map(&:to_sym)).uniq
1847
+ end
1848
+
1707
1849
  # Clears all partial fetch tracking state.
1708
- # Called after successful save since server returns updated object.
1709
1850
  # @return [void]
1710
1851
  def clear_partial_fetch_state!
1711
1852
  @_fetched_keys = nil
@@ -1777,9 +1918,73 @@ module Parse
1777
1918
  # @note This does not reload the object from the persistent store, for this use "reload!" instead.
1778
1919
  # @see #reload!
1779
1920
  def rollback!
1921
+ snapshot = defined?(@_acl_snapshot_before_change) ? @_acl_snapshot_before_change : nil
1780
1922
  restore_attributes
1923
+ # ACL edits are usually made in place (`acl.apply`, `acl.delete`), so
1924
+ # the value ActiveModel restores may be the edited object itself.
1925
+ # Restore from the pre-change snapshot instead, and drop the snapshot
1926
+ # so a later edit captures a fresh baseline.
1927
+ @acl = Parse::ACL.typecast(snapshot.as_json, self) if snapshot && !@acl.nil?
1928
+ @_acl_snapshot_before_change = nil
1929
+ end
1930
+
1931
+ # Keys that mass assignment never applies to an object: the objectId
1932
+ # under its local and remote names. An existing object's id must not be
1933
+ # retargeted by a params hash (`{"id" => "victim"}` would redirect the
1934
+ # next save to another record). A new object gets an id only through
1935
+ # the constructor, {Parse::Object.build}, or an explicit `id=` call.
1936
+ PROTECTED_IDENTITY_KEYS = %w[id objectId].freeze
1937
+
1938
+ # Mass-assign attributes with dirty tracking (the Rails form path).
1939
+ # Filters {Parse::Properties::PROTECTED_MASS_ASSIGNMENT_KEYS} (session
1940
+ # token, roles, ACL row permissions, auth data, timestamps, className)
1941
+ # and {PROTECTED_IDENTITY_KEYS}. Accepts a Hash or hash-like input such as
1942
+ # permitted `ActionController::Parameters` (unpermitted parameters raise
1943
+ # from `to_h`).
1944
+ # @param hash [Hash]
1945
+ def attributes=(hash)
1946
+ hash = hash.to_h if self.class.hash_like_init_input?(hash)
1947
+ return unless hash.is_a?(Hash)
1948
+ super(_without_identity_keys(hash))
1949
+ end
1950
+
1951
+ # @!visibility private
1952
+ # Strips {PROTECTED_IDENTITY_KEYS} on every mass-assignment
1953
+ # (`dirty_track: true`) call, and on hydration once the object already
1954
+ # has an id. Hydration of an object without an id (the constructor,
1955
+ # {Parse::Object.build}) may still set it.
1956
+ def apply_attributes!(hash, dirty_track: false, filter_protected: nil, protected_set: nil)
1957
+ if hash.is_a?(Hash) && (dirty_track || @id.present?)
1958
+ hash = _without_identity_keys(hash)
1959
+ end
1960
+ super(hash, dirty_track: dirty_track, filter_protected: filter_protected, protected_set: protected_set)
1961
+ end
1962
+
1963
+ private
1964
+
1965
+ # ActiveModel's `assign_attributes` funnels through this method after
1966
+ # its strong-parameters check. Apply the same protected-key filter as
1967
+ # {#attributes=} so `assign_attributes("session_token" => …, "id" => …,
1968
+ # "created_at" => …)` cannot set those fields.
1969
+ def _assign_attributes(attributes)
1970
+ if attributes.key?("ACL") || attributes.key?("acl") || attributes.key?(:ACL) || attributes.key?(:acl)
1971
+ Parse::Properties.warn_acl_mass_assignment_once!
1972
+ end
1973
+ blocked = Parse::Properties::PROTECTED_MASS_ASSIGNMENT_KEYS
1974
+ filtered = attributes.reject do |key, _|
1975
+ name = key.to_s
1976
+ blocked.include?(name) || PROTECTED_IDENTITY_KEYS.include?(name)
1977
+ end
1978
+ super(filtered)
1979
+ end
1980
+
1981
+ def _without_identity_keys(hash)
1982
+ return hash unless hash.any? { |key, _| PROTECTED_IDENTITY_KEYS.include?(key.to_s) }
1983
+ hash.reject { |key, _| PROTECTED_IDENTITY_KEYS.include?(key.to_s) }
1781
1984
  end
1782
1985
 
1986
+ public
1987
+
1783
1988
  # Overrides ActiveModel::Validations#validate! instance method.
1784
1989
  # It runs all validations for this object. If validation fails,
1785
1990
  # it raises ActiveModel::ValidationError otherwise it returns the object.
@@ -1877,7 +2082,9 @@ module Parse
1877
2082
  o.instance_variable_set(:@_nested_fetched_keys, nested_fetched_keys) if nested_fetched_keys.present?
1878
2083
  if fetched_keys.present?
1879
2084
  # Process fetched_keys like the setter does - convert to symbols and include :id
1880
- processed_keys = fetched_keys.map { |k| Parse::Query.format_field(k).to_sym }
2085
+ processed_keys = Parse::Query.with_field_aliases(klass.parse_class) do
2086
+ fetched_keys.map { |k| Parse::Query.format_field(k).to_sym }
2087
+ end
1881
2088
  processed_keys << :id unless processed_keys.include?(:id)
1882
2089
  processed_keys << :objectId unless processed_keys.include?(:objectId)
1883
2090
  processed_keys.uniq!
@@ -2025,10 +2232,51 @@ module Parse
2025
2232
  return owner.id if owner.id.present?
2026
2233
  return nil
2027
2234
  end
2028
- return owner if owner.is_a?(String) && owner.present?
2235
+ return owner if self.class.acl_owner_id_string?(owner)
2029
2236
  nil
2030
2237
  end
2031
2238
 
2239
+ class << self
2240
+ # @!visibility private
2241
+ # Whether `opts` is hash-like input that {#initialize} converts with
2242
+ # `to_h`: Rails strong parameters (`permitted?` / `to_unsafe_h`) or a
2243
+ # Struct / OpenStruct. Hash, String, Array and nil are handled by
2244
+ # {#initialize} directly; other objects are not converted.
2245
+ def hash_like_init_input?(opts)
2246
+ return false if opts.nil? || opts.is_a?(Hash) || opts.is_a?(String) || opts.is_a?(Array)
2247
+ return false unless opts.respond_to?(:to_h)
2248
+ return true if opts.respond_to?(:permitted?) || opts.respond_to?(:to_unsafe_h)
2249
+ return true if opts.is_a?(Struct)
2250
+ defined?(::OpenStruct) && opts.is_a?(::OpenStruct) ? true : false
2251
+ end
2252
+
2253
+ # @!visibility private
2254
+ # Whether `value` is usable as a raw user objectId for an ACL owner.
2255
+ # Refuses the public key "*" and role keys ("role:Name"), which would
2256
+ # grant the record to everyone or to a role instead of to one user.
2257
+ def acl_owner_id_string?(value)
2258
+ value.is_a?(String) && value.present? && value != "*" && !value.start_with?("role:")
2259
+ end
2260
+
2261
+ # @!visibility private
2262
+ # Validates the `as:` owner option given to {#initialize}. Accepts a
2263
+ # Parse::User, a Parse::Pointer to `_User` with an objectId, or a raw
2264
+ # user objectId String.
2265
+ # @raise [ArgumentError] for any other value.
2266
+ def validate_acl_owner_option!(owner)
2267
+ if owner.is_a?(Parse::Pointer)
2268
+ return if owner.parse_class == Parse::Model::CLASS_USER && owner.id.present?
2269
+ raise ArgumentError,
2270
+ "as: must be a Parse::User or a pointer to _User with an objectId " \
2271
+ "(got a #{owner.parse_class} #{owner.class})."
2272
+ end
2273
+ return if acl_owner_id_string?(owner)
2274
+ raise ArgumentError,
2275
+ "as: must be a Parse::User, a pointer to _User, or a user objectId String; " \
2276
+ "the public key \"*\" and role keys are not accepted."
2277
+ end
2278
+ end
2279
+
2032
2280
  set_callback :save, :before, :_resolve_default_acl
2033
2281
 
2034
2282
  # Override acl_will_change! to capture a snapshot of the ACL before modification.
@@ -71,7 +71,25 @@ module Parse
71
71
  # Regex to strip non-digit characters (except +)
72
72
  STRIP_NON_DIGITS = /[^\d+]/
73
73
 
74
+ # A 10-digit North American Numbering Plan number: area code and
75
+ # exchange code each start with 2-9.
76
+ NANP_NATIONAL = /\A[2-9]\d{2}[2-9]\d{6}\z/
77
+
74
78
  class << self
79
+ # Country calling code (digits only, no `+`) assumed for numbers
80
+ # entered without a leading `+`. Defaults to `"1"` (North America),
81
+ # under which 10-digit numbers get `+1`. Set to another code (for
82
+ # example `"44"`) to apply that country to national numbers, or to
83
+ # `nil` to make no assumption, in which case a national number
84
+ # without `+` is left unprefixed and fails validation.
85
+ # @return [String, nil]
86
+ attr_writer :default_country_code
87
+
88
+ # @return [String, nil] see {default_country_code=}.
89
+ def default_country_code
90
+ defined?(@default_country_code) ? @default_country_code : "1"
91
+ end
92
+
75
93
  # Check if phonelib is available for enhanced validation
76
94
  # @return [Boolean] true if phonelib gem is loaded
77
95
  def phonelib_available?
@@ -131,7 +149,25 @@ module Parse
131
149
  end
132
150
 
133
151
  # Normalize a phone number string to E.164 format.
134
- # Removes all non-digit characters except leading +.
152
+ # Removes all non-digit characters except a leading +.
153
+ #
154
+ # Input that already starts with `+` is kept as dialed. Input without
155
+ # a `+` is resolved as follows, identically with and without phonelib
156
+ # (phonelib parses the normalized result):
157
+ #
158
+ # 1. A leading `00` (international access prefix) becomes `+`.
159
+ # 2. With {Parse::Phone.default_country_code} set to `"1"` (the
160
+ # default), a 10-digit North American number (`4155551234`,
161
+ # `(415) 555-1234`) becomes `+14155551234`, and an 11-digit number
162
+ # starting with `1` gets a `+`.
163
+ # 3. Any other number of 11 or more digits that does not start with
164
+ # `0` is assumed to already include its country code.
165
+ # 4. A shorter national number (or one with a leading trunk `0`) gets
166
+ # the default country code, minus the trunk `0`, when that code is
167
+ # not `"1"`. With no default country code it is left without a `+`
168
+ # so it fails validation instead of being read as some other
169
+ # country's code (previously `4155551234` became the Swiss
170
+ # `+41 55 551 234`).
135
171
  #
136
172
  # @param value [String] the phone number string
137
173
  # @return [String, nil] the normalized number or nil if invalid
@@ -140,12 +176,25 @@ module Parse
140
176
 
141
177
  # Remove all non-digit characters except +
142
178
  cleaned = value.to_s.gsub(STRIP_NON_DIGITS, "")
179
+ return cleaned if cleaned.start_with?("+")
143
180
 
144
- # If it doesn't start with +, add it
145
- cleaned = "+#{cleaned}" unless cleaned.start_with?("+")
181
+ digits = cleaned.delete("+")
182
+ return "+#{digits}" if digits.empty?
183
+ return "+#{digits[2..]}" if digits.start_with?("00")
146
184
 
147
- # Return the cleaned value (may still be invalid, but we store it)
148
- cleaned
185
+ default_cc = Parse::Phone.default_country_code.to_s.delete("+").presence
186
+ if default_cc == "1"
187
+ return "+1#{digits}" if NANP_NATIONAL.match?(digits)
188
+ return "+#{digits}" if digits.length == 11 && digits.start_with?("1") && NANP_NATIONAL.match?(digits[1..])
189
+ end
190
+
191
+ return "+#{digits}" if digits.length >= 11 && !digits.start_with?("0")
192
+
193
+ if default_cc && default_cc != "1"
194
+ "+#{default_cc}#{digits.sub(/\A0/, "")}"
195
+ else
196
+ digits
197
+ end
149
198
  end
150
199
 
151
200
  # @return [String, nil] the E.164 formatted phone number
@@ -216,7 +216,9 @@ module Parse
216
216
  # Build query parameters for partial fetch
217
217
  query = {}
218
218
  if keys.present?
219
- keys_array = Array(keys).map { |k| Parse::Query.format_field(k) }
219
+ keys_array = Parse::Query.with_field_aliases(parse_class) do
220
+ Array(keys).map { |k| Parse::Query.format_field(k) }
221
+ end
220
222
  query[:keys] = keys_array.join(",")
221
223
  end
222
224
  if includes.present?
@@ -246,7 +248,9 @@ module Parse
246
248
  # For partial fetch, build with fetched_keys tracking
247
249
  if keys.present?
248
250
  # Parse keys to get top-level field names and nested keys
249
- top_level_keys = Array(keys).map { |k| Parse::Query.format_field(k).split(".").first.to_sym }
251
+ top_level_keys = Parse::Query.with_field_aliases(parse_class) do
252
+ Array(keys).map { |k| Parse::Query.format_field(k).split(".").first.to_sym }
253
+ end
250
254
  top_level_keys << :id unless top_level_keys.include?(:id)
251
255
  top_level_keys << :objectId unless top_level_keys.include?(:objectId)
252
256
  top_level_keys.uniq!
@@ -278,7 +282,9 @@ module Parse
278
282
  def fetch_json(keys: nil, includes: nil)
279
283
  query = {}
280
284
  if keys.present?
281
- keys_array = Array(keys).map { |k| Parse::Query.format_field(k) }
285
+ keys_array = Parse::Query.with_field_aliases(parse_class) do
286
+ Array(keys).map { |k| Parse::Query.format_field(k) }
287
+ end
282
288
  query[:keys] = keys_array.join(",")
283
289
  end
284
290
  if includes.present?
@@ -314,10 +320,15 @@ module Parse
314
320
  end
315
321
 
316
322
  # Two Parse::Pointers (or Parse::Objects) are equal if both of them have
317
- # the same Parse class and the same id.
323
+ # the same Parse class and the same id. An instance without an id (an
324
+ # unsaved object) is equal only to itself, as in ActiveRecord: two
325
+ # distinct new records are different records even though neither has
326
+ # an id yet.
318
327
  # @return [Boolean]
319
328
  def ==(o)
329
+ return true if equal?(o)
320
330
  return false unless o.is_a?(Pointer)
331
+ return false if id.blank? || o.id.blank?
321
332
  #only equal if the Parse class and object ID are the same.
322
333
  self.parse_class == o.parse_class && id == o.id
323
334
  end
@@ -333,8 +344,14 @@ module Parse
333
344
  # - Hash key lookups to find objects by identity
334
345
  # - Set operations
335
346
  #
347
+ # An instance without an id hashes by Ruby identity, consistent with
348
+ # {#==}. Its hash therefore changes once it is saved and receives an id,
349
+ # so re-index any Hash or Set that holds it as a key after saving (the
350
+ # same caveat as ActiveRecord).
351
+ #
336
352
  # @return [Integer] hash code based on class name and object id
337
353
  def hash
354
+ return super if id.blank?
338
355
  [parse_class, id].hash
339
356
  end
340
357
 
@@ -370,6 +387,12 @@ module Parse
370
387
  # @return [Object] the result of calling the method on the fetched object
371
388
  # @raise [Parse::AutofetchTriggeredError] if autofetch_raise_on_missing_keys is enabled
372
389
  def method_missing(method_name, *args, &block)
390
+ # A Parse::Object defines real accessors for its fields, so reaching
391
+ # here means a name with no accessor (for example the remote alias
392
+ # `objectId=` sent by ActiveModel's `assign_attributes`). Autofetching
393
+ # would issue a network request and run the call on a separate fetched
394
+ # copy, not on this object, so raise NoMethodError instead.
395
+ return super if is_a?(Parse::Object)
373
396
  # Try to find the model class for this pointer
374
397
  klass = Parse::Model.find_class(parse_class)
375
398
 
@@ -381,6 +404,16 @@ module Parse
381
404
  # We have a registered class with this field - handle autofetch
382
405
  field_name = method_name.to_s.chomp("=").to_sym
383
406
 
407
+ # A setter would change a fetched copy held inside this pointer, which
408
+ # nothing ever saves, so the write would be lost without notice.
409
+ if method_name.to_s.end_with?("=")
410
+ raise NoMethodError.new(
411
+ "undefined method '#{method_name}' for a #{self.class} to #{parse_class}. A pointer " \
412
+ "cannot be modified: fetch the object first (pointer.fetch) and set :#{field_name} on it.",
413
+ method_name,
414
+ )
415
+ end
416
+
384
417
  # If autofetch_raise_on_missing_keys is enabled, raise an error
385
418
  if Parse.autofetch_raise_on_missing_keys
386
419
  raise Parse::AutofetchTriggeredError.new(klass, id, field_name, is_pointer: true)
@@ -405,9 +438,10 @@ module Parse
405
438
  # @param include_private [Boolean] whether to include private methods
406
439
  # @return [Boolean] true if the method can be handled
407
440
  def respond_to_missing?(method_name, include_private = false)
441
+ return super if is_a?(Parse::Object)
408
442
  klass = Parse::Model.find_class(parse_class)
409
- if klass && klass.respond_to?(:fields)
410
- field_name = method_name.to_s.chomp("=").to_sym
443
+ if klass && klass.respond_to?(:fields) && !method_name.to_s.end_with?("=")
444
+ field_name = method_name.to_sym
411
445
  return true if klass.fields[field_name]
412
446
  end
413
447
  super