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