diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 9a052ab9da..639c8b35ee 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -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) ); @@ -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) ); diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 562cb8a028..97a67be3de 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -104,11 +104,25 @@ trait HasAttributes */ protected array $classCastCache = []; + /** + * The stored attributes each cached class cast was created from or last wrote. + * + * @var array> + */ + 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> + */ + protected array $attributeCastStoredValues = []; + /** * The cached merged casts array for this instance. */ @@ -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; @@ -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; @@ -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; } @@ -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; } /** @@ -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; @@ -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; } @@ -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; } } @@ -1260,6 +1289,8 @@ protected function setEnumCastableAttribute(string $key, mixed $value): void $this->getEnumCaseFromValue($enumClass, $value) ); } + + unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]); } /** @@ -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], + }); } /** @@ -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 { @@ -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. * @@ -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]); + } } /** @@ -1892,14 +1933,20 @@ 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; } @@ -1907,13 +1954,44 @@ protected function mergeAttributeFromAttributeCasts(string $key): void $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; } /** @@ -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; } @@ -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; } @@ -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; diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index b5ae0d7c90..8ebf1d37b5 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -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] ); } @@ -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; diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index ba6973fe99..398db7d6a6 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -1938,7 +1938,7 @@ public function addWhereExistsQuery(self $query, string $boolean = 'and', bool $ } /** - * Adds a where condition using row values. + * Add a where condition using row values. * * @throws InvalidArgumentException */ @@ -1948,6 +1948,10 @@ public function whereRowValues(array $columns, string $operator, array $values, throw new InvalidArgumentException('The number of columns must match the number of values'); } + if ($this->invalidOperator($operator)) { + throw new InvalidArgumentException('Invalid operator passed to whereRowValues method.'); + } + $type = 'RowValues'; $this->wheres[] = ['type' => $type, 'columns' => $columns, 'operator' => $operator, 'values' => $values, 'boolean' => $boolean]; diff --git a/src/support/src/Traits/ReadsClassAttributes.php b/src/support/src/Traits/ReadsClassAttributes.php index a29e59c8bf..d7e23c1597 100644 --- a/src/support/src/Traits/ReadsClassAttributes.php +++ b/src/support/src/Traits/ReadsClassAttributes.php @@ -81,6 +81,7 @@ protected function propertyOverridesAttribute(object $target, ReflectionClass $r return $propertyReflection->isPublic() && $propertyReflection->isInitialized($target) + && $target->{$property} !== null && $propertyReflection->getDeclaringClass()->isSubclassOf($attributeDeclaringClass->getName()); } } diff --git a/tests/Bus/BusDispatcherTest.php b/tests/Bus/BusDispatcherTest.php index c3e6adb7a8..a12add6310 100644 --- a/tests/Bus/BusDispatcherTest.php +++ b/tests/Bus/BusDispatcherTest.php @@ -10,6 +10,7 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Queue\Queue; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Queue\Attributes\Connection; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Queue\InteractsWithQueue; @@ -62,6 +63,26 @@ public function testCommandsThatShouldQueueIsQueuedUsingCustomQueueAndDelay(): v $dispatcher->dispatch(new BusDispatcherTestSpecificQueueAndDelayCommand); } + public function testCommandsAreQueuedUsingAttributesInheritedFromParentClass(): void + { + $container = new Container; + $container->instance('queue.routes', new QueueRoutes); + Container::setInstance($container); + $usedConnection = null; + $dispatcher = new Dispatcher($container, function (?string $connection) use (&$usedConnection): Queue { + $usedConnection = $connection; + + $mock = m::mock(Queue::class); + $mock->expects('later')->with(10, m::type(BusDispatcherTestQueueableChildCommand::class), '', 'foo'); + + return $mock; + }); + + $dispatcher->dispatch(new BusDispatcherTestQueueableChildCommand); + + $this->assertSame('redis', $usedConnection); + } + public function testCommandsThatShouldQueueIsQueuedUsingQueueAndDelayAttributes(): void { $container = new Container; @@ -359,6 +380,18 @@ class BusDispatcherTestSpecificQueueAndDelayAttributeWithPropertyCommand impleme use Queueable; } +#[Connection('redis')] +#[QueueAttribute('foo')] +#[Delay(10)] +abstract class BusDispatcherTestParentCommandWithAttributes implements ShouldQueue +{ +} + +class BusDispatcherTestQueueableChildCommand extends BusDispatcherTestParentCommandWithAttributes +{ + use Queueable; +} + class BusDispatcherQueueable implements ShouldQueue { use Queueable; diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index d8be4103a2..f9394dfc4e 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -61,10 +61,12 @@ use Hypervel\Database\Query\Grammars\PostgresGrammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Database\SQLiteConnection; +use Hypervel\Encryption\Encrypter as ConcreteEncrypter; use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\ClassInvoker; use Hypervel\Support\Collection as BaseCollection; +use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Fluent; use Hypervel\Support\HtmlString; use Hypervel\Support\InteractsWithTime; @@ -1865,6 +1867,428 @@ public function testFillingJSONAttributes(): void ); } + public function testFillingJSONAttributesKeepsUnsavedChangesOnCastObjects(): void + { + $model = new CastingStub; + $model->setRawAttributes([ + 'asarrayobjectAttribute' => '{"foo":"bar"}', + 'ascollectionAttribute' => '{"foo":"bar"}', + 'asFluentAttribute' => '{"foo":"bar"}', + ]); + + $model->asarrayobjectAttribute['foo'] = 'baz'; + $model->ascollectionAttribute->put('foo', 'baz'); + $model->asFluentAttribute->foo = 'baz'; + + $model->forceFill([ + 'asarrayobjectAttribute->bar' => 'qux', + 'ascollectionAttribute->bar' => 'qux', + 'asFluentAttribute->bar' => 'qux', + ]); + + $this->assertSame(['foo' => 'baz', 'bar' => 'qux'], $model->asarrayobjectAttribute->getArrayCopy()); + $this->assertSame(['foo' => 'baz', 'bar' => 'qux'], $model->ascollectionAttribute->all()); + $this->assertSame(['foo' => 'baz', 'bar' => 'qux'], $model->asFluentAttribute->toArray()); + } + + public function testFillingJSONAttributesRefreshesObjectsCachedByAttributeAccessors(): void + { + $model = new class extends Model { + /** + * Get the settings attribute. + */ + protected function settings(): Attribute + { + return Attribute::make( + get: fn (string $value): Fluent => new Fluent(json_decode($value, true)), + set: fn (mixed $value): string|false => json_encode($value), + ); + } + }; + + $model->setRawAttributes(['settings' => '{"theme":"light"}'], true); + + $this->assertSame('light', $model->settings->theme); + + $model->forceFill(['settings->theme' => 'dark']); + + $this->assertSame('dark', $model->settings->theme); + $this->assertSame(['settings' => '{"theme":"dark"}'], $model->getDirty()); + } + + public function testSettingAttributesRefreshesObjectsCachedByGetOnlyAttributeAccessors(): void + { + $model = new class extends Model { + /** + * Get the settings attribute. + */ + protected function settings(): Attribute + { + return Attribute::get(fn (string $value): Fluent => new Fluent(json_decode($value, true))); + } + }; + + $model->setRawAttributes(['settings' => '{"theme":"light"}'], true); + + $this->assertSame('light', $model->settings->theme); + + $model->settings = '{"theme":"dark"}'; + + $this->assertSame('dark', $model->settings->theme); + } + + public function testSettingEnumAttributesRefreshesCachedGetOnlyAccessors(): void + { + $model = new class extends Model { + protected array $casts = ['status' => StringStatus::class]; + + /** + * Get the status attribute. + */ + protected function status(): Attribute + { + return Attribute::get(fn (string $value): Fluent => new Fluent(['value' => $value])); + } + }; + $model->setRawAttributes(['status' => 'draft'], true); + + $this->assertSame('draft', $model->status->value); + + $model->status = StringStatus::Done; + + $this->assertSame('done', $model->status->value); + $this->assertSame(['status' => 'done'], $model->getDirty()); + } + + public function testClassCastThatReadsAnAccessorDoesNotLeaveItsCachedValueStale(): void + { + $model = new class extends Model { + protected array $casts = ['settings' => MergingSettingsCast::class]; + + /** + * Get the settings attribute. + */ + protected function settings(): Attribute + { + return Attribute::get(fn (string $value): Fluent => new Fluent(json_decode($value, true))); + } + }; + $model->setRawAttributes(['settings' => '{"theme":"light","locale":"en"}'], true); + + $this->assertSame('light', $model->settings->theme); + + $model->settings = ['theme' => 'dark']; + + $this->assertSame(['theme' => 'dark', 'locale' => 'en'], $model->settings->toArray()); + $this->assertSame(['settings' => '{"theme":"dark","locale":"en"}'], $model->getDirty()); + } + + public function testLegacySetterThatReadsAnAccessorDoesNotLeaveItsCachedValueStale(): void + { + $model = new class extends Model { + /** + * Get the settings attribute. + */ + protected function settings(): Attribute + { + return Attribute::get(fn (string $value): Fluent => new Fluent(json_decode($value, true))); + } + + /** + * Merge the given settings with the existing values. + */ + public function setSettingsAttribute(array $value): void + { + $this->attributes['settings'] = json_encode(array_replace($this->settings->toArray(), $value)); + } + }; + $model->setRawAttributes(['settings' => '{"theme":"light","locale":"en"}'], true); + + $this->assertSame('light', $model->settings->theme); + + $model->settings = ['theme' => 'dark']; + + $this->assertSame(['theme' => 'dark', 'locale' => 'en'], $model->settings->toArray()); + $this->assertSame(['settings' => '{"theme":"dark","locale":"en"}'], $model->getDirty()); + } + + #[TestWith([false, false])] + #[TestWith([true, false])] + #[TestWith([false, true])] + #[TestWith([true, true])] + public function testMutatorsReplaceCachedClassCastValues(bool $legacy, bool $encrypted): void + { + Crypt::swap(new ConcreteEncrypter(str_repeat('a', 32), 'AES-256-CBC')); + + if ($legacy) { + $model = new class extends Model { + protected array $casts = ['settings' => AsArrayObject::class]; + + /** + * Set the settings attribute. + */ + public function setSettingsAttribute(array $value): void + { + $this->attributes['settings'] = $this->getCasts()['settings'] === AsEncryptedArrayObject::class + ? Crypt::encryptString(json_encode($value)) + : json_encode($value); + $this->attributes['selected_theme'] = $this->settings['theme']; + $this->settings['locale'] = 'fr'; + } + }; + } else { + $model = new class extends Model { + protected array $casts = ['settings' => AsArrayObject::class]; + + /** + * Set the settings attribute. + */ + protected function settings(): Attribute + { + return Attribute::set(fn (array $value): string|false => $this->getCasts()['settings'] === AsEncryptedArrayObject::class + ? Crypt::encryptString(json_encode($value)) + : json_encode($value)); + } + }; + } + + $model->mergeCasts(['settings' => $encrypted ? AsEncryptedArrayObject::class : AsArrayObject::class]); + $model->setRawAttributes(['settings' => $encrypted ? Crypt::encryptString('{"theme":"light"}') : '{"theme":"light"}'], true); + + $this->assertSame('light', $model->settings['theme']); + + $model->settings = ['theme' => 'dark']; + + $this->assertSame('dark', $model->settings['theme']); + if ($legacy) { + $this->assertSame('dark', $model->selected_theme); + $this->assertSame('fr', $model->settings['locale']); + } + + $stored = $model->getDirty()['settings']; + $this->assertSame( + $legacy ? '{"theme":"dark","locale":"fr"}' : '{"theme":"dark"}', + $encrypted ? Crypt::decryptString($stored) : $stored + ); + } + + #[TestWith([LegacySettingsMutationModel::class, false, false])] + #[TestWith([LegacySettingsMutationModel::class, true, false])] + #[TestWith([LegacySettingsMutationModel::class, false, true])] + #[TestWith([LegacySettingsMutationModel::class, true, true])] + #[TestWith([AttributeSettingsMutationModel::class, false, false])] + #[TestWith([AttributeSettingsMutationModel::class, true, false])] + #[TestWith([AttributeSettingsMutationModel::class, false, true])] + #[TestWith([AttributeSettingsMutationModel::class, true, true])] + public function testMutatorsKeepInPlaceClassCastEdits(string $modelClass, bool $encrypted, bool $readFirst): void + { + Crypt::swap(new ConcreteEncrypter(str_repeat('a', 32), 'AES-256-CBC')); + $model = new $modelClass; + $model->mergeCasts(['settings' => $encrypted ? AsEncryptedArrayObject::class : AsArrayObject::class]); + $model->setRawAttributes(['settings' => $encrypted ? Crypt::encryptString('{"theme":"light"}') : '{"theme":"light"}'], true); + $held = $readFirst ? $model->settings : null; + + $model->settings = ['theme' => 'dark']; + + $this->assertSame('dark', $model->settings['theme']); + $this->assertSame(7, $model->settings_updated_by); + if ($readFirst) { + $this->assertSame($held, $model->settings); + $held['locale'] = 'fr'; + } + + $stored = $model->getDirty()['settings']; + $this->assertSame( + $readFirst ? '{"theme":"dark","locale":"fr"}' : '{"theme":"dark"}', + $encrypted ? Crypt::decryptString($stored) : $stored + ); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testReplacingOneBackingColumnKeepsOtherCastEdits(bool $attribute): void + { + $model = $attribute ? new class extends Model { + /** + * Get and set the address columns. + */ + protected function address(): Attribute + { + return Attribute::make( + get: static fn (mixed $value, array $attributes): Address => new Address($attributes['address_line_one'], $attributes['address_line_two']), + set: static fn (Address $value): array => [ + 'address_line_one' => $value->lineOne, + 'address_line_two' => $value->lineTwo, + ], + ); + } + } : (new ModelStub)->mergeCasts(['address' => Address::class]); + $model->setRawAttributes(['address_line_one' => 'First', 'address_line_two' => 'Second'], true); + $address = $model->address; + $address->lineTwo = 'Unsaved'; + $model->address_line_one = 'Replacement'; + + $this->assertSame('Replacement', $model->address->lineOne); + $this->assertSame('Unsaved', $model->address->lineTwo); + $this->assertSame(['address_line_one' => 'Replacement', 'address_line_two' => 'Unsaved'], $model->getDirty()); + } + + #[TestWith([false, false])] + #[TestWith([false, true])] + #[TestWith([true, false])] + #[TestWith([true, true])] + public function testPartialCastWritebackKeepsTheBaselineForLaterEdits(bool $attribute, bool $assign): void + { + $model = $attribute ? new class extends Model { + /** + * Get the address and write only its changed columns. + */ + protected function address(): Attribute + { + return Attribute::make( + get: static fn (mixed $value, array $attributes): Address => new Address($attributes['address_line_one'], $attributes['address_line_two']), + set: static fn (Address $value, array $attributes): array => array_diff_assoc([ + 'address_line_one' => $value->lineOne, + 'address_line_two' => $value->lineTwo, + ], $attributes), + ); + } + } : (new ModelStub)->mergeCasts(['address' => PartialAddressCast::class]); + $model->setRawAttributes(['address_line_one' => 'First', 'address_line_two' => 'Second'], true); + if ($assign) { + $model->address = new Address('First', 'Second'); + } + $address = $model->address; + + $this->assertSame([], $model->getDirty()); + + $address->lineOne = 'Changed'; + $this->assertSame(['address_line_one' => 'Changed'], $model->getDirty()); + + $address->lineTwo = 'Later'; + $this->assertSame(['address_line_one' => 'Changed', 'address_line_two' => 'Later'], $model->getDirty()); + $this->assertSame($address, $model->address); + } + + #[TestWith([false, 'old'])] + #[TestWith([true, 'old'])] + #[TestWith([false, null])] + #[TestWith([true, null])] + public function testCachedAccessorsRefreshAfterAnotherSetterWritesTheirRawValue(bool $getOnly, ?string $initial): void + { + $model = new class extends Model { + public bool $getOnly = false; + + public int $setterCalls = 0; + + /** + * Get the slug, optionally allowing explicit updates. + */ + protected function slug(): Attribute + { + return Attribute::make( + get: static fn (?string $value): ?string => $value === null ? null : '/' . $value, + set: $this->getOnly ? null : function (string $value): string { + ++$this->setterCalls; + + return ltrim($value, '/'); + }, + )->shouldCache(); + } + + /** + * Set the name and its stored slug. + */ + public function setNameAttribute(string $value): void + { + $this->attributes['name'] = $value; + $this->attributes['slug'] = strtolower($value); + } + }; + $model->getOnly = $getOnly; + $model->setRawAttributes(['name' => 'Old', 'slug' => $initial], true); + $this->assertSame($initial === null ? null : '/old', $model->slug); + + $model->name = 'New'; + + if ($getOnly || $initial === null) { + $this->assertSame(0, $model->setterCalls); + } + $this->assertSame('/new', $model->slug); + $this->assertSame(['name' => 'New', 'slug' => 'new'], $model->getDirty()); + } + + public function testRawAttributeReplacementDoesNotWriteBackCachedObjects(): void + { + $model = (new ModelStub)->mergeCasts(['settings' => AsArrayObject::class]); + $model->setRawAttributes(['settings' => '{"theme":"light"}'], true); + $model->settings['theme'] = 'unsaved'; + + $model->setRawAttributes(['settings' => '{"theme":"dark"}'], true); + + $this->assertSame('dark', $model->settings['theme']); + $this->assertSame('{"theme":"dark"}', $model->getRawOriginal('settings')); + $this->assertSame([], $model->getDirty()); + } + + public function testCasterWritesToOtherAttributesSurviveAssignmentAndWriteback(): void + { + $model = (new ModelStub)->mergeCasts(['settings' => AuditedSettingsCast::class]); + $settings = new Fluent(['theme' => 'dark']); + $model->settings = $settings; + + $this->assertSame('dark', $model->settings_theme); + $settings->theme = 'light'; + $this->assertSame(['settings_theme' => 'light', 'settings' => '{"theme":"light"}'], $model->getAttributes()); + } + + public function testAttributeSetterWritesToOtherAttributesSurviveAssignmentAndWriteback(): void + { + $model = new class extends Model { + /** + * Get and set the settings attribute. + */ + protected function settings(): Attribute + { + return Attribute::make( + get: fn (string $value): Fluent => new Fluent(json_decode($value, true)), + set: function (Fluent $value): string|false { + $this->attributes['settings_theme'] = $value->theme; + + return json_encode($value); + }, + ); + } + }; + $settings = new Fluent(['theme' => 'dark']); + $model->settings = $settings; + + $this->assertSame('dark', $model->settings_theme); + $settings->theme = 'light'; + $this->assertSame(['settings_theme' => 'light', 'settings' => '{"theme":"light"}'], $model->getAttributes()); + } + + public function testSetOnlyMutatorDoesNotOverwriteChangesToClassCastObjects(): void + { + $model = new class extends Model { + protected array $casts = ['settings' => AsArrayObject::class]; + + /** + * Set the settings attribute. + */ + protected function settings(): Attribute + { + return Attribute::set(fn (ArrayObject $value): string|false => json_encode($value)); + } + }; + $model->setRawAttributes(['settings' => '{"theme":"light"}'], true); + + $model->settings = new ArrayObject(['theme' => 'dark']); + $model->settings['locale'] = 'fr'; + + $this->assertSame(['settings' => '{"theme":"dark","locale":"fr"}'], $model->getDirty()); + } + public function testUnguardAllowsAnythingToBeSet(): void { $model = new ModelStub; @@ -5425,6 +5849,88 @@ protected function serializeDate(DateTimeInterface $date): string } } +class LegacySettingsMutationModel extends Model +{ + /** + * Update the existing settings and record their author. + */ + public function setSettingsAttribute(array $value): void + { + $this->settings['theme'] = $value['theme']; + $this->attributes['settings_updated_by'] = 7; + } +} + +class AttributeSettingsMutationModel extends Model +{ + /** + * Update the existing settings and record their author. + */ + protected function settings(): Attribute + { + return Attribute::set(function (array $value): array { + $this->settings['theme'] = $value['theme']; + $this->attributes['settings_updated_by'] = 7; + + return []; + }); + } +} + +class PartialAddressCast implements CastsAttributes +{ + /** + * Return the address from its backing columns. + */ + public function get(Model $model, string $key, mixed $value, array $attributes): Address + { + return new Address($attributes['address_line_one'], $attributes['address_line_two']); + } + + /** + * Return only the address columns changed by this write. + */ + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + return array_diff_assoc([ + 'address_line_one' => $value->lineOne, + 'address_line_two' => $value->lineTwo, + ], $attributes); + } +} + +class AuditedSettingsCast implements CastsAttributes +{ + /** + * Return the stored settings. + */ + public function get(Model $model, string $key, mixed $value, array $attributes): Fluent + { + return new Fluent(json_decode($value, true)); + } + + /** + * Store the settings and update their indexed theme. + */ + public function set(Model $model, string $key, mixed $value, array $attributes): string|false + { + $model->settings_theme = $value->theme; + + return json_encode($value); + } +} + +class MergingSettingsCast implements CastsInboundAttributes +{ + /** + * Merge incoming settings with the model's current settings. + */ + public function set(Model $model, string $key, mixed $value, array $attributes): string|false + { + return json_encode(array_replace($model->{$key}->toArray(), $value)); + } +} + class EnumCastingStub extends Model { protected array $casts = ['enumAttribute' => StringStatus::class]; diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index e6914ebb24..a3f9717f7a 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -7494,6 +7494,37 @@ public function testCursorPaginateMultipleOrderColumns(): void ]), $result); } + public function testCursorPaginateWrapsOrderColumnsContainingParentheses(): void + { + $perPage = 16; + $columns = ['*']; + $cursorName = 'cursor-name'; + $column = 'id) or 1=1 or (id'; + $cursor = new Cursor([$column => 'bar', 'id' => 'foo']); + $builder = $this->getMockQueryBuilder(['get']); + $builder->from('foobar')->orderBy($column)->orderBy('id'); + + $path = 'http://foo.bar?cursor=' . $cursor->encode(); + + $results = collect([['id' => 1], ['id' => 2]]); + + $builder->expects('get')->andReturnUsing(function () use ($builder, $results): Collection { + $this->assertSame( + 'select * from "foobar" where ("id) or 1=1 or (id" > ? or ("id) or 1=1 or (id" = ? and ("id" > ?))) order by "id) or 1=1 or (id" asc, "id" asc limit 17', + $builder->toSql() + ); + $this->assertEquals(['bar', 'bar', 'foo'], $builder->bindings['where']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path): string { + return $path; + }); + + $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + } + public function testCursorPaginateWithDefaultArguments(): void { $perPage = 15; @@ -8104,6 +8135,14 @@ public function testWhereRowValuesArityMismatch(): void $builder->select('*')->from('orders')->whereRowValues(['last_update'], '<', [1, 2]); } + public function testWhereRowValuesInvalidOperator(): void + { + $this->expectExceptionObject(new InvalidArgumentException('Invalid operator passed to whereRowValues method.')); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->whereRowValues(['last_update', 'order_number'], '< (1, 2) or 1=1 or (1, 2) <', [1, 2]); + } + public function testWhereJsonContainsMySql(): void { $builder = $this->getMySqlBuilder(); diff --git a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php index e50b719ddd..e04353ef56 100644 --- a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php +++ b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php @@ -18,6 +18,7 @@ use Hypervel\Support\Facades\Schema; use Hypervel\Support\Fluent; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use stdClass; class EloquentModelEncryptedCastingTest extends DatabaseTestCase @@ -344,6 +345,66 @@ public function testAsEncryptedArrayObject(): void $this->assertNull($subject->fresh()->secret_array); } + public function testAsEncryptedArrayObjectJsonAttributeIsCastable(): void + { + $this->encrypter->allows('encryptString') + ->with('{"key1":"value1"}') + ->andReturn('encrypted-secret-array-string-1'); + $this->encrypter->allows('decryptString') + ->with('encrypted-secret-array-string-1') + ->andReturn('{"key1":"value1"}'); + $this->encrypter->allows('encryptString') + ->with('{"key1":"value1","key2":"value2"}') + ->andReturn('encrypted-secret-array-string-2'); + $this->encrypter->allows('decryptString') + ->with('encrypted-secret-array-string-2') + ->andReturn('{"key1":"value1","key2":"value2"}'); + + $subject = new EncryptedCast; + + $subject->mergeCasts(['secret_array' => AsEncryptedArrayObject::class]); + + $subject->secret_array = ['key1' => 'value1']; + $subject->fill(['secret_array->key2' => 'value2']); + $subject->save(); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $subject->secret_array->getArrayCopy()); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_array' => 'encrypted-secret-array-string-2', + ]); + } + + public function testAsEncryptedCollectionJsonAttributeIsCastable(): void + { + $this->encrypter->allows('encryptString') + ->with('{"key1":"value1"}') + ->andReturn('encrypted-secret-collection-string-1'); + $this->encrypter->allows('decryptString') + ->with('encrypted-secret-collection-string-1') + ->andReturn('{"key1":"value1"}'); + $this->encrypter->allows('encryptString') + ->with('{"key1":"value1","key2":"value2"}') + ->andReturn('encrypted-secret-collection-string-2'); + $this->encrypter->allows('decryptString') + ->with('encrypted-secret-collection-string-2') + ->andReturn('{"key1":"value1","key2":"value2"}'); + + $subject = new EncryptedCast; + + $subject->mergeCasts(['secret_collection' => AsEncryptedCollection::class]); + + $subject->secret_collection = new Collection(['key1' => 'value1']); + $subject->fill(['secret_collection->key2' => 'value2']); + $subject->save(); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $subject->secret_collection->all()); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => 'encrypted-secret-collection-string-2', + ]); + } + /** * @param class-string $model */ @@ -465,6 +526,33 @@ public static function encryptedJsonAttributes(): array ]; } + #[TestWith([false])] + #[TestWith([true])] + public function testEncryptedClassCastDirtyCheckingUsesItsOwnEncrypter(bool $rotatingKey): void + { + $encrypter = new RealEncrypter(str_repeat('a', 16)); + $encrypter->previousKeys($rotatingKey ? [str_repeat('c', 16)] : []); + Crypt::swap($encrypter); + Model::encryptUsing(new RealEncrypter(str_repeat('b', 16))); + + $subject = new EncryptedCast; + $subject->mergeCasts(['secret_array' => AsEncryptedArrayObject::class]); + $subject->secret_array = ['key1' => 'value1']; + $subject->save(); + + $subject->secret_array = ['key1' => 'value1']; + + $this->assertSame($rotatingKey, $subject->isDirty('secret_array')); + + $subject->fill(['secret_array->key2' => 'value2']); + $subject->save(); + + $this->assertSame( + '{"key1":"value1","key2":"value2"}', + $encrypter->decryptString($subject->fresh()->getRawOriginal('secret_array')), + ); + } + public function testCustomEncrypterCanBeSpecified(): void { $customEncrypter = $this->mock(Encrypter::class); diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php index da0e59e61e..e7f8d9313f 100644 --- a/tests/Integration/Routing/CompiledRouteCollectionTest.php +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -807,6 +807,53 @@ public function testTrailingSlashIsTrimmedWhenMatchingCachedRoutes(): void $this->assertSame('foo', $this->collection()->match($request)->getName()); } + public function testMatchingRootUriWhenServedFromSubdirectory(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $request = Request::create('http://example.com/app/', 'GET', [], [], [], [ + 'SCRIPT_NAME' => '/app/index.php', + 'PHP_SELF' => '/app/index.php', + 'SCRIPT_FILENAME' => '/var/www/public/index.php', + ]); + + $this->assertSame('foo', $this->collection()->match($request)->getName()); + } + + public function testMatchingRootUriWithRepeatedSlashes(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $this->assertSame('foo', $this->collection()->match(Request::create('http://example.com//'))->getName()); + + $request = Request::create('http://example.com/app//', 'GET', [], [], [], [ + 'SCRIPT_NAME' => '/app/index.php', + 'PHP_SELF' => '/app/index.php', + 'SCRIPT_FILENAME' => '/var/www/public/index.php', + ]); + + $this->assertSame('foo', $this->collection()->match($request)->getName()); + } + + public function testMatchingUriWhenServedFromSubdirectory(): void + { + $this->routeCollection->add( + $this->newRoute('GET', 'foo/bar', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $request = Request::create('http://example.com/app/foo/bar/', 'GET', [], [], [], [ + 'SCRIPT_NAME' => '/app/index.php', + 'PHP_SELF' => '/app/index.php', + 'SCRIPT_FILENAME' => '/var/www/public/index.php', + ]); + + $this->assertSame('foo', $this->collection()->match($request)->getName()); + } + public function testTrailingSlashWithQueryStringIsTrimmedWhenMatchingCachedRoutes(): void { $this->routeCollection->add(