Passed
Push — master ( e1f52f...113f3e )
by Emmanuel
07:00
created

DB::processEntity()   B

Complexity

Conditions 6
Paths 13

Size

Total Lines 37
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 6

Importance

Changes 0
Metric Value
dl 0
loc 37
ccs 11
cts 11
cp 1
rs 8.439
c 0
b 0
f 0
nc 13
cc 6
eloc 22
nop 2
crap 6
1
<?php
2
/**
3
 * neuralyzer : Data Anonymization Library and CLI Tool
4
 *
5
 * PHP Version 7.1
6
 *
7
 * @author Emmanuel Dyan
8
 * @author Rémi Sauvat
9
 * @copyright 2018 Emmanuel Dyan
10
 *
11
 * @package edyan/neuralyzer
12
 *
13
 * @license GNU General Public License v2.0
14
 *
15
 * @link https://github.com/edyan/neuralyzer
16
 */
17
18
namespace Edyan\Neuralyzer\Anonymizer;
19
20
use Doctrine\DBAL\Connection;
21
use Doctrine\DBAL\Configuration as DbalConfiguration;
22
use Doctrine\DBAL\DriverManager as DbalDriverManager;
23
use Doctrine\DBAL\Query\QueryBuilder;
24
use Edyan\Neuralyzer\Exception\NeuralizerException;
25
26
/**
27
 * Implement AbstractAnonymizer for DB, to read and write data via Doctrine DBAL
28
 */
29
class DB extends AbstractAnonymizer
30
{
31
    /**
32
     * Doctrine DB Adapter
33
     * @var Connection
34
     */
35
    private $conn;
36
37
    /**
38
     * Primary Key
39
     * @var string
40
     */
41
    private $priKey;
42
43
44
    /**
45
     * Init connection
46
     *
47
     * @param $params   Parameters to send to Doctrine DB
48
     */
49
    public function __construct(array $params)
50
    {
51
        $this->conn = DbalDriverManager::getConnection($params, new DbalConfiguration());
52
        $this->conn->setFetchMode(\Doctrine\DBAL\FetchMode::ASSOCIATIVE);
53
    }
54
55
56
    /**
57
     * Get Doctrine Connection
58 34
     *
59
     * @return Connection
60 34
     */
61 33
    public function getConn(): Connection
62
    {
63
        return $this->conn;
64
    }
65
66
67
    /**
68
     * Process an entity by reading / writing to the DB
69 22
     *
70
     * @param string        $entity
71 22
     * @param callable|null $callback
72
     *
73
     * @return void|array
74
     */
75
    public function processEntity(string $entity, callable $callback = null): array
76
    {
77
        $schema = $this->conn->getSchemaManager();
78
        if ($schema->tablesExist($entity) === false) {
0 ignored issues
show
Documentation introduced by
$entity is of type string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
79
            throw new NeuralizerException("Table $entity does not exist");
80 5
        }
81
82 5
        $this->entity = $entity;
83 5
        $this->priKey = $this->getPrimaryKey();
84
        $this->entityCols = $this->getTableCols();
85
86
        $queries = [];
87
88
        $actionsOnThatEntity = $this->whatToDoWithEntity();
89
90
        if ($actionsOnThatEntity & self::TRUNCATE_TABLE) {
91
            $where = $this->getWhereConditionInConfig();
92
            $query = $this->runDelete($where);
93
            ($this->returnRes === true ? array_push($queries, $query) : '');
94
        }
95
96 18
        if ($actionsOnThatEntity & self::UPDATE_TABLE) {
97
            $queries = array_merge(
98
                $queries,
99
                $this->updateData($callback)
100
            );
101
        }
102 18
103 18
        if ($actionsOnThatEntity & self::INSERT_TABLE) {
104 1
            $queries = array_merge(
105
                $queries,
106
                $this->insertData($callback)
0 ignored issues
show
Bug introduced by
It seems like $callback defined by parameter $callback on line 75 can also be of type null; however, Edyan\Neuralyzer\Anonymizer\DB::insertData() does only seem to accept callable, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
107 17
            );
108 17
        }
109 16
110
        return $queries;
111 16
    }
112
113 16
    /**
114
     * Do a simple count for a table
115 14
     *
116 4
     * @param  string $table
117 4
     * @return int
118 3
     */
119
    public function countResults(string $table): int
120
    {
121 13
        $queryBuilder = $this->conn->createQueryBuilder();
122 10
        $rows = $queryBuilder->select('COUNT(1)')->from($table)->execute();
123 10
124 10
        return (int)$rows->fetch(\Doctrine\DBAL\FetchMode::NUMERIC)[0];
125
    }
126
127
128 11
    /**
129 3
     * Identify the primary key for a table
130 3
     *
131 3
     * @return string Field's name
132
     */
133
    private function getPrimaryKey(): string
134
    {
135 11
        $schema = $this->conn->getSchemaManager();
136
        $tableDetails = $schema->listTableDetails($this->entity);
137
        if ($tableDetails->hasPrimaryKey() === false) {
138
            throw new NeuralizerException("Can't find a primary key for '{$this->entity}'");
139
        }
140
141
        return $tableDetails->getPrimaryKey()->getColumns()[0];
142
    }
143
144 17
145
    /**
146 17
     * Retrieve columns list for a table with type and length
147 17
     *
148 17
     * @return array $cols
149 1
     */
150
    private function getTableCols(): array
151
    {
152 16
        $schema = $this->conn->getSchemaManager();
153
        $tableCols = $schema->listTableColumns($this->entity);
154
        $cols = [];
155
        foreach ($tableCols as $col) {
156
            $cols[$col->getName()] = [
157
                'length' => $col->getLength(),
158
                'type'   => $col->getType(),
159
                'unsigned' => $col->getUnsigned(),
160
            ];
161 16
        }
162
163 16
        return $cols;
164 16
    }
165 16
166 16
167 16
    /**
168 16
     * To debug, build the final SQL (can be approximative)
169 16
     * @param  QueryBuilder $queryBuilder
170 16
     * @return string
171
     */
172
    private function getRawSQL(QueryBuilder $queryBuilder)
173
    {
174 16
        $sql = $queryBuilder->getSQL();
175
        foreach ($queryBuilder->getParameters() as $parameter => $value) {
176
            $sql = str_replace($parameter, "'$value'", $sql);
177
        }
178
179
        return $sql;
180
    }
181
182
183
    /**
184 9
     * Execute the Delete with Doctrine Query Builder
185
     *
186 9
     * @param string $where
187
     *
188 7
     * @return string
189 7
     */
190 7
    private function runDelete(string $where): string
191 7
    {
192 7
        $queryBuilder = $this->conn->createQueryBuilder();
193
        $queryBuilder = $queryBuilder->delete($this->entity);
194 7
        if (!empty($where)) {
195 7
            $queryBuilder = $queryBuilder->where($where);
196
        }
197 7
        $sql = $queryBuilder->getSQL();
198
199
        if ($this->pretend === true) {
200
            return $sql;
201
        }
202
203
        try {
204
            $queryBuilder->execute();
205 3
        } catch (\Exception $e) {
206
            throw new NeuralizerException('Query DELETE Error (' . $e->getMessage() . ')');
207 3
        }
208
209 3
        return $sql;
210 3
    }
211 3
212 3
213 3
    /**
214
     * Build the condition by casting the value if needed
215
     *
216 3
     * @param  string $field
217
     * @return string
218
     */
219
    private function getCondition(string $field): string
220
    {
221
        $type = strtolower($this->entityCols[$field]['type']);
222
223
        $integerCast = $this->getIntegerCast($field);
224
225 8
        $condition = "(CASE $field WHEN NULL THEN NULL ELSE :$field END)";
226
227 8
        $typeToCast = [
228 8
            'date'     => 'DATE',
229 8
            'datetime' => 'DATE',
230
            'time'     => 'TIME',
231
            'smallint' => $integerCast,
232 8
            'integer'  => $integerCast,
233
            'bigint'   => $integerCast,
234
            'float'    => 'DECIMAL',
235
            'decimal'  => 'DECIMAL',
236
        ];
237
238
        // No cast required
239
        if (!array_key_exists($type, $typeToCast)) {
240
            return $condition;
241
        }
242
243
        return "CAST($condition AS {$typeToCast[$type]})";
244 4
    }
245
246 4
247 4
    /**
248 4
     * Get the right CAST for an INTEGER
249 3
     *
250
     * @param  string $field
251 4
     * @return string
252
     */
253 4
    private function getIntegerCast(string $field): string
254 1
    {
255
        $driver = $this->getConn()->getDriver();
256
        if ($driver->getName() === 'pdo_mysql') {
257
            return $this->entityCols[$field]['unsigned'] === true ? 'UNSIGNED' : 'SIGNED';
258 3
        }
259 1
260 1
        return 'INTEGER';
261
    }
262
263 2
264
    /**
265
     * Update data of table
266
     *
267
     * @param  callable $callback
268
     * @return array
269
     */
270
    private function updateData($callback = null): array
271
    {
272
        $queryBuilder = $this->conn->createQueryBuilder();
273 7
        if ($this->limit === 0) {
274
            $this->setLimit($this->countResults($this->entity));
275 7
        }
276
277 7
        $startAt = 0; // The first part of the limit (offset)
278
        $num = 0; // The number of rows updated
279 7
        $queries = [];
280
        while ($num < $this->limit) {
281
            $rows = $queryBuilder
282 7
                        ->select($this->priKey)->from($this->entity)
283 7
                        ->setFirstResult($startAt)->setMaxResults($this->batchSize)
284 7
                        ->orderBy($this->priKey)
285 7
                        ->execute();
286 7
287 7
            // I need to read line by line if I have to update the table
288 7
            // to make sure I do update by update (slower but no other choice for now)
289 7
            foreach ($rows as $row) {
0 ignored issues
show
Bug introduced by
The expression $rows of type object<Doctrine\DBAL\Driver\Statement>|integer is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
290
                $updateQuery = $this->prepareUpdate($row[$this->priKey]);
291
292
                ($this->returnRes === true ? array_push($queries, $this->getRawSQL($updateQuery)) : '');
293 7
294 7
                if ($this->pretend === false) {
295
                    $updateQuery->execute();
296
                }
297 7
298
                if (!is_null($callback)) {
299
                    $callback(++$num);
300
                }
301
302
                // Have to exit now as we have reached the max
303
                if ($num >= $this->limit) {
304
                    break 2;
305
                }
306
            }
307 7
            // Move the offset
308
            // Make sure the loop ends if we have nothing to process
309 7
            $num = $startAt += $this->batchSize;
310 7
        }
311 7
312
        return $queries;
313
    }
314
315
316
    /**
317
     * Execute the Update with Doctrine QueryBuilder
318
     *
319
     * @param  string $primaryKeyVal  Primary Key's Value
320
     * @return QueryBuilder           Doctrine DBAL QueryBuilder
321
     */
322
    private function prepareUpdate($primaryKeyVal): QueryBuilder
323
    {
324
        $data = $this->generateFakeData();
325
326 10
        $queryBuilder = $this->conn->createQueryBuilder();
327
        $queryBuilder = $queryBuilder->update($this->entity);
328
        foreach ($data as $field => $value) {
329
            $queryBuilder = $queryBuilder->set($field, $this->getCondition($field));
330 10
            $queryBuilder = $queryBuilder->setParameter(":$field", $value);
331 10
        }
332
        $queryBuilder = $queryBuilder->where("{$this->priKey} = :{$this->priKey}");
333 10
        $queryBuilder = $queryBuilder->setParameter(":{$this->priKey}", $primaryKeyVal);
334 10
335
        return $queryBuilder;
336 10
    }
337 9
338
339 7
    /**
340
     * Insert data into table
341 7
     *
342 4
     * @param  callable $callback
343
     * @return array
344
     */
345 7
    private function insertData($callback): array
346 3
    {
347
        $queries = [];
348
349 7
        $queryBuilder = $this->conn->createQueryBuilder();
0 ignored issues
show
Unused Code introduced by
$queryBuilder is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
350 7
351
        for ($rowNum = 1; $rowNum <= $this->limit; $rowNum++) {
352
            $queryBuilder = $this->prepareInsert();
353
354 8
            ($this->returnRes === true ? array_push($queries, $this->getRawSQL($queryBuilder)) : '');
355
356
            if ($this->pretend === false) {
357
                $queryBuilder->execute();
358
            }
359
360
            if (!is_null($callback)) {
361
                $callback($rowNum);
362
            }
363
        }
364
365
        return $queries;
366 3
    }
367
368 3
369
    /**
370 3
     * Execute an INSERT with Doctrine QueryBuilder
371
     *
372 3
     * @return QueryBuilder       Doctrine DBAL QueryBuilder
373 3
     */
374
    private function prepareInsert(): QueryBuilder
375 3
    {
376
        $data = $this->generateFakeData();
377 3
378 2
        $queryBuilder = $this->conn->createQueryBuilder();
379
        $queryBuilder = $queryBuilder->insert($this->entity);
380
        foreach ($data as $field => $value) {
381 3
            $queryBuilder = $queryBuilder->setValue($field, ":$field");
382 1
            $queryBuilder = $queryBuilder->setParameter(":$field", $value);
383
        }
384
385
        return $queryBuilder;
386 3
    }
387
}
388