diff --git a/.gitattributes b/.gitattributes index 433a2de9a..c361dca0e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,11 @@ .gitattributes export-ignore .github/ export-ignore .gitignore export-ignore +AGENTS.md export-ignore ncs.* export-ignore phpstan*.neon export-ignore src/**/*.latte export-ignore +docs/ export-ignore tests/ export-ignore *.php* diff=php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d17174eb8..2baec1798 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,12 +3,14 @@ name: Tests on: [push, pull_request] env: - php-extensions: mbstring, intl, pdo_sqlsrv-5.12.0 + # pdo_sqlsrv is deliberately unpinned: no single release covers the whole matrix + # (5.12 = PHP 8.1-8.3, 5.13 = PHP 8.3-8.5), so let setup-php pick a compatible one + php-extensions: mbstring, intl, pdo_sqlsrv php-tools: "composer:v2, pecl" jobs: tests: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: php: ['8.1', '8.2', '8.3', '8.4', '8.5'] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..bf7331dff --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# To My Agents! + +It is my fervent wish that this file guide every AI coding agent working with code in this repository. + +## Documentation + +Any distilled, agent-facing documentation for this package - how it works +internally and the rationale behind key design decisions - lives in `docs/`. +Consult it before non-trivial changes; it is the source of truth from which the +public manual is distilled. + +Two independent worlds - the low-level core and the Explorer (ActiveRow) layer - +each with sharp edges (lazy execution, accessed-column narrowing, N+1 batching, +context-detected preprocessor modes). Read `docs/internals/` before touching them. + +## Project Overview + +**Nette Database** is a database abstraction layer offering two components: + +1. **Core** - a PDO wrapper with an advanced SQL preprocessor and parameter + substitution. +2. **Explorer** - an ActiveRow layer (inspired by NotORM) with convention-based + relationships and automatic N+1 prevention. + +Supports MySQL, PostgreSQL, SQLite, MS SQL Server, and Oracle. + +- **PHP Version**: 8.1 - 8.5 +- **Package**: `nette/database` + +## Essential Commands + +```bash +# Run all tests +vendor/bin/tester tests -s -C + +# Run one test directory / file +vendor/bin/tester tests/Database/Explorer -s -C +vendor/bin/tester tests/Database/Explorer/Explorer.basic.phpt -s -C + +# Static analysis (PHPStan level 8 + nette/phpstan-rules) +composer phpstan +``` + +Most tests connect to real MySQL/PostgreSQL/MS SQL servers via +`@dataProvider databases.ini`. Bring the servers up with the repo's +`docker-compose.yml` (`docker compose up -d`, wait for `healthy`) before running +them; a `Connection refused` / `could not find driver` failure means the servers +aren't up yet, not a broken test. + +## Conventions + +- Every file starts with `declare(strict_types=1);`; everything typed; single + quotes unless the string contains an apostrophe; Nette Coding Standard. +- Use generic annotations for IDE/PHPStan support: `@return Selection`, + `@template T of ActiveRow`. Method phpDoc starts with a 3rd-person verb (Returns, + Formats, Checks); document a param/return only when it adds info beyond the type. +- Tests are Nette Tester `.phpt` files; use `@dataProvider databases.ini` to run + against every engine, `test()` / `testException()` with descriptive names, and + **no comment before `test()`**. Fixtures: `tests/Database/files/{driver}-nette_test1.sql`. + +## Working in this repo + +- **The Explorer is lazy and self-narrowing.** `accessColumn` is the single seam + every read passes through; a first query fetches `SELECT *`, later ones narrow to + the accessed columns (cached), and relations are batched to avoid N+1. The cache + key even depends on the call-site (`debug_backtrace`) - a real refactor trap. See + `docs/internals/explorer.md`. +- **The SQL preprocessor picks its array mode from surrounding SQL context** + (`?and`/`?set`/`?values`/`?order`/`?list`), so the same array expands differently + after `WHERE` vs `SET` vs `INSERT`. See `docs/internals/sql-preprocessor.md`. +- **Nested transactions use a depth counter, not savepoints** - only the outermost + `transaction()` issues a real BEGIN/COMMIT/ROLLBACK; there is no partial rollback, + and no retry mechanism (no `$attempts`, no `RetryableException`, no `onRetry`). + There is no `TypeConverter` class either (DB->PHP conversion is + `Helpers::normalizeRow`). Don't document designed-but-absent features as present. +- **Array expansion is a mass-assignment surface.** Passing raw user input as the + array to `insert`/`update`/`where` lets an attacker set arbitrary columns and + inject operators/SQL via keys - always whitelist columns first. Full guidance is + web-manual material. +- User-facing how-to (Explorer/Selection API, `?`-placeholder reference, NEON + config, transactions, Reflection API) is manual material and lives in the public + web docs, not here. diff --git a/docker-compose.yml b/docker-compose.yml index 04906d074..af57b92a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,33 @@ services: timeout: 5s retries: 5 + postgres16: + image: postgres:16 + ports: + - "5435:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: nette_test + healthcheck: + test: pg_isready + interval: 10s + timeout: 5s + retries: 5 + + mariadb: + image: mariadb:11.4 + ports: + - "3308:3306" + environment: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: nette_test + healthcheck: + test: healthcheck.sh --connect --innodb_initialized + interval: 10s + timeout: 5s + retries: 5 + mssql: image: mcr.microsoft.com/mssql/server:2022-latest ports: diff --git a/docs/internals/connection-drivers.md b/docs/internals/connection-drivers.md new file mode 100644 index 000000000..39d87e89c --- /dev/null +++ b/docs/internals/connection-drivers.md @@ -0,0 +1,55 @@ +# Connection & drivers + +## Execution path + +`Connection` connects **lazily** — the constructor opens the PDO only when the `lazy` +option is falsy; otherwise the first `getPdo()`/`getDriver()`/`preprocess()` triggers +`connect()` (which also instantiates the driver from `PDO::ATTR_DRIVER_NAME`, builds +the `SqlPreprocessor`, and fires `onConnect`). + +`query()` = `preprocess()` (which runs the preprocessor only when there are +parameters) → `new ResultSet(...)`. **The SQL actually executes in the `ResultSet` +constructor, not in `query()`** — so timing, binding, and exception conversion all +live there (see results-and-types.md). The `fetch*` shortcuts on `Connection` just +delegate to `query(...)->fetch*()`. + +## The `Driver` dialect abstraction + +Each engine implements `Driver`: `delimite` (identifier quoting — MySQL backticks, +PgSql double-quotes, both doubling), `formatDateTime`/`formatDateInterval`/`formatLike`, +`applyLimit`, schema reflection (`getTables`/`getColumns`/`getIndexes`/`getForeignKeys`/ +`getColumnTypes`), `convertException`, and `isSupported` over the `Support*` feature +constants. + +**`applyLimit` is the most dialect-divergent piece** — MySQL uses `LIMIT` (with the +`LIMIT 18446744073709551615 OFFSET` trick for offset-only), PgSql separate `LIMIT`/ +`OFFSET`, SQL Server `OFFSET … ROWS FETCH NEXT … ROWS ONLY`, MS SQL/ODBC inject a +`TOP n` (no offset), Oracle wraps in a `ROWNUM` subquery. Result-set type detection is +per-driver `getColumnTypes`: PgSql and MsSql map the whole result set via +`Helpers::detectTypes`, MySQL/SQLite/Sqlsrv go column-by-column via +`Helpers::detectType`, and Odbc/Oci detect nothing. MySQL adds dialect rules +(`NEWDECIMAL` precision 0 → integer, `TINY` len 1 + `convertBoolean` → bool, `TIME` → +interval). + +## Exception mapping + +``` +\PDOException → DriverException + ├── ConnectionException → ConnectionLostException + ├── ConstraintViolationException + │ ├── ForeignKey / NotNull / Unique / CheckConstraintViolation + ├── DeadlockException + └── LockTimeoutException +``` + +Note `Deadlock`/`LockTimeout` extend `DriverException` **directly**, not the +constraint hierarchy. There is **no** `RetryableException` marker and `transaction()` +performs no retries — retrying on deadlock/lock-timeout/connection-lost is the +caller's job. The mapping is **per driver** in +`convertException`: MySQL keys on the numeric error code, PgSql on the SQLSTATE; an +unrecognized error falls back to a bare `DriverException::from()`. `DriverException::from` +parses `errorInfo`, or the `SQLSTATE[..] [..] ..` pattern from the message when +`errorInfo` is absent. Conversion is invoked in the `ResultSet` constructor (which also +attaches the query string and params) and in `getInsertId`; `connect()`/`quote()` use +`ConnectionException::from`/`DriverException::from` directly because the driver may not +exist yet. diff --git a/docs/internals/explorer.md b/docs/internals/explorer.md new file mode 100644 index 000000000..8fb604789 --- /dev/null +++ b/docs/internals/explorer.md @@ -0,0 +1,259 @@ +# Explorer internals (Selection & ActiveRow) + +How the Explorer layer (`Selection`, `ActiveRow`, `GroupedSelection`, `SqlBuilder`) +really works inside — data flow, caching, the two optimizations, and above all the +**traps and invariants**. This is the reference for the current code. + +## Two layers + +- **Core** (`src/Database/`): `Connection` (PDO wrapper), `SqlPreprocessor`, + `ResultSet` (iterator + type normalization), `Driver` (per-engine), `Structure`/ + reflection. +- **Explorer** (`src/Database/Table/`): `Selection` (lazy fluent builder), + `ActiveRow` (a row), `GroupedSelection` (has-many), `SqlBuilder`. + +Explorer is NotORM-inspired, and its value is two optimizations: + +1. **N+1 prevention** — related rows are fetched in batches (`WHERE id IN (...)`), + a constant number of queries. +2. **SELECT narrowing** — after an initial `SELECT *` it learns which columns are + actually read and next time selects only those. **Works only with a cache.** + +## Selection: lazy execution + +`Selection` is a **lazy** fluent builder — `where`/`order`/`select`/… only push into +`SqlBuilder` and return `$this`. **Nothing executes until you touch data.** + +Key fields: `$rows` (`?array` of `[signature => ActiveRow]`; `null` = not executed), +`$data` (iterable form — for a plain Selection a COW copy of `$rows`, but for a +`GroupedSelection` **bound by reference** into the shared refCache — hence sharing), +`$cache` (without it narrowing is off), `$accessedColumns` (columns read *now*), +`$previousAccessedColumns` (learned last time, from cache — **per-instance**), the +two cache keys, `$refCache`/`$globalRefCache`, `$observeCache` (which instance owns +saving learned columns), and `$dataRefreshed` (a re-query happened; signal to +`ActiveRow` to pull fresh data). + +**`execute()`**: idempotent if `$rows !== null`; runs `query(getSql())` where +`getSql()` builds the SELECT column list from `previousAccessedColumns`; **on a +`DriverException` retries with `SELECT *`**, but only when the SELECT was actually +narrowed (non-empty `previousAccessedColumns`, no explicit `select()`) — a narrowed +SELECT can reference a column dropped by a schema change; builds an +`ActiveRow` per PDO row keyed by **signature** (PK values joined by `|`, or numeric +when PK-less); then marks the primary column(s) as accessed. + +`get($key)` = `clone $this` then `wherePrimary($key)->fetch()` — it **clones** so as +not to dirty the original. + +## ActiveRow: storage & access + +**All data lives in one private `$data` array** (`[column => value]`); no column is a +separate property, so every read falls into `__get` and flows through `$data` — the +only way to track which columns are actually read (SELECT narrowing). A subclass must +therefore **not** declare real typed properties (`public int $id`): an existing +property bypasses the magic; use `@property-read` annotations instead. (There is no +`EntityMapping` class and no read-side enum conversion — `BackedEnum` handling exists +only on the write side, in the preprocessor.) + +- `__get($key)`: `accessColumn($key)` → returns `$data[$key]`; if the column is + absent it tries a **relation** (`getReferencedTable`), else throws + `MemberAccessException` (with a did-you-mean hint). `__set`/`__unset` throw + (read-only). `toArray()` forces all columns via `accessColumn(null)`. +- `getPrimary()`/`getSignature()` read **only `$data[$primary]`, with no query** — so + row-cache writes can call them without triggering a fetch. + +## `accessColumn`: the single shared seam + +**Every** row-data access flows through `ActiveRow::accessColumn(?string $key)` — +`__get`, `__isset`, `toArray()` (via `accessColumn(null)`), and forward references +(`getReferencedTable` calls `$row->accessColumn($fkColumn)`). One hook covers every +path, which is why SELECT narrowing hangs off it. It: (1) delegates to +`$this->table->accessColumn($key)` and, if the Selection reports a re-query, pulls +fresh data from `$this->table[signature]->data`, (2) returns whether the column +exists. + +## Cache keys + +- `getGeneralCacheKey()` hashes `[table, conditions, debug_backtrace]` (plus a fixed + `Selection::class` constant — `self::class`, identical even for a `GroupedSelection`). It + **deliberately omits limit and select**, so it is stable across limited variants of + the same query (used by the limit re-query). **The trap:** it includes + `debug_backtrace`, so the cache key depends on the **call site** — two call sites + with identical conditions get different keys and different learned columns. This is + intentional (different sites read different columns) but surprising, and a + refactor that moves a call site (e.g. wraps `table()` in a factory) silently + "forgets" the learned columns. +- `getSpecificCacheKey()` hashes the **whole built select query** via + `SqlBuilder::getSelectQueryHash()` — conditions, order, aliases, limit/offset, + parameters, and the column list — so `SELECT id,name` vs `SELECT *` land in + different cache slots. + +## SELECT narrowing + +State: `$accessedColumns` (`array|false|null`; `false` = "all", forced +e.g. by `toArray()`), `$previousAccessedColumns` (learned last time). Off entirely +when `$cache === null` or when an explicit `select(...)` (even `select('*')`) is set. + +Flow: `accessColumn($key)` records the access; a **re-query** fires only when **all** +hold — the access wants a select column, `previous` is non-empty (something was +learned; the very first query with nothing learned just runs `SELECT *`), the key is +**not** in `previous`, and there is no explicit `select()`. `saveCacheState()` (from +`__destruct` / `emptyResultSet`) merges accessed into the cache, but only when +`observeCache === $this` — so only the owning instance saves. + +**The re-query** on an unlearned column: **without a limit**, `emptyResultSet`, set +`previous = []`, `execute()` again (now `SELECT *`); **with a limit**, it must not +re-run the original limited query (different rows), so it collects the already-loaded +rows' PKs, clones the SqlBuilder, **drops the limit**, `wherePrimary(collected PKs)`, +`execute()` (a `SELECT *` of exactly those rows), then restores the SqlBuilder — the +`generalCacheKey` is preserved throughout. In both branches, when the trigger was +`accessColumn(null)` (e.g. `toArray()` mid-iteration), the iterator position is +captured and restored after the re-query. It sets `dataRefreshed = true` so +`ActiveRow::accessColumn` pulls fresh data. This mechanism is **intrinsically inside +Selection** (it manipulates the SqlBuilder, rows, execute). + +**Shared vs per-instance — the key asymmetry.** `accessedColumns` is **shared** across +grouped clones (for the N+1 batch), bound by reference into +`&$referencing[$hash]['accessed']`. `previousAccessedColumns` is **not** shared. They +cannot be trivially unified into one shared object: the shared `accessed` slot is +selected by `hash` (`specificCacheKey`), which **depends on `previous`** — so if +`previous` lived inside the hash-selected object you'd get a cycle (object → need hash +→ need previous → need object). `previous` therefore must exist earlier and +independently, as a per-instance array. (A WeakMap does not help — the blocker is a +computation-order dependency, not object identity.) + +`Selection::__clone` clones only the SqlBuilder; `accessedColumns` is an **array** and +so is copied by value on clone (a `get()` clone is automatically independent). + +## N+1 prevention & refCache + +A Selection holds `$refCache` as a reference into the **root** selection's +`globalRefCache[$refPath]` (a GroupedSelection climbs up to build a `book.author.` +path), so relations are shared across the whole chain. Keys: `['referenced']` (forward +belongs-to batches), `['referencing']` (backward has-many batches + shared `accessed`, +`rows`, `data`), `['referencingPrototype']` (grouped prototypes). + +- **Forward (`$book->author`):** collect `author_id` from **all** parent rows, build + **one** `SELECT * FROM author WHERE id IN (...)`, index by FK — constant queries. +- **Backward (`$author->related('book')`):** `getReferencingTable` returns a **prototype** + `GroupedSelection` (cloned per access); its `execute` binds through `loadRefCache` to + the shared slot (`observeCache`, `rows`, `data`, **and** `accessedColumns` — all by + reference). The first clone queries **all** children of **all** parents at once and + buckets them by group value; later clones find the data cached. + +`loadRefCache` binds `$this->accessedColumns = &$referencing[$hash]['accessed']` (a +by-ref array), so a mutation from one clone is seen by all clones of the same relation +(they share the learned columns of the whole N+1 batch). + +## insert() & insertMany() + +`Selection::insert(iterable)` handles every form in one method (return type +`ActiveRow|array|int`, never `null`): a **list, a Selection, or a PK-less table** +returns the affected-row count (int). A **single associative row** collects the PK +from the data (an autoincrement part filled from `getInsertId`) and **re-fetches the +row eagerly** (`SELECT *` by PK) to return an `ActiveRow` — so a single insert always +costs a second query. Two fallbacks return early instead — a single-column PK without +autoincrement absent from the data → int, a composite PK without autoincrement with a +part missing → the original `$data` array. + +All the early-return branches above (int / array) call `clearReferencingCache()`; a +returned row is also registered into `rows`/`data` if the Selection was already +executed. + +`insertMany(iterable)` is a thin, typed wrapper over the bulk branch — it exists to +give callers a plain `int` instead of the `ActiveRow|array|int` union, and delegates +back to `insert()`. Three details are not obvious from the signature: + +- an **empty list returns 0 without touching the database**, whereas `insert([])` + inserts one row of database defaults (`?values` with an empty array) — the two are + deliberately not equivalent; +- rows are materialized by `Helpers::materializeRows()`, which drains a Traversable + **by position**, so nothing is lost when a generator yields rows under colliding + keys (`yield from` restarts at 0), and `Helpers::isRowList()` then accepts gaps left + by e.g. `array_filter()` — a plain `array_is_list()` would misread such rows as a + single associative row; +- a single associative row is **rejected up front** rather than inserted, so the + mistake surfaces before the write. + +`GroupedSelection` needs no override: `insertMany()` routes through `insert()`, whose +override there adds the grouping column to every row. + +## update() / delete() + +`ActiveRow::update($data)` runs UPDATE via `createSelectionInstance()->wherePrimary()` +then **re-fetches `SELECT *`** into `$this->data` (returns whether it changed). +`ActiveRow::delete()` runs DELETE and removes the row from `table[$signature]`. + +## The invariants that matter most + +1. **Row identity.** A returned row **must** be the same object that is in its + `table->rows` (else update + relations = stale). +2. **`debug_backtrace` in the cache key.** `generalCacheKey` depends on the call site; + a refactor that moves the call can change keys and "forget" learned columns — an + unsuspected source of "why is it suddenly `SELECT *`". +3. **Shared vs per-instance.** `accessedColumns` shared by reference, + `previousAccessedColumns` per-instance; not unifiable due to the previous→hash→slot + cycle above. +4. **`previousAccessedColumns` is transiently mutated** (`false` on retry, `[]` before + re-query, `null` after save) — which is exactly why it must **not** be shared across + grouped clones (it would corrupt another clone's hash computation). +5. **`select('*')` disables the optimization** — which is why `insert()` re-fetches + with `select('*')` (to avoid a cache-narrowed row and to avoid dirtying the cache). +6. **`offsetSet` on Selection writes only `rows`, not `data`** (asymmetry vs + `offsetUnset`) — latent fragility. +7. **refCache invalidation.** All "no identifiable row" insert branches call + `clearReferencingCache()`; a successful insert does not (it adds the row to `rows` + in place). + +## Three flows worth tracing + +**A) N+1 prevention (forward reference):** + +```php +$books = $explorer->table('book'); // lazy, nothing runs +foreach ($books as $book) { // execute(): SELECT * FROM book (query 1) + echo $book->author->name; // $book->author → getReferencedTable +} +``` + +On the **first** `$book->author`, `getReferencedTable` walks **all** `$books->rows`, +collects the `author_id`s and runs **one** `SELECT * FROM author WHERE id IN (...)` +(query 2), indexed by id. Later iterations just hit the cached selection — **2 queries +total** regardless of row count. (Backward `related()` works analogously via +`GroupedSelection`.) + +**B) insert():** + +```php +$row = $explorer->table('book')->insert([ // INSERT ... (query 1) + 'title' => 'Foo', 'author_id' => 1, +]); // then SELECT * WHERE id = ? (query 2) + // the PK comes from getInsertId() +$id = $row->id; // the row is already complete → no further query +echo $row->title; +``` + +**C) SELECT narrowing across requests (with cache):** + +```php +// request 1 (cache empty for this call site): +$book = $explorer->table('book')->get(1); // SELECT * FROM book WHERE id = 1 +echo $book->title; // accessColumn('title') records the access + // at the end: saveCacheState stores {id, title} under generalCacheKey + +// request 2 (cache already knows {id, title}): +$book = $explorer->table('book')->get(1); // SELECT id, title FROM book WHERE id = 1 (narrowed!) +echo $book->author->name; // needs author_id, missing from the narrowed {id, title} + // → re-query: SELECT * WHERE id = 1 + // getReferencedTable calls accessColumn('author_id') → marks it + // at the end: the cache grows to {id, title, author_id} +``` + +The learning is keyed by `generalCacheKey` (including `debug_backtrace`), so **the same +call site** gradually learns its own column set; a different call site learns separately. + +## Refactoring boundary + +The narrowing optimization is architecturally fused into Selection: the re-query +mechanism touches SqlBuilder/rows/execute, and the cache-persistence policy needs the +per-instance `previous` + cache + generalKey (the cycle blocker). Realistically only +the shared `accessed` state and its write operations are cleanly extractable. diff --git a/docs/internals/readme.md b/docs/internals/readme.md new file mode 100644 index 000000000..598c52f26 --- /dev/null +++ b/docs/internals/readme.md @@ -0,0 +1,15 @@ +# Database internals + +How `nette/database` works underneath, for agents editing it. Two independent +worlds — the low-level core and the Explorer (ActiveRow) layer — so split by seam: + +- **[explorer.md](explorer.md)** — the flagship: `Selection`/`ActiveRow` lazy + execution, `accessColumn`, SELECT narrowing, N+1 batching (`refCache`), lazy + insert. The subtlest, trap-richest code. +- **[sql-preprocessor.md](sql-preprocessor.md)** — parameter substitution and the + context-detected array modes. +- **[connection-drivers.md](connection-drivers.md)** — connection/execution, the + `Driver` dialect abstraction, and exception mapping. +- **[results-and-types.md](results-and-types.md)** — `ResultSet` and the DB→PHP + value normalization. +- **[transactions.md](transactions.md)** — `transaction()` and depth-counter nesting. diff --git a/docs/internals/results-and-types.md b/docs/internals/results-and-types.md new file mode 100644 index 000000000..ca44f220f --- /dev/null +++ b/docs/internals/results-and-types.md @@ -0,0 +1,38 @@ +# ResultSet & type normalization + +## ResultSet executes eagerly and iterates once + +The `ResultSet` constructor **runs the query immediately** — it times, prepares, binds +(PDO param types by PHP type: bool→`PARAM_BOOL`, int→`PARAM_INT`, resource→`PARAM_LOB`, +null→`PARAM_NULL`, else `PARAM_STR`), sets `FETCH_ASSOC`, and executes; a `PDOException` +becomes a converted `DriverException`. A query string beginning with **`::`** is a +special channel: it calls the named PDO method directly (this is how transactions issue +`::beginTransaction`/`::commit`/`::rollBack`). + +The iterator is **one-way** — `rewind()` throws once the iterator has advanced past +the first row. `fetchAssoc` is the core (each `fetch` → `normalizeRow`, with a +one-time duplicate-column check on the first fetched row); `fetch` wraps it in a `Row`; `fetchAll` +caches `iterator_to_array` into `$rows`. `getRowCount()` returns `rowCount()` (affected +rows for non-SELECT; `null` for `::`-channel calls with no statement). + +## DB→PHP conversion is a function, not a class + +There is **no `TypeConverter` class**. Conversion is `Helpers::normalizeRow()`, wired +into the `Connection` as the `rowNormalizer` closure (which the `newDateTime` option +swaps between `Nette\Database\DateTime` and `Nette\Utils\DateTime`). It can be replaced +via `setRowNormalizer`. + +`normalizeRow` iterates the result-set column types (`IStructure::FIELD_*`) and +converts, with a few traps worth knowing: + +- **`FIELD_INTEGER`** → `$value * 1`, but keeps the original **string** if it would + overflow to a float. +- **`FIELD_FLOAT`/`FIELD_DECIMAL`** → `(float)` — **a decimal is coerced to float** + (precision hazard). +- **`FIELD_BOOL`** → `$value && $value !== 'f' && $value !== 'F'` (handles PostgreSQL's + `'f'`). +- **`FIELD_DATETIME`/`FIELD_DATE`** → `new $dateTimeClass($value)`, but `'0000-00…'` + → `null`; `FIELD_TIME` sets the date to `0001-01-01`; `FIELD_TIME_INTERVAL` → + `\DateInterval`; `FIELD_UNIX_TIMESTAMP` → a datetime via `setTimestamp`. +- **Binary and JSON are not converted** — they come back as the raw PDO string. + `FIELD_BINARY` exists only for detection, not value conversion. diff --git a/docs/internals/sql-preprocessor.md b/docs/internals/sql-preprocessor.md new file mode 100644 index 000000000..c22318fe9 --- /dev/null +++ b/docs/internals/sql-preprocessor.md @@ -0,0 +1,51 @@ +# SqlPreprocessor + +Substitutes parameters into SQL, with **context-detected array modes** — the same +array means different SQL depending on where it appears. + +## Context detection + +`process()` scans the SQL fragments with one regex, recognizing the leading command +(`SELECT|INSERT|UPDATE|DELETE|REPLACE|EXPLAIN`) and the keywords +`SET|WHERE|HAVING|ORDER BY|GROUP BY|KEY UPDATE` **only when followed by end-of-string +or `?`**. A matched keyword sets `$arrayMode` via `CommandToMode`: + +| context | mode | array becomes | +|---|---|---| +| `INSERT`/`REPLACE` | `ModeValues` | `(cols) VALUES (…)` | +| `SET` / `KEY UPDATE` | `ModeSet` | `col = ?, …` | +| `WHERE` / `HAVING` | `ModeAnd` | `col = ? AND …` | +| `ORDER BY` / `GROUP BY` | `ModeOrder` | order list | + +An array parameter with no context and no explicit `?mode` **defaults to `ModeSet`**. +Explicit placeholders `?values`/`?set`/`?and`/`?or`/`?order`/`?list`/`?name` force a +mode; `?name` quotes an identifier by splitting on `.` and delimiting each part with +the driver. + +## Value formatting + +`formatValue` (`match(true)`) handles scalars (int/bool/float), a binary **resource** +(quoted stream contents), strings (`connection->quote`), `null`, and several object +types worth knowing: + +- **`SqlLiteral` is not escaped** — and may itself carry parameters + (`SqlLiteral($sql, $params)`), spliced in via a cloned preprocessor. +- **`ActiveRow` → its primary key** (this is how a row/subquery value is used). +- `DateTimeInterface`/`DateInterval` → the **driver's** format; `BackedEnum` → its + `value`; `Stringable` → its string. + +The same code either **inlines literals** or emits a bound `?` (pushing the value to +`remaining`), switched by `useParams` — which is turned on for the parametric +commands. `formatWhere` carries the fiddly bits: a compound key `'col op'` splits on +the first space; an empty array under a bare key or `IN` becomes `1=0` +(short-circuiting a whole `?and`), under `NOT`/`NOT IN` it becomes `1=1`; a `NULL` +value under a bare key renders `IS` (under `NOT`, `IS NOT`) — an explicitly written +`=` operator is **not** remapped, so `'col =' => null` yields a broken `= NULL`. +`formatSet` turns a `'points+='` key into `col = col + ?`. + +## Not implemented (so don't document as present) + +- **No `@` escaping.** There is no `@`-prefix logic anywhere in the preprocessor. +- **No PHP-array → PostgreSQL-array-literal serialization.** Arrays only travel through + the modes; on the read side a pg array column is merely detected as text (no + parsing). diff --git a/docs/internals/transactions.md b/docs/internals/transactions.md new file mode 100644 index 000000000..89e7b5e61 --- /dev/null +++ b/docs/internals/transactions.md @@ -0,0 +1,34 @@ +# Transactions + +Nesting is **counter-based only** — there are **no savepoints**. + +## `transaction()` + +`transaction(callable $callback): mixed` runs the callback between `BEGIN` and +`COMMIT`/`ROLLBACK`. Nesting is tracked purely by `$transactionDepth`: + +- a real `BEGIN` is issued only when `transactionDepth === 0`; a nested call merely + increments the counter and **emits no SQL**; +- `COMMIT` likewise only at depth 0; on an exception, `ROLLBACK` only when it unwinds + back to depth 0, then the exception is rethrown. Any failure of the rollback itself + (e.g. when the server already rolled back after a deadlock, or an `onQuery` handler + throws) is swallowed so it cannot mask the original exception. + +**The consequence to internalize:** a nested `transaction()` gives **no partial +rollback**. Only the outermost transaction issues real `BEGIN`/`COMMIT`/`ROLLBACK`, so +an inner failure tears down the *entire* outer transaction. The idea of savepoints is +**not implemented** — there is no `SAVEPOINT`/`RELEASE` anywhere in the code. + +**No retries either (so don't document them as present):** there is no `$attempts` +parameter, no retry loop, no `RetryableException` marker, no `onRetry` event. +`DeadlockException`, `LockTimeoutException` and `ConnectionLostException` exist, but +nothing in `transaction()` catches and retries them — retrying is the caller's job. + +## Manual control is fenced off inside a callback + +`beginTransaction()`/`commit()`/`rollBack()` each **throw** if called while +`transactionDepth !== 0`, i.e. manual transaction control is forbidden inside a +`transaction()` callback (they would desynchronize the counter). They execute via the +`::beginTransaction`/`::commit`/`::rollBack` PDO channel (see results-and-types.md). +`getInsertId` returns `lastInsertId` as a string (`'0'` on false), converting a +`PDOException` through the driver. diff --git a/phpstan.neon b/phpstan.neon index 46cea130e..63156a3fa 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -16,134 +16,191 @@ parameters: # $refPath is populated via @param-out by getRefTable() — PHPStan doesn't track this - identifier: variable.undefined + message: '#^Undefined variable: \$refPath$#' + count: 1 path: src/Database/Table/Selection.php - # Readonly lazy-loading via __get magic + # Readonly lazy-loading via __get magic (Reflection::$tables, Table::$columns/$indexes/$primaryKey/$foreignKeys) - identifier: property.uninitializedReadonly - paths: - - src/Database/Reflection.php - - src/Database/Reflection/Table.php + count: 2 + path: src/Database/Reflection.php + - + identifier: property.uninitializedReadonly + count: 8 + path: src/Database/Reflection/Table.php - identifier: unset.readOnlyProperty - paths: - - src/Database/Reflection.php - - src/Database/Reflection/Table.php + count: 1 + path: src/Database/Reflection.php + - + identifier: unset.readOnlyProperty + count: 4 + path: src/Database/Reflection/Table.php - identifier: property.readOnlyAssignNotInConstructor - paths: - - src/Database/Reflection.php - - src/Database/Reflection/Table.php + count: 1 + path: src/Database/Reflection.php + - + identifier: property.readOnlyAssignNotInConstructor + count: 4 + path: src/Database/Reflection/Table.php # Deprecated interfaces without generic types - identifier: missingType.generics - paths: - - src/Database/IRow.php - - src/Database/IRowContainer.php + count: 2 + path: src/Database/IRow.php + - + identifier: missingType.generics + count: 1 + path: src/Database/IRowContainer.php # Generic conflicts from deprecated IRowContainer/IRow extending Traversable without type params - identifier: generics.interfaceConflict - paths: - - src/Database/ResultSet.php - - src/Database/Row.php - - src/Database/Table/GroupedSelection.php - - src/Database/Table/ActiveRow.php - - src/Database/Table/Selection.php + count: 2 + path: src/Database/ResultSet.php + - + identifier: generics.interfaceConflict + count: 3 + path: src/Database/Row.php + - + identifier: generics.interfaceConflict + count: 2 + path: src/Database/Table/GroupedSelection.php + - + identifier: generics.interfaceConflict + count: 1 + path: src/Database/Table/ActiveRow.php + - + identifier: generics.interfaceConflict + count: 2 + path: src/Database/Table/Selection.php - # Iterator/ArrayAccess covariance/contravariance (PHP interface limitation) + # Iterator/ArrayAccess covariance/contravariance (PHP interface limitation): current() may return false, offset methods take int|string + - + identifier: method.childReturnType + message: '#::current\(\) should be covariant#' + count: 1 + path: src/Database/ResultSet.php - identifier: method.childReturnType - paths: - - src/Database/ResultSet.php - - src/Database/Table/Selection.php + message: '#::current\(\) should be covariant#' + count: 1 + path: src/Database/Table/Selection.php + - + identifier: method.childParameterType + message: '#\$key \(int\|string\) of method Nette\\Database\\Row::offset#' + count: 4 + path: src/Database/Row.php - identifier: method.childParameterType - paths: - - src/Database/Row.php - - src/Database/Table/Selection.php + message: '#::offsetSet\(\) should be contravariant#' + count: 1 + path: src/Database/Table/Selection.php # Intentional new static() in exception hierarchy - identifier: new.static + message: '#^Unsafe usage of new static\(\)\.$#' + count: 1 path: src/Database/DriverException.php # Closure variables consumed by require'd phtml template - identifier: closure.unusedUse + message: '#unused use \$(connection|queries)#' + count: 2 path: src/Bridges/DatabaseTracy/ConnectionPanel.php - # Defensive instanceof check in elseif branch for readability - - - identifier: instanceof.alwaysTrue - path: src/Bridges/DatabaseTracy/ConnectionPanel.php - - # Lazy-loading side effect via __get magic - - - identifier: expr.resultUnused - path: src/Database/Reflection.php - # DI extension: $this->config is array|object from Nette Schema - identifier: foreach.nonIterable + count: 2 path: src/Bridges/DatabaseDI/DatabaseExtension.php # PDOException::$queryString is set by PDO engine, not formally declared - identifier: property.notFound + message: '#PDOException::\$queryString#' + count: 1 path: src/Bridges/DatabaseTracy/ConnectionPanel.php # Dynamic callable construction: "is_$type"($value) where $type is always valid - identifier: callable.nonCallable + count: 1 path: src/Database/SqlPreprocessor.php # Defensive runtime checks unreachable per @param type - identifier: instanceof.alwaysTrue + message: '#Nette\\Database\\Row#' + count: 1 path: src/Database/SqlPreprocessor.php - identifier: booleanAnd.alwaysFalse + count: 1 path: src/Database/SqlPreprocessor.php - # getPrimary() returns string for single-column PK (composite PK not supported here) - - - identifier: argument.type - path: src/Database/Table/ActiveRow.php + # getPrimary() may return string[] for composite keys, but here it is always a string - identifier: array.invalidKey + message: '#array\|string#' + count: 1 path: src/Database/Table/ActiveRow.php - # Return type mismatches from generic covariance and internal caching + # fetchAssoc(): Arrays::associate() returns array|stdClass, but never stdClass for a string path + - + identifier: return.type + message: '#fetchAssoc\(\) should return array\|null but returns array\|stdClass#' + count: 1 + path: src/Database/ResultSet.php + + # structure array shape is built dynamically and re-loaded from cache - identifier: return.type - paths: - - src/Database/ResultSet.php - - src/Database/Structure.php + count: 2 + path: src/Database/Structure.php # Internal SQL is dynamically assembled from trusted components, not literal-string - - message: '#expects literal-string, .+ given#' - paths: - - src/Database/Table/Selection.php - - src/Database/Table/GroupedSelection.php + identifier: argument.type + message: '#expects literal-string, [\w-]+ given#' + count: 5 + path: src/Database/Table/Selection.php + - + identifier: argument.type + message: '#expects literal-string, [\w-]+ given#' + count: 1 + path: src/Database/Table/GroupedSelection.php - # Array offset access on Row/ActiveRow objects and nullable arrays + # parseColumnType(): regex match offsets guaranteed by the pattern - identifier: offsetAccess.notFound + message: '#^Offset \d might not exist on array#' + count: 4 + path: src/Database/Helpers.php + # toPairs(): offset access on array|Row|ActiveRow, all of them support it + - + identifier: offsetAccess.notFound + message: '#^Offset int\|string might not exist#' + count: 3 path: src/Database/Helpers.php # Defensive checks that are always true/false per PHPStan type narrowing - identifier: empty.offset + count: 1 path: src/Database/Helpers.php - - - identifier: isset.offset - path: src/Database/Helpers.php + # Latte-generated n:attr idiom: ($tmp = expr) === null ? '' : ... - identifier: identical.alwaysFalse - paths: - - src/Bridges/DatabaseTracy/dist/panel.phtml - - src/Bridges/DatabaseTracy/dist/tab.phtml + count: 3 + path: src/Bridges/DatabaseTracy/dist/panel.phtml + - + identifier: identical.alwaysFalse + count: 1 + path: src/Bridges/DatabaseTracy/dist/tab.phtml diff --git a/src/Bridges/DatabaseDI/DatabaseExtension.php b/src/Bridges/DatabaseDI/DatabaseExtension.php index 5b8560427..c6a9d8c63 100644 --- a/src/Bridges/DatabaseDI/DatabaseExtension.php +++ b/src/Bridges/DatabaseDI/DatabaseExtension.php @@ -10,7 +10,7 @@ use Nette; use Nette\Schema\Expect; use Tracy; -use function is_array, is_string; +use function array_key_exists, is_array, is_string; /** @@ -38,8 +38,8 @@ public function getConfigSchema(): Nette\Schema\Schema 'conventions' => Expect::string('discovered'), // Nette\Database\Conventions\DiscoveredConventions 'autowired' => Expect::bool(), ]), - )->before(fn($val) => is_array(reset($val)) || reset($val) === null - ? $val + )->before(fn($val) => is_array($val) && $val && !array_key_exists('dsn', $val) + ? $val // a set of named connections; a single connection always has the mandatory 'dsn' key : ['default' => $val]); } @@ -99,7 +99,7 @@ private function setupDatabase(\stdClass $config, string $name): void if (!empty($config->reflection)) { $conventionsServiceName = 'reflection'; $config->conventions = $config->reflection; - if (is_string($config->conventions) && strtolower($config->conventions) === 'conventional') { + if (strtolower($config->conventions) === 'conventional') { $config->conventions = 'Static'; } } else { @@ -109,16 +109,13 @@ private function setupDatabase(\stdClass $config, string $name): void if (!$config->conventions) { $conventions = null; - } elseif (is_string($config->conventions)) { + } else { $conventions = $builder->addDefinition($this->prefix("$name.$conventionsServiceName")) ->setFactory(preg_match('#^[a-z]+$#Di', $config->conventions) ? 'Nette\Database\Conventions\\' . ucfirst($config->conventions) . 'Conventions' : $config->conventions) ->setArguments(strtolower($config->conventions) === 'discovered' ? [$structure] : []) ->setAutowired($config->autowired); - - } else { - $conventions = Nette\DI\Helpers::filterArguments([$config->conventions])[0]; } $builder->addDefinition($this->prefix("$name.explorer")) diff --git a/src/Bridges/DatabaseTracy/ConnectionPanel.php b/src/Bridges/DatabaseTracy/ConnectionPanel.php index 9c1b27c2e..4e9f42de9 100644 --- a/src/Bridges/DatabaseTracy/ConnectionPanel.php +++ b/src/Bridges/DatabaseTracy/ConnectionPanel.php @@ -45,7 +45,12 @@ public static function initialize( ): ?self { $blueScreen ??= Tracy\Debugger::getBlueScreen(); - $blueScreen->addPanel(self::renderException(...)); + static $registered = null; + $registered ??= new \WeakMap; + if (!isset($registered[$blueScreen])) { // multiple connections must not register multiple identical panels + $registered[$blueScreen] = true; + $blueScreen->addPanel(self::renderException(...)); + } if ($addBarPanel) { $panel = new self($connection, $blueScreen); @@ -73,6 +78,13 @@ private function logQuery(Connection $connection, Nette\Database\ResultSet|\PDOE } $this->count++; + if ($result instanceof Nette\Database\ResultSet) { + $this->totalTime += $result->getTime(); + } + + if ($this->count > $this->maxQueries) { // count/time are still tracked, only the query detail is dropped + return; + } $trace = $result instanceof \PDOException ? array_map(fn($row) => array_diff_key($row, ['args' => null]), $result->getTrace()) @@ -90,14 +102,9 @@ private function logQuery(Connection $connection, Nette\Database\ResultSet|\PDOE array_shift($trace); } - if ($result instanceof Nette\Database\ResultSet) { - $this->totalTime += $result->getTime(); - if ($this->count < $this->maxQueries) { - $this->queries[] = [$connection, $result->getQueryString(), $result->getParameters(), $trace, $result->getTime(), $result->getRowCount(), null]; - } - } elseif ($result instanceof \PDOException && $this->count < $this->maxQueries) { - $this->queries[] = [$connection, $result->queryString, null, $trace, null, null, $result->getMessage()]; - } + $this->queries[] = $result instanceof Nette\Database\ResultSet + ? [$connection, $result->getQueryString(), $result->getParameters(), $trace, $result->getTime(), $result->getRowCount(), null] + : [$connection, $result->queryString, null, $trace, null, null, $result->getMessage()]; } diff --git a/src/Bridges/DatabaseTracy/dist/panel.phtml b/src/Bridges/DatabaseTracy/dist/panel.phtml index 2ba640d30..4d8af248f 100644 --- a/src/Bridges/DatabaseTracy/dist/panel.phtml +++ b/src/Bridges/DatabaseTracy/dist/panel.phtml @@ -19,9 +19,9 @@ echo ' -

Queries: {$count}{$totalTime ? sprintf(', time: %0.3f ms', $totalTime * 1000) : ''}, {$name}

+

Queries: {$count}{$totalTime ? sprintf(', time: %0.3f ms', $totalTime * 1000)}, {$name}

@@ -23,55 +23,43 @@ - {foreach $queries as [$connection, $sql, $params, $trace, $time, $rows, $error, $command, $explain]} - - + + {if $explain}
explain{/if} + {if $trace}
trace{/if} + - + {if $trace} + {substr_replace(Tracy\Helpers::editorLink($trace[0][file], $trace[0][line]), ' class="nette-DbConnectionPanel-source"', 2, 0)} +
Rows
- {if $error} - ERROR - {elseif $time !== null}{sprintf('%0.3f', $time * 1000)} - {/if} +
+ {if $error} + ERROR + {elseif $time !== null}{sprintf('%0.3f', $time * 1000)} + {/if} - {if $explain}
explain{/if} - {if $trace}
trace{/if} -
- {Nette\Database\Helpers::dumpSql($sql, $params, $connection)|noescape} + + {Nette\Database\Helpers::dumpSql($sql, $params, $connection)|noescape} - {if $explain} - - - {foreach $explain[0] as $col => $foo} - - {/foreach} - - {foreach $explain as $row} - - {foreach $row as $col} - - {/foreach} - - {/foreach} -
{$col}
{$col}
- {/if} + + + + + + + +
{$col}
{$col}
- {if $trace} - {substr_replace(Tracy\Helpers::editorLink($trace[0][file], $trace[0][line]), ' class="nette-DbConnectionPanel-source"', 2, 0)} - - {foreach $trace as $row} - - - - - {/foreach} -
{isset($row[file]) ? Tracy\Helpers::editorLink($row[file], $row[line]) : ''}{$row[class] ?? ''}{$row[type] ?? ''}{$row[function]}()
- {/if} -
+ + + + +
{isset($row[file]) ? Tracy\Helpers::editorLink($row[file], $row[line])}{$row[class] ?? ''}{$row[type] ?? ''}{$row[function]}()
+ {/if} + - {$rows} - - {/foreach} + {$rows} + - {if count($queries) < $count}

...and more

{/if} +

...and more

diff --git a/src/Bridges/DatabaseTracy/tab.latte b/src/Bridges/DatabaseTracy/tab.latte index d942ba4e9..c127ec17e 100644 --- a/src/Bridges/DatabaseTracy/tab.latte +++ b/src/Bridges/DatabaseTracy/tab.latte @@ -5,5 +5,5 @@ {$totalTime ? sprintf('%0.1f ms / ', $totalTime * 1000) : ''}{$count} + >{$totalTime ? sprintf('%0.1f ms / ', $totalTime * 1000)}{$count} diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 537702be7..e01e16059 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -12,7 +12,7 @@ use Nette\Utils\Arrays; use PDO; use PDOException; -use function str_replace, ucfirst; +use function class_exists, str_replace, ucfirst; /** @@ -20,10 +20,10 @@ */ class Connection { - /** @var array Occurs after connection is established */ + /** @var array Occurs after connection is established */ public array $onConnect = []; - /** @var array Occurs after query is executed */ + /** @var array Occurs after query is executed */ public array $onQuery = []; private Driver $driver; private SqlPreprocessor $preprocessor; @@ -72,6 +72,11 @@ public function connect(): void $class = empty($this->options['driverClass']) ? 'Nette\Database\Drivers\\' . ucfirst(str_replace('sql', 'Sql', $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME))) . 'Driver' : $this->options['driverClass']; + if (!class_exists($class)) { + throw new Nette\InvalidStateException(empty($this->options['driverClass']) + ? "Driver class '$class' not found, specify it using the 'driverClass' option." + : "Driver class '$class' not found."); + } $driver = new $class; if (!$driver instanceof Driver) { throw new Nette\InvalidStateException("Driver class '$class' does not implement " . Driver::class . '.'); @@ -205,6 +210,7 @@ public function commit(): void /** * Rolls back current transaction. * @throws \LogicException when called inside a transaction + * @throws DriverException */ public function rollBack(): void { @@ -216,6 +222,15 @@ public function rollBack(): void } + /** + * Checks whether a transaction is active, either via transaction() or manual beginTransaction(). + */ + public function isInTransaction(): bool + { + return $this->transactionDepth > 0 || ($this->pdo?->inTransaction() ?? false); + } + + /** * Executes callback inside a transaction. Supports nesting. * @param callable(static): mixed $callback @@ -232,7 +247,11 @@ public function transaction(callable $callback): mixed } catch (\Throwable $e) { $this->transactionDepth--; if ($this->transactionDepth === 0) { - $this->rollBack(); + try { + $this->rollBack(); + } catch (\Throwable) { + // e.g. after a deadlock the server has already rolled back; the original exception matters more + } } throw $e; diff --git a/src/Database/Conventions/DiscoveredConventions.php b/src/Database/Conventions/DiscoveredConventions.php index 352e4d9fc..65d549d35 100644 --- a/src/Database/Conventions/DiscoveredConventions.php +++ b/src/Database/Conventions/DiscoveredConventions.php @@ -69,7 +69,12 @@ public function getHasManyReference(string $nsTable, string $key): ?array } if (!empty($candidates)) { - throw new AmbiguousReferenceKeyException('Ambiguous joining column in related call.'); + throw new AmbiguousReferenceKeyException(sprintf( + "Ambiguous joining column in related('%s') called on table '%s', candidates: %s.", + $key, + $nsTable, + implode(', ', array_map(fn($c) => "{$c[1][0]}.{$c[1][1]}", $candidates)), + )); } if ($this->structure->isRebuilt()) { diff --git a/src/Database/Conventions/StaticConventions.php b/src/Database/Conventions/StaticConventions.php index 094484c41..a7df14ec5 100644 --- a/src/Database/Conventions/StaticConventions.php +++ b/src/Database/Conventions/StaticConventions.php @@ -18,7 +18,7 @@ class StaticConventions implements Conventions { /** * @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 $foreign %1$s stands for the referenced table (the key after -> in belongs-to, the parent table in has-many), %2$s for the opposite side * @param string $table %1$s stands for key used after ->, %2$s for table name */ public function __construct( diff --git a/src/Database/Driver.php b/src/Database/Driver.php index fdad8837b..3a4564aca 100644 --- a/src/Database/Driver.php +++ b/src/Database/Driver.php @@ -18,6 +18,7 @@ interface Driver SupportSelectUngroupedColumns = 'ungrouped_cols', SupportMultiInsertAsSelect = 'insert_as_select', SupportMultiColumnAsOrCondition = 'multi_column_as_or', + SupportDefaultValues = 'default_values', SupportSchema = 'schema'; /** @deprecated use Driver::Support* */ diff --git a/src/Database/DriverException.php b/src/Database/DriverException.php index 41a29d47d..2ed894a00 100644 --- a/src/Database/DriverException.php +++ b/src/Database/DriverException.php @@ -35,7 +35,6 @@ public static function from(\PDOException $src): static $e->code = $m[1]; } else { $e->errorInfo = $src->errorInfo; - $e->code = $src->code; $e->code = $e->errorInfo[0] ?? $src->code; } diff --git a/src/Database/Drivers/MsSqlDriver.php b/src/Database/Drivers/MsSqlDriver.php index b3f6e04c0..61a707bd8 100644 --- a/src/Database/Drivers/MsSqlDriver.php +++ b/src/Database/Drivers/MsSqlDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function array_values, explode, preg_replace, str_replace, strtoupper, strtr; +use function array_values, explode, preg_replace, str_contains, str_replace, strtoupper, strtr; /** @@ -27,14 +27,29 @@ public function initialize(Nette\Database\Connection $connection, array $options public function isSupported(string $feature): bool { - return false; + return $feature === self::SupportDefaultValues; } public function convertException(\PDOException $e): Nette\Database\DriverException { $code = $e->errorInfo[1] ?? null; - if ($code === 1205) { + if ($code === 2627 || $code === 2601) { + return Nette\Database\UniqueConstraintViolationException::from($e); + + } elseif ($code === 515) { + return Nette\Database\NotNullConstraintViolationException::from($e); + + } elseif ($code === 547) { + return match (true) { + str_contains($e->getMessage(), 'CHECK constraint') => Nette\Database\CheckConstraintViolationException::from($e), + str_contains($e->getMessage(), 'FOREIGN KEY constraint'), + str_contains($e->getMessage(), 'REFERENCE constraint') => Nette\Database\ForeignKeyConstraintViolationException::from($e), + // the message is localized per login language and cannot be classified further + default => Nette\Database\ConstraintViolationException::from($e), + }; + + } elseif ($code === 1205) { return Nette\Database\DeadlockException::from($e); } elseif ($code === 1222) { @@ -51,7 +66,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti public function delimite(string $name): string { // @see https://msdn.microsoft.com/en-us/library/ms176027.aspx - return '[' . str_replace(['[', ']'], ['[[', ']]'], $name) . ']'; + return '[' . str_replace(']', ']]', $name) . ']'; } @@ -125,7 +140,7 @@ public function getTables(): array public function getColumns(string $table): array { - [$table_schema, $table_name] = explode('.', $table); + [$table_schema, $table_name] = $this->splitTableName($table); $columns = []; $rows = $this->connection->query(<<<'X' @@ -171,7 +186,7 @@ public function getColumns(string $table): array public function getIndexes(string $table): array { - [, $table_name] = explode('.', $table); + [, $table_name] = $this->splitTableName($table); $indexes = []; $rows = $this->connection->query(<<<'X' @@ -208,7 +223,7 @@ public function getIndexes(string $table): array public function getForeignKeys(string $table): array { - [$table_schema, $table_name] = explode('.', $table); + [$table_schema, $table_name] = $this->splitTableName($table); $keys = []; $rows = $this->connection->query(<<<'X' @@ -252,4 +267,13 @@ public function getColumnTypes(\PDOStatement $statement): array { return Nette\Database\Helpers::detectTypes($statement); } + + + /** @return array{string, string} schema and table name */ + private function splitTableName(string $table): array + { + return str_contains($table, '.') + ? explode('.', $table, 2) + : ['dbo', $table]; + } } diff --git a/src/Database/Drivers/MySqlDriver.php b/src/Database/Drivers/MySqlDriver.php index 8ed8cc6fb..b0d69e892 100644 --- a/src/Database/Drivers/MySqlDriver.php +++ b/src/Database/Drivers/MySqlDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function addcslashes, array_change_key_case, array_values, in_array, str_replace, strtoupper, substr; +use function addcslashes, array_change_key_case, array_values, in_array, str_contains, str_replace, strtoupper, substr; /** @@ -66,7 +66,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti } elseif (in_array($code, [1062, 1557, 1569, 1586], strict: true)) { return Nette\Database\UniqueConstraintViolationException::from($e); - } elseif ($code === 3819) { + } elseif ($code === 3819 || $code === 4025) { // 3819 = MySQL, 4025 = MariaDB return Nette\Database\CheckConstraintViolationException::from($e); } elseif ($code === 1213) { @@ -75,7 +75,12 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti } elseif ($code === 1205) { return Nette\Database\LockTimeoutException::from($e); - } elseif ($code === 2006 || $code === 2013) { + } elseif ( + $code === 2006 + || $code === 2013 + // ER_CLIENT_INTERACTION_TIMEOUT (MySQL 8.0.24+); on MariaDB 4031 is an unrelated trigger error + || ($code === 4031 && str_contains($e->getMessage(), 'disconnected')) + ) { return Nette\Database\ConnectionLostException::from($e); } elseif ($code >= 2001 && $code <= 2028) { @@ -149,7 +154,7 @@ public function getTables(): array $tables[] = [ 'name' => (string) $row['TABLE_NAME'], 'view' => $row['TABLE_TYPE'] === 'VIEW', - 'comment' => (string) $row['TABLE_COMMENT'], + 'comment' => $row['TABLE_TYPE'] === 'VIEW' ? '' : (string) $row['TABLE_COMMENT'], // views have the literal comment 'VIEW' ]; } @@ -168,7 +173,7 @@ public function getColumns(string $table): array 'name' => $row['field'], 'table' => $table, 'nativetype' => strtoupper($typeInfo['type'] ?? ''), - 'size' => $typeInfo['length'], + 'size' => $typeInfo['size'], 'nullable' => $row['null'] === 'YES', 'default' => $row['default'], 'autoincrement' => $row['extra'] === 'auto_increment', @@ -194,7 +199,9 @@ public function getIndexes(string $table): array 'primary' => $id === 'PRIMARY', 'columns' => [], ]; - $indexes[$id]['columns'][(int) $row['Seq_in_index'] - 1] = (string) $row['Column_name']; + $indexes[$id]['columns'][(int) $row['Seq_in_index'] - 1] = $row['Column_name'] === null + ? (string) ($row['Expression'] ?? '') // functional index part + : (string) $row['Column_name']; } foreach ($indexes as &$index) { @@ -215,6 +222,7 @@ public function getForeignKeys(string $table): array WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL AND TABLE_NAME = ? + ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION X, $table); while ($row = $rows->fetch()) { diff --git a/src/Database/Drivers/OciDriver.php b/src/Database/Drivers/OciDriver.php index 7587dd572..2e2a8cf80 100644 --- a/src/Database/Drivers/OciDriver.php +++ b/src/Database/Drivers/OciDriver.php @@ -39,7 +39,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti if (in_array($code, [1, 2299, 38911], strict: true)) { return Nette\Database\UniqueConstraintViolationException::from($e); - } elseif (in_array($code, [1400], strict: true)) { + } elseif ($code === 1400) { return Nette\Database\NotNullConstraintViolationException::from($e); } elseif (in_array($code, [2266, 2291, 2292], strict: true)) { diff --git a/src/Database/Drivers/OdbcDriver.php b/src/Database/Drivers/OdbcDriver.php index 9b9403398..d0cc6cb3a 100644 --- a/src/Database/Drivers/OdbcDriver.php +++ b/src/Database/Drivers/OdbcDriver.php @@ -38,7 +38,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti public function delimite(string $name): string { - return '[' . str_replace(['[', ']'], ['[[', ']]'], $name) . ']'; + return '[' . str_replace(']', ']]', $name) . ']'; // only ] is escaped by doubling in bracketed identifiers } diff --git a/src/Database/Drivers/PgSqlDriver.php b/src/Database/Drivers/PgSqlDriver.php index 4024359f9..92123b8ed 100644 --- a/src/Database/Drivers/PgSqlDriver.php +++ b/src/Database/Drivers/PgSqlDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function array_map, array_values, explode, implode, str_contains, str_replace, strtr, substr; +use function array_map, array_values, count, explode, implode, str_contains, str_replace, strtr, substr; /** @@ -18,6 +18,9 @@ class PgSqlDriver implements Nette\Database\Driver { private Nette\Database\Connection $connection; + /** @var array> query => column types */ + private array $columnTypesCache = []; + public function initialize(Nette\Database\Connection $connection, array $options): void { @@ -27,7 +30,7 @@ public function initialize(Nette\Database\Connection $connection, array $options public function isSupported(string $feature): bool { - return $feature === self::SupportSequence || $feature === self::SupportSchema; + return $feature === self::SupportSequence || $feature === self::SupportSchema || $feature === self::SupportDefaultValues; } @@ -58,6 +61,9 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti } elseif ( $code === '08003' || $code === '08006' + || $code === '57P01' // admin_shutdown, e.g. pg_terminate_backend() or server restart + || $code === '57P02' // crash_shutdown + || $code === '57P03' // cannot_connect_now || ($code === 'HY000' && str_contains($e->getMessage(), 'server closed the connection unexpectedly')) ) { return Nette\Database\ConnectionLostException::from($e); @@ -135,7 +141,8 @@ public function getTables(): array c.relkind IN ('r', 'v', 'm', 'p') AND n.nspname = ANY (pg_catalog.current_schemas(FALSE)) ORDER BY - c.relname + c.relname, + array_position(pg_catalog.current_schemas(FALSE), n.nspname) -- shadowed names resolve in search_path order X); while ($row = $rows->fetch()) { @@ -216,15 +223,17 @@ public function getIndexes(string $table): array c2.relname::varchar AS name, i.indisunique AS unique, i.indisprimary AS primary, - a.attname::varchar AS column + coalesce(a.attname, pg_catalog.pg_get_indexdef(i.indexrelid, k.ord::int, TRUE))::varchar AS column -- expression part when attnum = 0 FROM pg_catalog.pg_class AS c1 JOIN pg_catalog.pg_index AS i ON c1.oid = i.indrelid JOIN pg_catalog.pg_class AS c2 ON i.indexrelid = c2.oid - LEFT JOIN pg_catalog.pg_attribute AS a ON c1.oid = a.attrelid AND a.attnum = ANY(i.indkey) + CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) + LEFT JOIN pg_catalog.pg_attribute AS a ON c1.oid = a.attrelid AND a.attnum = k.attnum WHERE c1.relkind IN ('r', 'p') AND c1.oid = ?::regclass + ORDER BY c2.relname, k.ord X, $this->delimiteFQN($table)); while ($row = $rows->fetch()) { @@ -280,10 +289,12 @@ public function getForeignKeys(string $table): array public function getColumnTypes(\PDOStatement $statement): array { - static $cache; - $item = &$cache[$statement->queryString]; - $item ??= Nette\Database\Helpers::detectTypes($statement); - return $item; + // per-instance cache, a process-wide one would leak between connections to different databases + if (count($this->columnTypesCache) > 1000) { + $this->columnTypesCache = []; + } + + return $this->columnTypesCache[$statement->queryString] ??= Nette\Database\Helpers::detectTypes($statement); } diff --git a/src/Database/Drivers/SqliteDriver.php b/src/Database/Drivers/SqliteDriver.php index 40abcb71b..74133bc82 100644 --- a/src/Database/Drivers/SqliteDriver.php +++ b/src/Database/Drivers/SqliteDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function addcslashes, array_values, in_array, preg_match, str_contains, strtoupper, strtr, substr; +use function addcslashes, array_values, in_array, preg_match, preg_quote, str_contains, strtoupper, substr; /** @@ -29,7 +29,7 @@ public function initialize(Nette\Database\Connection $connection, array $options public function isSupported(string $feature): bool { - return $feature === self::SupportMultiInsertAsSelect || $feature === self::SupportMultiColumnAsOrCondition; + return $feature === self::SupportMultiInsertAsSelect || $feature === self::SupportMultiColumnAsOrCondition || $feature === self::SupportDefaultValues; } @@ -48,7 +48,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti return Nette\Database\UniqueConstraintViolationException::from($e); } elseif ( - str_contains($msg, 'may not be null') + str_contains($msg, 'may not be NULL') || str_contains($msg, 'NOT NULL constraint failed') ) { return Nette\Database\NotNullConstraintViolationException::from($e); @@ -59,6 +59,9 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti ) { return Nette\Database\ForeignKeyConstraintViolationException::from($e); + } elseif (str_contains($msg, 'CHECK constraint failed')) { + return Nette\Database\CheckConstraintViolationException::from($e); + } else { return Nette\Database\ConstraintViolationException::from($e); } @@ -70,7 +73,9 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti public function delimite(string $name): string { - return '[' . strtr($name, '[]', ' ') . ']'; + return str_contains($name, ']') // there is no escape for ] inside [...] + ? throw new Nette\InvalidArgumentException('Identifier must not contain the ] character.') + : "[$name]"; } @@ -150,13 +155,14 @@ public function getColumns(string $table): array $rows = $this->connection->query('PRAGMA table_info(?name)', $table); while ($row = $rows->fetch()) { $column = $row['name']; - $pattern = "/(\"$column\"|`$column`|\\[$column\\]|$column)\\s+[^,]+\\s+PRIMARY\\s+KEY\\s+AUTOINCREMENT/Ui"; + $q = preg_quote($column, '/'); + $pattern = "/(\"$q\"|`$q`|\\[$q]|\\b$q)\\s+[^,]+\\s+PRIMARY\\s+KEY\\s+AUTOINCREMENT/Ui"; $typeInfo = Nette\Database\Helpers::parseColumnType($row['type']); $columns[] = [ 'name' => $column, 'table' => $table, 'nativetype' => strtoupper($typeInfo['type'] ?? 'BLOB'), - 'size' => $typeInfo['length'], + 'size' => $typeInfo['size'], 'nullable' => $row['notnull'] == 0, 'default' => $row['dflt_value'], 'autoincrement' => $createSql && preg_match($pattern, $createSql['sql']), @@ -176,34 +182,22 @@ public function getIndexes(string $table): array $rows = $this->connection->query('PRAGMA index_list(?name)', $table); while ($row = $rows->fetch()) { $id = (string) $row['name']; + $columns = []; + $res = $this->connection->query('PRAGMA index_info(?name)', $id); + while ($info = $res->fetch()) { + $columns[] = (string) $info['name']; + } + $indexes[$id] = [ 'name' => $id, 'unique' => (bool) $row['unique'], - 'primary' => false, - 'columns' => [], + 'primary' => ($row['origin'] ?? null) === 'pk', + 'columns' => $columns, ]; } - foreach ($indexes as $index => $values) { - $res = $this->connection->query('PRAGMA index_info(?name)', $index); - while ($row = $res->fetch()) { - $indexes[$index]['columns'][] = (string) $row['name']; - } - } - - $columns = $this->getColumns($table); - foreach ($indexes as $index => $values) { - $column = $values['columns'][0]; - foreach ($columns as $info) { - if ($column === $info['name']) { - $indexes[$index]['primary'] = (bool) $info['primary']; - break; - } - } - } - if (!$indexes) { // @see http://www.sqlite.org/lang_createtable.html#rowid - foreach ($columns as $column) { + foreach ($this->getColumns($table) as $column) { if ($column['vendor']['pk']) { $indexes[] = [ 'name' => 'ROWID', diff --git a/src/Database/Drivers/SqlsrvDriver.php b/src/Database/Drivers/SqlsrvDriver.php index 143f182b6..064057a2b 100644 --- a/src/Database/Drivers/SqlsrvDriver.php +++ b/src/Database/Drivers/SqlsrvDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function array_values, str_replace, strtr; +use function array_values, preg_replace, str_contains, str_replace, strtr; /** @@ -27,14 +27,29 @@ public function initialize(Nette\Database\Connection $connection, array $options public function isSupported(string $feature): bool { - return false; + return $feature === self::SupportDefaultValues; } public function convertException(\PDOException $e): Nette\Database\DriverException { $code = $e->errorInfo[1] ?? null; - if ($code === 1205) { + if ($code === 2627 || $code === 2601) { + return Nette\Database\UniqueConstraintViolationException::from($e); + + } elseif ($code === 515) { + return Nette\Database\NotNullConstraintViolationException::from($e); + + } elseif ($code === 547) { + return match (true) { + str_contains($e->getMessage(), 'CHECK constraint') => Nette\Database\CheckConstraintViolationException::from($e), + str_contains($e->getMessage(), 'FOREIGN KEY constraint'), + str_contains($e->getMessage(), 'REFERENCE constraint') => Nette\Database\ForeignKeyConstraintViolationException::from($e), + // the message is localized per login language and cannot be classified further + default => Nette\Database\ConstraintViolationException::from($e), + }; + + } elseif ($code === 1205) { return Nette\Database\DeadlockException::from($e); } elseif ($code === 1222) { @@ -81,10 +96,15 @@ public function applyLimit(string &$sql, ?int $limit, ?int $offset): void if ($limit < 0 || $offset < 0) { throw new Nette\InvalidArgumentException('Negative offset or limit.'); + } elseif ($limit === 0) { // FETCH NEXT 0 is rejected by the server + $sql = preg_replace('#^\s*(SELECT(\s+DISTINCT|\s+ALL)?)#i', '$0 TOP 0', $sql, 1, $count); + if (!$count) { + throw new Nette\InvalidArgumentException('SQL query must begin with SELECT command.'); + } } elseif ($limit !== null || $offset) { // requires ORDER BY, see https://technet.microsoft.com/en-us/library/gg699618(v=sql.110).aspx - $sql .= ' OFFSET ' . (int) $offset . ' ROWS ' - . 'FETCH NEXT ' . (int) $limit . ' ROWS ONLY'; + $sql .= ' OFFSET ' . (int) $offset . ' ROWS' + . ($limit !== null ? " FETCH NEXT $limit ROWS ONLY" : ''); } } diff --git a/src/Database/Explorer.php b/src/Database/Explorer.php index f030813b4..8f891ba2b 100644 --- a/src/Database/Explorer.php +++ b/src/Database/Explorer.php @@ -49,6 +49,15 @@ public function rollBack(): void } + /** + * Checks whether a transaction is active, either via transaction() or manual beginTransaction(). + */ + public function isInTransaction(): bool + { + return $this->connection->isInTransaction(); + } + + /** * Executes callback inside a transaction. * @param callable(static): mixed $callback @@ -115,7 +124,7 @@ public function getConventions(): Conventions /** - * Creates an ActiveRow instance, using the configured row mapping class if available. + * Creates an ActiveRow instance. Override in a subclass to map tables to custom row classes. * @param array $data * @param Table\Selection $selection */ diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index cef1af1f8..b499216cb 100644 --- a/src/Database/Helpers.php +++ b/src/Database/Helpers.php @@ -10,7 +10,7 @@ use Nette; use Nette\Bridges\DatabaseTracy\ConnectionPanel; use Tracy; -use function array_filter, array_keys, array_unique, count, fclose, fgets, fopen, fstat, get_resource_type, htmlspecialchars, implode, is_bool, is_float, is_resource, is_string, preg_last_error, preg_match, preg_replace, preg_replace_callback, reset, rtrim, set_time_limit, str_ends_with, str_starts_with, stream_get_meta_data, strlen, strncasecmp, substr, trim, wordwrap; +use function array_combine, array_filter, array_keys, array_unique, count, fclose, fgets, fopen, fstat, get_resource_type, htmlspecialchars, implode, is_array, is_bool, is_float, is_resource, is_string, preg_last_error, preg_match, preg_replace, preg_replace_callback, reset, rtrim, set_time_limit, str_ends_with, str_starts_with, stream_get_meta_data, strlen, strncasecmp, substr, trim, wordwrap; /** @@ -283,7 +283,7 @@ public static function loadFromFile(Connection $connection, string $file, ?calla $sql = ''; $count++; if ($onProgress) { - $onProgress($count, isset($stat['size']) ? $size * 100 / $stat['size'] : null); + $onProgress($count, empty($stat['size']) ? null : $size * 100 / $stat['size']); } } else { $sql .= $s; @@ -294,7 +294,7 @@ public static function loadFromFile(Connection $connection, string $file, ?calla $pdo->exec($sql); $count++; if ($onProgress) { - $onProgress($count, isset($stat['size']) ? 100 : null); + $onProgress($count, empty($stat['size']) ? null : 100); } } @@ -405,16 +405,54 @@ public static function findDuplicates(\PDOStatement $statement): string } + /** + * Materializes rows for insertion. A Traversable is drained by position, so that rows yielded + * under colliding keys (`yield from`) survive, while a single associative row stays inspectable. + * @param iterable $data + * @return array + * @internal + */ + public static function materializeRows(iterable $data): array + { + if (is_array($data)) { + return $data; + } + + $keys = $values = []; + foreach ($data as $key => $value) { + $keys[] = $key; + $values[] = $value; + } + + return $keys === array_filter($keys, 'is_int') + ? $values + : array_combine($keys, $values); + } + + + /** + * Checks whether the data is a list of rows rather than a single row. Integer keys are never + * column names, so any all-integer keys mean a list, even with gaps left by e.g. array_filter(). + * @param array $data + * @internal + */ + public static function isRowList(array $data): bool + { + $keys = array_keys($data); + return $keys !== [] && $keys === array_filter($keys, 'is_int'); + } + + /** * Parses a SQL column type string into its components. - * @return array{type: ?string, length: ?int, scale: ?int, parameters: ?string} + * @return array{type: ?string, size: ?int, scale: ?int, parameters: ?string} */ public static function parseColumnType(string $type): array { preg_match('/^([^(]+)(?:\((?:(\d+)(?:,(\d+))?|([^)]+))\))?/', $type, $m, PREG_UNMATCHED_AS_NULL); return [ 'type' => $m[1] ?? null, - 'length' => isset($m[2]) ? (int) $m[2] : null, + 'size' => isset($m[2]) ? (int) $m[2] : null, 'scale' => isset($m[3]) ? (int) $m[3] : null, 'parameters' => $m[4] ?? null, ]; diff --git a/src/Database/IStructure.php b/src/Database/IStructure.php index 48c5f7982..fd4781cbf 100644 --- a/src/Database/IStructure.php +++ b/src/Database/IStructure.php @@ -34,7 +34,7 @@ function getTables(): array; /** * Returns all columns in a table. - * @return list}> + * @return list}> */ function getColumns(string $table): array; diff --git a/src/Database/Reflection.php b/src/Database/Reflection.php index 036f0e9be..8e1ca442f 100644 --- a/src/Database/Reflection.php +++ b/src/Database/Reflection.php @@ -51,8 +51,9 @@ private function tryGetTable(string $name): ?Table { try { $table = new Table($this, $name); - $table->columns; - return $table; + return $table->columns + ? $table + : null; // some drivers (SQLite) report no columns instead of failing for an unknown table } catch (DriverException) { } return null; diff --git a/src/Database/ResultSet.php b/src/Database/ResultSet.php index 6051bbeb0..710419875 100644 --- a/src/Database/ResultSet.php +++ b/src/Database/ResultSet.php @@ -22,6 +22,7 @@ class ResultSet implements \Iterator, IRowContainer private ?\PDOStatement $pdoStatement = null; private Row|false|null $lastRow = null; private int $lastRowKey = -1; + private bool $duplicatesChecked = false; /** @var list */ private array $rows; @@ -39,7 +40,7 @@ public function __construct( /** @var ?\Closure(array, self): array */ private readonly ?\Closure $normalizer = null, ) { - $time = microtime(true); + $time = microtime(as_float: true); $types = ['boolean' => PDO::PARAM_BOOL, 'integer' => PDO::PARAM_INT, 'resource' => PDO::PARAM_LOB, 'NULL' => PDO::PARAM_NULL]; try { @@ -206,9 +207,12 @@ public function fetchAssoc(?string $path = null): ?array $this->pdoStatement?->closeCursor(); return null; - } elseif ($this->lastRow === null && count($data) !== $this->pdoStatement->columnCount()) { - $duplicates = Helpers::findDuplicates($this->pdoStatement); - trigger_error("Found duplicate columns in database result set: $duplicates."); + } elseif (!$this->duplicatesChecked) { + $this->duplicatesChecked = true; + if (count($data) !== $this->pdoStatement->columnCount()) { + $duplicates = Helpers::findDuplicates($this->pdoStatement); + trigger_error("Found duplicate columns in database result set: $duplicates."); + } } return $this->normalizeRow($data); diff --git a/src/Database/Row.php b/src/Database/Row.php index 3143a19fd..9b0fdd09f 100644 --- a/src/Database/Row.php +++ b/src/Database/Row.php @@ -26,7 +26,8 @@ public function __get(mixed $key): mixed public function __isset(string $key): bool { - return isset($this->key); + // called only for non-existent properties, so that `$row->missing ?? …` does not invoke throwing __get + return false; } @@ -56,7 +57,8 @@ public function offsetGet($key): mixed public function offsetExists($key): bool { if (is_int($key)) { - return (bool) current(array_slice((array) $this, $key, 1)); + $arr = array_slice((array) $this, $key, 1); + return $arr && current($arr) !== null; // null value → false, consistently with string keys } return parent::offsetExists($key); diff --git a/src/Database/SqlPreprocessor.php b/src/Database/SqlPreprocessor.php index 8a3e72348..cfa08a0a6 100644 --- a/src/Database/SqlPreprocessor.php +++ b/src/Database/SqlPreprocessor.php @@ -8,7 +8,7 @@ namespace Nette\Database; use Nette; -use function array_key_exists, array_keys, array_map, array_values, count, explode, get_debug_type, implode, in_array, is_array, is_bool, is_float, is_int, is_resource, is_scalar, is_string, iterator_to_array, ltrim, number_format, rtrim, str_contains, str_ends_with, stream_get_contents, strtoupper, substr; +use function array_key_exists, array_keys, array_map, array_slice, array_values, count, explode, get_debug_type, implode, in_array, is_array, is_bool, is_float, is_int, is_resource, is_scalar, is_string, iterator_to_array, ltrim, number_format, rtrim, str_contains, str_ends_with, stream_get_contents, strtoupper, substr; /** @@ -57,7 +57,7 @@ class SqlPreprocessor private int $counter; private bool $useParams; - /** values|set|and|order|items */ + /** values|set|and|or|order|list|name */ private ?string $arrayMode; @@ -231,6 +231,12 @@ private function formatList(array $values): string */ private function formatInsert(array $items): string { + if (!$items) { + return $this->driver->isSupported(Driver::SupportDefaultValues) + ? 'DEFAULT VALUES' + : '() VALUES ()'; + } + $cols = $vals = []; foreach ($items as $k => $v) { $cols[] = $this->delimit($k); @@ -248,7 +254,7 @@ private function formatInsert(array $items): string private function formatMultiInsert(array $groups): string { if (!is_array($groups[0]) && !$groups[0] instanceof Row) { - throw new Nette\InvalidArgumentException('Automaticaly detected multi-insert, but values aren\'t array. If you need try to change ?mode.'); + throw new Nette\InvalidArgumentException("Automatically detected multi-insert, but values aren't array. Use an explicit ?mode placeholder if needed."); } $cols = array_keys(is_array($groups[0]) ? $groups[0] : iterator_to_array($groups[0])); @@ -300,6 +306,7 @@ private function formatWhere(array $items, string $mode): string { $default = '1=1'; $res = []; + $begin = count($this->remaining); foreach ($items as $k => $v) { if (is_int($k)) { $res[] = $this->formatValue($v); @@ -316,6 +323,7 @@ private function formatWhere(array $items, string $mode): string } else { $default = $kind ? '1=0' : '1=1'; if ($kind === ($mode === self::ModeAnd)) { + $this->remaining = array_slice($this->remaining, 0, $begin); // drops params of discarded conditions return "($default)"; } } diff --git a/src/Database/Table/ActiveRow.php b/src/Database/Table/ActiveRow.php index 53d8e706d..5c3816a11 100644 --- a/src/Database/Table/ActiveRow.php +++ b/src/Database/Table/ActiveRow.php @@ -8,7 +8,7 @@ namespace Nette\Database\Table; use Nette; -use function array_intersect_key, array_key_exists, array_keys, implode, is_array, iterator_to_array; +use function array_intersect_key, array_key_exists, array_keys, implode, is_array, is_string, iterator_to_array; /** @@ -135,7 +135,12 @@ public function ref(string $key, ?string $throughColumn = null): ?self */ public function related(string $key, ?string $throughColumn = null): GroupedSelection { - $groupedSelection = $this->table->getReferencingTable($key, $throughColumn, $this->__get($this->table->getPrimary())); + $primary = $this->table->getPrimary(); + if (!is_string($primary)) { + throw new Nette\NotSupportedException('related() does not support tables with a composite primary key.'); + } + + $groupedSelection = $this->table->getReferencingTable($key, $throughColumn, $this->__get($primary)); if (!$groupedSelection) { throw new Nette\MemberAccessException("No reference found for \${$this->table->getName()}->related($key)."); } @@ -260,6 +265,15 @@ public function &__get(string $key): mixed return $referenced; } + // the column may exist but be excluded from the narrowed SELECT, e.g. when it was + // probed by isset() before a migration added it; reload all columns and retry once + if ($this->table->getPreviousAccessedColumns() && !$this->table->getSqlBuilder()->getSelect()) { + $this->accessColumn(null); + if (array_key_exists($key, $this->data)) { + return $this->data[$key]; + } + } + $this->removeAccessColumn($key); $hint = Nette\Utils\Helpers::getSuggestion(array_keys($this->data), $key); throw new Nette\MemberAccessException("Cannot read an undeclared column '$key'" . ($hint ? ", did you mean '$hint'?" : '.')); diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index b7ac2a170..82d75f85a 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -10,7 +10,6 @@ use Nette; use Nette\Database\Conventions; use Nette\Database\Explorer; -use function array_keys, count, iterator_to_array, preg_match, reset; /** @@ -164,24 +163,29 @@ protected function execute(): void $this->accessedColumns = $accessedColumns; $limit = $this->sqlBuilder->getLimit(); + $offset = $this->sqlBuilder->getOffset(); $rows = count($this->refTable->rows ?? []); if ($limit && $rows > 1) { $this->sqlBuilder->setLimit(null, null); } - parent::execute(); - $this->sqlBuilder->setLimit($limit, null); + try { + parent::execute(); + } finally { + $this->sqlBuilder->setLimit($limit, $offset); + } + $data = []; - $offset = []; + $skips = []; $this->accessColumn($this->column); foreach ((array) $this->rows as $key => $row) { $ref = &$data[$row[$this->column]]; - $skip = &$offset[$row[$this->column]]; + $skip = &$skips[$row[$this->column]]; if ( $limit === null || $rows <= 1 || (count($ref ?? []) < $limit - && $skip >= $this->sqlBuilder->getOffset()) + && $skip >= $offset) ) { $ref[$key] = $row; } else { @@ -254,6 +258,7 @@ protected function emptyResultSet(bool $clearCache = true, bool $deleteReference /** * @param iterable|Selection $data + * @return ($data is list|Selection ? int : T|array|int) */ public function insert(iterable $data): ActiveRow|array|int { @@ -261,13 +266,15 @@ public function insert(iterable $data): ActiveRow|array|int return parent::insert($data); } - $data = $data instanceof \Traversable ? iterator_to_array($data) : $data; - if (array_is_list($data)) { - foreach (array_keys($data) as $key) { - $data[$key][$this->column] = $this->active; + $data = Nette\Database\Helpers::materializeRows($data); + if (Nette\Database\Helpers::isRowList($data)) { + foreach ($data as $key => $row) { + $row = $row instanceof Nette\Database\Row ? clone $row : $row; // must not modify the caller's row + $row[$this->column] = $this->active; + $data[$key] = $row; } } else { - $data[$this->column] = $this->active; + $data[$this->column] = $this->active; // a single row (an empty one too) } return parent::insert($data); diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 76603ccd9..27e521467 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -54,7 +54,7 @@ class Selection implements \Iterator, IRowContainer, \ArrayAccess, \Countable protected ?string $generalCacheKey = null; protected ?string $specificCacheKey = null; - /** @var array> of [conditions => [group value => row]]; used by GroupedSelection */ + /** @var array> of [conditions => [group value => row]]; used by GroupedSelection */ protected array $aggregation = []; /** @var array|false|null column => selected */ @@ -154,7 +154,7 @@ public function getSql(): string */ public function getPreviousAccessedColumns(): array { - if ($this->cache && $this->previousAccessedColumns === null) { + if ($this->cache && $this->primary !== null && $this->previousAccessedColumns === null) { $this->accessedColumns = $this->previousAccessedColumns = $this->cache->load($this->getGeneralCacheKey()); $this->previousAccessedColumns ??= []; } @@ -419,6 +419,10 @@ public function limit(?int $limit, ?int $offset = null): static */ public function page(int $page, int $itemsPerPage, ?int &$numOfPages = null): static { + if ($itemsPerPage < 1) { + throw new Nette\InvalidArgumentException('Items per page must be at least 1.'); + } + if (func_num_args() > 2) { $numOfPages = (int) ceil($this->count('*') / $itemsPerPage); } @@ -680,6 +684,15 @@ protected function loadRefCache(): void } + /** + * Invalidates the cache of referencing selections after a manipulation that added rows. + */ + private function clearReferencingCache(): void + { + unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); + } + + /** * Returns general cache key independent of query parameters or SQL limit. * Used e.g. for previously accessed columns caching. @@ -724,7 +737,7 @@ protected function getSpecificCacheKey(): string */ public function accessColumn(?string $key, bool $selectColumn = true): bool { - if (!$this->cache) { + if (!$this->cache || $this->primary === null) { // narrowing needs the primary key to re-query missing columns return false; } @@ -810,31 +823,37 @@ public function getDataRefreshed(): 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|Selection $data - * @return ($data is array ? T|array : int) + * A single associative array inserts one row and returns the inserted ActiveRow; + * a list of rows or a Selection performs a bulk insert and returns the number of affected rows. + * @param iterable|iterable>|Selection $data + * @return ($data is list|Selection ? int : T|array|int) */ public function insert(iterable $data): ActiveRow|array|int { + if ($data instanceof self) { // INSERT ... SELECT identifies no row, it only reports the count + $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ' . $data->getSql(), ...$data->getSqlBuilder()->getParameters()); + $this->loadRefCache(); + unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); + return $return->getRowCount() + ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); + } + //should be called before query for not to spoil PDO::lastInsertId $primarySequenceName = $this->getPrimarySequence(); $primaryAutoincrementKey = $this->explorer->getStructure()->getPrimaryAutoincrementKey($this->name); - if ($data instanceof self) { - $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ' . $data->getSql(), ...$data->getSqlBuilder()->getParameters()); - - } else { - if ($data instanceof \Traversable) { - $data = iterator_to_array($data); - } - - $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); + // an empty array is a single row of database defaults, not a bulk insert + $data = Nette\Database\Helpers::materializeRows($data); + $bulk = Nette\Database\Helpers::isRowList($data); + if ($bulk) { + $data = array_values($data); // keys may have gaps, ?values needs a list } + $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); $this->loadRefCache(); - if ($data instanceof self || $this->primary === null) { - unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); + if ($bulk || $this->primary === null) { + $this->clearReferencingCache(); return $return->getRowCount() ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); } @@ -858,6 +877,7 @@ public function insert(iterable $data): ActiveRow|array|int } elseif (is_array($this->primary)) { foreach ($this->primary as $key) { if (!isset($data[$key])) { + $this->clearReferencingCache(); return $data; } } @@ -868,7 +888,7 @@ public function insert(iterable $data): ActiveRow|array|int // If primaryKey cannot be prepared, return inserted rows count } else { - unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); + $this->clearReferencingCache(); return $return->getRowCount() ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); } @@ -894,6 +914,27 @@ public function insert(iterable $data): ActiveRow|array|int } + /** + * Inserts multiple rows in a single query and returns the number of affected rows. + * @param iterable|Nette\Database\Row>|Selection $data + */ + public function insertMany(iterable $data): int + { + if ($data instanceof self) { + return $this->insert($data); + } + + $data = Nette\Database\Helpers::materializeRows($data); + if (!$data) { + return 0; + } elseif (!Nette\Database\Helpers::isRowList($data)) { + throw new Nette\InvalidArgumentException('insertMany() expects a list of rows or a Selection; use insert() for a single row.'); + } + + return $this->insert(array_values($data)); // the keys may have gaps, e.g. left by array_filter() + } + + /** * Updates all rows matching current conditions. JOINs in UPDATE are supported only by MySQL. * @param iterable $data diff --git a/src/Database/Table/SqlBuilder.php b/src/Database/Table/SqlBuilder.php index 6b33520be..6191439fe 100644 --- a/src/Database/Table/SqlBuilder.php +++ b/src/Database/Table/SqlBuilder.php @@ -13,7 +13,7 @@ use Nette\Database\Explorer; use Nette\Database\IStructure; use Nette\Database\SqlLiteral; -use function array_flip, array_keys, array_map, array_merge, array_pop, array_shift, array_unshift, array_values, count, end, explode, hash, implode, is_array, iterator_to_array, json_encode, key, preg_match, preg_match_all, preg_replace, preg_replace_callback, rtrim, str_contains, str_repeat, strlen, strtoupper, substr, substr_count, substr_replace, trim; +use function array_flip, array_keys, array_map, array_merge, array_pop, array_shift, array_unshift, array_values, count, end, explode, hash, implode, is_array, iterator_to_array, key, preg_match, preg_match_all, preg_replace, preg_replace_callback, rtrim, serialize, str_contains, str_repeat, strlen, strtoupper, substr, substr_count, substr_replace, trim; /** @@ -60,6 +60,9 @@ class SqlBuilder /** @var array alias => chain */ protected array $aliases = []; protected string $currentAlias = ''; + + /** distinguishes WHERE vs JOIN conditions in the dedup hash; a subclass calling addCondition() directly gets the WHERE scope */ + private string $conditionScope = ''; private readonly Driver $driver; private readonly IStructure $structure; @@ -144,7 +147,7 @@ public function getSelectQueryHash(?array $columns = null): string $parts[] = "{$this->delimitedTable}.*"; } - return $this->getConditionHash(json_encode($parts), [ + return $this->getConditionHash(serialize($parts), [ $this->parameters['select'], $this->parameters['joinCondition'], $this->parameters['where'], @@ -161,6 +164,7 @@ public function getSelectQueryHash(?array $columns = null): string */ public function buildSelectQuery(?array $columns = null): string { + $order = $this->order; if (!$this->order && ($this->limit !== null || $this->offset)) { $this->order = array_map( fn($col) => "$this->tableName.$col", @@ -168,6 +172,17 @@ public function buildSelectQuery(?array $columns = null): string ); } + try { + return $this->doBuildSelectQuery($columns); + } finally { + $this->order = $order; // the implicit ORDER BY must not persist in the builder state + } + } + + + /** @param list|null $columns */ + private function doBuildSelectQuery(?array $columns): string + { $queryJoinConditions = $this->buildJoinConditions(); $queryCondition = $this->buildConditions(); $queryEnd = $this->buildQueryEnd(); @@ -212,7 +227,11 @@ public function buildSelectQuery(?array $columns = null): string public function getParameters(): array { if (!isset($this->parameters['joinConditionSorted'])) { - $this->buildSelectQuery(); + if ($this->joinCondition) { + $this->buildSelectQuery(); + } else { + $this->parameters['joinConditionSorted'] = []; + } } return array_values(array_merge( @@ -306,7 +325,12 @@ public function addJoinCondition(string $tableChain, string|array $condition, mi $this->joinCondition[$tableChain] = $this->parameters['joinCondition'][$tableChain] = []; } - return $this->addCondition($condition, $params, $this->joinCondition[$tableChain], $this->parameters['joinCondition'][$tableChain]); + $this->conditionScope = "join:$tableChain\x00"; + try { + return $this->addCondition($condition, $params, $this->joinCondition[$tableChain], $this->parameters['joinCondition'][$tableChain]); + } finally { + $this->conditionScope = ''; + } } @@ -332,7 +356,7 @@ protected function addCondition( return $this->addConditionComposition($condition, $params[0], $conditions, $conditionsParameters); } - $hash = $this->getConditionHash($condition, $params); + $hash = $this->getConditionHash($this->conditionScope . $condition, $params); if (isset($this->conditions[$hash])) { return false; } @@ -949,7 +973,7 @@ private function getConditionHash(string $condition, array $parameters): string } } - return hash('xxh128', $condition . json_encode($parameters)); + return hash('xxh128', $condition . serialize($parameters)); // serialize() unlike json_encode() does not fail on binary strings } diff --git a/tests/Database.DI/DatabaseExtension.basic.phpt b/tests/Database.DI/DatabaseExtension.basic.phpt index f5a5bfe0d..96e5978d7 100644 --- a/tests/Database.DI/DatabaseExtension.basic.phpt +++ b/tests/Database.DI/DatabaseExtension.basic.phpt @@ -50,3 +50,28 @@ test('', function () { Assert::same($connection, $container->getService('nette.database.default')); Assert::same($explorer, $container->getService('nette.database.default.context')); }); + + +test('single connection is detected by the dsn key, not by the type of the first value', function () { + $loader = new DI\Config\Loader; + $config = $loader->load(Tester\FileMock::create(' + database: + options: + lazy: yes + dsn: "sqlite::memory:" + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'neon')); + + $compiler = new DI\Compiler; + $compiler->addExtension('database', new DatabaseExtension(false)); + eval($compiler->addConfig($config)->setClassName('Container3')->compile()); + + $container = new Container3; + $container->initialize(); + + $connection = $container->getService('database.default'); + Assert::type(Nette\Database\Connection::class, $connection); + Assert::same('sqlite::memory:', $connection->getDsn()); +}); diff --git a/tests/Database.DI/DatabaseExtension.options.phpt b/tests/Database.DI/DatabaseExtension.options.phpt new file mode 100644 index 000000000..e1baca525 --- /dev/null +++ b/tests/Database.DI/DatabaseExtension.options.phpt @@ -0,0 +1,113 @@ +load(Tester\FileMock::create($config, 'neon')); + + $compiler = new DI\Compiler; + $compiler->addExtension('database', new DatabaseExtension($debugMode)); + eval($compiler->addConfig($config)->setClassName($class)->compile()); + + $container = new $class; + $container->initialize(); + return $container; +} + + +test('PDO:: constants in options are translated to their values', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + options: + PDO::ATTR_CASE: PDO::CASE_LOWER + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt1'); + + $connection = $container->getService('database.default'); + Assert::same(PDO::CASE_LOWER, $connection->getPdo()->getAttribute(PDO::ATTR_CASE)); +}); + + +test('BC option reflection: creates the conventions service under the historical name', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + reflection: discovered + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt2'); + + Assert::true($container->hasService('database.default.reflection')); + Assert::false($container->hasService('database.default.conventions')); + Assert::type( + Nette\Database\Conventions\DiscoveredConventions::class, + $container->getService('database.default.reflection'), + ); +}); + + +test('empty conventions registers no conventions service and Explorer falls back to StaticConventions', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + conventions: "" + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt3'); + + Assert::false($container->hasService('database.default.conventions')); + $explorer = $container->getService('database.default.explorer'); + Assert::type(Nette\Database\Conventions\StaticConventions::class, $explorer->getConventions()); +}); + + +test('conventions accepts a custom class name', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + conventions: Nette\Database\Conventions\StaticConventions + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt4'); + + Assert::type( + Nette\Database\Conventions\StaticConventions::class, + $container->getService('database.default.conventions'), + ); +}); + + +test('debugger: yes wires the Tracy bar panel to the connection', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + debugger: yes + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt5', debugMode: true); + + $container->getService('database.default'); + Assert::type( + Nette\Bridges\DatabaseTracy\ConnectionPanel::class, + Tracy\Debugger::getBar()->getPanel(Nette\Bridges\DatabaseTracy\ConnectionPanel::class), + ); +}); diff --git a/tests/Database.Tracy/ConnectionPanel.phpt b/tests/Database.Tracy/ConnectionPanel.phpt index ef979c55d..78a0499c0 100644 --- a/tests/Database.Tracy/ConnectionPanel.phpt +++ b/tests/Database.Tracy/ConnectionPanel.phpt @@ -58,3 +58,45 @@ test('deprecated initialization', function () { Assert::matchFile(__DIR__ . '/tab.html', $panel->getTab()); Assert::matchFile(__DIR__ . '/panel.html', $panel->getPanel()); }); + + +test('maxQueries caps stored query details but not the count', function () { + $connection = new Connection('sqlite::memory:'); + $panel = ConnectionPanel::initialize($connection, addBarPanel: true, name: 'cap'); + $panel->maxQueries = 3; + + for ($i = 1; $i <= 5; $i++) { + $connection->query('SELECT ' . $i); + } + + $queries = (new ReflectionProperty($panel, 'queries'))->getValue($panel); + Assert::count(3, $queries); // exactly maxQueries stored + Assert::same('SELECT 1', $queries[0][1]); + Assert::same('SELECT 3', $queries[2][1]); + Assert::same(5, (new ReflectionProperty($panel, 'count'))->getValue($panel)); +}); + + +test('BlueScreen panel is registered only once for multiple connections', function () { + $blueScreen = new Tracy\BlueScreen; + $before = count((new ReflectionProperty($blueScreen, 'panels'))->getValue($blueScreen)); + + ConnectionPanel::initialize(new Connection('sqlite::memory:'), blueScreen: $blueScreen); + ConnectionPanel::initialize(new Connection('sqlite::memory:'), blueScreen: $blueScreen); + + $panels = (new ReflectionProperty($blueScreen, 'panels'))->getValue($blueScreen); + Assert::count($before + 1, $panels); +}); + + +test('disabled panel logs nothing', function () { + $connection = new Connection('sqlite::memory:'); + $panel = ConnectionPanel::initialize($connection, addBarPanel: true, name: 'off'); + $panel->disabled = true; + + $connection->query('SELECT 1'); + + Assert::same(0, (new ReflectionProperty($panel, 'count'))->getValue($panel)); + Assert::count(0, (new ReflectionProperty($panel, 'queries'))->getValue($panel)); + Assert::null($panel->getPanel()); +}); diff --git a/tests/Database.Tracy/panel.html b/tests/Database.Tracy/panel.html index 81cac1c74..858b74d5d 100644 --- a/tests/Database.Tracy/panel.html +++ b/tests/Database.Tracy/panel.html @@ -73,5 +73,4 @@

Queries: 4, time: %a% ms, foo

- diff --git a/tests/Database/Connection.exceptions.mariadb.phpt b/tests/Database/Connection.exceptions.mariadb.phpt new file mode 100644 index 000000000..649bef43c --- /dev/null +++ b/tests/Database/Connection.exceptions.mariadb.phpt @@ -0,0 +1,55 @@ +getConnection(); +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/files/{$driverName}-nette_test1.sql"); + + +test('Exception thrown for unique constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO author (id, name, web, born) VALUES (11, "", "", NULL)'), + Nette\Database\UniqueConstraintViolationException::class, + ); + + Assert::same(1062, $e->getDriverCode()); +}); + + +test('Exception thrown for not null constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO author (name, web, born) VALUES (NULL, "", NULL)'), + Nette\Database\NotNullConstraintViolationException::class, + ); + + Assert::same(1048, $e->getDriverCode()); +}); + + +test('Exception thrown for foreign key constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO book (author_id, translator_id, title) VALUES (999, 12, "")'), + Nette\Database\ForeignKeyConstraintViolationException::class, + ); + + Assert::same(1452, $e->getDriverCode()); +}); + + +test('Exception thrown for check constraint violation', function () use ($connection) { + $connection->query('CREATE TEMPORARY TABLE check_test (id int, price int CHECK (price >= 0))'); + + $e = Assert::exception( + fn() => $connection->query('INSERT INTO check_test (id, price) VALUES (1, -5)'), + Nette\Database\CheckConstraintViolationException::class, + ); + + Assert::same(4025, $e->getDriverCode()); // ER_CONSTRAINT_FAILED, differs from MySQL's 3819 +}); diff --git a/tests/Database/Connection.exceptions.sqlite.phpt b/tests/Database/Connection.exceptions.sqlite.phpt index ce94d1a37..ad40fa0c8 100644 --- a/tests/Database/Connection.exceptions.sqlite.phpt +++ b/tests/Database/Connection.exceptions.sqlite.phpt @@ -86,3 +86,17 @@ test('Exception thrown for foreign key constraint violation', function () use ($ Assert::same(19, $e->getDriverCode()); Assert::same($e->getCode(), $e->getSqlState()); }); + + +test('Exception thrown for check constraint violation', function () use ($connection) { + $connection->query('CREATE TABLE check_test (id int, price int CHECK (price >= 0))'); + + $e = Assert::exception( + fn() => $connection->query('INSERT INTO check_test (id, price) VALUES (1, -5)'), + Nette\Database\CheckConstraintViolationException::class, + '%a% CHECK constraint failed%a%', + '23000', + ); + + Assert::same(19, $e->getDriverCode()); +}); diff --git a/tests/Database/Connection.exceptions.sqlsrv.phpt b/tests/Database/Connection.exceptions.sqlsrv.phpt new file mode 100644 index 000000000..06a2dda02 --- /dev/null +++ b/tests/Database/Connection.exceptions.sqlsrv.phpt @@ -0,0 +1,57 @@ +getConnection(); +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/files/{$driverName}-nette_test1.sql"); + +$connection->query('DROP TABLE IF EXISTS exc_test'); +$connection->query('CREATE TABLE exc_test (id INT NOT NULL PRIMARY KEY, price INT CHECK (price >= 0))'); +$connection->query('INSERT INTO exc_test (id, price) VALUES (1, 1)'); + + +test('Exception thrown for unique constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO exc_test (id, price) VALUES (1, 1)'), + Nette\Database\UniqueConstraintViolationException::class, + ); + + Assert::same(2627, $e->getDriverCode()); +}); + + +test('Exception thrown for not null constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO exc_test (id, price) VALUES (NULL, 1)'), + Nette\Database\NotNullConstraintViolationException::class, + ); + + Assert::same(515, $e->getDriverCode()); +}); + + +test('Exception thrown for check constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO exc_test (id, price) VALUES (2, -5)'), + Nette\Database\CheckConstraintViolationException::class, + ); + + Assert::same(547, $e->getDriverCode()); +}); + + +test('Exception thrown for foreign key constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query("INSERT INTO book (author_id, translator_id, title) VALUES (999, 12, '')"), + Nette\Database\ForeignKeyConstraintViolationException::class, + ); + + Assert::same(547, $e->getDriverCode()); +}); diff --git a/tests/Database/Connection.transaction.phpt b/tests/Database/Connection.transaction.phpt index 2e4de1501..9eb31398d 100644 --- a/tests/Database/Connection.transaction.phpt +++ b/tests/Database/Connection.transaction.phpt @@ -110,3 +110,38 @@ test('beginTransaction(), commit() & rollBack() calls are forbidden in transacti Connection::class . '::rollBack() call is forbidden inside a transaction() callback', ); }); + + +test('failed ROLLBACK does not mask the original exception', function () use ($connection) { + Assert::exception( + fn() => $connection->transaction(function (Connection $connection) { + try { + $connection->query('ROLLBACK'); // ends the server-side transaction behind the wrapper's back + } catch (Nette\Database\DriverException $e) { + // some drivers (sqlsrv) complain right away, the transaction is gone either way + } + + throw new RuntimeException('original exception'); + }), + RuntimeException::class, + 'original exception', + ); +}); + + +test('isInTransaction() reflects both transaction() and manual control', function () use ($connection) { + Assert::false($connection->isInTransaction()); + + $connection->transaction(function (Connection $connection) { + Assert::true($connection->isInTransaction()); + $connection->transaction( + fn() => Assert::true($connection->isInTransaction()), // nested + ); + }); + Assert::false($connection->isInTransaction()); + + $connection->beginTransaction(); + Assert::true($connection->isInTransaction()); + $connection->rollBack(); + Assert::false($connection->isInTransaction()); +}); diff --git a/tests/Database/Drivers/SqlsrvDriver.applyLimit.phpt b/tests/Database/Drivers/SqlsrvDriver.applyLimit.phpt index 34668674f..274b18d54 100644 --- a/tests/Database/Drivers/SqlsrvDriver.applyLimit.phpt +++ b/tests/Database/Drivers/SqlsrvDriver.applyLimit.phpt @@ -13,7 +13,11 @@ Assert::same('SELECT 1 FROM t OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY', $query); $query = 'SELECT 1 FROM t'; $driver->applyLimit($query, 0, 20); -Assert::same('SELECT 1 FROM t OFFSET 20 ROWS FETCH NEXT 0 ROWS ONLY', $query); +Assert::same('SELECT TOP 0 1 FROM t', $query); + +$query = 'SELECT 1 FROM t'; +$driver->applyLimit($query, 0, null); +Assert::same('SELECT TOP 0 1 FROM t', $query); $query = 'SELECT 1 FROM t'; $driver->applyLimit($query, 10, 0); @@ -21,7 +25,7 @@ Assert::same('SELECT 1 FROM t OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY', $query); $query = 'SELECT 1 FROM t'; $driver->applyLimit($query, null, 20); -Assert::same('SELECT 1 FROM t OFFSET 20 ROWS FETCH NEXT 0 ROWS ONLY', $query); +Assert::same('SELECT 1 FROM t OFFSET 20 ROWS', $query); $query = 'SELECT 1 FROM t'; $driver->applyLimit($query, 10, null); diff --git a/tests/Database/Drivers/delimite.phpt b/tests/Database/Drivers/delimite.phpt new file mode 100644 index 000000000..77c6654b1 --- /dev/null +++ b/tests/Database/Drivers/delimite.phpt @@ -0,0 +1,27 @@ +delimite('hello')); + Assert::same('[a[b]', $driver->delimite('a[b')); + Assert::same('[a]]b]', $driver->delimite('a]b')); +} + +// SQLite has no escape for ] inside [...] +$sqlite = new Nette\Database\Drivers\SqliteDriver; +Assert::same('[hello]', $sqlite->delimite('hello')); +Assert::same('[a[b]', $sqlite->delimite('a[b')); +Assert::exception( + fn() => $sqlite->delimite('a]b'), + Nette\InvalidArgumentException::class, + 'Identifier must not contain the ] character.', +); diff --git a/tests/Database/Explorer.transaction.phpt b/tests/Database/Explorer.transaction.phpt index d23732cec..338a24f8c 100644 --- a/tests/Database/Explorer.transaction.phpt +++ b/tests/Database/Explorer.transaction.phpt @@ -46,3 +46,12 @@ test('commits explorer transaction successfully', function () use ($explorer) { Assert::null($explorer->fetchField('SELECT id FROM book WHERE id = ', 3)); }); + + +test('isInTransaction() mirrors the connection state', function () use ($explorer) { + Assert::false($explorer->isInTransaction()); + $explorer->transaction( + fn() => Assert::true($explorer->isInTransaction()), + ); + Assert::false($explorer->isInTransaction()); +}); diff --git a/tests/Database/Explorer/Explorer.cache.phpt b/tests/Database/Explorer/Explorer.cache.phpt index 64b863a9b..5d4d44d89 100644 --- a/tests/Database/Explorer/Explorer.cache.phpt +++ b/tests/Database/Explorer/Explorer.cache.phpt @@ -223,3 +223,65 @@ test('SQL query logging with caching', function () use ($explorer) { reformat('SELECT [id], [title], [translator_id] FROM [book] WHERE ([author_id] = ?)'), ], $sql); }); + + +test('table without primary key never narrows the select', function () use ($explorer) { + $explorer->table('note')->insert(['book_id' => 1, 'note' => 'test note']); + + $sql = []; + for ($i = 0; $i < 2; ++$i) { + $selection = $explorer->table('note'); + $sql[] = $selection->getSql(); + foreach ($selection as $row) { + $row->book_id; + if ($i > 0) { + Assert::same('test note', $row->note); // reading a column unknown to the cache must not throw + } + } + + $selection->__destruct(); + } + + Assert::same([ + reformat('SELECT * FROM [note]'), + reformat('SELECT * FROM [note]'), // never narrowed, a re-query would need the primary key + ], $sql); +}); + + +test('a column probed by isset() before it existed is readable after being added', function () use ($explorer) { + $connection = $explorer->getConnection(); + $connection->query('DROP TABLE IF EXISTS probe_test'); + $connection->query('CREATE TABLE probe_test (id INTEGER NOT NULL PRIMARY KEY, a INTEGER)'); + $connection->query('INSERT INTO probe_test (id, a) VALUES (1, 10)'); + $explorer->getStructure()->rebuild(); + + $extraValues = []; + for ($i = 0; $i < 3; ++$i) { + if ($i === 1) { + $connection->query('ALTER TABLE probe_test ADD extra INTEGER'); // "migration" + $connection->query('UPDATE probe_test SET extra = 42'); + } + + $selection = $explorer->table('probe_test'); + foreach ($selection as $row) { + $row->a; + if ($i === 0) { + isset($row->extra); // probes a column that does not exist yet + } else { + if ($i === 1) { + // isset() deliberately does not reload the narrowed row: a reload here would permanently + // disable narrowing for every legitimate isset() probe of an absent column + Assert::false(isset($row->extra)); + } + + $extraValues[] = $row->extra; // __get() reloads and heals the row, must not throw + Assert::true(isset($row->extra)); + } + } + + $selection->__destruct(); + } + + Assert::same([42, 42], $extraValues); +}); diff --git a/tests/Database/Explorer/Explorer.limit.sqlsrv.phpt b/tests/Database/Explorer/Explorer.limit.sqlsrv.phpt index 8d7756e24..531cec02c 100644 --- a/tests/Database/Explorer/Explorer.limit.sqlsrv.phpt +++ b/tests/Database/Explorer/Explorer.limit.sqlsrv.phpt @@ -38,9 +38,7 @@ Assert::same( ); Assert::same( - $version2008 - ? 'SELECT TOP 0 * FROM [author] ORDER BY [author].[id]' - : 'SELECT * FROM [author] ORDER BY [author].[id] OFFSET 0 ROWS FETCH NEXT 0 ROWS ONLY', + 'SELECT TOP 0 * FROM [author] ORDER BY [author].[id]', // FETCH NEXT 0 would be rejected by the server $explorer->table('author')->page(0, 10)->getSql(), ); @@ -70,3 +68,9 @@ if ($version2008) { ); Assert::same(2, $count); } + +// execute against the live server +Assert::count(0, $explorer->table('author')->page(0, 10)->fetchAll()); // limit 0 +if (!$version2008) { + Assert::count(1, $explorer->table('author')->order('id')->limit(null, 2)->fetchAll()); // offset without limit +} diff --git a/tests/Database/Explorer/Explorer.multi-primary-key.phpt b/tests/Database/Explorer/Explorer.multi-primary-key.phpt index 9d76650ae..204c760db 100644 --- a/tests/Database/Explorer/Explorer.multi-primary-key.phpt +++ b/tests/Database/Explorer/Explorer.multi-primary-key.phpt @@ -64,3 +64,13 @@ test('insert into multi-key table', function () use ($explorer) { $count = $explorer->table('book_tag')->where('book_id', 1)->count('*'); Assert::same(2, $count); }); + + +test('related() on a composite primary key row throws a comprehensible exception', function () use ($explorer) { + $bookTag = $explorer->table('book_tag')->where('book_id', 1)->fetch(); + Assert::exception( + fn() => $bookTag->related('whatever'), + Nette\NotSupportedException::class, + 'related() does not support tables with a composite primary key.', + ); +}); diff --git a/tests/Database/Explorer/Explorer.related().phpt b/tests/Database/Explorer/Explorer.related().phpt index 148a28cc9..e03445a00 100644 --- a/tests/Database/Explorer/Explorer.related().phpt +++ b/tests/Database/Explorer/Explorer.related().phpt @@ -97,3 +97,22 @@ test('conditional related entry fetching', function () use ($explorer) { Assert::same('JUSH', $author->related('book', null)->where('translator_id', null)->fetch()->title); }); + + +test('related() with limit and offset applies the offset for every parent row', function () use ($explorer) { + $titles = []; + foreach ($explorer->table('author')->where('id', [11, 12]) as $author) { + foreach ($author->related('book')->order('book.id')->limit(1, 1) as $book) { + $titles[] = $book->title; + } + } + + Assert::same(['JUSH', 'Dibi'], $titles); +}); + + +test('related() with limit and offset, single parent row', function () use ($explorer) { + $author = $explorer->table('author')->get(11); + $books = $author->related('book')->order('book.id')->limit(1, 1); + Assert::same(['JUSH'], array_values($books->fetchPairs(null, 'title'))); +}); diff --git a/tests/Database/Explorer/Selection.insert().multi.phpt b/tests/Database/Explorer/Selection.insert().multi.phpt index b560b268e..acf47c48d 100644 --- a/tests/Database/Explorer/Selection.insert().multi.phpt +++ b/tests/Database/Explorer/Selection.insert().multi.phpt @@ -17,7 +17,7 @@ Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverN test('', function () use ($explorer) { Assert::same(3, $explorer->table('author')->count()); - $explorer->table('author')->insert([ + $result = $explorer->table('author')->insert([ [ 'name' => 'Catelyn Stark', 'web' => 'http://example.com', @@ -29,15 +29,53 @@ test('', function () use ($explorer) { 'born' => new DateTime('2021-11-11'), ], ]); // INSERT INTO `author` (`name`, `web`, `born`) VALUES ('Catelyn Stark', 'http://example.com', '2011-11-11 00:00:00'), ('Sansa Stark', 'http://example.com', '2021-11-11 00:00:00') + Assert::same(2, $result); Assert::same(5, $explorer->table('author')->count()); $explorer->table('book_tag')->where('book_id', 1)->delete(); // DELETE FROM `book_tag` WHERE (`book_id` = ?) Assert::same(4, $explorer->table('book_tag')->count()); - $explorer->table('book')->get(1)->related('book_tag')->insert([ // SELECT * FROM `book` WHERE (`id` = ?) + $result = $explorer->table('book')->get(1)->related('book_tag')->insert([ // SELECT * FROM `book` WHERE (`id` = ?) ['tag_id' => 21], ['tag_id' => 22], ['tag_id' => 23], ]); // INSERT INTO `book_tag` (`tag_id`, `book_id`) VALUES (21, 1), (22, 1), (23, 1) + Assert::same(3, $result); Assert::same(7, $explorer->table('book_tag')->count()); }); + + +test('rows with non-sequential keys are treated as a multi-insert', function () use ($explorer) { + $rows = (function () { + yield 1 => ['name' => 'Arya Stark', 'web' => 'http://example.com']; + yield 3 => ['name' => 'Jon Snow', 'web' => 'http://example.com']; + })(); + + Assert::same(2, $explorer->table('author')->insert($rows)); + + // an array behaves the same, e.g. left over from array_filter() + Assert::same(2, $explorer->table('author')->insert([ + 0 => ['name' => 'Hodor', 'web' => 'http://example.com'], + 2 => ['name' => 'Osha', 'web' => 'http://example.com'], + ])); + + // and so does a GroupedSelection, which adds the grouping column to each row + $before = $explorer->table('book_tag')->where('book_id', 3)->count(); + Assert::same(2, $explorer->table('book')->get(3)->related('book_tag')->insert([ + 0 => ['tag_id' => 23], + 2 => ['tag_id' => 24], + ])); + Assert::same($before + 2, $explorer->table('book_tag')->where('book_id', 3)->count()); +}); + + +test('a generator yielding rows under colliding keys loses none of them', function () use ($explorer) { + $before = $explorer->table('author')->count(); + $rows = (function () { + yield from [['name' => 'Ygritte', 'web' => 'http://example.com']]; // both batches yield the key 0 + yield from [['name' => 'Gilly', 'web' => 'http://example.com']]; + })(); + + Assert::same(2, $explorer->table('author')->insert($rows)); + Assert::same($before + 2, $explorer->table('author')->count()); +}); diff --git a/tests/Database/Explorer/Selection.insert().primaryKeys.phpt b/tests/Database/Explorer/Selection.insert().primaryKeys.phpt index 85fe3ebe7..ef2a2519a 100644 --- a/tests/Database/Explorer/Selection.insert().primaryKeys.phpt +++ b/tests/Database/Explorer/Selection.insert().primaryKeys.phpt @@ -32,6 +32,23 @@ test('insert into table with simple primary index (autoincrement)', function () Assert::same('Some note here 2', $simplePkAutoincrementResult2->note); }); +test('insert with an empty array leaves all columns to their defaults', function () use ($explorer) { + $row = $explorer->table('simple_pk_autoincrement')->insert([]); + + Assert::type(Nette\Database\Table\ActiveRow::class, $row); + Assert::same(3, $row->identifier1); + Assert::null($row->note); +}); + +test('insert with an empty Traversable is a row of defaults, not an empty bulk', function () use ($explorer) { + // $form->getValues() returns an ArrayHash; an unfilled form must still insert a row + $row = $explorer->table('simple_pk_autoincrement')->insert(Nette\Utils\ArrayHash::from([])); + + Assert::type(Nette\Database\Table\ActiveRow::class, $row); + Assert::same(4, $row->identifier1); + Assert::null($row->note); +}); + test('insert into table with simple primary index (no autoincrement)', function () use ($explorer) { $simplePkNoAutoincrementResult = $explorer->table('simple_pk_no_autoincrement')->insert([ 'identifier1' => 100, diff --git a/tests/Database/Explorer/Selection.insertMany().phpt b/tests/Database/Explorer/Selection.insertMany().phpt new file mode 100644 index 000000000..be547ce38 --- /dev/null +++ b/tests/Database/Explorer/Selection.insertMany().phpt @@ -0,0 +1,120 @@ +getConnection(); + +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + + +test('inserts a list of rows and returns affected count', function () use ($explorer) { + Assert::same(3, $explorer->table('author')->count()); + $result = $explorer->table('author')->insertMany([ + ['name' => 'Catelyn Stark', 'web' => 'http://example.com', 'born' => new DateTime('2011-11-11')], + ['name' => 'Sansa Stark', 'web' => 'http://example.com', 'born' => new DateTime('2021-11-11')], + ]); + Assert::same(2, $result); + Assert::same(5, $explorer->table('author')->count()); +}); + + +test('works on a GroupedSelection (related)', function () use ($explorer) { + $explorer->table('book_tag')->where('book_id', 1)->delete(); + + Assert::same(4, $explorer->table('book_tag')->count()); + $result = $explorer->table('book')->get(1)->related('book_tag')->insertMany([ + ['tag_id' => 21], + ['tag_id' => 22], + ]); + Assert::same(2, $result); + Assert::same(6, $explorer->table('book_tag')->count()); +}); + + +test('empty list inserts nothing and returns 0', function () use ($explorer) { + $count = $explorer->table('author')->count(); + Assert::same(0, $explorer->table('author')->insertMany([])); + Assert::same($count, $explorer->table('author')->count()); +}); + + +test('rejects a single associative row', function () use ($explorer) { + $count = $explorer->table('author')->count(); + Assert::exception( + fn() => $explorer->table('author')->insertMany(['name' => 'Arya Stark']), + Nette\InvalidArgumentException::class, + ); + Assert::same($count, $explorer->table('author')->count()); // nothing was inserted +}); + + +test('accepts a generator', function () use ($explorer) { + $rows = (function () { + yield ['name' => 'Rickon Stark', 'web' => 'http://example.com']; + })(); + + Assert::same(1, $explorer->table('author')->insertMany($rows)); +}); + + +test('accepts a generator with colliding keys (yield from)', function () use ($explorer) { + $batch1 = [['name' => 'Hodor', 'web' => 'http://example.com']]; + $batch2 = [['name' => 'Osha', 'web' => 'http://example.com']]; + $rows = (function () use ($batch1, $batch2) { + yield from $batch1; // both batches yield the key 0 + yield from $batch2; + })(); + + Assert::same(2, $explorer->table('author')->insertMany($rows)); +}); + + +test('accepts rows under non-sequential keys, e.g. left by array_filter()', function () use ($explorer) { + $rows = array_filter([ + ['name' => 'Robb Stark', 'web' => 'http://example.com'], + ['name' => 'skip me', 'web' => ''], + ['name' => 'Bran Stark', 'web' => 'http://example.com'], + ], fn($row) => $row['web'] !== ''); // leaves keys 0 and 2 + + Assert::same(2, $explorer->table('author')->insertMany($rows)); +}); + + +test('a GroupedSelection assigns the group to rows under non-sequential keys too', function () use ($explorer) { + $before = $explorer->table('book_tag')->where('book_id', 3)->count(); + + Assert::same(2, $explorer->table('book')->get(3)->related('book_tag')->insertMany([ + 0 => ['tag_id' => 23], + 2 => ['tag_id' => 24], + ])); + + Assert::same($before + 2, $explorer->table('book_tag')->where('book_id', 3)->count()); +}); + + +test('accepts Row objects, which the preprocessor supports', function () use ($explorer) { + $rows = [ + Nette\Database\Row::from(['name' => 'Jon Snow', 'web' => 'http://example.com']), + Nette\Database\Row::from(['name' => 'Ygritte', 'web' => 'http://example.com']), + ]; + + Assert::same(2, $explorer->table('author')->insertMany($rows)); +}); + + +test('does not modify the caller\'s Row objects', function () use ($explorer) { + $explorer->table('book_tag')->where('book_id', 1)->delete(); + + $row = Nette\Database\Row::from(['tag_id' => 21]); + $explorer->table('book')->get(1)->related('book_tag')->insertMany([$row]); + + Assert::same(['tag_id' => 21], (array) $row); // no book_id leaked into it +}); diff --git a/tests/Database/Explorer/Selection.page().phpt b/tests/Database/Explorer/Selection.page().phpt index 858ba5bdb..4ebe6a750 100644 --- a/tests/Database/Explorer/Selection.page().phpt +++ b/tests/Database/Explorer/Selection.page().phpt @@ -60,3 +60,12 @@ test('less items than $itemsPerPage', function () use ($explorer) { $tags = $explorer->table('tag')->page(1, 100); Assert::same(4, count($tags)); //all four items from db }); + + +test('invalid items per page', function () use ($explorer) { + Assert::exception( + fn() => $explorer->table('tag')->page(1, 0), + Nette\InvalidArgumentException::class, + 'Items per page must be at least 1.', + ); +}); diff --git a/tests/Database/Explorer/SqlBuilder.addWhere().phpt b/tests/Database/Explorer/SqlBuilder.addWhere().phpt index 3c07a3756..00d8b5928 100644 --- a/tests/Database/Explorer/SqlBuilder.addWhere().phpt +++ b/tests/Database/Explorer/SqlBuilder.addWhere().phpt @@ -26,6 +26,25 @@ test('combine duplicate where conditions, ignoring repetition', function () use }); +test('conditions differing only in binary parameters are not deduplicated', function () use ($explorer) { + $sqlBuilder = new SqlBuilder('book', $explorer); + Assert::true($sqlBuilder->addWhere('title > ?', "\xC0\x01")); // non-UTF-8 values, json_encode() fails on them + Assert::true($sqlBuilder->addWhere('title > ?', "\xC0\x02")); + Assert::same(reformat('SELECT * FROM [book] WHERE ([title] > ?) AND ([title] > ?)'), $sqlBuilder->buildSelectQuery()); + Assert::count(2, $sqlBuilder->getParameters()); +}); + + +test('identical WHERE and JOIN conditions are deduplicated independently', function () use ($explorer) { + $sqlBuilder = new SqlBuilder('author', $explorer); + Assert::true($sqlBuilder->addJoinCondition(':book(translator)', ':book(translator).title ?', 'X')); + Assert::true($sqlBuilder->addWhere(':book(translator).title ?', 'X')); // same condition in WHERE must still be added + Assert::false($sqlBuilder->addWhere(':book(translator).title ?', 'X')); // duplicate WHERE + Assert::false($sqlBuilder->addJoinCondition(':book(translator)', ':book(translator).title ?', 'X')); // duplicate JOIN + Assert::count(2, $sqlBuilder->getParameters()); +}); + + test('handle named placeholders with mixed conditions', function () use ($explorer) { $sqlBuilder = new SqlBuilder('book', $explorer); $sqlBuilder->addWhere('?name ?', 'id', 3); diff --git a/tests/Database/Explorer/SqlBuilder.order.phpt b/tests/Database/Explorer/SqlBuilder.order.phpt index 4a2a06498..5666a4acf 100644 --- a/tests/Database/Explorer/SqlBuilder.order.phpt +++ b/tests/Database/Explorer/SqlBuilder.order.phpt @@ -12,6 +12,8 @@ require __DIR__ . '/../../bootstrap.php'; $explorer = connectToDB(); +Nette\Database\Helpers::loadFromFile($explorer->getConnection(), __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + test('add multiple order conditions with parameters', function () use ($explorer) { $sqlBuilder = new SqlBuilder('book', $explorer); $sqlBuilder->addOrder('id'); @@ -32,3 +34,24 @@ test('set order conditions replacing previous orders', function () use ($explore Assert::same(reformat('SELECT * FROM [book] ORDER BY FIELD([title], ?, ?)'), $sqlBuilder->buildSelectQuery()); Assert::same(['a', 'b'], $sqlBuilder->getParameters()); }); + + +test('implicit ORDER BY of a limited query does not leak into builder state', function () use ($explorer) { + $sqlBuilder = new SqlBuilder('book', $explorer); + $sqlBuilder->setLimit(5, null); + + $sql = $sqlBuilder->buildSelectQuery(); + Assert::match('%a%ORDER BY%a%', $sql); + Assert::same([], $sqlBuilder->getOrder()); // implicit order is not a builder state + Assert::same($sql, $sqlBuilder->buildSelectQuery()); // repeated build gives the same query +}); + + +test('getParameters() does not mutate the builder', function () use ($explorer) { + $sqlBuilder = new SqlBuilder('book', $explorer); + $sqlBuilder->addWhere('id > ?', 1); + $sqlBuilder->setLimit(5, null); + + Assert::same([1], $sqlBuilder->getParameters()); + Assert::same([], $sqlBuilder->getOrder()); +}); diff --git a/tests/Database/Helpers.parseColumnType.phpt b/tests/Database/Helpers.parseColumnType.phpt index 935e074ba..e3272ffe3 100644 --- a/tests/Database/Helpers.parseColumnType.phpt +++ b/tests/Database/Helpers.parseColumnType.phpt @@ -13,20 +13,20 @@ require __DIR__ . '/../bootstrap.php'; // Test basic type $result = Helpers::parseColumnType('UNSIGNED INT'); -Assert::same(['type' => 'UNSIGNED INT', 'length' => null, 'scale' => null, 'parameters' => null], $result); +Assert::same(['type' => 'UNSIGNED INT', 'size' => null, 'scale' => null, 'parameters' => null], $result); -// Test type with length +// Test type with size $result = Helpers::parseColumnType('VARCHAR(255)'); -Assert::same(['type' => 'VARCHAR', 'length' => 255, 'scale' => null, 'parameters' => null], $result); +Assert::same(['type' => 'VARCHAR', 'size' => 255, 'scale' => null, 'parameters' => null], $result); // Test type with precision and scale $result = Helpers::parseColumnType('DECIMAL(10,2)'); -Assert::same(['type' => 'DECIMAL', 'length' => 10, 'scale' => 2, 'parameters' => null], $result); +Assert::same(['type' => 'DECIMAL', 'size' => 10, 'scale' => 2, 'parameters' => null], $result); // Test type with additional parameters $result = Helpers::parseColumnType("ENUM('value1','value2')"); -Assert::same(['type' => 'ENUM', 'length' => null, 'scale' => null, 'parameters' => "'value1','value2'"], $result); +Assert::same(['type' => 'ENUM', 'size' => null, 'scale' => null, 'parameters' => "'value1','value2'"], $result); // Test omitted type $result = Helpers::parseColumnType(''); -Assert::same(['type' => null, 'length' => null, 'scale' => null, 'parameters' => null], $result); +Assert::same(['type' => null, 'size' => null, 'scale' => null, 'parameters' => null], $result); diff --git a/tests/Database/Reflection.driver.phpt b/tests/Database/Reflection.driver.phpt index 6d0c79060..ab6fd71cf 100644 --- a/tests/Database/Reflection.driver.phpt +++ b/tests/Database/Reflection.driver.phpt @@ -104,8 +104,8 @@ $expectedColumns = [ switch ($driverName) { case 'mysql': $version = $connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); - if (version_compare($version, '8.0', '>=')) { - $expectedColumns[0]['size'] = null; + if (stripos($version, 'MariaDB') === false && version_compare($version, '8.0', '>=')) { + $expectedColumns[0]['size'] = null; // MySQL 8 dropped integer display width, MariaDB keeps it } break; case 'pgsql': diff --git a/tests/Database/Reflection.foreignKeys.mysql.phpt b/tests/Database/Reflection.foreignKeys.mysql.phpt new file mode 100644 index 000000000..604d02c2b --- /dev/null +++ b/tests/Database/Reflection.foreignKeys.mysql.phpt @@ -0,0 +1,27 @@ +getConnection(); +$driver = $connection->getDriver(); + +$connection->query('SET foreign_key_checks = 0'); +$connection->query('DROP TABLE IF EXISTS fk_child, fk_parent'); +$connection->query('SET foreign_key_checks = 1'); +$connection->query('CREATE TABLE fk_parent (b INT NOT NULL, a INT NOT NULL, PRIMARY KEY (b, a))'); +$connection->query('CREATE TABLE fk_child (x INT, y INT, CONSTRAINT fk_comp FOREIGN KEY (y, x) REFERENCES fk_parent (b, a))'); + + +test('composite foreign key reports columns in constraint order', function () use ($driver) { + Assert::same([ + ['name' => 'fk_comp', 'local' => 'y', 'table' => 'fk_parent', 'foreign' => 'b'], + ['name' => 'fk_comp', 'local' => 'x', 'table' => 'fk_parent', 'foreign' => 'a'], + ], $driver->getForeignKeys('fk_child')); +}); diff --git a/tests/Database/Reflection.indexOrder.phpt b/tests/Database/Reflection.indexOrder.phpt new file mode 100644 index 000000000..09e61dc83 --- /dev/null +++ b/tests/Database/Reflection.indexOrder.phpt @@ -0,0 +1,35 @@ +getConnection(); +$driver = $connection->getDriver(); + +$connection->query('DROP TABLE IF EXISTS idx_test'); +$connection->query('CREATE TABLE idx_test (a INT NOT NULL, b INT NOT NULL)'); +$connection->query('CREATE INDEX idx_ba ON idx_test (b, a)'); + + +test('multi-column index reports columns in index order, not in table order', function () use ($driver) { + $indexes = array_column($driver->getIndexes('idx_test'), 'columns', 'name'); + Assert::same(['b', 'a'], $indexes['idx_ba']); +}); + + +test('expression index parts are reported as expressions (PostgreSQL)', function () use ($connection, $driver, $driverName) { + if ($driverName !== 'pgsql') { + return; + } + + $connection->query('DROP INDEX IF EXISTS idx_expr'); + $connection->query('CREATE INDEX idx_expr ON idx_test ((a + b), b)'); + $indexes = array_column($driver->getIndexes('idx_test'), 'columns', 'name'); + Assert::same(['(a + b)', 'b'], $indexes['idx_expr']); +}); diff --git a/tests/Database/Reflection.phpt b/tests/Database/Reflection.phpt index fe5e8a6e7..ae9c7bf94 100644 --- a/tests/Database/Reflection.phpt +++ b/tests/Database/Reflection.phpt @@ -121,8 +121,8 @@ $expectedColumns = [ switch ($driverName) { case 'mysql': $version = $connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); - if (version_compare($version, '8.0', '>=')) { - $expectedColumns['id']['size'] = null; + if (stripos($version, 'MariaDB') === false && version_compare($version, '8.0', '>=')) { + $expectedColumns['id']['size'] = null; // MySQL 8 dropped integer display width, MariaDB keeps it } break; case 'pgsql': @@ -215,3 +215,11 @@ switch ($driverName) { Assert::same([$table->getColumn('book_id')], $key->localColumns); Assert::same('book', $key->foreignTable->name); Assert::same([$key->foreignTable->getColumn('id')], $key->foreignColumns); + + +// unknown table +Assert::exception( + fn() => $reflection->getTable('unknown_table'), + InvalidArgumentException::class, + "Table 'unknown_table' not found.", +); diff --git a/tests/Database/Reflection.sqlite.autoincrement.phpt b/tests/Database/Reflection.sqlite.autoincrement.phpt new file mode 100644 index 000000000..02504a413 --- /dev/null +++ b/tests/Database/Reflection.sqlite.autoincrement.phpt @@ -0,0 +1,31 @@ +getConnection(); +$driver = $connection->getDriver(); + + +test('a column whose name is a substring of the autoincrement column is not flagged', function () use ($connection, $driver) { + $connection->query('CREATE TABLE ai_test (id INTEGER, paid INTEGER PRIMARY KEY AUTOINCREMENT)'); + + $autoincrement = array_column($driver->getColumns('ai_test'), 'autoincrement', 'name'); + Assert::false($autoincrement['id']); + Assert::true($autoincrement['paid']); +}); + + +test('a column name with regex meta-characters does not break the detection', function () use ($connection, $driver) { + $connection->query('CREATE TABLE ai_test2 ("price(usd)" TEXT, id INTEGER PRIMARY KEY AUTOINCREMENT)'); + + $autoincrement = array_column($driver->getColumns('ai_test2'), 'autoincrement', 'name'); + Assert::false($autoincrement['price(usd)']); + Assert::true($autoincrement['id']); +}); diff --git a/tests/Database/Row.phpt b/tests/Database/Row.phpt index 32c386ea1..14d5a7ae5 100644 --- a/tests/Database/Row.phpt +++ b/tests/Database/Row.phpt @@ -29,6 +29,11 @@ test('numeric field', function () use ($connection) { Assert::false(isset($row[1])); // null value Assert::false(isset($row[2])); // is not set + $falsy = $connection->fetch("SELECT 0 AS a, '' AS b"); + Assert::true(isset($falsy[0])); // falsy value is set + Assert::true(isset($falsy[1])); + Assert::false(isset($falsy[2])); + Assert::error( fn() => $row->{2}, Nette\MemberAccessException::class, @@ -43,6 +48,15 @@ test('numeric field', function () use ($connection) { }); +test('isset is not confused by a column named key', function () use ($connection) { + $row = $connection->fetch("SELECT 123 AS {$connection->getDriver()->delimite('key')}"); + Assert::true(isset($row->key)); + Assert::false(isset($row->missing)); + Assert::same('default', $row->missing ?? 'default'); + Assert::false(isset($row['missing'])); +}); + + test('named field', function () use ($connection) { $row = $connection->fetch('SELECT 123 AS title'); Assert::same(123, $row->title); diff --git a/tests/Database/SqlPreprocessor.phpt b/tests/Database/SqlPreprocessor.phpt index ad386f4c1..c62635a32 100644 --- a/tests/Database/SqlPreprocessor.phpt +++ b/tests/Database/SqlPreprocessor.phpt @@ -223,6 +223,30 @@ test('Empty WHERE conditions joined with OR', function () use ($preprocessor) { }); +test('Short-circuited WHERE discards params of preceding conditions', function () use ($preprocessor) { + [$sql, $params] = $preprocessor->process(['SELECT id FROM tbl WHERE', [ + 'a' => 1, + 'col_empty IN' => [], + ]]); + Assert::same(reformat('SELECT id FROM tbl WHERE (1=0)'), $sql); + Assert::same([], $params); + + [$sql, $params] = $preprocessor->process(['SELECT id FROM tbl WHERE ?and LIMIT ?', [ + 'a' => 1, + 'col_empty' => [], + ], 10]); + Assert::same(reformat('SELECT id FROM tbl WHERE (1=0) LIMIT ?'), $sql); + Assert::same([10], $params); + + [$sql, $params] = $preprocessor->process(['SELECT id FROM tbl WHERE ?or', [ + 'a' => 1, + 'col_empty NOT IN' => [], + ]]); + Assert::same(reformat('SELECT id FROM tbl WHERE (1=1)'), $sql); + Assert::same([], $params); +}); + + test('WHERE conditions with indexed items', function () use ($preprocessor) { [$sql, $params] = $preprocessor->process(['SELECT id FROM tbl WHERE', [ new SqlLiteral('foo'), @@ -469,11 +493,22 @@ test('?values placeholder in INSERT', function () use ($preprocessor) { }); +test('?values placeholder with an empty array inserts a row of defaults', function () use ($preprocessor) { + [$sql, $params] = $preprocessor->process(['INSERT INTO update ?values', []]); + + Assert::same(reformat([ + 'mysql' => 'INSERT INTO update () VALUES ()', // MySQL does not know DEFAULT VALUES + 'INSERT INTO update DEFAULT VALUES', + ]), $sql); + Assert::same([], $params); +}); + + test('Detects incorrect multi-insert usage', function () use ($preprocessor) { Assert::exception( fn() => $preprocessor->process(['INSERT INTO author (name) SELECT name FROM user WHERE id ?', [11, 12]]), Nette\InvalidArgumentException::class, - 'Automaticaly detected multi-insert, but values aren\'t array. If you need try to change ?mode.', + "Automatically detected multi-insert, but values aren't array. Use an explicit ?mode placeholder if needed.", ); }); diff --git a/tests/Database/connection.option.lazy.phpt b/tests/Database/connection.option.lazy.phpt index a8b48300c..021544f77 100644 --- a/tests/Database/connection.option.lazy.phpt +++ b/tests/Database/connection.option.lazy.phpt @@ -42,8 +42,19 @@ test('', function () { }); +test('isInTransaction() does not force a connect', function () { + $connection = new Nette\Database\Connection('dsn', 'user', 'password', ['lazy' => true]); + Assert::false($connection->isInTransaction()); +}); + + test('connect & disconnect', function () { $options = Tester\Environment::loadData() + ['username' => null, 'password' => null]; + if ($options['dsn'] !== 'sqlite::memory:') { + // serializes with the other tests on this DSN, whose fixtures recreate the whole database + Tester\Environment::lock($options['dsn'], getTempDir()); + } + $connections = 1; try { diff --git a/tests/Database/connection.options.sqlite.phpt b/tests/Database/connection.options.sqlite.phpt index f70bc148a..a89c6eddb 100644 --- a/tests/Database/connection.options.sqlite.phpt +++ b/tests/Database/connection.options.sqlite.phpt @@ -21,3 +21,11 @@ test('formatDateTime', function () { $driver = $connection->getDriver(); Assert::same('1978-01-23', $driver->formatDateTime(new DateTime('1978-01-23 00:00:00'))); }); + +test('unknown driverClass', function () { + Assert::exception( + fn() => new Nette\Database\Connection('sqlite::memory:', options: ['driverClass' => 'UnknownDriverClass']), + Nette\InvalidStateException::class, + "Driver class 'UnknownDriverClass' not found.", + ); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9386a542a..6f038bdb5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -29,7 +29,11 @@ function connectToDB(array $options = []): Nette\Database\Explorer $args['options'] = $options + $args['options']; if ($args['dsn'] !== 'sqlite::memory:') { + // TODO: drop once nette/tester with the fix in Environment::lock() is released + $limit = (int) ini_get('max_execution_time'); + set_time_limit(0); Tester\Environment::lock($args['dsn'], getTempDir()); + set_time_limit($limit); } $connection = new Nette\Database\Connection($args['dsn'], $args['username'], $args['password'], $args['options']); diff --git a/tests/databases.docker.ini b/tests/databases.docker.ini index a689c1054..d43a3836e 100644 --- a/tests/databases.docker.ini +++ b/tests/databases.docker.ini @@ -17,6 +17,19 @@ username = postgres password = postgres options[newDateTime] = yes +[postgresql 16] +dsn = "pgsql:host=127.0.0.1;port=5435;dbname=nette_test" +username = postgres +password = postgres +options[newDateTime] = yes + +[mariadb] +dsn = "mysql:host=127.0.0.1;port=3308;dbname=nette_test" +username = root +password = root +options[convertBoolean] = yes +options[newDateTime] = yes + [sqlsrv] dsn = "sqlsrv:Server=localhost,1434;Database=nette_test" username = SA diff --git a/tests/types/TypesTest.phpt b/tests/types/TypesTest.phpt index 1bd53e017..6bbbf6406 100644 --- a/tests/types/TypesTest.phpt +++ b/tests/types/TypesTest.phpt @@ -5,3 +5,4 @@ require __DIR__ . '/../bootstrap.php'; use Nette\PHPStan\Tester\TypeAssert; TypeAssert::assertTypes(__DIR__ . '/database-types.php'); +TypeAssert::assertNoErrors(__DIR__ . '/database-types.php'); diff --git a/tests/types/database-types.php b/tests/types/database-types.php index d553a9282..c19fb12b1 100644 --- a/tests/types/database-types.php +++ b/tests/types/database-types.php @@ -72,7 +72,7 @@ function testSelectionFluentMethods(Selection $selection): void function testParseColumnType(): void { $result = Helpers::parseColumnType('varchar(255)'); - assertType('array{type: string|null, length: int|null, scale: int|null, parameters: string|null}', $result); + assertType('array{type: string|null, size: int|null, scale: int|null, parameters: string|null}', $result); } @@ -116,3 +116,36 @@ function testResultSetFetchPairs(ResultSet $resultSet): void { assertType('array', $resultSet->fetchPairs()); } + + +/** @param Selection $selection */ +function testSelectionInsertSingleRow(Selection $selection): void +{ + // Single associative array -> inserted ActiveRow (or affected count / data for keyless tables) + $result = $selection->insert(['name' => 'Alice']); + assertType('array|int|Nette\Database\Table\ActiveRow', $result); +} + + +/** @param Selection $selection */ +function testSelectionInsertBulk(Selection $selection): void +{ + // List of rows -> bulk insert -> number of affected rows + $result = $selection->insert([ + ['name' => 'Alice'], + ['name' => 'Bob'], + ]); + assertType('int', $result); +} + + +/** + * @param Selection $selection + * @param Selection $source + */ +function testSelectionInsertFromSelection(Selection $selection, Selection $source): void +{ + // Insert from another Selection -> bulk insert -> number of affected rows + $result = $selection->insert($source); + assertType('int', $result); +}