diff --git a/core/class/sleekDB/Cache.php b/core/class/sleekDB/Cache.php new file mode 100644 index 00000000..806cb2a9 --- /dev/null +++ b/core/class/sleekDB/Cache.php @@ -0,0 +1,309 @@ +setCacheDir($cacheDir); + + $this->setCachePath($storePath); + + $this->setTokenArray($cacheTokenArray); + + $this->lifetime = $cacheLifetime; + } + + /** + * Retrieve the cache lifetime for current query. + * @return int|null lifetime in seconds (int) or no lifetime with null + */ + public function getLifetime() + { + return $this->lifetime; + } + + /** + * Retrieve the path to cache folder of current store. + * @return string path to cache directory + */ + public function getCachePath(): string + { + return $this->cachePath; + } + + /** + * Retrieve the cache token used as filename to store cache file. + * @return string unique token for current query. + */ + public function getToken(): string + { + $tokenArray = $this->getTokenArray(); + $tokenArray = self::convertClosuresToString($tokenArray); + + return md5(json_encode($tokenArray)); + } + + /** + * Delete all cache files for current store. + * @return bool + */ + public function deleteAll(): bool + { + return IoHelper::deleteFiles(glob($this->getCachePath()."*")); + } + + /** + * Delete all cache files with no lifetime (null) in current store. + * @return bool + */ + public function deleteAllWithNoLifetime(): bool + { + $noLifetimeFileString = self::NO_LIFETIME_FILE_STRING; + return IoHelper::deleteFiles(glob($this->getCachePath()."*.$noLifetimeFileString.json")); + } + + /** + * Save content for current query as a cache file. + * @param array $content + * @throws IOException if cache folder is not writable or saving failed. + */ + public function set(array $content){ + $lifetime = $this->getLifetime(); + $cachePath = $this->getCachePath(); + $token = $this->getToken(); + + $noLifetimeFileString = self::NO_LIFETIME_FILE_STRING; + $cacheFile = $cachePath . $token . ".$noLifetimeFileString.json"; + + if(is_int($lifetime)){ + $cacheFile = $cachePath . $token . ".$lifetime.json"; + } + + IoHelper::writeContentToFile($cacheFile, json_encode($content)); + } + + /** + * Retrieve content of cache file. + * @return array|null array on success, else null + * @throws IOException if cache file is not readable or does not exist. + */ + public function get(){ + $cachePath = $this->getCachePath(); + $token = $this->getToken(); + + $cacheFile = null; + + IoHelper::checkRead($cachePath); + + $cacheFiles = glob($cachePath.$token."*.json"); + + if($cacheFiles !== false && count($cacheFiles) > 0){ + $cacheFile = $cacheFiles[0]; + } + + if(!empty($cacheFile)){ + $cacheParts = explode(".", $cacheFile); + if(count($cacheParts) >= 3){ + $lifetime = $cacheParts[count($cacheParts) - 2]; + if(is_numeric($lifetime)){ + if($lifetime === "0"){ + return json_decode(IoHelper::getFileContent($cacheFile), true); + } + $fileExpiredAfter = filemtime($cacheFile) + (int) $lifetime; + if(time() <= $fileExpiredAfter){ + return json_decode(IoHelper::getFileContent($cacheFile), true); + } + IoHelper::deleteFile($cacheFile); + } else if($lifetime === self::NO_LIFETIME_FILE_STRING){ + return json_decode(IoHelper::getFileContent($cacheFile), true); + } + } + } + return null; + } + + /** + * Delete cache file/s for current query. + * @return bool + */ + public function delete(): bool + { + return IoHelper::deleteFiles(glob($this->getCachePath().$this->getToken()."*.json")); + } + + /** + * @param string $storePath + * @return Cache + */ + private function setCachePath(string $storePath): Cache + { + $cachePath = ""; + $cacheDir = $this->getCacheDir(); + + if(!empty($storePath)){ + IoHelper::normalizeDirectory($storePath); + $cachePath = $storePath . $cacheDir; + } + + $this->cachePath = $cachePath; + + return $this; + } + + /** + * Set the cache token array used for cache token string generation. + * @param array $tokenArray + * @return Cache + */ + private function setTokenArray(array &$tokenArray): Cache + { + $this->tokenArray = &$tokenArray; + return $this; + } + + /** + * Retrieve the cache token array. + * @return array + */ + private function getTokenArray(): array + { + return $this->tokenArray; + } + + /** + * Convert one or multiple closures to string. If array provided, recursively. + * @param mixed $data + * @return mixed + */ + private static function convertClosuresToString($data){ + if(!is_array($data)){ + if($data instanceof \Closure){ + return self::getClosureAsString($data); + } + return $data; + } + foreach ($data as $key => $token){ + if(is_array($token)){ + $data[$key] = self::convertClosuresToString($token); + } else if($token instanceof \Closure){ + $data[$key] = self::getClosureAsString($token); + } + } + return $data; + } + + /** + * Retrieve a string representation of a closure that can be used to differentiate between closures + * when generating the cache token string. + * @param Closure $closure + * @return false|string string representation of closure or false on failure. + */ + private static function getClosureAsString(Closure $closure) + { + try{ + $reflectionFunction = new ReflectionFunction($closure); // get reflection object + } catch (Exception $exception){ + return false; + } + $filePath = $reflectionFunction->getFileName(); // absolute path of php file containing function + $startLine = $reflectionFunction->getStartLine(); // start line of function + $endLine = $reflectionFunction->getEndLine(); // end line of function + $lineSeparator = PHP_EOL; // line separator "\n" + + $staticVariables = $reflectionFunction->getStaticVariables(); + $staticVariables = var_export($staticVariables, true); + + if($filePath === false || $startLine === false || $endLine === false){ + return false; + } + + $startEndDifference = $endLine - $startLine; + + $startLine--; // -1 to use it with the array representation of the file + + if($startLine < 0 || $startEndDifference < 0){ + return false; + } + + // get content of file containing function + $fp = fopen($filePath, 'rb'); + $fileContent = ""; + if(flock($fp, LOCK_SH)){ + $fileContent = @stream_get_contents($fp); + } + flock($fp, LOCK_UN); + fclose($fp); + + if(empty($fileContent)){ + return false; + } + + // separate the file into an array containing every line as one element + $fileContentArray = explode($lineSeparator, $fileContent); + if(count($fileContentArray) < $endLine){ + return false; + } + + // return the part of the file containing the function as a string. + $functionString = implode("", array_slice($fileContentArray, $startLine, $startEndDifference + 1)); + $functionString .= "|staticScopeVariables:".$staticVariables; + return $functionString; + } + + /** + * Set the cache directory name. + * @param string $cacheDir + * @return Cache + */ + private function setCacheDir(string $cacheDir): Cache + { + IoHelper::normalizeDirectory($cacheDir); + $this->cacheDir = $cacheDir; + return $this; + } + + /** + * Retrieve the cache directory name or the default cache directory name if empty. + * @return string + */ + private function getCacheDir(): string + { + return (!empty($this->cacheDir)) ? $this->cacheDir : self::DEFAULT_CACHE_DIR; + } +} \ No newline at end of file diff --git a/core/class/sleekDB/Classes/CacheHandler.php b/core/class/sleekDB/Classes/CacheHandler.php new file mode 100644 index 00000000..2104d9f8 --- /dev/null +++ b/core/class/sleekDB/Classes/CacheHandler.php @@ -0,0 +1,129 @@ +cacheTokenArray = $queryBuilder->_getCacheTokenArray(); + + $queryBuilderProperties = $queryBuilder->_getConditionProperties(); + $this->useCache = $queryBuilderProperties["useCache"]; + $this->regenerateCache = $queryBuilderProperties["regenerateCache"]; + + $this->cache = new Cache($storePath, $this->_getCacheTokenArray(), $queryBuilderProperties["cacheLifetime"]); + } + + /** + * @return Cache + */ + public function getCache(): Cache + { + return $this->cache; + } + + /** + * Get results from cache + * @return array|null + * @throws IOException + */ + public function getCacheContent($getOneDocument) + { + if($this->getUseCache() !== true){ + return null; + } + + $this->updateCacheTokenArray(['oneDocument' => $getOneDocument]); + + if($this->regenerateCache === true) { + $this->getCache()->delete(); + } + + $cacheResults = $this->getCache()->get(); + if(is_array($cacheResults)) { + return $cacheResults; + } + + return null; + } + + /** + * Add content to cache + * @param array $results + * @throws IOException + */ + public function setCacheContent(array $results) + { + if($this->getUseCache() === true){ + $this->getCache()->set($results); + } + } + + /** + * Delete all cache files that have no lifetime. + * @return bool + */ + public function deleteAllWithNoLifetime(): bool + { + return $this->getCache()->deleteAllWithNoLifetime(); + } + + /** + * Returns a reference to the array used for cache token generation + * @return array + */ + public function &_getCacheTokenArray(): array + { + return $this->cacheTokenArray; + } + + /** + * @param array $tokenUpdate + */ + private function updateCacheTokenArray(array $tokenUpdate) + { + if(empty($tokenUpdate)) { + return; + } + $cacheTokenArray = $this->_getCacheTokenArray(); + foreach ($tokenUpdate as $key => $value){ + $cacheTokenArray[$key] = $value; + } + $this->cacheTokenArray = $cacheTokenArray; + } + + /** + * Status if cache is used or not + * @return bool + */ + private function getUseCache(): bool + { + return $this->useCache; + } + +} \ No newline at end of file diff --git a/core/class/sleekDB/Classes/ConditionsHandler.php b/core/class/sleekDB/Classes/ConditionsHandler.php new file mode 100644 index 00000000..0bb59de6 --- /dev/null +++ b/core/class/sleekDB/Classes/ConditionsHandler.php @@ -0,0 +1,433 @@ +getTimestamp(); + $fieldValue = self::convertValueToTimeStamp($fieldValue); + } + + $condition = strtolower(trim($condition)); + switch ($condition){ + case "=": + case "===": + return ($fieldValue === $value); + case "==": + return ($fieldValue == $value); + case "<>": + return ($fieldValue != $value); + case "!==": + case "!=": + return ($fieldValue !== $value); + case ">": + return ($fieldValue > $value); + case ">=": + return ($fieldValue >= $value); + case "<": + return ($fieldValue < $value); + case "<=": + return ($fieldValue <= $value); + case "not like": + case "like": + + if(!is_string($value)){ + throw new InvalidArgumentException("When using \"LIKE\" or \"NOT LIKE\" the value has to be a string."); + } + + // escape characters that are part of regular expression syntax + // https://www.php.net/manual/en/function.preg-quote.php + // We can not use preg_quote because the following characters are also wildcard characters in sql + // so we will not escape them: [ ^ ] - + $charactersToEscape = [".", "\\", "+", "*", "?", "$", "(", ")", "{", "}", "=", "!", "<", ">", "|", ":", "#"]; + foreach ($charactersToEscape as $characterToEscape){ + $value = str_replace($characterToEscape, "\\".$characterToEscape, $value); + } + + $value = str_replace(array('%', '_'), array('.*', '.{1}'), $value); // (zero or more characters) and (single character) + $pattern = "/^" . $value . "$/i"; + $result = (preg_match($pattern, $fieldValue) === 1); + return ($condition === "not like") ? !$result : $result; + + case "not in": + case "in": + if(!is_array($value)){ + $value = (!is_object($value) && !is_array($value) && !is_null($value)) ? $value : gettype($value); + throw new InvalidArgumentException("When using \"in\" and \"not in\" you have to check against an array. Got: $value"); + } + if(!empty($value)){ + (list($firstElement) = $value); + if($firstElement instanceof DateTime){ + // if the user wants to use DateTime, every element of the array has to be an DateTime object. + + // compare timestamps + + // null, false or an empty string will convert to current date and time. + // That is not what we want. + if(empty($fieldValue)){ + return false; + } + + foreach ($value as $key => $item){ + if(!($item instanceof DateTime)){ + throw new InvalidArgumentException("If one DateTime object is given in an \"IN\" or \"NOT IN\" comparison, every element has to be a DateTime object!"); + } + $value[$key] = $item->getTimestamp(); + } + + $fieldValue = self::convertValueToTimeStamp($fieldValue); + } + } + $result = in_array($fieldValue, $value, true); + return ($condition === "not in") ? !$result : $result; + case "not between": + case "between": + + if(!is_array($value) || ($valueLength = count($value)) !== 2){ + $value = (!is_object($value) && !is_array($value) && !is_null($value)) ? $value : gettype($value); + if(isset($valueLength)){ + $value .= " | Length: $valueLength"; + } + throw new InvalidArgumentException("When using \"between\" you have to check against an array with a length of 2. Got: $value"); + } + + list($startValue, $endValue) = $value; + + $result = ( + self::verifyCondition(">=", $fieldValue, $startValue) + && self::verifyCondition("<=", $fieldValue, $endValue) + ); + + return ($condition === "not between") ? !$result : $result; + case "not contains": + case "contains": + + if(!is_array($fieldValue)){ + return ($condition === "not contains"); + } + + $fieldValues = []; + + if($value instanceof DateTime){ + // compare timestamps + $value = $value->getTimestamp(); + + foreach ($fieldValue as $item){ + // null, false or an empty string will convert to current date and time. + // That is not what we want. + if(empty($item)){ + continue; + } + try{ + $fieldValues[] = self::convertValueToTimeStamp($item); + } catch (Exception $exception){ + } + } + } + + if(!empty($fieldValues)){ + $result = in_array($value, $fieldValues, true); + } else { + $result = in_array($value, $fieldValue, true); + } + + return ($condition === "not contains") ? !$result : $result; + case 'exists': + return $fieldValue === $value; + default: + throw new InvalidArgumentException("Condition \"$condition\" is not allowed."); + } + } + + /** + * @param array $element condition or operation + * @param array $data + * @return bool + * @throws InvalidArgumentException + */ + public static function handleWhereConditions(array $element, array $data): bool + { + if(empty($element)){ + throw new InvalidArgumentException("Malformed where statement! Where statements can not contain empty arrays."); + } + if(array_keys($element) !== range(0, (count($element) - 1))){ + throw new InvalidArgumentException("Malformed where statement! Associative arrays are not allowed."); + } + // element is a where condition + if(is_string($element[0]) && is_string($element[1])){ + if(count($element) !== 3){ + throw new InvalidArgumentException("Where conditions have to be [fieldName, condition, value]"); + } + + $fieldName = $element[0]; + $condition = strtolower(trim($element[1])); + $fieldValue = ($condition === 'exists') + ? NestedHelper::nestedFieldExists($fieldName, $data) + : NestedHelper::getNestedValue($fieldName, $data); + + return self::verifyCondition($condition, $fieldValue, $element[2]); + } + + // element is an array "brackets" + + // prepare results array - example: [true, "and", false] + $results = []; + foreach ($element as $value){ + if(is_array($value)){ + $results[] = self::handleWhereConditions($value, $data); + } else if (is_string($value)) { + $results[] = $value; + } else if($value instanceof \Closure){ + $result = $value($data); + if(!is_bool($result)){ + $resultType = gettype($result); + $errorMsg = "The closure in the where condition needs to return a boolean. Got: $resultType"; + throw new InvalidArgumentException($errorMsg); + } + $results[] = $result; + } else { + $value = (!is_object($value) && !is_array($value) && !is_null($value)) ? $value : gettype($value); + throw new InvalidArgumentException("Invalid nested where statement element! Expected condition or operation, got: \"$value\""); + } + } + + // first result as default value + $returnValue = array_shift($results); + + if(is_bool($returnValue) === false){ + throw new InvalidArgumentException("Malformed where statement! First part of the statement have to be a condition."); + } + + // used to prioritize the "and" operation. + $orResults = []; + + // use results array to get the return value of the conditions within the bracket + while(!empty($results) || !empty($orResults)){ + + if(empty($results)) { + if($returnValue === true){ + // we need to check anymore, because the result of true || false is true + break; + } + // $orResults is not empty. + $nextResult = array_shift($orResults); + $returnValue = $returnValue || $nextResult; + continue; + } + + $operationOrNextResult = array_shift($results); + + if(is_string($operationOrNextResult)){ + $operation = $operationOrNextResult; + + if(empty($results)){ + throw new InvalidArgumentException("Malformed where statement! Last part of a condition can not be a operation."); + } + $nextResult = array_shift($results); + + if(!is_bool($nextResult)){ + throw new InvalidArgumentException("Malformed where statement! Two operations in a row are not allowed."); + } + } else if(is_bool($operationOrNextResult)){ + $operation = "AND"; + $nextResult = $operationOrNextResult; + } else { + throw new InvalidArgumentException("Malformed where statement! A where statement have to contain just operations and conditions."); + } + + if(!in_array(strtolower($operation), ["and", "or"])){ + $operation = (!is_object($operation) && !is_array($operation) && !is_null($operation)) ? $operation : gettype($operation); + throw new InvalidArgumentException("Expected 'and' or 'or' operator got \"$operation\""); + } + + // prepare $orResults execute after all "and" are done. + if(strtolower($operation) === "or"){ + $orResults[] = $returnValue; + $returnValue = $nextResult; + continue; + } + + $returnValue = $returnValue && $nextResult; + + } + + return $returnValue; + } + + /** + * @param array $results + * @param array $currentDocument + * @param array $distinctFields + * @return bool + */ + public static function handleDistinct(array $results, array $currentDocument, array $distinctFields): bool + { + // Distinct data check. + foreach ($results as $result) { + foreach ($distinctFields as $field) { + try { + $storePassed = (NestedHelper::getNestedValue($field, $result) !== NestedHelper::getNestedValue($field, $currentDocument)); + } catch (Throwable $th) { + continue; + } + if ($storePassed === false) { + return false; + } + } + } + + return true; + } + + /** + * @param array $data + * @param bool $storePassed + * @param array $nestedWhereConditions + * @return bool + * @throws InvalidArgumentException + * @deprecated since version 2.3, use handleWhereConditions instead. + */ + public static function handleNestedWhere(array $data, bool $storePassed, array $nestedWhereConditions): bool + { + // TODO remove nested where with v3.0 + + if(empty($nestedWhereConditions)){ + return $storePassed; + } + + // the outermost operation specify how the given conditions are connected with other conditions, + // like the ones that are specified using the where, orWhere, in or notIn methods + $outerMostOperation = (array_keys($nestedWhereConditions))[0]; + $nestedConditions = $nestedWhereConditions[$outerMostOperation]; + + // specifying outermost is optional and defaults to "and" + $outerMostOperation = (is_string($outerMostOperation)) ? strtolower($outerMostOperation) : "and"; + + // if the document already passed the store with another condition, we dont need to check it. + if($outerMostOperation === "or" && $storePassed === true){ + return true; + } + + return self::_nestedWhereHelper($nestedConditions, $data); + } + + /** + * @param array $element + * @param array $data + * @return bool + * @throws InvalidArgumentException + * @deprecated since version 2.3. use _handleWhere instead + */ + private static function _nestedWhereHelper(array $element, array $data): bool + { + // TODO remove nested where with v3.0 + // element is a where condition + if(array_keys($element) === range(0, (count($element) - 1)) && is_string($element[0])){ + if(count($element) !== 3){ + throw new InvalidArgumentException("Where conditions have to be [fieldName, condition, value]"); + } + + $fieldValue = NestedHelper::getNestedValue($element[0], $data); + + return self::verifyCondition($element[1], $fieldValue, $element[2]); + } + + // element is an array "brackets" + + // prepare results array - example: [true, "and", false] + $results = []; + foreach ($element as $value){ + if(is_array($value)){ + $results[] = self::_nestedWhereHelper($value, $data); + } else if (is_string($value)){ + $results[] = $value; + } else { + $value = (!is_object($value) && !is_array($value)) ? $value : gettype($value); + throw new InvalidArgumentException("Invalid nested where statement element! Expected condition or operation, got: \"$value\""); + } + } + + if(count($results) < 3){ + throw new InvalidArgumentException("Malformed nested where statement! A condition consists of at least 3 elements."); + } + + // first result as default value + $returnValue = array_shift($results); + + // use results array to get the return value of the conditions within the bracket + while(!empty($results)){ + $operation = array_shift($results); + $nextResult = array_shift($results); + + if(((count($results) % 2) !== 0)){ + throw new InvalidArgumentException("Malformed nested where statement!"); + } + + if(!is_string($operation) || !in_array(strtolower($operation), ["and", "or"])){ + $operation = (!is_object($operation) && !is_array($operation)) ? $operation : gettype($operation); + throw new InvalidArgumentException("Expected 'and' or 'or' operator got \"$operation\""); + } + + if(strtolower($operation) === "and"){ + $returnValue = $returnValue && $nextResult; + } else { + $returnValue = $returnValue || $nextResult; + } + } + + return $returnValue; + } + + /** + * @param $value + * @return int + * @throws InvalidArgumentException + */ + private static function convertValueToTimeStamp($value): int + { + $value = (is_string($value)) ? trim($value) : $value; + try{ + return (new DateTime($value))->getTimestamp(); + } catch (Exception $exception){ + $value = (!is_object($value) && !is_array($value)) + ? $value + : gettype($value); + throw new InvalidArgumentException( + "DateTime object given as value to check against. " + . "Could not convert value into DateTime. " + . "Value: $value" + ); + } + } +} \ No newline at end of file diff --git a/core/class/sleekDB/Classes/DocumentFinder.php b/core/class/sleekDB/Classes/DocumentFinder.php new file mode 100644 index 00000000..d2014966 --- /dev/null +++ b/core/class/sleekDB/Classes/DocumentFinder.php @@ -0,0 +1,387 @@ +storePath = $storePath; + $this->queryBuilderProperties = $queryBuilderProperties; + $this->primaryKey = $primaryKey; + } + + /** + * @param bool $getOneDocument + * @param bool $reduceAndJoinPossible + * @return array + * @throws IOException + * @throws InvalidArgumentException + */ + public function findDocuments(bool $getOneDocument, bool $reduceAndJoinPossible): array + { + $queryBuilderProperties = $this->queryBuilderProperties; + $dataPath = $this->getDataPath(); + $primaryKey = $this->primaryKey; + + $found = []; + // Start collecting and filtering data. + IoHelper::checkRead($dataPath); + + $conditions = $queryBuilderProperties["whereConditions"]; + $distinctFields = $queryBuilderProperties["distinctFields"]; + $nestedWhereConditions = $queryBuilderProperties["nestedWhere"]; + $listOfJoins = $queryBuilderProperties["listOfJoins"]; + $search = $queryBuilderProperties["search"]; + $searchOptions = $queryBuilderProperties["searchOptions"]; + $groupBy = $queryBuilderProperties["groupBy"]; + $havingConditions = $queryBuilderProperties["havingConditions"]; + $fieldsToSelect = $queryBuilderProperties["fieldsToSelect"]; + $orderBy = $queryBuilderProperties["orderBy"]; + $skip = $queryBuilderProperties["skip"]; + $limit = $queryBuilderProperties["limit"]; + $fieldsToExclude = $queryBuilderProperties["fieldsToExclude"]; + + unset($queryBuilderProperties); + + if ($handle = opendir($dataPath)) { + + while (false !== ($entry = readdir($handle))) { + + if ($entry === "." || $entry === "..") { + continue; + } + + $documentPath = $dataPath . $entry; + + try{ + $data = IoHelper::getFileContent($documentPath); + } catch (Exception $exception){ + continue; + } + $data = @json_decode($data, true); + if (!is_array($data)) { + continue; + } + + $storePassed = true; + + // Append only passed data from this store. + + // Process conditions + if(!empty($conditions)) { + // Iterate each conditions. + $storePassed = ConditionsHandler::handleWhereConditions($conditions, $data); + } + + // TODO remove nested where with version 3.0 + $storePassed = ConditionsHandler::handleNestedWhere($data, $storePassed, $nestedWhereConditions); + + if ($storePassed === true && count($distinctFields) > 0) { + $storePassed = ConditionsHandler::handleDistinct($found, $data, $distinctFields); + } + + if ($storePassed === true) { + $found[] = $data; + + // if we just check for existence or want to return the first item, we dont need to look for more documents + if ($getOneDocument === true) { + break; + } + } + } + closedir($handle); + } + + // apply additional changes to result like sort and limit + + if($reduceAndJoinPossible === true){ + DocumentReducer::joinData($found, $listOfJoins); + } + + if (count($found) > 0) { + self::performSearch($found, $search, $searchOptions); + } + + if ($reduceAndJoinPossible === true && !empty($groupBy) && count($found) > 0) { + + DocumentReducer::handleGroupBy( + $found, + $groupBy, + $fieldsToSelect + ); + } + + if($reduceAndJoinPossible === true && empty($groupBy) && count($found) > 0){ + // select specific fields + DocumentReducer::selectFields($found, $primaryKey, $fieldsToSelect); + } + + if(count($found) > 0){ + self::handleHaving($found, $havingConditions); + } + + if($reduceAndJoinPossible === true && count($found) > 0){ + // exclude specific fields + DocumentReducer::excludeFields($found, $fieldsToExclude); + } + + if(count($found) > 0){ + // sort the data. + self::sort($found, $orderBy); + } + + + if(count($found) > 0) { + // Skip data + self::skip($found, $skip); + } + + if(count($found) > 0) { + // Limit data. + self::limit($found, $limit); + } + + return $found; + } + + /** + * @return string + */ + private function getDataPath(): string + { + return $this->storePath . Store::dataDirectory; + } + + /** + * @param array $found + * @param array $orderBy + * @throws InvalidArgumentException + */ + private static function sort(array &$found, array $orderBy){ + if (!empty($orderBy)) { + + $resultSortArray = []; + + foreach ($orderBy as $orderByClause){ + // Start sorting on all data. + $order = $orderByClause['order']; + $fieldName = $orderByClause['fieldName']; + + $arrayColumn = []; + // Get value of the target field. + foreach ($found as $value) { + $arrayColumn[] = NestedHelper::getNestedValue($fieldName, $value); + } + + $resultSortArray[] = $arrayColumn; + + // Decide the order direction. + // order will be asc or desc (check is done in QueryBuilder class) + $resultSortArray[] = ($order === 'asc') ? SORT_ASC : SORT_DESC; + + } + + if(!empty($resultSortArray)){ + $resultSortArray[] = &$found; + array_multisort(...$resultSortArray); + } + unset($resultSortArray); + } + } + + /** + * @param array $found + * @param $skip + */ + private static function skip(array &$found, $skip){ + if (empty($skip) || $skip <= 0) { + return; + } + $found = array_slice($found, $skip); + } + + /** + * @param array $found + * @param $limit + */ + private static function limit(array &$found, $limit){ + if (empty($limit) || $limit <= 0) { + return; + } + $found = array_slice($found, 0, $limit); + } + + /** + * Do a search in store objects. This is like a doing a full-text search. + * @param array $found + * @param array $search + * @param array $searchOptions + * @throws InvalidArgumentException + */ + private static function performSearch(array &$found, array $search, array $searchOptions) + { + if(empty($search)){ + return; + } + $minLength = $searchOptions["minLength"]; + $searchScoreKey = $searchOptions["scoreKey"]; + $searchMode = $searchOptions["mode"]; + $searchAlgorithm = $searchOptions["algorithm"]; + + $scoreMultiplier = 64; + $encoding = "UTF-8"; + + $fields = $search["fields"]; + $query = $search["query"]; + $lowerQuery = mb_strtolower($query, $encoding); + $exactQuery = preg_quote($query, "/"); + + $fieldsLength = count($fields); + + $highestScore = $scoreMultiplier ** $fieldsLength; + + // split query + $searchWords = preg_replace('/(\s)/u', ',', $query); + $searchWords = explode(",", $searchWords); + + $prioritizeAlgorithm = (in_array($searchAlgorithm, [ + Query::SEARCH_ALGORITHM["prioritize"], + Query::SEARCH_ALGORITHM["prioritize_position"] + ], true)); + + $positionAlgorithm = ($searchAlgorithm === Query::SEARCH_ALGORITHM["prioritize_position"]); + + // apply min word length + $temp = []; + foreach ($searchWords as $searchWord){ + if(strlen($searchWord) >= $minLength){ + $temp[] = $searchWord; + } + } + $searchWords = $temp; + unset($temp); + $searchWords = array_map(static function($value){ + return preg_quote($value, "/"); + }, $searchWords); + + // apply mode + if($searchMode === "and"){ + $preg = ""; + foreach ($searchWords as $searchWord){ + $preg .= "(?=.*".$searchWord.")"; + } + $preg = '/^' . $preg . '.*/im'; + $pregOr = '!(' . implode('|', $searchWords) . ')!i'; + } else { + $preg = '!(' . implode('|', $searchWords) . ')!i'; + } + + // search + foreach ($found as $foundKey => &$document) { + $searchHits = 0; + $searchScore = 0; + foreach ($fields as $key => $field) { + if($prioritizeAlgorithm){ + $score = $highestScore / ($scoreMultiplier ** $key); + } else { + $score = $scoreMultiplier; + } + $value = NestedHelper::getNestedValue($field, $document); + + if (!is_string($value) || $value === "") { + continue; + } + + $lowerValue = mb_strtolower($value, $encoding); + + if ($lowerQuery === $lowerValue) { + // exact match + $searchHits++; + $searchScore += 16 * $score; + } elseif ($positionAlgorithm && mb_strpos($lowerValue, $lowerQuery, 0, $encoding) === 0) { + // exact beginning match + $searchHits++; + $searchScore += 8 * $score; + } elseif ($matches = preg_match_all('!' . $exactQuery . '!i', $value)) { + // exact query match + $searchHits += $matches; +// $searchScore += 2 * $score; + $searchScore += $matches * 2 * $score; + if($searchAlgorithm === Query::SEARCH_ALGORITHM["hits_prioritize"]){ + $searchScore += $matches * ($fieldsLength - $key); + } + } + + $matchesArray = []; + + $matches = ($searchMode === "and") ? preg_match($preg, $value) : preg_match_all($preg, $value, $matchesArray, PREG_OFFSET_CAPTURE); + + if ($matches) { + // any match + $searchHits += $matches; + $searchScore += $matches * $score; + if($searchAlgorithm === Query::SEARCH_ALGORITHM["hits_prioritize"]) { + $searchScore += $matches * ($fieldsLength - $key); + } + // because the "and" search algorithm at most finds one match we also use the amount of word occurrences + if($searchMode === "and" && isset($pregOr) && ($matches = preg_match_all($pregOr, $value, $matchesArray, PREG_OFFSET_CAPTURE))){ + $searchHits += $matches; + $searchScore += $matches * $score; + } + } + + // we apply a small very small number to the score to differentiate the distance from the beginning + if($positionAlgorithm && $matches && !empty($matchesArray)){ + $hitPosition = $matchesArray[0][0][1]; + if(!is_int($hitPosition) || !($hitPosition > 0)){ + $hitPosition = 1; + } + $searchScore += ($score / $highestScore) * ($hitPosition / ($hitPosition * $hitPosition)); + } + } + + if($searchHits > 0){ + if(!is_null($searchScoreKey)){ + $document[$searchScoreKey] = $searchScore; + } + } else { + unset($found[$foundKey]); + } + } + } + + /** + * @param array $found + * @param array $havingConditions + * @throws InvalidArgumentException + */ + private static function handleHaving(array &$found, array $havingConditions){ + if(empty($havingConditions)){ + return; + } + + foreach ($found as $key => $document){ + if(false === ConditionsHandler::handleWhereConditions($havingConditions, $document)){ + unset($found[$key]); + } + } + } + +} \ No newline at end of file diff --git a/core/class/sleekDB/Classes/DocumentReducer.php b/core/class/sleekDB/Classes/DocumentReducer.php new file mode 100644 index 00000000..d8615663 --- /dev/null +++ b/core/class/sleekDB/Classes/DocumentReducer.php @@ -0,0 +1,665 @@ + "avg", + "MAX" => "max", + "MIN" => "min", + "SUM" => "sum", + "ROUND" => "round", + "ABS" => "abs", + "POSITION" => "position", + "UPPER" => "upper", + "LOWER" => "lower", + "LENGTH" => "length", + "CONCAT" => "concat", + "CUSTOM" => "custom", + ]; + + const SELECT_FUNCTIONS_THAT_REDUCE_RESULT = [ + "AVG" => "avg", + "MAX" => "max", + "MIN" => "min", + "SUM" => "sum" + ]; + + /** + * @param array $found + * @param array $fieldsToExclude + */ + public static function excludeFields(array &$found, array $fieldsToExclude){ + if (empty($fieldsToExclude)) { + return; + } + foreach ($found as $key => &$document) { + if(!is_array($document)){ + continue; + } + foreach ($fieldsToExclude as $fieldToExclude) { + NestedHelper::removeNestedField($document, $fieldToExclude); + } + } + } + + /** + * @param array $results + * @param array $listOfJoins + * @throws IOException + * @throws InvalidArgumentException + */ + public static function joinData(array &$results, array $listOfJoins){ + if(empty($listOfJoins)){ + return; + } + // Join data. + foreach ($results as $key => $doc) { + foreach ($listOfJoins as $join) { + // Execute the child query. + $joinQuery = ($join['joinFunction'])($doc); // QueryBuilder or result of fetch + $propertyName = $join['propertyName']; + + // TODO remove SleekDB check in version 3.0 + if($joinQuery instanceof QueryBuilder || $joinQuery instanceof SleekDB){ + $joinResult = $joinQuery->getQuery()->fetch(); + } else if(is_array($joinQuery)){ + // user already fetched the query in the join query function + $joinResult = $joinQuery; + } else { + throw new InvalidArgumentException("Invalid join query."); + } + + // Add child documents with the current document. + $results[$key][$propertyName] = $joinResult; + } + } + } + + /** + * @param array $found + * @param array $groupBy + * @param array $fieldsToSelect + * @throws InvalidArgumentException + */ + public static function handleGroupBy(array &$found, array $groupBy, array $fieldsToSelect) + { + if(empty($groupBy)){ + return; + } + // TODO optimize algorithm if possible + $groupByFields = $groupBy["groupByFields"]; + $countKeyName = $groupBy["countKeyName"]; + $allowEmpty = $groupBy["allowEmpty"]; + + $hasSelectFunctionThatNotReduceResult = false; + $hasSelectFunctionThatReduceResult = false; + + $pattern = (!empty($fieldsToSelect))? $fieldsToSelect : $groupByFields; + + if(!empty($countKeyName) && empty($fieldsToSelect)){ + $pattern[] = $countKeyName; + } + + // remove duplicates + $patternWithOutDuplicates = []; + foreach ($pattern as $key => $value){ + if(array_key_exists($key, $patternWithOutDuplicates) && in_array($value, $patternWithOutDuplicates, true)){ + continue; + } + $patternWithOutDuplicates[$key] = $value; + + // validate pattern + if(!is_string($key) && !is_string($value)){ + throw new InvalidArgumentException("You need to format the select correctly when using Group By."); + } + if(!is_string($value)) { + + if($value instanceof Closure){ + if($hasSelectFunctionThatNotReduceResult === false){ + $hasSelectFunctionThatNotReduceResult = true; + } + if(!in_array($key, $groupByFields, true)){ // key is fieldAlias + throw new InvalidArgumentException("You can not select a field \"$key\" that is not grouped by."); + } + continue; + } + + if (!is_array($value) || empty($value)) { + throw new InvalidArgumentException("You need to format the select correctly when using Group By."); + } + + list($function) = array_keys($value); + $functionParameters = $value[$function]; + self::getFieldNamesOfSelectFunction($function, $functionParameters); + + if(is_string($function) ){ + if(!in_array(strtolower($function), self::SELECT_FUNCTIONS_THAT_REDUCE_RESULT)){ + if($hasSelectFunctionThatNotReduceResult === false){ + $hasSelectFunctionThatNotReduceResult = true; + } + if(!in_array($key, $groupByFields, true)){ // key is fieldAlias + throw new InvalidArgumentException("You can not select a field \"$key\" that is not grouped by."); + } + } else if($hasSelectFunctionThatReduceResult === false){ + $hasSelectFunctionThatReduceResult = true; + } + } + } else if($value !== $countKeyName && !in_array($value, $groupByFields, true)) { + throw new InvalidArgumentException("You can not select a field that is not grouped by."); + } + } + $pattern = $patternWithOutDuplicates; + unset($patternWithOutDuplicates); + + // Apply select functions that do not reduce result before grouping + if($hasSelectFunctionThatNotReduceResult){ + foreach ($found as &$document){ + foreach ($pattern as $key => $value){ + if(is_array($value)){ + list($function) = array_keys($value); + $functionParameters = $value[$function]; + if(in_array(strtolower($function), self::SELECT_FUNCTIONS_THAT_REDUCE_RESULT)){ + continue; + } + + $document[$key] = self::handleSelectFunction($function, $document, $functionParameters); + } else if($value instanceof Closure){ + $function = self::SELECT_FUNCTIONS['CUSTOM']; + $functionParameters = $value; + $document[$key] = self::handleSelectFunction($function, $document, $functionParameters); + } + } + } + unset($document); + } + + // GROUP + $groupedResult = []; + foreach ($found as $foundKey => $document){ + + // Prepare hash for group by + $values = []; + $isEmptyAndEmptyNotAllowed = false; + foreach ($groupByFields as $groupByField){ + $value = NestedHelper::getNestedValue($groupByField, $document); + if($allowEmpty === false && is_null($value)){ + $isEmptyAndEmptyNotAllowed = true; + break; + } + $values[$groupByField] = $value; + } + if($isEmptyAndEmptyNotAllowed === true){ + continue; + } + $valueHash = md5(json_encode($values)); + + // is new entry + if(!array_key_exists($valueHash, $groupedResult)){ + $resultDocument = []; + foreach ($pattern as $key => $patternValue){ + $resultFieldName = (is_string($key)) ? $key : $patternValue; + + if($resultFieldName === $countKeyName){ + // is a counter + $attributeValue = 1; + } else if(!is_string($patternValue)){ + // is a function + list($function) = array_keys($patternValue); + if(in_array(strtolower($function), self::SELECT_FUNCTIONS_THAT_REDUCE_RESULT)){ + // is a select function that reduce result. + $fieldNameToHandle = $patternValue[$function]; + $currentFieldValue = NestedHelper::getNestedValue($fieldNameToHandle, $document); + if(!is_numeric($currentFieldValue)){ + $attributeValue = [$function => [null]]; + } else { + $attributeValue = [$function => [$currentFieldValue]]; + } + } else { + // is a select function that does not reduce result. + $attributeValue = $document[$resultFieldName]; + } + } else { + // is a normal select + $attributeValue = NestedHelper::getNestedValue($patternValue, $document); + } + $resultDocument[$resultFieldName] = $attributeValue; + } + $groupedResult[$valueHash] = $resultDocument; + continue; + } + + // entry exists + $currentResult = $groupedResult[$valueHash]; + foreach ($pattern as $key => $patternValue){ + $resultFieldName = (is_string($key)) ? $key : $patternValue; + + if($resultFieldName === $countKeyName){ + $currentResult[$resultFieldName] += 1; + continue; + } + + if(is_array($patternValue)){ + list($function) = array_keys($patternValue); + if(in_array(strtolower($function), self::SELECT_FUNCTIONS_THAT_REDUCE_RESULT)){ + $fieldNameToHandle = $patternValue[$function]; + $currentFieldValue = NestedHelper::getNestedValue($fieldNameToHandle, $document); + $currentFieldValue = is_numeric($currentFieldValue) ? $currentFieldValue : null; + $currentResult[$resultFieldName][$function][] = $currentFieldValue; + } + } + } + $groupedResult[$valueHash] = $currentResult; + } + + // Apply select functions that reduce result + if($hasSelectFunctionThatReduceResult){ + foreach ($groupedResult as &$document){ + foreach ($pattern as $key => $value){ + if(!is_array($value)){ + continue; + } + list($function) = array_keys($value); + if(!in_array(strtolower($function), self::SELECT_FUNCTIONS_THAT_REDUCE_RESULT)){ + continue; + } + // "price" => ["sum" => [...]] + $functionParameters = $key.".".$function; + $document[$key] = self::handleSelectFunction($function, $document, $functionParameters); + } + } + unset($document); + } + + $found = array_values($groupedResult); + } + + /** + * @param array $found + * @param string $primaryKey + * @param array $fieldsToSelect + * @throws InvalidArgumentException + */ + public static function selectFields(array &$found, string $primaryKey, array $fieldsToSelect) + { + if (empty($fieldsToSelect)) { + return; + } + + $functionsThatReduceResultToSingleResult = self::SELECT_FUNCTIONS_THAT_REDUCE_RESULT; + $reducedResult = []; // "fieldName" => ["values",...] + $reduceResultToSingleResult = false; + + // check if result should be reduced to single result + foreach ($fieldsToSelect as $fieldToSelect){ + if(!is_array($fieldToSelect)){ + continue; + } + + list($function) = array_keys($fieldToSelect); + + if(in_array(strtolower($function), $functionsThatReduceResultToSingleResult, true)){ + $reduceResultToSingleResult = true; + } + + if($reduceResultToSingleResult === true){ + break; + } + } + + // Is not result of group by and contains function that reduces result to single result + if($reduceResultToSingleResult === true){ + foreach ($found as $key => $document) { + foreach ($fieldsToSelect as $fieldAlias => $fieldToSelect) { + $fieldName = (!is_int($fieldAlias))? $fieldAlias : $fieldToSelect; + if(!is_array($fieldToSelect)){ + continue; + } + + // no alias specified and select function (array) used as element + if(!is_string($fieldName)){ + $errorMsg = "You need to specify an alias for the field when using select functions."; + throw new InvalidArgumentException($errorMsg); + } + + list($function) = array_keys($fieldToSelect); + $functionParameters = $fieldToSelect[$function]; + + if(in_array(strtolower($function), $functionsThatReduceResultToSingleResult, true)){ + if(!is_string($functionParameters)){ + $errorMsg = "When using the function \"$function\" the parameter has to be a string (fieldName)."; + throw new InvalidArgumentException($errorMsg); + } + + $value = NestedHelper::getNestedValue($functionParameters, $document); + if(!array_key_exists($fieldName, $reducedResult)){ + $reducedResult[$fieldName] = []; + } + $reducedResult[$fieldName][] = $value; + } + } + } + + $newDocument = []; + foreach ($fieldsToSelect as $fieldAlias => $fieldToSelect){ + $fieldName = (!is_int($fieldAlias))? $fieldAlias : $fieldToSelect; + if(!is_array($fieldToSelect)){ + continue; + } + + list($function) = array_keys($fieldToSelect); + if(in_array(strtolower($function), $functionsThatReduceResultToSingleResult, true)){ + $newDocument[$fieldName] = self::handleSelectFunction($function, $reducedResult, $fieldName); + } + } + $found = [$newDocument]; + return; + } + + // result should not be reduced to single result + + foreach ($found as $key => &$document) { + $newDocument = []; + + $newDocument[$primaryKey] = $document[$primaryKey]; + foreach ($fieldsToSelect as $fieldAlias => $fieldToSelect) { + + $fieldName = (!is_int($fieldAlias))? $fieldAlias : $fieldToSelect; + + if(!is_string($fieldToSelect) && !is_int($fieldToSelect) && !is_array($fieldToSelect) + && !($fieldToSelect instanceof Closure)) + { + $errorMsg = "When using select an array containing fieldNames as strings or select functions has to be given"; + throw new InvalidArgumentException($errorMsg); + } + + // no alias specified and select function (array) used as element + if(!is_string($fieldName)){ + $errorMsg = "You need to specify an alias for the field when using select functions."; + throw new InvalidArgumentException($errorMsg); + } + + // if the fieldToSelect is an array, the user wants to use a select function + if(is_array($fieldToSelect)){ + // "fieldAlias" => ["function" => "field"] + list($function) = array_keys($fieldToSelect); + $functionParameters = $fieldToSelect[$function]; + $newDocument[$fieldName] = self::handleSelectFunction($function, $document, $functionParameters); + } else if($fieldToSelect instanceof Closure){ + $function = self::SELECT_FUNCTIONS['CUSTOM']; + $functionParameters = $fieldToSelect; + $newDocument[$fieldName] = self::handleSelectFunction($function, $document, $functionParameters); + } else { + // No select function is used (fieldToSelect is string or int) + $fieldValue = NestedHelper::getNestedValue((string) $fieldToSelect, $document); + $createdArray = NestedHelper::createNestedArray($fieldName, $fieldValue); + if(!empty($createdArray)){ + $createdArrayKey = array_keys($createdArray)[0]; + $newDocument[$createdArrayKey] = $createdArray[$createdArrayKey]; + } + } + } + $document = $newDocument; + } + } + + /** + * @param string $function + * @param array $document + * @param string|array|int|Closure $functionParameters + * @return mixed + * @throws InvalidArgumentException + */ + private static function handleSelectFunction(string $function, array $document, $functionParameters){ + + if(is_int($functionParameters)){ + $functionParameters = (string) $functionParameters; + } + + switch (strtolower($function)){ + case self::SELECT_FUNCTIONS["ROUND"]: + list($field, $precision) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + if(!is_string($field) || !is_int($precision)){ + $errorMsg = "When using the select function \"$function\" the field parameter has to be a string " + ."and the precision parameter has to be an integer"; + throw new InvalidArgumentException($errorMsg); + } + + $data = NestedHelper::getNestedValue($field, $document); + if(!is_numeric($data)){ + return null; + } + return round((float) $data, $precision); + case self::SELECT_FUNCTIONS["ABS"]: + list($field) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + $data = NestedHelper::getNestedValue($field, $document); + if(!is_numeric($data)){ + return null; + } + return abs($data); + case self::SELECT_FUNCTIONS["POSITION"]: + list($field, $subString) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + if(!is_string($subString) || !is_string($field)){ + $errorMsg = "When using the select function \"$function\" the subString and field parameters has to be strings"; + throw new InvalidArgumentException($errorMsg); + } + + $data = NestedHelper::getNestedValue($field, $document); + if(!is_string($data)){ + return null; + } + $result = strpos($data, $subString); + return ($result !== false)? $result + 1 : null; + case self::SELECT_FUNCTIONS["UPPER"]: + list($field) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + $data = NestedHelper::getNestedValue($field, $document); + if(!is_string($data)){ + return null; + } + return strtoupper($data); + case self::SELECT_FUNCTIONS["LOWER"]: + list($field) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + $data = NestedHelper::getNestedValue($field, $document); + if(!is_string($data)){ + return null; + } + return strtolower($data); + case self::SELECT_FUNCTIONS["LENGTH"]: + list($field) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + $data = NestedHelper::getNestedValue($field, $document); + if(is_string($data)){ + return strlen($data); + } + if(is_array($data)){ + return count($data); + } + return null; + case self::SELECT_FUNCTIONS["CONCAT"]: + list($fields, $glue) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + $result = ""; + foreach ($fields as $field){ + $data = NestedHelper::getNestedValue($field, $document); + // convertible to string + if( + ( !is_array( $data ) ) + && ($data !== "" && $data !== null) + && ( + ( !is_object( $data ) && settype( $data, 'string' ) !== false ) + || ( is_object( $data ) && method_exists( $data, '__toString' ) ) + ) + ) + { + if($result !== ""){ + $result .= $glue; + } + $result .= $data; + } + } + return ($result !== "") ? $result : null; + case self::SELECT_FUNCTIONS["SUM"]: + list($field) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + $data = NestedHelper::getNestedValue($field, $document); + if(!is_array($data)){ + return null; + } + + $result = 0; + $allEntriesNull = true; + foreach ($data as $value){ + if(!is_null($value)){ + $result += $value; + $allEntriesNull = false; + } + } + if($allEntriesNull === true){ + return null; + } + return $result; + case self::SELECT_FUNCTIONS["MIN"]: + list($field) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + $data = NestedHelper::getNestedValue($field, $document); + if(!is_array($data)){ + return null; + } + + $result = INF; + $allEntriesNull = true; + foreach ($data as $value){ + if(!is_null($value)){ + if($value < $result){ + $result = $value; + } + $allEntriesNull = false; + } + } + if($allEntriesNull === true){ + return null; + } + return $result; + case self::SELECT_FUNCTIONS["MAX"]: + list($field) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + $data = NestedHelper::getNestedValue($field, $document); + if(!is_array($data)){ + return null; + } + + $result = -INF; + $allEntriesNull = true; + foreach ($data as $value){ + if($value > $result && !is_null($value)) { + $result = $value; + $allEntriesNull = false; + } + } + if($allEntriesNull === true){ + return null; + } + return $result; + case self::SELECT_FUNCTIONS["AVG"]: + list($field) = self::getFieldNamesOfSelectFunction($function, $functionParameters); + $data = NestedHelper::getNestedValue($field, $document); + if(!is_array($data)){ + return null; + } + + $result = 0; + $resultValueAmount = (count($data) + 1); + $allEntriesNull = true; + foreach ($data as $value){ + if(!is_null($value)){ + $result += $value; + $allEntriesNull = false; + } + } + if($allEntriesNull === true){ + return null; + } + return ($result / $resultValueAmount); + case self::SELECT_FUNCTIONS['CUSTOM']: + if(!($functionParameters instanceof Closure)){ + throw new InvalidArgumentException("When using a custom select function you need to provide a closure."); + } + return $functionParameters($document); + default: + throw new InvalidArgumentException("The given select function \"$function\" is not supported."); + } + } + + /** + * @param string $function + * @param string|array|int $functionParameters + * @return array [array|string $fieldNames, $addition] + * @throws InvalidArgumentException + */ + private static function getFieldNamesOfSelectFunction(string $function, $functionParameters): array + { + if(is_int($functionParameters)){ + $functionParameters = (string) $functionParameters; + } + $function = strtolower($function); + switch ($function){ + case self::SELECT_FUNCTIONS["ROUND"]: + case self::SELECT_FUNCTIONS["POSITION"]: + if(!is_array($functionParameters) || count($functionParameters) !== 2){ + $type = gettype($functionParameters); + $length = (is_array($functionParameters)) ? count($functionParameters) : 0; + $errorMsg = "When using the select function \"$function\" the parameter " + ."has to be an array with length = 2, got $type with length $length"; + throw new InvalidArgumentException($errorMsg); + } + list($firstParameter, $secondParameter) = $functionParameters; + if($function === self::SELECT_FUNCTIONS["ROUND"]){ + $field = $firstParameter; + $addition = $secondParameter; + } else { + $field = $secondParameter; + $addition = $firstParameter; + } + return [$field, $addition]; + case self::SELECT_FUNCTIONS["ABS"]: + case self::SELECT_FUNCTIONS["UPPER"]: + case self::SELECT_FUNCTIONS["LOWER"]: + case self::SELECT_FUNCTIONS["LENGTH"]: + case self::SELECT_FUNCTIONS["SUM"]: + case self::SELECT_FUNCTIONS["MIN"]: + case self::SELECT_FUNCTIONS["MAX"]: + case self::SELECT_FUNCTIONS["AVG"]: + if(!is_string($functionParameters)){ + $type = gettype($functionParameters); + $errorMsg = "When using the select function \"$function\" the parameter " + ."has to be a string, got $type."; + throw new InvalidArgumentException($errorMsg); + } + return [$functionParameters, null]; + case self::SELECT_FUNCTIONS["CONCAT"]: + if(!is_array($functionParameters) || count($functionParameters) < 3){ + $type = gettype($functionParameters); + $length = (is_array($functionParameters)) ? count($functionParameters) : 0; + $errorMsg = "When using the select function \"$function\" the parameter " + ."has to be an array with length > 3, got $type with length $length"; + throw new InvalidArgumentException($errorMsg); + } + list($glue) = $functionParameters; + unset($functionParameters[array_keys($functionParameters)[0]]); + + return [$functionParameters, $glue]; + default: + throw new InvalidArgumentException("The given select function \"$function\" is not supported."); + } + } + +} \ No newline at end of file diff --git a/core/class/sleekDB/Classes/DocumentUpdater.php b/core/class/sleekDB/Classes/DocumentUpdater.php new file mode 100644 index 00000000..20fd98c2 --- /dev/null +++ b/core/class/sleekDB/Classes/DocumentUpdater.php @@ -0,0 +1,159 @@ +storePath = $storePath; + $this->primaryKey = $primaryKey; + } + + /** + * Update one or multiple documents, based on current query + * @param array $results + * @param array $updatable + * @param bool $returnUpdatedDocuments + * @return array|bool + * @throws IOException + */ + public function updateResults(array $results, array $updatable, bool $returnUpdatedDocuments) + { + if(count($results) === 0) { + return false; + } + + $primaryKey = $this->primaryKey; + $dataPath = $this->getDataPath(); + // check if all documents exist beforehand + foreach ($results as $key => $data) { + $primaryKeyValue = IoHelper::secureStringForFileAccess($data[$primaryKey]); + $data[$primaryKey] = (int) $primaryKeyValue; + $results[$key] = $data; + + $filePath = $dataPath . $primaryKeyValue . '.json'; + if(!file_exists($filePath)){ + return false; + } + } + + foreach ($results as $key => $data){ + $filePath = $dataPath . $data[$primaryKey] . '.json'; + foreach ($updatable as $fieldName => $value) { + // Do not update the primary key reserved index of a store. + if ($fieldName !== $primaryKey) { + NestedHelper::updateNestedValue($fieldName, $data, $value); + } + } + IoHelper::writeContentToFile($filePath, json_encode($data)); + $results[$key] = $data; + } + return ($returnUpdatedDocuments === true) ? $results : true; + } + + /** + * Deletes matched store objects. + * @param array $results + * @param int $returnOption + * @return bool|array|int + * @throws IOException + * @throws InvalidArgumentException + */ + public function deleteResults(array $results, int $returnOption) + { + $primaryKey = $this->primaryKey; + $dataPath = $this->getDataPath(); + switch ($returnOption){ + case Query::DELETE_RETURN_BOOL: + $returnValue = !empty($results); + break; + case Query::DELETE_RETURN_COUNT: + $returnValue = count($results); + break; + case Query::DELETE_RETURN_RESULTS: + $returnValue = $results; + break; + default: + throw new InvalidArgumentException("Return option \"$returnOption\" is not supported"); + } + + if (empty($results)) { + return $returnValue; + } + + // TODO implement beforehand check + + foreach ($results as $key => $data) { + $primaryKeyValue = IoHelper::secureStringForFileAccess($data[$primaryKey]); + $filePath = $dataPath . $primaryKeyValue . '.json'; + if(false === IoHelper::deleteFile($filePath)){ + throw new IOException( + 'Unable to delete document! + Already deleted documents: '.$key.'. + Location: "' . $filePath .'"' + ); + } + } + return $returnValue; + } + + /** + * @param array $results + * @param array $fieldsToRemove + * @return array|false + * @throws IOException + */ + public function removeFields(array &$results, array $fieldsToRemove) + { + $primaryKey = $this->primaryKey; + $dataPath = $this->getDataPath(); + + // check if all documents exist beforehand + foreach ($results as $key => $data) { + $primaryKeyValue = IoHelper::secureStringForFileAccess($data[$primaryKey]); + $data[$primaryKey] = $primaryKeyValue; + $results[$key] = $data; + + $filePath = $dataPath . $primaryKeyValue . '.json'; + if(!file_exists($filePath)){ + return false; + } + } + + foreach ($results as &$document){ + foreach ($fieldsToRemove as $fieldToRemove){ + if($fieldToRemove !== $primaryKey){ + NestedHelper::removeNestedField($document, $fieldToRemove); + } + } + $filePath = $dataPath . $document[$primaryKey] . '.json'; + IoHelper::writeContentToFile($filePath, json_encode($document)); + } + return $results; + } + + /** + * @return string + */ + private function getDataPath(): string + { + return $this->storePath . Store::dataDirectory; + } + +} \ No newline at end of file diff --git a/core/class/sleekDB/Classes/IoHelper.php b/core/class/sleekDB/Classes/IoHelper.php new file mode 100644 index 00000000..376b53b5 --- /dev/null +++ b/core/class/sleekDB/Classes/IoHelper.php @@ -0,0 +1,251 @@ +isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + return rmdir($folderPath); + } + + /** + * @param string $folderPath + * @throws IOException + */ + public static function createFolder(string $folderPath){ + // We don't need to create a folder if it already exists. + if(file_exists($folderPath) === true){ + return; + } + self::checkWrite($folderPath); + // Check if the data_directory exists or create one. + if (!file_exists($folderPath) && !mkdir($folderPath, 0777, true) && !is_dir($folderPath)) { + throw new IOException( + 'Unable to create the a directory at ' . $folderPath + ); + } + } + + /** + * @param string $filePath + * @param Closure $updateContentFunction Has to return a string or an array that will be encoded to json. + * @return string + * @throws IOException + * @throws JsonException + */ + public static function updateFileContent(string $filePath, Closure $updateContentFunction): string + { + self::checkRead($filePath); + self::checkWrite($filePath); + + $content = false; + + $fp = fopen($filePath, 'rb'); + if(flock($fp, LOCK_SH)){ + $content = stream_get_contents($fp); + } + flock($fp, LOCK_UN); + fclose($fp); + + if($content === false){ + throw new IOException("Could not get shared lock for file: $filePath"); + } + + $content = $updateContentFunction($content); + + if(!is_string($content)){ + $encodedContent = json_encode($content); + if($encodedContent === false){ + $content = (!is_object($content) && !is_array($content) && !is_null($content)) ? $content : gettype($content); + throw new JsonException("Could not encode content with json_encode. Content: \"$content\"."); + } + $content = $encodedContent; + } + + + if(file_put_contents($filePath, $content, LOCK_EX) === false){ + throw new IOException("Could not write content to file. Please check permissions at: $filePath"); + } + + + return $content; + } + + /** + * @param string $filePath + * @return bool + */ + public static function deleteFile(string $filePath): bool + { + + if(false === file_exists($filePath)){ + return true; + } + try{ + self::checkWrite($filePath); + }catch(Exception $exception){ + return false; + } + + return (@unlink($filePath) && !file_exists($filePath)); + } + + /** + * @param array $filePaths + * @return bool + */ + public static function deleteFiles(array $filePaths): bool + { + foreach ($filePaths as $filePath){ + // if a file does not exist, we do not need to delete it. + if(true === file_exists($filePath)){ + try{ + self::checkWrite($filePath); + if(false === @unlink($filePath) || file_exists($filePath)){ + return false; + } + } catch (Exception $exception){ + // TODO trigger a warning or exception + return false; + } + } + } + return true; + } + + /** + * Strip string for secure file access. + * @param string $string + * @return string + */ + public static function secureStringForFileAccess(string $string): string + { + return (str_replace(array(".", "/", "\\"), "", $string)); + } + + /** + * Appends a slash ("/") to the given directory path if there is none. + * @param string $directory + */ + public static function normalizeDirectory(string &$directory){ + if(!empty($directory) && substr($directory, -1) !== "/") { + $directory .= "/"; + } + } + + /** + * Returns the amount of files in folder. + * @param string $folder + * @return int + * @throws IOException + */ + public static function countFolderContent(string $folder): int + { + self::checkRead($folder); + $fi = new \FilesystemIterator($folder, \FilesystemIterator::SKIP_DOTS); + return iterator_count($fi); + } +} \ No newline at end of file diff --git a/core/class/sleekDB/Classes/NestedHelper.php b/core/class/sleekDB/Classes/NestedHelper.php new file mode 100644 index 00000000..6c1979fc --- /dev/null +++ b/core/class/sleekDB/Classes/NestedHelper.php @@ -0,0 +1,140 @@ + 1){ + $data = self::_updateNestedValueHelper($fieldNameArray, $data, $newValue, count($fieldNameArray)); + return; + } + $data[$fieldNameArray[0]] = $value; + } + + public static function createNestedArray(string $fieldName, $fieldValue): array + { + $temp = []; + $fieldNameArray = explode('.', $fieldName); + $fieldNameArrayReverse = array_reverse($fieldNameArray); + foreach ($fieldNameArrayReverse as $index => $i) { + if($index === 0){ + $temp = array($i => $fieldValue); + } else { + $temp = array($i => $temp); + } + } + + return $temp; + } + + public static function removeNestedField(array &$document, string $fieldToRemove){ + if (array_key_exists($fieldToRemove, $document)) { + unset($document[$fieldToRemove]); + return; + } + // should be a nested array at this point + $temp = &$document; + $fieldNameArray = explode('.', $fieldToRemove); + $fieldNameArrayCount = count($fieldNameArray); + foreach ($fieldNameArray as $index => $i) { + // last iteration + if(($fieldNameArrayCount - 1) === $index){ + if(is_array($temp) && array_key_exists($i, $temp)) { + unset($temp[$i]); + } + break; + } + if(!is_array($temp) || !array_key_exists($i, $temp)){ + break; + } + $temp = &$temp[$i]; + } + } + + /** + * @param array $keysArray + * @param $data + * @param $newValue + * @param int $originalKeySize + * @return mixed + */ + private static function _updateNestedValueHelper(array $keysArray, $data, $newValue, int $originalKeySize) + { + if(empty($keysArray)){ + return $newValue; + } + $currentKey = $keysArray[0]; + $result = (is_array($data)) ? $data : []; + if(!is_array($data) || !array_key_exists($currentKey, $data)){ + $result[$currentKey] = self::_updateNestedValueHelper(array_slice($keysArray, 1), $data, $newValue, $originalKeySize); + if(count($keysArray) !== $originalKeySize){ + return $result; + } + } + $result[$currentKey] = self::_updateNestedValueHelper(array_slice($keysArray, 1), $data[$currentKey], $newValue, $originalKeySize); + return $result; + } +} \ No newline at end of file diff --git a/core/class/sleekDB/Exceptions/IOException.php b/core/class/sleekDB/Exceptions/IOException.php new file mode 100644 index 00000000..cb634625 --- /dev/null +++ b/core/class/sleekDB/Exceptions/IOException.php @@ -0,0 +1,7 @@ + 1, + "hits_prioritize" => 2, + "prioritize" => 3, + "prioritize_position" => 4, + ]; + + /** + * @var CacheHandler|null + */ + protected $cacheHandler; + + /** + * @var DocumentFinder + */ + protected $documentFinder; + + /** + * @var DocumentUpdater + */ + protected $documentUpdater; + + /** + * Query constructor. + * @param QueryBuilder $queryBuilder + */ + public function __construct(QueryBuilder $queryBuilder) + { + $store = $queryBuilder->_getStore(); + $primaryKey = $store->getPrimaryKey(); + + $this->cacheHandler = new CacheHandler($store->getStorePath(), $queryBuilder); + $this->documentFinder = new DocumentFinder($store->getStorePath(), $queryBuilder->_getConditionProperties(), $primaryKey); + $this->documentUpdater = new DocumentUpdater($store->getStorePath(), $primaryKey); + } + + /** + * Execute query and get results. + * @return array + * @throws InvalidArgumentException + * @throws IOException + */ + public function fetch(): array + { + return $this->getResults(); + } + + /** + * Check if data is found. + * @return bool + * @throws InvalidArgumentException + * @throws IOException + */ + public function exists(): bool + { + // Return boolean on data exists check. + return !empty($this->first()); + } + + /** + * Return the first document. + * @return array empty array or single document + * @throws InvalidArgumentException + * @throws IOException + */ + public function first(): array + { + return $this->getResults(true); + } + + /** + * Update parts of one or multiple documents based on current query. + * @param array $updatable + * @param bool $returnUpdatedDocuments + * @return array|bool + * @throws InvalidArgumentException + * @throws IOException + */ + public function update(array $updatable, bool $returnUpdatedDocuments = false){ + + if(empty($updatable)){ + throw new InvalidArgumentException("You have to define what you want to update."); + } + + $results = $this->documentFinder->findDocuments(false, false); + + $this->getCacheHandler()->deleteAllWithNoLifetime(); + + return $this->documentUpdater->updateResults($results, $updatable, $returnUpdatedDocuments); + } + + /** + * Delete one or multiple documents based on current query. + * @param int $returnOption + * @return bool|array|int + * @throws InvalidArgumentException + * @throws IOException + */ + public function delete(int $returnOption = self::DELETE_RETURN_BOOL){ + $results = $this->documentFinder->findDocuments(false, false); + + $this->getCacheHandler()->deleteAllWithNoLifetime(); + + return $this->documentUpdater->deleteResults($results, $returnOption); + } + + /** + * Remove fields of one or multiple documents based on current query. + * @param array $fieldsToRemove + * @return array|false + * @throws IOException + * @throws InvalidArgumentException + */ + public function removeFields(array $fieldsToRemove) + { + if(empty($fieldsToRemove)){ + throw new InvalidArgumentException("You have to define what fields you want to remove."); + } + $results = $this->documentFinder->findDocuments(false, false); + + $this->getCacheHandler()->deleteAllWithNoLifetime(); + + return $this->documentUpdater->removeFields($results, $fieldsToRemove); + } + + /** + * Retrieve Cache object. + * @return Cache + */ + public function getCache(): Cache + { + return $this->getCacheHandler()->getCache(); + } + + /** + * Retrieve the results from either the cache or store. + * @param bool $getOneDocument + * @return array + * @throws IOException + * @throws InvalidArgumentException + */ + private function getResults(bool $getOneDocument = false): array + { + + $results = $this->getCacheHandler()->getCacheContent($getOneDocument); + + if($results !== null) { + return $results; + } + + $results = $this->documentFinder->findDocuments($getOneDocument, true); + + if ($getOneDocument === true && count($results) > 0) { + list($item) = $results; + $results = $item; + } + + $this->getCacheHandler()->setCacheContent($results); + + return $results; + } + + /** + * Retrieve the caching layer bridge. + * @return CacheHandler + */ + private function getCacheHandler(): CacheHandler + { + return $this->cacheHandler; + } +} \ No newline at end of file diff --git a/core/class/sleekDB/QueryBuilder.php b/core/class/sleekDB/QueryBuilder.php new file mode 100644 index 00000000..d9f711fd --- /dev/null +++ b/core/class/sleekDB/QueryBuilder.php @@ -0,0 +1,516 @@ + 2, + "scoreKey" => "searchScore", + "mode" => "or", + "algorithm" => Query::SEARCH_ALGORITHM["hits"] + ]; + + protected $fieldsToSelect = []; + protected $fieldsToExclude = []; + protected $groupBy = []; + protected $havingConditions = []; + + protected $listOfJoins = []; + protected $distinctFields = []; + + protected $useCache; + protected $regenerateCache = false; + protected $cacheLifetime; + + + // will also not be used for cache token + protected $propertiesNotUsedInConditionsArray = [ + "propertiesNotUsedInConditionsArray", + "propertiesNotUsedForCacheToken", + "store", + "cache", + ]; + + protected $propertiesNotUsedForCacheToken = [ + "useCache", + "regenerateCache", + "cacheLifetime" + ]; + + /** + * QueryBuilder constructor. + * @param Store $store + */ + public function __construct(Store $store) + { + $this->store = $store; + $this->useCache = $store->_getUseCache(); + $this->cacheLifetime = $store->_getDefaultCacheLifetime(); + $this->searchOptions = $store->_getSearchOptions(); + } + + /** + * Select specific fields + * @param array $fieldNames + * @return QueryBuilder + */ + public function select(array $fieldNames): QueryBuilder + { + foreach ($fieldNames as $key => $fieldName) { + if(is_string($key)){ + $this->fieldsToSelect[$key] = $fieldName; + } else { + $this->fieldsToSelect[] = $fieldName; + } + } + return $this; + } + + /** + * Exclude specific fields + * @param string[] $fieldNames + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function except(array $fieldNames): QueryBuilder + { + $errorMsg = "If except is used an array containing strings with fieldNames has to be given"; + foreach ($fieldNames as $fieldName) { + if (empty($fieldName)) { + continue; + } + if (!is_string($fieldName)) { + throw new InvalidArgumentException($errorMsg); + } + $this->fieldsToExclude[] = $fieldName; + } + return $this; + } + + /** + * Add conditions to filter data. + * @param array $conditions + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function where(array $conditions): QueryBuilder + { + if (empty($conditions)) { + throw new InvalidArgumentException("You need to specify a where clause"); + } + + $this->whereConditions[] = $conditions; + + return $this; + } + + /** + * Add or-where conditions to filter data. + * @param array $conditions array(array(string fieldName, string condition, mixed value) [, array(...)]) + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function orWhere(array $conditions): QueryBuilder + { + + if (empty($conditions)) { + throw new InvalidArgumentException("You need to specify a where clause"); + } + + $this->whereConditions[] = "or"; + $this->whereConditions[] = $conditions; + + return $this; + } + + /** + * Set the amount of data record to skip. + * @param int|string $skip + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function skip($skip = 0): QueryBuilder + { + if((!is_string($skip) || !is_numeric($skip)) && !is_int($skip)){ + throw new InvalidArgumentException("Skip has to be an integer or a numeric string"); + } + + if(!is_int($skip)){ + $skip = (int) $skip; + } + + if($skip < 0){ + throw new InvalidArgumentException("Skip has to be an integer >= 0"); + } + + $this->skip = $skip; + + return $this; + } + + /** + * Set the amount of data record to limit. + * @param int|string $limit + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function limit($limit = 0): QueryBuilder + { + + if((!is_string($limit) || !is_numeric($limit)) && !is_int($limit)){ + throw new InvalidArgumentException("Limit has to be an integer or a numeric string"); + } + + if(!is_int($limit)){ + $limit = (int) $limit; + } + + if($limit <= 0){ + throw new InvalidArgumentException("Limit has to be an integer > 0"); + } + + $this->limit = $limit; + + return $this; + } + + /** + * Set the sort order. + * @param array $criteria to order by. array($fieldName => $order). $order can be "asc" or "desc" + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function orderBy( array $criteria): QueryBuilder + { + foreach ($criteria as $fieldName => $order){ + + if(!is_string($order)) { + throw new InvalidArgumentException('Order has to be a string! Please use "asc" or "desc" only.'); + } + + $order = strtolower($order); + + if(!is_string($fieldName)) { + throw new InvalidArgumentException("Field name has to be a string"); + } + + if (!in_array($order, ['asc', 'desc'])) { + throw new InvalidArgumentException('Please use "asc" or "desc" only.'); + } + + $this->orderBy[] = [ + 'fieldName' => $fieldName, + 'order' => $order + ]; + } + + return $this; + } + + /** + * Do a fulltext like search against one or multiple fields. + * @param string|array $fields one or multiple fieldNames as an array + * @param string $query + * @param array $options + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function search($fields, string $query, array $options = []): QueryBuilder + { + if(!is_array($fields) && !is_string($fields)){ + throw new InvalidArgumentException("Fields to search through have to be either a string or an array."); + } + + if(!is_array($fields)){ + $fields = (array)$fields; + } + + if (empty($fields)) { + throw new InvalidArgumentException('Cant perform search due to no field name was provided'); + } + + if(count($fields) > 100){ + trigger_error('Searching through more than 100 fields is not recommended and can be resource heavy.', E_USER_WARNING); + } + + if (!empty($query)) { + $this->search = [ + 'fields' => $fields, + 'query' => $query + ]; + if(!empty($options)){ + if(array_key_exists("minLength", $options) && is_int($options["minLength"]) && $options["minLength"] > 0){ + $this->searchOptions["minLength"] = $options["minLength"]; + } + if(array_key_exists("mode", $options) && is_string($options["mode"])){ + $searchMode = strtolower(trim($options["mode"])); + if(in_array($searchMode, ["and", "or"])){ + $this->searchOptions["mode"] = $searchMode; + } + } + if(array_key_exists("scoreKey", $options) && (is_string($options["scoreKey"]) || is_null($options["scoreKey"]))){ + $this->searchOptions["scoreKey"] = $options["scoreKey"]; + } + if(array_key_exists("algorithm", $options) && in_array($options["algorithm"], Query::SEARCH_ALGORITHM, true)){ + $this->searchOptions["algorithm"] = $options["algorithm"]; + } + } + } + return $this; + } + + /** + * @param Closure $joinFunction + * @param string $propertyName + * @return QueryBuilder + */ + public function join(Closure $joinFunction, string $propertyName): QueryBuilder + { + $this->listOfJoins[] = [ + 'propertyName' => $propertyName, + 'joinFunction' => $joinFunction + ]; + return $this; + } + + + /** + * Return distinct values. + * @param array|string $fields + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function distinct($fields = []): QueryBuilder + { + $fieldType = gettype($fields); + if ($fieldType === 'array') { + if ($fields === array_values($fields)) { + // Append fields. + $this->distinctFields = array_merge($this->distinctFields, $fields); + } else { + throw new InvalidArgumentException( + 'Field value in distinct() method can not be an associative array, + please provide a string or a list of string as a non-associative array.' + ); + } + } else if ($fieldType === 'string' && !empty($fields)) { + $this->distinctFields[] = trim($fields); + } else { + throw new InvalidArgumentException( + 'Field value in distinct() is invalid.' + ); + } + return $this; + } + + /** + * Use caching for current query + * @param null|int $lifetime time to live as int in seconds or null to regenerate cache on every insert, update and delete + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function useCache(int $lifetime = null): QueryBuilder + { + $this->useCache = true; + if((!is_int($lifetime) || $lifetime < 0) && !is_null($lifetime)){ + throw new InvalidArgumentException("lifetime has to be int >= 0 or null"); + } + $this->cacheLifetime = $lifetime; + return $this; + } + + /** + * Disable cache for the query. + * @return QueryBuilder + */ + public function disableCache(): QueryBuilder + { + $this->useCache = false; + return $this; + } + + /** + * Re-generate the cache for the query. + * @return QueryBuilder + */ + public function regenerateCache(): QueryBuilder + { + $this->regenerateCache = true; + return $this; + } + + /** + * @return Query + */ + public function getQuery(): Query + { + return new Query($this); + } + + /** + * @param array $groupByFields + * @param string|null $countKeyName + * @param bool $allowEmpty + * @return QueryBuilder + */ + public function groupBy(array $groupByFields, string $countKeyName = null, bool $allowEmpty = false): QueryBuilder + { + $this->groupBy = [ + "groupByFields" => $groupByFields, + "countKeyName" => $countKeyName, + "allowEmpty" => $allowEmpty + ]; + return $this; + } + + /** + * Filter result data of groupBy + * @param array $criteria + * @return QueryBuilder + * @throws InvalidArgumentException + */ + public function having(array $criteria): QueryBuilder + { + if (empty($criteria)) { + throw new InvalidArgumentException("You need to specify a having clause"); + } + $this->havingConditions = $criteria; + return $this; + } + + /** + * Returns a an array used to generate a unique token for the current query. + * @return array + */ + public function _getCacheTokenArray(): array + { + $properties = []; + $conditionsArray = $this->_getConditionProperties(); + + foreach ($conditionsArray as $propertyName => $propertyValue){ + if(!in_array($propertyName, $this->propertiesNotUsedForCacheToken, true)){ + $properties[$propertyName] = $propertyValue; + } + } + + return $properties; + } + + /** + * Returns an array containing all information needed to execute an query. + * @return array + */ + public function _getConditionProperties(): array + { + $allProperties = get_object_vars($this); + $properties = []; + + foreach ($allProperties as $propertyName => $propertyValue){ + if(!in_array($propertyName, $this->propertiesNotUsedInConditionsArray, true)){ + $properties[$propertyName] = $propertyValue; + } + } + + return $properties; + } + + /** + * Returns the Store object used to create the QueryBuilder object. + * @return Store + */ + public function _getStore(): Store{ + return $this->store; + } + + /** + * Add "in" condition to filter data. + * @param string $fieldName + * @param array $values + * @return QueryBuilder + * @throws InvalidArgumentException + * @deprecated since version 2.4, use where and orWhere instead. + */ + public function in(string $fieldName, array $values = []): QueryBuilder + { + if (empty($fieldName)) { + throw new InvalidArgumentException('Field name for in clause can not be empty.'); + } + + // Add to conditions with "AND" operation + $this->whereConditions[] = [$fieldName, "in", $values]; + return $this; + } + + /** + * Add "not in" condition to filter data. + * @param string $fieldName + * @param array $values + * @return QueryBuilder + * @throws InvalidArgumentException + * @deprecated since version 2.4, use where and orWhere instead. + */ + public function notIn(string $fieldName, array $values = []): QueryBuilder + { + if (empty($fieldName)) { + throw new InvalidArgumentException('Field name for notIn clause can not be empty.'); + } + + // Add to conditions with "AND" operation + $this->whereConditions[] = [$fieldName, "not in", $values]; + return $this; + } + + /** + * Add a where statement that is nested. ( $x or ($y and $z) ) + * @param array $conditions + * @return QueryBuilder + * @throws InvalidArgumentException + * @deprecated since version 2.3, use where or orWhere instead. + */ + public function nestedWhere(array $conditions): QueryBuilder + { + // TODO remove with version 3.0 + if(empty($conditions)){ + throw new InvalidArgumentException("You need to specify nested where clauses"); + } + + if(count($conditions) > 1){ + throw new InvalidArgumentException("You are not allowed to specify multiple elements at the first depth!"); + } + + $outerMostOperation = (array_keys($conditions))[0]; + $outerMostOperation = (is_string($outerMostOperation)) ? strtolower($outerMostOperation) : $outerMostOperation; + + $allowedOuterMostOperations = [0, "and", "or"]; + + if(!in_array($outerMostOperation, $allowedOuterMostOperations, true)){ + throw new InvalidArgumentException("Outer most operation has to one of the following: ( 0 / and / or ) "); + } + + $this->nestedWhere = $conditions; + + return $this; + } + +} diff --git a/core/class/sleekDB/SleekDB.php b/core/class/sleekDB/SleekDB.php new file mode 100644 index 00000000..76ef66eb --- /dev/null +++ b/core/class/sleekDB/SleekDB.php @@ -0,0 +1,578 @@ +init($storeName, $dataDir, $configuration); + } + + /** + * Initialize the SleekDB instance. + * @param string $storeName + * @param string $dataDir + * @param array $conf + * @throws InvalidArgumentException + * @throws IOException + * @throws InvalidConfigurationException + */ + public function init(string $storeName, string $dataDir, array $conf = []){ + $this->setStore(new Store($storeName, $dataDir, $conf)); + $this->setQueryBuilder($this->getStore()->createQueryBuilder()); + } + + /** + * Initialize the store. + * @param string $storeName + * @param string $dataDir + * @param array $configuration + * @return SleekDB + * @throws InvalidArgumentException + * @throws IOException + * @throws InvalidConfigurationException + */ + public static function store(string $storeName, string $dataDir, array $configuration = []): SleekDB + { + return new SleekDB($storeName, $dataDir, $configuration); + } + + /** + * Execute Query and get Results + * @return array + * @throws InvalidArgumentException + * @throws IOException + */ + public function fetch(): array + { + return $this->getQuery()->fetch(); + } + + /** + * Check if data is found + * @return bool + * @throws InvalidArgumentException + * @throws IOException + */ + public function exists(): bool + { + return $this->getQuery()->exists(); + } + + /** + * Return the first document. + * @return array + * @throws InvalidArgumentException + * @throws IOException + */ + public function first(): array + { + return $this->getQuery()->first(); + } + + /** + * Creates a new object in the store. + * It is stored as a plaintext JSON document. + * @param array $storeData + * @return array + * @throws IOException + * @throws IdNotAllowedException + * @throws InvalidArgumentException + * @throws JsonException + */ + public function insert(array $storeData): array + { + return $this->getStore()->insert($storeData); + } + + /** + * Creates multiple objects in the store. + * @param array $storeData + * @return array + * @throws IOException + * @throws IdNotAllowedException + * @throws InvalidArgumentException + * @throws JsonException + */ + public function insertMany(array $storeData): array + { + return $this->getStore()->insertMany($storeData); + } + + /** + * Update one or multiple documents, based on current query + * @param array $updatable + * @return bool + * @throws InvalidArgumentException + * @throws IOException + */ + public function update(array $updatable): bool + { + return $this->getQuery()->update($updatable); + } + + /** + * Deletes matched store objects. + * @param int $returnOption + * @return bool|array|int + * @throws InvalidArgumentException + * @throws IOException + */ + public function delete(int $returnOption = Query::DELETE_RETURN_BOOL){ + return $this->getQuery()->delete($returnOption); + } + + /** + * Deletes a store and wipes all the data and cache it contains. + * @return bool + * @throws IOException + */ + public function deleteStore(): bool + { + return $this->getStore()->deleteStore(); + } + + /** + * This method would make a unique token for the current query. + * We would use this hash token as the id/name of the cache file. + * @return string + */ + public function getCacheToken(): string + { + return $this->getQueryBuilder()->getQuery()->getCache()->getToken(); + } + + /** + * Select specific fields + * @param string[] $fieldNames + * @return SleekDB + */ + public function select(array $fieldNames): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->select($fieldNames)); + return $this; + } + + /** + * Exclude specific fields + * @param string[] $fieldNames + * @return SleekDB + * @throws InvalidArgumentException + */ + public function except(array $fieldNames): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->except($fieldNames)); + return $this; + } + + /** + * Add conditions to filter data. + * @param string|array|mixed ...$conditions (string fieldName, string condition, mixed value) OR (array(array(string fieldName, string condition, mixed value)[, array(...)])) + * @return SleekDB + * @throws InvalidArgumentException + */ + public function where(...$conditions): SleekDB + { + foreach ($conditions as $key => $arg) { + if ($key > 0) { + throw new InvalidArgumentException("Allowed: (string fieldName, string condition, mixed value) OR (array(array(string fieldName, string condition, mixed value)[, array(...)]))"); + } + if (is_array($arg)) { + // parameters given as arrays for multiple "where" with "and" between each condition + $this->setQueryBuilder($this->getQueryBuilder()->where($arg)); + break; + } + if (count($conditions) === 3) { + // parameters given as (string fieldName, string condition, mixed value) for a single "where" + $this->setQueryBuilder($this->getQueryBuilder()->where($conditions)); + break; + } + } + + return $this; + } + + /** + * Add "in" condition to filter data. + * @param string $fieldName + * @param array $values + * @return SleekDB + * @throws InvalidArgumentException + */ + public function in(string $fieldName, array $values = []): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->in($fieldName, $values)); + return $this; + } + + /** + * Add "not in" condition to filter data. + * @param string $fieldName + * @param array $values + * @return SleekDB + * @throws InvalidArgumentException + */ + public function notIn(string $fieldName, array $values = []): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->notIn($fieldName, $values)); + return $this; + } + + /** + * Add or-where conditions to filter data. + * @param string|array|mixed ...$conditions (string fieldName, string condition, mixed value) OR array(array(string fieldName, string condition, mixed value) [, array(...)]) + * @return SleekDB + * @throws InvalidArgumentException + */ + public function orWhere(...$conditions): SleekDB + { + foreach ($conditions as $key => $arg) { + if ($key > 0) { + throw new InvalidArgumentException("Allowed: (string fieldName, string condition, mixed value) OR array(array(string fieldName, string condition, mixed value) [, array(...)])"); + } + if (is_array($arg)) { + // parameters given as arrays for an "or where" with "and" between each condition + $this->setQueryBuilder($this->getQueryBuilder()->orWhere($arg)); + break; + } + if (count($conditions) === 3) { + // parameters given as (string fieldName, string condition, mixed value) for a single "or where" + $this->setQueryBuilder($this->getQueryBuilder()->orWhere($conditions)); + break; + } + } + + return $this; + } + + /** + * Set the amount of data record to skip. + * @param int $skip + * @return SleekDB + * @throws InvalidArgumentException + */ + public function skip(int $skip = 0): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->skip($skip)); + return $this; + } + + /** + * Set the amount of data record to limit. + * @param int $limit + * @return SleekDB + * @throws InvalidArgumentException + */ + public function limit(int $limit = 0): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->limit($limit)); + return $this; + } + + /** + * Set the sort order. + * @param string $order "asc" or "desc" + * @param string $orderBy + * @return SleekDB + * @throws InvalidArgumentException + */ + public function orderBy(string $order, string $orderBy = '_id'): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->orderBy([$orderBy => $order])); + return $this; + } + + /** + * Do a fulltext like search against more than one field. + * @param string|array $field one fieldName or multiple fieldNames as an array + * @param string $keyword + * @return SleekDB + * @throws InvalidArgumentException + */ + public function search($field, string $keyword): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->search($field, $keyword)); + return $this; + } + + /** + * @param Closure $joinedStore + * @param string $dataPropertyName + * @return SleekDB + */ + public function join(Closure $joinedStore, string $dataPropertyName): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->join($joinedStore, $dataPropertyName)); + return $this; + } + + /** + * Re-generate the cache for the query. + * @return SleekDB + */ + public function makeCache(): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->regenerateCache()); + return $this; + } + + /** + * Disable cache for the query. + * @return SleekDB + */ + public function disableCache(): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->disableCache()); + return $this; + } + + /** + * Use caching for current query + * @param int|null $lifetime time to live as int in seconds or null to regenerate cache on every insert, update and delete + * @return SleekDB + * @throws InvalidArgumentException + */ + public function useCache(int $lifetime = null): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->useCache($lifetime)); + return $this; + } + + /** + * Delete cache file/s for current query. + * @return SleekDB + */ + public function deleteCache(): SleekDB + { + $this->getQueryBuilder()->getQuery()->getCache()->delete(); + return $this; + } + + /** + * Delete all cache files for current store. + * @return SleekDB + */ + public function deleteAllCache(): SleekDB + { + $this->getQueryBuilder()->getQuery()->getCache()->deleteAll(); + return $this; + } + + /** + * Keep the active query conditions. + * @return SleekDB + */ + public function keepConditions(): SleekDB + { + $this->shouldKeepConditions = true; + return $this; + } + + /** + * Return distinct values. + * @param array|string $fields + * @return SleekDB + * @throws InvalidArgumentException + */ + public function distinct($fields = []): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->distinct($fields)); + return $this; + } + + /** + * @return QueryBuilder + */ + public function getQueryBuilder(): QueryBuilder + { + return $this->queryBuilder; + } + + /** + * @param QueryBuilder $queryBuilder + */ + private function setQueryBuilder(QueryBuilder $queryBuilder){ + $this->queryBuilder = $queryBuilder; + } + + /** + * @return Query + */ + public function getQuery(): Query + { + $query = $this->getQueryBuilder()->getQuery(); + $this->resetQueryBuilder(); + return $query; + } + + /** + * @return Cache + */ + public function getCache(): Cache + { + // we do not want to reset the QueryBuilder + return $this->getQueryBuilder()->getQuery()->getCache(); + } + + + /** + * @param Store $store + */ + private function setStore(Store $store){ + $this->store = $store; + } + + /** + * @return Store + */ + public function getStore(): Store + { + return $this->store; + } + + /** + * Handle shouldKeepConditions and reset queryBuilder accordingly + */ + private function resetQueryBuilder(){ + if($this->shouldKeepConditions === true) { + return; + } + $this->setQueryBuilder($this->getStore()->createQueryBuilder()); + } + + + /** + * Retrieve all documents. + * @return array + * @throws IOException + * @throws InvalidArgumentException + */ + public function findAll(): array + { + return $this->getStore()->findAll(); + } + + /** + * Retrieve one document by its _id. Very fast because it finds the document by its file path. + * @param int $id + * @return array|null + * @throws InvalidArgumentException + */ + public function findById(int $id){ + return $this->getStore()->findById($id); + } + + /** + * Retrieve one or multiple documents. + * @param array $criteria + * @param array $orderBy + * @param int $limit + * @param int $offset + * @return array + * @throws IOException + * @throws InvalidArgumentException + */ + public function findBy(array $criteria, array $orderBy = null, int $limit = null, int $offset = null): array + { + return $this->getStore()->findBy($criteria, $orderBy, $limit, $offset); + } + + /** + * Retrieve one document. + * @param array $criteria + * @return array|null single document or NULL if no document can be found + * @throws IOException + * @throws InvalidArgumentException + */ + public function findOneBy(array $criteria) + { + return $this->getStore()->findOneBy($criteria); + } + + /** + * Update one or multiple documents. + * @param array $updatable true if all documents could be updated and false if one document did not exist + * @return bool + * @throws IOException + * @throws InvalidArgumentException + */ + public function updateBy(array $updatable): bool + { + return $this->getStore()->update($updatable); + } + + /** + * Delete one or multiple documents. + * @param $criteria + * @param int $returnOption + * @return array|bool|int + * @throws IOException + * @throws InvalidArgumentException + */ + public function deleteBy($criteria, $returnOption = Query::DELETE_RETURN_BOOL){ + return $this->getStore()->deleteBy($criteria, $returnOption); + } + + /** + * Delete one document by its _id. Very fast because it deletes the document by its file path. + * @param $id + * @return bool true if document does not exist or deletion was successful, false otherwise + * @throws IOException + */ + public function deleteById($id): bool + { + return $this->getStore()->deleteById($id); + } + + /** + * Add a where statement that is nested. ( $x or ($y and $z) ) + * @param array $conditions + * @return $this + * @throws InvalidArgumentException + * @deprecated since version 2.3, use where and orWhere instead. + */ + public function nestedWhere(array $conditions): SleekDB + { + $this->setQueryBuilder($this->getQueryBuilder()->nestedWhere($conditions)); + return $this; + } + +} diff --git a/core/class/sleekDB/Store.php b/core/class/sleekDB/Store.php new file mode 100644 index 00000000..9ce2492c --- /dev/null +++ b/core/class/sleekDB/Store.php @@ -0,0 +1,888 @@ + 2, + "scoreKey" => "searchScore", + "mode" => "or", + "algorithm" => Query::SEARCH_ALGORITHM["hits"] + ]; + + const dataDirectory = "data/"; + + /** + * Store constructor. + * @param string $storeName + * @param string $databasePath + * @param array $configuration + * @throws InvalidArgumentException + * @throws IOException + * @throws InvalidConfigurationException + */ + public function __construct(string $storeName, string $databasePath, array $configuration = []) + { + $storeName = trim($storeName); + if (empty($storeName)) { + throw new InvalidArgumentException('store name can not be empty'); + } + $this->storeName = $storeName; + + $databasePath = trim($databasePath); + if (empty($databasePath)) { + throw new InvalidArgumentException('data directory can not be empty'); + } + + IoHelper::normalizeDirectory($databasePath); + $this->databasePath = $databasePath; + + $this->setConfiguration($configuration); + + // boot store + $this->createDatabasePath(); + $this->createStore(); + } + + /** + * Change the destination of the store object. + * @param string $storeName + * @param string|null $databasePath If empty, previous database path will be used. + * @param array $configuration + * @return Store + * @throws IOException + * @throws InvalidArgumentException + * @throws InvalidConfigurationException + */ + public function changeStore(string $storeName, string $databasePath = null, array $configuration = []): Store + { + if(empty($databasePath)){ + $databasePath = $this->getDatabasePath(); + } + $this->__construct($storeName, $databasePath, $configuration); + return $this; + } + + /** + * @return string + */ + public function getStoreName(): string + { + return $this->storeName; + } + + /** + * @return string + */ + public function getDatabasePath(): string + { + return $this->databasePath; + } + + /** + * @return QueryBuilder + */ + public function createQueryBuilder(): QueryBuilder + { + return new QueryBuilder($this); + } + + /** + * Insert a new document to the store. + * It is stored as a plaintext JSON document. + * @param array $data + * @return array inserted document + * @throws IOException + * @throws IdNotAllowedException + * @throws InvalidArgumentException + * @throws JsonException + */ + public function insert(array $data): array + { + // Handle invalid data + if (empty($data)) { + throw new InvalidArgumentException('No data found to insert in the store'); + } + + $data = $this->writeNewDocumentToStore($data); + + $this->createQueryBuilder()->getQuery()->getCache()->deleteAllWithNoLifetime(); + + return $data; + } + + /** + * Insert multiple documents to the store. + * They are stored as plaintext JSON documents. + * @param array $data + * @return array inserted documents + * @throws IOException + * @throws IdNotAllowedException + * @throws InvalidArgumentException + * @throws JsonException + */ + public function insertMany(array $data): array + { + // Handle invalid data + if (empty($data)) { + throw new InvalidArgumentException('No data found to insert in the store'); + } + + // All results. + $results = []; + foreach ($data as $document) { + $results[] = $this->writeNewDocumentToStore($document); + } + + $this->createQueryBuilder()->getQuery()->getCache()->deleteAllWithNoLifetime(); + return $results; + } + + + /** + * Delete store with all its data and cache. + * @return bool + * @throws IOException + */ + public function deleteStore(): bool + { + $storePath = $this->getStorePath(); + return IoHelper::deleteFolder($storePath); + } + + + /** + * Return the last created store object ID. + * @return int + * @throws IOException + */ + public function getLastInsertedId(): int + { + $counterPath = $this->getStorePath() . '_cnt.sdb'; + + return (int) IoHelper::getFileContent($counterPath); + } + + /** + * @return string + */ + public function getStorePath(): string + { + return $this->storePath; + } + + /** + * Retrieve all documents. + * @param array|null $orderBy array($fieldName => $order). $order can be "asc" or "desc" + * @param int|null $limit the amount of data record to limit + * @param int|null $offset the amount of data record to skip + * @return array + * @throws IOException + * @throws InvalidArgumentException + */ + public function findAll(array $orderBy = null, int $limit = null, int $offset = null): array + { + $qb = $this->createQueryBuilder(); + if(!is_null($orderBy)){ + $qb->orderBy($orderBy); + } + if(!is_null($limit)){ + $qb->limit($limit); + } + if(!is_null($offset)){ + $qb->skip($offset); + } + return $qb->getQuery()->fetch(); + } + + /** + * Retrieve one document by its primary key. Very fast because it finds the document by its file path. + * @param int|string $id + * @return array|null + * @throws InvalidArgumentException + */ + public function findById($id){ + + $id = $this->checkAndStripId($id); + + $filePath = $this->getDataPath() . "$id.json"; + + try{ + $content = IoHelper::getFileContent($filePath); + } catch (Exception $exception){ + return null; + } + + return @json_decode($content, true); + } + + /** + * Retrieve one or multiple documents. + * @param array $criteria + * @param array $orderBy + * @param int $limit + * @param int $offset + * @return array + * @throws IOException + * @throws InvalidArgumentException + */ + public function findBy(array $criteria, array $orderBy = null, int $limit = null, int $offset = null): array + { + $qb = $this->createQueryBuilder(); + + $qb->where($criteria); + + if($orderBy !== null) { + $qb->orderBy($orderBy); + } + if($limit !== null) { + $qb->limit($limit); + } + if($offset !== null) { + $qb->skip($offset); + } + + return $qb->getQuery()->fetch(); + } + + /** + * Retrieve one document. + * @param array $criteria + * @return array|null single document or NULL if no document can be found + * @throws IOException + * @throws InvalidArgumentException + */ + public function findOneBy(array $criteria) + { + $qb = $this->createQueryBuilder(); + + $qb->where($criteria); + + $result = $qb->getQuery()->first(); + + return (!empty($result))? $result : null; + + } + + /** + * Update or insert one document. + * @param array $data + * @param bool $autoGenerateIdOnInsert + * @return array updated / inserted document + * @throws IOException + * @throws InvalidArgumentException + * @throws JsonException + */ + public function updateOrInsert(array $data, bool $autoGenerateIdOnInsert = true): array + { + $primaryKey = $this->getPrimaryKey(); + + if(empty($data)) { + throw new InvalidArgumentException("No document to update or insert."); + } + +// // we can use this check to determine if multiple documents are given +// // because documents have to have at least the primary key. +// if(array_keys($data) !== range(0, (count($data) - 1))){ +// $data = [ $data ]; +// } + + if(!array_key_exists($primaryKey, $data)) { +// $documentString = var_export($document, true); +// throw new InvalidArgumentException("Documents have to have the primary key \"$primaryKey\". Got data: $documentString"); + $data[$primaryKey] = $this->increaseCounterAndGetNextId(); + } else { + $data[$primaryKey] = $this->checkAndStripId($data[$primaryKey]); + if($autoGenerateIdOnInsert && $this->findById($data[$primaryKey]) === null){ + $data[$primaryKey] = $this->increaseCounterAndGetNextId(); + } + } + + // One document to update or insert + + // save to access file with primary key value because we secured it above + $storePath = $this->getDataPath() . "$data[$primaryKey].json"; + IoHelper::writeContentToFile($storePath, json_encode($data)); + + $this->createQueryBuilder()->getQuery()->getCache()->deleteAllWithNoLifetime(); + + return $data; + } + + /** + * Update or insert multiple documents. + * @param array $data + * @param bool $autoGenerateIdOnInsert + * @return array updated / inserted documents + * @throws IOException + * @throws InvalidArgumentException + * @throws JsonException + */ + public function updateOrInsertMany(array $data, bool $autoGenerateIdOnInsert = true): array + { + $primaryKey = $this->getPrimaryKey(); + + if(empty($data)) { + throw new InvalidArgumentException("No documents to update or insert."); + } + +// // we can use this check to determine if multiple documents are given +// // because documents have to have at least the primary key. +// if(array_keys($data) !== range(0, (count($data) - 1))){ +// $data = [ $data ]; +// } + + // Check if all documents have the primary key before updating or inserting any + foreach ($data as $key => $document){ + if(!is_array($document)) { + throw new InvalidArgumentException('Documents have to be an arrays.'); + } + if(!array_key_exists($primaryKey, $document)) { +// $documentString = var_export($document, true); +// throw new InvalidArgumentException("Documents have to have the primary key \"$primaryKey\". Got data: $documentString"); + $document[$primaryKey] = $this->increaseCounterAndGetNextId(); + } else { + $document[$primaryKey] = $this->checkAndStripId($document[$primaryKey]); + if($autoGenerateIdOnInsert && $this->findById($document[$primaryKey]) === null){ + $document[$primaryKey] = $this->increaseCounterAndGetNextId(); + } + } + // after the stripping and checking we apply it back + $data[$key] = $document; + } + + // One or multiple documents to update or insert + foreach ($data as $document) { + // save to access file with primary key value because we secured it above + $storePath = $this->getDataPath() . "$document[$primaryKey].json"; + IoHelper::writeContentToFile($storePath, json_encode($document)); + } + + $this->createQueryBuilder()->getQuery()->getCache()->deleteAllWithNoLifetime(); + + return $data; + } + + + /** + * Update one or multiple documents. + * @param array $updatable + * @return bool true if all documents could be updated and false if one document did not exist + * @throws IOException + * @throws InvalidArgumentException + */ + public function update(array $updatable): bool + { + $primaryKey = $this->getPrimaryKey(); + + if(empty($updatable)) { + throw new InvalidArgumentException("No documents to update."); + } + + // we can use this check to determine if multiple documents are given + // because documents have to have at least the primary key. + if(array_keys($updatable) !== range(0, (count($updatable) - 1))){ + $updatable = [ $updatable ]; + } + + // Check if all documents exist and have the primary key before updating any + foreach ($updatable as $key => $document){ + if(!is_array($document)) { + throw new InvalidArgumentException('Documents have to be an arrays.'); + } + if(!array_key_exists($primaryKey, $document)) { + throw new InvalidArgumentException("Documents have to have the primary key \"$primaryKey\"."); + } + + $document[$primaryKey] = $this->checkAndStripId($document[$primaryKey]); + // after the stripping and checking we apply it back to the updatable array. + $updatable[$key] = $document; + + $storePath = $this->getDataPath() . "$document[$primaryKey].json"; + + if (!file_exists($storePath)) { + return false; + } + } + + // One or multiple documents to update + foreach ($updatable as $document) { + // save to access file with primary key value because we secured it above + $storePath = $this->getDataPath() . "$document[$primaryKey].json"; + IoHelper::writeContentToFile($storePath, json_encode($document)); + } + + $this->createQueryBuilder()->getQuery()->getCache()->deleteAllWithNoLifetime(); + + return true; + } + + /** + * Update properties of one document. + * @param int|string $id + * @param array $updatable + * @return array|false Updated document or false if document does not exist. + * @throws IOException If document could not be read or written. + * @throws InvalidArgumentException If one key to update is primary key or $id is not int or string. + * @throws JsonException If content of document file could not be decoded. + */ + public function updateById($id, array $updatable) + { + + $id = $this->checkAndStripId($id); + + $filePath = $this->getDataPath() . "$id.json"; + + $primaryKey = $this->getPrimaryKey(); + + if(array_key_exists($primaryKey, $updatable)) { + throw new InvalidArgumentException("You can not update the primary key \"$primaryKey\" of documents."); + } + + if(!file_exists($filePath)){ + return false; + } + + $content = IoHelper::updateFileContent($filePath, function($content) use ($filePath, $updatable){ + $content = @json_decode($content, true); + if(!is_array($content)){ + throw new JsonException("Could not decode content of \"$filePath\" with json_decode."); + } + foreach ($updatable as $key => $value){ + NestedHelper::updateNestedValue($key, $content, $value); + } + return json_encode($content); + }); + + $this->createQueryBuilder()->getQuery()->getCache()->deleteAllWithNoLifetime(); + + return json_decode($content, true); + } + + /** + * Delete one or multiple documents. + * @param array $criteria + * @param int $returnOption + * @return array|bool|int + * @throws IOException + * @throws InvalidArgumentException + */ + public function deleteBy(array $criteria, int $returnOption = Query::DELETE_RETURN_BOOL){ + + $query = $this->createQueryBuilder()->where($criteria)->getQuery(); + + $query->getCache()->deleteAllWithNoLifetime(); + + return $query->delete($returnOption); + } + + /** + * Delete one document by its primary key. Very fast because it deletes the document by its file path. + * @param int|string $id + * @return bool true if document does not exist or deletion was successful, false otherwise + * @throws InvalidArgumentException + */ + public function deleteById($id): bool + { + + $id = $this->checkAndStripId($id); + + $filePath = $this->getDataPath() . "$id.json"; + + $this->createQueryBuilder()->getQuery()->getCache()->deleteAllWithNoLifetime(); + + return (!file_exists($filePath) || true === @unlink($filePath)); + } + + /** + * Remove fields from one document by its primary key. + * @param int|string $id + * @param array $fieldsToRemove + * @return false|array + * @throws IOException + * @throws InvalidArgumentException + * @throws JsonException + */ + public function removeFieldsById($id, array $fieldsToRemove) + { + $id = $this->checkAndStripId($id); + $filePath = $this->getDataPath() . "$id.json"; + $primaryKey = $this->getPrimaryKey(); + + if(in_array($primaryKey, $fieldsToRemove, false)) { + throw new InvalidArgumentException("You can not remove the primary key \"$primaryKey\" of documents."); + } + if(!file_exists($filePath)){ + return false; + } + + $content = IoHelper::updateFileContent($filePath, function($content) use ($filePath, $fieldsToRemove){ + $content = @json_decode($content, true); + if(!is_array($content)){ + throw new JsonException("Could not decode content of \"$filePath\" with json_decode."); + } + foreach ($fieldsToRemove as $fieldToRemove){ + NestedHelper::removeNestedField($content, $fieldToRemove); + } + return $content; + }); + + $this->createQueryBuilder()->getQuery()->getCache()->deleteAllWithNoLifetime(); + + return json_decode($content, true); + } + + /** + * Do a fulltext like search against one or multiple fields. + * @param array $fields + * @param string $query + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @return array + * @throws IOException + * @throws InvalidArgumentException + */ + public function search(array $fields, string $query, array $orderBy = null, int $limit = null, int $offset = null): array + { + + $qb = $this->createQueryBuilder(); + + $qb->search($fields, $query); + + if($orderBy !== null) { + $qb->orderBy($orderBy); + } + + if($limit !== null) { + $qb->limit($limit); + } + + if($offset !== null) { + $qb->skip($offset); + } + + return $qb->getQuery()->fetch(); + } + + /** + * Get the name of the field used as the primary key. + * @return string + */ + public function getPrimaryKey(): string + { + return $this->primaryKey; + } + + /** + * Returns the amount of documents in the store. + * @return int + * @throws IOException + */ + public function count(): int + { + if($this->_getUseCache() === true){ + $cacheTokenArray = ["count" => true]; + $cache = new Cache($this->getStorePath(), $cacheTokenArray, null); + $cacheValue = $cache->get(); + if(is_array($cacheValue) && array_key_exists("count", $cacheValue)){ + return $cacheValue["count"]; + } + } + $value = [ + "count" => IoHelper::countFolderContent($this->getDataPath()) + ]; + if(isset($cache)) { + $cache->set($value); + } + return $value["count"]; + } + + /** + * Returns the search options of the store. + * @return array + */ + public function _getSearchOptions(): array + { + return $this->searchOptions; + } + + /** + * Returns if caching is enabled store wide. + * @return bool + */ + public function _getUseCache(): bool + { + return $this->useCache; + } + + /** + * Returns the store wide default cache lifetime. + * @return null|int + */ + public function _getDefaultCacheLifetime() + { + return $this->defaultCacheLifetime; + } + + /** + * @return string + * @deprecated since version 2.7, use getDatabasePath instead. + */ + public function getDataDirectory(): string + { + // TODO remove with version 3.0 + return $this->databasePath; + } + + /** + * @throws IOException + */ + private function createDatabasePath() + { + $databasePath = $this->getDatabasePath(); + IoHelper::createFolder($databasePath); + } + + /** + * @throws IOException + */ + private function createStore() + { + $storeName = $this->getStoreName(); + // Prepare store name. + IoHelper::normalizeDirectory($storeName); + // Store directory path. + $this->storePath = $this->getDatabasePath() . $storeName; + $storePath = $this->getStorePath(); + IoHelper::createFolder($storePath); + + // Create the cache directory. + $cacheDirectory = $storePath . 'cache'; + IoHelper::createFolder($cacheDirectory); + + // Create the data directory. + IoHelper::createFolder($storePath . self::dataDirectory); + + // Create the store counter file. + $counterFile = $storePath . '_cnt.sdb'; + if(!file_exists($counterFile)){ + IoHelper::writeContentToFile($counterFile, '0'); + } + } + + /** + * @param array $configuration + * @throws InvalidConfigurationException + */ + private function setConfiguration(array $configuration) + { + if(array_key_exists("auto_cache", $configuration)){ + $autoCache = $configuration["auto_cache"]; + if(!is_bool($configuration["auto_cache"])){ + throw new InvalidConfigurationException("auto_cache has to be boolean"); + } + + $this->useCache = $autoCache; + } + + if(array_key_exists("cache_lifetime", $configuration)){ + $defaultCacheLifetime = $configuration["cache_lifetime"]; + if(!is_int($defaultCacheLifetime) && !is_null($defaultCacheLifetime)){ + throw new InvalidConfigurationException("cache_lifetime has to be null or int"); + } + + $this->defaultCacheLifetime = $defaultCacheLifetime; + } + + // TODO remove timeout on major update + // Set timeout. + if (array_key_exists("timeout", $configuration)) { + if ((!is_int($configuration['timeout']) || $configuration['timeout'] <= 0) && !($configuration['timeout'] === false)){ + throw new InvalidConfigurationException("timeout has to be an int > 0 or false"); + } + $this->timeout = $configuration["timeout"]; + } + if($this->timeout !== false){ + $message = 'The "timeout" configuration is deprecated and will be removed with the next major update.' . + ' Set the "timeout" configuration to false and if needed use the set_timeout_limit() function in your own code.'; + trigger_error($message, E_USER_DEPRECATED); + set_time_limit($this->timeout); + } + + if(array_key_exists("primary_key", $configuration)){ + $primaryKey = $configuration["primary_key"]; + if(!is_string($primaryKey)){ + throw new InvalidConfigurationException("primary key has to be a string"); + } + $this->primaryKey = $primaryKey; + } + + if(array_key_exists("search", $configuration)){ + $searchConfig = $configuration["search"]; + + if(array_key_exists("min_length", $searchConfig)){ + $searchMinLength = $searchConfig["min_length"]; + if(!is_int($searchMinLength) || $searchMinLength <= 0){ + throw new InvalidConfigurationException("min length for searching has to be an int >= 0"); + } + $this->searchOptions["minLength"] = $searchMinLength; + } + + if(array_key_exists("mode", $searchConfig)){ + $searchMode = $searchConfig["mode"]; + if(!is_string($searchMode) || !in_array(strtolower(trim($searchMode)), ["and", "or"])){ + throw new InvalidConfigurationException("search mode can just be \"and\" or \"or\""); + } + $this->searchOptions["mode"] = strtolower(trim($searchMode)); + } + + if(array_key_exists("score_key", $searchConfig)){ + $searchScoreKey = $searchConfig["score_key"]; + if((!is_string($searchScoreKey) && !is_null($searchScoreKey))){ + throw new InvalidConfigurationException("search score key for search has to be a not empty string or null"); + } + $this->searchOptions["scoreKey"] = $searchScoreKey; + } + + if(array_key_exists("algorithm", $searchConfig)){ + $searchAlgorithm = $searchConfig["algorithm"]; + if(!in_array($searchAlgorithm, Query::SEARCH_ALGORITHM, true)){ + $searchAlgorithm = implode(', ', $searchAlgorithm); + throw new InvalidConfigurationException("The search algorithm has to be one of the following integer values ($searchAlgorithm)"); + } + $this->searchOptions["algorithm"] = $searchAlgorithm; + } + } + } + + /** + * Writes an object in a store. + * @param array $storeData + * @return array + * @throws IOException + * @throws IdNotAllowedException + * @throws JsonException + */ + private function writeNewDocumentToStore(array $storeData): array + { + $primaryKey = $this->getPrimaryKey(); + // Check if it has the primary key + if (isset($storeData[$primaryKey])) { + throw new IdNotAllowedException( + "The \"$primaryKey\" index is reserved by SleekDB, please delete the $primaryKey key and try again" + ); + } + $id = $this->increaseCounterAndGetNextId(); + // Add the system ID with the store data array. + $storeData[$primaryKey] = $id; + // Prepare storable data + $storableJSON = @json_encode($storeData); + if ($storableJSON === false) { + throw new JsonException('Unable to encode the data array, + please provide a valid PHP associative array'); + } + // Define the store path + $filePath = $this->getDataPath()."$id.json"; + + IoHelper::writeContentToFile($filePath, $storableJSON); + + return $storeData; + } + + /** + * Increments the store wide unique store object ID and returns it. + * @return int + * @throws IOException + * @throws JsonException + */ + private function increaseCounterAndGetNextId(): int + { + $counterPath = $this->getStorePath() . '_cnt.sdb'; + + if (!file_exists($counterPath)) { + throw new IOException("File $counterPath does not exist."); + } + + $dataPath = $this->getDataPath(); + + return (int) IoHelper::updateFileContent($counterPath, function ($counter) use ($dataPath){ + $newCounter = ((int) $counter) + 1; + + while(file_exists($dataPath."$newCounter.json") === true){ + $newCounter++; + } + return (string)$newCounter; + }); + } + + + /** + * @param string|int $id + * @return int + * @throws InvalidArgumentException + */ + private function checkAndStripId($id): int + { + if(!is_string($id) && !is_int($id)){ + throw new InvalidArgumentException("The id of the document has to be an integer or string"); + } + + if(is_string($id)){ + $id = IoHelper::secureStringForFileAccess($id); + } + + if(!is_numeric($id)){ + throw new InvalidArgumentException("The id of the document has to be numeric"); + } + + return (int) $id; + } + + /** + * @return string + */ + private function getDataPath(): string + { + return $this->getStorePath() . self::dataDirectory; + } + +}