Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.95% covered (success)
95.95%
142 / 148
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
schema_generator
95.95% covered (success)
95.95%
142 / 148
62.50% covered (warning)
62.50%
5 / 8
43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 get_schema
94.29% covered (success)
94.29%
33 / 35
0.00% covered (danger)
0.00%
0 / 1
11.02
 apply_migration_to_schema
96.08% covered (success)
96.08%
49 / 51
0.00% covered (danger)
0.00%
0 / 1
10
 for_each_table
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 set_all
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 unset_all
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 handle_add_column
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 get_value_transform
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2/**
3*
4* This file is part of the phpBB Forum Software package.
5*
6* @copyright (c) phpBB Limited <https://www.phpbb.com>
7* @license GNU General Public License, version 2 (GPL-2.0)
8*
9* For full copyright and license information, please see
10* the docs/CREDITS.txt file.
11*
12*/
13
14namespace phpbb\db\migration;
15
16use Closure;
17use phpbb\config\config;
18use phpbb\db\driver\driver_interface;
19use phpbb\db\migrator;
20use phpbb\db\tools\tools_interface;
21use UnexpectedValueException;
22
23/**
24* The schema generator generates the schema based on the existing migrations
25*/
26class schema_generator
27{
28    /** @var config */
29    protected $config;
30
31    /** @var driver_interface */
32    protected $db;
33
34    /** @var tools_interface */
35    protected $db_tools;
36
37    /** @var array */
38    protected $class_names;
39
40    /** @var string */
41    protected $table_prefix;
42
43    /** @var string */
44    protected $phpbb_root_path;
45
46    /** @var string */
47    protected $php_ext;
48
49    /** @var array */
50    protected $tables;
51
52    /** @var array */
53    protected $table_names;
54
55    /**
56     * Constructor
57     * @param array $class_names
58     * @param config $config
59     * @param driver_interface $db
60     * @param tools_interface $db_tools
61     * @param string $phpbb_root_path
62     * @param string $php_ext
63     * @param string $table_prefix
64     * @param array $tables
65     */
66    public function __construct(
67        array $class_names,
68        config $config,
69        driver_interface $db,
70        tools_interface $db_tools,
71        string $phpbb_root_path,
72        string $php_ext,
73        string $table_prefix,
74        array $tables)
75    {
76        $this->config = $config;
77        $this->db = $db;
78        $this->db_tools = $db_tools;
79        $this->class_names = $class_names;
80        $this->phpbb_root_path = $phpbb_root_path;
81        $this->php_ext = $php_ext;
82        $this->table_prefix = $table_prefix;
83        $this->table_names = $tables;
84    }
85
86    /**
87    * Loads all migrations and their application state from the database.
88    *
89    * @return array An array describing the database schema.
90    *
91    * @throws UnexpectedValueException    If a migration tries to use an undefined schema change.
92    * @throws UnexpectedValueException    If a dependency can't be resolved or there are circular
93    *                                     dependencies between migrations.
94    */
95    public function get_schema() : array
96    {
97        if (!empty($this->tables))
98        {
99            return $this->tables;
100        }
101
102        $dependency_counts = [];
103        $dependencies = [];
104        $applicable_migrations = [];
105        $migration_count = 0;
106        foreach ($this->class_names as $class_name)
107        {
108            if (!migrator::is_migration($class_name))
109            {
110                continue;
111            }
112
113            $migration_count++;
114            $migration_dependencies = $class_name::depends_on();
115            if (empty($migration_dependencies))
116            {
117                $applicable_migrations[] = $class_name;
118                continue;
119            }
120
121            $dependency_counts[$class_name] = count($migration_dependencies);
122            foreach ($migration_dependencies as $migration_dependency)
123            {
124                $dependencies[$migration_dependency][] = $class_name;
125            }
126        }
127
128        $applied_migrations = 0;
129        while (!empty($applicable_migrations))
130        {
131            $migration = array_pop($applicable_migrations);
132            $this->apply_migration_to_schema($migration);
133            ++$applied_migrations;
134
135            if (!array_key_exists($migration, $dependencies))
136            {
137                continue;
138            }
139
140            $dependents = $dependencies[$migration];
141            foreach ($dependents as $dependent)
142            {
143                $dependency_counts[$dependent]--;
144                if ($dependency_counts[$dependent] === 0)
145                {
146                    $applicable_migrations[] = $dependent;
147                }
148            }
149        }
150
151        if ($migration_count !== $applied_migrations)
152        {
153            throw new UnexpectedValueException(
154                "Migrations either have circular dependencies or unsatisfiable dependencies."
155            );
156        }
157
158        ksort($this->tables);
159        return $this->tables;
160    }
161
162    /**
163     * Apply the changes defined in the migration to the database schema.
164     *
165     * @param string $migration_class The name of the migration class.
166     *
167     * @throws UnexpectedValueException If a migration tries to use an undefined schema change.
168     */
169    private function apply_migration_to_schema(string $migration_class)
170    {
171        $migration = new $migration_class(
172            $this->config,
173            $this->db,
174            $this->db_tools,
175            $this->phpbb_root_path,
176            $this->php_ext,
177            $this->table_prefix,
178            $this->table_names
179        );
180
181        $column_map = [
182            'add_tables'        => null,
183            'drop_tables'        => null,
184            'add_columns'        => 'COLUMNS',
185            'drop_columns'        => 'COLUMNS',
186            'change_columns'    => 'COLUMNS',
187            'add_index'            => 'KEYS',
188            'add_primary_keys'    => 'PRIMARY_KEY',
189            'add_unique_index'    => 'KEYS',
190            'drop_keys'            => 'KEYS',
191            'rename_index'        => 'KEYS',
192        ];
193
194        $schema_changes = $migration->update_schema();
195        foreach ($schema_changes as $change_type => $changes)
196        {
197            if (!array_key_exists($change_type, $column_map))
198            {
199                throw new UnexpectedValueException("$migration_class contains undefined schema changes: $change_type.");
200            }
201
202            $split_position = strpos($change_type, '_');
203            $schema_change_type = substr($change_type, 0, $split_position);
204            $schema_type = substr($change_type, $split_position + 1);
205
206            $action = null;
207            switch ($schema_change_type)
208            {
209                case 'add':
210                case 'change':
211                case 'rename':
212                    $action = function(&$value, $changes, $value_transform = null) {
213                        self::set_all($value, $changes, $value_transform);
214                    };
215                break;
216
217                case 'drop':
218                    $action = function(&$value, $changes, $value_transform = null) {
219                        self::unset_all($value, $changes);
220                    };
221                break;
222
223                default:
224                    throw new UnexpectedValueException("$migration_class contains undefined schema changes: $change_type.");
225            }
226
227            switch ($schema_type)
228            {
229                case 'tables':
230                    $action($this->tables, $changes);
231                break;
232
233                default:
234                    $this->for_each_table(
235                        $changes,
236                        $action,
237                        $column_map[$change_type],
238                        self::get_value_transform($schema_change_type, $schema_type)
239                    );
240            }
241        }
242    }
243
244    /**
245     * Apply `$callback` to each table specified in `$data`.
246     *
247     * @param array            $data                Array describing the schema changes.
248     * @param callable        $callback            Callback function to be applied.
249     * @param string|null    $column                Column of the `$this->tables` array for the table on which
250     *                                             the change will be made or null.
251     * @param callable|null    $value_transform    Value transformation callback function or null.
252     */
253    private function for_each_table(array $data, callable $callback, $column = null, $value_transform = null)
254    {
255        foreach ($data as $table => $values)
256        {
257            $target = &$this->tables[$table];
258            if ($column !== null)
259            {
260                $target = &$target[$column];
261            }
262
263            $callback($target, $values, $value_transform);
264        }
265    }
266
267    /**
268     * Set an array of key-value pairs in the schema.
269     *
270     * @param mixed            $schema                Reference to the schema entry.
271     * @param mixed            $data                Array of values to be set.
272     * @param callable|null    $value_transform    Callback to transform the value being set.
273     */
274    private static function set_all(&$schema, $data, callable|null $value_transform = null)
275    {
276        $data = (!is_array($data)) ? [$data] : $data;
277        foreach ($data as $key => $change)
278        {
279            if (is_callable($value_transform))
280            {
281                $value_transform($schema, $key, $change);
282            }
283            else
284            {
285                $schema[$key] = $change;
286            }
287        }
288    }
289
290    /**
291     * Remove an array of values from the schema
292     *
293     * @param mixed $schema                        Reference to the schema entry.
294     * @param mixed $data                        Array of values to be removed.
295     */
296    private static function unset_all(&$schema, $data)
297    {
298        $data = (!is_array($data)) ? [$data] : $data;
299        foreach ($data as $key)
300        {
301            unset($schema[$key]);
302        }
303    }
304
305    /**
306     * Logic for adding a new column to a table.
307     *
308     * @param array        $value    The table column entry.
309     * @param string    $key    The column name to add.
310     * @param array        $change    The column data.
311     */
312    private static function handle_add_column(array &$value, string $key, array $change)
313    {
314        if (!array_key_exists('after', $change))
315        {
316            $value[$key] = $change;
317            return;
318        }
319
320        $after = $change['after'];
321        unset($change['after']);
322
323        if ($after === null)
324        {
325            $value[$key] = array_values($change);
326            return;
327        }
328
329        $offset = array_search($after, array_keys($value));
330        if ($offset === false)
331        {
332            $value[$key] = array_values($change);
333            return;
334        }
335
336        $value = array_merge(
337            array_slice($value, 0, $offset + 1, true),
338            [$key => array_values($change)],
339            array_slice($value, $offset)
340        );
341    }
342
343    /**
344     * Returns the value transform for the change.
345     *
346     * @param string $change_type    The type of the change.
347     * @param string $schema_type    The schema type on which the change is to be performed.
348     *
349     * @return Closure|null The value transformation callback or null if it is not needed.
350     */
351    private static function get_value_transform(string $change_type, string $schema_type) : Closure|null
352    {
353        if (!in_array($change_type, ['add', 'rename']))
354        {
355            return null;
356        }
357
358        switch ($schema_type)
359        {
360            case 'index':
361                if ($change_type == 'rename')
362                {
363                    return function(&$value, $key, $change) {
364                        if (isset($value[$key]))
365                        {
366                            $data_backup = $value[$key];
367                            unset($value[$key]);
368                            $value[$change] = $data_backup;
369                            unset($data_backup);
370                        }
371                        else
372                        {
373                            return null;
374                        }
375                    };
376                }
377
378                return function(&$value, $key, $change) {
379                    $value[$key] = ['INDEX', $change];
380                };
381
382            case 'unique_index':
383                return function(&$value, $key, $change) {
384                    $value[$key] = ['UNIQUE', $change];
385                };
386
387            case 'columns':
388                return function(&$value, $key, $change) {
389                    self::handle_add_column($value, $key, $change);
390                };
391        }
392
393        return null;
394    }
395}