Passed
Push — master ( bb1547...2e64cc )
by P.R.
03:46
created

readStoredRoutineMetadata()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3.072

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 3
nop 0
dl 0
loc 8
ccs 4
cts 5
cp 0.8
crap 3.072
rs 10
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
4
namespace SetBased\Stratum\MySql\Backend;
5
6
use SetBased\Exception\RuntimeException;
7
use SetBased\Helper\InvalidCastException;
8
use SetBased\Stratum\Backend\RoutineLoaderWorker;
9
use SetBased\Stratum\Common\Exception\RoutineLoaderException;
10
use SetBased\Stratum\Common\Helper\SourceFinderHelper;
11
use SetBased\Stratum\Middle\Exception\ResultException;
12
use SetBased\Stratum\Middle\Helper\RowSetHelper;
13
use SetBased\Stratum\Middle\NameMangler\NameMangler;
14
use SetBased\Stratum\MySql\Exception\MySqlConnectFailedException;
15
use SetBased\Stratum\MySql\Exception\MySqlDataLayerException;
16
use SetBased\Stratum\MySql\Exception\MySqlQueryErrorException;
17
use SetBased\Stratum\MySql\Helper\RoutineLoaderHelper;
18
use SetBased\Stratum\MySql\Helper\SqlModeHelper;
19
use Symfony\Component\Console\Formatter\OutputFormatter;
20
21
/**
22
 * Command for loading stored routines into a MySQL instance from pseudo SQL files.
23
 */
24
class MySqlRoutineLoaderWorker extends MySqlWorker implements RoutineLoaderWorker
25
{
26
  //--------------------------------------------------------------------------------------------------------------------
27
  /**
28
   * The maximum column size in bytes.
29
   */
30
  const MAX_COLUMN_SIZE = 65532;
31
32
  /**
33
   * Details of all character sets.
34
   *
35
   * @var array[]
36
   */
37
  private $characterSets;
38
39
  /**
40
   * Name of the class that contains all constants.
41
   *
42
   * @var string
43
   */
44
  private $constantClassName;
45
46
  /**
47
   * The default character set under which the stored routine will be loaded and run.
48
   *
49
   * @var string
50
   */
51
  private $defaultCharacterSet;
52
53
  /**
54
   * The default collate under which the stored routine will be loaded and run.
55
   *
56
   * @var string
57
   */
58
  private $defaultCollate;
59
60
  /**
61
   * An array with source filenames that are not loaded into MySQL.
62
   *
63
   * @var string[]
64
   */
65
  private $errorFilenames = [];
66
67
  /**
68
   * Class name for mangling routine and parameter names.
69
   *
70
   * @var string
71
   */
72
  private $nameMangler;
73
74
  /**
75
   * The metadata of all stored routines. Note: this data is stored in the metadata file and is generated by PhpStratum.
76
   *
77
   * @var array
78
   */
79
  private $phpStratumMetadata;
80
81
  /**
82
   * The filename of the file with the metadata of all stored routines.
83
   *
84
   * @var string
85
   */
86
  private $phpStratumMetadataFilename;
87
88
  /**
89
   * Old metadata of all stored routines. Note: this data comes from information_schema.ROUTINES.
90
   *
91
   * @var array
92
   */
93
  private $rdbmsOldMetadata;
94
95
  /**
96
   * A map from placeholders to their actual values.
97
   *
98
   * @var array
99
   */
100
  private $replacePairs = [];
101
102
  /**
103
   * Pattern where of the sources files.
104
   *
105
   * @var string
106
   */
107
  private $sourcePattern;
108
109
  /**
110
   * All sources with stored routines. Each element is an array with the following keys:
111
   * <ul>
112
   * <li> path_name    The path the source file.
113
   * <li> routine_name The name of the routine (equals the basename of the path).
114
   * <li> method_name  The name of the method in the data layer for the wrapper method of the stored routine.
115
   * </ul>
116
   *
117
   * @var array[]
118
   */
119
  private $sources = [];
120
121
  /**
122
   * The SQL mode under which the stored routine will be loaded and run.
123
   *
124
   * @var string
125
   */
126
  private $sqlMode;
127
128
  //--------------------------------------------------------------------------------------------------------------------
129
  /**
130
   * @inheritdoc
131
   *
132
   * @throws InvalidCastException
133
   * @throws MySqlConnectFailedException
134
   * @throws MySqlDataLayerException
135
   * @throws MySqlQueryErrorException
136
   * @throws RuntimeException
137
   * @throws \ReflectionException
138
   * @throws \RuntimeException
139
   */
140 1
  public function execute(?array $sources = null): int
141
  {
142 1
    $this->io->title('PhpStratum: MySql Loader');
143
144 1
    $this->phpStratumMetadataFilename = $this->settings->manString('loader.metadata');
145 1
    $this->sourcePattern              = $this->settings->manString('loader.sources');
146 1
    $this->sqlMode                    = $this->settings->manString('loader.sql_mode');
147 1
    $this->defaultCharacterSet        = $this->settings->manString('loader.character_set');
148 1
    $this->defaultCollate             = $this->settings->manString('loader.collate');
149 1
    $this->constantClassName          = $this->settings->optString('constants.class');
150 1
    $this->nameMangler                = $this->settings->optString('wrapper.mangler_class');
151
152 1
    $this->connect();
153
154 1
    $this->characterSets = $this->dl->allCharacterSets();
155
156 1
    if (empty($sources))
157
    {
158 1
      $this->loadAll();
159
    }
160
    else
161
    {
162
      $this->loadList($sources);
163
    }
164
165 1
    $this->logOverviewErrors();
166
167 1
    $this->disconnect();
168
169 1
    return ($this->errorFilenames) ? 1 : 0;
170
  }
171
172
  //--------------------------------------------------------------------------------------------------------------------
173
  /**
174
   * Detects stored routines that would result in duplicate wrapper method name.
175
   */
176 1
  private function detectNameConflicts(): void
177
  {
178
    // Get same method names from array
179 1
    [$sourcesByPath, $sourcesByMethod] = $this->getDuplicates();
180
181
    // Add every not unique method name to myErrorFileNames
182 1
    foreach ($sourcesByPath as $source)
183
    {
184
      $this->errorFilenames[] = $source['path_name'];
185
    }
186
187
    // Log the sources files with duplicate method names.
188 1
    foreach ($sourcesByMethod as $method => $sources)
189
    {
190
      $tmp = [];
191
      foreach ($sources as $source)
192
      {
193
        $tmp[] = $source['path_name'];
194
      }
195
196
      $this->io->error(sprintf("The following source files would result wrapper methods with equal name '%s'",
197
                               $method));
198
      $this->io->listing($tmp);
199
    }
200
201
    // Remove duplicates from sources.
202 1
    foreach ($this->sources as $key => $source)
203
    {
204 1
      if (isset($sourcesByPath[$source['path_name']]))
205
      {
206
        unset($this->sources[$key]);
207
      }
208
    }
209 1
  }
210
211
  //--------------------------------------------------------------------------------------------------------------------
212
  /**
213
   * Drops obsolete stored routines (i.e. stored routines that exits in the current schema but for which we don't have
214
   * a source file).
215
   *
216
   * @throws MySqlQueryErrorException
217
   */
218 1
  private function dropObsoleteRoutines(): void
219
  {
220
    // Make a lookup table from routine name to source.
221 1
    $lookup = [];
222 1
    foreach ($this->sources as $source)
223
    {
224 1
      $lookup[$source['routine_name']] = $source;
225
    }
226
227
    // Drop all routines not longer in sources.
228 1
    foreach ($this->rdbmsOldMetadata as $oldRoutine)
229
    {
230 1
      if (!isset($lookup[$oldRoutine['routine_name']]))
231
      {
232
        $this->io->logInfo('Dropping %s <dbo>%s</dbo>',
233
                           strtolower($oldRoutine['routine_type']),
234
                           $oldRoutine['routine_name']);
235
236
        $this->dl->dropRoutine($oldRoutine['routine_type'], $oldRoutine['routine_name']);
237
      }
238
    }
239 1
  }
240
241
  //--------------------------------------------------------------------------------------------------------------------
242
  /**
243
   * Searches recursively for all source files.
244
   */
245 1
  private function findSourceFiles(): void
246
  {
247 1
    $helper    = new SourceFinderHelper(dirname($this->settings->manString('stratum.config_path')));
248 1
    $filenames = $helper->findSources($this->sourcePattern);
249
250 1
    foreach ($filenames as $filename)
251
    {
252 1
      $routineName     = pathinfo($filename, PATHINFO_FILENAME);
253 1
      $this->sources[] = ['path_name'    => $filename,
254 1
                          'routine_name' => $routineName,
255 1
                          'method_name'  => $this->methodName($routineName)];
256
    }
257 1
  }
258
259
  //--------------------------------------------------------------------------------------------------------------------
260
  /**
261
   * Finds all source files that actually exists from a list of file names.
262
   *
263
   * @param string[] $sources The list of file names.
264
   */
265
  private function findSourceFilesFromList(array $sources): void
266
  {
267
    foreach ($sources as $path)
268
    {
269
      if (!file_exists($path))
270
      {
271
        $this->io->error(sprintf("File not exists: '%s'", $path));
272
        $this->errorFilenames[] = $path;
273
      }
274
      else
275
      {
276
        $routineName     = pathinfo($path, PATHINFO_FILENAME);
277
        $this->sources[] = ['path_name'    => $path,
278
                            'routine_name' => $routineName,
279
                            'method_name'  => $this->methodName($routineName)];
280
      }
281
    }
282
  }
283
284
  //--------------------------------------------------------------------------------------------------------------------
285
  /**
286
   * Returns all elements in {@link $sources} with duplicate method names.
287
   *
288
   * @return array[]
289
   */
290 1
  private function getDuplicates(): array
291
  {
292
    // First pass make lookup table by method_name.
293 1
    $lookup = [];
294 1
    foreach ($this->sources as $source)
295
    {
296 1
      if (isset($source['method_name']))
297
      {
298 1
        if (!isset($lookup[$source['method_name']]))
299
        {
300 1
          $lookup[$source['method_name']] = [];
301
        }
302
303 1
        $lookup[$source['method_name']][] = $source;
304
      }
305
    }
306
307
    // Second pass find duplicate sources.
308 1
    $duplicatesSources = [];
309 1
    $duplicatesMethods = [];
310 1
    foreach ($this->sources as $source)
311
    {
312 1
      if (sizeof($lookup[$source['method_name']])>1)
313
      {
314
        $duplicatesSources[$source['path_name']]   = $source;
315
        $duplicatesMethods[$source['method_name']] = $lookup[$source['method_name']];
316
      }
317
    }
318
319 1
    return [$duplicatesSources, $duplicatesMethods];
320
  }
321
322
  //--------------------------------------------------------------------------------------------------------------------
323
  /**
324
   * Retrieves information about all stored routines in the current schema.
325
   *
326
   * @throws MySqlQueryErrorException
327
   */
328 1
  private function getOldStoredRoutinesInfo(): void
329
  {
330 1
    $this->rdbmsOldMetadata = [];
331
332 1
    $routines = $this->dl->allRoutines();
333 1
    foreach ($routines as $routine)
334
    {
335 1
      $this->rdbmsOldMetadata[$routine['routine_name']] = $routine;
336
    }
337 1
  }
338
339
  //--------------------------------------------------------------------------------------------------------------------
340
  /**
341
   * Loads all stored routines into MySQL.
342
   *
343
   * @throws InvalidCastException
344
   * @throws MySqlQueryErrorException
345
   * @throws RuntimeException
346
   * @throws \ReflectionException
347
   */
348 1
  private function loadAll(): void
349
  {
350 1
    $this->findSourceFiles();
351 1
    $this->detectNameConflicts();
352 1
    $this->replacePairs();
353 1
    $this->readStoredRoutineMetadata();
354 1
    $this->getOldStoredRoutinesInfo();
355
356 1
    $this->loadStoredRoutines();
357
358 1
    $this->dropObsoleteRoutines();
359 1
    $this->removeObsoleteMetadata();
360
361 1
    $this->io->writeln('');
362
363 1
    $this->writeStoredRoutineMetadata();
364 1
  }
365
366
  //--------------------------------------------------------------------------------------------------------------------
367
  /**
368
   * Loads all stored routines in a list into MySQL.
369
   *
370
   * @param string[] $sources The list of files to be loaded.
371
   *
372
   * @throws InvalidCastException
373
   * @throws MySqlQueryErrorException
374
   * @throws \LogicException
375
   * @throws \ReflectionException
376
   * @throws \RuntimeException
377
   */
378
  private function loadList(array $sources): void
379
  {
380
    $this->findSourceFilesFromList($sources);
381
    $this->detectNameConflicts();
382
    $this->replacePairs();
383
    $this->readStoredRoutineMetadata();
384
    $this->getOldStoredRoutinesInfo();
385
386
    $this->loadStoredRoutines();
387
388
    $this->writeStoredRoutineMetadata();
389
  }
390
391
  //--------------------------------------------------------------------------------------------------------------------
392
  /**
393
   * Loads all stored routines.
394
   *
395
   * @throws InvalidCastException
396
   * @throws MySqlQueryErrorException
397
   * @throws ResultException
398
   */
399 1
  private function loadStoredRoutines(): void
400
  {
401 1
    $this->io->writeln('');
402
403 1
    usort($this->sources, function ($a, $b) {
404 1
      return strcmp($a['routine_name'], $b['routine_name']);
405 1
    });
406
407 1
    $sqlModeHelper = new SqlModeHelper($this->dl, $this->sqlMode);
408
409 1
    foreach ($this->sources as $filename)
410
    {
411 1
      $routineName = $filename['routine_name'];
412
413 1
      $helper = new RoutineLoaderHelper($this->dl,
414 1
                                        $this->io,
415
                                        $sqlModeHelper,
416 1
                                        $filename['path_name'],
417 1
                                        $this->phpStratumMetadata[$routineName] ?? [],
418 1
                                        $this->replacePairs,
419 1
                                        $this->rdbmsOldMetadata[$routineName] ?? [],
420 1
                                        $this->defaultCharacterSet,
421 1
                                        $this->defaultCollate);
422
423
      try
424
      {
425 1
        $this->phpStratumMetadata[$routineName] = $helper->loadStoredRoutine();
426
      }
427
      catch (RoutineLoaderException $e)
428
      {
429
        $messages = [$e->getMessage(),
430
                     sprintf("Failed to load file '%s'", $filename['path_name'])];
431
        $this->io->error($messages);
432
433
        $this->errorFilenames[] = $filename['path_name'];
434
        unset($this->phpStratumMetadata[$routineName]);
435
      }
436
      catch (MySqlQueryErrorException $e)
437
      {
438
        // Exception is caused by a SQL error. Log the message and the SQL statement with highlighting the error.
439
        $this->io->error($e->getMessage());
440
        $this->io->text($e->styledQuery());
441
442
        $this->errorFilenames[] = $filename['path_name'];
443
        unset($this->phpStratumMetadata[$routineName]);
444
      }
445
      catch (MySqlDataLayerException $e)
446
      {
447
        $this->io->error($e->getMessage());
448
449
        $this->errorFilenames[] = $filename['path_name'];
450
        unset($this->phpStratumMetadata[$routineName]);
451
      }
452
    }
453 1
  }
454
455
  //--------------------------------------------------------------------------------------------------------------------
456
  /**
457
   * Logs the source files that were not successfully loaded into MySQL.
458
   */
459 1
  private function logOverviewErrors(): void
460
  {
461 1
    if (!empty($this->errorFilenames))
462
    {
463
      $this->io->warning('Routines in the files below are not loaded:');
464
      $this->io->listing($this->errorFilenames);
465
    }
466 1
  }
467
468
  //--------------------------------------------------------------------------------------------------------------------
469
  /**
470
   * Returns the maximum number of characters in a VARCHAR or CHAR.
471
   *
472
   * @param string $characterSetName The name of the character set of the column.
473
   *
474
   * @return int
475
   */
476 1
  private function maxCharacters(string $characterSetName): ?int
477
  {
478 1
    $key = RowSetHelper::searchInRowSet($this->characterSets, 'character_set_name', $characterSetName);
479 1
    if ($key===null) return null;
480
481 1
    $size = $this->characterSets[$key]['maxlen'];
482
483 1
    return (int)floor(self::MAX_COLUMN_SIZE / $size);
484
  }
485
486
  //--------------------------------------------------------------------------------------------------------------------
487
  /**
488
   * Returns the method name in the wrapper for a stored routine. Returns null when name mangler is not set.
489
   *
490
   * @param string $routineName The name of the routine.
491
   *
492
   * @return null|string
493
   */
494 1
  private function methodName(string $routineName): ?string
495
  {
496 1
    if ($this->nameMangler!==null)
497
    {
498
      /** @var NameMangler $mangler */
499 1
      $mangler = $this->nameMangler;
500
501 1
      return $mangler::getMethodName($routineName);
502
    }
503
504
    return null;
505
  }
506
507
  //--------------------------------------------------------------------------------------------------------------------
508
  /**
509
   * Reads the metadata of stored routines from the metadata file.
510
   *
511
   * @throws RuntimeException
512
   */
513 1
  private function readStoredRoutineMetadata(): void
514
  {
515 1
    if (file_exists($this->phpStratumMetadataFilename))
516
    {
517 1
      $this->phpStratumMetadata = (array)json_decode(file_get_contents($this->phpStratumMetadataFilename), true);
518 1
      if (json_last_error()!==JSON_ERROR_NONE)
519
      {
520
        throw new RuntimeException("Error decoding JSON: '%s'.", json_last_error_msg());
521
      }
522
    }
523 1
  }
524
525
  //--------------------------------------------------------------------------------------------------------------------
526
  /**
527
   * Removes obsolete entries from the metadata of all stored routines.
528
   */
529 1
  private function removeObsoleteMetadata(): void
530
  {
531
    // Of both arrays the keys are routine names.
532 1
    $this->phpStratumMetadata = array_intersect_key($this->phpStratumMetadata, $this->phpStratumMetadata);
533 1
  }
534
535
  //--------------------------------------------------------------------------------------------------------------------
536
  /**
537
   * Gathers all replace pairs.
538
   *
539
   * @throws MySqlQueryErrorException
540
   * @throws \ReflectionException
541
   */
542 1
  private function replacePairs(): void
543
  {
544 1
    $this->replacePairsColumnTypes();
545 1
    $this->replacePairsConstants();
546 1
  }
547
548
  //--------------------------------------------------------------------------------------------------------------------
549
  /**
550
   * Selects schema, table, column names and the column type from MySQL and saves them as replace pairs.
551
   *
552
   * @throws MySqlQueryErrorException
553
   */
554 1
  private function replacePairsColumnTypes(): void
555
  {
556 1
    $columns = $this->dl->allTableColumns();
557
558 1
    $this->replacePairsColumnTypesExact($columns);
559 1
    $this->replacePairsColumnTypesMaxLength($columns);
560
561 1
    $this->io->text(sprintf('Selected %d column types for substitution', sizeof($columns)));
562 1
  }
563
564
  //--------------------------------------------------------------------------------------------------------------------
565
  /**
566
   * Gathers replace pairs based on exact column types.
567
   *
568
   * @param array[] $columns The details of all table columns.
569
   */
570 1
  private function replacePairsColumnTypesExact(array $columns): void
571
  {
572 1
    foreach ($columns as $column)
573
    {
574 1
      $key = mb_strtoupper('@'.$column['table_name'].'.'.$column['column_name'].'%type@');
575
576 1
      $value = $column['column_type'];
577
578
      // For VARCHAR and TEXT columns add character set.
579 1
      if ($column['character_set_name']!==null)
580
      {
581 1
        $value .= ' character set '.$column['character_set_name'];
582
      }
583
584 1
      $this->replacePairs[$key] = $value;
585
    }
586 1
  }
587
588
  //--------------------------------------------------------------------------------------------------------------------
589
  /**
590
   * Gathers replace pairs based on column types with maximum length.
591
   *
592
   * @param array[] $columns The details of all table columns.
593
   */
594 1
  private function replacePairsColumnTypesMaxLength(array $columns): void
595
  {
596 1
    foreach ($columns as $column)
597
    {
598 1
      $key = mb_strtoupper('@'.$column['table_name'].'.'.$column['column_name'].'%sort@');
599
600 1
      switch ($column['data_type'])
601
      {
602 1
        case 'char':
603 1
        case 'varchar':
604 1
          $max = $this->maxCharacters($column['character_set_name']);
605 1
          if ($max!==null)
606
          {
607 1
            $value = sprintf('%s(%d) character set %s',
608 1
                             $column['data_type'],
609
                             $max,
610 1
                             $column['character_set_name']);
611
          }
612
          else
613
          {
614
            $value = null;
615
          }
616 1
          break;
617
618 1
        case 'binary':
619 1
        case 'varbinary':
620 1
          $value = sprintf('%s(%d)', $column['data_type'], self::MAX_COLUMN_SIZE);
621 1
          break;
622
623
        default:
624 1
          $value = null;
625
      }
626
627 1
      if ($value!==null) $this->replacePairs[$key] = $value;
628
    }
629 1
  }
630
631
  //--------------------------------------------------------------------------------------------------------------------
632
  /**
633
   * Reads constants set the PHP configuration file and  adds them to the replace pairs.
634
   *
635
   * @throws \ReflectionException
636
   */
637 1
  private function replacePairsConstants(): void
638
  {
639 1
    if (!isset($this->constantClassName)) return;
640
641 1
    $reflection = new \ReflectionClass($this->constantClassName);
642
643 1
    $constants = $reflection->getConstants();
644 1
    foreach ($constants as $name => $value)
645
    {
646
      if (!is_numeric($value)) $value = "'".$value."'";
647
648
      $this->replacePairs['@'.$name.'@'] = $value;
649
    }
650
651 1
    $this->io->text(sprintf('Read %d constants for substitution from <fso>%s</fso>',
652 1
                            sizeof($constants),
653 1
                            OutputFormatter::escape($reflection->getFileName())));
654 1
  }
655
656
  //--------------------------------------------------------------------------------------------------------------------
657
  /**
658
   * Writes the metadata of all stored routines to the metadata file.
659
   *
660
   * @throws RuntimeException
661
   */
662 1
  private function writeStoredRoutineMetadata(): void
663
  {
664 1
    $json = json_encode($this->phpStratumMetadata, JSON_PRETTY_PRINT);
665 1
    if (json_last_error()!=JSON_ERROR_NONE)
666
    {
667
      throw new RuntimeException("Error of encoding to JSON: '%s'.", json_last_error_msg());
668
    }
669
670 1
    $this->writeTwoPhases($this->phpStratumMetadataFilename, $json);
671 1
  }
672
673
  //--------------------------------------------------------------------------------------------------------------------
674
}
675
676
//----------------------------------------------------------------------------------------------------------------------
677