├── Classes ├── Domain │ └── Service │ │ ├── IndexInterface.php │ │ ├── MysqlIndex.php │ │ └── SqLiteIndex.php ├── Exception.php ├── Factory │ └── IndexFactory.php └── Search │ ├── MysqlQueryBuilder.php │ ├── QueryBuilderInterface.php │ └── SqLiteQueryBuilder.php ├── Configuration └── Objects.yaml ├── LICENSE ├── README.md └── composer.json /Classes/Domain/Service/IndexInterface.php: -------------------------------------------------------------------------------- 1 | 48 | */ 49 | protected $propertyFieldsAvailable; 50 | 51 | /** 52 | * @param string $indexName 53 | * @param string $dataSourceName 54 | * @Flow\Autowiring(false) 55 | */ 56 | public function __construct(string $indexName, string $dataSourceName) 57 | { 58 | $this->indexName = $indexName; 59 | $this->dataSourceName = $dataSourceName; 60 | } 61 | 62 | /** 63 | * Lifecycle method 64 | * 65 | * @throws Exception 66 | */ 67 | public function initializeObject(): void 68 | { 69 | $this->connect(); 70 | } 71 | 72 | /** 73 | * Connect to the database 74 | * 75 | * @return void 76 | * @throws Exception if the connection cannot be established 77 | */ 78 | protected function connect(): void 79 | { 80 | if ($this->connection !== null) { 81 | return; 82 | } 83 | 84 | $splitdsn = explode(':', $this->dataSourceName, 2); 85 | $this->pdoDriver = $splitdsn[0]; 86 | 87 | try { 88 | $this->connection = new \PDO($this->dataSourceName, $this->username, $this->password); 89 | $this->connection->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); 90 | 91 | if ($this->pdoDriver === 'mysql') { 92 | $this->connection->exec('SET SESSION sql_mode=\'ANSI\';'); 93 | } 94 | } catch (\PDOException $exception) { 95 | throw new Exception(sprintf('Could not connect to index database with DSN "%s". PDO error: %s', $this->dataSourceName, $exception->getMessage()), 1576771168, $exception); 96 | } 97 | 98 | $this->createIndexTables(); 99 | $this->loadAvailablePropertyFields(); 100 | } 101 | 102 | /** 103 | * @param string $identifier identifier for the data 104 | * @param array $properties Properties to put into index 105 | * @param array $fullText array to push to fulltext index for this entry (keys are h1,h2,h3,h4,h5,h6,text) - all keys optional, results weighted by key 106 | * @return void 107 | */ 108 | public function indexData(string $identifier, array $properties, array $fullText): void 109 | { 110 | $this->connection->exec('BEGIN'); 111 | $this->adjustIndexToGivenProperties(array_keys($properties)); 112 | $this->insertOrUpdatePropertiesToIndex($properties, $identifier); 113 | $this->insertOrUpdateFulltextToIndex($fullText, $identifier); 114 | $this->connection->exec('COMMIT'); 115 | } 116 | 117 | /** 118 | * @param string $identifier 119 | * @return void 120 | */ 121 | public function removeData(string $identifier): void 122 | { 123 | $this->connection->exec('BEGIN'); 124 | $statement = $this->connection->prepare('DELETE FROM "fulltext_objects" WHERE "__identifier__" = :identifier'); 125 | $statement->bindValue(':identifier', $identifier); 126 | $statement->execute(); 127 | $statement = $this->connection->prepare('DELETE FROM "fulltext_index" WHERE "__identifier__" = :identifier'); 128 | $statement->bindValue(':identifier', $identifier); 129 | $statement->execute(); 130 | $this->connection->exec('COMMIT'); 131 | } 132 | 133 | /** 134 | * @param array $properties 135 | * @param string $identifier 136 | * @return void 137 | */ 138 | public function insertOrUpdatePropertiesToIndex(array $properties, string $identifier): void 139 | { 140 | $propertyColumnNamesString = '"__identifier__", '; 141 | $valueNamesString = ':__identifier__, '; 142 | $statementArgumentNumber = 1; 143 | foreach ($properties as $propertyName => $propertyValue) { 144 | $propertyColumnNamesString .= '"' . $propertyName . '", '; 145 | $valueNamesString .= $this->preparedStatementArgumentName($statementArgumentNumber) . ', '; 146 | $statementArgumentNumber++; 147 | } 148 | $propertyColumnNamesString = trim($propertyColumnNamesString, ', \t\n\r\0\x0B'); 149 | $valueNamesString = trim($valueNamesString, ', \t\n\r\0\x0B'); 150 | $preparedStatement = $this->connection->prepare('REPLACE INTO "fulltext_objects" (' . $propertyColumnNamesString . ') VALUES (' . $valueNamesString . ')'); 151 | 152 | $preparedStatement->bindValue(':__identifier__', $identifier); 153 | 154 | $statementArgumentNumber = 1; 155 | foreach ($properties as $propertyValue) { 156 | if (is_array($propertyValue)) { 157 | $propertyValue = implode(',', $propertyValue); 158 | } 159 | $preparedStatement->bindValue($this->preparedStatementArgumentName($statementArgumentNumber), $propertyValue); 160 | $statementArgumentNumber++; 161 | } 162 | 163 | $preparedStatement->execute(); 164 | } 165 | 166 | /** 167 | * @param int $argumentNumber 168 | * @return string 169 | */ 170 | protected function preparedStatementArgumentName(int $argumentNumber): string 171 | { 172 | return ':arg' . $argumentNumber; 173 | } 174 | 175 | /** 176 | * @param array $fulltext 177 | * @param string $identifier 178 | */ 179 | protected function insertOrUpdateFulltextToIndex(array $fulltext, string $identifier): void 180 | { 181 | $preparedStatement = $this->connection->prepare('REPLACE INTO "fulltext_index" ("__identifier__", "h1", "h2", "h3", "h4", "h5", "h6", "text") VALUES (:identifier, :h1, :h2, :h3, :h4, :h5, :h6, :text);'); 182 | $preparedStatement->bindValue(':identifier', $identifier); 183 | $this->bindFulltextParametersToStatement($preparedStatement, $fulltext); 184 | $preparedStatement->execute(); 185 | } 186 | 187 | /** 188 | * @param array $fulltext 189 | * @param string $identifier 190 | */ 191 | public function addToFulltext(array $fulltext, string $identifier): void 192 | { 193 | $preparedStatement = $this->connection->prepare('UPDATE IGNORE "fulltext_index" SET "h1" = CONCAT("h1", \' \', :h1), "h2" = CONCAT("h2", \' \', :h2), "h3" = CONCAT("h3", \' \', :h3), "h4" = CONCAT("h4", \' \', :h4), "h5" = CONCAT("h5", \' \', :h5), "h6" = CONCAT("h6", \' \', :h6), "text" = CONCAT("text", \' \', :text) WHERE "__identifier__" = :identifier'); 194 | $preparedStatement->bindValue(':identifier', $identifier); 195 | $this->bindFulltextParametersToStatement($preparedStatement, $fulltext); 196 | $preparedStatement->execute(); 197 | } 198 | 199 | /** 200 | * Binds fulltext parameters to a prepared statement as this happens in multiple places. 201 | * 202 | * @param \PDOStatement $preparedStatement 203 | * @param array $fulltext array (keys are h1,h2,h3,h4,h5,h6,text) - all keys optional 204 | */ 205 | protected function bindFulltextParametersToStatement(\PDOStatement $preparedStatement, array $fulltext): void 206 | { 207 | $preparedStatement->bindValue(':h1', $fulltext['h1'] ?? ''); 208 | $preparedStatement->bindValue(':h2', $fulltext['h2'] ?? ''); 209 | $preparedStatement->bindValue(':h3', $fulltext['h3'] ?? ''); 210 | $preparedStatement->bindValue(':h4', $fulltext['h4'] ?? ''); 211 | $preparedStatement->bindValue(':h5', $fulltext['h5'] ?? ''); 212 | $preparedStatement->bindValue(':h6', $fulltext['h6'] ?? ''); 213 | $preparedStatement->bindValue(':text', $fulltext['text'] ?? ''); 214 | } 215 | 216 | /** 217 | * Returns an index entry by identifier or NULL if it doesn't exist. 218 | * 219 | * @param string $identifier 220 | * @return array|FALSE 221 | */ 222 | public function findOneByIdentifier(string $identifier) 223 | { 224 | $statement = $this->connection->prepare('SELECT * FROM "fulltext_objects" WHERE "__identifier__" = :identifier LIMIT 1'); 225 | $statement->bindValue(':identifier', $identifier); 226 | 227 | if ($statement->execute()) { 228 | return $statement->fetch(\PDO::FETCH_ASSOC); 229 | } 230 | 231 | return false; 232 | } 233 | 234 | /** 235 | * Execute a prepared statement. 236 | * 237 | * @param string $statementQuery The statement query 238 | * @param array $parameters The statement parameters as map 239 | * @return array 240 | */ 241 | public function executeStatement(string $statementQuery, array $parameters): array 242 | { 243 | $statement = $this->connection->prepare($statementQuery); 244 | foreach ($parameters as $parameterName => $parameterValue) { 245 | $statement->bindValue($parameterName, $parameterValue); 246 | } 247 | 248 | if ($statement->execute()) { 249 | return $statement->fetchAll(\PDO::FETCH_ASSOC); 250 | } 251 | 252 | return []; 253 | } 254 | 255 | /** 256 | * @return string 257 | */ 258 | public function getIndexName(): string 259 | { 260 | return $this->indexName; 261 | } 262 | 263 | /** 264 | * completely empties the index. 265 | */ 266 | public function flush(): void 267 | { 268 | $this->connection->exec('DROP TABLE "fulltext_objects"'); 269 | $this->connection->exec('DROP TABLE "fulltext_index"'); 270 | $this->createIndexTables(); 271 | } 272 | 273 | /** 274 | * Optimize the database tables. 275 | * 276 | * @noinspection PdoApiUsageInspection query MUST be used for OPTIMIZE TABLE to work 277 | */ 278 | public function optimize(): void 279 | { 280 | $this->connection->exec('SET GLOBAL innodb_optimize_fulltext_only = 1'); 281 | $this->connection->query('OPTIMIZE TABLE "fulltext_index"'); 282 | $this->connection->exec('SET GLOBAL innodb_optimize_fulltext_only = 0'); 283 | $this->connection->query('OPTIMIZE TABLE "fulltext_objects", "fulltext_index"'); 284 | } 285 | 286 | /** 287 | * @return void 288 | */ 289 | protected function createIndexTables(): void 290 | { 291 | $result = $this->connection->query('SHOW TABLES'); 292 | $tables = $result->fetchAll(\PDO::FETCH_COLUMN); 293 | 294 | if (!in_array('fulltext_objects', $tables, true)) { 295 | $this->connection->exec('CREATE TABLE "fulltext_objects" ( 296 | "__identifier__" VARCHAR(40), 297 | PRIMARY KEY ("__identifier__") 298 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); 299 | $this->propertyFieldsAvailable = []; 300 | } 301 | 302 | if (!in_array('fulltext_index', $tables, true)) { 303 | $this->connection->exec('CREATE TABLE "fulltext_index" ( 304 | "__identifier__" VARCHAR(40), 305 | "h1" MEDIUMTEXT, 306 | "h2" MEDIUMTEXT, 307 | "h3" MEDIUMTEXT, 308 | "h4" MEDIUMTEXT, 309 | "h5" MEDIUMTEXT, 310 | "h6" MEDIUMTEXT, 311 | "text" MEDIUMTEXT, 312 | PRIMARY KEY ("__identifier__"), 313 | FULLTEXT nodeindex ("h1", 314 | "h2", 315 | "h3", 316 | "h4", 317 | "h5", 318 | "h6", 319 | "text") 320 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB' 321 | ); 322 | } 323 | } 324 | 325 | /** 326 | * @return void 327 | */ 328 | protected function loadAvailablePropertyFields(): void 329 | { 330 | $result = $this->connection->query('DESCRIBE fulltext_objects'); 331 | $this->propertyFieldsAvailable = $result->fetchAll(\PDO::FETCH_COLUMN); 332 | } 333 | 334 | /** 335 | * @param string $propertyName 336 | */ 337 | protected function addPropertyToIndex(string $propertyName): void 338 | { 339 | $this->connection->exec('ALTER TABLE "fulltext_objects" ADD COLUMN "' . $propertyName . '" MEDIUMTEXT DEFAULT NULL'); 340 | $this->propertyFieldsAvailable[] = $propertyName; 341 | } 342 | 343 | /** 344 | * @param array $propertyNames 345 | * @return void 346 | */ 347 | protected function adjustIndexToGivenProperties(array $propertyNames): void 348 | { 349 | foreach ($propertyNames as $propertyName) { 350 | if (!in_array($propertyName, $this->propertyFieldsAvailable, true)) { 351 | $this->addPropertyToIndex($propertyName); 352 | } 353 | } 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /Classes/Domain/Service/SqLiteIndex.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | protected $propertyFieldsAvailable; 37 | 38 | /** 39 | * @param string $indexName 40 | * @param string $storageFolder The absolute file path (with trailing slash) to store this index in. 41 | * @Flow\Autowiring(false) 42 | */ 43 | public function __construct(string $indexName, string $storageFolder) 44 | { 45 | $this->indexName = $indexName; 46 | $this->storageFolder = $storageFolder; 47 | } 48 | 49 | /** 50 | * Lifecycle method 51 | */ 52 | public function initializeObject(): void 53 | { 54 | $databaseFilePath = $this->storageFolder . md5($this->getIndexName()) . '.db'; 55 | $createDatabaseTables = false; 56 | 57 | if (!is_file($databaseFilePath)) { 58 | if (!is_dir($this->storageFolder) && !mkdir($concurrentDirectory = $this->storageFolder, 0777, true) && !is_dir($concurrentDirectory)) { 59 | throw new \RuntimeException(sprintf('Directory "%s" could not be created', $concurrentDirectory), 1576769055); 60 | } 61 | $createDatabaseTables = true; 62 | } 63 | $this->connection = new \SQLite3($databaseFilePath); 64 | 65 | if ($createDatabaseTables) { 66 | $this->createIndexTables(); 67 | } else { 68 | $this->loadAvailablePropertyFields(); 69 | } 70 | } 71 | 72 | /** 73 | * @param string $identifier identifier for the data 74 | * @param array $properties Properties to put into index 75 | * @param array $fullText array to push to fulltext index for this entry (keys are h1,h2,h3,h4,h5,h6,text) - all keys optional, results weighted by key 76 | * @return void 77 | */ 78 | public function indexData(string $identifier, array $properties, array $fullText): void 79 | { 80 | $this->connection->query('BEGIN IMMEDIATE TRANSACTION;'); 81 | $this->adjustIndexToGivenProperties(array_keys($properties)); 82 | $this->insertOrUpdatePropertiesToIndex($properties, $identifier); 83 | $this->insertOrUpdateFulltextToIndex($fullText, $identifier); 84 | $this->connection->query('COMMIT TRANSACTION;'); 85 | } 86 | 87 | /** 88 | * @param string $identifier 89 | * @return void 90 | */ 91 | public function removeData(string $identifier): void 92 | { 93 | $statement = $this->connection->prepare('DELETE FROM objects WHERE __identifier__ = :identifier;'); 94 | $statement->bindValue(':identifier', $identifier); 95 | $statement->execute(); 96 | $statement = $this->connection->prepare('DELETE FROM fulltext WHERE __identifier__ = :identifier;'); 97 | $statement->bindValue(':identifier', $identifier); 98 | $statement->execute(); 99 | } 100 | 101 | /** 102 | * @param array $properties 103 | * @param string $identifier 104 | * @return void 105 | */ 106 | public function insertOrUpdatePropertiesToIndex(array $properties, string $identifier): void 107 | { 108 | $propertyColumnNamesString = '__identifier__, '; 109 | $valueNamesString = ':__identifier__, '; 110 | $statementArgumentNumber = 1; 111 | foreach ($properties as $propertyName => $propertyValue) { 112 | $propertyColumnNamesString .= '"' . $propertyName . '", '; 113 | $valueNamesString .= $this->preparedStatementArgumentName($statementArgumentNumber) . ', '; 114 | $statementArgumentNumber++; 115 | } 116 | $propertyColumnNamesString = trim($propertyColumnNamesString, ", \t\n\r\0\x0B"); 117 | $valueNamesString = trim($valueNamesString, ", \t\n\r\0\x0B"); 118 | $preparedStatement = $this->connection->prepare('INSERT OR REPLACE INTO objects (' . $propertyColumnNamesString . ') VALUES (' . $valueNamesString . ');'); 119 | 120 | $statementArgumentNumber = 1; 121 | foreach ($properties as $propertyValue) { 122 | if (is_array($propertyValue)) { 123 | $propertyValue = implode(',', $propertyValue); 124 | } 125 | $preparedStatement->bindValue($this->preparedStatementArgumentName($statementArgumentNumber), $propertyValue); 126 | $statementArgumentNumber++; 127 | } 128 | 129 | $preparedStatement->bindValue(':__identifier__', $identifier); 130 | 131 | $preparedStatement->execute(); 132 | } 133 | 134 | /** 135 | * @param int $argumentNumber 136 | * @return string 137 | */ 138 | protected function preparedStatementArgumentName(int $argumentNumber): string 139 | { 140 | return ':arg' . $argumentNumber; 141 | } 142 | 143 | /** 144 | * @param array $fulltext 145 | * @param string $identifier 146 | */ 147 | protected function insertOrUpdateFulltextToIndex(array $fulltext, string $identifier): void 148 | { 149 | $preparedStatement = $this->connection->prepare('INSERT OR REPLACE INTO fulltext (__identifier__, h1, h2, h3, h4, h5, h6, text) VALUES (:identifier, :h1, :h2, :h3, :h4, :h5, :h6, :text);'); 150 | $preparedStatement->bindValue(':identifier', $identifier); 151 | $this->bindFulltextParametersToStatement($preparedStatement, $fulltext); 152 | $preparedStatement->execute(); 153 | } 154 | 155 | /** 156 | * @param array $fulltext 157 | * @param string $identifier 158 | */ 159 | public function addToFulltext(array $fulltext, string $identifier): void 160 | { 161 | $preparedStatement = $this->connection->prepare('UPDATE OR IGNORE fulltext SET h1 = (h1 || " " || :h1), h2 = (h2 || " " || :h2), h3 = (h3 || " " || :h3), h4 = (h4 || " " || :h4), h5 = (h5 || " " || :h5), h6 = (h6 || " " || :h6), text = (text || " " || :text) WHERE __identifier__ = :identifier;'); 162 | $preparedStatement->bindValue(':identifier', $identifier); 163 | $this->bindFulltextParametersToStatement($preparedStatement, $fulltext); 164 | $preparedStatement->execute(); 165 | } 166 | 167 | /** 168 | * Binds fulltext parameters to a prepared statement as this happens in multiple places. 169 | * 170 | * @param \SQLite3Stmt $preparedStatement 171 | * @param array $fulltext array (keys are h1,h2,h3,h4,h5,h6,text) - all keys optional 172 | */ 173 | protected function bindFulltextParametersToStatement(\SQLite3Stmt $preparedStatement, array $fulltext): void 174 | { 175 | foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'text'] as $bucketName) { 176 | $preparedStatement->bindValue(':' . $bucketName, $fulltext[$bucketName] ?? ''); 177 | } 178 | } 179 | 180 | /** 181 | * Returns an index entry by identifier or NULL if it doesn't exist. 182 | * 183 | * @param string $identifier 184 | * @return array|FALSE 185 | */ 186 | public function findOneByIdentifier(string $identifier) 187 | { 188 | $statement = $this->connection->prepare('SELECT * FROM objects WHERE __identifier__ = :identifier LIMIT 1'); 189 | $statement->bindValue(':identifier', $identifier); 190 | 191 | return $statement->execute()->fetchArray(SQLITE3_ASSOC); 192 | } 193 | 194 | /** 195 | * Execute a prepared statement. 196 | * 197 | * @param string $statementQuery The statement query 198 | * @param array $parameters The statement parameters as map 199 | * @return array 200 | */ 201 | public function executeStatement(string $statementQuery, array $parameters): array 202 | { 203 | $statement = $this->connection->prepare($statementQuery); 204 | foreach ($parameters as $parameterName => $parameterValue) { 205 | $statement->bindValue($parameterName, $parameterValue); 206 | } 207 | 208 | $result = $statement->execute(); 209 | $resultArray = []; 210 | while ($resultRow = $result->fetchArray(SQLITE3_ASSOC)) { 211 | $resultArray[] = $resultRow; 212 | } 213 | 214 | return $resultArray; 215 | } 216 | 217 | /** 218 | * @return string 219 | */ 220 | public function getIndexName(): string 221 | { 222 | return $this->indexName; 223 | } 224 | 225 | /** 226 | * completely empties the index. 227 | */ 228 | public function flush(): void 229 | { 230 | $this->connection->exec('DROP TABLE objects;'); 231 | $this->connection->exec('DROP TABLE fulltext;'); 232 | $this->createIndexTables(); 233 | } 234 | 235 | /** 236 | * Optimize the sqlite database. 237 | */ 238 | public function optimize(): void 239 | { 240 | $this->connection->exec('VACUUM'); 241 | } 242 | 243 | /** 244 | * @return void 245 | */ 246 | protected function createIndexTables(): void 247 | { 248 | $this->connection->exec('CREATE TABLE objects ( 249 | __identifier__ VARCHAR, 250 | PRIMARY KEY ("__identifier__") 251 | );'); 252 | 253 | $this->connection->exec('CREATE VIRTUAL TABLE fulltext USING fts3( 254 | __identifier__ VARCHAR, 255 | h1, 256 | h2, 257 | h3, 258 | h4, 259 | h5, 260 | h6, 261 | text 262 | );'); 263 | 264 | $this->propertyFieldsAvailable = []; 265 | } 266 | 267 | /** 268 | * @return void 269 | */ 270 | protected function loadAvailablePropertyFields(): void 271 | { 272 | $result = $this->connection->query('PRAGMA table_info(objects);'); 273 | while ($property = $result->fetchArray(SQLITE3_ASSOC)) { 274 | $this->propertyFieldsAvailable[] = $property['name']; 275 | } 276 | } 277 | 278 | /** 279 | * @param string $propertyName 280 | */ 281 | protected function addPropertyToIndex(string $propertyName): void 282 | { 283 | $this->connection->exec('ALTER TABLE objects ADD COLUMN "' . $propertyName . '";'); 284 | $this->propertyFieldsAvailable[] = $propertyName; 285 | } 286 | 287 | /** 288 | * @param array $propertyNames 289 | * @return void 290 | */ 291 | protected function adjustIndexToGivenProperties(array $propertyNames): void 292 | { 293 | foreach ($propertyNames as $propertyName) { 294 | if (!in_array($propertyName, $this->propertyFieldsAvailable, true)) { 295 | $this->addPropertyToIndex($propertyName); 296 | } 297 | } 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /Classes/Exception.php: -------------------------------------------------------------------------------- 1 | objectManager = $objectManager; 33 | } 34 | 35 | /** 36 | * @param string $indexName 37 | * @param string|null $indexType Class name for index instance 38 | * @return IndexInterface 39 | * @throws Exception 40 | */ 41 | public function create(string $indexName, string $indexType = null): IndexInterface 42 | { 43 | if (!isset($indexType)) { 44 | if ($this->objectManager === null) { 45 | throw new Exception('If this package is used outside of a Neos Flow context you must specify the $indexType argument.', 1398018955); 46 | } 47 | 48 | $indexType = $this->objectManager->getClassNameByObjectName(IndexInterface::class); 49 | } 50 | 51 | $instanceIdentifier = md5($indexName . '#' . $indexType); 52 | 53 | if (!isset($this->searchIndexInstances[$instanceIdentifier])) { 54 | $this->searchIndexInstances[$instanceIdentifier] = new $indexType($indexName); 55 | } 56 | 57 | return $this->searchIndexInstances[$instanceIdentifier]; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Classes/Search/MysqlQueryBuilder.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | protected $sorting = []; 37 | 38 | /** 39 | * where clauses 40 | * 41 | * @var array 42 | */ 43 | protected $where = []; 44 | 45 | /** 46 | * Map of query parameters to bind to the final statement. 47 | * 48 | * @var array 49 | */ 50 | protected $parameterMap = []; 51 | 52 | /** 53 | * Injection method used by Flow dependency injection 54 | * 55 | * @param IndexInterface $indexClient 56 | */ 57 | public function injectIndexClient(IndexInterface $indexClient): void 58 | { 59 | $this->indexClient = $indexClient; 60 | } 61 | 62 | /** 63 | * Sort descending by $propertyName 64 | * 65 | * @param string $propertyName the property name to sort by 66 | * @return QueryBuilderInterface 67 | */ 68 | public function sortDesc(string $propertyName): QueryBuilderInterface 69 | { 70 | $this->sorting[] = '"fulltext_objects"."' . $propertyName . '" DESC'; 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * Sort ascending by $propertyName 77 | * 78 | * @param string $propertyName the property name to sort by 79 | * @return QueryBuilderInterface 80 | */ 81 | public function sortAsc(string $propertyName): QueryBuilderInterface 82 | { 83 | $this->sorting[] = '"fulltext_objects"."' . $propertyName . '" ASC'; 84 | 85 | return $this; 86 | } 87 | 88 | /** 89 | * output only $limit records 90 | * 91 | * @param int|null $limit 92 | * @return QueryBuilderInterface 93 | */ 94 | public function limit($limit): QueryBuilderInterface 95 | { 96 | $this->limit = $limit === null ? $limit : (int)$limit; 97 | return $this; 98 | } 99 | 100 | /** 101 | * Start returned results $from number results. 102 | * 103 | * @param int|null $from 104 | * @return QueryBuilderInterface 105 | */ 106 | public function from($from): QueryBuilderInterface 107 | { 108 | $this->from = $from === null ? $from : (int)$from; 109 | return $this; 110 | } 111 | 112 | /** 113 | * add an exact-match query for a given property 114 | * 115 | * @param string $propertyName 116 | * @param string $propertyValue 117 | * @return QueryBuilderInterface 118 | */ 119 | public function exactMatch(string $propertyName, $propertyValue): QueryBuilderInterface 120 | { 121 | $this->compare($propertyName, $propertyValue, '='); 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * add an like query for a given property 128 | * 129 | * @param string $propertyName 130 | * @param $propertyValue 131 | * @return QueryBuilderInterface 132 | */ 133 | public function like(string $propertyName, $propertyValue): QueryBuilderInterface 134 | { 135 | $parameterName = ':' . md5($propertyName . '#' . count($this->where)); 136 | $this->where[] = '("' . $propertyName . '" LIKE ' . $parameterName . ')'; 137 | $this->parameterMap[$parameterName] = '%' . $propertyValue . '%'; 138 | 139 | return $this; 140 | } 141 | 142 | /** 143 | * Add a custom condition 144 | * 145 | * @param string $conditon 146 | * @return QueryBuilderInterface 147 | */ 148 | public function customCondition(string $conditon): QueryBuilderInterface 149 | { 150 | $this->where[] = $conditon; 151 | 152 | return $this; 153 | } 154 | 155 | /** 156 | * @param string $searchword 157 | * @return QueryBuilderInterface 158 | */ 159 | public function fulltext(string $searchword): QueryBuilderInterface 160 | { 161 | $parameterName = ':' . md5('FULLTEXT#' . count($this->where)); 162 | $this->where[] = '("__identifier__" IN (SELECT "__identifier__" FROM "fulltext_index" WHERE MATCH ("h1", "h2", "h3", "h4", "h5", "h6", "text") AGAINST (' . $parameterName . ')))'; 163 | $this->parameterMap[$parameterName] = $searchword; 164 | 165 | return $this; 166 | } 167 | 168 | /** 169 | * add a greater than query for a given property 170 | * 171 | * @param string $propertyName 172 | * @param string $propertyValue 173 | * @return QueryBuilderInterface 174 | */ 175 | public function greaterThan($propertyName, $propertyValue): QueryBuilderInterface 176 | { 177 | return $this->compare($propertyName, $propertyValue, '>'); 178 | } 179 | 180 | /** 181 | * add a greater than or equal query for a given property 182 | * 183 | * @param string $propertyName 184 | * @param string $propertyValue 185 | * @return QueryBuilderInterface 186 | */ 187 | public function greaterThanOrEqual($propertyName, $propertyValue): QueryBuilderInterface 188 | { 189 | return $this->compare($propertyName, $propertyValue, '>='); 190 | } 191 | 192 | /** 193 | * add a less than query for a given property 194 | * 195 | * @param $propertyName 196 | * @param $propertyValue 197 | * @return QueryBuilderInterface 198 | */ 199 | public function lessThan($propertyName, $propertyValue): QueryBuilderInterface 200 | { 201 | return $this->compare($propertyName, $propertyValue, '<'); 202 | } 203 | 204 | /** 205 | * add a less than or equal query for a given property 206 | * 207 | * @param $propertyName 208 | * @param $propertyValue 209 | * @return QueryBuilderInterface 210 | */ 211 | public function lessThanOrEqual($propertyName, $propertyValue): QueryBuilderInterface 212 | { 213 | return $this->compare($propertyName, $propertyValue, '<='); 214 | } 215 | 216 | /** 217 | * Execute the query and return the list of results 218 | * 219 | * @return array 220 | */ 221 | public function execute(): array 222 | { 223 | $query = $this->buildQueryString(); 224 | $result = $this->indexClient->executeStatement($query, $this->parameterMap); 225 | 226 | if (empty($result)) { 227 | return []; 228 | } 229 | 230 | return array_values($result); 231 | } 232 | 233 | /** 234 | * Return the total number of hits for the query. 235 | * 236 | * @return int 237 | */ 238 | public function count(): int 239 | { 240 | $result = $this->execute(); 241 | return count($result); 242 | } 243 | 244 | /** 245 | * Produces a snippet with the first match result for the search term. 246 | * 247 | * @param string $searchword The search word 248 | * @param int $resultTokens The amount of characters to get surrounding the match hit. (defaults to 200) 249 | * @param string $ellipsis added to the end of the string if the text was longer than the snippet produced. (defaults to "...") 250 | * @param string $beginModifier added immediately before the searchword in the snippet (defaults to ) 251 | * @param string $endModifier added immediately after the searchword in the snippet (defaults to ) 252 | * @return string 253 | * @see https://github.com/boyter/php-excerpt 254 | */ 255 | public function fulltextMatchResult($searchword, $resultTokens = 200, $ellipsis = '...', $beginModifier = '', $endModifier = ''): string 256 | { 257 | $searchword = trim($searchword); 258 | 259 | $query = $this->buildQueryString(); 260 | $results = $this->indexClient->executeStatement($query, $this->parameterMap); 261 | 262 | if ($results === []) { 263 | return ''; 264 | } 265 | 266 | $matches = []; 267 | foreach ($results[0] as $indexedFieldName => $indexedFieldContent) { 268 | if (!empty($indexedFieldContent) && strpos($indexedFieldName, '_') !== 0) { 269 | $matches[] = trim(strip_tags((string)$indexedFieldContent)); 270 | } 271 | } 272 | $matchContent = implode(' ', $matches); 273 | 274 | $searchWordParts = explode(' ', $searchword); 275 | $matchContent = preg_replace( 276 | array_map(static function (string $searchWordPart) { 277 | return sprintf('/(%s)/iu', preg_quote($searchWordPart, '/')); 278 | }, $searchWordParts), 279 | array_fill(0, count($searchWordParts), sprintf('%s$1%s', $beginModifier, $endModifier)), 280 | $matchContent 281 | ); 282 | 283 | $matchLength = strlen($matchContent); 284 | if ($matchLength <= $resultTokens) { 285 | return $matchContent; 286 | } 287 | 288 | $locations = $this->extractLocations($searchWordParts, $matchContent); 289 | $snippetLocation = $this->determineSnippetLocation($locations, (int)($resultTokens / 3)); 290 | 291 | return $this->extractSnippet($resultTokens, $ellipsis, $matchLength, $snippetLocation, $matchContent); 292 | } 293 | 294 | /** 295 | * find the locations of each of the words 296 | * Nothing exciting here. The array_unique is required 297 | * unless you decide to make the words unique before passing in 298 | * 299 | * @param array $words 300 | * @param string $fulltext 301 | * @return array 302 | * @see https://github.com/boyter/php-excerpt 303 | */ 304 | private function extractLocations(array $words, string $fulltext): array 305 | { 306 | $locations = []; 307 | foreach ($words as $word) { 308 | $loc = stripos($fulltext, $word); 309 | while ($loc !== false) { 310 | $locations[0] = $loc; 311 | $loc = stripos($fulltext, $word, $loc + strlen($word)); 312 | } 313 | } 314 | 315 | $locations = array_unique($locations); 316 | 317 | sort($locations); 318 | return $locations; 319 | } 320 | 321 | /** 322 | * Work out which is the most relevant portion to display 323 | * 324 | * This is done by looping over each match and finding the smallest distance between two found 325 | * strings. The idea being that the closer the terms are the better match the snippet would be. 326 | * When checking for matches we only change the location if there is a better match. 327 | * The only exception is where we have only two matches in which case we just take the 328 | * first as will be equally distant. 329 | * 330 | * @param array $locations 331 | * @param int $relativePosition 332 | * @return int 333 | * @see https://github.com/boyter/php-excerpt 334 | */ 335 | private function determineSnippetLocation(array $locations, int $relativePosition): int 336 | { 337 | $locationsCount = count($locations); 338 | $smallestDiff = PHP_INT_MAX; 339 | 340 | if ($locationsCount === 0) { 341 | return 0; 342 | } 343 | 344 | $startPosition = $locations[0]; 345 | if ($locationsCount > 2) { 346 | // skip the first as we check 1 behind 347 | for ($i = 1; $i < $locationsCount; $i++) { 348 | if ($i === $locationsCount - 1) { // at the end 349 | $diff = $locations[$i] - $locations[$i - 1]; 350 | } else { 351 | $diff = $locations[$i + 1] - $locations[$i]; 352 | } 353 | 354 | if ($smallestDiff > $diff) { 355 | $smallestDiff = $diff; 356 | $startPosition = $locations[$i]; 357 | } 358 | } 359 | } 360 | 361 | return $startPosition > $relativePosition ? $startPosition - $relativePosition : 0; 362 | } 363 | 364 | /** 365 | * @param int $resultTokens 366 | * @param string $ellipsis 367 | * @param int $matchLength 368 | * @param int $snippetLocation 369 | * @param string $matchContent 370 | * @return string 371 | */ 372 | private function extractSnippet(int $resultTokens, string $ellipsis, int $matchLength, int $snippetLocation, string $matchContent): string 373 | { 374 | if ($matchLength - $snippetLocation < $resultTokens) { 375 | $snippetLocation = (int)($snippetLocation - ($matchLength - $snippetLocation) / 2); 376 | } 377 | 378 | $snippet = substr($matchContent, $snippetLocation, $resultTokens); 379 | 380 | if ($snippetLocation + $resultTokens < $matchLength) { 381 | $snippet = substr($snippet, 0, strrpos($snippet, ' ')) . $ellipsis; 382 | } 383 | 384 | if ($snippetLocation !== 0) { 385 | $snippet = $ellipsis . substr($snippet, strpos($snippet, ' ') + 1); 386 | } 387 | 388 | return $snippet; 389 | } 390 | 391 | /** 392 | * Match any value in the given array for the property 393 | * 394 | * @param string $propertyName 395 | * @param array $propertyValues 396 | * @return QueryBuilderInterface 397 | */ 398 | public function anyMatch(string $propertyName, array $propertyValues): QueryBuilderInterface 399 | { 400 | if ($propertyValues === null || empty($propertyValues) || $propertyValues[0] === null) { 401 | return $this; 402 | } 403 | 404 | $queryString = null; 405 | $lastElemtentKey = count($propertyValues) - 1; 406 | foreach ($propertyValues as $key => $propertyValue) { 407 | $parameterName = ':' . md5($propertyName . '#' . count($this->where) . $key); 408 | $this->parameterMap[$parameterName] = $propertyValue; 409 | 410 | if ($key === 0) { 411 | $queryString .= '('; 412 | } 413 | if ($key !== $lastElemtentKey) { 414 | $queryString .= sprintf('("%s") = %s OR ', $propertyName, $parameterName); 415 | } else { 416 | $queryString .= sprintf('("%s") = %s )', $propertyName, $parameterName); 417 | } 418 | } 419 | 420 | $this->where[] = $queryString; 421 | 422 | return $this; 423 | } 424 | 425 | /** 426 | * Match any value which is like in the given array for the property 427 | * 428 | * @param string $propertyName 429 | * @param array $propertyValues 430 | * @return QueryBuilderInterface 431 | */ 432 | public function likeAnyMatch(string $propertyName, array $propertyValues): QueryBuilderInterface 433 | { 434 | if ($propertyValues === null || empty($propertyValues) || $propertyValues[0] === null) { 435 | return $this; 436 | } 437 | 438 | $queryString = null; 439 | $lastElementKey = count($propertyValues) - 1; 440 | foreach ($propertyValues as $key => $propertyValue) { 441 | $parameterName = ':' . md5($propertyName . '#' . count($this->where) . $key); 442 | $this->parameterMap[$parameterName] = '%' . $propertyValue . '%'; 443 | 444 | if ($key === 0) { 445 | $queryString .= '('; 446 | } 447 | if ($key !== $lastElementKey) { 448 | $queryString .= sprintf('("%s") LIKE %s OR ', $propertyName, $parameterName); 449 | } else { 450 | $queryString .= sprintf('("%s") LIKE %s)', $propertyName, $parameterName); 451 | } 452 | } 453 | 454 | $this->where[] = $queryString; 455 | 456 | return $this; 457 | } 458 | 459 | /** 460 | * @return string 461 | */ 462 | protected function buildQueryString(): string 463 | { 464 | $whereString = implode(' AND ', $this->where); 465 | $orderString = implode(', ', $this->sorting); 466 | 467 | $queryString = 'SELECT * FROM "fulltext_objects" WHERE ' . $whereString; 468 | if (count($this->sorting)) { 469 | $queryString .= ' ORDER BY ' . $orderString; 470 | } 471 | 472 | if ($this->limit !== null) { 473 | $queryString .= ' LIMIT ' . $this->limit; 474 | } 475 | 476 | if ($this->from !== null) { 477 | $queryString .= ' OFFSET ' . $this->from; 478 | } 479 | 480 | return $queryString; 481 | } 482 | 483 | /** 484 | * @param string $propertyName 485 | * @param mixed $propertyValue 486 | * @param string $comparator Comparator sign i.e. '>' or '<=' 487 | * @return QueryBuilderInterface 488 | */ 489 | protected function compare($propertyName, $propertyValue, $comparator): QueryBuilderInterface 490 | { 491 | if ($propertyValue instanceof \DateTime) { 492 | $this->where[] = sprintf("datetime(`%s`) %s strftime('%s', '%s')", $propertyName, $comparator, '%Y-%m-%d %H:%M:%S', $propertyValue->format('Y-m-d H:i:s')); 493 | } else { 494 | $parameterName = ':' . md5($propertyName . '#' . count($this->where)); 495 | $this->parameterMap[$parameterName] = $propertyValue; 496 | $this->where[] = sprintf('("%s") %s %s', $propertyName, $comparator, $parameterName); 497 | } 498 | 499 | return $this; 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /Classes/Search/QueryBuilderInterface.php: -------------------------------------------------------------------------------- 1 | 82 | */ 83 | public function execute(): array; 84 | 85 | /** 86 | * Return the total number of hits for the query. 87 | * 88 | * @return int 89 | */ 90 | public function count(): int; 91 | } 92 | -------------------------------------------------------------------------------- /Classes/Search/SqLiteQueryBuilder.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | protected $sorting = []; 39 | 40 | /** 41 | * where clauses 42 | * 43 | * @var array 44 | */ 45 | protected $where = []; 46 | 47 | /** 48 | * Map of query parameters to bind to the final statement. 49 | * 50 | * @var array 51 | */ 52 | protected $parameterMap = []; 53 | 54 | /** 55 | * Sort descending by $propertyName 56 | * 57 | * @param string $propertyName the property name to sort by 58 | * @return QueryBuilderInterface 59 | */ 60 | public function sortDesc(string $propertyName): QueryBuilderInterface 61 | { 62 | $this->sorting[] = 'objects.' . $propertyName . ' DESC'; 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * Sort ascending by $propertyName 69 | * 70 | * @param string $propertyName the property name to sort by 71 | * @return QueryBuilderInterface 72 | */ 73 | public function sortAsc(string $propertyName): QueryBuilderInterface 74 | { 75 | $this->sorting[] = 'objects.' . $propertyName . ' ASC'; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * output only $limit records 82 | * 83 | * @param int|null $limit 84 | * @return QueryBuilderInterface 85 | */ 86 | public function limit($limit): QueryBuilderInterface 87 | { 88 | $this->limit = $limit === null ? $limit : (int)$limit; 89 | return $this; 90 | } 91 | 92 | /** 93 | * Start returned results $from number results. 94 | * 95 | * @param int|null $from 96 | * @return QueryBuilderInterface 97 | */ 98 | public function from($from): QueryBuilderInterface 99 | { 100 | $this->from = $from === null ? $from : (int)$from; 101 | return $this; 102 | } 103 | 104 | /** 105 | * add an exact-match query for a given property 106 | * 107 | * @param string $propertyName 108 | * @param mixed $propertyValue 109 | * @return QueryBuilderInterface 110 | */ 111 | public function exactMatch(string $propertyName, $propertyValue): QueryBuilderInterface 112 | { 113 | $this->compare($propertyName, $propertyValue, '='); 114 | 115 | return $this; 116 | } 117 | 118 | /** 119 | * add an like query for a given property 120 | * 121 | * @param string $propertyName 122 | * @param $propertyValue 123 | * @return QueryBuilderInterface 124 | */ 125 | public function like(string $propertyName, $propertyValue): QueryBuilderInterface 126 | { 127 | $parameterName = ':' . md5($propertyName . '#' . count($this->where)); 128 | $this->where[] = '(`' . $propertyName . '` LIKE ' . $parameterName . ')'; 129 | $this->parameterMap[$parameterName] = '%' . $propertyValue . '%'; 130 | 131 | return $this; 132 | } 133 | 134 | /** 135 | * Add a custom condition 136 | * 137 | * @param string $conditon 138 | * @return QueryBuilderInterface 139 | */ 140 | public function customCondition(string $conditon): QueryBuilderInterface 141 | { 142 | $this->where[] = $conditon; 143 | 144 | return $this; 145 | } 146 | 147 | /** 148 | * @param string $searchword 149 | * @return QueryBuilderInterface 150 | */ 151 | public function fulltext(string $searchword): QueryBuilderInterface 152 | { 153 | $parameterName = ':' . md5('FULLTEXT#' . count($this->where)); 154 | $this->where[] = '(__identifier__ IN (SELECT __identifier__ FROM fulltext WHERE fulltext MATCH ' . $parameterName . ' ORDER BY offsets(fulltext) ASC))'; 155 | $this->parameterMap[$parameterName] = $searchword; 156 | 157 | return $this; 158 | } 159 | 160 | /** 161 | * add a greater than query for a given property 162 | * 163 | * @param string $propertyName 164 | * @param mixed $propertyValue 165 | * @return QueryBuilderInterface 166 | */ 167 | public function greaterThan(string $propertyName, $propertyValue): QueryBuilderInterface 168 | { 169 | return $this->compare($propertyName, $propertyValue, '>'); 170 | } 171 | 172 | /** 173 | * add a greater than or equal query for a given property 174 | * 175 | * @param string $propertyName 176 | * @param mixed $propertyValue 177 | * @return QueryBuilderInterface 178 | */ 179 | public function greaterThanOrEqual(string $propertyName, $propertyValue): QueryBuilderInterface 180 | { 181 | return $this->compare($propertyName, $propertyValue, '>='); 182 | } 183 | 184 | /** 185 | * add a less than query for a given property 186 | * 187 | * @param string $propertyName 188 | * @param mixed $propertyValue 189 | * @return QueryBuilderInterface 190 | */ 191 | public function lessThan(string $propertyName, $propertyValue): QueryBuilderInterface 192 | { 193 | return $this->compare($propertyName, $propertyValue, '<'); 194 | } 195 | 196 | /** 197 | * add a less than or equal query for a given property 198 | * 199 | * @param string $propertyName 200 | * @param mixed $propertyValue 201 | * @return QueryBuilderInterface 202 | */ 203 | public function lessThanOrEqual(string $propertyName, $propertyValue): QueryBuilderInterface 204 | { 205 | return $this->compare($propertyName, $propertyValue, '<='); 206 | } 207 | 208 | /** 209 | * Execute the query and return the list of results 210 | * 211 | * @return array 212 | */ 213 | public function execute(): array 214 | { 215 | $query = $this->buildQueryString(); 216 | $result = $this->indexClient->executeStatement($query, $this->parameterMap); 217 | 218 | if (empty($result)) { 219 | return []; 220 | } 221 | 222 | return array_values($result); 223 | } 224 | 225 | /** 226 | * Return the total number of hits for the query. 227 | * 228 | * @return int 229 | */ 230 | public function count(): int 231 | { 232 | $result = $this->execute(); 233 | return count($result); 234 | } 235 | 236 | /** 237 | * Produces a snippet with the first match result for the search term. 238 | * 239 | * @param string $searchword The search word 240 | * @param int $resultTokens The amount of tokens (words) to get surrounding the match hit. (defaults to 60) 241 | * @param string $ellipsis added to the end of the string if the text was longer than the snippet produced. (defaults to "...") 242 | * @param string $beginModifier added immediately before the searchword in the snippet (defaults to ) 243 | * @param string $endModifier added immediately after the searchword in the snippet (defaults to ) 244 | * @return string 245 | */ 246 | public function fulltextMatchResult(string $searchword, int $resultTokens = 60, string $ellipsis = '...', string $beginModifier = '', string $endModifier = ''): string 247 | { 248 | $query = $this->buildQueryString(); 249 | $results = $this->indexClient->executeStatement($query, $this->parameterMap); 250 | // SQLite3 has a hard-coded limit of 999 query variables, so we split the $result in chunks 251 | // of 990 elements (we need some space for our own variables), query these, and return the first result. 252 | // @see https://sqlite.org/limits.html -> "Maximum Number Of Host Parameters In A Single SQL Statement" 253 | $chunks = array_chunk($results, 990); 254 | foreach ($chunks as $chunk) { 255 | $queryParameters = []; 256 | $identifierParameters = []; 257 | foreach ($chunk as $key => $result) { 258 | $parameterName = ':possibleIdentifier' . $key; 259 | $identifierParameters[] = $parameterName; 260 | $queryParameters[$parameterName] = $result['__identifier__']; 261 | } 262 | 263 | $queryParameters[':beginModifier'] = $beginModifier; 264 | $queryParameters[':endModifier'] = $endModifier; 265 | $queryParameters[':ellipsis'] = $ellipsis; 266 | $queryParameters[':resultTokens'] = ($resultTokens * -1); 267 | 268 | $matchQuery = 'SELECT snippet(fulltext, :beginModifier, :endModifier, :ellipsis, -1, :resultTokens) as snippet FROM fulltext WHERE fulltext MATCH :searchword AND __identifier__ IN (' . implode(',', $identifierParameters) . ') LIMIT 1;'; 269 | $queryParameters[':searchword'] = $searchword; 270 | $matchSnippet = $this->indexClient->executeStatement($matchQuery, $queryParameters); 271 | 272 | // If we have a hit here, we stop searching and return it. 273 | if (isset($matchSnippet[0]['snippet']) && $matchSnippet[0]['snippet'] !== '') { 274 | return $matchSnippet[0]['snippet']; 275 | } 276 | } 277 | return ''; 278 | } 279 | 280 | /** 281 | * Match any value in the given array for the property 282 | * 283 | * @param string $propertyName 284 | * @param array $propertyValues 285 | * @return QueryBuilderInterface 286 | */ 287 | public function anyMatch(string $propertyName, array $propertyValues): QueryBuilderInterface 288 | { 289 | if ($propertyValues === null || empty($propertyValues) || $propertyValues[0] === null) { 290 | return $this; 291 | } 292 | 293 | $queryString = null; 294 | $lastElemtentKey = count($propertyValues) - 1; 295 | foreach ($propertyValues as $key => $propertyValue) { 296 | $parameterName = ':' . md5($propertyName . '#' . count($this->where) . $key); 297 | $this->parameterMap[$parameterName] = $propertyValue; 298 | 299 | if ($key === 0) { 300 | $queryString .= '('; 301 | } 302 | if ($key !== $lastElemtentKey) { 303 | $queryString .= sprintf('(`%s`) = %s OR ', $propertyName, $parameterName); 304 | } else { 305 | $queryString .= sprintf('(`%s`) = %s )', $propertyName, $parameterName); 306 | } 307 | } 308 | 309 | $this->where[] = $queryString; 310 | 311 | return $this; 312 | } 313 | 314 | /** 315 | * Match any value which is like in the given array for the property 316 | * 317 | * @param string $propertyName 318 | * @param array $propertyValues 319 | * @return QueryBuilderInterface 320 | */ 321 | public function likeAnyMatch(string $propertyName, array $propertyValues): QueryBuilderInterface 322 | { 323 | if ($propertyValues === null || empty($propertyValues) || $propertyValues[0] === null) { 324 | return $this; 325 | } 326 | 327 | $queryString = null; 328 | $lastElemtentKey = count($propertyValues) - 1; 329 | foreach ($propertyValues as $key => $propertyValue) { 330 | $parameterName = ':' . md5($propertyName . '#' . count($this->where) . $key); 331 | $this->parameterMap[$parameterName] = '%' . $propertyValue . '%'; 332 | 333 | if ($key === 0) { 334 | $queryString .= '('; 335 | } 336 | if ($key !== $lastElemtentKey) { 337 | $queryString .= sprintf('(`%s`) LIKE %s OR ', $propertyName, $parameterName); 338 | } else { 339 | $queryString .= sprintf('(`%s`) LIKE %s)', $propertyName, $parameterName); 340 | } 341 | } 342 | 343 | $this->where[] = $queryString; 344 | 345 | return $this; 346 | } 347 | 348 | /** 349 | * @return string 350 | */ 351 | protected function buildQueryString(): string 352 | { 353 | $whereString = implode(' AND ', $this->where); 354 | $orderString = implode(', ', $this->sorting); 355 | 356 | $queryString = 'SELECT DISTINCT(__identifier__), * FROM objects WHERE ' . $whereString; 357 | if (count($this->sorting)) { 358 | $queryString .= ' ORDER BY ' . $orderString; 359 | } 360 | 361 | if ($this->limit !== null) { 362 | $queryString .= ' LIMIT ' . $this->limit; 363 | } 364 | 365 | if ($this->from !== null) { 366 | $queryString .= ' OFFSET ' . $this->from; 367 | } 368 | 369 | return $queryString; 370 | } 371 | 372 | /** 373 | * @param string $propertyName 374 | * @param mixed $propertyValue 375 | * @param string $comparator Comparator sign i.e. '>' or '<=' 376 | * @return QueryBuilderInterface 377 | */ 378 | protected function compare(string $propertyName, $propertyValue, string $comparator): QueryBuilderInterface 379 | { 380 | if ($propertyValue instanceof \DateTime) { 381 | $this->where[] = sprintf("datetime(`%s`) %s strftime('%s', '%s')", $propertyName, $comparator, '%Y-%m-%d %H:%M:%S', $propertyValue->format('Y-m-d H:i:s')); 382 | } else { 383 | $parameterName = ':' . md5($propertyName . '#' . count($this->where)); 384 | $this->parameterMap[$parameterName] = $propertyValue; 385 | $this->where[] = sprintf('(`%s`) %s %s', $propertyName, $comparator, $parameterName); 386 | } 387 | 388 | return $this; 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /Configuration/Objects.yaml: -------------------------------------------------------------------------------- 1 | Flowpack\SimpleSearch\Factory\IndexFactory: 2 | scope: singleton 3 | 4 | Flowpack\SimpleSearch\Domain\Service\IndexInterface: 5 | className: Flowpack\SimpleSearch\Domain\Service\SqLiteIndex 6 | 7 | Flowpack\SimpleSearch\Domain\Service\SqLiteIndex: 8 | arguments: 9 | 2: 10 | value: '%FLOW_PATH_DATA%Persistent/Flowpack_SimpleSearch_SqLite/' 11 | 12 | Flowpack\SimpleSearch\Domain\Service\MysqlIndex: 13 | arguments: 14 | 2: 15 | value: 'mysql:host=127.0.0.1;dbname=;charset=utf8mb4' 16 | properties: 17 | username: 18 | value: '' 19 | password: 20 | value: '' 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Christian Müller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flowpack.SimpleSearch 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/flowpack/simplesearch/v/stable)](https://packagist.org/packages/flowpack/simplesearch) [![Total Downloads](https://poser.pugx.org/flowpack/simplesearch/downloads)](https://packagist.org/packages/flowpack/simplesearch) 4 | 5 | A simple php search engine based on SQLite or MySQL. Performance is acceptable but 6 | decreases quickly with the amount of entries. 7 | Depending on the queries you want to perform a sane upper limit is somewhere around 8 | 50000 entries (for SQLite). 9 | 10 | This package has no hard dependencies on anything so could be used in any project. 11 | 12 | If you look at the code the sqlite storage of properties looks pretty strange but 13 | with SQlite3 the actual storage type is determined per row, so a column can contain 14 | different data types in each row. That should make all those empty rows more or less 15 | acceptable. We are trying to mimic a document database here after all. 16 | 17 | ## Using MySQL 18 | 19 | 20 | To use MySQL, switch the implementation for the interfaces in your `Objects.yaml` 21 | and configure the DB connection as needed: 22 | 23 | Flowpack\SimpleSearch\Domain\Service\IndexInterface: 24 | className: 'Flowpack\SimpleSearch\Domain\Service\MysqlIndex' 25 | 26 | Neos\ContentRepository\Search\Search\QueryBuilderInterface: 27 | className: 'Flowpack\SimpleSearch\ContentRepositoryAdaptor\Search\MysqlQueryBuilder' 28 | 29 | Flowpack\SimpleSearch\Domain\Service\MysqlIndex: 30 | arguments: 31 | 1: 32 | value: 'Neos_CR' 33 | 2: 34 | value: 'mysql:host=%env:DATABASE_HOST%;dbname=%env:DATABASE_NAME%;charset=utf8mb4' 35 | properties: 36 | username: 37 | value: '%env:DATABASE_USERNAME%' 38 | password: 39 | value: '%env:DATABASE_PASSWORD%' 40 | 41 | The `arguments` are the index identifier (can be chosen freely) and the DSN. 42 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowpack/simplesearch", 3 | "type": "neos-package", 4 | "description": "Plain PHP search engine using sqlite3 or MySQL as storage backend.", 5 | "license": "MIT", 6 | "require": { 7 | "neos/flow": "^7.3 || ^8.0 || ^9.0 || dev-master" 8 | }, 9 | "autoload": { 10 | "psr-4": { 11 | "Flowpack\\SimpleSearch\\": "Classes" 12 | } 13 | }, 14 | "extra": { 15 | "applied-flow-migrations": [ 16 | "TYPO3.FLOW3-201201261636", 17 | "TYPO3.Fluid-201205031303", 18 | "TYPO3.FLOW3-201205292145", 19 | "TYPO3.FLOW3-201206271128", 20 | "TYPO3.FLOW3-201209201112", 21 | "TYPO3.Flow-201209251426", 22 | "TYPO3.Flow-201211151101", 23 | "TYPO3.Flow-201212051340", 24 | "TYPO3.TypoScript-130516234520", 25 | "TYPO3.TypoScript-130516235550", 26 | "TYPO3.TYPO3CR-130523180140", 27 | "TYPO3.Neos.NodeTypes-201309111655", 28 | "TYPO3.Flow-201310031523", 29 | "TYPO3.Flow-201405111147", 30 | "TYPO3.Neos-201407061038", 31 | "TYPO3.Neos-201409071922", 32 | "TYPO3.TYPO3CR-140911160326", 33 | "TYPO3.Neos-201410010000", 34 | "TYPO3.TYPO3CR-141101082142", 35 | "TYPO3.Neos-20141113115300", 36 | "TYPO3.Fluid-20141113120800", 37 | "TYPO3.Flow-20141113121400", 38 | "TYPO3.Fluid-20141121091700", 39 | "TYPO3.Neos-20141218134700", 40 | "TYPO3.Fluid-20150214130800", 41 | "TYPO3.Neos-20150303231600", 42 | "TYPO3.TYPO3CR-20150510103823", 43 | "TYPO3.Flow-20151113161300", 44 | "TYPO3.Form-20160601101500", 45 | "TYPO3.Flow-20161115140400", 46 | "TYPO3.Flow-20161115140430", 47 | "Neos.Flow-20161124204700", 48 | "Neos.Flow-20161124204701", 49 | "Neos.Twitter.Bootstrap-20161124204912", 50 | "Neos.Form-20161124205254", 51 | "Neos.Flow-20161124224015", 52 | "Neos.Party-20161124225257", 53 | "Neos.Eel-20161124230101", 54 | "Neos.Kickstart-20161124230102", 55 | "Neos.Setup-20161124230842", 56 | "Neos.Imagine-20161124231742", 57 | "Neos.Media-20161124233100", 58 | "Neos.NodeTypes-20161125002300", 59 | "Neos.SiteKickstarter-20161125002311", 60 | "Neos.Neos-20161125002322", 61 | "Neos.ContentRepository-20161125012000", 62 | "Neos.Fusion-20161125013710", 63 | "Neos.Setup-20161125014759", 64 | "Neos.SiteKickstarter-20161125095901", 65 | "Neos.Fusion-20161125104701", 66 | "Neos.NodeTypes-20161125104800", 67 | "Neos.Neos-20161125104802", 68 | "Neos.Kickstarter-20161125110814", 69 | "Neos.Neos-20161125122412", 70 | "Neos.Flow-20161125124112" 71 | ] 72 | } 73 | } 74 | --------------------------------------------------------------------------------