├── tests
├── .gitignore
└── DatabaseTest.php
├── .gitignore
├── .travis.yml
├── phpunit.xml.dist
├── src
└── MicroDB
│ ├── Event.php
│ ├── Cache.php
│ ├── Index.php
│ └── Database.php
├── composer.json
├── LICENSE.md
└── README.md
/tests/.gitignore:
--------------------------------------------------------------------------------
1 | data/
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor/
2 | composer.lock
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: php
2 | php:
3 | - 5.4
4 | - 5.6
5 | - 7
6 | - hhvm
7 | - nightly
8 |
9 | install: composer install
10 | script: ./vendor/bin/phpunit tests/DatabaseTest.php --coverage-clover build/logs/clover.xml
11 | after_success:
12 | - travis_retry ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
7 | The MIT License (MIT) 8 | 9 | Copyright (c) 2014 Morris Brodersen <mb@morrisbrodersen.de> 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | THE SOFTWARE. 28 |-------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://travis-ci.org/morris/microdb) [](https://coveralls.io/github/morris/microdb?branch=master) [](https://badge.fury.io/gh/morris%2Fmicrodb)  2 | 3 | # MicroDB 4 | 5 | **MicroDB is currently not maintained and highly experimental. See https://github.com/morris/microdb/issues/15 for details.** 6 | 7 | MicroDB is a minimalistic file-based JSON object database written in 8 | PHP. 9 | 10 | http://morris.github.io/microdb 11 | 12 | 13 | ## Features 14 | 15 | - Stores JSON objects as plain files 16 | - Arbitrary indices using custom key functions 17 | - Listen to database operations through events 18 | - Synchronize arbitrary operations 19 | - Make use of a subtree 20 | 21 | 22 | ## Documentation 23 | 24 | For documentation, examples and API, see the [online documentation](http://morris.github.io/microdb). 25 | **** 26 | 27 | ## Requirements 28 | 29 | - PHP 5.4+ 30 | 31 | 32 | ## Installation 33 | 34 | The composer package name is `morris/microdb`. You can also download or 35 | fork the repository. 36 | 37 | 38 | ## License 39 | 40 | MicroDB is licensed under the MIT License. See `LICENSE.md` for details. 41 | -------------------------------------------------------------------------------- /src/MicroDB/Cache.php: -------------------------------------------------------------------------------- 1 | db = $db; 33 | } 34 | 35 | /** 36 | * Load a possibly cached item 37 | * 38 | * @param string $id 39 | * @return array|mixed|null 40 | */ 41 | function load($id) 42 | { 43 | if (is_array($id)) { 44 | $results = array(); 45 | foreach ($id as $i) { 46 | $results[$i] = $this->load($i); 47 | } 48 | return $results; 49 | } 50 | 51 | if (isset($this->map[$id])) { 52 | return $this->map[$id]; 53 | } else { 54 | $data = $this->db->load($id); 55 | $this->map[$id] = $data; 56 | return $data; 57 | } 58 | } 59 | 60 | /** 61 | * Execute a function on each item (id, data) 62 | * 63 | * @param callable $func 64 | */ 65 | public function each($func) 66 | { 67 | if (!$this->complete) { 68 | $this->db->eachId(array($this, 'load')); 69 | $this->complete = true; 70 | } 71 | 72 | foreach ($this->map as $id => $data) { 73 | $func($id, $data); 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /tests/DatabaseTest.php: -------------------------------------------------------------------------------- 1 | guid = self::guid(); 30 | 31 | // create index 32 | self::$guidIndex = new Index(self::$db, 'guid', 'guid'); 33 | 34 | // random delay for concurrent testing 35 | usleep(rand(0, 100)); 36 | } 37 | 38 | public static function tearDownAfterClass() 39 | { 40 | // delay for concurrent testing 41 | sleep(1); 42 | 43 | // remove all data files 44 | $files = @scandir(__DIR__ . '/data'); 45 | if ($files) { 46 | $files = array_slice($files, 2); 47 | foreach ($files as $file) { 48 | @unlink(__DIR__ . '/data/' . $file); 49 | } 50 | 51 | @rmdir(__DIR__ . '/data'); 52 | } 53 | } 54 | 55 | public static function guid() 56 | { 57 | return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', 58 | mt_rand(0, 0xffff), mt_rand(0, 0xffff), 59 | mt_rand(0, 0xffff), 60 | mt_rand(0, 0x0fff) | 0x4000, 61 | mt_rand(0, 0x3fff) | 0x8000, 62 | mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) 63 | ); 64 | } 65 | 66 | function testCreate() 67 | { 68 | self::$db->create(); 69 | } 70 | 71 | public function testBasics() 72 | { 73 | $g = self::guid(); 74 | $t1 = array('guid' => $g, 'name' => 'foo'); 75 | $t2 = array('guid' => $g, 'name' => 'bar'); 76 | 77 | $id1 = self::$db->create(); 78 | self::$db->save($id1, $t1); 79 | 80 | $id2 = self::$db->create($t2); 81 | 82 | $this->assertEquals($t1, self::$db->load($id1)); 83 | $this->assertEquals($t2, self::$db->load($id2)); 84 | } 85 | 86 | public function testFind() 87 | { 88 | $g = self::guid(); 89 | $t1 = array('guid' => $g, 'name' => 'foo'); 90 | $t2 = array('guid' => $g, 'name' => 'bar'); 91 | 92 | $id1 = self::$db->create($t1); 93 | $id2 = self::$db->create($t2); 94 | 95 | $a = self::$db->find(array('guid' => $g)); 96 | $ex = array($id1 => $t1, $id2 => $t2); 97 | $this->assertEquals($ex, $a); 98 | 99 | $a = self::$db->first(array('guid' => $g)); 100 | $this->assertTrue(in_array($t1['name'], array('foo', 'bar'))); 101 | } 102 | 103 | public function testDelete() 104 | { 105 | 106 | $g = self::guid(); 107 | $id = self::$db->create(array('guid' => $g)); 108 | self::$db->delete($id); 109 | $this->assertEquals(null, self::$db->load($id)); 110 | 111 | } 112 | 113 | public function testIndex() 114 | { 115 | $g1 = self::guid(); 116 | $g2 = self::guid(); 117 | 118 | self::$db->create(array('guid' => $g1)); 119 | self::$db->create(array('guid' => array($g1))); 120 | self::$db->create(array('guid' => $g2)); 121 | self::$db->create(array('guid' => array($g2))); 122 | self::$db->create(array('guid' => array($g1, $g2))); 123 | 124 | $this->assertEquals(3, count(self::$guidIndex->find($g2))); 125 | } 126 | 127 | public function testIndexSlice() 128 | { 129 | $g = self::guid(); 130 | 131 | $tempIndex = new Index(self::$db, $g, 'name'); 132 | 133 | $id1 = self::$db->create(array('name' => 'foo')); 134 | $id2 = self::$db->create(array('name' => 'bar')); 135 | $id3 = self::$db->create(array('name' => 'baz')); 136 | 137 | $a = $tempIndex->loadSlice(1, 2); 138 | $ex = array( 139 | $id3 => array('name' => 'baz'), 140 | $id1 => array('name' => 'foo') 141 | ); 142 | $this->assertEquals($ex, $a); 143 | } 144 | 145 | public function testDeleteIndex() 146 | { 147 | $g = self::guid(); 148 | $id = self::$db->create(array('guid' => $g)); 149 | self::$db->delete($id); 150 | $a = self::$guidIndex->find($g); 151 | 152 | $this->assertTrue(empty($a)); 153 | } 154 | 155 | public function testRepair() 156 | { 157 | self::$db->repair(); 158 | } 159 | 160 | public function testEvents() 161 | { 162 | $a = array(); 163 | 164 | $f = function ($id, $data, $event = null) use (&$a) { 165 | if (!isset($event)) { 166 | $event = $data; 167 | } 168 | $a[] = $event; 169 | }; 170 | 171 | self::$db->on('beforeSave', $f); 172 | self::$db->on('saved', $f); 173 | self::$db->on(array('beforeLoad', 'loaded'), $f); 174 | self::$db->on('beforeDelete deleted', $f); 175 | 176 | self::$db->save('events', array('foo' => 'bar')); 177 | self::$db->load('events'); 178 | self::$db->delete('events'); 179 | 180 | $ex = array('beforeSave', 'saved', 'beforeLoad', 'loaded', 'beforeDelete', 'deleted'); 181 | $this->assertEquals($ex, $a); 182 | } 183 | 184 | public function testSynchronized() 185 | { 186 | $a = array(); 187 | 188 | self::$db->synchronized('sync', function () use (&$a) { 189 | $a[] = 'called'; 190 | 191 | self::$db->eachId(function ($id) { 192 | if ($id == '_sync_lock') { 193 | $a[] = 'each'; 194 | } 195 | }); 196 | 197 | $file = self::$db->getPath() . '_sync_lock'; 198 | $handle = fopen($file, 'w+'); 199 | if ($handle && flock($handle, LOCK_EX | LOCK_NB, $wouldblock)) { 200 | flock($handle, LOCK_UN); 201 | fclose($handle); 202 | } else { 203 | $a[] = 'locked'; 204 | } 205 | 206 | 207 | if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { 208 | // this fails on windows 8 209 | // whereas LOCK_NB appears to be supported, at least by windows 8 210 | // $this->assertEquals(1, $wouldblock); 211 | } else { 212 | $this->assertEquals(1, $wouldblock); 213 | } 214 | 215 | self::$db->synchronized('sync', function () use (&$a) { 216 | $a[] = 'nested'; 217 | }); 218 | }); 219 | 220 | $ex = array('called', 'locked', 'nested'); 221 | $this->assertEquals($ex, $a); 222 | } 223 | 224 | public function testCache() 225 | { 226 | $a = array(); 227 | 228 | $f = function ($id, $data, $event = null) use (&$a) { 229 | if (!isset($event)) { 230 | $event = $data; 231 | } 232 | $a[] = $event; 233 | }; 234 | 235 | self::$db->on('loaded', $f); 236 | 237 | $cache = new Cache(self::$db); 238 | 239 | self::$db->save('cached', array('foo' => 'bar')); 240 | $a[] = $cache->load('cached'); 241 | $a[] = $cache->load('cached'); 242 | 243 | $ex = array('loaded', array('foo' => 'bar'), array('foo' => 'bar')); 244 | $this->assertEquals($ex, $a); 245 | } 246 | 247 | public function testValidId() 248 | { 249 | $a = array(); 250 | $a[] = self::$db->load(null); 251 | $a[] = self::$db->load(''); 252 | $a[] = self::$db->load(0); 253 | 254 | $ex = array(null, null, null); 255 | $this->assertEquals($ex, $a); 256 | } 257 | 258 | } 259 | -------------------------------------------------------------------------------- /src/MicroDB/Index.php: -------------------------------------------------------------------------------- 1 | db = $db; 55 | $this->name = $name; 56 | $this->keyFunc = $keyFunc; 57 | $this->compare = $compare; 58 | 59 | // register with database 60 | $this->db->on('saved', array($this, 'update')); 61 | $this->db->on('deleted', array($this, 'delete')); 62 | $this->db->on('repair', array($this, 'rebuild')); 63 | } 64 | 65 | /** 66 | * Find ids that match a key/callback 67 | * 68 | * @param $where 69 | * @param bool $first 70 | * @return array 71 | */ 72 | public function find($where, $first = false) 73 | { 74 | if (empty($this->map)) { 75 | $this->restore(); 76 | } 77 | 78 | if (!is_string($where) && is_callable($where)) { 79 | $this->$ids = array(); 80 | 81 | foreach ($this->map as $k => $i) { 82 | if ($where($k)) { 83 | if ($first) { 84 | return $i; 85 | } 86 | $ids = array_merge($ids, $i); 87 | } 88 | } 89 | return $ids; 90 | } 91 | 92 | if ($first) { 93 | if (isset($this->map[$where][0])) { 94 | return $this->map[$where][0]; 95 | } 96 | } else { 97 | if (isset($this->map[$where])) { 98 | return $this->map[$where]; 99 | } 100 | return array(); 101 | } 102 | } 103 | 104 | /** 105 | * Get first matching id 106 | * 107 | * @param $where 108 | * @return array 109 | */ 110 | public function first($where) 111 | { 112 | return $this->find($where, true); 113 | } 114 | 115 | /** 116 | * Get slice of mapping, useful for paging 117 | * 118 | * @param int $offset 119 | * @param null $length 120 | * @return mixed 121 | */ 122 | public function slice($offset = 0, $length = null) 123 | { 124 | if (empty($this->map)) { 125 | $this->restore(); 126 | } 127 | 128 | $slice = array_slice($this->map, $offset, $length); 129 | return call_user_func_array('array_merge', $slice); 130 | } 131 | 132 | /** 133 | * Load items that match a key/callback 134 | * 135 | * @param $where 136 | * @param bool $first 137 | * @return array|mixed|null 138 | */ 139 | public function load($where, $first = false) 140 | { 141 | $ids = $this->find($where, $first); 142 | return $this->db->load($ids); 143 | } 144 | 145 | /** 146 | * Load first matching item 147 | * 148 | * @param $where 149 | * @return array|mixed|null 150 | */ 151 | public function loadFirst($where) 152 | { 153 | return $this->load($where, true); 154 | } 155 | 156 | /** 157 | * Load slice of mapping 158 | * 159 | * @param int $offset 160 | * @param null $length 161 | * @return array|mixed|null 162 | */ 163 | public function loadSlice($offset = 0, $length = null) 164 | { 165 | $ids = $this->slice($offset, $length); 166 | return $this->db->load($ids); 167 | } 168 | 169 | /** 170 | * Update item in index 171 | * Synchronized 172 | * 173 | * @param $event 174 | */ 175 | public function update($event) 176 | { 177 | $self = $this; 178 | $id = $event->id; 179 | $data = $event->data; 180 | 181 | $this->apply(function () use ($self, $id, $data) { 182 | if ($self->updateTemp($id, $data)) { 183 | $self->store(); 184 | } 185 | }); 186 | } 187 | 188 | /** Update temporary data 189 | * 190 | * @param string $id 191 | * @param array $data 192 | * @return bool|void 193 | */ 194 | protected function updateTemp($id, $data) 195 | { 196 | // compute new keys 197 | $keys = $this->keys($data); 198 | 199 | // skip if key is undefined 200 | if (empty($keys)) { 201 | return; 202 | } 203 | 204 | if (!is_array($keys)) { 205 | $keys = array($keys); 206 | } 207 | 208 | $store = false; 209 | $oldKeys = @$this->inverse[$id]; 210 | 211 | // insert new keys 212 | foreach ($keys as $key) { 213 | // skip if key is already in index 214 | if (isset($oldKeys[$key])) { 215 | unset($oldKeys[$key]); // don't remove that entry later 216 | continue; 217 | } 218 | 219 | $this->map[$key][] = $id; 220 | $this->inverse[$id][$key] = count($this->map[$key]) - 1; 221 | $store = true; 222 | } 223 | 224 | // remove remaining invalid entries 225 | if (!empty($oldKeys)) { 226 | foreach ($oldKeys as $key => $offset) { 227 | array_splice($this->map[$key], $offset, 1); 228 | unset($this->inverse[$id][$key]); 229 | $store = true; 230 | } 231 | } 232 | 233 | return $store; 234 | } 235 | 236 | /** 237 | * Delete item from index 238 | * Synchronized 239 | * 240 | * @param mixed $event 241 | */ 242 | public function delete($event) 243 | { 244 | $self = $this; 245 | $id = $event->id; 246 | 247 | $this->apply(function () use ($self, $id) { 248 | $store = false; 249 | $oldKeys = @$self->inverse[$id]; 250 | 251 | // remove all old entries 252 | if (!empty($oldKeys)) { 253 | foreach ($oldKeys as $key => $offset) { 254 | array_splice($self->map[$key], $offset, 1); 255 | unset($self->inverse[$id][$key]); 256 | $store = true; 257 | } 258 | } 259 | 260 | if ($store) { 261 | $self->store(); 262 | } 263 | }); 264 | } 265 | 266 | /** 267 | * Rebuild index completely 268 | */ 269 | public function rebuild() 270 | { 271 | $self = $this; 272 | $this->db->synchronized('_repair', function () use ($self) { 273 | $self->map = array(); 274 | $self->inverse = array(); 275 | 276 | $self->db->eachId(function ($id) use ($self) { 277 | $self->updateTemp($id, $self->db->load($id)); 278 | }); 279 | 280 | $self->store(); 281 | }); 282 | } 283 | 284 | /** 285 | * Apply a synchronized operation on the index 286 | * 287 | * @param callable $func 288 | * @throws Exception 289 | */ 290 | public function apply($func) 291 | { 292 | $self = $this; 293 | $this->db->synchronized($this->name . '_index', function () use ($self, $func) { 294 | $self->restore(); 295 | $func(); 296 | }); 297 | } 298 | 299 | /** 300 | * Load index 301 | */ 302 | public function restore() 303 | { 304 | $index = $this->db->load('_' . $this->name . '_index'); 305 | 306 | $this->map = @$index['map']; 307 | $this->inverse = @$index['inverse']; 308 | 309 | if (empty($this->map)) { 310 | $this->map = array(); 311 | } 312 | if (empty($this->inverse)) { 313 | $this->inverse = array(); 314 | } 315 | 316 | $this->sort(); // json does not guarantee sorted storage 317 | } 318 | 319 | /** 320 | * Save index 321 | */ 322 | public function store() 323 | { 324 | $this->sort(); // keep map sorted by key 325 | 326 | $this->db->save('_' . $this->name . '_index', array( 327 | 'name' => $this->name, 328 | 'type' => 'index', 329 | 'map' => $this->map, 330 | 'inverse' => $this->inverse 331 | )); 332 | } 333 | 334 | /** 335 | * Compute index key(s) of data 336 | * 337 | * @param $data 338 | * @return mixed 339 | */ 340 | public function keys($data) 341 | { 342 | $keys = $this->keyFunc; 343 | if (!is_string($keys) && is_callable($keys)) { 344 | return $keys($data); 345 | } 346 | return @$data[$keys]; 347 | } 348 | 349 | /** 350 | * Get name of index 351 | * 352 | * @return string 353 | */ 354 | public function getName() 355 | { 356 | return $this->name; 357 | } 358 | 359 | /** 360 | * Sort index by key 361 | * 362 | * @return array 363 | */ 364 | protected function sort() 365 | { 366 | if (is_callable($this->compare)) { 367 | return uksort($this->map, $this->compare); 368 | } 369 | 370 | return ksort($this->map); 371 | } 372 | } -------------------------------------------------------------------------------- /src/MicroDB/Database.php: -------------------------------------------------------------------------------- 1 | path = $path; 49 | $this->mode = $mode; 50 | $this->options = $options; 51 | 52 | $this->makeDir($this->path, $this->mode); 53 | } 54 | 55 | /** 56 | * Create an item with auto incrementing id 57 | * 58 | * @param array $data 59 | * @return array 60 | * @throws Exception if synchronization failed 61 | */ 62 | public function create(array $data = []) 63 | { 64 | 65 | $self = $this; 66 | 67 | return $this->synchronized('_auto', function () use ($self, $data) { 68 | 69 | $next = 1; 70 | 71 | if ($self->exists('_auto')) { 72 | 73 | $next = $self->load('_auto', 'next'); 74 | 75 | } 76 | 77 | $self->save('_auto', array('next' => $next + 1)); 78 | $self->save($next, $data); 79 | 80 | return $next; 81 | }); 82 | 83 | } 84 | 85 | /** 86 | * Save data to database 87 | * 88 | * @param mixed $id 89 | * @param mixed $data 90 | * @return void 91 | * @throws Exception if synchronization failed 92 | */ 93 | public function save($id, $data) 94 | { 95 | 96 | $self = $this; 97 | $event = new Event($this, $id, $data); 98 | 99 | return $this->synchronized($id, function () use ($self, $event) { 100 | 101 | $self->triggerId('beforeSave', $event); 102 | 103 | $self->put($this->path . $this->generateSubTree($event->id), json_encode($event->data)); 104 | 105 | $self->triggerId('saved', $event); 106 | 107 | }); 108 | 109 | } 110 | 111 | /** 112 | * Load data from database 113 | * 114 | * @param $id 115 | * @param null $key 116 | * @return array|mixed|null 117 | */ 118 | public function load($id, $key = null) 119 | { 120 | if (is_array($id)) { 121 | $results = []; 122 | foreach ($id as $i) { 123 | $results[$i] = $this->load($i); 124 | } 125 | return $results; 126 | } 127 | 128 | if (!$this->validId($id)) { 129 | return null; 130 | } 131 | 132 | $event = new Event($this, $id); 133 | 134 | $this->triggerId('beforeLoad', $event); 135 | 136 | $event->data = json_decode($this->get($this->path . $this->generateSubTree($event->id)), true); 137 | 138 | $this->triggerId('loaded', $event); 139 | 140 | if (isset($key)) { 141 | return @$event->data[$key]; 142 | } 143 | return $event->data; 144 | } 145 | 146 | /** 147 | * Delete data from database 148 | * @param mixed $id 149 | * @return array 150 | * @throws Exception if synchronization failed 151 | */ 152 | public function delete($id) 153 | { 154 | if (is_array($id)) { 155 | $results = []; 156 | foreach ($id as $i) { 157 | $results[$i] = $this->delete($i); 158 | } 159 | return $results; 160 | } 161 | 162 | $self = $this; 163 | $event = new Event($this, $id); 164 | 165 | return $this->synchronized($id, function () use ($self, $event) { 166 | $self->triggerId('beforeDelete', $event); 167 | 168 | $self->erase($this->path . $this->generateSubTree($event->id)); 169 | 170 | $self->triggerId('deleted', $event); 171 | }); 172 | } 173 | 174 | /** 175 | * Find data matching key-value map or callback 176 | * 177 | * @param array $where 178 | * @param bool $first 179 | * @return array 180 | */ 181 | public function find($where = [], $first = false) 182 | { 183 | $results = []; 184 | 185 | if (!is_string($where) && is_callable($where)) { 186 | $this->eachId(function ($id) use (&$results, $where, $first) { 187 | $data = $this->load($id); 188 | if ($where($data)) { 189 | if ($first) { 190 | $results = $data; 191 | return true; 192 | } 193 | $results[$id] = $data; 194 | } 195 | }); 196 | } else { 197 | $this->eachId(function ($id) use (&$results, $where, $first) { 198 | $match = true; 199 | $data = $this->load($id); 200 | foreach ($where as $key => $value) { 201 | if (@$data[$key] !== $value) { 202 | $match = false; 203 | break; 204 | } 205 | } 206 | if ($match) { 207 | if ($first) { 208 | $results = $data; 209 | return true; 210 | } 211 | $results[$id] = $data; 212 | } 213 | }); 214 | } 215 | 216 | return $results; 217 | } 218 | 219 | /** 220 | * Find first item key-value map or callback 221 | * 222 | * @param null $where 223 | * @return array 224 | */ 225 | public function first($where = null) 226 | { 227 | return $this->find($where, true); 228 | } 229 | 230 | /** 231 | * Checks whether an id exists 232 | * 233 | * @param mixed $id 234 | * @return bool 235 | */ 236 | public function exists($id) 237 | { 238 | return is_file($this->path . $id); 239 | } 240 | 241 | /** 242 | * Triggers "repair" event. 243 | * On this event, applications should repair inconsistencies in the 244 | * database, e.g. rebuild indices. 245 | * 246 | * @return void 247 | */ 248 | public function repair() 249 | { 250 | $this->trigger('repair'); 251 | } 252 | 253 | /** 254 | * Call a function for each id in the database 255 | * 256 | * @param callable $func 257 | * @return void 258 | */ 259 | public function eachId($func) 260 | { 261 | $res = opendir($this->path); 262 | 263 | while (($id = readdir($res)) !== false) { 264 | if ($id == "." || $id == ".." || $id{0} == '_') { 265 | continue; 266 | } 267 | 268 | if ($func($id)) { 269 | return; 270 | } 271 | } 272 | } 273 | 274 | /** 275 | * Trigger an event only if id is not hidden 276 | * 277 | * @param $type 278 | * @param mixed $event 279 | * @return Database 280 | */ 281 | protected function triggerId($type, $event) 282 | { 283 | if (is_object($event) && !$this->hidden($event->id)) { 284 | call_user_func_array(array($this, 'trigger'), func_get_args()); 285 | } 286 | return $this; 287 | } 288 | 289 | /** 290 | * Is this id hidden, i.e. no events should be triggered? 291 | * Hidden ids start with an underscore 292 | * 293 | * @param mixed $id 294 | * @return bool 295 | */ 296 | public function hidden($id) 297 | { 298 | return $id{0} == '_'; 299 | } 300 | 301 | /** 302 | * Check if id is valid 303 | * 304 | * @param mixed $id 305 | * @return bool 306 | */ 307 | public function validId($id) 308 | { 309 | $id = (string)$id; 310 | return $id !== '.' && $id !== '..' && preg_match('#^[^/?*:;{}\\\\]+$#', $id); 311 | } 312 | 313 | /** 314 | * Call a function in a mutually exclusive way, locking on files 315 | * A process will only block other processes and never block itself, 316 | * so you can safely nest synchronized operations. 317 | * 318 | * @param array $locks 319 | * @param callable $func 320 | * @return mixed 321 | * @throws Exception if synchronization failed 322 | */ 323 | public function synchronized($locks, $func) 324 | { 325 | 326 | if (!is_array($locks)) { 327 | $locks = array($locks); 328 | } 329 | 330 | // remove already acquired locks 331 | $acquire = []; 332 | foreach ($locks as $lock) { 333 | 334 | if (!isset($this->locks[$lock])) { 335 | 336 | $acquire[] = $lock; 337 | 338 | } 339 | 340 | } 341 | $locks = $acquire; 342 | 343 | array_unique($locks); 344 | 345 | $handles = []; 346 | 347 | try { 348 | 349 | // acquire each lock 350 | foreach ($locks as $lock) { 351 | 352 | $file = $this->path . '_' . $lock . '_lock'; 353 | $handle = fopen($file, 'w'); 354 | 355 | if ($handle && flock($handle, LOCK_EX)) { 356 | 357 | $this->locks[$lock] = true; 358 | $handles[$lock] = $handle; 359 | 360 | } else { 361 | 362 | throw new Exception('Unable to synchronize over ' . $lock); 363 | 364 | } 365 | 366 | } 367 | 368 | $return = $func(); 369 | 370 | // release 371 | foreach ($locks as $lock) { 372 | 373 | unset($this->locks[$lock]); 374 | 375 | if (isset($handles[$lock])) { 376 | 377 | flock($handles[$lock], LOCK_UN); 378 | fclose($handles[$lock]); 379 | 380 | } 381 | 382 | } 383 | 384 | return $return; 385 | 386 | } catch (Exception $e) { 387 | 388 | // release 389 | foreach ($locks as $lock) { 390 | 391 | unset($this->locks[$lock]); 392 | 393 | if (isset($handles[$lock])) { 394 | 395 | flock($handles[$lock], LOCK_UN); 396 | fclose($handles[$lock]); 397 | 398 | } 399 | 400 | } 401 | 402 | throw $e; 403 | } 404 | 405 | } 406 | 407 | /** 408 | * Put file contents 409 | * 410 | * @param string $file 411 | * @param mixed $data 412 | * @return bool|void 413 | * @internal param bool $mode 414 | */ 415 | protected function put($file, $data) 416 | { 417 | // don't overwrite if unchanged, just touch 418 | if (is_file($file) && file_get_contents($file) === $data) { 419 | touch($file); 420 | chmod($file, $this->mode); 421 | return; 422 | } 423 | 424 | file_put_contents($file, $data); 425 | chmod($file, $this->mode); 426 | 427 | return true; 428 | } 429 | 430 | /** 431 | * Get file contents 432 | * 433 | * @param string $file 434 | * @return null|string 435 | */ 436 | protected function get($file) 437 | { 438 | if (!is_file($file)) { 439 | return null; 440 | } 441 | 442 | return file_get_contents($file); 443 | } 444 | 445 | /** 446 | * Remove file from filesystem 447 | * 448 | * @param string $file 449 | * @return bool 450 | */ 451 | protected function erase($file) 452 | { 453 | return unlink($file); 454 | } 455 | 456 | /** 457 | * Get data path 458 | * 459 | * @return string 460 | */ 461 | public function getPath() 462 | { 463 | return $this->path; 464 | } 465 | 466 | /** 467 | * Bind a handler to an event, with given priority. 468 | * Higher priority handlers will be executed earlier. 469 | * 470 | * @param array $event 471 | * @param callable $handler 472 | * @param int $priority 473 | * @return Database 474 | */ 475 | public function on($event, $handler, $priority = 0) 476 | { 477 | $events = $this->splitEvents($event); 478 | 479 | foreach ($events as $event) { 480 | if (!is_callable($handler)) { 481 | throw new InvalidArgumentException('Handler must be callable'); 482 | } 483 | 484 | if (!isset($this->handlers[$event])) { 485 | $this->handlers[$event] = []; 486 | } 487 | 488 | if (!isset($this->handlers[$event][$priority])) { 489 | $this->handlers[$event][$priority] = []; 490 | 491 | // keep handlers sorted by priority 492 | krsort($this->handlers[$event]); 493 | } 494 | 495 | $this->handlers[$event][$priority][] = $handler; 496 | } 497 | 498 | return $this; 499 | } 500 | 501 | /** 502 | * Unbind a handler on one, multiple or all events 503 | * 504 | * @param string|array $event Event keys, comma separated 505 | * @param callable $handler 506 | * @return Database 507 | */ 508 | public function off($event, $handler = null) 509 | { 510 | if (!is_string($event) && is_callable($event)) { 511 | $handler = $event; 512 | $event = array_keys($this->handlers); 513 | } 514 | 515 | $events = $this->splitEvents($event); 516 | 517 | foreach ($events as $event) { 518 | foreach ($this->handlers[$event] as $priority => $handlers) { 519 | foreach ($handlers as $i => $h) { 520 | if (!isset($handler) || $handler === $h) { 521 | unset($this->handlers[$event][$priority][$i]); 522 | } 523 | } 524 | } 525 | } 526 | 527 | return $this; 528 | } 529 | 530 | /** 531 | * Trigger one or more events with given arguments 532 | * 533 | * @param string|array Event keys, whitespace/comma separated 534 | * @param mixed $args 535 | * @return Database 536 | */ 537 | public function trigger($event, $args = null) 538 | { 539 | $args = func_get_args(); 540 | array_shift($args); 541 | $args[] = $event; 542 | 543 | if (isset($this->handlers[$event])) { 544 | foreach ($this->handlers[$event] as $priority => $handlers) { 545 | foreach ($handlers as $handler) { 546 | call_user_func_array($handler, $args); 547 | } 548 | } 549 | } 550 | 551 | return $this; 552 | } 553 | 554 | /** 555 | * Split event keys by whitespace and/or comma 556 | * 557 | * @param array $events 558 | * @return array 559 | */ 560 | protected function splitEvents($events) 561 | { 562 | if (is_array($events)) { 563 | return $events; 564 | } 565 | 566 | return preg_split('([\s,]+)', $events); 567 | } 568 | 569 | /** 570 | * Make a given directory with given chmod's 571 | * 572 | * @param string $path 573 | * @param int $mode 574 | * @return void 575 | */ 576 | private function makeDir($path, $mode) 577 | { 578 | if (!is_dir($path)) { 579 | mkdir($path, $mode, true); 580 | } 581 | } 582 | 583 | /** 584 | * Generates the file subtree if required 585 | * @param integer $id 586 | * 587 | * @return string 588 | */ 589 | function generateSubTree ($id) 590 | { 591 | if (!isset($this->options["subtree"])) { 592 | return $id; 593 | } 594 | 595 | $i = 0; 596 | $idLength = strlen($id); 597 | $path = ""; 598 | 599 | while ($i < $this->options["subtree"] && $i < $idLength) 600 | { 601 | $path .= substr($id, $i, 1) . "/"; 602 | $i++; 603 | } 604 | 605 | // ensure that this path exists 606 | $this->makeDir($path, $this->mode); 607 | 608 | $path .= $id; 609 | 610 | return $path; 611 | } 612 | } 613 | --------------------------------------------------------------------------------