diff --git a/.phan/config.php b/.phan/config.php index 20a8999..2ef46a8 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -91,6 +91,8 @@ 'PhanUnreferencedProtectedProperty', 'PhanUnusedVariableValueOfForeachWithKey', 'PhanNonClassMethodCall', + 'PhanUnreferencedClass', + 'PhanUnextractableAnnotationSuffix', ], // A list of directories that should be parsed for class and diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..03291dc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,823 @@ +# AGENTS.md - DealNews Database Library + +> **AI Agent Context Document** +> This file provides comprehensive context for AI agents working with the DealNews Database Library. + +## Library Overview + +**Name**: `dealnews/db` +**Type**: PHP Library +**License**: BSD-3-Clause +**PHP Version**: ^8.2 +**Purpose**: Database abstraction library providing PDO connection factory, CRUD operations, and data mapper pattern implementation + +This library simplifies database operations by providing: +- Factory pattern for creating PDO connections from configuration +- CRUD helper wrapping common PDO operations +- Data mapper pattern implementation for separating objects from persistence +- Automatic retry logic for transient database errors +- Support for MySQL, PostgreSQL, and generic PDO connections + +## Core Architecture + +### Component Hierarchy + +``` +DealNews\DB\ +├── Factory # Creates PDO connections from config +├── PDO # Wrapper adding retry/reconnect logic +├── PDOStatement # Wrapper adding retry logic to statements +├── CRUD # Helper for basic CRUD operations +├── AbstractMapper # Base class for data mappers +├── ColumnMapper # Maps array columns to/from tables +└── Util\ + ├── Query # Fluent SQL query builder for complex SELECTs + ├── Raw # Value object for raw SQL fragments + └── Search\Text # Creates SQL LIKE clauses from search strings +``` + +### Key Design Patterns + +1. **Factory Pattern**: `Factory::init()` creates database connections +2. **Singleton Pattern**: CRUD instances cached per database name +3. **Data Mapper Pattern**: Separates domain objects from database persistence +4. **Decorator Pattern**: PDO/PDOStatement wrappers add resilience +5. **Template Method Pattern**: AbstractMapper defines save/load flow + +## Configuration + +### Database Configuration (config.ini) + +Located in `[app home]/etc/config.ini`: + +```ini +db.mydb.type = mysql # mysql, pgsql, or pdo +db.mydb.server = 127.0.0.1 # comma-separated list +db.mydb.port = 3306 # optional, defaults vary +db.mydb.db = database_name # database name +db.mydb.user = username # optional for some drivers +db.mydb.pass = password # optional for some drivers +db.mydb.charset = utf8mb4 # mysql only, defaults to utf8mb4 +db.mydb.options = {"key": "value"} # JSON-encoded PDO options +db.mydb.table_prefix = prefix # optional table prefix +``` + +Alternative prefix: +```ini +db.factory.prefix = custom_prefix +custom_prefix.mydb.type = mysql +``` + +## Core Components + +### 1. Factory (`DealNews\DB\Factory`) + +**Purpose**: Creates PDO connection objects from configuration. + +**Key Methods**: +- `init(string $db, ?array $options = null, ?string $type = null): PDO` +- `build(array $config): PDO` +- `loadConfig(array $config, ?array $options = null, ?string $type = null): array` +- `getConfig(string $db, ?GetConfig $cfg = null): array` + +**Usage**: +```php +$pdo = \DealNews\DB\Factory::init("mydb"); +``` + +**Important Behaviors**: +- Returns singleton instances (cached per db+type+options) +- Shuffles multiple servers for load balancing +- Auto-detects database type from config (defaults to mysql) +- Merges options from config and parameters + +### 2. PDO (`DealNews\DB\PDO`) + +**Purpose**: Wraps \PDO with automatic retry and reconnection logic. + +**Key Features**: +- Automatic retry for deadlocks and transient errors (up to 3 attempts) +- Automatic reconnection for connection failures +- Default fetch mode: `PDO::FETCH_ASSOC` +- Default error mode: `PDO::ERRMODE_EXCEPTION` +- Emulated prepares for retry compatibility +- Default timeout: 10 seconds + +**Retry Error Codes**: +- MySQL: 1422 (commit in trigger), 1213 (deadlock), 1205 (lock timeout) +- PostgreSQL: 40000-40003, 40P01 (serialization/deadlock errors) + +**Reconnect Error Codes**: +- MySQL: Connection errors (1040, 2002, 2003, 2006, 2013, etc.) +- PostgreSQL: Connection errors (08000, 08003, 08006, etc.) + +**Usage**: +```php +$pdo->connect(); // Lazy connect +$pdo->connect(true); // Force reconnect +$pdo->ping(); // Test connection +$pdo->close(); // Close connection +``` + +### 3. CRUD (`DealNews\DB\CRUD`) + +**Purpose**: Simplifies common database operations with prepared statements. + +**Key Methods**: +- `create(string $table, array $data): bool` +- `read(string $table, array $data = [], ?int $limit = null, ?int $start = null, array $fields = ['*'], string $order = ''): array` +- `update(string $table, array $data, array $where): bool` +- `delete(string $table, array $data): bool` +- `run(string $query, array $params = []): PDOStatement` +- `runFetch(string $query, array $params = []): array` + +**Factory Method**: +```php +$crud = \DealNews\DB\CRUD::factory('mydb'); +``` + +**Query Building**: +- Field names automatically quoted (backticks for MySQL, double-quotes otherwise) +- Parameters use named placeholders (`:field_name`) +- WHERE clauses support AND/OR logic with nested arrays +- Array values generate OR clauses: `['id' => [1, 2, 3]]` → `(id = :id0 OR id = :id1 OR id = :id2)` + +**Advanced Filters**: +```php +// AND logic (default) +['status' => 'active', 'age' => 25] +// (status = :status0 AND age = :age0) + +// OR logic +['OR' => ['status' => 'active', 'age' => 25]] +// (status = :status1 OR age = :age1) + +// Mixed logic with nesting +[ + 'status' => 'active', + ['OR' => ['age' => 25, 'name' => 'John']] +] +// (status = :status0 AND (age = :age1 OR name = :name1)) +``` + +### 4. AbstractMapper (`DealNews\DB\AbstractMapper`) + +**Purpose**: Base class for implementing the data mapper pattern. + +**Required Constants**: +```php +public const DATABASE_NAME = 'mydb'; # Config name +public const TABLE = 'books'; # Table name +public const PRIMARY_KEY = 'id'; # Primary key column +public const SEQUENCE_NAME = null; # For PostgreSQL sequences +public const MAPPED_CLASS = Book::class; # Value object class +public const MAPPING = [ # Property-to-column mapping + 'id' => [], + 'title' => [], + 'author' => [], +]; +``` + +**Key Methods**: +- `load($id): ?object` - Load single object by primary key +- `loadMulti(array $ids): ?array` - Load multiple objects +- `find(array $filter, ?int $limit = null, ?int $start = null, string $order = ''): ?array` +- `save($object): object` - Insert or update (returns reloaded object) +- `delete($id): bool` - Delete by primary key + +**Mapping Configuration**: + +Basic property mapping: +```php +public const MAPPING = [ + 'property_name' => [], # Auto-maps to column 'property_name' +]; +``` + +Column name override: +```php +'property_name' => ['column' => 'different_column_name'] +``` + +Type casting: +```php +'created_at' => ['type' => 'datetime'] # Converts to DateTime +'price' => ['type' => 'float'] +'active' => ['type' => 'boolean'] +``` + +**Relational Mapping**: + +One-to-Many (foreign key in related table): +```php +'comments' => [ + 'mapper' => CommentMapper::class, + 'foreign_column' => 'post_id', # Column in comments table +] +``` + +Many-to-Many (lookup/xref table): +```php +'tags' => [ + 'type' => 'lookup', + 'mapper' => TagMapper::class, + 'table' => 'post_tags', # Lookup table + 'primary_key' => 'id', # Lookup table PK + 'foreign_column' => 'post_id', # Foreign key to this object + 'mapper_column' => 'tag_id', # Foreign key to related object +] +``` + +**Transaction Behavior**: +- `save()` creates transaction if not already in one +- Nested `save()` calls reuse existing transaction +- Automatic rollback on exceptions +- Commits only at outermost transaction level + +### 5. ColumnMapper (`DealNews\DB\ColumnMapper`) + +**Purpose**: Maps arrays to/from a single column in a related table. + +**Use Case**: When you need to store multiple simple values (not objects) in a separate table. + +**Example**: +```php +// Map array of email addresses to email_addresses table +'emails' => [ + 'mapper' => ColumnMapper::class, + 'table' => 'user_emails', + 'primary_key' => 'id', + 'foreign_column' => 'user_id', + 'column' => 'email', +] +``` + +**Behavior**: +- `load()` returns array of column values +- `save()` diffs existing vs new, adds/removes as needed +- Participates in parent transaction + +### 6. Util\Search\Text (`DealNews\DB\Util\Search\Text`) + +**Purpose**: Converts user search strings to SQL LIKE clauses. + +**Features**: +- Quoted strings for exact matches: `"exact phrase"` +- Boolean AND (space): `term1 term2` +- Boolean OR (comma): `term1, term2` +- NOT modifier: `-unwanted` +- Grouping: `(term1, term2) term3` +- Anchors: `^start` (starts with), `end$` (ends with) + +**Usage**: +```php +$search = \DealNews\DB\Util\Search\Text::init(); +$like_clause = $search->createLikeString(['title', 'description'], 'foo bar'); +// Returns: ((title LIKE '%foo%' OR description LIKE '%foo%') AND (title LIKE '%bar%' OR description LIKE '%bar%')) +``` + +### 7. Util\Query (`DealNews\DB\Util\Query`) + +**Purpose**: Fluent SQL query builder for complex SELECT queries. + +**Features**: +- Fluent method chaining API +- SELECT with column aliases and raw expressions +- JOINs: INNER, LEFT, RIGHT +- WHERE conditions: AND, OR, IN, NOT IN, NULL, nested groups +- GROUP BY and HAVING +- ORDER BY with multiple columns +- LIMIT/OFFSET with driver-aware syntax (MySQL vs PostgreSQL) +- Raw SQL fragments via `Query::raw()` +- Automatic parameter binding + +**Usage**: +```php +$crud = \DealNews\DB\CRUD::factory('mydb'); +$query = new \DealNews\DB\Util\Query($crud); + +$query->select([ + 'u.id', + 'u.name', + Query::raw('COUNT(p.id) AS post_count'), + ]) + ->from('users', 'u') + ->leftJoin('posts', 'p', 'p.user_id', '=', 'u.id') + ->where('u.status', '=', 'active') + ->where(function ($q) { + $q->where('u.role', '=', 'admin') + ->orWhere('u.role', '=', 'moderator'); + }) + ->groupBy(['u.id', 'u.name']) + ->having('post_count', '>', 5) + ->orderBy('post_count', 'DESC') + ->limit(20) + ->offset(40); + +$rows = $crud->runFetch($query->getSql(), $query->getParams()); +``` + +**Key Methods**: +- `select(array $columns)` - Set columns (strings, `Query::raw()`, or subqueries) +- `from(string $table, ?string $alias)` - Set FROM table +- `join()`, `innerJoin()`, `leftJoin()`, `rightJoin()` - Add JOIN clauses +- `where()`, `orWhere()` - Add WHERE (supports callable for nesting) +- `whereIn()`, `whereNotIn()`, `whereNull()`, `whereNotNull()`, `whereRaw()` +- `groupBy(array $columns)` - GROUP BY +- `having()`, `orHaving()` - HAVING conditions +- `orderBy(string $column, string $direction)` - ORDER BY +- `limit(int)`, `offset(int)` - Pagination +- `getSql()` - Build and return SQL string +- `getParams()` - Get bound parameters array +- `Query::raw(string $value, array $params)` - Create raw SQL fragment + +**Error Handling**: +- Throws `\LogicException` with unique codes for invalid operators, directions, JOIN types +- Validates required SELECT and FROM before building + +### 8. Util\Raw (`DealNews\DB\Util\Raw`) + +**Purpose**: Value object for raw SQL fragments in Query builder. + +**Usage**: +```php +// Static factory (preferred) +$raw = \DealNews\DB\Util\Query::raw('COUNT(*)', [':min' => 100]); + +// Direct instantiation +$raw = new \DealNews\DB\Util\Raw('YEAR(created_at) = ?', [2024]); + +// Use in Query +$query->select([ + 'id', + Query::raw('CONCAT(first, " ", last) AS full_name'), +]); +``` + +## Code Generation Tool + +### bin/create_objects.php + +**Purpose**: Generates value objects and mapper classes from database schema. + +**Usage**: +```bash +./vendor/bin/create_objects.php \ + --db mydb \ + --namespace MyApp\\Data \ + --table users \ + --dir src/Data +``` + +**Options**: +- `--db DBNAME` - Database config name (required) +- `--schema SCHEMA` - Schema name if different from db name +- `--table TABLE` - Table to generate from (required) +- `--namespace NAMESPACE` - Base namespace (required) +- `--dir DIR` - Output directory (default: src) +- `--ini-file FILE` - Config file (default: etc/config.ini) +- `--base-class CLASS` - Optional base class for value objects +- `-v, -vv, -vvv` - Verbosity levels +- `-q` - Quiet mode + +**Generated Files**: +- Value object class with typed properties +- Mapper class extending AbstractMapper +- PHPDoc blocks with type information + +**Recommended Base Class**: [Moonspot\ValueObjects](https://github.com/brianlmoon/value-objects) for easier manipulation. + +## Testing + +### Test Structure + +``` +tests/ +├── bootstrap.php # Test setup +├── RequireDatabase.php # Trait for functional tests +├── setup.sh / teardown.sh # Container management +├── containers/ # Docker configs for MySQL/PostgreSQL +├── fixtures/ # Test data +└── chinook.db # SQLite test database +``` + +### Running Tests + +**Unit tests only** (default): +```bash +composer test +``` + +**Functional tests** (requires Docker): +```bash +phpunit --group functional +``` + +**Requirements for functional tests**: +- Docker host machine +- PHP extensions: pdo_pgsql, pdo_mysql, pdo_sqlite + +### Test Groups +- `unit` - Fast, no database required +- `functional` - Requires database containers + +## Coding Standards + +### General Principles + +1. **Brace Style**: 1TBS (opening brace on same line) +2. **Visibility**: Use `protected` over `private` for testability +3. **Type Hints**: Always declare parameter and return types +4. **Return Values**: Single return point (except early validation) +5. **Naming**: `snake_case` for variables/properties, `PascalCase` for classes +6. **Arrays**: Short syntax `[]`, trailing commas in multi-line +7. **Line Length**: 80 characters preferred +8. **Whitespace**: Unix `\n`, no trailing whitespace + +### PHP-Specific Rules + +**Property Types**: +```php +// Good +public string $name = ''; +public ?DateTime $created_at = null; + +// Avoid +public $name; +``` + +**Functions/Methods**: +```php +// Good +public function doSomething(int $foo, int $bar): ?string { + $result = null; + if ($condition) { + $result = 'value'; + } + return $result; +} + +// Avoid multiple returns +public function doSomething(int $foo, int $bar) { + if ($condition) { + return 'value'; + } + return null; +} +``` + +**No Pass-by-Reference**: +```php +// Good +public function modify(object $obj): object { + return $obj; +} + +// Avoid +public function modify(object &$obj) {} +``` + +**Constants over Static Variables**: +```php +// Good +public const CONFIG = ['key' => 'value']; + +// Avoid +public static $config = ['key' => 'value']; +``` + +### PHPDoc Requirements + +All public classes, methods, and functions require PHPDoc: + +```php +/** + * Brief description of what this does + * + * @param int $id The primary key + * @param array $options Additional options + * + * @return object|null + * + * @throws \PDOException + * @throws \LogicException + */ +public function load(int $id, array $options = []): ?object { + // implementation +} +``` + +## Common Patterns and Best Practices + +### Pattern: Loading Related Data + +```php +class PostMapper extends AbstractMapper { + // ... constants ... + + public const MAPPING = [ + 'id' => [], + 'title' => [], + 'content' => [], + // One-to-many: load all comments for this post + 'comments' => [ + 'mapper' => CommentMapper::class, + 'foreign_column' => 'post_id', + ], + // Many-to-many: load tags via lookup table + 'tags' => [ + 'type' => 'lookup', + 'mapper' => TagMapper::class, + 'table' => 'post_tags', + 'primary_key' => 'id', + 'foreign_column' => 'post_id', + 'mapper_column' => 'tag_id', + ], + // Array column: simple values in related table + 'keywords' => [ + 'mapper' => ColumnMapper::class, + 'table' => 'post_keywords', + 'primary_key' => 'id', + 'foreign_column' => 'post_id', + 'column' => 'keyword', + ], + ]; +} +``` + +### Pattern: Custom Mapper Logic + +Override `getData()` or `setData()` for complex transformations: + +```php +class UserMapper extends AbstractMapper { + protected function getData($object): array { + $data = parent::getData($object); + // Custom serialization + if (isset($data['preferences'])) { + $data['preferences'] = json_encode($data['preferences']); + } + return $data; + } + + protected function setData(array $data): object { + // Custom deserialization + if (isset($data['preferences'])) { + $data['preferences'] = json_decode($data['preferences'], true); + } + return parent::setData($data); + } +} +``` + +### Pattern: Complex Queries + +For queries beyond simple CRUD: + +```php +$crud = CRUD::factory('mydb'); + +// Raw query with parameters +$stmt = $crud->run( + "SELECT u.*, COUNT(p.id) as post_count + FROM users u + LEFT JOIN posts p ON u.id = p.user_id + WHERE u.status = :status + GROUP BY u.id + HAVING post_count > :min_posts", + [ + ':status' => 'active', + ':min_posts' => 10, + ] +); +$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); +``` + +### Pattern: Transaction Management + +```php +$crud = CRUD::factory('mydb'); +$crud->pdo->beginTransaction(); + +try { + $crud->create('users', ['name' => 'John']); + $user_id = $crud->pdo->lastInsertId(); + + $crud->create('profiles', [ + 'user_id' => $user_id, + 'bio' => 'Developer', + ]); + + $crud->pdo->commit(); +} catch (\Throwable $e) { + $crud->pdo->rollBack(); + throw $e; +} +``` + +### Pattern: Custom Update Constraints + +Override `getUpdateConstraint()` for composite keys or custom logic: + +```php +protected function getUpdateConstraint(object $object): array { + return [ + 'tenant_id' => $object->tenant_id, + 'user_id' => $object->user_id, + ]; +} +``` + +## Dependencies + +### Required +- `php: ^8.2` +- `dealnews/console: ^0.2.1` - CLI option parsing +- `dealnews/data-mapper: ^3.4` - Base mapper functionality +- `dealnews/get-config: ^2.2` - Configuration management + +### Dev Dependencies +- `friendsofphp/php-cs-fixer: ^3.89` - Code formatting +- `php-parallel-lint/php-parallel-lint: ^1.4` - Syntax checking +- `phpunit/phpunit: ^11.5` - Testing framework + +## Scripts and Tooling + +**Composer Scripts**: +```bash +composer test # Lint + unit tests +composer lint # PHP syntax check +composer fix # Auto-fix code style +composer phan # Static analysis (if configured) +``` + +**Manual Commands**: +```bash +vendor/bin/phpunit --colors=never +vendor/bin/phpunit --group functional +vendor/bin/php-cs-fixer fix --config .php-cs-fixer.dist.php src tests +vendor/bin/parallel-lint src/ tests/ +``` + +## Error Handling + +### Exception Types + +- `\PDOException` - Database errors (automatically retried if transient) +- `\LogicException` - Configuration or usage errors +- `\UnexpectedValueException` - Invalid config values +- `\InvalidArgumentException` - Invalid method arguments + +### Retry Logic + +The PDO and PDOStatement wrappers automatically retry: +1. Deadlocks and serialization failures (up to 3 times) +2. Connection failures (reconnects then retries up to 3 times) +3. Other transient errors based on error code + +**Not retried**: +- Syntax errors +- Constraint violations +- Permission errors +- After 3 failed attempts + +### Custom Error Handling + +```php +try { + $object = $mapper->save($object); +} catch (\PDOException $e) { + // Already retried 3 times, handle permanent failure + error_log("Failed to save object: " . $e->getMessage()); + throw $e; +} +``` + +## Performance Considerations + +1. **Connection Pooling**: Factory returns singletons, reuse them +2. **Prepared Statements**: CRUD automatically uses prepared statements +3. **Batch Operations**: Load multiple objects with `loadMulti()` +4. **Lazy Loading**: Relations loaded only when `find()` or `load()` called +5. **Transaction Grouping**: Wrap multiple saves in one transaction +6. **Emulated Prepares**: Enabled for retry compatibility (slight overhead) + +## Troubleshooting + +### Common Issues + +**"No database configuration for X"**: +- Check `etc/config.ini` has `db.X.type` defined +- Verify `DATABASE_NAME` constant matches config key + +**"Either `server` or `dsn` is required"**: +- For mysql/pgsql types, specify `server` in config +- For pdo type, specify `dsn` in config + +**"Lock wait timeout" or "Deadlock found"**: +- Already retried 3 times automatically +- Consider increasing MySQL `innodb_lock_wait_timeout` +- Review transaction scope and ordering + +**Relations not loading**: +- Verify mapper class is imported and autoloadable +- Check `foreign_column` matches actual database column +- For lookup tables, verify all column names in mapping + +**"Too many connections"**: +- Reuse CRUD/Factory instances (they're singletons) +- Call `$crud->pdo->close()` when done with connections +- Increase database max_connections setting + +## Migration from Older Versions + +### From direct PDO usage: + +```php +// Old +$pdo = new \PDO($dsn, $user, $pass); +$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?"); +$stmt->execute([$id]); +$row = $stmt->fetch(\PDO::FETCH_ASSOC); + +// New +$crud = \DealNews\DB\CRUD::factory('mydb'); +$rows = $crud->read('users', ['id' => $id]); +$row = $rows[0] ?? null; +``` + +### From array-based data: + +```php +// Old +$user = [ + 'id' => 1, + 'name' => 'John', + 'email' => 'john@example.com' +]; + +// New - create value object +class User { + public int $id = 0; + public string $name = ''; + public string $email = ''; +} + +class UserMapper extends \DealNews\DB\AbstractMapper { + public const DATABASE_NAME = 'mydb'; + public const TABLE = 'users'; + public const PRIMARY_KEY = 'id'; + public const MAPPED_CLASS = User::class; + public const MAPPING = [ + 'id' => [], + 'name' => [], + 'email' => [], + ]; +} + +$mapper = new UserMapper(); +$user = $mapper->load(1); +``` + +## Quick Reference + +### Create Connection +```php +$pdo = \DealNews\DB\Factory::init("mydb"); +$crud = \DealNews\DB\CRUD::factory("mydb"); +``` + +### Basic CRUD +```php +$crud->create('users', ['name' => 'John']); +$rows = $crud->read('users', ['status' => 'active'], limit: 10); +$crud->update('users', ['status' => 'inactive'], ['id' => 5]); +$crud->delete('users', ['id' => 5]); +``` + +### Data Mapper +```php +$mapper = new UserMapper(); +$user = $mapper->load(1); +$users = $mapper->find(['status' => 'active'], limit: 10); +$user = $mapper->save($user); +$mapper->delete(1); +``` + +### Transactions +```php +$crud->pdo->beginTransaction(); +try { + // operations + $crud->pdo->commit(); +} catch (\Throwable $e) { + $crud->pdo->rollBack(); + throw $e; +} +``` + +--- + +**Last Updated**: 2025-12-31 +**Maintained By**: DealNews.com, Inc. +**Copyright**: 1997-Present DealNews.com, Inc. diff --git a/README.md b/README.md index 2fc5fbb..516f009 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,85 @@ make working with the value objects easier. Documentation coming +## Query Builder + +The `Query` class provides a fluent interface for building complex SELECT queries +with JOINs, subqueries, GROUP BY, HAVING, and more. It complements CRUD for queries +that go beyond simple single-table operations. + +### Basic Usage + +```php +$crud = \DealNews\DB\CRUD::factory('mydb'); +$query = new \DealNews\DB\Util\Query($crud); + +$query->select(['id', 'name', 'email']) + ->from('users') + ->where('status', '=', 'active') + ->orderBy('created_at', 'DESC') + ->limit(10); + +$rows = $crud->runFetch($query->getSql(), $query->getParams()); +``` + +### Complex Queries with JOINs + +```php +$query = new \DealNews\DB\Util\Query($crud); + +$query->select([ + 'u.id', + 'u.name', + \DealNews\DB\Util\Query::raw('COUNT(p.id) AS post_count'), + \DealNews\DB\Util\Query::raw('AVG(p.views) AS avg_views'), + ]) + ->from('users', 'u') + ->leftJoin('posts', 'p', 'p.user_id', '=', 'u.id') + ->leftJoin('profiles', 'pr', 'pr.user_id', '=', 'u.id') + ->where('u.status', '=', 'active') + ->where('pr.verified', '=', true) + ->groupBy(['u.id', 'u.name']) + ->having('post_count', '>', 5) + ->orderBy('post_count', 'DESC') + ->limit(20); + +$rows = $crud->runFetch($query->getSql(), $query->getParams()); +``` + +### Nested WHERE Conditions + +```php +$query->select(['id']) + ->from('users') + ->where('status', '=', 'active') + ->where(function ($q) { + $q->where('role', '=', 'admin') + ->orWhere('role', '=', 'moderator'); + }); + +// Generates: WHERE status = :p0 AND (role = :p1 OR role = :p2) +``` + +### Available Methods + +| Method | Description | +|--------|-------------| +| `select(array $columns)` | Set SELECT columns | +| `from(string $table, ?string $alias)` | Set FROM table | +| `join()`, `innerJoin()`, `leftJoin()`, `rightJoin()` | Add JOIN clauses | +| `where()`, `orWhere()` | Add WHERE conditions | +| `whereIn()`, `whereNotIn()` | Add IN conditions | +| `whereNull()`, `whereNotNull()` | Add NULL checks | +| `whereRaw(string $sql, array $params)` | Add raw WHERE SQL | +| `groupBy(array $columns)` | Add GROUP BY | +| `having()`, `orHaving()` | Add HAVING conditions | +| `orderBy(string $column, string $direction)` | Add ORDER BY | +| `limit(int $limit)` | Set LIMIT | +| `offset(int $offset)` | Set OFFSET | +| `getSql()` | Build and return the SQL string | +| `getParams()` | Get the bound parameters | +| `Query::raw(string $value, array $params)` | Create a raw SQL fragment | + ## Testing By default, only unit tests are run. To run the functional tests the host diff --git a/bin/create_objects.php b/bin/create_objects.php index bc17f5c..1d0e388 100755 --- a/bin/create_objects.php +++ b/bin/create_objects.php @@ -295,7 +295,7 @@ function create_mapper($properties, $namespace, $object_name, $base_class, $sche $file .= " * Defines the properties that are mapped and any\n"; $file .= " * additional information needed to map them.\n"; $file .= " */\n"; - $file .= " protected const MAPPING = [\n"; + $file .= " public const MAPPING = [\n"; foreach (array_keys($properties) as $name) { $file .= " '$name' => [],\n"; } diff --git a/composer.json b/composer.json index d745171..cc79cca 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ ], "require": { "php": "^8.2", + "ext-pdo": "*", "dealnews/console": "^0.2.1", "dealnews/data-mapper": "^3.4", "dealnews/get-config": "^2.2" diff --git a/src/AbstractMapper.php b/src/AbstractMapper.php index 4bfac52..e67f32e 100644 --- a/src/AbstractMapper.php +++ b/src/AbstractMapper.php @@ -3,7 +3,7 @@ namespace DealNews\DB; /** - * Maps an object to a database accesible via PDO + * Maps an object to a database accessible via PDO * * @author Brian Moon * @copyright 1997-Present DealNews.com, Inc @@ -74,7 +74,7 @@ abstract class AbstractMapper extends \DealNews\DataMapper\AbstractMapper { * CRUD PDO helper object * @var \DealNews\DB\CRUD */ - protected CRUD $crud; + public readonly CRUD $crud; /** * Creates a new mapper @@ -95,6 +95,7 @@ public function __construct(?CRUD $crud = null) { } else { $this->table = $this::TABLE; } + parent::__construct(); } /** diff --git a/src/CRUD.php b/src/CRUD.php index 9b6f9ce..1425e12 100644 --- a/src/CRUD.php +++ b/src/CRUD.php @@ -15,16 +15,16 @@ class CRUD { /** * PDO Object - * @var \DealNews\DB\PDO + * @var PDO */ - protected $pdo; + public readonly PDO $pdo; /** * Character used to quote column names in queries * * @var string */ - protected $quote_column_char = '"'; + protected string $quote_column_char = '"'; /** * Helper factory for creating singletons using only a db name @@ -46,7 +46,7 @@ public static function factory(string $db_name): CRUD { /** * Creates a new CRUD object * - * @param \DealNews\DB\PDO $pdo PDO object + * @param PDO $pdo PDO object */ public function __construct(PDO $pdo) { $this->pdo = $pdo; @@ -61,20 +61,6 @@ public function __construct(PDO $pdo) { } } - /** - * Getter for getting the pdo object - * - * @param string $var Property name. Only `pdo` is allowed. - * @return \DealNews\DB\PDO - */ - public function __get($var) { - if ($var == 'pdo') { - return $this->pdo; - } else { - throw new \LogicException("Invalid property $var for " . get_class($this)); - } - } - /** * Inserts a new row into a table * @@ -392,7 +378,7 @@ public function buildSelectQuery(string $table, array $data = [], ?int $limit = * * @return string */ - protected function quoteField(string $field): string { + public function quoteField(string $field): string { return $this->quote_column_char . $field . $this->quote_column_char; } } diff --git a/src/ColumnMapper.php b/src/ColumnMapper.php index 9366354..e8efebc 100644 --- a/src/ColumnMapper.php +++ b/src/ColumnMapper.php @@ -65,7 +65,7 @@ public function __construct( /** * Loads the data from the database * - * @param int|string $id Primay key id of the object to look up + * @param int|string $id Primary key id of the object to look up * * @return array */ @@ -90,7 +90,7 @@ public function load(int|string $id): array { /** * Saves the data * - * @param int|string $id Primay key id of the object to look up + * @param int|string $id Primary key id of the object to look up * @param array $data Values to save * * @return array diff --git a/src/PDO.php b/src/PDO.php index 26e9d50..674b4cb 100644 --- a/src/PDO.php +++ b/src/PDO.php @@ -22,65 +22,65 @@ class PDO { /** * Real \PDO instance * - * @var \PDO + * @var ?\PDO */ - protected $pdo; + protected ?\PDO $pdo = null; /** * PDO Driver * * @var string */ - protected $driver = ''; + protected string $driver = ''; /** * Database name * * @var string */ - protected $db = ''; + protected mixed $db = ''; /** * Database server address * * @var string */ - protected $server = ''; + protected mixed $server = ''; /** * PDO DSN * * @var string */ - protected $dsn = ''; + protected string $dsn = ''; /** * Database username * - * @var string + * @var ?string */ - protected $username = ''; + protected ?string $username = ''; /** * Database password * - * @var string + * @var ?string */ - protected $passwd = ''; + protected ?string $passwd = ''; /** * PDO options * - * @var array + * @var ?array */ - protected $options = []; + protected ?array $options = []; /** * Determines if debug info is logged * * @var boolean */ - protected static $debug = false; + protected static bool $debug = false; protected const ERROR_CODES = [ 'mysql' => [ @@ -185,15 +185,16 @@ public function close(): bool { /** * Connects to the database by creating the real \PDO object * - * @param boolean $reconnect If true, a new object will be created - * + * @param boolean $reconnect If true, a new object will be created + * @param string|null $pdo_class * @return void */ - public function connect($reconnect = false, ?string $pdo_class = \PDO::class) { + public function connect(bool $reconnect = false, ?string $pdo_class = \PDO::class): void { if (empty($this->pdo) || $reconnect) { $this->pdo = null; for ($x = 1; $x <= $this::RETRY_LIMIT; $x++) { try { + // @phan-suppress-next-line PhanTypeExpectedObjectOrClassName $this->pdo = new $pdo_class($this->dsn, $this->username, $this->passwd, $this->options); return; @@ -212,11 +213,11 @@ public function connect($reconnect = false, ?string $pdo_class = \PDO::class) { * * @return bool */ - public function ping() { + public function ping(): bool { try { $this->query('select 1'); $result = true; - } catch (\PDOException $e) { // @phan-suppress-current-line PhanUnusedVariableCaughtException + } catch (\PDOException) { // @phan-suppress-current-line PhanUnusedVariableCaughtException $result = false; } @@ -230,7 +231,7 @@ public function ping() { * * @return bool Previous value */ - public static function debug(bool $toggle) { + public static function debug(bool $toggle): bool { $current = self::$debug; self::$debug = $toggle; @@ -240,12 +241,12 @@ public static function debug(bool $toggle) { /** * Wrapper for \PDO object * - * @param string $method Method name - * @param array $args Arguments + * @param string $method Method name + * @param array $args Arguments * * @return mixed */ - public function __call($method, $args = []) { + public function __call(string $method, array $args = []) { $this->connect(); for ($x = 1; $x <= $this::RETRY_LIMIT; $x++) { try { @@ -268,7 +269,7 @@ public function __call($method, $args = []) { * @return PDOStatement * @phan-suppress PhanUnusedPublicNoOverrideMethodParameter */ - public function prepare(string $statement, ?array $driver_options = []) { + public function prepare(string $statement, ?array $driver_options = []): PDOStatement { $stmt = $this->__call(__FUNCTION__, func_get_args()); // Convert \PDOStatement to a DealNews\DB\PDOStatement @@ -281,11 +282,11 @@ public function prepare(string $statement, ?array $driver_options = []) { /** * @see http://php.net/manual/en/pdo.query.php - * @param string $statement - * @return PDOStatement + * @param string $statement + * @return PDOStatement|\PDOStatement * @phan-suppress PhanUnusedPublicNoOverrideMethodParameter, PhanPossiblyUndeclaredVariable */ - public function query(string $statement) { + public function query(string $statement): PDOStatement|\PDOStatement { for ($x = 1; $x <= $this::RETRY_LIMIT; $x++) { try { $stmt = $this->__call(__FUNCTION__, func_get_args()); @@ -308,11 +309,11 @@ public function query(string $statement) { /** * Determines if an error code is one that should be retried * - * @param integer|string $code Error code from \PDOException + * @param integer|string $code Error code from \PDOException * * @return bool */ - public function checkErrorCode($code): bool { + public function checkErrorCode(int|string $code): bool { $retry = false; $retry_codes = []; $reconnect_codes = []; diff --git a/src/PDOStatement.php b/src/PDOStatement.php index 436b8f5..1c56312 100644 --- a/src/PDOStatement.php +++ b/src/PDOStatement.php @@ -16,14 +16,14 @@ class PDOStatement { * * @var \PDOStatement */ - protected $stmt; + protected \PDOStatement $stmt; /** * PDO object which created the statement * * @var PDO */ - protected $pdo; + protected PDO $pdo; /** * Creates the object @@ -39,12 +39,12 @@ public function __construct(\PDOStatement $stmt, PDO $pdo) { /** * Wrapper for \PDOStatement object * - * @param string $method Method name - * @param array $args Arguments + * @param string $method Method name + * @param array $args Arguments * * @return mixed */ - public function __call($method, $args = []) { + public function __call(string $method, array $args = []) { return call_user_func_array( [$this->stmt, $method], $args @@ -54,11 +54,11 @@ public function __call($method, $args = []) { /** * Wrapper for \PDOStatement object * - * @param string $property + * @param string $property * * @return mixed */ - public function __get($property) { + public function __get(string $property) { return $this->stmt->$property ?? null; } @@ -68,7 +68,7 @@ public function __get($property) { * @return bool * @phan-suppress PhanUnusedPublicNoOverrideMethodParameter */ - public function execute(?array $input_parameters = []) { + public function execute(?array $input_parameters = []): bool { $result = false; for ($x = 1; $x <= PDO::RETRY_LIMIT; $x++) { try { @@ -101,11 +101,11 @@ public function execute(?array $input_parameters = []) { /** * Calls connect on PDO object * - * @param boolean $reconnect If true, a new object will be created + * @param boolean $reconnect If true, a new object will be created * * @return void */ - public function connect($reconnect = false) { + public function connect(bool $reconnect = false): void { $this->pdo->connect($reconnect); } } diff --git a/src/Util/Query.php b/src/Util/Query.php new file mode 100644 index 0000000..854bf96 --- /dev/null +++ b/src/Util/Query.php @@ -0,0 +1,1009 @@ +select(['u.id', 'u.name', 'COUNT(p.id) AS post_count']) + * ->from('users', 'u') + * ->leftJoin('posts', 'p', 'p.user_id', '=', 'u.id') + * ->where('u.status', '=', 'active') + * ->groupBy(['u.id', 'u.name']) + * ->having('post_count', '>', 10) + * ->orderBy('post_count', 'DESC') + * ->limit(20); + * + * $rows = $crud->runFetch($query->getSql(), $query->getParams()); + * ``` + * + * @author Brian Moon + * @copyright 1997-Present DealNews.com, Inc + * @package DB + */ +class Query { + + /** + * Error code: No SELECT columns specified + */ + public const ERR_NO_SELECT = 1; + + /** + * Error code: No FROM table specified + */ + public const ERR_NO_FROM = 2; + + /** + * Error code: Invalid comparison operator + */ + public const ERR_INVALID_OPERATOR = 3; + + /** + * Error code: Invalid ORDER BY direction + */ + public const ERR_INVALID_DIRECTION = 4; + + /** + * Error code: Invalid JOIN type + */ + public const ERR_INVALID_JOIN_TYPE = 5; + + /** + * Valid comparison operators + */ + public const VALID_OPERATORS = [ + '=', + '!=', + '<>', + '<', + '>', + '<=', + '>=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'IS', + 'IS NOT', + ]; + + /** + * Valid ORDER BY directions + */ + public const VALID_DIRECTIONS = ['ASC', 'DESC']; + + /** + * Valid JOIN types + */ + public const VALID_JOIN_TYPES = ['INNER', 'LEFT', 'RIGHT', 'FULL']; + + /** + * Database driver name (mysql, pgsql, sqlite, etc.) + * + * @var string + */ + protected string $driver = ''; + + /** + * Character used to quote column/table names + * + * @var string + */ + protected string $quote_char = '"'; + + /** + * SELECT columns + * + * @var array + */ + protected array $select = []; + + /** + * FROM table name + * + * @var string + */ + protected string $from = ''; + + /** + * FROM table alias + * + * @var string + */ + protected string $from_alias = ''; + + /** + * JOIN clauses + * + * @var array + */ + protected array $joins = []; + + /** + * WHERE conditions + * + * @var array + */ + protected array $wheres = []; + + /** + * GROUP BY columns + * + * @var array + */ + protected array $group_by = []; + + /** + * HAVING conditions + * + * @var array + */ + protected array $havings = []; + + /** + * ORDER BY clauses + * + * @var array + */ + protected array $order_by = []; + + /** + * LIMIT value + * + * @var ?int + */ + protected ?int $limit = null; + + /** + * OFFSET value + * + * @var ?int + */ + protected ?int $offset = null; + + /** + * Bound parameters + * + * @var array + */ + protected array $params = []; + + /** + * Parameter counter for unique naming + * + * @var int + */ + protected int $param_counter = 0; + + /** + * Creates a new Query builder instance + * + * @param CRUD|PDO|string $connection Database connection or driver name + * @param string|null $driver_override Optional driver name override + */ + public function __construct(CRUD|PDO|string $connection, ?string $driver_override = null) { + if ($driver_override !== null) { + $this->driver = $driver_override; + $this->quote_char = ($driver_override === 'mysql') ? '`' : '"'; + } elseif (is_string($connection)) { + $this->driver = $connection; + $this->quote_char = ($connection === 'mysql') ? '`' : '"'; + } else { + $this->detectDriver($connection); + } + } + + /** + * Creates a Raw SQL fragment + * + * Use for expressions that should not be quoted or escaped. + * + * @param string $value The raw SQL string + * @param array $params Parameters to bind (optional) + * + * @return Raw + */ + public static function raw(string $value, array $params = []): Raw { + return new Raw($value, $params); + } + + /** + * Sets the SELECT columns + * + * @param array $columns Column names or Raw expressions + * + * @return self + */ + public function select(array $columns): self { + $this->select = $columns; + + return $this; + } + + /** + * Sets the FROM table + * + * @param string $table Table name + * @param string|null $alias Optional table alias + * + * @return self + */ + public function from(string $table, ?string $alias = null): self { + $this->from = $table; + $this->from_alias = $alias ?? ''; + + return $this; + } + + /** + * Adds a JOIN clause + * + * @param string $table Table to join + * @param string $alias Table alias + * @param string $column1 First column (from joined table) + * @param string $operator Comparison operator + * @param string $column2 Second column (from existing table) + * @param string $type JOIN type (INNER, LEFT, RIGHT, FULL) + * + * @return self + * + * @throws \LogicException If invalid JOIN type + */ + public function join( + string $table, + string $alias, + string $column1, + string $operator, + string $column2, + string $type = 'INNER' + ): self { + $type = strtoupper($type); + + if (!in_array($type, self::VALID_JOIN_TYPES, true)) { + throw new \LogicException( + "Invalid JOIN type: $type", + self::ERR_INVALID_JOIN_TYPE + ); + } + + $this->joins[] = [ + 'type' => $type, + 'table' => $table, + 'alias' => $alias, + 'column1' => $column1, + 'operator' => $operator, + 'column2' => $column2, + ]; + + return $this; + } + + /** + * Adds an INNER JOIN clause + * + * @param string $table Table to join + * @param string $alias Table alias + * @param string $column1 First column + * @param string $operator Comparison operator + * @param string $column2 Second column + * + * @return self + */ + public function innerJoin( + string $table, + string $alias, + string $column1, + string $operator, + string $column2 + ): self { + return $this->join($table, $alias, $column1, $operator, $column2, 'INNER'); + } + + /** + * Adds a LEFT JOIN clause + * + * @param string $table Table to join + * @param string $alias Table alias + * @param string $column1 First column + * @param string $operator Comparison operator + * @param string $column2 Second column + * + * @return self + */ + public function leftJoin( + string $table, + string $alias, + string $column1, + string $operator, + string $column2 + ): self { + return $this->join($table, $alias, $column1, $operator, $column2, 'LEFT'); + } + + /** + * Adds a RIGHT JOIN clause + * + * @param string $table Table to join + * @param string $alias Table alias + * @param string $column1 First column + * @param string $operator Comparison operator + * @param string $column2 Second column + * + * @return self + */ + public function rightJoin( + string $table, + string $alias, + string $column1, + string $operator, + string $column2 + ): self { + return $this->join($table, $alias, $column1, $operator, $column2, 'RIGHT'); + } + + /** + * Adds a WHERE condition (AND) + * + * @param string|callable $column Column name or callable for nested + * @param string|null $operator Comparison operator (if column is string) + * @param mixed $value Value to compare (if column is string) + * + * @return self + * + * @throws \LogicException If invalid operator + */ + public function where( + string|callable $column, + ?string $operator = null, + mixed $value = null + ): self { + return $this->addWhere('AND', $column, $operator, $value); + } + + /** + * Adds a WHERE condition (OR) + * + * @param string|callable $column Column name or callable for nested + * @param string|null $operator Comparison operator (if column is string) + * @param mixed $value Value to compare (if column is string) + * + * @return self + * + * @throws \LogicException If invalid operator + */ + public function orWhere( + string|callable $column, + ?string $operator = null, + mixed $value = null + ): self { + return $this->addWhere('OR', $column, $operator, $value); + } + + /** + * Adds a WHERE IN condition + * + * @param string $column Column name + * @param array $values Values to match + * + * @return self + */ + public function whereIn(string $column, array $values): self { + return $this->addWhere('AND', $column, 'IN', $values); + } + + /** + * Adds a WHERE NOT IN condition + * + * @param string $column Column name + * @param array $values Values to exclude + * + * @return self + */ + public function whereNotIn(string $column, array $values): self { + return $this->addWhere('AND', $column, 'NOT IN', $values); + } + + /** + * Adds a WHERE IS NULL condition + * + * @param string $column Column name + * + * @return self + */ + public function whereNull(string $column): self { + return $this->addWhere('AND', $column, 'IS', null); + } + + /** + * Adds a WHERE IS NOT NULL condition + * + * @param string $column Column name + * + * @return self + */ + public function whereNotNull(string $column): self { + return $this->addWhere('AND', $column, 'IS NOT', null); + } + + /** + * Adds a raw WHERE condition + * + * @param string $sql Raw SQL condition + * @param array $params Parameters to bind + * + * @return self + */ + public function whereRaw(string $sql, array $params = []): self { + $raw = new Raw($sql, $params); + + $this->wheres[] = [ + 'type' => 'AND', + 'column' => $raw, + 'operator' => null, + 'value' => null, + 'nested' => null, + ]; + + return $this; + } + + /** + * Sets the GROUP BY columns + * + * @param array $columns Column names + * + * @return self + */ + public function groupBy(array $columns): self { + $this->group_by = $columns; + + return $this; + } + + /** + * Adds a HAVING condition (AND) + * + * @param string $column Column name or aggregate + * @param string $operator Comparison operator + * @param mixed $value Value to compare + * + * @return self + * + * @throws \LogicException If invalid operator + */ + public function having(string $column, string $operator, mixed $value): self { + return $this->addHaving('AND', $column, $operator, $value); + } + + /** + * Adds a HAVING condition (OR) + * + * @param string $column Column name or aggregate + * @param string $operator Comparison operator + * @param mixed $value Value to compare + * + * @return self + * + * @throws \LogicException If invalid operator + */ + public function orHaving(string $column, string $operator, mixed $value): self { + return $this->addHaving('OR', $column, $operator, $value); + } + + /** + * Adds an ORDER BY clause + * + * @param string $column Column name + * @param string $direction Sort direction (ASC or DESC) + * + * @return self + * + * @throws \LogicException If invalid direction + */ + public function orderBy(string $column, string $direction = 'ASC'): self { + $direction = strtoupper($direction); + + if (!in_array($direction, self::VALID_DIRECTIONS, true)) { + throw new \LogicException( + "Invalid ORDER BY direction: $direction", + self::ERR_INVALID_DIRECTION + ); + } + + $this->order_by[] = [ + 'column' => $column, + 'direction' => $direction, + ]; + + return $this; + } + + /** + * Sets the LIMIT value + * + * @param int $limit Maximum rows to return + * + * @return self + */ + public function limit(int $limit): self { + $this->limit = $limit; + + return $this; + } + + /** + * Sets the OFFSET value + * + * @param int $offset Number of rows to skip + * + * @return self + */ + public function offset(int $offset): self { + $this->offset = $offset; + + return $this; + } + + /** + * Builds and returns the SQL query string + * + * @return string The complete SQL query + * + * @throws \LogicException If required parts are missing + */ + public function getSql(): string { + // Reset params for fresh build + $this->params = []; + $this->param_counter = 0; + + if (empty($this->select)) { + throw new \LogicException( + 'No SELECT columns specified', + self::ERR_NO_SELECT + ); + } + + if (empty($this->from)) { + throw new \LogicException( + 'No FROM table specified', + self::ERR_NO_FROM + ); + } + + $sql = $this->buildSelect(); + $sql .= $this->buildFrom(); + $sql .= $this->buildJoins(); + $sql .= $this->buildWhere(); + $sql .= $this->buildGroupBy(); + $sql .= $this->buildHaving(); + $sql .= $this->buildOrderBy(); + $sql .= $this->buildLimitOffset(); + + return $sql; + } + + /** + * Returns the bound parameters for the query + * + * Call this after getSql() to get the parameter array. + * + * @return array + */ + public function getParams(): array { + return $this->params; + } + + /** + * Detects the database driver from the connection + * + * @param CRUD|PDO $connection Database connection + * + * @return void + */ + protected function detectDriver(CRUD|PDO $connection): void { + if ($connection instanceof CRUD) { + $pdo = $connection->pdo; + } else { + $pdo = $connection; + } + + $this->driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + $this->quote_char = ($this->driver === 'mysql') ? '`' : '"'; + } + + /** + * Adds a WHERE condition + * + * @param string $type AND or OR + * @param string|callable $column Column or callable for nested + * @param string|null $operator Comparison operator + * @param mixed $value Value to compare + * + * @return self + * + * @throws \LogicException If invalid operator + */ + protected function addWhere( + string $type, + string|callable $column, + ?string $operator, + mixed $value + ): self { + + if (is_callable($column)) { + // Nested conditions via callable + $nested_query = new self($this->driver); + $column($nested_query); + $nested = $nested_query->wheres; + + $this->wheres[] = [ + 'type' => $type, + 'column' => null, + 'operator' => null, + 'value' => null, + 'nested' => $nested, + ]; + } else { + if ($operator !== null) { + $operator = strtoupper($operator); + + if (!in_array($operator, self::VALID_OPERATORS, true)) { + throw new \LogicException( + "Invalid operator: $operator", + self::ERR_INVALID_OPERATOR + ); + } + } + + $this->wheres[] = [ + 'type' => $type, + 'column' => $column, + 'operator' => $operator, + 'value' => $value, + 'nested' => null, + ]; + } + + return $this; + } + + /** + * Adds a HAVING condition + * + * @param string $type AND or OR + * @param string $column Column or aggregate + * @param string $operator Comparison operator + * @param mixed $value Value to compare + * + * @return self + * + * @throws \LogicException If invalid operator + */ + protected function addHaving( + string $type, + string $column, + string $operator, + mixed $value + ): self { + $operator = strtoupper($operator); + + if (!in_array($operator, self::VALID_OPERATORS, true)) { + throw new \LogicException( + "Invalid operator: $operator", + self::ERR_INVALID_OPERATOR + ); + } + + $this->havings[] = [ + 'type' => $type, + 'column' => $column, + 'operator' => $operator, + 'value' => $value, + ]; + + return $this; + } + + /** + * Adds a parameter and returns the placeholder name + * + * @param mixed $value The value to bind + * + * @return string The placeholder name (e.g., :qb_param_0) + */ + protected function addParam(mixed $value): string { + $param_name = ':qb_param_' . $this->param_counter++; + $this->params[$param_name] = $value; + + return $param_name; + } + + /** + * Quotes a column or table name + * + * @param string $name The name to quote + * + * @return string The quoted name + */ + protected function quoteIdentifier(string $name): string { + // Handle qualified names (e.g., "table.column") + if (strpos($name, '.') !== false) { + $parts = explode('.', $name, 2); + + return $this->quote_char . $parts[0] . $this->quote_char . + '.' . + $this->quote_char . $parts[1] . $this->quote_char; + } + + return $this->quote_char . $name . $this->quote_char; + } + + /** + * Builds the SELECT clause + * + * @return string + */ + protected function buildSelect(): string { + $columns = []; + + foreach ($this->select as $column) { + if ($column instanceof Raw) { + $columns[] = $column->value; + foreach ($column->params as $key => $value) { + $this->params[$key] = $value; + } + } elseif ($column instanceof self) { + // Subquery in SELECT + $columns[] = '(' . $column->getSql() . ')'; + foreach ($column->getParams() as $key => $value) { + $this->params[$key] = $value; + } + } elseif ($column === '*') { + $columns[] = '*'; + } elseif (stripos($column, ' AS ') !== false) { + // Handle "column AS alias" syntax + $parts = preg_split('/\\s+AS\\s+/i', $column, 2); + $columns[] = $this->quoteIdentifier($parts[0]) . + ' AS ' . + $this->quoteIdentifier($parts[1]); + } else { + $columns[] = $this->quoteIdentifier($column); + } + } + + return 'SELECT ' . implode(', ', $columns); + } + + /** + * Builds the FROM clause + * + * @return string + */ + protected function buildFrom(): string { + $sql = ' FROM ' . $this->quoteIdentifier($this->from); + + if (!empty($this->from_alias)) { + $sql .= ' ' . $this->quoteIdentifier($this->from_alias); + } + + return $sql; + } + + /** + * Builds the JOIN clauses + * + * @return string + */ + protected function buildJoins(): string { + $sql = ''; + + foreach ($this->joins as $join) { + $sql .= ' ' . $join['type'] . ' JOIN '; + $sql .= $this->quoteIdentifier($join['table']); + $sql .= ' ' . $this->quoteIdentifier($join['alias']); + $sql .= ' ON ' . $this->quoteIdentifier($join['column1']); + $sql .= ' ' . $join['operator'] . ' '; + $sql .= $this->quoteIdentifier($join['column2']); + } + + return $sql; + } + + /** + * Builds the WHERE clause + * + * @return string + */ + protected function buildWhere(): string { + $sql = ''; + + if (!empty($this->wheres)) { + $sql = ' WHERE ' . $this->buildWhereConditions($this->wheres); + } + + return $sql; + } + + /** + * Builds WHERE conditions recursively + * + * @param array $conditions The conditions to build + * + * @return string + */ + protected function buildWhereConditions(array $conditions): string { + $clauses = []; + + foreach ($conditions as $index => $condition) { + if ($condition['nested'] !== null) { + // Nested conditions + $clause = '(' . $this->buildWhereConditions($condition['nested']) . ')'; + } elseif ($condition['column'] instanceof Raw) { + // Raw SQL + $raw = $condition['column']; + $clause = $raw->value; + foreach ($raw->params as $key => $value) { + $this->params[$key] = $value; + } + } else { + // Regular condition + $clause = $this->buildCondition( + $condition['column'], + $condition['operator'], + $condition['value'] + ); + } + + if ($index === 0) { + $clauses[] = $clause; + } else { + $clauses[] = $condition['type'] . ' ' . $clause; + } + } + + return implode(' ', $clauses); + } + + /** + * Builds a single condition + * + * @param string $column Column name + * @param string|null $operator Operator + * @param mixed $value Value + * + * @return string + */ + protected function buildCondition( + string $column, + ?string $operator, + mixed $value + ): string { + $quoted_column = $this->quoteIdentifier($column); + + if ($operator === 'IS' || $operator === 'IS NOT') { + return $quoted_column . ' ' . $operator . ' NULL'; + } + + if ($operator === 'IN' || $operator === 'NOT IN') { + $placeholders = []; + foreach ($value as $v) { + $placeholders[] = $this->addParam($v); + } + + return $quoted_column . ' ' . $operator . + ' (' . implode(', ', $placeholders) . ')'; + } + + $placeholder = $this->addParam($value); + + return $quoted_column . ' ' . $operator . ' ' . $placeholder; + } + + /** + * Builds the GROUP BY clause + * + * @return string + */ + protected function buildGroupBy(): string { + $sql = ''; + + if (!empty($this->group_by)) { + $columns = []; + foreach ($this->group_by as $column) { + $columns[] = $this->quoteIdentifier($column); + } + $sql = ' GROUP BY ' . implode(', ', $columns); + } + + return $sql; + } + + /** + * Builds the HAVING clause + * + * @return string + */ + protected function buildHaving(): string { + $sql = ''; + + if (!empty($this->havings)) { + $clauses = []; + + foreach ($this->havings as $index => $having) { + $placeholder = $this->addParam($having['value']); + $clause = $having['column'] . ' ' . + $having['operator'] . ' ' . + $placeholder; + + if ($index === 0) { + $clauses[] = $clause; + } else { + $clauses[] = $having['type'] . ' ' . $clause; + } + } + + $sql = ' HAVING ' . implode(' ', $clauses); + } + + return $sql; + } + + /** + * Builds the ORDER BY clause + * + * @return string + */ + protected function buildOrderBy(): string { + $sql = ''; + + if (!empty($this->order_by)) { + $clauses = []; + foreach ($this->order_by as $order) { + $clauses[] = $this->quoteIdentifier($order['column']) . + ' ' . $order['direction']; + } + $sql = ' ORDER BY ' . implode(', ', $clauses); + } + + return $sql; + } + + /** + * Builds the LIMIT/OFFSET clause + * + * @return string + */ + protected function buildLimitOffset(): string { + $sql = ''; + + if ($this->limit !== null) { + if ($this->driver === 'pgsql') { + $sql = ' LIMIT ' . $this->limit; + if ($this->offset !== null) { + $sql .= ' OFFSET ' . $this->offset; + } + } else { + // MySQL style + $sql = ' LIMIT'; + if ($this->offset !== null) { + $sql .= ' ' . $this->offset . ','; + } + $sql .= ' ' . $this->limit; + } + } + + return $sql; + } +} diff --git a/src/Util/Raw.php b/src/Util/Raw.php new file mode 100644 index 0000000..a807624 --- /dev/null +++ b/src/Util/Raw.php @@ -0,0 +1,41 @@ + + * @copyright 1997-Present DealNews.com, Inc + * @package DB + */ +class Raw { + + /** + * The raw SQL string + * + * @var string + */ + public string $value = ''; + + /** + * Parameters to bind with this raw SQL fragment + * + * @var array + */ + public array $params = []; + + /** + * Creates a new Raw SQL fragment + * + * @param string $value The raw SQL string + * @param array $params Parameters to bind (optional) + */ + public function __construct(string $value, array $params = []) { + $this->value = $value; + $this->params = $params; + } +} diff --git a/src/Util/Search/Text.php b/src/Util/Search/Text.php index 5101ef7..a5e4547 100644 --- a/src/Util/Search/Text.php +++ b/src/Util/Search/Text.php @@ -18,7 +18,7 @@ class Text { * * @return self */ - public static function init() { + public static function init(): Text { static $inst; if (empty($inst)) { @@ -116,7 +116,7 @@ public function createLikeStringFromTokens(array $fields, array $tokens) : strin } } else { - // strip off the ^ and $ for already wildcarded strings + // strip off the ^ and $ for already wild carded strings // as it may not be intuitive otherwise if (mb_substr($tok, 0, 1) == '^') { @@ -260,7 +260,7 @@ public function tokenizeString(string $string) : array { */ if ($char == '"') { /** - * only put us in quotes mode when there there is at + * only put us in quotes mode when there is at * least another bare quote somewhere in the string */ $quote_count = mb_substr_count(mb_substr($string, $x + 1), '"'); diff --git a/tests/CRUDMock.php b/tests/CRUDMock.php index 9ee03bb..53ba61a 100644 --- a/tests/CRUDMock.php +++ b/tests/CRUDMock.php @@ -5,7 +5,7 @@ class CRUDMock extends \DealNews\DB\CRUD { use GetStack; - public $pdo; + public \DealNews\DB\PDO $pdo; public function __construct(array $stacks) { $this->stacks = $stacks; @@ -87,13 +87,13 @@ public function __construct(array $stacks) { $this->stacks = $stacks; } - public function __call($method, $args = []) { + public function __call(string $method, array $args = []) { $return = $this->getStack($method, null); return $return; } - public function __get($property) { + public function __get(string $property) { $return = $this->getStack($method, null); return $return; @@ -105,7 +105,7 @@ public function execute(?array $input_parameters = []) { return $return; } - public function connect($reconnect = false) { + public function connect(bool $reconnect = false) { } }; diff --git a/tests/CRUDTest.php b/tests/CRUDTest.php index 282b19f..a72cb25 100644 --- a/tests/CRUDTest.php +++ b/tests/CRUDTest.php @@ -86,11 +86,6 @@ public function testBadInsert() { ); } - public function testBadGetter() { - $this->expectException('\\LogicException'); - $result = $this->crud->foo; - } - public function testBuildParametersException() { $this->expectException('\\LogicException'); $result = $this->crud->buildParameters( diff --git a/tests/PDOTest.php b/tests/PDOTest.php index 817b06d..2eed240 100644 --- a/tests/PDOTest.php +++ b/tests/PDOTest.php @@ -23,7 +23,7 @@ class PDOTest extends \PHPUnit\Framework\TestCase { #[DataProvider('errorCodeData')] public function testCheckErrorCode($code, $reconnect, $retry, $driver) { $class = new class($driver) extends PDO { - protected $driver; + protected string $driver; public function __construct($driver) { $this->driver = $driver; @@ -31,7 +31,7 @@ public function __construct($driver) { public $reconnect = false; - public function connect($reconnect = false, ?string $pdo_class = \PDO::class) { + public function connect(bool $reconnect = false, ?string $pdo_class = \PDO::class): void { $this->reconnect = $reconnect; } @@ -94,7 +94,7 @@ public function testClose() { MockPDO::$mock_attempt_count = 0; MockPDO::$mock_throw = false; $pdo = new class($config['dsn'], $config['user'], $config['pass'], $config['options']) extends \DealNews\DB\PDO { - public function connect($reconnect = false, ?string $pdo_class = \PDO::class) { + public function connect(bool $reconnect = false, ?string $pdo_class = \PDO::class): void { parent::connect($reconnect, MockPDO::class); } }; diff --git a/tests/Util/QueryTest.php b/tests/Util/QueryTest.php new file mode 100644 index 0000000..50063b9 --- /dev/null +++ b/tests/Util/QueryTest.php @@ -0,0 +1,677 @@ + + * @copyright 1997-Present DealNews.com, Inc + * @package DB + */ +class QueryTest extends TestCase { + + /** + * Creates a Query instance for MySQL driver testing + * + * @return Query + */ + protected function createMySqlQuery(): Query { + return new Query('mysql'); + } + + /** + * Creates a Query instance for PostgreSQL driver testing + * + * @return Query + */ + protected function createPgSqlQuery(): Query { + return new Query('pgsql'); + } + + /** + * Tests basic SELECT query + */ + public function testBasicSelect(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id', 'name', 'email']) + ->from('users'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('SELECT', $sql); + $this->assertStringContainsString('`id`', $sql); + $this->assertStringContainsString('`name`', $sql); + $this->assertStringContainsString('`email`', $sql); + $this->assertStringContainsString('FROM `users`', $sql); + } + + /** + * Tests SELECT with table alias + */ + public function testSelectWithTableAlias(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id', 'name']) + ->from('users', 'u'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('FROM `users` `u`', $sql); + } + + /** + * Tests SELECT with column alias using AS syntax + */ + public function testSelectWithColumnAlias(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id AS user_id', 'name AS full_name']) + ->from('users'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('`id` AS `user_id`', $sql); + $this->assertStringContainsString('`name` AS `full_name`', $sql); + } + + /** + * Tests SELECT with star (all columns) + */ + public function testSelectStar(): void { + $query = $this->createMySqlQuery(); + + $query->select(['*']) + ->from('users'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('SELECT *', $sql); + } + + /** + * Tests WHERE condition + */ + public function testWhereCondition(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id', 'name']) + ->from('users') + ->where('status', '=', 'active'); + + $sql = $query->getSql(); + $params = $query->getParams(); + + $this->assertStringContainsString('WHERE `status` =', $sql); + $this->assertArrayHasKey(':qb_param_0', $params); + $this->assertEquals('active', $params[':qb_param_0']); + } + + /** + * Tests multiple WHERE conditions with AND + */ + public function testMultipleWhereConditions(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->where('status', '=', 'active') + ->where('age', '>', 18); + + $sql = $query->getSql(); + $params = $query->getParams(); + + $this->assertStringContainsString('WHERE `status` =', $sql); + $this->assertStringContainsString('AND `age` >', $sql); + $this->assertEquals('active', $params[':qb_param_0']); + $this->assertEquals(18, $params[':qb_param_1']); + } + + /** + * Tests OR WHERE condition + */ + public function testOrWhereCondition(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->where('status', '=', 'active') + ->orWhere('role', '=', 'admin'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('WHERE `status` =', $sql); + $this->assertStringContainsString('OR `role` =', $sql); + } + + /** + * Tests nested WHERE conditions + */ + public function testNestedWhereConditions(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->where('status', '=', 'active') + ->where(function ($q) { + $q->where('role', '=', 'admin') + ->orWhere('role', '=', 'moderator'); + }); + + $sql = $query->getSql(); + $params = $query->getParams(); + + $this->assertStringContainsString('WHERE `status` =', $sql); + $this->assertStringContainsString('AND (`role` =', $sql); + $this->assertStringContainsString('OR `role` =', $sql); + $this->assertEquals('active', $params[':qb_param_0']); + $this->assertEquals('admin', $params[':qb_param_1']); + $this->assertEquals('moderator', $params[':qb_param_2']); + } + + /** + * Tests WHERE IN condition + */ + public function testWhereIn(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->whereIn('status', ['active', 'pending', 'review']); + + $sql = $query->getSql(); + $params = $query->getParams(); + + $this->assertStringContainsString('WHERE `status` IN (', $sql); + $this->assertCount(3, $params); + } + + /** + * Tests WHERE NOT IN condition + */ + public function testWhereNotIn(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->whereNotIn('status', ['banned', 'deleted']); + + $sql = $query->getSql(); + + $this->assertStringContainsString('WHERE `status` NOT IN (', $sql); + } + + /** + * Tests WHERE IS NULL condition + */ + public function testWhereNull(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->whereNull('deleted_at'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('WHERE `deleted_at` IS NULL', $sql); + } + + /** + * Tests WHERE IS NOT NULL condition + */ + public function testWhereNotNull(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->whereNotNull('verified_at'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('WHERE `verified_at` IS NOT NULL', $sql); + } + + /** + * Tests raw WHERE condition + */ + public function testWhereRaw(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->whereRaw('YEAR(created_at) = :year', [':year' => 2024]); + + $sql = $query->getSql(); + $params = $query->getParams(); + + $this->assertStringContainsString('WHERE YEAR(created_at) = :year', $sql); + $this->assertEquals(2024, $params[':year']); + } + + /** + * Tests INNER JOIN + */ + public function testInnerJoin(): void { + $query = $this->createMySqlQuery(); + + $query->select(['u.id', 'p.title']) + ->from('users', 'u') + ->innerJoin('posts', 'p', 'p.user_id', '=', 'u.id'); + + $sql = $query->getSql(); + + $this->assertStringContainsString( + 'INNER JOIN `posts` `p` ON `p`.`user_id` = `u`.`id`', + $sql + ); + } + + /** + * Tests LEFT JOIN + */ + public function testLeftJoin(): void { + $query = $this->createMySqlQuery(); + + $query->select(['u.id']) + ->from('users', 'u') + ->leftJoin('posts', 'p', 'p.user_id', '=', 'u.id'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('LEFT JOIN `posts` `p`', $sql); + } + + /** + * Tests RIGHT JOIN + */ + public function testRightJoin(): void { + $query = $this->createMySqlQuery(); + + $query->select(['u.id']) + ->from('users', 'u') + ->rightJoin('posts', 'p', 'p.user_id', '=', 'u.id'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('RIGHT JOIN `posts` `p`', $sql); + } + + /** + * Tests multiple JOINs + */ + public function testMultipleJoins(): void { + $query = $this->createMySqlQuery(); + + $query->select(['u.id']) + ->from('users', 'u') + ->leftJoin('posts', 'p', 'p.user_id', '=', 'u.id') + ->leftJoin('profiles', 'pr', 'pr.user_id', '=', 'u.id'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('LEFT JOIN `posts` `p`', $sql); + $this->assertStringContainsString('LEFT JOIN `profiles` `pr`', $sql); + } + + /** + * Tests GROUP BY + */ + public function testGroupBy(): void { + $query = $this->createMySqlQuery(); + + $query->select(['u.id', Query::raw('COUNT(p.id) AS post_count')]) + ->from('users', 'u') + ->leftJoin('posts', 'p', 'p.user_id', '=', 'u.id') + ->groupBy(['u.id']); + + $sql = $query->getSql(); + + $this->assertStringContainsString('GROUP BY `u`.`id`', $sql); + } + + /** + * Tests HAVING + */ + public function testHaving(): void { + $query = $this->createMySqlQuery(); + + $query->select(['u.id', Query::raw('COUNT(p.id) AS post_count')]) + ->from('users', 'u') + ->leftJoin('posts', 'p', 'p.user_id', '=', 'u.id') + ->groupBy(['u.id']) + ->having('post_count', '>', 5); + + $sql = $query->getSql(); + $params = $query->getParams(); + + $this->assertStringContainsString('HAVING post_count >', $sql); + $this->assertEquals(5, $params[':qb_param_0']); + } + + /** + * Tests multiple HAVING conditions + */ + public function testMultipleHaving(): void { + $query = $this->createMySqlQuery(); + + $query->select(['u.id', Query::raw('COUNT(p.id) AS post_count')]) + ->from('users', 'u') + ->leftJoin('posts', 'p', 'p.user_id', '=', 'u.id') + ->groupBy(['u.id']) + ->having('post_count', '>', 5) + ->orHaving('post_count', '<', 2); + + $sql = $query->getSql(); + + $this->assertStringContainsString('HAVING post_count >', $sql); + $this->assertStringContainsString('OR post_count <', $sql); + } + + /** + * Tests ORDER BY + */ + public function testOrderBy(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id', 'name']) + ->from('users') + ->orderBy('created_at', 'DESC'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('ORDER BY `created_at` DESC', $sql); + } + + /** + * Tests multiple ORDER BY + */ + public function testMultipleOrderBy(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id', 'name']) + ->from('users') + ->orderBy('status', 'ASC') + ->orderBy('created_at', 'DESC'); + + $sql = $query->getSql(); + + $this->assertStringContainsString( + 'ORDER BY `status` ASC, `created_at` DESC', + $sql + ); + } + + /** + * Tests LIMIT (MySQL style) + */ + public function testLimitMySQL(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->limit(10); + + $sql = $query->getSql(); + + $this->assertStringContainsString('LIMIT 10', $sql); + } + + /** + * Tests LIMIT with OFFSET (MySQL style) + */ + public function testLimitOffsetMySQL(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->limit(10) + ->offset(20); + + $sql = $query->getSql(); + + $this->assertStringContainsString('LIMIT 20, 10', $sql); + } + + /** + * Tests LIMIT with OFFSET (PostgreSQL style) + */ + public function testLimitOffsetPostgreSQL(): void { + $query = $this->createPgSqlQuery(); + + $query->select(['id']) + ->from('users') + ->limit(10) + ->offset(20); + + $sql = $query->getSql(); + + $this->assertStringContainsString('LIMIT 10 OFFSET 20', $sql); + } + + /** + * Tests PostgreSQL quoting + */ + public function testPostgreSQLQuoting(): void { + $query = $this->createPgSqlQuery(); + + $query->select(['id', 'name']) + ->from('users'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('"id"', $sql); + $this->assertStringContainsString('"name"', $sql); + $this->assertStringContainsString('FROM "users"', $sql); + } + + /** + * Tests Raw SQL in SELECT + */ + public function testRawInSelect(): void { + $query = $this->createMySqlQuery(); + + $query->select([ + 'id', + Query::raw('COUNT(*) AS total'), + Query::raw('CONCAT(first_name, " ", last_name) AS full_name'), + ]) + ->from('users'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('COUNT(*) AS total', $sql); + $this->assertStringContainsString( + 'CONCAT(first_name, " ", last_name) AS full_name', + $sql + ); + } + + /** + * Tests exception for no SELECT columns + */ + public function testExceptionNoSelect(): void { + $query = $this->createMySqlQuery(); + $query->from('users'); + + $this->expectException(\LogicException::class); + $this->expectExceptionCode(Query::ERR_NO_SELECT); + + $query->getSql(); + } + + /** + * Tests exception for no FROM table + */ + public function testExceptionNoFrom(): void { + $query = $this->createMySqlQuery(); + $query->select(['id']); + + $this->expectException(\LogicException::class); + $this->expectExceptionCode(Query::ERR_NO_FROM); + + $query->getSql(); + } + + /** + * Tests exception for invalid operator + */ + public function testExceptionInvalidOperator(): void { + $query = $this->createMySqlQuery(); + + $this->expectException(\LogicException::class); + $this->expectExceptionCode(Query::ERR_INVALID_OPERATOR); + + $query->select(['id']) + ->from('users') + ->where('status', 'INVALID', 'active'); + } + + /** + * Tests exception for invalid ORDER BY direction + */ + public function testExceptionInvalidDirection(): void { + $query = $this->createMySqlQuery(); + + $this->expectException(\LogicException::class); + $this->expectExceptionCode(Query::ERR_INVALID_DIRECTION); + + $query->select(['id']) + ->from('users') + ->orderBy('id', 'INVALID'); + } + + /** + * Tests exception for invalid JOIN type + */ + public function testExceptionInvalidJoinType(): void { + $query = $this->createMySqlQuery(); + + $this->expectException(\LogicException::class); + $this->expectExceptionCode(Query::ERR_INVALID_JOIN_TYPE); + + $query->select(['id']) + ->from('users', 'u') + ->join('posts', 'p', 'p.user_id', '=', 'u.id', 'INVALID'); + } + + /** + * Tests complex query with all features + */ + public function testComplexQuery(): void { + $query = $this->createMySqlQuery(); + + $query->select([ + 'u.id', + 'u.name', + Query::raw('COUNT(p.id) AS post_count'), + Query::raw('AVG(p.views) AS avg_views'), + ]) + ->from('users', 'u') + ->leftJoin('posts', 'p', 'p.user_id', '=', 'u.id') + ->leftJoin('profiles', 'pr', 'pr.user_id', '=', 'u.id') + ->where('u.status', '=', 'active') + ->where('pr.verified', '=', true) + ->groupBy(['u.id', 'u.name']) + ->having('post_count', '>', 5) + ->orderBy('post_count', 'DESC') + ->limit(20) + ->offset(40); + + $sql = $query->getSql(); + $params = $query->getParams(); + + $this->assertStringContainsString('SELECT', $sql); + $this->assertStringContainsString('FROM `users` `u`', $sql); + $this->assertStringContainsString('LEFT JOIN `posts` `p`', $sql); + $this->assertStringContainsString('LEFT JOIN `profiles` `pr`', $sql); + $this->assertStringContainsString('WHERE', $sql); + $this->assertStringContainsString('GROUP BY', $sql); + $this->assertStringContainsString('HAVING', $sql); + $this->assertStringContainsString('ORDER BY', $sql); + $this->assertStringContainsString('LIMIT', $sql); + + $this->assertEquals('active', $params[':qb_param_0']); + $this->assertEquals(true, $params[':qb_param_1']); + $this->assertEquals(5, $params[':qb_param_2']); + } + + /** + * Tests that getSql() resets params on each call + */ + public function testGetSqlResetsParams(): void { + $query = $this->createMySqlQuery(); + + $query->select(['id']) + ->from('users') + ->where('status', '=', 'active'); + + $query->getSql(); + $params1 = $query->getParams(); + + $query->getSql(); + $params2 = $query->getParams(); + + $this->assertEquals($params1, $params2); + $this->assertCount(1, $params2); + } + + /** + * Tests Raw class directly + */ + public function testRawClass(): void { + $raw = new Raw('COUNT(*)', [':foo' => 'bar']); + + $this->assertEquals('COUNT(*)', $raw->value); + $this->assertEquals([':foo' => 'bar'], $raw->params); + } + + /** + * Tests static raw() factory method + */ + public function testRawFactoryMethod(): void { + $raw = Query::raw('SUM(amount)', [':min' => 100]); + + $this->assertInstanceOf(Raw::class, $raw); + $this->assertEquals('SUM(amount)', $raw->value); + $this->assertEquals([':min' => 100], $raw->params); + } + + /** + * Tests driver override in constructor + */ + public function testDriverOverride(): void { + $pdo = new \DealNews\DB\PDO('sqlite::memory:'); + $query = new Query($pdo, 'pgsql'); + + $query->select(['id']) + ->from('users'); + + $sql = $query->getSql(); + + // Should use double quotes despite SQLite + $this->assertStringContainsString('"id"', $sql); + $this->assertStringContainsString('"users"', $sql); + } + + /** + * Tests qualified column names (table.column) + */ + public function testQualifiedColumnNames(): void { + $query = $this->createMySqlQuery(); + + $query->select(['users.id', 'users.name']) + ->from('users'); + + $sql = $query->getSql(); + + $this->assertStringContainsString('`users`.`id`', $sql); + $this->assertStringContainsString('`users`.`name`', $sql); + } +}