exec('DROP TABLE IF EXISTS "test"'); $pdo->exec( <<exec('DROP TABLE IF EXISTS "fts_table"'); $pdo->exec('CREATE VIRTUAL TABLE "fts_table" USING fts5( name, json_data);'); } public function setUp(): void { $this->handler = new TestHandler(); $logger = new Logger('logger'); $logger->pushHandler($this->handler); Guid::setLogger($logger); if (null === Config::get('database', null)) { Config::init([ 'database' => ag(require __DIR__ . '/../../config/config.php', 'database', []) ]); } $this->db = new DBLayer(new PDO(dsn: 'sqlite::memory:', options: Config::get('database.options', []))); $this->createDB($this->db->getBackend()); foreach (Config::get('database.exec', []) as $cmd) { $this->db->exec($cmd); } } public function test_exec() { $this->checkException( closure: fn() => $this->db->exec('SELECT * FROM movies'), reason: 'Should throw an exception when an error occurs and no on_failure handler is set.', exception: DBLayerException::class, exceptionMessage: 'no such table', ); $this->checkException( closure: fn() => $this->db->exec( sql: 'SELECT * FROM movies', options: ['on_failure' => fn(Throwable $e) => throw new ErrorException('Error occurred')] ), reason: 'the on_failure handler should be called when an error occurs.', exception: ErrorException::class, exceptionMessage: 'Error occurred', ); $this->assertSame(0, $this->db->exec('DELETE FROM test')); } public function test_query() { $this->checkException( closure: fn() => $this->db->query(sql: 'SELECT * FROM movies'), reason: 'Should throw an exception when an error occurs and no on_failure handler is set.', exception: DBLayerException::class, exceptionMessage: 'no such table', ); $this->checkException( closure: fn() => $this->db->query( sql: 'SELECT * FROM movies', options: ['on_failure' => fn(Throwable $e) => throw new ErrorException('Error occurred')] ), reason: 'the on_failure handler should be called when an error occurs.', exception: ErrorException::class, exceptionMessage: 'Error occurred', ); $this->checkException( closure: function () { $options = Config::get('database.options', []); $options[PDO::ATTR_ERRMODE] = PDO::ERRMODE_SILENT; $pdo = new PDO(dsn: 'sqlite::memory:', options: $options); $db = new DBLayer($pdo); foreach (Config::get('database.exec', []) as $cmd) { $this->db->exec($cmd); } new PDOAdapter(new Logger('test'), $this->db)->migrations('up'); return $db->query(sql: 'SELECT * FROM test WHERE zid = :id'); }, reason: 'If PDO error mode is set to silent mode, failing to prepare a statement should still throw an exception.', exception: DBLayerException::class, exceptionMessage: 'Unable to prepare statement.', ); } public function test_transactions_operations() { $this->db->start(); $this->assertTrue($this->db->inTransaction(), 'Should be in transaction.'); $this->assertFalse($this->db->start(), 'Should not start a new transaction if we are already in one.'); $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); $this->assertSame('1', $this->db->lastInsertId(), 'Should return last insert id.'); $this->db->rollBack(); $this->assertFalse($this->db->inTransaction(), 'Should not be in transaction.'); $this->db->start(); $this->assertCount( 0, $this->db->select('sqlite_sequence')->fetchAll(), 'Should not have any records, as we rolled back.' ); $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); $this->assertSame('1', $this->db->lastInsertId(), 'Should return last insert id.'); $this->db->commit(); $this->assertFalse($this->db->inTransaction(), 'Should not be in transaction.'); $this->assertCount( 1, $this->db->select('sqlite_sequence')->fetchAll(), 'Should have one record, as we committed.' ); $this->checkException( closure: fn() => $this->db->transactional(function (DBLayer $db) { $this->db->insert('sqlite_sequence', ['name' => 'test2', 'seq' => 1]); $db->insert('not_set', ['name' => 'test', 'seq' => 1]); }), reason: 'Should throw an exception when trying to commit without starting a transaction.', exception: DBLayerException::class, exceptionMessage: 'no such table', ); $this->assertCount( 1, $this->db->select('sqlite_sequence')->fetchAll(), 'Should have one record, as the previous transaction was rolled back.' ); $ret = $this->db->transactional(function (DBLayer $db) { return $db->insert('sqlite_sequence', ['name' => 'test2', 'seq' => 1]); }); $this->assertSame(1, $ret->rowCount(), 'Should return the number of affected rows.'); } public function test_insert() { $this->checkException( closure: fn() => $this->db->insert('test', []), reason: 'Should throw exception if conditions parameter is empty.', exception: RuntimeException::class ); } public function test_delete() { $this->checkException( closure: fn() => $this->db->delete('test', []), reason: 'Should throw exception if conditions parameter is empty.', exception: RuntimeException::class ); $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); try { $this->assertSame( 1, $this->db->delete('sqlite_sequence', ['name' => 'test'], options: [ 'limit' => 1, 'ignore_safety' => true ])->rowCount(), 'Should return the number of affected rows.' ); } catch (DBLayerException $e) { if (str_contains($e->getMessage(), 'near "LIMIT": syntax error') && 'sqlite' === $this->db->getDriver()) { $this->assertSame( 1, $this->db->delete('sqlite_sequence', ['name' => 'test'])->rowCount(), 'Should return the number of affected rows.' ); } else { throw $e; } } } public function test_getCount() { $this->assertSame( 0, $this->db->getCount('sqlite_sequence'), 'Should return the number of records in the table.' ); $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); $total = $this->db->getCount('sqlite_sequence', [ 'seq' => [DBLayer::IS_HIGHER_THAN_OR_EQUAL, 1] ], options: [ 'groupby' => ['name'], 'orderby' => ['name' => 'ASC'], ]); $this->assertSame(1, $total, 'Should return the number of records in the table.'); $this->assertSame($total, $this->db->totalRows(), 'Should return the number of records in the table.'); $this->db->delete('sqlite_sequence', ['name' => 'test']); $this->assertSame( 0, $this->db->getCount('sqlite_sequence'), 'Should return the number of records in the table.' ); } public function test_update() { $this->checkException( closure: fn() => $this->db->update('test', [], ['id' => 1]), reason: 'Should throw exception if conditions parameter is empty.', exception: RuntimeException::class ); $this->checkException( closure: fn() => $this->db->update('test', ['name' => 'test'], []), reason: 'Should throw exception if conditions parameter is empty.', exception: RuntimeException::class ); $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); $this->assertSame( 1, $this->db->update('sqlite_sequence', ['seq' => 2], ['name' => 'test'])->rowCount(), 'Should return the number of affected rows.' ); try { $this->assertSame( 1, $this->db->update('sqlite_sequence', ['seq' => 1], ['name' => 'test'], options: [ 'limit' => 1, 'ignore_safety' => true ])->rowCount(), 'Should return the number of affected rows.' ); } catch (DBLayerException $e) { if (str_contains($e->getMessage(), 'near "LIMIT": syntax error') && 'sqlite' === $this->db->getDriver()) { $this->assertSame( 1, $this->db->update('sqlite_sequence', ['seq' => 1], ['name' => 'test'])->rowCount(), 'Should return the number of affected rows.' ); } else { throw $e; } } } public function test_quote() { if ('sqlite' === $this->db->getDriver()) { $this->assertEquals("'test'", $this->db->quote('test'), "Should return 'test'."); $this->assertSame("'''test'''", $this->db->quote("'test'"), "Should return ''''test''''."); $this->assertSame("'\"test\"'", $this->db->quote('"test"'), "Should return '\"test\"'."); } } public function test_id() { $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); $this->assertSame('1', $this->db->id('test'), 'Should return the last insert id.'); $this->assertSame('1', $this->db->id(), 'Should return the last insert id.'); } public function test_escapeIdentifier() { $this->checkException( closure: fn() => $this->db->escapeIdentifier(''), reason: 'Should throw exception if the identifier is empty.', exception: RuntimeException::class, exceptionMessage: 'Column/table must be valid ASCII code' ); $this->checkException( closure: fn() => $this->db->escapeIdentifier('😊'), reason: 'Should throw exception if the identifier contains non-ASCII characters.', exception: RuntimeException::class, exceptionMessage: 'Column/table must be valid ASCII code.' ); $this->checkException( closure: fn() => $this->db->escapeIdentifier('1foo'), reason: 'Should throw exception if the identifier contains non-ASCII characters.', exception: RuntimeException::class, exceptionMessage: 'Must begin with a letter or underscore' ); $this->assertSame('foo', $this->db->escapeIdentifier('foo'), 'Should return foo if quote is off.'); if ('sqlite' === $this->db->getDriver()) { $this->assertSame('"foo"', $this->db->escapeIdentifier('foo', true), 'Should return "foo".'); $this->assertSame( '""foo"."bar""', $this->db->escapeIdentifier('"foo"."bar"', true), 'Should return ""foo"."bar"".' ); } } public function test_getBackend() { $this->assertInstanceOf(PDO::class, $this->db->getBackend(), 'Should return the PDO instance.'); } public function test_select() { $this->db->insert('test', [ 'name' => 'test', 'watched' => 1, 'added_at' => 1, 'updated_at' => 2, 'json_data' => json_encode([ 'my_id' => 1, 'my_name' => 'test', 'my_data' => [ 'my_id' => 1, 'my_name' => 'test', ], ]), ]); $this->db->insert('test', [ 'name' => 'test2', 'watched' => 0, 'added_at' => 3, 'updated_at' => 4, 'json_data' => json_encode([ 'my_id' => 2, 'my_name' => 'test2', 'my_data' => [ 'my_id' => 2, 'my_name' => 'test2', ], ]), ]); $data1 = $this->db->select('test', [], ['id' => 1])->fetch(); $data2 = $this->db->select('test', [], ['id' => 2])->fetch(); $this->checkException( closure: fn() => $this->db->select('test', ['id' => 1], ['id' => 1]), reason: 'Should throw TypeError exception if cols value is not a string.', exception: TypeError::class, exceptionMessage: 'must be of type string', ); $this->checkException( closure: fn() => $this->db->select('test', ['*'], ['id'], options: ['count' => true]), reason: 'Should throw exception if conditions parameter is not an key/value pairs.', exception: TypeError::class, exceptionMessage: 'must be of type string', ); $this->assertSame( $data1, $this->db->select('test', [], ['id' => 1])->fetch(), 'Should return the record with id 1.' ); $this->assertSame( $data2, $this->db->select('test', [], ['id' => 2])->fetch(), 'Should return the record with id 2.' ); $this->assertSame( $data2, $this->db->select('test', [], ['id' => 2], options: [ 'orderby' => ['id' => 'DESC'], 'limit' => 1, 'start' => 0, 'groupby' => ['id'], ])->fetch(), 'Should return the record with id 2.' ); } public function test_lock_retry() { /** @noinspection PhpUnhandledExceptionInspection */ $random = random_int(1, 100); $this->db->transactional(function (DBLayer $db, array $options = []) use ($random) { // -- trigger database lock exception if ((int)ag($options, 'attempts', 0) < 1) { throw new PDOException('database is locked'); } $db->insert('sqlite_sequence', ['name' => 'test-' . $random, 'seq' => 1]); }, options: [ 'max_sleep' => 0, ]); $this->assertSame(1, $this->db->getCount('sqlite_sequence', ['name' => 'test-' . $random])); $this->checkException( closure: function () use ($random) { $this->db->transactional(fn() => throw new PDOException('database is locked'), options: [ 'max_sleep' => 0, 'max_attempts' => 1, ]); }, reason: 'Should throw an exception when the maximum number of attempts is reached.', exception: DBLayerException::class, exceptionMessage: 'database is locked', ); $this->checkException( closure: function () use ($random) { $this->db->transactional(fn() => throw new PDOException('database is locked'), options: [ 'max_sleep' => 0, 'max_attempts' => 1, 'on_lock' => fn() => throw new DBLayerException('on_lock called'), ]); }, reason: 'Should throw an exception when the maximum number of attempts is reached.', exception: DBLayerException::class, exceptionMessage: 'on_lock called', ); } public function test_condition_parser() { $this->db->insert('test', [ 'name' => 'test', 'watched' => 1, 'added_at' => 1, 'updated_at' => 2, 'json_data' => json_encode([ 'my_id' => 1, 'my_name' => 'test', 'my_data' => [ 'my_id' => 1, 'my_name' => 'test', ], ]), ]); $this->db->insert('test', [ 'name' => 'test2', 'watched' => 0, 'added_at' => 3, 'updated_at' => 4, 'json_data' => json_encode([ 'my_id' => 2, 'my_name' => 'test2', 'my_data' => [ 'my_id' => 2, 'my_name' => 'test2', ], ]), ]); $data1 = $this->db->select('test', [], ['id' => 1])->fetch(); $data2 = $this->db->select('test', [], ['id' => 2])->fetch(); $this->db->insert('fts_table', ['name' => $data1['name'], 'json_data' => $data1['json_data']]); $this->db->insert('fts_table', ['name' => $data2['name'], 'json_data' => $data2['json_data']]); $this->assertSame( $data1, $this->db->select('test', [], [ 'id' => [DBLayer::IS_LOWER_THAN_OR_EQUAL, 1], ])->fetch(), 'Should return the record with id 1.' ); $this->assertSame( $data2, $this->db->select('test', [], [ 'id' => [DBLayer::IS_HIGHER_THAN_OR_EQUAL, 2], ])->fetch(), 'Should return the record with id 1.' ); $this->assertSame( $data1, $this->db->select('test', [], [ 'added_at' => [DBLayer::IS_BETWEEN, [1, 2]], ])->fetch(), 'Should return the record with id 1.' ); $this->assertSame( $data2, $this->db->select('test', [], [ 'added_at' => [DBLayer::IS_NOT_BETWEEN, [1, 2]], ])->fetch(), 'Should return the record with id 2.' ); $this->assertSame( $data1, $this->db->select('test', [], [ 'nullable' => [DBLayer::IS_NULL], ])->fetch(), 'Should return the record with id 1.' ); $this->assertSame( $data2, $this->db->select('test', [], [ 'name' => [DBLayer::IS_LIKE, 'test2'], ])->fetch(), 'Should return the record with id 2.' ); $this->assertSame( $data1, $this->db->select('test', [], [ 'name' => [DBLayer::IS_NOT_LIKE, 'test2'], ])->fetch(), 'Should return the record with id 1.' ); $this->assertSame( $data1, $this->db->select('test', [], [ 'id' => [DBLayer::IS_IN, [0, 1]], ])->fetch(), 'Should return the record with id 1.' ); $this->assertSame( $data2, $this->db->select('test', [], [ 'id' => [DBLayer::IS_NOT_IN, [0, 1]], ])->fetch(), 'Should return the record with id 2.' ); try { $this->assertSame( $data2, $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_CONTAINS, '$.my_id', 2],])->fetch(), 'Should return the record with id 1.' ); } catch (DBLayerException $e) { if (str_contains($e->getMessage(), 'no such function') && 'sqlite' === $this->db->getDriver()) { // -- pass as sqlite does not support json_contains } else { throw $e; } } $this->checkException( closure: fn() => $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_CONTAINS, '$.my_id'],] )->fetch(), reason: 'Should throw an exception when json_contains receives less then expected parameters.', exception: RuntimeException::class, exceptionMessage: 'IS_JSON_CONTAINS: expects 2', ); $this->assertSame( $data2, $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_EXTRACT, '$.my_id', '>', 1]])->fetch(), 'Should return the record with id 2.' ); $this->checkException( closure: fn() => $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_EXTRACT, '$.my_id', '>']]), reason: 'Should throw an exception when json_extract receives less then expected parameters.', exception: RuntimeException::class, exceptionMessage: 'IS_JSON_EXTRACT: expects 3', ); $this->checkException( closure: fn() => $this->db->select('test', [], ['json_data' => ['NOT_SET', '$.my_id', '>']]), reason: 'Should throw exception on unknown operator.', exception: RuntimeException::class, exceptionMessage: 'expr not implemented', ); $this->assertSame( 'test', $this->db->select('fts_table', [], [ 'name' => [DBLayer::IS_MATCH_AGAINST, ['name'], 'test'], ])->fetch()['name'], 'Should return the record with id 2.' ); $this->checkException( closure: fn() => $this->db->select('fts_table', [], [ 'name' => [DBLayer::IS_MATCH_AGAINST, ['name']], ])->fetch(), reason: 'Should throw an exception when match against receives less then expected parameters.', exception: RuntimeException::class, exceptionMessage: 'IS_MATCH_AGAINST: expects 2', ); $this->checkException( closure: fn() => $this->db->select('fts_table', [], [ 'name' => [DBLayer::IS_MATCH_AGAINST, 'name', 'test'], ])->fetch(), reason: 'Should throw an exception when match against receives less then expected parameters.', exception: RuntimeException::class, exceptionMessage: 'IS_MATCH_AGAINST: expects parameter 1 to be array', ); $this->checkException( closure: fn() => $this->db->select('fts_table', [], [ 'name' => [DBLayer::IS_MATCH_AGAINST, ['name'], ['test']], ])->fetch(), reason: 'Should throw an exception when match against receives less then expected parameters.', exception: RuntimeException::class, exceptionMessage: 'IS_MATCH_AGAINST: expects parameter 2 to be string', ); } }