Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
39 / 39
SchemaDefDriver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
3 / 3
18
100.00% covered (success)
100.00%
39 / 39
 process
n/a
0 / 0
1
n/a
0 / 0
 loadMetadataForClass
100.00% covered (success)
100.00%
1 / 1
14
100.00% covered (success)
100.00%
34 / 34
 isTransient
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 kv_to_name_col
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
1<?php
2
3// {{{ License
4// This file is part of GNU social - https://www.gnu.org/software/social
5//
6// GNU social is free software: you can redistribute it and/or modify
7// it under the terms of the GNU Affero General Public License as published by
8// the Free Software Foundation, either version 3 of the License, or
9// (at your option) any later version.
10//
11// GNU social is distributed in the hope that it will be useful,
12// but WITHOUT ANY WARRANTY; without even the implied warranty of
13// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14// GNU Affero General Public License for more details.
15//
16// You should have received a copy of the GNU Affero General Public License
17// along with GNU social.  If not, see <http://www.gnu.org/licenses/>.
18// }}}
19
20/**
21 * Compiler pass which triggers Symfony to tell Doctrine to
22 * use our `SchemaDef` metadata driver
23 *
24 * @package  GNUsocial
25 * @category DB
26 *
27 * @author    Hugo Sales <hugo@hsal.es>
28 * @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
29 * @license   https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
30 */
31
32namespace App\DependencyInjection\Compiler;
33
34use Doctrine\Persistence\Mapping\ClassMetadata;
35use Doctrine\Persistence\Mapping\Driver\StaticPHPDriver;
36use Functional as F;
37use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
38use Symfony\Component\DependencyInjection\ContainerBuilder;
39use Symfony\Component\DependencyInjection\Reference;
40
41/**
42 * Register a new ORM driver to allow use to use the old (and better) schemaDef format
43 */
44class SchemaDefDriver extends StaticPHPDriver implements CompilerPassInterface
45{
46    /**
47     * Register `app.schemadef_driver` (this class instantiated with argument src/Entity) as a metadata driver
48     *
49     * @codeCoverageIgnore
50     */
51    public function process(ContainerBuilder $container)
52    {
53        $container->findDefinition('doctrine.orm.default_metadata_driver')
54                  ->addMethodCall('addDriver',
55                                  [new Reference('app.schemadef_driver'), 'App\\Entity']
56                  );
57    }
58
59    /**
60     * V2 DB type => Doctrine type
61     */
62    private const types = [
63        'varchar'      => 'string',
64        'char'         => 'string', // char is a fixed witdh varchar
65        'int'          => 'integer',
66        'serial'       => 'integer',
67        'tinyint'      => 'smallint', // no portable tinyint
68        'bigint'       => 'bigint',
69        'bool'         => 'boolean',
70        'numeric'      => 'decimal',
71        'text'         => 'text',
72        'datetime'     => 'datetime',
73        'timestamp'    => 'datetime',
74        'phone_number' => 'phone_number',
75        // Unused in V2, but might start being used
76        'date'        => 'date',
77        'time'        => 'time',
78        'datetimez'   => 'datetimez',
79        'object'      => 'object',
80        'array'       => 'array',
81        'simplearray' => 'simplearray',
82        'json_array'  => 'json_array',
83        'float'       => 'float',
84        'guid'        => 'guid',
85        'blob'        => 'blob',
86    ];
87
88    /**
89     * Fill in the database $metadata for $class_name
90     *
91     * @param string        $class_name
92     * @param ClassMetadata $metadata
93     */
94    public function loadMetadataForClass($class_name, ClassMetadata $metadata)
95    {
96        $schema = $class_name::schemaDef();
97
98        $metadata->setPrimaryTable([
99            'name'              => $schema['name'],
100            'indexes'           => self::kv_to_name_col($schema['indexes'] ?? []),
101            'uniqueConstraints' => self::kv_to_name_col($schema['unique keys'] ?? []),
102            'options'           => ['comment' => $schema['description'] ?? ''],
103        ]);
104
105        foreach ($schema['fields'] as $name => $opts) {
106            $unique = null;
107            foreach ($schema['unique keys'] ?? [] as $key => $uniq_arr) {
108                if (in_array($name, $uniq_arr)) {
109                    $unique = $key;
110                    break;
111                }
112            }
113
114            if (false && $opts['foreign key'] ?? false) {
115                // @codeCoverageIgnoreStart
116                // TODO: Get foreign keys working
117                foreach (['target', 'multiplicity'] as $f) {
118                    if (!isset($opts[$f])) {
119                        throw new \Exception("{$class_name}.{$name} doesn't have the required field `{$f}`");
120                    }
121                }
122
123                // See Doctrine\ORM\Mapping::associationMappings
124
125                // TODO still need to map nullability, comment, fk name and such, but
126                // the interface doesn't seem to support it currently
127                [$target_entity, $target_field] = explode('.', $opts['target']);
128                $map                            = [
129                    'fieldName'    => $name,
130                    'targetEntity' => $target_entity,
131                    'joinColumns'  => [[
132                        'name'                 => $name,
133                        'referencedColumnName' => $target_field,
134                    ]],
135                    'id'     => in_array($name, $schema['primary key']),
136                    'unique' => $unique,
137                ];
138
139                switch ($opts['multiplicity']) {
140                case 'one to one':
141                    $metadata->mapOneToOne($map);
142                    break;
143                case 'many to one':
144                    $metadata->mapManyToOne($map);
145                    break;
146                case 'one to many':
147                    $map['mappedBy'] = $target_field;
148                    $metadata->mapOneToMany($map);
149                    break;
150                case 'many to many':
151                    $metadata->mapManyToMany($map);
152                    break;
153                default:
154                    throw new \Exception("Invalid multiplicity specified: '${opts['multiplicity']}' in class: {$class_name}");
155                }
156                // @codeCoverageIgnoreEnd
157            } else {
158                // Convert old to new types
159                // For ints, prepend the size (smallint)
160                // The size field doesn't exist otherwise
161                $type    = self::types[($opts['size'] ?? '') . $opts['type']];
162                $default = $opts['default'] ?? null;
163
164                $field = [
165                    // boolean, optional
166                    'id' => in_array($name, $schema['primary key']),
167                    // string
168                    'fieldName' => $name,
169                    // string
170                    'type' => $type,
171                    // string, optional
172                    'unique' => $unique,
173                    // String length, ignored if not a string
174                    // int, optional
175                    'length' => $opts['length'] ?? null,
176                    // boolean, optional
177                    'nullable' => !($opts['not null'] ?? false),
178                    // Numeric precision and scale, ignored if not a number
179                    // integer, optional
180                    'precision' => $opts['precision'] ?? null,
181                    // integer, optional
182                    'scale'   => $opts['scale'] ?? null,
183                    'options' => [
184                        'comment'  => $opts['description'] ?? null,
185                        'default'  => $default,
186                        'unsigned' => $opts['unsigned'] ?? null,
187                        // bool, optional
188                        'fixed' => $opts['type'] === 'char',
189                        // 'collation' => string, unused
190                        // 'check', unused
191                    ],
192                    // 'columnDefinition', unused
193                ];
194                // The optional feilds from earlier were populated with null, remove them
195                $field            = array_filter($field, F\not('is_null'));
196                $field['options'] = array_filter($field['options'], F\not('is_null'));
197
198                $metadata->mapField($field);
199                if ($opts['type'] === 'serial') {
200                    $metadata->setIdGeneratorType($metadata::GENERATOR_TYPE_AUTO);
201                }
202            }
203        }
204    }
205
206    /**
207     * Override StaticPHPDriver's method,
208     * we care about classes that have the method `schemaDef`,
209     * instead of `loadMetadata`.
210     *
211     * @param string $class_name
212     *
213     * @return bool
214     */
215    public function isTransient($class_name)
216    {
217        return !method_exists($class_name, 'schemaDef');
218    }
219
220    /**
221     * Convert [$key => $val] to ['name' => $key, 'columns' => $val]
222     *
223     * @param array $arr
224     *
225     * @return array
226     */
227    private static function kv_to_name_col(array $arr): array
228    {
229        $res = [];
230        foreach ($arr as $name => $cols) {
231            $res[] = ['name' => $name, 'columns' => $cols];
232        }
233        return $res;
234    }
235}