Version20251014132200::consolidateOne()   F
last analyzed

Complexity

Conditions 23
Paths 122

Size

Total Lines 112
Code Lines 70

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 23
eloc 70
nc 122
nop 4
dl 0
loc 112
rs 3.9833
c 1
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\CoreBundle\Migrations\Schema\V200;
8
9
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
10
use Chamilo\CoreBundle\Settings\SettingsManager;
11
use Chamilo\CoreBundle\Transformer\ArrayToIdentifierTransformer;
12
use Doctrine\DBAL\ArrayParameterType;
13
use Doctrine\DBAL\Connection;
14
use Doctrine\DBAL\Schema\Schema;
15
use Sylius\Bundle\SettingsBundle\Schema\SettingsBuilder;
16
use Throwable;
17
18
use const SORT_FLAG_CASE;
19
use const SORT_STRING;
20
21
final class Version20251014132200 extends AbstractMigrationChamilo
22
{
23
    /**
24
     * Toggle verbose debug logs.
25
     */
26
    private const DEBUG = false;
27
28
    public function getDescription(): string
29
    {
30
        return 'Auto-detect checkbox/multi settings from Schemas and consolidate to single row per (access_url, category, variable) with CSV values built from enabled subkeys.';
31
    }
32
33
    public function up(Schema $schema): void
34
    {
35
        $conn = $this->connection;
36
37
        $this->dbg('--- [START] Checkbox-like settings consolidation ---');
38
39
        // Discover checkbox-like variables dynamically (from Schemas)
40
        $targetVars = $this->discoverCheckboxLikeVariables();
41
        if (empty($targetVars)) {
42
            $this->dbg('[INFO] No checkbox-like variables discovered; nothing to do.');
43
            $this->dbg('--- [END] Checkbox-like settings consolidation ---');
44
45
            return;
46
        }
47
48
        // Normalize bracketed arrays -> CSV (e.g. ['a','b'] -> a,b) for those variables
49
        $this->normalizeBracketedArraysToCsv($conn, $targetVars);
50
51
        // Consolidate duplicates by (access_url, category, variable)
52
        $this->consolidateDuplicatesFor($conn, $targetVars);
53
54
        // Safety pass: any remaining duplicates (for any variable)
55
        $this->consolidateAnyRemainingDuplicates($conn);
56
57
        $this->dbg('--- [END] Checkbox-like settings consolidation ---');
58
    }
59
60
    public function down(Schema $schema): void
61
    {
62
        $this->dbg('[WARN] Down migration is a no-op.');
63
    }
64
65
    /**
66
     * Get variables that behave as multi/checkbox: by transformer, allowedTypes=array, or default=array.
67
     *
68
     * @return string[]
69
     */
70
    private function discoverCheckboxLikeVariables(): array
71
    {
72
        /** @var SettingsManager|null $manager */
73
        $manager = $this->container->get(SettingsManager::class);
0 ignored issues
show
Bug introduced by
The method get() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

73
        /** @scrutinizer ignore-call */ 
74
        $manager = $this->container->get(SettingsManager::class);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
74
        if (!$manager) {
75
            $this->dbg('[WARN] SettingsManager not available; fallback to empty list.');
76
77
            return [];
78
        }
79
80
        $vars = [];
81
        $schemas = $manager->getSchemas();
82
        $this->dbg('[INFO] Schemas discovered: '.\count($schemas));
83
84
        foreach (array_keys($schemas) as $serviceId) {
85
            $namespace = $manager->convertServiceToNameSpace($serviceId);
86
            $this->dbg('[SCAN] Inspecting schema: '.$namespace);
87
88
            // Build SettingsBuilder to inspect allowedTypes and transformers
89
            $sb = new SettingsBuilder();
90
            $schemas[$serviceId]->buildSettings($sb);
91
92
            // Transformer: ArrayToIdentifierTransformer
93
            foreach ($sb->getTransformers() as $param => $transformer) {
94
                if ($transformer instanceof ArrayToIdentifierTransformer) {
95
                    $vars[$param] = true;
96
                    $this->dbg("[DETECT] '{$param}' flagged by ArrayToIdentifierTransformer");
97
                }
98
            }
99
100
            // Allowed types contains 'array'
101
            $allowed = $this->safeGetAllowedTypes($sb);
102
            foreach ($allowed as $param => $types) {
103
                if (\in_array('array', (array) $types, true)) {
104
                    $vars[$param] = true;
105
                    $this->dbg("[DETECT] '{$param}' flagged by allowedTypes=array");
106
                }
107
            }
108
109
            // Default value from schema is array
110
            $bag = $manager->load($namespace);
111
            $params = method_exists($bag, 'getParameters') ? (array) $bag->getParameters() : [];
112
            foreach ($params as $param => $defVal) {
113
                if (\is_array($defVal)) {
114
                    $vars[$param] = true;
115
                    $this->dbg("[DETECT] '{$param}' flagged by default=array");
116
                }
117
            }
118
        }
119
120
        $list = array_keys($vars);
121
        sort($list, SORT_STRING | SORT_FLAG_CASE);
122
        $this->dbg('[INFO] Checkbox-like variables: '.implode(', ', $list));
123
124
        return $list;
125
    }
126
127
    private function safeGetAllowedTypes(SettingsBuilder $sb): array
128
    {
129
        try {
130
            if (method_exists($sb, 'getAllowedTypes')) {
131
                /** @var array<string, string[]> $types */
132
                $types = $sb->getAllowedTypes();
133
134
                return $types ?? [];
135
            }
136
        } catch (Throwable $e) {
137
            $this->dbg('[WARN] Could not get allowedTypes: '.$e->getMessage());
138
        }
139
140
        return [];
141
    }
142
143
    private function normalizeBracketedArraysToCsv(Connection $conn, array $targetVars): void
144
    {
145
        $this->dbg('[STEP] Normalizing bracketed arrays to CSV');
146
147
        // Count rows starting with '[' after trimming left spaces
148
        $count = (int) $conn->fetchOne(
149
            "SELECT COUNT(*) FROM settings
150
             WHERE variable IN (?)
151
               AND selected_value IS NOT NULL
152
               AND LTRIM(selected_value) LIKE '[%'",
153
            [$targetVars],
154
            [ArrayParameterType::STRING]
155
        );
156
        $this->dbg("[INFO] Rows to normalize (bracketed arrays): {$count}");
157
158
        // Strip [ ] " ' and trim extra commas at ends
159
        $sql = <<<'SQL'
160
UPDATE settings
161
   SET selected_value = TRIM(BOTH ',' FROM
162
                             REPLACE(REPLACE(REPLACE(REPLACE(selected_value,'[',''),']',''),'"',''),'''',''))
163
 WHERE variable IN (?)
164
   AND selected_value IS NOT NULL
165
   AND LTRIM(selected_value) LIKE '[%'
166
SQL;
167
168
        $affected = $conn->executeStatement($sql, [$targetVars], [ArrayParameterType::STRING]);
169
        $this->dbg("[DONE] Normalized rows: {$affected}");
170
    }
171
172
    private function consolidateDuplicatesFor(Connection $conn, array $targetVars): void
173
    {
174
        $this->dbg('[STEP] Consolidating duplicates for detected variables (grouped by access_url, category, variable)');
175
176
        $dups = $conn->fetchAllAssociative(
177
            'SELECT access_url, category, variable, COUNT(*) c
178
               FROM settings
179
              WHERE variable IN (?)
180
              GROUP BY access_url, category, variable
181
             HAVING c > 1',
182
            [$targetVars],
183
            [ArrayParameterType::STRING]
184
        );
185
186
        $this->dbg('[INFO] Duplicate groups found: '.\count($dups));
187
188
        foreach ($dups as $row) {
189
            $accessUrl = $row['access_url']; // can be null
190
            $category = (string) $row['category'];
191
            $var = (string) $row['variable'];
192
            $cnt = (int) $row['c'];
193
194
            $this->dbg("[GROUP] Consolidating variable='{$var}' category='{$category}' access_url=".
195
                (null === $accessUrl ? 'NULL' : (string) $accessUrl)." (rows={$cnt})");
196
197
            $this->consolidateOne($conn, $accessUrl, $category, $var);
198
        }
199
    }
200
201
    private function consolidateAnyRemainingDuplicates(Connection $conn): void
202
    {
203
        $this->dbg('[STEP] Safety pass: consolidating remaining duplicates (any variable)');
204
205
        $dups = $conn->fetchAllAssociative(
206
            'SELECT access_url, category, variable, COUNT(*) c
207
               FROM settings
208
              GROUP BY access_url, category, variable
209
             HAVING c > 1'
210
        );
211
212
        $this->dbg('[INFO] Remaining duplicate groups: '.\count($dups));
213
214
        foreach ($dups as $row) {
215
            $accessUrl = $row['access_url'];
216
            $category = (string) $row['category'];
217
            $var = (string) $row['variable'];
218
            $cnt = (int) $row['c'];
219
220
            $this->dbg("[GROUP] (safety) Consolidating variable='{$var}' category='{$category}' access_url=".
221
                (null === $accessUrl ? 'NULL' : (string) $accessUrl)." (rows={$cnt})");
222
223
            $this->consolidateOne($conn, $accessUrl, $category, $var);
224
        }
225
    }
226
227
    /**
228
     * Consolidate one (access_url, category, variable) group into a single row.
229
     * Correct rule:
230
     *  - If rows have subkey: CSV = list of subkeys whose value is "truthy" (1/true/yes/on) OR equals the subkey (e.g. showonline: subkey=course, value='course').
231
     *  - If no subkey rows: CSV from selected_value tokens (cleaned).
232
     *
233
     * @param int|string|null $accessUrl
234
     */
235
    private function consolidateOne(Connection $conn, $accessUrl, string $category, string $variable): void
236
    {
237
        // Build SELECT based on nullability of access_url
238
        if (null === $accessUrl) {
239
            $items = $conn->fetchAllAssociative(
240
                'SELECT id, selected_value, subkey
241
                   FROM settings
242
                  WHERE access_url IS NULL AND category = ? AND variable = ?
243
                  ORDER BY id ASC',
244
                [$category, $variable]
245
            );
246
        } else {
247
            $items = $conn->fetchAllAssociative(
248
                'SELECT id, selected_value, subkey
249
                   FROM settings
250
                  WHERE access_url = ? AND category = ? AND variable = ?
251
                  ORDER BY id ASC',
252
                [$accessUrl, $category, $variable]
253
            );
254
        }
255
256
        if (empty($items)) {
257
            $this->dbg("[SKIP] No rows for variable='{$variable}' category='{$category}' access_url=".
258
                (null === $accessUrl ? 'NULL' : (string) $accessUrl));
259
260
            return;
261
        }
262
263
        $this->dbg("[WORK] Processing variable='{$variable}' category='{$category}' access_url=".
264
            (null === $accessUrl ? 'NULL' : (string) $accessUrl).' (row_count='.\count($items).')');
265
266
        // Determine if there is at least one subkey among rows
267
        $hasSubkey = false;
268
        foreach ($items as $it) {
269
            if (!empty($it['subkey'])) {
270
                $hasSubkey = true;
271
272
                break;
273
            }
274
        }
275
276
        // Build final token set (preserve first-seen order)
277
        $enabled = [];
278
279
        if ($hasSubkey) {
280
            // Multi-row with subkeys: include subkey when value is truthy OR equal to subkey
281
            foreach ($items as $it) {
282
                $id = (int) $it['id'];
283
                $rawVal = (string) ($it['selected_value'] ?? '');
284
                $subkey = $it['subkey'] ?? null;
285
286
                $clean = str_replace(['[', ']', '"', "'", ' '], '', $rawVal);
287
                $this->dbg("[TOKENIZE] id={$id} subkey=".
288
                    (null === $subkey ? 'NULL' : "'".$subkey."'").
289
                    " raw='{$rawVal}' cleaned='{$clean}'");
290
291
                if (null !== $subkey && '' !== $subkey) {
292
                    $v = strtolower(trim($rawVal));
293
                    if ($this->isTruthy($v) || $v === strtolower($subkey)) {
294
                        if (!isset($enabled[$subkey])) {
295
                            $enabled[$subkey] = true;
296
                        }
297
                    }
298
                } else {
299
                    // Defensive: a row without subkey inside a subkey group -> parse as CSV
300
                    foreach ($this->splitCsvTokens($clean) as $tok) {
301
                        if (!isset($enabled[$tok])) {
302
                            $enabled[$tok] = true;
303
                        }
304
                    }
305
                }
306
            }
307
        } else {
308
            // No subkeys at all: merge CSV tokens from all rows
309
            foreach ($items as $it) {
310
                $id = (int) $it['id'];
311
                $rawVal = (string) ($it['selected_value'] ?? '');
312
                $clean = str_replace(['[', ']', '"', "'", ' '], '', $rawVal);
313
314
                $this->dbg("[TOKENIZE] id={$id} subkey=NULL raw='{$rawVal}' cleaned='{$clean}'");
315
316
                foreach ($this->splitCsvTokens($clean) as $tok) {
317
                    if (!isset($enabled[$tok])) {
318
                        $enabled[$tok] = true;
319
                    }
320
                }
321
            }
322
        }
323
324
        $finalTokens = array_keys($enabled);
325
        $csv = implode(',', $finalTokens);
326
        $keepId = (int) $items[0]['id'];
327
328
        $this->dbg("[UPDATE] KEEP id={$keepId} variable='{$variable}' category='{$category}' access_url=".
329
            (null === $accessUrl ? 'NULL' : (string) $accessUrl)." final_csv='{$csv}'");
330
331
        $conn->executeStatement(
332
            'UPDATE settings SET selected_value = ? WHERE id = ?',
333
            [$csv, $keepId]
334
        );
335
336
        // Delete the rest
337
        $idsToDelete = array_map(static fn ($it) => (int) $it['id'], \array_slice($items, 1));
338
        if ($idsToDelete) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $idsToDelete of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
339
            $this->dbg("[DELETE] variable='{$variable}' category='{$category}' access_url=".
340
                (null === $accessUrl ? 'NULL' : (string) $accessUrl).' deleting_ids='.
341
                json_encode($idsToDelete));
342
343
            $in = implode(',', array_fill(0, \count($idsToDelete), '?'));
344
            $conn->executeStatement("DELETE FROM settings WHERE id IN ($in)", $idsToDelete);
345
        } else {
346
            $this->dbg('[KEEPONLY] Only one row existed; nothing deleted.');
347
        }
348
    }
349
350
    private function isTruthy(string $v): bool
351
    {
352
        $v = strtolower(trim($v));
353
354
        return '1' === $v || 'true' === $v || 'yes' === $v || 'on' === $v;
355
    }
356
357
    private function splitCsvTokens(string $clean): array
358
    {
359
        if ('' === $clean) {
360
            return [];
361
        }
362
        $parts = array_map('trim', explode(',', $clean));
363
        $parts = array_filter($parts, static fn ($x) => '' !== $x);
364
365
        return array_values($parts);
366
    }
367
368
    private function dbg(string $msg): void
369
    {
370
        if (self::DEBUG) {
371
            error_log('[MIG][CheckboxFix] '.$msg);
372
        }
373
    }
374
}
375