* @license BSD */ namespace Domframework; /** * DBJSON : a NoSQL DB in JSON * Documentation * - A filter is an array containing the fields and the values to found * array ("key"=>"val") <== Look for the key equal val * array ("key=>array ("val", "<=")) <== Look for the key lighter or equal * than val * array () <== Look for all the documents (no * filter) * array ("key"=>"val", "key2"=>"val2") <== Look for two parameters * - A document is an array containing the fields and the values to store * array ("key"=>"val) * * - The field named _id is the document key */ class Dbjson { /** * The DSN of the connection */ private $dsn = ""; /** * The database file */ private $dbfile = ""; /** * The lock file */ private $dbfileLock = ""; /** * The last Insert Id */ private $lastInsertId = 0; /** * The database content */ private $db; /** * The constructor * @param string $dsn The DSN of the connection */ public function __construct($dsn) { if (! function_exists("openssl_random_pseudo_bytes")) { throw new \Exception( "Function openssl_random_pseudo_bytes missing", 500 ); } $pos = strpos($dsn, "://"); if ($pos === false) { throw new \Exception(dgettext( "domframework", "No DSN provided to dbjson" ), 500); } if (substr($dsn, 0, $pos) !== "dbjson") { throw new \Exception(dgettext( "domframework", "Invalid database type provided in dbjson" ), 500); } $this->dbfile = substr($dsn, $pos + 3); $directory = dirname($this->dbfile); if (! file_exists($directory)) { @mkdir($directory, 0777, true); } if (! file_exists($directory)) { throw new \Exception(sprintf(dgettext( "domframework", "Directory '%s' doesn't exists" ), $directory), 500); } if (! file_exists($this->dbfile)) { if (! is_readable($directory)) { throw new \Exception(sprintf( dgettext( "domframework", "Directory '%s' not writeable and dbfile '%s' not exists" ), $directory, $this->dbfile ), 500); } touch($this->dbfile); } if (! is_readable($this->dbfile)) { throw new \Exception(sprintf(dgettext( "domframework", "File '%s' not readable" ), $this->dbfile), 500); } if (! is_writeable($this->dbfile)) { throw new \Exception(sprintf(dgettext( "domframework", "File '%s' not readable" ), $this->dbfile), 500); } $this->dsn = $dsn; $this->dbfile = $this->dbfile; } /** * Store one document in database * @param string $collection The collection name * @param array $document The document to insert * @return integer return The number of document inserted in the database */ public function insertOne($collection, $document) { $uniqueKey = $this->uniqueKey(); $this->lockEX(); $this->db = $this->readDB(); $this->db[$collection]["content"][$uniqueKey] = array_merge( ["_id" => $uniqueKey], $document ); $this->writeDB(); $this->lockUN(); return 1; } /** * Store multiple documents in database * @param string $collection The collection name * @param array $documents array(array ()) * @return integer The number of documents inserted in the database */ public function insertMany($collection, $documents) { foreach ($documents as $document) { if (! is_array($document)) { throw new \Exception("insertMany need an array of array", 406); } } $this->lockEX(); $this->db = $this->readDB(); foreach ($documents as $document) { $uniqueKey = $this->uniqueKey(); $this->db[$collection]["content"][$uniqueKey] = array_merge( ["_id" => $uniqueKey], $document ); } $this->writeDB(); $this->db = null; $this->lockUN(); return count($documents); } /** * Look at the documents matching $filter (all by default). * Then return only the $fields (all by default). * The field _id is always returned * Return $limit maximum documents (no limit by default) * @param string $collection The collection name * @param array $filter The filter to apply to found the documents * @param array|string $fields The fields to display (* for all, empty array * for none) * @param integer $limit The number of documents to display * @return array The documents matching the parameters */ public function find( $collection, $filter = [], $fields = "*", $limit = null ) { $this->lockSH(); $this->db = $this->readDB(); // Get the keys of the documents based on the filter $keys = $this->filter($collection, $filter); $res = []; foreach ($keys as $key) { // Limit the fields $tmp = []; if ($fields === "*") { $tmp = $this->db[$collection]["content"][$key]; } elseif (is_array($fields)) { if (! in_array("_id", $fields, true)) { array_unshift($fields, "_id"); } foreach ($fields as $field) { if ( array_key_exists( $field, $this->db[$collection]["content"][$key] ) ) { $tmp[$field] = $this->db[$collection]["content"][$key][$field]; } } } else { throw new \Exception("Invalid field list provided", 500); } $res[$key] = $tmp; // Limit the number of results if ($limit !== null && count($res) >= $limit) { break; } } $this->lockUN(); $this->db = null; return $res; } /** * Update some existing documents. Do not change the _id keys * @param string $collection The collection name * @param array $filter The filter to apply to found the documents * @param array $document The data to update * @return integer The number of modified documents * To unset a field, add in the document array a "_unset"=>array("field)" */ public function update($collection, $filter, $document) { $this->lockEX(); $this->db = $this->readDB(); // Get the keys of the documents based on the filter $keys = $this->filter($collection, $filter); $unset = []; if (array_key_exists("_unset", $document)) { $unset = $document["_unset"]; unset($document["_unset"]); } foreach ($keys as $key) { // Merge the new document with the old $tmp = $this->db[$collection]["content"][$key]; // We need to merge the old and the new document. // If there is an array in the document, it is overwrited foreach ($document as $k => $v) { if (is_array($v)) { if (isset($tmp[$k])) { $tmp[$k] = array_merge($tmp[$k], $v); } else { $tmp[$k] = $v; } } else { $tmp[$k] = $v; } } $this->db[$collection]["content"][$key] = $tmp; // Remove the needed unset fields foreach ($unset as $field) { if ( array_key_exists( $field, $this->db[$collection]["content"][$key] ) ) { unset($this->db[$collection]["content"][$key][$field]); } } } $this->writeDB(); $this->lockUN(); $this->db = null; return count($keys); } /** * Replace some existing documents. Do not change the _id keys * @param string $collection The collection name * @param array $filter The filter to apply to found the documents * @param array $document The data to update * @return integer The number of modified documents */ public function replace($collection, $filter, $document) { $this->lockEX(); $this->db = $this->readDB(); // Get the keys of the documents based on the filter $keys = $this->filter($collection, $filter); foreach ($keys as $key) { $tmp = $this->db[$collection]["content"][$key]; $replace = []; $replace["_id"] = $tmp["_id"]; $replace = array_merge($replace, $document); $this->db[$collection]["content"][$key] = $replace; } $this->writeDB(); $this->lockUN(); $this->db = null; return count($keys); } /** * Delete the first document matching the filter * @param string $collection The collection name * @param array $filter The filter to found the documents * @return integer The number of deleted documents */ public function deleteOne($collection, $filter) { $this->lockEX(); $this->db = $this->readDB(); // Get the keys of the documents based on the filter $keys = $this->filter($collection, $filter); if (count($keys) === 0) { return 0; } reset($keys); $key = reset($keys); unset($this->db[$collection]["content"][$key]); $this->writeDB(); $this->lockUN(); $this->db = null; return 1; } /** * Delete all the documents matching the filter * @param string $collection The collection name * @param array $filter The filter to apply to found the documents * @return integer The number of deleted documents */ public function deleteMany($collection, $filter) { $this->lockEX(); $this->db = $this->readDB(); // Get the keys of the documents based on the filter $keys = $this->filter($collection, $filter); foreach ($keys as $key) { unset($this->db[$collection]["content"][$key]); } $this->writeDB(); $this->lockUN(); $this->db = null; return count($keys); } /** * Look for the keys corresponding to the filter in the collection * Don't manage the locks ! * @param string $collection The collection name * @param array $filter The filter to apply to found the documents * - A filter is an array containing the fields and the values to found * array () <== Look for all the documents (no * filter) * array ("key"=>"val") <== Look for the key equal val * array ("key=>array ("val", "<=")) <== Look for the key lighter or * equal than val * array ("key"=>"val", "key2"=>"val2") <== Look for two parameters * array ("key"=>array ("val", "=="), * "key2"=>array ("val2", "==")) <== Look for two complex parameters * Here is the comparison types available : ==, * @return array the keys matching the filter */ public function filter($collection, $filter) { if ($this->db === null) { $this->db = $this->readDB(); } $keys = []; if (! array_key_exists($collection, $this->db)) { $this->db[$collection]["content"] = []; } foreach ($this->db[$collection]["content"] as $key => $document) { if ($filter === []) { $keys[] = $key; continue; } $matchFilter = false; foreach ($filter as $fkey => $fvals) { if (is_array($fvals)) { // $fvals = array ("key=>array ("val", "<=")) if (array_key_exists($fkey, $document)) { if ( $fvals[1] !== "==" && $fvals[1] !== "<=" && $fvals[1] !== ">=" && $fvals[1] !== "<" && $fvals[1] !== ">" && $fvals[1] !== "exists" && $fvals[1] !== "not exists" && $fvals[1] !== "in_array" ) { throw new \Exception("Invalid filter operator provided", 500); } if ($fvals[1] === "==" && $document[$fkey] === $fvals[0]) { $matchFilter = true; } elseif ($fvals[1] === "<=" && $document[$fkey] <= $fvals[0]) { $matchFilter = true; } elseif ($fvals[1] === ">=" && $document[$fkey] >= $fvals[0]) { $matchFilter = true; } elseif ($fvals[1] === "<" && $document[$fkey] < $fvals[0]) { $matchFilter = true; } elseif ($fvals[1] === ">" && $document[$fkey] > $fvals[0]) { $matchFilter = true; } elseif ( strtolower($fvals[1]) === "exists" && array_key_exists($fkey, $document) ) { $matchFilter = true; } elseif ( strtolower($fvals[1]) === "not exists" && ! array_key_exists($fkey, $document) ) { $matchFilter = true; } elseif ( strtolower($fvals[1]) === "in_array" && in_array($fvals[0], $document[$fkey], true) ) { $matchFilter = true; } else { $matchFilter = false; break; } } } else { // $fvals = array ("key"=>"val") if ( array_key_exists($fkey, $document) && $document[$fkey] === $fvals ) { $matchFilter = true; } else { $matchFilter = false; break; } } } if ($matchFilter === true) { $keys[] = $key; } } return $keys; } /** * Generate a unique key * @return string the Unique key generated */ private function uniqueKey() { $data = openssl_random_pseudo_bytes(16); $data[6] = chr(ord($data[6]) & 0x0f | 0x40); // set version to 0010 $data[8] = chr(ord($data[8]) & 0x3f | 0x80); // set bits 6-7 to 10 return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); } /** * Exclusive lock the database file */ private function lockEX() { $this->dbfileLock = fopen($this->dbfile, "rt"); if (flock($this->dbfileLock, LOCK_EX) === false) { throw new \Exception("Can't get exclusive lock on dbfile", 500); } } /** * Shared lock the database file */ private function lockSH() { $this->dbfileLock = fopen($this->dbfile, "rt"); if (flock($this->dbfileLock, LOCK_SH) === false) { throw new \Exception("Can't get shared lock on dbfile", 500); } } /** * Unlock the database file */ private function lockUN() { if ($this->dbfileLock !== null) { flock($this->dbfileLock, LOCK_UN); fclose($this->dbfileLock); $this->dbfileLock = null; } } /** * Read the dbfile and return an array containing the data. This function * don't do locks ! * @return array The database content from the dbfile */ private function readDB() { $res = json_decode(file_get_contents($this->dbfile), true); if ($res === null) { $res = []; } return $res; } /** * Write the dbfile with the provided data. This function don't do locks ! * @return bool True if the recording is OK, false if there is a problem */ private function writeDB() { return !! file_put_contents($this->dbfile, json_encode($this->db)); } }