These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more
1 | <?php |
||
2 | |||
3 | namespace SilverStripe\SQLite; |
||
4 | |||
5 | use SilverStripe\ORM\DataList; |
||
6 | use SilverStripe\ORM\ArrayList; |
||
7 | use SilverStripe\ORM\Connect\SS_Database; |
||
8 | use Config; |
||
9 | use Deprecation; |
||
10 | use PaginatedList; |
||
11 | use SilverStripe\ORM\Queries\SQLSelect; |
||
12 | |||
13 | |||
14 | /** |
||
15 | * SQLite database controller class |
||
16 | * |
||
17 | * @package SQLite3 |
||
18 | */ |
||
19 | class SQLite3Database extends SS_Database |
||
20 | { |
||
21 | |||
22 | /** |
||
23 | * Database schema manager object |
||
24 | * |
||
25 | * @var SQLite3SchemaManager |
||
26 | */ |
||
27 | protected $schemaManager = null; |
||
28 | |||
29 | /* |
||
30 | * This holds the parameters that the original connection was created with, |
||
31 | * so we can switch back to it if necessary (used for unit tests) |
||
32 | * |
||
33 | * @var array |
||
34 | */ |
||
35 | protected $parameters; |
||
36 | |||
37 | /* |
||
38 | * if we're on a In-Memory db |
||
39 | * |
||
40 | * @var boolean |
||
41 | */ |
||
42 | protected $livesInMemory = false; |
||
43 | |||
44 | /** |
||
45 | * List of default pragma values |
||
46 | * |
||
47 | * @todo Migrate to SS config |
||
48 | * |
||
49 | * @var array |
||
50 | */ |
||
51 | public static $default_pragma = array( |
||
52 | 'encoding' => '"UTF-8"', |
||
53 | 'locking_mode' => 'NORMAL' |
||
54 | ); |
||
55 | |||
56 | |||
57 | /** |
||
58 | * Extension used to distinguish between sqllite database files and other files. |
||
59 | * Required to handle multiple databases. |
||
60 | * |
||
61 | * @return string |
||
62 | */ |
||
63 | public static function database_extension() |
||
64 | { |
||
65 | return Config::inst()->get('SQLite3Database', 'database_extension'); |
||
66 | } |
||
67 | |||
68 | /** |
||
69 | * Check if a database name has a valid extension |
||
70 | * |
||
71 | * @param string $name |
||
72 | * @return boolean |
||
73 | */ |
||
74 | public static function is_valid_database_name($name) |
||
75 | { |
||
76 | $extension = self::database_extension(); |
||
77 | if (empty($extension)) { |
||
78 | return true; |
||
79 | } |
||
80 | |||
81 | return substr_compare($name, $extension, -strlen($extension), strlen($extension)) === 0; |
||
82 | } |
||
83 | |||
84 | /** |
||
85 | * Connect to a SQLite3 database. |
||
86 | * @param array $parameters An map of parameters, which should include: |
||
87 | * - database: The database to connect to, with the correct file extension (.sqlite) |
||
88 | * - path: the path to the SQLite3 database file |
||
89 | * - key: the encryption key (needs testing) |
||
90 | * - memory: use the faster In-Memory database for unit tests |
||
91 | */ |
||
92 | public function connect($parameters) |
||
93 | { |
||
94 | if (!empty($parameters['memory'])) { |
||
95 | Deprecation::notice( |
||
96 | '1.4.0', |
||
97 | "\$databaseConfig['memory'] is deprecated. Use \$databaseConfig['path'] = ':memory:' instead.", |
||
98 | Deprecation::SCOPE_GLOBAL |
||
99 | ); |
||
100 | unset($parameters['memory']); |
||
101 | $parameters['path'] = ':memory:'; |
||
102 | } |
||
103 | |||
104 | //We will store these connection parameters for use elsewhere (ie, unit tests) |
||
105 | $this->parameters = $parameters; |
||
106 | $this->schemaManager->flushCache(); |
||
107 | |||
108 | // Ensure database name is set |
||
109 | if (empty($parameters['database'])) { |
||
110 | $parameters['database'] = 'database' . self::database_extension(); |
||
111 | } |
||
112 | $dbName = $parameters['database']; |
||
113 | if (!self::is_valid_database_name($dbName)) { |
||
114 | // If not using the correct file extension for database files then the |
||
115 | // results of SQLite3SchemaManager::databaseList will be unpredictable |
||
116 | $extension = self::database_extension(); |
||
117 | Deprecation::notice('3.2', "SQLite3Database now expects a database file with extension \"$extension\". Behaviour may be unpredictable otherwise."); |
||
118 | } |
||
119 | |||
120 | // use the very lightspeed SQLite In-Memory feature for testing |
||
121 | if ($this->getLivesInMemory()) { |
||
122 | $file = ':memory:'; |
||
123 | } else { |
||
124 | // Ensure path is given |
||
125 | if (empty($parameters['path'])) { |
||
126 | $parameters['path'] = ASSETS_PATH . '/.sqlitedb'; |
||
127 | } |
||
128 | |||
129 | //assumes that the path to dbname will always be provided: |
||
130 | $file = $parameters['path'] . '/' . $dbName; |
||
131 | if (!file_exists($parameters['path'])) { |
||
132 | SQLiteDatabaseConfigurationHelper::create_db_dir($parameters['path']); |
||
133 | SQLiteDatabaseConfigurationHelper::secure_db_dir($parameters['path']); |
||
134 | } |
||
135 | } |
||
136 | |||
137 | // 'path' and 'database' are merged into the full file path, which |
||
138 | // is the format that connectors such as PDOConnector expect |
||
139 | $parameters['filepath'] = $file; |
||
140 | |||
141 | // Ensure that driver is available (required by PDO) |
||
142 | if (empty($parameters['driver'])) { |
||
143 | $parameters['driver'] = $this->getDatabaseServer(); |
||
144 | } |
||
145 | |||
146 | $this->connector->connect($parameters, true); |
||
147 | |||
148 | foreach (self::$default_pragma as $pragma => $value) { |
||
149 | $this->setPragma($pragma, $value); |
||
150 | } |
||
151 | |||
152 | if (empty(self::$default_pragma['locking_mode'])) { |
||
153 | self::$default_pragma['locking_mode'] = $this->getPragma('locking_mode'); |
||
154 | } |
||
155 | } |
||
156 | |||
157 | /** |
||
158 | * Retrieve parameters used to connect to this SQLLite database |
||
159 | * |
||
160 | * @return array |
||
161 | */ |
||
162 | public function getParameters() |
||
163 | { |
||
164 | return $this->parameters; |
||
165 | } |
||
166 | |||
167 | public function getLivesInMemory() |
||
168 | { |
||
169 | return isset($this->parameters['path']) && $this->parameters['path'] === ':memory:'; |
||
170 | } |
||
171 | |||
172 | public function supportsCollations() |
||
173 | { |
||
174 | return true; |
||
175 | } |
||
176 | |||
177 | public function supportsTimezoneOverride() |
||
178 | { |
||
179 | return false; |
||
180 | } |
||
181 | |||
182 | /** |
||
183 | * Execute PRAGMA commands. |
||
184 | * |
||
185 | * @param string $pragma name |
||
186 | * @param string $value to set |
||
187 | */ |
||
188 | public function setPragma($pragma, $value) |
||
189 | { |
||
190 | $this->query("PRAGMA $pragma = $value"); |
||
191 | } |
||
192 | |||
193 | /** |
||
194 | * Gets pragma value. |
||
195 | * |
||
196 | * @param string $pragma name |
||
197 | * @return string the pragma value |
||
198 | */ |
||
199 | public function getPragma($pragma) |
||
200 | { |
||
201 | return $this->query("PRAGMA $pragma")->value(); |
||
202 | } |
||
203 | |||
204 | public function getDatabaseServer() |
||
205 | { |
||
206 | return "sqlite"; |
||
207 | } |
||
208 | |||
209 | public function selectDatabase($name, $create = false, $errorLevel = E_USER_ERROR) |
||
210 | { |
||
211 | if (!$this->schemaManager->databaseExists($name)) { |
||
212 | // Check DB creation permisson |
||
213 | if (!$create) { |
||
214 | if ($errorLevel !== false) { |
||
215 | user_error("Attempted to connect to non-existing database \"$name\"", $errorLevel); |
||
216 | } |
||
217 | // Unselect database |
||
218 | $this->connector->unloadDatabase(); |
||
219 | return false; |
||
220 | } |
||
221 | $this->schemaManager->createDatabase($name); |
||
222 | } |
||
223 | |||
224 | // Reconnect using the existing parameters |
||
225 | $parameters = $this->parameters; |
||
226 | $parameters['database'] = $name; |
||
227 | $this->connect($parameters); |
||
228 | return true; |
||
229 | } |
||
230 | |||
231 | public function now() |
||
232 | { |
||
233 | return "datetime('now', 'localtime')"; |
||
234 | } |
||
235 | |||
236 | public function random() |
||
237 | { |
||
238 | return 'random()'; |
||
239 | } |
||
240 | |||
241 | /** |
||
242 | * The core search engine configuration. |
||
243 | * @todo There is a fulltext search for SQLite making use of virtual tables, the fts3 extension and the |
||
244 | * MATCH operator |
||
245 | * there are a few issues with fts: |
||
246 | * - shared cached lock doesn't allow to create virtual tables on versions prior to 3.6.17 |
||
247 | * - there must not be more than one MATCH operator per statement |
||
248 | * - the fts3 extension needs to be available |
||
249 | * for now we use the MySQL implementation with the MATCH()AGAINST() uglily replaced with LIKE |
||
250 | * |
||
251 | * @param array $classesToSearch |
||
252 | * @param string $keywords Keywords as a space separated string |
||
253 | * @param int $start |
||
254 | * @param int $pageLength |
||
255 | * @param string $sortBy |
||
256 | * @param string $extraFilter |
||
257 | * @param bool $booleanSearch |
||
258 | * @param string $alternativeFileFilter |
||
259 | * @param bool $invertedMatch |
||
260 | * @return PaginatedList DataObjectSet of result pages |
||
261 | */ |
||
262 | public function searchEngine($classesToSearch, $keywords, $start, $pageLength, $sortBy = "Relevance DESC", |
||
263 | $extraFilter = "", $booleanSearch = false, $alternativeFileFilter = "", $invertedMatch = false |
||
264 | ) { |
||
265 | $keywords = $this->escapeString(str_replace(array('*', '+', '-', '"', '\''), '', $keywords)); |
||
266 | $htmlEntityKeywords = htmlentities(utf8_decode($keywords)); |
||
267 | |||
268 | $extraFilters = array('SiteTree' => '', 'File' => ''); |
||
269 | |||
270 | if ($extraFilter) { |
||
271 | $extraFilters['SiteTree'] = " AND $extraFilter"; |
||
272 | |||
273 | if ($alternativeFileFilter) { |
||
274 | $extraFilters['File'] = " AND $alternativeFileFilter"; |
||
275 | } else { |
||
276 | $extraFilters['File'] = $extraFilters['SiteTree']; |
||
277 | } |
||
278 | } |
||
279 | |||
280 | // Always ensure that only pages with ShowInSearch = 1 can be searched |
||
281 | $extraFilters['SiteTree'] .= ' AND ShowInSearch <> 0'; |
||
282 | // File.ShowInSearch was added later, keep the database driver backwards compatible |
||
283 | // by checking for its existence first |
||
284 | $fields = $this->getSchemaManager()->fieldList('File'); |
||
285 | if (array_key_exists('ShowInSearch', $fields)) { |
||
286 | $extraFilters['File'] .= " AND ShowInSearch <> 0"; |
||
287 | } |
||
288 | |||
289 | $limit = $start . ", " . (int) $pageLength; |
||
290 | |||
291 | $notMatch = $invertedMatch ? "NOT " : ""; |
||
292 | if ($keywords) { |
||
293 | $match['SiteTree'] = " |
||
294 | (Title LIKE '%$keywords%' OR MenuTitle LIKE '%$keywords%' OR Content LIKE '%$keywords%' OR MetaDescription LIKE '%$keywords%' OR |
||
295 | Title LIKE '%$htmlEntityKeywords%' OR MenuTitle LIKE '%$htmlEntityKeywords%' OR Content LIKE '%$htmlEntityKeywords%' OR MetaDescription LIKE '%$htmlEntityKeywords%') |
||
296 | "; |
||
297 | $match['File'] = "(Name LIKE '%$keywords%' OR Title LIKE '%$keywords%') AND ClassName = 'File'"; |
||
298 | |||
299 | // We make the relevance search by converting a boolean mode search into a normal one |
||
300 | $relevanceKeywords = $keywords; |
||
301 | $htmlEntityRelevanceKeywords = $htmlEntityKeywords; |
||
302 | $relevance['SiteTree'] = "(Title LIKE '%$relevanceKeywords%' OR MenuTitle LIKE '%$relevanceKeywords%' OR Content LIKE '%$relevanceKeywords%' OR MetaDescription LIKE '%$relevanceKeywords%') + (Title LIKE '%$htmlEntityRelevanceKeywords%' OR MenuTitle LIKE '%$htmlEntityRelevanceKeywords%' OR Content LIKE '%$htmlEntityRelevanceKeywords%' OR MetaDescription LIKE '%$htmlEntityRelevanceKeywords%')"; |
||
303 | $relevance['File'] = "(Name LIKE '%$relevanceKeywords%' OR Title LIKE '%$relevanceKeywords%')"; |
||
304 | } else { |
||
305 | $relevance['SiteTree'] = $relevance['File'] = 1; |
||
306 | $match['SiteTree'] = $match['File'] = "1 = 1"; |
||
307 | } |
||
308 | |||
309 | // Generate initial queries and base table names |
||
310 | $baseClasses = array('SiteTree' => '', 'File' => ''); |
||
311 | $queries = array(); |
||
312 | foreach ($classesToSearch as $class) { |
||
313 | $queries[$class] = DataList::create($class) |
||
314 | ->where($notMatch . $match[$class] . $extraFilters[$class]) |
||
315 | ->dataQuery() |
||
316 | ->query(); |
||
317 | $fromArr = $queries[$class]->getFrom(); |
||
318 | $baseClasses[$class] = reset($fromArr); |
||
319 | } |
||
320 | |||
321 | // Make column selection lists |
||
322 | $select = array( |
||
323 | 'SiteTree' => array( |
||
324 | "\"ClassName\"", |
||
325 | "\"ID\"", |
||
326 | "\"ParentID\"", |
||
327 | "\"Title\"", |
||
328 | "\"URLSegment\"", |
||
329 | "\"Content\"", |
||
330 | "\"LastEdited\"", |
||
331 | "\"Created\"", |
||
332 | "NULL AS \"Name\"", |
||
333 | "\"CanViewType\"", |
||
334 | "$relevance[SiteTree] AS Relevance" |
||
335 | ), |
||
336 | 'File' => array( |
||
337 | "\"ClassName\"", |
||
338 | "\"ID\"", |
||
339 | "NULL AS \"ParentID\"", |
||
340 | "\"Title\"", |
||
341 | "NULL AS \"URLSegment\"", |
||
342 | "NULL AS \"Content\"", |
||
343 | "\"LastEdited\"", |
||
344 | "\"Created\"", |
||
345 | "\"Name\"", |
||
346 | "NULL AS \"CanViewType\"", |
||
347 | "$relevance[File] AS Relevance" |
||
348 | ) |
||
349 | ); |
||
350 | |||
351 | // Process queries |
||
352 | foreach ($classesToSearch as $class) { |
||
353 | // There's no need to do all that joining |
||
354 | $queries[$class]->setFrom($baseClasses[$class]); |
||
355 | |||
356 | $queries[$class]->setSelect(array()); |
||
357 | foreach ($select[$class] as $clause) { |
||
358 | if (preg_match('/^(.*) +AS +"?([^"]*)"?/i', $clause, $matches)) { |
||
359 | $queries[$class]->selectField($matches[1], $matches[2]); |
||
360 | } else { |
||
361 | $queries[$class]->selectField(str_replace('"', '', $clause)); |
||
362 | } |
||
363 | } |
||
364 | |||
365 | $queries[$class]->setOrderBy(array()); |
||
366 | } |
||
367 | |||
368 | // Combine queries |
||
369 | $querySQLs = array(); |
||
370 | $queryParameters = array(); |
||
371 | $totalCount = 0; |
||
372 | foreach ($queries as $query) { |
||
373 | /** @var SQLSelect $query */ |
||
374 | $querySQLs[] = $query->sql($parameters); |
||
375 | $queryParameters = array_merge($queryParameters, $parameters); |
||
376 | $totalCount += $query->unlimitedRowCount(); |
||
377 | } |
||
378 | |||
379 | $fullQuery = implode(" UNION ", $querySQLs) . " ORDER BY $sortBy LIMIT $limit"; |
||
380 | // Get records |
||
381 | $records = $this->preparedQuery($fullQuery, $queryParameters); |
||
382 | |||
383 | foreach ($records as $record) { |
||
384 | $objects[] = new $record['ClassName']($record); |
||
385 | } |
||
386 | |||
387 | if (isset($objects)) { |
||
388 | $doSet = new ArrayList($objects); |
||
389 | } else { |
||
390 | $doSet = new ArrayList(); |
||
391 | } |
||
392 | $list = new PaginatedList($doSet); |
||
393 | $list->setPageStart($start); |
||
394 | $list->setPageLength($pageLength); |
||
395 | $list->setTotalItems($totalCount); |
||
396 | return $list; |
||
397 | } |
||
398 | |||
399 | /* |
||
400 | * Does this database support transactions? |
||
401 | */ |
||
402 | public function supportsTransactions() |
||
403 | { |
||
404 | return version_compare($this->getVersion(), '3.6', '>='); |
||
405 | } |
||
406 | |||
407 | public function supportsExtensions($extensions = array('partitions', 'tablespaces', 'clustering')) |
||
408 | { |
||
409 | if (isset($extensions['partitions'])) { |
||
410 | return true; |
||
411 | } elseif (isset($extensions['tablespaces'])) { |
||
412 | return true; |
||
413 | } elseif (isset($extensions['clustering'])) { |
||
414 | return true; |
||
415 | } else { |
||
416 | return false; |
||
417 | } |
||
418 | } |
||
419 | |||
420 | public function transactionStart($transaction_mode = false, $session_characteristics = false) |
||
421 | { |
||
422 | $this->query('BEGIN'); |
||
423 | } |
||
424 | |||
425 | public function transactionSavepoint($savepoint) |
||
426 | { |
||
427 | $this->query("SAVEPOINT \"$savepoint\""); |
||
428 | } |
||
429 | |||
430 | public function transactionRollback($savepoint = false) |
||
431 | { |
||
432 | if ($savepoint) { |
||
433 | $this->query("ROLLBACK TO $savepoint;"); |
||
434 | } else { |
||
435 | $this->query('ROLLBACK;'); |
||
436 | } |
||
437 | } |
||
438 | |||
439 | public function transactionEnd($chain = false) |
||
440 | { |
||
441 | $this->query('COMMIT;'); |
||
442 | } |
||
443 | |||
444 | public function clearTable($table) |
||
445 | { |
||
446 | $this->query("DELETE FROM \"$table\""); |
||
447 | } |
||
448 | |||
449 | public function comparisonClause($field, $value, $exact = false, $negate = false, $caseSensitive = null, |
||
450 | $parameterised = false |
||
451 | ) { |
||
452 | if ($exact && !$caseSensitive) { |
||
453 | $comp = ($negate) ? '!=' : '='; |
||
454 | } else { |
||
455 | if ($caseSensitive) { |
||
456 | // GLOB uses asterisks as wildcards. |
||
457 | // Replace them in search string, without replacing escaped percetage signs. |
||
458 | $comp = 'GLOB'; |
||
459 | $value = preg_replace('/^%([^\\\\])/', '*$1', $value); |
||
460 | $value = preg_replace('/([^\\\\])%$/', '$1*', $value); |
||
461 | $value = preg_replace('/([^\\\\])%/', '$1*', $value); |
||
462 | } else { |
||
463 | $comp = 'LIKE'; |
||
464 | } |
||
465 | if ($negate) { |
||
466 | $comp = 'NOT ' . $comp; |
||
467 | } |
||
468 | } |
||
469 | |||
470 | if ($parameterised) { |
||
471 | return sprintf("%s %s ?", $field, $comp); |
||
472 | } else { |
||
473 | return sprintf("%s %s '%s'", $field, $comp, $value); |
||
474 | } |
||
475 | } |
||
476 | |||
477 | public function formattedDatetimeClause($date, $format) |
||
478 | { |
||
479 | preg_match_all('/%(.)/', $format, $matches); |
||
480 | foreach ($matches[1] as $match) { |
||
481 | if (array_search($match, array('Y', 'm', 'd', 'H', 'i', 's', 'U')) === false) { |
||
482 | user_error('formattedDatetimeClause(): unsupported format character %' . $match, E_USER_WARNING); |
||
483 | } |
||
484 | } |
||
485 | |||
486 | $translate = array( |
||
487 | '/%i/' => '%M', |
||
488 | '/%s/' => '%S', |
||
489 | '/%U/' => '%s', |
||
490 | ); |
||
491 | $format = preg_replace(array_keys($translate), array_values($translate), $format); |
||
492 | |||
493 | $modifiers = array(); |
||
494 | if ($format == '%s' && $date != 'now') { |
||
495 | $modifiers[] = 'utc'; |
||
496 | } |
||
497 | if ($format != '%s' && $date == 'now') { |
||
498 | $modifiers[] = 'localtime'; |
||
499 | } |
||
500 | |||
501 | View Code Duplication | if (preg_match('/^now$/i', $date)) { |
|
0 ignored issues
–
show
|
|||
502 | $date = "'now'"; |
||
503 | } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) { |
||
504 | $date = "'$date'"; |
||
505 | } |
||
506 | |||
507 | $modifier = empty($modifiers) ? '' : ", '" . implode("', '", $modifiers) . "'"; |
||
508 | return "strftime('$format', $date$modifier)"; |
||
509 | } |
||
510 | |||
511 | public function datetimeIntervalClause($date, $interval) |
||
512 | { |
||
513 | $modifiers = array(); |
||
514 | if ($date == 'now') { |
||
515 | $modifiers[] = 'localtime'; |
||
516 | } |
||
517 | |||
518 | View Code Duplication | if (preg_match('/^now$/i', $date)) { |
|
0 ignored issues
–
show
This code seems to be duplicated across your project.
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation. You can also find more detailed suggestions in the “Code” section of your repository. ![]() |
|||
519 | $date = "'now'"; |
||
520 | } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) { |
||
521 | $date = "'$date'"; |
||
522 | } |
||
523 | |||
524 | $modifier = empty($modifiers) ? '' : ", '" . implode("', '", $modifiers) . "'"; |
||
525 | return "datetime($date$modifier, '$interval')"; |
||
526 | } |
||
527 | |||
528 | public function datetimeDifferenceClause($date1, $date2) |
||
529 | { |
||
530 | $modifiers1 = array(); |
||
531 | $modifiers2 = array(); |
||
532 | |||
533 | if ($date1 == 'now') { |
||
534 | $modifiers1[] = 'localtime'; |
||
535 | } |
||
536 | if ($date2 == 'now') { |
||
537 | $modifiers2[] = 'localtime'; |
||
538 | } |
||
539 | |||
540 | if (preg_match('/^now$/i', $date1)) { |
||
541 | $date1 = "'now'"; |
||
542 | } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date1)) { |
||
543 | $date1 = "'$date1'"; |
||
544 | } |
||
545 | |||
546 | if (preg_match('/^now$/i', $date2)) { |
||
547 | $date2 = "'now'"; |
||
548 | } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date2)) { |
||
549 | $date2 = "'$date2'"; |
||
550 | } |
||
551 | |||
552 | $modifier1 = empty($modifiers1) ? '' : ", '" . implode("', '", $modifiers1) . "'"; |
||
553 | $modifier2 = empty($modifiers2) ? '' : ", '" . implode("', '", $modifiers2) . "'"; |
||
554 | |||
555 | return "strftime('%s', $date1$modifier1) - strftime('%s', $date2$modifier2)"; |
||
556 | } |
||
557 | } |
||
558 |
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.