Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions src/core/src/Database/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DateTimeInterface;
use Hyperf\Collection\Collection as BaseCollection;
use Hyperf\Contract\Castable;
use Hyperf\Contract\CastsAttributes;
use Hyperf\Contract\CastsInboundAttributes;
use Hypervel\Encryption\Contracts\Encrypter;
use Hypervel\Support\Facades\Crypt;
use Hypervel\Support\Facades\Date;
use Hypervel\Support\Facades\Hash;
use Hypervel\Support\Str;
use RuntimeException;

trait HasAttributes
{
Expand All @@ -24,6 +30,158 @@ trait HasAttributes
*/
protected static array $castsCache = [];

/**
* The encrypter instance that is used to encrypt attributes.
*/
protected static ?Encrypter $encrypter = null;

/**
* Set the encrypter instance that will be used to encrypt attributes.
*/
public static function encryptUsing(?Encrypter $encrypter): void
{
static::$encrypter = $encrypter;
}

/**
* Get the current encrypter being used by the model.
*/
public static function currentEncrypter(): Encrypter
{
return static::$encrypter ?? Crypt::getFacadeRoot();
}

/**
* Determine whether a value is an encrypted castable for inbound manipulation.
*/
protected function isEncryptedCastable(string $key): bool
{
return $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']);
}

/**
* Decrypt the given encrypted string.
*/
public function fromEncryptedString(string $value): mixed
{
return static::currentEncrypter()->decrypt($value, false);
}

/**
* Cast the given attribute to an encrypted string.
*/
protected function castAttributeAsEncryptedString(string $key, mixed $value): string
{
return static::currentEncrypter()->encrypt($value, false);
}

/**
* Cast the given attribute to a hashed string.
*
* @throws RuntimeException
*/
protected function castAttributeAsHashedString(string $key, mixed $value): ?string
{
if ($value === null) {
return null;
}

if (! Hash::isHashed($value)) {
return Hash::make($value);
}

if (! Hash::verifyConfiguration($value)) {
throw new RuntimeException("Could not verify the hashed value's configuration.");
}

return $value;
}

/**
* Get the type of cast for a model attribute.
*/
protected function getCastType(string $key): string
{
$castType = $this->getCasts()[$key];

if ($this->isCustomDateTimeCast($castType)) {
return 'custom_datetime';
}

if ($this->isImmutableCustomDateTimeCast($castType)) {
return 'immutable_custom_datetime';
}

if ($this->isDecimalCast($castType)) {
return 'decimal';
}

return trim(strtolower($castType));
}

/**
* Determine if the cast type is an immutable custom date time cast.
*/
protected function isImmutableCustomDateTimeCast(string $cast): bool
{
return str_starts_with($cast, 'immutable_date:')
|| str_starts_with($cast, 'immutable_datetime:');
}

/**
* Cast an attribute to a native PHP type.
*/
protected function castAttribute(string $key, mixed $value): mixed
{
$castType = $this->getCastType($key);

if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) {
return null;
}

// If the key is one of the encrypted castable types, we'll first decrypt
// the value and update the cast type so we may leverage the following
// logic for casting this value to any additionally specified types.
if ($this->isEncryptedCastable($key)) {
$value = $this->fromEncryptedString($value);

$castType = Str::after($castType, 'encrypted:');
}

return match ($castType) {
'int', 'integer' => (int) $value,
'real', 'float', 'double' => $this->fromFloat($value),
'decimal' => $this->asDecimal($value, (int) explode(':', $this->getCasts()[$key], 2)[1]),
'string' => (string) $value,
'bool', 'boolean' => (bool) $value,
'object' => $this->fromJson($value, true),
'array', 'json', 'json:unicode' => $this->fromJson($value),
'collection' => new BaseCollection($this->fromJson($value)),
'date' => $this->asDate($value),
'datetime', 'custom_datetime' => $this->asDateTime($value),
'immutable_date' => $this->asDate($value)->toImmutable(),
'immutable_custom_datetime', 'immutable_datetime' => $this->asDateTime($value)->toImmutable(),
'timestamp' => $this->asTimestamp($value),
default => $this->castAttributeDefault($key, $value),
};
}

/**
* Handle default cast attribute types (enum and class casts).
*/
protected function castAttributeDefault(string $key, mixed $value): mixed
{
if ($this->isEnumCastable($key)) {
return $this->getEnumCastableAttributeValue($key, $value);
}

if ($this->isClassCastable($key)) {
return $this->getClassCastableAttributeValue($key, $value);
}

return $value;
}

/**
* Resolve the custom caster class for a given key.
*/
Expand Down Expand Up @@ -81,6 +239,119 @@ protected function casts(): array
return [];
}

/**
* Set a given attribute on the model.
*
* @return static
*/
public function setAttribute(string $key, mixed $value)
{
// First we will check for the presence of a mutator for the set operation
// which simply lets the developers tweak the attribute as it is set on
// the model, such as "json_encoding" an listing of data for storage.
if ($this->hasSetMutator($key)) {
return $this->setMutatedAttributeValue($key, $value);
}

// If an attribute is listed as a "date", we'll convert it from a DateTime
// instance into a form proper for storage on the database tables using
// the connection grammar's date format. We will auto set the values.
if ($value && $this->isDateAttribute($key)) {
$value = $this->fromDateTime($value);
}

if ($this->isEnumCastable($key)) {
$this->setEnumCastableAttribute($key, $value);

return $this;
}

if ($this->isClassCastable($key)) {
$this->setClassCastableAttribute($key, $value);

return $this;
}

if ($this->isJsonCastable($key) && ! is_null($value)) {
$value = $this->castAttributeAsJson($key, $value);
}

// If this attribute contains a JSON ->, we'll set the proper value in the
// attribute's underlying array. This takes care of properly nesting an
// attribute in the array's value in the case of deeply nested items.
if (str_contains($key, '->')) {
return $this->fillJsonAttribute($key, $value);
}

if (! is_null($value) && $this->isEncryptedCastable($key)) {
$value = $this->castAttributeAsEncryptedString($key, $value);
}

if (! is_null($value) && $this->hasCast($key, 'hashed')) {
$value = $this->castAttributeAsHashedString($key, $value);
}

$this->attributes[$key] = $value;

return $this;
}

/**
* Cast the given attribute to JSON.
*/
protected function castAttributeAsJson(string $key, mixed $value): string
{
$value = $this->asJson($value, $this->getJsonCastFlags($key));

if ($value === false) {
throw new \Hyperf\Database\Model\JsonEncodingException(
"Unable to encode attribute [{$key}] for model [" . static::class . '] to JSON: ' . json_last_error_msg()
);
}

return $value;
}

/**
* Get the JSON casting flags for the given attribute.
*/
protected function getJsonCastFlags(string $key): int
{
$flags = 0;

if ($this->hasCast($key, ['json:unicode'])) {
$flags |= JSON_UNESCAPED_UNICODE;
}

return $flags;
}

/**
* Encode the given value as JSON.
*/
protected function asJson(mixed $value, int $flags = 0): string|false
{
return json_encode($value, $flags);
}

/**
* Determine whether a value is JSON castable for inbound manipulation.
*/
protected function isJsonCastable(string $key): bool
{
return $this->hasCast($key, [
'array',
'json',
'json:unicode',
'object',
'collection',
'encrypted:array',
'encrypted:collection',
'encrypted:json',
'encrypted:object',
]);
}

/**
* Return a timestamp as DateTime object with time set to 00:00:00.
*
Expand Down
35 changes: 35 additions & 0 deletions src/core/src/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann
use HasTimestamps;
use TransformsToResource;

/**
* The built-in, primitive cast types supported by Eloquent.
*
* @var string[]
*/
protected static array $primitiveCastTypes = [
'array',
'bool',
'boolean',
'collection',
'custom_datetime',
'date',
'datetime',
'decimal',
'double',
'encrypted',
'encrypted:array',
'encrypted:collection',
'encrypted:json',
'encrypted:object',
'float',
'hashed',
'immutable_date',
'immutable_datetime',
'immutable_custom_datetime',
'int',
'integer',
'json',
'json:unicode',
'object',
'real',
'string',
'timestamp',
];

/**
* The resolved builder class names by model.
*
Expand Down
35 changes: 35 additions & 0 deletions src/core/src/Database/Eloquent/Relations/MorphPivot.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,41 @@ class MorphPivot extends BaseMorphPivot
use HasObservers;
use HasTimestamps;

/**
* The built-in, primitive cast types supported by Eloquent.
*
* @var string[]
*/
protected static array $primitiveCastTypes = [
'array',
'bool',
'boolean',
'collection',
'custom_datetime',
'date',
'datetime',
'decimal',
'double',
'encrypted',
'encrypted:array',
'encrypted:collection',
'encrypted:json',
'encrypted:object',
'float',
'hashed',
'immutable_date',
'immutable_datetime',
'immutable_custom_datetime',
'int',
'integer',
'json',
'json:unicode',
'object',
'real',
'string',
'timestamp',
];

/**
* Delete the pivot model record from the database.
*
Expand Down
Loading