Lines 95.68% 355 / 371
Methods 88.23% 30 / 34
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 mapSearchRowToProcess 100.00% 29 / 29 100.00% 1 / 1 6
 readSearch 100.00% 19 / 19 100.00% 1 / 1 2
 readSearchCount 100.00% 8 / 8 100.00% 1 / 1 1
 buildSearchQuery 89.79% 44 / 49 0.00% 0 / 1 10.11
 extractSearchQuery 100.00% 4 / 4 100.00% 1 / 1 3
 addSearchConditions 100.00% 9 / 9 100.00% 1 / 1 1
 addBasicSearchConditions 100.00% 20 / 20 100.00% 1 / 1 10
 addAdditionalSearchConditions 100.00% 20 / 20 100.00% 1 / 1 11
 resolveSearchReferences 100.00% 8 / 8 100.00% 1 / 1 2
 buildCombinedSearchSql 100.00% 31 / 31 100.00% 1 / 1 1
 shouldDenyHistory 100.00% 4 / 4 100.00% 1 / 1 4
 readScopeIdsParameter 90.00% 9 / 10 0.00% 0 / 1 3.01
 wrapCombinedSearchSql 100.00% 9 / 9 100.00% 1 / 1 3
 buildCombinedSearchCountSql 100.00% 11 / 11 100.00% 1 / 1 1
 getHistoryAppointmentFrom 78.26% 18 / 23 0.00% 0 / 1 4.16
 [BO\Zmsbackend\Base] __construct 100.00% 2 / 2 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] init 100.00% 2 / 2 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] getWriter 100.00% 4 / 4 100.00% 1 / 1 2
 [BO\Zmsbackend\Base] getReader 100.00% 5 / 5 100.00% 1 / 1 3
 [BO\Zmsbackend\Base] fetchPreparedStatement 100.00% 9 / 9 100.00% 1 / 1 3
 [BO\Zmsbackend\Base] startExecute 100.00% 5 / 5 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] pdoExceptionHandler 100.00% 13 / 13 100.00% 1 / 1 4
 [BO\Zmsbackend\Base] fetchStatement 100.00% 3 / 3 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] fetchOne 100.00% 7 / 7 100.00% 1 / 1 2
 [BO\Zmsbackend\Base] fetchRow 100.00% 7 / 7 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] fetchValue 100.00% 7 / 7 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] fetchAll 100.00% 7 / 7 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] fetchHandle 100.00% 5 / 5 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] fetchList 100.00% 8 / 8 100.00% 1 / 1 2
 [BO\Zmsbackend\Base] writeItem 100.00% 7 / 7 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] deleteItem 100.00% 1 / 1 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] perform 100.00% 7 / 7 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] fetchAffected 100.00% 8 / 8 100.00% 1 / 1 1
 [BO\Zmsbackend\Base] readResolvedReferences n/a 0 / 0 n/a 0 / 0 1
 [BO\Zmsbackend\Base] hashStringValue 50.00% 5 / 10 0.00% 0 / 1 8.12
11class ProcessSearch extends \BO\Zmsbackend\Base
12{
13    private const int DEFAULT_HISTORY_DAYS = 90;
14
15    public function mapSearchRowToProcess(array $row): Entity
16    {
17        return new Entity([
18            'id' => (int) $row['process_id'],
19            'displayNumber' => (string) ($row['display_number'] ?? ''),
20
21            'clients__0__familyName' => (string) ($row['citizen_name'] ?? ''),
22            'clients__0__telephone' => (string) ($row['telephone'] ?? ''),
23            'clients__0__email' => (string) ($row['citizen_email'] ?? ''),
24
25            'amendment' => (string) ($row['amendment'] ?? ''),
26
27            'appointments__0__date' => !empty($row['appointment_at'])
28                ? strtotime($row['appointment_at'])
29                : 0,
30
31            'appointments__0__scope__id' => (int) $row['scope_id'],
32
33            'createTimestamp' => ($row['source'] ?? '') === 'active'
34                ? (int) ($row['booked_at'] ?? 0)
35                : (!empty($row['booked_at'])
36                    ? strtotime($row['booked_at'])
37                    : 0),
38
39            'queue__callTime' => !empty($row['called_at'])
40                ? strtotime($row['called_at'])
41                : 0,
42
43            'scope__id' => (int) $row['scope_id'],
44            'scope__shortName' => (string) ($row['location_name'] ?? ''),
45            'scope__contact__name' => (string) ($row['provider_name'] ?? ''),
46
47            'status' => (string) $row['technical_status'],
48            'source' => (string) $row['source'],
49            'appointmentStatus' => (string) $row['appointment_status'],
50            'finalizedAt' => !empty($row['finalized_at'])
51                ? strtotime((string) $row['finalized_at'])
52                : 0,
53        ]);
54    }
55
56    public function readSearch(
57        array $parameter,
58        int $resolveReferences = 0,
59        int $limit = 100,
60        int $offset = 0
61    ): Collection {
62        $combined = $this->buildCombinedSearchSql(
63            $parameter,
64            $this->getHistoryAppointmentFrom(),
65            (int) $limit,
66            (int) $offset
67        );
68
69        $rows = $this->fetchAll(
70            $combined['sql'],
71            $combined['parameters']
72        );
73
74        $processList = new Collection();
75
76        foreach ($rows as $row) {
77            $entity = $this->mapSearchRowToProcess($row);
78
79            $entity = $this->resolveSearchReferences(
80                $entity,
81                $resolveReferences
82            );
83
84            $processList->addEntity($entity);
85        }
86
87        return $processList;
88    }
89
90    public function readSearchCount(array $parameter): int
91    {
92        $combined = $this->buildCombinedSearchCountSql(
93            $parameter,
94            $this->getHistoryAppointmentFrom()
95        );
96
97        return (int) $this->fetchValue(
98            $combined['sql'],
99            $combined['parameters']
100        );
101    }
102
103    protected function buildSearchQuery(
104        array $parameter
105    ): ProcessSearchRepository {
106        $query = new ProcessSearchRepository(QueryBase::SELECT);
107
108        $query
109            ->addConditionAssigned()
110            ->addConditionIgnoreSlots()
111            ->addConditionActiveSearchStatuses();
112
113        if (!empty($parameter['upcomingOnly'])) {
114            $now = class_exists('\App') && isset(\App::$now)
115                ? \App::$now
116                : new \DateTimeImmutable(
117                    'now',
118                    new \DateTimeZone('Europe/Berlin')
119                );
120
121            $query->addConditionUpcomingOnly($now);
122        }
123
124        if (array_key_exists('scopeIds', $parameter)) {
125            $scopeIds = is_array($parameter['scopeIds'])
126                ? $parameter['scopeIds']
127                : array_map(
128                    'intval',
129                    explode(',', (string) $parameter['scopeIds'])
130                );
131
132            $scopeIds = array_values(
133                array_filter($scopeIds)
134            );
135
136            $query->addConditionScopeIds($scopeIds);
137        }
138
139        if (isset($parameter['query'])) {
140            if (preg_match('#^\d+$#', $parameter['query'])) {
141                $query->addConditionProcessIdOrSearch(
142                    $parameter['query']
143                );
144            } else {
145                $query->addConditionSearch(
146                    $parameter['query']
147                );
148            }
149
150            unset($parameter['query']);
151        }
152
153        if (count($parameter)) {
154            foreach (
155                [
156                    'upcomingOnly',
157                    'scopeIds',
158                    'page',
159                    'limit',
160                    'offset',
161                    'includePast',
162                    'denyHistory',
163                ] as $reservedKey
164            ) {
165                unset($parameter[$reservedKey]);
166            }
167
168            $query = $this->addSearchConditions(
169                $query,
170                $parameter
171            );
172        }
173
174        return $query;
175    }
176
177    protected function extractSearchQuery(array $parameter): ?string
178    {
179        if (!isset($parameter['query'])) {
180            return null;
181        }
182
183        $queryString = trim((string) $parameter['query']);
184
185        return $queryString !== '' ? $queryString : null;
186    }
187
188    protected function addSearchConditions(
189        ProcessSearchRepository $query,
190        array $parameter
191    ): ProcessSearchRepository {
192        $this->addBasicSearchConditions(
193            $query,
194            $parameter
195        );
196
197        $this->addAdditionalSearchConditions(
198            $query,
199            $parameter
200        );
201
202        return $query;
203    }
204
205    private function addBasicSearchConditions(
206        ProcessSearchRepository $query,
207        array $parameter
208    ): void {
209        if (isset($parameter['processId']) && $parameter['processId']) {
210            $query->addConditionProcessId(
211                $parameter['processId']
212            );
213        }
214
215        if (isset($parameter['name']) && $parameter['name']) {
216            $exact = isset($parameter['exact'])
217                ? $parameter['exact']
218                : false;
219
220            $query->addConditionName(
221                $parameter['name'],
222                $exact
223            );
224        }
225
226        if (isset($parameter['amendment']) && $parameter['amendment']) {
227            $query->addConditionAmendment(
228                $parameter['amendment']
229            );
230        }
231
232        if (isset($parameter['scopeId']) && $parameter['scopeId']) {
233            $query->addConditionScopeId(
234                $parameter['scopeId']
235            );
236        }
237    }
238
239    private function addAdditionalSearchConditions(
240        ProcessSearchRepository $query,
241        array $parameter
242    ): void {
243        if (isset($parameter['authKey']) && $parameter['authKey']) {
244            $query->addConditionAuthKey(
245                $parameter['authKey']
246            );
247        }
248
249        if (isset($parameter['requestId']) && $parameter['requestId']) {
250            $query->addConditionRequestId(
251                $parameter['requestId']
252            );
253        }
254
255        if (isset($parameter['provider']) && $parameter['provider']) {
256            $query->addConditionScopeNameSearch(
257                $parameter['provider']
258            );
259        }
260
261        if (isset($parameter['service']) && $parameter['service']) {
262            $query->addConditionServiceNameSearch(
263                $parameter['service']
264            );
265        }
266
267        if (isset($parameter['date']) && $parameter['date']) {
268            $query->addConditionDate(
269                $parameter['date']
270            );
271        }
272    }
273
274    protected function resolveSearchReferences(
275        Entity $entity,
276        int $resolveReferences
277    ): Entity {
278        if (($entity->source ?? 'active') === 'history') {
279            return $entity;
280        }
281
282        (new \BO\Zmsbackend\Process\Service\Process())
283            ->readResolvedReferences(
284                $entity,
285                $resolveReferences
286            );
287
288        return $entity;
289    }
290
291    protected function buildCombinedSearchSql(
292        array $parameter,
293        ?\DateTimeInterface $appointmentFrom = null,
294        ?int $limit = null,
295        int $offset = 0
296    ): array {
297        $searchRepository = $this->buildSearchQuery($parameter);
298        $searchRepository->addCombinedActiveProjection();
299
300        $historyParameters = [];
301        $historySql = $searchRepository->getHistorySelectSql(
302            $this->readScopeIdsParameter($parameter),
303            $appointmentFrom,
304            $this->extractSearchQuery($parameter),
305            $historyParameters,
306            $parameter['date'] ?? null,
307            $parameter['provider'] ?? null,
308            $parameter['service'] ?? null,
309            [
310                'name' => $parameter['name'] ?? null,
311                'exact' => !empty($parameter['exact']),
312                'amendment' => $parameter['amendment'] ?? null,
313                'processId' => $parameter['processId'] ?? null,
314                'scopeId' => $parameter['scopeId'] ?? null,
315                'denyHistory' => $this->shouldDenyHistory($parameter),
316            ]
317        );
318
319        return [
320            'sql' => $this->wrapCombinedSearchSql(
321                $searchRepository->getCombinedSelectSql($historySql),
322                $limit,
323                $offset
324            ),
325            'parameters' => array_merge(
326                $searchRepository->getParameters(),
327                $historyParameters
328            ),
329        ];
330    }
331
332    private function shouldDenyHistory(array $parameter): bool
333    {
334        return !empty($parameter['denyHistory'])
335            || !empty($parameter['authKey'])
336            || !empty($parameter['requestId'])
337            || !empty($parameter['upcomingOnly']);
338    }
339
340    private function readScopeIdsParameter(array $parameter): ?array
341    {
342        if (!array_key_exists('scopeIds', $parameter)) {
343            return null;
344        }
345
346        $scopeIds = is_array($parameter['scopeIds'])
347            ? $parameter['scopeIds']
348            : explode(',', (string) $parameter['scopeIds']);
349
350        return array_values(
351            array_filter(
352                array_map('intval', $scopeIds)
353            )
354        );
355    }
356
357    private function wrapCombinedSearchSql(
358        string $combinedSql,
359        ?int $limit,
360        int $offset
361    ): string {
362        $sql = '
363            SELECT *
364            FROM (
365                ' . $combinedSql . '
366            ) combined
367            ORDER BY
368                combined.appointment_at DESC,
369                combined.source_record_id DESC,
370                combined.source ASC
371        ';
372
373        if ($limit === null) {
374            return $sql;
375        }
376
377        $sql .= ' LIMIT ' . max(0, $limit);
378
379        if ($offset > 0) {
380            $sql .= ' OFFSET ' . max(0, $offset);
381        }
382
383        return $sql;
384    }
385
386    protected function buildCombinedSearchCountSql(
387        array $parameter,
388        ?\DateTimeInterface $appointmentFrom = null
389    ): array {
390        $combined = $this->buildCombinedSearchSql(
391            $parameter,
392            $appointmentFrom
393        );
394
395        $sql = '
396            SELECT COUNT(*) AS total_count
397            FROM (
398                ' . $combined['sql'] . '
399            ) combined_count
400        ';
401
402        return [
403            'sql' => $sql,
404            'parameters' => $combined['parameters'],
405        ];
406    }
407
408    protected function getHistoryAppointmentFrom(): \DateTimeImmutable
409    {
410        $now = class_exists('\App') && isset(\App::$now)
411            ? \App::$now
412            : new \DateTimeImmutable(
413                'now',
414                new \DateTimeZone('Europe/Berlin')
415            );
416
417        $config = (new ConfigService())->readEntity();
418
419        $retentionDays = filter_var(
420            $config->getPreference(
421                'processSearchHistory',
422                'deleteOlderThanDays'
423            ),
424            FILTER_VALIDATE_INT,
425            [
426                'options' => [
427                    'min_range' => 1,
428                ],
429            ]
430        );
431
432        if ($retentionDays === false) {
433            $retentionDays = self::DEFAULT_HISTORY_DAYS;
434        }
435
436        return \DateTimeImmutable::createFromInterface($now)
437            ->modify('-' . $retentionDays . ' days');
438    }
439}

Inherited from BO\Zmsbackend\Base

38    public function __construct(?Connection\Pdo $writeConnection = null, ?Connection\Pdo $readConnection = null)
39    {
40        $this->writeDb = $writeConnection;
41        $this->readDb = $readConnection;
42    }
44    public static function init(?Connection\Pdo $writeConnection = null, ?Connection\Pdo $readConnection = null): static
45    {
46        $instance = new static($writeConnection, $readConnection);
47        return $instance;
48    }
50    public function getWriter(): Connection\Pdo
51    {
52        if (null === $this->writeDb) {
53            $this->writeDb = Connection\Select::getWriteConnection();
54            $this->readDb = $this->writeDb;
55        }
56        return $this->writeDb;
57    }
59    public function getReader(): Connection\Pdo
60    {
61        if (null !== $this->writeDb) {
62            // if readDB gets a reset, still use writeDB
63            return $this->writeDb;
64        }
65        if (null === $this->readDb) {
66            $this->readDb = Connection\Select::getReadConnection();
67        }
68        return $this->readDb;
69    }
71    public function fetchPreparedStatement(Query\Base|string $query)
72    {
73        $sql = "$query";
74        $reader = $this->getReader();
75        if (spl_object_hash($reader) != static::$preparedConnectionId) {
76            // do not use prepared statements on a different connection
77            static::$preparedCache = [];
78            static::$preparedConnectionId = spl_object_hash($reader);
79        }
80        if (!isset(static::$preparedCache[$sql])) {
81            $prepared = $this->getReader()->prepare($sql);
82            static::$preparedCache[$sql] = $prepared;
83        }
84        return static::$preparedCache[$sql];
85    }
87    public function startExecute($statement, array $parameters)
88    {
89        $statement = static::pdoExceptionHandler(function () use ($statement, $parameters) {
90            $statement->execute($parameters);
91            return $statement;
92        });
93        return $statement;
94    }
96    protected static function pdoExceptionHandler(\Closure $pdoFunction, $parameters = [])
97    {
98        try {
99            $statement = $pdoFunction($parameters);
100        } catch (\PDOException $pdoException) {
101            if (stripos($pdoException->getMessage(), 'Lock wait timeout') !== false) {
102                throw new \BO\Zmsbackend\Exception\Pdo\LockTimeout();
103            }
104            //@codeCoverageIgnoreStart
105            if (stripos($pdoException->getMessage(), 'Deadlock found') !== false) {
106                throw new \BO\Zmsbackend\Exception\Pdo\DeadLockFound();
107            }
108            //@codeCoverageIgnoreEnd
109            $message = "SQL: "
110                . " Err: "
111                . $pdoException->getMessage()
112                //. " || Statement: "
113                //.$statement->queryString
114                //." || Parameters=". var_export($parameters, true)
115                ;
116            throw new \BO\Zmsbackend\Exception\Pdo\PDOFailed($message, 0, $pdoException);
117        }
118        return $statement;
119    }
121    public function fetchStatement(Query\Base $query)
122    {
123        $parameters = $query->getParameters();
124        $statement = $this->startExecute($this->fetchPreparedStatement($query), $parameters);
125        return $statement;
126    }
133    public function fetchOne(Query\Base $query, \BO\Zmsentities\Schema\Entity $entity)
134    {
135        $statement = $this->fetchStatement($query);
136        $data = $statement->fetch(\PDO::FETCH_ASSOC);
137        if ($data) {
138            $entity->exchangeArray($query->postProcessJoins($data));
139            $entity->setResolveLevel($query->getResolveLevel());
140        }
141        $statement->closeCursor();
142        return $entity;
143    }
145    public function fetchRow(Query\Base|string $query, array|null $parameters = null)
146    {
147        return static::pdoExceptionHandler(function () use ($query, $parameters) {
148            $prepared = $this->fetchPreparedStatement($query);
149            $prepared->execute($parameters);
150            $row = $prepared->fetch(\PDO::FETCH_ASSOC);
151            $prepared->closeCursor();
152            return $row;
153        });
154    }
156    public function fetchValue(Query\Base|string $query, array|null $parameters = null)
157    {
158        return static::pdoExceptionHandler(function () use ($query, $parameters) {
159            $prepared = $this->fetchPreparedStatement($query);
160            $prepared->execute($parameters);
161            $value = $prepared->fetchColumn();
162            $prepared->closeCursor();
163            return $value;
164        });
165    }
167    public function fetchAll(Query\Base|string $query, array|null $parameters = null)
168    {
169        return static::pdoExceptionHandler(function () use ($query, $parameters) {
170            $prepared = $this->fetchPreparedStatement($query);
171            $prepared->execute($parameters);
172            $list = $prepared->fetchAll(\PDO::FETCH_ASSOC);
173            $prepared->closeCursor();
174            return $list;
175        });
176    }
178    public function fetchHandle(Query\Base|string $query, array|null $parameters = null)
179    {
180        return static::pdoExceptionHandler(function () use ($query, $parameters) {
181            $prepared = $this->fetchPreparedStatement($query);
182            $prepared->execute($parameters);
183            return $prepared;
184        });
185    }
187    public function fetchList(Query\Base $query, \BO\Zmsentities\Schema\Entity $entity, array|\ArrayAccess $resultList = [])
188    {
189        $statement = $this->fetchStatement($query);
190        while ($data = $statement->fetch(\PDO::FETCH_ASSOC)) {
191            $dataEntity = clone $entity;
192            $dataEntity->exchangeArray($query->postProcessJoins($data));
193            $dataEntity->setResolveLevel($query->getResolveLevel());
194            $resultList[] = $dataEntity;
195        }
196        $statement->closeCursor();
197        return $resultList;
198    }
206    public function writeItem(Query\Base $query)
207    {
208        return static::pdoExceptionHandler(function () use ($query) {
209            $this->getWriter(); //Switch to writer for write/delete
210            $statement = $this->fetchPreparedStatement($query);
211            $status = $statement->execute($query->getParameters());
212            $statement->closeCursor();
213            return $status;
214        });
215    }
221    public function deleteItem(Query\Base $query)
222    {
223        return $this->writeItem($query);
224    }
226    public function perform(string $sql, array|null $parameters = null)
227    {
228        return static::pdoExceptionHandler(function () use ($sql, $parameters) {
229            $this->getWriter(); //Switch to writer for perform
230            $prepared = $this->fetchPreparedStatement($sql);
231            $status = $prepared->execute($parameters);
232            $prepared->closeCursor();
233            return $status;
234        });
235    }
237    public function fetchAffected(string $sql, array|null $parameters = null)
238    {
239        return static::pdoExceptionHandler(function () use ($sql, $parameters) {
240            $this->getWriter(); //Switch to writer for perform
241            $prepared = $this->fetchPreparedStatement($sql);
242            $prepared->execute($parameters);
243            $count = $prepared->rowCount();
244            $prepared->closeCursor();
245            return $count;
246        });
247    }
256    public function readResolvedReferences(\BO\Zmsentities\Schema\Entity $entity, int $resolveReferences)
257    {
258        return $entity;
259    }
268    public function hashStringValue(string $value, ?callable $callable = null): string
269    {
270        if ($callable === null) {
271            $callable = 'sha1';
272        }
273
274        if (is_callable($callable)) {
275            $hash = $callable($value);
276
277            if (is_string($callable)) {
278                return $callable . ':' . $hash;
279            }
280            if ($callable instanceof \Closure) {
281                return 'closure:' . $hash;
282            }
283            // else
284            return 'custom:' . $hash;
285        }
286
287        throw new \InvalidArgumentException('hashStringValue() should be called with a callable as second parameter.');
288    }