From 95010c7233428b03fd9157038b04fd4ed5d4d7f2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:07:42 +0000 Subject: [PATCH 1/6] Add shared Eloquent create-or-first validation Allow custom Eloquent builders to validate uniqueness-dependent creation through one public ensureCanCreateOrFirst method. The default implementation imposes no restriction, preserving ordinary relational behavior and existing helper signatures. Invoke validation before reads, writes, or value callbacks in direct helpers and the ordinary, through, and many-to-many relationship implementations. Resolve the policy from the related builder so relationships with parents on another driver honor it as well. Keep savepoint handling, collision recovery, pivot behavior, and normal create/save/firstOrNew paths unchanged. Document the extension in the database guide and cover direct, polymorphic, through, and many-to-many helper dispatch. Let existing full-mock fixtures execute the default method without changing their assertions. Verified affected Eloquent unit tests, SQLite relationship and collision integration tests, formatting, and source and type-fixture analysis. --- src/database/src/Eloquent/Builder.php | 11 +++ .../src/Eloquent/Relations/BelongsToMany.php | 4 + .../src/Eloquent/Relations/HasOneOrMany.php | 4 + .../Relations/HasOneOrManyThrough.php | 4 + src/docs/database.md | 2 + ...aseEloquentCreateOrFirstValidationTest.php | 83 +++++++++++++++++++ .../Database/DatabaseEloquentHasManyTest.php | 1 + tests/Database/DatabaseEloquentMorphTest.php | 1 + 8 files changed, 110 insertions(+) create mode 100644 tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index fed1f350fe..2a73324418 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -644,6 +644,8 @@ public function firstOrNew(array $attributes = [], Closure|array $values = []): */ public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model { + $this->ensureCanCreateOrFirst(); + if (! is_null($instance = (clone $this)->where($attributes)->first())) { return $instance; } @@ -658,6 +660,8 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = [] */ public function createOrFirst(array $attributes = [], Closure|array $values = []): Model { + $this->ensureCanCreateOrFirst(); + try { return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)))); } catch (UniqueConstraintViolationException $e) { @@ -665,6 +669,13 @@ public function createOrFirst(array $attributes = [], Closure|array $values = [] } } + /** + * Validate first-or-create and create-or-first operations, including relationship calls. + */ + public function ensureCanCreateOrFirst(): void + { + } + /** * Create or update a record matching the attributes, and fill it with values. * diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 69e77829af..6e70b2b06d 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -596,6 +596,8 @@ public function firstOrNew(array $attributes = [], Closure|array $values = []): */ public function firstOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model { + $this->getQuery()->ensureCanCreateOrFirst(); + if (is_null($instance = (clone $this)->where($attributes)->first())) { if (is_null($instance = $this->related->where($attributes)->first())) { $instance = $this->createOrFirst($attributes, $values, $joining, $touch); @@ -620,6 +622,8 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = [] */ public function createOrFirst(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model { + $this->getQuery()->ensureCanCreateOrFirst(); + try { return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)), $joining, $touch)); } catch (UniqueConstraintViolationException $exception) { diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php index bd04c892fb..b77b4cfbfa 100755 --- a/src/database/src/Eloquent/Relations/HasOneOrMany.php +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -240,6 +240,8 @@ public function firstOrNew(array $attributes = [], Closure|array $values = []): */ public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model { + $this->getQuery()->ensureCanCreateOrFirst(); + if (is_null($instance = (clone $this)->where($attributes)->first())) { $instance = $this->createOrFirst($attributes, $values); } @@ -254,6 +256,8 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = [] */ public function createOrFirst(array $attributes = [], Closure|array $values = []): Model { + $this->getQuery()->ensureCanCreateOrFirst(); + try { return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)))); } catch (UniqueConstraintViolationException $e) { diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 10c383b3ba..0d445c9e18 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -205,6 +205,8 @@ public function firstOrNew(array $attributes = [], array $values = []): Model */ public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model { + $this->getQuery()->ensureCanCreateOrFirst(); + if (! is_null($instance = (clone $this)->where($attributes)->first())) { return $instance; } @@ -219,6 +221,8 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = [] */ public function createOrFirst(array $attributes = [], Closure|array $values = []): Model { + $this->getQuery()->ensureCanCreateOrFirst(); + try { return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)))); } catch (UniqueConstraintViolationException $exception) { diff --git a/src/docs/database.md b/src/docs/database.md index 08b61bcaad..2826d8f16d 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -370,6 +370,8 @@ Both execution methods construct database errors through the protected `newQuery Query builders with statement-level options may override the protected `Query\Builder::ensureCanEmbedQuery` method to reject options that belong on the outer statement. It runs when attaching a subquery, scalar or exists predicate, or union member; call the parent to preserve the built-in rejection of embedded timeouts. +Custom Eloquent builders may override the public `ensureCanCreateOrFirst(): void` method to reject helpers that depend on unique-constraint recovery. Both `firstOrCreate` and `createOrFirst` call it before reading or writing, including their relationship forms; `updateOrCreate` and `incrementOrCreate` reach it through those helpers. Relationships use the related model's builder, even when the parent uses another driver. The default method imposes no restriction, and ordinary `create`, `save`, and `firstOrNew` are unchanged. + The migration repository delegates its table definition to `Schema\Builder::createMigrationRepositoryTable`. A driver may override this method when it needs a different physical schema, while retaining the standard repository and migration commands. Its table must support storing migration names and integer batch numbers; the default definition also includes an auto-incrementing `id`. The native `DatabaseTruncation` testing trait delegates to `Schema\Builder::truncateTables` after applying its table filters. It passes the complete list of selected schema-qualified names with the connection's table prefix temporarily disabled. The default implementation checks for rows on the write connection and truncates non-empty tables through the query builder, so replica lag cannot skip cleanup. Drivers with engine-specific reset behavior may override this bulk method while keeping `getTables` accurate and using the native testing traits. If a selected table cannot be safely reset, throw an exception instead of silently leaving test data behind. diff --git a/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php b/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php new file mode 100644 index 0000000000..259b065b31 --- /dev/null +++ b/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php @@ -0,0 +1,83 @@ +shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('query')->andReturnUsing(fn () => new QueryBuilder($connection, new Grammar($connection), new Processor)); + $connection->shouldNotReceive('select'); + $connection->shouldNotReceive('insert'); + $connection->shouldNotReceive('update'); + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->andReturn($connection); + Model::setConnectionResolver($resolver); + $parent = new CreationValidationParent(['id' => 1]); + $related = CreationValidationModel::class; + $query = match ($relation) { + 'direct' => (new $related)->newQuery(), + 'hasOne' => $parent->hasOne($related, 'parent_id'), + 'hasMany' => $parent->hasMany($related, 'parent_id'), + 'morphOne' => $parent->morphOne($related, 'parent'), + 'morphMany' => $parent->morphMany($related, 'parent'), + 'hasOneThrough' => $parent->hasOneThrough($related, CreationValidationParent::class, 'parent_id', 'through_id'), + 'hasManyThrough' => $parent->hasManyThrough($related, CreationValidationParent::class, 'parent_id', 'through_id'), + 'belongsToMany' => $parent->belongsToMany($related, 'parent_related', 'parent_id', 'related_id', relation: 'related'), + 'morphToMany' => $parent->morphToMany($related, 'parent', 'parent_related', 'parent_id', 'related_id', relation: 'related'), + }; + $values = $method === 'updateOrCreate' ? [] : function (): never { + $this->fail('The value callback must not run before validation.'); + }; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Create-or-first is unavailable for this builder.'); + $query->{$method}(['id' => 2], $values); + } + + public static function creationHelpers(): iterable + { + foreach (['direct', 'hasOne', 'hasMany', 'morphOne', 'morphMany', 'hasOneThrough', 'hasManyThrough', 'belongsToMany', 'morphToMany'] as $relation) { + foreach (['firstOrCreate', 'createOrFirst', 'updateOrCreate'] as $method) { + yield $relation . ' ' . $method => [$relation, $method]; + } + } + } +} + +class CreationValidationParent extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} + +class CreationValidationModel extends CreationValidationParent +{ + protected static string $builder = CreationValidationBuilder::class; +} + +class CreationValidationBuilder extends Builder +{ + public function ensureCanCreateOrFirst(): never + { + throw new LogicException('Create-or-first is unavailable for this builder.'); + } +} diff --git a/tests/Database/DatabaseEloquentHasManyTest.php b/tests/Database/DatabaseEloquentHasManyTest.php index 615c0b3b54..4cdb7d0048 100755 --- a/tests/Database/DatabaseEloquentHasManyTest.php +++ b/tests/Database/DatabaseEloquentHasManyTest.php @@ -382,6 +382,7 @@ protected function getRelation() { $queryBuilder = m::mock(QueryBuilder::class); $builder = m::mock(Builder::class, [$queryBuilder]); + $builder->shouldReceive('ensureCanCreateOrFirst')->passthru(); $builder->shouldReceive('whereNotNull')->with('table.foreign_key'); $builder->shouldReceive('where')->with('table.foreign_key', '=', 1); $related = m::mock(Model::class); diff --git a/tests/Database/DatabaseEloquentMorphTest.php b/tests/Database/DatabaseEloquentMorphTest.php index fa3f365ca9..fa510f95a6 100755 --- a/tests/Database/DatabaseEloquentMorphTest.php +++ b/tests/Database/DatabaseEloquentMorphTest.php @@ -478,6 +478,7 @@ protected function getOneRelation() { $queryBuilder = m::mock(QueryBuilder::class); $builder = m::mock(Builder::class, [$queryBuilder]); + $builder->shouldReceive('ensureCanCreateOrFirst')->passthru(); $builder->shouldReceive('whereNotNull')->once()->with('table.morph_id'); $builder->shouldReceive('where')->once()->with('table.morph_id', '=', 1); $related = m::mock(Model::class); From ea272d0ac7acfb436d268e8f62ed48a9c0e658a0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:42:19 +0000 Subject: [PATCH 2/6] Preserve custom builder types through Eloquent forwarding Resolve forwarded query methods from the Eloquent builder's declared getQuery return type, and resolve relationship methods from the related model's declared query builder. Bind generic query row signatures to the model while retaining other active template arguments and leaving direct raw-query types unchanged. Preserve native method and named-scope precedence, honor passthru defaults on custom Eloquent builders, and distinguish discarded query results from terminal returns. Reuse the existing fluent reflection for receiving-object results and retain bound method reflections for terminals, without changing runtime dispatch. Rename the forwarding extension to reflect its broader responsibility, document custom builder typing, and add max-level type fixtures and focused runtime tests for custom clauses, model subclasses, callbacks, scope collisions, passthru behavior, fixed query types, and relationship decoration. --- src/database/extension.neon | 2 +- ...hp => ForwardedBuilderMethodExtension.php} | 153 +++++++--- src/docs/database.md | 2 + .../Eloquent/CustomBuilderForwardingTest.php | 163 ++++++++++ ...> ForwardedBuilderMethodExtensionTest.php} | 7 +- .../Eloquent/CustomBuilderForwarding.php | 283 ++++++++++++++++++ 6 files changed, 560 insertions(+), 50 deletions(-) rename src/database/src/PHPStan/{ForwardedFluentMethodExtension.php => ForwardedBuilderMethodExtension.php} (58%) create mode 100644 tests/Database/Eloquent/CustomBuilderForwardingTest.php rename tests/Database/PHPStan/{ForwardedFluentMethodExtensionTest.php => ForwardedBuilderMethodExtensionTest.php} (58%) create mode 100644 types/Database/Eloquent/CustomBuilderForwarding.php diff --git a/src/database/extension.neon b/src/database/extension.neon index 0905e50497..425d29e9c0 100644 --- a/src/database/extension.neon +++ b/src/database/extension.neon @@ -2,7 +2,7 @@ services: - class: Hypervel\Database\PHPStan\ModelScopeMethodResolver - - class: Hypervel\Database\PHPStan\ForwardedFluentMethodExtension + class: Hypervel\Database\PHPStan\ForwardedBuilderMethodExtension tags: - phpstan.broker.methodsClassReflectionExtension - diff --git a/src/database/src/PHPStan/ForwardedFluentMethodExtension.php b/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php similarity index 58% rename from src/database/src/PHPStan/ForwardedFluentMethodExtension.php rename to src/database/src/PHPStan/ForwardedBuilderMethodExtension.php index d098d4bffd..0849afc0b7 100644 --- a/src/database/src/PHPStan/ForwardedFluentMethodExtension.php +++ b/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php @@ -6,7 +6,6 @@ use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Relations\Relation; -use Hypervel\Database\Query\Builder as QueryBuilder; use LogicException; use PHPStan\Analyser\OutOfClassScope; use PHPStan\Reflection\ClassReflection; @@ -19,9 +18,9 @@ use PHPStan\Type\Type; /** - * Preserve the receiving Eloquent builder or relation through fluent forwarding. + * Resolve declared builders while preserving Eloquent and relation forwarding. */ -class ForwardedFluentMethodExtension implements MethodsClassReflectionExtension +class ForwardedBuilderMethodExtension implements MethodsClassReflectionExtension { /** @var list */ private const array RELATION_NON_DECORATED_METHODS = [ @@ -35,8 +34,8 @@ class ForwardedFluentMethodExtension implements MethodsClassReflectionExtension 'withcan', ]; - /** @var null|list */ - private ?array $passthru = null; + /** @var array> */ + private array $passthru = []; /** @var array */ private array $methods = []; @@ -44,15 +43,17 @@ class ForwardedFluentMethodExtension implements MethodsClassReflectionExtension private readonly OutOfClassScope $scope; /** - * Create a forwarded fluent method extension. + * Create a forwarded builder method extension. */ - public function __construct(private readonly ReflectionProvider $reflectionProvider) - { + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly ModelScopeMethodResolver $scopeMethods, + ) { $this->scope = new OutOfClassScope; } /** - * Determine whether the class exposes the forwarded fluent method. + * Determine whether the class exposes the forwarded builder method. */ public function hasMethod(ClassReflection $classReflection, string $methodName): bool { @@ -60,20 +61,20 @@ public function hasMethod(ClassReflection $classReflection, string $methodName): } /** - * Return the forwarded fluent method. + * Return the forwarded builder method. */ public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection { return $this->resolveMethod($classReflection, $methodName) ?? throw new LogicException(sprintf( - 'Forwarded fluent method [%s::%s] was not resolved.', + 'Forwarded builder method [%s::%s] was not resolved.', $classReflection->getName(), $methodName, )); } /** - * Resolve and cache a forwarded fluent method. + * Resolve and cache a forwarded builder method. */ private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection { @@ -109,70 +110,87 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN } /** - * Resolve a fluent method forwarded by an Eloquent builder. + * Resolve a query method forwarded by an Eloquent builder. */ private function resolveEloquentBuilderMethod( ClassReflection $classReflection, string $methodName, ): ?MethodReflection { - if (in_array(strtolower($methodName), $this->passthruMethods(), strict: true)) { + $modelType = $this->templateType($classReflection, EloquentBuilder::class, 'TModel'); + + if ($this->hasNamedScope($modelType, $methodName)) { return null; } - $queryBuilder = $this->reflectionProvider->getClass(QueryBuilder::class); + $queryType = $this->queryBuilderType($classReflection, $modelType); + $queryClasses = $queryType->getObjectClassReflections(); - if (! $queryBuilder->hasNativeMethod($methodName)) { + if (count($queryClasses) !== 1 || ! $queryClasses[0]->hasNativeMethod($methodName)) { return null; } - if (! $this->returnsStatic($queryBuilder->getNativeMethod($methodName))) { + $method = $queryType->getMethod($methodName, $this->scope); + + if (! $method->isPublic()) { return null; } - $modelType = $this->templateType($classReflection, EloquentBuilder::class, 'TModel'); - $method = $this->queryBuilderType($modelType)->getMethod($methodName, $this->scope); + if (in_array(strtolower($methodName), $this->passthruMethods($classReflection), strict: true)) { + return $method; + } + // Eloquent discards ordinary forwarded results, regardless of their declared type. return new ForwardedFluentMethodReflection($classReflection, $method); } /** - * Resolve a fluent method forwarded by a relation. + * Resolve a builder method forwarded by a relation. */ private function resolveRelationMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection { $normalizedMethodName = strtolower($methodName); $relatedType = $this->templateType($classReflection, Relation::class, 'TRelatedModel'); - $eloquentBuilder = $this->reflectionProvider->getClass(EloquentBuilder::class); + $builderType = $this->eloquentBuilderType($relatedType); + $builderClasses = $builderType->getObjectClassReflections(); + + if (count($builderClasses) !== 1) { + return null; + } + + $eloquentBuilder = $builderClasses[0]; if ($eloquentBuilder->hasNativeMethod($methodName)) { - if (in_array($normalizedMethodName, self::RELATION_NON_DECORATED_METHODS, strict: true) - || ! $this->returnsStatic($eloquentBuilder->getNativeMethod($methodName))) { + $method = $builderType->getMethod($methodName, $this->scope); + + if (! $method->isPublic()) { return null; } - $method = $this->eloquentBuilderType($relatedType)->getMethod($methodName, $this->scope); + if (in_array($normalizedMethodName, self::RELATION_NON_DECORATED_METHODS, strict: true) + || ! $this->returnsStatic($eloquentBuilder->getNativeMethod($methodName))) { + return $method; + } return new ForwardedFluentMethodReflection($classReflection, $method); } - $queryBuilder = $this->reflectionProvider->getClass(QueryBuilder::class); - - if ($queryBuilder->hasNativeMethod($methodName)) { - if (in_array(strtolower($methodName), $this->passthruMethods(), strict: true) - || ! $this->returnsStatic($queryBuilder->getNativeMethod($methodName))) { - return null; - } + if ($this->hasNamedScope($relatedType, $methodName)) { + return null; + } - $method = $this->queryBuilderType($relatedType)->getMethod($methodName, $this->scope); + $method = $this->resolveEloquentBuilderMethod($eloquentBuilder, $methodName); - return new ForwardedFluentMethodReflection($classReflection, $method); + if ($method !== null) { + return $method instanceof ForwardedFluentMethodReflection + ? new ForwardedFluentMethodReflection($classReflection, $method) + : $method; } if (! in_array($normalizedMethodName, self::RELATION_DOCUMENTED_FLUENT_METHODS, strict: true)) { return null; } - $method = $this->eloquentBuilderType($relatedType)->getMethod($methodName, $this->scope); + $method = $builderType->getMethod($methodName, $this->scope); return new ForwardedFluentMethodReflection($classReflection, $method); } @@ -182,19 +200,19 @@ private function resolveRelationMethod(ClassReflection $classReflection, string * * @return list */ - private function passthruMethods(): array + private function passthruMethods(ClassReflection $builderClass): array { - if ($this->passthru === null) { + $className = $builderClass->getName(); + + if (! isset($this->passthru[$className])) { /** @var list $passthru */ - $passthru = $this->reflectionProvider - ->getClass(EloquentBuilder::class) - ->getNativeReflection() + $passthru = $builderClass->getNativeReflection() ->getDefaultProperties()['passthru']; - $this->passthru = $passthru; + $this->passthru[$className] = $passthru; } - return $this->passthru; + return $this->passthru[$className]; } /** @@ -249,19 +267,62 @@ private function templateType( } /** - * Create a generic Eloquent builder type. + * Resolve the related model's declared builder, retaining late-static model types. */ - private function eloquentBuilderType(Type $modelType): GenericObjectType + private function eloquentBuilderType(Type $modelType): Type { + $modelClasses = $modelType->getObjectClassReflections(); + + if (count($modelClasses) === 1) { + $modelClass = $modelClasses[0]; + $variants = $modelClass->getMethod('query', $this->scope)->getVariants(); + + return ModelScopeTypeResolver::bindToModel($variants[0]->getReturnType(), $modelClass); + } + return new GenericObjectType(EloquentBuilder::class, [$modelType]); } /** - * Create a generic query builder type. + * Bind forwarded query signatures to model rows without changing raw-query types. + */ + private function queryBuilderType(ClassReflection $builderClass, Type $modelType): Type + { + $variants = $builderClass->getNativeMethod('getQuery')->getVariants(); + $queryType = $variants[0]->getReturnType(); + $queryClasses = $queryType->getObjectClassReflections(); + + if (count($queryClasses) !== 1) { + return $queryType; + } + + $queryClass = $queryClasses[0]; + $templates = $queryClass->getTemplateTypeMap(); + + if ($templates->getType('TKey') === null || $templates->getType('TValue') === null) { + return $queryType; + } + + $types = $queryClass->getActiveTemplateTypeMap()->map( + static fn (string $name, Type $type): Type => match ($name) { + 'TKey' => new IntegerType, + 'TValue' => $modelType, + default => $type, + }, + ); + + return new GenericObjectType($queryClass->getName(), $queryClass->typeMapToList($types)); + } + + /** + * Determine whether a named scope owns dispatch before query forwarding. */ - private function queryBuilderType(Type $modelType): GenericObjectType + private function hasNamedScope(Type $modelType, string $methodName): bool { - return new GenericObjectType(QueryBuilder::class, [new IntegerType, $modelType]); + $modelClasses = $modelType->getObjectClassReflections(); + + return count($modelClasses) === 1 + && $this->scopeMethods->resolve($modelClasses[0], $methodName) !== null; } /** diff --git a/src/docs/database.md b/src/docs/database.md index 2826d8f16d..5cc03de78e 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -398,6 +398,8 @@ includes: A scope that declares no return type, or declares `void`, `null`, or the query builder, stays chainable. Declaring a broader type such as `mixed` or `object` tells the analyzer the scope may return something else, so that type is preserved. When a scope declares a union containing the query builder, such as `Builder|int`, the builder becomes the chainable receiver and the remaining types are kept. +For a [custom Eloquent builder](/docs/{{version}}/eloquent#custom-eloquent-builders), use the `HasBuilder` trait on the model with `@use HasBuilder>`. The extension follows the model's declared `query()` return type, including through relationships. If your Eloquent builder also uses a custom query builder, declare that type on `getQuery()`. Forwarded methods retain the Eloquent builder or relationship when they are chainable, while custom Eloquent terminal methods retain their result types. Query builders declaring `TKey` and `TValue` templates preserve model-valued callback signatures during forwarding; direct `getQuery()` and `toBase()` calls keep their raw-row types. + ## Running SQL Queries diff --git a/tests/Database/Eloquent/CustomBuilderForwardingTest.php b/tests/Database/Eloquent/CustomBuilderForwardingTest.php new file mode 100644 index 0000000000..6b1d8cd103 --- /dev/null +++ b/tests/Database/Eloquent/CustomBuilderForwardingTest.php @@ -0,0 +1,163 @@ +builder(new ForwardingTestModel); + + $this->assertSame($builder, $builder->tenant('one')); + $this->assertSame('one', $builder->getQuery()->wheres[0]['value']); + $this->assertSame($builder, $builder->ignoredAnswer()); + $this->assertSame(42, $builder->rawAnswer()); + $this->assertSame($builder->getModel(), $builder->models()->sole()); + } + + public function testRelationsDecorateFluentResultsButRetainTerminalsAndClonedBuilders(): void + { + $builder = $this->builder(new ForwardingTestModel); + $parent = new ForwardingTestModel; + $parent->setRawAttributes(['id' => 1]); + $relation = new HasMany($builder, $parent, 'items.parent_id', 'id'); + + $this->assertSame($relation, $relation->tenant('one')->published()); + $this->assertSame($relation, $relation->ignoredAnswer()); + $this->assertSame(42, $relation->rawAnswer()); + $this->assertSame($builder->getModel(), $relation->models()->sole()); + $clone = $relation->clone(); + $this->assertInstanceOf(ForwardingTestBuilder::class, $clone); + $this->assertNotSame($builder, $clone); + $this->assertNotSame($builder->getQuery(), $clone->getQuery()); + } + + public function testScopesWinOverQueryForwardingButNotNativeEloquentOrRelationMethods(): void + { + $builder = $this->builder(new ForwardingTestScopedModel); + $parent = new ForwardingTestModel; + $parent->exists = true; + $relation = new HasMany($builder, $parent, 'items.parent_id', 'id'); + + $this->assertSame(5, $builder->limit('scope')); + $this->assertSame(5, $relation->offset('scope')); + $this->assertSame(99, $builder->rawAnswer()); + $this->assertSame(99, $relation->rawAnswer()); + $this->assertSame($builder, $builder->published()); + $this->assertSame($relation, $relation->published()); + $this->assertSame($relation, $relation->limit(1)); + // The native relation method ignores the scalar returned by its inner scope call. + $this->assertNull($builder->getQuery()->limit); + } + + /** + * Construct real forwarding objects without an external database. + */ + private function builder(Model $model): ForwardingTestBuilder + { + $connection = m::mock(Connection::class); + $query = new ForwardingTestQuery($connection, new Grammar($connection), new Processor); + + return (new ForwardingTestBuilder($query))->setModel($model); + } +} + +class ForwardingTestQuery extends QueryBuilder +{ + /** + * Add a custom query-only predicate. + */ + public function tenant(string $tenant): static + { + return $this->where('tenant_id', $tenant); + } + + /** + * Return an explicitly passed-through scalar. + */ + public function rawAnswer(): int + { + return 42; + } + + /** + * Return a scalar that ordinary forwarding discards. + */ + public function ignoredAnswer(): int + { + return 42; + } +} + +class ForwardingTestBuilder extends Builder +{ + protected array $passthru = ['rawanswer']; + + /** + * Return a native fluent result. + */ + public function published(): static + { + return $this; + } + + /** + * Return a native terminal result without running a query. + */ + public function models(): Collection + { + return new Collection([$this->getModel()]); + } +} + +class ForwardingTestModel extends Model +{ +} + +class ForwardingTestScopedModel extends ForwardingTestModel +{ + /** + * Override a query-only method with a scalar-returning scope. + */ + public function scopeLimit(Builder $query, int|string $label): int + { + return is_int($label) ? $label : strlen($label); + } + + /** + * Override a method that the relation does not own. + */ + public function scopeOffset(Builder $query, string $label): int + { + return strlen($label); + } + + /** + * Remain subordinate to the builder's native method. + */ + public function scopePublished(Builder $query): int + { + return 1; + } + + /** + * Take precedence over a passthru declaration. + */ + public function scopeRawAnswer(Builder $query): int + { + return 99; + } +} diff --git a/tests/Database/PHPStan/ForwardedFluentMethodExtensionTest.php b/tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php similarity index 58% rename from tests/Database/PHPStan/ForwardedFluentMethodExtensionTest.php rename to tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php index 9f6c9a619c..a3c2e1fa1b 100644 --- a/tests/Database/PHPStan/ForwardedFluentMethodExtensionTest.php +++ b/tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php @@ -4,17 +4,18 @@ namespace Hypervel\Tests\Database\PHPStan; -use Hypervel\Database\PHPStan\ForwardedFluentMethodExtension; +use Hypervel\Database\PHPStan\ForwardedBuilderMethodExtension; +use Hypervel\Database\PHPStan\ModelScopeMethodResolver; use Hypervel\Tests\TestCase; use PHPStan\Reflection\ReflectionProvider; -class ForwardedFluentMethodExtensionTest extends TestCase +class ForwardedBuilderMethodExtensionTest extends TestCase { public function testDoesNotReflectClassesDuringConstruction(): void { $reflectionProvider = $this->createMock(ReflectionProvider::class); $reflectionProvider->expects($this->never())->method('getClass'); - new ForwardedFluentMethodExtension($reflectionProvider); + new ForwardedBuilderMethodExtension($reflectionProvider, new ModelScopeMethodResolver); } } diff --git a/types/Database/Eloquent/CustomBuilderForwarding.php b/types/Database/Eloquent/CustomBuilderForwarding.php new file mode 100644 index 0000000000..871e209749 --- /dev/null +++ b/types/Database/Eloquent/CustomBuilderForwarding.php @@ -0,0 +1,283 @@ +', User::tenant('one')); + assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', Admin::tenant('one')); + assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', $user->tenant('one')); + assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', User::query()->selectRaw('id', record: true)); + assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', User::query()->ignoredAnswer()); + assertType('int', User::query()->rawAnswer()); + assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', User::query()->dump()); + assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', User::query()->getQuery()); + assertType('Hypervel\Support\Collection', User::tenant('one')->models()); + assertType('Hypervel\Support\Collection', Admin::tenant('one')->models()); + + User::query()->tenant('one')->each(function ($model, $key): void { + assertType('Hypervel\Types\CustomBuilderForwarding\User', $model); + assertType('int', $key); + }); + + User::query()->inspectRow(function ($model): void { + assertType('Hypervel\Types\CustomBuilderForwarding\User', $model); + }); + + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->children()->tenant('one')->published()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->children()->ignoredAnswer()); + assertType('Hypervel\Support\Collection', $user->children()->tenant('one')->models()); + assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', $user->children()->clone()); + assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', $user->children()->applyScopes()); + assertType('int', $user->children()->rawAnswer()); + assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', $user->children()->dump()); + + assertType('int', ScopedUser::query()->limit('scope')); + assertType('int', ScopedUser::limit('scope')); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $scoped->children()->limit(1)); + assertType('int', $scoped->children()->offset('scope')); + assertType('string', ScopedUser::query()->rawAnswer('scope')); + assertType('string', $scoped->children()->rawAnswer('scope')); + assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', ScopedUser::query()->published()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $scoped->children()->published()); + + assertType('Hypervel\Types\CustomBuilderForwarding\FixedBuilder', FixedUser::tenant('one')); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $fixed->children()->tenant('one')); + + FixedUser::query()->inspectRow(function ($row): void { + assertType('stdClass', $row); + }); +} + +class User extends Model +{ + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = UserBuilder::class; + + /** + * Build a relationship retaining the concrete model type. + * + * @return HasMany + */ + public function children(): HasMany + { + return $this->hasMany(static::class, 'parent_id'); + } + + /** + * Create the custom query used by this model. + */ + protected function newBaseQueryBuilder(): UserQuery + { + return new UserQuery($this->getConnection()); + } +} + +class Admin extends User +{ +} + +class ScopedUser extends User +{ + /** + * Override query-only dispatch with a scope of a different signature. + * + * @param UserBuilder $query + */ + public function scopeLimit(UserBuilder $query, string $label): int + { + return strlen($label); + } + + /** + * Override a query-only method that is not owned by the relation either. + * + * @param UserBuilder $query + */ + public function scopeOffset(UserBuilder $query, string $label): int + { + return strlen($label); + } + + /** + * Override a passthru method with a scope-specific signature and result. + * + * @param UserBuilder $query + */ + public function scopeRawAnswer(UserBuilder $query, string $label): string + { + return $label; + } + + /** + * Remain subordinate to a real Eloquent builder method of the same name. + * + * @param UserBuilder $query + */ + public function scopePublished(UserBuilder $query): int + { + return 1; + } +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class UserBuilder extends Builder +{ + /** @var UserQuery */ + protected QueryBuilder $query; + + protected array $passthru = ['rawanswer', 'dump']; + + /** + * Return the declared raw query. + */ + public function getQuery(): UserQuery + { + return $this->query; + } + + /** + * Apply a custom fluent model constraint. + */ + public function published(): static + { + return $this->whereNotNull('published_at'); + } + + /** + * Return a custom model-valued terminal result. + * + * @return Collection + */ + public function models(): Collection + { + return $this->get(); + } +} + +/** + * @template TKey of array-key = int + * @template TValue = stdClass + * @template TOption of string = 'fixture' + * + * @extends QueryBuilder + */ +class UserQuery extends QueryBuilder +{ + /** + * Add a query-only fluent method with an additional template default. + * + * @param TOption $option + */ + public function tenant(string $tenant, string $option = 'fixture'): static + { + return $this->where('tenant_id', $tenant); + } + + /** + * Extend an inherited signature with a named option. + * + * @param array $bindings + */ + public function selectRaw(string $expression, array $bindings = [], bool $record = false): static + { + return parent::selectRaw($expression, $bindings); + } + + /** + * Describe a callback receiving the active forwarded row type. + * + * @param callable(TValue): void $callback + */ + public function inspectRow(callable $callback): static + { + return $this; + } + + /** + * Return a value explicitly passed through the custom Eloquent builder. + */ + public function rawAnswer(): int + { + return 42; + } + + /** + * Return a value that ordinary Eloquent forwarding deliberately discards. + */ + public function ignoredAnswer(): int + { + return 42; + } +} + +/** @extends UserQuery */ +class FixedQuery extends UserQuery +{ +} + +/** + * @template TModel of Model + * + * @extends UserBuilder + */ +class FixedBuilder extends UserBuilder +{ + /** @var FixedQuery */ + protected QueryBuilder $query; + + /** + * Return a non-generic concrete query builder. + */ + public function getQuery(): FixedQuery + { + return $this->query; + } +} + +class FixedUser extends Model +{ + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = FixedBuilder::class; + + /** + * Create the fixed raw query used by this model. + */ + protected function newBaseQueryBuilder(): FixedQuery + { + return new FixedQuery($this->getConnection()); + } + + /** + * Build a relationship using the fixed query builder. + * + * @return HasMany + */ + public function children(): HasMany + { + return $this->hasMany(static::class, 'parent_id'); + } +} From cbb7867afa6c9783c6319d93680b03e00892ef77 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:10:46 +0000 Subject: [PATCH 3/6] Support PostgreSQL generated column changes and correct removal ordering Compile restated generated expressions using PostgreSQL SET EXPRESSION rather than rejecting all expression changes. Keep generated clauses ahead of ordinary column alterations so expression removal happens before SET or DROP DEFAULT. Omit the implicit default removal only when a generated expression is restated; explicit contradictory defaults still receive the native database error. Allow null in the existing storedAs and virtualAs annotations and verify fluent base and custom column types. Add exact SQL coverage and native regression tests for expression recalculation, removal, type changes, retained values, new defaults, ordinary writes, and invalid conversions. Document the PostgreSQL version requirements and the upstream combined expression/type constraint-cleanup defect. Keep native SQL unchanged and retain issue-linked regression skips for affected constraints, with working nullable and PostgreSQL 17 non-null cases enabled. Update the existing Blueprint snapshot to reflect the corrected clause ordering. --- src/database/src/Schema/ColumnDefinition.php | 4 +- .../src/Schema/Grammars/PostgresGrammar.php | 17 ++- src/docs/migrations.md | 9 +- .../DatabasePostgresSchemaGrammarTest.php | 60 ++++++++ .../Postgres/PostgresSchemaBuilderTest.php | 133 ++++++++++++++++++ .../Sqlite/DatabaseSchemaBlueprintTest.php | 2 +- types/Database/Schema.php | 6 + 7 files changed, 223 insertions(+), 8 deletions(-) diff --git a/src/database/src/Schema/ColumnDefinition.php b/src/database/src/Schema/ColumnDefinition.php index 40a9381dd9..ac872e1340 100644 --- a/src/database/src/Schema/ColumnDefinition.php +++ b/src/database/src/Schema/ColumnDefinition.php @@ -30,13 +30,13 @@ * @method $this spatialIndex(bool|string $indexName = null) Add a spatial index * @method $this vectorIndex(bool|string $indexName = null) Add a vector index * @method $this startingValue(int $startingValue) Set the starting value of an auto-incrementing field (MySQL/PostgreSQL) - * @method $this storedAs(\Hypervel\Contracts\Database\Query\Expression|string $expression) Create a stored generated column (MySQL/PostgreSQL/SQLite) + * @method $this storedAs(null|\Hypervel\Contracts\Database\Query\Expression|string $expression) Create a stored generated column (MySQL/PostgreSQL/SQLite) * @method $this type(string $type) Specify a type for the column * @method $this unique(bool|string $indexName = null) Add a unique index * @method $this unsigned() Set the INTEGER column as UNSIGNED (MySQL) * @method $this useCurrent() Set the TIMESTAMP column to use CURRENT_TIMESTAMP as default value * @method $this useCurrentOnUpdate() Set the TIMESTAMP column to use CURRENT_TIMESTAMP when updating (MySQL) - * @method $this virtualAs(\Hypervel\Contracts\Database\Query\Expression|string $expression) Create a virtual generated column (MySQL/PostgreSQL/SQLite) + * @method $this virtualAs(null|\Hypervel\Contracts\Database\Query\Expression|string $expression) Create a virtual generated column (MySQL/PostgreSQL/SQLite) */ class ColumnDefinition extends Fluent { diff --git a/src/database/src/Schema/Grammars/PostgresGrammar.php b/src/database/src/Schema/Grammars/PostgresGrammar.php index 27208b74b7..cdb6b3ede8 100755 --- a/src/database/src/Schema/Grammars/PostgresGrammar.php +++ b/src/database/src/Schema/Grammars/PostgresGrammar.php @@ -8,7 +8,6 @@ use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Collection; use Hypervel\Support\Fluent; -use LogicException; use Override; class PostgresGrammar extends Grammar @@ -260,7 +259,12 @@ public function compileChange(Blueprint $blueprint, Fluent $command): array|stri $constraints = (array) $this->{$method}($blueprint, $column); foreach ($constraints as $constraint) { - $changes[] = $constraint; + // Keep generated clauses first so DROP EXPRESSION precedes SET/DROP DEFAULT. + if ($modifier === 'VirtualAs' || $modifier === 'StoredAs') { + array_unshift($changes, $constraint); + } else { + $changes[] = $constraint; + } } } } @@ -992,6 +996,11 @@ protected function modifyNullable(Blueprint $blueprint, Fluent $column): string protected function modifyDefault(Blueprint $blueprint, Fluent $column): ?string { if ($column->change) { + // A restated generated expression cannot have an implicit DROP DEFAULT. + if ($column->default === null && ($column->storedAs !== null || $column->virtualAs !== null)) { + return null; + } + if (! $column->autoIncrement || ! is_null($column->generatedAs)) { return is_null($column->default) ? 'drop default' : 'set default ' . $this->getDefaultValue($column->default); } @@ -1030,7 +1039,7 @@ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column): ?strin if (array_key_exists('virtualAs', $column->getAttributes())) { return is_null($column->virtualAs) ? 'drop expression if exists' - : throw new LogicException('This database driver does not support modifying generated columns.'); + : "set expression as ({$this->getValue($column->virtualAs)})"; } return null; @@ -1052,7 +1061,7 @@ protected function modifyStoredAs(Blueprint $blueprint, Fluent $column): ?string if (array_key_exists('storedAs', $column->getAttributes())) { return is_null($column->storedAs) ? 'drop expression if exists' - : throw new LogicException('This database driver does not support modifying generated columns.'); + : "set expression as ({$this->getValue($column->storedAs)})"; } return null; diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 6d3ff8a855..ff6c066b8f 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -1330,7 +1330,7 @@ The following table contains all of the available column modifiers. This list do | `->unsigned()` | Set `INTEGER` columns as `UNSIGNED` (MariaDB / MySQL). | | `->useCurrent()` | Set `TIMESTAMP` columns to use `CURRENT_TIMESTAMP` as default value. | | `->useCurrentOnUpdate()` | Set `TIMESTAMP` columns to use `CURRENT_TIMESTAMP` when a record is updated (MariaDB / MySQL). | -| `->virtualAs($expression)` | Create a virtual generated column (MariaDB / MySQL / SQLite). | +| `->virtualAs($expression)` | Create a virtual generated column (MariaDB / MySQL / PostgreSQL 18+ / SQLite). | | `->generatedAs($expression)` | Create an identity column with specified sequence options (PostgreSQL). | | `->always()` | Defines the precedence of sequence values over input for an identity column (PostgreSQL). | @@ -1440,6 +1440,13 @@ $table->bigIncrements('id')->primary()->change(); $table->char('postal_code', 10)->unique(false)->change(); ``` +On PostgreSQL 17 and later, you may change a stored generated expression using `storedAs($expression)->change()`. Virtual generated expressions may be changed using `virtualAs($expression)->change()` on PostgreSQL 18 and later. Changing a stored expression recalculates the column's existing values. + +> [!WARNING] +> A [PostgreSQL bug](https://www.postgresql.org/message-id/CACJufxHZsgn3zM5g-x7YmtFGzNDnRwR07S%2BGYfiUs%2BtZ45MDDw@mail.gmail.com) can make these expression changes fail on columns with CHECK constraints, or NOT NULL constraints on PostgreSQL 18. For affected columns, issue `ALTER TABLE ... ALTER COLUMN ... SET EXPRESSION AS (...)` separately through `DB::statement()` instead of `change()`. + +To turn a stored generated column into an ordinary column on PostgreSQL 13 and later, use `storedAs(null)->change()`. Existing values are preserved, and you may specify a new default in the same definition. PostgreSQL does not support removing a virtual column's expression. + ### Renaming Columns diff --git a/tests/Database/DatabasePostgresSchemaGrammarTest.php b/tests/Database/DatabasePostgresSchemaGrammarTest.php index 119a1fa161..b6a81af8a5 100755 --- a/tests/Database/DatabasePostgresSchemaGrammarTest.php +++ b/tests/Database/DatabasePostgresSchemaGrammarTest.php @@ -1181,6 +1181,66 @@ public function testAddingStoredAs() ], $statements); } + #[TestWith(['storedAs'])] + #[TestWith(['virtualAs'])] + public function testRemovingGeneratedExpressionsPrecedesDefaultChanges(string $modifier): void + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('value')->{$modifier}(null)->change(); + + $this->assertSame([ + 'alter table "users" alter column "value" drop expression if exists, alter column "value" type integer, alter column "value" set not null, alter column "value" drop default, alter column "value" drop identity if exists', + 'comment on column "users"."value" is NULL', + ], $blueprint->toSql()); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('value')->{$modifier}(null)->default(7)->nullable()->change(); + + $this->assertSame([ + 'alter table "users" alter column "value" drop expression if exists, alter column "value" type integer, alter column "value" drop not null, alter column "value" set default \'7\', alter column "value" drop identity if exists', + 'comment on column "users"."value" is NULL', + ], $blueprint->toSql()); + } + + #[TestWith(['storedAs'])] + #[TestWith(['virtualAs'])] + public function testChangingGeneratedExpressionsDoesNotDropDefaults(string $modifier): void + { + foreach (['source * 10', new Expression('source * 10')] as $expression) { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigInteger('value')->{$modifier}($expression)->change(); + + $this->assertSame([ + 'alter table "users" alter column "value" set expression as (source * 10), alter column "value" type bigint, alter column "value" set not null, alter column "value" drop identity if exists', + 'comment on column "users"."value" is NULL', + ], $blueprint->toSql()); + } + } + + #[TestWith(['storedAs'])] + #[TestWith(['virtualAs'])] + public function testGeneratedExpressionChangesDoNotDiscardExplicitDefaults(string $modifier): void + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('value')->{$modifier}('source * 10')->default(7)->change(); + + $this->assertSame([ + 'alter table "users" alter column "value" set expression as (source * 10), alter column "value" type integer, alter column "value" set not null, alter column "value" set default \'7\', alter column "value" drop identity if exists', + 'comment on column "users"."value" is NULL', + ], $blueprint->toSql()); + } + + public function testOrdinaryChangesKeepDefaultRemovalAndClauseOrder(): void + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('value')->change(); + + $this->assertSame([ + 'alter table "users" alter column "value" type integer, alter column "value" set not null, alter column "value" drop default, alter column "value" drop identity if exists', + 'comment on column "users"."value" is NULL', + ], $blueprint->toSql()); + } + public function testAddingIpAddress() { $blueprint = new Blueprint($this->getConnection(), 'users'); diff --git a/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php b/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php index 4faab8c6f4..fc2014cd18 100644 --- a/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php +++ b/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php @@ -13,6 +13,7 @@ use Hypervel\Testbench\Attributes\RequiresDatabase; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\TestWith; #[RequiresOperatingSystem('Linux|Darwin')] #[RequiresPhpExtension('pdo_pgsql')] @@ -180,6 +181,138 @@ public function testAddTableCommentOnExistingTable() $this->assertEquals('This is a new comment', DB::selectOne("select obj_description('public.posts'::regclass, 'pg_class')")->obj_description); } + #[RequiresDatabase('pgsql', '>=13')] + #[TestWith(['storedAs'])] + #[TestWith(['virtualAs'])] + public function testRemovingStoredExpressionsPreservesRowsAndAllowsOrdinaryWrites(string $modifier): void + { + Schema::create('generated_records', function (Blueprint $table): void { + $table->integer('source'); + $table->integer('value')->storedAs('source * 2'); + $table->integer('label')->storedAs('source * 3'); + }); + DB::table('generated_records')->insert(['source' => 2]); + + Schema::table('generated_records', function (Blueprint $table) use ($modifier): void { + $table->integer('value')->{$modifier}(null)->change(); + $table->text('label')->{$modifier}(null)->default('new')->change(); + }); + + $this->assertSame(['source' => 2, 'value' => 4, 'label' => '6'], (array) DB::table('generated_records')->first()); + $columns = collect(Schema::getColumns('generated_records'))->keyBy('name'); + $this->assertNull($columns['value']['generation']); + $this->assertNull($columns['value']['default']); + $this->assertNull($columns['label']['generation']); + $this->assertSame('text', $columns['label']['type_name']); + + DB::table('generated_records')->insert(['source' => 3, 'value' => 11]); + $this->assertSame(['source' => 3, 'value' => 11, 'label' => 'new'], (array) DB::table('generated_records')->where('source', 3)->first()); + DB::table('generated_records')->where('source', 2)->update(['value' => 12, 'label' => 'changed']); + $this->assertSame(['source' => 2, 'value' => 12, 'label' => 'changed'], (array) DB::table('generated_records')->where('source', 2)->first()); + } + + #[RequiresDatabase('pgsql', '>=13')] + public function testRemovingAnAbsentExpressionAllowsANewDefault(): void + { + Schema::create('ordinary_records', function (Blueprint $table): void { + $table->integer('value')->default(1); + }); + DB::table('ordinary_records')->insert(['value' => 2]); + + Schema::table('ordinary_records', function (Blueprint $table): void { + $table->integer('value')->storedAs(null)->default(7)->change(); + }); + + DB::statement('insert into ordinary_records default values'); + $this->assertSame([2, 7], DB::table('ordinary_records')->orderBy('value')->pluck('value')->all()); + } + + #[RequiresDatabase('pgsql', '>=17')] + #[TestWith(['storedAs', 'stored', false])] + #[TestWith(['virtualAs', 'virtual', false])] + #[TestWith(['storedAs', 'stored', true])] + #[TestWith(['virtualAs', 'virtual', true])] + public function testChangingGeneratedExpressionsRecalculatesValues(string $modifier, string $generation, bool $nullable): void + { + if ($generation === 'virtual' && version_compare($this->getConnection()->getServerVersion(), '18', '<')) { + $this->markTestSkipped('Virtual generated columns require PostgreSQL 18.'); + } + + if (! $nullable && version_compare($this->getConnection()->getServerVersion(), '18', '>=')) { + // @TODO Enable after the PostgreSQL double constraint-cleanup fix ships and is verified: + // https://www.postgresql.org/message-id/CACJufxHZsgn3zM5g-x7YmtFGzNDnRwR07S%2BGYfiUs%2BtZ45MDDw@mail.gmail.com + $this->markTestSkipped('PostgreSQL cannot combine expression and type changes with an existing NOT NULL constraint.'); + } + + Schema::create('generated_records', function (Blueprint $table) use ($modifier, $nullable): void { + $table->integer('source'); + $table->integer('value')->nullable($nullable)->{$modifier}('source * 2'); + }); + DB::table('generated_records')->insert(['source' => 2]); + + Schema::table('generated_records', function (Blueprint $table) use ($modifier, $nullable): void { + $table->integer('value')->nullable($nullable)->{$modifier}('source * 10')->change(); + }); + $this->assertSame(20, DB::table('generated_records')->value('value')); + + Schema::table('generated_records', function (Blueprint $table) use ($modifier, $nullable): void { + $table->bigInteger('value')->nullable($nullable)->{$modifier}(DB::raw('source * 20'))->change(); + }); + DB::table('generated_records')->insert(['source' => 3]); + + $this->assertSame([40, 60], DB::table('generated_records')->orderBy('source')->pluck('value')->all()); + $column = collect(Schema::getColumns('generated_records'))->firstWhere('name', 'value'); + $this->assertSame('int8', $column['type_name']); + $this->assertSame($generation, $column['generation']['type']); + $this->assertNull($column['default']); + } + + #[RequiresDatabase('pgsql', '>=17')] + public function testChangingGeneratedExpressionsPreservesCheckConstraints(): void + { + // @TODO Enable after the PostgreSQL double constraint-cleanup fix ships and is verified: + // https://www.postgresql.org/message-id/CACJufxHZsgn3zM5g-x7YmtFGzNDnRwR07S%2BGYfiUs%2BtZ45MDDw@mail.gmail.com + $this->markTestSkipped('PostgreSQL cannot combine expression and type changes with an existing CHECK constraint.'); + + DB::statement('create table generated_records (source integer, value integer generated always as (source * 2) stored check (value > 0))'); + DB::table('generated_records')->insert(['source' => 2]); + + Schema::table('generated_records', function (Blueprint $table): void { + $table->bigInteger('value')->nullable()->storedAs('source * 10')->change(); + }); + + $this->assertSame(20, DB::table('generated_records')->value('value')); + $this->expectException(QueryException::class); + $this->expectExceptionMessage('violates check constraint'); + DB::table('generated_records')->insert(['source' => -1]); + } + + #[RequiresDatabase('pgsql', '>=17')] + #[TestWith([false])] + #[TestWith([true])] + public function testInvalidGeneratedExpressionChangesRetainNativeErrors(bool $generated): void + { + Schema::create('generated_records', function (Blueprint $table) use ($generated): void { + $table->integer('source'); + $column = $table->integer('value')->nullable(); + + if ($generated) { + $column->storedAs('source * 2'); + } + }); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage($generated ? 'is a generated column' : 'is not a generated column'); + + Schema::table('generated_records', function (Blueprint $table) use ($generated): void { + $column = $table->integer('value')->storedAs('source * 10')->change(); + + if ($generated) { + $column->default(5); + } + }); + } + public function testWithoutForeignKeyConstraintsNestsUntilTheOuterScopeRestoresImmediateChecks(): void { Schema::create('constraint_parents', function (Blueprint $table): void { diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php index 90bf3a69ff..58138b5e8b 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php @@ -139,10 +139,10 @@ public function testNativeColumnModifyingOnPostgreSql(): void $this->assertEquals([ 'alter table "users" ' + . 'alter column "added_at" drop expression if exists, ' . 'alter column "added_at" type timestamp(2) without time zone, ' . 'alter column "added_at" set not null, ' . 'alter column "added_at" set default CURRENT_TIMESTAMP, ' - . 'alter column "added_at" drop expression if exists, ' . 'alter column "added_at" drop identity if exists', 'comment on column "users"."added_at" is NULL', ], $blueprint->toSql()); diff --git a/types/Database/Schema.php b/types/Database/Schema.php index 5f9c56bb7a..ca9ee24d65 100644 --- a/types/Database/Schema.php +++ b/types/Database/Schema.php @@ -17,6 +17,10 @@ function testColumnDefinitionsUseTheDefaultType(Blueprint $table): void assertType('Hypervel\Database\Schema\ColumnDefinition', $table->string('name')); assertType('Hypervel\Database\Schema\ColumnDefinition', $table->softDeletes()->nullable()); assertType('Hypervel\Support\Collection', $table->timestamps()); + assertType('Hypervel\Database\Schema\ColumnDefinition', $table->timestamp('created_at')->useCurrent()->storedAs(null)); + assertType('Hypervel\Database\Schema\ColumnDefinition', $table->timestamp('created_at')->useCurrent()->storedAs(null)->change()); + assertType('Hypervel\Database\Schema\ColumnDefinition', $table->integer('value')->virtualAs(null)); + assertType('Hypervel\Database\Schema\ColumnDefinition', $table->integer('value')->virtualAs(null)->change()); } /** @@ -27,6 +31,8 @@ function testCustomColumnDefinitionsUseTheFactoryType(CustomBlueprint $table): v assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->string('name')->nullable()->label('Display name')); assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->unsignedBigInteger('count')); assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->softDeletes()); + assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->timestamp('created_at')->storedAs(null)->change()->label('Created')); + assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->integer('value')->virtualAs(null)->change()->label('Value')); assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->addColumn('string', 'title')); assertType('Hypervel\Support\Collection', $table->timestamps()); assertType('Hypervel\Support\Collection', $table->datetimes()); From 4a6fabf6937d8eb6aba181cfc85a9869e28ac923 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:08:29 +0000 Subject: [PATCH 4/6] Add a shared schema metadata read extension point Route schema inspection through a protected selectMetadata method so driver-specific schema builders can apply execution policy without copying the public discovery methods. The default continues to read from the write connection and leaves grammar SQL, prefixes, result processing and fallback discovery unchanged. Keep table-existence scalar validation, including empty results and rejection of multi-column responses. Include SQLite's table, view and schema-state list reads in the shared path while preserving its internal scalar and session reads. Document the read extension beside schema execution and migration hooks. Cover inherited discovery, derived checks, both SQLite table-listing branches, schema-state reads and writer routing; update built-in existence fixtures for the raw-row boundary. --- src/database/src/Schema/Builder.php | 32 +++++-- src/database/src/Schema/SQLiteBuilder.php | 10 +-- src/docs/database.md | 2 + .../DatabaseMariaDbSchemaBuilderTest.php | 2 +- .../DatabaseMySQLSchemaBuilderTest.php | 2 +- .../Database/DatabasePostgresBuilderTest.php | 10 +-- .../DatabasePostgresSchemaBuilderTest.php | 2 +- .../DatabaseSQLiteSchemaMetadataTest.php | 89 +++++++++++++++++++ tests/Database/DatabaseSchemaBuilderTest.php | 83 +++++++++++++++++ 9 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 tests/Database/DatabaseSQLiteSchemaMetadataTest.php diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index 8f2d30eb69..86f0e3dbf9 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Container\Container; use Hypervel\Database\Connection; +use Hypervel\Database\MultipleColumnsSelectedException; use Hypervel\Database\PostgresConnection; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; @@ -156,7 +157,7 @@ public function dropDatabaseIfExists(string $name): bool public function getSchemas(): array { return $this->connection->getPostProcessor()->processSchemas( - $this->connection->selectFromWriteConnection($this->grammar->compileSchemas()) + $this->selectMetadata($this->grammar->compileSchemas()) ); } @@ -170,8 +171,13 @@ public function hasTable(string $table): bool $table = $this->connection->getTablePrefix() . $table; if ($sql = $this->grammar->compileTableExists($schema, $table)) { - // Schema existence must be read from the same write connection that migrations mutate. - return (bool) $this->connection->scalar($sql, [], false); + $record = (array) ($this->selectMetadata($sql)[0] ?? []); + + if (count($record) > 1) { + throw new MultipleColumnsSelectedException; + } + + return (bool) array_first($record); } foreach ($this->getTables($schema ?? $this->getCurrentSchemaName()) as $value) { @@ -210,7 +216,7 @@ public function hasView(string $view): bool public function getTables(array|string|null $schema = null): array { return $this->connection->getPostProcessor()->processTables( - $this->connection->selectFromWriteConnection($this->grammar->compileTables($schema)) + $this->selectMetadata($this->grammar->compileTables($schema)) ); } @@ -235,7 +241,7 @@ public function getTableListing(array|string|null $schema = null, bool $schemaQu public function getViews(array|string|null $schema = null): array { return $this->connection->getPostProcessor()->processViews( - $this->connection->selectFromWriteConnection($this->grammar->compileViews($schema)) + $this->selectMetadata($this->grammar->compileViews($schema)) ); } @@ -247,7 +253,7 @@ public function getViews(array|string|null $schema = null): array public function getTypes(array|string|null $schema = null): array { return $this->connection->getPostProcessor()->processTypes( - $this->connection->selectFromWriteConnection($this->grammar->compileTypes($schema)) + $this->selectMetadata($this->grammar->compileTypes($schema)) ); } @@ -358,7 +364,7 @@ public function getColumns(string $table): array $table = $this->connection->getTablePrefix() . $table; return $this->connection->getPostProcessor()->processColumns( - $this->connection->selectFromWriteConnection( + $this->selectMetadata( $this->grammar->compileColumns($schema, $table) ) ); @@ -376,7 +382,7 @@ public function getIndexes(string $table): array $table = $this->connection->getTablePrefix() . $table; return $this->connection->getPostProcessor()->processIndexes( - $this->connection->selectFromWriteConnection( + $this->selectMetadata( $this->grammar->compileIndexes($schema, $table) ) ); @@ -455,7 +461,7 @@ public function getForeignKeys(string $table): array $table = $this->connection->getTablePrefix() . $table; return $this->connection->getPostProcessor()->processForeignKeys( - $this->connection->selectFromWriteConnection( + $this->selectMetadata( $this->grammar->compileForeignKeys($schema, $table) ) ); @@ -728,6 +734,14 @@ protected function executeStatements(array $statements): void } } + /** + * Read schema metadata from the same write connection that migrations mutate. + */ + protected function selectMetadata(string $query): array + { + return $this->connection->selectFromWriteConnection($query); + } + /** * Determine whether every executable command is declared by the framework grammar. * diff --git a/src/database/src/Schema/SQLiteBuilder.php b/src/database/src/Schema/SQLiteBuilder.php index ea74aff6e2..2325c8afdb 100644 --- a/src/database/src/Schema/SQLiteBuilder.php +++ b/src/database/src/Schema/SQLiteBuilder.php @@ -124,7 +124,7 @@ public function getTables(array|string|null $schema = null): array $tables = []; foreach (Arr::wrap($schema) as $name) { - $tables = array_merge($tables, $this->connection->selectFromWriteConnection( + $tables = array_merge($tables, $this->selectMetadata( $this->grammar->compileLegacyTables($name, $withSize) )); } @@ -133,7 +133,7 @@ public function getTables(array|string|null $schema = null): array } return $this->connection->getPostProcessor()->processTables( - $this->connection->selectFromWriteConnection( + $this->selectMetadata( $this->grammar->compileTables($schema, $withSize) ) ); @@ -147,7 +147,7 @@ public function getViews(array|string|null $schema = null): array $views = []; foreach (Arr::wrap($schema) as $name) { - $views = array_merge($views, $this->connection->selectFromWriteConnection( + $views = array_merge($views, $this->selectMetadata( $this->grammar->compileViews($name) )); } @@ -172,7 +172,7 @@ public function getColumnsForSchemaState(string $table): array [$schema, $table] = $this->parseSchemaAndTable($table); $table = $this->connection->getTablePrefix() . $table; - $columns = $this->connection->selectFromWriteConnection($this->grammar->compileColumns($schema, $table)); + $columns = $this->selectMetadata($this->grammar->compileColumns($schema, $table)); // Rebuild guards must inspect the stored definition on the same write PDO as the columns. $sql = $this->connection->scalar($this->grammar->compileSqlCreateStatement($schema, $table), [], false) ?? ''; @@ -201,7 +201,7 @@ public function getIndexesForSchemaState(string $table): array $processor = $this->connection->getPostProcessor(); return $processor->processIndexesForSchemaState( - $this->connection->selectFromWriteConnection( + $this->selectMetadata( $this->grammar->compileIndexes($schema, $table) ) ); diff --git a/src/docs/database.md b/src/docs/database.md index 5cc03de78e..ee36fb265e 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -374,6 +374,8 @@ Custom Eloquent builders may override the public `ensureCanCreateOrFirst(): void The migration repository delegates its table definition to `Schema\Builder::createMigrationRepositoryTable`. A driver may override this method when it needs a different physical schema, while retaining the standard repository and migration commands. Its table must support storing migration names and integer batch numbers; the default definition also includes an auto-incrementing `id`. +Schema builders may override the protected `selectMetadata(string $query): array` method to customize metadata execution without copying the public inspection methods. The default reads from the write connection, so schema checks observe the same database that migrations modify. It covers schema, table, view, type, column, index, and foreign-key list reads, including built-in builder overrides and the checks derived from them. Driver-internal scalar reads, such as SQLite's stored table definition and session pragmas, remain on their own execution paths. Return the raw rows expected by the driver's processor. For schema writes, the protected `executeStatements(array $statements): void` method executes compiled statements in order and throws if a statement returns false. + The native `DatabaseTruncation` testing trait delegates to `Schema\Builder::truncateTables` after applying its table filters. It passes the complete list of selected schema-qualified names with the connection's table prefix temporarily disabled. The default implementation checks for rows on the write connection and truncates non-empty tables through the query builder, so replica lag cannot skip cleanup. Drivers with engine-specific reset behavior may override this bulk method while keeping `getTables` accurate and using the native testing traits. If a selected table cannot be safely reset, throw an exception instead of silently leaving test data behind. To add column modifiers, a `Schema\Blueprint` subclass may override the protected `newColumnDefinition(array $attributes)` method and return its own `ColumnDefinition` subclass. Declare `@extends Blueprint` on the Blueprint subclass so static analysis recognizes the custom return type from inherited helpers such as `string`, `unsignedBigInteger`, and `timestamps`. Foreign-ID helpers retain their specialized definition and constraint methods. Column-list accessors continue to return base definitions because a Blueprint may contain several definition types. diff --git a/tests/Database/DatabaseMariaDbSchemaBuilderTest.php b/tests/Database/DatabaseMariaDbSchemaBuilderTest.php index 50a5755aae..b656d4b4a6 100755 --- a/tests/Database/DatabaseMariaDbSchemaBuilderTest.php +++ b/tests/Database/DatabaseMariaDbSchemaBuilderTest.php @@ -22,7 +22,7 @@ public function testHasTable() $builder = new MariaDbBuilder($connection); $grammar->shouldReceive('compileTableExists')->once()->andReturn('sql'); $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); - $connection->shouldReceive('scalar')->once()->with('sql', [], false)->andReturn(1); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('sql')->andReturn([['exists' => 1]]); $this->assertTrue($builder->hasTable('table')); } diff --git a/tests/Database/DatabaseMySQLSchemaBuilderTest.php b/tests/Database/DatabaseMySQLSchemaBuilderTest.php index 6869aa4e5d..9b50e0a179 100755 --- a/tests/Database/DatabaseMySQLSchemaBuilderTest.php +++ b/tests/Database/DatabaseMySQLSchemaBuilderTest.php @@ -22,7 +22,7 @@ public function testHasTable() $builder = new MySqlBuilder($connection); $grammar->shouldReceive('compileTableExists')->once()->andReturn('sql'); $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); - $connection->shouldReceive('scalar')->once()->with('sql', [], false)->andReturn(1); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('sql')->andReturn([['exists' => 1]]); $this->assertTrue($builder->hasTable('table')); } diff --git a/tests/Database/DatabasePostgresBuilderTest.php b/tests/Database/DatabasePostgresBuilderTest.php index 03205ac462..b881e828fe 100644 --- a/tests/Database/DatabasePostgresBuilderTest.php +++ b/tests/Database/DatabasePostgresBuilderTest.php @@ -187,7 +187,7 @@ public function testHasTableWhenSchemaUnqualifiedAndSearchPathMissing() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['exists' => 1]]); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -202,7 +202,7 @@ public function testHasTableWhenSchemaUnqualifiedAndSearchPathFilled() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['exists' => 1]]); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -218,7 +218,7 @@ public function testHasTableWhenSchemaUnqualifiedAndSearchPathFallbackFilled() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['exists' => 1]]); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -234,7 +234,7 @@ public function testHasTableWhenSchemaUnqualifiedAndSearchPathIsUserVariable() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['exists' => 1]]); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -249,7 +249,7 @@ public function testHasTableWhenSchemaQualifiedAndSearchPathMismatches() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['exists' => 1]]); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); diff --git a/tests/Database/DatabasePostgresSchemaBuilderTest.php b/tests/Database/DatabasePostgresSchemaBuilderTest.php index 118389fa45..db598475a3 100755 --- a/tests/Database/DatabasePostgresSchemaBuilderTest.php +++ b/tests/Database/DatabasePostgresSchemaBuilderTest.php @@ -21,7 +21,7 @@ public function testHasTable() $builder = new PostgresBuilder($connection); $grammar->shouldReceive('compileTableExists')->twice()->andReturn('sql'); $connection->shouldReceive('getTablePrefix')->twice()->andReturn('prefix_'); - $connection->shouldReceive('scalar')->twice()->with('sql', [], false)->andReturn(1); + $connection->shouldReceive('selectFromWriteConnection')->twice()->with('sql')->andReturn([['exists' => 1]]); $this->assertTrue($builder->hasTable('table')); $this->assertTrue($builder->hasTable('public.table')); diff --git a/tests/Database/DatabaseSQLiteSchemaMetadataTest.php b/tests/Database/DatabaseSQLiteSchemaMetadataTest.php new file mode 100644 index 0000000000..4907ee6ce7 --- /dev/null +++ b/tests/Database/DatabaseSQLiteSchemaMetadataTest.php @@ -0,0 +1,89 @@ +builder(); + $connection->shouldReceive('getServerVersion')->once()->andReturn($version); + $grammar->shouldReceive('compileDbstatExists')->once()->andReturn('dbstat sql'); + $connection->shouldReceive('scalar')->once()->with('dbstat sql')->andReturn(1); + $grammar->shouldReceive($compiler)->once()->with('main', true)->andReturn('tables sql'); + $rows = [(object) ['name' => 'users']]; + $connection->shouldReceive('selectFromWriteConnection')->once()->with('tables sql')->andReturn($rows); + $processor->shouldReceive('processTables')->once()->with($rows)->andReturn([['name' => 'users']]); + + $this->assertSame([['name' => 'users']], $builder->getTables('main')); + $this->assertSame(['tables sql'], $builder->metadataQueries); + } + + public static function tableListingVersions(): array + { + return [ + 'legacy listing' => ['3.36.0', 'compileLegacyTables'], + 'current listing' => ['3.37.0', 'compileTables'], + ]; + } + + public function testViewsAndSchemaStateReadsUseTheMetadataHook(): void + { + [$builder, $connection, $grammar, $processor] = $this->builder(); + $connection->shouldReceive('getTablePrefix')->andReturn('app_'); + $grammar->shouldReceive('compileViews')->once()->with('main')->andReturn('views sql'); + $grammar->shouldReceive('compileColumns')->once()->with('main', 'app_users')->andReturn('columns sql'); + $grammar->shouldReceive('compileIndexes')->once()->with('main', 'app_users')->andReturn('indexes sql'); + $grammar->shouldReceive('compileSqlCreateStatement')->once()->with('main', 'app_users')->andReturn('definition sql'); + $connection->shouldReceive('scalar')->once()->with('definition sql', [], false)->andReturn('create table app_users (id integer)'); + + foreach (['views', 'columns', 'indexes'] as $kind) { + $connection->shouldReceive('selectFromWriteConnection')->once()->with($kind . ' sql')->andReturn([(object) ['name' => $kind]]); + } + + $processor->shouldReceive('processViews')->once()->with([(object) ['name' => 'views']])->andReturn([['name' => 'active_users']]); + $processor->shouldReceive('processColumns')->once()->with([(object) ['name' => 'columns']], 'create table app_users (id integer)')->andReturn([['name' => 'id']]); + $processor->shouldReceive('processIndexesForSchemaState')->once()->with([(object) ['name' => 'indexes']])->andReturn([['name' => 'primary']]); + + $this->assertSame([['name' => 'active_users']], $builder->getViews('main')); + $this->assertSame(['columns' => [['name' => 'id']], 'sql' => 'create table app_users (id integer)'], $builder->getColumnsForSchemaState('main.users')); + $this->assertSame([['name' => 'primary']], $builder->getIndexesForSchemaState('main.users')); + $this->assertSame(['views sql', 'columns sql', 'indexes sql'], $builder->metadataQueries); + } + + protected function builder(): array + { + $connection = m::mock(Connection::class); + $grammar = m::mock(SQLiteGrammar::class); + $processor = m::mock(SQLiteProcessor::class); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + + return [new SQLiteMetadataRecordingBuilder($connection), $connection, $grammar, $processor]; + } +} + +class SQLiteMetadataRecordingBuilder extends SQLiteBuilder +{ + public array $metadataQueries = []; + + #[Override] + protected function selectMetadata(string $query): array + { + $this->metadataQueries[] = $query; + + return parent::selectMetadata($query); + } +} diff --git a/tests/Database/DatabaseSchemaBuilderTest.php b/tests/Database/DatabaseSchemaBuilderTest.php index b2289ac9df..99f77096ae 100644 --- a/tests/Database/DatabaseSchemaBuilderTest.php +++ b/tests/Database/DatabaseSchemaBuilderTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Database; use Hypervel\Database\Connection; +use Hypervel\Database\MultipleColumnsSelectedException; use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Query\Processors\Processor; @@ -14,10 +15,80 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class DatabaseSchemaBuilderTest extends TestCase { + #[DataProvider('metadataMethods')] + public function testMetadataReadsUseTheOverridableWriterHook(string $method, array $arguments, string $compile, array $compileArguments, string $process): void + { + $connection = m::mock(Connection::class); + $grammar = m::mock(Grammar::class); + $processor = m::mock(Processor::class); + $rows = [(object) ['name' => 'id']]; + $processed = [['name' => 'id']]; + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $connection->shouldReceive('getTablePrefix')->andReturn('prefix_'); + $grammar->shouldReceive($compile)->once()->with(...$compileArguments)->andReturn('metadata sql'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('metadata sql')->andReturn($rows); + $processor->shouldReceive($process)->once()->with($rows)->andReturn($processed); + $builder = new DatabaseSchemaMetadataBuilder($connection); + + $this->assertSame($method === 'hasColumn' ? true : $processed, $builder->{$method}(...$arguments)); + $this->assertSame(['metadata sql'], $builder->metadataQueries); + } + + public static function metadataMethods(): array + { + return [ + 'schemas' => ['getSchemas', [], 'compileSchemas', [], 'processSchemas'], + 'tables' => ['getTables', ['public'], 'compileTables', ['public'], 'processTables'], + 'views' => ['getViews', ['public'], 'compileViews', ['public'], 'processViews'], + 'types' => ['getTypes', ['public'], 'compileTypes', ['public'], 'processTypes'], + 'columns' => ['getColumns', ['public.users'], 'compileColumns', ['public', 'prefix_users'], 'processColumns'], + 'indexes' => ['getIndexes', ['public.users'], 'compileIndexes', ['public', 'prefix_users'], 'processIndexes'], + 'foreign keys' => ['getForeignKeys', ['public.users'], 'compileForeignKeys', ['public', 'prefix_users'], 'processForeignKeys'], + 'derived column check' => ['hasColumn', ['public.users', 'ID'], 'compileColumns', ['public', 'prefix_users'], 'processColumns'], + ]; + } + + #[DataProvider('tableExistenceResults')] + public function testTableExistenceUsesTheMetadataHookAndPreservesScalarValidation(array $rows, ?bool $expected): void + { + $connection = m::mock(Connection::class); + $grammar = m::mock(Grammar::class); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getTablePrefix')->andReturn('prefix_'); + $grammar->shouldReceive('compileTableExists')->once()->with('public', 'prefix_users')->andReturn('exists sql'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('exists sql')->andReturn($rows); + $builder = new DatabaseSchemaMetadataBuilder($connection); + + if ($expected === null) { + $this->expectException(MultipleColumnsSelectedException::class); + } + + try { + $this->assertSame($expected, $builder->hasTable('public.users')); + } finally { + $this->assertSame(['exists sql'], $builder->metadataQueries); + } + } + + public static function tableExistenceResults(): array + { + return [ + 'true object' => [[(object) ['exists' => 1]], true], + 'false object' => [[(object) ['exists' => 0]], false], + 'true array' => [[['exists' => 1]], true], + 'first row only' => [[['exists' => 0], ['exists' => 1]], false], + 'empty' => [[], false], + 'null value' => [[['exists' => null]], false], + 'invalid columns' => [[(object) ['exists' => 1, 'unexpected' => 2]], null], + ]; + } + public function testCreateDatabase() { $connection = m::mock(Connection::class); @@ -244,3 +315,15 @@ public function testGetColumnTypeAddsPrefix() $this->assertSame('integer', $builder->getColumnType('users', 'id')); } } + +class DatabaseSchemaMetadataBuilder extends Builder +{ + public array $metadataQueries = []; + + protected function selectMetadata(string $query): array + { + $this->metadataQueries[] = $query; + + return parent::selectMetadata($query); + } +} From 917f739f84f97d383b4ad94f907770759e872591 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:08:45 +0000 Subject: [PATCH 5/6] Keep SQLite schema-state reads on one metadata path Read the stored table definition through selectMetadata(), alongside the column rows used to reconstruct SQLite schema state. Custom metadata execution policies now apply to both inputs without changing SQL, query count, or default writer routing. Share scalar extraction with table-existence checks through scalarMetadata(). Preserve first-row handling, null results, and the existing multiple-column exception, leaving session and capability reads on their own paths. Cover stored-definition hook dispatch, object and array rows, absent and null definitions, and invalid multi-column results. Verified formatting, source and type analysis, the database suite, and native SQLite schema and rebuild coverage. --- src/database/src/Schema/Builder.php | 24 +++++++--- src/database/src/Schema/SQLiteBuilder.php | 4 +- .../DatabaseSQLiteSchemaMetadataTest.php | 47 ++++++++++++++++++- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index 044af91806..7ec73f51c8 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -172,13 +172,7 @@ public function hasTable(string $table): bool $table = $this->connection->getTablePrefix() . $table; if ($sql = $this->grammar->compileTableExists($schema, $table)) { - $record = (array) ($this->selectMetadata($sql)[0] ?? []); - - if (count($record) > 1) { - throw new MultipleColumnsSelectedException; - } - - return (bool) array_first($record); + return (bool) $this->scalarMetadata($sql); } foreach ($this->getTables($schema ?? $this->getCurrentSchemaName()) as $value) { @@ -748,6 +742,22 @@ protected function selectMetadata(string $query): array return $this->connection->selectFromWriteConnection($query); } + /** + * Read a scalar result through the metadata execution hook. + * + * @throws MultipleColumnsSelectedException + */ + protected function scalarMetadata(string $query): mixed + { + $record = (array) ($this->selectMetadata($query)[0] ?? []); + + if (count($record) > 1) { + throw new MultipleColumnsSelectedException; + } + + return array_first($record); + } + /** * Determine whether every executable command is declared by the framework grammar. * diff --git a/src/database/src/Schema/SQLiteBuilder.php b/src/database/src/Schema/SQLiteBuilder.php index 2325c8afdb..7bcfd09748 100644 --- a/src/database/src/Schema/SQLiteBuilder.php +++ b/src/database/src/Schema/SQLiteBuilder.php @@ -173,8 +173,8 @@ public function getColumnsForSchemaState(string $table): array $table = $this->connection->getTablePrefix() . $table; $columns = $this->selectMetadata($this->grammar->compileColumns($schema, $table)); - // Rebuild guards must inspect the stored definition on the same write PDO as the columns. - $sql = $this->connection->scalar($this->grammar->compileSqlCreateStatement($schema, $table), [], false) ?? ''; + // Columns and their stored definition must share metadata execution policy. + $sql = $this->scalarMetadata($this->grammar->compileSqlCreateStatement($schema, $table)) ?? ''; return [ 'columns' => $this->connection->getPostProcessor()->processColumns( diff --git a/tests/Database/DatabaseSQLiteSchemaMetadataTest.php b/tests/Database/DatabaseSQLiteSchemaMetadataTest.php index 4907ee6ce7..b6a8173345 100644 --- a/tests/Database/DatabaseSQLiteSchemaMetadataTest.php +++ b/tests/Database/DatabaseSQLiteSchemaMetadataTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Database; use Hypervel\Database\Connection; +use Hypervel\Database\MultipleColumnsSelectedException; use Hypervel\Database\Query\Processors\SQLiteProcessor; use Hypervel\Database\Schema\Grammars\SQLiteGrammar; use Hypervel\Database\Schema\SQLiteBuilder; @@ -47,7 +48,8 @@ public function testViewsAndSchemaStateReadsUseTheMetadataHook(): void $grammar->shouldReceive('compileColumns')->once()->with('main', 'app_users')->andReturn('columns sql'); $grammar->shouldReceive('compileIndexes')->once()->with('main', 'app_users')->andReturn('indexes sql'); $grammar->shouldReceive('compileSqlCreateStatement')->once()->with('main', 'app_users')->andReturn('definition sql'); - $connection->shouldReceive('scalar')->once()->with('definition sql', [], false)->andReturn('create table app_users (id integer)'); + $connection->shouldNotReceive('scalar'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('definition sql')->andReturn([(object) ['sql' => 'create table app_users (id integer)']]); foreach (['views', 'columns', 'indexes'] as $kind) { $connection->shouldReceive('selectFromWriteConnection')->once()->with($kind . ' sql')->andReturn([(object) ['name' => $kind]]); @@ -60,7 +62,48 @@ public function testViewsAndSchemaStateReadsUseTheMetadataHook(): void $this->assertSame([['name' => 'active_users']], $builder->getViews('main')); $this->assertSame(['columns' => [['name' => 'id']], 'sql' => 'create table app_users (id integer)'], $builder->getColumnsForSchemaState('main.users')); $this->assertSame([['name' => 'primary']], $builder->getIndexesForSchemaState('main.users')); - $this->assertSame(['views sql', 'columns sql', 'indexes sql'], $builder->metadataQueries); + $this->assertSame(['views sql', 'columns sql', 'definition sql', 'indexes sql'], $builder->metadataQueries); + } + + #[DataProvider('storedDefinitions')] + public function testStoredDefinitionRetainsScalarResultHandling(array $rows, string $sql): void + { + [$builder, $connection, $grammar, $processor] = $this->builder(); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $grammar->shouldReceive('compileColumns')->once()->with('main', 'users')->andReturn('columns sql'); + $grammar->shouldReceive('compileSqlCreateStatement')->once()->with('main', 'users')->andReturn('definition sql'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('columns sql')->andReturn([]); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('definition sql')->andReturn($rows); + $connection->shouldNotReceive('scalar'); + $processor->shouldReceive('processColumns')->once()->with([], $sql)->andReturn([]); + + $this->assertSame(['columns' => [], 'sql' => $sql], $builder->getColumnsForSchemaState('main.users')); + $this->assertSame(['columns sql', 'definition sql'], $builder->metadataQueries); + } + + public static function storedDefinitions(): array + { + return [ + 'no row' => [[], ''], + 'null definition' => [[(object) ['sql' => null]], ''], + 'array row' => [[['sql' => 'create table users (id integer)']], 'create table users (id integer)'], + ]; + } + + public function testStoredDefinitionRejectsMultipleColumns(): void + { + [$builder, $connection, $grammar, $processor] = $this->builder(); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $grammar->shouldReceive('compileColumns')->once()->with('main', 'users')->andReturn('columns sql'); + $grammar->shouldReceive('compileSqlCreateStatement')->once()->with('main', 'users')->andReturn('definition sql'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('columns sql')->andReturn([]); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('definition sql')->andReturn([(object) ['sql' => 'create table users (id integer)', 'extra' => 1]]); + $connection->shouldNotReceive('scalar'); + $processor->shouldNotReceive('processColumns'); + + $this->expectException(MultipleColumnsSelectedException::class); + + $builder->getColumnsForSchemaState('main.users'); } protected function builder(): array From 95505357eaf67989edc4d1268458b9362b98d84a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:09:02 +0000 Subject: [PATCH 6/6] Clarify database extension contracts and creation validation Document that create-or-first validation is a side-effect-free capability check and may run again when public helpers delegate to one another. Preserve the existing createOrFirst override dispatch and validation before reads, value callbacks, and writes. Exercise value-callback rejection for every updateOrCreate path that accepts a closure, while retaining array inputs for through relationships. Clarify the metadata hook's coverage of SQLite stored definitions and distinguish custom builder runtime registration from HasBuilder static typing. Verified the creation-helper regression suite, existing relational creation behavior, database tests, formatting, and source and type analysis. --- src/database/src/Eloquent/Builder.php | 2 ++ src/docs/database.md | 6 +++--- .../DatabaseEloquentCreateOrFirstValidationTest.php | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 2a73324418..a8977b685a 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -671,6 +671,8 @@ public function createOrFirst(array $attributes = [], Closure|array $values = [] /** * Validate first-or-create and create-or-first operations, including relationship calls. + * + * Helpers delegate to each other, so this may run more than once per operation; keep overrides side-effect-free. */ public function ensureCanCreateOrFirst(): void { diff --git a/src/docs/database.md b/src/docs/database.md index e0335571be..fcac53dc3e 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -370,11 +370,11 @@ Both execution methods construct database errors through the protected `newQuery Query builders with statement-level options may override the protected `Query\Builder::ensureCanEmbedQuery` method to reject options that belong on the outer statement. It runs when attaching a subquery, scalar or exists predicate, or union member; call the parent to preserve the built-in rejection of embedded timeouts. -Custom Eloquent builders may override the public `ensureCanCreateOrFirst(): void` method to reject helpers that depend on unique-constraint recovery. Both `firstOrCreate` and `createOrFirst` call it before reading or writing, including their relationship forms; `updateOrCreate` and `incrementOrCreate` reach it through those helpers. Relationships use the related model's builder, even when the parent uses another driver. The default method imposes no restriction, and ordinary `create`, `save`, and `firstOrNew` are unchanged. +Custom Eloquent builders may override the public `ensureCanCreateOrFirst(): void` method to reject helpers that depend on unique-constraint recovery. Both `firstOrCreate` and `createOrFirst` call it before reading or writing, including their relationship forms; `updateOrCreate` and `incrementOrCreate` reach it through those helpers. Helpers delegate to each other, so validation may run more than once per operation; keep overrides side-effect-free. Relationships use the related model's builder, even when the parent uses another driver. The default method imposes no restriction, and ordinary `create`, `save`, and `firstOrNew` are unchanged. The migration repository delegates its table definition to `Schema\Builder::createMigrationRepositoryTable`. A driver may override this method when it needs a different physical schema, while retaining the standard repository and migration commands. Its table must support storing migration names and integer batch numbers; the default definition also includes an auto-incrementing `id`. Repository reads may return batch numbers as integers or numeric strings, depending on the driver, and do not require an `id` column. -Schema builders may override the protected `selectMetadata(string $query): array` method to customize metadata execution without copying the public inspection methods. The default reads from the write connection, so schema checks observe the same database that migrations modify. It covers schema, table, view, type, column, index, and foreign-key list reads, including built-in builder overrides and the checks derived from them. Driver-internal scalar reads, such as SQLite's stored table definition and session pragmas, remain on their own execution paths. Return the raw rows expected by the driver's processor. For schema writes, the protected `executeStatements(array $statements): void` method executes compiled statements in order and throws if a statement returns false. +Schema builders may override the protected `selectMetadata(string $query): array` method to customize metadata execution without copying the public inspection methods. The default reads from the write connection, so schema checks observe the same database that migrations modify. It covers schema, table, view, type, column, index, and foreign-key list reads, including built-in builder overrides, SQLite's stored table definitions, and the checks derived from them. Session-state reads and capability probes retain their own execution paths. Return the raw rows expected by the driver's processor. For schema writes, the protected `executeStatements(array $statements): void` method executes compiled statements in order and throws if a statement returns false. The native `DatabaseTruncation` testing trait delegates to `Schema\Builder::truncateTables` after applying its table filters. It passes the complete list of selected schema-qualified names with the connection's table prefix temporarily disabled. The default implementation checks for rows on the write connection and truncates non-empty tables through the query builder, so replica lag cannot skip cleanup. Drivers with engine-specific reset behavior may override this bulk method while keeping `getTables` accurate and using the native testing traits. If a selected table cannot be safely reset, throw an exception instead of silently leaving test data behind. @@ -400,7 +400,7 @@ includes: A scope that declares no return type, or declares `void`, `null`, or the query builder, stays chainable. Declaring a broader type such as `mixed` or `object` tells the analyzer the scope may return something else, so that type is preserved. When a scope declares a union containing the query builder, such as `Builder|int`, the builder becomes the chainable receiver and the remaining types are kept. -For a [custom Eloquent builder](/docs/{{version}}/eloquent#custom-eloquent-builders), use the `HasBuilder` trait on the model with `@use HasBuilder>`. The extension follows the model's declared `query()` return type, including through relationships. If your Eloquent builder also uses a custom query builder, declare that type on `getQuery()`. Forwarded methods retain the Eloquent builder or relationship when they are chainable, while custom Eloquent terminal methods retain their result types. Query builders declaring `TKey` and `TValue` templates preserve model-valued callback signatures during forwarding; direct `getQuery()` and `toBase()` calls keep their raw-row types. +Configure a [custom Eloquent builder](/docs/{{version}}/eloquent#custom-eloquent-builders) on the model, for example with `#[UseEloquentBuilder(YourBuilder::class)]`, then add the `HasBuilder` trait with `@use HasBuilder>` for static typing. The extension follows the model's declared `query()` return type, including through relationships. If your Eloquent builder also uses a custom query builder, declare that type on `getQuery()`. Forwarded methods retain the Eloquent builder or relationship when they are chainable, while custom Eloquent terminal methods retain their result types. Query builders declaring `TKey` and `TValue` templates preserve model-valued callback signatures during forwarding; direct `getQuery()` and `toBase()` calls keep their raw-row types. ## Running SQL Queries diff --git a/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php b/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php index 259b065b31..f0d8cfe639 100644 --- a/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php +++ b/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php @@ -43,7 +43,7 @@ public function testRelatedBuilderValidationRunsBeforeQueriesAndValueCallbacks(s 'belongsToMany' => $parent->belongsToMany($related, 'parent_related', 'parent_id', 'related_id', relation: 'related'), 'morphToMany' => $parent->morphToMany($related, 'parent', 'parent_related', 'parent_id', 'related_id', relation: 'related'), }; - $values = $method === 'updateOrCreate' ? [] : function (): never { + $values = $method === 'updateOrCreate' && in_array($relation, ['hasOneThrough', 'hasManyThrough'], true) ? [] : function (): never { $this->fail('The value callback must not run before validation.'); };