Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 182
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
DefaultDataImportCommand
0.00% covered (danger)
0.00%
0 / 182
0.00% covered (danger)
0.00%
0 / 4
3306
0.00% covered (danger)
0.00%
0 / 1
 buildOptionParser
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
306
 importTableData
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 1
1056
 toggleMySqlForeignKeyChecks
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2declare(strict_types=1);
3
4namespace App\Command;
5
6use Cake\Command\Command;
7use Cake\Console\Arguments;
8use Cake\Console\ConsoleIo;
9use Cake\Console\ConsoleOptionParser;
10use Cake\Database\Connection;
11// Keep for potential direct connection use if needed elsewhere
12use Cake\ORM\Exception\MissingTableClassException;
13use Cake\ORM\TableRegistry;
14use Cake\Utility\Inflector;
15use Exception; // General exceptions
16
17/**
18 * DefaultDataImportCommand
19 *
20 * This command allows importing data from JSON files in the specified input directory
21 * into database tables. It can import data for a specific table, all tables found
22 * as .json files, or allow interactive selection.
23 * For each table, existing data is deleted before new data is inserted, wrapped in a transaction.
24 * Can optionally disable MySQL foreign key checks during import.
25 */
26class DefaultDataImportCommand extends Command
27{
28    /**
29     * Configures the option parser for the command.
30     *
31     * @param \Cake\Console\ConsoleOptionParser $parser The option parser to configure.
32     * @return \Cake\Console\ConsoleOptionParser The configured option parser.
33     */
34    protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
35    {
36        $parser
37            ->setDescription('Imports default data from JSON files into specified tables (MySQL focused).')
38            ->addArgument('table', [
39                'help' => 'The specific table (PascalCase, e.g., Users) to 
40                    import data for. The file name is inferred (e.g., users.json).',
41                'required' => false,
42            ])
43            ->addOption('input', [
44                'short' => 'i',
45                'help' => 'Input directory containing the JSON files.',
46                'default' => ROOT . DS . 'default_data',
47            ])
48            ->addOption('all', [
49                'short' => 'a',
50                'help' => 'Import data from all JSON files found in the input directory.',
51                'boolean' => true,
52            ])
53            ->addOption('disable-fk-checks', [
54                'help' => 'Temporarily disable MySQL foreign key checks during import.',
55                'boolean' => true,
56                'default' => false,
57            ]);
58
59        return $parser;
60    }
61
62    /**
63     * Executes the command to import data.
64     *
65     * @param \Cake\Console\Arguments $args The command arguments.
66     * @param \Cake\Console\ConsoleIo $io The console io object.
67     * @return int The exit code of the command.
68     */
69    public function execute(Arguments $args, ConsoleIo $io): int
70    {
71        $inputDir = (string)$args->getOption('input');
72        $specificTableArg = $args->getArgument('table');
73        $importAll = (bool)$args->getOption('all');
74        $disableFkChecks = (bool)$args->getOption('disable-fk-checks');
75
76        if (!is_dir($inputDir)) {
77            if (!mkdir($inputDir, 0755, true) && !is_dir($inputDir)) {
78                $io->error(sprintf('Input directory "%s" does not exist and could not be created.', $inputDir));
79
80                return Command::CODE_ERROR;
81            }
82            $io->info(sprintf('Input directory "%s" created.', $inputDir));
83        }
84
85        $filesToProcess = [];
86
87        if ($specificTableArg !== null) {
88            $baseFileName = Inflector::underscore($specificTableArg);
89            $filePath = $inputDir . DS . $baseFileName . '.json';
90
91            if (!file_exists($filePath)) {
92                $io->error(sprintf('No JSON file found for table "%s" (expected: %s).', $specificTableArg, $filePath));
93
94                return Command::CODE_ERROR;
95            }
96            $filesToProcess[] = $filePath;
97        } else {
98            $jsonFilesInDir = glob($inputDir . DS . '*.json');
99            if ($jsonFilesInDir === false || empty($jsonFilesInDir)) {
100                $io->warning(sprintf('No JSON files found in the directory: %s', $inputDir));
101
102                return Command::CODE_SUCCESS;
103            }
104
105            if ($importAll) {
106                $filesToProcess = $jsonFilesInDir;
107            } else {
108                $io->out('Available data files:');
109                foreach ($jsonFilesInDir as $index => $file) {
110                    $io->out(sprintf('[%d] %s', $index + 1, basename($file)));
111                }
112
113                $choiceStr = $io->ask('Choose a file to import by number:');
114                if (!ctype_digit($choiceStr)) {
115                    $io->error('Invalid input. Please enter a number. Exiting.');
116
117                    return Command::CODE_ERROR;
118                }
119                $choiceIndex = (int)$choiceStr - 1;
120
121                if (!isset($jsonFilesInDir[$choiceIndex])) {
122                    $io->error('Invalid choice. Exiting.');
123
124                    return Command::CODE_ERROR;
125                }
126                $filesToProcess[] = $jsonFilesInDir[$choiceIndex];
127            }
128        }
129
130        if (count($filesToProcess) === 0) {
131            $io->info('No files selected or found for import.');
132
133            return Command::CODE_SUCCESS;
134        }
135
136        $successfulImports = 0;
137        $failedImports = 0;
138
139        foreach ($filesToProcess as $filePath) {
140            $baseFileName = basename($filePath, '.json');
141            if ($this->importTableData($baseFileName, $inputDir, $io, $disableFkChecks)) {
142                $successfulImports++;
143            } else {
144                $failedImports++;
145            }
146        }
147
148        if ($successfulImports > 0) {
149            $io->success(sprintf('Successfully imported data from %d file(s).', $successfulImports));
150        }
151        if ($failedImports > 0) {
152            $io->error(sprintf('Failed to import data from %d file(s). Check messages above.', $failedImports));
153
154            return Command::CODE_ERROR;
155        }
156
157        return Command::CODE_SUCCESS;
158    }
159
160    /**
161     * Imports data from a JSON file into the specified table.
162     *
163     * @param string $baseFileName The base name of the JSON file (e.g., 'users', 'user_permissions').
164     * @param string $inputDir The directory containing the JSON files.
165     * @param \Cake\Console\ConsoleIo $io The console io object.
166     * @param bool $disableFkChecks Whether to temporarily disable MySQL foreign key checks.
167     * @return bool True on success, false on failure.
168     */
169    protected function importTableData(
170        string $baseFileName,
171        string $inputDir,
172        ConsoleIo $io,
173        bool $disableFkChecks = false,
174    ): bool {
175        $tableAlias = Inflector::camelize($baseFileName);
176        $inputFile = $inputDir . DS . $baseFileName . '.json';
177
178        $io->out(sprintf('Processing import for table "%s" from file "%s"...', $tableAlias, $baseFileName . '.json'));
179
180        if (!file_exists($inputFile)) {
181            $io->error(sprintf('Input file not found: %s', $inputFile));
182
183            return false;
184        }
185
186        try {
187            $table = TableRegistry::getTableLocator()->get($tableAlias);
188        } catch (MissingTableClassException $e) {
189            $io->error(
190                sprintf(
191                    'Table class for "%s" (derived from file "%s.json") could not be found. Error: %s',
192                    $tableAlias,
193                    $baseFileName,
194                    $e->getMessage(),
195                ),
196            );
197
198            return false;
199        }
200
201        $jsonContent = file_get_contents($inputFile);
202        if ($jsonContent === false) {
203            $io->error(sprintf('Could not read file content from: %s', $inputFile));
204
205            return false;
206        }
207
208        $data = json_decode($jsonContent, true);
209
210        if ($data === null && json_last_error() !== JSON_ERROR_NONE) {
211            $io->error(sprintf('Failed to decode JSON from file: %s. Error: %s', $inputFile, json_last_error_msg()));
212
213            return false;
214        }
215
216        if (empty($data) && $jsonContent !== '[]' && $jsonContent !== '{}') {
217            $io->warning(
218                sprintf(
219                    'No data to import from %s, or JSON content was not a valid array of records.',
220                    $inputFile,
221                ),
222            );
223
224            return true;
225        }
226        if (empty($data) && ($jsonContent === '[]' || $jsonContent === '{}')) {
227            $io->info(
228                sprintf(
229                    'File %s contains an empty JSON array/object. No records to import for table %s.',
230                    $inputFile,
231                    $tableAlias,
232                ),
233            );
234
235            return true;
236        }
237
238        $connection = $table->getConnection();
239
240        try {
241            if ($disableFkChecks) {
242                $this->toggleMySqlForeignKeyChecks($connection, false, $io);
243            }
244
245            $connection->begin();
246            $io->info(sprintf('Attempting to delete existing records from table: %s', $tableAlias));
247            $deleteResult = $table->deleteAll([]);
248            $io->out(sprintf('Deleted %d existing record(s) from table: %s', $deleteResult, $tableAlias));
249
250            $importedCount = 0;
251            foreach ($data as $rowIndex => $row) {
252                $entity = $table->newEntity($row);
253
254                if ($tableAlias === 'Settings' && isset($entity->value_type) && property_exists($entity, 'value')) {
255                    switch ($entity->value_type) {
256                        case 'text':
257                        case 'string':
258                            $entity->value = (string)$entity->value;
259                            break;
260                        case 'numeric':
261                        case 'integer':
262                            $entity->value = (int)$entity->value;
263                            break;
264                        case 'bool':
265                        case 'boolean':
266                            if (is_string($entity->value) && strtolower($entity->value) === 'false') {
267                                $entity->value = false;
268                            } elseif (is_string($entity->value) && $entity->value === '0') {
269                                $entity->value = false;
270                            } else {
271                                $entity->value = (bool)$entity->value;
272                            }
273                            break;
274                        case 'float':
275                            $entity->value = (float)$entity->value;
276                            break;
277                    }
278                }
279
280                // Optionally skip ORM-level rule checks if data is known to be valid
281                if (!$table->save($entity, ['checkRules' => false, 'checkExisting' => false])) {
282                    throw new Exception(sprintf(
283                        'Failed to save entity for table %s (row %d). Errors: %s',
284                        $tableAlias,
285                        $rowIndex,
286                        json_encode($entity->getErrors()),
287                    ));
288                }
289                $importedCount++;
290            }
291
292            $connection->commit();
293            $io->success(sprintf('Successfully imported %d record(s) into table: %s', $importedCount, $tableAlias));
294
295            return true;
296        } catch (Exception $e) {
297            if ($connection->inTransaction()) {
298                $connection->rollback();
299            }
300            $io->error(sprintf(
301                'Error importing data for table %s: %s. Transaction rolled back.',
302                $tableAlias,
303                $e->getMessage(),
304            ));
305
306            return false;
307        } finally {
308            // ALWAYS re-enable FK checks if they were disabled
309            if ($disableFkChecks) {
310                $this->toggleMySqlForeignKeyChecks($connection, true, $io);
311            }
312        }
313    }
314
315    /**
316     * Helper method to toggle MySQL foreign key checks.
317     *
318     * @param \Cake\Database\Connection $connection The database connection.
319     * @param bool $enable True to enable, false to disable.
320     * @param \Cake\Console\ConsoleIo $io The console io object.
321     * @return void
322     */
323    protected function toggleMySqlForeignKeyChecks(
324        Connection $connection,
325        bool $enable,
326        ConsoleIo $io,
327    ): void {
328        $action = $enable ? 'Enabling' : 'Disabling';
329        $mode = $enable ? '1' : '0';
330        $io->info(sprintf('%s MySQL foreign key checks...', $action));
331        try {
332            $connection->execute('SET FOREIGN_KEY_CHECKS=' . $mode . ';');
333            $io->info(sprintf('MySQL foreign key checks %s.', ($enable ? 'enabled' : 'disabled')));
334        } catch (Exception $e) {
335            $io->error(sprintf('Failed to %s MySQL foreign key checks: %s', strtolower($action), $e->getMessage()));
336            // Attempt to revert to enabled state if disabling failed, or notify if enabling failed.
337            if (!$enable) {
338                $io->warning('Attempting to re-enable MySQL foreign key checks due to previous error...');
339                try {
340                    $connection->execute('SET FOREIGN_KEY_CHECKS=1;');
341                    $io->info('MySQL foreign key checks re-enabled.');
342                } catch (Exception $reEnableEx) {
343                    $io->error('Critical: Failed to re-enable MySQL foreign key checks: ' . $reEnableEx->getMessage());
344                }
345            }
346        }
347    }
348}