Initial commit
This commit is contained in:
commit
422e6be3f4
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/composer.lock
|
||||
/vendor
|
53
README.md
Normal file
53
README.md
Normal 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
23
composer.json
Normal 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
236
src/LiteDb.php
Normal 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
284
src/ObjectStore.php
Normal 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 [];
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user