Initial commit

This commit is contained in:
Chris 2020-06-27 01:00:32 +02:00
commit 422e6be3f4
5 changed files with 598 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/composer.lock
/vendor

53
README.md Normal file
View File

@ -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');

23
composer.json Normal file
View File

@ -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"
}
}

236
src/LiteDb.php Normal file
View File

@ -0,0 +1,236 @@
<?php
namespace NoccyLabs\LiteDb;
use PDO;
use PDOException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
*
*
*
*/
class LiteDb
{
/** @var int|null The current database schema version or null if not initialized */
private $version = null;
/** @var string|null The database filename */
private $dbfile;
/** @var PDO */
private $pdo;
/** @var array Store metadata */
private $storeMeta = [];
/** @var ObjectStore[] */
private $stores = [];
/** @var array */
private $metadata = [];
/** @var null|LoggerInterface */
public static $logger;
// Metadata constants
const META_LDB_VERSION = "ldb.version";
const META_SCHEMA_VERSION = "schema.version";
const META_STORES = "schema.stores";
/**
* Constructor
*
* @param string The path to the database file to use
* @param int The database version, defaults to 1
* @param callable|null The upgrade handler
* @throws \RuntimeException
*/
public function __construct(string $dbfile, int $version=1, ?callable $upgradeHandler=null)
{
// Assign a null logger if none has been set
if (self::$logger == null) {
self::$logger = new NullLogger();
}
try {
self::$logger->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;
}
}

284
src/ObjectStore.php Normal file
View File

@ -0,0 +1,284 @@
<?php
namespace NoccyLabs\LiteDb;
use PDO;
/**
*
*
*
*/
class ObjectStore
{
/** @var LiteDb */
private $ldb;
/** @var PDO The PDO object */
private $pdo;
/** @var string The name of the store */
private $storeName;
/** @var array Store options */
private $options = [];
/** @var array The indexes and their options */
private $indexes = [];
/** @var string The name of the primary key column */
private $primaryKey;
// Store option constants
const OPT_AUTO_INDEX = "auto_index";
// Key option constants
const KEY_UNIQUE = "unique";
const KEY_GENERATOR = "generator";
const KEY_AUTO_INCREMENT = "autoIncrement";
const KEY_PRIMARY = "primary";
const KEY_LENGTH = "length";
const KEY_REGENERATE = "regenerate";
const KEY_PATH = "path";
const KEY_REQUIRED = "required";
/**
* Constructor
*
* @param LiteDb
* @param string
* @param array
*/
public function __construct(LiteDb $ldb, string $storeName, array $options)
{
$this->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 [];
}
}