Passed
Push — master ( 570f33...caf7cf )
by P.R.
01:50
created

MySqlRoutineLoaderWorker::gatherTableColumns()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 0
dl 0
loc 9
ccs 7
cts 7
cp 1
crap 2
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 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 type hints based on exact column types.
403
   *
404 1
   * @param array[] $columns The details of all table columns.
405
   */
406 1
  private function gatherTypeHintsColumnTypesExact(array $columns): void
407 1
  {
408
    foreach ($columns as $column)
409 1
    {
410 1
      $key   = $column['table_name'].'.'.$column['column_name'];
411 1
      $value = $column['column_type'];
412 1
413 1
      // For VARCHAR and TEXT columns add character set.
414 1
      if ($column['character_set_name']!==null)
415
      {
416
        $value .= ' character set '.$column['character_set_name'];
417
      }
418 1
419 1
      $this->typeHintPool[$key] = $value;
420
    }
421
  }
422
423
  //--------------------------------------------------------------------------------------------------------------------
424
  /**
425
   * Gathers schema, table, column names and the column type from MySQL and saves them as placeholders.
426
   *
427
   * @throws MySqlQueryErrorException
428
   */
429
  private function gatherTypeHintsPlaceholdersColumnTypes(): void
430
  {
431
    $columns = $this->dl->allTableColumns();
432
    $this->gatherPlaceholdersColumnTypesExact($columns);
433
    $this->gatherPlaceholdersColumnTypesMaxLength($columns);
434
    $this->gatherTypeHintsColumnTypesExact($columns);
435
436
    $this->io->text(sprintf('Selected %d column types for substitution', sizeof($columns)));
437
  }
438
439
  //--------------------------------------------------------------------------------------------------------------------
440
  /**
441
   * Returns all elements in {@link $sources} with duplicate method names.
442
   *
443
   * @return array[]
444
   */
445
  private function getDuplicates(): array
446
  {
447
    // First pass make lookup table by method_name.
448
    $lookup = [];
449
    foreach ($this->sources as $source)
450
    {
451
      if (isset($source['method_name']))
452 1
      {
453
        if (!isset($lookup[$source['method_name']]))
454 1
        {
455
          $lookup[$source['method_name']] = [];
456
        }
457
458
        $lookup[$source['method_name']][] = $source;
459
      }
460
    }
461
462
    // Second pass find duplicate sources.
463
    $duplicatesSources = [];
464
    $duplicatesMethods = [];
465
    foreach ($this->sources as $source)
466
    {
467
      if (sizeof($lookup[$source['method_name']])>1)
468
      {
469 1
        $duplicatesSources[$source['path_name']]   = $source;
470
        $duplicatesMethods[$source['method_name']] = $lookup[$source['method_name']];
471 1
      }
472 1
    }
473
474 1
    return [$duplicatesSources, $duplicatesMethods];
475
  }
476 1
477
  //--------------------------------------------------------------------------------------------------------------------
478
  /**
479
   * Retrieves information about all stored routines in the current schema.
480
   *
481
   * @throws MySqlQueryErrorException
482
   */
483
  private function getOldStoredRoutinesInfo(): void
484
  {
485
    $this->rdbmsOldMetadata = [];
486
487 1
    $routines = $this->dl->allRoutines();
488
    foreach ($routines as $routine)
489 1
    {
490
      $this->rdbmsOldMetadata[$routine['routine_name']] = $routine;
491
    }
492 1
  }
493
494 1
  //--------------------------------------------------------------------------------------------------------------------
495
  /**
496
   * Loads all stored routines into MySQL.
497
   *
498
   * @throws InvalidCastException
499
   * @throws MySqlQueryErrorException
500
   * @throws RuntimeException
501
   * @throws \ReflectionException
502
   */
503
  private function loadAll(): void
504 1
  {
505
    $this->findSourceFiles();
506 1
    $this->detectNameConflicts();
507 1
    $this->gatherPlaceholdersAndTypeHints();
508
    $this->getOldStoredRoutinesInfo();
509 1
    $this->loadStoredRoutines();
510
    $this->dropObsoleteRoutines();
511
    $this->removeObsoleteMetadata();
512 1
    $this->writeStoredRoutineMetadata();
513
  }
514
515
  //--------------------------------------------------------------------------------------------------------------------
516
  /**
517
   * Loads all stored routines in a list into MySQL.
518
   *
519
   * @param string[] $sources The list of files to be loaded.
520
   *
521
   * @throws InvalidCastException
522 1
   * @throws MySqlQueryErrorException
523
   * @throws \LogicException
524 1
   * @throws \RuntimeException
525 1
   * @throws \ReflectionException
526
   */
527
  private function loadList(array $sources): void
528
  {
529
    $this->findSourceFilesFromList($sources);
530
    $this->detectNameConflicts();
531
    $this->gatherPlaceholdersAndTypeHints();
532
    $this->getOldStoredRoutinesInfo();
533
    $this->loadStoredRoutines();
534 1
    $this->writeStoredRoutineMetadata();
535
  }
536 1
537
  //--------------------------------------------------------------------------------------------------------------------
538 1
  /**
539 1
   * Loads all stored routines.
540
   *
541 1
   * @throws InvalidCastException
542
   * @throws MySqlQueryErrorException
543
   * @throws ResultException
544
   */
545
  private function loadStoredRoutines(): void
546
  {
547
    $this->io->writeln('');
548
549
    usort($this->sources, function ($a, $b) {
550 1
      return strcmp($a['routine_name'], $b['routine_name']);
551
    });
552 1
553
    $sqlModeHelper = new SqlModeHelper($this->dl, $this->sqlMode);
554 1
555
    foreach ($this->sources as $filename)
556 1
    {
557
      $routineName = $filename['routine_name'];
558
559 1
      $helper = new RoutineLoaderHelper($this->dl,
560
                                        $this->io,
561 1
                                        $sqlModeHelper,
562
                                        $filename['path_name'],
563
                                        $this->stratumMetaData->getMetadata($routineName),
564 1
                                        $this->placeholderPool,
565
                                        $this->typeHintPool,
566
                                        $this->rdbmsOldMetadata[$routineName] ?? [],
567
                                        $this->defaultCharacterSet,
568
                                        $this->defaultCollate);
569
570
      try
571
      {
572
        $metadata = $helper->loadStoredRoutine();
573
        $this->stratumMetaData->putMetadata($routineName, $metadata);
574 1
      }
575
      catch (RoutineLoaderException $e)
576 1
      {
577
        $messages = [$e->getMessage(), sprintf("Failed to load file '%s'", $filename['path_name'])];
578 1
        $this->io->error($messages);
579
580 1
        $this->errorFilenames[] = $filename['path_name'];
581
        $this->stratumMetaData->delMetadata($routineName);
582 1
      }
583 1
      catch (MySqlQueryErrorException $e)
584 1
      {
585 1
        // Exception is caused by a SQL error. Log the message and the SQL statement with highlighting the error.
586
        $this->io->error($e->getMessage());
587 1
        $this->io->text($e->styledQuery());
588 1
589
        $this->errorFilenames[] = $filename['path_name'];
590 1
        $this->stratumMetaData->delMetadata($routineName);
591
      }
592
      catch (MySqlDataLayerException $e)
593
      {
594
        $this->io->error($e->getMessage());
595
596 1
        $this->errorFilenames[] = $filename['path_name'];
597
        $this->stratumMetaData->delMetadata($routineName);
598 1
      }
599 1
    }
600 1
  }
601 1
602
  //--------------------------------------------------------------------------------------------------------------------
603
  /**
604 1
   * Logs the source files that were not successfully loaded into MySQL.
605
   */
606
  private function logOverviewErrors(): void
607 1
  {
608
    if (!empty($this->errorFilenames))
609
    {
610
      $this->io->warning('Routines in the files below are not loaded:');
611
      $this->io->listing($this->errorFilenames);
612
    }
613
  }
614
615
  //--------------------------------------------------------------------------------------------------------------------
616
  /**
617 1
   * Returns the maximum number of characters in a VARCHAR or CHAR.
618
   *
619 1
   * @param string $characterSetName The name of the character set of the column.
620
   *
621 1
   * @return int|null
622
   */
623 1
  private function maxCharacters(string $characterSetName): ?int
624 1
  {
625
    $key = RowSetHelper::searchInRowSet($this->characterSets, 'character_set_name', $characterSetName);
626
    if ($key===null)
627
    {
628
      return null;
629
    }
630
631 1
    $size = $this->characterSets[$key]['maxlen'];
632 1
633 1
    return (int)floor(self::MAX_COLUMN_SIZE / $size);
634
  }
635
636
  //--------------------------------------------------------------------------------------------------------------------
637
  /**
638
   * Returns the method name in the wrapper for a stored routine. Returns null when name mangler is not set.
639
   *
640
   * @param string $routineName The name of the routine.
641
   *
642 1
   * @return string|null
643
   */
644 1
  private function methodName(string $routineName): ?string
645
  {
646
    if ($this->nameMangler!==null)
647
    {
648
      /** @var NameMangler $mangler */
649
      $mangler = $this->nameMangler;
650
651
      return $mangler::getMethodName($routineName);
652
    }
653
654
    return null;
655
  }
656
657
  //--------------------------------------------------------------------------------------------------------------------
658
  /**
659
   * Removes obsolete entries from the metadata of all stored routines.
660
   */
661
  private function removeObsoleteMetadata(): void
662
  {
663
    $routines = [];
664
    foreach ($this->sources as $source)
665
    {
666
      $routines[] = $source['routine_name'];
667
    }
668
669
    $this->stratumMetaData->purge($routines);
670
  }
671
672
  //--------------------------------------------------------------------------------------------------------------------
673
  /**
674
   * Writes the metadata of all stored routines to the metadata file.
675
   *
676
   * @throws RuntimeException
677
   */
678
  private function writeStoredRoutineMetadata(): void
679
  {
680
    $this->stratumMetaData->writeMetadata($this->io);
681
  }
682
683
  //--------------------------------------------------------------------------------------------------------------------
684
}
685
686
//----------------------------------------------------------------------------------------------------------------------
687