diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 8af71c683..36cd4b85f 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -1,9 +1,6 @@ -name: Static Analysis (only informative) +name: Static Analysis -on: - push: - branches: - - master +on: [push, pull_request] jobs: phpstan: @@ -13,9 +10,8 @@ jobs: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.5 coverage: none - run: composer install --no-progress --prefer-dist - run: composer phpstan -- --no-progress - continue-on-error: true # is only informative diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 03ce917d6..37c8b1aaa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,6 +72,20 @@ jobs: --health-timeout 5s --health-retries 5 + postgres15: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: nette_test + ports: + - 5434:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mssql: image: mcr.microsoft.com/mssql/server:latest env: @@ -103,7 +117,7 @@ jobs: run: docker exec -i mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P 'YourStrong!Passw0rd' -Q 'CREATE DATABASE nette_test' -N -C - run: composer install --no-progress --prefer-dist - - run: vendor/bin/tester tests -s -C + - run: composer tester - if: failure() uses: actions/upload-artifact@v4 with: @@ -125,4 +139,4 @@ jobs: run: cp ./tests/databases.sqlite.ini ./tests/Database/databases.ini - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable - - run: vendor/bin/tester tests -s -C + - run: composer tester diff --git a/composer.json b/composer.json index 5c926e4d0..3b047bcbf 100644 --- a/composer.json +++ b/composer.json @@ -21,11 +21,13 @@ "nette/utils": "^4.0" }, "require-dev": { - "nette/tester": "^2.5", + "nette/tester": "^2.6", "nette/di": "^3.1", "mockery/mockery": "^1.6@stable", "tracy/tracy": "^2.9", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan": "^2.1@stable", + "phpstan/extension-installer": "^1.4@stable", + "nette/phpstan-rules": "^1.0", "jetbrains/phpstorm-attributes": "^1.2" }, "autoload": { @@ -43,5 +45,10 @@ "branch-alias": { "dev-master": "3.2-dev" } + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + } } } diff --git a/phpstan.neon b/phpstan.neon index 8a634adc9..a08469300 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,6 +3,3 @@ parameters: paths: - src - -includes: - - vendor/phpstan/phpstan-nette/extension.neon diff --git a/src/Bridges/DatabaseDI/DatabaseExtension.php b/src/Bridges/DatabaseDI/DatabaseExtension.php index cda600eb2..745c13446 100644 --- a/src/Bridges/DatabaseDI/DatabaseExtension.php +++ b/src/Bridges/DatabaseDI/DatabaseExtension.php @@ -14,7 +14,7 @@ /** - * Nette Framework Database services. + * Registers database Connection, Structure, and Explorer services in the DI container. */ class DatabaseExtension extends Nette\DI\CompilerExtension { @@ -36,6 +36,10 @@ public function getConfigSchema(): Nette\Schema\Schema 'explain' => Expect::bool(true), 'reflection' => Expect::string(), // BC 'conventions' => Expect::string('discovered'), // Nette\Database\Conventions\DiscoveredConventions + 'mapping' => Expect::structure([ + 'convention' => Expect::string(''), + 'tables' => Expect::arrayOf('string', 'string'), + ])->before(fn($v) => is_string($v) ? ['convention' => $v] : $v), 'autowired' => Expect::bool(), ]), )->before(fn($val) => is_array(reset($val)) || reset($val) === null @@ -120,8 +124,15 @@ private function setupDatabase(\stdClass $config, string $name): void $conventions = Nette\DI\Helpers::filterArguments([$config->conventions])[0]; } + $rowMapping = ($config->mapping->convention || $config->mapping->tables) + ? new Nette\DI\Definitions\Statement([self::class, 'createRowMapping'], [ + $config->mapping->convention, + (array) $config->mapping->tables, + ]) + : null; + $builder->addDefinition($this->prefix("$name.explorer")) - ->setFactory(Nette\Database\Explorer::class, [$connection, $structure, $conventions]) + ->setFactory(Nette\Database\Explorer::class, [$connection, $structure, $conventions, null, $rowMapping]) ->setAutowired($config->autowired); $builder->addAlias($this->prefix("$name.context"), $this->prefix("$name.explorer")); @@ -132,4 +143,28 @@ private function setupDatabase(\stdClass $config, string $name): void $builder->addAlias("nette.database.$name.context", $this->prefix("$name.explorer")); } } + + + /** + * Creates a row mapping closure that resolves an ActiveRow subclass for each table name. + * @param array $tables + * @return \Closure(string): string + */ + public static function createRowMapping(string $convention, array $tables): \Closure + { + return static function (string $table) use ($convention, $tables): string { + if (isset($tables[$table])) { + return $tables[$table]; + } + + if ($convention !== '') { + $class = str_replace('*', str_replace(' ', '', ucwords(strtr($table, '_', ' '))), $convention); + if (class_exists($class)) { + return $class; + } + } + + return Nette\Database\Table\ActiveRow::class; + }; + } } diff --git a/src/Bridges/DatabaseTracy/ConnectionPanel.php b/src/Bridges/DatabaseTracy/ConnectionPanel.php index cf6d66817..bbe6ae6f4 100644 --- a/src/Bridges/DatabaseTracy/ConnectionPanel.php +++ b/src/Bridges/DatabaseTracy/ConnectionPanel.php @@ -15,7 +15,7 @@ /** - * Debug panel for Nette\Database. + * Tracy Bar panel showing executed SQL queries with timing and EXPLAIN support. */ class ConnectionPanel implements Tracy\IBarPanel { @@ -26,10 +26,15 @@ class ConnectionPanel implements Tracy\IBarPanel public float $performanceScale = 0.25; private float $totalTime = 0; private int $count = 0; + + /** @var list, list>, ?float, ?int, ?string}> */ private array $queries = []; private Tracy\BlueScreen $blueScreen; + /** + * Registers the panel with Tracy. Optionally adds it to the Tracy Bar. + */ public static function initialize( Connection $connection, bool $addBarPanel = false, @@ -61,6 +66,7 @@ public function __construct(Connection $connection, Tracy\BlueScreen $blueScreen } + /** @param Nette\Database\ResultSet|\PDOException $result */ private function logQuery(Connection $connection, $result): void { if ($this->disabled) { @@ -91,6 +97,7 @@ private function logQuery(Connection $connection, $result): void } + /** @return array{tab: string, panel: string}|null */ public static function renderException(?\Throwable $e): ?array { if (!$e instanceof \PDOException) { diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 6f9d81eee..33bbabf6b 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -28,12 +28,13 @@ class Connection private SqlPreprocessor $preprocessor; private ?PDO $pdo = null; - /** @var callable(array, ResultSet): array */ - private $rowNormalizer = [Helpers::class, 'normalizeRow']; + /** @var ?\Closure(array, ResultSet): array */ + private ?\Closure $rowNormalizer; private ?string $sql = null; private int $transactionDepth = 0; + /** @param array $options */ public function __construct( private readonly string $dsn, #[\SensitiveParameter] @@ -42,9 +43,9 @@ public function __construct( private readonly ?string $password = null, private readonly array $options = [], ) { - if (!empty($options['newDateTime'])) { - $this->rowNormalizer = fn($row, $resultSet) => Helpers::normalizeRow($row, $resultSet, DateTime::class); - } + $this->rowNormalizer = !empty($options['newDateTime']) + ? fn(array $row, ResultSet $resultSet): array => Helpers::normalizeRow($row, $resultSet, DateTime::class) + : Helpers::normalizeRow(...); if (empty($options['lazy'])) { $this->connect(); } @@ -52,6 +53,7 @@ public function __construct( /** + * Connects to the database server if not already connected. * @throws ConnectionException */ public function connect(): void @@ -130,17 +132,18 @@ public function getReflection(): Reflection /** - * Sets callback for row preprocessing. + * Sets a callback for normalizing each result row (e.g., type conversion). Pass null to disable. + * @param ?(callable(array, ResultSet): array) $normalizer */ public function setRowNormalizer(?callable $normalizer): static { - $this->rowNormalizer = $normalizer; + $this->rowNormalizer = $normalizer ? $normalizer(...) : null; return $this; } /** - * Returns last inserted ID. + * Returns the ID of the last inserted row, or the last value from a sequence. */ public function getInsertId(?string $sequence = null): string { @@ -209,7 +212,8 @@ public function rollBack(): void /** - * Executes callback inside a transaction. + * Executes callback inside a transaction. Supports nesting. + * @param callable(static): mixed $callback */ public function transaction(callable $callback): mixed { @@ -242,7 +246,7 @@ public function transaction(callable $callback): mixed * Generates and executes SQL query. * @param literal-string $sql */ - public function query(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ResultSet + public function query(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ResultSet { [$this->sql, $params] = $this->preprocess($sql, ...$params); try { @@ -257,7 +261,11 @@ public function query(#[Language('SQL')] string $sql, #[Language('GenericSQL')] } - /** @deprecated use query() */ + /** + * @deprecated use query() + * @param literal-string $sql + * @param array $params + */ public function queryArgs(string $sql, array $params): ResultSet { return $this->query($sql, ...$params); @@ -265,10 +273,11 @@ public function queryArgs(string $sql, array $params): ResultSet /** + * Preprocesses SQL query with parameter substitution and returns the resulting SQL and bound parameters. * @param literal-string $sql - * @return array{string, array} + * @return array{string, array} */ - public function preprocess(string $sql, ...$params): array + public function preprocess(string $sql, mixed ...$params): array { $this->connect(); return $params @@ -287,70 +296,75 @@ public function getLastQueryString(): ?string /** - * Shortcut for query()->fetch() + * Executes SQL query and returns the first row, or null if no rows were returned. * @param literal-string $sql */ - public function fetch(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ?Row + public function fetch(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ?Row { return $this->query($sql, ...$params)->fetch(); } /** - * Shortcut for query()->fetchAssoc() + * Executes SQL query and returns the first row as an associative array, or null. * @param literal-string $sql + * @return ?array */ - public function fetchAssoc(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ?array + public function fetchAssoc(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ?array { return $this->query($sql, ...$params)->fetchAssoc(); } /** - * Shortcut for query()->fetchField() + * Executes SQL query and returns the first field of the first row, or null. * @param literal-string $sql */ - public function fetchField(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): mixed + public function fetchField(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): mixed { return $this->query($sql, ...$params)->fetchField(); } /** - * Shortcut for query()->fetchList() + * Executes SQL query and returns the first row as an indexed array, or null. * @param literal-string $sql + * @return ?list */ - public function fetchList(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ?array + public function fetchList(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ?array { return $this->query($sql, ...$params)->fetchList(); } /** - * Shortcut for query()->fetchList() + * Executes SQL query and returns the first row as an indexed array, or null. * @param literal-string $sql + * @return ?list */ - public function fetchFields(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ?array + public function fetchFields(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ?array { return $this->query($sql, ...$params)->fetchList(); } /** - * Shortcut for query()->fetchPairs() + * Executes SQL query and returns rows as key-value pairs. * @param literal-string $sql + * @return array */ - public function fetchPairs(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): array + public function fetchPairs(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): array { return $this->query($sql, ...$params)->fetchPairs(); } /** - * Shortcut for query()->fetchAll() + * Executes SQL query and returns all rows as an array of Row objects. * @param literal-string $sql + * @return list */ - public function fetchAll(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): array + public function fetchAll(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): array { return $this->query($sql, ...$params)->fetchAll(); } @@ -359,7 +373,7 @@ public function fetchAll(#[Language('SQL')] string $sql, #[Language('GenericSQL' /** * Creates SQL literal value. */ - public static function literal(string $value, ...$params): SqlLiteral + public static function literal(string $value, mixed ...$params): SqlLiteral { return new SqlLiteral($value, $params); } diff --git a/src/Database/Conventions.php b/src/Database/Conventions.php index 31cd5db54..8ed7aaf6c 100644 --- a/src/Database/Conventions.php +++ b/src/Database/Conventions.php @@ -17,26 +17,26 @@ interface Conventions { /** * Returns primary key for table. + * @return string|list|null */ function getPrimary(string $table): string|array|null; /** - * Returns referenced table & referenced column. - * Example: - * (author, book) returns [book, author_id] + * Returns the referencing table name and referencing column for a has-many relationship. + * Example: (author, book) returns [book, author_id] * - * @return array|null [referenced table, referenced column] + * @return ?array{string, string} * @throws AmbiguousReferenceKeyException */ function getHasManyReference(string $table, string $key): ?array; /** - * Returns referenced table & referencing column. - * Example + * Returns the referenced table name and local foreign key column for a belongs-to relationship. + * Example: * (book, author) returns [author, author_id] * (book, translator) returns [author, translator_id] * - * @return array|null [referenced table, referencing column] + * @return ?array{string, string} */ function getBelongsToReference(string $table, string $key): ?array; } diff --git a/src/Database/Conventions/AmbiguousReferenceKeyException.php b/src/Database/Conventions/AmbiguousReferenceKeyException.php index 860370534..f2a1c7480 100644 --- a/src/Database/Conventions/AmbiguousReferenceKeyException.php +++ b/src/Database/Conventions/AmbiguousReferenceKeyException.php @@ -9,7 +9,7 @@ /** - * Ambiguous reference key exception. + * Multiple matching columns found for a relationship reference. */ class AmbiguousReferenceKeyException extends \RuntimeException { diff --git a/src/Database/Conventions/DiscoveredConventions.php b/src/Database/Conventions/DiscoveredConventions.php index ee50c0b19..1bdec3df5 100644 --- a/src/Database/Conventions/DiscoveredConventions.php +++ b/src/Database/Conventions/DiscoveredConventions.php @@ -28,6 +28,10 @@ public function getPrimary(string $table): string|array|null } + /** + * Finds the referencing table and column for a has-many relationship by searching structure metadata. + * Triggers structure rebuild if needed. Throws on ambiguous match. + */ public function getHasManyReference(string $nsTable, string $key): ?array { $candidates = $columnCandidates = []; @@ -77,6 +81,10 @@ public function getHasManyReference(string $nsTable, string $key): ?array } + /** + * Finds the referenced table and local foreign key column for a belongs-to relationship. + * Triggers structure rebuild if needed. + */ public function getBelongsToReference(string $table, string $key): ?array { $tableColumns = $this->structure->getBelongsToReference($table); diff --git a/src/Database/Conventions/StaticConventions.php b/src/Database/Conventions/StaticConventions.php index 32da714f9..094484c41 100644 --- a/src/Database/Conventions/StaticConventions.php +++ b/src/Database/Conventions/StaticConventions.php @@ -17,7 +17,6 @@ class StaticConventions implements Conventions { /** - * Create static conventional structure. * @param string $primary %s stands for table name * @param string $foreign %1$s stands for key used after ->, %2$s for table name * @param string $table %1$s stands for key used after ->, %2$s for table name diff --git a/src/Database/DateTime.php b/src/Database/DateTime.php index 37e46bc44..b4b2fca6c 100644 --- a/src/Database/DateTime.php +++ b/src/Database/DateTime.php @@ -9,7 +9,7 @@ /** - * Date Time. + * Immutable date-time value with JSON and string serialization support. */ final class DateTime extends \DateTimeImmutable implements \JsonSerializable { diff --git a/src/Database/Driver.php b/src/Database/Driver.php index 0c1dfad4f..20f6ec341 100644 --- a/src/Database/Driver.php +++ b/src/Database/Driver.php @@ -38,6 +38,7 @@ function isSupported(string $feature): bool; /** * Initializes connection. + * @param array $options */ function initialize(Connection $connection, array $options): void; @@ -73,7 +74,7 @@ function getTables(): array; /** * Returns metadata for all columns in a table. - * @return list + * @return list}> */ function getColumns(string $table): array; diff --git a/src/Database/DriverException.php b/src/Database/DriverException.php index a0d7f7f94..41a29d47d 100644 --- a/src/Database/DriverException.php +++ b/src/Database/DriverException.php @@ -16,9 +16,14 @@ class DriverException extends \PDOException { public ?string $queryString = null; + + /** @var array|null */ public ?array $params = null; + /** + * Creates a DriverException from a PDOException, preserving error info and stack trace location. + */ public static function from(\PDOException $src): static { $e = new static($src->message, 0, $src); @@ -38,12 +43,18 @@ public static function from(\PDOException $src): static } + /** + * Returns the driver-specific error code, or null if not available. + */ public function getDriverCode(): int|string|null { return $this->errorInfo[1] ?? null; } + /** + * Returns the SQLSTATE error code, or null if not available. + */ public function getSqlState(): ?string { return $this->errorInfo[0] ?? null; @@ -56,6 +67,7 @@ public function getQueryString(): ?string } + /** @return array|null */ public function getParameters(): ?array { return $this->params; diff --git a/src/Database/Drivers/PgSqlDriver.php b/src/Database/Drivers/PgSqlDriver.php index deb3e927e..ec23f84e1 100644 --- a/src/Database/Drivers/PgSqlDriver.php +++ b/src/Database/Drivers/PgSqlDriver.php @@ -148,11 +148,11 @@ public function getColumns(string $table): array ELSE NULL END AS size, NOT (a.attnotnull OR t.typtype = 'd' AND t.typnotnull) AS nullable, - pg_catalog.pg_get_expr(ad.adbin, 'pg_catalog.pg_attrdef'::regclass)::varchar AS default, + pg_catalog.pg_get_expr(ad.adbin, ad.adrelid)::varchar AS default, coalesce(co.contype = 'p' AND (seq.relname IS NOT NULL OR strpos(pg_catalog.pg_get_expr(ad.adbin, ad.adrelid), 'nextval') = 1), FALSE) AS autoincrement, coalesce(co.contype = 'p', FALSE) AS primary, coalesce(col_description(c.oid, a.attnum)::varchar, '') AS comment, - coalesce(seq.relname, substring(pg_catalog.pg_get_expr(ad.adbin, 'pg_catalog.pg_attrdef'::regclass) from 'nextval[(]''"?([^''"]+)')) AS sequence + coalesce(seq.relname, substring(pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) from 'nextval[(]''"?([^''"]+)')) AS sequence FROM pg_catalog.pg_attribute AS a JOIN pg_catalog.pg_class AS c ON a.attrelid = c.oid diff --git a/src/Database/Explorer.php b/src/Database/Explorer.php index 729a7fb15..04b64a7bb 100644 --- a/src/Database/Explorer.php +++ b/src/Database/Explorer.php @@ -26,6 +26,8 @@ public function __construct( private readonly IStructure $structure, ?Conventions $conventions = null, private readonly ?Nette\Caching\Storage $cacheStorage = null, + /** @var ?\Closure(string): class-string */ + private readonly ?\Closure $rowMapping = null, ) { $this->conventions = $conventions ?: new StaticConventions; } @@ -49,6 +51,10 @@ public function rollBack(): void } + /** + * Executes callback inside a transaction. + * @param callable(static): mixed $callback + */ public function transaction(callable $callback): mixed { return $this->connection->transaction(fn() => $callback($this)); @@ -65,13 +71,17 @@ public function getInsertId(?string $sequence = null): string * Generates and executes SQL query. * @param literal-string $sql */ - public function query(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ResultSet + public function query(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ResultSet { return $this->connection->query($sql, ...$params); } - /** @deprecated use query() */ + /** + * @deprecated use query() + * @param literal-string $sql + * @param array $params + */ public function queryArgs(string $sql, array $params): ResultSet { return $this->connection->query($sql, ...$params); @@ -106,13 +116,25 @@ public function getConventions(): Conventions } + /** + * Creates an ActiveRow instance, using the configured row mapping class if available. + * @param array $data + * @param Table\Selection $selection + */ public function createActiveRow(array $data, Table\Selection $selection): Table\ActiveRow { - return new Table\ActiveRow($data, $selection); + $class = $this->rowMapping + ? ($this->rowMapping)($selection->getName()) + : Table\ActiveRow::class; + return new $class($data, $selection); } - /** @internal */ + /** + * @internal + * @param Table\Selection $refSelection + * @return Table\GroupedSelection + */ public function createGroupedSelection( Table\Selection $refSelection, string $table, @@ -127,70 +149,75 @@ public function createGroupedSelection( /** - * Shortcut for query()->fetch() + * Executes SQL query and returns the first row, or null if no rows were returned. * @param literal-string $sql */ - public function fetch(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ?Row + public function fetch(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ?Row { return $this->connection->query($sql, ...$params)->fetch(); } /** - * Shortcut for query()->fetchAssoc() + * Executes SQL query and returns the first row as an associative array, or null. * @param literal-string $sql + * @return ?array */ - public function fetchAssoc(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ?array + public function fetchAssoc(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ?array { return $this->connection->query($sql, ...$params)->fetchAssoc(); } /** - * Shortcut for query()->fetchField() + * Executes SQL query and returns the first field of the first row, or null. * @param literal-string $sql */ - public function fetchField(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): mixed + public function fetchField(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): mixed { return $this->connection->query($sql, ...$params)->fetchField(); } /** - * Shortcut for query()->fetchList() + * Executes SQL query and returns the first row as an indexed array, or null. * @param literal-string $sql + * @return ?list */ - public function fetchList(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ?array + public function fetchList(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ?array { return $this->connection->query($sql, ...$params)->fetchList(); } /** - * Shortcut for query()->fetchList() + * Executes SQL query and returns the first row as an indexed array, or null. * @param literal-string $sql + * @return ?list */ - public function fetchFields(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): ?array + public function fetchFields(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): ?array { return $this->connection->query($sql, ...$params)->fetchList(); } /** - * Shortcut for query()->fetchPairs() + * Executes SQL query and returns rows as key-value pairs. * @param literal-string $sql + * @return array */ - public function fetchPairs(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): array + public function fetchPairs(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): array { return $this->connection->query($sql, ...$params)->fetchPairs(); } /** - * Shortcut for query()->fetchAll() + * Executes SQL query and returns all rows as an array of Row objects. * @param literal-string $sql + * @return list */ - public function fetchAll(#[Language('SQL')] string $sql, #[Language('GenericSQL')] ...$params): array + public function fetchAll(#[Language('SQL')] string $sql, #[Language('GenericSQL')] mixed ...$params): array { return $this->connection->query($sql, ...$params)->fetchAll(); } @@ -199,7 +226,7 @@ public function fetchAll(#[Language('SQL')] string $sql, #[Language('GenericSQL' /** * Creates SQL literal value. */ - public static function literal(string $value, ...$params): SqlLiteral + public static function literal(string $value, mixed ...$params): SqlLiteral { return new SqlLiteral($value, $params); } diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index 3429545b1..a86b56e98 100644 --- a/src/Database/Helpers.php +++ b/src/Database/Helpers.php @@ -23,6 +23,7 @@ class Helpers /** maximum SQL length */ public static int $maxLength = 100; + /** @var array */ public static array $typePatterns = [ '^_' => IStructure::FIELD_TEXT, // PostgreSQL arrays '(TINY|SMALL|SHORT|MEDIUM|BIG|LONG)(INT)?|INT(EGER|\d+| IDENTITY| UNSIGNED)?|(SMALL|BIG|)SERIAL\d*|COUNTER|YEAR|BYTE|LONGLONG|UNSIGNED BIG INT' => IStructure::FIELD_INTEGER, @@ -84,7 +85,8 @@ public static function dumpResult(ResultSet $result): void /** - * Returns syntax highlighted SQL command. + * Returns syntax-highlighted SQL query as an HTML string. + * @param ?array $params */ public static function dumpSql(string $sql, ?array $params = null, ?Connection $connection = null): string { @@ -163,7 +165,8 @@ public static function dumpSql(string $sql, ?array $params = null, ?Connection $ /** - * Returns column types from result set. + * Detects column types from a PDO statement using column metadata. + * @return array column name => IStructure::FIELD_* type */ public static function detectTypes(\PDOStatement $statement): array { @@ -181,7 +184,7 @@ public static function detectTypes(\PDOStatement $statement): array /** - * Detects column type from native type. + * Maps a native column type string to an IStructure::FIELD_* constant. * @internal */ public static function detectType(string $type): string @@ -200,7 +203,12 @@ public static function detectType(string $type): string } - /** @internal */ + /** + * Converts raw column values to PHP types based on column type metadata. + * @internal + * @param array $row + * @return array + */ public static function normalizeRow( array $row, ResultSet $resultSet, @@ -245,7 +253,7 @@ public static function normalizeRow( /** * Imports SQL dump from file. - * @param ?array $onProgress Called after each query + * @param ?(callable(int, ?float): void) $onProgress Called after each query * @return int Number of executed commands * @throws Nette\FileNotFoundException */ @@ -322,7 +330,10 @@ public static function initializeTracy( /** - * Converts rows to key-value pairs. + * Transforms rows into an associative array using the specified key and value columns. + * @param array> $rows + * @param string|int|(\Closure(Row|Table\ActiveRow|array): array{0: mixed, 1?: mixed})|null $key + * @return array */ public static function toPairs(array $rows, string|int|\Closure|null $key, string|int|null $value): array { @@ -366,7 +377,7 @@ public static function toPairs(array $rows, string|int|\Closure|null $key, strin /** - * Returns duplicate columns from result set. + * Returns a human-readable string listing duplicate column names in the result set. */ public static function findDuplicates(\PDOStatement $statement): string { @@ -388,7 +399,10 @@ public static function findDuplicates(\PDOStatement $statement): string } - /** @return array{type: ?string, length: ?null, scale: ?null, parameters: ?string} */ + /** + * Parses a SQL column type string into its components. + * @return array{type: ?string, length: ?int, scale: ?int, parameters: ?string} + */ public static function parseColumnType(string $type): array { preg_match('/^([^(]+)(?:\((?:(\d+)(?:,(\d+))?|([^)]+))\))?/', $type, $m, PREG_UNMATCHED_AS_NULL); diff --git a/src/Database/IStructure.php b/src/Database/IStructure.php index 2a8e781e5..48c5f7982 100644 --- a/src/Database/IStructure.php +++ b/src/Database/IStructure.php @@ -27,18 +27,20 @@ interface IStructure FIELD_TIME_INTERVAL = 'timeint'; /** - * Returns tables list. + * Returns all tables in the database. + * @return list */ function getTables(): array; /** - * Returns table columns list. + * Returns all columns in a table. + * @return list}> */ function getColumns(string $table): array; /** * Returns table primary key. - * @return string|string[]|null + * @return string|list|null */ function getPrimaryKey(string $table): string|array|null; @@ -53,14 +55,14 @@ function getPrimaryAutoincrementKey(string $table): ?string; function getPrimaryKeySequence(string $table): ?string; /** - * Returns hasMany reference. - * If a targetTable is not provided, returns references for all tables. + * Returns tables referencing the given table via foreign key, or null if unknown. + * @return array>|null referencing table name => list of referencing columns */ function getHasManyReference(string $table): ?array; /** - * Returns belongsTo reference. - * If a column is not provided, returns references for all columns. + * Returns foreign key columns in the given table mapped to their referenced tables, or null if unknown. + * @return ?array local column name => referenced table name */ function getBelongsToReference(string $table): ?array; @@ -70,7 +72,7 @@ function getBelongsToReference(string $table): ?array; function rebuild(): void; /** - * Returns true if database cached structure has been rebuilt. + * Checks whether the structure has been rebuilt from the database during this request. */ function isRebuilt(): bool; } diff --git a/src/Database/Reflection.php b/src/Database/Reflection.php index 7b1d8afd2..036f0e9be 100644 --- a/src/Database/Reflection.php +++ b/src/Database/Reflection.php @@ -27,7 +27,7 @@ public function __construct( } - /** @return Table[] */ + /** @return list */ public function getTables(): array { return array_values($this->tables); diff --git a/src/Database/Reflection/Column.php b/src/Database/Reflection/Column.php index 96c09c0c3..16a6dba13 100644 --- a/src/Database/Reflection/Column.php +++ b/src/Database/Reflection/Column.php @@ -24,6 +24,7 @@ public function __construct( public readonly bool $autoIncrement = false, public readonly bool $primary = false, public readonly ?string $comment = null, + /** @var array */ public readonly array $vendor = [], ) { } diff --git a/src/Database/Reflection/ForeignKey.php b/src/Database/Reflection/ForeignKey.php index 6a2f00501..245677d9e 100644 --- a/src/Database/Reflection/ForeignKey.php +++ b/src/Database/Reflection/ForeignKey.php @@ -16,9 +16,9 @@ final class ForeignKey /** @internal */ public function __construct( public readonly Table $foreignTable, - /** @var Column[] */ + /** @var list */ public readonly array $localColumns, - /** @var Column[] */ + /** @var list */ public readonly array $foreignColumns, public readonly ?string $name = null, ) { diff --git a/src/Database/Reflection/Index.php b/src/Database/Reflection/Index.php index d490f14d3..cc089d846 100644 --- a/src/Database/Reflection/Index.php +++ b/src/Database/Reflection/Index.php @@ -15,7 +15,7 @@ final class Index { /** @internal */ public function __construct( - /** @var Column[] */ + /** @var list */ public readonly array $columns, public readonly bool $unique = false, public readonly bool $primary = false, diff --git a/src/Database/Reflection/Table.php b/src/Database/Reflection/Table.php index b863d58a0..165347702 100644 --- a/src/Database/Reflection/Table.php +++ b/src/Database/Reflection/Table.php @@ -12,7 +12,7 @@ /** - * Database table structure. + * Database table metadata including columns, indexes, and foreign keys. */ final class Table { diff --git a/src/Database/ResultSet.php b/src/Database/ResultSet.php index d6aac809a..d2a07878d 100644 --- a/src/Database/ResultSet.php +++ b/src/Database/ResultSet.php @@ -15,30 +15,31 @@ /** * Represents a database result set. + * @implements \Iterator */ class ResultSet implements \Iterator, IRowContainer { private ?\PDOStatement $pdoStatement = null; - - /** @var callable(array, ResultSet): array */ - private readonly mixed $normalizer; private Row|false|null $lastRow = null; private int $lastRowKey = -1; - /** @var Row[] */ + /** @var list */ private array $rows; private float $time; + + /** @var array column name => type */ private array $types; public function __construct( private readonly Connection $connection, private readonly string $queryString, + /** @var mixed[] */ private readonly array $params, - ?callable $normalizer = null, + /** @var ?\Closure(array, self): array */ + private readonly ?\Closure $normalizer = null, ) { $time = microtime(true); - $this->normalizer = $normalizer; $types = ['boolean' => PDO::PARAM_BOOL, 'integer' => PDO::PARAM_INT, 'resource' => PDO::PARAM_LOB, 'NULL' => PDO::PARAM_NULL]; try { @@ -85,6 +86,7 @@ public function getQueryString(): string } + /** @return mixed[] */ public function getParameters(): array { return $this->params; @@ -103,6 +105,7 @@ public function getRowCount(): ?int } + /** @return array */ public function getColumnTypes(): array { $this->types ??= $this->connection->getDriver()->getColumnTypes($this->pdoStatement); @@ -116,7 +119,11 @@ public function getTime(): float } - /** @internal */ + /** + * @internal + * @param array $row + * @return array + */ public function normalizeRow(array $row): array { return $this->normalizer @@ -180,7 +187,9 @@ public function valid(): bool /** - * Returns the next row as an associative array or null if there are no more rows. + * Returns the next row as an associative array, or null if there are no more rows. + * When $path is given, fetches all rows and restructures them using Arrays::associate(). + * @return ?array */ public function fetchAssoc(?string $path = null): ?array { @@ -229,6 +238,7 @@ public function fetchField(): mixed /** * Returns the next row as indexed array or null if there are no more rows. + * @return ?list */ public function fetchList(): ?array { @@ -239,6 +249,7 @@ public function fetchList(): ?array /** * Alias for fetchList(). + * @return ?list */ public function fetchFields(): ?array { @@ -250,6 +261,8 @@ public function fetchFields(): ?array * Returns all rows as associative array, where first argument specifies key column and second value column. * For duplicate keys, the last value is used. When using null as key, array is indexed from zero. * Alternatively accepts callback returning value or key-value pairs. + * @param string|int|(\Closure(Row|Table\ActiveRow|array): array{0: mixed, 1?: mixed})|null $keyOrCallback + * @return array */ public function fetchPairs(string|int|\Closure|null $keyOrCallback = null, string|int|null $value = null): array { @@ -259,7 +272,7 @@ public function fetchPairs(string|int|\Closure|null $keyOrCallback = null, strin /** * Returns all remaining rows as array of Row objects. - * @return Row[] + * @return list */ public function fetchAll(): array { diff --git a/src/Database/Row.php b/src/Database/Row.php index 8e34b920f..3143a19fd 100644 --- a/src/Database/Row.php +++ b/src/Database/Row.php @@ -13,6 +13,7 @@ /** * Represents a single database table row. + * @extends Nette\Utils\ArrayHash */ class Row extends Nette\Utils\ArrayHash implements IRow { @@ -30,7 +31,7 @@ public function __isset(string $key): bool /** - * Returns a item. + * Returns an item by key or numeric index. * @param string|int $key key or index */ public function offsetGet($key): mixed @@ -49,7 +50,7 @@ public function offsetGet($key): mixed /** - * Checks if $key exists. + * Checks if a key or numeric index exists. * @param string|int $key key or index */ public function offsetExists($key): bool diff --git a/src/Database/SqlLiteral.php b/src/Database/SqlLiteral.php index e110f53cd..07070a799 100644 --- a/src/Database/SqlLiteral.php +++ b/src/Database/SqlLiteral.php @@ -15,6 +15,7 @@ class SqlLiteral { public function __construct( private readonly string $value, + /** @var mixed[] */ private readonly array $parameters = [], ) { } @@ -26,6 +27,7 @@ public function getSql(): string } + /** @return mixed[] */ public function getParameters(): array { return $this->parameters; diff --git a/src/Database/SqlPreprocessor.php b/src/Database/SqlPreprocessor.php index 9495dc143..aae1c435f 100644 --- a/src/Database/SqlPreprocessor.php +++ b/src/Database/SqlPreprocessor.php @@ -48,7 +48,11 @@ class SqlPreprocessor private readonly Connection $connection; private readonly Driver $driver; + + /** @var list */ private array $params; + + /** @var list */ private array $remaining; private int $counter; private bool $useParams; @@ -66,7 +70,9 @@ public function __construct(Connection $connection) /** * Processes SQL query with parameter substitution. - * @return array{string, array} + * @param mixed[] $params + * @param bool $useParams when true, scalar values are kept as bound parameters instead of being inlined + * @return array{string, list} */ public function process(array $params, bool $useParams = false): array { @@ -113,7 +119,9 @@ public function process(array $params, bool $useParams = false): array /** - * Handles SQL placeholders and skips string literals and comments. + * Processes a regex match from the SQL scan: skips string literals and comments, + * detects SQL command keywords to set array mode, and replaces ? placeholders. + * @param string[] $match */ private function parsePart(array $match): string { @@ -141,8 +149,8 @@ private function parsePart(array $match): string /** - * Formats a value for use in SQL query where ? placeholder is used. - * For arrays, the formatting is determined by $mode or last SQL keyword before the placeholder + * Formats a value for use in SQL at a ? placeholder. + * For arrays, the mode is taken from $mode or detected from the last SQL keyword before the placeholder. */ private function formatParameter(mixed $value, ?string $mode = null): string { @@ -200,6 +208,7 @@ private function formatValue(mixed $value): string /** * Output: value, value, ... | (tuple), (tuple), ... + * @param mixed[] $values */ private function formatList(array $values): string { @@ -218,6 +227,7 @@ private function formatList(array $values): string /** * Output format: (key, key, ...) VALUES (value, value, ...) + * @param array $items */ private function formatInsert(array $items): string { @@ -233,6 +243,7 @@ private function formatInsert(array $items): string /** * Output format: (key, key, ...) VALUES (value, value, ...), (value, value, ...), ... + * @param list|Row> $groups */ private function formatMultiInsert(array $groups): string { @@ -261,6 +272,7 @@ private function formatMultiInsert(array $groups): string /** * Output format: key=value, key=value, ... + * @param mixed[] $items */ private function formatSet(array $items): string { @@ -282,6 +294,7 @@ private function formatSet(array $items): string /** * Output format: (key [operator] value) AND/OR ... + * @param mixed[] $items */ private function formatWhere(array $items, string $mode): string { @@ -322,6 +335,7 @@ private function formatWhere(array $items, string $mode): string /** * Output format: key, key DESC, ... + * @param array $items column => direction (positive = ASC, negative = DESC) */ private function formatOrderBy(array $items): string { diff --git a/src/Database/Structure.php b/src/Database/Structure.php index 891433f09..f3fbba88c 100644 --- a/src/Database/Structure.php +++ b/src/Database/Structure.php @@ -18,7 +18,7 @@ class Structure implements IStructure { protected readonly Nette\Caching\Cache $cache; - /** @var array{tables: array, columns: array, primary: array, aliases: array, hasMany: array, belongsTo: array} */ + /** @var array{tables: list, columns: array>>, primary: array|null>, aliases: array, hasMany: array>>, belongsTo: array>} */ protected array $structure; protected bool $isRebuilt = false; @@ -31,6 +31,7 @@ public function __construct( } + /** @return list */ public function getTables(): array { $this->needStructure(); @@ -48,7 +49,7 @@ public function getColumns(string $table): array /** - * @return string|string[]|null + * @return string|list|null */ public function getPrimaryKey(string $table): string|array|null { @@ -58,6 +59,9 @@ public function getPrimaryKey(string $table): string|array|null } + /** + * Returns the name of the autoincrement primary key column, or null if none exists. + */ public function getPrimaryAutoincrementKey(string $table): ?string { $primaryKey = $this->getPrimaryKey($table); @@ -88,6 +92,9 @@ public function getPrimaryAutoincrementKey(string $table): ?string } + /** + * Returns the sequence name for the primary key column, or null if not applicable. + */ public function getPrimaryKeySequence(string $table): ?string { $this->needStructure(); @@ -113,6 +120,7 @@ public function getPrimaryKeySequence(string $table): ?string } + /** @return array> referencing table name => list of referencing columns */ public function getHasManyReference(string $table): array { $this->needStructure(); @@ -121,6 +129,7 @@ public function getHasManyReference(string $table): array } + /** @return array local column name => referenced table name */ public function getBelongsToReference(string $table): array { $this->needStructure(); @@ -139,12 +148,18 @@ public function rebuild(): void } + /** + * Checks whether the structure has been rebuilt from the database during this request. + */ public function isRebuilt(): bool { return $this->isRebuilt; } + /** + * Ensures the structure is loaded, from cache or from the database. + */ protected function needStructure(): void { if (isset($this->structure)) { @@ -157,6 +172,7 @@ protected function needStructure(): void /** * Loads complete structure from database. + * @return array{tables: list, columns: array>>, primary: array|null>, aliases: array, hasMany: array>>, belongsTo: array>} */ protected function loadStructure(): array { @@ -193,6 +209,10 @@ protected function loadStructure(): array } + /** + * @param list $columns + * @return string|list|null + */ protected function analyzePrimaryKey(array $columns): string|array|null { $primary = []; @@ -212,6 +232,7 @@ protected function analyzePrimaryKey(array $columns): string|array|null } + /** @param array $structure */ protected function analyzeForeignKeys(array &$structure, string $table): void { $lowerTable = strtolower($table); diff --git a/src/Database/Table/ActiveRow.php b/src/Database/Table/ActiveRow.php index 083a4995e..8241abddf 100644 --- a/src/Database/Table/ActiveRow.php +++ b/src/Database/Table/ActiveRow.php @@ -14,6 +14,7 @@ /** * Represents database row with support for relations. * ActiveRow is based on the great library NotORM http://www.notorm.com written by Jakub Vrana. + * @implements \IteratorAggregate */ class ActiveRow implements \IteratorAggregate, IRow { @@ -21,20 +22,28 @@ class ActiveRow implements \IteratorAggregate, IRow public function __construct( + /** @var array */ private array $data, + /** @var Selection */ private Selection $table, ) { } - /** @internal */ + /** + * @internal + * @param Selection $table + */ public function setTable(Selection $table): void { $this->table = $table; } - /** @internal */ + /** + * @internal + * @return Selection + */ public function getTable(): Selection { return $this->table; @@ -47,6 +56,7 @@ public function __toString(): string } + /** @return array */ public function toArray(): array { $this->accessColumn(null); @@ -55,8 +65,7 @@ public function toArray(): array /** - * Returns primary key value. - * @return mixed possible int, string, array, object (Nette\Utils\DateTime) + * Returns primary key value, or an array of values for composite primary keys. */ public function getPrimary(bool $throw = true): mixed { @@ -101,8 +110,7 @@ public function getSignature(bool $throw = true): string /** - * Returns referenced row. - * @return self|null if the row does not exist + * Returns referenced row, or null if the row does not exist. */ public function ref(string $key, ?string $throughColumn = null): ?self { @@ -117,6 +125,7 @@ public function ref(string $key, ?string $throughColumn = null): ?self /** * Returns referencing rows collection. + * @return GroupedSelection */ public function related(string $key, ?string $throughColumn = null): GroupedSelection { @@ -130,7 +139,8 @@ public function related(string $key, ?string $throughColumn = null): GroupedSele /** - * Updates row data. + * Updates row data and refreshes the instance from database. Returns true if the row was changed. + * @param iterable $data */ public function update(iterable $data): bool { @@ -166,8 +176,8 @@ public function update(iterable $data): bool /** - * Deletes row from database. - * @return int number of affected rows + * Deletes the row from database. + * @return int number of affected rows */ public function delete(): int { @@ -186,6 +196,7 @@ public function delete(): int /********************* interface IteratorAggregate ****************d*g**/ + /** @return \ArrayIterator */ public function getIterator(): \Iterator { $this->accessColumn(null); @@ -227,8 +238,9 @@ public function __set(string $column, mixed $value): void /** + * Returns column value, or a referenced row if the key matches a relationship. * @return ActiveRow|mixed - * @throws Nette\MemberAccessException + * @throws Nette\MemberAccessException if the column does not exist and no relationship is found */ public function &__get(string $key): mixed { diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index f58b589cb..880479c37 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -16,6 +16,8 @@ /** * Represents filtered table grouped by referencing table. * GroupedSelection is based on the great library NotORM http://www.notorm.com written by Jakub Vrana. + * @template T of ActiveRow + * @extends Selection */ class GroupedSelection extends Selection { @@ -28,6 +30,7 @@ class GroupedSelection extends Selection /** * Creates filtered and grouped table representation. + * @param Selection $refTable */ public function __construct( Explorer $explorer, @@ -55,7 +58,10 @@ public function setActive(int|string $active): static } - public function select(string $columns, ...$params): static + /** + * Adds a SELECT clause. Automatically prepends the grouping column if no select exists yet. + */ + public function select(string $columns, mixed ...$params): static { if (!$this->sqlBuilder->getSelect()) { $this->sqlBuilder->addSelect("$this->name.$this->column"); @@ -65,7 +71,10 @@ public function select(string $columns, ...$params): static } - public function order(string $columns, ...$params): static + /** + * Adds an ORDER BY clause. Automatically prepends the grouping column (matching direction) to improve index utilization. + */ + public function order(string $columns, mixed ...$params): static { if (!$this->sqlBuilder->getOrder()) { // improve index utilization @@ -76,6 +85,9 @@ public function order(string $columns, ...$params): static } + /** + * Invalidates cached data and forces reload on next access. + */ public function refreshData(): void { unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); @@ -195,7 +207,7 @@ protected function execute(): void } - protected function getRefTable(&$refPath): Selection + protected function getRefTable(mixed &$refPath): Selection { $refObj = $this->refTable; $refPath = $this->name . '.'; @@ -232,7 +244,7 @@ protected function emptyResultSet(bool $clearCache = true, bool $deleteReference /********************* manipulation ****************d*g**/ - public function insert(iterable $data): ActiveRow|array|int|bool + public function insert(iterable $data): ActiveRow|array|int { if ($data instanceof \Traversable && !$data instanceof Selection) { $data = iterator_to_array($data); diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index a09193c75..6d3791d2a 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -17,8 +17,8 @@ * Represents filtered table result. * Selection is based on the great library NotORM http://www.notorm.com written by Jakub Vrana. * @template T of ActiveRow - * @implements \Iterator - * @implements \ArrayAccess + * @implements \Iterator + * @implements \ArrayAccess */ class Selection implements \Iterator, IRowContainer, \ArrayAccess, \Countable { @@ -39,10 +39,10 @@ class Selection implements \Iterator, IRowContainer, \ArrayAccess, \Countable /** primary column sequence name, false for autodetection */ protected string|bool|null $primarySequence = false; - /** @var array|null data read from database in [primary key => ActiveRow] format */ + /** @var ?array data read from database in [primary key => ActiveRow] format */ protected ?array $rows = null; - /** @var array|null modifiable data in [primary key => ActiveRow] format */ + /** @var ?array modifiable data in [primary key => ActiveRow] format */ protected ?array $data = null; protected bool $dataRefreshed = false; @@ -54,15 +54,19 @@ class Selection implements \Iterator, IRowContainer, \ArrayAccess, \Countable protected ?string $generalCacheKey = null; protected ?string $specificCacheKey = null; - /** of [conditions => [key => ActiveRow]]; used by GroupedSelection */ + /** @var array> of [conditions => [key => ActiveRow]]; used by GroupedSelection */ protected array $aggregation = []; + + /** @var array|false|null column => selected */ protected array|false|null $accessedColumns = null; + + /** @var array|false|null */ protected array|false|null $previousAccessedColumns = null; - /** should instance observe accessed columns caching */ + /** @var ?static should instance observe accessed columns caching */ protected ?self $observeCache = null; - /** of primary key values */ + /** @var list of primary key values */ protected array $keys = []; @@ -130,7 +134,6 @@ public function getPrimarySequence(): ?string } - /** @return static */ public function setPrimarySequence(string $sequence): static { $this->primarySequence = $sequence; @@ -147,8 +150,9 @@ public function getSql(): string /** * Loads cache of previous accessed columns and returns it. * @internal + * @return list */ - public function getPreviousAccessedColumns(): array|bool + public function getPreviousAccessedColumns(): array { if ($this->cache && $this->previousAccessedColumns === null) { $this->accessedColumns = $this->previousAccessedColumns = $this->cache->load($this->getGeneralCacheKey()); @@ -173,7 +177,7 @@ public function getSqlBuilder(): SqlBuilder /** * Returns row specified by primary key. - * @return T|null + * @return ?T */ public function get(mixed $key): ?ActiveRow { @@ -184,7 +188,7 @@ public function get(mixed $key): ?ActiveRow /** * Returns the next row or null if there are no more rows. - * @return T|null + * @return ?T */ public function fetch(): ?ActiveRow { @@ -218,6 +222,7 @@ public function fetchField(?string $column = null): mixed * Returns all rows as associative array, where first argument specifies key column and second value column. * For duplicate keys, the last value is used. When using null as key, array is indexed from zero. * Alternatively accepts callback returning value or key-value pairs. + * @param string|int|\Closure(T): array{0: mixed, 1?: mixed}|null $keyOrCallback * @return array */ public function fetchPairs(string|int|\Closure|null $keyOrCallback = null, string|int|null $value = null): array @@ -227,7 +232,7 @@ public function fetchPairs(string|int|\Closure|null $keyOrCallback = null, strin /** - * Returns all rows. + * Returns all rows as an array indexed by primary key. * @return T[] */ public function fetchAll(): array @@ -239,6 +244,7 @@ public function fetchAll(): array /** * Returns all rows as associative tree. * @deprecated + * @return array */ public function fetchAssoc(string $path): array { @@ -253,9 +259,8 @@ public function fetchAssoc(string $path): array /** * Adds select clause, more calls append to the end. * @param string $columns for example "column, MD5(column) AS column_md5" - * @return static */ - public function select(string $columns, ...$params): static + public function select(string $columns, mixed ...$params): static { $this->emptyResultSet(); $this->sqlBuilder->addSelect($columns, ...$params); @@ -265,7 +270,6 @@ public function select(string $columns, ...$params): static /** * Adds condition for primary key. - * @return static */ public function wherePrimary(mixed $key): static { @@ -289,10 +293,9 @@ public function wherePrimary(mixed $key): static /** * Adds where condition, more calls append with AND. - * @param string|array $condition possibly containing ? - * @return static + * @param string|array $condition possibly containing ? */ - public function where(string|array $condition, ...$params): static + public function where(string|array $condition, mixed ...$params): static { $this->condition($condition, $params); return $this; @@ -303,9 +306,8 @@ public function where(string|array $condition, ...$params): static * Adds ON condition when joining specified table, more calls appends with AND. * @param string $tableChain table chain or table alias for which you need additional left join condition * @param string $condition possibly containing ? - * @return static */ - public function joinWhere(string $tableChain, string $condition, ...$params): static + public function joinWhere(string $tableChain, string $condition, mixed ...$params): static { $this->condition($condition, $params, $tableChain); return $this; @@ -313,8 +315,9 @@ public function joinWhere(string $tableChain, string $condition, ...$params): st /** - * Adds condition, more calls appends with AND. + * Adds a WHERE or JOIN condition. When $tableChain is given, the condition is added to the JOIN ON clause. * @param string|string[] $condition possibly containing ? + * @param mixed[] $params */ protected function condition(string|array $condition, array $params, ?string $tableChain = null): void { @@ -338,9 +341,8 @@ protected function condition(string|array $condition, array $params, ?string $ta /** * Adds where condition using the OR operator between parameters. * More calls appends with AND. - * @param array $parameters ['column1' => 1, 'column2 > ?' => 2, 'full condition'] + * @param array $parameters ['column1' => 1, 'column2 > ?' => 2, 'full condition'] * @throws Nette\InvalidArgumentException - * @return static */ public function whereOr(array $parameters): static { @@ -375,9 +377,8 @@ public function whereOr(array $parameters): static /** * Adds ORDER BY clause, more calls appends to the end. * @param string $columns for example 'column1, column2 DESC' - * @return static */ - public function order(string $columns, ...$params): static + public function order(string $columns, mixed ...$params): static { $this->emptyResultSet(); $this->sqlBuilder->addOrder($columns, ...$params); @@ -387,7 +388,6 @@ public function order(string $columns, ...$params): static /** * Sets LIMIT clause, more calls rewrite old values. - * @return static */ public function limit(?int $limit, ?int $offset = null): static { @@ -398,10 +398,9 @@ public function limit(?int $limit, ?int $offset = null): static /** - * Sets OFFSET using page number, more calls rewrite old values. - * @return static + * Sets LIMIT and OFFSET for the given page number. Optionally calculates total number of pages. */ - public function page(int $page, int $itemsPerPage, &$numOfPages = null): static + public function page(int $page, int $itemsPerPage, ?int &$numOfPages = null): static { if (func_num_args() > 2) { $numOfPages = (int) ceil($this->count('*') / $itemsPerPage); @@ -417,9 +416,8 @@ public function page(int $page, int $itemsPerPage, &$numOfPages = null): static /** * Sets GROUP BY clause, more calls rewrite old value. - * @return static */ - public function group(string $columns, ...$params): static + public function group(string $columns, mixed ...$params): static { $this->emptyResultSet(); $this->sqlBuilder->setGroup($columns, ...$params); @@ -429,9 +427,8 @@ public function group(string $columns, ...$params): static /** * Sets HAVING clause, more calls rewrite old value. - * @return static */ - public function having(string $having, ...$params): static + public function having(string $having, mixed ...$params): static { $this->emptyResultSet(); $this->sqlBuilder->setHaving($having, ...$params); @@ -441,7 +438,6 @@ public function having(string $having, ...$params): static /** * Aliases table. Example ':book:book_tag.tag', 'tg' - * @return static */ public function alias(string $tableChain, string $alias): static { @@ -476,7 +472,7 @@ public function aggregation(string $function, ?string $groupFunction = null): mi /** - * Counts number of rows. If column is not provided returns count of result rows, otherwise runs new sql counting query. + * Returns count of fetched rows, or runs COUNT($column) query when column is specified. */ public function count(?string $column = null): int { @@ -563,21 +559,30 @@ protected function execute(): void } - /** @deprecated */ + /** + * @deprecated + * @param array $row + */ protected function createRow(array $row): ActiveRow { return $this->explorer->createActiveRow($row, $this); } - /** @deprecated */ + /** + * @deprecated + * @return static + */ public function createSelectionInstance(?string $table = null): self { return $this->explorer->table($table ?: $this->name); } - /** @deprecated */ + /** + * @deprecated + * @return GroupedSelection + */ protected function createGroupedSelectionInstance(string $table, string $column): GroupedSelection { return $this->explorer->createGroupedSelection($this, $table, $column); @@ -638,9 +643,11 @@ protected function saveCacheState(): void /** - * Returns Selection parent for caching. + * Returns the root Selection used as the shared cache anchor for referenced rows. + * @param-out string $refPath + * @return static */ - protected function getRefTable(&$refPath): self + protected function getRefTable(mixed &$refPath): self { $refPath = ''; return $this; @@ -648,7 +655,7 @@ protected function getRefTable(&$refPath): self /** - * Loads refCache references + * Initializes the reference cache for the current selection. Overridden by GroupedSelection. */ protected function loadRefCache(): void { @@ -656,8 +663,8 @@ protected function loadRefCache(): void /** - * Returns general cache key independent on query parameters or sql limit - * Used e.g. for previously accessed columns caching + * Returns general cache key independent of query parameters or SQL limit. + * Used e.g. for previously accessed columns caching. */ protected function getGeneralCacheKey(): string { @@ -679,8 +686,8 @@ protected function getGeneralCacheKey(): string /** - * Returns object specific cache key dependent on query parameters - * Used e.g. for reference memory caching + * Returns object-specific cache key dependent on query parameters. + * Used e.g. for reference memory caching. */ protected function getSpecificCacheKey(): string { @@ -694,7 +701,7 @@ protected function getSpecificCacheKey(): string /** * @internal - * @param string|null column name or null to reload all columns + * @param ?string $key column name or null to reload all columns * @return bool if selection requeried for more columns. */ public function accessColumn(?string $key, bool $selectColumn = true): bool @@ -772,7 +779,7 @@ public function removeAccessColumn(string $key): void /** - * Returns if selection requeried for more columns. + * Checks whether the selection re-queried for additional columns. */ public function getDataRefreshed(): bool { @@ -784,11 +791,12 @@ public function getDataRefreshed(): bool /** - * Inserts row in a table. Returns ActiveRow or number of affected rows for Selection or table without primary key. - * @param iterable|Selection $data - * @return T|array|int|bool + * Inserts one or more rows into the table. + * Returns the inserted ActiveRow for single-row inserts, or the number of affected rows otherwise. + * @param iterable|self $data + * @return ($data is array ? T|array : int) */ - public function insert(iterable $data): ActiveRow|array|int|bool + public function insert(iterable $data): ActiveRow|array|int { //should be called before query for not to spoil PDO::lastInsertId $primarySequenceName = $this->getPrimarySequence(); @@ -865,9 +873,9 @@ public function insert(iterable $data): ActiveRow|array|int|bool /** - * Updates all rows in result set. - * Joins in UPDATE are supported only in MySQL - * @return int number of affected rows + * Updates all rows matching current conditions. JOINs in UPDATE are supported only by MySQL. + * @param iterable $data + * @return int number of affected rows */ public function update(iterable $data): int { @@ -887,8 +895,8 @@ public function update(iterable $data): int /** - * Deletes all rows in result set. - * @return int number of affected rows + * Deletes all rows matching current conditions. + * @return int number of affected rows */ public function delete(): int { @@ -900,8 +908,9 @@ public function delete(): int /** - * Returns referenced row. - * @return ActiveRow|false|null null if the row does not exist, false if the relationship does not exist + * Returns a referenced (parent) row for a belongs-to relationship. + * Returns null if the referenced row does not exist, false if the relationship is not defined. + * @return ActiveRow|false|null */ public function getReferencedTable(ActiveRow $row, ?string $table, ?string $column = null): ActiveRow|false|null { @@ -948,7 +957,8 @@ public function getReferencedTable(ActiveRow $row, ?string $table, ?string $colu /** - * Returns referencing rows. + * Returns a grouped selection of referencing (child) rows for a has-many relationship. + * @return GroupedSelection|null */ public function getReferencingTable( string $table, @@ -1024,7 +1034,7 @@ public function valid(): bool /** - * Mimic row. + * Sets a row by primary key. * @param string $key * @param ActiveRow $value */ diff --git a/src/Database/Table/SqlBuilder.php b/src/Database/Table/SqlBuilder.php index 454739119..aca3c99e9 100644 --- a/src/Database/Table/SqlBuilder.php +++ b/src/Database/Table/SqlBuilder.php @@ -17,18 +17,27 @@ /** - * Builds SQL query. - * SqlBuilder is based on great library NotORM http://www.notorm.com written by Jakub Vrana. + * Builds SQL queries for the Explorer layer. */ class SqlBuilder { protected readonly string $tableName; protected readonly Conventions $conventions; protected readonly string $delimitedTable; + + /** @var string[] */ protected array $select = []; + + /** @var string[] */ protected array $where = []; + + /** @var array table chain => conditions */ protected array $joinCondition = []; + + /** @var array condition hash => condition */ protected array $conditions = []; + + /** @var array> */ protected array $parameters = [ 'select' => [], 'joinCondition' => [], @@ -37,17 +46,27 @@ class SqlBuilder 'having' => [], 'order' => [], ]; + + /** @var string[] */ protected array $order = []; protected ?int $limit = null; protected ?int $offset = null; protected string $group = ''; protected string $having = ''; + + /** @var array table name => chain */ protected array $reservedTableNames = []; + + /** @var array alias => chain */ protected array $aliases = []; protected string $currentAlias = ''; private readonly Driver $driver; private readonly IStructure $structure; + + /** @var array table fullName => exists */ private array $cacheTableList = []; + + /** @var array tables being expanded (cycle detection) */ private array $expandingJoins = []; @@ -104,6 +123,7 @@ public function buildDeleteQuery(): string /** * Returns select query hash for caching. + * @param string[]|null $columns */ public function getSelectQueryHash(?array $columns = null): string { @@ -137,7 +157,7 @@ public function getSelectQueryHash(?array $columns = null): string /** * Returns SQL query. - * @param string[] $columns + * @param list|null $columns */ public function buildSelectQuery(?array $columns = null): string { @@ -188,6 +208,7 @@ public function buildSelectQuery(?array $columns = null): string } + /** @return list */ public function getParameters(): array { if (!isset($this->parameters['joinConditionSorted'])) { @@ -205,6 +226,9 @@ public function getParameters(): array } + /** + * Copies WHERE conditions and aliases from another builder. + */ public function importConditions(self $builder): void { $this->where = $builder->where; @@ -217,6 +241,9 @@ public function importConditions(self $builder): void } + /** + * Copies GROUP BY and HAVING clauses from another builder. Returns true if HAVING was present. + */ public function importGroupConditions(self $builder): bool { if ($builder->having) { @@ -237,13 +264,14 @@ public function importGroupConditions(self $builder): bool /** * Adds SELECT clause, more calls append to the end. */ - public function addSelect(string $columns, ...$params): void + public function addSelect(string $columns, mixed ...$params): void { $this->select[] = $columns; $this->parameters['select'] = array_merge($this->parameters['select'], $params); } + /** @return string[] */ public function getSelect(): array { return $this->select; @@ -259,8 +287,9 @@ public function resetSelect(): void /** * Adds WHERE condition, more calls append with AND. + * @param array|string $condition */ - public function addWhere(string|array $condition, ...$params): bool + public function addWhere(string|array $condition, mixed ...$params): bool { return $this->addCondition($condition, $params, $this->where, $this->parameters['where']); } @@ -268,8 +297,9 @@ public function addWhere(string|array $condition, ...$params): bool /** * Adds JOIN condition. + * @param array|string $condition */ - public function addJoinCondition(string $tableChain, string|array $condition, ...$params): bool + public function addJoinCondition(string $tableChain, string|array $condition, mixed ...$params): bool { $this->parameters['joinConditionSorted'] = null; if (!isset($this->joinCondition[$tableChain])) { @@ -280,6 +310,14 @@ public function addJoinCondition(string $tableChain, string|array $condition, .. } + /** + * Normalizes and appends a condition with its parameters. Deduplicates identical conditions. + * Returns true if the condition was added, false if it was a duplicate. + * @param array|string $condition + * @param array $params + * @param array $conditions + * @param array $conditionsParameters + */ protected function addCondition( string|array $condition, array $params, @@ -417,6 +455,7 @@ protected function addCondition( } + /** @return list */ public function getConditions(): array { return array_values($this->conditions); @@ -437,6 +476,9 @@ public function addAlias(string $chain, string $alias): void } + /** + * Ensures a table alias is not used for two different chains, throwing on conflict. + */ protected function checkUniqueTableName(string $tableName, string $chain): void { if (isset($this->aliases[$tableName]) && ($chain === '.' . $tableName)) { @@ -458,13 +500,17 @@ protected function checkUniqueTableName(string $tableName, string $chain): void /** * Adds ORDER BY clause, more calls append to the end. */ - public function addOrder(string|array $columns, ...$params): void + public function addOrder(string $columns, mixed ...$params): void { $this->order[] = $columns; $this->parameters['order'] = array_merge($this->parameters['order'], $params); } + /** + * @param string[] $columns + * @param mixed[] $parameters + */ public function setOrder(array $columns, array $parameters): void { $this->order = $columns; @@ -472,6 +518,7 @@ public function setOrder(array $columns, array $parameters): void } + /** @return string[] */ public function getOrder(): array { return $this->order; @@ -503,7 +550,7 @@ public function getOffset(): ?int /** * Sets GROUP BY and HAVING clause. */ - public function setGroup(string|array $columns, ...$params): void + public function setGroup(string $columns, mixed ...$params): void { $this->group = $columns; $this->parameters['group'] = $params; @@ -516,7 +563,7 @@ public function getGroup(): string } - public function setHaving(string $having, ...$params): void + public function setHaving(string $having, mixed ...$params): void { $this->having = $having; $this->parameters['having'] = $params; @@ -532,12 +579,18 @@ public function getHaving(): string /********************* SQL building ****************d*g**/ + /** @param string[] $columns */ protected function buildSelect(array $columns): string { return 'SELECT ' . implode(', ', $columns); } + /** + * @param array $joins + * @param array $joinConditions + * @return array + */ protected function parseJoinConditions(array &$joins, array $joinConditions): array { $tableJoins = $leftJoinDependency = $finalJoinConditions = []; @@ -574,6 +627,11 @@ protected function parseJoinConditions(array &$joins, array $joinConditions): ar } + /** + * @param array $leftJoinDependency + * @param array $tableJoins + * @param array $finalJoins + */ protected function getSortedJoins( string $table, array &$leftJoinDependency, @@ -627,6 +685,7 @@ protected function getSortedJoins( } + /** @param array $joins */ protected function parseJoins(array &$joins, string &$query): void { $query = preg_replace_callback($this->getColumnChainsRegxp(), function (array $match) use (&$joins): string { @@ -643,6 +702,10 @@ private function getColumnChainsRegxp(): string } + /** + * @param array $joins + * @param array $match + */ public function parseJoinsCb(array &$joins, array $match): string { $chain = $match['chain']; @@ -761,6 +824,10 @@ public function parseJoinsCb(array &$joins, array $match): string } + /** + * @param array $joins + * @param array $leftJoinConditions + */ protected function buildQueryJoins(array $joins, array $leftJoinConditions = []): string { $return = ''; @@ -775,6 +842,9 @@ protected function buildQueryJoins(array $joins, array $leftJoinConditions = []) } + /** + * @return array table chain => condition SQL + */ protected function buildJoinConditions(): array { $conditions = []; @@ -813,6 +883,9 @@ protected function buildQueryEnd(): string } + /** + * Delimits lowercase identifiers in a SQL fragment while leaving uppercase keywords untouched. + */ protected function tryDelimite(string $s): string { return preg_replace_callback( @@ -825,6 +898,13 @@ protected function tryDelimite(string $s): string } + /** + * Adds a multi-column IN condition using OR expansion or tuple syntax depending on driver support. + * @param string[] $columns + * @param list> $parameters + * @param array $conditions + * @param mixed[] $conditionsParameters + */ protected function addConditionComposition( array $columns, array $parameters, @@ -842,6 +922,7 @@ protected function addConditionComposition( } + /** @param mixed[] $parameters */ private function getConditionHash(string $condition, array $parameters): string { foreach ($parameters as $key => &$parameter) { @@ -860,6 +941,7 @@ private function getConditionHash(string $condition, array $parameters): string } + /** @return array */ private function getCachedTableList(): array { if (!$this->cacheTableList) { diff --git a/tests/Database.DI/DatabaseExtension.rowMapping.phpt b/tests/Database.DI/DatabaseExtension.rowMapping.phpt new file mode 100644 index 000000000..91b48db95 --- /dev/null +++ b/tests/Database.DI/DatabaseExtension.rowMapping.phpt @@ -0,0 +1,138 @@ +load(Tester\FileMock::create(' + database: + dsn: "sqlite::memory:" + mapping: App\Entity\*Row + debugger: no + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'neon')); + + $compiler = new DI\Compiler; + $compiler->addExtension('database', new DatabaseExtension(false)); + $code = $compiler->addConfig($config)->setClassName('Container1')->compile(); + eval($code); + + $container = new Container1; + $container->initialize(); + + $explorer = $container->getService('database.default.explorer'); + Assert::type(Nette\Database\Explorer::class, $explorer); + + // verify the mapping closure was set by checking generated code + Assert::contains('createRowMapping', $code); +}); + + +test('full mapping with convention and tables', function () { + $loader = new DI\Config\Loader; + $config = $loader->load(Tester\FileMock::create(' + database: + dsn: "sqlite::memory:" + mapping: + convention: App\Entity\*Row + tables: + special: App\Entity\SpecialRow + debugger: no + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'neon')); + + $compiler = new DI\Compiler; + $compiler->addExtension('database', new DatabaseExtension(false)); + $code = $compiler->addConfig($config)->setClassName('Container2')->compile(); + eval($code); + + $container = new Container2; + $container->initialize(); + + $explorer = $container->getService('database.default.explorer'); + Assert::type(Nette\Database\Explorer::class, $explorer); + Assert::contains('createRowMapping', $code); +}); + + +test('no mapping by default', function () { + $loader = new DI\Config\Loader; + $config = $loader->load(Tester\FileMock::create(' + database: + dsn: "sqlite::memory:" + debugger: no + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'neon')); + + $compiler = new DI\Compiler; + $compiler->addExtension('database', new DatabaseExtension(false)); + $code = $compiler->addConfig($config)->setClassName('Container3')->compile(); + eval($code); + + $container = new Container3; + $container->initialize(); + + $explorer = $container->getService('database.default.explorer'); + Assert::type(Nette\Database\Explorer::class, $explorer); + + // no mapping should be set + Assert::notContains('createRowMapping', $code); +}); + + +test('createRowMapping() with convention', function () { + $mapping = DatabaseExtension::createRowMapping('App\Entity\*Row', []); + + // unknown class -> fallback to ActiveRow + Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('nonexistent')); +}); + + +test('createRowMapping() with explicit tables', function () { + $mapping = DatabaseExtension::createRowMapping('', [ + 'my_table' => 'Nette\Database\Table\ActiveRow', + ]); + + Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('my_table')); + + // not in tables and no convention -> fallback + Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('other')); +}); + + +test('createRowMapping() tables override convention', function () { + $mapping = DatabaseExtension::createRowMapping('Some\*Row', [ + 'special' => 'Nette\Database\Table\ActiveRow', + ]); + + // explicit override wins + Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('special')); +}); + + +test('createRowMapping() snake_case to PascalCase', function () { + $mapping = DatabaseExtension::createRowMapping('*', []); + + // We can't test actual entity classes (they don't exist in test env), + // but we can verify the fallback for non-existent classes + Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('some_table')); + + // Verify convention produces correct class names by using a class that exists + $mapping = DatabaseExtension::createRowMapping('Nette\Database\Table\*', []); + Assert::same('Nette\Database\Table\ActiveRow', $mapping('active_row')); +}); diff --git a/tests/Database/Reflection.postgre.phpt b/tests/Database/Reflection.postgre.phpt index b3788e82a..cbdde4f6b 100644 --- a/tests/Database/Reflection.postgre.phpt +++ b/tests/Database/Reflection.postgre.phpt @@ -76,3 +76,32 @@ test('Tables in schema', function () use ($connection) { $connection->query('SET search_path TO one, two'); Assert::same(['one_slave_fk', 'one_two_fk'], names($driver->getForeignKeys('slave'))); }); + + +test('Table with GENERATED ALWAYS AS stored columns', function () use ($connection) { + $ver = $connection->query('SHOW server_version')->fetchField(); + if (version_compare($ver, '12') < 0) { + Tester\Environment::skip("GENERATED ALWAYS AS requires PostgreSQL 12+, running $ver."); + } + + Nette\Database\Helpers::loadFromFile($connection, Tester\FileMock::create(' + DROP TABLE IF EXISTS "generated_test"; + + CREATE TABLE "generated_test" ( + "id" serial PRIMARY KEY, + "first_name" varchar(50) NOT NULL, + "last_name" varchar(50) NOT NULL, + "full_name" text GENERATED ALWAYS AS ("first_name" || \' \' || "last_name") STORED + ); + ')); + + $driver = $connection->getDriver(); + $columns = $driver->getColumns('generated_test'); + $columnNames = array_column($columns, 'name'); + + Assert::same(['id', 'first_name', 'last_name', 'full_name'], $columnNames); + + $fullNameCol = $columns[3]; + Assert::same('full_name', $fullNameCol['name']); + Assert::same('TEXT', $fullNameCol['nativetype']); +}); diff --git a/tests/databases.github.ini b/tests/databases.github.ini index d00f44271..ba5deb2c8 100644 --- a/tests/databases.github.ini +++ b/tests/databases.github.ini @@ -24,6 +24,12 @@ username = postgres password = postgres options[newDateTime] = yes +[postgresql 15] +dsn = "pgsql:host=127.0.0.1;port=5434;dbname=nette_test" +username = postgres +password = postgres +options[newDateTime] = yes + [sqlsrv] dsn = "sqlsrv:Server=localhost,1433;Database=nette_test" username = SA diff --git a/tests/types/TypesTest.phpt b/tests/types/TypesTest.phpt new file mode 100644 index 000000000..1bd53e017 --- /dev/null +++ b/tests/types/TypesTest.phpt @@ -0,0 +1,7 @@ + $row) { + assertType('int', $key); + assertType(Row::class, $row); + } +} + + +function testActiveRowIterator(ActiveRow $activeRow): void +{ + foreach ($activeRow as $key => $value) { + assertType('string', $key); + assertType('mixed', $value); + } +} + + +/** + * @param Selection $selection + */ +function testSelectionIterator(Selection $selection): void +{ + foreach ($selection as $key => $row) { + assertType('string', $key); + assertType(ActiveRow::class, $row); + } +} + + +/** + * @param Selection $selection + */ +function testSelectionArrayAccess(Selection $selection): void +{ + $row = $selection['key']; + assertType(ActiveRow::class . '|null', $row); +} + + +/** + * @param Selection $selection + */ +function testSelectionFluentMethods(Selection $selection): void +{ + $result = $selection->where('id', 1); + assertType('Nette\Database\Table\Selection', $result); + + $result = $selection->limit(10); + assertType('Nette\Database\Table\Selection', $result); + + $result = $selection->page(1, 10); + assertType('Nette\Database\Table\Selection', $result); +} + + +function testParseColumnType(): void +{ + $result = Helpers::parseColumnType('varchar(255)'); + assertType('array{type: string|null, length: int|null, scale: int|null, parameters: string|null}', $result); +} + + +/** @param Selection $selection */ +function testSelectionFetch(Selection $selection): void +{ + $result = $selection->fetch(); + assertType(ActiveRow::class . '|null', $result); +} + + +/** @param Selection $selection */ +function testSelectionGet(Selection $selection): void +{ + $result = $selection->get(1); + assertType(ActiveRow::class . '|null', $result); +} + + +/** @param Selection $selection */ +function testSelectionFetchAll(Selection $selection): void +{ + $result = $selection->fetchAll(); + assertType('array', $result); +} + + +function testReflectionGetTables(Nette\Database\Reflection $reflection): void +{ + assertType('list', $reflection->getTables()); +} + + +function testResultSetFetchAll(ResultSet $resultSet): void +{ + assertType('list', $resultSet->fetchAll()); +} + + +function testResultSetFetchPairs(ResultSet $resultSet): void +{ + assertType('array', $resultSet->fetchPairs()); +}