Passed
Push — master ( af632f...e91275 )
by
unknown
16:49 queued 08:02
created

normalizeBracketedArraysToCsv()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 27
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 19
nc 1
nop 2
dl 0
loc 27
rs 9.6333
c 1
b 0
f 0
1
<?php
2
/* For licensing terms, see /license.txt */
3
4
declare(strict_types=1);
5
6
namespace Chamilo\CoreBundle\Migrations\Schema\V200;
7
8
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
9
use Chamilo\CoreBundle\Settings\SettingsManager;
10
use Chamilo\CoreBundle\Transformer\ArrayToIdentifierTransformer;
11
use Doctrine\DBAL\ArrayParameterType;
12
use Doctrine\DBAL\Connection;
13
use Doctrine\DBAL\Schema\Schema;
14
use Sylius\Bundle\SettingsBundle\Schema\SettingsBuilder;
15
16
final class Version20251014132200 extends AbstractMigrationChamilo
17
{
18
    /** Toggle verbose debug logs */
19
    private const DEBUG = false;
20
21
    public function getDescription(): string
22
    {
23
        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.';
24
    }
25
26
    public function up(Schema $schema): void
27
    {
28
        $conn = $this->connection;
29
30
        $this->dbg('--- [START] Checkbox-like settings consolidation ---');
31
32
        // Discover checkbox-like variables dynamically (from Schemas)
33
        $targetVars = $this->discoverCheckboxLikeVariables();
34
        if (empty($targetVars)) {
35
            $this->dbg('[INFO] No checkbox-like variables discovered; nothing to do.');
36
            $this->dbg('--- [END] Checkbox-like settings consolidation ---');
37
            return;
38
        }
39
40
        // Normalize bracketed arrays -> CSV (e.g. ['a','b'] -> a,b) for those variables
41
        $this->normalizeBracketedArraysToCsv($conn, $targetVars);
42
43
        // Consolidate duplicates by (access_url, category, variable)
44
        $this->consolidateDuplicatesFor($conn, $targetVars);
45
46
        // Safety pass: any remaining duplicates (for any variable)
47
        $this->consolidateAnyRemainingDuplicates($conn);
48
49
        $this->dbg('--- [END] Checkbox-like settings consolidation ---');
50
    }
51
52
    public function down(Schema $schema): void
53
    {
54
        $this->dbg('[WARN] Down migration is a no-op.');
55
    }
56
57
    /**
58
     * Get variables that behave as multi/checkbox: by transformer, allowedTypes=array, or default=array.
59
     *
60
     * @return string[]
61
     */
62
    private function discoverCheckboxLikeVariables(): array
63
    {
64
        /** @var SettingsManager|null $manager */
65
        $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

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