Passed
Pull Request — master (#135)
by
unknown
13:18 queued 10s
created

TDBMService::getPrimaryKeyValues()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 9
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 1
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 || $filter_bag === []) {
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
            $key = $this->cachePrefix.'_tableToBean_'.$tableName;
550
            $cache = $this->cache->fetch($key);
551
            if ($cache) {
552
                return $cache;
553
            }
554
555
            $className = $this->beanNamespace.'\\'.$this->namingStrategy->getBeanClassName($tableName);
556
557
            if (!class_exists($className)) {
558
                throw new TDBMInvalidArgumentException(sprintf('Could not find class "%s". Does table "%s" exist? If yes, consider regenerating the DAOs and beans.', $className, $tableName));
559
            }
560
561
            $this->tableToBeanMap[$tableName] = $className;
562
            $this->cache->save($key, $className);
563
            return $className;
564
        }
565
    }
566
567
    /**
568
     * Saves $object by INSERTing or UPDAT(E)ing it in the database.
569
     *
570
     * @param AbstractTDBMObject $object
571
     *
572
     * @throws TDBMException
573
     */
574
    public function save(AbstractTDBMObject $object): void
575
    {
576
        $this->connection->beginTransaction();
577
        try {
578
            $status = $object->_getStatus();
579
580
            // Let's attach this object if it is in detached state.
581
            if ($status === TDBMObjectStateEnum::STATE_DETACHED) {
582
                $object->_attach($this);
583
                $status = $object->_getStatus();
584
            }
585
586
            if ($status === TDBMObjectStateEnum::STATE_NEW) {
587
                $dbRows = $object->_getDbRows();
588
589
                $unindexedPrimaryKeys = array();
590
591
                foreach ($dbRows as $dbRow) {
592
                    if ($dbRow->_getStatus() == TDBMObjectStateEnum::STATE_SAVING) {
593
                        throw TDBMCyclicReferenceException::createCyclicReference($dbRow->_getDbTableName(), $object);
594
                    }
595
                    $dbRow->_setStatus(TDBMObjectStateEnum::STATE_SAVING);
596
                    $tableName = $dbRow->_getDbTableName();
597
598
                    $schema = $this->tdbmSchemaAnalyzer->getSchema();
599
                    $tableDescriptor = $schema->getTable($tableName);
600
601
                    $primaryKeyColumns = $this->getPrimaryKeyColumns($tableName);
602
603
                    $references = $dbRow->_getReferences();
604
605
                    // Let's save all references in NEW or DETACHED state (we need their primary key)
606
                    foreach ($references as $fkName => $reference) {
607
                        if ($reference !== null) {
608
                            $refStatus = $reference->_getStatus();
609
                            if ($refStatus === TDBMObjectStateEnum::STATE_NEW || $refStatus === TDBMObjectStateEnum::STATE_DETACHED) {
610
                                try {
611
                                    $this->save($reference);
612
                                } catch (TDBMCyclicReferenceException $e) {
613
                                    throw TDBMCyclicReferenceException::extendCyclicReference($e, $dbRow->_getDbTableName(), $object, $fkName);
614
                                }
615
                            }
616
                        }
617
                    }
618
619
                    if (empty($unindexedPrimaryKeys)) {
620
                        $primaryKeys = $this->getPrimaryKeysForObjectFromDbRow($dbRow);
621
                    } else {
622
                        // First insert, the children must have the same primary key as the parent.
623
                        $primaryKeys = $this->_getPrimaryKeysFromIndexedPrimaryKeys($tableName, $unindexedPrimaryKeys);
624
                        $dbRow->_setPrimaryKeys($primaryKeys);
625
                    }
626
627
                    $dbRowData = $dbRow->_getDbRow();
628
629
                    // Let's see if the columns for primary key have been set before inserting.
630
                    // We assume that if one of the value of the PK has been set, the PK is set.
631
                    $isPkSet = !empty($primaryKeys);
632
633
                    /*if (!$isPkSet) {
634
                        // if there is no autoincrement and no pk set, let's go in error.
635
                        $isAutoIncrement = true;
636
637
                        foreach ($primaryKeyColumns as $pkColumnName) {
638
                            $pkColumn = $tableDescriptor->getColumn($pkColumnName);
639
                            if (!$pkColumn->getAutoincrement()) {
640
                                $isAutoIncrement = false;
641
                            }
642
                        }
643
644
                        if (!$isAutoIncrement) {
645
                            $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.";
646
                            throw new TDBMException($msg);
647
                        }
648
649
                    }*/
650
651
                    $types = [];
652
                    $escapedDbRowData = [];
653
654
                    foreach ($dbRowData as $columnName => $value) {
655
                        $columnDescriptor = $tableDescriptor->getColumn($columnName);
656
                        $types[] = $columnDescriptor->getType();
657
                        $escapedDbRowData[$this->connection->quoteIdentifier($columnName)] = $value;
658
                    }
659
660
                    $quotedTableName = $this->connection->quoteIdentifier($tableName);
661
                    $this->connection->insert($quotedTableName, $escapedDbRowData, $types);
662
663
                    if (!$isPkSet && count($primaryKeyColumns) === 1) {
664
                        $id = $this->connection->lastInsertId();
665
666
                        if ($id === false) {
667
                            // In Oracle (if we are in 11g), the lastInsertId will fail. We try again with the column.
668
                            $sequenceName = $this->connection->getDatabasePlatform()->getIdentitySequenceName(
669
                                $quotedTableName,
670
                                $this->connection->quoteIdentifier($primaryKeyColumns[0])
671
                            );
672
                            $id = $this->connection->lastInsertId($sequenceName);
673
                        }
674
675
                        $pkColumn = $primaryKeyColumns[0];
676
                        // lastInsertId returns a string but the column type is usually a int. Let's convert it back to the correct type.
677
                        $id = $tableDescriptor->getColumn($pkColumn)->getType()->convertToPHPValue($id, $this->getConnection()->getDatabasePlatform());
678
                        $primaryKeys[$pkColumn] = $id;
679
                    }
680
681
                    // TODO: change this to some private magic accessor in future
682
                    $dbRow->_setPrimaryKeys($primaryKeys);
683
                    $unindexedPrimaryKeys = array_values($primaryKeys);
684
685
                    // Let's remove this object from the $new_objects static table.
686
                    $this->removeFromToSaveObjectList($dbRow);
687
688
                    // Let's add this object to the list of objects in cache.
689
                    $this->_addToCache($dbRow);
690
                }
691
692
                $object->_setStatus(TDBMObjectStateEnum::STATE_LOADED);
693
            } elseif ($status === TDBMObjectStateEnum::STATE_DIRTY) {
694
                $dbRows = $object->_getDbRows();
695
696
                foreach ($dbRows as $dbRow) {
697
                    if ($dbRow->_getStatus() !== TDBMObjectStateEnum::STATE_DIRTY) {
698
                        // Not all db_rows in a bean need to be dirty when the bean itself is dirty.
699
                        continue;
700
                    }
701
                    $references = $dbRow->_getReferences();
702
703
                    // Let's save all references in NEW state (we need their primary key)
704
                    foreach ($references as $fkName => $reference) {
705
                        if ($reference !== null && $reference->_getStatus() === TDBMObjectStateEnum::STATE_NEW) {
706
                            $this->save($reference);
707
                        }
708
                    }
709
710
                    $tableName = $dbRow->_getDbTableName();
711
                    $dbRowData = $dbRow->_getUpdatedDbRow();
712
713
                    $schema = $this->tdbmSchemaAnalyzer->getSchema();
714
                    $tableDescriptor = $schema->getTable($tableName);
715
716
                    $primaryKeys = $dbRow->_getPrimaryKeys();
717
718
                    $types = [];
719
                    $escapedDbRowData = [];
720
                    $escapedPrimaryKeys = [];
721
722
                    foreach ($dbRowData as $columnName => $value) {
723
                        $columnDescriptor = $tableDescriptor->getColumn($columnName);
724
                        $types[] = $columnDescriptor->getType();
725
                        $escapedDbRowData[$this->connection->quoteIdentifier($columnName)] = $value;
726
                    }
727
                    foreach ($primaryKeys as $columnName => $value) {
728
                        $columnDescriptor = $tableDescriptor->getColumn($columnName);
729
                        $types[] = $columnDescriptor->getType();
730
                        $escapedPrimaryKeys[$this->connection->quoteIdentifier($columnName)] = $value;
731
                    }
732
733
                    $this->connection->update($this->connection->quoteIdentifier($tableName), $escapedDbRowData, $escapedPrimaryKeys, $types);
734
735
                    // Let's check if the primary key has been updated...
736
                    $needsUpdatePk = false;
737
                    foreach ($primaryKeys as $column => $value) {
738
                        if (isset($dbRowData[$column]) && $dbRowData[$column] != $value) {
739
                            $needsUpdatePk = true;
740
                            break;
741
                        }
742
                    }
743
                    if ($needsUpdatePk) {
744
                        $this->objectStorage->remove($tableName, $this->getObjectHash($primaryKeys));
745
                        $newPrimaryKeys = $this->getPrimaryKeysForObjectFromDbRow($dbRow);
746
                        $dbRow->_setPrimaryKeys($newPrimaryKeys);
747
                        $this->objectStorage->set($tableName, $this->getObjectHash($primaryKeys), $dbRow);
748
                    }
749
750
                    // Let's remove this object from the list of objects to save.
751
                    $this->removeFromToSaveObjectList($dbRow);
752
                }
753
754
                $object->_setStatus(TDBMObjectStateEnum::STATE_LOADED);
755
            } elseif ($status === TDBMObjectStateEnum::STATE_DELETED) {
756
                throw new TDBMInvalidOperationException('This object has been deleted. It cannot be saved.');
757
            }
758
759
            // Finally, let's save all the many to many relationships to this bean.
760
            $this->persistManyToManyRelationships($object);
761
            $this->connection->commit();
762
        } catch (\Throwable $t) {
763
            $this->connection->rollBack();
764
            throw $t;
765
        }
766
    }
767
768
    private function persistManyToManyRelationships(AbstractTDBMObject $object): void
769
    {
770
        foreach ($object->_getCachedRelationships() as $pivotTableName => $storage) {
771
            $tableDescriptor = $this->tdbmSchemaAnalyzer->getSchema()->getTable($pivotTableName);
772
            list($localFk, $remoteFk) = $this->getPivotTableForeignKeys($pivotTableName, $object);
773
774
            $toRemoveFromStorage = [];
775
776
            foreach ($storage as $remoteBean) {
777
                /* @var $remoteBean AbstractTDBMObject */
778
                $statusArr = $storage[$remoteBean];
779
                $status = $statusArr['status'];
780
                $reverse = $statusArr['reverse'];
781
                if ($reverse) {
782
                    continue;
783
                }
784
785
                if ($status === 'new') {
786
                    $remoteBeanStatus = $remoteBean->_getStatus();
787
                    if ($remoteBeanStatus === TDBMObjectStateEnum::STATE_NEW || $remoteBeanStatus === TDBMObjectStateEnum::STATE_DETACHED) {
788
                        // Let's save remote bean if needed.
789
                        $this->save($remoteBean);
790
                    }
791
792
                    ['filters' => $filters, 'types' => $types] = $this->getPivotFilters($object, $remoteBean, $localFk, $remoteFk, $tableDescriptor);
793
794
                    $this->connection->insert($this->connection->quoteIdentifier($pivotTableName), $filters, $types);
795
796
                    // Finally, let's mark relationships as saved.
797
                    $statusArr['status'] = 'loaded';
798
                    $storage[$remoteBean] = $statusArr;
799
                    $remoteStorage = $remoteBean->_getCachedRelationships()[$pivotTableName];
800
                    $remoteStatusArr = $remoteStorage[$object];
801
                    $remoteStatusArr['status'] = 'loaded';
802
                    $remoteStorage[$object] = $remoteStatusArr;
803
                } elseif ($status === 'delete') {
804
                    ['filters' => $filters, 'types' => $types] = $this->getPivotFilters($object, $remoteBean, $localFk, $remoteFk, $tableDescriptor);
805
806
                    $this->connection->delete($this->connection->quoteIdentifier($pivotTableName), $filters, $types);
807
808
                    // Finally, let's remove relationships completely from bean.
809
                    $toRemoveFromStorage[] = $remoteBean;
810
811
                    $remoteBean->_getCachedRelationships()[$pivotTableName]->detach($object);
812
                }
813
            }
814
815
            // Note: due to https://bugs.php.net/bug.php?id=65629, we cannot delete an element inside a foreach loop on a SplStorageObject.
816
            // Therefore, we cache elements in the $toRemoveFromStorage to remove them at a later stage.
817
            foreach ($toRemoveFromStorage as $remoteBean) {
818
                $storage->detach($remoteBean);
819
            }
820
        }
821
    }
822
823
    /**
824
     * @return mixed[] An array with 2 keys: "filters" and "types"
825
     */
826
    private function getPivotFilters(AbstractTDBMObject $localBean, AbstractTDBMObject $remoteBean, ForeignKeyConstraint $localFk, ForeignKeyConstraint $remoteFk, Table $tableDescriptor): array
827
    {
828
        $localBeanPk = $this->getPrimaryKeyValues($localBean);
829
        $remoteBeanPk = $this->getPrimaryKeyValues($remoteBean);
830
        $localColumns = $localFk->getUnquotedLocalColumns();
831
        $remoteColumns = $remoteFk->getUnquotedLocalColumns();
832
833
        $localFilters = SafeFunctions::arrayCombine($localColumns, $localBeanPk);
834
        $remoteFilters = SafeFunctions::arrayCombine($remoteColumns, $remoteBeanPk);
835
836
        $filters = array_merge($localFilters, $remoteFilters);
837
838
        $types = [];
839
        $escapedFilters = [];
840
841
        foreach ($filters as $columnName => $value) {
842
            $columnDescriptor = $tableDescriptor->getColumn((string) $columnName);
843
            $types[] = $columnDescriptor->getType();
844
            $escapedFilters[$this->connection->quoteIdentifier((string) $columnName)] = $value;
845
        }
846
        return ['filters' => $escapedFilters, 'types' => $types];
847
    }
848
849
    /**
850
     * Returns the "values" of the primary key.
851
     * This returns the primary key from the $primaryKey attribute, not the one stored in the columns.
852
     *
853
     * @param AbstractTDBMObject $bean
854
     *
855
     * @return mixed[] numerically indexed array of values
856
     */
857
    private function getPrimaryKeyValues(AbstractTDBMObject $bean): array
858
    {
859
        $dbRows = $bean->_getDbRows();
860
        $dbRow = reset($dbRows);
861
        if ($dbRow === false) {
862
            throw new \RuntimeException('Unexpected error: empty dbRow'); // @codeCoverageIgnore
863
        }
864
865
        return array_values($dbRow->_getPrimaryKeys());
866
    }
867
868
    /**
869
     * Returns a unique hash used to store the object based on its primary key.
870
     * If the array contains only one value, then the value is returned.
871
     * Otherwise, a hash representing the array is returned.
872
     *
873
     * @param mixed[] $primaryKeys An array of columns => values forming the primary key
874
     *
875
     * @return string|int
876
     */
877
    public function getObjectHash(array $primaryKeys)
878
    {
879
        if (count($primaryKeys) === 1) {
880
            return reset($primaryKeys);
881
        } else {
882
            ksort($primaryKeys);
883
884
            $pkJson = json_encode($primaryKeys);
885
            if ($pkJson === false) {
886
                throw new TDBMException('Unexepected error: unable to encode primary keys'); // @codeCoverageIgnore
887
            }
888
            return md5($pkJson);
889
        }
890
    }
891
892
    /**
893
     * Returns an array of primary keys from the object.
894
     * The primary keys are extracted from the object columns and not from the primary keys stored in the
895
     * $primaryKeys variable of the object.
896
     *
897
     * @param DbRow $dbRow
898
     *
899
     * @return mixed[] Returns an array of column => value
900
     */
901
    public function getPrimaryKeysForObjectFromDbRow(DbRow $dbRow): array
902
    {
903
        $table = $dbRow->_getDbTableName();
904
905
        $primaryKeyColumns = $this->getPrimaryKeyColumns($table);
906
        $values = array();
907
        $dbRowValues = $dbRow->_getDbRow();
908
        foreach ($primaryKeyColumns as $column) {
909
            if (isset($dbRowValues[$column])) {
910
                $values[$column] = $dbRowValues[$column];
911
            }
912
        }
913
914
        return $values;
915
    }
916
917
    /**
918
     * Returns an array of primary keys for the given row.
919
     * The primary keys are extracted from the object columns.
920
     *
921
     * @param string $table
922
     * @param mixed[] $columns
923
     *
924
     * @return mixed[] Returns an array of column => value
925
     */
926
    public function _getPrimaryKeysFromObjectData(string $table, array $columns): array
927
    {
928
        $primaryKeyColumns = $this->getPrimaryKeyColumns($table);
929
        $values = [];
930
        foreach ($primaryKeyColumns as $column) {
931
            if (isset($columns[$column])) {
932
                $values[$column] = $columns[$column];
933
            }
934
        }
935
936
        return $values;
937
    }
938
939
    /**
940
     * Attaches $object to this TDBMService.
941
     * The $object must be in DETACHED state and will pass in NEW state.
942
     *
943
     * @param AbstractTDBMObject $object
944
     *
945
     * @throws TDBMInvalidOperationException
946
     */
947
    public function attach(AbstractTDBMObject $object): void
948
    {
949
        $object->_attach($this);
950
    }
951
952
    /**
953
     * Returns an associative array (column => value) for the primary keys from the table name and an
954
     * indexed array of primary key values.
955
     *
956
     * @param string $tableName
957
     * @param mixed[] $indexedPrimaryKeys
958
     * @return mixed[]
959
     */
960
    public function _getPrimaryKeysFromIndexedPrimaryKeys(string $tableName, array $indexedPrimaryKeys): array
961
    {
962
        $primaryKeyColumns = $this->getPrimaryKeyColumns($tableName);
963
964
        if (count($primaryKeyColumns) !== count($indexedPrimaryKeys)) {
965
            throw new TDBMException(sprintf('Wrong number of columns passed for primary key. Expected %s columns for table "%s",
966
			got %s instead.', count($primaryKeyColumns), $tableName, count($indexedPrimaryKeys)));
967
        }
968
969
        return SafeFunctions::arrayCombine($primaryKeyColumns, $indexedPrimaryKeys);
970
    }
971
972
    /**
973
     * Return the list of tables (from child to parent) joining the tables passed in parameter.
974
     * Tables must be in a single line of inheritance. The method will find missing tables.
975
     *
976
     * Algorithm: one of those tables is the ultimate child. From this child, by recursively getting the parent,
977
     * we must be able to find all other tables.
978
     *
979
     * @param string[] $tables
980
     *
981
     * @return string[]
982
     */
983
    public function _getLinkBetweenInheritedTables(array $tables): array
984
    {
985
        sort($tables);
986
987
        return $this->fromCache(
988
            $this->cachePrefix.'_linkbetweeninheritedtables_'.implode('__split__', $tables),
989
            function () use ($tables) {
990
                return $this->_getLinkBetweenInheritedTablesWithoutCache($tables);
991
            }
992
        );
993
    }
994
995
    /**
996
     * Return the list of tables (from child to parent) joining the tables passed in parameter.
997
     * Tables must be in a single line of inheritance. The method will find missing tables.
998
     *
999
     * Algorithm: one of those tables is the ultimate child. From this child, by recursively getting the parent,
1000
     * we must be able to find all other tables.
1001
     *
1002
     * @param string[] $tables
1003
     *
1004
     * @return string[]
1005
     */
1006
    private function _getLinkBetweenInheritedTablesWithoutCache(array $tables): array
1007
    {
1008
        $schemaAnalyzer = $this->schemaAnalyzer;
1009
1010
        foreach ($tables as $currentTable) {
1011
            $allParents = [$currentTable];
1012
            while ($currentFk = $schemaAnalyzer->getParentRelationship($currentTable)) {
1013
                $currentTable = $currentFk->getForeignTableName();
1014
                $allParents[] = $currentTable;
1015
            }
1016
1017
            // Now, does the $allParents contain all the tables we want?
1018
            $notFoundTables = array_diff($tables, $allParents);
1019
            if (empty($notFoundTables)) {
1020
                // We have a winner!
1021
                return $allParents;
1022
            }
1023
        }
1024
1025
        throw TDBMInheritanceException::create($tables);
1026
    }
1027
1028
    /**
1029
     * Returns the list of tables related to this table (via a parent or child inheritance relationship).
1030
     *
1031
     * @param string $table
1032
     *
1033
     * @return string[]
1034
     */
1035
    public function _getRelatedTablesByInheritance(string $table): array
1036
    {
1037
        return $this->fromCache($this->cachePrefix.'_relatedtables_'.$table, function () use ($table) {
1038
            return $this->_getRelatedTablesByInheritanceWithoutCache($table);
1039
        });
1040
    }
1041
1042
    /**
1043
     * Returns the list of tables related to this table (via a parent or child inheritance relationship).
1044
     *
1045
     * @param string $table
1046
     *
1047
     * @return string[]
1048
     */
1049
    private function _getRelatedTablesByInheritanceWithoutCache(string $table): array
1050
    {
1051
        $schemaAnalyzer = $this->schemaAnalyzer;
1052
1053
        // Let's scan the parent tables
1054
        $currentTable = $table;
1055
1056
        $parentTables = [];
1057
1058
        // Get parent relationship
1059
        while ($currentFk = $schemaAnalyzer->getParentRelationship($currentTable)) {
1060
            $currentTable = $currentFk->getForeignTableName();
1061
            $parentTables[] = $currentTable;
1062
        }
1063
1064
        // Let's recurse in children
1065
        $childrenTables = $this->exploreChildrenTablesRelationships($schemaAnalyzer, $table);
1066
1067
        return array_merge(array_reverse($parentTables), $childrenTables);
1068
    }
1069
1070
    /**
1071
     * Explore all the children and descendant of $table and returns ForeignKeyConstraints on those.
1072
     *
1073
     * @return string[]
1074
     */
1075
    private function exploreChildrenTablesRelationships(SchemaAnalyzer $schemaAnalyzer, string $table): array
1076
    {
1077
        $tables = [$table];
1078
        $keys = $schemaAnalyzer->getChildrenRelationships($table);
1079
1080
        foreach ($keys as $key) {
1081
            $tables = array_merge($tables, $this->exploreChildrenTablesRelationships($schemaAnalyzer, $key->getLocalTableName()));
1082
        }
1083
1084
        return $tables;
1085
    }
1086
1087
    /**
1088
     * Returns a `ResultIterator` object representing filtered records of "$mainTable" .
1089
     *
1090
     * The findObjects method should be the most used query method in TDBM if you want to query the database for objects.
1091
     * (Note: if you want to query the database for an object by its primary key, use the findObjectByPk method).
1092
     *
1093
     * The findObjects method takes in parameter:
1094
     * 	- mainTable: the kind of bean you want to retrieve. In TDBM, a bean matches a database row, so the
1095
     * 			`$mainTable` parameter should be the name of an existing table in database.
1096
     *  - filter: The filter is a filter bag. It is what you use to filter your request (the WHERE part in SQL).
1097
     *          It can be a string (SQL Where clause), or even a bean or an associative array (key = column to filter, value = value to find)
1098
     *  - parameters: The parameters used in the filter. If you pass a SQL string as a filter, be sure to avoid
1099
     *          concatenating parameters in the string (this leads to SQL injection and also to poor caching performance).
1100
     *          Instead, please consider passing parameters (see documentation for more details).
1101
     *  - additionalTablesFetch: An array of SQL tables names. The beans related to those tables will be fetched along
1102
     *          the main table. This is useful to avoid hitting the database with numerous subqueries.
1103
     *  - mode: The fetch mode of the result. See `setFetchMode()` method for more details.
1104
     *
1105
     * The `findObjects` method will return a `ResultIterator`. A `ResultIterator` is an object that behaves as an array
1106
     * (in ARRAY mode) at least. It can be iterated using a `foreach` loop.
1107
     *
1108
     * Finally, if filter_bag is null, the whole table is returned.
1109
     *
1110
     * @param string                       $mainTable             The name of the table queried
1111
     * @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)
1112
     * @param mixed[]                      $parameters
1113
     * @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)
1114
     * @param string[]                     $additionalTablesFetch
1115
     * @param int|null                     $mode
1116
     * @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
1117
     *
1118
     * @return ResultIterator An object representing an array of results
1119
     *
1120
     * @throws TDBMException
1121
     */
1122
    public function findObjects(string $mainTable, $filter = null, array $parameters = array(), $orderString = null, array $additionalTablesFetch = array(), ?int $mode = null, string $className = null) : ResultIterator
1123
    {
1124
        // $mainTable is not secured in MagicJoin, let's add a bit of security to avoid SQL injection.
1125
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $mainTable)) {
1126
            throw new TDBMException(sprintf("Invalid table name: '%s'", $mainTable));
1127
        }
1128
1129
        $mode = $mode ?: $this->mode;
1130
1131
        // We quote in MySQL because MagicJoin requires MySQL style quotes
1132
        $mysqlPlatform = new MySqlPlatform();
1133
        list($filterString, $additionalParameters) = $this->buildFilterFromFilterBag($filter, $mysqlPlatform);
1134
1135
        $parameters = array_merge($parameters, $additionalParameters);
1136
1137
        $queryFactory = new FindObjectsQueryFactory($mainTable, $additionalTablesFetch, $filterString, $orderString, $this, $this->tdbmSchemaAnalyzer->getSchema(), $this->orderByAnalyzer);
1138
1139
        return new ResultIterator($queryFactory, $parameters, $this->objectStorage, $className, $this, $this->magicQuery, $mode, $this->logger);
1140
    }
1141
1142
    /**
1143
     * @param string                       $mainTable   The name of the table queried
1144
     * @param string                       $from        The from sql statement
1145
     * @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)
1146
     * @param mixed[]                      $parameters
1147
     * @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)
1148
     * @param int                          $mode
1149
     * @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
1150
     *
1151
     * @return ResultIterator An object representing an array of results
1152
     *
1153
     * @throws TDBMException
1154
     */
1155
    public function findObjectsFromSql(string $mainTable, string $from, $filter = null, array $parameters = array(), $orderString = null, ?int $mode = null, string $className = null): ResultIterator
1156
    {
1157
        // $mainTable is not secured in MagicJoin, let's add a bit of security to avoid SQL injection.
1158
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $mainTable)) {
1159
            throw new TDBMException(sprintf("Invalid table name: '%s'", $mainTable));
1160
        }
1161
1162
        $mode = $mode ?: $this->mode;
1163
1164
        // We quote in MySQL because MagicJoin requires MySQL style quotes
1165
        $mysqlPlatform = new MySqlPlatform();
1166
        list($filterString, $additionalParameters) = $this->buildFilterFromFilterBag($filter, $mysqlPlatform);
1167
1168
        $parameters = array_merge($parameters, $additionalParameters);
1169
1170
        $queryFactory = new FindObjectsFromSqlQueryFactory($mainTable, $from, $filterString, $orderString, $this, $this->tdbmSchemaAnalyzer->getSchema(), $this->orderByAnalyzer, $this->schemaAnalyzer, $this->cache, $this->cachePrefix);
1171
1172
        return new ResultIterator($queryFactory, $parameters, $this->objectStorage, $className, $this, $this->magicQuery, $mode, $this->logger);
1173
    }
1174
1175
    /**
1176
     * @param string $table
1177
     * @param mixed[] $primaryKeys
1178
     * @param string[] $additionalTablesFetch
1179
     * @param bool $lazy Whether to perform lazy loading on this object or not
1180
     * @param string $className
1181
     *
1182
     * @return AbstractTDBMObject
1183
     *
1184
     * @throws TDBMException
1185
     */
1186
    public function findObjectByPk(string $table, array $primaryKeys, array $additionalTablesFetch = array(), bool $lazy = false, string $className = null): AbstractTDBMObject
1187
    {
1188
        $primaryKeys = $this->_getPrimaryKeysFromObjectData($table, $primaryKeys);
1189
        $hash = $this->getObjectHash($primaryKeys);
1190
1191
        $dbRow = $this->objectStorage->get($table, $hash);
1192
        if ($dbRow !== null) {
1193
            $bean = $dbRow->getTDBMObject();
1194
            if ($className !== null && !is_a($bean, $className)) {
1195
                throw new TDBMException("TDBM cannot create a bean of class '".$className."'. The requested object was already loaded and its class is '".get_class($bean)."'");
1196
            }
1197
1198
            return $bean;
1199
        }
1200
1201
        // Are we performing lazy fetching?
1202
        if ($lazy === true) {
1203
            // Can we perform lazy fetching?
1204
            $tables = $this->_getRelatedTablesByInheritance($table);
1205
            // Only allowed if no inheritance.
1206
            if (count($tables) === 1) {
1207
                if ($className === null) {
1208
                    try {
1209
                        $className = $this->getBeanClassName($table);
1210
                    } catch (TDBMInvalidArgumentException $e) {
1211
                        $className = TDBMObject::class;
1212
                    }
1213
                }
1214
1215
                // Let's construct the bean
1216
                if (!isset($this->reflectionClassCache[$className])) {
1217
                    $this->reflectionClassCache[$className] = new \ReflectionClass($className);
1218
                }
1219
                // Let's bypass the constructor when creating the bean!
1220
                /** @var AbstractTDBMObject */
1221
                $bean = $this->reflectionClassCache[$className]->newInstanceWithoutConstructor();
1222
                $bean->_constructLazy($table, $primaryKeys, $this);
1223
1224
                return $bean;
1225
            }
1226
        }
1227
1228
        // Did not find the object in cache? Let's query it!
1229
        try {
1230
            return $this->findObjectOrFail($table, $primaryKeys, [], $additionalTablesFetch, $className);
1231
        } catch (NoBeanFoundException $exception) {
1232
            $primaryKeysStringified = implode(' and ', array_map(function ($key, $value) {
1233
                return "'".$key."' = ".$value;
1234
            }, array_keys($primaryKeys), $primaryKeys));
1235
            throw new NoBeanFoundException("No result found for query on table '".$table."' for ".$primaryKeysStringified, 0, $exception);
1236
        }
1237
    }
1238
1239
    /**
1240
     * Returns a unique bean (or null) according to the filters passed in parameter.
1241
     *
1242
     * @param string            $mainTable             The name of the table queried
1243
     * @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)
1244
     * @param mixed[]           $parameters
1245
     * @param string[]          $additionalTablesFetch
1246
     * @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
1247
     *
1248
     * @return AbstractTDBMObject|null The object we want, or null if no object matches the filters
1249
     *
1250
     * @throws TDBMException
1251
     */
1252
    public function findObject(string $mainTable, $filter = null, array $parameters = array(), array $additionalTablesFetch = array(), string $className = null) : ?AbstractTDBMObject
1253
    {
1254
        $objects = $this->findObjects($mainTable, $filter, $parameters, null, $additionalTablesFetch, self::MODE_ARRAY, $className);
1255
        return $this->getAtMostOneObjectOrFail($objects, $mainTable, $filter, $parameters);
1256
    }
1257
1258
    /**
1259
     * @param string|array|null $filter
1260
     * @param mixed[]           $parameters
1261
     */
1262
    private function getAtMostOneObjectOrFail(ResultIterator $objects, string $mainTable, $filter, array $parameters): ?AbstractTDBMObject
1263
    {
1264
        $page = $objects->take(0, 2);
1265
1266
1267
        $pageArr = $page->toArray();
1268
        // Optimisation: the $page->count() query can trigger an additional SQL query in platforms other than MySQL.
1269
        // We try to avoid calling at by fetching all 2 columns instead.
1270
        $count = count($pageArr);
1271
1272
        if ($count > 1) {
1273
            $additionalErrorInfos = '';
1274
            if (is_string($filter) && !empty($parameters)) {
1275
                $additionalErrorInfos = ' for filter "' . $filter.'"';
1276
                foreach ($parameters as $fieldName => $parameter) {
1277
                    if (is_array($parameter)) {
1278
                        $value = '(' . implode(',', $parameter) . ')';
1279
                    } else {
1280
                        $value = $parameter;
1281
                    }
1282
                    $additionalErrorInfos = str_replace(':' . $fieldName, var_export($value, true), $additionalErrorInfos);
1283
                }
1284
            }
1285
            $additionalErrorInfos .= '.';
1286
            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);
1287
        } elseif ($count === 0) {
1288
            return null;
1289
        }
1290
1291
        return $pageArr[0];
1292
    }
1293
1294
    /**
1295
     * Returns a unique bean (or null) according to the filters passed in parameter.
1296
     *
1297
     * @param string            $mainTable  The name of the table queried
1298
     * @param string            $from       The from sql statement
1299
     * @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)
1300
     * @param mixed[]           $parameters
1301
     * @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
1302
     *
1303
     * @return AbstractTDBMObject|null The object we want, or null if no object matches the filters
1304
     *
1305
     * @throws TDBMException
1306
     */
1307
    public function findObjectFromSql(string $mainTable, string $from, $filter = null, array $parameters = array(), ?string $className = null) : ?AbstractTDBMObject
1308
    {
1309
        $objects = $this->findObjectsFromSql($mainTable, $from, $filter, $parameters, null, self::MODE_ARRAY, $className);
1310
        return $this->getAtMostOneObjectOrFail($objects, $mainTable, $filter, $parameters);
1311
    }
1312
1313
    /**
1314
     * @param string $mainTable
1315
     * @param string $sql
1316
     * @param mixed[] $parameters
1317
     * @param int|null $mode
1318
     * @param string|null $className
1319
     * @param string $sqlCount
1320
     *
1321
     * @return ResultIterator
1322
     *
1323
     * @throws TDBMException
1324
     */
1325
    public function findObjectsFromRawSql(string $mainTable, string $sql, array $parameters = array(), ?int $mode = null, string $className = null, string $sqlCount = null): ResultIterator
1326
    {
1327
        // $mainTable is not secured in MagicJoin, let's add a bit of security to avoid SQL injection.
1328
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $mainTable)) {
1329
            throw new TDBMException(sprintf("Invalid table name: '%s'", $mainTable));
1330
        }
1331
1332
        $mode = $mode ?: $this->mode;
1333
1334
        $queryFactory = new FindObjectsFromRawSqlQueryFactory($this, $this->tdbmSchemaAnalyzer->getSchema(), $mainTable, $sql, $sqlCount);
1335
1336
        return new ResultIterator($queryFactory, $parameters, $this->objectStorage, $className, $this, $this->magicQuery, $mode, $this->logger);
1337
    }
1338
1339
    /**
1340
     * Returns a unique bean according to the filters passed in parameter.
1341
     * Throws a NoBeanFoundException if no bean was found for the filter passed in parameter.
1342
     *
1343
     * @param string            $mainTable             The name of the table queried
1344
     * @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)
1345
     * @param mixed[]           $parameters
1346
     * @param string[]          $additionalTablesFetch
1347
     * @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
1348
     *
1349
     * @return AbstractTDBMObject The object we want
1350
     *
1351
     * @throws TDBMException
1352
     */
1353
    public function findObjectOrFail(string $mainTable, $filter = null, array $parameters = array(), array $additionalTablesFetch = array(), string $className = null): AbstractTDBMObject
1354
    {
1355
        $bean = $this->findObject($mainTable, $filter, $parameters, $additionalTablesFetch, $className);
1356
        if ($bean === null) {
1357
            throw new NoBeanFoundException("No result found for query on table '".$mainTable."'");
1358
        }
1359
1360
        return $bean;
1361
    }
1362
1363
    /**
1364
     * @param array<string, array> $beanData An array of data: array<table, array<column, value>>
1365
     *
1366
     * @return mixed[] an array with first item = class name, second item = table name and third item = list of tables needed
1367
     *
1368
     * @throws TDBMInheritanceException
1369
     */
1370
    public function _getClassNameFromBeanData(array $beanData): array
1371
    {
1372
        if (count($beanData) === 1) {
1373
            $tableName = (string) array_keys($beanData)[0];
1374
            $allTables = [$tableName];
1375
        } else {
1376
            $tables = [];
1377
            foreach ($beanData as $table => $row) {
1378
                $primaryKeyColumns = $this->getPrimaryKeyColumns($table);
1379
                $pkSet = false;
1380
                foreach ($primaryKeyColumns as $columnName) {
1381
                    if ($row[$columnName] !== null) {
1382
                        $pkSet = true;
1383
                        break;
1384
                    }
1385
                }
1386
                if ($pkSet) {
1387
                    $tables[] = $table;
1388
                }
1389
            }
1390
1391
            // $tables contains the tables for this bean. Let's view the top most part of the hierarchy
1392
            try {
1393
                $allTables = $this->_getLinkBetweenInheritedTables($tables);
1394
            } catch (TDBMInheritanceException $e) {
1395
                throw TDBMInheritanceException::extendException($e, $this, $beanData);
1396
            }
1397
            $tableName = $allTables[0];
1398
        }
1399
1400
        // Only one table in this bean. Life is sweat, let's look at its type:
1401
        try {
1402
            $className = $this->getBeanClassName($tableName);
1403
        } catch (TDBMInvalidArgumentException $e) {
1404
            $className = 'TheCodingMachine\\TDBM\\TDBMObject';
1405
        }
1406
1407
        return [$className, $tableName, $allTables];
1408
    }
1409
1410
    /**
1411
     * Returns an item from cache or computes it using $closure and puts it in cache.
1412
     *
1413
     * @param string   $key
1414
     * @param callable $closure
1415
     *
1416
     * @return mixed
1417
     */
1418
    private function fromCache(string $key, callable $closure)
1419
    {
1420
        $item = $this->cache->fetch($key);
1421
        if ($item === false) {
1422
            $item = $closure();
1423
            $result = $this->cache->save($key, $item);
1424
1425
            if ($result === false) {
1426
                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.');
1427
            }
1428
        }
1429
1430
        return $item;
1431
    }
1432
1433
    /**
1434
     * @param string $pivotTableName
1435
     * @param AbstractTDBMObject $bean
1436
     *
1437
     * @return AbstractTDBMObject[]|ResultIterator
1438
     */
1439
    public function _getRelatedBeans(string $pivotTableName, AbstractTDBMObject $bean): ResultIterator
1440
    {
1441
        list($localFk, $remoteFk) = $this->getPivotTableForeignKeys($pivotTableName, $bean);
1442
        /* @var $localFk ForeignKeyConstraint */
1443
        /* @var $remoteFk ForeignKeyConstraint */
1444
        $remoteTable = $remoteFk->getForeignTableName();
1445
1446
        $primaryKeys = $this->getPrimaryKeyValues($bean);
1447
        $columnNames = array_map(function ($name) use ($pivotTableName) {
1448
            return $pivotTableName.'.'.$name;
1449
        }, $localFk->getUnquotedLocalColumns());
1450
1451
        $filter = SafeFunctions::arrayCombine($columnNames, $primaryKeys);
1452
1453
        return $this->findObjects($remoteTable, $filter);
1454
    }
1455
1456
    /**
1457
     * @param string $pivotTableName
1458
     * @param AbstractTDBMObject $bean The LOCAL bean
1459
     *
1460
     * @return ForeignKeyConstraint[] First item: the LOCAL bean, second item: the REMOTE bean
1461
     *
1462
     * @throws TDBMException
1463
     */
1464
    private function getPivotTableForeignKeys(string $pivotTableName, AbstractTDBMObject $bean): array
1465
    {
1466
        $fks = array_values($this->tdbmSchemaAnalyzer->getSchema()->getTable($pivotTableName)->getForeignKeys());
1467
        $table1 = $fks[0]->getForeignTableName();
1468
        $table2 = $fks[1]->getForeignTableName();
1469
1470
        $beanTables = array_map(function (DbRow $dbRow) {
1471
            return $dbRow->_getDbTableName();
1472
        }, $bean->_getDbRows());
1473
1474
        if (in_array($table1, $beanTables)) {
1475
            return [$fks[0], $fks[1]];
1476
        } elseif (in_array($table2, $beanTables)) {
1477
            return [$fks[1], $fks[0]];
1478
        } else {
1479
            throw new TDBMException("Unexpected bean type in getPivotTableForeignKeys. Awaiting beans from table {$table1} and {$table2} for pivot table {$pivotTableName}");
1480
        }
1481
    }
1482
1483
    /**
1484
     * Returns a list of pivot tables linked to $bean.
1485
     *
1486
     * @param AbstractTDBMObject $bean
1487
     *
1488
     * @return string[]
1489
     */
1490
    public function _getPivotTablesLinkedToBean(AbstractTDBMObject $bean): array
1491
    {
1492
        $junctionTables = [];
1493
        $allJunctionTables = $this->schemaAnalyzer->detectJunctionTables(true);
1494
        foreach ($bean->_getDbRows() as $dbRow) {
1495
            foreach ($allJunctionTables as $table) {
1496
                // There are exactly 2 FKs since this is a pivot table.
1497
                $fks = array_values($table->getForeignKeys());
1498
1499
                if ($fks[0]->getForeignTableName() === $dbRow->_getDbTableName() || $fks[1]->getForeignTableName() === $dbRow->_getDbTableName()) {
1500
                    $junctionTables[] = $table->getName();
1501
                }
1502
            }
1503
        }
1504
1505
        return $junctionTables;
1506
    }
1507
1508
    /**
1509
     * Array of types for tables.
1510
     * Key: table name
1511
     * Value: array of types indexed by column.
1512
     *
1513
     * @var array[]
1514
     */
1515
    private $typesForTable = [];
1516
1517
    /**
1518
     * @internal
1519
     *
1520
     * @param string $tableName
1521
     *
1522
     * @return Type[]
1523
     */
1524
    public function _getColumnTypesForTable(string $tableName): array
1525
    {
1526
        if (!isset($this->typesForTable[$tableName])) {
1527
            $columns = $this->tdbmSchemaAnalyzer->getSchema()->getTable($tableName)->getColumns();
1528
            foreach ($columns as $column) {
1529
                $this->typesForTable[$tableName][$column->getName()] = $column->getType();
1530
            }
1531
        }
1532
1533
        return $this->typesForTable[$tableName];
1534
    }
1535
1536
    /**
1537
     * Sets the minimum log level.
1538
     * $level must be one of Psr\Log\LogLevel::xxx.
1539
     *
1540
     * Defaults to LogLevel::WARNING
1541
     *
1542
     * @param string $level
1543
     */
1544
    public function setLogLevel(string $level): void
1545
    {
1546
        $this->logger = new LevelFilter($this->rootLogger, $level);
1547
    }
1548
}
1549