HelperTrait.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. <?php
  2. namespace SleekDB\Traits;
  3. use SleekDB\Exceptions\ConditionNotAllowedException;
  4. use SleekDB\Exceptions\IdNotAllowedException;
  5. use SleekDB\Exceptions\IOException;
  6. use SleekDB\Exceptions\InvalidConfigurationException;
  7. use SleekDB\Exceptions\EmptyStoreNameException;
  8. use SleekDB\Exceptions\IndexNotFoundException;
  9. use SleekDB\Exceptions\JsonException;
  10. use SleekDB\Exceptions\EmptyFieldNameException;
  11. use SleekDB\Exceptions\InvalidDataException;
  12. /**
  13. * Collections of method that helps to manage the data.
  14. * All methods in this trait should be private.
  15. *
  16. */
  17. trait HelperTrait {
  18. /**
  19. * @param array $conf
  20. * @throws IOException
  21. * @throws InvalidConfigurationException
  22. */
  23. private function init( $conf ) {
  24. // Check for valid configurations.
  25. if( empty( $conf ) OR !is_array( $conf ) ) throw new InvalidConfigurationException( 'Invalid configurations was found.' );
  26. // Check if the 'data_directory' was provided.
  27. if ( !isset( $conf[ 'data_directory' ] ) ) throw new InvalidConfigurationException( '"data_directory" was not provided in the configurations.' );
  28. // Check if data_directory is empty.
  29. if ( empty( $conf[ 'data_directory' ] ) ) throw new InvalidConfigurationException( '"data_directory" cant be empty in the configurations.' );
  30. // Prepare the data directory.
  31. $dataDir = trim( $conf[ 'data_directory' ] );
  32. // Handle directory path ending.
  33. if ( substr( $dataDir, -1 ) !== '/' ) $dataDir = $dataDir . '/';
  34. // Check if the data_directory exists.
  35. if ( !file_exists( $dataDir ) ) {
  36. // The directory was not found, create one.
  37. if ( !mkdir( $dataDir, 0777, true ) ) throw new IOException( 'Unable to create the data directory at ' . $dataDir );
  38. }
  39. // Check if PHP has write permission in that directory.
  40. if ( !is_writable( $dataDir ) ) throw new IOException( 'Data directory is not writable at "' . $dataDir . '." Please change data directory permission.' );
  41. // Finally check if the directory is readable by PHP.
  42. if ( !is_readable( $dataDir ) ) throw new IOException( 'Data directory is not readable at "' . $dataDir . '." Please change data directory permission.' );
  43. // Set the data directory.
  44. $this->dataDirectory = $dataDir;
  45. // Set auto cache settings.
  46. $autoCache = true;
  47. if ( isset( $conf[ 'auto_cache' ] ) ) $autoCache = $conf[ 'auto_cache' ];
  48. $this->initAutoCache( $autoCache );
  49. // Set timeout.
  50. $timeout = 120;
  51. if ( isset( $conf[ 'timeout' ] ) ) {
  52. if ( !empty( $conf[ 'timeout' ] ) ) $timeout = (int) $conf[ 'timeout' ];
  53. }
  54. set_time_limit( $timeout );
  55. // Control when to keep or delete the active query conditions. Delete conditions by default.
  56. $this->shouldKeepConditions = false;
  57. } // End of init()
  58. /**
  59. * Init data that SleekDB required to operate.
  60. */
  61. private function initVariables() {
  62. if(!$this->shouldKeepConditions) {
  63. // Set empty results
  64. $this->results = [];
  65. // Set a default limit
  66. $this->limit = 0;
  67. // Set a default skip
  68. $this->skip = 0;
  69. // Set default conditions
  70. $this->conditions = [];
  71. // Or conditions
  72. $this->orConditions = [];
  73. // In clause conditions
  74. $this->in = [];
  75. // notIn clause conditions
  76. $this->notIn = [];
  77. // Set default group by value
  78. $this->orderBy = [
  79. 'order' => false,
  80. 'field' => '_id'
  81. ];
  82. // Set the default search keyword as an empty string.
  83. $this->searchKeyword = '';
  84. // Disable make cache by default.
  85. $this->makeCache = false;
  86. // Control when to keep or delete the active query conditions. Delete conditions by default.
  87. $this->shouldKeepConditions = false;
  88. // specific fields to select
  89. $this->fieldsToSelect = [];
  90. $this->fieldsToExclude = [];
  91. $this->orConditionsWithAnd = [];
  92. }
  93. } // End of initVariables()
  94. /**
  95. * Initialize the auto cache settings.
  96. * @param bool $autoCache
  97. */
  98. private function initAutoCache ( $autoCache = true ) {
  99. // Decide the cache status.
  100. if ( $autoCache === true ) {
  101. $this->useCache = true;
  102. // A flag that is used to check if cache should be empty
  103. // while create a new object in a store.
  104. $this->deleteCacheOnCreate = true;
  105. } else {
  106. $this->useCache = false;
  107. // A flag that is used to check if cache should be empty
  108. // while create a new object in a store.
  109. $this->deleteCacheOnCreate = false;
  110. }
  111. }
  112. /**
  113. * Method to boot a store.
  114. * @throws EmptyStoreNameException
  115. * @throws IOException
  116. */
  117. private function bootStore() {
  118. $store = trim( $this->storeName );
  119. // Validate the store name.
  120. if ( !$store || empty( $store ) ) throw new EmptyStoreNameException( 'Invalid store name was found' );
  121. // Prepare store name.
  122. if ( substr( $store, -1 ) !== '/' ) $store = $store . '/';
  123. // Store directory path.
  124. $this->storePath = $this->dataDirectory . $store;
  125. // Check if the store exists.
  126. if ( !file_exists( $this->storePath ) ) {
  127. // The directory was not found, create one with cache directory.
  128. if ( !mkdir( $this->storePath, 0777, true ) ) throw new IOException( 'Unable to create the store path at ' . $this->storePath );
  129. // Create the cache directory.
  130. if ( !mkdir( $this->storePath . 'cache', 0777, true ) ) throw new IOException( 'Unable to create the store\'s cache directory at ' . $this->storePath . 'cache' );
  131. // Create the data directory.
  132. if ( !mkdir( $this->storePath . 'data', 0777, true ) ) throw new IOException( 'Unable to create the store\'s data directory at ' . $this->storePath . 'data' );
  133. // Create the store counter file.
  134. if ( !file_put_contents( $this->storePath . '_cnt.sdb', '0' ) ) throw new IOException( 'Unable to create the system counter for the store! Please check write permission' );
  135. }
  136. // Check if PHP has write permission in that directory.
  137. if ( !is_writable( $this->storePath ) ) throw new IOException( 'Store path is not writable at "' . $this->storePath . '." Please change store path permission.' );
  138. // Finally check if the directory is readable by PHP.
  139. if ( !is_readable( $this->storePath ) ) throw new IOException( 'Store path is not readable at "' . $this->storePath . '." Please change store path permission.' );
  140. }
  141. // Returns a new and unique store object ID, by calling this method it would also
  142. // increment the ID system-wide only for the store.
  143. private function getStoreId() {
  144. $counter = 1; // default (first) id
  145. $counterPath = $this->storePath . '_cnt.sdb';
  146. if ( file_exists( $counterPath ) ) {
  147. $fp = fopen($counterPath, 'r+');
  148. for($retries = 10; $retries > 0; $retries--) {
  149. flock($fp, LOCK_UN);
  150. if (flock($fp, LOCK_EX) === false) {
  151. sleep(1);
  152. } else {
  153. $counter = (int) fgets($fp);
  154. $counter++;
  155. rewind($fp);
  156. fwrite($fp, (string) $counter);
  157. break;
  158. }
  159. }
  160. flock($fp, LOCK_UN);
  161. fclose($fp);
  162. }
  163. return $counter;
  164. }
  165. /**
  166. * Return the last created store object ID.
  167. * @return int
  168. */
  169. private function getLastStoreId() {
  170. $counterPath = $this->storePath . '_cnt.sdb';
  171. if ( file_exists( $counterPath ) ) {
  172. return (int) file_get_contents( $counterPath );
  173. }
  174. return 0;
  175. }
  176. /**
  177. * Get a store by its system id. "_id"
  178. * @param $id
  179. * @return array|mixed
  180. */
  181. private function getStoreDocumentById( $id ) {
  182. $store = $this->storePath . 'data/' . $id . '.json';
  183. if ( file_exists( $store ) ) {
  184. $data = json_decode( file_get_contents( $store ), true );
  185. if ( $data !== false ) return $data;
  186. }
  187. return [];
  188. }
  189. /**
  190. * @param string $file
  191. * @return mixed
  192. */
  193. private function getDocumentByPath ( $file ) {
  194. return @json_decode( @file_get_contents( $file ), true );
  195. }
  196. /**
  197. * @param string $condition
  198. * @param mixed $fieldValue value of current field
  199. * @param mixed $value value to check
  200. * @throws ConditionNotAllowedException
  201. * @return bool
  202. */
  203. private function verifyWhereConditions ( $condition, $fieldValue, $value ) {
  204. // Check the type of rule.
  205. if ( $condition === '=' ) {
  206. // Check equal.
  207. return ( $fieldValue == $value );
  208. } else if ( $condition === '!=' ) {
  209. // Check not equal.
  210. return ( $fieldValue != $value );
  211. } else if ( $condition === '>' ) {
  212. // Check greater than.
  213. return ( $fieldValue > $value );
  214. } else if ( $condition === '>=' ) {
  215. // Check greater equal.
  216. return ( $fieldValue >= $value );
  217. } else if ( $condition === '<' ) {
  218. // Check less than.
  219. return ( $fieldValue < $value );
  220. } else if ( $condition === '<=' ) {
  221. // Check less equal.
  222. return ( $fieldValue <= $value );
  223. } else if (strtolower($condition) === 'like'){
  224. $value = str_replace('%', '(.)*', $value);
  225. $pattern = "/^".$value."$/i";
  226. return (preg_match($pattern, $fieldValue) === 1);
  227. }
  228. throw new ConditionNotAllowedException('condition '.$condition.' is not allowed');
  229. }
  230. /**
  231. * @return array
  232. * @throws IndexNotFoundException
  233. * @throws ConditionNotAllowedException
  234. * @throws EmptyFieldNameException
  235. * @throws InvalidDataException
  236. */
  237. private function findStoreDocuments() {
  238. $found = [];
  239. // Start collecting and filtering data.
  240. $storeDataPath = $this->storePath . 'data/';
  241. if( $handle = opendir($storeDataPath) ) {
  242. while ( false !== ($entry = readdir($handle)) ) {
  243. if ($entry != "." && $entry != "..") {
  244. $file = $storeDataPath . $entry;
  245. $data = $this->getDocumentByPath( $file );
  246. $document = false;
  247. if ( ! empty( $data ) ) {
  248. // Filter data found.
  249. if ( empty( $this->conditions ) ) {
  250. // Append all data of this store.
  251. $document = $data;
  252. } else {
  253. // Append only passed data from this store.
  254. $storePassed = true;
  255. // Iterate each conditions.
  256. foreach ( $this->conditions as $condition ) {
  257. if ( $storePassed === true ) {
  258. // Check for valid data from data source.
  259. $validData = true;
  260. $fieldValue = '';
  261. try {
  262. $fieldValue = $this->getNestedProperty( $condition[ 'fieldName' ], $data );
  263. } catch( \Exception $e ) {
  264. $validData = false;
  265. $storePassed = false;
  266. }
  267. if( $validData === true ) {
  268. $storePassed = $this->verifyWhereConditions( $condition[ 'condition' ], $fieldValue, $condition[ 'value' ] );
  269. }
  270. }
  271. }
  272. // Check if current store is updatable or not.
  273. if ( $storePassed === true ) {
  274. // Append data to the found array.
  275. $document = $data;
  276. } else {
  277. // Check if a or-where condition will allow this document.
  278. foreach ( $this->orConditions as $condition ) {
  279. // Check for valid data from data source.
  280. $validData = true;
  281. $fieldValue = '';
  282. try {
  283. $fieldValue = $this->getNestedProperty( $condition[ 'fieldName' ], $data );
  284. } catch( \Exception $e ) {
  285. $validData = false;
  286. $storePassed = false;
  287. }
  288. if( $validData === true ) {
  289. $storePassed = $this->verifyWhereConditions( $condition[ 'condition' ], $fieldValue, $condition[ 'value' ] );
  290. if( $storePassed ) {
  291. // Append data to the found array.
  292. $document = $data;
  293. break;
  294. }
  295. }
  296. }
  297. }
  298. // Check if current store is updatable or not.
  299. if ( $storePassed === true ) {
  300. // Append data to the found array.
  301. $document = $data;
  302. } else if(count($this->orConditionsWithAnd) > 0) {
  303. // Check if a all conditions will allow this document.
  304. $allConditionMatched = true;
  305. foreach ( $this->orConditionsWithAnd as $condition ) {
  306. // Check for valid data from data source.
  307. $validData = true;
  308. $fieldValue = '';
  309. try {
  310. $fieldValue = $this->getNestedProperty( $condition[ 'fieldName' ], $data );
  311. } catch( \Exception $e ) {
  312. $validData = false;
  313. }
  314. if( $validData === true ) {
  315. $storePassed = $this->verifyWhereConditions( $condition[ 'condition' ], $fieldValue, $condition[ 'value' ] );
  316. if($storePassed) continue;
  317. }
  318. // if data was invalid or store did not pass
  319. $allConditionMatched = false;
  320. break;
  321. }
  322. if( $allConditionMatched === true ) {
  323. // Append data to the found array.
  324. $document = $data;
  325. }
  326. }
  327. } // Completed condition checks.
  328. // IN clause.
  329. if( $document && !empty($this->in) ) {
  330. foreach ( $this->in as $inClause) {
  331. $validData = true;
  332. $fieldValue = '';
  333. try {
  334. $fieldValue = $this->getNestedProperty( $inClause[ 'fieldName' ], $data );
  335. } catch( \Exception $e ) {
  336. $validData = false;
  337. $document = false;
  338. break;
  339. }
  340. if( $validData === true ) {
  341. if( !in_array( $fieldValue, $inClause[ 'value' ] ) ) {
  342. $document = false;
  343. break;
  344. }
  345. }
  346. }
  347. }
  348. // notIn clause.
  349. if ( $document && !empty($this->notIn) ) {
  350. foreach ( $this->notIn as $notInClause) {
  351. $validData = true;
  352. $fieldValue = '';
  353. try {
  354. $fieldValue = $this->getNestedProperty( $notInClause[ 'fieldName' ], $data );
  355. } catch( \Exception $e ) {
  356. $validData = false;
  357. break;
  358. }
  359. if( $validData === true ) {
  360. if( in_array( $fieldValue, $notInClause[ 'value' ] ) ) {
  361. $document = false;
  362. break;
  363. }
  364. }
  365. }
  366. }
  367. // Check if there is any document appendable.
  368. if( $document ) {
  369. $found[] = $document;
  370. }
  371. }
  372. }
  373. }
  374. closedir( $handle );
  375. }
  376. if ( count( $found ) > 0 ) {
  377. // Check do we need to sort the data.
  378. if ( $this->orderBy[ 'order' ] !== false ) {
  379. // Start sorting on all data.
  380. $found = $this->sortArray( $this->orderBy[ 'field' ], $found, $this->orderBy[ 'order' ] );
  381. }
  382. // If there was text search then we would also sort the result by search ranking.
  383. if ( ! empty( $this->searchKeyword ) ) {
  384. $found = $this->performSearch( $found );
  385. }
  386. // Skip data
  387. if ( $this->skip > 0 ) $found = array_slice( $found, $this->skip );
  388. // Limit data.
  389. if ( $this->limit > 0 ) $found = array_slice( $found, 0, $this->limit );
  390. }
  391. if(count($found) > 0){
  392. if(count($this->fieldsToSelect) > 0){
  393. $found = $this->applyFieldsToSelect($found);
  394. }
  395. if(count($this->fieldsToExclude) > 0){
  396. $found = $this->applyFieldsToExclude($found);
  397. }
  398. }
  399. return $found;
  400. }
  401. /**
  402. * @param array $found
  403. * @return array
  404. */
  405. private function applyFieldsToSelect($found){
  406. if(!(count($found) > 0) || !(count($this->fieldsToSelect) > 0)){
  407. return $found;
  408. }
  409. foreach ($found as $key => $item){
  410. $newItem = [];
  411. $newItem['_id'] = $item['_id'];
  412. foreach ($this->fieldsToSelect as $fieldToSelect){
  413. if(array_key_exists($fieldToSelect, $item)){
  414. $newItem[$fieldToSelect] = $item[$fieldToSelect];
  415. }
  416. }
  417. $found[$key] = $newItem;
  418. }
  419. return $found;
  420. }
  421. /**
  422. * @param array $found
  423. * @return array
  424. */
  425. private function applyFieldsToExclude($found){
  426. if(!(count($found) > 0) || !(count($this->fieldsToExclude) > 0)){
  427. return $found;
  428. }
  429. foreach ($found as $key => $item){
  430. foreach ($this->fieldsToExclude as $fieldToExclude){
  431. if(array_key_exists($fieldToExclude, $item)){
  432. unset($item[$fieldToExclude]);
  433. }
  434. }
  435. $found[$key] = $item;
  436. }
  437. return $found;
  438. }
  439. /**
  440. * Writes an object in a store.
  441. * @param $storeData
  442. * @return array
  443. * @throws IOException
  444. * @throws JsonException
  445. * @throws IdNotAllowedException
  446. */
  447. private function writeInStore( $storeData ) {
  448. // Cast to array
  449. $storeData = (array) $storeData;
  450. // Check if it has _id key
  451. if ( isset( $storeData[ '_id' ] ) ) throw new IdNotAllowedException( 'The _id index is reserved by SleekDB, please delete the _id key and try again' );
  452. $id = $this->getStoreId();
  453. // Add the system ID with the store data array.
  454. $storeData[ '_id' ] = $id;
  455. // Prepare storable data
  456. $storableJSON = json_encode( $storeData );
  457. if ( $storableJSON === false ) throw new JsonException( 'Unable to encode the data array,
  458. please provide a valid PHP associative array' );
  459. // Define the store path
  460. $storePath = $this->storePath . 'data/' . $id . '.json';
  461. if ( ! file_put_contents( $storePath, $storableJSON ) ) {
  462. throw new IOException( "Unable to write the object file! Please check if PHP has write permission." );
  463. }
  464. return $storeData;
  465. }
  466. /**
  467. * Sort store objects.
  468. * @param $field
  469. * @param $data
  470. * @param string $order
  471. * @return array
  472. * @throws IndexNotFoundException
  473. * @throws EmptyFieldNameException
  474. * @throws InvalidDataException
  475. */
  476. private function sortArray( $field, $data, $order = 'ASC' ) {
  477. $dryData = [];
  478. // Check if data is an array.
  479. if( is_array( $data ) ) {
  480. // Get value of the target field.
  481. foreach ( $data as $value ) {
  482. $dryData[] = $this->getNestedProperty( $field, $value );
  483. }
  484. }
  485. // Descide the order direction.
  486. if ( strtolower( $order ) === 'asc' ) asort( $dryData );
  487. else if ( strtolower( $order ) === 'desc' ) arsort( $dryData );
  488. // Re arrange the array.
  489. $finalArray = [];
  490. foreach ( $dryData as $key => $value) {
  491. $finalArray[] = $data[ $key ];
  492. }
  493. return $finalArray;
  494. }
  495. /**
  496. * Get nested properties of a store object.
  497. * @param string $fieldName
  498. * @param array $data
  499. * @return array|mixed
  500. * @throws EmptyFieldNameException
  501. * @throws IndexNotFoundException
  502. * @throws InvalidDataException
  503. */
  504. private function getNestedProperty($fieldName, $data ) {
  505. if( !is_array( $data ) ) throw new InvalidDataException('data has to be an array');
  506. if(empty( $fieldName )) throw new EmptyFieldNameException('fieldName is not allowed to be empty');
  507. // Dive deep step by step.
  508. foreach(explode( '.', $fieldName ) as $i ) {
  509. // If the field do not exists then insert an empty string.
  510. if ( ! isset( $data[ $i ] ) ) {
  511. $data = '';
  512. throw new IndexNotFoundException( '"'.$i.'" index was not found in the provided data array' );
  513. }
  514. // The index is valid, collect the data.
  515. $data = $data[ $i ];
  516. }
  517. return $data;
  518. }
  519. /**
  520. * Do a search in store objects. This is like a doing a full-text search.
  521. * @param array $data
  522. * @return array
  523. */
  524. private function performSearch($data = [] ) {
  525. if ( empty( $data ) ) return $data;
  526. $nodesRank = [];
  527. // Looping on each store data.
  528. foreach ($data as $key => $value) {
  529. // Looping on each field name of search-able fields.
  530. foreach ($this->searchKeyword[ 'field' ] as $field) {
  531. try {
  532. $nodeValue = $this->getNestedProperty( $field, $value );
  533. // The searchable field was found, do comparison against search keyword.
  534. similar_text( strtolower($nodeValue), strtolower($this->searchKeyword['keyword']), $perc );
  535. if ( $perc > 50 ) {
  536. // Check if current store object already has a value, if so then add the new value.
  537. if ( isset( $nodesRank[ $key ] ) ) $nodesRank[ $key ] += $perc;
  538. else $nodesRank[ $key ] = $perc;
  539. }
  540. } catch ( \Exception $e ) {
  541. continue;
  542. }
  543. }
  544. }
  545. if ( empty( $nodesRank ) ) {
  546. // No matched store was found against the search keyword.
  547. return [];
  548. }
  549. // Sort nodes in descending order by the rank.
  550. arsort( $nodesRank );
  551. // Map original nodes by the rank.
  552. $nodes = [];
  553. foreach ( $nodesRank as $key => $value ) {
  554. $nodes[] = $data[ $key ];
  555. }
  556. return $nodes;
  557. }
  558. }