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/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index fed1f350fe..a8977b685a 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,15 @@ 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 + { + } + /** * 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 9c9f6de1cd..485f87f637 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/database/src/Migrations/DatabaseMigrationRepository.php b/src/database/src/Migrations/DatabaseMigrationRepository.php index bbc29d4e66..80457f3cd5 100755 --- a/src/database/src/Migrations/DatabaseMigrationRepository.php +++ b/src/database/src/Migrations/DatabaseMigrationRepository.php @@ -40,7 +40,9 @@ public function getRan(): array /** * Get the list of migrations. * - * @return object{id: int, migration: string, batch: int}[] + * Driver-owned migration schemas may omit id and return numeric-string batches. + * + * @return object{migration: string, batch: int|numeric-string}[] */ public function getMigrations(int $steps): array { @@ -56,7 +58,7 @@ public function getMigrations(int $steps): array /** * Get the list of the migrations by batch number. * - * @return object{id: int, migration: string, batch: int}[] + * @return object{migration: string, batch: int|numeric-string}[] */ public function getMigrationsByBatch(int $batch): array { @@ -70,7 +72,7 @@ public function getMigrationsByBatch(int $batch): array /** * Get the last migration batch. * - * @return object{id: int, migration: string, batch: int}[] + * @return object{migration: string, batch: int|numeric-string}[] */ public function getLast(): array { @@ -82,7 +84,7 @@ public function getLast(): array /** * Get the completed migrations with their batch numbers. * - * @return array + * @return array */ public function getMigrationBatches(): array { @@ -105,7 +107,7 @@ public function log(string $file, int $batch): void /** * Remove a migration from the log. * - * @param object{id?: int, migration: string, batch?: int} $migration + * @param object{migration: string} $migration */ public function delete(object $migration): void { diff --git a/src/database/src/Migrations/MigrationRepositoryInterface.php b/src/database/src/Migrations/MigrationRepositoryInterface.php index 9de783476b..3322c32387 100755 --- a/src/database/src/Migrations/MigrationRepositoryInterface.php +++ b/src/database/src/Migrations/MigrationRepositoryInterface.php @@ -16,28 +16,30 @@ public function getRan(): array; /** * Get the list of migrations. * - * @return object{id: int, migration: string, batch: int}[] + * Driver-owned migration schemas may omit id and return numeric-string batches. + * + * @return object{migration: string, batch: int|numeric-string}[] */ public function getMigrations(int $steps): array; /** * Get the list of the migrations by batch. * - * @return object{id: int, migration: string, batch: int}[] + * @return object{migration: string, batch: int|numeric-string}[] */ public function getMigrationsByBatch(int $batch): array; /** * Get the last migration batch. * - * @return object{id: int, migration: string, batch: int}[] + * @return object{migration: string, batch: int|numeric-string}[] */ public function getLast(): array; /** * Get the completed migrations with their batch numbers. * - * @return array + * @return array */ public function getMigrationBatches(): array; @@ -49,7 +51,7 @@ public function log(string $file, int $batch): void; /** * Remove a migration from the log. * - * @param object{id?: int, migration: string, batch?: int} $migration + * @param object{migration: string} $migration */ public function delete(object $migration): void; diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index 99f28139f4..0d057bef9d 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -265,7 +265,7 @@ public function rollback(array|string $paths = [], array $options = []): array * Get the migrations for a rollback operation. * * @param array $options - * @return object{id: int, migration: string, batch: int}[] + * @return object{migration: string, batch: int|numeric-string}[] */ protected function getMigrationsForRollback(array $options): array { @@ -283,6 +283,7 @@ protected function getMigrationsForRollback(array $options): array /** * Rollback the given migrations. * + * @param object{migration: string}[] $migrations * @param string|string[] $paths * @param array $options * @return string[] 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/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index dfda897661..7ec73f51c8 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; @@ -157,7 +158,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()) ); } @@ -171,8 +172,7 @@ 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); + return (bool) $this->scalarMetadata($sql); } foreach ($this->getTables($schema ?? $this->getCurrentSchemaName()) as $value) { @@ -211,7 +211,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)) ); } @@ -236,7 +236,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)) ); } @@ -248,7 +248,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)) ); } @@ -359,7 +359,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) ) ); @@ -377,7 +377,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) ) ); @@ -456,7 +456,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) ) ); @@ -734,6 +734,30 @@ 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); + } + + /** + * 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/ColumnDefinition.php b/src/database/src/Schema/ColumnDefinition.php index 3a7d66eaa8..d6c8db3ad7 100644 --- a/src/database/src/Schema/ColumnDefinition.php +++ b/src/database/src/Schema/ColumnDefinition.php @@ -29,13 +29,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/database/src/Schema/SQLiteBuilder.php b/src/database/src/Schema/SQLiteBuilder.php index ea74aff6e2..7bcfd09748 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,9 +172,9 @@ 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)); - // 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 = $this->selectMetadata($this->grammar->compileColumns($schema, $table)); + // Columns and their stored definition must share metadata execution policy. + $sql = $this->scalarMetadata($this->grammar->compileSqlCreateStatement($schema, $table)) ?? ''; return [ 'columns' => $this->connection->getPostProcessor()->processColumns( @@ -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 08b61bcaad..fcac53dc3e 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -370,7 +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. -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`. +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, 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. @@ -396,6 +400,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. +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/src/docs/migrations.md b/src/docs/migrations.md index aa4cdcd08e..3926775d0b 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). | @@ -1436,6 +1436,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/DatabaseEloquentCreateOrFirstValidationTest.php b/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php new file mode 100644 index 0000000000..f0d8cfe639 --- /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' && in_array($relation, ['hasOneThrough', 'hasManyThrough'], true) ? [] : 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); 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/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/Database/DatabaseSQLiteSchemaMetadataTest.php b/tests/Database/DatabaseSQLiteSchemaMetadataTest.php new file mode 100644 index 0000000000..b6a8173345 --- /dev/null +++ b/tests/Database/DatabaseSQLiteSchemaMetadataTest.php @@ -0,0 +1,132 @@ +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->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]]); + } + + $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', '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 + { + $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); + } +} 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/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/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'); + } +} diff --git a/types/Database/Migrations.php b/types/Database/Migrations.php index fb3deef27e..d0176d78ac 100644 --- a/types/Database/Migrations.php +++ b/types/Database/Migrations.php @@ -16,18 +16,23 @@ function testMigrationRepositoryTypes(MigrationRepositoryInterface $repository, { assertType('array', $repository->getRan()); assertType('array', $database->getRan()); - assertType('array', $repository->getMigrationBatches()); - assertType('array', $database->getMigrationBatches()); + assertType('array', $repository->getMigrationBatches()); + assertType('array', $database->getMigrationBatches()); - assertType('array', $repository->getMigrations(1)); - assertType('array', $database->getMigrations(1)); - assertType('array', $repository->getMigrationsByBatch(1)); - assertType('array', $database->getMigrationsByBatch(1)); - assertType('array', $repository->getLast()); - assertType('array', $database->getLast()); + assertType('array', $repository->getMigrations(1)); + assertType('array', $database->getMigrations(1)); + assertType('array', $repository->getMigrationsByBatch(1)); + assertType('array', $database->getMigrationsByBatch(1)); + assertType('array', $repository->getLast()); + assertType('array', $database->getLast()); $repository->delete((object) ['migration' => 'create_users_table']); $database->delete((object) ['migration' => 'create_users_table']); + + $repository->delete((object) ['migration' => 'create_users_table', 'batch' => '1']); + $database->delete((object) ['migration' => 'create_users_table', 'batch' => '1']); + $repository->delete((object) ['id' => 1, 'migration' => 'create_users_table', 'batch' => 1]); + $database->delete((object) ['id' => 1, 'migration' => 'create_users_table', 'batch' => 1]); } function testMigrationCallbackTypes(MigrationCreator $creator, Connection $connection): void diff --git a/types/Database/Schema.php b/types/Database/Schema.php index 5655403ce9..50a972350b 100644 --- a/types/Database/Schema.php +++ b/types/Database/Schema.php @@ -18,6 +18,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()); } /** @@ -28,6 +32,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());