Passed
Push — master ( 47f1d1...f27cfc )
by P.R.
01:53
created

MySqlRoutineLoaderWorker::replacePairs()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 4
ccs 1
cts 1
cp 1
crap 1
rs 10
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 SetBased\Stratum\MySql\Helper\StratumMetadataHelper;
20
use Symfony\Component\Console\Formatter\OutputFormatter;
21
22
/**
23
 * Command for loading stored routines into a MySQL instance from pseudo SQL files.
24
 */
25
class MySqlRoutineLoaderWorker extends MySqlWorker implements RoutineLoaderWorker
26
{
27
  //--------------------------------------------------------------------------------------------------------------------
28
  /**
29
   * The maximum column size in bytes.
30
   */
31
  const MAX_COLUMN_SIZE = 65532;
32
33
  /**
34
   * Details of all character sets.
35
   *
36
   * @var array[]
37
   */
38
  private array $characterSets;
39
40
  /**
41
   * Name of the class that contains all constants.
42
   *
43
   * @var string|null
44
   */
45
  private ?string $constantClassName;
46
47
  /**
48
   * The default character set under which the stored routine will be loaded and run.
49
   *
50
   * @var string
51
   */
52
  private string $defaultCharacterSet;
53
54
  /**
55
   * The default collate under which the stored routine will be loaded and run.
56
   *
57
   * @var string
58
   */
59
  private string $defaultCollate;
60
61
  /**
62
   * An array with source filenames that are not loaded into MySQL.
63
   *
64
   * @var string[]
65
   */
66
  private array $errorFilenames = [];
67
68
  /**
69
   * Class name for mangling routine and parameter names.
70
   *
71
   * @var string|null
72
   */
73
  private ?string $nameMangler;
74
75
  /**
76
   * A map from placeholders that are actually used in the stored routine to their values.
77
   *
78
   * @var array
79
   */
80
  private array $placeholderPool = [];
81
82
  /**
83
   * Old metadata of all stored routines. Note: this data comes from information_schema.ROUTINES.
84
   *
85
   * @var array
86
   */
87
  private array $rdbmsOldMetadata;
88
89
  /**
90
   * Pattern where of the sources files.
91
   *
92
   * @var string
93
   */
94
  private string $sourcePattern;
95
96
  /**
97
   * All sources with stored routines. Each element is an array with the following keys:
98
   * <ul>
99
   * <li> path_name    The path the source file.
100
   * <li> routine_name The name of the routine (equals the basename of the path).
101
   * <li> method_name  The name of the method in the data layer for the wrapper method of the stored routine.
102
   * </ul>
103
   *
104
   * @var array[]
105
   */
106
  private array $sources = [];
107
108
  /**
109
   * The SQL mode under which the stored routine will be loaded and run.
110
   *
111
   * @var string
112
   */
113
  private string $sqlMode;
114
115
  /**
116
   * The metadata of all stored routines. Note: this data is stored in the metadata file and is generated by PhpStratum.
117
   *
118
   * @var StratumMetadataHelper
119
   */
120
  private StratumMetadataHelper $stratumMetaData;
121
122
  /**
123
   * A map from all possible table and column names to their actual column type.
124
   *
125
   * @var array
126
   */
127
  private array $typeHintPool = [];
128
129
  //--------------------------------------------------------------------------------------------------------------------
130
  /**
131
   * @inheritdoc
132
   *
133
   * @throws InvalidCastException
134 1
   * @throws MySqlConnectFailedException
135
   * @throws MySqlDataLayerException
136 1
   * @throws MySqlQueryErrorException
137
   * @throws RuntimeException
138 1
   * @throws \ReflectionException
139 1
   * @throws \RuntimeException
140 1
   */
141 1
  public function execute(?array $sources = null): int
142 1
  {
143 1
    $this->io->title('PhpStratum: MySql Loader');
144 1
145
    $metadataFilename          = $this->settings->manString('loader.metadata');
146 1
    $this->sourcePattern       = $this->settings->manString('loader.sources');
147
    $this->sqlMode             = $this->settings->manString('loader.sql_mode');
148 1
    $this->defaultCharacterSet = $this->settings->manString('loader.character_set');
149 1
    $this->defaultCollate      = $this->settings->manString('loader.collate');
150
    $this->constantClassName   = $this->settings->optString('constants.class');
151 1
    $this->nameMangler         = $this->settings->optString('wrapper.mangler_class');
152
153 1
    $this->connect();
154
155
    $this->stratumMetaData = new StratumMetadataHelper($metadataFilename, RoutineLoaderHelper::METADATA_REVISION);
156
    $this->characterSets   = $this->dl->allCharacterSets();
157
158
    if (empty($sources))
159
    {
160 1
      $this->loadAll();
161
    }
162 1
    else
163
    {
164 1
      $this->loadList($sources);
165
    }
166
167
    $this->logOverviewErrors();
168
169
    $this->disconnect();
170
171 1
    return ($this->errorFilenames) ? 1 : 0;
172
  }
173
174 1
  //--------------------------------------------------------------------------------------------------------------------
175
  /**
176
   * Detects stored routines that would result in duplicate wrapper method name.
177 1
   */
178
  private function detectNameConflicts(): void
179
  {
180
    // Get same method names from array
181
    [$sourcesByPath, $sourcesByMethod] = $this->getDuplicates();
182
183 1
    // Add every not unique method name to myErrorFileNames
184
    foreach ($sourcesByPath as $source)
185
    {
186
      $this->errorFilenames[] = $source['path_name'];
187
    }
188
189
    // Log the sources files with duplicate method names.
190
    foreach ($sourcesByMethod as $method => $sources)
191
    {
192
      $tmp = [];
193
      foreach ($sources as $source)
194
      {
195
        $tmp[] = $source['path_name'];
196
      }
197 1
198
      $this->io->error(sprintf("The following source files would result wrapper methods with equal name '%s'",
199 1
                               $method));
200
      $this->io->listing($tmp);
201
    }
202
203
    // Remove duplicates from sources.
204
    foreach ($this->sources as $key => $source)
205
    {
206
      if (isset($sourcesByPath[$source['path_name']]))
207
      {
208
        unset($this->sources[$key]);
209
      }
210
    }
211
  }
212
213 1
  //--------------------------------------------------------------------------------------------------------------------
214
  /**
215
   * Drops obsolete stored routines (i.e. stored routines that exits in the current schema but for which we don't have
216 1
   * a source file).
217 1
   *
218
   * @throws MySqlQueryErrorException
219 1
   */
220
  private function dropObsoleteRoutines(): void
221
  {
222
    // Make a lookup table from routine name to source.
223 1
    $lookup = [];
224
    foreach ($this->sources as $source)
225
    {
226
      $lookup[$source['routine_name']] = $source;
227
    }
228
229
    // Drop all routines that are no longer in sources.
230
    foreach ($this->rdbmsOldMetadata as $oldRoutine)
231
    {
232
      if (!isset($lookup[$oldRoutine['routine_name']]))
233
      {
234
        $this->io->logInfo('Dropping %s <dbo>%s</dbo>',
235
                           strtolower($oldRoutine['routine_type']),
236
                           $oldRoutine['routine_name']);
237
238
        $this->dl->dropRoutine($oldRoutine['routine_type'], $oldRoutine['routine_name']);
239
      }
240 1
    }
241
  }
242 1
243 1
  //--------------------------------------------------------------------------------------------------------------------
244
  /**
245 1
   * Searches recursively for all source files.
246
   */
247 1
  private function findSourceFiles(): void
248 1
  {
249
    $helper    = new SourceFinderHelper(dirname($this->settings->manString('stratum.config_path')));
250 1
    $filenames = $helper->findSources($this->sourcePattern);
251
252
    foreach ($filenames as $filename)
253
    {
254
      $routineName     = pathinfo($filename, PATHINFO_FILENAME);
255
      $this->sources[] = ['path_name'    => $filename,
256
                          'routine_name' => $routineName,
257
                          'method_name'  => $this->methodName($routineName)];
0 ignored issues
show
Bug introduced by
It seems like $routineName can also be of type array; however, parameter $routineName of SetBased\Stratum\MySql\B...derWorker::methodName() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

257
                          'method_name'  => $this->methodName(/** @scrutinizer ignore-type */ $routineName)];
Loading history...
258
    }
259
  }
260
261
  //--------------------------------------------------------------------------------------------------------------------
262
  /**
263
   * Finds all source files that actually exists from a list of file names.
264
   *
265
   * @param string[] $sources The list of file names.
266
   */
267
  private function findSourceFilesFromList(array $sources): void
268
  {
269
    foreach ($sources as $path)
270
    {
271
      if (!file_exists($path))
272
      {
273
        $this->io->error(sprintf("File not exists: '%s'", $path));
274
        $this->errorFilenames[] = $path;
275
      }
276
      else
277
      {
278
        $routineName     = pathinfo($path, PATHINFO_FILENAME);
279
        $this->sources[] = ['path_name'    => $path,
280
                            'routine_name' => $routineName,
281
                            'method_name'  => $this->methodName($routineName)];
0 ignored issues
show
Bug introduced by
It seems like $routineName can also be of type array; however, parameter $routineName of SetBased\Stratum\MySql\B...derWorker::methodName() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

281
                            'method_name'  => $this->methodName(/** @scrutinizer ignore-type */ $routineName)];
Loading history...
282
      }
283
    }
284
  }
285 1
286
  //--------------------------------------------------------------------------------------------------------------------
287
  /**
288 1
   * Gathers all replace pairs.
289 1
   *
290
   * @throws MySqlQueryErrorException
291 1
   * @throws \ReflectionException
292
   */
293 1
  private function gatherPlaceholdersAndTypeHints(): void
294
  {
295 1
    $this->gatherTypeHintsPlaceholdersColumnTypes();
296
    $this->gatherPlaceholdersConstants();
297
  }
298 1
299
  //--------------------------------------------------------------------------------------------------------------------
300
  /**
301
   * Gathers placeholders based on exact column types.
302
   *
303 1
   * @param array[] $columns The details of all table columns.
304 1
   */
305 1
  private function gatherPlaceholdersColumnTypesExact(array $columns): void
306
  {
307 1
    foreach ($columns as $column)
308
    {
309
      $key = mb_strtoupper('@'.$column['table_name'].'.'.$column['column_name'].'%type@');
310
311
      $value = $column['column_type'];
312
313
      // For VARCHAR and TEXT columns add character set.
314 1
      if ($column['character_set_name']!==null)
315
      {
316
        $value .= ' character set '.$column['character_set_name'];
317
      }
318
319
      $this->placeholderPool[$key] = $value;
320
    }
321
  }
322
323 1
  //--------------------------------------------------------------------------------------------------------------------
324
  /**
325 1
   * Gathers placeholders based on column types with maximum length.
326
   *
327 1
   * @param array[] $columns The details of all table columns.
328 1
   */
329
  private function gatherPlaceholdersColumnTypesMaxLength(array $columns): void
330
  {
331
    foreach ($columns as $column)
332
    {
333
      $key = mb_strtoupper('@'.$column['table_name'].'.'.$column['column_name'].'%sort@');
334
335
      switch ($column['data_type'])
336
      {
337
        case 'char':
338
        case 'varchar':
339
          $max = $this->maxCharacters($column['character_set_name']);
340
          if ($max!==null)
341
          {
342
            $value = sprintf('%s(%d) character set %s',
343 1
                             $column['data_type'],
344
                             $max,
345 1
                             $column['character_set_name']);
346 1
          }
347 1
          else
348 1
          {
349
            $value = null;
350 1
          }
351
          break;
352 1
353 1
        case 'binary':
354
        case 'varbinary':
355 1
          $value = sprintf('%s(%d)', $column['data_type'], self::MAX_COLUMN_SIZE);
356
          break;
357 1
358
        default:
359
          $value = null;
360
      }
361
362
      if ($value!==null)
363
      {
364
        $this->placeholderPool[$key] = $value;
365
      }
366
    }
367
  }
368
369
  //--------------------------------------------------------------------------------------------------------------------
370
  /**
371
   * Reads constants set the PHP configuration file and adds them to the placeholder pool.
372
   *
373
   * @throws \ReflectionException
374
   */
375
  private function gatherPlaceholdersConstants(): void
376
  {
377
    if ($this->constantClassName===null)
378
    {
379
      return;
380
    }
381
382
    $reflection = new \ReflectionClass($this->constantClassName);
383
384
    $constants = $reflection->getConstants();
385
    foreach ($constants as $name => $value)
386
    {
387
      if (!is_numeric($value))
388
      {
389
        $value = "'".$value."'";
390
      }
391
392 1
      $this->placeholderPool['@'.$name.'@'] = $value;
393
    }
394 1
395
    $this->io->text(sprintf('Read %d constants for substitution from <fso>%s</fso>',
396 1
                            sizeof($constants),
397 1
                            OutputFormatter::escape($reflection->getFileName())));
398
  }
399
400 1
  //--------------------------------------------------------------------------------------------------------------------
401
  /**
402 1
   * Gathers metadata of all columns types off all tables in the database.
403
   *
404 1
   * @return array[]
405
   * @throws MySqlQueryErrorException
406 1
   */
407 1
  private function gatherTableColumns(): array
408
  {
409 1
    $columns = $this->dl->allTableColumns();
410 1
    foreach ($columns as $index => $column)
411 1
    {
412 1
      $columns[$index]['column_type'] = str_replace(',', ', ', $column['column_type']);
413 1
    }
414 1
415
    return $columns;
416
  }
417
418 1
  //--------------------------------------------------------------------------------------------------------------------
419 1
  /**
420
   * Gathers type hints based on exact column types.
421
   *
422
   * @param array[] $columns The details of all table columns.
423
   */
424
  private function gatherTypeHintsColumnTypesExact(array $columns): void
425
  {
426
    foreach ($columns as $column)
427
    {
428
      $key   = $column['table_name'].'.'.$column['column_name'];
429
      $value = $column['column_type'];
430
431
      // For VARCHAR and TEXT columns add character set.
432
      if ($column['character_set_name']!==null)
433
      {
434
        $value .= ' character set '.$column['character_set_name'];
435
      }
436
437
      $this->typeHintPool[$key] = $value;
438
    }
439
  }
440
441
  //--------------------------------------------------------------------------------------------------------------------
442
  /**
443
   * Gathers schema, table, column names and the column type from MySQL and saves them as placeholders.
444
   *
445
   * @throws MySqlQueryErrorException
446
   */
447
  private function gatherTypeHintsPlaceholdersColumnTypes(): void
448
  {
449
    $columns = $this->gatherTableColumns();
450
    $this->gatherPlaceholdersColumnTypesExact($columns);
451
    $this->gatherPlaceholdersColumnTypesMaxLength($columns);
452 1
    $this->gatherTypeHintsColumnTypesExact($columns);
453
454 1
    $this->io->text(sprintf('Selected %d column types for substitution', sizeof($columns)));
455
  }
456
457
  //--------------------------------------------------------------------------------------------------------------------
458
  /**
459
   * Returns all elements in {@link $sources} with duplicate method names.
460
   *
461
   * @return array[]
462
   */
463
  private function getDuplicates(): array
464
  {
465
    // First pass make lookup table by method_name.
466
    $lookup = [];
467
    foreach ($this->sources as $source)
468
    {
469 1
      if (isset($source['method_name']))
470
      {
471 1
        if (!isset($lookup[$source['method_name']]))
472 1
        {
473
          $lookup[$source['method_name']] = [];
474 1
        }
475
476 1
        $lookup[$source['method_name']][] = $source;
477
      }
478
    }
479
480
    // Second pass find duplicate sources.
481
    $duplicatesSources = [];
482
    $duplicatesMethods = [];
483
    foreach ($this->sources as $source)
484
    {
485
      if (sizeof($lookup[$source['method_name']])>1)
486
      {
487 1
        $duplicatesSources[$source['path_name']]   = $source;
488
        $duplicatesMethods[$source['method_name']] = $lookup[$source['method_name']];
489 1
      }
490
    }
491
492 1
    return [$duplicatesSources, $duplicatesMethods];
493
  }
494 1
495
  //--------------------------------------------------------------------------------------------------------------------
496
  /**
497
   * Retrieves information about all stored routines in the current schema.
498
   *
499
   * @throws MySqlQueryErrorException
500
   */
501
  private function getOldStoredRoutinesInfo(): void
502
  {
503
    $this->rdbmsOldMetadata = [];
504 1
505
    $routines = $this->dl->allRoutines();
506 1
    foreach ($routines as $routine)
507 1
    {
508
      $this->rdbmsOldMetadata[$routine['routine_name']] = $routine;
509 1
    }
510
  }
511
512 1
  //--------------------------------------------------------------------------------------------------------------------
513
  /**
514
   * Loads all stored routines into MySQL.
515
   *
516
   * @throws InvalidCastException
517
   * @throws MySqlQueryErrorException
518
   * @throws RuntimeException
519
   * @throws \ReflectionException
520
   */
521
  private function loadAll(): void
522 1
  {
523
    $this->findSourceFiles();
524 1
    $this->detectNameConflicts();
525 1
    $this->gatherPlaceholdersAndTypeHints();
526
    $this->getOldStoredRoutinesInfo();
527
    $this->loadStoredRoutines();
528
    $this->dropObsoleteRoutines();
529
    $this->removeObsoleteMetadata();
530
    $this->writeStoredRoutineMetadata();
531
  }
532
533
  //--------------------------------------------------------------------------------------------------------------------
534 1
  /**
535
   * Loads all stored routines in a list into MySQL.
536 1
   *
537
   * @param string[] $sources The list of files to be loaded.
538 1
   *
539 1
   * @throws InvalidCastException
540
   * @throws MySqlQueryErrorException
541 1
   * @throws \LogicException
542
   * @throws \RuntimeException
543
   * @throws \ReflectionException
544
   */
545
  private function loadList(array $sources): void
546
  {
547
    $this->findSourceFilesFromList($sources);
548
    $this->detectNameConflicts();
549
    $this->gatherPlaceholdersAndTypeHints();
550 1
    $this->getOldStoredRoutinesInfo();
551
    $this->loadStoredRoutines();
552 1
    $this->writeStoredRoutineMetadata();
553
  }
554 1
555
  //--------------------------------------------------------------------------------------------------------------------
556 1
  /**
557
   * Loads all stored routines.
558
   *
559 1
   * @throws InvalidCastException
560
   * @throws MySqlQueryErrorException
561 1
   * @throws ResultException
562
   */
563
  private function loadStoredRoutines(): void
564 1
  {
565
    $this->io->writeln('');
566
567
    usort($this->sources, function ($a, $b) {
568
      return strcmp($a['routine_name'], $b['routine_name']);
569
    });
570
571
    $sqlModeHelper = new SqlModeHelper($this->dl, $this->sqlMode);
572
573
    foreach ($this->sources as $filename)
574 1
    {
575
      $routineName = $filename['routine_name'];
576 1
577
      $helper = new RoutineLoaderHelper($this->dl,
578 1
                                        $this->io,
579
                                        $sqlModeHelper,
580 1
                                        $filename['path_name'],
581
                                        $this->stratumMetaData->getMetadata($routineName),
582 1
                                        $this->placeholderPool,
583 1
                                        $this->typeHintPool,
584 1
                                        $this->rdbmsOldMetadata[$routineName] ?? [],
585 1
                                        $this->defaultCharacterSet,
586
                                        $this->defaultCollate);
587 1
588 1
      try
589
      {
590 1
        $metadata = $helper->loadStoredRoutine();
591
        $this->stratumMetaData->putMetadata($routineName, $metadata);
592
      }
593
      catch (RoutineLoaderException $e)
594
      {
595
        $messages = [$e->getMessage(), sprintf("Failed to load file '%s'", $filename['path_name'])];
596 1
        $this->io->error($messages);
597
598 1
        $this->errorFilenames[] = $filename['path_name'];
599 1
        $this->stratumMetaData->delMetadata($routineName);
600 1
      }
601 1
      catch (MySqlQueryErrorException $e)
602
      {
603
        // Exception is caused by a SQL error. Log the message and the SQL statement with highlighting the error.
604 1
        $this->io->error($e->getMessage());
605
        $this->io->text($e->styledQuery());
606
607 1
        $this->errorFilenames[] = $filename['path_name'];
608
        $this->stratumMetaData->delMetadata($routineName);
609
      }
610
      catch (MySqlDataLayerException $e)
611
      {
612
        $this->io->error($e->getMessage());
613
614
        $this->errorFilenames[] = $filename['path_name'];
615
        $this->stratumMetaData->delMetadata($routineName);
616
      }
617 1
    }
618
  }
619 1
620
  //--------------------------------------------------------------------------------------------------------------------
621 1
  /**
622
   * Logs the source files that were not successfully loaded into MySQL.
623 1
   */
624 1
  private function logOverviewErrors(): void
625
  {
626
    if (!empty($this->errorFilenames))
627
    {
628
      $this->io->warning('Routines in the files below are not loaded:');
629
      $this->io->listing($this->errorFilenames);
630
    }
631 1
  }
632 1
633 1
  //--------------------------------------------------------------------------------------------------------------------
634
  /**
635
   * Returns the maximum number of characters in a VARCHAR or CHAR.
636
   *
637
   * @param string $characterSetName The name of the character set of the column.
638
   *
639
   * @return int|null
640
   */
641
  private function maxCharacters(string $characterSetName): ?int
642 1
  {
643
    $key = RowSetHelper::searchInRowSet($this->characterSets, 'character_set_name', $characterSetName);
644 1
    if ($key===null)
645
    {
646
      return null;
647
    }
648
649
    $size = $this->characterSets[$key]['maxlen'];
650
651
    return (int)floor(self::MAX_COLUMN_SIZE / $size);
652
  }
653
654
  //--------------------------------------------------------------------------------------------------------------------
655
  /**
656
   * Returns the method name in the wrapper for a stored routine. Returns null when name mangler is not set.
657
   *
658
   * @param string $routineName The name of the routine.
659
   *
660
   * @return string|null
661
   */
662
  private function methodName(string $routineName): ?string
663
  {
664
    if ($this->nameMangler!==null)
665
    {
666
      /** @var NameMangler $mangler */
667
      $mangler = $this->nameMangler;
668
669
      return $mangler::getMethodName($routineName);
670
    }
671
672
    return null;
673
  }
674
675
  //--------------------------------------------------------------------------------------------------------------------
676
  /**
677
   * Removes obsolete entries from the metadata of all stored routines.
678
   */
679
  private function removeObsoleteMetadata(): void
680
  {
681
    $routines = [];
682
    foreach ($this->sources as $source)
683
    {
684
      $routines[] = $source['routine_name'];
685
    }
686
687
    $this->stratumMetaData->purge($routines);
688
  }
689
690
  //--------------------------------------------------------------------------------------------------------------------
691
  /**
692
   * Writes the metadata of all stored routines to the metadata file.
693
   *
694
   * @throws RuntimeException
695
   */
696
  private function writeStoredRoutineMetadata(): void
697
  {
698
    $this->stratumMetaData->writeMetadata($this->io);
699
  }
700
701
  //--------------------------------------------------------------------------------------------------------------------
702
}
703
704
//----------------------------------------------------------------------------------------------------------------------
705