├── 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 | [](https://packagist.org/packages/flowpack/simplesearch) [](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 |
--------------------------------------------------------------------------------