diff --git a/README.md b/README.md index fd8dfa1..f09f709 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ An active record is mapping a database entity to a PHP object. Spoken plainly, if you have a users table in your database, you can "translate" a row in that table to a `User` class and a `$user` object in your codebase. See [basic example](#basic-example). +## Installation + +Simply install with Composer + +```php +composer require flightphp/active-record +``` + ## Basic Example Let's assume you have the following table: @@ -88,17 +96,108 @@ $users = $user->like('name', '%mamma%')->findAll(); See how much fun this is? Let's install it and get started! -## Installation +## Aggregate Queries -Simply install with Composer +```php +// Count rows with conditions +$user->count(); +$user->eq('status', 'active')->count(); + +// Check if any rows match +$user->eq('name', 'Bobby')->exists(); // true +``` + +## Scalar Extraction ```php -composer require flightphp/active-record +// Get flat array of values from a single column +$user->pluck('name'); // ['Bobby', 'Joseph', ...] + +// Get primary keys +$user->ids(); // [1, 2, ...] +``` + +## Convenient Finders + +```php +// Get first/last record (ordered by primary key) +$user->first(); +$user->last(); + +// Update a single attribute on a loaded record +$loadedUser = $user->find(1); +$loadedUser->updateAttribute('name', 'New Name'); +``` + +## Distinct + +```php +$user->distinct()->pluck('status'); // ['active', 'inactive', ...] +``` + +## Batch Operations + +```php +// Update multiple rows +$user->eq('status', 'inactive')->updateAll(['status' => 'active']); + +// Delete multiple rows (use with caution!) +$user->eq('status', 'deleted')->deleteAll(); +``` + +## Timestamps + +Automatically set `created_at` and `updated_at` columns: + +```php +class User extends ActiveRecord +{ + protected bool $timestamps = true; + + public function __construct($databaseConnection) + { + parent::__construct($databaseConnection, 'users'); + } +} +``` + +## Scopes + +Define reusable query chains as methods: + +```php +class User extends ActiveRecord +{ + public function active(): self + { + return $this->eq('status', 'active'); + } + + public function recent(int $days = 7): self + { + return $this->ge('created_at', date('Y-m-d', strtotime("-{$days} days"))); + } +} + +// Usage +$users = (new User($db))->active()->findAll(); +$recent = (new User($db))->active()->recent(30)->findAll(); +``` + +## Transactions + +Wrap multiple operations in a transaction: + +```php +$user->transaction(function ($model) { + $model->insert(); + // Automatically commits on success, rolls back on exception +}); ``` ## Documentation -Head over to the [documentation page](https://docs.flightphp.com/awesome-plugins/active-record) to learn more about usage and how cool this thing is! :) +Head over to the [documentation page](https://docs.flightphp.com/en/v3/awesome-plugins/active-record) to learn more about usage and how cool this thing is! :) ## License diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php index 87ad175..c15211c 100644 --- a/src/ActiveRecord.php +++ b/src/ActiveRecord.php @@ -166,6 +166,16 @@ abstract class ActiveRecord extends Base implements JsonSerializable */ protected bool $isHydrated = false; + /** + * @var boolean Whether the next SELECT query should use SELECT DISTINCT + */ + protected bool $isDistinct = false; + + /** + * @var boolean Whether created_at/updated_at are managed automatically on insert/update + */ + protected bool $timestamps = false; + /** * The construct * @@ -354,6 +364,7 @@ protected function resetQueryData(): self $this->sqlExpressions = []; $this->join = null; $this->eagerLoad = []; + $this->isDistinct = false; return $this; } /** @@ -582,6 +593,110 @@ public function findAll(): array $this->processEvent('afterFindAll', [$results]); return $results; } + + /** + * Count the number of records matching the current query conditions. + * + * Any active group/groupBy is intentionally ignored: `SELECT COUNT(*) ... GROUP BY` + * returns one row per group, which a single scalar count cannot represent. + * + * @return int + */ + public function count(): int + { + $this->select = new Expressions([ + 'operator' => 'SELECT COUNT(*) AS _ar_count' + ]); + + $result = $this->queryScalar($this->buildSql(['select', 'from', 'join', 'where', 'having']), $this->params); + + return (int) $result; + } + + /** + * Whether any records match the current query conditions. + * + * @return bool + */ + public function exists(): bool + { + $this->select = new Expressions([ + 'operator' => 'SELECT 1' + ]); + + if ($this->limit === null) { + $this->limit(1); + } + + $result = $this->queryScalar($this->buildSql(['select', 'from', 'join', 'where', 'group', 'having', 'limit']), $this->params); + + return $result !== false; + } + + /** + * Fetch a single column's values for all matching records. + * + * @param string $column Column name + * @return array + */ + public function pluck(string $column): array + { + $prefix = $this->isDistinct ? 'SELECT DISTINCT ' : 'SELECT '; + $this->select = new Expressions([ + 'operator' => $prefix . $this->escapeIdentifier($column) + ]); + + return $this->queryColumn($this->buildSql(['select', 'from', 'join', 'where', 'group', 'having', 'order', 'limit', 'offset']), $this->params); + } + + /** + * Fetch the primary keys of all matching records. + * + * @return array + */ + public function ids(): array + { + return $this->pluck($this->primaryKey); + } + + /** + * Find the first record matching the current query conditions. + * + * Defaults to ordering by the primary key ascending and limiting to 1 + * unless an explicit order/limit is already set. Like find(), never + * returns null — check isHydrated() when nothing matched. + * + * @return self + */ + public function first(): self + { + if ($this->order === null) { + $this->orderByColumn($this->primaryKey, 'ASC'); + } + if ($this->limit === null) { + $this->limit(1); + } + return $this->find(); + } + + /** + * Find the last record matching the current query conditions. + * + * Same as first() but defaults to ordering by the primary key descending. + * Like find(), never returns null — check isHydrated() when nothing matched. + * + * @return self + */ + public function last(): self + { + if ($this->order === null) { + $this->orderByColumn($this->primaryKey, 'DESC'); + } + if ($this->limit === null) { + $this->limit(1); + } + return $this->find(); + } /** * Function to delete current record in database. * @return bool @@ -596,12 +711,36 @@ public function delete() $this->processEvent('afterDelete', [$this]); return $result instanceof DatabaseStatementInterface; } + /** + * Set timestamp columns on insert/update if $this->timestamps is true. + * + * Explicitly dirty values are never overwritten. + * + * @param bool $isNew True when inserting, false when updating + * @return void + */ + protected function setTimestamps(bool $isNew = true): void + { + if ($this->timestamps === false) { + return; + } + $now = date('Y-m-d H:i:s'); + if ($isNew === true && array_key_exists('created_at', $this->dirty) === false) { + $this->created_at = $now; + } + if (array_key_exists('updated_at', $this->dirty) === false) { + $this->updated_at = $now; + } + } + /** * function to build insert SQL, and insert current record into database. * @return bool|ActiveRecord if insert success return current object */ public function insert(): ActiveRecord { + $this->setTimestamps(true); + // execute this before anything else, this could change $this->dirty $this->processEvent(['beforeInsert', 'beforeSave'], [$this]); @@ -655,6 +794,8 @@ public function insert(): ActiveRecord */ public function update(): ActiveRecord { + $this->setTimestamps(false); + $this->processEvent(['beforeUpdate', 'beforeSave'], [$this]); // Sync typed public properties that changed since the last find/sync. @@ -674,6 +815,93 @@ public function update(): ActiveRecord return $this->dirty()->resetQueryData(); } + /** + * Update a single attribute on this record in the database. + * + * Requires a loaded record (see update()). Does not run validation. + * + * @param string $name Column name + * @param mixed $value New value for the column + * @return self + */ + public function updateAttribute(string $name, $value): self + { + return $this->dirty([ $name => $value ])->update(); + } + + /** + * Update all records matching the current query conditions in a single + * statement (batch update). No callbacks are fired and no records are + * hydrated. + * + * Refuses to run without WHERE conditions unless $allowEmptyConditions + * is explicitly set to true. + * + * @param array $attributes Column/value pairs + * @param bool $allowEmptyConditions Allow updating every row when no WHERE conditions are set + * @return int Affected row count + * @throws Exception When no WHERE conditions are set and $allowEmptyConditions is false + */ + public function updateAll(array $attributes, bool $allowEmptyConditions = false): int + { + if ($allowEmptyConditions === false && $this->where === null) { + throw new Exception('updateAll() requires WHERE conditions; pass true to update every row'); + } + + foreach ($attributes as $field => $value) { + $this->addCondition($field, '=', $value, ',', 'set'); + } + + return $this->execute($this->buildSql(['update', 'set', 'where']), $this->params)->rowCount(); + } + + /** + * Delete all records matching the current query conditions in a single + * statement (batch delete). No callbacks are fired and no records are + * hydrated. + * + * Refuses to run without WHERE conditions unless $allowEmptyConditions + * is explicitly set to true. + * + * @param bool $allowEmptyConditions Allow deleting every row when no WHERE conditions are set + * @return int Affected row count + * @throws Exception When no WHERE conditions are set and $allowEmptyConditions is false + */ + public function deleteAll(bool $allowEmptyConditions = false): int + { + if ($allowEmptyConditions === false && $this->where === null) { + throw new Exception('deleteAll() requires WHERE conditions; pass true to delete every row'); + } + + return $this->execute($this->buildSql(['delete', 'from', 'where']), $this->params)->rowCount(); + } + + /** + * Execute a callable within a database transaction. + * + * If the callable returns normally, the transaction is committed. + * If the callable throws, the transaction is rolled back and the exception re-thrown. + * + * Note: nested transactions are not supported (no savepoints). + * + * @template T + * @param callable(self): T $callback + * @return T The return value of the callback + * @throws \Throwable Re-thrown if the callback fails + */ + public function transaction(callable $callback) + { + $this->databaseConnection->beginTransaction(); + try { + $result = $callback($this); + $this->databaseConnection->commit(); + return $result; + } catch (\Throwable $e) { + $this->databaseConnection->rollback(); + throw $e; + } + } + /** * Updates or inserts a record * @@ -750,6 +978,35 @@ public function query(string $sql, array $param = [], ?ActiveRecord $obj = null, } return $result; } + + /** + * Execute a SQL query and return a single scalar value. + * + * @param string $sql SQL with named placeholders + * @param array $params Bound parameters + * @return mixed The first column of the first row, or false if no row is available + */ + private function queryScalar(string $sql, array $params = []) + { + return $this->execute($sql, $params)->fetchColumn(); + } + + /** + * Execute a SQL query and return an array of values from a single column. + * + * @param string $sql SQL with named placeholders + * @param array $params Bound parameters + * @return array + */ + private function queryColumn(string $sql, array $params = []): array + { + $statement = $this->execute($sql, $params); + $values = []; + while (($value = $statement->fetchColumn()) !== false) { + $values[] = $value; + } + return $values; + } /** * helper function to get relation of this object. * There was three types of relations: {BELONGS_TO, HAS_ONE, HAS_MANY} @@ -996,9 +1253,10 @@ protected function assignEagerLoadedRelations( protected function buildSqlCallback(string $sqlStatement, ActiveRecord $object): string { // First add the SELECT table.* - if ('select' === $sqlStatement && null == $object->$sqlStatement) { - $sqlStatement = strtoupper($sqlStatement) . ' ' . $this->escapeIdentifier($object->table) . '.*'; - } elseif (('update' === $sqlStatement || 'from' === $sqlStatement) && null == $object->$sqlStatement) { + if ('select' === $sqlStatement && null === $object->$sqlStatement) { + $prefix = $object->isDistinct ? 'SELECT DISTINCT ' : 'SELECT '; + $sqlStatement = $prefix . $this->escapeIdentifier($object->table) . '.*'; + } elseif (('update' === $sqlStatement || 'from' === $sqlStatement) && null === $object->$sqlStatement) { $sqlStatement = strtoupper($sqlStatement) . ' ' . $this->escapeIdentifier($object->table); } elseif ('delete' === $sqlStatement) { $sqlStatement = strtoupper($sqlStatement); @@ -1023,8 +1281,6 @@ protected function buildSql(array $sqlStatements = []): string $finalSql[] = $statement; } } - //this code to debug info. - //echo 'SQL: ', implode(' ', $sqlStatements), "\n", "PARAMS: ", implode(', ', $this->params), "\n"; $this->builtSql = implode(' ', $finalSql); // get rid of multiple spaces in the query for prettiness @@ -1180,6 +1436,40 @@ public function join(string $table, string $on, string $type = 'LEFT') return $this; } + /** + * Select distinct rows on the next query. + * + * Applies to the default table.* select and to pluck(); count() deliberately + * ignores it (DISTINCT over a single aggregate row is a no-op). + * + * @return self + */ + public function distinct(): self + { + $this->isDistinct = true; + return $this; + } + + /** + * Call a named scope method on this model by name. + * + * Scopes are convention-based instance methods on the subclass that return + * $this, e.g. `public function published(): self { return $this->eq('status', 'published'); }`. + * They must be called on an instance that already has a database connection. + * + * @param string $name Scope method name + * @param mixed ...$args Arguments to pass to the scope method + * @return self + * @throws \BadMethodCallException if the scope method does not exist + */ + public function scope(string $name, ...$args): self + { + if (method_exists($this, $name) === false) { + throw new \BadMethodCallException("Scope '{$name}' does not exist"); + } + return $this->{$name}(...$args); + } + /** * ORDER BY a single column with ASC/DESC. Safe when the column name may be untrusted * (e.g. from a request), unlike order()/orderBy() which accept raw SQL fragments. diff --git a/src/commands/RecordCommand.php b/src/commands/RecordCommand.php index 22ae27a..2d7f742 100644 --- a/src/commands/RecordCommand.php +++ b/src/commands/RecordCommand.php @@ -116,7 +116,7 @@ public function execute(string $tableName, ?string $className = null) $class->addComment('ActiveRecord class for the ' . $tableName . ' table.'); - $class->addComment('@link https://docs.flightphp.com/awesome-plugins/active-record'); + $class->addComment('@link https://docs.flightphp.com/en/v3/awesome-plugins/active-record'); $class->addComment(''); foreach ($fields as $field) { @@ -126,7 +126,7 @@ public function execute(string $tableName, ?string $className = null) ->setVisibility('protected') ->setType('array') ->setValue([]) - ->addComment('@var array $relations Set the relationships for the model' . "\n" . ' https://docs.flightphp.com/awesome-plugins/active-record#relationships'); + ->addComment('@var array $relations Set the relationships for the model' . "\n" . ' https://docs.flightphp.com/en/v3/awesome-plugins/active-record#relationships'); $method = $class->addMethod('__construct') ->addComment('Constructor') ->addComment('@param mixed $databaseConnection The connection to the database') diff --git a/src/database/DatabaseInterface.php b/src/database/DatabaseInterface.php index a7ecd13..6ffebec 100644 --- a/src/database/DatabaseInterface.php +++ b/src/database/DatabaseInterface.php @@ -20,4 +20,25 @@ public function prepare(string $sql): DatabaseStatementInterface; * @return int|string */ public function lastInsertId(); + + /** + * Begin a transaction + * + * @return bool + */ + public function beginTransaction(): bool; + + /** + * Commit the active transaction + * + * @return bool + */ + public function commit(): bool; + + /** + * Roll back the active transaction + * + * @return bool + */ + public function rollback(): bool; } diff --git a/src/database/DatabaseStatementInterface.php b/src/database/DatabaseStatementInterface.php index 71b3be4..f76aebc 100644 --- a/src/database/DatabaseStatementInterface.php +++ b/src/database/DatabaseStatementInterface.php @@ -21,4 +21,21 @@ public function execute(array $params = []): bool; * @return array|object|null */ public function fetch(&$object); + + /** + * Fetch the first column of the next row, or false when there are no more rows. + * + * NULL column values are returned as-is (null) and are distinct from the + * false end-of-results sentinel. + * + * @return mixed The column value, null for SQL NULL, or false if no row is available + */ + public function fetchColumn(); + + /** + * Number of rows affected by the last statement. + * + * @return int + */ + public function rowCount(): int; } diff --git a/src/database/mysqli/MysqliAdapter.php b/src/database/mysqli/MysqliAdapter.php index 9238a12..f4110d9 100644 --- a/src/database/mysqli/MysqliAdapter.php +++ b/src/database/mysqli/MysqliAdapter.php @@ -45,6 +45,30 @@ public function lastInsertId() return $this->mysqli->insert_id; } + /** + * @inheritDoc + */ + public function beginTransaction(): bool + { + return $this->mysqli->begin_transaction(); + } + + /** + * @inheritDoc + */ + public function commit(): bool + { + return $this->mysqli->commit(); + } + + /** + * @inheritDoc + */ + public function rollback(): bool + { + return $this->mysqli->rollback(); + } + /** * Because mysqli can't handle named placeholders, we need to convert them to question marks. * diff --git a/src/database/mysqli/MysqliStatementAdapter.php b/src/database/mysqli/MysqliStatementAdapter.php index 9349129..540f21b 100644 --- a/src/database/mysqli/MysqliStatementAdapter.php +++ b/src/database/mysqli/MysqliStatementAdapter.php @@ -87,6 +87,46 @@ public function fetch(&$object) return $object; } + /** + * @inheritDoc + */ + public function fetchColumn() + { + // If there are no more results to fetch, return false. + if ($this->allResultsCount > 0 && $this->resultIndex >= $this->allResultsCount) { + return false; + } + + // If it hasn't run the query just yet, run it and store the first column of all rows. + if ($this->resultIndex === 0) { + $raw_result = $this->statement->get_result(); + if ($raw_result === false) { + throw new Exception($this->getErrorList()[0]['error']); + } + + while ($row = $raw_result->fetch_row()) { + $this->allResults[] = $row[0]; + ++$this->allResultsCount; + } + } + + // No results to fetch + if ($this->allResultsCount === 0) { + return false; + } + + return $this->allResults[$this->resultIndex++]; + } + + /** + * @inheritDoc + * @codeCoverageIgnore Can't mock this if your life depends on it. + */ + public function rowCount(): int + { + return $this->statement->affected_rows; + } + /** * Gets the error list (easier to mock with unit testing) * diff --git a/src/database/pdo/PdoAdapter.php b/src/database/pdo/PdoAdapter.php index 89c7acc..48e1e15 100644 --- a/src/database/pdo/PdoAdapter.php +++ b/src/database/pdo/PdoAdapter.php @@ -43,6 +43,30 @@ public function lastInsertId() return $this->pdo->lastInsertId(); } + /** + * @inheritDoc + */ + public function beginTransaction(): bool + { + return $this->pdo->beginTransaction(); + } + + /** + * @inheritDoc + */ + public function commit(): bool + { + return $this->pdo->commit(); + } + + /** + * @inheritDoc + */ + public function rollback(): bool + { + return $this->pdo->rollBack(); + } + /** * Returns a PDO connection to the database. * diff --git a/src/database/pdo/PdoStatementAdapter.php b/src/database/pdo/PdoStatementAdapter.php index a229361..a571675 100644 --- a/src/database/pdo/PdoStatementAdapter.php +++ b/src/database/pdo/PdoStatementAdapter.php @@ -44,4 +44,20 @@ public function fetch(&$object) $this->statement->setFetchMode(PDO::FETCH_INTO, $object); return $this->statement->fetch(); } + + /** + * @inheritDoc + */ + public function fetchColumn() + { + return $this->statement->fetchColumn(0); + } + + /** + * @inheritDoc + */ + public function rowCount(): int + { + return $this->statement->rowCount(); + } } diff --git a/tests/ActiveRecordMysqliTest.php b/tests/ActiveRecordMysqliTest.php index d091e54..3246815 100644 --- a/tests/ActiveRecordMysqliTest.php +++ b/tests/ActiveRecordMysqliTest.php @@ -240,4 +240,58 @@ public function testConstructTransformAndPersist() $this->assertNotEquals($mysqli, $db_connection); $this->assertInstanceOf(MysqliAdapter::class, $db_connection); } + + public function testFetchColumn() + { + $mysqli_stmt = $this->createMock(mysqli_stmt::class); + $mysqli_result = $this->createMock(mysqli_result::class); + $mysqli_result->method('fetch_row')->will($this->onConsecutiveCalls(['1'], ['2'], false)); + $mysqli_stmt->method('get_result')->willReturn($mysqli_result); + + $MysqliStatementAdapter = new class ($mysqli_stmt) extends MysqliStatementAdapter { + }; + $this->assertSame('1', $MysqliStatementAdapter->fetchColumn()); + $this->assertSame('2', $MysqliStatementAdapter->fetchColumn()); + $this->assertFalse($MysqliStatementAdapter->fetchColumn()); + } + + public function testFetchColumnNoResults() + { + $mysqli_stmt = $this->createMock(mysqli_stmt::class); + $mysqli_result = $this->createMock(mysqli_result::class); + $mysqli_result->method('fetch_row')->willReturn(false); + $mysqli_stmt->method('get_result')->willReturn($mysqli_result); + + $MysqliStatementAdapter = new class ($mysqli_stmt) extends MysqliStatementAdapter { + }; + $this->assertFalse($MysqliStatementAdapter->fetchColumn()); + } + + public function testFetchColumnBadResult() + { + $mysqli_stmt = $this->createMock(mysqli_stmt::class); + $mysqli_stmt->method('get_result')->willReturn(false); + $MysqliStatementAdapter = new class ($mysqli_stmt) extends MysqliStatementAdapter { + protected function getErrorList(): array + { + return [ [ 'sqlstate' => 'HY000', 'errno' => 1, 'error' => 'No results found'] ]; + } + }; + $this->expectException(Exception::class); + $this->expectExceptionMessage('No results found'); + $MysqliStatementAdapter->fetchColumn(); + } + + public function testTransactionMethods() + { + $mysqli = $this->createMock(mysqli::class); + $mysqli->method('begin_transaction')->willReturn(true); + $mysqli->method('commit')->willReturn(true); + $mysqli->method('rollback')->willReturn(true); + $adapter = new MysqliAdapter($mysqli); + + $this->assertTrue($adapter->beginTransaction()); + $this->assertTrue($adapter->commit()); + $this->assertTrue($adapter->rollback()); + } } diff --git a/tests/ActiveRecordPdoIntegrationTest.php b/tests/ActiveRecordPdoIntegrationTest.php index a1ef8fb..c6d5755 100644 --- a/tests/ActiveRecordPdoIntegrationTest.php +++ b/tests/ActiveRecordPdoIntegrationTest.php @@ -48,6 +48,9 @@ public function tearDown(): void $this->ActiveRecord->execute("DROP TABLE IF EXISTS contact;"); $this->ActiveRecord->execute("DROP TABLE IF EXISTS user;"); $this->ActiveRecord->execute("DROP TABLE IF EXISTS my_text_table;"); + $this->ActiveRecord->execute("DROP TABLE IF EXISTS distinct_test;"); + $this->ActiveRecord->execute("DROP TABLE IF EXISTS timestamped;"); + $this->ActiveRecord->execute("DROP TABLE IF EXISTS no_ts_columns;"); } public function testInsert() @@ -777,4 +780,906 @@ public function testHasManyEmptyRelation() $this->assertIsArray($user->contacts); $this->assertEquals(0, count($user->contacts)); } + + public function testCountAll() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $this->assertSame(3, $user->count()); + } + + public function testCountWithWhere() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $this->assertSame(2, $user->like('name', 'bob%')->count()); + } + + public function testCountEmptyTable() + { + $user = new User(new PDO('sqlite:test.db')); + $this->assertSame(0, $user->count()); + } + + public function testCountIgnoresGroup() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + // Two distinct names: a grouped count would be 2. The total is 3. + $this->assertSame(3, $user->groupBy('name')->count()); + } + + public function testExistsTrue() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $this->assertTrue($user->eq('name', 'bob')->exists()); + } + + public function testExistsFalse() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $this->assertFalse($user->eq('name', 'nobody')->exists()); + } + + public function testExistsNoConditions() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $this->assertTrue($user->exists()); + } + + public function testExistsEmptyTable() + { + $user = new User(new PDO('sqlite:test.db')); + $this->assertFalse($user->exists()); + } + + public function testChainingCount() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'active', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'active', 'password' => 'pass2' ]); + $user->insert(); + + $this->assertSame(2, $user->eq('name', 'active')->count()); + } + + public function testChainingExists() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $this->assertFalse($user->eq('name', 'nonexistent')->exists()); + } + + public function testFetchColumnInterface() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + + $statement = $this->ActiveRecord->execute('SELECT name FROM user ORDER BY id ASC'); + $this->assertSame('bob', $statement->fetchColumn()); + $this->assertSame('bob2', $statement->fetchColumn()); + $this->assertFalse($statement->fetchColumn()); + } + + public function testFetchColumnReturnsFalseWhenNoRows() + { + $statement = $this->ActiveRecord->execute('SELECT name FROM user WHERE 1 = 0'); + $this->assertFalse($statement->fetchColumn()); + } + + public function testPluckSingleColumn() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $this->assertSame([ 'bob', 'bob2', 'alice' ], $user->orderByColumn('id')->pluck('name')); + } + + public function testPluckWithWhere() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $this->assertSame([ 'bob', 'bob2' ], $user->like('name', 'bob%')->orderByColumn('id')->pluck('name')); + } + + public function testPluckEmptyTable() + { + $user = new User(new PDO('sqlite:test.db')); + $this->assertSame([], $user->pluck('name')); + } + + public function testPluckPreservesType() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + + // Strings stay strings, and numeric keys stay numeric for the driver in use + $this->assertSame([ 'bob', 'bob2' ], $user->orderByColumn('id')->pluck('name')); + $this->assertEquals([ 1, 2 ], $user->orderByColumn('id')->pluck('id')); + } + + public function testPluckWithOrder() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $this->assertSame([ 'bob', 'alice' ], $user->orderByColumn('name', 'DESC')->pluck('name')); + } + + public function testPluckWithLimit() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $this->assertSame([ 'bob', 'bob2' ], $user->orderByColumn('id')->limit(2)->pluck('name')); + } + + public function testPluckWithNullValuesDoesNotTruncate() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => null, 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $this->assertSame([ 'bob', null, 'alice' ], $user->orderByColumn('id')->pluck('name')); + } + + public function testIdsReturnsPrimaryKeys() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + + $this->assertEquals([ 1, 2 ], $user->orderByColumn('id')->ids()); + } + + public function testIdsWithConditions() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $this->assertEquals([ 2 ], $user->eq('name', 'alice')->ids()); + } + + public function testChainingIds() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $this->assertEquals([ 1, 2 ], $user->eq('password', 'pass')->eq('name', 'alice', 'or')->orderByColumn('id')->ids()); + } + + public function testFirstReturnsFirstByPk() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $first = $user->first(); + $this->assertInstanceOf(User::class, $first); + $this->assertSame('bob', $first->name); + } + + public function testFirstWithWhere() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $first = $user->eq('name', 'alice')->first(); + $this->assertSame('alice', $first->name); + } + + public function testFirstEmptyTable() + { + $user = new User(new PDO('sqlite:test.db')); + $first = $user->first(); + $this->assertInstanceOf(User::class, $first); + $this->assertFalse($first->isHydrated()); + } + + public function testFirstRespectsExplicitOrder() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'alice', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob', 'password' => 'pass2' ]); + $user->insert(); + + $first = $user->orderByColumn('name', 'DESC')->first(); + $this->assertSame('bob', $first->name); + } + + public function testLastReturnsLastByPk() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $last = $user->last(); + $this->assertInstanceOf(User::class, $last); + $this->assertSame('alice', $last->name); + } + + public function testLastWithWhere() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $last = $user->eq('password', 'pass')->last(); + $this->assertSame('bob', $last->name); + } + + public function testLastEmptyTable() + { + $user = new User(new PDO('sqlite:test.db')); + $last = $user->last(); + $this->assertInstanceOf(User::class, $last); + $this->assertFalse($last->isHydrated()); + } + + public function testLastRespectsExplicitOrder() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $last = $user->orderByColumn('name', 'DESC')->last(); + $this->assertSame('bob', $last->name); + } + + public function testUpdateAttributeSingleField() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $loaded = new User(new PDO('sqlite:test.db')); + $loaded->find(1); + $loaded->updateAttribute('name', 'robert'); + + $check = new User(new PDO('sqlite:test.db')); + $check->find(1); + $this->assertSame('robert', $check->name); + $this->assertSame('pass', $check->password); + } + + public function testUpdateAttributeDoesNotTouchOtherFields() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $loaded = new User(new PDO('sqlite:test.db')); + $loaded->find(1); + $loaded->updateAttribute('password', 'newpass'); + + $check = new User(new PDO('sqlite:test.db')); + $check->find(1); + $this->assertSame('bob', $check->name); + $this->assertSame('newpass', $check->password); + } + + public function testDistinctProducesSelectDistinct() + { + $this->ActiveRecord->execute("CREATE TABLE distinct_test (name TEXT)"); + $record = new class (new PDO('sqlite:test.db'), 'distinct_test') extends ActiveRecord { + }; + $record->dirty([ 'name' => 'bob' ]); + $record->insert(); + $record->dirty([ 'name' => 'bob' ]); + $record->insert(); + + $rows = $record->distinct()->findAll(); + $this->assertCount(1, $rows); + $this->assertStringContainsString('SELECT DISTINCT "distinct_test".*', $record->getBuiltSql()); + } + + public function testDistinctWithWhere() + { + $this->ActiveRecord->execute("CREATE TABLE distinct_test (name TEXT)"); + $record = new class (new PDO('sqlite:test.db'), 'distinct_test') extends ActiveRecord { + }; + $record->dirty([ 'name' => 'bob' ]); + $record->insert(); + $record->dirty([ 'name' => 'bob' ]); + $record->insert(); + $record->dirty([ 'name' => 'alice' ]); + $record->insert(); + + $rows = $record->eq('name', 'bob')->distinct()->findAll(); + $this->assertCount(1, $rows); + } + + public function testDistinctWithPluck() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $this->assertSame([ 'bob', 'alice' ], $user->orderByColumn('id')->distinct()->pluck('name')); + } + + public function testUpdateAllMatchingRows() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + + $count = $user->eq('name', 'bob')->updateAll([ 'password' => 'newpass' ]); + $this->assertSame(1, $count); + + $check = new User(new PDO('sqlite:test.db')); + $check->find(1); + $this->assertSame('newpass', $check->password); + + $check2 = new User(new PDO('sqlite:test.db')); + $check2->find(2); + $this->assertSame('pass2', $check2->password); + } + + public function testUpdateAllRequiresWhere() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('updateAll() requires WHERE conditions; pass true to update every row'); + $user->updateAll([ 'password' => 'reset' ]); + } + + public function testUpdateAllAllowEmptyConditions() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $this->assertSame(3, $user->updateAll([ 'password' => 'reset' ], true)); + } + + public function testUpdateAllReturnsCount() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + + $this->assertSame(2, $user->updateAll([ 'password' => 'x' ], true)); + $this->assertSame(0, $user->eq('name', 'nobody')->updateAll([ 'password' => 'y' ])); + } + + public function testUpdateAllNoCallbacks() + { + $user = new class (new PDO('sqlite:test.db')) extends User { + protected function beforeUpdate(self $self) + { + throw new \Exception('beforeUpdate should not fire for batch updates'); + } + protected function afterUpdate(self $self) + { + throw new \Exception('afterUpdate should not fire for batch updates'); + } + }; + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $this->assertSame(1, $user->eq('name', 'bob')->updateAll([ 'password' => 'x' ])); + } + + public function testUpdateAllAllowEmptyConditionsWithWhere() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + // Both flag and WHERE present: WHERE governs, flag is moot + $this->assertSame(1, $user->eq('name', 'bob')->updateAll([ 'password' => 'reset' ], true)); + + $check = new User(new PDO('sqlite:test.db')); + $check->find(1); + $this->assertSame('reset', $check->password); + + $check2 = new User(new PDO('sqlite:test.db')); + $check2->find(2); + $this->assertSame('pass2', $check2->password); + } + + public function testDeleteAllMatchingRows() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + + $this->assertSame(1, $user->eq('name', 'bob')->deleteAll()); + $this->assertSame(1, $user->count()); + } + + public function testDeleteAllRequiresWhere() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('deleteAll() requires WHERE conditions; pass true to delete every row'); + $user->deleteAll(); + } + + public function testDeleteAllAllowEmptyConditions() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $this->assertSame(3, $user->deleteAll(true)); + } + + public function testDeleteAllReturnsCount() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $this->assertSame(0, $user->eq('name', 'nobody')->deleteAll()); + $this->assertSame(1, $user->deleteAll(true)); + } + + public function testDeleteAllNoCallbacks() + { + $user = new class (new PDO('sqlite:test.db')) extends User { + protected function beforeDelete(self $self) + { + throw new \Exception('beforeDelete should not fire for batch deletes'); + } + protected function afterDelete(self $self) + { + throw new \Exception('afterDelete should not fire for batch deletes'); + } + }; + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + $this->assertSame(1, $user->eq('name', 'bob')->deleteAll()); + } + + public function testDeleteAllAllowEmptyConditionsWithWhere() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + // Both flag and WHERE present: WHERE governs, flag is moot + $this->assertSame(1, $user->eq('name', 'bob')->deleteAll(true)); + + $check = new User(new PDO('sqlite:test.db')); + $this->assertSame(1, $check->count()); + $check->find(2); + $this->assertSame('alice', $check->name); + } + + public function testInsertSetsTimestamps() + { + $this->ActiveRecord->execute("CREATE TABLE timestamped ( + id INTEGER PRIMARY KEY, + name TEXT, + created_at TEXT, + updated_at TEXT + )"); + $record = new class (new PDO('sqlite:test.db'), 'timestamped') extends ActiveRecord { + protected bool $timestamps = true; + }; + $record->dirty([ 'name' => 'bob' ]); + $record->insert(); + + $this->assertNotNull($record->created_at); + $this->assertNotNull($record->updated_at); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $record->created_at); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $record->updated_at); + } + + public function testUpdateSetsUpdatedAt() + { + $this->ActiveRecord->execute("CREATE TABLE timestamped ( + id INTEGER PRIMARY KEY, + name TEXT, + created_at TEXT, + updated_at TEXT + )"); + $record = new class (new PDO('sqlite:test.db'), 'timestamped') extends ActiveRecord { + protected bool $timestamps = true; + }; + $record->dirty([ 'name' => 'bob' ]); + $record->insert(); + $created_at = $record->created_at; + + $loaded = new class (new PDO('sqlite:test.db'), 'timestamped') extends ActiveRecord { + protected bool $timestamps = true; + }; + $loaded->find(1); + $loaded->updateAttribute('name', 'robert'); + + $this->assertSame($created_at, $loaded->created_at); + $this->assertNotNull($loaded->updated_at); + $this->assertGreaterThanOrEqual($created_at, $loaded->updated_at); + } + + public function testTimestampsDisabledByDefault() + { + $this->ActiveRecord->execute("CREATE TABLE timestamped ( + id INTEGER PRIMARY KEY, + name TEXT, + created_at TEXT, + updated_at TEXT + )"); + $record = new class (new PDO('sqlite:test.db'), 'timestamped') extends ActiveRecord { + }; + $record->dirty([ 'name' => 'bob' ]); + $record->insert(); + + $this->assertNull($record->created_at); + $this->assertNull($record->updated_at); + } + + public function testTimestampsDontOverwrite() + { + $this->ActiveRecord->execute("CREATE TABLE timestamped ( + id INTEGER PRIMARY KEY, + name TEXT, + created_at TEXT, + updated_at TEXT + )"); + $record = new class (new PDO('sqlite:test.db'), 'timestamped') extends ActiveRecord { + protected bool $timestamps = true; + }; + $record->created_at = '2024-01-01 00:00:00'; + $record->updated_at = '2024-01-01 00:00:00'; + $record->name = 'bob'; + $record->insert(); + + $this->assertSame('2024-01-01 00:00:00', $record->created_at); + $this->assertSame('2024-01-01 00:00:00', $record->updated_at); + } + + public function testTimestampsWithCustomFormat() + { + $this->ActiveRecord->execute("CREATE TABLE timestamped ( + id INTEGER PRIMARY KEY, + name TEXT, + created_at TEXT, + updated_at TEXT + )"); + $record = new class (new PDO('sqlite:test.db'), 'timestamped') extends ActiveRecord { + protected bool $timestamps = true; + protected function setTimestamps(bool $isNew = true): void + { + if ($isNew === true) { + $this->created_at = 'custom-created'; + } + $this->updated_at = 'custom-updated'; + } + }; + $record->dirty([ 'name' => 'bob' ]); + $record->insert(); + + $this->assertSame('custom-created', $record->created_at); + $this->assertSame('custom-updated', $record->updated_at); + } + + public function testTimestampsMissingColumnsErrors() + { + $this->ActiveRecord->execute("CREATE TABLE no_ts_columns ( + id INTEGER PRIMARY KEY, + name TEXT + )"); + $record = new class (new PDO('sqlite:test.db'), 'no_ts_columns') extends ActiveRecord { + protected bool $timestamps = true; + }; + $record->dirty([ 'name' => 'bob' ]); + + try { + $record->insert(); + $this->fail('Enabling timestamps on a table without the columns should error'); + } catch (\Exception $e) { + $this->assertStringContainsString('has no column named created_at', $e->getMessage()); + } + } + + public function testScopeReturnsConfiguredModel() + { + $user = new class (new PDO('sqlite:test.db')) extends User { + public function namedBob(): self + { + return $this->eq('name', 'bob'); + } + }; + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $result = $user->namedBob(); + $this->assertSame($user, $result); + $this->assertTrue($user->exists()); + } + + public function testScopesChainable() + { + $user = new class (new PDO('sqlite:test.db')) extends User { + public function namedBob(): self + { + return $this->eq('name', 'bob'); + } + }; + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bobby', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $users = $user->namedBob()->eq('password', 'pass')->findAll(); + $this->assertCount(1, $users); + $this->assertSame('bob', $users[0]->name); + } + + public function testScopeWithParams() + { + $user = new class (new PDO('sqlite:test.db')) extends User { + public function nameLike(string $name): self + { + return $this->like('name', $name . '%'); + } + }; + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'bobby', 'password' => 'pass2' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass3' ]); + $user->insert(); + + $users = $user->nameLike('bob')->findAll(); + $this->assertCount(2, $users); + } + + public function testScopeHelperByName() + { + $user = new class (new PDO('sqlite:test.db')) extends User { + public function namedBob(): self + { + return $this->eq('name', 'bob'); + } + }; + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + $user->dirty([ 'name' => 'alice', 'password' => 'pass2' ]); + $user->insert(); + + $users = $user->scope('namedBob')->findAll(); + $this->assertCount(1, $users); + $this->assertSame('bob', $users[0]->name); + } + + public function testScopeOnConfiguredInstance() + { + $user = new class (new PDO('sqlite:test.db')) extends User { + public function namedBob(): self + { + return $this->eq('name', 'bob'); + } + }; + $user->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $user->insert(); + + // Scopes run against the live connection — no null-connection crash + $found = $user->namedBob()->find(); + $this->assertTrue($found->isHydrated()); + $this->assertSame('bob', $found->name); + } + + public function testTransactionCommit() + { + $user = new User(new PDO('sqlite:test.db')); + $user->transaction(function ($u) { + $u->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $u->insert(); + }); + + $check = new User(new PDO('sqlite:test.db')); + $this->assertSame(1, $check->count()); + } + + public function testTransactionRollback() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'keep', 'password' => 'pass' ]); + $user->insert(); + + try { + $user->transaction(function ($u) { + $u->dirty([ 'name' => 'rolled', 'password' => 'pass2' ]); + $u->insert(); + throw new \Exception('boom'); + }); + $this->fail('Exception should have been re-thrown'); + } catch (\Exception $e) { + $this->assertSame('boom', $e->getMessage()); + } + + $check = new User(new PDO('sqlite:test.db')); + $this->assertSame(1, $check->count()); + } + + public function testTransactionReturnsValue() + { + $user = new User(new PDO('sqlite:test.db')); + $result = $user->transaction(function ($u) { + $u->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $u->insert(); + return 42; + }); + + $this->assertSame(42, $result); + } + + public function testTransactionRethrowsException() + { + $user = new User(new PDO('sqlite:test.db')); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('boom'); + $user->transaction(function () { + throw new \Exception('boom'); + }); + } + + public function testTransactionMultipleInserts() + { + $user = new User(new PDO('sqlite:test.db')); + $user->transaction(function ($u) { + $u->dirty([ 'name' => 'bob', 'password' => 'pass' ]); + $u->insert(); + $u->dirty([ 'name' => 'bob2', 'password' => 'pass2' ]); + $u->insert(); + }); + + $check = new User(new PDO('sqlite:test.db')); + $this->assertSame(2, $check->count()); + } + + public function testTransactionRollbackPreservesState() + { + $user = new User(new PDO('sqlite:test.db')); + $user->dirty([ 'name' => 'keep', 'password' => 'pass' ]); + $user->insert(); + + try { + $user->transaction(function ($u) { + $u->dirty([ 'name' => 'doomed', 'password' => 'pass2' ]); + $u->insert(); + throw new \Exception('rollback'); + }); + $this->fail('Exception should have been re-thrown'); + } catch (\Exception $e) { + // expected + } + + $check = new User(new PDO('sqlite:test.db')); + $check->find(1); + $this->assertSame('keep', $check->name); + $this->assertSame(1, $check->count()); + } + + public function testTransactionNestedAttempts() + { + $user = new User(new PDO('sqlite:test.db')); + + try { + $user->transaction(function ($u) { + $u->transaction(function () { + return null; + }); + }); + $this->fail('Nested transactions are not supported and should throw'); + } catch (\Exception $e) { + $this->assertStringContainsString('transaction', strtolower($e->getMessage())); + } + } } diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 43b3d90..730e0d7 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -483,4 +483,384 @@ public function query(string $sql, array $param = [], ?ActiveRecord $obj = null, $this->assertStringContainsString('"name" = :ph', $sql); $this->assertStringNotContainsString($payload, $sql); } + + public function testCountSqlGeneration() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetchColumn')->willReturn('3'); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $this->assertSame(3, $record->eq('status', 'active')->count()); + $sql = $record->getBuiltSql(); + $this->assertStringContainsString('SELECT COUNT(*)', $sql); + $this->assertStringContainsString('WHERE "test_table"."status" = :ph1', $sql); + $this->assertStringNotContainsString('GROUP BY', $sql); + } + + public function testCountSqlGenerationIgnoresGroup() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetchColumn')->willReturn('3'); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $record->groupBy('name')->count(); + $this->assertStringNotContainsString('GROUP BY', $record->getBuiltSql()); + } + + public function testExistsSqlGeneration() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetchColumn')->willReturn('1'); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $this->assertTrue($record->eq('name', 'John')->exists()); + $sql = $record->getBuiltSql(); + $this->assertStringContainsString('SELECT 1', $sql); + $this->assertStringContainsString('LIMIT 1', $sql); + } + + public function testExistsSqlGenerationNoMatch() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetchColumn')->willReturn(false); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $this->assertFalse($record->eq('name', 'Nobody')->exists()); + } + + public function testExistsSqlGenerationRespectsExistingLimit() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetchColumn')->willReturn('1'); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $this->assertTrue($record->eq('name', 'John')->limit(5)->exists()); + $this->assertStringContainsString('LIMIT 5', $record->getBuiltSql()); + } + + public function testPluckSqlGeneration() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetchColumn')->willReturnOnConsecutiveCalls('bob', false); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $this->assertSame([ 'bob' ], $record->pluck('name')); + $this->assertStringContainsString('SELECT "name" FROM "test_table"', $record->getBuiltSql()); + } + + public function testPluckMultipleRows() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetchColumn')->willReturnOnConsecutiveCalls('bob', 'bob2', 'alice', false); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $this->assertSame([ 'bob', 'bob2', 'alice' ], $record->pluck('name')); + } + + public function testPluckEmptyResultSet() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetchColumn')->willReturn(false); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $this->assertSame([], $record->pluck('name')); + } + + public function testFirstSqlGeneration() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetch')->willReturn(false); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $result = $record->first(); + $this->assertInstanceOf(ActiveRecord::class, $result); + $this->assertFalse($result->isHydrated()); + $this->assertStringContainsString('ORDER BY "id" ASC LIMIT 1', $record->getBuiltSql()); + } + + public function testLastSqlGeneration() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetch')->willReturn(false); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $result = $record->last(); + $this->assertInstanceOf(ActiveRecord::class, $result); + $this->assertFalse($result->isHydrated()); + $this->assertStringContainsString('ORDER BY "id" DESC LIMIT 1', $record->getBuiltSql()); + } + + public function testFirstSqlGenerationRespectsExistingLimit() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('fetch')->willReturn(false); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + // find() forces LIMIT 1 regardless (existing contract) — the explicit + // limit branch in first() must simply not overwrite the order default. + $record->limit(5)->first(); + $this->assertStringContainsString('ORDER BY "id" ASC LIMIT 1', $record->getBuiltSql()); + } + + public function testDistinctSqlGeneration() + { + $record = new class (null, 'test_table') extends ActiveRecord { + public function query(string $sql, array $param = [], ?ActiveRecord $obj = null, bool $single = false) + { + return $this; + } + }; + $record->distinct()->find(); + $this->assertStringContainsString('SELECT DISTINCT test_table.*', $record->getBuiltSql()); + } + + public function testDistinctDefaultFalse() + { + $record = new class (null, 'test_table') extends ActiveRecord { + public function getIsDistinct() + { + return $this->isDistinct; + } + }; + $this->assertFalse($record->getIsDistinct()); + } + + public function testDistinctResetsAfterQuery() + { + $record = new class (null, 'test_table') extends ActiveRecord { + public function getIsDistinct() + { + return $this->isDistinct; + } + public function query(string $sql, array $param = [], ?ActiveRecord $obj = null, bool $single = false) + { + return $this; + } + }; + $record->distinct()->find(); + $this->assertFalse($record->getIsDistinct()); + } + + public function testUpdateAllSqlGeneration() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('rowCount')->willReturn(2); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $this->assertSame(2, $record->eq('name', 'John')->updateAll([ 'password' => 'secret' ])); + $this->assertStringContainsString( + 'UPDATE "test_table" SET "password" = :ph2 WHERE "test_table"."name" = :ph1', + $record->getBuiltSql() + ); + } + + public function testUpdateAllSqlGenerationRequiresWhere() + { + $record = new class (null, 'test_table') extends ActiveRecord { + }; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('updateAll() requires WHERE conditions; pass true to update every row'); + $record->updateAll([ 'password' => 'secret' ]); + } + + public function testDeleteAllSqlGenerationRequiresWhere() + { + $record = new class (null, 'test_table') extends ActiveRecord { + }; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('deleteAll() requires WHERE conditions; pass true to delete every row'); + $record->deleteAll(); + } + + public function testDeleteAllSqlGeneration() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $statement_mock->method('rowCount')->willReturn(1); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $this->assertSame(1, $record->eq('name', 'John')->deleteAll()); + $this->assertStringContainsString( + 'DELETE FROM "test_table" WHERE "test_table"."name" = :ph1', + $record->getBuiltSql() + ); + } + + public function testTimestampsSqlGeneration() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + protected bool $timestamps = true; + }; + $record->dirty([ 'name' => 'bob' ]); + $record->insert(); + + $sql = $record->getBuiltSql(); + $this->assertStringContainsString('"created_at"', $sql); + $this->assertStringContainsString('"updated_at"', $sql); + } + + public function testTimestampsUpdateSqlGeneration() + { + $statement_mock = $this->createStub(PDOStatement::class); + $statement_mock->method('execute')->willReturn(true); + $pdo_mock = $this->createStub(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->method('prepare')->willReturn($statement_mock); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + protected bool $timestamps = true; + }; + $record->dirty([ 'name' => 'bob' ]); + $record->update(); + + $sql = $record->getBuiltSql(); + $this->assertStringContainsString('"updated_at"', $sql); + $this->assertStringNotContainsString('"created_at"', $sql); + } + + public function testScopeSqlGeneration() + { + $record = new class (null, 'test_table') extends ActiveRecord { + public function published(): self + { + return $this->eq('status', 'published'); + } + public function query(string $sql, array $param = [], ?ActiveRecord $obj = null, bool $single = false) + { + return $this; + } + }; + $record->published()->eq('views', 100)->find(); + $this->assertStringContainsString( + 'WHERE test_table.status = :ph1 AND test_table.views = :ph2', + $record->getBuiltSql() + ); + } + + public function testScopeHelperReturnsModel() + { + $record = new class (null, 'test_table') extends ActiveRecord { + public function published(): self + { + return $this->eq('status', 'published'); + } + }; + $this->assertSame($record, $record->scope('published')); + } + + public function testScopeThrowsOnUndefined() + { + $record = new class (null, 'test_table') extends ActiveRecord { + }; + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage("Scope 'nonexistent' does not exist"); + $record->scope('nonexistent'); + } + + public function testTransactionSqlGeneration() + { + $pdo_mock = $this->createMock(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->expects($this->once())->method('beginTransaction')->willReturn(true); + $pdo_mock->expects($this->once())->method('commit')->willReturn(true); + $pdo_mock->expects($this->never())->method('rollBack'); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + $result = $record->transaction(function ($r) use ($record) { + $this->assertSame($record, $r); + return 'done'; + }); + $this->assertSame('done', $result); + } + + public function testTransactionRollbackCallsAdapter() + { + $pdo_mock = $this->createMock(PDO::class); + $pdo_mock->method('getAttribute')->willReturn('sqlite'); + $pdo_mock->expects($this->once())->method('beginTransaction')->willReturn(true); + $pdo_mock->expects($this->once())->method('rollBack')->willReturn(true); + $pdo_mock->expects($this->never())->method('commit'); + $record = new class ($pdo_mock, 'test_table') extends ActiveRecord { + }; + + try { + $record->transaction(function () { + throw new \Exception('boom'); + }); + $this->fail('Exception should have been re-thrown'); + } catch (\Exception $e) { + $this->assertSame('boom', $e->getMessage()); + } + } } diff --git a/tests/classes/QueryCountingAdapter.php b/tests/classes/QueryCountingAdapter.php index 43ae9ed..1c13ea3 100644 --- a/tests/classes/QueryCountingAdapter.php +++ b/tests/classes/QueryCountingAdapter.php @@ -28,6 +28,21 @@ public function lastInsertId() return $this->wrappedAdapter->lastInsertId(); } + public function beginTransaction(): bool + { + return $this->wrappedAdapter->beginTransaction(); + } + + public function commit(): bool + { + return $this->wrappedAdapter->commit(); + } + + public function rollback(): bool + { + return $this->wrappedAdapter->rollback(); + } + public function getQueryCount(): int { return count($this->executedQueries);