Skip to content
4 changes: 2 additions & 2 deletions src/database/src/Concerns/BuildsQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ protected function paginateUsingCursor(
$originalColumn ??= $this->getOriginalColumnNameForCursorPagination($this, $previousColumn);

$builder->where(
Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn,
$originalColumn !== $previousColumn && Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn,
'=',
$this->getCursorParameterBinding($cursor, $previousColumn)
);
Expand All @@ -542,7 +542,7 @@ protected function paginateUsingCursor(
$originalColumn = $this->getOriginalColumnNameForCursorPagination($this, $column);

$secondBuilder->where(
Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn,
$originalColumn !== $column && Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn,
$direction === 'asc' ? '>' : '<',
$this->getCursorParameterBinding($cursor, $column)
);
Expand Down
190 changes: 137 additions & 53 deletions src/database/src/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,25 @@ trait HasAttributes
*/
protected array $classCastCache = [];

/**
* The stored attributes each cached class cast was created from or last wrote.
*
* @var array<string, array<string, mixed>>
*/
protected array $classCastStoredValues = [];

/**
* The attributes that have been cast using "Attribute" return type mutators.
*/
protected array $attributeCastCache = [];

/**
* The stored attributes each cached accessor was created from or last wrote.
*
* @var array<string, array<string, mixed>>
*/
protected array $attributeCastStoredValues = [];

/**
* The cached merged casts array for this instance.
*/
Expand Down Expand Up @@ -731,8 +745,9 @@ protected function mutateAttributeMarkedAttribute(string $key, mixed $value): mi

if ($attribute->withCaching || (is_object($value) && $attribute->withObjectCaching)) {
$this->attributeCastCache[$key] = $value;
$this->attributeCastStoredValues[$key] = $this->attributes;
} else {
unset($this->attributeCastCache[$key]);
unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]);
}

return $value;
Expand Down Expand Up @@ -903,9 +918,12 @@ protected function getClassCastableAttributeValue(string $key, mixed $value): mi
if ($caster instanceof CastsInboundAttributes
|| ! is_object($value)
|| $objectCachingDisabled) {
unset($this->classCastCache[$key]);
unset($this->classCastCache[$key], $this->classCastStoredValues[$key]);
} else {
$this->classCastCache[$key] = $value;
// The first writeback identifies the cast's backing columns. Until then,
// retain the values it read without invoking its setter a second time.
$this->classCastStoredValues[$key] = $this->attributes;
}

return $value;
Expand Down Expand Up @@ -1108,6 +1126,8 @@ public function setAttribute(string|int $key, mixed $value): mixed

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

unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]);

return $this;
}

Expand Down Expand Up @@ -1149,7 +1169,13 @@ protected function setMutatedAttributeValue(string $key, mixed $value): mixed
{
$this->mergeAttributesFromCachedCasts();

return $this->{'set' . StrCache::studly($key) . 'Attribute'}($value);
$result = $this->{'set' . StrCache::studly($key) . 'Attribute'}($value);

// A legacy setter can refresh stored data after reading a cached accessor.
// Class casts reconcile their own writes so in-place mutations survive.
unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]);

return $result;
}

/**
Expand All @@ -1165,18 +1191,17 @@ protected function setAttributeMarkedMutatedAttributeValue(string $key, mixed $v
$this->attributes[$key] = $value;
};

$this->attributes = array_merge(
$this->attributes,
$this->normalizeCastClassResponse(
$key,
$callback($value, $this->attributes)
)
);
// Read attributes after the callback so its other writes and cast
// synchronizations are not replaced by an earlier argument snapshot.
$attributes = $this->normalizeCastClassResponse($key, $callback($value, $this->attributes));
$this->attributes = array_merge($this->attributes, $attributes);

if ($attribute->withCaching || (is_object($value) && $attribute->withObjectCaching)) {
// Setter-only inputs must not overwrite later changes to objects returned by class casts.
if ($attribute->get && ($attribute->withCaching || (is_object($value) && $attribute->withObjectCaching))) {
$this->attributeCastCache[$key] = $value;
$this->attributeCastStoredValues[$key] = $this->attributes;
} else {
unset($this->attributeCastCache[$key]);
unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]);
}

return $this;
Expand All @@ -1198,20 +1223,26 @@ public function fillJsonAttribute(string $key, mixed $value): static
{
[$key, $path] = explode('->', $key, 2);

$this->mergeAttributeFromCachedCasts($key);

$value = $this->castAttributeAsJson($key, $this->getArrayAttributeWithValue(
$path,
$key,
$value
));

$this->attributes[$key] = $this->isEncryptedCastable($key)
? $this->castAttributeAsEncryptedString($key, $value)
: $value;
$this->attributes[$key] = match (true) {
$this->isEncryptedCastable($key) => $this->castAttributeAsEncryptedString($key, $value),
$this->isEncryptedClassCastable($key) => Crypt::encryptString($value),
default => $value,
};

if ($this->isClassCastable($key)) {
unset($this->classCastCache[$key]);
unset($this->classCastCache[$key], $this->classCastStoredValues[$key]);
}

unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]);

return $this;
}

Expand All @@ -1222,22 +1253,20 @@ protected function setClassCastableAttribute(string $key, mixed $value): void
{
$caster = $this->resolveCasterClass($key);

$this->attributes = array_replace(
$this->attributes,
$this->normalizeCastClassResponse($key, $caster->set(
$this,
$key,
$value,
$this->attributes
))
);
// A caster can update other attributes while producing its stored values.
$attributes = $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes));
$this->attributes = array_replace($this->attributes, $attributes);

// A custom caster may read the accessor while preparing the new stored value.
unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]);

if ($caster instanceof CastsInboundAttributes
|| ! is_object($value)
|| ($caster->withoutObjectCaching ?? false)) {
unset($this->classCastCache[$key]);
unset($this->classCastCache[$key], $this->classCastStoredValues[$key]);
} else {
$this->classCastCache[$key] = $value;
$this->classCastStoredValues[$key] = $this->attributes;
}
}

Expand All @@ -1260,6 +1289,8 @@ protected function setEnumCastableAttribute(string $key, mixed $value): void
$this->getEnumCaseFromValue($enumClass, $value)
);
}

unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]);
}

/**
Expand Down Expand Up @@ -1309,11 +1340,11 @@ protected function getArrayAttributeByKey(string $key): array
return [];
}

return $this->fromJson(
$this->isEncryptedCastable($key)
? $this->fromEncryptedString($this->attributes[$key])
: $this->attributes[$key]
);
return $this->fromJson(match (true) {
$this->isEncryptedCastable($key) => $this->fromEncryptedString($this->attributes[$key]),
$this->isEncryptedClassCastable($key) => Crypt::decryptString($this->attributes[$key]),
default => $this->attributes[$key],
});
}

/**
Expand Down Expand Up @@ -1390,7 +1421,7 @@ protected function castAttributeAsEncryptedString(string $key, #[SensitiveParame
* Set the encrypter instance that will be used to encrypt attributes.
*
* Boot-only. The encrypter persists in a static property for the worker
* lifetime and is used by every encrypted attribute across all coroutines.
* lifetime and is used by primitive encrypted casts across all coroutines.
*/
public static function encryptUsing(?EncrypterContract $encrypter): void
{
Expand Down Expand Up @@ -1675,6 +1706,15 @@ protected function isEncryptedCastable(string $key): bool
return $this->castMetadataCache['encryptedCastable'][$key] ??= $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']);
}

/**
* Determine whether a value is an encrypted class castable for inbound manipulation.
*/
protected function isEncryptedClassCastable(string $key): bool
{
return $this->isClassCastable($key)
&& Str::startsWith($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class]);
}

/**
* Determine if the given key is cast using a custom class.
*
Expand Down Expand Up @@ -1869,12 +1909,13 @@ protected function mergeAttributeFromClassCasts(string $key): void
$value = $this->classCastCache[$key];
$caster = $this->resolveCasterClass($key);

$this->attributes = array_merge(
$this->attributes,
$caster instanceof CastsInboundAttributes
? [$key => $value]
: $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes))
);
$attributes = $caster instanceof CastsInboundAttributes
? [$key => $value]
: $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes));

if ($this->mergeCachedCastAttributes($attributes, $this->classCastStoredValues[$key])) {
unset($this->classCastCache[$key], $this->classCastStoredValues[$key]);
}
}

/**
Expand All @@ -1892,28 +1933,65 @@ protected function mergeAttributesFromAttributeCasts(): void
*/
protected function mergeAttributeFromAttributeCasts(string $key): void
{
if (! isset($this->attributeCastCache[$key])) {
if (! array_key_exists($key, $this->attributeCastCache)) {
return;
}

$value = $this->attributeCastCache[$key];
$attribute = $this->{StrCache::camel($key)}();
$attribute = $value === null ? null : $this->{StrCache::camel($key)}();

// Cached nulls and get-only accessors have no writeback. Refresh them when
// their raw input changes without invoking a setter or guessing dependencies.
if ($attribute === null || ($attribute->get && ! $attribute->set)) {
if (($this->attributes[$key] ?? null) !== ($this->attributeCastStoredValues[$key][$key] ?? null)) {
unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]);
}

if ($attribute->get && ! $attribute->set) {
return;
}

$callback = $attribute->set ?: function ($value) use ($key) {
$this->attributes[$key] = $value;
};

$this->attributes = array_merge(
$this->attributes,
$this->normalizeCastClassResponse(
$key,
$callback($value, $this->attributes)
)
);
// Preserve writes performed by the setter before merging its returned values.
$attributes = $this->normalizeCastClassResponse($key, $callback($value, $this->attributes));

if ($this->mergeCachedCastAttributes($attributes, $this->attributeCastStoredValues[$key])) {
unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]);
}
}

/**
* Merge cached cast values, advance their baseline, and indicate whether stored replacements took precedence.
*/
protected function mergeCachedCastAttributes(array $attributes, array &$storedValues): bool
{
$replaced = false;

// Replaced backing values take precedence over cached edits. Keep edits
// to other columns; unrelated attributes can change independently,
// including encrypted casts that are written back during reads.
foreach ($attributes as $column => $attribute) {
if (($this->attributes[$column] ?? null) !== ($storedValues[$column] ?? null)) {
unset($attributes[$column]);
$replaced = true;
}
}

$this->attributes = array_merge($this->attributes, $attributes);

if (! $replaced) {
// Partial responses retain omitted columns. Skip unchanged values so
// read-only writeback does not copy the shared attribute snapshot.
foreach ($attributes as $column => $attribute) {
if (($storedValues[$column] ?? null) !== $attribute) {
$storedValues[$column] = $attribute;
}
}
}

return $replaced;
}

/**
Expand Down Expand Up @@ -2005,13 +2083,16 @@ public function setRawAttributes(array $attributes, bool $sync = false): static
{
$this->attributes = $attributes;

// Synchronizing original values must not write back objects from the old row.
$this->classCastCache = [];
$this->classCastStoredValues = [];
$this->attributeCastCache = [];
$this->attributeCastStoredValues = [];

if ($sync) {
$this->syncOriginal();
}

$this->classCastCache = [];
$this->attributeCastCache = [];

return $this;
}

Expand Down Expand Up @@ -2182,7 +2263,9 @@ public function discardChanges(): static
[$this->attributes, $this->changes, $this->previous] = [$this->original, [], []];

$this->classCastCache = [];
$this->classCastStoredValues = [];
$this->attributeCastCache = [];
$this->attributeCastStoredValues = [];

return $this;
}
Expand Down Expand Up @@ -2365,8 +2448,9 @@ public function originalIsEquivalent(string $key): bool
return $current === $original;
}
if ($this->isClassCastable($key) && $original !== null && Str::startsWith($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class])) {
if (empty(static::currentEncrypter()->getPreviousKeys())) {
return $this->fromEncryptedString($attribute) === $this->fromEncryptedString($original);
// These casts use Crypt, independently of the model's custom encrypter.
if (empty(Crypt::getPreviousKeys())) {
return Crypt::decryptString($attribute) === Crypt::decryptString($original);
}

return false;
Expand Down
6 changes: 5 additions & 1 deletion src/database/src/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -3086,7 +3086,9 @@ public function offsetUnset($offset): void
$this->attributes[$offset],
$this->relations[$offset],
$this->attributeCastCache[$offset],
$this->classCastCache[$offset]
$this->attributeCastStoredValues[$offset],
$this->classCastCache[$offset],
$this->classCastStoredValues[$offset]
);
}

Expand Down Expand Up @@ -3173,7 +3175,9 @@ public function __sleep(): array
$this->mergeAttributesFromCachedCasts();

$this->classCastCache = [];
$this->classCastStoredValues = [];
$this->attributeCastCache = [];
$this->attributeCastStoredValues = [];
$this->flushCastCaches();
$this->relationAutoloadCallback = null;
$this->relationAutoloadContext = null;
Expand Down
Loading
Loading