From 422e6be3f4d4048edcfc8a51ca9bd4fc41697b70 Mon Sep 17 00:00:00 2001 From: Christopher Vagnetoft Date: Sat, 27 Jun 2020 01:00:32 +0200 Subject: [PATCH] Initial commit --- .gitignore | 2 + README.md | 53 +++++++++ composer.json | 23 ++++ src/LiteDb.php | 236 ++++++++++++++++++++++++++++++++++++ src/ObjectStore.php | 284 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 598 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/LiteDb.php create mode 100644 src/ObjectStore.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff72e2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/composer.lock +/vendor diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7e5045 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# LiteDB: An IndexedDB/MongoDb-inspired wrapper around SQLite + + + +## Usage + +To use LiteDb, pass the path to the database file, the current schema version +and an upgrade handler. This makes it easy to upgrade the database as new +stores and indexes are needed. + +### Create (or upgrade) a store + +Use the `createStore()` method to create or upgrade a store. If the store exists, +the instance will be returned so the same fluid code style can be used. + + $db->createStore("foo") + ->addIndex("bar"); + +### Create (insert) data + +Create records using the `add()` or `addAll()` methods on the store. + +### Update data + +Update records using the `put()` method on the store. + +### Delete data + +Delete records using the `delete()` method on the store. + +### Retrieving (selecting) data + +Find records with the `get()` method, passing the key value to match against +and optionally the key to match. + +### Example + + $db = new LiteDb("data.db", 1, function ($db, $oldVersion) { + switch ($oldVersion) { + case null: + $db->createStore('users', 'username') + ->addIndex('username', [ 'unique' => true ]) + ->addIndex('id', [ 'autoIncrement' => true ]); + } + }); + + + $db->users->add([ + 'username' => 'bob', + 'password' => 'supersecret' + ]); + + $user = $db->users->get('bob'); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..41c8a50 --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "name": "noccylabs/litedb", + "description": "MongoDb/IndexedDb like wrapper around Sqlite", + "type": "library", + "license": "GPL-3.0-OR-LATER", + "authors": [ + { + "name": "Christopher Vagnetoft", + "email": "cvagnetoft@gmail.com" + } + ], + "autoload": { + "psr-4": { + "NoccyLabs\\LiteDb\\": "src/" + } + }, + "require": { + "psr/log": "^1.1" + }, + "require-dev": { + "noccylabs/log-hoc": "@dev" + } +} diff --git a/src/LiteDb.php b/src/LiteDb.php new file mode 100644 index 0000000..1bf1aef --- /dev/null +++ b/src/LiteDb.php @@ -0,0 +1,236 @@ +info("Opening Sqlite3 database file {$dbfile}"); + /** @var PDO $pdo */ + $pdo = new PDO("sqlite:" . $dbfile); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->initializeDatabaseFile($pdo); + $this->pdo = $pdo; + } catch (PDOException $e) { + // rethrow + throw new \RuntimeException("Database could not be opened/created", 0, $e); + } + + $dbVersion = $this->dbGetMeta(self::META_LDB_VERSION, null); + if ($dbVersion == null) { + $this->dbSetMeta(self::META_LDB_VERSION, 1); + } elseif ($dbVersion > 1) { + throw new \RuntimeException("Unsupported database version {$dbVersion}"); + } + + $this->version = $this->dbGetMeta(self::META_SCHEMA_VERSION, null); + + // Call on the upgrade handler if this version is newer + if (is_callable($upgradeHandler) && ($version > $this->version)) { + self::$logger->notice("The database schema version " . ($this->version??'null') . " need to be upgraded to version {$version}"); + call_user_func($upgradeHandler, $this, $this->version); + // Write the new version number to the database + $this->dbSetMeta(self::META_SCHEMA_VERSION, $version); + } + + // Refresh the database version, for debug purposes + $dbVersion = $this->dbGetMeta(self::META_LDB_VERSION, null); + self::$logger->debug("Database version: {$dbVersion}"); + self::$logger->debug("Current schema version: {$version}"); + self::$logger->debug("Created stores:"); + foreach ($this->storeMeta as $k=>$v) + self::$logger->debug(sprintf(" %s: %s", $k, json_encode($v))); + + + } + + /** + * Open and initialize the database file. + * + * Creates all the necessary tables and sets the current database version. + * + * @param PDO The opened database handle + */ + protected function initializeDatabaseFile(PDO $pdo) + { + // check to ensure that if there are more than 0 tables, at least one of them is + // the ldb_metadata table. If not, we are trying to open the wrong file. + $tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table';")->fetchAll(PDO::FETCH_ASSOC); + $tables = array_map(function ($v) { return $v['name']; }, $tables); + + // Check for the magic table + if (count($tables) > 0 && !in_array('ldb_metadata', $tables)) { + throw new \RuntimeException("Invalid database file, no ldb_metadata table found"); + } + + // Create or update core tables and indexes + // + // ldb_metadata key (string, primary) + // value + // ldb_store name (string) + // options (string) [serialized] + // indexes (string) [serialized] + // primkey (int) + // + $pdo->exec("create table if not exists ldb_metadata (key string primary key, value);"); + $pdo->exec("create unique index if not exists ldb_metadata_key on ldb_metadata(key);"); + + // Preload the metadata + $meta = $pdo->query("SELECT key,value FROM ldb_metadata;")->fetchAll(PDO::FETCH_ASSOC); + foreach ($meta as $row) { + $this->metadata[$row['key']] = unserialize($row['value']); + } + + // Preload the defined stores + $this->storeMeta = $this->dbGetMeta(self::META_STORES, []); + + } + + /** + * Create or update an object store + * + * @param string The name of the store to create or update + * @param null|array An array holding any options for the store + */ + public function createStore(string $storeName, ?array $options=null): ObjectStore + { + // Make sure our options are an array, and then set a magic flag to indicate that + // we want to create the store if it doesn't exist. + $options = (array)$options; + self::$logger->info("Creating store facade for {$storeName}"); + $store = new ObjectStore($this, $storeName, $options); + $this->stores[$storeName] = $store; + return $store; + } + + /** + * Helper magic function to fetch a store + * + * @param string The name of the store to access + * @return ObjectStore The object store facade + */ + public function __get(string $storeName) + { + return $this->getStore($storeName); + } + + /** + * Fetch a store + * + * @param string The name of the store to access + * @return ObjectStore The object store facade + */ + public function getStore(string $storeName) + { + if (!array_key_exists($storeName, $this->stores) && array_key_exists($storeName, $this->storeMeta)) { + // Open the store + $this->createStore($storeName); + } elseif (!array_key_exists($storeName, $this->storeMeta)) { + // Throw exception + } + + return $this->stores[$storeName]; + } + + /** + * + * @param string + * @param mixed + */ + public function dbSetMeta(string $key, $value) + { + $qKey = $this->pdo->quote($key); + $qValue = $this->pdo->quote(serialize($value)); + $this->pdo->exec("REPLACE INTO ldb_metadata (key,value) VALUES ({$qKey},{$qValue});"); + self::$logger->info("Setting metadata {$key} = ".json_encode($value)); + + $this->metadata[$key] = $value; + } + + public function dbSetStoreMeta(string $store, array $meta) + { + $this->storeMeta[$store] = $meta; + $this->dbSetMeta(self::META_STORES, $this->storeMeta); + } + + public function dbGetStoreMeta(string $store): array + { + if (!array_key_exists($store, $this->storeMeta)) { + return []; + } + return $this->storeMeta[$store]; + } + + public function dbHasStore(string $store): bool + { + return array_key_exists($store, $this->storeMeta); + } + + /** + * + * + * @param string + * @param mixed + */ + public function dbGetMeta(string $key, $defaultValue=null) + { + if (array_key_exists($key, $this->metadata)) { + return $this->metadata[$key]; + } else { + return $defaultValue; + } + } + + public function dbGetHandle(): ?PDO + { + return $this->pdo; + } + +} \ No newline at end of file diff --git a/src/ObjectStore.php b/src/ObjectStore.php new file mode 100644 index 0000000..eec4ce4 --- /dev/null +++ b/src/ObjectStore.php @@ -0,0 +1,284 @@ +ldb = $ldb; + $this->pdo = $ldb->dbGetHandle(); + $this->storeName = $storeName; + + $this->initializeStore($options); + } + + /** + * Make sure that the schema metadata is up to date + */ + protected function initializeStore(array $options) + { + LiteDb::$logger->debug(sprintf("Initializing store %s (options: %s)", $this->storeName, json_encode($options))); + + if (!$this->ldb->dbHasStore($this->storeName)) { + LiteDb::$logger->info("Creating new store {$this->storeName}"); + $this->pdo->exec("CREATE TABLE {$this->storeName} (_data text);"); + $this->writeMetadata(); + } else { + // load the store meta + $meta = $this->ldb->dbGetStoreMeta($this->storeName); + $this->options = $meta['options']; + $this->indexes = $meta['indexes']; + $this->primaryKey = $meta['primary']; + } + } + + protected function writeMetadata() + { + $meta = [ + 'options' => $this->options, + 'indexes' => $this->indexes, + 'primary' => $this->primaryKey, + ]; + $this->ldb->dbSetStoreMeta($this->storeName, $meta); + } + + /** + * Generate the index data for a newly created index + * + * @param string The index to generate data for + */ + protected function generateIndex(string $key) + { + LiteDb::$logger->notice("(Re)generating index data for key {$key}"); + // Check if we can quickly update the column with an SQL update statement + + + // Create the actual column index + + } + + /** + * Generate the key value for the data + * + * @param string + * @param array + */ + protected function generateIndexValue(string $key, array $data) + { + // Check if a generator is set for this index. If so we can resolve the value + // through the generator. + $generator = array_key_exists(self::KEY_GENERATOR, $this->indexes[$key]) + ?$this->indexes[$key] + :null; + + if (is_callable($generator)) { + $indexValue = call_user_func($generator, $data, $key); + } elseif (array_key_exists($key, $data)) { + $indexValue = $data[$key]; + } + + return $indexValue; + } + + /** + * Create or update an index column + * + * @param string + * @param array + * @return ObjectStore $this + */ + public function addIndex(string $key, array $options=[]): ObjectStore + { + $created = !array_key_exists($key, $this->indexes); + $regenerate = $options[self::KEY_REGENERATE]??false; + + // Modify the table if needed, to add the column to hold the index data + + $this->indexes[$key] = $options; + + if ($created) { + $this->pdo->exec("alter table {$this->storeName} add column {$key};"); + if ($options[self::KEY_UNIQUE]??false) { + $this->pdo->exec("create unique index {$this->storeName}_{$key} on {$this->storeName}({$key});"); + } else { + $this->pdo->exec("create index {$this->storeName}_{$key} on {$this->storeName}({$key});"); + } + } + + // If the index is being created (doesn't exist) or the regenerate option is + // set, (re)generate the index data for any rows in the db. + if ($created || $regenerate) { + $this->generateIndex($key); + } + + // We want to save this as the primary key if requested + if ($options[self::KEY_PRIMARY]??false) { + $this->primaryKey = $key; + } + + // Update the store metadata + $this->writeMetadata(); + + return $this; + } + + /** + * Delete an index column + * + * @param string + * @return ObjectStore $this + */ + public function deleteIndex(string $key): ObjectStore + { + return $this; + } + + /** + * Insert a record into the database + * + * @param array The data to store + * @param string|null If set, the value used for the primary key + */ + public function add(array $data, $keyValue=null) + { + $cols = [ + '_data' => serialize($data) + ]; + + foreach ($this->indexes as $name=>$meta) { + $cols[$name] = $this->generateIndexValue($name, $data); + } + + // Insert the record + $sql = sprintf( + "insert into %s (%s) values (%s);", + $this->storeName, + join(",", array_keys($cols)), + join(",", array_map([$this->pdo, 'quote'], array_values($cols))) + ); + LiteDb::$logger->debug("exec: {$sql}"); + + $this->pdo->exec($sql); + + } + + /** + * Add multiple records to the database. + * + * @param array The records to store + * @param bool If true, the array key will be used as record key + */ + public function addAll(array $records, bool $useKeys=false) + { + foreach ($records as $key=>$record) { + try { + $this->add($record, ($useKeys?$key:null)); + } catch (\PDOException $e) { + // Silently consume this one + } + } + } + + /** + * Update a record in the database + * + * @param array The data to store + * @param string|null If set, the value used for the primary key + */ + public function put(array $data, $keyValue=null) + { + // If data has a primaryKeyValue, attempt to update it + // otherwise, call on add() to create the row + } + + /** + * Delete an object from its primary key value + * + * @param string The key value to match against + * @param string|null The key to match against, if null uses primary key + */ + public function delete(string $keyValue, string $key=null) + { + + } + + /** + * Fetch an object from its primary key value + * + * @param string The key value to match against + * @param string|null The key to match against, if null uses primary key + */ + public function get(string $keyValue, ?string $key=null) + { + if ($key === null) { + $key = $this->primaryKey; + } + + if (!array_key_exists($key, $this->indexes)) { + throw new \RuntimeException("Invalid key {$key}"); + } + + $sql = sprintf("select _data from %s where %s=:keyvalue limit 1;", $this->storeName, $key); + $query = $this->pdo->query($sql); + $query->execute([ + ":keyvalue" => $keyValue + ]); + + $result = $query->fetchColumn(0); + $blob = unserialize($result); + + return $blob; + + } + + /** + * Fetch all objects from the store + * + * @return array + */ + public function getAll(): array + { + return []; + } +} \ No newline at end of file