Passed
Pull Request — master (#123)
by David
03:12
created

TDBMService::getAtMostOneObjectOrFail()   B

Complexity

Conditions 7
Paths 4

Size

Total Lines 30
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 18
dl 0
loc 30
rs 8.8333
c 0
b 0
f 0
cc 7
nc 4
nop 4
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
use function var_export;
49
50
/**
51
 * The TDBMService class is the main TDBM class. It provides methods to retrieve TDBMObject instances
52
 * from the database.
53
 *
54
 * @author David Negrier
55
 * @ExtendedAction {"name":"Generate DAOs", "url":"tdbmadmin/", "default":false}
56
 */
57
class TDBMService
58
{
59
    const MODE_CURSOR = 1;
60
    const MODE_ARRAY = 2;
61
62
    /**
63
     * The database connection.
64
     *
65
     * @var Connection
66
     */
67
    private $connection;
68
69
    /**
70
     * @var SchemaAnalyzer
71
     */
72
    private $schemaAnalyzer;
73
74
    /**
75
     * @var MagicQuery
76
     */
77
    private $magicQuery;
78
79
    /**
80
     * @var TDBMSchemaAnalyzer
81
     */
82
    private $tdbmSchemaAnalyzer;
83
84
    /**
85
     * @var string
86
     */
87
    private $cachePrefix;
88
89
    /**
90
     * Cache of table of primary keys.
91
     * Primary keys are stored by tables, as an array of column.
92
     * For instance $primary_key['my_table'][0] will return the first column of the primary key of table 'my_table'.
93
     *
94
     * @var string[][]
95
     */
96
    private $primaryKeysColumns;
97
98
    /**
99
     * Service storing objects in memory.
100
     * Access is done by table name and then by primary key.
101
     * If the primary key is split on several columns, access is done by an array of columns, serialized.
102
     *
103
     * @var StandardObjectStorage|WeakrefObjectStorage
104
     */
105
    private $objectStorage;
106
107
    /**
108
     * The fetch mode of the result sets returned by `getObjects`.
109
     * Can be one of: TDBMObjectArray::MODE_CURSOR or TDBMObjectArray::MODE_ARRAY or TDBMObjectArray::MODE_COMPATIBLE_ARRAY.
110
     *
111
     * In 'MODE_ARRAY' mode (default), the result is an array. Use this mode by default (unless the list returned is very big).
112
     * 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,
113
     * and it cannot be accessed via key. Use this mode for large datasets processed by batch.
114
     * In 'MODE_COMPATIBLE_ARRAY' mode, the result is an old TDBMObjectArray (used up to TDBM 3.2).
115
     * You can access the array by key, or using foreach, several times.
116
     *
117
     * @var int
118
     */
119
    private $mode = self::MODE_ARRAY;
120
121
    /**
122
     * Table of new objects not yet inserted in database or objects modified that must be saved.
123
     *
124
     * @var \SplObjectStorage of DbRow objects
125
     */
126
    private $toSaveObjects;
127
128
    /**
129
     * A cache service to be used.
130
     *
131
     * @var Cache
132
     */
133
    private $cache;
134
135
    /**
136
     * Map associating a table name to a fully qualified Bean class name.
137
     *
138
     * @var array
139
     */
140
    private $tableToBeanMap = [];
141
142
    /**
143
     * @var \ReflectionClass[]
144
     */
145
    private $reflectionClassCache = array();
146
147
    /**
148
     * @var LoggerInterface
149
     */
150
    private $rootLogger;
151
152
    /**
153
     * @var LevelFilter|NullLogger
154
     */
155
    private $logger;
156
157
    /**
158
     * @var OrderByAnalyzer
159
     */
160
    private $orderByAnalyzer;
161
162
    /**
163
     * @var string
164
     */
165
    private $beanNamespace;
166
167
    /**
168
     * @var NamingStrategyInterface
169
     */
170
    private $namingStrategy;
171
    /**
172
     * @var ConfigurationInterface
173
     */
174
    private $configuration;
175
176
    /**
177
     * @param ConfigurationInterface $configuration The configuration object
178
     */
179
    public function __construct(ConfigurationInterface $configuration)
180
    {
181
        if (extension_loaded('weakref')) {
182
            $this->objectStorage = new WeakrefObjectStorage();
183
        } else {
184
            $this->objectStorage = new StandardObjectStorage();
185
        }
186
        $this->connection = $configuration->getConnection();
187
        $this->cache = $configuration->getCache();
188
        $this->schemaAnalyzer = $configuration->getSchemaAnalyzer();
189
190
        $this->magicQuery = new MagicQuery($this->connection, $this->cache, $this->schemaAnalyzer);
191
192
        $this->tdbmSchemaAnalyzer = new TDBMSchemaAnalyzer($this->connection, $this->cache, $this->schemaAnalyzer);
193
        $this->cachePrefix = $this->tdbmSchemaAnalyzer->getCachePrefix();
194
195
        $this->toSaveObjects = new \SplObjectStorage();
196
        $logger = $configuration->getLogger();
197
        if ($logger === null) {
198
            $this->logger = new NullLogger();
199
            $this->rootLogger = new NullLogger();
200
        } else {
201
            $this->rootLogger = $logger;
202
            $this->setLogLevel(LogLevel::WARNING);
203
        }
204
        $this->orderByAnalyzer = new OrderByAnalyzer($this->cache, $this->cachePrefix);
205
        $this->beanNamespace = $configuration->getBeanNamespace();
206
        $this->namingStrategy = $configuration->getNamingStrategy();
207
        $this->configuration = $configuration;
208
    }
209
210
    /**
211
     * Returns the object used to connect to the database.
212
     *
213
     * @return Connection
214
     */
215
    public function getConnection(): Connection
216
    {
217
        return $this->connection;
218
    }
219
220
    /**
221
     * Sets the default fetch mode of the result sets returned by `findObjects`.
222
     * Can be one of: TDBMObjectArray::MODE_CURSOR or TDBMObjectArray::MODE_ARRAY.
223
     *
224
     * 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).
225
     * 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
226
     * several times. In cursor mode, you cannot access the result set by key. Use this mode for large datasets processed by batch.
227
     *
228
     * @param int $mode
229
     *
230
     * @return self
231
     *
232
     * @throws TDBMException
233
     */
234
    public function setFetchMode(int $mode): self
235
    {
236
        if ($mode !== self::MODE_CURSOR && $mode !== self::MODE_ARRAY) {
237
            throw new TDBMException("Unknown fetch mode: '".$this->mode."'");
238
        }
239
        $this->mode = $mode;
240
241
        return $this;
242
    }
243
244
    /**
245
     * Removes the given object from database.
246
     * This cannot be called on an object that is not attached to this TDBMService
247
     * (will throw a TDBMInvalidOperationException).
248
     *
249
     * @param AbstractTDBMObject $object the object to delete
250
     *
251
     * @throws DBALException
252
     * @throws TDBMInvalidOperationException
253
     */
254
    public function delete(AbstractTDBMObject $object): void
255
    {
256
        switch ($object->_getStatus()) {
257
            case TDBMObjectStateEnum::STATE_DELETED:
258
                // Nothing to do, object already deleted.
259
                return;
260
            case TDBMObjectStateEnum::STATE_DETACHED:
261
                throw new TDBMInvalidOperationException('Cannot delete a detached object');
262
            case TDBMObjectStateEnum::STATE_NEW:
263
                $this->deleteManyToManyRelationships($object);
264
                foreach ($object->_getDbRows() as $dbRow) {
265
                    $this->removeFromToSaveObjectList($dbRow);
266
                }
267
                break;
268
            case TDBMObjectStateEnum::STATE_DIRTY:
269
                foreach ($object->_getDbRows() as $dbRow) {
270
                    $this->removeFromToSaveObjectList($dbRow);
271
                }
272
            // And continue deleting...
273
            // no break
274
            case TDBMObjectStateEnum::STATE_NOT_LOADED:
275
            case TDBMObjectStateEnum::STATE_LOADED:
276
                $this->connection->beginTransaction();
277
                try {
278
                    $this->deleteManyToManyRelationships($object);
279
                    // Let's delete db rows, in reverse order.
280
                    foreach (array_reverse($object->_getDbRows()) as $dbRow) {
281
                        /* @var $dbRow DbRow */
282
                        $tableName = $dbRow->_getDbTableName();
283
                        $primaryKeys = $dbRow->_getPrimaryKeys();
284
                        $quotedPrimaryKeys = [];
285
                        foreach ($primaryKeys as $column => $value) {
286
                            $quotedPrimaryKeys[$this->connection->quoteIdentifier($column)] = $value;
287
                        }
288
                        $this->connection->delete($this->connection->quoteIdentifier($tableName), $quotedPrimaryKeys);
289
                        $this->objectStorage->remove($dbRow->_getDbTableName(), $this->getObjectHash($primaryKeys));
290
                    }
291
                    $this->connection->commit();
292
                } catch (DBALException $e) {
293
                    $this->connection->rollBack();
294
                    throw $e;
295
                }
296
                break;
297
            // @codeCoverageIgnoreStart
298
            default:
299
                throw new TDBMInvalidOperationException('Unexpected status for bean');
300
            // @codeCoverageIgnoreEnd
301
        }
302
303
        $object->_setStatus(TDBMObjectStateEnum::STATE_DELETED);
304
    }
305
306
    /**
307
     * Removes all many to many relationships for this object.
308
     *
309
     * @param AbstractTDBMObject $object
310
     */
311
    private function deleteManyToManyRelationships(AbstractTDBMObject $object): void
312
    {
313
        foreach ($object->_getDbRows() as $tableName => $dbRow) {
314
            $pivotTables = $this->tdbmSchemaAnalyzer->getPivotTableLinkedToTable($tableName);
315
            foreach ($pivotTables as $pivotTable) {
316
                $remoteBeans = $object->_getRelationships($pivotTable);
317
                foreach ($remoteBeans as $remoteBean) {
318
                    $object->_removeRelationship($pivotTable, $remoteBean);
319
                }
320
            }
321
        }
322
        $this->persistManyToManyRelationships($object);
323
    }
324
325
    /**
326
     * This function removes the given object from the database. It will also remove all objects relied to the one given
327
     * by parameter before all.
328
     *
329
     * Notice: if the object has a multiple primary key, the function will not work.
330
     *
331
     * @param AbstractTDBMObject $objToDelete
332
     */
333
    public function deleteCascade(AbstractTDBMObject $objToDelete): void
334
    {
335
        $this->deleteAllConstraintWithThisObject($objToDelete);
336
        $this->delete($objToDelete);
337
    }
338
339
    /**
340
     * This function is used only in TDBMService (private function)
341
     * It will call deleteCascade function foreach object relied with a foreign key to the object given by parameter.
342
     *
343
     * @param AbstractTDBMObject $obj
344
     */
345
    private function deleteAllConstraintWithThisObject(AbstractTDBMObject $obj): void
346
    {
347
        $dbRows = $obj->_getDbRows();
348
        foreach ($dbRows as $dbRow) {
349
            $tableName = $dbRow->_getDbTableName();
350
            $pks = array_values($dbRow->_getPrimaryKeys());
351
            if (!empty($pks)) {
352
                $incomingFks = $this->tdbmSchemaAnalyzer->getIncomingForeignKeys($tableName);
353
354
                foreach ($incomingFks as $incomingFk) {
355
                    $filter = SafeFunctions::arrayCombine($incomingFk->getUnquotedLocalColumns(), $pks);
356
357
                    $results = $this->findObjects($incomingFk->getLocalTableName(), $filter);
358
359
                    foreach ($results as $bean) {
360
                        $this->deleteCascade($bean);
361
                    }
362
                }
363
            }
364
        }
365
    }
366
367
    /**
368
     * This function performs a save() of all the objects that have been modified.
369
     */
370
    public function completeSave(): void
371
    {
372
        foreach ($this->toSaveObjects as $dbRow) {
373
            $this->save($dbRow->getTDBMObject());
374
        }
375
    }
376
377
    /**
378
     * Takes in input a filter_bag (which can be about anything from a string to an array of TDBMObjects... see above from documentation),
379
     * and gives back a proper Filter object.
380
     *
381
     * @param mixed $filter_bag
382
     * @param AbstractPlatform $platform The platform used to quote identifiers
383
     * @param int $counter
384
     * @return mixed[] First item: filter string, second item: parameters, third item: the count
385
     *
386
     * @throws TDBMException
387
     */
388
    public function buildFilterFromFilterBag($filter_bag, AbstractPlatform $platform, int $counter = 1): array
389
    {
390
        if ($filter_bag === null) {
391
            return ['', [], $counter];
392
        } elseif (is_string($filter_bag)) {
393
            return [$filter_bag, [], $counter];
394
        } elseif (is_array($filter_bag)) {
395
            $sqlParts = [];
396
            $parameters = [];
397
398
            foreach ($filter_bag as $column => $value) {
399
                if (is_int($column)) {
400
                    list($subSqlPart, $subParameters, $counter) = $this->buildFilterFromFilterBag($value, $platform, $counter);
401
                    $sqlParts[] = $subSqlPart;
402
                    $parameters += $subParameters;
403
                } else {
404
                    $paramName = 'tdbmparam'.$counter;
405
                    if (is_array($value)) {
406
                        $sqlParts[] = $platform->quoteIdentifier($column).' IN :'.$paramName;
407
                    } else {
408
                        $sqlParts[] = $platform->quoteIdentifier($column).' = :'.$paramName;
409
                    }
410
                    $parameters[$paramName] = $value;
411
                    ++$counter;
412
                }
413
            }
414
415
            return [implode(' AND ', $sqlParts), $parameters, $counter];
416
        } elseif ($filter_bag instanceof AbstractTDBMObject) {
417
            $sqlParts = [];
418
            $parameters = [];
419
            $dbRows = $filter_bag->_getDbRows();
420
            $dbRow = reset($dbRows);
421
            if ($dbRow === false) {
422
                throw new \RuntimeException('Unexpected error: empty dbRow'); // @codeCoverageIgnore
423
            }
424
            $primaryKeys = $dbRow->_getPrimaryKeys();
425
426
            foreach ($primaryKeys as $column => $value) {
427
                $paramName = 'tdbmparam'.$counter;
428
                $sqlParts[] = $platform->quoteIdentifier($dbRow->_getDbTableName()).'.'.$platform->quoteIdentifier($column).' = :'.$paramName;
429
                $parameters[$paramName] = $value;
430
                ++$counter;
431
            }
432
433
            return [implode(' AND ', $sqlParts), $parameters, $counter];
434
        } elseif ($filter_bag instanceof \Iterator) {
435
            return $this->buildFilterFromFilterBag(iterator_to_array($filter_bag), $platform, $counter);
436
        } else {
437
            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.');
438
        }
439
    }
440
441
    /**
442
     * @param string $table
443
     *
444
     * @return string[]
445
     */
446
    public function getPrimaryKeyColumns(string $table): array
447
    {
448
        if (!isset($this->primaryKeysColumns[$table])) {
449
            $primaryKey = $this->tdbmSchemaAnalyzer->getSchema()->getTable($table)->getPrimaryKey();
450
            if ($primaryKey === null) {
451
                // Security check: a table MUST have a primary key
452
                throw new TDBMException(sprintf('Table "%s" does not have any primary key', $table));
453
            }
454
455
            $this->primaryKeysColumns[$table] = $primaryKey->getUnquotedColumns();
456
457
            // TODO TDBM4: See if we need to improve error reporting if table name does not exist.
458
459
            /*$arr = array();
460
            foreach ($this->connection->getPrimaryKey($table) as $col) {
461
                $arr[] = $col->name;
462
            }
463
            // The primaryKeysColumns contains only the column's name, not the DB_Column object.
464
            $this->primaryKeysColumns[$table] = $arr;
465
            if (empty($this->primaryKeysColumns[$table]))
466
            {
467
                // Unable to find primary key.... this is an error
468
                // Let's try to be precise in error reporting. Let's try to find the table.
469
                $tables = $this->connection->checkTableExist($table);
470
                if ($tables === true)
471
                throw new TDBMException("Could not find table primary key for table '$table'. Please define a primary key for this table.");
472
                elseif ($tables !== null) {
473
                    if (count($tables)==1)
474
                    $str = "Could not find table '$table'. Maybe you meant this table: '".$tables[0]."'";
475
                    else
476
                    $str = "Could not find table '$table'. Maybe you meant one of those tables: '".implode("', '",$tables)."'";
477
                    throw new TDBMException($str);
478
                }
479
            }*/
480
        }
481
482
        return $this->primaryKeysColumns[$table];
483
    }
484
485
    /**
486
     * This is an internal function, you should not use it in your application.
487
     * This is used internally by TDBM to add an object to the object cache.
488
     *
489
     * @param DbRow $dbRow
490
     */
491
    public function _addToCache(DbRow $dbRow): void
492
    {
493
        $primaryKey = $this->getPrimaryKeysForObjectFromDbRow($dbRow);
494
        $hash = $this->getObjectHash($primaryKey);
495
        $this->objectStorage->set($dbRow->_getDbTableName(), $hash, $dbRow);
496
    }
497
498
    /**
499
     * This is an internal function, you should not use it in your application.
500
     * This is used internally by TDBM to remove the object from the list of objects that have been
501
     * created/updated but not saved yet.
502
     *
503
     * @param DbRow $myObject
504
     */
505
    private function removeFromToSaveObjectList(DbRow $myObject): void
506
    {
507
        unset($this->toSaveObjects[$myObject]);
508
    }
509
510
    /**
511
     * This is an internal function, you should not use it in your application.
512
     * This is used internally by TDBM to add an object to the list of objects that have been
513
     * created/updated but not saved yet.
514
     *
515
     * @param DbRow $myObject
516
     */
517
    public function _addToToSaveObjectList(DbRow $myObject): void
518
    {
519
        $this->toSaveObjects[$myObject] = true;
520
    }
521
522
    /**
523
     * Generates all the daos and beans.
524
     */
525
    public function generateAllDaosAndBeans() : void
526
    {
527
        // Purge cache before generating anything.
528
        if ($this->cache instanceof ClearableCache) {
529
            $this->cache->deleteAll();
530
        }
531
532
        $tdbmDaoGenerator = new TDBMDaoGenerator($this->configuration, $this->tdbmSchemaAnalyzer);
533
        $tdbmDaoGenerator->generateAllDaosAndBeans();
534
    }
535
536
    /**
537
     * Returns the fully qualified class name of the bean associated with table $tableName.
538
     *
539
     *
540
     * @param string $tableName
541
     *
542
     * @return string
543
     */
544
    public function getBeanClassName(string $tableName) : string
545
    {
546
        if (isset($this->tableToBeanMap[$tableName])) {
547
            return $this->tableToBeanMap[$tableName];
548
        } else {
549
            $className = $this->beanNamespace.'\\'.$this->namingStrategy->getBeanClassName($tableName);
550
551
            if (!class_exists($className)) {
552
                throw new TDBMInvalidArgumentException(sprintf('Could not find class "%s". Does table "%s" exist? If yes, consider regenerating the DAOs and beans.', $className, $tableName));
553
            }
554
555
            $this->tableToBeanMap[$tableName] = $className;
556
            return $className;
557
        }
558
    }
559
560
    /**
561
     * Saves $object by INSERTing or UPDAT(E)ing it in the database.
562
     *
563
     * @param AbstractTDBMObject $object
564
     *
565
     * @throws TDBMException
566
     */
567
    public function save(AbstractTDBMObject $object): void
568
    {
569
        $this->connection->beginTransaction();
570
        try {
571
            $status = $object->_getStatus();
572
573
            // Let's attach this object if it is in detached state.
574
            if ($status === TDBMObjectStateEnum::STATE_DETACHED) {
575
                $object->_attach($this);
576
                $status = $object->_getStatus();
577
            }
578
579
            if ($status === TDBMObjectStateEnum::STATE_NEW) {
580
                $dbRows = $object->_getDbRows();
581
582
                $unindexedPrimaryKeys = array();
583
584
                foreach ($dbRows as $dbRow) {
585
                    if ($dbRow->_getStatus() == TDBMObjectStateEnum::STATE_SAVING) {
586
                        throw TDBMCyclicReferenceException::createCyclicReference($dbRow->_getDbTableName(), $object);
587
                    }
588
                    $dbRow->_setStatus(TDBMObjectStateEnum::STATE_SAVING);
589
                    $tableName = $dbRow->_getDbTableName();
590
591
                    $schema = $this->tdbmSchemaAnalyzer->getSchema();
592
                    $tableDescriptor = $schema->getTable($tableName);
593
594
                    $primaryKeyColumns = $this->getPrimaryKeyColumns($tableName);
595
596
                    $references = $dbRow->_getReferences();
597
598
                    // Let's save all references in NEW or DETACHED state (we need their primary key)
599
                    foreach ($references as $fkName => $reference) {
600
                        if ($reference !== null) {
601
                            $refStatus = $reference->_getStatus();
602
                            if ($refStatus === TDBMObjectStateEnum::STATE_NEW || $refStatus === TDBMObjectStateEnum::STATE_DETACHED) {
603
                                try {
604
                                    $this->save($reference);
605
                                } catch (TDBMCyclicReferenceException $e) {
606
                                    throw TDBMCyclicReferenceException::extendCyclicReference($e, $dbRow->_getDbTableName(), $object, $fkName);
607
                                }
608
                            }
609
                        }
610
                    }
611
612
                    if (empty($unindexedPrimaryKeys)) {
613
                        $primaryKeys = $this->getPrimaryKeysForObjectFromDbRow($dbRow);
614
                    } else {
615
                        // First insert, the children must have the same primary key as the parent.
616
                        $primaryKeys = $this->_getPrimaryKeysFromIndexedPrimaryKeys($tableName, $unindexedPrimaryKeys);
617
                        $dbRow->_setPrimaryKeys($primaryKeys);
618
                    }
619
620
                    $dbRowData = $dbRow->_getDbRow();
621
622
                    // Let's see if the columns for primary key have been set before inserting.
623
                    // We assume that if one of the value of the PK has been set, the PK is set.
624
                    $isPkSet = !empty($primaryKeys);
625
626
                    /*if (!$isPkSet) {
627
                        // if there is no autoincrement and no pk set, let's go in error.
628
                        $isAutoIncrement = true;
629
630
                        foreach ($primaryKeyColumns as $pkColumnName) {
631
                            $pkColumn = $tableDescriptor->getColumn($pkColumnName);
632
                            if (!$pkColumn->getAutoincrement()) {
633
                                $isAutoIncrement = false;
634
                            }
635
                        }
636
637
                        if (!$isAutoIncrement) {
638
                            $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.";
639
                            throw new TDBMException($msg);
640
                        }
641
642
                    }*/
643
644
                    $types = [];
645
                    $escapedDbRowData = [];
646
647
                    foreach ($dbRowData as $columnName => $value) {
648
                        $columnDescriptor = $tableDescriptor->getColumn($columnName);
649
                        $types[] = $columnDescriptor->getType();
650
                        $escapedDbRowData[$this->connection->quoteIdentifier($columnName)] = $value;
651
                    }
652
653
                    $quotedTableName = $this->connection->quoteIdentifier($tableName);
654
                    $this->connection->insert($quotedTableName, $escapedDbRowData, $types);
655
656
                    if (!$isPkSet && count($primaryKeyColumns) === 1) {
657
                        $id = $this->connection->lastInsertId();
658
659
                        if ($id === false) {
660
                            // In Oracle (if we are in 11g), the lastInsertId will fail. We try again with the column.
661
                            $sequenceName = $this->connection->getDatabasePlatform()->getIdentitySequenceName(
662
                                $quotedTableName,
663
                                $this->connection->quoteIdentifier($primaryKeyColumns[0])
664
                            );
665
                            $id = $this->connection->lastInsertId($sequenceName);
666
                        }
667
668
                        $pkColumn = $primaryKeyColumns[0];
669
                        // lastInsertId returns a string but the column type is usually a int. Let's convert it back to the correct type.
670
                        $id = $tableDescriptor->getColumn($pkColumn)->getType()->convertToPHPValue($id, $this->getConnection()->getDatabasePlatform());
671
                        $primaryKeys[$pkColumn] = $id;
672
                    }
673
674
                    // TODO: change this to some private magic accessor in future
675
                    $dbRow->_setPrimaryKeys($primaryKeys);
676
                    $unindexedPrimaryKeys = array_values($primaryKeys);
677
678
                    // Let's remove this object from the $new_objects static table.
679
                    $this->removeFromToSaveObjectList($dbRow);
680
681
                    // Let's add this object to the list of objects in cache.
682
                    $this->_addToCache($dbRow);
683
                }
684
685
                $object->_setStatus(TDBMObjectStateEnum::STATE_LOADED);
686
            } elseif ($status === TDBMObjectStateEnum::STATE_DIRTY) {
687
                $dbRows = $object->_getDbRows();
688
689
                foreach ($dbRows as $dbRow) {
690
                    if ($dbRow->_getStatus() !== TDBMObjectStateEnum::STATE_DIRTY) {
691
                        // Not all db_rows in a bean need to be dirty when the bean itself is dirty.
692
                        continue;
693
                    }
694
                    $references = $dbRow->_getReferences();
695
696
                    // Let's save all references in NEW state (we need their primary key)
697
                    foreach ($references as $fkName => $reference) {
698
                        if ($reference !== null && $reference->_getStatus() === TDBMObjectStateEnum::STATE_NEW) {
699
                            $this->save($reference);
700
                        }
701
                    }
702
703
                    $tableName = $dbRow->_getDbTableName();
704
                    $dbRowData = $dbRow->_getUpdatedDbRow();
705
706
                    $schema = $this->tdbmSchemaAnalyzer->getSchema();
707
                    $tableDescriptor = $schema->getTable($tableName);
708
709
                    $primaryKeys = $dbRow->_getPrimaryKeys();
710
711
                    $types = [];
712
                    $escapedDbRowData = [];
713
                    $escapedPrimaryKeys = [];
714
715
                    foreach ($dbRowData as $columnName => $value) {
716
                        $columnDescriptor = $tableDescriptor->getColumn($columnName);
717
                        $types[] = $columnDescriptor->getType();
718
                        $escapedDbRowData[$this->connection->quoteIdentifier($columnName)] = $value;
719
                    }
720
                    foreach ($primaryKeys as $columnName => $value) {
721
                        $columnDescriptor = $tableDescriptor->getColumn($columnName);
722
                        $types[] = $columnDescriptor->getType();
723
                        $escapedPrimaryKeys[$this->connection->quoteIdentifier($columnName)] = $value;
724
                    }
725
726
                    $this->connection->update($this->connection->quoteIdentifier($tableName), $escapedDbRowData, $escapedPrimaryKeys, $types);
727
728
                    // Let's check if the primary key has been updated...
729
                    $needsUpdatePk = false;
730
                    foreach ($primaryKeys as $column => $value) {
731
                        if (isset($dbRowData[$column]) && $dbRowData[$column] != $value) {
732
                            $needsUpdatePk = true;
733
                            break;
734
                        }
735
                    }
736
                    if ($needsUpdatePk) {
737
                        $this->objectStorage->remove($tableName, $this->getObjectHash($primaryKeys));
738
                        $newPrimaryKeys = $this->getPrimaryKeysForObjectFromDbRow($dbRow);
739
                        $dbRow->_setPrimaryKeys($newPrimaryKeys);
740
                        $this->objectStorage->set($tableName, $this->getObjectHash($primaryKeys), $dbRow);
741
                    }
742
743
                    // Let's remove this object from the list of objects to save.
744
                    $this->removeFromToSaveObjectList($dbRow);
745
                }
746
747
                $object->_setStatus(TDBMObjectStateEnum::STATE_LOADED);
748
            } elseif ($status === TDBMObjectStateEnum::STATE_DELETED) {
749
                throw new TDBMInvalidOperationException('This object has been deleted. It cannot be saved.');
750
            }
751
752
            // Finally, let's save all the many to many relationships to this bean.
753
            $this->persistManyToManyRelationships($object);
754
            $this->connection->commit();
755
        } catch (\Throwable $t) {
756
            $this->connection->rollBack();
757
            throw $t;
758
        }
759
    }
760
761
    private function persistManyToManyRelationships(AbstractTDBMObject $object): void
762
    {
763
        foreach ($object->_getCachedRelationships() as $pivotTableName => $storage) {
764
            $tableDescriptor = $this->tdbmSchemaAnalyzer->getSchema()->getTable($pivotTableName);
765
            list($localFk, $remoteFk) = $this->getPivotTableForeignKeys($pivotTableName, $object);
766
767
            $toRemoveFromStorage = [];
768
769
            foreach ($storage as $remoteBean) {
770
                /* @var $remoteBean AbstractTDBMObject */
771
                $statusArr = $storage[$remoteBean];
772
                $status = $statusArr['status'];
773
                $reverse = $statusArr['reverse'];
774
                if ($reverse) {
775
                    continue;
776
                }
777
778
                if ($status === 'new') {
779
                    $remoteBeanStatus = $remoteBean->_getStatus();
780
                    if ($remoteBeanStatus === TDBMObjectStateEnum::STATE_NEW || $remoteBeanStatus === TDBMObjectStateEnum::STATE_DETACHED) {
781
                        // Let's save remote bean if needed.
782
                        $this->save($remoteBean);
783
                    }
784
785
                    ['filters' => $filters, 'types' => $types] = $this->getPivotFilters($object, $remoteBean, $localFk, $remoteFk, $tableDescriptor);
786
787
                    $this->connection->insert($this->connection->quoteIdentifier($pivotTableName), $filters, $types);
788
789
                    // Finally, let's mark relationships as saved.
790
                    $statusArr['status'] = 'loaded';
791
                    $storage[$remoteBean] = $statusArr;
792
                    $remoteStorage = $remoteBean->_getCachedRelationships()[$pivotTableName];
793
                    $remoteStatusArr = $remoteStorage[$object];
794
                    $remoteStatusArr['status'] = 'loaded';
795
                    $remoteStorage[$object] = $remoteStatusArr;
796
                } elseif ($status === 'delete') {
797
                    ['filters' => $filters, 'types' => $types] = $this->getPivotFilters($object, $remoteBean, $localFk, $remoteFk, $tableDescriptor);
798
799
                    $this->connection->delete($this->connection->quoteIdentifier($pivotTableName), $filters, $types);
800
801
                    // Finally, let's remove relationships completely from bean.
802
                    $toRemoveFromStorage[] = $remoteBean;
803
804
                    $remoteBean->_getCachedRelationships()[$pivotTableName]->detach($object);
805
                }
806
            }
807
808
            // Note: due to https://bugs.php.net/bug.php?id=65629, we cannot delete an element inside a foreach loop on a SplStorageObject.
809
            // Therefore, we cache elements in the $toRemoveFromStorage to remove them at a later stage.
810
            foreach ($toRemoveFromStorage as $remoteBean) {
811
                $storage->detach($remoteBean);
812
            }
813
        }
814
    }
815
816
    /**
817
     * @return mixed[] An array with 2 keys: "filters" and "types"
818
     */
819
    private function getPivotFilters(AbstractTDBMObject $localBean, AbstractTDBMObject $remoteBean, ForeignKeyConstraint $localFk, ForeignKeyConstraint $remoteFk, Table $tableDescriptor): array
820
    {
821
        $localBeanPk = $this->getPrimaryKeyValues($localBean);
822
        $remoteBeanPk = $this->getPrimaryKeyValues($remoteBean);
823
        $localColumns = $localFk->getUnquotedLocalColumns();
824
        $remoteColumns = $remoteFk->getUnquotedLocalColumns();
825
826
        $localFilters = SafeFunctions::arrayCombine($localColumns, $localBeanPk);
827
        $remoteFilters = SafeFunctions::arrayCombine($remoteColumns, $remoteBeanPk);
828
829
        $filters = array_merge($localFilters, $remoteFilters);
830
831
        $types = [];
832
        $escapedFilters = [];
833
834
        foreach ($filters as $columnName => $value) {
835
            $columnDescriptor = $tableDescriptor->getColumn($columnName);
836
            $types[] = $columnDescriptor->getType();
837
            $escapedFilters[$this->connection->quoteIdentifier($columnName)] = $value;
838
        }
839
        return ['filters' => $escapedFilters, 'types' => $types];
840
    }
841
842
    /**
843
     * Returns the "values" of the primary key.
844
     * This returns the primary key from the $primaryKey attribute, not the one stored in the columns.
845
     *
846
     * @param AbstractTDBMObject $bean
847
     *
848
     * @return mixed[] numerically indexed array of values
849
     */
850
    private function getPrimaryKeyValues(AbstractTDBMObject $bean): array
851
    {
852
        $dbRows = $bean->_getDbRows();
853
        $dbRow = reset($dbRows);
854
        if ($dbRow === false) {
855
            throw new \RuntimeException('Unexpected error: empty dbRow'); // @codeCoverageIgnore
856
        }
857
858
        return array_values($dbRow->_getPrimaryKeys());
859
    }
860
861
    /**
862
     * Returns a unique hash used to store the object based on its primary key.
863
     * If the array contains only one value, then the value is returned.
864
     * Otherwise, a hash representing the array is returned.
865
     *
866
     * @param mixed[] $primaryKeys An array of columns => values forming the primary key
867
     *
868
     * @return string|int
869
     */
870
    public function getObjectHash(array $primaryKeys)
871
    {
872
        if (count($primaryKeys) === 1) {
873
            return reset($primaryKeys);
874
        } else {
875
            ksort($primaryKeys);
876
877
            $pkJson = json_encode($primaryKeys);
878
            if ($pkJson === false) {
879
                throw new TDBMException('Unexepected error: unable to encode primary keys'); // @codeCoverageIgnore
880
            }
881
            return md5($pkJson);
882
        }
883
    }
884
885
    /**
886
     * Returns an array of primary keys from the object.
887
     * The primary keys are extracted from the object columns and not from the primary keys stored in the
888
     * $primaryKeys variable of the object.
889
     *
890
     * @param DbRow $dbRow
891
     *
892
     * @return mixed[] Returns an array of column => value
893
     */
894
    public function getPrimaryKeysForObjectFromDbRow(DbRow $dbRow): array
895
    {
896
        $table = $dbRow->_getDbTableName();
897
898
        $primaryKeyColumns = $this->getPrimaryKeyColumns($table);
899
        $values = array();
900
        $dbRowValues = $dbRow->_getDbRow();
901
        foreach ($primaryKeyColumns as $column) {
902
            if (isset($dbRowValues[$column])) {
903
                $values[$column] = $dbRowValues[$column];
904
            }
905
        }
906
907
        return $values;
908
    }
909
910
    /**
911
     * Returns an array of primary keys for the given row.
912
     * The primary keys are extracted from the object columns.
913
     *
914
     * @param string $table
915
     * @param mixed[] $columns
916
     *
917
     * @return mixed[] Returns an array of column => value
918
     */
919
    public function _getPrimaryKeysFromObjectData(string $table, array $columns): array
920
    {
921
        $primaryKeyColumns = $this->getPrimaryKeyColumns($table);
922
        $values = [];
923
        foreach ($primaryKeyColumns as $column) {
924
            if (isset($columns[$column])) {
925
                $values[$column] = $columns[$column];
926
            }
927
        }
928
929
        return $values;
930
    }
931
932
    /**
933
     * Attaches $object to this TDBMService.
934
     * The $object must be in DETACHED state and will pass in NEW state.
935
     *
936
     * @param AbstractTDBMObject $object
937
     *
938
     * @throws TDBMInvalidOperationException
939
     */
940
    public function attach(AbstractTDBMObject $object): void
941
    {
942
        $object->_attach($this);
943
    }
944
945
    /**
946
     * Returns an associative array (column => value) for the primary keys from the table name and an
947
     * indexed array of primary key values.
948
     *
949
     * @param string $tableName
950
     * @param mixed[] $indexedPrimaryKeys
951
     * @return mixed[]
952
     */
953
    public function _getPrimaryKeysFromIndexedPrimaryKeys(string $tableName, array $indexedPrimaryKeys): array
954
    {
955
        $primaryKeyColumns = $this->getPrimaryKeyColumns($tableName);
956
957
        if (count($primaryKeyColumns) !== count($indexedPrimaryKeys)) {
958
            throw new TDBMException(sprintf('Wrong number of columns passed for primary key. Expected %s columns for table "%s",
959
			got %s instead.', count($primaryKeyColumns), $tableName, count($indexedPrimaryKeys)));
960
        }
961
962
        return SafeFunctions::arrayCombine($primaryKeyColumns, $indexedPrimaryKeys);
963
    }
964
965
    /**
966
     * Return the list of tables (from child to parent) joining the tables passed in parameter.
967
     * Tables must be in a single line of inheritance. The method will find missing tables.
968
     *
969
     * Algorithm: one of those tables is the ultimate child. From this child, by recursively getting the parent,
970
     * we must be able to find all other tables.
971
     *
972
     * @param string[] $tables
973
     *
974
     * @return string[]
975
     */
976
    public function _getLinkBetweenInheritedTables(array $tables): array
977
    {
978
        sort($tables);
979
980
        return $this->fromCache(
981
            $this->cachePrefix.'_linkbetweeninheritedtables_'.implode('__split__', $tables),
982
            function () use ($tables) {
983
                return $this->_getLinkBetweenInheritedTablesWithoutCache($tables);
984
            }
985
        );
986
    }
987
988
    /**
989
     * Return the list of tables (from child to parent) joining the tables passed in parameter.
990
     * Tables must be in a single line of inheritance. The method will find missing tables.
991
     *
992
     * Algorithm: one of those tables is the ultimate child. From this child, by recursively getting the parent,
993
     * we must be able to find all other tables.
994
     *
995
     * @param string[] $tables
996
     *
997
     * @return string[]
998
     */
999
    private function _getLinkBetweenInheritedTablesWithoutCache(array $tables): array
1000
    {
1001
        $schemaAnalyzer = $this->schemaAnalyzer;
1002
1003
        foreach ($tables as $currentTable) {
1004
            $allParents = [$currentTable];
1005
            while ($currentFk = $schemaAnalyzer->getParentRelationship($currentTable)) {
1006
                $currentTable = $currentFk->getForeignTableName();
1007
                $allParents[] = $currentTable;
1008
            }
1009
1010
            // Now, does the $allParents contain all the tables we want?
1011
            $notFoundTables = array_diff($tables, $allParents);
1012
            if (empty($notFoundTables)) {
1013
                // We have a winner!
1014
                return $allParents;
1015
            }
1016
        }
1017
1018
        throw TDBMInheritanceException::create($tables);
1019
    }
1020
1021
    /**
1022
     * Returns the list of tables related to this table (via a parent or child inheritance relationship).
1023
     *
1024
     * @param string $table
1025
     *
1026
     * @return string[]
1027
     */
1028
    public function _getRelatedTablesByInheritance(string $table): array
1029
    {
1030
        return $this->fromCache($this->cachePrefix.'_relatedtables_'.$table, function () use ($table) {
1031
            return $this->_getRelatedTablesByInheritanceWithoutCache($table);
1032
        });
1033
    }
1034
1035
    /**
1036
     * Returns the list of tables related to this table (via a parent or child inheritance relationship).
1037
     *
1038
     * @param string $table
1039
     *
1040
     * @return string[]
1041
     */
1042
    private function _getRelatedTablesByInheritanceWithoutCache(string $table): array
1043
    {
1044
        $schemaAnalyzer = $this->schemaAnalyzer;
1045
1046
        // Let's scan the parent tables
1047
        $currentTable = $table;
1048
1049
        $parentTables = [];
1050
1051
        // Get parent relationship
1052
        while ($currentFk = $schemaAnalyzer->getParentRelationship($currentTable)) {
1053
            $currentTable = $currentFk->getForeignTableName();
1054
            $parentTables[] = $currentTable;
1055
        }
1056
1057
        // Let's recurse in children
1058
        $childrenTables = $this->exploreChildrenTablesRelationships($schemaAnalyzer, $table);
1059
1060
        return array_merge(array_reverse($parentTables), $childrenTables);
1061
    }
1062
1063
    /**
1064
     * Explore all the children and descendant of $table and returns ForeignKeyConstraints on those.
1065
     *
1066
     * @return string[]
1067
     */
1068
    private function exploreChildrenTablesRelationships(SchemaAnalyzer $schemaAnalyzer, string $table): array
1069
    {
1070
        $tables = [$table];
1071
        $keys = $schemaAnalyzer->getChildrenRelationships($table);
1072
1073
        foreach ($keys as $key) {
1074
            $tables = array_merge($tables, $this->exploreChildrenTablesRelationships($schemaAnalyzer, $key->getLocalTableName()));
1075
        }
1076
1077
        return $tables;
1078
    }
1079
1080
    /**
1081
     * Returns a `ResultIterator` object representing filtered records of "$mainTable" .
1082
     *
1083
     * The findObjects method should be the most used query method in TDBM if you want to query the database for objects.
1084
     * (Note: if you want to query the database for an object by its primary key, use the findObjectByPk method).
1085
     *
1086
     * The findObjects method takes in parameter:
1087
     * 	- mainTable: the kind of bean you want to retrieve. In TDBM, a bean matches a database row, so the
1088
     * 			`$mainTable` parameter should be the name of an existing table in database.
1089
     *  - filter: The filter is a filter bag. It is what you use to filter your request (the WHERE part in SQL).
1090
     *          It can be a string (SQL Where clause), or even a bean or an associative array (key = column to filter, value = value to find)
1091
     *  - parameters: The parameters used in the filter. If you pass a SQL string as a filter, be sure to avoid
1092
     *          concatenating parameters in the string (this leads to SQL injection and also to poor caching performance).
1093
     *          Instead, please consider passing parameters (see documentation for more details).
1094
     *  - additionalTablesFetch: An array of SQL tables names. The beans related to those tables will be fetched along
1095
     *          the main table. This is useful to avoid hitting the database with numerous subqueries.
1096
     *  - mode: The fetch mode of the result. See `setFetchMode()` method for more details.
1097
     *
1098
     * The `findObjects` method will return a `ResultIterator`. A `ResultIterator` is an object that behaves as an array
1099
     * (in ARRAY mode) at least. It can be iterated using a `foreach` loop.
1100
     *
1101
     * Finally, if filter_bag is null, the whole table is returned.
1102
     *
1103
     * @param string                       $mainTable             The name of the table queried
1104
     * @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)
1105
     * @param mixed[]                      $parameters
1106
     * @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)
1107
     * @param string[]                     $additionalTablesFetch
1108
     * @param int|null                     $mode
1109
     * @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
1110
     *
1111
     * @return ResultIterator An object representing an array of results
1112
     *
1113
     * @throws TDBMException
1114
     */
1115
    public function findObjects(string $mainTable, $filter = null, array $parameters = array(), $orderString = null, array $additionalTablesFetch = array(), ?int $mode = null, string $className = null) : ResultIterator
1116
    {
1117
        // $mainTable is not secured in MagicJoin, let's add a bit of security to avoid SQL injection.
1118
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $mainTable)) {
1119
            throw new TDBMException(sprintf("Invalid table name: '%s'", $mainTable));
1120
        }
1121
1122
        $mode = $mode ?: $this->mode;
1123
1124
        // We quote in MySQL because MagicJoin requires MySQL style quotes
1125
        $mysqlPlatform = new MySqlPlatform();
1126
        list($filterString, $additionalParameters) = $this->buildFilterFromFilterBag($filter, $mysqlPlatform);
1127
1128
        $parameters = array_merge($parameters, $additionalParameters);
1129
1130
        $queryFactory = new FindObjectsQueryFactory($mainTable, $additionalTablesFetch, $filterString, $orderString, $this, $this->tdbmSchemaAnalyzer->getSchema(), $this->orderByAnalyzer);
1131
1132
        return new ResultIterator($queryFactory, $parameters, $this->objectStorage, $className, $this, $this->magicQuery, $mode, $this->logger);
1133
    }
1134
1135
    /**
1136
     * @param string                       $mainTable   The name of the table queried
1137
     * @param string                       $from        The from sql statement
1138
     * @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)
1139
     * @param mixed[]                      $parameters
1140
     * @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)
1141
     * @param int                          $mode
1142
     * @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
1143
     *
1144
     * @return ResultIterator An object representing an array of results
1145
     *
1146
     * @throws TDBMException
1147
     */
1148
    public function findObjectsFromSql(string $mainTable, string $from, $filter = null, array $parameters = array(), $orderString = null, ?int $mode = null, string $className = null): ResultIterator
1149
    {
1150
        // $mainTable is not secured in MagicJoin, let's add a bit of security to avoid SQL injection.
1151
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $mainTable)) {
1152
            throw new TDBMException(sprintf("Invalid table name: '%s'", $mainTable));
1153
        }
1154
1155
        $mode = $mode ?: $this->mode;
1156
1157
        // We quote in MySQL because MagicJoin requires MySQL style quotes
1158
        $mysqlPlatform = new MySqlPlatform();
1159
        list($filterString, $additionalParameters) = $this->buildFilterFromFilterBag($filter, $mysqlPlatform);
1160
1161
        $parameters = array_merge($parameters, $additionalParameters);
1162
1163
        $queryFactory = new FindObjectsFromSqlQueryFactory($mainTable, $from, $filterString, $orderString, $this, $this->tdbmSchemaAnalyzer->getSchema(), $this->orderByAnalyzer, $this->schemaAnalyzer, $this->cache, $this->cachePrefix);
1164
1165
        return new ResultIterator($queryFactory, $parameters, $this->objectStorage, $className, $this, $this->magicQuery, $mode, $this->logger);
1166
    }
1167
1168
    /**
1169
     * @param string $table
1170
     * @param mixed[] $primaryKeys
1171
     * @param string[] $additionalTablesFetch
1172
     * @param bool $lazy Whether to perform lazy loading on this object or not
1173
     * @param string $className
1174
     *
1175
     * @return AbstractTDBMObject
1176
     *
1177
     * @throws TDBMException
1178
     */
1179
    public function findObjectByPk(string $table, array $primaryKeys, array $additionalTablesFetch = array(), bool $lazy = false, string $className = null): AbstractTDBMObject
1180
    {
1181
        $primaryKeys = $this->_getPrimaryKeysFromObjectData($table, $primaryKeys);
1182
        $hash = $this->getObjectHash($primaryKeys);
1183
1184
        $dbRow = $this->objectStorage->get($table, $hash);
1185
        if ($dbRow !== null) {
1186
            $bean = $dbRow->getTDBMObject();
1187
            if ($className !== null && !is_a($bean, $className)) {
1188
                throw new TDBMException("TDBM cannot create a bean of class '".$className."'. The requested object was already loaded and its class is '".get_class($bean)."'");
1189
            }
1190
1191
            return $bean;
1192
        }
1193
1194
        // Are we performing lazy fetching?
1195
        if ($lazy === true) {
1196
            // Can we perform lazy fetching?
1197
            $tables = $this->_getRelatedTablesByInheritance($table);
1198
            // Only allowed if no inheritance.
1199
            if (count($tables) === 1) {
1200
                if ($className === null) {
1201
                    try {
1202
                        $className = $this->getBeanClassName($table);
1203
                    } catch (TDBMInvalidArgumentException $e) {
1204
                        $className = TDBMObject::class;
1205
                    }
1206
                }
1207
1208
                // Let's construct the bean
1209
                if (!isset($this->reflectionClassCache[$className])) {
1210
                    $this->reflectionClassCache[$className] = new \ReflectionClass($className);
1211
                }
1212
                // Let's bypass the constructor when creating the bean!
1213
                /** @var AbstractTDBMObject */
1214
                $bean = $this->reflectionClassCache[$className]->newInstanceWithoutConstructor();
1215
                $bean->_constructLazy($table, $primaryKeys, $this);
1216
1217
                return $bean;
1218
            }
1219
        }
1220
1221
        // Did not find the object in cache? Let's query it!
1222
        try {
1223
            return $this->findObjectOrFail($table, $primaryKeys, [], $additionalTablesFetch, $className);
1224
        } catch (NoBeanFoundException $exception) {
1225
            $primaryKeysStringified = implode(' and ', array_map(function ($key, $value) {
1226
                return "'".$key."' = ".$value;
1227
            }, array_keys($primaryKeys), $primaryKeys));
1228
            throw new NoBeanFoundException("No result found for query on table '".$table."' for ".$primaryKeysStringified, 0, $exception);
1229
        }
1230
    }
1231
1232
    /**
1233
     * Returns a unique bean (or null) according to the filters passed in parameter.
1234
     *
1235
     * @param string            $mainTable             The name of the table queried
1236
     * @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)
1237
     * @param mixed[]           $parameters
1238
     * @param string[]          $additionalTablesFetch
1239
     * @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
1240
     *
1241
     * @return AbstractTDBMObject|null The object we want, or null if no object matches the filters
1242
     *
1243
     * @throws TDBMException
1244
     */
1245
    public function findObject(string $mainTable, $filter = null, array $parameters = array(), array $additionalTablesFetch = array(), string $className = null) : ?AbstractTDBMObject
1246
    {
1247
        $objects = $this->findObjects($mainTable, $filter, $parameters, null, $additionalTablesFetch, self::MODE_ARRAY, $className);
1248
        return $this->getAtMostOneObjectOrFail($objects, $mainTable, $filter, $parameters);
1249
    }
1250
1251
    /**
1252
     * @param string|array|null $filter
1253
     * @param mixed[]           $parameters
1254
     */
1255
    private function getAtMostOneObjectOrFail(ResultIterator $objects, string $mainTable, $filter, array $parameters): ?AbstractTDBMObject
1256
    {
1257
        $page = $objects->take(0, 2);
1258
1259
1260
        $pageArr = $page->toArray();
1261
        // Optimisation: the $page->count() query can trigger an additional SQL query in platforms other than MySQL.
1262
        // We try to avoid calling at by fetching all 2 columns instead.
1263
        $count = count($pageArr);
1264
1265
        if ($count > 1) {
1266
            $additionalErrorInfos = '';
1267
            if (is_string($filter) && !empty($parameters)) {
1268
                $additionalErrorInfos = ' for filter "' . $filter.'"';
1269
                foreach ($parameters as $fieldName => $parameter) {
1270
                    if (is_array($parameter)) {
1271
                        $value = '(' . implode(',', $parameter) . ')';
1272
                    } else {
1273
                        $value = $parameter;
1274
                    }
1275
                    $additionalErrorInfos = str_replace(':' . $fieldName, var_export($value, true), $additionalErrorInfos);
1276
                }
1277
            }
1278
            $additionalErrorInfos .= '.';
1279
            throw new DuplicateRowException("Error while querying an object in table '$mainTable': More than 1 row have been returned, but we should have received at most one" . $additionalErrorInfos);
1280
        } elseif ($count === 0) {
1281
            return null;
1282
        }
1283
1284
        return $pageArr[0];
1285
    }
1286
1287
    /**
1288
     * Returns a unique bean (or null) according to the filters passed in parameter.
1289
     *
1290
     * @param string            $mainTable  The name of the table queried
1291
     * @param string            $from       The from sql statement
1292
     * @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)
1293
     * @param mixed[]           $parameters
1294
     * @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
1295
     *
1296
     * @return AbstractTDBMObject|null The object we want, or null if no object matches the filters
1297
     *
1298
     * @throws TDBMException
1299
     */
1300
    public function findObjectFromSql(string $mainTable, string $from, $filter = null, array $parameters = array(), ?string $className = null) : ?AbstractTDBMObject
1301
    {
1302
        $objects = $this->findObjectsFromSql($mainTable, $from, $filter, $parameters, null, self::MODE_ARRAY, $className);
1303
        return $this->getAtMostOneObjectOrFail($objects, $mainTable, $filter, $parameters);
1304
    }
1305
1306
    /**
1307
     * @param string $mainTable
1308
     * @param string $sql
1309
     * @param mixed[] $parameters
1310
     * @param int|null $mode
1311
     * @param string|null $className
1312
     * @param string $sqlCount
1313
     *
1314
     * @return ResultIterator
1315
     *
1316
     * @throws TDBMException
1317
     */
1318
    public function findObjectsFromRawSql(string $mainTable, string $sql, array $parameters = array(), ?int $mode = null, string $className = null, string $sqlCount = null): ResultIterator
1319
    {
1320
        // $mainTable is not secured in MagicJoin, let's add a bit of security to avoid SQL injection.
1321
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $mainTable)) {
1322
            throw new TDBMException(sprintf("Invalid table name: '%s'", $mainTable));
1323
        }
1324
1325
        $mode = $mode ?: $this->mode;
1326
1327
        $queryFactory = new FindObjectsFromRawSqlQueryFactory($this, $this->tdbmSchemaAnalyzer->getSchema(), $mainTable, $sql, $sqlCount);
1328
1329
        return new ResultIterator($queryFactory, $parameters, $this->objectStorage, $className, $this, $this->magicQuery, $mode, $this->logger);
1330
    }
1331
1332
    /**
1333
     * Returns a unique bean according to the filters passed in parameter.
1334
     * Throws a NoBeanFoundException if no bean was found for the filter passed in parameter.
1335
     *
1336
     * @param string            $mainTable             The name of the table queried
1337
     * @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)
1338
     * @param mixed[]           $parameters
1339
     * @param string[]          $additionalTablesFetch
1340
     * @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
1341
     *
1342
     * @return AbstractTDBMObject The object we want
1343
     *
1344
     * @throws TDBMException
1345
     */
1346
    public function findObjectOrFail(string $mainTable, $filter = null, array $parameters = array(), array $additionalTablesFetch = array(), string $className = null): AbstractTDBMObject
1347
    {
1348
        $bean = $this->findObject($mainTable, $filter, $parameters, $additionalTablesFetch, $className);
1349
        if ($bean === null) {
1350
            throw new NoBeanFoundException("No result found for query on table '".$mainTable."'");
1351
        }
1352
1353
        return $bean;
1354
    }
1355
1356
    /**
1357
     * @param array[] $beanData An array of data: array<table, array<column, value>>
1358
     *
1359
     * @return mixed[] an array with first item = class name, second item = table name and third item = list of tables needed
1360
     *
1361
     * @throws TDBMInheritanceException
1362
     */
1363
    public function _getClassNameFromBeanData(array $beanData): array
1364
    {
1365
        if (count($beanData) === 1) {
1366
            $tableName = array_keys($beanData)[0];
1367
            $allTables = [$tableName];
1368
        } else {
1369
            $tables = [];
1370
            foreach ($beanData as $table => $row) {
1371
                $primaryKeyColumns = $this->getPrimaryKeyColumns($table);
1372
                $pkSet = false;
1373
                foreach ($primaryKeyColumns as $columnName) {
1374
                    if ($row[$columnName] !== null) {
1375
                        $pkSet = true;
1376
                        break;
1377
                    }
1378
                }
1379
                if ($pkSet) {
1380
                    $tables[] = $table;
1381
                }
1382
            }
1383
1384
            // $tables contains the tables for this bean. Let's view the top most part of the hierarchy
1385
            try {
1386
                $allTables = $this->_getLinkBetweenInheritedTables($tables);
1387
            } catch (TDBMInheritanceException $e) {
1388
                throw TDBMInheritanceException::extendException($e, $this, $beanData);
1389
            }
1390
            $tableName = $allTables[0];
1391
        }
1392
1393
        // Only one table in this bean. Life is sweat, let's look at its type:
1394
        try {
1395
            $className = $this->getBeanClassName($tableName);
1396
        } catch (TDBMInvalidArgumentException $e) {
1397
            $className = 'TheCodingMachine\\TDBM\\TDBMObject';
1398
        }
1399
1400
        return [$className, $tableName, $allTables];
1401
    }
1402
1403
    /**
1404
     * Returns an item from cache or computes it using $closure and puts it in cache.
1405
     *
1406
     * @param string   $key
1407
     * @param callable $closure
1408
     *
1409
     * @return mixed
1410
     */
1411
    private function fromCache(string $key, callable $closure)
1412
    {
1413
        $item = $this->cache->fetch($key);
1414
        if ($item === false) {
1415
            $item = $closure();
1416
            $result = $this->cache->save($key, $item);
1417
1418
            if ($result === false) {
1419
                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.');
1420
            }
1421
        }
1422
1423
        return $item;
1424
    }
1425
1426
    /**
1427
     * Returns the foreign key object.
1428
     *
1429
     * @param string $table
1430
     * @param string $fkName
1431
     *
1432
     * @return ForeignKeyConstraint
1433
     */
1434
    public function _getForeignKeyByName(string $table, string $fkName): ForeignKeyConstraint
1435
    {
1436
        return $this->tdbmSchemaAnalyzer->getSchema()->getTable($table)->getForeignKey($fkName);
1437
    }
1438
1439
    /**
1440
     * @param string $pivotTableName
1441
     * @param AbstractTDBMObject $bean
1442
     *
1443
     * @return AbstractTDBMObject[]|ResultIterator
1444
     */
1445
    public function _getRelatedBeans(string $pivotTableName, AbstractTDBMObject $bean): ResultIterator
1446
    {
1447
        list($localFk, $remoteFk) = $this->getPivotTableForeignKeys($pivotTableName, $bean);
1448
        /* @var $localFk ForeignKeyConstraint */
1449
        /* @var $remoteFk ForeignKeyConstraint */
1450
        $remoteTable = $remoteFk->getForeignTableName();
1451
1452
        $primaryKeys = $this->getPrimaryKeyValues($bean);
1453
        $columnNames = array_map(function ($name) use ($pivotTableName) {
1454
            return $pivotTableName.'.'.$name;
1455
        }, $localFk->getUnquotedLocalColumns());
1456
1457
        $filter = SafeFunctions::arrayCombine($columnNames, $primaryKeys);
1458
1459
        return $this->findObjects($remoteTable, $filter);
1460
    }
1461
1462
    /**
1463
     * @param string $pivotTableName
1464
     * @param AbstractTDBMObject $bean The LOCAL bean
1465
     *
1466
     * @return ForeignKeyConstraint[] First item: the LOCAL bean, second item: the REMOTE bean
1467
     *
1468
     * @throws TDBMException
1469
     */
1470
    private function getPivotTableForeignKeys(string $pivotTableName, AbstractTDBMObject $bean): array
1471
    {
1472
        $fks = array_values($this->tdbmSchemaAnalyzer->getSchema()->getTable($pivotTableName)->getForeignKeys());
1473
        $table1 = $fks[0]->getForeignTableName();
1474
        $table2 = $fks[1]->getForeignTableName();
1475
1476
        $beanTables = array_map(function (DbRow $dbRow) {
1477
            return $dbRow->_getDbTableName();
1478
        }, $bean->_getDbRows());
1479
1480
        if (in_array($table1, $beanTables)) {
1481
            return [$fks[0], $fks[1]];
1482
        } elseif (in_array($table2, $beanTables)) {
1483
            return [$fks[1], $fks[0]];
1484
        } else {
1485
            throw new TDBMException("Unexpected bean type in getPivotTableForeignKeys. Awaiting beans from table {$table1} and {$table2} for pivot table {$pivotTableName}");
1486
        }
1487
    }
1488
1489
    /**
1490
     * Returns a list of pivot tables linked to $bean.
1491
     *
1492
     * @param AbstractTDBMObject $bean
1493
     *
1494
     * @return string[]
1495
     */
1496
    public function _getPivotTablesLinkedToBean(AbstractTDBMObject $bean): array
1497
    {
1498
        $junctionTables = [];
1499
        $allJunctionTables = $this->schemaAnalyzer->detectJunctionTables(true);
1500
        foreach ($bean->_getDbRows() as $dbRow) {
1501
            foreach ($allJunctionTables as $table) {
1502
                // There are exactly 2 FKs since this is a pivot table.
1503
                $fks = array_values($table->getForeignKeys());
1504
1505
                if ($fks[0]->getForeignTableName() === $dbRow->_getDbTableName() || $fks[1]->getForeignTableName() === $dbRow->_getDbTableName()) {
1506
                    $junctionTables[] = $table->getName();
1507
                }
1508
            }
1509
        }
1510
1511
        return $junctionTables;
1512
    }
1513
1514
    /**
1515
     * Array of types for tables.
1516
     * Key: table name
1517
     * Value: array of types indexed by column.
1518
     *
1519
     * @var array[]
1520
     */
1521
    private $typesForTable = [];
1522
1523
    /**
1524
     * @internal
1525
     *
1526
     * @param string $tableName
1527
     *
1528
     * @return Type[]
1529
     */
1530
    public function _getColumnTypesForTable(string $tableName): array
1531
    {
1532
        if (!isset($this->typesForTable[$tableName])) {
1533
            $columns = $this->tdbmSchemaAnalyzer->getSchema()->getTable($tableName)->getColumns();
1534
            foreach ($columns as $column) {
1535
                $this->typesForTable[$tableName][$column->getName()] = $column->getType();
1536
            }
1537
        }
1538
1539
        return $this->typesForTable[$tableName];
1540
    }
1541
1542
    /**
1543
     * Sets the minimum log level.
1544
     * $level must be one of Psr\Log\LogLevel::xxx.
1545
     *
1546
     * Defaults to LogLevel::WARNING
1547
     *
1548
     * @param string $level
1549
     */
1550
    public function setLogLevel(string $level): void
1551
    {
1552
        $this->logger = new LevelFilter($this->rootLogger, $level);
1553
    }
1554
}
1555