Completed
Push — master ( 888f28...18f4b4 )
by David
14s queued 10s
created

TDBMService::setFetchMode()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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