diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..8533a6382 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(cat:*)", + "Bash(wc:*)", + "Bash(composer run phpstan:*)", + "mcp__jetbrains__list_directory_tree", + "mcp__jetbrains__get_file_text_by_path", + "Bash(git stash:*)", + "Bash(grep:*)", + "Bash(cd W:/Nette/Database/src/Bridges/DatabaseTracy && cmd //c compile.bat 2>&1)", + "Bash(cd \"W:/Nette/Database/src/Bridges/DatabaseTracy\" && cmd /c \"compile.bat\" 2>&1)", + "Bash(cd:*)" + ] + } +} 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..3467a8248 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,15 +3,17 @@ 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'] + php: ['8.3', '8.4', '8.5'] fail-fast: false @@ -49,7 +51,7 @@ jobs: - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.3 coverage: none - name: Create databases.ini diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8f79e351d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# 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.3 - 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. + 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/composer.json b/composer.json index 3b047bcbf..5f648e145 100644 --- a/composer.json +++ b/composer.json @@ -15,21 +15,24 @@ } ], "require": { - "php": "8.1 - 8.5", + "php": "8.3 - 8.5", "ext-pdo": "*", - "nette/caching": "^3.2", - "nette/utils": "^4.0" + "nette/caching": "^3.4", + "nette/utils": "^4.1" }, "require-dev": { "nette/tester": "^2.6", "nette/di": "^3.1", "mockery/mockery": "^1.6@stable", - "tracy/tracy": "^2.9", + "tracy/tracy": "^2.12", "phpstan/phpstan": "^2.1@stable", "phpstan/extension-installer": "^1.4@stable", "nette/phpstan-rules": "^1.0", "jetbrains/phpstorm-attributes": "^1.2" }, + "conflict": { + "tracy/tracy": "<2.12" + }, "autoload": { "classmap": ["src/"], "psr-4": { @@ -43,7 +46,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-master": "3.3-dev" } }, "config": { 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..b93986d13 --- /dev/null +++ b/docs/internals/connection-drivers.md @@ -0,0 +1,54 @@ +# 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 (Retryable) + ├── ConstraintViolationException + │ ├── ForeignKey / NotNull / Unique / CheckConstraintViolation + ├── DeadlockException (Retryable) + └── LockTimeoutException (Retryable) +``` + +Note `Deadlock`/`LockTimeout` extend `DriverException` **directly**, not the +constraint hierarchy, and the three retryable ones implement the `RetryableException` +marker (used by `transaction()` retries). 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..0e773f902 --- /dev/null +++ b/docs/internals/explorer.md @@ -0,0 +1,256 @@ +# 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. A subclass may declare typed properties (`public int $id`) for +IDE/PHPStan, but the constructor **`unset()`s them**, so reads fall into `__get` and +flow through `$data`. That is the only way to track which columns are actually read +(SELECT narrowing) — and the reflectable real-property type is also what enables +**enum conversion** (a `@property` annotation would not). + +- `__get($key)`: maps property→column via `EntityMapping` → `accessColumn` → returns + `$data[$column]` (with `BackedEnum` conversion by the declared type); if the column + is absent it tries a **relation**, else throws `MemberAccessException`. `__set` is + read-only (throws). `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)`: a **single associative row** returns a **lazy +`ActiveRow`** knowing only its PK (or `null` when the full PK can't be determined); a +**list or a Selection** is routed to `insertMany()`, and doing that through `insert()` +is **deprecated**. + +Lazy is the **only** mode — used whenever the **full PK is known** (single or +composite; an autoincrement part is filled from `getInsertId`). There is no +eager-fetch fallback: a PK-less table or an incomplete PK returns `null` +(+ `clearReferencingCache()`). A returned row is also registered into `rows`/`data` +if the Selection was already executed. + +`insertMany(iterable)` owns the bulk logic and always returns an int. 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` overrides both: `insert()` assigns the grouping column to a single +row, `insertMany()` to every row of the list — on a **clone** of each `Row`, never on +the caller's object. + +## 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..9bf2ccc48 --- /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()`, nesting, and retries. 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..5724d8fe1 --- /dev/null +++ b/docs/internals/transactions.md @@ -0,0 +1,32 @@ +# Transactions + +Nesting is **counter-based only** — there are **no savepoints**. + +## `transaction()` + +`transaction(callable $callback, int $attempts = 1)` runs a phase machine +(`begin`/`body`/`commit`) inside a retry loop. 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 (wrapped in try/catch, since the server may have rolled back + already); +- a **retry** happens only at the outermost level, when `$attempt < $attempts` and the + exception implements `RetryableException` (deadlock / lock timeout / connection + lost) — firing `onRetry` between attempts. + +**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. + +## 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..81d751d3d 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: 6 + 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/readme.md b/readme.md index 0457ab40f..abfd8a922 100644 --- a/readme.md +++ b/readme.md @@ -1,25 +1,26 @@ -Nette Database -============== +[![Nette Database](https://github.com/nette/database/assets/194960/97d8f31b-096c-466c-a76f-f5b9e511ea8d)](https://doc.nette.org/database) [![Downloads this Month](https://img.shields.io/packagist/dm/nette/database.svg)](https://packagist.org/packages/nette/database) -[![Tests](https://github.com/nette/database/actions/workflows/tests.yml/badge.svg?branch=v3.2)](https://github.com/nette/database/actions) +[![Tests](https://github.com/nette/database/actions/workflows/tests.yml/badge.svg?branch=v3.3)](https://github.com/nette/database/actions) [![Latest Stable Version](https://poser.pugx.org/nette/database/v/stable)](https://github.com/nette/database/releases) [![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/database/blob/master/license.md) +  Introduction ------------ Nette provides a powerful layer for accessing your database easily. -- composes SQL queries with ease -- easily fetches data -- uses efficient queries and does not transmit unnecessary data +✅ composes SQL queries with ease
+✅ significantly simplifies retrieving data without writing SQL queries
+✅ uses efficient queries and does not transmit unnecessary data -The [Nette Database Core](https://doc.nette.org/database-core) is a wrapper around the PDO and provides core functionality. +The [Nette Database Core](https://doc.nette.org/en/database/core) is a wrapper around the PDO and provides core functionality. -The [Nette Database Explorer](https://doc.nette.org/database-explorer) layer helps you to fetch database data more easily and in a more optimized way. +The [Nette Database Explorer](https://doc.nette.org/en/database/explorer) layer helps you to fetch database data more easily and in a more optimized way. +  [Support Me](https://github.com/sponsors/dg) -------------------------------------------- @@ -30,6 +31,7 @@ Do you like Nette Database? Are you looking forward to the new features? Thank you! +  Installation ------------ @@ -40,8 +42,9 @@ The recommended way to install is via Composer: composer require nette/database ``` -It requires PHP version 8.1 and supports PHP up to 8.5. +It requires PHP version 8.3 and supports PHP up to 8.5. +  Running Tests ------------- @@ -66,6 +69,7 @@ Usage This is just a piece of documentation. [Please see our website](https://doc.nette.org/database). +  Database Core ------------- @@ -89,6 +93,8 @@ $database->query('UPDATE users SET ? WHERE id=?', $data, $id); $database->query('SELECT * FROM categories WHERE id=?', 123)->dump(); ``` +  + Database Explorer ----------------- diff --git a/src/Bridges/DatabaseDI/DatabaseExtension.php b/src/Bridges/DatabaseDI/DatabaseExtension.php index 5b8560427..9e147d7da 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; /** @@ -30,6 +30,7 @@ public function getConfigSchema(): Nette\Schema\Schema Expect::structure([ 'dsn' => Expect::string()->required()->dynamic(), 'user' => Expect::string()->nullable()->dynamic(), + 'username' => Expect::string()->nullable()->dynamic(), 'password' => Expect::string()->nullable()->dynamic(), 'options' => Expect::array(), 'debugger' => Expect::bool(), @@ -37,9 +38,16 @@ public function getConfigSchema(): Nette\Schema\Schema 'reflection' => Expect::string(), // BC 'conventions' => Expect::string('discovered'), // Nette\Database\Conventions\DiscoveredConventions 'autowired' => Expect::bool(), + 'mapping' => Expect::structure([ + 'tables' => Expect::anyOf( + Expect::string()->transform(fn(string $v) => ['*' => $v]), + Expect::arrayOf('string', 'string'), + )->default([]), + 'camelCase' => Expect::bool(false), + ]), ]), - )->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]); } @@ -88,7 +96,7 @@ private function setupDatabase(\stdClass $config, string $name): void } $connection = $builder->addDefinition($this->prefix("$name.connection")) - ->setFactory(Nette\Database\Connection::class, [$config->dsn, $config->user, $config->password, $config->options]) + ->setFactory(Nette\Database\Connection::class, [$config->dsn, $config->username ?? $config->user, $config->password, $config->options]) ->setAutowired($config->autowired); $structure = $builder->addDefinition($this->prefix("$name.structure")) @@ -99,7 +107,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,20 +117,21 @@ 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]; } + $entityMapping = $config->mapping?->tables || $config->mapping?->camelCase + ? new Nette\DI\Definitions\Statement(Nette\Database\DefaultEntityMapping::class, [$config->mapping->tables, $config->mapping->camelCase]) + : null; + $builder->addDefinition($this->prefix("$name.explorer")) - ->setFactory(Nette\Database\Explorer::class, [$connection, $structure, $conventions]) + ->setFactory(Nette\Database\Explorer::class, [$connection, $structure, $conventions, null, $entityMapping]) ->setAutowired($config->autowired); $builder->addAlias($this->prefix("$name.context"), $this->prefix("$name.explorer")); diff --git a/src/Bridges/DatabaseTracy/ConnectionPanel.php b/src/Bridges/DatabaseTracy/ConnectionPanel.php index 9c1b27c2e..38db718c4 100644 --- a/src/Bridges/DatabaseTracy/ConnectionPanel.php +++ b/src/Bridges/DatabaseTracy/ConnectionPanel.php @@ -11,7 +11,6 @@ use Nette\Database\Connection; use Nette\Database\Helpers; use Tracy; -use function is_string; /** @@ -29,7 +28,6 @@ class ConnectionPanel implements Tracy\IBarPanel /** @var list, list>, ?float, ?int, ?string}> */ private array $queries = []; - private Tracy\BlueScreen $blueScreen; /** @@ -45,10 +43,15 @@ 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); + $panel = new self($connection); $panel->explain = $explain; $panel->name = $name; $bar ??= Tracy\Debugger::getBar(); @@ -59,10 +62,9 @@ public static function initialize( } - public function __construct(Connection $connection, Tracy\BlueScreen $blueScreen) + public function __construct(Connection $connection) { $connection->onQuery[] = $this->logQuery(...); - $this->blueScreen = $blueScreen; } @@ -73,31 +75,23 @@ 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()) + ? array_map(fn($row) => ['args' => []] + $row, $result->getTrace()) : debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); - foreach ($trace as $row) { - $file = $row['file'] ?? null; - if (is_string($file) - && preg_match('~\.(php.?|phtml)$~', $file) - && !$this->blueScreen->isCollapsed($file) - ) { - break; - } - - array_shift($trace); - } + $trace = array_slice($trace, Tracy\Helpers::countTransparentFrames($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()]; } @@ -170,4 +164,20 @@ public function getPanel(): ?string require __DIR__ . '/dist/panel.phtml'; }); } + + + public function getAgentInfo(): ?string + { + if (!$this->count) { + return null; + } + + return Nette\Utils\Helpers::capture(function () { + $queries = $this->queries; + $name = $this->name; + $count = $this->count; + $totalTime = $this->totalTime; + require __DIR__ . '/dist/panel.agent.phtml'; + }); + } } diff --git a/src/Bridges/DatabaseTracy/dist/panel.agent.phtml b/src/Bridges/DatabaseTracy/dist/panel.agent.phtml new file mode 100644 index 000000000..4e4f2007c --- /dev/null +++ b/src/Bridges/DatabaseTracy/dist/panel.agent.phtml @@ -0,0 +1,64 @@ + $queries */ +/** @var string $name */ +/** @var int $count */ +/** @var float $totalTime */ +if (!$count) /* pos 5:1 */ return; +echo '## Database queries'; +if ($name !== '') /* pos 6:20 */ { + echo ' ('; + echo Tracy\Helpers::escapeMd($name) /* pos 6:39 */; + echo ')'; +} +echo ' + +'; +echo Tracy\Helpers::escapeMd($count) /* pos 8:1 */; +echo ' '; +echo Tracy\Helpers::escapeMd($count === 1 ? 'query' : 'queries') /* pos 8:10 */; +if ($totalTime) /* pos 8:42 */ { + echo ', time '; + echo Tracy\Helpers::escapeMd(sprintf('%0.3f', $totalTime * 1000)) /* pos 8:64 */; + echo ' ms'; +} +echo ' + +```sql +'; +foreach ($queries as $i => [$connection, $sql, $params, $trace, $time, $rows, $error]) /* pos 11:1 */ { + if ($i > 0) /* pos 12:2 */ { + echo "\n"; + } + echo '-- '; + if ($error) /* pos 15:5 */ { + echo 'ERROR: '; + echo Tracy\Helpers::escapeMd($error) /* pos 15:23 */; + } else /* pos 15:31 */ { + echo Tracy\Helpers::escapeMd(sprintf('%0.3f', $time * 1000)) /* pos 15:37 */; + echo ' ms'; + if ($rows !== null) /* pos 15:72 */ { + echo ', '; + echo Tracy\Helpers::escapeMd($rows) /* pos 15:93 */; + echo ' row'; + if ($rows !== 1) /* pos 15:104 */ { + echo 's'; + } + } + } + echo "\n"; + echo Tracy\Helpers::escapeMd(trim($sql)) /* pos 16:2 */; + echo '; +'; + +} + +echo '``` +'; +if (count($queries) < $count) /* pos 19:1 */ { + echo ' +...and '; + echo Tracy\Helpers::escapeMd($count - count($queries)) /* pos 21:9 */; + echo ' more +'; +} 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..3e2679704 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,11 +20,14 @@ */ 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 = []; + + /** @var array Occurs before a transaction() retry */ + public array $onRetry = []; private Driver $driver; private SqlPreprocessor $preprocessor; private ?PDO $pdo = null; @@ -72,6 +75,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 +213,7 @@ public function commit(): void /** * Rolls back current transaction. * @throws \LogicException when called inside a transaction + * @throws DriverException */ public function rollBack(): void { @@ -216,34 +225,71 @@ 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. + * When $attempts > 1, a RetryableException raised during begin, commit + * or inside the callback on the outermost transaction triggers a retry + * of the whole callback. Callbacks must be idempotent. The $onRetry + * event fires before each retry and is the place to apply backoff. * @param callable(static): mixed $callback */ - public function transaction(callable $callback): mixed + public function transaction(callable $callback, int $attempts = 1): mixed { - if ($this->transactionDepth === 0) { - $this->beginTransaction(); + if ($attempts < 1) { + throw new Nette\InvalidArgumentException('Number of attempts must be at least 1.'); } - $this->transactionDepth++; - try { - $res = $callback($this); - } catch (\Throwable $e) { - $this->transactionDepth--; - if ($this->transactionDepth === 0) { - $this->rollBack(); + for ($attempt = 1; ; $attempt++) { + $phase = 'begin'; + try { + if ($this->transactionDepth === 0) { + $this->beginTransaction(); + } + + $this->transactionDepth++; + $phase = 'body'; + $res = $callback($this); + $this->transactionDepth--; + $phase = 'commit'; + if ($this->transactionDepth === 0) { + $this->commit(); + } + + return $res; + } catch (\Throwable $e) { + if ($phase === 'body') { + $this->transactionDepth--; + } + + if ($this->transactionDepth === 0 && $phase !== 'begin') { + try { + $this->rollBack(); + } catch (\Throwable) { + // server may have already rolled back (deadlock) or the + // connection may be gone; the original $e is what matters + } + } + + if ($this->transactionDepth === 0 + && $attempt < $attempts + && $e instanceof RetryableException + ) { + Arrays::invoke($this->onRetry, $this, $attempt, $e); + continue; + } + + throw $e; } - - throw $e; } - - $this->transactionDepth--; - if ($this->transactionDepth === 0) { - $this->commit(); - } - - return $res; } 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/DefaultEntityMapping.php b/src/Database/DefaultEntityMapping.php new file mode 100644 index 000000000..5c27e877e --- /dev/null +++ b/src/Database/DefaultEntityMapping.php @@ -0,0 +1,104 @@ + */ + private array $propertyCache = []; + + /** @var array */ + private array $columnCache = []; + + + /** + * @param array|string> $tables table-to-class map; keys + * may contain a single '*' wildcard (e.g. 'forum_*'), and a bare '*' acts as a catch-all + * fallback. Class names may contain '*' which is replaced with PascalCase of the captured + * portion (or the full table name for exact keys). Exact keys take precedence; wildcard + * entries are tried in declaration order. + * @param bool $camelCase whether to convert snake_case column names to camelCase properties + */ + public function __construct( + private readonly array $tables = [], + private readonly bool $camelCase = false, + ) { + } + + + public function getClassName(string $table): ?string + { + if (isset($this->tables[$table])) { + return $this->expandClass($this->tables[$table], $table); + } + + foreach ($this->tables as $pattern => $class) { + if (!str_contains($pattern, '*')) { + continue; + } + $regex = '#^' . str_replace('\*', '(.*)', preg_quote($pattern, '#')) . '$#D'; + if (preg_match($regex, $table, $m)) { + return $this->expandClass($class, $m[1]); + } + } + + return null; + } + + + /** + * Substitutes '*' in the class pattern with PascalCase of the captured name. + * @return class-string + */ + private function expandClass(string $class, string $capture): string + { + /** @var class-string $result */ + $result = str_contains($class, '*') + ? str_replace('*', self::toPascalCase($capture), $class) + : $class; + return $result; + } + + + /** + * With camelCase enabled, expects a snake_case column name (e.g. 'first_name') + * and returns its camelCase property form (e.g. 'firstName'). + */ + public function getPropertyName(string $name): string + { + return $this->camelCase + ? $this->propertyCache[$name] ??= lcfirst(self::toPascalCase($name)) + : $name; + } + + + /** + * With camelCase enabled, expects a camelCase property name (e.g. 'firstName') + * and returns its snake_case column form (e.g. 'first_name'). PascalCase + * input would produce a leading underscore (e.g. 'FirstName' → '_first_name'), + * so the first letter is expected to be lowercase. + */ + public function getColumnName(string $name): string + { + return $this->camelCase + ? $this->columnCache[$name] ??= strtolower((string) preg_replace('#[A-Z]#', '_$0', $name)) + : $name; + } + + + private static function toPascalCase(string $name): string + { + $name = preg_replace('#^.*\.#', '', $name); // strip schema prefix + return str_replace(' ', '', ucwords(strtr($name, '_', ' '))); + } +} diff --git a/src/Database/Driver.php b/src/Database/Driver.php index fdad8837b..382af4034 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* */ @@ -74,7 +75,7 @@ function getTables(): array; /** * Returns metadata for all columns in a table. - * @return list}> + * @return list}> */ function getColumns(string $table): array; diff --git a/src/Database/DriverException.php b/src/Database/DriverException.php index 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..54a4ae2c2 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' @@ -134,6 +149,7 @@ public function getColumns(string $table): array c.DATA_TYPE, c.CHARACTER_MAXIMUM_LENGTH, c.NUMERIC_PRECISION, + c.NUMERIC_SCALE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.DOMAIN_NAME, @@ -155,6 +171,7 @@ public function getColumns(string $table): array 'table' => $table, 'nativetype' => strtoupper($row['DATA_TYPE']), 'size' => $row['CHARACTER_MAXIMUM_LENGTH'] ?? $row['NUMERIC_PRECISION'], + 'scale' => (int) $row['NUMERIC_SCALE'] ?: null, 'unsigned' => false, 'nullable' => $row['IS_NULLABLE'] === 'YES', 'default' => $row['COLUMN_DEFAULT'], @@ -171,7 +188,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 +225,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 +269,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..b6390bad9 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,8 @@ public function getColumns(string $table): array 'name' => $row['field'], 'table' => $table, 'nativetype' => strtoupper($typeInfo['type'] ?? ''), - 'size' => $typeInfo['length'], + 'size' => $typeInfo['size'], + 'scale' => $typeInfo['scale'], 'nullable' => $row['null'] === 'YES', 'default' => $row['default'], 'autoincrement' => $row['extra'] === 'auto_increment', @@ -194,7 +200,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 +223,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..2b5fcebc0 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()) { @@ -165,6 +172,10 @@ public function getColumns(string $table): array WHEN t.typlen > 0 THEN t.typlen -- length for fixed-length types ELSE NULL END AS size, + CASE + WHEN a.atttypid IN (1700, 1231) THEN (a.atttypmod - 4) & 65535 + ELSE null + END AS scale, NOT (a.attnotnull OR t.typtype = 'd' AND t.typnotnull) AS nullable, pg_catalog.pg_get_expr(ad.adbin, 'pg_catalog.pg_attrdef'::regclass)::varchar AS default, coalesce(co.contype = 'p' AND (seq.relname IS NOT NULL OR strpos(pg_catalog.pg_get_expr(ad.adbin, ad.adrelid), 'nextval') = 1), FALSE) AS autoincrement, @@ -195,6 +206,7 @@ public function getColumns(string $table): array 'table' => (string) $row['table'], 'nativetype' => (string) $row['nativetype'], 'size' => $row['size'] !== null ? (int) $row['size'] : null, + 'scale' => $row['scale'] !== null ? (int) $row['scale'] : null, 'nullable' => (bool) $row['nullable'], 'default' => $row['default'], 'autoincrement' => (bool) $row['autoincrement'], @@ -216,15 +228,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 +294,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..05a77eab0 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,15 @@ 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'], + 'scale' => $typeInfo['scale'], 'nullable' => $row['notnull'] == 0, 'default' => $row['dflt_value'], 'autoincrement' => $createSql && preg_match($pattern, $createSql['sql']), @@ -176,34 +183,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..b02930e5d 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" : ''); } } @@ -138,6 +158,7 @@ public function getColumns(string $table): array WHEN c.max_length <> -1 THEN c.max_length ELSE NULL END AS size, + c.scale AS scale, c.is_nullable AS nullable, OBJECT_DEFINITION(c.default_object_id) AS [default], c.is_identity AS autoincrement, @@ -167,6 +188,7 @@ public function getColumns(string $table): array 'table' => (string) $row['table'], 'nativetype' => (string) $row['nativetype'], 'size' => $row['size'] !== null ? (int) $row['size'] : null, + 'scale' => (int) $row['scale'] ?: null, 'nullable' => (bool) $row['nullable'], 'default' => $row['default'], 'autoincrement' => (bool) $row['autoincrement'], diff --git a/src/Database/EntityMapping.php b/src/Database/EntityMapping.php new file mode 100644 index 000000000..0c8a59816 --- /dev/null +++ b/src/Database/EntityMapping.php @@ -0,0 +1,34 @@ + + */ + function getClassName(string $table): ?string; + + /** + * Translates database column name to PHP property name. + */ + function getPropertyName(string $name): string; + + /** + * Translates PHP property name to database column name. In dotted paths + * (e.g. 'book.title' in WHERE/ORDER fragments) it is invoked only on the + * last segment; preceding segments are treated as table/alias names and + * left untouched. + */ + function getColumnName(string $name): string; +} diff --git a/src/Database/Explorer.php b/src/Database/Explorer.php index f030813b4..bc79a812a 100644 --- a/src/Database/Explorer.php +++ b/src/Database/Explorer.php @@ -26,6 +26,7 @@ public function __construct( private readonly IStructure $structure, ?Conventions $conventions = null, private readonly ?Nette\Caching\Storage $cacheStorage = null, + private readonly ?EntityMapping $entityMapping = null, ) { $this->conventions = $conventions ?: new StaticConventions; } @@ -50,12 +51,25 @@ public function rollBack(): void /** - * Executes callback inside a transaction. + * 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. Supports nesting. + * When $attempts > 1, a RetryableException raised during begin, commit + * or inside the callback on the outermost transaction triggers a retry + * of the whole callback. Callbacks must be idempotent. Subscribe to + * Connection::$onRetry to plug in backoff between attempts. * @param callable(static): mixed $callback */ - public function transaction(callable $callback): mixed + public function transaction(callable $callback, int $attempts = 1): mixed { - return $this->connection->transaction(fn() => $callback($this)); + return $this->connection->transaction(fn() => $callback($this), $attempts); } @@ -114,14 +128,22 @@ public function getConventions(): Conventions } + public function getEntityMapping(): ?EntityMapping + { + return $this->entityMapping; + } + + /** - * 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 */ - public function createActiveRow(array $data, Table\Selection $selection): Table\ActiveRow + public function createActiveRow(array $data, Table\Selection $selection, bool $deferredFetch = false): Table\ActiveRow { - return new Table\ActiveRow($data, $selection); + $class = $this->entityMapping?->getClassName($selection->getName()); + $class = $class && class_exists($class) ? $class : Table\ActiveRow::class; + return new $class($data, $selection, $deferredFetch); } diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index cef1af1f8..f90ea247b 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_int, 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); } } @@ -312,6 +312,7 @@ public static function createDebugPanel( Tracy\BlueScreen $blueScreen, ): ?ConnectionPanel { + trigger_error(__METHOD__ . '() is deprecated, use Nette\Bridges\DatabaseTracy\ConnectionPanel::initialize()', E_USER_DEPRECATED); return ConnectionPanel::initialize($connection, true, $name, $explain, $bar, $blueScreen); } @@ -326,6 +327,7 @@ public static function initializeTracy( ?Tracy\BlueScreen $blueScreen = null, ): ?ConnectionPanel { + trigger_error(__METHOD__ . '() is deprecated, use Nette\Bridges\DatabaseTracy\ConnectionPanel::initialize()', E_USER_DEPRECATED); return ConnectionPanel::initialize($connection, $addBarPanel, $name, $explain, $bar, $blueScreen); } @@ -405,16 +407,77 @@ 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'); + } + + + /** + * Translates array keys from PHP property names to database column names via EntityMapping. + * Preserves integer keys and compound assignment operator suffixes (e.g. `firstName+=`). + * @param array $data + * @return array + * @internal + */ + public static function translateColumns(array $data, EntityMapping $mapping): array + { + $result = []; + foreach ($data as $key => $value) { + if (is_int($key)) { + $result[$key] = $value; + } elseif (preg_match('#^(.*?)([+\-]?=)$#D', $key, $m)) { + $result[$mapping->getColumnName($m[1]) . $m[2]] = $value; + } else { + $result[$mapping->getColumnName($key)] = $value; + } + } + return $result; + } + + /** * 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/Reflection/Column.php b/src/Database/Reflection/Column.php index 16a6dba13..6db439da6 100644 --- a/src/Database/Reflection/Column.php +++ b/src/Database/Reflection/Column.php @@ -19,6 +19,7 @@ public function __construct( public readonly ?Table $table = null, public readonly string $nativeType = '', public readonly ?int $size = null, + public readonly ?int $scale = null, public readonly bool $nullable = false, public readonly mixed $default = null, public readonly bool $autoIncrement = false, diff --git a/src/Database/Reflection/Table.php b/src/Database/Reflection/Table.php index 8dc3cc651..b24e92ac5 100644 --- a/src/Database/Reflection/Table.php +++ b/src/Database/Reflection/Table.php @@ -53,7 +53,7 @@ private function initColumns(): void { $res = []; foreach ($this->reflection->getDriver()->getColumns($this->name) as $row) { - $res[$row['name']] = new Column($row['name'], $this, $row['nativetype'], $row['size'], $row['nullable'], $row['default'], $row['autoincrement'], $row['primary'], $row['comment'] ?? null, $row['vendor']); + $res[$row['name']] = new Column($row['name'], $this, $row['nativetype'], $row['size'], $row['scale'], $row['nullable'], $row['default'], $row['autoincrement'], $row['primary'], $row['comment'] ?? null, $row['vendor']); } $this->columns = $res; } 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..12d0e3d64 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_diff_key, array_flip, array_key_exists, array_key_first, 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,15 +254,24 @@ 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])); + $cols = array_keys(iterator_to_array($groups[0])); $vals = []; - foreach ($groups as $group) { + foreach ($groups as $i => $group) { + $group = is_array($group) ? $group : iterator_to_array($group); $rowVals = []; foreach ($cols as $k) { - $rowVals[] = $this->formatValue($group[$k]); + if (!array_key_exists($k, $group)) { // the column would be silently filled with NULL + trigger_error("Missing value for column '$k' in multi-insert row #$i.", E_USER_WARNING); + } + + $rowVals[] = $this->formatValue($group[$k] ?? null); + } + + if ($extra = array_diff_key($group, array_flip($cols))) { // the column is taken from the first row only + trigger_error("Unexpected column '" . array_key_first($extra) . "' in multi-insert row #$i.", E_USER_WARNING); } $vals[] = implode(', ', $rowVals); @@ -300,6 +315,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 +332,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..94129ceca 100644 --- a/src/Database/Table/ActiveRow.php +++ b/src/Database/Table/ActiveRow.php @@ -7,307 +7,17 @@ namespace Nette\Database\Table; -use Nette; -use function array_intersect_key, array_key_exists, array_keys, implode, is_array, iterator_to_array; - /** * Represents database row with support for relations. * ActiveRow is based on the great library NotORM http://www.notorm.com written by Jakub Vrana. + * + * Must stay an empty shell over RowBehavior: any state or behavior belongs to the trait, + * so that row classes composing RowBehavior themselves behave identically to ActiveRow. + * * @implements \IteratorAggregate */ -class ActiveRow implements \IteratorAggregate, IRow +class ActiveRow implements Row, \IteratorAggregate, IRow { - private bool $dataRefreshed = false; - - - public function __construct( - /** @var array */ - private array $data, - /** @var Selection */ - private Selection $table, - ) { - } - - - /** - * @internal - * @param Selection $table - */ - public function setTable(Selection $table): void - { - $this->table = $table; - } - - - /** - * @internal - * @return Selection - */ - public function getTable(): Selection - { - return $this->table; - } - - - public function getExplorer(): Nette\Database\Explorer - { - return $this->table->getExplorer(); - } - - - public function __toString(): string - { - return (string) $this->getPrimary(); - } - - - /** @return array */ - public function toArray(): array - { - $this->accessColumn(null); - return $this->data; - } - - - /** - * Returns primary key value, or an array of values for composite primary keys. - */ - public function getPrimary(bool $throw = true): mixed - { - $primary = $this->table->getPrimary($throw); - if ($primary === null) { - return null; - - } elseif (!is_array($primary)) { - if (isset($this->data[$primary])) { - return $this->data[$primary]; - } elseif ($throw) { - throw new Nette\InvalidStateException("Row does not contain primary $primary column data."); - } else { - return null; - } - } else { - $primaryVal = []; - foreach ($primary as $key) { - if (!isset($this->data[$key])) { - if ($throw) { - throw new Nette\InvalidStateException("Row does not contain primary $key column data."); - } else { - return null; - } - } - - $primaryVal[$key] = $this->data[$key]; - } - - return $primaryVal; - } - } - - - /** - * Returns row signature (composition of primary keys). - */ - public function getSignature(bool $throw = true): string - { - return implode('|', (array) $this->getPrimary($throw)); - } - - - /** - * Returns referenced row, or null if the row does not exist. - */ - public function ref(string $key, ?string $throughColumn = null): ?self - { - $row = $this->table->getReferencedTable($this, $key, $throughColumn); - if ($row === false) { - throw new Nette\MemberAccessException("No reference found for \${$this->table->getName()}->ref($key)."); - } - - return $row; - } - - - /** - * Returns referencing rows collection. - * @return GroupedSelection - */ - public function related(string $key, ?string $throughColumn = null): GroupedSelection - { - $groupedSelection = $this->table->getReferencingTable($key, $throughColumn, $this->__get($this->table->getPrimary())); - if (!$groupedSelection) { - throw new Nette\MemberAccessException("No reference found for \${$this->table->getName()}->related($key)."); - } - - return $groupedSelection; - } - - - /** - * Updates row data and refreshes the instance from database. Returns true if the row was changed. - * @param iterable $data - */ - public function update(iterable $data): bool - { - if ($data instanceof \Traversable) { - $data = iterator_to_array($data); - } - - $primary = $this->getPrimary(); - if (!is_array($primary)) { - $primary = [$this->table->getPrimary() => $primary]; - } - - $selection = $this->table->createSelectionInstance() - ->wherePrimary($primary); - - if ($selection->update($data)) { - if ($tmp = array_intersect_key($data, $primary)) { - $selection = $this->table->createSelectionInstance() - ->wherePrimary($tmp + $primary); - } - - $selection->select('*'); - if (($row = $selection->fetch()) === null) { - throw new Nette\InvalidStateException('Database refetch failed; row does not exist!'); - } - - $this->data = $row->data; - return true; - } else { - return false; - } - } - - - /** - * Deletes the row from database. - * @return int number of affected rows - */ - public function delete(): int - { - $res = $this->table->createSelectionInstance() - ->wherePrimary($this->getPrimary()) - ->delete(); - - if ($res > 0 && ($signature = $this->getSignature(throw: false))) { - unset($this->table[$signature]); - } - - return $res; - } - - - /********************* interface IteratorAggregate ****************d*g**/ - - - /** @return \ArrayIterator */ - public function getIterator(): \Iterator - { - $this->accessColumn(null); - return new \ArrayIterator($this->data); - } - - - /********************* interface ArrayAccess & magic accessors ****************d*g**/ - - - public function offsetSet($column, $value): void - { - $this->__set($column, $value); - } - - - public function offsetGet($column): mixed - { - return $this->__get($column); - } - - - public function offsetExists($column): bool - { - return $this->__isset($column); - } - - - public function offsetUnset($column): void - { - $this->__unset($column); - } - - - public function __set(string $column, mixed $value): void - { - throw new Nette\DeprecatedException('ActiveRow is read-only; use update() method instead.'); - } - - - /** - * Returns column value, or a referenced row if the key matches a relationship. - * @return ActiveRow|mixed - * @throws Nette\MemberAccessException if the column does not exist and no relationship is found - */ - public function &__get(string $key): mixed - { - if ($this->accessColumn($key)) { - return $this->data[$key]; - } - - $referenced = $this->table->getReferencedTable($this, $key); - if ($referenced !== false) { - $this->accessColumn($key, selectColumn: false); - return $referenced; - } - - $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'?" : '.')); - } - - - public function __isset(string $key): bool - { - if ($this->accessColumn($key)) { - return isset($this->data[$key]); - } - - $referenced = $this->table->getReferencedTable($this, $key); - if ($referenced !== false) { - $this->accessColumn($key, selectColumn: false); - return (bool) $referenced; - } - - $this->removeAccessColumn($key); - return false; - } - - - public function __unset(string $key): void - { - throw new Nette\DeprecatedException('ActiveRow is read-only.'); - } - - - /** @internal */ - public function accessColumn(?string $key, bool $selectColumn = true): bool - { - if ($this->table->accessColumn($key, $selectColumn) && !$this->dataRefreshed) { - if (!isset($this->table[$this->getSignature()])) { - throw new Nette\InvalidStateException("Database refetch failed; row with signature '{$this->getSignature()}' does not exist!"); - } - - $this->data = $this->table[$this->getSignature()]->data; - $this->dataRefreshed = true; - } - - $key ??= ''; - return isset($this->data[$key]) || array_key_exists($key, $this->data); - } - - - protected function removeAccessColumn(string $key): void - { - $this->table->removeAccessColumn($key); - } + use RowBehavior; } diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index b7ac2a170..fad10236a 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -10,7 +10,7 @@ use Nette; use Nette\Database\Conventions; use Nette\Database\Explorer; -use function array_keys, count, iterator_to_array, preg_match, reset; +use function count, preg_match, reset; /** @@ -136,7 +136,7 @@ public function aggregation(string $function, ?string $groupFunction = null): mi } } - return 0; + return null; // the group has no rows, which is what the aggregate function would return for them } @@ -164,24 +164,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,23 +259,39 @@ protected function emptyResultSet(bool $clearCache = true, bool $deleteReference /** * @param iterable|Selection $data + * @return ($data is non-empty-list|Selection ? int : T|null) */ - public function insert(iterable $data): ActiveRow|array|int + public function insert(iterable $data): ActiveRow|int|null { - if ($data instanceof Selection) { - return parent::insert($data); + if (!$data instanceof Selection) { + $data = Nette\Database\Helpers::materializeRows($data); + if (!Nette\Database\Helpers::isRowList($data)) { + $data[$this->column] = $this->active; // single row (an empty one too): assign to the referencing group + } } - $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; + // bulk (list / Selection) is routed to insertMany() by parent, which assigns the group per row + return parent::insert($data); + } + + + /** + * @param iterable|Nette\Database\Row>|Selection $data + */ + public function insertMany(iterable $data): int + { + if (!$data instanceof Selection) { + $data = Nette\Database\Helpers::materializeRows($data); + if (Nette\Database\Helpers::isRowList($data)) { // anything else is left to parent to reject + 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; } - return parent::insert($data); + return parent::insertMany($data); } diff --git a/src/Database/Table/Row.php b/src/Database/Table/Row.php new file mode 100644 index 000000000..f7e7a1986 --- /dev/null +++ b/src/Database/Table/Row.php @@ -0,0 +1,74 @@ + $table + */ + function setTable(Selection $table): void; + + /** + * @internal + * @return Selection + */ + function getTable(): Selection; + + function getExplorer(): Nette\Database\Explorer; + + /** @return array */ + function toArray(): array; + + /** + * Returns primary key value, or an array of values for composite primary keys. + */ + function getPrimary(bool $throw = true): mixed; + + /** + * Returns row signature (composition of primary keys). + */ + function getSignature(bool $throw = true): string; + + /** + * Returns referenced row, or null if the row does not exist. + */ + function ref(string $key, ?string $throughColumn = null): ?ActiveRow; + + /** + * Returns referencing rows collection. + * @return GroupedSelection + */ + function related(string $key, ?string $throughColumn = null): GroupedSelection; + + /** + * Updates row data and refreshes the instance from database. Returns true if the row was changed. + * @param iterable $data + */ + function update(iterable $data): bool; + + /** + * Deletes the row from database. + * @return int number of affected rows + */ + function delete(): int; + + /** @internal */ + function accessColumn(?string $key, bool $selectColumn = true): bool; +} diff --git a/src/Database/Table/RowBehavior.php b/src/Database/Table/RowBehavior.php new file mode 100644 index 000000000..8736ed12c --- /dev/null +++ b/src/Database/Table/RowBehavior.php @@ -0,0 +1,436 @@ +> */ + private static array $declaredProperties = []; + + /** @var array>> */ + private static array $enumProperties = []; + + private bool $dataRefreshed = false; + private readonly ?Nette\Database\EntityMapping $entityMapping; + + + public function __construct( + /** @var array */ + private array $data, + /** @var Selection */ + private Selection $table, + // when true, the row holds only its primary key and fetches the rest on first access + private bool $deferredFetch = false, + ) { + $this->entityMapping = $table->getExplorer()->getEntityMapping(); + foreach (self::declaredProperties(static::class) as $name) { + unset($this->$name); + } + } + + + /** + * Returns names of declared public non-static properties of the row class, so they can be unset to fall through to __get. + * @param class-string $class + * @return list + */ + private static function declaredProperties(string $class): array + { + if (isset(self::$declaredProperties[$class])) { + return self::$declaredProperties[$class]; + } + $result = []; + foreach ((new \ReflectionClass($class))->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { + if (!$prop->isStatic()) { + $result[] = $prop->getName(); + } + } + return self::$declaredProperties[$class] = $result; + } + + + /** + * Returns map of property name to BackedEnum class for typed properties declared on the row class. + * @param class-string $class + * @return array> + */ + private static function enumProperties(string $class): array + { + if (isset(self::$enumProperties[$class])) { + return self::$enumProperties[$class]; + } + $result = []; + foreach ((new \ReflectionClass($class))->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { + $type = $prop->getType(); + if (!$prop->isStatic() + && $type instanceof \ReflectionNamedType + && !$type->isBuiltin() + && is_subclass_of($type->getName(), \BackedEnum::class) + ) { + $result[$prop->getName()] = $type->getName(); + } + } + return self::$enumProperties[$class] = $result; + } + + + /** + * @internal + * @param Selection $table + */ + public function setTable(Selection $table): void + { + $this->table = $table; + } + + + /** + * @internal + * @return Selection + */ + public function getTable(): Selection + { + return $this->table; + } + + + public function getExplorer(): Nette\Database\Explorer + { + return $this->table->getExplorer(); + } + + + public function __toString(): string + { + return (string) $this->getPrimary(); + } + + + /** @return array */ + public function toArray(): array + { + $this->accessColumn(null); + $entityMapping = $this->entityMapping; + $enums = self::enumProperties(static::class); + if (!$entityMapping && !$enums) { + return $this->data; + } + $result = []; + foreach ($this->data as $key => $value) { + $propName = $entityMapping ? $entityMapping->getPropertyName($key) : $key; + if ($value !== null && isset($enums[$propName])) { + $value = $enums[$propName]::from($value); + } + $result[$propName] = $value; + } + return $result; + } + + + /** + * Returns primary key value, or an array of values for composite primary keys. + * Composite key arrays are keyed by database column names (unlike toArray(), + * which uses property names) so the result can be passed directly to + * Selection::wherePrimary(). + */ + public function getPrimary(bool $throw = true): mixed + { + $primary = $this->table->getPrimary($throw); + if ($primary === null) { + return null; + + } elseif (!is_array($primary)) { + if (isset($this->data[$primary])) { + return $this->data[$primary]; + } elseif ($throw) { + throw new Nette\InvalidStateException("Row does not contain primary $primary column data."); + } else { + return null; + } + } else { + $primaryVal = []; + foreach ($primary as $key) { + if (!isset($this->data[$key])) { + if ($throw) { + throw new Nette\InvalidStateException("Row does not contain primary $key column data."); + } else { + return null; + } + } + + $primaryVal[$key] = $this->data[$key]; + } + + return $primaryVal; + } + } + + + /** + * Returns row signature (composition of primary keys). + */ + public function getSignature(bool $throw = true): string + { + return implode('|', (array) $this->getPrimary($throw)); + } + + + /** + * Returns referenced row, or null if the row does not exist. + */ + public function ref(string $key, ?string $throughColumn = null): ?ActiveRow + { + $row = $this->table->getReferencedTable($this, $key, $throughColumn); + if ($row === false) { + throw new Nette\MemberAccessException("No reference found for \${$this->table->getName()}->ref($key)."); + } + + return $row; + } + + + /** + * Returns referencing rows collection. + * @return GroupedSelection + */ + public function related(string $key, ?string $throughColumn = null): GroupedSelection + { + $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)."); + } + + return $groupedSelection; + } + + + /** + * Updates row data and refreshes the instance from database. Returns true if the row was changed. + * @param iterable $data + */ + public function update(iterable $data): bool + { + $data = iterator_to_array($data); + + $primary = $this->getPrimary(); + if (!is_array($primary)) { + $primary = [$this->table->getPrimary() => $primary]; + } + + $selection = $this->table->createSelectionInstance() + ->wherePrimary($primary); + + if ($selection->update($data)) { + $columnData = $this->entityMapping + ? Nette\Database\Helpers::translateColumns($data, $this->entityMapping) + : $data; + if ($tmp = array_intersect_key($columnData, $primary)) { + $selection = $this->table->createSelectionInstance() + ->wherePrimary($tmp + $primary); + } + + $selection->select('*'); + if (($row = $selection->fetch()) === null) { + throw new Nette\InvalidStateException('Database refetch failed; row does not exist!'); + } + + $this->data = $row->data; + return true; + } else { + return false; + } + } + + + /** + * Deletes the row from database. + * @return int number of affected rows + */ + public function delete(): int + { + $res = $this->table->createSelectionInstance() + ->wherePrimary($this->getPrimary()) + ->delete(); + + if ($res > 0 && ($signature = $this->getSignature(throw: false))) { + unset($this->table[$signature]); + } + + return $res; + } + + + /********************* interface IteratorAggregate ****************d*g**/ + + + /** @return \ArrayIterator */ + public function getIterator(): \Iterator + { + return new \ArrayIterator($this->toArray()); + } + + + /********************* interface ArrayAccess & magic accessors ****************d*g**/ + + + public function offsetSet($column, $value): void + { + $this->__set($column, $value); + } + + + public function offsetGet($column): mixed + { + return $this->__get($column); + } + + + public function offsetExists($column): bool + { + return $this->__isset($column); + } + + + public function offsetUnset($column): void + { + $this->__unset($column); + } + + + public function __set(string $column, mixed $value): void + { + throw new Nette\DeprecatedException('ActiveRow is read-only; use update() method instead.'); + } + + + /** + * Returns column value, or a referenced row if the key matches a relationship. + * @return ActiveRow|mixed + * @throws Nette\MemberAccessException if the column does not exist and no relationship is found + */ + public function &__get(string $key): mixed + { + $column = $this->entityMapping?->getColumnName($key) ?? $key; + + if ($this->accessColumn($column)) { + $enums = self::enumProperties(static::class); + if ($this->data[$column] !== null && isset($enums[$key])) { + $value = $enums[$key]::from($this->data[$column]); + return $value; + } + return $this->data[$column]; + } + + $referenced = $this->table->getReferencedTable($this, $key); + if ($referenced !== false) { + $this->accessColumn($key, selectColumn: false); + 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($column, $this->data)) { + return $this->data[$column]; + } + } + + $this->removeAccessColumn($column); + $available = $this->entityMapping + ? array_map(fn(string $col) => $this->entityMapping->getPropertyName($col), array_keys($this->data)) + : array_keys($this->data); + $hint = Nette\Utils\Helpers::getSuggestion($available, $key); + throw new Nette\MemberAccessException("Cannot read an undeclared column '$key'" . ($hint ? ", did you mean '$hint'?" : '.')); + } + + + public function __isset(string $key): bool + { + $column = $this->entityMapping?->getColumnName($key) ?? $key; + + if ($this->accessColumn($column)) { + return isset($this->data[$column]); + } + + $referenced = $this->table->getReferencedTable($this, $key); + if ($referenced !== false) { + $this->accessColumn($key, selectColumn: false); + return (bool) $referenced; + } + + $this->removeAccessColumn($column); + return false; + } + + + public function __unset(string $key): void + { + throw new Nette\DeprecatedException('ActiveRow is read-only.'); + } + + + /** @internal */ + public function accessColumn(?string $key, bool $selectColumn = true): bool + { + if ($this->deferredFetch && ($key === null || !array_key_exists($key, $this->data))) { + $this->completeData(); + } + + if ($this->table->accessColumn($key, $selectColumn) && !$this->dataRefreshed) { + if (!isset($this->table[$this->getSignature()])) { + throw new Nette\InvalidStateException("Database refetch failed; row with signature '{$this->getSignature()}' does not exist!"); + } + + $this->data = $this->table[$this->getSignature()]->data; + $this->dataRefreshed = true; + } + + $key ??= ''; + return isset($this->data[$key]) || array_key_exists($key, $this->data); + } + + + protected function removeAccessColumn(string $key): void + { + $this->table->removeAccessColumn($key); + } + + + /** + * Loads the full row by primary key. Used by rows returned from insert(), which initially + * hold only the primary key, to fetch the remaining columns on first access. + */ + private function completeData(): void + { + $this->deferredFetch = false; + $full = $this->table->fetch() + ?? throw new Nette\ShouldNotHappenException("Database refetch failed; inserted row with signature '{$this->getSignature()}' no longer exists!"); + $this->data = $full->data; + + // become the canonical row of the selection, so later table-level operations (e.g. referenced-table + // resolution after update()) see this instance and its data instead of the just-fetched duplicate + if (($signature = $this->getSignature(false)) !== '') { + $this->table[$signature] = $this; + } + } +} diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 76603ccd9..94677fbf9 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -10,7 +10,7 @@ use Nette; use Nette\Database\Conventions; use Nette\Database\Explorer; -use function array_filter, array_intersect_key, array_keys, array_map, array_merge, array_values, ceil, count, current, explode, func_num_args, hash, implode, is_array, is_int, iterator_to_array, key, next, reset, serialize, str_contains, substr_count; +use function array_filter, array_intersect_key, array_keys, array_map, array_merge, array_values, ceil, count, current, explode, func_num_args, hash, implode, is_array, is_int, is_string, iterator_to_array, key, next, reset, serialize, str_contains, substr_count; /** @@ -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; } @@ -809,76 +822,82 @@ 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) + * Inserts a single row into the table and returns the inserted ActiveRow, or null when the + * inserted row cannot be identified by its primary key. The returned row is loaded lazily: + * it initially holds only the primary key and fetches the remaining columns on first access. + * Passing a list of rows or a Selection is deprecated, use insertMany() instead. + * @param iterable|list>|Selection $data + * @return ($data is non-empty-list|Selection ? int : T|null) */ - public function insert(iterable $data): ActiveRow|array|int + public function insert(iterable $data): ActiveRow|int|null { + if ($data instanceof self) { + trigger_error(__METHOD__ . '() with a Selection is deprecated, use insertMany() instead.', E_USER_DEPRECATED); + return $this->insertMany($data); + } + + // an empty array is a single row of database defaults, not a bulk insert + $data = Nette\Database\Helpers::materializeRows($data); + if (Nette\Database\Helpers::isRowList($data)) { + trigger_error(__METHOD__ . '() with a list of rows is deprecated, use insertMany() instead.', E_USER_DEPRECATED); + return $this->insertMany($data); + } + //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); + if ($mapping = $this->explorer->getEntityMapping()) { + $data = Nette\Database\Helpers::translateColumns($data, $mapping); } + $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()]); - return $return->getRowCount() - ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); + if ($this->primary === null) { + $this->clearReferencingCache(); + return null; // a table without a primary key has no identifiable row } + // collect the primary key of the inserted row to load it back as an ActiveRow + $primaryColumns = (array) $this->primary; $primaryKey = []; - foreach ((array) $this->primary as $key) { - if (isset($data[$key])) { - $primaryKey[$key] = $data[$key]; + foreach ($primaryColumns as $column) { + if (isset($data[$column])) { + $primaryKey[$column] = $data[$column]; } } - // First check sequence - if (!empty($primarySequenceName) && $primaryAutoincrementKey) { - $primaryKey[$primaryAutoincrementKey] = $this->explorer->getInsertId($this->explorer->getConnection()->getDriver()->delimite($primarySequenceName)); + if ($primaryAutoincrementKey) { + $primaryKey[$primaryAutoincrementKey] = $this->explorer->getInsertId($primarySequenceName + ? $this->explorer->getConnection()->getDriver()->delimite($primarySequenceName) + : $primarySequenceName); + } - // Autoincrement primary without sequence - } elseif ($primaryAutoincrementKey) { - $primaryKey[$primaryAutoincrementKey] = $this->explorer->getInsertId($primarySequenceName); + if (count($primaryKey) !== count($primaryColumns)) { + $this->clearReferencingCache(); + return null; // the inserted row cannot be identified by its primary key + } - // Multi column primary without autoincrement - } elseif (is_array($this->primary)) { - foreach ($this->primary as $key) { - if (!isset($data[$key])) { - return $data; + // return a lazy row holding only the primary key and fetching the remaining columns on first access + $selection = $this->createSelectionInstance($this->name) + ->select('*') + ->wherePrimary($primaryKey); + + // normalize numeric strings to int for integer columns, to match what a fetch would return + $columns = null; + foreach ($primaryKey as $key => $value) { + if (is_string($value) && (string) (int) $value === $value) { + $columns ??= array_column($this->explorer->getStructure()->getColumns($this->name), 'nativetype', 'name'); + if (Nette\Database\Helpers::detectType($columns[$key] ?? '') === Nette\Database\IStructure::FIELD_INTEGER) { + $primaryKey[$key] = (int) $value; } } - - // Primary without autoincrement, try get primary from inserting data - } elseif ($this->primary && isset($data[$this->primary])) { - $primaryKey = $data[$this->primary]; - - // If primaryKey cannot be prepared, return inserted rows count - } else { - unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); - return $return->getRowCount() - ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); } /** @phpstan-var T $row */ - $row = $this->createSelectionInstance() - ->select('*') - ->wherePrimary($primaryKey) - ->fetch() - ?? throw new Nette\ShouldNotHappenException; + $row = $this->explorer->createActiveRow($primaryKey, $selection, deferredFetch: true); if ($this->rows !== null) { if ($signature = $row->getSignature(false)) { @@ -894,6 +913,38 @@ 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->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ' . $data->getSql(), ...$data->getSqlBuilder()->getParameters()); + + } else { + $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.'); + } + + $data = array_values($data); // the keys may have gaps, e.g. left by array_filter() + if ($mapping = $this->explorer->getEntityMapping()) { + $data = array_map(fn($row) => Nette\Database\Helpers::translateColumns(iterator_to_array($row), $mapping), $data); + } + + $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); + } + + $this->loadRefCache(); + $this->clearReferencingCache(); + return $return->getRowCount() + ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); + } + + /** * Updates all rows matching current conditions. JOINs in UPDATE are supported only by MySQL. * @param iterable $data @@ -901,14 +952,15 @@ public function insert(iterable $data): ActiveRow|array|int */ public function update(iterable $data): int { - if ($data instanceof \Traversable) { - $data = iterator_to_array($data); - } - + $data = iterator_to_array($data); if (!$data) { return 0; } + if ($mapping = $this->explorer->getEntityMapping()) { + $data = Nette\Database\Helpers::translateColumns($data, $mapping); + } + return $this->explorer ->query($this->sqlBuilder->buildUpdateQuery(), ...array_merge([$data], $this->sqlBuilder->getParameters())) ->getRowCount() @@ -1007,6 +1059,7 @@ public function getReferencingTable( /** @var ?GroupedSelection $prototype */ $prototype = &$this->refCache['referencingPrototype'][$this->getSpecificCacheKey()]["$table.$column"]; if (!$prototype) { + $this->execute(); // the selection may not be executed yet, e.g. when the row comes lazily from insert() $prototype = $this->createGroupedSelectionInstance($table, $column); $prototype->where("$table.$column", array_keys((array) $this->rows)); $prototype->getSpecificCacheKey(); diff --git a/src/Database/Table/SqlBuilder.php b/src/Database/Table/SqlBuilder.php index 6b33520be..d55a251bc 100644 --- a/src/Database/Table/SqlBuilder.php +++ b/src/Database/Table/SqlBuilder.php @@ -10,10 +10,11 @@ use Nette; use Nette\Database\Conventions; use Nette\Database\Driver; +use Nette\Database\EntityMapping; 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,8 +61,12 @@ 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; + private readonly ?EntityMapping $entityMapping; /** @var array table fullName => exists */ private array $cacheTableList = []; @@ -76,6 +81,7 @@ public function __construct(string $tableName, Explorer $explorer) $this->driver = $explorer->getConnection()->getDriver(); $this->conventions = $explorer->getConventions(); $this->structure = $explorer->getStructure(); + $this->entityMapping = $explorer->getEntityMapping(); $tableNameParts = explode('.', $tableName); $this->delimitedTable = implode('.', array_map($this->driver->delimite(...), $tableNameParts)); $this->checkUniqueTableName(end($tableNameParts), $tableName); @@ -144,7 +150,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 +167,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 +175,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 +230,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 +328,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 +359,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; } @@ -895,14 +922,40 @@ protected function buildQueryEnd(): string /** * Delimits lowercase identifiers in a SQL fragment while leaving uppercase keywords untouched. + * String literals are not supported - values belong in parameters - and warn when present. */ protected function tryDelimite(string $s): string { + if (str_contains($s, "'")) { + // the literal is not recognized and its content gets delimited as if it were an identifier + trigger_error("SQL string literals are not supported here, pass the value as a parameter instead: $s", E_USER_WARNING); + } + + if (!$this->entityMapping) { + return preg_replace_callback( + '#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?=[^\w`"(\]]|$)#Di', + fn(array $m): string => strtoupper($m[0]) === $m[0] + ? $m[0] + : $this->driver->delimite($m[0]), + $s, + ); + } + return preg_replace_callback( - '#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?=[^\w`"(\]]|$)#Di', - fn(array $m): string => strtoupper($m[0]) === $m[0] - ? $m[0] - : $this->driver->delimite($m[0]), + '#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)*(?=[^\w`"(\]]|$)#Di', + function (array $m): string { + $parts = explode('.', $m[0]); + $last = count($parts) - 1; + foreach ($parts as $i => &$part) { + if (strtoupper($part) !== $part) { + if ($i === $last) { + $part = $this->entityMapping->getColumnName($part); + } + $part = $this->driver->delimite($part); + } + } + return implode('.', $parts); + }, $s, ); } @@ -949,7 +1002,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/src/Database/exceptions.php b/src/Database/exceptions.php index 800130046..aa0a0c905 100644 --- a/src/Database/exceptions.php +++ b/src/Database/exceptions.php @@ -8,6 +8,16 @@ namespace Nette\Database; +/** + * Marks transient exceptions that are safe to retry, such as deadlocks, + * lock timeouts, or lost connections. Connection::transaction() retries + * the callback automatically when $attempts > 1. + */ +interface RetryableException +{ +} + + /** * Failed to connect to the database server. */ @@ -21,7 +31,7 @@ class ConnectionException extends DriverException * restart, network failure, idle-timeout). A reconnect is needed before * the connection can be used again. */ -class ConnectionLostException extends ConnectionException +class ConnectionLostException extends ConnectionException implements RetryableException { } @@ -70,7 +80,7 @@ class CheckConstraintViolationException extends ConstraintViolationException * Deadlock or serialization failure detected by the server; the transaction * was rolled back and can be retried. */ -class DeadlockException extends DriverException +class DeadlockException extends DriverException implements RetryableException { } @@ -79,6 +89,6 @@ class DeadlockException extends DriverException * A lock wait exceeded the configured timeout. The statement was aborted, * typically leaving the surrounding transaction alive. */ -class LockTimeoutException extends DriverException +class LockTimeoutException extends DriverException implements RetryableException { } diff --git a/tests/Database.DI/ActiveRow.enumProperties.phpt b/tests/Database.DI/ActiveRow.enumProperties.phpt new file mode 100644 index 000000000..4266bf44e --- /dev/null +++ b/tests/Database.DI/ActiveRow.enumProperties.phpt @@ -0,0 +1,110 @@ +query('CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL, + status TEXT NOT NULL, + role INTEGER NULL + )'); + $connection->query("INSERT INTO users (email, status, role) VALUES ('a@x.com', 'active', 1)"); + $connection->query("INSERT INTO users (email, status, role) VALUES ('b@x.com', 'suspended', NULL)"); + + $storage = new MemoryStorage; + $structure = new Structure($connection, $storage); + $conventions = new DiscoveredConventions($structure); + $mapping = new DefaultEntityMapping(['users' => UserRow::class]); + return new Explorer($connection, $structure, $conventions, $storage, $mapping); +} + + +test('BackedEnum column is converted on __get', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + + Assert::same(UserStatus::Active, $row->status); + Assert::same(Role::Admin, $row->role); +}); + + +test('nullable enum property handles NULL', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(2); + + Assert::same(UserStatus::Suspended, $row->status); + Assert::null($row->role); +}); + + +test('non-enum typed property returns scalar unchanged', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + + Assert::same(1, $row->id); + Assert::same('a@x.com', $row->email); +}); + + +test('toArray and iterator return converted enum values', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + + $arr = $row->toArray(); + Assert::same(UserStatus::Active, $arr['status']); + Assert::same(Role::Admin, $arr['role']); + + $iter = iterator_to_array($row); + Assert::same(UserStatus::Active, $iter['status']); + Assert::same(Role::Admin, $iter['role']); +}); + + +test('update accepts enum values, refetched row exposes converted enum', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + $row->update(['status' => UserStatus::Suspended]); + + Assert::same(UserStatus::Suspended, $row->status); + + $fresh = $explorer->table('users')->get(1); + Assert::same(UserStatus::Suspended, $fresh->status); +}); diff --git a/tests/Database.DI/ActiveRow.typedProperties.phpt b/tests/Database.DI/ActiveRow.typedProperties.phpt new file mode 100644 index 000000000..4ae2c11d2 --- /dev/null +++ b/tests/Database.DI/ActiveRow.typedProperties.phpt @@ -0,0 +1,99 @@ +query('CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL, + nickname TEXT NULL + )'); + $connection->query("INSERT INTO users (email, nickname) VALUES ('a@x.com', 'al')"); + $connection->query("INSERT INTO users (email, nickname) VALUES ('b@x.com', NULL)"); + + $storage = new MemoryStorage; + $structure = new Structure($connection, $storage); + $conventions = new DiscoveredConventions($structure); + $mapping = new DefaultEntityMapping(['users' => UserRow::class]); + return new Explorer($connection, $structure, $conventions, $storage, $mapping); +} + + +test('typed properties are readable via __get', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + + Assert::type(UserRow::class, $row); + Assert::same(1, $row->id); + Assert::same('a@x.com', $row->email); + Assert::same('al', $row->nickname); +}); + + +test('nullable typed property returns null when NULL in database', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(2); + + Assert::null($row->nickname); +}); + + +test('isset works on typed properties', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + $row2 = $explorer->table('users')->get(2); + + Assert::true(isset($row->id)); + Assert::true(isset($row->email)); + Assert::true(isset($row->nickname)); + Assert::false(isset($row2->nickname)); +}); + + +test('iterator yields all columns', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + $arr = iterator_to_array($row); + + Assert::same(['id' => 1, 'email' => 'a@x.com', 'nickname' => 'al'], $arr); +}); + + +test('plain ActiveRow without typed props still works', function () { + $connection = new Connection('sqlite::memory:'); + $connection->query('CREATE TABLE thing (id INTEGER PRIMARY KEY, label TEXT NOT NULL)'); + $connection->query("INSERT INTO thing (label) VALUES ('x')"); + $storage = new MemoryStorage; + $structure = new Structure($connection, $storage); + $conventions = new DiscoveredConventions($structure); + $explorer = new Explorer($connection, $structure, $conventions, $storage); + + $row = $explorer->table('thing')->get(1); + Assert::type(ActiveRow::class, $row); + Assert::same('x', $row->label); +}); 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.entityMapping.phpt b/tests/Database.DI/DatabaseExtension.entityMapping.phpt new file mode 100644 index 000000000..4bc98d64e --- /dev/null +++ b/tests/Database.DI/DatabaseExtension.entityMapping.phpt @@ -0,0 +1,219 @@ +load(Tester\FileMock::create($neon, 'neon')); + $compiler = new DI\Compiler; + $compiler->addExtension('database', new DatabaseExtension(false)); + eval($compiler->addConfig($config)->setClassName($className)->compile()); + $container = new $className; + $container->initialize(); + return $container; +} + + +function getEntityMapping(Explorer $explorer): ?Nette\Database\EntityMapping +{ + return (new ReflectionProperty($explorer, 'entityMapping'))->getValue($explorer); +} + + +test('full mapping with tables map', function () { + $container = createContainer(' + database: + dsn: "sqlite::memory:" + mapping: + tables: + special: App\Entity\SpecialRow + "*": App\Entity\*Row + debugger: no + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'Container2'); + + $explorer = $container->getService('database.default.explorer'); + Assert::type(DefaultEntityMapping::class, getEntityMapping($explorer)); +}); + + +test('tables string shortcut', function () { + $container = createContainer(' + database: + dsn: "sqlite::memory:" + mapping: + tables: App\Entity\*Row + debugger: no + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'Container2b'); + + $explorer = $container->getService('database.default.explorer'); + Assert::type(DefaultEntityMapping::class, getEntityMapping($explorer)); +}); + + +test('no mapping by default', function () { + $container = createContainer(' + database: + dsn: "sqlite::memory:" + debugger: no + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'Container3'); + + $explorer = $container->getService('database.default.explorer'); + Assert::null(getEntityMapping($explorer)); +}); + + +test('DefaultEntityMapping: explicit tables', function () { + $mapping = new DefaultEntityMapping([ + 'my_table' => 'Nette\Database\Table\ActiveRow', + ]); + + Assert::same(Nette\Database\Table\ActiveRow::class, $mapping->getClassName('my_table')); + Assert::null($mapping->getClassName('other')); +}); + + +test('DefaultEntityMapping: exact key overrides wildcard fallback', function () { + $mapping = new DefaultEntityMapping([ + 'special' => 'Nette\Database\Table\ActiveRow', + '*' => 'Nette\Database\Table\*', + ]); + + Assert::same(Nette\Database\Table\ActiveRow::class, $mapping->getClassName('special')); + Assert::same('Nette\Database\Table\ActiveRow', $mapping->getClassName('active_row')); +}); + + +test('DefaultEntityMapping: bare wildcard with snake_case to PascalCase', function () { + $mapping = new DefaultEntityMapping(['*' => 'Nette\Database\Table\*']); + + Assert::same('Nette\Database\Table\ActiveRow', $mapping->getClassName('active_row')); + Assert::same('Nette\Database\Table\NonexistentTable', $mapping->getClassName('nonexistent_table')); +}); + + +test('DefaultEntityMapping: empty map returns null', function () { + $mapping = new DefaultEntityMapping; + + Assert::null($mapping->getClassName('any_table')); +}); + + +test('DefaultEntityMapping: camelCase off = identity', function () { + $mapping = new DefaultEntityMapping; + + Assert::same('first_name', $mapping->getPropertyName('first_name')); + Assert::same('firstName', $mapping->getColumnName('firstName')); +}); + + +test('DefaultEntityMapping: camelCase on', function () { + $mapping = new DefaultEntityMapping(camelCase: true); + + Assert::same('firstName', $mapping->getPropertyName('first_name')); + Assert::same('authorId', $mapping->getPropertyName('author_id')); + Assert::same('id', $mapping->getPropertyName('id')); + Assert::same('name', $mapping->getPropertyName('name')); + Assert::same('createdAt', $mapping->getPropertyName('created_at')); + Assert::same('address2', $mapping->getPropertyName('address2')); + + Assert::same('first_name', $mapping->getColumnName('firstName')); + Assert::same('author_id', $mapping->getColumnName('authorId')); + Assert::same('id', $mapping->getColumnName('id')); + Assert::same('name', $mapping->getColumnName('name')); + Assert::same('created_at', $mapping->getColumnName('createdAt')); + Assert::same('address2', $mapping->getColumnName('address2')); +}); + + +test('DefaultEntityMapping: camelCase roundtrip', function () { + $mapping = new DefaultEntityMapping(camelCase: true); + + foreach (['id', 'name', 'first_name', 'author_id', 'created_at', 'address2'] as $column) { + Assert::same($column, $mapping->getColumnName($mapping->getPropertyName($column))); + } +}); + + +test('DefaultEntityMapping: schema prefix is stripped in class name', function () { + $mapping = new DefaultEntityMapping(['*' => 'App\Model\*Row']); + + Assert::same('App\Model\UserAccountRow', $mapping->getClassName('public.user_account')); + Assert::same('App\Model\UserAccountRow', $mapping->getClassName('user_account')); +}); + + +test('DefaultEntityMapping: wildcard pattern in tables key', function () { + $mapping = new DefaultEntityMapping([ + 'forum_*' => 'App\Forum\*Row', + 'shop_*' => 'App\Shop\*Row', + ]); + + Assert::same('App\Forum\PostRow', $mapping->getClassName('forum_post')); + Assert::same('App\Forum\UserAccountRow', $mapping->getClassName('forum_user_account')); + Assert::same('App\Shop\OrderRow', $mapping->getClassName('shop_order')); + Assert::null($mapping->getClassName('unmatched_table')); +}); + + +test('DefaultEntityMapping: exact key wins over wildcard', function () { + $mapping = new DefaultEntityMapping([ + 'forum_post' => 'App\Forum\Post', + 'forum_*' => 'App\Forum\*Row', + ]); + + Assert::same('App\Forum\Post', $mapping->getClassName('forum_post')); + Assert::same('App\Forum\TagRow', $mapping->getClassName('forum_tag')); +}); + + +test('DefaultEntityMapping: wildcard patterns tried in declaration order', function () { + $mapping = new DefaultEntityMapping([ + 'forum_admin_*' => 'App\Admin\*Row', + 'forum_*' => 'App\Forum\*Row', + ]); + + Assert::same('App\Admin\UserRow', $mapping->getClassName('forum_admin_user')); + Assert::same('App\Forum\PostRow', $mapping->getClassName('forum_post')); +}); + + +test('DefaultEntityMapping: bare * acts as catch-all fallback', function () { + $mapping = new DefaultEntityMapping([ + 'forum_*' => 'App\Forum\*Row', + '*' => 'App\Entity\*Row', + ]); + + Assert::same('App\Forum\PostRow', $mapping->getClassName('forum_post')); + Assert::same('App\Entity\UserRow', $mapping->getClassName('user')); +}); + + +test('DefaultEntityMapping: wildcard value without * is fixed class', function () { + $mapping = new DefaultEntityMapping([ + 'log_*' => 'App\Logging\LogRow', + ]); + + Assert::same('App\Logging\LogRow', $mapping->getClassName('log_access')); + Assert::same('App\Logging\LogRow', $mapping->getClassName('log_error')); +}); 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.DI/EntityMapping.integration.phpt b/tests/Database.DI/EntityMapping.integration.phpt new file mode 100644 index 000000000..8a9d3f288 --- /dev/null +++ b/tests/Database.DI/EntityMapping.integration.phpt @@ -0,0 +1,221 @@ +query('CREATE TABLE user_account ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email_address TEXT NOT NULL + )'); + $connection->query("INSERT INTO user_account (first_name, last_name, email_address) VALUES ('John', 'Doe', 'john@example.com')"); + $connection->query("INSERT INTO user_account (first_name, last_name, email_address) VALUES ('Jane', 'Smith', 'jane@example.com')"); + + $storage = new MemoryStorage; + $structure = new Structure($connection, $storage); + $conventions = new DiscoveredConventions($structure); + return new Explorer($connection, $structure, $conventions, $storage, $mapping); +} + + +test('__get translates property name to column name', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::same('John', $row->FIRST_NAME); + Assert::same('Doe', $row->LAST_NAME); + Assert::same('john@example.com', $row->EMAIL_ADDRESS); + Assert::same(1, $row->ID); +}); + + +test('__get suggestion is in property names', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::exception( + fn() => $row->FIRST_NAM, + Nette\MemberAccessException::class, + "Cannot read an undeclared column 'FIRST_NAM', did you mean 'FIRST_NAME'?", + ); +}); + + +test('__isset translates property name', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::true(isset($row->FIRST_NAME)); + Assert::true(isset($row->ID)); + Assert::false(isset($row->NONEXISTENT)); +}); + + +test('toArray returns translated keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::same(['ID', 'FIRST_NAME', 'LAST_NAME', 'EMAIL_ADDRESS'], array_keys($row->toArray())); + Assert::same('John', $row->toArray()['FIRST_NAME']); +}); + + +test('getIterator returns translated keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::same(['ID', 'FIRST_NAME', 'LAST_NAME', 'EMAIL_ADDRESS'], array_keys(iterator_to_array($row))); +}); + + +test('update translates property keys to column names', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->get(1); + + $row->update(['FIRST_NAME' => 'Johnny', 'LAST_NAME' => 'Updated']); + + Assert::same('Johnny', $row->FIRST_NAME); + Assert::same('Updated', $row->LAST_NAME); + + $fresh = $explorer->table('user_account')->get(1); + Assert::same('Johnny', $fresh->FIRST_NAME); +}); + + +test('update handles compound assignment operators', function () { + $explorer = createExplorer(new UpperCaseMapping); + $connection = $explorer->getConnection(); + $connection->query('CREATE TABLE product (id INTEGER PRIMARY KEY, total_score INTEGER NOT NULL)'); + $connection->query('INSERT INTO product (total_score) VALUES (10)'); + + $row = $explorer->table('product')->get(1); + $row->update(['TOTAL_SCORE+=' => 5]); + + $fresh = $explorer->table('product')->get(1); + Assert::same(15, $fresh->TOTAL_SCORE); +}); + + +test('where translates via tryDelimite', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account') + ->where('FIRST_NAME', 'Jane') + ->fetch(); + + Assert::same('Jane', $row->FIRST_NAME); + Assert::same('Smith', $row->LAST_NAME); +}); + + +test('order by translates via tryDelimite', function () { + $explorer = createExplorer(new UpperCaseMapping); + $rows = array_values($explorer->table('user_account') + ->order('FIRST_NAME DESC') + ->fetchAll()); + + Assert::same('John', $rows[0]->FIRST_NAME); + Assert::same('Jane', $rows[1]->FIRST_NAME); +}); + + +test('Selection::insert translates property keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->insert([ + 'FIRST_NAME' => 'Alice', + 'LAST_NAME' => 'Wonder', + 'EMAIL_ADDRESS' => 'alice@example.com', + ]); + + Assert::same('Alice', $row->FIRST_NAME); + Assert::same('Wonder', $row->LAST_NAME); +}); + + +test('Selection::insertMany translates property keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $explorer->table('user_account')->insertMany([ + ['FIRST_NAME' => 'Bob', 'LAST_NAME' => 'Builder', 'EMAIL_ADDRESS' => 'bob@example.com'], + ['FIRST_NAME' => 'Cara', 'LAST_NAME' => 'Coder', 'EMAIL_ADDRESS' => 'cara@example.com'], + ]); + + $rows = array_values($explorer->table('user_account')->where('FIRST_NAME', ['Bob', 'Cara'])->order('FIRST_NAME')->fetchAll()); + Assert::count(2, $rows); + Assert::same('Bob', $rows[0]->FIRST_NAME); + Assert::same('Cara', $rows[1]->FIRST_NAME); +}); + + +test('Selection::update translates property keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $affected = $explorer->table('user_account') + ->where('ID', 1) + ->update(['FIRST_NAME' => 'Renamed']); + + Assert::same(1, $affected); + Assert::same('Renamed', $explorer->table('user_account')->get(1)->FIRST_NAME); +}); + + +test('Selection::update translates compound assignment', function () { + $explorer = createExplorer(new UpperCaseMapping); + $connection = $explorer->getConnection(); + $connection->query('CREATE TABLE counter (id INTEGER PRIMARY KEY, total_score INTEGER NOT NULL)'); + $connection->query('INSERT INTO counter (total_score) VALUES (10)'); + + $explorer->table('counter')->where('ID', 1)->update(['TOTAL_SCORE+=' => 7]); + + Assert::same(17, $explorer->table('counter')->get(1)->TOTAL_SCORE); +}); + + +test('round-trip: toArray feeds insert', function () { + $explorer = createExplorer(new UpperCaseMapping); + $source = $explorer->table('user_account')->get(1)->toArray(); + unset($source['ID']); + $source['EMAIL_ADDRESS'] = 'copy@example.com'; + + $row = $explorer->table('user_account')->insert($source); + Assert::same('John', $row->FIRST_NAME); + Assert::same('copy@example.com', $row->EMAIL_ADDRESS); +}); diff --git a/tests/Database.Tracy/ConnectionPanel.phpt b/tests/Database.Tracy/ConnectionPanel.phpt index ef979c55d..cde7d6254 100644 --- a/tests/Database.Tracy/ConnectionPanel.phpt +++ b/tests/Database.Tracy/ConnectionPanel.phpt @@ -25,6 +25,13 @@ test('Tracy Bar', function () { Assert::matchFile(__DIR__ . '/tab.html', $panel->getTab()); Assert::matchFile(__DIR__ . '/panel.html', $panel->getPanel()); + Assert::matchFile(__DIR__ . '/panel.agent.md', $panel->getAgentInfo()); +}); + +test('getAgentInfo() returns null when no queries', function () { + $connection = new Connection('sqlite::memory:'); + $panel = ConnectionPanel::initialize($connection, addBarPanel: true, name: 'foo'); + Assert::null($panel->getAgentInfo()); }); test('Bluescreen Panel', function () { @@ -45,7 +52,7 @@ test('Bluescreen Panel', function () { test('deprecated initialization', function () { $connection = new Connection('sqlite::memory:'); - $panel = Nette\Database\Helpers::initializeTracy($connection, addBarPanel: true, name: 'foo'); + $panel = @Nette\Database\Helpers::initializeTracy($connection, addBarPanel: true, name: 'foo'); // deprecated $connection->beginTransaction(); $connection->query('SELECT 1'); @@ -58,3 +65,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.agent.md b/tests/Database.Tracy/panel.agent.md new file mode 100644 index 000000000..8f6aef5e1 --- /dev/null +++ b/tests/Database.Tracy/panel.agent.md @@ -0,0 +1,17 @@ +## Database queries (foo) + +4 queries, time %a% ms + +```sql +-- %a% ms +::beginTransaction; + +-- %a% ms, 0 rows +SELECT 1; + +-- %a% ms +::commit; + +-- ERROR: %A% +SELECT; +``` 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/Connection.transaction.retry.phpt b/tests/Database/Connection.transaction.retry.phpt new file mode 100644 index 000000000..40218928d --- /dev/null +++ b/tests/Database/Connection.transaction.retry.phpt @@ -0,0 +1,272 @@ +errorInfo = ['40001', 1213, 'Deadlock found']; + return DeadlockException::from($pdo); +} + + +test('retries on DeadlockException and eventually succeeds', function () { + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + $result = $connection->transaction(function () use (&$attempts) { + $attempts++; + if ($attempts < 3) { + throw makeDeadlock(); + } + return 'success'; + }, attempts: 5); + + Assert::same('success', $result); + Assert::same(3, $attempts); +}); + + +test('gives up after exhausting attempts and rethrows last deadlock', function () { + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + Assert::exception( + function () use ($connection, &$attempts) { + $connection->transaction(function () use (&$attempts) { + $attempts++; + throw makeDeadlock(); + }, attempts: 3); + }, + DeadlockException::class, + ); + + Assert::same(3, $attempts); +}); + + +test('does not retry on non-deadlock exceptions', function () { + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + Assert::exception( + function () use ($connection, &$attempts) { + $connection->transaction(function () use (&$attempts) { + $attempts++; + throw new Exception('something else'); + }, attempts: 5); + }, + Exception::class, + 'something else', + ); + + Assert::same(1, $attempts); +}); + + +test('inner nested transaction does not retry on its own', function () { + $connection = new Connection('sqlite::memory:'); + $outerAttempts = 0; + $innerAttempts = 0; + + // outer attempts=1 → inner deadlock bubbles up, outer rethrows without retry + // verifies that inner nested transaction does NOT retry by itself + Assert::exception( + function () use ($connection, &$outerAttempts, &$innerAttempts) { + $connection->transaction(function (Connection $connection) use (&$outerAttempts, &$innerAttempts) { + $outerAttempts++; + $connection->transaction(function () use (&$innerAttempts) { + $innerAttempts++; + throw makeDeadlock(); + }, attempts: 5); + }); + }, + DeadlockException::class, + ); + + Assert::same(1, $outerAttempts); + Assert::same(1, $innerAttempts); +}); + + +test('outer transaction retries even when inner transaction throws deadlock', function () { + $connection = new Connection('sqlite::memory:'); + $outerAttempts = 0; + + $result = $connection->transaction(function (Connection $connection) use (&$outerAttempts) { + $outerAttempts++; + if ($outerAttempts < 3) { + $connection->transaction(function () { + throw makeDeadlock(); + }); + } + return 'ok'; + }, attempts: 5); + + Assert::same('ok', $result); + Assert::same(3, $outerAttempts); +}); + + +test('default attempts = 1 does not retry', function () { + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + Assert::exception( + function () use ($connection, &$attempts) { + $connection->transaction(function () use (&$attempts) { + $attempts++; + throw makeDeadlock(); + }); + }, + DeadlockException::class, + ); + + Assert::same(1, $attempts); +}); + + +test('attempts < 1 throws InvalidArgumentException', function () { + $connection = new Connection('sqlite::memory:'); + + Assert::exception( + fn() => $connection->transaction(fn() => null, attempts: 0), + Nette\InvalidArgumentException::class, + 'Number of attempts must be at least 1.', + ); +}); + + +test('retries any user-defined RetryableException', function () { + $userException = new class ('optimistic lock conflict') extends \RuntimeException implements RetryableException { + }; + + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + $result = $connection->transaction(function () use (&$attempts, $userException) { + $attempts++; + if ($attempts < 2) { + throw $userException; + } + return 'ok'; + }, attempts: 3); + + Assert::same('ok', $result); + Assert::same(2, $attempts); +}); + + +test('onRetry hook fires before each retry with attempt number and exception', function () { + $connection = new Connection('sqlite::memory:'); + $hookCalls = []; + $connection->onRetry[] = function (Connection $conn, int $attempt, RetryableException $e) use (&$hookCalls) { + $hookCalls[] = [$attempt, $e::class]; + }; + + $attempts = 0; + $connection->transaction(function () use (&$attempts) { + $attempts++; + if ($attempts < 3) { + throw makeDeadlock(); + } + return 'ok'; + }, attempts: 5); + + Assert::same([[1, DeadlockException::class], [2, DeadlockException::class]], $hookCalls); +}); + + +test('onRetry hook does not fire when retry is exhausted', function () { + $connection = new Connection('sqlite::memory:'); + $hookCalls = 0; + $connection->onRetry[] = function () use (&$hookCalls) { + $hookCalls++; + }; + + Assert::exception( + fn() => $connection->transaction(fn() => throw makeDeadlock(), attempts: 3), + DeadlockException::class, + ); + + // fires before attempts 2 and 3; not before the final failed throw + Assert::same(2, $hookCalls); +}); + + +test('retries when commit() throws RetryableException', function () { + $connection = new class ('sqlite::memory:') extends Connection { + public int $commitFailuresLeft = 2; + + public function commit(): void + { + if ($this->commitFailuresLeft-- > 0) { + try { + parent::rollBack(); + } catch (\Throwable) { + } + throw makeDeadlock(); + } + parent::commit(); + } + }; + + $attempts = 0; + $result = $connection->transaction(function () use (&$attempts) { + $attempts++; + return 'ok'; + }, attempts: 5); + + Assert::same('ok', $result); + Assert::same(3, $attempts); +}); + + +test('retries when beginTransaction() throws RetryableException', function () { + $connection = new class ('sqlite::memory:') extends Connection { + public int $beginFailuresLeft = 2; + + public function beginTransaction(): void + { + if ($this->beginFailuresLeft-- > 0) { + throw makeDeadlock(); + } + parent::beginTransaction(); + } + }; + + $attempts = 0; + $result = $connection->transaction(function () use (&$attempts) { + $attempts++; + return 'ok'; + }, attempts: 5); + + Assert::same('ok', $result); + Assert::same(1, $attempts); +}); + + +test('failure inside rollBack() does not mask the original exception', function () { + $connection = new class ('sqlite::memory:') extends Connection { + public function rollBack(): void + { + throw new RuntimeException('rollback failed'); + } + }; + + $original = makeDeadlock(); + Assert::exception( + fn() => $connection->transaction(fn() => throw $original), + DeadlockException::class, + ); +}); 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.aggregation.phpt b/tests/Database/Explorer/Explorer.aggregation.phpt index dee750846..fe987db11 100644 --- a/tests/Database/Explorer/Explorer.aggregation.phpt +++ b/tests/Database/Explorer/Explorer.aggregation.phpt @@ -80,6 +80,19 @@ test('filtering groups by related count', function () use ($explorer) { ], $bookTags); }); +test('aggregation of a group without rows', function () use ($explorer) { + $aggregates = []; + foreach ($explorer->table('author') as $author) { + $books = $author->related('book'); + $aggregates[$author->name] = [$books->count('*'), $books->max('id'), $books->min('id'), $books->sum('id')]; + } + + // Geek has no books, so the aggregate functions have nothing to return but null; only count() is a number + Assert::same([0, null, null, null], $aggregates['Geek']); + Assert::same([2, 4, 3, 7], $aggregates['David Grudl']); +}); + + test('nested group by and having', function () use ($explorer) { $bookTags = []; foreach ($explorer->table('author') as $author) { 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/GroupedSelection.insert().phpt b/tests/Database/Explorer/GroupedSelection.insert().phpt index 067cf8b79..4363153cc 100644 --- a/tests/Database/Explorer/GroupedSelection.insert().phpt +++ b/tests/Database/Explorer/GroupedSelection.insert().phpt @@ -36,3 +36,11 @@ test('insert works after iteration conversion', function () use ($explorer) { $book->related('book_tag')->insert(['tag_id' => 23]); Assert::same(3, $book->related('book_tag')->count()); }); + + +test('insert with an empty array still assigns the referencing group', function () use ($explorer) { + $explorer->table('note')->where('book_id', 1)->delete(); + + $explorer->table('book')->get(1)->related('note.book_id')->insert([]); // all columns left to defaults + Assert::same(1, $explorer->table('note')->where('book_id', 1)->count()); // the group column is filled anyway +}); diff --git a/tests/Database/Explorer/RowBehavior.entity.phpt b/tests/Database/Explorer/RowBehavior.entity.phpt new file mode 100644 index 000000000..e35c53f7d --- /dev/null +++ b/tests/Database/Explorer/RowBehavior.entity.phpt @@ -0,0 +1,135 @@ +translator_id !== null; + } +} + + +// attached row in its target form: composing RowBehavior is enough, its constructor +// unsets the declared value properties so they fall through to magic column access +final class BookRow extends Book implements Table\Row +{ + use Table\RowBehavior; +} + + +class EntityExplorer extends Nette\Database\Explorer +{ + public function createActiveRow(array $data, Table\Selection $selection, bool $deferredFetch = false): Table\ActiveRow + { + return $selection->getName() === 'book' + ? new BookRow($data, $selection, $deferredFetch) + : parent::createActiveRow($data, $selection, $deferredFetch); + } +} + + +$explorer = connectToDB(); +$connection = $explorer->getConnection(); +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + +$cacheMemoryStorage = new Nette\Caching\Storages\MemoryStorage; +$structure = new Nette\Database\Structure($connection, $cacheMemoryStorage); +$conventions = new Nette\Database\Conventions\DiscoveredConventions($structure); +$explorer = new EntityExplorer($connection, $structure, $conventions, $cacheMemoryStorage); + + +test('hydrated row is the entity class and reads columns through magic access', function () use ($explorer) { + $book = $explorer->table('book')->get(1); + Assert::type(BookRow::class, $book); + Assert::true($book instanceof Book); + Assert::true($book instanceof Table\Row); + Assert::true($book instanceof Table\ActiveRow); + Assert::same('1001 tipu a triku pro PHP', $book->title); + Assert::same(11, $book->author_id); + Assert::true($book->isTranslated()); +}); + + +test('relations work from the entity row', function () use ($explorer) { + $book = $explorer->table('book')->get(1); + Assert::same('Jakub Vrana', $book->author->name); + Assert::same('Jakub Vrana', $book->ref('author', 'author_id')->name); + + $tags = []; + foreach ($book->related('book_tag') as $bookTag) { + $tags[] = $bookTag->tag->name; + } + + sort($tags); + Assert::same(['MySQL', 'PHP'], $tags); +}); + + +test('attached row stays read-only', function () use ($explorer) { + $book = $explorer->table('book')->get(1); + Assert::exception( + fn() => $book->title = 'x', + Nette\DeprecatedException::class, + 'ActiveRow is read-only; use update() method instead.', + ); +}); + + +test('update() writes to database and refreshes declared properties', function () use ($explorer) { + $book = $explorer->table('book')->get(2); + $book->update(['title' => 'JUSH 2']); + Assert::same('JUSH 2', $book->title); + Assert::same('JUSH 2', $explorer->table('book')->get(2)->title); +}); + + +test('insert() returns a lazy entity row completed on first access', function () use ($explorer) { + $row = $explorer->table('book')->insert([ + 'author_id' => 12, + 'title' => 'Value objects in practice', + ]); + Assert::type(BookRow::class, $row); + Assert::same('Value objects in practice', $row->title); + Assert::false($row->isTranslated()); +}); + + +test('detached value is constructible and mutable without database', function () { + $draft = new Book(title: 'Draft', author_id: 12); + Assert::same('Draft', $draft->title); + Assert::false($draft->isTranslated()); + + $draft->translator_id = 11; + Assert::true($draft->isTranslated()); +}); + + +test('detached value refuses database operations', function () { + $draft = new Book(title: 'Draft'); + Assert::exception( + fn() => $draft->update(['title' => 'x']), + Error::class, + '%a%$table must not be accessed before initialization', + ); +}); diff --git a/tests/Database/Explorer/Selection.insert().deprecated.phpt b/tests/Database/Explorer/Selection.insert().deprecated.phpt new file mode 100644 index 000000000..ec62e5017 --- /dev/null +++ b/tests/Database/Explorer/Selection.insert().deprecated.phpt @@ -0,0 +1,69 @@ +getConnection(); + +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + + +test('insert() with a list of rows is deprecated but still inserts', function () use ($explorer) { + $result = null; + Assert::error( + function () use ($explorer, &$result) { + $result = $explorer->table('author')->insert([ + ['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')], + ]); + }, + E_USER_DEPRECATED, + 'Nette\Database\Table\Selection::insert() with a list of rows is deprecated, use insertMany() instead.', + ); + Assert::same(2, $result); + Assert::same(5, $explorer->table('author')->count()); +}); + + +test('insert() with a list on a GroupedSelection is deprecated but still inserts', function () use ($explorer) { + $explorer->table('book_tag')->where('book_id', 1)->delete(); + + $result = null; + Assert::error( + function () use ($explorer, &$result) { + $result = $explorer->table('book')->get(1)->related('book_tag')->insert([ + ['tag_id' => 21], + ['tag_id' => 22], + ['tag_id' => 23], + ]); + }, + E_USER_DEPRECATED, + 'Nette\Database\Table\Selection::insert() with a list of rows is deprecated, use insertMany() instead.', + ); + Assert::same(3, $result); + Assert::same(7, $explorer->table('book_tag')->count()); +}); + + +test('deprecated insert() with a generator does not lose rows to colliding keys', function () use ($explorer) { + $rows = (function () { + yield from [['name' => 'Hodor', 'web' => 'http://example.com']]; // both batches yield the key 0 + yield from [['name' => 'Osha', 'web' => 'http://example.com']]; + })(); + + $result = null; + Assert::error( + function () use ($explorer, $rows, &$result) { + $result = $explorer->table('author')->insert($rows); + }, + E_USER_DEPRECATED, + ); + Assert::same(2, $result); +}); diff --git a/tests/Database/Explorer/Selection.insert().lazy.phpt b/tests/Database/Explorer/Selection.insert().lazy.phpt new file mode 100644 index 000000000..5ce5802b4 --- /dev/null +++ b/tests/Database/Explorer/Selection.insert().lazy.phpt @@ -0,0 +1,164 @@ +getConnection(); + +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + + +test('reading only the primary key triggers no SELECT', function () use ($explorer, $connection) { + $row = $explorer->table('author')->insert([ + 'name' => 'Eddard Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::type('int', $row->id); + Assert::same(0, $count); +}); + + +test('reading a non-primary column triggers exactly one SELECT', function () use ($explorer, $connection) { + $row = $explorer->table('author')->insert([ + 'name' => 'Catelyn Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::same('Catelyn Stark', $row->name); + Assert::same(1, $count); + + Assert::same('http://example.com', $row->web); // already loaded + Assert::same(1, $count); +}); + + +test('toArray() materializes the whole row', function () use ($explorer, $connection) { + $row = $explorer->table('author')->insert([ + 'name' => 'Robb Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + $arr = $row->toArray(); + Assert::same(1, $count); + Assert::same('Robb Stark', $arr['name']); +}); + + +test('column computed by the database is read correctly', function () use ($explorer) { + $row = $explorer->table('author')->insert([ + 'name' => $explorer->literal('LOWER(?)', 'Eddard Stark'), + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + Assert::same('eddard stark', $row->name); +}); + + +test('relationship is accessible right after insert', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Jon Snow', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $book = $explorer->table('book')->insert([ + 'title' => 'Winterfell', + 'author_id' => $author->id, + ]); + + Assert::same('Jon Snow', $book->author->name); +}); + + +test('related() right after insert sees the referencing rows', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Arya Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $explorer->table('book')->insert([ + 'title' => 'Needle', + 'author_id' => $author->id, + ]); + + $books = $author->related('book.author_id'); + Assert::same(1, $books->count('*')); + Assert::same('Needle', $books->fetch()->title); +}); + + +test('row inserted via related() gets the group column and is fully usable', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Sansa Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $book = $author->related('book.author_id')->insert(['title' => 'Alayne']); + + Assert::type(Nette\Database\Table\ActiveRow::class, $book); + Assert::same($author->id, $book->author_id); + Assert::same('Alayne', $book->title); + Assert::same('Sansa Stark', $book->author->name); // ref() on a lazy row from GroupedSelection + + $tag = $explorer->table('tag')->insert(['name' => 'saga']); + $explorer->table('book_tag')->insert(['book_id' => $book->id, 'tag_id' => $tag->id]); + Assert::same(1, $book->related('book_tag')->count('*')); // related() on a lazy row from GroupedSelection + + $book->update(['title' => 'Alayne Stone']); + Assert::same('Alayne Stone', $book->title); +}); + + +test('lazy row completes its data even after related() executed the selection', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Rickon Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + Assert::same(0, $author->related('book.author_id')->count('*')); // executes the backing selection + Assert::same('Rickon Stark', $author->name); // deferred fetch must still find the row +}); + + +test('lazy row stays consistent across related(), completion and update()', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Benjen Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + $explorer->table('book')->insert([ + 'title' => 'The Wall', + 'author_id' => $author->id, + ]); + + Assert::same(1, $author->related('book.author_id')->count('*')); + Assert::same('Benjen Stark', $author->name); // completes data, row becomes canonical in the selection + $author->update(['name' => 'First Ranger']); + Assert::same('First Ranger', $author->name); + Assert::same(1, $author->related('book.author_id')->count('*')); // cached prototype keys still match +}); diff --git a/tests/Database/Explorer/Selection.insert().multi.phpt b/tests/Database/Explorer/Selection.insert().multi.phpt deleted file mode 100644 index b560b268e..000000000 --- a/tests/Database/Explorer/Selection.insert().multi.phpt +++ /dev/null @@ -1,43 +0,0 @@ -getConnection(); - -Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); - - -test('', function () use ($explorer) { - Assert::same(3, $explorer->table('author')->count()); - $explorer->table('author')->insert([ - [ - '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'), - ], - ]); // 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(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` = ?) - ['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(7, $explorer->table('book_tag')->count()); -}); diff --git a/tests/Database/Explorer/Selection.insert().phpt b/tests/Database/Explorer/Selection.insert().phpt index 910be3d2c..a038a18ad 100644 --- a/tests/Database/Explorer/Selection.insert().phpt +++ b/tests/Database/Explorer/Selection.insert().phpt @@ -63,14 +63,14 @@ if ($driverName !== 'sqlsrv') { default => Assert::fail("Unsupported driver $driverName"), }; - $explorer->table('book')->insert($selection); + $explorer->table('book')->insertMany($selection); Assert::same(4, $explorer->table('book')->where('title LIKE', 'Biography%')->count('*')); } -// Insert into table without primary key +// Insert into table without primary key returns null (no identifiable row) $inserted = $explorer->table('note')->insert([ 'book_id' => 1, 'note' => 'Good one!', ]); -Assert::same(1, $inserted); +Assert::null($inserted); diff --git a/tests/Database/Explorer/Selection.insert().primaryKeys.phpt b/tests/Database/Explorer/Selection.insert().primaryKeys.phpt index 85fe3ebe7..2b757a540 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, @@ -104,5 +121,54 @@ test('insert into table without primary key', function () use ($explorer) { $noPkResult1 = $explorer->table('no_pk')->insert([ 'note' => 'Some note here', ]); - Assert::same(1, $noPkResult1); + Assert::null($noPkResult1); +}); + +test('composite primary key insert returns a lazy row', function () use ($explorer, $connection) { + $row = $explorer->table('multi_pk_no_autoincrement')->insert([ + 'identifier1' => 7, + 'identifier2' => 14, + 'note' => 'lazy composite', + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::same(7, $row->identifier1); + Assert::same(14, $row->identifier2); + Assert::same(0, $count); // both primary key columns are available without a query + + Assert::same('lazy composite', $row->note); + Assert::same(1, $count); // the first non-primary access fetches the rest +}); + +test('numeric-string primary key values are normalized to int for integer columns', function () use ($explorer, $connection) { + $row = $explorer->table('multi_pk_no_autoincrement')->insert([ + 'identifier1' => '8', + 'identifier2' => '16', + 'note' => 'string ids', + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::same(8, $row->identifier1); + Assert::same(16, $row->identifier2); + Assert::same(0, $count); // normalized without fetching the row +}); + +test('numeric-string value in a string primary key column stays a string', function () use ($explorer, $connection) { + $row = $explorer->table('string_pk')->insert([ + 'identifier1' => '9', + 'note' => 'string pk', + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::same('9', $row->identifier1); + Assert::same(0, $count); + + Assert::same('string pk', $row->note); + Assert::same('9', $row->identifier1); // matches what a fetch returns }); 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/Explorer/SqlBuilder.tryDelimite().phpt b/tests/Database/Explorer/SqlBuilder.tryDelimite().phpt index 66dae9397..adc6eebad 100644 --- a/tests/Database/Explorer/SqlBuilder.tryDelimite().phpt +++ b/tests/Database/Explorer/SqlBuilder.tryDelimite().phpt @@ -24,3 +24,10 @@ Assert::same(reformat('HELLO([world])'), $tryDelimite->invoke($sqlBuilder, 'HELL Assert::same(reformat('hello([world])'), $tryDelimite->invoke($sqlBuilder, 'hello(world)')); Assert::same('[hello]', $tryDelimite->invoke($sqlBuilder, '[hello]')); Assert::same(reformat('::int'), $tryDelimite->invoke($sqlBuilder, '::int')); + +// string literals are not supported, the content would be delimited as an identifier +Assert::error( + fn() => $tryDelimite->invoke($sqlBuilder, "name = 'abc'"), + E_USER_WARNING, + "SQL string literals are not supported here, pass the value as a parameter instead: name = 'abc'", +); 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.columns.mysql.phpt b/tests/Database/Reflection.columns.mysql.phpt index aea563ff8..681e14706 100644 --- a/tests/Database/Reflection.columns.mysql.phpt +++ b/tests/Database/Reflection.columns.mysql.phpt @@ -23,6 +23,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => $version80 ? 'INT UNSIGNED' : 'INT', 'size' => $version80 ? null : 11, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -33,6 +34,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT', 'size' => $version80 ? null : 11, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -43,6 +45,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLINT', 'size' => $version80 ? null : 6, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -53,6 +56,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYINT', 'size' => $version80 ? null : 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -63,6 +67,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MEDIUMINT', 'size' => $version80 ? null : 9, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -73,6 +78,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIGINT', 'size' => $version80 ? null : 20, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -83,6 +89,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYINT', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -93,6 +100,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIT', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -103,6 +111,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DECIMAL', 'size' => 10, + 'scale' => 0, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -113,6 +122,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DECIMAL', 'size' => 10, + 'scale' => 2, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -123,6 +133,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -133,6 +144,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DOUBLE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -143,6 +155,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -153,6 +166,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIME', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -163,6 +177,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATETIME', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -173,6 +188,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIMESTAMP', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -183,6 +199,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'YEAR', 'size' => $version80 ? null : 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -193,6 +210,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CHAR', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -203,6 +221,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARCHAR', 'size' => 30, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -213,6 +232,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BINARY', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -223,6 +243,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARBINARY', 'size' => 30, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -233,6 +254,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -243,6 +265,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYBLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -253,6 +276,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MEDIUMBLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -263,6 +287,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'LONGBLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -273,6 +298,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -283,6 +309,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYTEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -293,6 +320,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MEDIUMTEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -303,6 +331,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'LONGTEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -313,6 +342,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'ENUM', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -323,6 +353,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SET', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -337,6 +368,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, diff --git a/tests/Database/Reflection.columns.postgre.phpt b/tests/Database/Reflection.columns.postgre.phpt index cede867a6..bee303f4c 100644 --- a/tests/Database/Reflection.columns.postgre.phpt +++ b/tests/Database/Reflection.columns.postgre.phpt @@ -22,6 +22,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT2', 'size' => 2, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -32,6 +33,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT4', 'size' => 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -42,6 +44,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT8', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -52,6 +55,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NUMERIC', 'size' => 3, + 'scale' => 2, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -62,6 +66,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT4', 'size' => 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -72,6 +77,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT8', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -82,6 +88,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MONEY', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -92,6 +99,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BOOL', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -102,6 +110,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATE', 'size' => 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -112,6 +121,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIME', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -122,6 +132,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIMESTAMP', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -132,6 +143,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIMESTAMPTZ', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -142,6 +154,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INTERVAL', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -152,6 +165,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BPCHAR', 'size' => 30, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -162,6 +176,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARCHAR', 'size' => 30, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -172,6 +187,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -182,6 +198,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TSQUERY', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -192,6 +209,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TSVECTOR', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -202,6 +220,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'UUID', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -212,6 +231,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'XML', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -222,6 +242,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CIDR', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -232,6 +253,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INET', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -242,6 +264,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MACADDR', 'size' => 6, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -252,6 +275,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIT', 'size' => -3, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -262,6 +286,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARBIT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -272,6 +297,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BYTEA', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -282,6 +308,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BOX', 'size' => 32, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -292,6 +319,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CIRCLE', 'size' => 24, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -302,6 +330,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'LSEG', 'size' => 32, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -312,6 +341,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'PATH', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -322,6 +352,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'POINT', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -332,6 +363,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'POLYGON', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -346,6 +378,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, diff --git a/tests/Database/Reflection.columns.sqlite.phpt b/tests/Database/Reflection.columns.sqlite.phpt index f88fe1f45..18e6369d4 100644 --- a/tests/Database/Reflection.columns.sqlite.phpt +++ b/tests/Database/Reflection.columns.sqlite.phpt @@ -22,6 +22,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -32,6 +33,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INTEGER', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -42,6 +44,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYINT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -52,6 +55,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLINT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -62,6 +66,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MEDIUMINT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -72,6 +77,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIGINT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -82,6 +88,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'UNSIGNED BIG INT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -92,6 +99,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT2', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -102,6 +110,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT8', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -112,6 +121,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CHARACTER', 'size' => 20, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -122,6 +132,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARCHAR', 'size' => 255, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -132,6 +143,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARYING CHARACTER', 'size' => 255, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -142,6 +154,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NCHAR', 'size' => 55, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -152,6 +165,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NATIVE CHARACTER', 'size' => 70, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -162,6 +176,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NVARCHAR', 'size' => 100, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -172,6 +187,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -182,6 +198,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -192,6 +209,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -202,6 +220,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'REAL', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -212,6 +231,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DOUBLE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -222,6 +242,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DOUBLE PRECISION', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -232,6 +253,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -242,6 +264,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NUMERIC', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -252,6 +275,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DECIMAL', 'size' => 10, + 'scale' => 5, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -262,6 +286,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BOOLEAN', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -272,6 +297,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -282,6 +308,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATETIME', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -292,6 +319,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -306,6 +334,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, diff --git a/tests/Database/Reflection.columns.sqlsrv.phpt b/tests/Database/Reflection.columns.sqlsrv.phpt index f2878f06b..5502e719b 100644 --- a/tests/Database/Reflection.columns.sqlsrv.phpt +++ b/tests/Database/Reflection.columns.sqlsrv.phpt @@ -22,6 +22,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIGINT', 'size' => 19, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -32,6 +33,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BINARY', 'size' => 3, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -42,6 +44,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIT', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -52,6 +55,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CHAR', 'size' => 5, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -62,6 +66,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATE', 'size' => 10, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -72,6 +77,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATETIME', 'size' => 23, + 'scale' => 3, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -82,6 +88,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATETIME2', 'size' => 27, + 'scale' => 7, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -92,6 +99,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DECIMAL', 'size' => 18, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -102,6 +110,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT', 'size' => 53, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -112,6 +121,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'GEOGRAPHY', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -122,6 +132,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'GEOMETRY', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -132,6 +143,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'HIERARCHYID', 'size' => 892, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -142,6 +154,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT', 'size' => 10, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -152,6 +165,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MONEY', 'size' => 19, + 'scale' => 4, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -162,6 +176,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NCHAR', 'size' => 2, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -172,6 +187,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NTEXT', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -182,6 +198,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NUMERIC', 'size' => 10, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -192,6 +209,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NUMERIC', 'size' => 10, + 'scale' => 2, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -202,6 +220,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NVARCHAR', 'size' => 2, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -212,6 +231,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'REAL', 'size' => 24, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -222,6 +242,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLDATETIME', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -232,6 +253,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLINT', 'size' => 5, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -242,6 +264,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLMONEY', 'size' => 10, + 'scale' => 4, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -252,6 +275,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TEXT', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -262,6 +286,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIME', 'size' => 16, + 'scale' => 7, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -272,6 +297,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYINT', 'size' => 3, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -282,6 +308,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'UNIQUEIDENTIFIER', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -292,6 +319,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARBINARY', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -302,6 +330,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARCHAR', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -312,6 +341,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'XML', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -326,6 +356,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, diff --git a/tests/Database/Reflection.driver.phpt b/tests/Database/Reflection.driver.phpt index 6d0c79060..af3b11449 100644 --- a/tests/Database/Reflection.driver.phpt +++ b/tests/Database/Reflection.driver.phpt @@ -60,6 +60,7 @@ $expectedColumns = [ 'table' => 'author', 'nativetype' => 'INT', 'size' => 11, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoincrement' => true, @@ -71,6 +72,7 @@ $expectedColumns = [ 'table' => 'author', 'nativetype' => 'VARCHAR', 'size' => 30, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoincrement' => false, @@ -82,6 +84,7 @@ $expectedColumns = [ 'table' => 'author', 'nativetype' => 'VARCHAR', 'size' => 100, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoincrement' => false, @@ -93,6 +96,7 @@ $expectedColumns = [ 'table' => 'author', 'nativetype' => 'DATE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoincrement' => false, @@ -104,8 +108,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..52b3e66d8 100644 --- a/tests/Database/Reflection.phpt +++ b/tests/Database/Reflection.phpt @@ -81,6 +81,7 @@ $expectedColumns = [ 'table' => 'author', 'nativeType' => 'INT', 'size' => 11, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoIncrement' => true, @@ -91,6 +92,7 @@ $expectedColumns = [ 'table' => 'author', 'nativeType' => 'VARCHAR', 'size' => 30, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoIncrement' => false, @@ -101,6 +103,7 @@ $expectedColumns = [ 'table' => 'author', 'nativeType' => 'VARCHAR', 'size' => 100, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoIncrement' => false, @@ -111,6 +114,7 @@ $expectedColumns = [ 'table' => 'author', 'nativeType' => 'DATE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -121,8 +125,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': @@ -155,6 +159,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, @@ -215,3 +220,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..738f5daf8 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,12 +493,54 @@ 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.", + ); +}); + + +test('multi-insert row with a missing or unexpected column warns', function () use ($preprocessor) { + Assert::error( + fn() => $preprocessor->process(['INSERT INTO author', [ + ['name' => 'Catelyn Stark', 'born' => null], + ['name' => 'Sansa Stark'], // the column would be silently filled with NULL + ]]), + E_USER_WARNING, + "Missing value for column 'born' in multi-insert row #1.", + ); + + Assert::error( + fn() => $preprocessor->process(['INSERT INTO author', [ + ['name' => 'Catelyn Stark'], + ['name' => 'Sansa Stark', 'born' => null], // the column would be silently dropped + ]]), + E_USER_WARNING, + "Unexpected column 'born' in multi-insert row #1.", ); + + [$sql, $params] = $preprocessor->process(['INSERT INTO author', [ + ['name' => 'Catelyn Stark', 'born' => null], + ['born' => null, 'name' => 'Sansa Stark'], // different key order is fine + ]]); + Assert::same(reformat([ + 'sqlite' => 'INSERT INTO author ([name], [born]) SELECT ?, NULL UNION ALL SELECT ?, NULL', + 'INSERT INTO author ([name], [born]) VALUES (?, NULL), (?, NULL)', + ]), $sql); + Assert::same(['Catelyn Stark', 'Sansa Stark'], $params); }); diff --git a/tests/Database/_create_db.php b/tests/Database/_create_db.php new file mode 100644 index 000000000..3eab99157 --- /dev/null +++ b/tests/Database/_create_db.php @@ -0,0 +1,6 @@ +query('CREATE DATABASE IF NOT EXISTS nette_test'); diff --git a/tests/Database/_sqlbuilder.phpt b/tests/Database/_sqlbuilder.phpt new file mode 100644 index 000000000..a40e5154f --- /dev/null +++ b/tests/Database/_sqlbuilder.phpt @@ -0,0 +1,34 @@ +getConnection()); + +[$sql, $params] = $preprocessor->process(['SELECT id FROM author WHERE', [ + 'b', +]]); +//dump($sql); +//dump($params); + + +$_POST = ['0) UNION SELECT name, salary FROM users WHERE (0']; + +try { + $explorer->table('Operator1') + ->where($_POST) + ->fetch(); +} catch (Throwable $e) { + echo $e->getMessage(), "\n\n"; +} + +echo $explorer->getConnection()->getLastQueryString(); 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/Database/files/mysql-nette_test4.sql b/tests/Database/files/mysql-nette_test4.sql index 754ab2f1c..c12da300e 100644 --- a/tests/Database/files/mysql-nette_test4.sql +++ b/tests/Database/files/mysql-nette_test4.sql @@ -31,3 +31,9 @@ CREATE TABLE multi_pk_autoincrement( CREATE TABLE no_pk ( note varchar(100) ) ENGINE=InnoDB; + +CREATE TABLE string_pk ( + identifier1 varchar(20) NOT NULL, + note varchar(100), + PRIMARY KEY (identifier1) +) ENGINE=InnoDB; diff --git a/tests/Database/files/pgsql-nette_test4.sql b/tests/Database/files/pgsql-nette_test4.sql index b45fa6850..d215ca4a1 100644 --- a/tests/Database/files/pgsql-nette_test4.sql +++ b/tests/Database/files/pgsql-nette_test4.sql @@ -30,3 +30,9 @@ CREATE TABLE multi_pk_autoincrement( CREATE TABLE no_pk ( note varchar(100) ); + +CREATE TABLE string_pk ( + identifier1 varchar(20) NOT NULL, + note varchar(100), + PRIMARY KEY (identifier1) +); diff --git a/tests/Database/files/sqlite-nette_test4.sql b/tests/Database/files/sqlite-nette_test4.sql index e4ffe7889..232a55204 100644 --- a/tests/Database/files/sqlite-nette_test4.sql +++ b/tests/Database/files/sqlite-nette_test4.sql @@ -3,6 +3,7 @@ DROP TABLE IF EXISTS simple_pk_no_autoincrement; DROP TABLE IF EXISTS multi_pk_no_autoincrement; DROP TABLE IF EXISTS multi_pk_autoincrement; DROP TABLE IF EXISTS no_pk; +DROP TABLE IF EXISTS string_pk; CREATE TABLE simple_pk_autoincrement ( identifier1 integer PRIMARY KEY AUTOINCREMENT, @@ -25,3 +26,9 @@ CREATE TABLE multi_pk_no_autoincrement ( CREATE TABLE no_pk ( note varchar(100) ); + +CREATE TABLE string_pk ( + identifier1 varchar(20) NOT NULL, + note varchar(100), + PRIMARY KEY (identifier1) +); diff --git a/tests/Database/files/sqlsrv-nette_test4.sql b/tests/Database/files/sqlsrv-nette_test4.sql index b9b5a86c7..2c38104e7 100644 --- a/tests/Database/files/sqlsrv-nette_test4.sql +++ b/tests/Database/files/sqlsrv-nette_test4.sql @@ -3,6 +3,7 @@ IF OBJECT_ID('simple_pk_no_autoincrement', 'U') IS NOT NULL DROP TABLE simple_pk IF OBJECT_ID('multi_pk_no_autoincrement', 'U') IS NOT NULL DROP TABLE multi_pk_no_autoincrement; IF OBJECT_ID('multi_pk_autoincrement', 'U') IS NOT NULL DROP TABLE multi_pk_autoincrement; IF OBJECT_ID('no_pk', 'U') IS NOT NULL DROP TABLE no_pk; +IF OBJECT_ID('string_pk', 'U') IS NOT NULL DROP TABLE string_pk; CREATE TABLE simple_pk_autoincrement ( @@ -34,3 +35,9 @@ ALTER TABLE multi_pk_autoincrement ADD CONSTRAINT PK_multi_pk_autoincrement PRIM CREATE TABLE no_pk ( note varchar(100) ); + +CREATE TABLE string_pk ( + identifier1 varchar(20) NOT NULL, + note varchar(100), + PRIMARY KEY (identifier1) +); 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..b01e84eeb 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,48 @@ function testResultSetFetchPairs(ResultSet $resultSet): void { assertType('array', $resultSet->fetchPairs()); } + + +/** @param Selection $selection */ +function testSelectionInsertSingleRow(Selection $selection): void +{ + // Single associative array -> inserted ActiveRow (or null when the row can't be identified) + $result = $selection->insert(['name' => 'Alice']); + assertType('Nette\Database\Table\ActiveRow|null', $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); +} + + +/** + * @param Selection $selection + * @param Selection $source + */ +function testSelectionInsertMany(Selection $selection, Selection $source): void +{ + // insertMany() always returns the number of affected rows + assertType('int', $selection->insertMany([['name' => 'Alice'], ['name' => 'Bob']])); + assertType('int', $selection->insertMany($source)); +}