diff --git a/app/Arr.php b/app/Arr.php new file mode 100644 index 00000000000..5b76e9a8cb0 --- /dev/null +++ b/app/Arr.php @@ -0,0 +1,151 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees; + +use ArrayObject; +use Closure; + +use function array_filter; +use function array_map; +use function array_merge; +use function array_unique; +use function array_values; +use function uasort; + +/** + * Arrays + * + * @template TKey of array-key + * @template TValue + * @extends ArrayObject + */ +class Arr extends ArrayObject +{ + /** + * @param Arr $arr2 + * + * @return self + */ + public function concat(Arr $arr2): self + { + $arr1 = array_values(array: $this->getArrayCopy()); + $arr2 = array_values(array: $arr2->getArrayCopy()); + + return new self(array: $arr1 + $arr2); + } + + /** + * @param Closure(TValue):bool $closure + * + * @return self + */ + public function filter(Closure $closure): self + { + return new self(array: array_filter(array: $this->getArrayCopy(), callback: $closure)); + } + + /** + * @return self + */ + public function flatten(): self + { + return new self(array: array_merge(...$this->getArrayCopy())); + } + + /** + * @param null|Closure(TValue):bool $closure + * + * @return TValue|null + */ + public function first(Closure|null $closure = null): mixed + { + foreach ($this->getArrayCopy() as $value) { + if ($closure === null || $closure($value)) { + return $value; + } + } + + return null; + } + + /** + * @param null|Closure(TValue):bool $closure + * + * @return TValue|null + */ + public function last(Closure|null $closure = null): mixed + { + return $this->reverse()->first(closure: $closure); + } + + /** + * @template T + * + * @param Closure(TValue):T $closure + * + * @return self + */ + public function map(Closure $closure): self + { + return new self(array: array_map(callback: $closure, array: $this->getArrayCopy())); + } + + /** + * @return self + */ + public function reverse(): self + { + return new self(array: array_reverse(array: $this->getArrayCopy())); + } + + /** + * @param Closure(TValue,TValue):int $closure + * + * @return self + */ + public function sort(Closure $closure): self + { + $arr = $this->getArrayCopy(); + uasort(array: $arr, callback: $closure); + + return new self(array: $arr); + } + + /** + * @param Closure(TKey,TKey):int $closure + * + * @return self + */ + public function sortKeys(Closure $closure): self + { + $arr = $this->getArrayCopy(); + uksort(array: $arr, callback: $closure); + + return new self(array: $arr); + } + + /** + * @return self + */ + public function unique(): self + { + return new self(array: array_unique(array: $this->getArrayCopy())); + } +} diff --git a/app/Cli/Commands/DatabaseRepair.php b/app/Cli/Commands/DatabaseRepair.php new file mode 100644 index 00000000000..232dc93fd8a --- /dev/null +++ b/app/Cli/Commands/DatabaseRepair.php @@ -0,0 +1,147 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Cli\Commands; + +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Fisharebest\Webtrees\DB; +use Fisharebest\Webtrees\DB\Schema; +use Fisharebest\Webtrees\DB\WebtreesSchema; +use Fisharebest\Webtrees\Webtrees; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +use function array_filter; +use function implode; +use function str_contains; + +class DatabaseRepair extends Command +{ + protected function configure(): void + { + $this + ->setName(name: 'database-repair') + ->setDescription(description: 'Repair the database schema'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle(input: $input, output: $output); + + if (Webtrees::SCHEMA_VERSION !== 45) { + $io->error(message: 'This script only works with schema version 45'); + + return Command::FAILURE; + } + + $platform = DB::getDBALConnection()->getDatabasePlatform(); + $schema_manager = DB::getDBALConnection()->createSchemaManager(); + $comparator = $schema_manager->createComparator(); + $source = $schema_manager->introspectSchema(); + $target = WebtreesSchema::schema(); + + // doctrine/dbal 4.x does not have the concept of "saveSQL" + foreach ($source->getTables() as $table) { + if (!$target->hasTable(name: $table->getName())) { + $source->dropTable(name: $table->getName()); + } + } + + // Workaround for https://github.com/doctrine/dbal/issues/4541 + foreach ($target->getTables() as $table) { + foreach ($table->getIndexes() as $index) { + if (preg_match('/^IDX_[0-9A-F]+$/', $index->getName())) { + if ($table->getPrimaryKey()->spansColumns($index->getColumns())) { + $io->info('Dropping unnecessary index created by DBAL: ' . $table->getName() . '.' . $index->getName()); + $table->dropIndex(name: $index->getName()); + } + } + } + } + + $schema_diff = $comparator->compareSchemas(oldSchema: $source, newSchema: $target); + $queries = $platform->getAlterSchemaSQL(diff: $schema_diff); + + // Workaround for https://github.com/doctrine/dbal/issues/6092 + $phase1 = array_filter(array: $queries, callback: $this->phase1(...)); + $phase2 = array_filter(array: $queries, callback: $this->phase2(...)); + $phase3 = array_filter(array: $queries, callback: $this->phase3(...)); + + if ($phase3 === []) { + $phase3a = []; + } else { + // If we are creating foreign keys, delete any invalid references first. + $phase3a = $this->deleteOrphans(target: $target, platform: $platform); + } + + foreach ([...$phase1, ...$phase2, ...$phase3a, ...$phase3] as $query) { + $io->info(message: $query); + DB::exec(sql: $query); + } + + return Command::SUCCESS; + } + + private function phase1(string $query): bool + { + return str_contains($query, 'DROP FOREIGN KEY'); + } + + private function phase2(string $query): bool + { + return !str_contains($query, 'FOREIGN KEY'); + } + + /** @return list */ + private function deleteOrphans(Schema $target, AbstractPlatform $platform): array + { + $queries = []; + + foreach ($target->getTables() as $table) { + foreach ($table->getForeignKeys() as $foreign_key) { + $foreign_table = $foreign_key->getQuotedForeignTableName(platform: $platform); + + if ($table->getName() !== $foreign_key->getForeignTableName()) { + $local_columns = implode(separator: ',', array: $foreign_key->getQuotedLocalColumns(platform: $platform)); + $foreign_columns = implode(separator: ',', array: $foreign_key->getQuotedForeignColumns(platform: $platform)); + + $query = DB::delete(table: $table->getName()) + ->where( + '(' . $local_columns . ') NOT IN (SELECT ' . $foreign_columns . ' FROM ' . $foreign_table . ')' + ); + + foreach ($foreign_key->getLocalColumns() as $column) { + $query = $query->andWhere(DB::expression()->isNotNull(x: $column)); + } + + $queries[] = $query->getSQL(); + } + } + } + + return $queries; + } + + private function phase3(string $query): bool + { + return str_contains($query, 'FOREIGN KEY') && !str_contains($query, 'DROP FOREIGN KEY'); + } +} diff --git a/app/Cli/Console.php b/app/Cli/Console.php index f0fe1042937..fdd0a3db100 100644 --- a/app/Cli/Console.php +++ b/app/Cli/Console.php @@ -31,6 +31,7 @@ final class Console extends Application { private const array COMMANDS = [ Commands\CompilePoFiles::class, + Commands\DatabaseRepair::class, Commands\TreeCreate::class, Commands\TreeList::class, Commands\UserCreate::class, diff --git a/app/DB.php b/app/DB.php index d2f4d4745ad..dbf3c87bc19 100644 --- a/app/DB.php +++ b/app/DB.php @@ -19,6 +19,20 @@ namespace Fisharebest\Webtrees; +use Doctrine\DBAL\Configuration; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Query\Expression\ExpressionBuilder; +use Doctrine\DBAL\Query\QueryBuilder; +use Fisharebest\Webtrees\DB\Column; +use Fisharebest\Webtrees\DB\ColumnType; +use Fisharebest\Webtrees\DB\Drivers\MySQLDriver; +use Fisharebest\Webtrees\DB\Drivers\PostgreSQLDriver; +use Fisharebest\Webtrees\DB\Drivers\SQLiteDriver; +use Fisharebest\Webtrees\DB\Drivers\SQLServerDriver; +use Fisharebest\Webtrees\DB\ForeignKey; +use Fisharebest\Webtrees\DB\Index; +use Fisharebest\Webtrees\DB\PrimaryKey; +use Fisharebest\Webtrees\DB\UniqueIndex; use Illuminate\Database\Capsule\Manager; use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Expression; @@ -27,6 +41,8 @@ use RuntimeException; use SensitiveParameter; +use function str_starts_with; + /** * Database abstraction */ @@ -66,6 +82,8 @@ class DB extends Manager self::SQL_SERVER => 'SET language us_english', // For timestamp columns ]; + private static Connection $dbal_connection; + public static function connect( #[SensitiveParameter] string $driver, @@ -133,6 +151,23 @@ public static function connect( if ($sql !== '') { self::exec($sql); } + + $dbal_driver = match ($driver) { + self::MYSQL => new MySQLDriver(pdo: self::pdo()), + self::POSTGRES => new PostgreSQLDriver(pdo: self::pdo()), + self::SQLITE => new SQLiteDriver(pdo: self::pdo()), + self::SQL_SERVER => new SQLServerDriver(pdo: self::pdo()), + }; + + $configuration = new Configuration(); + $configuration->setSchemaAssetsFilter(schemaAssetsFilter: self::schemaAssetsFilter(...)); + + self::$dbal_connection = new Connection(params: [], driver: $dbal_driver, config: $configuration); + } + + private static function schemaAssetsFilter(string $asset): bool + { + return str_starts_with(haystack: $asset, needle: self::prefix()); } public static function driverName(): string @@ -232,4 +267,142 @@ public static function query(): Builder { return parent::connection()->query(); } + + public static function getDBALConnection(): Connection + { + return self::$dbal_connection; + } + + public static function select(string ...$expressions): QueryBuilder + { + return self::$dbal_connection + ->createQueryBuilder() + ->select(...$expressions); + } + + public static function update(string $table): QueryBuilder + { + return parent::connection()->update(self::prefix($table)); + } + + /** + * @param array> $rows + */ + public static function insert(string $table, array $rows): void + { + foreach ($rows as $row) { + self::getDBALConnection()->insert(self::prefix($table), $row); + } + } + + public static function delete(string ...$expressions): QueryBuilder + { + return self::$dbal_connection + ->createQueryBuilder() + ->delete(...$expressions); + } + + public static function expression(): ExpressionBuilder + { + return self::$dbal_connection->createExpressionBuilder(); + } + + public static function char(string $name, int $length): Column + { + return new Column( + name: $name, + type: ColumnType::Char, + length: $length, + fixed: true, + collation: self::COLLATION_ASCII[self::driverName()], + ); + } + + public static function varchar(string $name, int $length): Column + { + return new Column( + name: $name, + type: ColumnType::Char, + length: $length, + collation: self::COLLATION_ASCII[self::driverName()], + ); + } + + public static function nchar(string $name, int $length): Column + { + return new Column( + name: $name, + type: ColumnType::NChar, + length: $length, + fixed: true, + collation: self::COLLATION_UTF8[self::driverName()], + ); + } + + public static function nvarchar(string $name, int $length): Column + { + return new Column( + name: $name, + type: ColumnType::NVarChar, + length: $length, + collation: self::COLLATION_UTF8[self::driverName()], + ); + } + + public static function integer(string $name): Column + { + return new Column(name: $name, type: ColumnType::Integer); + } + + public static function float(string $name): Column + { + return new Column(name: $name, type: ColumnType::Float); + } + + public static function text(string $name): Column + { + return new Column(name: $name, type: ColumnType::Text, collation: self::COLLATION_UTF8[self::driverName()]); + } + + public static function timestamp(string $name, int $precision = 0): Column + { + return new Column(name: $name, type: ColumnType::Timestamp, precision: $precision); + } + + /** + * @param array $columns + */ + public static function primaryKey(array $columns): PrimaryKey + { + return new PrimaryKey(columns: $columns); + } + + /** + * @param list $columns + */ + public static function index(array $columns): Index + { + return new Index(columns: $columns); + } + + /** + * @param list $columns + */ + public static function uniqueIndex(array $columns): UniqueIndex + { + return new UniqueIndex(columns: $columns); + } + + /** + * @param list $local_columns + * @param list $foreign_columns + */ + public static function foreignKey(array $local_columns, string $foreign_table, array|null $foreign_columns = null): ForeignKey + { + return new ForeignKey( + local_columns: $local_columns, + foreign_table: $foreign_table, + foreign_columns: $foreign_columns ?? $local_columns, + ); + } } diff --git a/app/DB/Column.php b/app/DB/Column.php new file mode 100644 index 00000000000..26fe82c41aa --- /dev/null +++ b/app/DB/Column.php @@ -0,0 +1,126 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Doctrine\DBAL\Schema\Column as DBALColumn; + +/** + * Fluent/immutable constructors for doctrine/dbal. + */ +final class Column extends DBALColumn +{ + public function __construct( + private readonly string $name, + private readonly ColumnType $type, + private readonly int $length = 0, + private readonly int $precision = 0, + private readonly int $scale = 0, + private readonly bool $unsigned = false, + private readonly bool $fixed = false, + private readonly bool $nullable = false, + private readonly float|int|string|null $default = null, + private readonly bool $autoincrement = false, + private readonly string|null $collation = null, + ) { + parent::__construct( + name: $name, + type: ColumnType::toDBALType(column_type: $type), + options: [ + 'length' => $length, + 'precision' => $precision, + 'scale' => $scale, + 'unsigned' => $unsigned, + 'fixed' => $fixed, + 'notnull' => !$nullable, + 'default' => $default, + 'autoincrement' => $autoincrement, + 'platformOptions' => ['collation' => $collation], + ], + ); + } + + public function autoincrement(): self + { + return new self( + name: $this->name, + type: $this->type, + length: $this->length, + precision: $this->precision, + scale: $this->scale, + unsigned: $this->unsigned, + fixed: $this->fixed, + nullable: $this->nullable, + default: $this->default, + autoincrement: true, + collation: $this->collation, + ); + } + + public function default(float|int|string $default): self + { + return new self( + name: $this->name, + type: $this->type, + length: $this->length, + precision: $this->precision, + scale: $this->scale, + unsigned: $this->unsigned, + fixed: $this->fixed, + nullable: $this->nullable, + default: $default, + autoincrement: $this->autoincrement, + collation: $this->collation, + ); + } + + public function fixed(): self + { + return new self( + name: $this->name, + type: $this->type, + length: $this->length, + precision: $this->precision, + scale: $this->scale, + unsigned: $this->unsigned, + fixed: true, + nullable: $this->nullable, + default: $this->default, + autoincrement: $this->autoincrement, + collation: $this->collation, + ); + } + + public function nullable(): self + { + return new self( + name: $this->name, + type: $this->type, + length: $this->length, + precision: $this->precision, + scale: $this->scale, + unsigned: $this->unsigned, + fixed: $this->fixed, + nullable: true, + default: $this->default, + autoincrement: $this->autoincrement, + collation: $this->collation, + ); + } +} diff --git a/app/DB/ColumnType.php b/app/DB/ColumnType.php new file mode 100644 index 00000000000..fb563c9d64d --- /dev/null +++ b/app/DB/ColumnType.php @@ -0,0 +1,59 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Doctrine\DBAL\Types\AsciiStringType; +use Doctrine\DBAL\Types\DateTimeImmutableType; +use Doctrine\DBAL\Types\FloatType; +use Doctrine\DBAL\Types\IntegerType; +use Doctrine\DBAL\Types\StringType; +use Doctrine\DBAL\Types\TextType; +use Doctrine\DBAL\Types\Type; + +/** + * Simplify constructor arguments for doctrine/dbal. + * + * @internal + */ +enum ColumnType +{ + case Char; + case Float; + case Integer; + case NChar; + case NVarChar; + case Text; + case Timestamp; + case VarChar; + + public static function toDBALType(ColumnType $column_type): Type + { + return match ($column_type) { + self::Char => new AsciiStringType(), + self::Float => new FloatType(), + self::Integer => new IntegerType(), + self::NChar => new StringType(), + self::NVarChar => new StringType(), + self::Text => new TextType(), + self::Timestamp => new DateTimeImmutableType(), + self::VarChar => new AsciiStringType(), + }; + } +} diff --git a/app/DB/Drivers/DriverInterface.php b/app/DB/Drivers/DriverInterface.php new file mode 100644 index 00000000000..a390c78fce2 --- /dev/null +++ b/app/DB/Drivers/DriverInterface.php @@ -0,0 +1,31 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB\Drivers; + +use Doctrine\DBAL\Driver\PDO\Connection; +use SensitiveParameter; + +interface DriverInterface +{ + public function connect( + #[SensitiveParameter] + array $params, + ): Connection; +} diff --git a/app/DB/Drivers/DriverTrait.php b/app/DB/Drivers/DriverTrait.php new file mode 100644 index 00000000000..6fbeaca1aff --- /dev/null +++ b/app/DB/Drivers/DriverTrait.php @@ -0,0 +1,90 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB\Drivers; + +use Doctrine\DBAL\Driver\PDO\Connection; +use PDO; +use PDOException; +use RuntimeException; +use SensitiveParameter; + +use function is_bool; +use function is_int; + +/** + * Common functionality for all drivers. + */ +trait DriverTrait +{ + public function __construct(private readonly PDO $pdo) + { + } + + public function connect( + #[SensitiveParameter] + array $params, + ): Connection { + return new Connection($this->pdo); + } + + /** + * Prepare, bind and execute a select query. + * + * @param string $sql + * @param array $bindings + * + * @return array + */ + public function query(string $sql, array $bindings = []): array + { + try { + $statement = $this->pdo->prepare($sql); + } catch (PDOException) { + $statement = false; + } + + if ($statement === false) { + throw new RuntimeException('Failed to prepare statement: ' . $sql); + } + + foreach ($bindings as $param => $value) { + $type = match (true) { + $value === null => PDO::PARAM_NULL, + is_bool(value: $value) => PDO::PARAM_BOOL, + is_int(value: $value) => PDO::PARAM_INT, + default => PDO::PARAM_STR, + }; + + if (is_int(value: $param)) { + // Positional parameters are numeric, starting at 1. + $statement->bindValue(param: $param + 1, value: $value, type: $type); + } else { + // Named parameters are (optionally) prefixed with a colon. + $statement->bindValue(param: ':' . $param, value: $value, type: $type); + } + } + + if ($statement->execute()) { + return $statement->fetchAll(PDO::FETCH_OBJ); + } + + throw new RuntimeException('Failed to execute statement: ' . $sql); + } +} diff --git a/app/DB/Drivers/MySQLDriver.php b/app/DB/Drivers/MySQLDriver.php new file mode 100644 index 00000000000..8bc6ce2971c --- /dev/null +++ b/app/DB/Drivers/MySQLDriver.php @@ -0,0 +1,27 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB\Drivers; + +use Doctrine\DBAL\Driver\AbstractMySQLDriver; + +final class MySQLDriver extends AbstractMySQLDriver implements DriverInterface +{ + use DriverTrait; +} diff --git a/app/DB/Drivers/PostgreSQLDriver.php b/app/DB/Drivers/PostgreSQLDriver.php new file mode 100644 index 00000000000..7cc8e4b2e2e --- /dev/null +++ b/app/DB/Drivers/PostgreSQLDriver.php @@ -0,0 +1,27 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB\Drivers; + +use Doctrine\DBAL\Driver\AbstractPostgreSQLDriver; + +final class PostgreSQLDriver extends AbstractPostgreSQLDriver implements DriverInterface +{ + use DriverTrait; +} diff --git a/app/DB/Drivers/SQLServerDriver.php b/app/DB/Drivers/SQLServerDriver.php new file mode 100644 index 00000000000..cf95edfeef4 --- /dev/null +++ b/app/DB/Drivers/SQLServerDriver.php @@ -0,0 +1,27 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB\Drivers; + +use Doctrine\DBAL\Driver\AbstractSQLServerDriver; + +final class SQLServerDriver extends AbstractSQLServerDriver implements DriverInterface +{ + use DriverTrait; +} diff --git a/app/DB/Drivers/SQLiteDriver.php b/app/DB/Drivers/SQLiteDriver.php new file mode 100644 index 00000000000..648f64435f1 --- /dev/null +++ b/app/DB/Drivers/SQLiteDriver.php @@ -0,0 +1,27 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB\Drivers; + +use Doctrine\DBAL\Driver\AbstractSQLiteDriver; + +final class SQLiteDriver extends AbstractSQLiteDriver implements DriverInterface +{ + use DriverTrait; +} diff --git a/app/DB/Expression.php b/app/DB/Expression.php new file mode 100644 index 00000000000..87392e3880f --- /dev/null +++ b/app/DB/Expression.php @@ -0,0 +1,37 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Stringable; + +/** + * Extend the PDO database connection to support prefixes and introspection. + */ +class Expression implements Stringable +{ + public function __construct(private readonly string $sql) + { + } + + public function __toString() + { + return $this->sql; + } +} diff --git a/app/DB/ForeignKey.php b/app/DB/ForeignKey.php new file mode 100644 index 00000000000..acd1396c39d --- /dev/null +++ b/app/DB/ForeignKey.php @@ -0,0 +1,95 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Doctrine\DBAL\Schema\ForeignKeyConstraint as DBALForeignKey; +use Fisharebest\Webtrees\DB; + +/** + * Fluent/immutable constructors for doctrine/dbal. + */ +final class ForeignKey extends DBALForeignKey +{ + /** + * @param array $local_columns + * @param array $foreign_columns + */ + public function __construct( + private readonly array $local_columns, + private readonly string $foreign_table, + private readonly array $foreign_columns, + private readonly string $on_delete = 'NO ACTION', + private readonly string $on_update = 'NO ACTION', + string $name = '', + ) { + parent::__construct( + localColumnNames: $this->local_columns, + foreignTableName: DB::prefix($this->foreign_table), + foreignColumnNames: $this->foreign_columns, + name: $name, + options: ['onDelete' => $this->on_delete, 'onUpdate' => $this->on_update], + ); + } + + public function onDeleteCascade(): self + { + return new self( + local_columns: $this->local_columns, + foreign_table: $this->foreign_table, + foreign_columns: $this->foreign_columns, + on_delete: 'CASCADE', + on_update: $this->on_update, + ); + } + + public function onDeleteSetNull(): self + { + return new self( + local_columns: $this->local_columns, + foreign_table: $this->foreign_table, + foreign_columns: $this->foreign_columns, + on_delete: 'SET NULL', + on_update: $this->on_update, + ); + } + + public function onUpdateCascade(): self + { + return new self( + local_columns: $this->local_columns, + foreign_table: $this->foreign_table, + foreign_columns: $this->foreign_columns, + on_delete: $this->on_delete, + on_update: 'CASCADE', + ); + } + + public function name(string $name): self + { + return new self( + local_columns: $this->local_columns, + foreign_table: $this->foreign_table, + foreign_columns: $this->foreign_columns, + on_delete: $this->on_delete, + on_update: $this->on_update, + name: $name, + ); + } +} diff --git a/app/DB/GroupConcat.php b/app/DB/GroupConcat.php new file mode 100644 index 00000000000..2657f802642 --- /dev/null +++ b/app/DB/GroupConcat.php @@ -0,0 +1,51 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees; + +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Query\Expression; + +/** + * SQL GROUP_CONCAT function. + */ +class GroupConcat extends Expression +{ + public function __construct(string $expression, string $separator = ',', string $order_by = '', string $alias = '') + { + $quoted_expression = DB::getTheConnection()->getSchemaGrammar()->wrap($expression); + $quoted_separator = DB::getTheConnection()->getPdo()->quote($separator); + $quoted_order_by = DB::getTheConnection()->getSchemaGrammar()->wrap($order_by); + + switch (DB::getTheConnection()->getDriverName()) { + case 'sqlsrv': + $sql = 'STRING_AGG(' . $quoted_expression . ',' . $quoted_separator . ') WITHIN GROUP (ORDER BY ' . $quoted_order_by . ' ASC)'; + break; + default: + $sql = 'GROUP_CONCAT(' . $quoted_expression . ',' . $quoted_separator . ' ORDER BY ' . $quoted_order_by . ')'; + break; + } + + if ($alias !== '') { + $sql .= ' AS ' . DB::getTheConnection()->getSchemaGrammar()->wrap($alias); + } + + parent::__construct($sql); + } +} diff --git a/app/DB/Index.php b/app/DB/Index.php new file mode 100644 index 00000000000..3cb485f5851 --- /dev/null +++ b/app/DB/Index.php @@ -0,0 +1,41 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Doctrine\DBAL\Schema\Index as DBALIndex; + +/** + * Fluent/immutable constructors for doctrine/dbal. + */ +final class Index extends DBALIndex +{ + /** + * @param array $columns + */ + public function __construct(public readonly array $columns, string|null $name = null) + { + parent::__construct(name: $name, columns: $this->columns); + } + + public function name(string $name): self + { + return new self(columns: $this->columns, name: $name); + } +} diff --git a/app/DB/PrimaryKey.php b/app/DB/PrimaryKey.php new file mode 100644 index 00000000000..1f0d219dcf2 --- /dev/null +++ b/app/DB/PrimaryKey.php @@ -0,0 +1,36 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Doctrine\DBAL\Schema\Index as DBALIndex; + +/** + * Fluent/immutable constructors for doctrine/dbal. + */ +final class PrimaryKey extends DBALIndex +{ + /** + * @param array $columns + */ + public function __construct(private readonly array $columns) + { + parent::__construct(name: 'primary', columns: $this->columns, isUnique: true, isPrimary: true); + } +} diff --git a/app/DB/Query.php b/app/DB/Query.php new file mode 100644 index 00000000000..1f1c6fc0976 --- /dev/null +++ b/app/DB/Query.php @@ -0,0 +1,114 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Fisharebest\Webtrees\Arr; +use Fisharebest\Webtrees\DB; +use LogicException; + +/** + * Simplify constructor arguments for doctrine/dbal. + * + * @internal - use DB::select(), DB::update(), DB::insertInto(), DB::deleteFrom() + */ +final class Query +{ + /** + * @param array $columns + */ + public function __construct( + private readonly array $columns = [], + private readonly bool $distinct = false, + private readonly string $table = '', + private readonly int $offset = 0, + private readonly int $limit = 0, + ) { + } + + public function distinct(): self + { + return new self( + columns: $this->columns, + distinct: true, + table: $this->table, + offset: $this->offset, + limit: $this->limit, + ); + } + + public function from(string $table): self + { + return new self( + columns: $this->columns, + distinct: $this->distinct, + table: DB::prefix($table), + offset: $this->offset, + limit: $this->limit, + ); + } + + /** + * This is an update query. Return the count of updated rows. + * + * @param array $updates + * + * @return int + */ + public function set(array $updates): int + { + if ( + $this->columns !== [] || + $this->distinct === true || + $this->table === '' || + $this->offset !== 0 || + $this->limit !== 0 + ) { + throw new LogicException('Invalid SQL query definition'); + } + + return 0; + } + + /** + * @return Arr + */ + public function rows(): Arr + { + return new Arr(); + } + + /** + * @return Arr + */ + public function pluck(): Arr + { + return new Arr(); + } + + public function firstRow(): object + { + return (object) []; + } + + public function first(): string|int|float|null + { + return 0; + } +} diff --git a/app/DB/QueryBuilder.php b/app/DB/QueryBuilder.php new file mode 100644 index 00000000000..77851a9067e --- /dev/null +++ b/app/DB/QueryBuilder.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Doctrine\DBAL\Query\QueryBuilder as DbalQueryBuilder; +use Fisharebest\Webtrees\DB; + +/** + * Add table-prefixes to doctrine/dbal. + */ +class QueryBuilder extends DbalQueryBuilder +{ + public function from(string $table, $alias = null): self + { + return parent::from(table: DB::prefix($table), alias: $alias ?? $table); + } + + public function insert(string $table): self + { + return parent::insert(table: DB::prefix($table)); + } + + public function update(string $table): self + { + return parent::update(table: DB::prefix($table)); + } + + public function delete(string $table): self + { + return parent::delete(table: DB::prefix($table)); + } +} diff --git a/app/DB/Schema.php b/app/DB/Schema.php new file mode 100644 index 00000000000..fbf4bec5f0d --- /dev/null +++ b/app/DB/Schema.php @@ -0,0 +1,57 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Doctrine\DBAL\Schema\Schema as DBALSchema; + +use function array_filter; +use function array_map; + +/** + * Fluent/immutable constructors for doctrine/dbal. + */ +final class Schema extends DBALSchema +{ + /** @param list $tables */ + public function __construct(private readonly array $tables) + { + parent::__construct(tables: $tables); + } + + public function dropTable(string $name): self + { + return new self( + tables: array_filter( + array: $this->tables, + callback: static fn(Table $table): bool => $table->name !== $name, + ) + ); + } + + public function dropForeignKeys(): self + { + return new self( + tables: array_map( + callback: static fn(Table $table) => $table->dropForeignKeys(), + array: $this->tables, + ) + ); + } +} diff --git a/app/DB/Table.php b/app/DB/Table.php new file mode 100644 index 00000000000..73733447ce5 --- /dev/null +++ b/app/DB/Table.php @@ -0,0 +1,163 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Doctrine\DBAL\Schema\Table as DBALTable; +use Exception; +use Fisharebest\Webtrees\DB; + +use function array_filter; +use function array_keys; +use function array_map; +use function array_values; +use function implode; + +/** + * Fluent/immutable constructors for doctrine/dbal. + */ +final class Table extends DBALTable +{ + /** @var list */ + public readonly array $columns; + + /** @var list */ + public readonly array $indexes; + + /** @var list */ + public readonly array $primary_keys; + + /** @var list */ + public readonly array $unique_indexes; + + /** @var list */ + public readonly array $foreign_keys; + + public function __construct( + public readonly string $name, + Column|Index|UniqueIndex|ForeignKey|PrimaryKey ...$components, + ) { + $this->columns = array_values( + array: array_filter( + array: $components, + callback: static fn (mixed $component): bool => $component instanceof Column, + ) + ); + + $this->primary_keys = array_values( + array: array_filter( + array: $components, + callback: static fn (mixed $component): bool => $component instanceof PrimaryKey, + ) + ); + + $indexes = array_values( + array: array_filter( + array: $components, + callback: static fn (mixed $component): bool => $component instanceof Index, + ) + ); + + $this->indexes = array_map( + $this->namedIndex(...), + $indexes, + array_keys(array: $indexes), + ); + + $unique_indexes = array_values( + array: array_filter( + array: $components, + callback: static fn (mixed $component): bool => $component instanceof UniqueIndex, + ) + ); + + $this->unique_indexes = array_map( + $this->namedUniqueIndex(...), + $unique_indexes, + array_keys(array: $unique_indexes), + ); + + $foreign_keys = array_values( + array: array_filter( + array: $components, + callback: static fn (mixed $component): bool => $component instanceof ForeignKey, + ) + ); + + $this->foreign_keys = array_map( + $this->namedForeignKey(...), + $foreign_keys, + array_keys(array: $foreign_keys), + ); + + parent::__construct( + name: DB::prefix(identifier: $name), + columns: $this->columns, + indexes: [...$this->primary_keys, ...$this->unique_indexes, ...$this->indexes], + fkConstraints: $this->foreign_keys, + ); + + foreach ($this->foreign_keys as $foreign_key) { + if (!$this->columnsAreIndexed(columnNames: $foreign_key->getLocalColumns())) { + $columns = implode(separator: ', ', array: $foreign_key->getLocalColumns()); + throw new Exception(message: 'Table: ' . $name . ': Foreign key columns must be indexed: ' . $columns); + } + } + } + + private function namedIndex(Index $index, int $n): Index + { + return $index->name(name: DB::prefix(identifier: $this->name . '_ix' . $n + 1)); + } + + private function namedUniqueIndex(UniqueIndex $unique_index, int $n): UniqueIndex + { + return $unique_index->name(name: DB::prefix(identifier: $this->name . '_ux' . $n + 1)); + } + + private function namedForeignKey(ForeignKey $foreign_key, int $n): ForeignKey + { + return $foreign_key->name(name: DB::prefix(identifier: $this->name . '_fk' . $n + 1)); + } + + public function dropColumn(string $name): self + { + $columns = array_filter(array: $this->columns, callback: static fn (Column $column): bool => $column->getName() !== $name); + + return new self( + $this->name, + ...$columns, + ...$this->indexes, + ...$this->primary_keys, + ...$this->unique_indexes, + ...$this->foreign_keys, + ); + } + + public function dropForeignKeys(): self + { + return new self( + $this->name, + ...$this->columns, + ...$this->indexes, + ...$this->primary_keys, + ...$this->unique_indexes, + ); + } +} diff --git a/app/DB/UniqueIndex.php b/app/DB/UniqueIndex.php new file mode 100644 index 00000000000..80569e05b8e --- /dev/null +++ b/app/DB/UniqueIndex.php @@ -0,0 +1,44 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Doctrine\DBAL\Schema\Index as DBALIndex; +use Fisharebest\Webtrees\DB; + +use function implode; + +/** + * Fluent/immutable constructors for doctrine/dbal. + */ +final class UniqueIndex extends DBALIndex +{ + /** + * @param array $columns + */ + public function __construct(private readonly array $columns, string|null $name = null) + { + parent::__construct(name: $name, columns: $this->columns, isUnique: true); + } + + public function name(string $name): self + { + return new self(columns: $this->columns, name: $name); + } +} diff --git a/app/DB/WebtreesSchema.php b/app/DB/WebtreesSchema.php new file mode 100644 index 00000000000..b5e36cdda2e --- /dev/null +++ b/app/DB/WebtreesSchema.php @@ -0,0 +1,670 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\DB; + +use Fisharebest\Webtrees\DB; + +/** + * Definitions for the webtrees database. + */ +class WebtreesSchema +{ + /** + * @return void + */ + public function historicSchemaVersions(): void + { + switch ('webtrees_schema') { + case 1: // webtrees 1.0.0 - 1.0.3 + case 2: // webtrees 1.0.4 + case 3: + case 4: // webtrees 1.0.5 + case 5: // webtrees 1.0.6 + case 6: + case 7: + case 8: + case 9: // webtrees 1.1.0 - 1.1.1 + case 10: // webtrees 1.1.2 + case 11: // webtrees 1.2.0 + case 12: // webtrees 1.2.1 - 1.2.3 + case 13: + case 14: + case 15: // webtrees 1.2.4 - 1.2.5 + case 16: // webtrees 1.2.7 + case 17: + case 18: // webtrees 1.3.0 + case 19: // webtrees 1.3.1 + case 20: // webtrees 1.3.2 + case 21: + case 22: + case 23: // webtrees 1.4.0 - 1.4.1 + case 24: + case 25: // webtrees 1.4.2 - 1.4.4, 1.5.0 + case 26: // webtrees 1.4.5 - 1.4.6 + case 27: // webtrees 1.5.1 - 1.6.0 + case 28: + case 29: // webtrees 1.6.1 - 1.6.2 + case 30: + case 31: // webtrees 1.7.0 - 1.7.1 + case 32: // webtrees 1.7.2 + case 33: + case 34: // webtrees 1.7.3 - 1.7.4 + case 35: + case 36: // webtrees 1.7.5 - 1.7.7 + case 37: // webtrees 1.7.8 - 2.0.0 + case 38: + case 39: + case 40: // webtrees 2.0.1 - 2.1.15 + } + } + + public static function tableBlock(): Table + { + return new Table( + 'block', + DB::integer(name: 'block_id')->autoincrement(), + DB::integer(name: 'gedcom_id')->nullable(), + DB::integer(name: 'user_id')->nullable(), + DB::varchar(name: 'xref', length: 20)->nullable(), + DB::char(name: 'location', length: 4)->nullable(), + DB::integer(name: 'block_order'), + DB::varchar(name: 'module_name', length: 32), + DB::primaryKey(columns: ['block_id']), + DB::index(columns: ['gedcom_id']), + DB::index(columns: ['user_id']), + DB::index(columns: ['module_name']), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + DB::foreignKey(local_columns: ['module_name'], foreign_table: 'module')->onDeleteCascade()->onUpdateCascade(), + DB::foreignKey(local_columns: ['user_id'], foreign_table: 'user')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableBlockSetting(): Table + { + return new Table( + 'block_setting', + DB::integer(name: 'block_id'), + DB::varchar(name: 'setting_name', length: 32), + DB::text('setting_value'), + DB::primaryKey(columns: ['block_id', 'setting_name']), + DB::foreignKey(local_columns: ['block_id'], foreign_table: 'block')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableChange(): Table + { + return new Table( + 'change', + DB::integer(name: 'change_id')->autoincrement(), + DB::timestamp(name: 'change_time')->default(default: 'CURRENT_TIMESTAMP'), + DB::char(name: 'status', length: 8), + DB::integer(name: 'gedcom_id'), + DB::varchar(name: 'xref', length: 20), + DB::text(name: 'old_gedcom'), + DB::text(name: 'new_gedcom'), + DB::integer(name: 'user_id'), + DB::primaryKey(columns: ['change_id']), + DB::index(columns: ['gedcom_id', 'status', 'xref']), + DB::index(columns: ['user_id']), + DB::foreignKey(local_columns: ['user_id'], foreign_table: 'user')->onDeleteCascade()->onUpdateCascade(), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableDates(): Table + { + return new Table( + 'dates', + DB::integer(name: 'd_day'), + DB::char(name: 'd_month', length: 5), + DB::integer(name: 'd_mon'), + DB::integer(name: 'd_year'), + DB::integer(name: 'd_julianday1'), + DB::integer(name: 'd_julianday2'), + DB::varchar(name: 'd_fact', length: 15), + DB::varchar(name: 'd_gid', length: 20), + DB::integer(name: 'd_file'), + DB::varchar(name: 'd_type', length: 13), + DB::index(columns: ['d_day']), + DB::index(columns: ['d_month']), + DB::index(columns: ['d_mon']), + DB::index(columns: ['d_year']), + DB::index(columns: ['d_julianday1']), + DB::index(columns: ['d_julianday2']), + DB::index(columns: ['d_gid']), + DB::index(columns: ['d_file']), + DB::index(columns: ['d_type']), + DB::index(columns: ['d_fact', 'd_gid']), + DB::foreignKey(local_columns: ['d_file'], foreign_table: 'gedcom', foreign_columns: ['gedcom_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableDefaultResn(): Table + { + return new Table( + 'default_resn', + DB::integer(name: 'default_resn_id')->autoincrement(), + DB::integer(name: 'gedcom_id'), + DB::varchar(name: 'xref', length: 20)->nullable(), + DB::varchar(name: 'tag_type', length: 15)->nullable(), + DB::varchar(name: 'resn', length: 12), + DB::primaryKey(columns: ['default_resn_id']), + DB::uniqueIndex(columns: ['gedcom_id', 'xref', 'tag_type']), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableFamilies(): Table + { + return new Table( + 'families', + DB::varchar(name: 'f_id', length: 20), + DB::integer(name: 'f_file'), + DB::varchar(name: 'f_husb', length: 20)->nullable(), + DB::varchar(name: 'f_wife', length: 20)->nullable(), + DB::text(name: 'f_gedcom'), + DB::integer(name: 'f_numchil'), + DB::primaryKey(columns: ['f_file', 'f_id']), + DB::uniqueIndex(columns: ['f_id', 'f_file']), + DB::index(columns: ['f_file', 'f_husb']), + DB::index(columns: ['f_file', 'f_wife']), + DB::index(columns: ['f_file', 'f_numchil']), + DB::foreignKey(local_columns: ['f_file'], foreign_table: 'gedcom', foreign_columns: ['gedcom_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableFavorite(): Table + { + return new Table( + 'favorite', + DB::integer(name: 'favorite_id')->autoincrement(), + DB::integer(name: 'user_id')->nullable(), + DB::integer(name: 'gedcom_id'), + DB::varchar(name: 'xref', length: 20)->nullable(), + DB::char(name: 'favorite_type', length: 4), + DB::varchar(name: 'url', length: 255)->nullable(), + DB::varchar(name: 'title', length: 255)->nullable(), + DB::varchar(name: 'note', length: 1000)->nullable(), + DB::primaryKey(columns: ['favorite_id']), + DB::index(columns: ['user_id']), + DB::index(columns: ['gedcom_id', 'user_id']), + DB::foreignKey(local_columns: ['user_id'], foreign_table: 'user')->onDeleteCascade()->onUpdateCascade(), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableFile(): Table + { + return new Table( + 'file', + DB::nvarchar(name: 'name', length: 255), + DB::integer(name: 'size')->nullable(), + DB::integer(name: 'last_modified')->nullable(), + DB::varchar(name: 'mime_type', length: 255)->nullable(), + DB::nvarchar(name: 'sha1', length: 40)->nullable(), + DB::integer(name: 'file_exists')->nullable(), + DB::primaryKey(['name']), + DB::index(columns: ['sha1']), + DB::index(columns: ['size']), + DB::index(columns: ['mime_type']), + DB::index(columns: ['last_modified']), + ); + } + + public static function tableGedcom(): Table + { + return new Table( + 'gedcom', + DB::integer(name: 'gedcom_id')->autoincrement(), + DB::nvarchar(name: 'gedcom_name', length: 255), + DB::integer(name: 'sort_order')->default(default: 0), + DB::primaryKey(columns: ['gedcom_id']), + DB::uniqueIndex(columns: ['gedcom_name']), + DB::index(columns: ['sort_order']), + ); + } + + public static function tableGedcomChunk(): Table + { + return new Table( + 'gedcom_chunk', + DB::integer(name: 'gedcom_chunk_id')->autoincrement(), + DB::integer(name: 'gedcom_id'), + DB::text(name: 'chunk_data'), + DB::integer(name: 'imported')->default(default: 0), + DB::primaryKey(columns: ['gedcom_chunk_id']), + DB::index(columns: ['gedcom_id', 'imported']), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableGedcomSetting(): Table + { + return new Table( + 'gedcom_setting', + DB::integer('gedcom_id'), + DB::varchar('setting_name', length: 32), + DB::nvarchar('setting_value', length: 255), + DB::primaryKey(columns: ['gedcom_id', 'setting_name']), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableHitCounter(): Table + { + return new Table( + 'hit_counter', + DB::integer('gedcom_id'), + DB::varchar('page_name', length: 32), + DB::varchar('page_parameter', length: 32), + DB::integer('page_count'), + DB::primaryKey(columns: ['gedcom_id', 'page_name', 'page_parameter']), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableIndividuals(): Table + { + return new Table( + 'individuals', + DB::varchar(name: 'i_id', length: 20), + DB::integer(name: 'i_file'), + DB::varchar(name: 'i_rin', length: 20), + DB::char(name: 'i_sex', length: 1), + DB::text(name: 'i_gedcom'), + DB::primaryKey(columns: ['i_id', 'i_file']), + DB::uniqueIndex(columns: ['i_file', 'i_id']), + DB::index(columns: ['i_file', 'i_sex']), + DB::foreignKey(local_columns: ['i_file'], foreign_table: 'gedcom', foreign_columns: ['gedcom_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableJob(): Table + { + return new Table( + 'job', + DB::integer(name: 'job_id')->autoincrement(), + DB::varchar(name: 'job_status', length: 15)->default('queued'), + DB::integer(name: 'fail_count')->default(0), + DB::timestamp(name: 'queued_at')->default(default: 'CURRENT_TIMESTAMP'), + DB::timestamp(name: 'queued_at'), + DB::primaryKey(columns: ['job_id']), + ); + } + + public static function tableLink(): Table + { + return new Table( + 'link', + DB::integer(name: 'l_file'), + DB::varchar(name: 'l_from', length: 20), + DB::varchar(name: 'l_type', length: 15), + DB::varchar(name: 'l_to', length: 20), + DB::primaryKey(columns: ['l_from', 'l_file', 'l_type', 'l_to']), + DB::uniqueIndex(columns: ['l_to', 'l_file', 'l_type', 'l_from']), + DB::foreignKey(local_columns: ['l_file'], foreign_table: 'gedcom', foreign_columns: ['gedcom_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableLog(): Table + { + return new Table( + 'log', + DB::integer(name: 'log_id')->autoincrement(), + DB::timestamp(name: 'log_time')->default(default: 'CURRENT_TIMESTAMP'), + DB::varchar(name: 'log_type', length: 6), + DB::text(name: 'log_message'), + DB::varchar(name: 'ip_address', length: 45), + DB::integer(name: 'user_id')->nullable(), + DB::integer(name: 'gedcom_id')->nullable(), + DB::primaryKey(columns: ['log_id']), + DB::index(columns: ['gedcom_id']), + DB::index(columns: ['user_id']), + DB::index(columns: ['log_time']), + DB::index(columns: ['log_type']), + DB::index(columns: ['ip_address']), + DB::foreignKey(local_columns: ['user_id'], foreign_table: 'user')->onDeleteSetNull()->onUpdateCascade(), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteSetNull()->onUpdateCascade(), + ); + } + + public static function tableMedia(): Table + { + return new Table( + 'media', + DB::varchar(name: 'm_id', length: 20), + DB::integer(name: 'm_file'), + DB::text(name: 'm_gedcom'), + DB::primaryKey(columns: ['m_file', 'm_id']), + DB::uniqueIndex(columns: ['m_id', 'm_file']), + DB::foreignKey(local_columns: ['m_file'], foreign_table: 'gedcom', foreign_columns: ['gedcom_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableMediaFile(): Table + { + return new Table( + 'media_file', + DB::integer(name: 'id')->autoincrement(), + DB::varchar(name: 'm_id', length: 20), + DB::integer(name: 'm_file'), + DB::nvarchar(name: 'multimedia_file_refn', length: 248), + DB::nvarchar(name: 'multimedia_format', length: 4), + DB::nvarchar(name: 'source_media_type', length: 15), + DB::nvarchar(name: 'descriptive_title', length: 248), + DB::primaryKey(columns: ['id']), + DB::index(columns: ['m_id', 'm_file']), + DB::index(columns: ['m_file', 'm_id']), + DB::index(columns: ['m_file', 'multimedia_file_refn']), + DB::index(columns: ['m_file', 'multimedia_format']), + DB::index(columns: ['m_file', 'source_media_type']), + DB::index(columns: ['m_file', 'descriptive_title']), + ); + } + + public static function tableMessage(): Table + { + return new Table( + 'message', + DB::integer(name: 'message_id')->autoincrement(), + DB::nvarchar(name: 'sender', length: 64), + DB::varchar(name: 'ip_address', length: 45), + DB::integer(name: 'user_id'), + DB::nvarchar(name: 'subject', length: 255), + DB::text(name: 'body'), + DB::timestamp(name: 'created')->default(default: 'CURRENT_TIMESTAMP'), + DB::primaryKey(columns: ['message_id']), + DB::index(columns: ['user_id']), + DB::foreignKey(local_columns: ['user_id'], foreign_table: 'user')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableModule(): Table + { + return new Table( + 'module', + DB::varchar(name: 'module_name', length: 32), + DB::char(name: 'status', length: 8)->default(default: 'enabled'), + DB::integer(name: 'tab_order')->nullable(), + DB::integer(name: 'menu_order')->nullable(), + DB::integer(name: 'sidebar_order')->nullable(), + DB::integer(name: 'footer_order')->nullable(), + DB::primaryKey(columns: ['module_name']), + ); + } + + public static function tableModulePrivacy(): Table + { + return new Table( + 'module_privacy', + DB::integer(name: 'id')->autoincrement(), + DB::varchar(name: 'module_name', length: 32), + DB::integer(name: 'gedcom_id'), + DB::varchar(name: 'interface', length: 255), + DB::integer(name: 'access_level'), + DB::primaryKey(columns: ['id']), + DB::uniqueIndex(columns: ['gedcom_id', 'module_name', 'interface']), + DB::uniqueIndex(columns: ['module_name', 'gedcom_id', 'interface']), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + DB::foreignKey(local_columns: ['module_name'], foreign_table: 'module')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableModuleSetting(): Table + { + return new Table( + 'module_setting', + DB::varchar(name: 'module_name', length: 32), + DB::varchar(name: 'setting_name', length: 32), + DB::text(name: 'setting_value'), + DB::primaryKey(columns: ['module_name', 'setting_name']), + DB::foreignKey(local_columns: ['module_name'], foreign_table: 'module')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableName(): Table + { + return new Table( + 'name', + DB::integer(name: 'n_file'), + DB::varchar(name: 'n_id', length: 20), + DB::integer(name: 'n_num'), + DB::varchar(name: 'n_type', length: 15), + DB::nvarchar(name: 'n_sort', length: 255), + DB::nvarchar(name: 'n_full', length: 255), + DB::nvarchar(name: 'n_surname', length: 255)->nullable(), + DB::nvarchar(name: 'n_surn', length: 255)->nullable(), + DB::nvarchar(name: 'n_givn', length: 255)->nullable(), + DB::varchar(name: 'n_soundex_givn_std', length: 255)->nullable(), + DB::varchar(name: 'n_soundex_surn_std', length: 255)->nullable(), + DB::varchar(name: 'n_soundex_givn_dm', length: 255)->nullable(), + DB::varchar(name: 'n_soundex_surn_dm', length: 255)->nullable(), + DB::primaryKey(columns: ['n_id', 'n_file', 'n_num']), + DB::index(columns: ['n_full', 'n_id', 'n_file']), + DB::index(columns: ['n_givn', 'n_file', 'n_type', 'n_id']), + DB::index(columns: ['n_surn', 'n_file', 'n_type', 'n_id']), + DB::foreignKey(local_columns: ['n_file', 'n_id'], foreign_table: 'individuals', foreign_columns: ['i_file', 'i_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableNews(): Table + { + return new Table( + 'news', + DB::integer(name: 'news_id')->autoincrement(), + DB::integer(name: 'user_id')->nullable(), + DB::integer(name: 'gedcom_id')->nullable(), + DB::nvarchar(name: 'subject', length: 255), + DB::text(name: 'body'), + DB::timestamp(name: 'updated')->default(default: 'CURRENT_TIMESTAMP'), + DB::primaryKey(columns: ['news_id']), + DB::foreignKey(local_columns: ['user_id'], foreign_table: 'user')->onDeleteCascade()->onUpdateCascade(), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableOther(): Table + { + return new Table( + 'other', + DB::varchar(name: 'o_id', length: 20), + DB::integer(name: 'o_file'), + DB::varchar(name: 'o_type', length: 15), + DB::text(name: 'o_gedcom'), + DB::primaryKey(columns: ['o_file', 'o_id']), + DB::uniqueIndex(columns: ['o_id', 'o_file']), + DB::foreignKey(local_columns: ['o_file'], foreign_table: 'gedcom', foreign_columns: ['gedcom_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tablePlaceLocation(): Table + { + return new Table( + 'place_location', + DB::integer(name: 'id')->autoincrement(), + DB::integer(name: 'parent_id')->nullable(), + DB::nvarchar(name: 'place', length: 120), + DB::float(name: 'latitude')->nullable(), + DB::float(name: 'longitude')->nullable(), + DB::primaryKey(columns: ['id']), + DB::uniqueIndex(columns: ['parent_id', 'place']), + DB::uniqueIndex(columns: ['place', 'parent_id']), + DB::foreignKey(local_columns: ['parent_id'], foreign_table: 'place_location', foreign_columns: ['id']), + DB::index(columns: ['latitude']), + DB::index(columns: ['longitude']), + ); + } + + public static function tablePlaceLinks(): Table + { + return new Table( + 'placelinks', + DB::integer(name: 'pl_p_id'), + DB::varchar(name: 'pl_gid', length: 20), + DB::integer(name: 'pl_file'), + DB::primaryKey(columns: ['pl_p_id', 'pl_gid', 'pl_file']), + DB::index(columns: ['pl_p_id']), + DB::index(columns: ['pl_gid']), + DB::index(columns: ['pl_file']), + DB::foreignKey(local_columns: ['pl_file'], foreign_table: 'gedcom', foreign_columns: ['gedcom_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tablePlaces(): Table + { + return new Table( + 'places', + DB::integer(name: 'p_id')->autoincrement(), + DB::nvarchar(name: 'p_place', length: 150), + DB::integer(name: 'p_parent_id')->nullable(), + DB::integer(name: 'p_file'), + DB::text(name: 'p_std_soundex'), + DB::text(name: 'p_dm_soundex'), + DB::primaryKey(columns: ['p_id']), + DB::uniqueIndex(columns: ['p_parent_id', 'p_file', 'p_place']), + DB::foreignKey(local_columns: ['p_file'], foreign_table: 'gedcom', foreign_columns: ['gedcom_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableSession(): Table + { + return new Table( + 'session', + DB::varchar(name: 'session_id', length: 32), + DB::timestamp(name: 'session_time')->default(default: 'CURRENT_TIMESTAMP'), + DB::integer(name: 'user_id')->nullable(), + DB::varchar(name: 'ip_address', length: 45), + DB::text(name: 'session_data'), + DB::primaryKey(columns: ['session_id']), + DB::index(columns: ['session_time']), + DB::index(columns: ['user_id', 'ip_address']), + DB::foreignKey(local_columns: ['user_id'], foreign_table: 'user')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableSiteSetting(): Table + { + return new Table( + 'site_setting', + DB::varchar(name: 'setting_name', length: 32), + DB::nvarchar(name: 'setting_value', length: 2000), + DB::primaryKey(columns: ['setting_name']), + ); + } + + public static function tableSources(): Table + { + return new Table( + 'sources', + DB::varchar(name: 's_id', length: 20), + DB::integer(name: 's_file'), + DB::nvarchar(name: 's_name', length: 255), + DB::text(name: 's_gedcom'), + DB::primaryKey(columns: ['s_file', 's_id']), + DB::uniqueIndex(columns: ['s_id', 's_file']), + DB::index(columns: ['s_file', 's_name']), + DB::foreignKey(local_columns: ['s_file'], foreign_table: 'gedcom', foreign_columns: ['gedcom_id'])->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableUser(): Table + { + return new Table( + 'user', + DB::integer('user_id')->autoincrement(), + DB::nvarchar('user_name', length: 32), + DB::nvarchar('real_name', length: 64), + DB::nvarchar('email', length: 64), + DB::varchar('password', length: 128), + DB::primaryKey(columns: ['user_id']), + DB::uniqueIndex(columns: ['user_name']), + DB::uniqueIndex(columns: ['email']), + ); + } + + public static function tableUserGedcomSetting(): Table + { + return new Table( + 'user_gedcom_setting', + DB::integer(name: 'user_id'), + DB::integer(name: 'gedcom_id'), + DB::varchar(name: 'setting_name', length: 32), + DB::nvarchar(name: 'setting_value', length: 255), + DB::primaryKey(columns: ['user_id', 'gedcom_id', 'setting_name']), + DB::index(columns: ['gedcom_id']), + DB::foreignKey(local_columns: ['user_id'], foreign_table: 'user')->onDeleteCascade()->onUpdateCascade(), + DB::foreignKey(local_columns: ['gedcom_id'], foreign_table: 'gedcom')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function tableUserSetting(): Table + { + return new Table( + 'user_setting', + DB::integer(name: 'user_id'), + DB::varchar(name: 'setting_name', length: 32), + DB::nvarchar(name: 'setting_value', length: 255), + DB::primaryKey(columns: ['user_id', 'setting_name']), + DB::foreignKey(local_columns: ['user_id'], foreign_table: 'user')->onDeleteCascade()->onUpdateCascade(), + ); + } + + public static function schema(): Schema + { + return new Schema( + [ + self::tableBlock(), + self::tableBlockSetting(), + self::tableChange(), + self::tableDates(), + self::tableDefaultResn(), + self::tableFamilies(), + self::tableFavorite(), + //self::tableFile(), + self::tableGedcom(), + self::tableGedcomChunk(), + self::tableGedcomSetting(), + self::tableHitCounter(), + self::tableIndividuals(), + //self::tableJob(), + self::tableLink(), + self::tableLog(), + self::tableMedia(), + self::tableMediaFile(), + self::tableMessage(), + self::tableModule(), + self::tableModulePrivacy(), + self::tableModuleSetting(), + self::tableName(), + self::tableNews(), + self::tableOther(), + self::tablePlaceLocation(), + self::tablePlaceLinks(), + self::tablePlaces(), + self::tableSession(), + self::tableSiteSetting(), + self::tableSources(), + self::tableUser(), + self::tableUserGedcomSetting(), + self::tableUserSetting(), + ], + ); + } +} diff --git a/composer.json b/composer.json index 5a86f6e752d..91b4d28a1e5 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "ext-xml": "*", "aura/router": "3.3.0", "ezyang/htmlpurifier": "4.18.0", + "doctrine/dbal": "4.2.1", "fig/http-message-util": "1.1.5", "fisharebest/algorithm": "1.6.0", "fisharebest/ext-calendar": "2.6.0", diff --git a/composer.lock b/composer.lock index 16e578d7ae2..d3d5c30e533 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "98a739e645ec791709e6f94dd26909fb", + "content-hash": "4b03313042eef8f7880ad5c34edb208d", "packages": [ { "name": "aura/router", @@ -263,6 +263,159 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/dbal", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "dadd35300837a3a2184bd47d403333b15d0a9bd0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/dadd35300837a3a2184bd47d403333b15d0a9bd0", + "reference": "dadd35300837a3a2184bd47d403333b15d0a9bd0", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^0.5.3|^1", + "php": "^8.1", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "12.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.2", + "phpstan/phpstan": "1.12.6", + "phpstan/phpstan-phpunit": "1.4.0", + "phpstan/phpstan-strict-rules": "^1.6", + "phpunit/phpunit": "10.5.30", + "psalm/plugin-phpunit": "0.19.0", + "slevomat/coding-standard": "8.13.1", + "squizlabs/php_codesniffer": "3.10.2", + "symfony/cache": "^6.3.8|^7.0", + "symfony/console": "^5.4|^6.3|^7.0", + "vimeo/psalm": "5.25.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/4.2.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2024-10-10T18:01:27+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", + "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12", + "phpstan/phpstan": "1.4.10 || 2.0.3", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + }, + "time": "2024-12-07T21:18:45+00:00" + }, { "name": "doctrine/inflector", "version": "2.0.10", @@ -1123,16 +1276,16 @@ }, { "name": "illuminate/collections", - "version": "v11.36.1", + "version": "v11.37.0", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "21868f9ac221a42d4346dc56495d11ab7e0d339a" + "reference": "9100b083eeb85d38d78fc1de28f7326596ab2eda" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/21868f9ac221a42d4346dc56495d11ab7e0d339a", - "reference": "21868f9ac221a42d4346dc56495d11ab7e0d339a", + "url": "https://api.github.com/repos/illuminate/collections/zipball/9100b083eeb85d38d78fc1de28f7326596ab2eda", + "reference": "9100b083eeb85d38d78fc1de28f7326596ab2eda", "shasum": "" }, "require": { @@ -1175,11 +1328,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-12-13T13:58:10+00:00" + "time": "2024-12-18T14:14:45+00:00" }, { "name": "illuminate/conditionable", - "version": "v11.36.1", + "version": "v11.37.0", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -1225,7 +1378,7 @@ }, { "name": "illuminate/container", - "version": "v11.36.1", + "version": "v11.37.0", "source": { "type": "git", "url": "https://github.com/illuminate/container.git", @@ -1276,7 +1429,7 @@ }, { "name": "illuminate/contracts", - "version": "v11.36.1", + "version": "v11.37.0", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", @@ -1393,7 +1546,7 @@ }, { "name": "illuminate/macroable", - "version": "v11.36.1", + "version": "v11.37.0", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c792cdf9236..10253cd3c3a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,59 @@ parameters: ignoreErrors: + - + message: '#^Method Fisharebest\\Webtrees\\Arr\:\:concat\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/Arr.php + + - + message: '#^Method Fisharebest\\Webtrees\\Arr\:\:filter\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/Arr.php + + - + message: '#^Method Fisharebest\\Webtrees\\Arr\:\:flatten\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/Arr.php + + - + message: '#^Method Fisharebest\\Webtrees\\Arr\:\:map\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/Arr.php + + - + message: '#^Method Fisharebest\\Webtrees\\Arr\:\:reverse\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/Arr.php + + - + message: '#^Method Fisharebest\\Webtrees\\Arr\:\:sort\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/Arr.php + + - + message: '#^Method Fisharebest\\Webtrees\\Arr\:\:sortKeys\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/Arr.php + + - + message: '#^Method Fisharebest\\Webtrees\\Arr\:\:unique\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/Arr.php + + - + message: '#^Parameter \#1 \.\.\.\$arrays of function array_merge expects array, TValue given\.$#' + identifier: argument.type + count: 1 + path: app/Arr.php + - message: '#^Cannot call method find\(\) on mixed\.$#' identifier: method.nonObject @@ -199,23 +253,257 @@ parameters: path: app/Container.php - - message: '#^Constant Fisharebest\\Webtrees\\DB\:\:COLLATION_ASCII is unused\.$#' - identifier: classConstant.unused + message: '#^Match expression does not handle remaining value\: string$#' + identifier: match.unhandled count: 1 path: app/DB.php - - message: '#^Constant Fisharebest\\Webtrees\\DB\:\:COLLATION_UTF8 is unused\.$#' - identifier: classConstant.unused + message: '#^Method Fisharebest\\Webtrees\\DB\:\:driverName\(\) should return string but returns mixed\.$#' + identifier: return.type count: 1 path: app/DB.php - - message: '#^Method Fisharebest\\Webtrees\\DB\:\:driverName\(\) should return string but returns mixed\.$#' + message: '#^Method Fisharebest\\Webtrees\\DB\:\:update\(\) should return Doctrine\\DBAL\\Query\\QueryBuilder but returns int\.$#' identifier: return.type count: 1 path: app/DB.php + - + message: '#^Parameter \$columns of class Fisharebest\\Webtrees\\DB\\Index constructor expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: app/DB.php + + - + message: '#^Parameter \$columns of class Fisharebest\\Webtrees\\DB\\PrimaryKey constructor expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: app/DB.php + + - + message: '#^Parameter \$columns of class Fisharebest\\Webtrees\\DB\\UniqueIndex constructor expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: app/DB.php + + - + message: '#^Parameter \$foreign_columns of class Fisharebest\\Webtrees\\DB\\ForeignKey constructor expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: app/DB.php + + - + message: '#^Parameter \$local_columns of class Fisharebest\\Webtrees\\DB\\ForeignKey constructor expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: app/DB.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Drivers\\DriverInterface\:\:connect\(\) has parameter \$params with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: app/DB/Drivers/DriverInterface.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Drivers\\MySQLDriver\:\:query\(\) should return array\ but returns array\.$#' + identifier: return.type + count: 1 + path: app/DB/Drivers/MySQLDriver.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Drivers\\PostgreSQLDriver\:\:query\(\) should return array\ but returns array\.$#' + identifier: return.type + count: 1 + path: app/DB/Drivers/PostgreSQLDriver.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Drivers\\SQLServerDriver\:\:query\(\) should return array\ but returns array\.$#' + identifier: return.type + count: 1 + path: app/DB/Drivers/SQLServerDriver.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Drivers\\SQLiteDriver\:\:query\(\) should return array\ but returns array\.$#' + identifier: return.type + count: 1 + path: app/DB/Drivers/SQLiteDriver.php + + - + message: '#^Binary operation "\." between '' AS '' and mixed results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: app/DB/GroupConcat.php + + - + message: '#^Binary operation "\." between ''GROUP_CONCAT\('' and mixed results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: app/DB/GroupConcat.php + + - + message: '#^Binary operation "\." between ''STRING_AGG\('' and mixed results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: app/DB/GroupConcat.php + + - + message: '#^Binary operation "\." between non\-falsy\-string and mixed results in an error\.$#' + identifier: binaryOp.invalid + count: 4 + path: app/DB/GroupConcat.php + + - + message: '#^Call to an undefined static method Illuminate\\Database\\Capsule\\Manager\:\:getTheConnection\(\)\.$#' + identifier: staticMethod.notFound + count: 5 + path: app/DB/GroupConcat.php + + - + message: '#^Cannot call method getDriverName\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: app/DB/GroupConcat.php + + - + message: '#^Cannot call method getPdo\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: app/DB/GroupConcat.php + + - + message: '#^Cannot call method getSchemaGrammar\(\) on mixed\.$#' + identifier: method.nonObject + count: 3 + path: app/DB/GroupConcat.php + + - + message: '#^Cannot call method quote\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: app/DB/GroupConcat.php + + - + message: '#^Cannot call method wrap\(\) on mixed\.$#' + identifier: method.nonObject + count: 3 + path: app/DB/GroupConcat.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Query\:\:first\(\) never returns float so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: app/DB/Query.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Query\:\:first\(\) never returns null so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: app/DB/Query.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Query\:\:first\(\) never returns string so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: app/DB/Query.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Query\:\:pluck\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/DB/Query.php + + - + message: '#^Method Fisharebest\\Webtrees\\DB\\Query\:\:rows\(\) should return Fisharebest\\Webtrees\\Arr\ but returns Fisharebest\\Webtrees\\Arr\<\(int\|string\), mixed\>\.$#' + identifier: return.type + count: 1 + path: app/DB/Query.php + + - + message: '#^Access to an undefined property Fisharebest\\Webtrees\\DB\\Table\:\:\$unique_keys\.$#' + identifier: property.notFound + count: 1 + path: app/DB/Table.php + + - + message: '#^Only iterables can be unpacked, mixed given in argument \#5\.$#' + identifier: argument.unpackNonIterable + count: 1 + path: app/DB/Table.php + + - + message: '#^Parameter \#5 \.\.\.\$components of class Fisharebest\\Webtrees\\DB\\Table constructor expects Fisharebest\\Webtrees\\DB\\Column\|Fisharebest\\Webtrees\\DB\\ForeignKey\|Fisharebest\\Webtrees\\DB\\Index\|Fisharebest\\Webtrees\\DB\\PrimaryKey\|Fisharebest\\Webtrees\\DB\\UniqueIndex, mixed given\.$#' + identifier: argument.type + count: 1 + path: app/DB/Table.php + + - + message: '#^Parameter \$callback of function array_walk expects callable\(Fisharebest\\Webtrees\\DB\\Column\|Fisharebest\\Webtrees\\DB\\ForeignKey\|Fisharebest\\Webtrees\\DB\\Index\|Fisharebest\\Webtrees\\DB\\PrimaryKey\|Fisharebest\\Webtrees\\DB\\UniqueIndex, int\<0, max\>\)\: mixed, Closure\(Fisharebest\\Webtrees\\DB\\ForeignKey, int\|string\)\: void given\.$#' + identifier: argument.type + count: 1 + path: app/DB/Table.php + + - + message: '#^Parameter \$callback of function array_walk expects callable\(Fisharebest\\Webtrees\\DB\\Column\|Fisharebest\\Webtrees\\DB\\ForeignKey\|Fisharebest\\Webtrees\\DB\\Index\|Fisharebest\\Webtrees\\DB\\PrimaryKey\|Fisharebest\\Webtrees\\DB\\UniqueIndex, int\<0, max\>\)\: mixed, Closure\(Fisharebest\\Webtrees\\DB\\Index, int\|string\)\: void given\.$#' + identifier: argument.type + count: 1 + path: app/DB/Table.php + + - + message: '#^Parameter \$callback of function array_walk expects callable\(Fisharebest\\Webtrees\\DB\\Column\|Fisharebest\\Webtrees\\DB\\ForeignKey\|Fisharebest\\Webtrees\\DB\\Index\|Fisharebest\\Webtrees\\DB\\PrimaryKey\|Fisharebest\\Webtrees\\DB\\UniqueIndex, int\<0, max\>\)\: mixed, Closure\(Fisharebest\\Webtrees\\DB\\UniqueIndex, int\|string\)\: void given\.$#' + identifier: argument.type + count: 1 + path: app/DB/Table.php + + - + message: '#^Parameter \$columns of method Doctrine\\DBAL\\Schema\\Table\:\:__construct\(\) expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: app/DB/Table.php + + - + message: '#^Parameter \$fkConstraints of method Doctrine\\DBAL\\Schema\\Table\:\:__construct\(\) expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: app/DB/Table.php + + - + message: '#^Parameter \$indexes of method Doctrine\\DBAL\\Schema\\Table\:\:__construct\(\) expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: app/DB/Table.php + + - + message: '#^Property Fisharebest\\Webtrees\\DB\\Table\:\:\$columns \(array\\) does not accept list\\.$#' + identifier: assign.propertyType + count: 1 + path: app/DB/Table.php + + - + message: '#^Property Fisharebest\\Webtrees\\DB\\Table\:\:\$foreign_keys \(array\\) does not accept list\\.$#' + identifier: assign.propertyType + count: 1 + path: app/DB/Table.php + + - + message: '#^Property Fisharebest\\Webtrees\\DB\\Table\:\:\$indexes \(array\\) does not accept list\\.$#' + identifier: assign.propertyType + count: 1 + path: app/DB/Table.php + + - + message: '#^Property Fisharebest\\Webtrees\\DB\\Table\:\:\$primary_keys \(array\\) does not accept list\\.$#' + identifier: assign.propertyType + count: 1 + path: app/DB/Table.php + + - + message: '#^Property Fisharebest\\Webtrees\\DB\\Table\:\:\$unique_indexes \(array\\) does not accept list\\.$#' + identifier: assign.propertyType + count: 1 + path: app/DB/Table.php + - message: '#^Cannot access offset int on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -2497,31 +2785,13 @@ parameters: path: app/Http/RequestHandlers/SearchReplaceAction.php - - message: '#^Binary operation "\." between ''CREATE DATABASE IF…'' and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Binary operation "\." between ''setup/step\-4…'' and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Binary operation "\." between literal\-string&non\-falsy\-string and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Cannot call method isNotEmpty\(\) on mixed\.$#' + message: '#^Cannot call method isNotEmpty\(\) on string\.$#' identifier: method.nonObject count: 2 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Cannot call method push\(\) on mixed\.$#' + message: '#^Cannot call method push\(\) on string\.$#' identifier: method.nonObject count: 2 path: app/Http/RequestHandlers/SetupWizard.php @@ -2533,173 +2803,77 @@ parameters: path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#1 \$code of static method Fisharebest\\Webtrees\\I18N\:\:init\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \#1 \$driver of method Fisharebest\\Webtrees\\Services\\ServerCheckService\:\:serverErrors\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \#1 \$driver of method Fisharebest\\Webtrees\\Services\\ServerCheckService\:\:serverWarnings\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \#1 \$identifier of method Fisharebest\\Webtrees\\Services\\UserService\:\:findByIdentifier\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \#1 \$password of method Fisharebest\\Webtrees\\User\:\:setPassword\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \#1 \$request of static method Fisharebest\\Webtrees\\Session\:\:start\(\) expects Psr\\Http\\Message\\ServerRequestInterface, mixed given\.$#' - identifier: argument.type - count: 1 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \#1 \$server of static method Fisharebest\\Localization\\Locale\:\:httpAcceptLanguage\(\) expects array\, array given\.$#' - identifier: argument.type - count: 1 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \#1 \$url of function redirect expects string, mixed given\.$#' + message: '#^Parameter \#1 \$data of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:step1Language\(\) expects array\, array\\|Illuminate\\Support\\Collection\\|int\|string\> given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#1 \$user_name of method Fisharebest\\Webtrees\\Services\\UserService\:\:create\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$data of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:step2CheckServer\(\) expects array\, array\\|Illuminate\\Support\\Collection\\|int\|string\> given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#1 \$value of function e expects BackedEnum\|float\|Illuminate\\Contracts\\Support\\DeferringDisplayableValue\|Illuminate\\Contracts\\Support\\Htmlable\|int\|string\|null, string\|false given\.$#' + message: '#^Parameter \#1 \$data of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:step3DatabaseType\(\) expects array\, array\\|Illuminate\\Support\\Collection\\|int\|string\> given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#1 \$wtname of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:checkAdminUser\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$data of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:step4DatabaseConnection\(\) expects array\, array\\|Illuminate\\Support\\Collection\\|int\|string\> given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#2 \$real_name of method Fisharebest\\Webtrees\\Services\\UserService\:\:create\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$data of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:step5Administrator\(\) expects array\, array\\|Illuminate\\Support\\Collection\\|int\|string\> given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#2 \$setting_value of method Fisharebest\\Webtrees\\User\:\:setPreference\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$data of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:step6Install\(\) expects array\, array\\|Illuminate\\Support\\Collection\\|int\|string\> given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#2 \$wtuser of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:checkAdminUser\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$driver of method Fisharebest\\Webtrees\\Services\\ServerCheckService\:\:serverErrors\(\) expects string, Illuminate\\Support\\Collection\\|int\|string given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#3 \$email of method Fisharebest\\Webtrees\\Services\\UserService\:\:create\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$driver of method Fisharebest\\Webtrees\\Services\\ServerCheckService\:\:serverWarnings\(\) expects string, Illuminate\\Support\\Collection\\|Illuminate\\Support\\Collection\\|int\|string given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#3 \$wtpass of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:checkAdminUser\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$password of method Fisharebest\\Webtrees\\User\:\:setPassword\(\) expects string, mixed given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#4 \$password of method Fisharebest\\Webtrees\\Services\\UserService\:\:create\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$request of static method Fisharebest\\Webtrees\\Session\:\:start\(\) expects Psr\\Http\\Message\\ServerRequestInterface, mixed given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \#4 \$wtemail of method Fisharebest\\Webtrees\\Http\\RequestHandlers\\SetupWizard\:\:checkAdminUser\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$server of static method Fisharebest\\Localization\\Locale\:\:httpAcceptLanguage\(\) expects array\, array given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Parameter \$ca of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \$certificate of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \$database of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' + message: '#^Parameter \#1 \$value of function e expects BackedEnum\|float\|Illuminate\\Contracts\\Support\\DeferringDisplayableValue\|Illuminate\\Contracts\\Support\\Htmlable\|int\|string\|null, string\|false given\.$#' identifier: argument.type count: 1 path: app/Http/RequestHandlers/SetupWizard.php - - - message: '#^Parameter \$driver of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \$host of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \$key of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \$password of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \$port of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \$prefix of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - - - message: '#^Parameter \$username of static method Fisharebest\\Webtrees\\DB\:\:connect\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: app/Http/RequestHandlers/SetupWizard.php - - message: '#^Access to an undefined property object\:\:\$gedcom_name\.$#' identifier: property.notFound