Passed
Push — master ( 1ab114...d8759b )
by Emmanuel
02:02
created

DB::countResults()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 7
ccs 0
cts 0
cp 0
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 4
nc 1
nop 1
crap 2
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
     *
34
     * @var Connection
35
     */
36
    private $conn;
37
38
39
    /**
40
     * Primary Key
41
     *
42
     * @var string
43
     */
44
    private $priKey;
45
46
    /**
47
     * Limit the number of updates or create
48
     *
49
     * @var int
50
     */
51
    private $limit = 0;
52
53
    /**
54
     * Set the batch size for updates
55
     * @var int
56
     */
57
    private $batchSize = 1000;
58 34
59
60 34
    /**
61 33
     * Init connection
62
     *
63
     * @param $params   Parameters to send to Doctrine DB
64
     */
65
    public function __construct(array $params)
66
    {
67
        $this->conn = DbalDriverManager::getConnection($params, new DbalConfiguration());
68
        $this->conn->setFetchMode(\Doctrine\DBAL\FetchMode::ASSOCIATIVE);
69 22
    }
70
71 22
72
    /**
73
     * Get Doctrine Connection
74
     *
75
     * @return Connection
76
     */
77
    public function getConn(): Connection
78
    {
79
        return $this->conn;
80 5
    }
81
82 5
83 5
    /**
84
     * Set the limit for updates and creates
85
     *
86
     * @param int $limit
87
     * @return DB
88
     */
89
    public function setLimit(int $limit): DB
90
    {
91
        $this->limit = $limit;
92
        if ($this->limit < $this->batchSize) {
93
            $this->batchSize = $this->limit;
94
        }
95
96 18
        return $this;
97
    }
98
99
100
    /**
101
     * Process an entity by reading / writing to the DB
102 18
     *
103 18
     * @param string        $entity
104 1
     * @param callable|null $callback
105
     * @param bool          $pretend
106
     * @param bool          $returnRes
107 17
     *
108 17
     * @return void|array
109 16
     */
110
    public function processEntity(
111 16
        string $entity,
112
        callable $callback = null,
113 16
        bool $pretend = true,
114
        bool $returnRes = false
115 14
    ): array {
116 4
        $schema = $this->conn->getSchemaManager();
117 4
        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...
118 3
            throw new NeuralizerException("Table $entity does not exist");
119
        }
120
121 13
        $this->entity = $entity;
122 10
        $this->priKey = $this->getPrimaryKey();
123 10
        $this->entityCols = $this->getTableCols();
124 10
125
        $queries = [];
126
127
        $actionsOnThatEntity = $this->whatToDoWithEntity();
128 11
129 3
        if ($actionsOnThatEntity & self::TRUNCATE_TABLE) {
130 3
            $where = $this->getWhereConditionInConfig();
131 3
            $query = $this->runDelete($where, $pretend);
132
            ($returnRes === true ? array_push($queries, $query) : '');
133
        }
134
135 11
        if ($actionsOnThatEntity & self::UPDATE_TABLE) {
136
            $queries = array_merge(
137
                $queries,
138
                $this->updateData($returnRes, $pretend, $callback)
139
            );
140
        }
141
142
        if ($actionsOnThatEntity & self::INSERT_TABLE) {
143
            $queries = array_merge(
144 17
                $queries,
145
                $this->insertData($returnRes, $pretend, $callback)
0 ignored issues
show
Bug introduced by
It seems like $callback defined by parameter $callback on line 112 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...
146 17
            );
147 17
        }
148 17
149 1
        return $queries;
150
    }
151
152 16
153
    public function countResults(string $table): int
154
    {
155
        $queryBuilder = $this->conn->createQueryBuilder();
156
        $rows = $queryBuilder->select('COUNT(1)')->from($table)->execute();
157
158
        return (int)$rows->fetch(\Doctrine\DBAL\FetchMode::NUMERIC)[0];
159
    }
160
161 16
162
    /**
163 16
     * Identify the primary key for a table
164 16
     *
165 16
     * @return string Field's name
166 16
     */
167 16
    private function getPrimaryKey(): string
168 16
    {
169 16
        $schema = $this->conn->getSchemaManager();
170 16
        $tableDetails = $schema->listTableDetails($this->entity);
171
        if ($tableDetails->hasPrimaryKey() === false) {
172
            throw new NeuralizerException("Can't find a primary key for '{$this->entity}'");
173
        }
174 16
175
        return $tableDetails->getPrimaryKey()->getColumns()[0];
176
    }
177
178
179
    /**
180
     * Retrieve columns list for a table with type and length
181
     *
182
     * @return array $cols
183
     */
184 9
    private function getTableCols(): array
185
    {
186 9
        $schema = $this->conn->getSchemaManager();
187
        $tableCols = $schema->listTableColumns($this->entity);
188 7
        $cols = [];
189 7
        foreach ($tableCols as $col) {
190 7
            $cols[$col->getName()] = [
191 7
                'length' => $col->getLength(),
192 7
                'type'   => $col->getType(),
193
                'unsigned' => $col->getUnsigned(),
194 7
            ];
195 7
        }
196
197 7
        return $cols;
198
    }
199
200
201
    /**
202
     * Execute the Update with Doctrine QueryBuilder
203
     *
204
     * @param  string $primaryKeyVal  Primary Key's Value
205 3
     * @return QueryBuilder           Doctrine DBAL QueryBuilder
206
     */
207 3
    private function prepareUpdate($primaryKeyVal): QueryBuilder
208
    {
209 3
        $data = $this->generateFakeData();
210 3
211 3
        $queryBuilder = $this->conn->createQueryBuilder();
212 3
        $queryBuilder = $queryBuilder->update($this->entity);
213 3
        foreach ($data as $field => $value) {
214
            $queryBuilder = $queryBuilder->set($field, $this->getCondition($field));
215
            $queryBuilder = $queryBuilder->setParameter(":$field", $value);
216 3
        }
217
        $queryBuilder = $queryBuilder->where("{$this->priKey} = :{$this->priKey}");
218
        $queryBuilder = $queryBuilder->setParameter(":{$this->priKey}", $primaryKeyVal);
219
220
        return $queryBuilder;
221
    }
222
223
    /**
224
     * Execute the Update with Doctrine QueryBuilder
225 8
     *
226
     * @return QueryBuilder       Doctrine DBAL QueryBuilder
227 8
     */
228 8
    private function prepareInsert(): QueryBuilder
229 8
    {
230
        $data = $this->generateFakeData();
231
232 8
        $queryBuilder = $this->conn->createQueryBuilder();
233
        $queryBuilder = $queryBuilder->insert($this->entity);
234
        foreach ($data as $field => $value) {
235
            $queryBuilder = $queryBuilder->setValue($field, ":$field");
236
            $queryBuilder = $queryBuilder->setParameter(":$field", $value);
237
        }
238
239
        return $queryBuilder;
240
    }
241
242
243
    /**
244 4
     * To debug, build the final SQL (can be approximative)
245
     * @param  QueryBuilder $queryBuilder
246 4
     * @return string
247 4
     */
248 4
    private function getRawSQL(QueryBuilder $queryBuilder)
249 3
    {
250
        $sql = $queryBuilder->getSQL();
251 4
        foreach ($queryBuilder->getParameters() as $parameter => $value) {
252
            $sql = str_replace($parameter, "'$value'", $sql);
253 4
        }
254 1
255
        return $sql;
256
    }
257
258 3
259 1
    /**
260 1
     * Execute the Delete with Doctrine Query Builder
261
     *
262
     * @param string $where
263 2
     * @param bool   $pretend
264
     *
265
     * @return string
266
     */
267
    private function runDelete(string $where, bool $pretend): string
268
    {
269
        $queryBuilder = $this->conn->createQueryBuilder();
270
        $queryBuilder = $queryBuilder->delete($this->entity);
271
        if (!empty($where)) {
272
            $queryBuilder = $queryBuilder->where($where);
273 7
        }
274
        $sql = $queryBuilder->getSQL();
275 7
276
        if ($pretend === true) {
277 7
            return $sql;
278
        }
279 7
280
        try {
281
            $queryBuilder->execute();
282 7
        } catch (\Exception $e) {
283 7
            throw new NeuralizerException('Query DELETE Error (' . $e->getMessage() . ')');
284 7
        }
285 7
286 7
        return $sql;
287 7
    }
288 7
289 7
290
    /**
291
     * Build the condition by casting the value if needed
292
     *
293 7
     * @param  string $field
294 7
     * @return string
295
     */
296
    private function getCondition(string $field): string
297 7
    {
298
        $type = strtolower($this->entityCols[$field]['type']);
299
300
        $integerCast = $this->getIntegerCast($field);
301
302
        $condition = "(CASE $field WHEN NULL THEN NULL ELSE :$field END)";
303
304
        $typeToCast = [
305
            'date'     => 'DATE',
306
            'datetime' => 'DATE',
307 7
            'time'     => 'TIME',
308
            'smallint' => $integerCast,
309 7
            'integer'  => $integerCast,
310 7
            'bigint'   => $integerCast,
311 7
            'float'    => 'DECIMAL',
312
            'decimal'  => 'DECIMAL',
313
        ];
314
315
        // No cast required
316
        if (!array_key_exists($type, $typeToCast)) {
317
            return $condition;
318
        }
319
320
        return "CAST($condition AS {$typeToCast[$type]})";
321
    }
322
323
324
    /**
325
     * Get the right CAST for an INTEGER
326 10
     *
327
     * @param  string $field
328
     * @return string
329
     */
330 10
    private function getIntegerCast(string $field): string
331 10
    {
332
        $driver = $this->getConn()->getDriver();
333 10
        if ($driver->getName() === 'pdo_mysql') {
334 10
            return $this->entityCols[$field]['unsigned'] === true ? 'UNSIGNED' : 'SIGNED';
335
        }
336 10
337 9
        return 'INTEGER';
338
    }
339 7
340
341 7
    /**
342 4
     * Update data of table
343
     *
344
     * @param  bool     $returnRes
345 7
     * @param  bool     $pretend
346 3
     * @param  callable $callback
347
     * @return array
348
     */
349 7
    private function updateData(bool $returnRes, bool $pretend, $callback = null): array
350 7
    {
351
        $queryBuilder = $this->conn->createQueryBuilder();
352
        if ($this->limit === 0) {
353
            $this->setLimit($this->countResults($this->entity));
354 8
        }
355
356
        $startAt = 0; // The first part of the limit (offset)
357
        $num = 0; // The number of rows updated
358
        $queries = [];
359
        while ($num < $this->limit) {
360
            $rows = $queryBuilder
361
                        ->select($this->priKey)->from($this->entity)
362
                        ->setFirstResult($startAt)->setMaxResults($this->batchSize)
363
                        ->execute();
364
365
            // I need to read line by line if I have to update the table
366 3
            // to make sure I do update by update (slower but no other choice for now)
367
            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...
368 3
                $updateQuery = $this->prepareUpdate($row[$this->priKey]);
369
370 3
                ($returnRes === true ? array_push($queries, $this->getRawSQL($updateQuery)) : '');
371
372 3
                if ($pretend === false) {
373 3
                    $updateQuery->execute();
374
                }
375 3
376
                if (!is_null($callback)) {
377 3
                    $callback(++$num);
378 2
                }
379
380
                // Have to exit now as we have reached the max
381 3
                if ($num >= $this->limit) {
382 1
                    break 2;
383
                }
384
            }
385
            // Move the offset
386 3
            // Make sure the loop ends if we have nothing to process
387
            $num = $startAt += $this->batchSize;
388
        }
389
390
        return $queries;
391
    }
392
393
394
    /**
395
     * Insert data into table
396
     *
397
     * @param  bool   $returnRes
398
     * @param  bool   $pretend
399
     * @param  callable $callback
400
     * @return array
401
     */
402
    private function insertData(bool $returnRes, bool $pretend, $callback): array
403
    {
404
        $queries = [];
405
406
        $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...
407
408
        for ($rowNum = 1; $rowNum <= $this->limit; $rowNum++) {
409
            $queryBuilder = $this->prepareInsert();
410
411
            ($returnRes === true ? array_push($queries, $this->getRawSQL($queryBuilder)) : '');
412
413
            if ($pretend === false) {
414
                $queryBuilder->execute();
415
            }
416
417
            if (!is_null($callback)) {
418
                $callback($rowNum);
419
            }
420
        }
421
422
        return $queries;
423
    }
424
}
425