From 2e524fb7d0ff50159fa63e47751c1d5ed70f4264 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Mon, 1 Jul 2024 13:10:10 -0500 Subject: [PATCH] Initial commit --- .gitignore | 3 + Database.php | 364 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 41 +++++- test.php | 128 ++++++++++++++++++ 4 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Database.php create mode 100644 test.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6412174 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +test.db +test.db-shm +test.db-wal \ No newline at end of file diff --git a/Database.php b/Database.php new file mode 100644 index 0000000..7683a40 --- /dev/null +++ b/Database.php @@ -0,0 +1,364 @@ + [], + 'order' => [], + 'limit' => 0, + 'values' => [], + ]; + + /** + * Open a connection to the database. + */ + public function __construct(string $path, array $opts = []) + { + $opts = !empty($opts) ? $opts : [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + + try { + $this->c = new PDO("sqlite:$path", null, null, $opts); + $this->c->exec('PRAGMA foreign_keys = ON;'); // Enable foreign keys + $this->c->exec('PRAGMA journal_mode = WAL;'); // Enable WAL + } catch (PDOException $e) { + $this->error = "Failed to open database: " . $e->getMessage(); + throw $e; + } + } + + /** + * Change the current working table. + */ + public function table(string $name): Database + { + $this->table = $name; + return $this; + } + + /** + * Create a table with the name of the current working table. Will drop + * the table if it already exists. + */ + public function create(array $columns): PDOStatement|false + { + $query = "CREATE TABLE IF NOT EXISTS $this->table (" . implode(', ', $columns) . ');'; + $this->addToLog($query); + return $this->c->query($query); + } + + /** + * Drop the current working table if it exists. + */ + public function drop(): PDOStatement|false + { + $query = "DROP TABLE IF EXISTS $this->table;"; + $this->addToLog($query); + return $this->c->query($query); + } + + /** + * Return the current working table's schema. + */ + public function schema(): array + { + $query = "PRAGMA table_info($this->table);"; + $this->addToLog($query); + return $this->c->query($query)->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Insert data into the current working table. Pass an array of key/value + * pairs to insert one record, or an array of arrays to insert multiple. Returns + * the number of rows inserted, or false on failure. + */ + public function insert(array $data): int|false + { + // If the first element is not an array we can assume we're doing a single insert. + if (!isset($data[0])) { + $query = "INSERT INTO $this->table (" . implode(', ', array_keys($data)) . ')' + . ' VALUES (' . implode(', ', array_fill(0, count($data), '?')) . ');'; + $this->addToLog($query); + return $this->c->prepare($query)->execute(array_values($data)); + } + + // Otherwise we will build a multi-insert query. + $query = "INSERT INTO $this->table (" . implode(', ', array_keys($data[0])) . ') VALUES '; + $placeholders = []; + $values = []; + + foreach ($data as $row) { + $placeholders[] = '(' . implode(', ', array_fill(0, count($row), '?')) . ')'; + foreach ($row as $value) $values[] = $value; + } + + $query .= implode(', ', $placeholders) . ';'; + + $this->addToLog($query); + return $this->c->prepare($query)->execute($values); + } + + /** + * Add a where clause to the builder. All values are paramaterized. + */ + public function where(string $column, string $operator, mixed $value): Database + { + $this->builder['where'][] = "$column $operator ?"; + $this->builder['values'][] = $value; + return $this; + } + + /** + * Add an order clause to the builder. + */ + public function order(string $column, string $direction = 'ASC'): Database + { + $this->builder['order'] = "$column $direction"; + return $this; + } + + /** + * Set a limit in the builder. + */ + public function limit(int $limit): Database + { + $this->builder['limit'] = $limit; + return $this; + } + + /** + * Build a select query and return the result. By default selects the entire + * row. Optionally use fetchAll, but there is also the selectAll() alias method + * for this. + */ + public function select(string $what = '*', bool $fetchAll = false): array|false + { + $query = "SELECT $what FROM $this->table"; + if (!empty($this->builder['where'])) $query .= " WHERE " . implode(' AND ', $this->builder['where']); + if (!empty($this->builder['order'])) $query .= " ORDER BY " . $this->builder['order']; + if (!empty($this->builder['limit'])) $query .= " LIMIT " . $this->builder['limit']; + + $this->addToLog($query); + + try { + $stmt = $this->c->prepare($query); + $stmt->execute($this->builder['values']); + } catch (PDOException $e) { + $this->error = "Failed to execute query: " . $e->getMessage(); + return false; + } + + $this->resetBuilder(); + return $fetchAll ? $stmt->fetchAll() : $stmt->fetch(); + } + + /** + * Delete records from the current working table. Returns the number of rows + * deleted, or false on failure. Uses where clauses if set. + */ + public function delete(): int|false + { + $query = "DELETE FROM $this->table"; + if (!empty($this->builder['where'])) $query .= " WHERE " . implode(' AND ', $this->builder['where']); + $this->addToLog($query); + + $this->resetBuilder(); + return $this->c->exec($query); + } + + /** + * Update records in the current working table. Returns the number of rows + * updated, or false on failure. Uses where clauses if set. + */ + public function update(array $data): int|false + { + $query = "UPDATE $this->table SET " . implode(', ', array_keys($data)) . ' = ?'; + if (!empty($this->builder['where'])) $query .= " WHERE " . implode(' AND ', $this->builder['where']); + $this->addToLog($query); + + $values = array_merge(array_values($data), $this->builder['values']); + + $this->resetBuilder(); + return $this->c->prepare($query)->execute($values); + } + + /** + * Get the number of rows in the current working table. Can use where clauses. + */ + public function count(): int|false + { + return $this->select('COUNT(*)', true)[0]['COUNT(*)']; + } + + /** + * Alias for $db->select(true) + */ + public function selectAll(string $what = '*'): array|false + { + return $this->select($what, true); + } + + /** + * Return whether the given table exists. + */ + public function tableExists(string $name): bool + { + $query = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$name';"; + $this->addToLog($query); + return $this->c->query($query)->fetchColumn() > 0; + } + + /** + * Return the PDO instance; for when more complex operations are needed. + */ + public function c(): PDO + { + return $this->c; + } + + /** + * Alias for PDO::prepare + */ + public function prepare(string $query): PDOStatement + { + return $this->c->prepare($query); + } + + /** + * Alias for PDO::lastInsertId + */ + public function lastInsertId(): int + { + return $this->c->lastInsertId(); + } + + /** + * Alias for PDO::exec + */ + public function exec(string $query): int + { + $this->addToLog($query); + return $this->c->exec($query); + } + + /** + * Alias for PDO::beginTransaction + */ + public function begin(): bool + { + return $this->c->beginTransaction(); + } + + /** + * Alias for PDO::commit + */ + public function commit(): bool + { + return $this->c->commit(); + } + + /** + * Alias for PDO::rollBack + */ + public function rollBack(): bool + { + return $this->c->rollBack(); + } + + /** + * Alias for PDO::query + */ + public function query(string $query): PDOStatement|false + { + $this->addToLog($query); + return $this->c->query($query); + } + + /** + * Add the query to the log and increment the number of queries executed. + */ + private function addToLog(string $query): void + { + $this->log[] = $query; + $this->queries++; + } + + /** + * Reset the builder to it's default structure. + */ + public function resetBuilder(): void + { + $this->builder = [ + 'where' => [], + 'order' => [], + 'limit' => 0, + 'values' => [], + ]; + } + + /** + * Return the log of executed queries. + */ + public function log(): array + { + return $this->log; + } + + /** + * Return the number of queries executed. + */ + public function queries(): int + { + return $this->queries; + } + + /** + * Return the last error message. + */ + public function error(): string + { + return $this->error; + } +} \ No newline at end of file diff --git a/README.md b/README.md index 3b8bfef..c21a1da 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,42 @@ # DBLite -A simple-to-use database API using PDO's SQLite driver. \ No newline at end of file +The DBLite wrapper provides a simple-to-use database API using PDO's SQLite driver. +It was designed to have method signatures very close to Laravel's Eloquent ORM. There +are not, however, any ORM features in the DBLite wrapper. These must be handled at +the model level. + +By default, foreign keys are enabled and the database is in WAL mode. In general +these defaults should be perfect for most use cases. + +## Testing +I wrote a simple test file; run it with PHP directly. `php test.php` +This test script covers almost every relevant method in the class. + +## Examples +```php +$db = new Database('test.db'); + +$db->table('test')->create([ + 'id INTEGER PRIMARY KEY', + 'name TEXT NOT NULL', + 'age INTEGER DEFAULT 18' +]); + +$db->table('test')->insert([ + 'name' => 'John', + 'age' => 32 +]); + +$db->table('test')->where('name', '=', 'John')->select(); +$db->table('test')->where('age', '>', 16)->selectAll(); + +$db->table('test')->where('name', '=', 'John')->delete(); +``` + +## Notes +`Database->table()` sets the current working table's name; there is no real need to use it for every query. +```php +$db->table('test'); +$db->where('name', '=', 'John')->select(); +$db->where('age', '>', 16)->selectAll('age'); +``` \ No newline at end of file diff --git a/test.php b/test.php new file mode 100644 index 0000000..df409b2 --- /dev/null +++ b/test.php @@ -0,0 +1,128 @@ +table('test')->create([ + 'id INTEGER PRIMARY KEY', + 'name TEXT NOT NULL', + 'age INTEGER DEFAULT 18' +]); + +// Make sure the table exists +if (!$db->tableExists('test')) { + exit(bad('Failed to create table.')); +} else { + good('Table created successfully.'); +} + +// Insert some data +$db->table('test')->insert([ + 'name' => 'John', + 'age' => 32 +]); +$db->table('test')->insert([ + ['name' => 'Smith', 'age' => 22], + ['name' => 'Jane', 'age' => 18] +]); + +// Make sure all three rows exist. +if ($db->table('test')->count() !== 3) { + exit(bad('Failed to insert data.')); +} else { + good('Data inserted successfully.'); +} + +// Make sure we only count two rows above age 18. +if ($db->table('test')->where('age', '>', 18)->count() !== 2) { + exit(bad('Failed to count data.')); +} else { + good('Data counted successfully.'); +} + +// Delete John from the table. +$db->table('test')->where('name', '=', 'John')->delete(); + +// Make sure John does not exist, and Jane and Smith still exist. +if ( + $db->table('test')->where('name', '=', 'John')->select() == false + && $db->table('test')->where('name', '=', 'Jane')->select() != false + && $db->table('test')->where('name', '=', 'Smith')->select() != false +) { + exit(bad('Failed to delete data.')); +} else { + good('Data deleted successfully.'); +} + +// Make sure that Jane's age is 18. +if ($db->table('test')->where('name', '=', 'Jane')->select()['age'] !== 18) { + exit(bad('Jane is not 18.')); +} else { + good('Jane is 18.'); +} + +// Update Jane's age to 20. +$db->table('test')->where('name', '=', 'Jane')->update(['age' => 20]); + +// Make sure that Jane's age is 20. +if ($db->table('test')->where('name', '=', 'Jane')->select()['age'] !== 20) { + exit(bad('Failed to update Jane\'s age.')); +} else { + good('Jane\'s age updated successfully.'); +} + +// Drop the table. +$db->table('test')->drop(); + +// Make sure the table does not exist. +if ($db->tableExists('test')) { + exit(bad('Failed to drop table.')); +} else { + good('Table dropped successfully.'); +} + +// Delete the test database. +if (file_exists('test.db')) { + unlink('test.db'); + file_exists('test.db-shm') && unlink('test.db-shm'); + file_exists('test.db-wal') && unlink('test.db-wal'); + + neutral('Database removed.'); +} else { + neutral('Database not found.'); +} + +// ---------------------------------------------------------------- +// ---------------------------------------------------------------- +// Helpers + +function neutral(string $message) +{ + echo "⚪ $message" . PHP_EOL; +} + +function good(string $message) +{ + echo "🟢 $message" . PHP_EOL; +} + +function bad(string $message) +{ + echo "🔴 $message" . PHP_EOL; +} \ No newline at end of file