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,174 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Core
6
+ # Model-declarative Atlas Search index DSL. Mixed into Parse::Object
7
+ # so subclasses can declare the Atlas Search indexes they expect to
8
+ # exist on their collection. Declarations are inert at load time —
9
+ # they only land on Atlas when {Parse::Schema::SearchIndexMigrator}
10
+ # reads them and `apply_search_indexes!` is invoked through the
11
+ # writer connection.
12
+ #
13
+ # Parallels {Parse::Core::Indexing} (the regular `mongo_index` DSL)
14
+ # but with three meaningful differences:
15
+ #
16
+ # - **Multi-per-class.** A single model can declare several search
17
+ # indexes (one for full-text, one for autocomplete, one for
18
+ # vector search), each with a unique name.
19
+ # - **Definition is opaque.** The DSL doesn't introspect field
20
+ # references — Atlas owns the mapping schema. The DSL validates
21
+ # name shape and Hash-non-emptiness only; everything else is
22
+ # forwarded to Atlas verbatim.
23
+ # - **Async build.** Mutations don't return a "READY" guarantee
24
+ # — the migrator's rake task is fire-and-forget by default,
25
+ # `WAIT=true` opts into polling via
26
+ # {Parse::AtlasSearch::IndexManager.wait_for_ready}.
27
+ #
28
+ # SECURITY POSTURE — purely declarative. No network I/O at
29
+ # declaration time, no class introspection. The validation rules
30
+ # below surface typos as load-time errors instead of runtime
31
+ # surprises during `rake parse:mongo:search_indexes:apply` in prod.
32
+ #
33
+ # @example Declaring search indexes
34
+ # class Song < Parse::Object
35
+ # property :title, :string
36
+ # property :artist, :string
37
+ #
38
+ # mongo_search_index "song_search", {
39
+ # mappings: { dynamic: false, fields: {
40
+ # title: { type: "string", analyzer: "lucene.standard" },
41
+ # artist: { type: "string" },
42
+ # } },
43
+ # }
44
+ # mongo_search_index "song_autocomplete", {
45
+ # mappings: { fields: {
46
+ # title: { type: "autocomplete", tokenization: "edgeGram" },
47
+ # } },
48
+ # }
49
+ # end
50
+ module SearchIndexing
51
+ # Atlas Search index name shape. Same regex used at the
52
+ # {Parse::MongoDB.create_search_index} layer.
53
+ INDEX_NAME_PATTERN = /\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/.freeze
54
+
55
+ # Allowed `type:` values for `mongo_search_index`. `search` is the
56
+ # default and covers the conventional text-search / autocomplete /
57
+ # faceted-search use cases; `vectorSearch` is for vector similarity
58
+ # indexes. Atlas rejects any other value at command time, but we
59
+ # check at declaration time so the typo doesn't survive to prod.
60
+ ALLOWED_INDEX_TYPES = %w[search vectorSearch].freeze
61
+
62
+ # Storage for declared search indexes. Each entry is a frozen Hash
63
+ # with keys `:name`, `:definition`, `:type`.
64
+ # @return [Array<Hash>]
65
+ def mongo_search_index_declarations
66
+ @mongo_search_index_declarations ||= []
67
+ end
68
+
69
+ # Declare an Atlas Search index for this model's collection.
70
+ #
71
+ # @param name [String] the search index name. Must match
72
+ # {INDEX_NAME_PATTERN}.
73
+ # @param definition [Hash] the search index definition (mappings,
74
+ # analyzers, etc.). Forwarded verbatim to Atlas after
75
+ # string-keyed normalization at apply time. Must be a non-empty
76
+ # Hash.
77
+ # @param type [String] one of {ALLOWED_INDEX_TYPES}. Default
78
+ # `"search"`.
79
+ # @return [Hash] the registered declaration (frozen)
80
+ # @raise [ArgumentError] when validation fails, or when a
81
+ # declaration with the same name was already registered on this
82
+ # class with a different definition or type (idempotent
83
+ # redeclaration with identical content returns the existing
84
+ # entry).
85
+ def mongo_search_index(name, definition, type: "search")
86
+ name_str = name.to_s
87
+ unless name_str.match?(INDEX_NAME_PATTERN)
88
+ raise ArgumentError,
89
+ "#{self}.mongo_search_index name #{name.inspect} must match #{INDEX_NAME_PATTERN.inspect}"
90
+ end
91
+ unless definition.is_a?(Hash) && !definition.empty?
92
+ raise ArgumentError,
93
+ "#{self}.mongo_search_index #{name_str.inspect} requires a non-empty Hash definition; got #{definition.inspect}"
94
+ end
95
+ type_str = type.to_s
96
+ unless ALLOWED_INDEX_TYPES.include?(type_str)
97
+ raise ArgumentError,
98
+ "#{self}.mongo_search_index #{name_str.inspect} type=#{type.inspect} must be one of #{ALLOWED_INDEX_TYPES.inspect}"
99
+ end
100
+
101
+ declaration = {
102
+ name: name_str,
103
+ definition: deep_freeze(definition),
104
+ type: type_str,
105
+ }.freeze
106
+
107
+ existing = mongo_search_index_declarations.find { |d| d[:name] == name_str }
108
+ if existing
109
+ # Idempotent redeclaration with identical content — common in
110
+ # autoloading / class-reopening setups. Re-declarations that
111
+ # disagree on definition or type fail loudly so the operator
112
+ # notices the conflict at class load instead of at apply time.
113
+ if existing[:definition] == declaration[:definition] && existing[:type] == declaration[:type]
114
+ return existing
115
+ end
116
+ raise ArgumentError,
117
+ "#{self}.mongo_search_index #{name_str.inspect} re-declared with a different " \
118
+ "definition or type. Each name may have one declaration per class. " \
119
+ "Use a unique name for the new index, or update the existing declaration in place."
120
+ end
121
+
122
+ mongo_search_index_declarations << declaration
123
+ declaration
124
+ end
125
+
126
+ # Dry-run reconciliation between declared search indexes and what
127
+ # exists on Atlas. Delegates to {Parse::Schema::SearchIndexMigrator}.
128
+ # @return [Hash] see {Parse::Schema::SearchIndexMigrator#plan}
129
+ def search_indexes_plan
130
+ Parse::Schema::SearchIndexMigrator.new(self).plan
131
+ end
132
+
133
+ # Apply declared search-index changes via the writer connection.
134
+ #
135
+ # @param update [Boolean] when true, drift-detected indexes are
136
+ # updated via `updateSearchIndex`. When false (default), drift
137
+ # is reported and the index is left untouched (operator must
138
+ # either re-declare to match or explicitly opt-in to update).
139
+ # @param drop [Boolean] when true, orphan search indexes (those
140
+ # on the collection but not declared) are dropped.
141
+ # @param wait [Boolean] when true, block on
142
+ # {Parse::AtlasSearch::IndexManager.wait_for_ready} after every
143
+ # create / update to confirm the build completed before
144
+ # returning.
145
+ # @param timeout [Integer] wait timeout in seconds (when `wait:
146
+ # true`). Default 600.
147
+ # @return [Hash] see {Parse::Schema::SearchIndexMigrator#apply!}
148
+ def apply_search_indexes!(update: false, drop: false, wait: false, timeout: 600)
149
+ Parse::Schema::SearchIndexMigrator.new(self).apply!(
150
+ update: update, drop: drop, wait: wait, timeout: timeout,
151
+ )
152
+ end
153
+
154
+ private
155
+
156
+ # Recursive deep-freeze so a stored declaration can't be mutated
157
+ # post-registration by code that holds a reference to the original
158
+ # `definition` Hash.
159
+ def deep_freeze(value)
160
+ case value
161
+ when Hash
162
+ value.each { |_, v| deep_freeze(v) }
163
+ value.freeze
164
+ when Array
165
+ value.each { |v| deep_freeze(v) }
166
+ value.freeze
167
+ else
168
+ value.freeze if value.respond_to?(:freeze) && !value.frozen? rescue value
169
+ value
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,88 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require "date"
6
+ require "active_model"
7
+ require "active_support"
8
+ require "active_support/inflector"
9
+ require "active_support/core_ext/object"
10
+ require "active_support/core_ext/date/calculations"
11
+ require "active_support/core_ext/date_time/calculations"
12
+ require "active_support/core_ext/time/calculations"
13
+ require "active_model/serializers/json"
14
+ require_relative "model"
15
+
16
+ module Parse
17
+ # This class manages dates in the special JSON format it requires for
18
+ # properties of type _:date_.
19
+ class Date < ::DateTime
20
+ # The default attributes in a Parse Date hash.
21
+ ATTRIBUTES = { __type: :string, iso: :string }.freeze
22
+ include ::ActiveModel::Model
23
+ include ::ActiveModel::Serializers::JSON
24
+
25
+ # @return [Parse::Model::TYPE_DATE]
26
+ def self.parse_class; Parse::Model::TYPE_DATE; end
27
+ # @return [Parse::Model::TYPE_DATE]
28
+ def parse_class; self.class.parse_class; end
29
+
30
+ alias_method :__type, :parse_class
31
+
32
+ # @return [Hash]
33
+ def attributes
34
+ ATTRIBUTES
35
+ end
36
+
37
+ # @return [String] the ISO8601 time string including milliseconds
38
+ def iso
39
+ # For Rails 8+ compatibility, avoid to_time and use appropriate UTC conversion
40
+ if respond_to?(:utc)
41
+ utc.iso8601(3)
42
+ else
43
+ # Fallback for Date objects without time zone info
44
+ to_datetime.utc.iso8601(3)
45
+ end
46
+ end
47
+
48
+ # @return (see #iso)
49
+ def to_s(*args)
50
+ args.empty? ? iso : super(*args)
51
+ end
52
+ end
53
+ end
54
+
55
+ # Adds extensions to Time class to be compatible with {Parse::Date}.
56
+ class Time
57
+ # @return [Parse::Date] Converts object to Parse::Date
58
+ def parse_date
59
+ Parse::Date.parse iso8601(3)
60
+ end
61
+ end
62
+
63
+ # Adds extensions to DateTime class to be compatible with {Parse::Date}.
64
+ class DateTime
65
+ # @return [Parse::Date] Converts object to Parse::Date
66
+ def parse_date
67
+ Parse::Date.parse iso8601(3)
68
+ end
69
+ end
70
+
71
+ # Adds extensions to ActiveSupport class to be compatible with {Parse::Date}.
72
+ module ActiveSupport
73
+ # Adds extensions to ActiveSupport::TimeWithZone class to be compatible with {Parse::Date}.
74
+ class TimeWithZone
75
+ # @return [Parse::Date] Converts object to Parse::Date
76
+ def parse_date
77
+ Parse::Date.parse iso8601(3)
78
+ end
79
+ end
80
+ end
81
+
82
+ # Adds extensions to Date class to be compatible with {Parse::Date}.
83
+ class Date
84
+ # @return [Parse::Date] Converts object to Parse::Date
85
+ def parse_date
86
+ Parse::Date.parse iso8601
87
+ end
88
+ end
@@ -0,0 +1,213 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "model"
5
+
6
+ module Parse
7
+ # This class provides email validation for Parse properties.
8
+ # It wraps a string value and provides validation according to RFC 5322.
9
+ #
10
+ # When declaring a property of type :email, the framework will automatically add a validation
11
+ # to ensure the email is either nil or a valid email address format.
12
+ #
13
+ # @example
14
+ # class Contact < Parse::Object
15
+ # property :email, :email
16
+ # property :work_email, :email, required: true
17
+ # end
18
+ #
19
+ # contact = Contact.new
20
+ # contact.email = "user@example.com"
21
+ # contact.email.valid? # => true
22
+ # contact.email.local # => "user"
23
+ # contact.email.domain # => "example.com"
24
+ #
25
+ # contact.email = "invalid"
26
+ # contact.email.valid? # => false
27
+ #
28
+ # @version 3.0.0
29
+ class Email
30
+ # RFC 5322 compliant email regex (simplified but robust version)
31
+ # This regex validates most common email formats while avoiding catastrophic backtracking.
32
+ # For stricter validation, consider using a dedicated email validation library.
33
+ EMAIL_REGEX = /\A[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z/
34
+
35
+ # Common disposable email domains (for optional filtering)
36
+ DISPOSABLE_DOMAINS = %w[
37
+ mailinator.com guerrillamail.com tempmail.com throwaway.email
38
+ 10minutemail.com fakeinbox.com trashmail.com
39
+ ].freeze
40
+
41
+ # @return [String] the raw input value
42
+ attr_reader :raw
43
+
44
+ # @return [String] the normalized email address (or nil if invalid input)
45
+ attr_reader :address
46
+
47
+ # Creates a new Email instance.
48
+ #
49
+ # @overload new(address)
50
+ # @param address [String] an email address
51
+ # @return [Parse::Email]
52
+ # @overload new(email)
53
+ # @param email [Parse::Email] another Email instance to copy
54
+ # @return [Parse::Email]
55
+ #
56
+ # @example
57
+ # Parse::Email.new("user@example.com")
58
+ # Parse::Email.new(" USER@EXAMPLE.COM ") # Will normalize
59
+ def initialize(value)
60
+ @raw = nil
61
+ @address = nil
62
+
63
+ if value.is_a?(String)
64
+ @raw = value
65
+ @address = normalize(value)
66
+ elsif value.is_a?(Parse::Email)
67
+ @raw = value.raw
68
+ @address = value.address
69
+ elsif value.respond_to?(:to_s) && !value.nil?
70
+ @raw = value.to_s
71
+ @address = normalize(@raw)
72
+ end
73
+ end
74
+
75
+ # Normalize an email address.
76
+ # - Strips whitespace
77
+ # - Converts to lowercase
78
+ #
79
+ # @param value [String] the email string
80
+ # @return [String, nil] the normalized email or nil if blank
81
+ def normalize(value)
82
+ return nil if value.blank?
83
+ value.to_s.strip.downcase
84
+ end
85
+
86
+ # @return [String, nil] the normalized email address
87
+ def to_s
88
+ @address
89
+ end
90
+
91
+ # @return [String, nil] the email address for JSON serialization
92
+ def as_json(*args)
93
+ @address
94
+ end
95
+
96
+ # Check if this email address is valid.
97
+ #
98
+ # @return [Boolean] true if the email is valid format
99
+ #
100
+ # @example
101
+ # Parse::Email.new("user@example.com").valid? # => true
102
+ # Parse::Email.new("invalid").valid? # => false
103
+ def valid?
104
+ return false if @address.blank?
105
+ EMAIL_REGEX.match?(@address)
106
+ end
107
+
108
+ # Get the local part of the email (before @).
109
+ #
110
+ # @return [String, nil] the local part or nil if invalid
111
+ #
112
+ # @example
113
+ # Parse::Email.new("user@example.com").local # => "user"
114
+ def local
115
+ return nil unless valid?
116
+ @address.split("@").first
117
+ end
118
+
119
+ # Get the domain part of the email (after @).
120
+ #
121
+ # @return [String, nil] the domain or nil if invalid
122
+ #
123
+ # @example
124
+ # Parse::Email.new("user@example.com").domain # => "example.com"
125
+ def domain
126
+ return nil unless valid?
127
+ @address.split("@").last
128
+ end
129
+
130
+ # Get the top-level domain (TLD) of the email.
131
+ #
132
+ # @return [String, nil] the TLD or nil if invalid
133
+ #
134
+ # @example
135
+ # Parse::Email.new("user@example.com").tld # => "com"
136
+ # Parse::Email.new("user@example.co.uk").tld # => "uk"
137
+ def tld
138
+ d = domain
139
+ return nil unless d
140
+ d.split(".").last
141
+ end
142
+
143
+ # Check if this email is from a disposable email service.
144
+ # Note: This is a basic check against a small list. For production use,
145
+ # consider using a dedicated disposable email detection service.
146
+ #
147
+ # @return [Boolean] true if the domain is a known disposable email provider
148
+ #
149
+ # @example
150
+ # Parse::Email.new("user@mailinator.com").disposable? # => true
151
+ # Parse::Email.new("user@gmail.com").disposable? # => false
152
+ def disposable?
153
+ d = domain
154
+ return false unless d
155
+ DISPOSABLE_DOMAINS.include?(d)
156
+ end
157
+
158
+ # Format the email with the local part obscured for privacy.
159
+ #
160
+ # @return [String, nil] the masked email or nil if invalid
161
+ #
162
+ # @example
163
+ # Parse::Email.new("username@example.com").masked # => "u***e@example.com"
164
+ def masked
165
+ return nil unless valid?
166
+ l = local
167
+ d = domain
168
+ return nil unless l && d
169
+
170
+ if l.length <= 2
171
+ "#{l[0]}*@#{d}"
172
+ else
173
+ "#{l[0]}#{"*" * [l.length - 2, 3].min}#{l[-1]}@#{d}"
174
+ end
175
+ end
176
+
177
+ # Check equality with another email.
178
+ #
179
+ # @param other [Parse::Email, String] the other email
180
+ # @return [Boolean] true if the emails are equal
181
+ def ==(other)
182
+ if other.is_a?(Parse::Email)
183
+ @address == other.address
184
+ elsif other.is_a?(String)
185
+ @address == normalize(other)
186
+ else
187
+ false
188
+ end
189
+ end
190
+
191
+ # @return [Boolean] true if the email is blank/nil
192
+ def blank?
193
+ @address.blank?
194
+ end
195
+
196
+ # @return [Boolean] true if the email is present
197
+ def present?
198
+ !blank?
199
+ end
200
+
201
+ # Type casting support for Parse properties.
202
+ # This allows the property system to convert values to Email instances.
203
+ #
204
+ # @param value [Object] the value to typecast
205
+ # @return [Parse::Email, nil] the Email instance or nil
206
+ # @api private
207
+ def self.typecast(value)
208
+ return nil if value.nil?
209
+ return value if value.is_a?(Parse::Email)
210
+ Parse::Email.new(value)
211
+ end
212
+ end
213
+ end