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