Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
b692bec
fixed PHPStan errors
dg Jun 6, 2026
36a2e9e
phpstan.neon: narrow ignore
dg May 27, 2026
d8e8ade
cs
dg May 31, 2026
3c5e4c7
improved phpDoc types
dg May 12, 2026
c87e70d
SqlPreprocessor: empty ?values inserts a row of database defaults on …
dg Jul 16, 2026
959066d
Selection::insert() returns number of inserted rows during performing…
dg Jun 4, 2026
0c9eda5
Selection::insert() phpDoc fixed (#326)
haltuf Apr 22, 2026
e279109
Helpers::parseColumnType() returns 'size' instead of 'length'
dg May 12, 2026
76db6b7
ConnectionPanel: use n:attributes for cleaner syntax
dg Apr 25, 2026
bb61b68
Selection: centralized referencing-cache invalidation into a private …
dg Jun 8, 2026
95437b0
Selection::insert() invalidates the referencing cache on every uniden…
dg Jun 8, 2026
81d8311
added AGENTS.md & DOCS
dg May 17, 2026
7599fe5
SqlPreprocessor: short-circuited ?and/?or no longer leaks bound param…
dg Jul 16, 2026
4451075
Row: fixed __isset() testing a column literally named 'key' instead o…
dg Jul 16, 2026
7b0cb4c
Row: offsetExists() with numeric index no longer reports falsy values…
dg Jul 16, 2026
1182d95
SqlBuilder: cache-key hashes use serialize(), json_encode() returns f…
dg Jul 16, 2026
2cca49f
SqlBuilder: WHERE and JOIN conditions are deduplicated independently,…
dg Jul 16, 2026
c98bff0
SqlBuilder: buildSelectQuery() and getParameters() no longer permanen…
dg Jul 16, 2026
a095c8d
GroupedSelection: limit() offset is no longer ignored with multiple p…
dg Jul 16, 2026
c8629f5
Selection: SELECT narrowing is disabled for tables without a primary …
dg Jul 16, 2026
a9256cc
ActiveRow: related() on a composite primary key throws NotSupportedEx…
dg Jul 16, 2026
8a485fb
Connection: transaction() no longer masks the original exception when…
dg Jul 16, 2026
1e60e3a
Connection: unknown PDO driver name reports InvalidStateException ins…
dg Jul 16, 2026
1593fc8
Drivers: completed exception mapping (SQL Server constraint violation…
dg Jul 16, 2026
042a7f8
SqlsrvDriver: applyLimit() no longer generates invalid SQL for offset…
dg Jul 16, 2026
febe4d8
SqliteDriver: autoincrement detection no longer misfires on substring…
dg Jul 16, 2026
fddcba2
Reflection: getTable() for an unknown table throws instead of returni…
dg Jul 16, 2026
beff827
MsSqlDriver, OdbcDriver: delimite() no longer doubles the [ character…
dg Jul 16, 2026
dbc0f19
MySqlDriver, PgSqlDriver: reflection fixes (composite FK and index co…
dg Jul 16, 2026
18a9acf
ConnectionPanel: fixed off-by-one in maxQueries and skips backtrace c…
dg Jul 16, 2026
aee80cb
ConnectionPanel: the BlueScreen SQL panel is registered only once, mu…
dg Jul 16, 2026
7d2b897
DatabaseExtension: a single connection is detected by the presence of…
dg Jul 16, 2026
31000b3
ResultSet: duplicate-column check runs only once per result set, matc…
dg Jul 16, 2026
a59e2b3
Helpers: loadFromFile() progress callback no longer divides by zero f…
dg Jul 16, 2026
efb41ba
DriverException: removed dead assignment in from()
dg Jul 16, 2026
9ade77d
fixed misleading phpDoc and messages: createActiveRow() promised abse…
dg Jul 16, 2026
70b5de8
ActiveRow: a column probed by isset() before it existed is no longer …
dg Jul 16, 2026
6059bb9
SqliteDriver: index primary flag comes from PRAGMA index_list origin;…
dg Jul 16, 2026
8b461bd
PgSqlDriver: getTables() resolves shadowed table names in search_path…
dg Jul 16, 2026
3549b84
MsSqlDriver: reflection no longer fatals on table names without an ex…
dg Jul 16, 2026
f1bf7f7
Selection: page() with less than one item per page throws instead of …
dg Jul 16, 2026
c87d6ae
Conventions: AmbiguousReferenceKeyException message names the table, …
dg Jul 16, 2026
a4cbc2a
tests: connect & disconnect test acquires the DSN lock, a parallel fi…
dg Jul 16, 2026
ea61603
added Selection::insertMany() - typed bulk insert returning the numbe…
dg Jul 16, 2026
eaf7754
added Connection::isInTransaction() and Explorer::isInTransaction()
dg Jul 16, 2026
7ee7539
phpstan.neon: every ignore entry is pinned by count and message where…
dg Jul 16, 2026
00b2ef1
tests: database matrix extended with PostgreSQL 16 and MariaDB 11.4, …
dg Jul 16, 2026
b785265
tests: covered DatabaseExtension config paths (PDO:: constants, refle…
dg Jul 16, 2026
b191ef5
tests: waiting for the DSN lock no longer counts towards the test tim…
dg Jul 16, 2026
426f44e
GroupedSelection: insert() no longer modifies the caller's Row objects
dg Jul 16, 2026
e66e109
CI: unpinned pdo_sqlsrv, the pin could not cover the whole PHP matrix
dg Jul 16, 2026
3f82830
opened 3.3-dev
dg Jun 7, 2026
6719dd2
requires PHP 8.3
dg Jun 7, 2026
3d0b477
used PHP 8.3 features
dg Dec 23, 2025
6527169
readme: added jumbo
dg May 16, 2024
480b672
some deprecated methods trigger notices
dg Jan 19, 2022
420bdda
composer: increased dependencies versions
dg Jun 7, 2026
7f67c8c
Reflection: added 'scale' field
dg Dec 27, 2024
27ddcc3
DatabaseExtension: added 'username' as alias for 'user'
dg Nov 4, 2024
63262c0
requires tracy 2.12
dg Apr 13, 2026
cd6863f
ConnectionPanel: added support for AI agents
dg Apr 22, 2026
eaddfe6
Connection, Explorer: transaction() supports retry on deadlock, added…
dg Apr 21, 2026
c5c1c23
added EntityMapping for row class mapping and `mapping` config option
dg Mar 9, 2026
3efab6d
EntityMapping: added column-to-property name translation
dg May 20, 2026
cecc9bf
ActiveRow: split into Row interface and RowBehavior trait
dg Jul 16, 2026
0fe2b92
ActiveRow: subclasses can declare typed public properties for IDE/sta…
dg Jun 7, 2026
32e0c80
ActiveRow: auto-converts BackedEnum columns based on declared propert…
dg May 17, 2026
69617db
Selection: insertMany() took over the bulk logic, bulk insert() is de…
dg Jun 7, 2026
a72bf2f
Selection::insert() returns a lazy row
dg Jun 8, 2026
878406c
Selection::insert() returns null instead of the input array for an un…
dg Jun 8, 2026
2578dc4
Selection::insert() returns null consistently when no row can be iden…
dg Jun 8, 2026
8b9084d
Selection::insert(): simplified primary-key resolution to always retu…
dg Jun 9, 2026
7aa17fc
SqlPreprocessor: a multi-insert row with a missing or unexpected colu…
dg Jul 16, 2026
ff42a97
SqlBuilder: tryDelimite() warns about unsupported string literals
dg Jul 16, 2026
9d32efb
GroupedSelection: an aggregate over a group without rows returns null
dg Jul 16, 2026
90dd5f4
Fetching rows as native arrays
Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -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:*)"
]
}
}
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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<ProductRow>`,
`@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.
13 changes: 8 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -43,7 +46,7 @@
},
"extra": {
"branch-alias": {
"dev-master": "3.2-dev"
"dev-master": "3.3-dev"
}
},
"config": {
Expand Down
27 changes: 27 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions docs/internals/connection-drivers.md
Original file line number Diff line number Diff line change
@@ -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.
Loading