Passed
Pull Request — master (#123)
by
unknown
02:57
created

TDBMService::findObject()   B

Complexity

Conditions 7
Paths 4

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 19
dl 0
loc 31
rs 8.8333
c 0
b 0
f 0
cc 7
nc 4
nop 5
1
<?php
2
declare(strict_types=1);
3
4
/*
5
 Copyright (C) 2006-2017 David Négrier - THE CODING MACHINE
6
7
This program is free software; you can redistribute it and/or modify
8
it under the terms of the GNU General Public License as published by
9
the Free Software Foundation; either version 2 of the License, or
10
(at your option) any later version.
11
12
This program is distributed in the hope that it will be useful,
13
but WITHOUT ANY WARRANTY; without even the implied warranty of
14
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
GNU General Public License for more details.
16
17
You should have received a copy of the GNU General Public License
18
along with this program; if not, write to the Free Software
19
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
20
*/
21
22
namespace TheCodingMachine\TDBM;
23
24
use Doctrine\Common\Cache\Cache;
25
use Doctrine\Common\Cache\ClearableCache;
26
use Doctrine\Common\Cache\VoidCache;
27
use Doctrine\DBAL\Connection;
28
use Doctrine\DBAL\DBALException;
29
use Doctrine\DBAL\Platforms\AbstractPlatform;
30
use Doctrine\DBAL\Platforms\MySqlPlatform;
31
use Doctrine\DBAL\Platforms\OraclePlatform;
32
use Doctrine\DBAL\Schema\Column;
33
use Doctrine\DBAL\Schema\ForeignKeyConstraint;
34
use Doctrine\DBAL\Schema\Schema;
35
use Doctrine\DBAL\Schema\Table;
36
use Doctrine\DBAL\Types\Type;
37
use Mouf\Database\MagicQuery;
38
use Mouf\Database\SchemaAnalyzer\SchemaAnalyzer;
39
use TheCodingMachine\TDBM\QueryFactory\FindObjectsFromSqlQueryFactory;
40
use TheCodingMachine\TDBM\QueryFactory\FindObjectsQueryFactory;
41
use TheCodingMachine\TDBM\QueryFactory\FindObjectsFromRawSqlQueryFactory;
42
use TheCodingMachine\TDBM\Utils\NamingStrategyInterface;
43
use TheCodingMachine\TDBM\Utils\TDBMDaoGenerator;
44
use Phlib\Logger\Decorator\LevelFilter;
45
use Psr\Log\LoggerInterface;
46
use Psr\Log\LogLevel;
47
use Psr\Log\NullLogger;
48
49
/**
50
 * The TDBMService class is the main TDBM class. It provides methods to retrieve TDBMObject instances
51
 * from the database.
52
 *
53
 * @author David Negrier
54
 * @ExtendedAction {"name":"Generate DAOs", "url":"tdbmadmin/", "default":false}
55
 */
56
class TDBMService
57
{
58
    const MODE_CURSOR = 1;
59
    const MODE_ARRAY = 2;
60
61
    /**
62
     * The database connection.
63
     *
64
     * @var Connection
65
     */
66
    private $connection;
67
68
    /**
69
     * @var SchemaAnalyzer
70
     */
71
    private $schemaAnalyzer;
72
73
    /**
74
     * @var MagicQuery
75
     */
76
    private $magicQuery;
77
78
    /**
79
     * @var TDBMSchemaAnalyzer
80
     */
81
    private $tdbmSchemaAnalyzer;
82
83
    /**
84
     * @var string
85
     */
86
    private $cachePrefix;
87
88
    /**
89
     * Cache of table of primary keys.
90
     * Primary keys are stored by tables, as an array of column.
91
     * For instance $primary_key['my_table'][0] will return the first column of the primary key of table 'my_table'.
92
     *
93
     * @var string[][]
94
     */
95
    private $primaryKeysColumns;
96
97
    /**
98
     * Service storing objects in memory.
99
     * Access is done by table name and then by primary key.
100
     * If the primary key is split on several columns, access is done by an array of columns, serialized.
101
     *
102
     * @var StandardObjectStorage|WeakrefObjectStorage
103
     */
104
    private $objectStorage;
105
106
    /**
107
     * The fetch mode of the result sets returned by `getObjects`.
108
     * Can be one of: TDBMObjectArray::MODE_CURSOR or TDBMObjectArray::MODE_ARRAY or TDBMObjectArray::MODE_COMPATIBLE_ARRAY.
109
     *
110
     * In 'MODE_ARRAY' mode (default), the result is an array. Use this mode by default (unless the list returned is very big).
111
     * In 'MODE_CURSOR' mode, the result is a Generator which is an iterable collection that can be scanned only once (only one "foreach") on it,
112
     * and it cannot be accessed via key. Use this mode for large datasets processed by batch.
113
     * In 'MODE_COMPATIBLE_ARRAY' mode, the result is an old TDBMObjectArray (used up to TDBM 3.2).
114
     * You can access the array by key, or using foreach, several times.
115
     *
116
     * @var int
117
     */
118
    private $mode = self::MODE_ARRAY;
119
120
    /**
121
     * Table of new objects not yet inserted in database or objects modified that must be saved.
122
     *
123
     * @var \SplObjectStorage of DbRow objects
124
     */
125
    private $toSaveObjects;
126
127
    /**
128
     * A cache service to be used.
129
     *
130
     * @var Cache
131
     */
132
    private $cache;
133
134
    /**
135
     * Map associating a table name to a fully qualified Bean class name.
136
     *
137
     * @var array
138
     */
139
    private $tableToBeanMap = [];
140
141
    /**
142
     * @var \ReflectionClass[]
143
     */
144
    private $reflectionClassCache = array();
145
146
    /**
147
     * @var LoggerInterface
148
     */
149
    private $rootLogger;
150
151
    /**
152
     * @var LevelFilter|NullLogger
153
     */
154
    private $logger;
155
156
    /**
157
     * @var OrderByAnalyzer
158
     */
159
    private $orderByAnalyzer;
160
161
    /**
162
     * @var string
163
     */
164
    private $beanNamespace;
165
166
    /**
167
     * @var NamingStrategyInterface
168
     */
169
    private $namingStrategy;
170
    /**
171
     * @var ConfigurationInterface
172
     */
173
    private $configuration;
174
175
    /**
176
     * @param ConfigurationInterface $configuration The configuration object
177
     */
178
    public function __construct(ConfigurationInterface $configuration)
179
    {
180
        if (extension_loaded('weakref')) {
181
            $this->objectStorage = new WeakrefObjectStorage();
182
        } else {
183
            $this->objectStorage = new StandardObjectStorage();
184
        }
185
        $this->connection = $configuration->getConnection();
186
        $this->cache = $configuration->getCache();
187
        $this->schemaAnalyzer = $configuration->getSchemaAnalyzer();
188
189
        $this->magicQuery = new MagicQuery($this->connection, $this->cache, $this->schemaAnalyzer);
190
191
        $this->tdbmSchemaAnalyzer = new TDBMSchemaAnalyzer($this->connection, $this->cache, $this->schemaAnalyzer);
192
        $this->cachePrefix = $this->tdbmSchemaAnalyzer->getCachePrefix();
193
194
        $this->toSaveObjects = new \SplObjectStorage();
195
        $logger = $configuration->getLogger();
196
        if ($logger === null) {
197
            $this->logger = new NullLogger();
198
            $this->rootLogger = new NullLogger();
199
        } else {
200
            $this->rootLogger = $logger;
201
            $this->setLogLevel(LogLevel::WARNING);
202
        }
203
        $this->orderByAnalyzer = new OrderByAnalyzer($this->cache, $this->cachePrefix);
204
        $this->beanNamespace = $configuration->getBeanNamespace();
205
        $this->namingStrategy = $configuration->getNamingStrategy();
206
        $this->configuration = $configuration;
207
    }
208
209
    /**
210
     * Returns the object used to connect to the database.
211
     *
212
     * @return Connection
213
     */
214
    public function getConnection(): Connection
215
    {
216
        return $this->connection;
217
    }
218
219
    /**
220
     * Sets the default fetch mode of the result sets returned by `findObjects`.
221
     * Can be one of: TDBMObjectArray::MODE_CURSOR or TDBMObjectArray::MODE_ARRAY.
222
     *
223
     * In 'MODE_ARRAY' mode (default), the result is a ResultIterator object that behaves like an array. Use this mode by default (unless the list returned is very big).
224
     * In 'MODE_CURSOR' mode, the result is a ResultIterator object. If you scan it many times (by calling several time a foreach loop), the query will be run
225
     * several times. In cursor mode, you cannot access the result set by key. Use this mode for large datasets processed by batch.
226
     *
227
     * @param int $mode
228
     *
229
     * @return self
230
     *
231
     * @throws TDBMException
232
     */
233
    public function setFetchMode(int $mode): self
234
    {
235
        if ($mode !== self::MODE_CURSOR && $mode !== self::MODE_ARRAY) {
236
            throw new TDBMException("Unknown fetch mode: '".$this->mode."'");
237
        }
238
        $this->mode = $mode;
239
240
        return $this;
241
    }
242
243
    /**
244
     * Removes the given object from database.
245
     * This cannot be called on an object that is not attached to this TDBMService
246
     * (will throw a TDBMInvalidOperationException).
247
     *
248
     * @param AbstractTDBMObject $object the object to delete
249
     *
250
     * @throws DBALException
251
     * @throws TDBMInvalidOperationException
252
     */
253
    public function delete(AbstractTDBMObject $object): void
254
    {
255
        switch ($object->_getStatus()) {
256
            case TDBMObjectStateEnum::STATE_DELETED:
257
                // Nothing to do, object already deleted.
258
                return;
259
            case TDBMObjectStateEnum::STATE_DETACHED:
260
                throw new TDBMInvalidOperationException('Cannot delete a detached object');
261
            case TDBMObjectStateEnum::STATE_NEW:
262
                $this->deleteManyToManyRelationships($object);
263
                foreach ($object->_getDbRows() as $dbRow) {
264
                    $this->removeFromToSaveObjectList($dbRow);
265
                }
266
                break;
267
            case TDBMObjectStateEnum::STATE_DIRTY:
268
                foreach ($object->_getDbRows() as $dbRow) {
269
                    $this->removeFromToSaveObjectList($dbRow);
270
                }
271
            // And continue deleting...
272
            // no break
273
            case TDBMObjectStateEnum::STATE_NOT_LOADED:
274
            case TDBMObjectStateEnum::STATE_LOADED:
275
                $this->connection->beginTransaction();
276
                try {
277
                    $this->deleteManyToManyRelationships($object);
278
                    // Let's delete db rows, in reverse order.
279
                    foreach (array_reverse($object->_getDbRows()) as $dbRow) {
280
                        /* @var $dbRow DbRow */
281
                        $tableName = $dbRow->_getDbTableName();
282
                        $primaryKeys = $dbRow->_getPrimaryKeys();
283
                        $quotedPrimaryKeys = [];
284
                        foreach ($primaryKeys as $column => $value) {
285
                            $quotedPrimaryKeys[$this->connection->quoteIdentifier($column)] = $value;
286
                        }
287
                        $this->connection->delete($this->connection->quoteIdentifier($tableName), $quotedPrimaryKeys);
288
                        $this->objectStorage->remove($dbRow->_getDbTableName(), $this->getObjectHash($primaryKeys));
289
                    }
290
                    $this->connection->commit();
291
                } catch (DBALException $e) {
292
                    $this->connection->rollBack();
293
                    throw $e;
294
                }
295
                break;
296
            // @codeCoverageIgnoreStart
297
            default:
298
                throw new TDBMInvalidOperationException('Unexpected status for bean');
299
            // @codeCoverageIgnoreEnd
300
        }
301
302
        $object->_setStatus(TDBMObjectStateEnum::STATE_DELETED);
303
    }
304
305
    /**
306
     * Removes all many to many relationships for this object.
307
     *
308
     * @param AbstractTDBMObject $object
309
     */
310
    private function deleteManyToManyRelationships(AbstractTDBMObject $object): void
311
    {
312
        foreach ($object->_getDbRows() as $tableName => $dbRow) {
313
            $pivotTables = $this->tdbmSchemaAnalyzer->getPivotTableLinkedToTable($tableName);
314
            foreach ($pivotTables as $pivotTable) {
315
                $remoteBeans = $object->_getRelationships($pivotTable);
316
                foreach ($remoteBeans as $remoteBean) {
317
                    $object->_removeRelationship($pivotTable, $remoteBean);
318
                }
319
            }
320
        }
321
        $this->persistManyToManyRelationships($object);
322
    }
323
324
    /**
325
     * This function removes the given object from the database. It will also remove all objects relied to the one given
326
     * by parameter before all.
327
     *
328
     * Notice: if the object has a multiple primary key, the function will not work.
329
     *
330
     * @param AbstractTDBMObject $objToDelete
331
     */
332
    public function deleteCascade(AbstractTDBMObject $objToDelete): void
333
    {
334
        $this->deleteAllConstraintWithThisObject($objToDelete);
335
        $this->delete($objToDelete);
336
    }
337
338
    /**
339
     * This function is used only in TDBMService (private function)
340
     * It will call deleteCascade function foreach object relied with a foreign key to the object given by parameter.
341
     *
342
     * @param AbstractTDBMObject $obj
343
     */
344
    private function deleteAllConstraintWithThisObject(AbstractTDBMObject $obj): void
345
    {
346
        $dbRows = $obj->_getDbRows();
347
        foreach ($dbRows as $dbRow) {
348
            $tableName = $dbRow->_getDbTableName();
349
            $pks = array_values($dbRow->_getPrimaryKeys());
350
            if (!empty($pks)) {
351
                $incomingFks = $this->tdbmSchemaAnalyzer->getIncomingForeignKeys($tableName);
352
353
                foreach ($incomingFks as $incomingFk) {
354
                    $filter = SafeFunctions::arrayCombine($incomingFk->getUnquotedLocalColumns(), $pks);
355
356
                    $results = $this->findObjects($incomingFk->getLocalTableName(), $filter);
357
358
                    foreach ($results as $bean) {
359
                        $this->deleteCascade($bean);
360
                    }
361
                }
362
            }
363
        }
364
    }
365
366
    /**
367
     * This function performs a save() of all the objects that have been modified.
368
     */
369
    public function completeSave(): void
370
    {
371
        foreach ($this->toSaveObjects as $dbRow) {
372
            $this->save($dbRow->getTDBMObject());
373
        }
374
    }
375
376
    /**
377
     * Takes in input a filter_bag (which can be about anything from a string to an array of TDBMObjects... see above from documentation),
378
     * and gives back a proper Filter object.
379
     *
380
     * @param mixed $filter_bag
381
     * @param AbstractPlatform $platform The platform used to quote identifiers
382
     * @param int $counter
383
     * @return mixed[] First item: filter string, second item: parameters, third item: the count
384
     *
385
     * @throws TDBMException
386
     */
387
    public function buildFilterFromFilterBag($filter_bag, AbstractPlatform $platform, int $counter = 1): array
388
    {
389
        if ($filter_bag === null) {
390
            return ['', [], $counter];
391
        } elseif (is_string($filter_bag)) {
392
            return [$filter_bag, [], $counter];
393
        } elseif (is_array($filter_bag)) {
394
            $sqlParts = [];
395
            $parameters = [];
396
397
            foreach ($filter_bag as $column => $value) {
398
                if (is_int($column)) {
399
                    list($subSqlPart, $subParameters, $counter) = $this->buildFilterFromFilterBag($value, $platform, $counter);
400
                    $sqlParts[] = $subSqlPart;
401
                    $parameters += $subParameters;
402
                } else {
403
                    $paramName = 'tdbmparam'.$counter;
404
                    if (is_array($value)) {
405
                        $sqlParts[] = $platform->quoteIdentifier($column).' IN :'.$paramName;
406
                    } else {
407
                        $sqlParts[] = $platform->quoteIdentifier($column).' = :'.$paramName;
408
                    }
409
                    $parameters[$paramName] = $value;
410
                    ++$counter;
411
                }
412
            }
413
414
            return [implode(' AND ', $sqlParts), $parameters, $counter];
415
        } elseif ($filter_bag instanceof AbstractTDBMObject) {
416
            $sqlParts = [];
417
            $parameters = [];
418
            $dbRows = $filter_bag->_getDbRows();
419
            $dbRow = reset($dbRows);
420
            if ($dbRow === false) {
421
                throw new \RuntimeException('Unexpected error: empty dbRow'); // @codeCoverageIgnore
422
            }
423
            $primaryKeys = $dbRow->_getPrimaryKeys();
424
425
            foreach ($primaryKeys as $column => $value) {
426
                $paramName = 'tdbmparam'.$counter;
427
                $sqlParts[] = $platform->quoteIdentifier($dbRow->_getDbTableName()).'.'.$platform->quoteIdentifier($column).' = :'.$paramName;
428
                $parameters[$paramName] = $value;
429
                ++$counter;
430
            }
431
432
            return [implode(' AND ', $sqlParts), $parameters, $counter];
433
        } elseif ($filter_bag instanceof \Iterator) {
434
            return $this->buildFilterFromFilterBag(iterator_to_array($filter_bag), $platform, $counter);
435
        } else {
436
            throw new TDBMException('Error in filter. An object has been passed that is neither a SQL string, nor an array, nor a bean, nor null.');
437
        }
438
    }
439
440
    /**
441
     * @param string $table
442
     *
443
     * @return string[]
444
     */
445
    public function getPrimaryKeyColumns(string $table): array
446
    {
447
        if (!isset($this->primaryKeysColumns[$table])) {
448
            $primaryKey = $this->tdbmSchemaAnalyzer->getSchema()->getTable($table)->getPrimaryKey();
449
            if ($primaryKey === null) {
450
                // Security check: a table MUST have a primary key
451
                throw new TDBMException(sprintf('Table "%s" does not have any primary key', $table));
452
            }
453
454
            $this->primaryKeysColumns[$table] = $primaryKey->getUnquotedColumns();
455
456
            // TODO TDBM4: See if we need to improve error reporting if table name does not exist.
457
458
            /*$arr = array();
459
            foreach ($this->connection->getPrimaryKey($table) as $col) {
460
                $arr[] = $col->name;
461
            }
462
            // The primaryKeysColumns contains only the column's name, not the DB_Column object.
463
            $this->primaryKeysColumns[$table] = $arr;
464
            if (empty($this->primaryKeysColumns[$table]))
465
            {
466
                // Unable to find primary key.... this is an error
467
                // Let's try to be precise in error reporting. Let's try to find the table.
468
                $tables = $this->connection->checkTableExist($table);
469
                if ($tables === true)
470
                throw new TDBMException("Could not find table primary key for table '$table'. Please define a primary key for this table.");
471
                elseif ($tables !== null) {
472
                    if (count($tables)==1)
473
                    $str = "Could not find table '$table'. Maybe you meant this table: '".$tables[0]."'";
474
                    else
475
                    $str = "Could not find table '$table'. Maybe you meant one of those tables: '".implode("', '",$tables)."'";
476
                    throw new TDBMException($str);
477
                }
478
            }*/
479
        }
480
481
        return $this->primaryKeysColumns[$table];
482
    }
483
484
    /**
485
     * This is an internal function, you should not use it in your application.
486
     * This is used internally by TDBM to add an object to the object cache.
487
     *
488
     * @param DbRow $dbRow
489
     */
490
    public function _addToCache(DbRow $dbRow): void
491
    {
492
        $primaryKey = $this->getPrimaryKeysForObjectFromDbRow($dbRow);
493
        $hash = $this->getObjectHash($primaryKey);
494
        $this->objectStorage->set($dbRow->_getDbTableName(), $hash, $dbRow);
495
    }
496
497
    /**
498
     * This is an internal function, you should not use it in your application.
499
     * This is used internally by TDBM to remove the object from the list of objects that have been
500
     * created/updated but not saved yet.
501
     *
502
     * @param DbRow $myObject
503
     */
504
    private function removeFromToSaveObjectList(DbRow $myObject): void
505
    {
506
        unset($this->toSaveObjects[$myObject]);
507
    }
508
509
    /**
510
     * This is an internal function, you should not use it in your application.
511
     * This is used internally by TDBM to add an object to the list of objects that have been
512
     * created/updated but not saved yet.
513
     *
514
     * @param DbRow $myObject
515
     */
516
    public function _addToToSaveObjectList(DbRow $myObject): void
517
    {
518
        $this->toSaveObjects[$myObject] = true;
519
    }
520
521
    /**
522
     * Generates all the daos and beans.
523
     */
524
    public function generateAllDaosAndBeans() : void
525
    {
526
        // Purge cache before generating anything.
527
        if ($this->cache instanceof ClearableCache) {
528
            $this->cache->deleteAll();
529
        }
530
531
        $tdbmDaoGenerator = new TDBMDaoGenerator($this->configuration, $this->tdbmSchemaAnalyzer);
532
        $tdbmDaoGenerator->generateAllDaosAndBeans();
533
    }
534
535
    /**
536
     * Returns the fully qualified class name of the bean associated with table $tableName.
537
     *
538
     *
539
     * @param string $tableName
540
     *
541
     * @return string
542
     */
543
    public function getBeanClassName(string $tableName) : string
544
    {
545
        if (isset($this->tableToBeanMap[$tableName])) {
546
            return $this->tableToBeanMap[$tableName];
547
        } else {
548
            $className = $this->beanNamespace.'\\'.$this->namingStrategy->getBeanClassName($tableName);
549
550
            if (!class_exists($className)) {
551
                throw new TDBMInvalidArgumentException(sprintf('Could not find class "%s". Does table "%s" exist? If yes, consider regenerating the DAOs and beans.', $className, $tableName));
552
            }
553
554
            $this->tableToBeanMap[$tableName] = $className;
555
            return $className;
556
        }
557
    }
558
559
    /**
560
     * Saves $object by INSERTing or UPDAT(E)ing it in the database.
561
     *
562
     * @param AbstractTDBMObject $object
563
     *
564
     * @throws TDBMException
565
     */
566
    public function save(AbstractTDBMObject $object): void
567
    {
568
        $this->connection->beginTransaction();
569
        try {
570
            $status = $object->_getStatus();
571
572
            // Let's attach this object if it is in detached state.
573
            if ($status === TDBMObjectStateEnum::STATE_DETACHED) {
574
                $object->_attach($this);
575
                $status = $object->_getStatus();
576
            }
577
578
            if ($status === TDBMObjectStateEnum::STATE_NEW) {
579
                $dbRows = $object->_getDbRows();
580
581
                $unindexedPrimaryKeys = array();
582
583
                foreach ($dbRows as $dbRow) {
584
                    if ($dbRow->_getStatus() == TDBMObjectStateEnum::STATE_SAVING) {
585
                        throw TDBMCyclicReferenceException::createCyclicReference($dbRow->_getDbTableName(), $object);
586
                    }
587
                    $dbRow->_setStatus(TDBMObjectStateEnum::STATE_SAVING);
588
                    $tableName = $dbRow->_getDbTableName();
589
590
                    $schema = $this->tdbmSchemaAnalyzer->getSchema();
591
                    $tableDescriptor = $schema->getTable($tableName);
592
593
                    $primaryKeyColumns = $this->getPrimaryKeyColumns($tableName);
594
595
                    $references = $dbRow->_getReferences();
596
597
                    // Let's save all references in NEW or DETACHED state (we need their primary key)
598
                    foreach ($references as $fkName => $reference) {
599
                        if ($reference !== null) {
600
                            $refStatus = $reference->_getStatus();
601
                            if ($refStatus === TDBMObjectStateEnum::STATE_NEW || $refStatus === TDBMObjectStateEnum::STATE_DETACHED) {
602
                                try {
603
                                    $this->save($reference);
604
                                } catch (TDBMCyclicReferenceException $e) {
605
                                    throw TDBMCyclicReferenceException::extendCyclicReference($e, $dbRow->_getDbTableName(), $object, $fkName);
606
                                }
607
                            }
608
                        }
609
                    }
610
611
                    if (empty($unindexedPrimaryKeys)) {
612
                        $primaryKeys = $this->getPrimaryKeysForObjectFromDbRow($dbRow);
613
                    } else {
614
                        // First insert, the children must have the same primary key as the parent.
615
                        $primaryKeys = $this->_getPrimaryKeysFromIndexedPrimaryKeys($tableName, $unindexedPrimaryKeys);
616
                        $dbRow->_setPrimaryKeys($primaryKeys);
617
                    }
618
619
                    $dbRowData = $dbRow->_getDbRow();
620
621
                    // Let's see if the columns for primary key have been set before inserting.
622
                    // We assume that if one of the value of the PK has been set, the PK is set.
623
                    $isPkSet = !empty($primaryKeys);
624
625
                    /*if (!$isPkSet) {
626
                        // if there is no autoincrement and no pk set, let's go in error.
627
                        $isAutoIncrement = true;
628
629
                        foreach ($primaryKeyColumns as $pkColumnName) {
630
                            $pkColumn = $tableDescriptor->getColumn($pkColumnName);
631
                            if (!$pkColumn->getAutoincrement()) {
632
                                $isAutoIncrement = false;
633
                            }
634
                        }
635
636
                        if (!$isAutoIncrement) {
637
                            $msg = "Error! You did not set the primary key(s) for the new object of type '$tableName'. The primary key is not set to 'autoincrement' so you must either set the primary key in the object or modify the DB model to create an primary key with auto-increment.";
638
                            throw new TDBMException($msg);
639
                        }
640
641
                    }*/
642
643
                    $types = [];
644
                    $escapedDbRowData = [];
645
646
                    foreach ($dbRowData as $columnName => $value) {
647
                        $columnDescriptor = $tableDescriptor->getColumn($columnName);
648
                        $types[] = $columnDescriptor->getType();
649
                        $escapedDbRowData[$this->connection->quoteIdentifier($columnName)] = $value;
650
                    }
651
652
                    $quotedTableName = $this->connection->quoteIdentifier($tableName);
653
                    $this->connection->insert($quotedTableName, $escapedDbRowData, $types);
654
655
                    if (!$isPkSet && count($primaryKeyColumns) === 1) {
656
                        $id = $this->connection->lastInsertId();
657
658
                        if ($id === false) {
659
                            // In Oracle (if we are in 11g), the lastInsertId will fail. We try again with the column.
660
                            $sequenceName = $this->connection->getDatabasePlatform()->getIdentitySequenceName(
661
                                $quotedTableName,
662
                                $this->connection->quoteIdentifier($primaryKeyColumns[0])
663
                            );
664
                            $id = $this->connection->lastInsertId($sequenceName);
665
                        }
666
667
                        $pkColumn = $primaryKeyColumns[0];
668
                        // lastInsertId returns a string but the column type is usually a int. Let's convert it back to the correct type.
669
                        $id = $tableDescriptor->getColumn($pkColumn)->getType()->convertToPHPValue($id, $this->getConnection()->getDatabasePlatform());
670
                        $primaryKeys[$pkColumn] = $id;
671
                    }
672
673
                    // TODO: change this to some private magic accessor in future
674
                    $dbRow->_setPrimaryKeys($primaryKeys);
675
                    $unindexedPrimaryKeys = array_values($primaryKeys);
676
677
                    // Let's remove this object from the $new_objects static table.
678
                    $this->removeFromToSaveObjectList($dbRow);
679
680
                    // Let's add this object to the list of objects in cache.
681
                    $this->_addToCache($dbRow);
682
                }
683
684
                $object->_setStatus(TDBMObjectStateEnum::STATE_LOADED);
685
            } elseif ($status === TDBMObjectStateEnum::STATE_DIRTY) {
686
                $dbRows = $object->_getDbRows();
687
688
                foreach ($dbRows as $dbRow) {
689
                    if ($dbRow->_getStatus() !== TDBMObjectStateEnum::STATE_DIRTY) {
690
                        // Not all db_rows in a bean need to be dirty when the bean itself is dirty.
691
                        continue;
692
                    }
693
                    $references = $dbRow->_getReferences();
694
695
                    // Let's save all references in NEW state (we need their primary key)
696
                    foreach ($references as $fkName => $reference) {
697
                        if ($reference !== null && $reference->_getStatus() === TDBMObjectStateEnum::STATE_NEW) {
698
                            $this->save($reference);
699
                        }
700
                    }
701
702
                    $tableName = $dbRow->_getDbTableName();
703
                    $dbRowData = $dbRow->_getUpdatedDbRow();
704
705
                    $schema = $this->tdbmSchemaAnalyzer->getSchema();
706
                    $tableDescriptor = $schema->getTable($tableName);
707
708
                    $primaryKeys = $dbRow->_getPrimaryKeys();
709
710
                    $types = [];
711
                    $escapedDbRowData = [];
712
                    $escapedPrimaryKeys = [];
713
714
                    foreach ($dbRowData as $columnName => $value) {
715
                        $columnDescriptor = $tableDescriptor->getColumn($columnName);
716
                        $types[] = $columnDescriptor->getType();
717
                        $escapedDbRowData[$this->connection->quoteIdentifier($columnName)] = $value;
718
                    }
719
                    foreach ($primaryKeys as $columnName => $value) {
720
                        $columnDescriptor = $tableDescriptor->getColumn($columnName);
721
                        $types[] = $columnDescriptor->getType();
722
                        $escapedPrimaryKeys[$this->connection->quoteIdentifier($columnName)] = $value;
723
                    }
724
725
                    $this->connection->update($this->connection->quoteIdentifier($tableName), $escapedDbRowData, $escapedPrimaryKeys, $types);
726
727
                    // Let's check if the primary key has been updated...
728
                    $needsUpdatePk = false;
729
                    foreach ($primaryKeys as $column => $value) {
730
                        if (isset($dbRowData[$column]) && $dbRowData[$column] != $value) {
731
                            $needsUpdatePk = true;
732
                            break;
733
                        }
734
                    }
735
                    if ($needsUpdatePk) {
736
                        $this->objectStorage->remove($tableName, $this->getObjectHash($primaryKeys));
737
                        $newPrimaryKeys = $this->getPrimaryKeysForObjectFromDbRow($dbRow);
738
                        $dbRow->_setPrimaryKeys($newPrimaryKeys);
739
                        $this->objectStorage->set($tableName, $this->getObjectHash($primaryKeys), $dbRow);
740
                    }
741
742
                    // Let's remove this object from the list of objects to save.
743
                    $this->removeFromToSaveObjectList($dbRow);
744
                }
745
746
                $object->_setStatus(TDBMObjectStateEnum::STATE_LOADED);
747
            } elseif ($status === TDBMObjectStateEnum::STATE_DELETED) {
748
                throw new TDBMInvalidOperationException('This object has been deleted. It cannot be saved.');
749
            }
750
751
            // Finally, let's save all the many to many relationships to this bean.
752
            $this->persistManyToManyRelationships($object);
753
            $this->connection->commit();
754
        } catch (\Throwable $t) {
755
            $this->connection->rollBack();
756
            throw $t;
757
        }
758
    }
759
760
    private function persistManyToManyRelationships(AbstractTDBMObject $object): void
761
    {
762
        foreach ($object->_getCachedRelationships() as $pivotTableName => $storage) {
763
            $tableDescriptor = $this->tdbmSchemaAnalyzer->getSchema()->getTable($pivotTableName);
764
            list($localFk, $remoteFk) = $this->getPivotTableForeignKeys($pivotTableName, $object);
765
766
            $toRemoveFromStorage = [];
767
768
            foreach ($storage as $remoteBean) {
769
                /* @var $remoteBean AbstractTDBMObject */
770
                $statusArr = $storage[$remoteBean];
771
                $status = $statusArr['status'];
772
                $reverse = $statusArr['reverse'];
773
                if ($reverse) {
774
                    continue;
775
                }
776
777
                if ($status === 'new') {
778
                    $remoteBeanStatus = $remoteBean->_getStatus();
779
                    if ($remoteBeanStatus === TDBMObjectStateEnum::STATE_NEW || $remoteBeanStatus === TDBMObjectStateEnum::STATE_DETACHED) {
780
                        // Let's save remote bean if needed.
781
                        $this->save($remoteBean);
782
                    }
783
784
                    ['filters' => $filters, 'types' => $types] = $this->getPivotFilters($object, $remoteBean, $localFk, $remoteFk, $tableDescriptor);
785
786
                    $this->connection->insert($this->connection->quoteIdentifier($pivotTableName), $filters, $types);
787
788
                    // Finally, let's mark relationships as saved.
789
                    $statusArr['status'] = 'loaded';
790
                    $storage[$remoteBean] = $statusArr;
791
                    $remoteStorage = $remoteBean->_getCachedRelationships()[$pivotTableName];
792
                    $remoteStatusArr = $remoteStorage[$object];
793
                    $remoteStatusArr['status'] = 'loaded';
794
                    $remoteStorage[$object] = $remoteStatusArr;
795
                } elseif ($status === 'delete') {
796
                    ['filters' => $filters, 'types' => $types] = $this->getPivotFilters($object, $remoteBean, $localFk, $remoteFk, $tableDescriptor);
797
798
                    $this->connection->delete($this->connection->quoteIdentifier($pivotTableName), $filters, $types);
799
800
                    // Finally, let's remove relationships completely from bean.
801
                    $toRemoveFromStorage[] = $remoteBean;
802
803
                    $remoteBean->_getCachedRelationships()[$pivotTableName]->detach($object);
804
                }
805
            }
806
807
            // Note: due to https://bugs.php.net/bug.php?id=65629, we cannot delete an element inside a foreach loop on a SplStorageObject.
808
            // Therefore, we cache elements in the $toRemoveFromStorage to remove them at a later stage.
809
            foreach ($toRemoveFromStorage as $remoteBean) {
810
                $storage->detach($remoteBean);
811
            }
812
        }
813
    }
814
815
    /**
816
     * @return mixed[] An array with 2 keys: "filters" and "types"
817
     */
818
    private function getPivotFilters(AbstractTDBMObject $localBean, AbstractTDBMObject $remoteBean, ForeignKeyConstraint $localFk, ForeignKeyConstraint $remoteFk, Table $tableDescriptor): array
819
    {
820
        $localBeanPk = $this->getPrimaryKeyValues($localBean);
821
        $remoteBeanPk = $this->getPrimaryKeyValues($remoteBean);
822
        $localColumns = $localFk->getUnquotedLocalColumns();
823
        $remoteColumns = $remoteFk->getUnquotedLocalColumns();
824
825
        $localFilters = SafeFunctions::arrayCombine($localColumns, $localBeanPk);
826
        $remoteFilters = SafeFunctions::arrayCombine($remoteColumns, $remoteBeanPk);
827
828
        $filters = array_merge($localFilters, $remoteFilters);
829
830
        $types = [];
831
        $escapedFilters = [];
832
833
        foreach ($filters as $columnName => $value) {
834
            $columnDescriptor = $tableDescriptor->getColumn($columnName);
835
            $types[] = $columnDescriptor->getType();
836
            $escapedFilters[$this->connection->quoteIdentifier($columnName)] = $value;
837
        }
838
        return ['filters' => $escapedFilters, 'types' => $types];
839
    }
840
841
    /**
842
     * Returns the "values" of the primary key.
843
     * This returns the primary key from the $primaryKey attribute, not the one stored in the columns.
844
     *
845
     * @param AbstractTDBMObject $bean
846
     *
847
     * @return mixed[] numerically indexed array of values
848
     */
849
    private function getPrimaryKeyValues(AbstractTDBMObject $bean): array
850
    {
851
        $dbRows = $bean->_getDbRows();
852
        $dbRow = reset($dbRows);
853
        if ($dbRow === false) {
854
            throw new \RuntimeException('Unexpected error: empty dbRow'); // @codeCoverageIgnore
855
        }
856
857
        return array_values($dbRow->_getPrimaryKeys());
858
    }
859
860
    /**
861
     * Returns a unique hash used to store the object based on its primary key.
862
     * If the array contains only one value, then the value is returned.
863
     * Otherwise, a hash representing the array is returned.
864
     *
865
     * @param mixed[] $primaryKeys An array of columns => values forming the primary key
866
     *
867
     * @return string|int
868
     */
869
    public function getObjectHash(array $primaryKeys)
870
    {
871
        if (count($primaryKeys) === 1) {
872
            return reset($primaryKeys);
873
        } else {
874
            ksort($primaryKeys);
875
876
            $pkJson = json_encode($primaryKeys);
877
            if ($pkJson === false) {
878
                throw new TDBMException('Unexepected error: unable to encode primary keys'); // @codeCoverageIgnore
879
            }
880
            return md5($pkJson);
881
        }
882
    }
883
884
    /**
885
     * Returns an array of primary keys from the object.
886
     * The primary keys are extracted from the object columns and not from the primary keys stored in the
887
     * $primaryKeys variable of the object.
888
     *
889
     * @param DbRow $dbRow
890
     *
891
     * @return mixed[] Returns an array of column => value
892
     */
893
    public function getPrimaryKeysForObjectFromDbRow(DbRow $dbRow): array
894
    {
895
        $table = $dbRow->_getDbTableName();
896
897
        $primaryKeyColumns = $this->getPrimaryKeyColumns($table);
898
        $values = array();
899
        $dbRowValues = $dbRow->_getDbRow();
900
        foreach ($primaryKeyColumns as $column) {
901
            if (isset($dbRowValues[$column])) {
902
                $values[$column] = $dbRowValues[$column];
903
            }
904
        }
905
906
        return $values;
907
    }
908
909
    /**
910
     * Returns an array of primary keys for the given row.
911
     * The primary keys are extracted from the object columns.
912
     *
913
     * @param string $table
914
     * @param mixed[] $columns
915
     *
916
     * @return mixed[] Returns an array of column => value
917
     */
918
    public function _getPrimaryKeysFromObjectData(string $table, array $columns): array
919
    {
920
        $primaryKeyColumns = $this->getPrimaryKeyColumns($table);
921
        $values = [];
922
        foreach ($primaryKeyColumns as $column) {
923
            if (isset($columns[$column])) {
924
                $values[$column] = $columns[$column];
925
            }
926
        }
927
928
        return $values;
929
    }
930
931
    /**
932
     * Attaches $object to this TDBMService.
933
     * The $object must be in DETACHED state and will pass in NEW state.
934
     *
935
     * @param AbstractTDBMObject $object
936
     *
937
     * @throws TDBMInvalidOperationException
938
     */
939
    public function attach(AbstractTDBMObject $object): void
940
    {
941
        $object->_attach($this);
942
    }
943
944
    /**
945
     * Returns an associative array (column => value) for the primary keys from the table name and an
946
     * indexed array of primary key values.
947
     *
948
     * @param string $tableName
949
     * @param mixed[] $indexedPrimaryKeys
950
     * @return mixed[]
951
     */
952
    public function _getPrimaryKeysFromIndexedPrimaryKeys(string $tableName, array $indexedPrimaryKeys): array
953
    {
954
        $primaryKeyColumns = $this->getPrimaryKeyColumns($tableName);
955
956
        if (count($primaryKeyColumns) !== count($indexedPrimaryKeys)) {
957
            throw new TDBMException(sprintf('Wrong number of columns passed for primary key. Expected %s columns for table "%s",
958
			got %s instead.', count($primaryKeyColumns), $tableName, count($indexedPrimaryKeys)));
959
        }
960
961
        return SafeFunctions::arrayCombine($primaryKeyColumns, $indexedPrimaryKeys);
962
    }
963
964
    /**
965
     * Return the list of tables (from child to parent) joining the tables passed in parameter.
966
     * Tables must be in a single line of inheritance. The method will find missing tables.
967
     *
968
     * Algorithm: one of those tables is the ultimate child. From this child, by recursively getting the parent,
969
     * we must be able to find all other tables.
970
     *
971
     * @param string[] $tables
972
     *
973
     * @return string[]
974
     */
975
    public function _getLinkBetweenInheritedTables(array $tables): array
976
    {
977
        sort($tables);
978
979
        return $this->fromCache(
980
            $this->cachePrefix.'_linkbetweeninheritedtables_'.implode('__split__', $tables),
981
            function () use ($tables) {
982
                return $this->_getLinkBetweenInheritedTablesWithoutCache($tables);
983
            }
984
        );
985
    }
986
987
    /**
988
     * Return the list of tables (from child to parent) joining the tables passed in parameter.
989
     * Tables must be in a single line of inheritance. The method will find missing tables.
990
     *
991
     * Algorithm: one of those tables is the ultimate child. From this child, by recursively getting the parent,
992
     * we must be able to find all other tables.
993
     *
994
     * @param string[] $tables
995
     *
996
     * @return string[]
997
     */
998
    private function _getLinkBetweenInheritedTablesWithoutCache(array $tables): array
999
    {
1000
        $schemaAnalyzer = $this->schemaAnalyzer;
1001
1002
        foreach ($tables as $currentTable) {
1003
            $allParents = [$currentTable];
1004
            while ($currentFk = $schemaAnalyzer->getParentRelationship($currentTable)) {
1005
                $currentTable = $currentFk->getForeignTableName();
1006
                $allParents[] = $currentTable;
1007
            }
1008
1009
            // Now, does the $allParents contain all the tables we want?
1010
            $notFoundTables = array_diff($tables, $allParents);
1011
            if (empty($notFoundTables)) {
1012
                // We have a winner!
1013
                return $allParents;
1014
            }
1015
        }
1016
1017
        throw TDBMInheritanceException::create($tables);
1018
    }
1019
1020
    /**
1021
     * Returns the list of tables related to this table (via a parent or child inheritance relationship).
1022
     *
1023
     * @param string $table
1024
     *
1025
     * @return string[]
1026
     */
1027
    public function _getRelatedTablesByInheritance(string $table): array
1028
    {
1029
        return $this->fromCache($this->cachePrefix.'_relatedtables_'.$table, function () use ($table) {
1030
            return $this->_getRelatedTablesByInheritanceWithoutCache($table);
1031
        });
1032
    }
1033
1034
    /**
1035
     * Returns the list of tables related to this table (via a parent or child inheritance relationship).
1036
     *
1037
     * @param string $table
1038
     *
1039
     * @return string[]
1040
     */
1041
    private function _getRelatedTablesByInheritanceWithoutCache(string $table): array
1042
    {
1043
        $schemaAnalyzer = $this->schemaAnalyzer;
1044
1045
        // Let's scan the parent tables
1046
        $currentTable = $table;
1047
1048
        $parentTables = [];
1049
1050
        // Get parent relationship
1051
        while ($currentFk = $schemaAnalyzer->getParentRelationship($currentTable)) {
1052
            $currentTable = $currentFk->getForeignTableName();
1053
            $parentTables[] = $currentTable;
1054
        }
1055
1056
        // Let's recurse in children
1057
        $childrenTables = $this->exploreChildrenTablesRelationships($schemaAnalyzer, $table);
1058
1059
        return array_merge(array_reverse($parentTables), $childrenTables);
1060
    }
1061
1062
    /**
1063
     * Explore all the children and descendant of $table and returns ForeignKeyConstraints on those.
1064
     *
1065
     * @return string[]
1066
     */
1067
    private function exploreChildrenTablesRelationships(SchemaAnalyzer $schemaAnalyzer, string $table): array
1068
    {
1069
        $tables = [$table];
1070
        $keys = $schemaAnalyzer->getChildrenRelationships($table);
1071
1072
        foreach ($keys as $key) {
1073
            $tables = array_merge($tables, $this->exploreChildrenTablesRelationships($schemaAnalyzer, $key->getLocalTableName()));
1074
        }
1075
1076
        return $tables;
1077
    }
1078
1079
    /**
1080
     * Returns a `ResultIterator` object representing filtered records of "$mainTable" .
1081
     *
1082
     * The findObjects method should be the most used query method in TDBM if you want to query the database for objects.
1083
     * (Note: if you want to query the database for an object by its primary key, use the findObjectByPk method).
1084
     *
1085
     * The findObjects method takes in parameter:
1086
     * 	- mainTable: the kind of bean you want to retrieve. In TDBM, a bean matches a database row, so the
1087
     * 			`$mainTable` parameter should be the name of an existing table in database.
1088
     *  - filter: The filter is a filter bag. It is what you use to filter your request (the WHERE part in SQL).
1089
     *          It can be a string (SQL Where clause), or even a bean or an associative array (key = column to filter, value = value to find)
1090
     *  - parameters: The parameters used in the filter. If you pass a SQL string as a filter, be sure to avoid
1091
     *          concatenating parameters in the string (this leads to SQL injection and also to poor caching performance).
1092
     *          Instead, please consider passing parameters (see documentation for more details).
1093
     *  - additionalTablesFetch: An array of SQL tables names. The beans related to those tables will be fetched along
1094
     *          the main table. This is useful to avoid hitting the database with numerous subqueries.
1095
     *  - mode: The fetch mode of the result. See `setFetchMode()` method for more details.
1096
     *
1097
     * The `findObjects` method will return a `ResultIterator`. A `ResultIterator` is an object that behaves as an array
1098
     * (in ARRAY mode) at least. It can be iterated using a `foreach` loop.
1099
     *
1100
     * Finally, if filter_bag is null, the whole table is returned.
1101
     *
1102
     * @param string                       $mainTable             The name of the table queried
1103
     * @param string|array|null            $filter                The SQL filters to apply to the query (the WHERE part). Columns from tables different from $mainTable must be prefixed by the table name (in the form: table.column)
1104
     * @param mixed[]                      $parameters
1105
     * @param string|UncheckedOrderBy|null $orderString           The ORDER BY part of the query. Columns from tables different from $mainTable must be prefixed by the table name (in the form: table.column)
1106
     * @param string[]                     $additionalTablesFetch
1107
     * @param int|null                     $mode
1108
     * @param string                       $className             Optional: The name of the class to instantiate. This class must extend the TDBMObject class. If none is specified, a TDBMObject instance will be returned
1109
     *
1110
     * @return ResultIterator An object representing an array of results
1111
     *
1112
     * @throws TDBMException
1113
     */
1114
    public function findObjects(string $mainTable, $filter = null, array $parameters = array(), $orderString = null, array $additionalTablesFetch = array(), ?int $mode = null, string $className = null) : ResultIterator
1115
    {
1116
        // $mainTable is not secured in MagicJoin, let's add a bit of security to avoid SQL injection.
1117
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $mainTable)) {
1118
            throw new TDBMException(sprintf("Invalid table name: '%s'", $mainTable));
1119
        }
1120
1121
        $mode = $mode ?: $this->mode;
1122
1123
        // We quote in MySQL because MagicJoin requires MySQL style quotes
1124
        $mysqlPlatform = new MySqlPlatform();
1125
        list($filterString, $additionalParameters) = $this->buildFilterFromFilterBag($filter, $mysqlPlatform);
1126
1127
        $parameters = array_merge($parameters, $additionalParameters);
1128
1129
        $queryFactory = new FindObjectsQueryFactory($mainTable, $additionalTablesFetch, $filterString, $orderString, $this, $this->tdbmSchemaAnalyzer->getSchema(), $this->orderByAnalyzer);
1130
1131
        return new ResultIterator($queryFactory, $parameters, $this->objectStorage, $className, $this, $this->magicQuery, $mode, $this->logger);
1132
    }
1133
1134
    /**
1135
     * @param string                       $mainTable   The name of the table queried
1136
     * @param string                       $from        The from sql statement
1137
     * @param string|array|null            $filter      The SQL filters to apply to the query (the WHERE part). All columns must be prefixed by the table name (in the form: table.column)
1138
     * @param mixed[]                      $parameters
1139
     * @param string|UncheckedOrderBy|null $orderString The ORDER BY part of the query. All columns must be prefixed by the table name (in the form: table.column)
1140
     * @param int                          $mode
1141
     * @param string                       $className   Optional: The name of the class to instantiate. This class must extend the TDBMObject class. If none is specified, a TDBMObject instance will be returned
1142
     *
1143
     * @return ResultIterator An object representing an array of results
1144
     *
1145
     * @throws TDBMException
1146
     */
1147
    public function findObjectsFromSql(string $mainTable, string $from, $filter = null, array $parameters = array(), $orderString = null, ?int $mode = null, string $className = null): ResultIterator
1148
    {
1149
        // $mainTable is not secured in MagicJoin, let's add a bit of security to avoid SQL injection.
1150
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $mainTable)) {
1151
            throw new TDBMException(sprintf("Invalid table name: '%s'", $mainTable));
1152
        }
1153
1154
        $mode = $mode ?: $this->mode;
1155
1156
        // We quote in MySQL because MagicJoin requires MySQL style quotes
1157
        $mysqlPlatform = new MySqlPlatform();
1158
        list($filterString, $additionalParameters) = $this->buildFilterFromFilterBag($filter, $mysqlPlatform);
1159
1160
        $parameters = array_merge($parameters, $additionalParameters);
1161
1162
        $queryFactory = new FindObjectsFromSqlQueryFactory($mainTable, $from, $filterString, $orderString, $this, $this->tdbmSchemaAnalyzer->getSchema(), $this->orderByAnalyzer, $this->schemaAnalyzer, $this->cache, $this->cachePrefix);
1163
1164
        return new ResultIterator($queryFactory, $parameters, $this->objectStorage, $className, $this, $this->magicQuery, $mode, $this->logger);
1165
    }
1166
1167
    /**
1168
     * @param string $table
1169
     * @param mixed[] $primaryKeys
1170
     * @param string[] $additionalTablesFetch
1171
     * @param bool $lazy Whether to perform lazy loading on this object or not
1172
     * @param string $className
1173
     *
1174
     * @return AbstractTDBMObject
1175
     *
1176
     * @throws TDBMException
1177
     */
1178
    public function findObjectByPk(string $table, array $primaryKeys, array $additionalTablesFetch = array(), bool $lazy = false, string $className = null): AbstractTDBMObject
1179
    {
1180
        $primaryKeys = $this->_getPrimaryKeysFromObjectData($table, $primaryKeys);
1181
        $hash = $this->getObjectHash($primaryKeys);
1182
1183
        $dbRow = $this->objectStorage->get($table, $hash);
1184
        if ($dbRow !== null) {
1185
            $bean = $dbRow->getTDBMObject();
1186
            if ($className !== null && !is_a($bean, $className)) {
1187
                throw new TDBMException("TDBM cannot create a bean of class '".$className."'. The requested object was already loaded and its class is '".get_class($bean)."'");
1188
            }
1189
1190
            return $bean;
1191
        }
1192
1193
        // Are we performing lazy fetching?
1194
        if ($lazy === true) {
1195
            // Can we perform lazy fetching?
1196
            $tables = $this->_getRelatedTablesByInheritance($table);
1197
            // Only allowed if no inheritance.
1198
            if (count($tables) === 1) {
1199
                if ($className === null) {
1200
                    try {
1201
                        $className = $this->getBeanClassName($table);
1202
                    } catch (TDBMInvalidArgumentException $e) {
1203
                        $className = TDBMObject::class;
1204
                    }
1205
                }
1206
1207
                // Let's construct the bean
1208
                if (!isset($this->reflectionClassCache[$className])) {
1209
                    $this->reflectionClassCache[$className] = new \ReflectionClass($className);
1210
                }
1211
                // Let's bypass the constructor when creating the bean!
1212
                /** @var AbstractTDBMObject */
1213
                $bean = $this->reflectionClassCache[$className]->newInstanceWithoutConstructor();
1214
                $bean->_constructLazy($table, $primaryKeys, $this);
1215
1216
                return $bean;
1217
            }
1218
        }
1219
1220
        // Did not find the object in cache? Let's query it!
1221
        try {
1222
            return $this->findObjectOrFail($table, $primaryKeys, [], $additionalTablesFetch, $className);
1223
        } catch (NoBeanFoundException $exception) {
1224
            $primaryKeysStringified = implode(' and ', array_map(function ($key, $value) {
1225
                return "'".$key."' = ".$value;
1226
            }, array_keys($primaryKeys), $primaryKeys));
1227
            throw new NoBeanFoundException("No result found for query on table '".$table."' for ".$primaryKeysStringified, 0, $exception);
1228
        }
1229
    }
1230
1231
    /**
1232
     * Returns a unique bean (or null) according to the filters passed in parameter.
1233
     *
1234
     * @param string            $mainTable             The name of the table queried
1235
     * @param string|array|null $filter                The SQL filters to apply to the query (the WHERE part). All columns must be prefixed by the table name (in the form: table.column)
1236
     * @param mixed[]           $parameters
1237
     * @param string[]          $additionalTablesFetch
1238
     * @param string            $className             Optional: The name of the class to instantiate. This class must extend the TDBMObject class. If none is specified, a TDBMObject instance will be returned
1239
     *
1240
     * @return AbstractTDBMObject|null The object we want, or null if no object matches the filters
1241
     *
1242
     * @throws TDBMException
1243
     */
1244
    public function findObject(string $mainTable, $filter = null, array $parameters = array(), array $additionalTablesFetch = array(), string $className = null) : ?AbstractTDBMObject
1245
    {
1246
        $objects = $this->findObjects($mainTable, $filter, $parameters, null, $additionalTablesFetch, self::MODE_ARRAY, $className);
1247
        $page = $objects->take(0, 2);
1248
1249
1250
        $pageArr = $page->toArray();
1251
        // Optimisation: the $page->count() query can trigger an additional SQL query in platforms other than MySQL.
1252
        // We try to avoid calling at by fetching all 2 columns instead.
1253
        $count = count($pageArr);
1254
1255
        if ($count > 1) {
1256
	    $additionalErrorInfos = '';
1257
            if ($filter && !empty($parameters)) {
1258
                $additionalErrorInfos = ' for ' . $filter;
0 ignored issues
show
Bug introduced by
Are you sure $filter of type array|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1258
                $additionalErrorInfos = ' for ' . /** @scrutinizer ignore-type */ $filter;
Loading history...
1259
                foreach ($parameters as $fieldname => $parameter) {
1260
                    if (is_array($parameter)) {
1261
                        $value = '(' . implode(',', $parameter) . ')';
1262
                    } else {
1263
                        $value = $parameter;
1264
                    }
1265
                    $additionalErrorInfos = str_replace(':' . $fieldname, $value, $additionalErrorInfos);
1266
                }
1267
            }
1268
            $additionalErrorInfos .= '.';
1269
            throw new DuplicateRowException("Error while querying an object for table '$mainTable': More than 1 row have been returned, but we should have received at most one" . $additionalErrorInfos);
1270
        } elseif ($count === 0) {
1271
            return null;
1272
        }
1273
1274
        return $pageArr[0];
1275
    }
1276
1277
    /**
1278
     * Returns a unique bean (or null) according to the filters passed in parameter.
1279
     *
1280
     * @param string            $mainTable  The name of the table queried
1281
     * @param string            $from       The from sql statement
1282
     * @param string|array|null $filter     The SQL filters to apply to the query (the WHERE part). All columns must be prefixed by the table name (in the form: table.column)
1283
     * @param mixed[]           $parameters
1284
     * @param string            $className  Optional: The name of the class to instantiate. This class must extend the TDBMObject class. If none is specified, a TDBMObject instance will be returned
1285
     *
1286
     * @return AbstractTDBMObject|null The object we want, or null if no object matches the filters
1287
     *
1288
     * @throws TDBMException
1289
     */
1290
    public function findObjectFromSql(string $mainTable, string $from, $filter = null, array $parameters = array(), ?string $className = null) : ?AbstractTDBMObject
1291
    {
1292
        $objects = $this->findObjectsFromSql($mainTable, $from, $filter, $parameters, null, self::MODE_ARRAY, $className);
1293
        $page = $objects->take(0, 2);
1294
        $count = $page->count();
1295
        if ($count > 1) {
1296
	    $additionalErrorInfos = '';
1297
            if ($filter && !empty($parameters)) {
1298
                $additionalErrorInfos = ' for ' . $filter;
0 ignored issues
show
Bug introduced by
Are you sure $filter of type array|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1298
                $additionalErrorInfos = ' for ' . /** @scrutinizer ignore-type */ $filter;
Loading history...
1299
                foreach ($parameters as $fieldname => $parameter) {
1300
                    if (is_array($parameter)) {
1301
                        $value = '(' . implode(',', $parameter) . ')';
1302
                    } else {
1303
                        $value = $parameter;
1304
                    }
1305
                    $additionalErrorInfos = str_replace(':' . $fieldname, $value, $additionalErrorInfos);
1306
                }
1307
            }
1308
            $additionalErrorInfos .= '.';
1309
            throw new DuplicateRowException("Error while querying an object for table '$mainTable': More than 1 row have been returned, but we should have received at most one" . $additionalErrorInfos);
1310
        } elseif ($count === 0) {
1311
            return null;
1312
        }
1313
1314
        return $page[0];
1315
    }
1316
1317
    /**
1318
     * @param string $mainTable
1319
     * @param string $sql
1320
     * @param mixed[] $parameters
1321
     * @param int|null $mode
1322
     * @param string|null $className
1323
     * @param string $sqlCount
1324
     *
1325
     * @return ResultIterator
1326
     *
1327
     * @throws TDBMException
1328
     */
1329
    public function findObjectsFromRawSql(string $mainTable, string $sql, array $parameters = array(), ?int $mode = null, string $className = null, string $sqlCount = null): ResultIterator
1330
    {
1331
        // $mainTable is not secured in MagicJoin, let's add a bit of security to avoid SQL injection.
1332
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $mainTable)) {
1333
            throw new TDBMException(sprintf("Invalid table name: '%s'", $mainTable));
1334
        }
1335
1336
        $mode = $mode ?: $this->mode;
1337
1338
        $queryFactory = new FindObjectsFromRawSqlQueryFactory($this, $this->tdbmSchemaAnalyzer->getSchema(), $mainTable, $sql, $sqlCount);
1339
1340
        return new ResultIterator($queryFactory, $parameters, $this->objectStorage, $className, $this, $this->magicQuery, $mode, $this->logger);
1341
    }
1342
1343
    /**
1344
     * Returns a unique bean according to the filters passed in parameter.
1345
     * Throws a NoBeanFoundException if no bean was found for the filter passed in parameter.
1346
     *
1347
     * @param string            $mainTable             The name of the table queried
1348
     * @param string|array|null $filter                The SQL filters to apply to the query (the WHERE part). All columns must be prefixed by the table name (in the form: table.column)
1349
     * @param mixed[]           $parameters
1350
     * @param string[]          $additionalTablesFetch
1351
     * @param string            $className             Optional: The name of the class to instantiate. This class must extend the TDBMObject class. If none is specified, a TDBMObject instance will be returned
1352
     *
1353
     * @return AbstractTDBMObject The object we want
1354
     *
1355
     * @throws TDBMException
1356
     */
1357
    public function findObjectOrFail(string $mainTable, $filter = null, array $parameters = array(), array $additionalTablesFetch = array(), string $className = null): AbstractTDBMObject
1358
    {
1359
        $bean = $this->findObject($mainTable, $filter, $parameters, $additionalTablesFetch, $className);
1360
        if ($bean === null) {
1361
            throw new NoBeanFoundException("No result found for query on table '".$mainTable."'");
1362
        }
1363
1364
        return $bean;
1365
    }
1366
1367
    /**
1368
     * @param array[] $beanData An array of data: array<table, array<column, value>>
1369
     *
1370
     * @return mixed[] an array with first item = class name, second item = table name and third item = list of tables needed
1371
     *
1372
     * @throws TDBMInheritanceException
1373
     */
1374
    public function _getClassNameFromBeanData(array $beanData): array
1375
    {
1376
        if (count($beanData) === 1) {
1377
            $tableName = array_keys($beanData)[0];
1378
            $allTables = [$tableName];
1379
        } else {
1380
            $tables = [];
1381
            foreach ($beanData as $table => $row) {
1382
                $primaryKeyColumns = $this->getPrimaryKeyColumns($table);
1383
                $pkSet = false;
1384
                foreach ($primaryKeyColumns as $columnName) {
1385
                    if ($row[$columnName] !== null) {
1386
                        $pkSet = true;
1387
                        break;
1388
                    }
1389
                }
1390
                if ($pkSet) {
1391
                    $tables[] = $table;
1392
                }
1393
            }
1394
1395
            // $tables contains the tables for this bean. Let's view the top most part of the hierarchy
1396
            try {
1397
                $allTables = $this->_getLinkBetweenInheritedTables($tables);
1398
            } catch (TDBMInheritanceException $e) {
1399
                throw TDBMInheritanceException::extendException($e, $this, $beanData);
1400
            }
1401
            $tableName = $allTables[0];
1402
        }
1403
1404
        // Only one table in this bean. Life is sweat, let's look at its type:
1405
        try {
1406
            $className = $this->getBeanClassName($tableName);
1407
        } catch (TDBMInvalidArgumentException $e) {
1408
            $className = 'TheCodingMachine\\TDBM\\TDBMObject';
1409
        }
1410
1411
        return [$className, $tableName, $allTables];
1412
    }
1413
1414
    /**
1415
     * Returns an item from cache or computes it using $closure and puts it in cache.
1416
     *
1417
     * @param string   $key
1418
     * @param callable $closure
1419
     *
1420
     * @return mixed
1421
     */
1422
    private function fromCache(string $key, callable $closure)
1423
    {
1424
        $item = $this->cache->fetch($key);
1425
        if ($item === false) {
1426
            $item = $closure();
1427
            $result = $this->cache->save($key, $item);
1428
1429
            if ($result === false) {
1430
                throw new TDBMException('An error occured while storing an object in cache. Please check that: 1. your cache is not full, 2. if you are using APC in CLI mode, that you have the "apc.enable_cli=On" setting added to your php.ini file.');
1431
            }
1432
        }
1433
1434
        return $item;
1435
    }
1436
1437
    /**
1438
     * Returns the foreign key object.
1439
     *
1440
     * @param string $table
1441
     * @param string $fkName
1442
     *
1443
     * @return ForeignKeyConstraint
1444
     */
1445
    public function _getForeignKeyByName(string $table, string $fkName): ForeignKeyConstraint
1446
    {
1447
        return $this->tdbmSchemaAnalyzer->getSchema()->getTable($table)->getForeignKey($fkName);
1448
    }
1449
1450
    /**
1451
     * @param string $pivotTableName
1452
     * @param AbstractTDBMObject $bean
1453
     *
1454
     * @return AbstractTDBMObject[]|ResultIterator
1455
     */
1456
    public function _getRelatedBeans(string $pivotTableName, AbstractTDBMObject $bean): ResultIterator
1457
    {
1458
        list($localFk, $remoteFk) = $this->getPivotTableForeignKeys($pivotTableName, $bean);
1459
        /* @var $localFk ForeignKeyConstraint */
1460
        /* @var $remoteFk ForeignKeyConstraint */
1461
        $remoteTable = $remoteFk->getForeignTableName();
1462
1463
        $primaryKeys = $this->getPrimaryKeyValues($bean);
1464
        $columnNames = array_map(function ($name) use ($pivotTableName) {
1465
            return $pivotTableName.'.'.$name;
1466
        }, $localFk->getUnquotedLocalColumns());
1467
1468
        $filter = SafeFunctions::arrayCombine($columnNames, $primaryKeys);
1469
1470
        return $this->findObjects($remoteTable, $filter);
1471
    }
1472
1473
    /**
1474
     * @param string $pivotTableName
1475
     * @param AbstractTDBMObject $bean The LOCAL bean
1476
     *
1477
     * @return ForeignKeyConstraint[] First item: the LOCAL bean, second item: the REMOTE bean
1478
     *
1479
     * @throws TDBMException
1480
     */
1481
    private function getPivotTableForeignKeys(string $pivotTableName, AbstractTDBMObject $bean): array
1482
    {
1483
        $fks = array_values($this->tdbmSchemaAnalyzer->getSchema()->getTable($pivotTableName)->getForeignKeys());
1484
        $table1 = $fks[0]->getForeignTableName();
1485
        $table2 = $fks[1]->getForeignTableName();
1486
1487
        $beanTables = array_map(function (DbRow $dbRow) {
1488
            return $dbRow->_getDbTableName();
1489
        }, $bean->_getDbRows());
1490
1491
        if (in_array($table1, $beanTables)) {
1492
            return [$fks[0], $fks[1]];
1493
        } elseif (in_array($table2, $beanTables)) {
1494
            return [$fks[1], $fks[0]];
1495
        } else {
1496
            throw new TDBMException("Unexpected bean type in getPivotTableForeignKeys. Awaiting beans from table {$table1} and {$table2} for pivot table {$pivotTableName}");
1497
        }
1498
    }
1499
1500
    /**
1501
     * Returns a list of pivot tables linked to $bean.
1502
     *
1503
     * @param AbstractTDBMObject $bean
1504
     *
1505
     * @return string[]
1506
     */
1507
    public function _getPivotTablesLinkedToBean(AbstractTDBMObject $bean): array
1508
    {
1509
        $junctionTables = [];
1510
        $allJunctionTables = $this->schemaAnalyzer->detectJunctionTables(true);
1511
        foreach ($bean->_getDbRows() as $dbRow) {
1512
            foreach ($allJunctionTables as $table) {
1513
                // There are exactly 2 FKs since this is a pivot table.
1514
                $fks = array_values($table->getForeignKeys());
1515
1516
                if ($fks[0]->getForeignTableName() === $dbRow->_getDbTableName() || $fks[1]->getForeignTableName() === $dbRow->_getDbTableName()) {
1517
                    $junctionTables[] = $table->getName();
1518
                }
1519
            }
1520
        }
1521
1522
        return $junctionTables;
1523
    }
1524
1525
    /**
1526
     * Array of types for tables.
1527
     * Key: table name
1528
     * Value: array of types indexed by column.
1529
     *
1530
     * @var array[]
1531
     */
1532
    private $typesForTable = [];
1533
1534
    /**
1535
     * @internal
1536
     *
1537
     * @param string $tableName
1538
     *
1539
     * @return Type[]
1540
     */
1541
    public function _getColumnTypesForTable(string $tableName): array
1542
    {
1543
        if (!isset($this->typesForTable[$tableName])) {
1544
            $columns = $this->tdbmSchemaAnalyzer->getSchema()->getTable($tableName)->getColumns();
1545
            foreach ($columns as $column) {
1546
                $this->typesForTable[$tableName][$column->getName()] = $column->getType();
1547
            }
1548
        }
1549
1550
        return $this->typesForTable[$tableName];
1551
    }
1552
1553
    /**
1554
     * Sets the minimum log level.
1555
     * $level must be one of Psr\Log\LogLevel::xxx.
1556
     *
1557
     * Defaults to LogLevel::WARNING
1558
     *
1559
     * @param string $level
1560
     */
1561
    public function setLogLevel(string $level): void
1562
    {
1563
        $this->logger = new LevelFilter($this->rootLogger, $level);
1564
    }
1565
}
1566