This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Nette Database is a database abstraction layer for PHP providing two components:
- Database Core - PDO wrapper with advanced query building and parameter substitution
- Database Explorer - ActiveRow pattern implementation (inspired by NotORM) for efficient relationship handling
Supports PHP 8.1-8.5 and multiple database engines (MySQL, PostgreSQL, SQLite, MS SQL Server, Oracle).
# Run all tests
composer run tester
# Run specific test directory
vendor/bin/tester tests/Database/Explorer -s -C
# Run single test file (use php directly, not through tester)
php tests/Database/Explorer/Explorer.basic.phpt
# Run tests with database fixtures
vendor/bin/tester tests/Database -c tests/php.ini -s -CTest framework: Nette Tester with .phpt extension
- Tests use
@dataProviderto run against multiple databases (databases.ini) - Each test uses
test()function with descriptive names - No comments before
test()calls - the description parameter serves this purpose
# Run PHPStan
composer run phpstanConfiguration: PHPStan level 5 with phpstan-nette extension
Core Layer (src/Database/)
Connection- PDO connection management, query execution, transactionsSqlPreprocessor- Advanced parameter substitution with multiple modes (ModeAnd, ModeSet, ModeValues, ModeOrder, ModeList)ResultSet- Iterator-based result setsDriverinterface + database-specific implementationsStructure- Cached database metadata (tables, columns, foreign keys, indexes)Reflection- On-demand schema reflection with lazy loading
Explorer Layer (src/Database/Table/)
Explorer- Entry point for high-level database operationsSelection- Fluent query builder with lazy loading and generic type support (Selection<T>)ActiveRow- Represents database rows with relationship navigationGroupedSelection- Handles grouped/aggregated queriesSqlBuilder- Constructs SQL from Selection API calls
ActiveRecord with Automatic N+1 Prevention
// Automatically batches related queries - constant query count regardless of result size
foreach ($books as $book) {
echo $book->author->name; // First iteration: SELECT * FROM author WHERE id IN (...)
}Convention-based Relationships
DiscoveredConventions- Auto-discovers from database structureStaticConventions- Manual configuration- Forward references:
$book->author(via foreign key) - Back-references:
$author->related('book')(reverse relationship)
Relationship Navigation with Colon Notation
->where('category.slug', $slug) // Forward reference via foreign key
->where(':order_item.quantity >', 1) // Back-reference (reverse relationship)
->where('category.parent.name', 'Root') // Deep relationship traversalSmart Query Optimization
- Accessed column caching - only SELECTs columns actually used after first query
- Automatic JOIN batching for related data
- Two-level caching: Structure cache (metadata) + accessed columns cache
Each driver implements Driver interface with database-specific features:
- SQL formatting (delimiters, datetime, LIKE encoding)
- LIMIT/OFFSET application (varies by database)
- Schema reflection
- Exception mapping (PDOException → specific constraint violations)
- Feature detection (sequences, schemas, ungrouped columns support)
Exception Hierarchy:
DriverException
├── ConnectionException
└── ConstraintViolationException
├── ForeignKeyConstraintViolationException
├── NotNullConstraintViolationException
└── UniqueConstraintViolationException
- Every file must include
declare(strict_types=1) - All parameters, properties, and return values must have types
- Use generic type annotations for IDE support:
@template T of ActiveRow - Use single quotes for strings unless containing apostrophes
- Follow Nette Coding Standard (PSR-12 based)
- Document class purpose concisely without "Class that..." phrases
- Only document parameters/returns when adding info beyond PHP types
- For arrays, specify contents:
@return string[]or@param ActiveRow[] $rows - Use single-line format for simple properties:
/** @var string[] */ - Start method docs with 3rd person singular present tense verb (Returns, Formats, Checks, Creates)
test('descriptive name of what is being tested', function () use ($explorer) {
$result = $explorer->table('book')->where('id', 1)->fetch();
Assert::same('expected', $result->title);
});
testException('throws exception for invalid input', function () use ($explorer) {
$explorer->table('book')->get(999);
}, Nette\InvalidArgumentException::class, 'Expected message pattern');Always use generic annotations for Selection returns:
/** @return Selection<ProductRow> */
public function getProducts(): Selection
{
return $this->db->table('product');
}Context-aware array expansion in SqlPreprocessor:
ModeAnd- WHERE/HAVING conditions:['name' => 'John', 'age >' => 18]→name = 'John' AND age > 18ModeSet- UPDATE SET clauses:['name' => 'John', 'age' => 30]→name = 'John', age = 30ModeValues- INSERT/REPLACE statementsModeOrder- ORDER BY/GROUP BY clausesModeList- IN clauses:[1, 2, 3]→IN (1, 2, 3)
Catch specific constraint violations, not generic PDOException:
try {
$row->update(['email' => $newEmail]);
} catch (Nette\Database\UniqueConstraintViolationException $e) {
// Handle unique constraint violation
}Follow foreign key naming without _id suffix:
- Database column:
author_id - Relationship property:
$book->author(not$book->author_id)
Use Selection API for:
- Standard CRUD operations
- Simple filtering and sorting
- Queries benefiting from lazy loading
- Dynamic condition chaining
Use raw SQL for:
- Complex analytics and reporting
- Recursive queries (WITH RECURSIVE)
- Performance-critical queries
- Complex joins awkward in fluent API
- First query fetches all columns:
SELECT * FROM book - Subsequent queries use accessed column cache:
SELECT id, title, author_id FROM book - Cache persists across requests via Nette\Caching\Storage
- Test environment uses MemoryStorage for isolation
tests/Database/
├── Connection.*.phpt # Core connection functionality
├── ResultSet.*.phpt # Result fetching methods
├── SqlPreprocessor.phpt # Query preprocessing
├── Structure.phpt # Metadata caching
├── Explorer/ # Explorer layer tests
│ ├── Explorer.*.phpt # Main Explorer functionality
│ ├── Selection.*.phpt # Selection API methods
│ ├── bugs/ # Regression tests
│ └── SqlBuilder.*.phpt # Query building
├── Drivers/ # Database-specific driver tests
├── Conventions/ # Relationship convention tests
└── files/ # SQL fixtures for test databases
Database fixtures: Tests use @dataProvider databases.ini to run against multiple database engines. Fixtures in files/ directory named by pattern: {driver}-nette_test1.sql
DatabaseExtension (src/Bridges/DatabaseDI/DatabaseExtension.php) provides:
- Nette DI container integration
- Multi-database configuration support
- Auto-wiring for Connection, Structure, and Explorer services
- Tracy debugger integration via ConnectionPanel
The SQL preprocessor uses special placeholders for different contexts:
| Placeholder | Purpose | Auto-detected for |
|---|---|---|
? |
Standard parameter placeholder | - |
?name |
Table/column identifier (properly quoted) | - |
?values |
INSERT format: (col1, col2) VALUES (?, ?) |
INSERT ... ?, REPLACE ... ? |
?set |
UPDATE format: col1 = ?, col2 = ? |
SET ?, ON DUPLICATE KEY UPDATE ? |
?and |
WHERE format with AND: col1 = ? AND col2 = ? |
WHERE ?, HAVING ? |
?or |
WHERE format with OR: col1 = ? OR col2 = ? |
- |
?order |
ORDER BY format | ORDER BY ?, GROUP BY ? |
Examples:
// Dynamic identifiers (use only with trusted values!)
$database->query('SELECT ?name FROM ?name', $column, $table);
// SELECT `name` FROM `users` (MySQL)
// OR conditions (auto-detection uses AND by default)
$database->query('SELECT * FROM users WHERE ?or', [
'name' => 'John',
'email' => 'john@example.com',
]);
// WHERE `name` = 'John' OR `email` = 'john@example.com'
// Context-aware array processing
$database->query('INSERT INTO users ?', ['name' => 'John', 'year' => 1994]);
// INSERT INTO users (`name`, `year`) VALUES ('John', 1994)
$database->query('UPDATE users SET ? WHERE id = ?', ['name' => 'John'], 1);
// UPDATE users SET `name` = 'John' WHERE id = 1
$database->query('SELECT * FROM users WHERE', ['active' => true, 'role' => 'admin']);
// WHERE `active` = 1 AND `role` = 'admin'Special value types:
$database->query('INSERT INTO articles', [
'title' => 'My Article',
'published_at' => new DateTime, // Converted to database format
'content' => fopen('image.png', 'r'), // Binary file content
'state' => Status::Draft, // Enum converted to value
'updated_at' => $database::literal('NOW()'), // SQL literal - not escaped
]);Never use unvalidated user input directly in arrays for INSERT/UPDATE/WHERE:
// ❌ DANGEROUS - attacker can set any column
$database->query('INSERT INTO users', $_POST);
$table->insert($_POST);
$table->where($_POST);
// ❌ DANGEROUS - attacker can use operators and SQL injection
$_POST['salary >'] = 100000;
$table->where($_POST); // WHERE `salary` > 100000
$_POST['0) UNION SELECT password FROM users WHERE (1'] = true;
$table->where($_POST); // SQL injection via array keysAlways use column whitelist:
// ✅ Safe - only allowed columns
$allowedColumns = ['name', 'email', 'active'];
$filteredData = array_intersect_key($userData, array_flip($allowedColumns));
$database->query('INSERT INTO users', $filteredData);
$table->update($filteredData);
$table->where($filteredData);Use parameterized queries, never string concatenation:
// ❌ DANGEROUS
$table->where('name = ' . $_GET['name']);
$table->where("name = '$_GET[name]'");
$database->query("SELECT * FROM users WHERE name = '$name'");
// ✅ Safe
$table->where('name = ?', $name);
$database->query('SELECT * FROM users WHERE name = ?', $name);For dynamic identifiers, use whitelist + ?name:
// ✅ Safe - validate against whitelist first
$allowedColumns = ['name', 'email', 'created_at'];
if (!in_array($column, $allowedColumns)) {
throw new InvalidArgumentException('Invalid column');
}
$database->query('SELECT ?name FROM users', $column);database:
# Single connection
dsn: 'mysql:host=127.0.0.1;dbname=test'
user: root
password: password
# Options
debugger: true # Tracy panel
explain: true # Show EXPLAIN in Tracy
autowired: true # Enable autowiring
conventions: discovered # discovered|static|ClassName
options:
lazy: false # Connect only when needed
charset: utf8mb4 # MySQL: SET NAMES
sqlmode: '' # MySQL: SET sql_mode
convertBoolean: false # MySQL: TINYINT(1) → bool
newDateTime: false # Return DateTimeImmutable instead of DateTime
formatDateTime: 'U' # SQLite/Oracle: DateTime format (default: Unix timestamp)
# Multiple connections
database:
main:
dsn: 'mysql:host=127.0.0.1;dbname=test'
user: root
password: password
autowired: true # Only first connection is autowired by default
another:
dsn: 'sqlite::memory:'
autowired: false
# DI service names
services:
# Explicitly reference non-autowired connections
- UserFacade(@database.another.connection)Service names: database.{name}.connection and database.{name}.explorer
where() - Automatic operator detection:
$table->where('id', 1); // WHERE `id` = 1
$table->where('id', null); // WHERE `id` IS NULL
$table->where('id', [1, 2, 3]); // WHERE `id` IN (1, 2, 3)
$table->where('id NOT', [1, 2, 3]); // WHERE `id` NOT IN (1, 2, 3)
$table->where('id', []); // WHERE `id` IS NULL AND FALSE (finds nothing)
// Array syntax with explicit operators
$table->where([
'age >' => 18,
'name LIKE' => 'John%',
'status' => ['active', 'pending'],
]);
// Subqueries
$table->where('id', $explorer->table('other')->select('foreign_id'));whereOr() - OR conditions:
$table->whereOr([
'status' => 'active',
'deleted' => false,
]);
// WHERE (`status` = 'active') OR (`deleted` = 0)wherePrimary() - Primary key filtering:
$table->wherePrimary(123); // WHERE `id` = 123
$table->wherePrimary([1, 2, 3]); // WHERE `id` IN (1, 2, 3)
// Composite primary key
$table->wherePrimary(['foo_id' => 1, 'bar_id' => 5]);
// WHERE `foo_id` = 1 AND `bar_id` = 5Accessing parent (1:N from child perspective):
$book = $explorer->table('book')->get(1);
echo $book->author->name; // Via author_id column
echo $book->translator?->name; // Via translator_id column (nullable)
// Alternative: ref() method
$book->ref('author', 'author_id')->name;
$book->ref('author', 'translator_id')->name;Accessing children (1:N from parent perspective):
$author = $explorer->table('author')->get(1);
// Explicit column specification
foreach ($author->related('book.author_id') as $book) {
echo $book->title;
}
// Auto-detection (uses book.author_id based on parent table name)
foreach ($author->related('book') as $book) {
echo $book->title;
}
// Translated books
foreach ($author->related('book.translator_id') as $book) {
echo $book->title;
}Many-to-many relationships:
$book = $explorer->table('book')->get(1);
// Go through junction table first
foreach ($book->related('book_tag') as $bookTag) {
echo $bookTag->tag->name; // Then access final table
}Querying through relationships (automatic JOINs):
// Dot notation (forward: 1:N from child)
$books->where('author.name LIKE ?', 'Jon%');
$books->order('author.name DESC');
$books->select('book.title, author.name');
// Colon notation (backward: 1:N from parent)
$authors->where(':book.title LIKE ?', '%PHP%');
$authors->select('*, COUNT(:book.id) AS book_count')->group('author.id');
// Specify joining column explicitly
$authors->where(':book(translator_id).title LIKE ?', '%PHP%');
// Chain relationships
$authors->where(':book:book_tag.tag.name', 'PHP');joinWhere() - Extend JOIN conditions:
// Adds condition to JOIN ON clause (not WHERE clause)
$books = $explorer->table('book')
->joinWhere('translator', 'translator.name', 'David');
// LEFT JOIN author translator ON book.translator_id = translator.id
// AND (translator.name = 'David')
// With alias for complex queries
$tags = $explorer->table('tag')
->joinWhere(':book_tag.book.author', 'book_author.born < ?', 1950)
->alias(':book_tag.book.author', 'book_author');insert() - Returns ActiveRow for single insert, int for multi-insert:
// Single insert
$row = $explorer->table('users')->insert([
'name' => 'John',
'email' => 'john@example.com',
]);
echo $row->id; // Auto-generated ID
// Multi-insert
$count = $explorer->table('users')->insert([
['name' => 'John', 'year' => 1994],
['name' => 'Jack', 'year' => 1995],
]);
// Returns number of inserted rows
// Insert from selection
$newUsers = $explorer->table('potential_users')
->where('approved', 1)
->select('name, email');
$explorer->table('users')->insert($newUsers);update() - Returns number of affected rows:
$affected = $explorer->table('users')
->where('id', 10)
->update([
'name' => 'John Smith',
'points+=' => 1, // Increment
'coins-=' => 1, // Decrement
]);
// ActiveRow::update() - updates single row, returns true if changed
$article = $explorer->table('article')->get(1);
$changed = $article->update(['views+=' => 1]);delete() - Returns number of deleted rows:
$count = $explorer->table('users')
->where('id', 10)
->delete();
// ActiveRow::delete()
$book = $explorer->table('book')->get(1);
$book->delete();$table->count('*'); // SELECT COUNT(*) FROM table
$table->count('DISTINCT column'); // SELECT COUNT(DISTINCT column)
$table->min('price'); // SELECT MIN(price)
$table->max('price'); // SELECT MAX(price)
$table->sum('price * quantity'); // SELECT SUM(price * quantity)
// Generic aggregation
$avgPrice = $products->where('category_id', 1)
->aggregation('AVG(price)');
// Aggregation over grouped results
$totalPrice = $products
->select('category_id, SUM(price * stock) AS category_total')
->group('category_id')
->aggregation('SUM(category_total)', 'SUM');Automatic transaction management:
// Preferred: automatic commit/rollback
$result = $database->transaction(function ($database) use ($id) {
$database->query('DELETE FROM articles WHERE id = ?', $id);
$database->query('INSERT INTO audit_log', [
'article_id' => $id,
'action' => 'delete',
]);
return $database->getInsertId(); // Can return values
});
// Manual control
$database->beginTransaction();
try {
$database->query('...');
$database->commit();
} catch (\Exception $e) {
$database->rollBack();
throw $e;
}Introspect database structure:
$reflection = $database->getReflection();
// Tables
foreach ($reflection->tables as $name => $table) {
echo $table->name;
echo $table->view ? 'VIEW' : 'TABLE';
echo $table->fullName; // Including schema if exists
}
if ($reflection->hasTable('users')) {
$table = $reflection->getTable('users');
}
// Columns
foreach ($table->columns as $name => $column) {
echo "$column->name: $column->nativeType";
echo $column->nullable ? 'NULL' : 'NOT NULL';
echo $column->default;
echo $column->autoIncrement ? 'AUTO_INCREMENT' : '';
echo $column->primary ? 'PRIMARY KEY' : '';
}
// Indexes
foreach ($table->indexes as $index) {
$columns = implode(', ', array_map(fn($col) => $col->name, $index->columns));
echo ($index->primary ? 'PRIMARY KEY' : 'INDEX') . ": $columns";
echo $index->unique ? 'UNIQUE' : '';
}
// Primary key
if ($primaryKey = $table->primaryKey) {
$columns = implode(', ', array_map(fn($col) => $col->name, $primaryKey->columns));
}
// Foreign keys
foreach ($table->foreignKeys as $fk) {
$localCols = implode(', ', array_map(fn($col) => $col->name, $fk->localColumns));
$foreignCols = implode(', ', array_map(fn($col) => $col->name, $fk->foreignColumns));
echo "$localCols -> {$fk->foreignTable->name}($foreignCols)";
}