vendor/ibexa/core/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabase.php line 75

Open in your IDE?
  1. <?php
  2. /**
  3.  * @copyright Copyright (C) Ibexa AS. All rights reserved.
  4.  * @license For full copyright and license information view LICENSE file distributed with this source code.
  5.  */
  6. declare(strict_types=1);
  7. namespace Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway;
  8. use Doctrine\DBAL\Connection;
  9. use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
  10. use Doctrine\DBAL\FetchMode;
  11. use Doctrine\DBAL\ParameterType;
  12. use Ibexa\Core\Base\Exceptions\BadStateException;
  13. use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator;
  14. use Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway;
  15. use RuntimeException;
  16. /**
  17.  * UrlAlias gateway implementation using the Doctrine database.
  18.  *
  19.  * @internal Gateway implementation is considered internal. Use Persistence UrlAlias Handler instead.
  20.  *
  21.  * @see \Ibexa\Contracts\Core\Persistence\Content\UrlAlias\Handler
  22.  */
  23. final class DoctrineDatabase extends Gateway
  24. {
  25.     /**
  26.      * 2^30, since PHP_INT_MAX can cause overflows in DB systems, if PHP is run
  27.      * on 64 bit systems.
  28.      */
  29.     public const MAX_LIMIT 1073741824;
  30.     private const URL_ALIAS_DATA_COLUMN_TYPE_MAP = [
  31.         'id' => ParameterType::INTEGER,
  32.         'link' => ParameterType::INTEGER,
  33.         'is_alias' => ParameterType::INTEGER,
  34.         'alias_redirects' => ParameterType::INTEGER,
  35.         'is_original' => ParameterType::INTEGER,
  36.         'action' => ParameterType::STRING,
  37.         'action_type' => ParameterType::STRING,
  38.         'lang_mask' => ParameterType::INTEGER,
  39.         'text' => ParameterType::STRING,
  40.         'parent' => ParameterType::INTEGER,
  41.         'text_md5' => ParameterType::STRING,
  42.     ];
  43.     /** @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator */
  44.     private $languageMaskGenerator;
  45.     /**
  46.      * Main URL database table name.
  47.      *
  48.      * @var string
  49.      */
  50.     private $table;
  51.     /** @var \Doctrine\DBAL\Connection */
  52.     private $connection;
  53.     /** @var \Doctrine\DBAL\Platforms\AbstractPlatform */
  54.     private $dbPlatform;
  55.     /**
  56.      * @throws \Doctrine\DBAL\DBALException
  57.      */
  58.     public function __construct(
  59.         Connection $connection,
  60.         LanguageMaskGenerator $languageMaskGenerator
  61.     ) {
  62.         $this->connection $connection;
  63.         $this->languageMaskGenerator $languageMaskGenerator;
  64.         $this->table = static::TABLE;
  65.         $this->dbPlatform $this->connection->getDatabasePlatform();
  66.     }
  67.     public function setTable(string $name): void
  68.     {
  69.         $this->table $name;
  70.     }
  71.     /**
  72.      * Loads all list of aliases by given $locationId.
  73.      */
  74.     public function loadAllLocationEntries(int $locationId): array
  75.     {
  76.         $query $this->connection->createQueryBuilder();
  77.         $query
  78.             ->select(array_keys(self::URL_ALIAS_DATA_COLUMN_TYPE_MAP))
  79.             ->from($this->connection->quoteIdentifier($this->table))
  80.             ->where('action = :action')
  81.             ->andWhere('is_original = :is_original')
  82.             ->setParameter('action'"eznode:{$locationId}"ParameterType::STRING)
  83.             ->setParameter('is_original'1ParameterType::INTEGER);
  84.         return $query->execute()->fetchAll(FetchMode::ASSOCIATIVE);
  85.     }
  86.     public function loadLocationEntries(
  87.         int $locationId,
  88.         bool $custom false,
  89.         ?int $languageId null
  90.     ): array {
  91.         $query $this->connection->createQueryBuilder();
  92.         $expr $query->expr();
  93.         $query
  94.             ->select(
  95.                 'id',
  96.                 'link',
  97.                 'is_alias',
  98.                 'alias_redirects',
  99.                 'lang_mask',
  100.                 'is_original',
  101.                 'parent',
  102.                 'text',
  103.                 'text_md5',
  104.                 'action'
  105.             )
  106.             ->from($this->connection->quoteIdentifier($this->table))
  107.             ->where(
  108.                 $expr->eq(
  109.                     'action',
  110.                     $query->createPositionalParameter(
  111.                         "eznode:{$locationId}",
  112.                         ParameterType::STRING
  113.                     )
  114.                 )
  115.             )
  116.             ->andWhere(
  117.                 $expr->eq(
  118.                     'is_original',
  119.                     $query->createPositionalParameter(1ParameterType::INTEGER)
  120.                 )
  121.             )
  122.             ->andWhere(
  123.                 $expr->eq(
  124.                     'is_alias',
  125.                     $query->createPositionalParameter($custom 0ParameterType::INTEGER)
  126.                 )
  127.             )
  128.         ;
  129.         if (null !== $languageId) {
  130.             $query->andWhere(
  131.                 $expr->gt(
  132.                     $this->dbPlatform->getBitAndComparisonExpression(
  133.                         'lang_mask',
  134.                         $query->createPositionalParameter($languageIdParameterType::INTEGER)
  135.                     ),
  136.                     0
  137.                 )
  138.             );
  139.         }
  140.         $statement $query->execute();
  141.         return $statement->fetchAll(FetchMode::ASSOCIATIVE);
  142.     }
  143.     public function listGlobalEntries(
  144.         ?string $languageCode null,
  145.         int $offset 0,
  146.         int $limit = -1
  147.     ): array {
  148.         $limit $limit === -self::MAX_LIMIT $limit;
  149.         $query $this->connection->createQueryBuilder();
  150.         $expr $query->expr();
  151.         $query
  152.             ->select(
  153.                 'action',
  154.                 'id',
  155.                 'link',
  156.                 'is_alias',
  157.                 'alias_redirects',
  158.                 'lang_mask',
  159.                 'is_original',
  160.                 'parent',
  161.                 'text_md5'
  162.             )
  163.             ->from($this->connection->quoteIdentifier($this->table))
  164.             ->where(
  165.                 $expr->eq(
  166.                     'action_type',
  167.                     $query->createPositionalParameter(
  168.                         'module',
  169.                         ParameterType::STRING
  170.                     )
  171.                 )
  172.             )
  173.             ->andWhere(
  174.                 $expr->eq(
  175.                     'is_original',
  176.                     $query->createPositionalParameter(1ParameterType::INTEGER)
  177.                 )
  178.             )
  179.             ->andWhere(
  180.                 $expr->eq(
  181.                     'is_alias',
  182.                     $query->createPositionalParameter(1ParameterType::INTEGER)
  183.                 )
  184.             )
  185.             ->setMaxResults(
  186.                 $limit
  187.             )
  188.             ->setFirstResult($offset);
  189.         if (isset($languageCode)) {
  190.             $query->andWhere(
  191.                 $expr->gt(
  192.                     $this->dbPlatform->getBitAndComparisonExpression(
  193.                         'lang_mask',
  194.                         $query->createPositionalParameter(
  195.                             $this->languageMaskGenerator->generateLanguageIndicator(
  196.                                 $languageCode,
  197.                                 false
  198.                             ),
  199.                             ParameterType::INTEGER
  200.                         )
  201.                     ),
  202.                     0
  203.                 )
  204.             );
  205.         }
  206.         $statement $query->execute();
  207.         return $statement->fetchAll(FetchMode::ASSOCIATIVE);
  208.     }
  209.     public function isRootEntry(int $id): bool
  210.     {
  211.         $query $this->connection->createQueryBuilder();
  212.         $query
  213.             ->select(
  214.                 'text',
  215.                 'parent'
  216.             )
  217.             ->from($this->connection->quoteIdentifier($this->table))
  218.             ->where(
  219.                 $query->expr()->eq(
  220.                     'id',
  221.                     $query->createPositionalParameter($idParameterType::INTEGER)
  222.                 )
  223.             );
  224.         $statement $query->execute();
  225.         $row $statement->fetch(FetchMode::ASSOCIATIVE);
  226.         return strlen($row['text']) == && $row['parent'] == 0;
  227.     }
  228.     public function cleanupAfterPublish(
  229.         string $action,
  230.         int $languageId,
  231.         int $newId,
  232.         int $parentId,
  233.         string $textMD5
  234.     ): void {
  235.         $query $this->connection->createQueryBuilder();
  236.         $expr $query->expr();
  237.         $query
  238.             ->select(
  239.                 'parent',
  240.                 'text_md5',
  241.                 'lang_mask'
  242.             )
  243.             ->from($this->connection->quoteIdentifier($this->table))
  244.             // 1) Autogenerated aliases that match action and language...
  245.             ->where(
  246.                 $expr->eq(
  247.                     'action',
  248.                     $query->createPositionalParameter($actionParameterType::STRING)
  249.                 )
  250.             )
  251.             ->andWhere(
  252.                 $expr->eq(
  253.                     'is_original',
  254.                     $query->createPositionalParameter(1ParameterType::INTEGER)
  255.                 )
  256.             )
  257.             ->andWhere(
  258.                 $expr->eq(
  259.                     'is_alias',
  260.                     $query->createPositionalParameter(0ParameterType::INTEGER)
  261.                 )
  262.             )
  263.             ->andWhere(
  264.                 $expr->gt(
  265.                     $this->dbPlatform->getBitAndComparisonExpression(
  266.                         'lang_mask',
  267.                         $query->createPositionalParameter($languageIdParameterType::INTEGER)
  268.                     ),
  269.                     0
  270.                 )
  271.             )
  272.             // 2) ...but not newly published entry
  273.             ->andWhere(
  274.                 sprintf(
  275.                     'NOT (%s)',
  276.                     $expr->andX(
  277.                         $expr->eq(
  278.                             'parent',
  279.                             $query->createPositionalParameter($parentIdParameterType::INTEGER)
  280.                         ),
  281.                         $expr->eq(
  282.                             'text_md5',
  283.                             $query->createPositionalParameter($textMD5ParameterType::STRING)
  284.                         )
  285.                     )
  286.                 )
  287.             );
  288.         $statement $query->execute();
  289.         $row $statement->fetch(FetchMode::ASSOCIATIVE);
  290.         if (!empty($row)) {
  291.             $this->archiveUrlAliasForDeletedTranslation(
  292.                 (int)$row['lang_mask'],
  293.                 (int)$languageId,
  294.                 (int)$row['parent'],
  295.                 $row['text_md5'],
  296.                 (int)$newId
  297.             );
  298.         }
  299.     }
  300.     /**
  301.      * Archive (remove or historize) obsolete URL aliases (for translations that were removed).
  302.      *
  303.      * @param int $languageMask all languages bit mask
  304.      * @param int $languageId removed language Id
  305.      * @param string $textMD5 checksum
  306.      */
  307.     private function archiveUrlAliasForDeletedTranslation(
  308.         int $languageMask,
  309.         int $languageId,
  310.         int $parent,
  311.         string $textMD5,
  312.         int $linkId
  313.     ): void {
  314.         // If language mask is composite (consists of multiple languages) then remove given language from entry
  315.         if ($languageMask & ~($languageId 1)) {
  316.             $this->removeTranslation($parent$textMD5$languageId);
  317.         } else {
  318.             // Otherwise mark entry as history
  319.             $this->historize($parent$textMD5$linkId);
  320.         }
  321.     }
  322.     public function historizeBeforeSwap(string $actionint $languageMask): void
  323.     {
  324.         $query $this->connection->createQueryBuilder();
  325.         $query
  326.             ->update($this->connection->quoteIdentifier($this->table))
  327.             ->set(
  328.                 'is_original',
  329.                 $query->createPositionalParameter(0ParameterType::INTEGER)
  330.             )
  331.             ->set(
  332.                 'id',
  333.                 $query->createPositionalParameter(
  334.                     $this->getNextId(),
  335.                     ParameterType::INTEGER
  336.                 )
  337.             )
  338.             ->where(
  339.                 $query->expr()->andX(
  340.                     $query->expr()->eq(
  341.                         'action',
  342.                         $query->createPositionalParameter($actionParameterType::STRING)
  343.                     ),
  344.                     $query->expr()->eq(
  345.                         'is_original',
  346.                         $query->createPositionalParameter(1ParameterType::INTEGER)
  347.                     ),
  348.                     $query->expr()->gt(
  349.                         $this->dbPlatform->getBitAndComparisonExpression(
  350.                             'lang_mask',
  351.                             $query->createPositionalParameter(
  352.                                 $languageMask & ~1,
  353.                                 ParameterType::INTEGER
  354.                             )
  355.                         ),
  356.                         0
  357.                     )
  358.                 )
  359.             );
  360.         $query->execute();
  361.     }
  362.     /**
  363.      * Update single row matched by composite primary key.
  364.      *
  365.      * Sets "is_original" to 0 thus marking entry as history.
  366.      *
  367.      * Re-links history entries.
  368.      *
  369.      * When location alias is published we need to check for new history entries created with self::downgrade()
  370.      * with the same action and language, update their "link" column with id of the published entry.
  371.      * History entry "id" column is moved to next id value so that all active (non-history) entries are kept
  372.      * under the same id.
  373.      */
  374.     private function historize(int $parentIdstring $textMD5int $newId): void
  375.     {
  376.         $query $this->connection->createQueryBuilder();
  377.         $query
  378.             ->update($this->connection->quoteIdentifier($this->table))
  379.             ->set(
  380.                 'is_original',
  381.                 $query->createPositionalParameter(0ParameterType::INTEGER)
  382.             )
  383.             ->set(
  384.                 'link',
  385.                 $query->createPositionalParameter($newIdParameterType::INTEGER)
  386.             )
  387.             ->set(
  388.                 'id',
  389.                 $query->createPositionalParameter(
  390.                     $this->getNextId(),
  391.                     ParameterType::INTEGER
  392.                 )
  393.             )
  394.             ->where(
  395.                 $query->expr()->andX(
  396.                     $query->expr()->eq(
  397.                         'parent',
  398.                         $query->createPositionalParameter($parentIdParameterType::INTEGER)
  399.                     ),
  400.                     $query->expr()->eq(
  401.                         'text_md5',
  402.                         $query->createPositionalParameter($textMD5ParameterType::STRING)
  403.                     )
  404.                 )
  405.             );
  406.         $query->execute();
  407.     }
  408.     /**
  409.      * Update single row data matched by composite primary key.
  410.      *
  411.      * Removes given $languageId from entry's language mask
  412.      */
  413.     private function removeTranslation(int $parentIdstring $textMD5int $languageId): void
  414.     {
  415.         $query $this->connection->createQueryBuilder();
  416.         $query
  417.             ->update($this->connection->quoteIdentifier($this->table))
  418.             ->set(
  419.                 'lang_mask',
  420.                 $this->dbPlatform->getBitAndComparisonExpression(
  421.                     'lang_mask',
  422.                     $query->createPositionalParameter(
  423.                         ~$languageId,
  424.                         ParameterType::INTEGER
  425.                     )
  426.                 )
  427.             )
  428.             ->where(
  429.                 $query->expr()->eq(
  430.                     'parent',
  431.                     $query->createPositionalParameter(
  432.                         $parentId,
  433.                         ParameterType::INTEGER
  434.                     )
  435.                 )
  436.             )
  437.             ->andWhere(
  438.                 $query->expr()->eq(
  439.                     'text_md5',
  440.                     $query->createPositionalParameter(
  441.                         $textMD5,
  442.                         ParameterType::STRING
  443.                     )
  444.                 )
  445.             )
  446.         ;
  447.         $query->execute();
  448.     }
  449.     public function historizeId(int $idint $link): void
  450.     {
  451.         if ($id === $link) {
  452.             return;
  453.         }
  454.         $query $this->connection->createQueryBuilder();
  455.         $query->select(
  456.             'parent',
  457.             'text_md5'
  458.         )->from(
  459.             $this->connection->quoteIdentifier($this->table)
  460.         )->where(
  461.             $query->expr()->andX(
  462.                 $query->expr()->eq(
  463.                     'is_alias',
  464.                     $query->createPositionalParameter(0ParameterType::INTEGER)
  465.                 ),
  466.                 $query->expr()->eq(
  467.                     'is_original',
  468.                     $query->createPositionalParameter(1ParameterType::INTEGER)
  469.                 ),
  470.                 $query->expr()->eq(
  471.                     'action_type',
  472.                     $query->createPositionalParameter(
  473.                         'eznode',
  474.                         ParameterType::STRING
  475.                     )
  476.                 ),
  477.                 $query->expr()->eq(
  478.                     'link',
  479.                     $query->createPositionalParameter($idParameterType::INTEGER)
  480.                 )
  481.             )
  482.         );
  483.         $statement $query->execute();
  484.         $rows $statement->fetchAll(FetchMode::ASSOCIATIVE);
  485.         foreach ($rows as $row) {
  486.             $this->historize((int)$row['parent'], $row['text_md5'], $link);
  487.         }
  488.     }
  489.     public function reparent(int $oldParentIdint $newParentId): void
  490.     {
  491.         $query $this->connection->createQueryBuilder();
  492.         $query->update(
  493.             $this->connection->quoteIdentifier($this->table)
  494.         )->set(
  495.             'parent',
  496.             $query->createPositionalParameter($newParentIdParameterType::INTEGER)
  497.         )->where(
  498.             $query->expr()->eq(
  499.                 'parent',
  500.                 $query->createPositionalParameter(
  501.                     $oldParentId,
  502.                     ParameterType::INTEGER
  503.                 )
  504.             )
  505.         );
  506.         $query->execute();
  507.     }
  508.     public function updateRow(int $parentIdstring $textMD5, array $values): void
  509.     {
  510.         $query $this->connection->createQueryBuilder();
  511.         $query->update($this->connection->quoteIdentifier($this->table));
  512.         foreach ($values as $columnName => $value) {
  513.             $query->set(
  514.                 $columnName,
  515.                 $query->createNamedParameter(
  516.                     $value,
  517.                     self::URL_ALIAS_DATA_COLUMN_TYPE_MAP[$columnName],
  518.                     ":{$columnName}"
  519.                 )
  520.             );
  521.         }
  522.         $query
  523.             ->where(
  524.                 $query->expr()->eq(
  525.                     'parent',
  526.                     $query->createNamedParameter($parentIdParameterType::INTEGER':parent')
  527.                 )
  528.             )
  529.             ->andWhere(
  530.                 $query->expr()->eq(
  531.                     'text_md5',
  532.                     $query->createNamedParameter($textMD5ParameterType::STRING':text_md5')
  533.                 )
  534.             );
  535.         $query->execute();
  536.     }
  537.     public function insertRow(array $values): int
  538.     {
  539.         if (!isset($values['id'])) {
  540.             $values['id'] = $this->getNextId();
  541.         }
  542.         if (!isset($values['link'])) {
  543.             $values['link'] = $values['id'];
  544.         }
  545.         if (!isset($values['is_original'])) {
  546.             $values['is_original'] = ($values['id'] == $values['link'] ? 0);
  547.         }
  548.         if (!isset($values['is_alias'])) {
  549.             $values['is_alias'] = 0;
  550.         }
  551.         if (!isset($values['alias_redirects'])) {
  552.             $values['alias_redirects'] = 0;
  553.         }
  554.         if (
  555.             !isset($values['action_type'])
  556.             && preg_match('#^(.+):.*#'$values['action'], $matches)
  557.         ) {
  558.             $values['action_type'] = $matches[1];
  559.         }
  560.         if ($values['is_alias']) {
  561.             $values['is_original'] = 1;
  562.         }
  563.         if ($values['action'] === self::NOP_ACTION) {
  564.             $values['is_original'] = 0;
  565.         }
  566.         $query $this->connection->createQueryBuilder();
  567.         $query->insert($this->connection->quoteIdentifier($this->table));
  568.         foreach ($values as $columnName => $value) {
  569.             $query->setValue(
  570.                 $columnName,
  571.                 $query->createNamedParameter(
  572.                     $value,
  573.                     self::URL_ALIAS_DATA_COLUMN_TYPE_MAP[$columnName],
  574.                     ":{$columnName}"
  575.                 )
  576.             );
  577.         }
  578.         $query->execute();
  579.         return (int)$values['id'];
  580.     }
  581.     public function getNextId(): int
  582.     {
  583.         $query $this->connection->createQueryBuilder();
  584.         $query
  585.             ->insert(self::INCR_TABLE)
  586.             ->values(
  587.                 [
  588.                     'id' => $this->dbPlatform->supportsSequences()
  589.                         ? sprintf('NEXTVAL(\'%s\')'self::INCR_TABLE_SEQ)
  590.                         : $query->createPositionalParameter(nullParameterType::NULL),
  591.                 ]
  592.             );
  593.         $query->execute();
  594.         return (int)$this->connection->lastInsertId(self::INCR_TABLE_SEQ);
  595.     }
  596.     public function loadRow(int $parentIdstring $textMD5): array
  597.     {
  598.         $query $this->connection->createQueryBuilder();
  599.         $query->select('*')->from(
  600.             $this->connection->quoteIdentifier($this->table)
  601.         )->where(
  602.             $query->expr()->andX(
  603.                 $query->expr()->eq(
  604.                     'parent',
  605.                     $query->createPositionalParameter(
  606.                         $parentId,
  607.                         ParameterType::INTEGER
  608.                     )
  609.                 ),
  610.                 $query->expr()->eq(
  611.                     'text_md5',
  612.                     $query->createPositionalParameter(
  613.                         $textMD5,
  614.                         ParameterType::STRING
  615.                     )
  616.                 )
  617.             )
  618.         );
  619.         $result $query->execute()->fetch(FetchMode::ASSOCIATIVE);
  620.         return false !== $result $result : [];
  621.     }
  622.     public function loadUrlAliasData(array $urlHashes): array
  623.     {
  624.         $query $this->connection->createQueryBuilder();
  625.         $expr $query->expr();
  626.         $count count($urlHashes);
  627.         foreach ($urlHashes as $level => $urlPartHash) {
  628.             $tableAlias $level !== $count $this->table $level 'u';
  629.             $query
  630.                 ->addSelect(
  631.                     array_map(
  632.                         static function (string $columnName) use ($tableAlias) {
  633.                             // do not alias data for top level url part
  634.                             $columnAlias 'u' === $tableAlias
  635.                                 $columnName
  636.                                 "{$tableAlias}_{$columnName}";
  637.                             $columnName "{$tableAlias}.{$columnName}";
  638.                             return "{$columnName} AS {$columnAlias}";
  639.                         },
  640.                         array_keys(self::URL_ALIAS_DATA_COLUMN_TYPE_MAP)
  641.                     )
  642.                 )
  643.                 ->from($this->connection->quoteIdentifier($this->table), $tableAlias);
  644.             $query
  645.                 ->andWhere(
  646.                     $expr->eq(
  647.                         "{$tableAlias}.text_md5",
  648.                         $query->createPositionalParameter($urlPartHashParameterType::STRING)
  649.                     )
  650.                 )
  651.                 ->andWhere(
  652.                     $expr->eq(
  653.                         "{$tableAlias}.parent",
  654.                         // root entry has parent column set to 0
  655.                         isset($previousTableName) ? $previousTableName '.link' $query->createPositionalParameter(
  656.                             0,
  657.                             ParameterType::INTEGER
  658.                         )
  659.                     )
  660.                 );
  661.             $previousTableName $tableAlias;
  662.         }
  663.         $query->setMaxResults(1);
  664.         $result $query->execute()->fetch(FetchMode::ASSOCIATIVE);
  665.         return false !== $result $result : [];
  666.     }
  667.     public function loadAutogeneratedEntry(string $action, ?int $parentId null): array
  668.     {
  669.         $query $this->connection->createQueryBuilder();
  670.         $query->select(
  671.             '*'
  672.         )->from(
  673.             $this->connection->quoteIdentifier($this->table)
  674.         )->where(
  675.             $query->expr()->andX(
  676.                 $query->expr()->eq(
  677.                     'action',
  678.                     $query->createPositionalParameter($actionParameterType::STRING)
  679.                 ),
  680.                 $query->expr()->eq(
  681.                     'is_original',
  682.                     $query->createPositionalParameter(1ParameterType::INTEGER)
  683.                 ),
  684.                 $query->expr()->eq(
  685.                     'is_alias',
  686.                     $query->createPositionalParameter(0ParameterType::INTEGER)
  687.                 )
  688.             )
  689.         );
  690.         if (isset($parentId)) {
  691.             $query->andWhere(
  692.                 $query->expr()->eq(
  693.                     'parent',
  694.                     $query->createPositionalParameter(
  695.                         $parentId,
  696.                         ParameterType::INTEGER
  697.                     )
  698.                 )
  699.             );
  700.         }
  701.         $entry $query->execute()->fetch(FetchMode::ASSOCIATIVE);
  702.         return false !== $entry $entry : [];
  703.     }
  704.     public function loadPathData(int $id): array
  705.     {
  706.         $pathData = [];
  707.         while ($id != 0) {
  708.             $query $this->connection->createQueryBuilder();
  709.             $query->select(
  710.                 'parent',
  711.                 'lang_mask',
  712.                 'text'
  713.             )->from(
  714.                 $this->connection->quoteIdentifier($this->table)
  715.             )->where(
  716.                 $query->expr()->eq(
  717.                     'id',
  718.                     $query->createPositionalParameter($idParameterType::INTEGER)
  719.                 )
  720.             );
  721.             $statement $query->execute();
  722.             $rows $statement->fetchAll(FetchMode::ASSOCIATIVE);
  723.             if (empty($rows)) {
  724.                 // Normally this should never happen
  725.                 $pathDataArray = [];
  726.                 foreach ($pathData as $path) {
  727.                     if (!isset($path[0]['text'])) {
  728.                         continue;
  729.                     }
  730.                     $pathDataArray[] = $path[0]['text'];
  731.                 }
  732.                 $path implode('/'$pathDataArray);
  733.                 throw new BadStateException(
  734.                     'id',
  735.                     "Unable to load path data, path '{$path}' is broken, alias with ID '{$id}' not found. " .
  736.                     'To fix all broken paths run the ezplatform:urls:regenerate-aliases command'
  737.                 );
  738.             }
  739.             $id $rows[0]['parent'];
  740.             array_unshift($pathData$rows);
  741.         }
  742.         return $pathData;
  743.     }
  744.     public function loadPathDataByHierarchy(array $hierarchyData): array
  745.     {
  746.         $query $this->connection->createQueryBuilder();
  747.         $hierarchyConditions = [];
  748.         foreach ($hierarchyData as $levelData) {
  749.             $hierarchyConditions[] = $query->expr()->andX(
  750.                 $query->expr()->eq(
  751.                     'parent',
  752.                     $query->createPositionalParameter(
  753.                         $levelData['parent'],
  754.                         ParameterType::INTEGER
  755.                     )
  756.                 ),
  757.                 $query->expr()->eq(
  758.                     'action',
  759.                     $query->createPositionalParameter(
  760.                         $levelData['action'],
  761.                         ParameterType::STRING
  762.                     )
  763.                 ),
  764.                 $query->expr()->eq(
  765.                     'id',
  766.                     $query->createPositionalParameter(
  767.                         $levelData['id'],
  768.                         ParameterType::INTEGER
  769.                     )
  770.                 )
  771.             );
  772.         }
  773.         $query->select(
  774.             'action',
  775.             'lang_mask',
  776.             'text'
  777.         )->from(
  778.             $this->connection->quoteIdentifier($this->table)
  779.         )->where(
  780.             $query->expr()->orX(...$hierarchyConditions)
  781.         );
  782.         $statement $query->execute();
  783.         $rows $statement->fetchAll(FetchMode::ASSOCIATIVE);
  784.         $rowsMap = [];
  785.         foreach ($rows as $row) {
  786.             $rowsMap[$row['action']][] = $row;
  787.         }
  788.         if (count($rowsMap) !== count($hierarchyData)) {
  789.             throw new RuntimeException('The path is corrupted.');
  790.         }
  791.         $data = [];
  792.         foreach ($hierarchyData as $levelData) {
  793.             $data[] = $rowsMap[$levelData['action']];
  794.         }
  795.         return $data;
  796.     }
  797.     public function removeCustomAlias(int $parentIdstring $textMD5): bool
  798.     {
  799.         $query $this->connection->createQueryBuilder();
  800.         $query->delete(
  801.             $this->connection->quoteIdentifier($this->table)
  802.         )->where(
  803.             $query->expr()->andX(
  804.                 $query->expr()->eq(
  805.                     'parent',
  806.                     $query->createPositionalParameter(
  807.                         $parentId,
  808.                         ParameterType::INTEGER
  809.                     )
  810.                 ),
  811.                 $query->expr()->eq(
  812.                     'text_md5',
  813.                     $query->createPositionalParameter(
  814.                         $textMD5,
  815.                         ParameterType::STRING
  816.                     )
  817.                 ),
  818.                 $query->expr()->eq(
  819.                     'is_alias',
  820.                     $query->createPositionalParameter(1ParameterType::INTEGER)
  821.                 )
  822.             )
  823.         );
  824.         return $query->execute() === 1;
  825.     }
  826.     public function remove(string $action, ?int $id null): void
  827.     {
  828.         $query $this->connection->createQueryBuilder();
  829.         $expr $query->expr();
  830.         $query
  831.             ->delete($this->connection->quoteIdentifier($this->table))
  832.             ->where(
  833.                 $expr->eq(
  834.                     'action',
  835.                     $query->createPositionalParameter($actionParameterType::STRING)
  836.                 )
  837.             );
  838.         if ($id !== null) {
  839.             $query
  840.                 ->andWhere(
  841.                     $expr->eq(
  842.                         'is_alias',
  843.                         $query->createPositionalParameter(0ParameterType::INTEGER)
  844.                     ),
  845.                 )
  846.                 ->andWhere(
  847.                     $expr->eq(
  848.                         'id',
  849.                         $query->createPositionalParameter(
  850.                             $id,
  851.                             ParameterType::INTEGER
  852.                         )
  853.                     )
  854.                 );
  855.         }
  856.         $query->execute();
  857.     }
  858.     public function loadAutogeneratedEntries(int $parentIdbool $includeHistory false): array
  859.     {
  860.         $query $this->connection->createQueryBuilder();
  861.         $expr $query->expr();
  862.         $query
  863.             ->select('*')
  864.             ->from($this->connection->quoteIdentifier($this->table))
  865.             ->where(
  866.                 $expr->eq(
  867.                     'parent',
  868.                     $query->createPositionalParameter(
  869.                         $parentId,
  870.                         ParameterType::INTEGER
  871.                     )
  872.                 ),
  873.             )
  874.             ->andWhere(
  875.                 $expr->eq(
  876.                     'action_type',
  877.                     $query->createPositionalParameter(
  878.                         'eznode',
  879.                         ParameterType::STRING
  880.                     )
  881.                 )
  882.             )
  883.             ->andWhere(
  884.                 $expr->eq(
  885.                     'is_alias',
  886.                     $query->createPositionalParameter(0ParameterType::INTEGER)
  887.                 )
  888.             );
  889.         if (!$includeHistory) {
  890.             $query->andWhere(
  891.                 $expr->eq(
  892.                     'is_original',
  893.                     $query->createPositionalParameter(1ParameterType::INTEGER)
  894.                 )
  895.             );
  896.         }
  897.         $statement $query->execute();
  898.         return $statement->fetchAll(FetchMode::ASSOCIATIVE);
  899.     }
  900.     public function getLocationContentMainLanguageId(int $locationId): int
  901.     {
  902.         $queryBuilder $this->connection->createQueryBuilder();
  903.         $expr $queryBuilder->expr();
  904.         $queryBuilder
  905.             ->select('c.initial_language_id')
  906.             ->from('ezcontentobject''c')
  907.             ->join('c''ezcontentobject_tree''t'$expr->eq('t.contentobject_id''c.id'))
  908.             ->where(
  909.                 $expr->eq('t.node_id'':locationId')
  910.             )
  911.             ->setParameter('locationId'$locationIdParameterType::INTEGER);
  912.         $statement $queryBuilder->execute();
  913.         $languageId $statement->fetchColumn();
  914.         if ($languageId === false) {
  915.             throw new RuntimeException("Could not find Content for Location #{$locationId}");
  916.         }
  917.         return (int)$languageId;
  918.     }
  919.     public function bulkRemoveTranslation(int $languageId, array $actions): void
  920.     {
  921.         $query $this->connection->createQueryBuilder();
  922.         $query
  923.             ->update($this->connection->quoteIdentifier($this->table))
  924.             // parameter for bitwise operation has to be placed verbatim (w/o binding) for this to work cross-DBMS
  925.             ->set('lang_mask''lang_mask & ~ ' $languageId)
  926.             ->where('action IN (:actions)')
  927.             ->setParameter(':actions'$actionsConnection::PARAM_STR_ARRAY);
  928.         $query->execute();
  929.         // cleanup: delete single language rows (including alwaysAvailable)
  930.         $query $this->connection->createQueryBuilder();
  931.         $query
  932.             ->delete($this->connection->quoteIdentifier($this->table))
  933.             ->where('action IN (:actions)')
  934.             ->andWhere('lang_mask IN (0, 1)')
  935.             ->setParameter(':actions'$actionsConnection::PARAM_STR_ARRAY);
  936.         $query->execute();
  937.     }
  938.     public function archiveUrlAliasesForDeletedTranslations(
  939.         int $locationId,
  940.         int $parentId,
  941.         array $languageIds
  942.     ): void {
  943.         // determine proper parent for linking historized entry
  944.         $existingLocationEntry $this->loadAutogeneratedEntry(
  945.             'eznode:' $locationId,
  946.             $parentId
  947.         );
  948.         // filter existing URL alias entries by any of the specified removed languages
  949.         $rows $this->loadLocationEntriesMatchingMultipleLanguages(
  950.             $locationId,
  951.             $languageIds
  952.         );
  953.         // remove specific languages from a bit mask
  954.         foreach ($rows as $row) {
  955.             // filter mask to reduce the number of calls to storage engine
  956.             $rowLanguageMask = (int)$row['lang_mask'];
  957.             $languageIdsToBeRemoved array_filter(
  958.                 $languageIds,
  959.                 static function ($languageId) use ($rowLanguageMask) {
  960.                     return $languageId $rowLanguageMask;
  961.                 }
  962.             );
  963.             if (empty($languageIdsToBeRemoved)) {
  964.                 continue;
  965.             }
  966.             // use existing entry to link archived alias or use current alias id
  967.             $linkToId = !empty($existingLocationEntry)
  968.                 ? (int)$existingLocationEntry['id']
  969.                 : (int)$row['id'];
  970.             foreach ($languageIdsToBeRemoved as $languageId) {
  971.                 $this->archiveUrlAliasForDeletedTranslation(
  972.                     (int)$row['lang_mask'],
  973.                     (int)$languageId,
  974.                     (int)$row['parent'],
  975.                     $row['text_md5'],
  976.                     $linkToId
  977.                 );
  978.             }
  979.         }
  980.     }
  981.     /**
  982.      * Load list of aliases for given $locationId matching any of the specified Languages.
  983.      *
  984.      * @param int[] $languageIds
  985.      */
  986.     private function loadLocationEntriesMatchingMultipleLanguages(
  987.         int $locationId,
  988.         array $languageIds
  989.     ): array {
  990.         // note: alwaysAvailable for this use case is not relevant
  991.         $languageMask $this->languageMaskGenerator->generateLanguageMaskFromLanguageIds(
  992.             $languageIds,
  993.             false
  994.         );
  995.         /** @var \Doctrine\DBAL\Connection $connection */
  996.         $query $this->connection->createQueryBuilder();
  997.         $query
  998.             ->select('id''lang_mask''parent''text_md5')
  999.             ->from($this->connection->quoteIdentifier($this->table))
  1000.             ->where('action = :action')
  1001.             // fetch rows matching any of the given Languages
  1002.             ->andWhere('lang_mask & :languageMask <> 0')
  1003.             ->setParameter(':action''eznode:' $locationId)
  1004.             ->setParameter(':languageMask'$languageMask);
  1005.         $statement $query->execute();
  1006.         return $statement->fetchAll(FetchMode::ASSOCIATIVE);
  1007.     }
  1008.     /**
  1009.      * @throws \Doctrine\DBAL\DBALException
  1010.      */
  1011.     public function deleteUrlAliasesWithoutLocation(): int
  1012.     {
  1013.         $dbPlatform $this->connection->getDatabasePlatform();
  1014.         $subQuery $this->connection->createQueryBuilder();
  1015.         $subQuery
  1016.             ->select('node_id')
  1017.             ->from('ezcontentobject_tree''t')
  1018.             ->where(
  1019.                 $subQuery->expr()->eq(
  1020.                     't.node_id',
  1021.                     sprintf(
  1022.                         'CAST(%s as %s)',
  1023.                         $dbPlatform->getSubstringExpression(
  1024.                             $this->connection->quoteIdentifier($this->table) . '.action',
  1025.                             8
  1026.                         ),
  1027.                         $this->getIntegerType()
  1028.                     )
  1029.                 )
  1030.             );
  1031.         $deleteQuery $this->connection->createQueryBuilder();
  1032.         $deleteQuery
  1033.             ->delete($this->connection->quoteIdentifier($this->table))
  1034.             ->where(
  1035.                 $deleteQuery->expr()->eq(
  1036.                     'action_type',
  1037.                     $deleteQuery->createPositionalParameter('eznode')
  1038.                 )
  1039.             )
  1040.             ->andWhere(
  1041.                 sprintf('NOT EXISTS (%s)'$subQuery->getSQL())
  1042.             );
  1043.         return $deleteQuery->execute();
  1044.     }
  1045.     public function deleteUrlAliasesWithoutParent(): int
  1046.     {
  1047.         $existingAliasesQuery $this->getAllUrlAliasesQuery();
  1048.         $query $this->connection->createQueryBuilder();
  1049.         $query
  1050.             ->delete($this->connection->quoteIdentifier($this->table))
  1051.             ->where(
  1052.                 $query->expr()->neq(
  1053.                     'parent',
  1054.                     $query->createPositionalParameter(0ParameterType::INTEGER)
  1055.                 )
  1056.             )
  1057.             ->andWhere(
  1058.                 $query->expr()->notIn(
  1059.                     'parent',
  1060.                     $existingAliasesQuery
  1061.                 )
  1062.             );
  1063.         return $query->execute();
  1064.     }
  1065.     public function deleteUrlAliasesWithBrokenLink(): int
  1066.     {
  1067.         $existingAliasesQuery $this->getAllUrlAliasesQuery();
  1068.         $query $this->connection->createQueryBuilder();
  1069.         $query
  1070.             ->delete($this->connection->quoteIdentifier($this->table))
  1071.             ->where(
  1072.                 $query->expr()->neq('id''link')
  1073.             )
  1074.             ->andWhere(
  1075.                 $query->expr()->notIn(
  1076.                     'link',
  1077.                     $existingAliasesQuery
  1078.                 )
  1079.             );
  1080.         return (int)$query->execute();
  1081.     }
  1082.     public function repairBrokenUrlAliasesForLocation(int $locationId): void
  1083.     {
  1084.         $urlAliasesData $this->getUrlAliasesForLocation($locationId);
  1085.         $originalUrlAliases $this->filterOriginalAliases($urlAliasesData);
  1086.         if (count($originalUrlAliases) === count($urlAliasesData)) {
  1087.             // no archived aliases - nothing to fix
  1088.             return;
  1089.         }
  1090.         $updateQueryBuilder $this->connection->createQueryBuilder();
  1091.         $expr $updateQueryBuilder->expr();
  1092.         $updateQueryBuilder
  1093.             ->update($this->connection->quoteIdentifier($this->table))
  1094.             ->set('link'':linkId')
  1095.             ->set('parent'':newParentId')
  1096.             ->where(
  1097.                 $expr->eq('action'':action')
  1098.             )
  1099.             ->andWhere(
  1100.                 $expr->eq(
  1101.                     'is_original',
  1102.                     $updateQueryBuilder->createNamedParameter(0ParameterType::INTEGER)
  1103.                 )
  1104.             )
  1105.             ->andWhere(
  1106.                 $expr->eq('parent'':oldParentId')
  1107.             )
  1108.             ->andWhere(
  1109.                 $expr->eq('text_md5'':textMD5')
  1110.             )
  1111.             ->setParameter(':action'"eznode:{$locationId}");
  1112.         foreach ($urlAliasesData as $urlAliasData) {
  1113.             if ($urlAliasData['is_original'] === || !isset($originalUrlAliases[$urlAliasData['lang_mask']])) {
  1114.                 // ignore non-archived entries and deleted Translations
  1115.                 continue;
  1116.             }
  1117.             $originalUrlAlias $originalUrlAliases[$urlAliasData['lang_mask']];
  1118.             if ($urlAliasData['link'] === $originalUrlAlias['link']) {
  1119.                 // ignore correct entries to avoid unnecessary updates
  1120.                 continue;
  1121.             }
  1122.             $updateQueryBuilder
  1123.                 ->setParameter(':linkId'$originalUrlAlias['link'], ParameterType::INTEGER)
  1124.                 // attempt to fix missing parent case
  1125.                 ->setParameter(
  1126.                     ':newParentId',
  1127.                     $urlAliasData['existing_parent'] ?? $originalUrlAlias['parent'],
  1128.                     ParameterType::INTEGER
  1129.                 )
  1130.                 ->setParameter(':oldParentId'$urlAliasData['parent'], ParameterType::INTEGER)
  1131.                 ->setParameter(':textMD5'$urlAliasData['text_md5']);
  1132.             try {
  1133.                 $updateQueryBuilder->execute();
  1134.             } catch (UniqueConstraintViolationException $e) {
  1135.                 // edge case: if such row already exists, there's no way to restore history
  1136.                 $this->deleteRow((int) $urlAliasData['parent'], $urlAliasData['text_md5']);
  1137.             }
  1138.         }
  1139.     }
  1140.     /**
  1141.      * @throws \Doctrine\DBAL\DBALException
  1142.      */
  1143.     public function deleteUrlNopAliasesWithoutChildren(): int
  1144.     {
  1145.         $platform $this->connection->getDatabasePlatform();
  1146.         $queryBuilder $this->connection->createQueryBuilder();
  1147.         // The wrapper select is needed for SQL "Derived Table Merge" issue for deleting
  1148.         $wrapperQueryBuilder = clone $queryBuilder;
  1149.         $selectQueryBuilder = clone $queryBuilder;
  1150.         $expressionBuilder $queryBuilder->expr();
  1151.         $selectQueryBuilder
  1152.             ->select('u_parent.id AS inner_id')
  1153.             ->from($this->table'u_parent')
  1154.             ->leftJoin(
  1155.                 'u_parent',
  1156.                 $this->table,
  1157.                 'u',
  1158.                 $expressionBuilder->eq('u_parent.id''u.parent')
  1159.             )
  1160.             ->where(
  1161.                 $expressionBuilder->eq(
  1162.                     'u_parent.action_type',
  1163.                     ':actionType'
  1164.                 )
  1165.             )
  1166.             ->groupBy('u_parent.id')
  1167.             ->having(
  1168.                 $expressionBuilder->eq($platform->getCountExpression('u.id'), 0)
  1169.             );
  1170.         $wrapperQueryBuilder
  1171.             ->select('inner_id')
  1172.             ->from(
  1173.                 sprintf('(%s)'$selectQueryBuilder),
  1174.                 'wrapper'
  1175.             )
  1176.             ->where('id = inner_id');
  1177.         $queryBuilder
  1178.             ->delete($this->table)
  1179.             ->where(
  1180.                 sprintf('EXISTS (%s)'$wrapperQueryBuilder)
  1181.             )
  1182.             ->setParameter('actionType'self::NOP);
  1183.         return $queryBuilder->execute();
  1184.     }
  1185.     /**
  1186.      * @throws \Doctrine\DBAL\DBALException
  1187.      */
  1188.     public function getAllChildrenAliases(int $parentId): array
  1189.     {
  1190.         $queryBuilder $this->connection->createQueryBuilder();
  1191.         $expressionBuilder $queryBuilder->expr();
  1192.         $queryBuilder
  1193.             ->select('parent''text_md5')
  1194.             ->from($this->table)
  1195.             ->where(
  1196.                 $expressionBuilder->eq(
  1197.                     'parent',
  1198.                     $queryBuilder->createPositionalParameter($parentIdParameterType::INTEGER)
  1199.                 )
  1200.             )->andWhere(
  1201.                 $expressionBuilder->eq(
  1202.                     'is_alias',
  1203.                     $queryBuilder->createPositionalParameter(1ParameterType::INTEGER)
  1204.                 )
  1205.             );
  1206.         return $queryBuilder->execute()->fetchAll();
  1207.     }
  1208.     /**
  1209.      * Filter from the given result set original (current) only URL aliases and index them by language_mask.
  1210.      *
  1211.      * Note: each language_mask can have one URL Alias.
  1212.      *
  1213.      * @param array $urlAliasesData
  1214.      */
  1215.     private function filterOriginalAliases(array $urlAliasesData): array
  1216.     {
  1217.         $originalUrlAliases array_filter(
  1218.             $urlAliasesData,
  1219.             static function ($urlAliasData) {
  1220.                 // filter is_original=true ignoring broken parent records (cleaned up elsewhere)
  1221.                 return (bool)$urlAliasData['is_original'] && $urlAliasData['existing_parent'] !== null;
  1222.             }
  1223.         );
  1224.         // return language_mask-indexed array
  1225.         return array_combine(
  1226.             array_column($originalUrlAliases'lang_mask'),
  1227.             $originalUrlAliases
  1228.         );
  1229.     }
  1230.     /**
  1231.      * Get sub-query for IDs of all URL aliases.
  1232.      */
  1233.     private function getAllUrlAliasesQuery(): string
  1234.     {
  1235.         $existingAliasesQueryBuilder $this->connection->createQueryBuilder();
  1236.         $innerQueryBuilder $this->connection->createQueryBuilder();
  1237.         return $existingAliasesQueryBuilder
  1238.             ->select('tmp.id')
  1239.             ->from(
  1240.                 // nest sub-query to avoid same-table update error
  1241.                 '(' $innerQueryBuilder->select('id')->from(
  1242.                     $this->connection->quoteIdentifier($this->table)
  1243.                 )->getSQL() . ')',
  1244.                 'tmp'
  1245.             )
  1246.             ->getSQL();
  1247.     }
  1248.     /**
  1249.      * Get DBMS-specific integer type.
  1250.      */
  1251.     private function getIntegerType(): string
  1252.     {
  1253.         return $this->dbPlatform->getName() === 'mysql' 'signed' 'integer';
  1254.     }
  1255.     /**
  1256.      * Get all URL aliases for the given Location (including archived ones).
  1257.      */
  1258.     private function getUrlAliasesForLocation(int $locationId): array
  1259.     {
  1260.         $queryBuilder $this->connection->createQueryBuilder();
  1261.         $queryBuilder
  1262.             ->select(
  1263.                 't1.id',
  1264.                 't1.is_original',
  1265.                 't1.lang_mask',
  1266.                 't1.link',
  1267.                 't1.parent',
  1268.                 // show existing parent only if its row exists, special case for root parent
  1269.                 'CASE t1.parent WHEN 0 THEN 0 ELSE t2.id END AS existing_parent',
  1270.                 't1.text_md5'
  1271.             )
  1272.             ->from($this->connection->quoteIdentifier($this->table), 't1')
  1273.             // selecting t2.id above will result in null if parent is broken
  1274.             ->leftJoin(
  1275.                 't1',
  1276.                 $this->connection->quoteIdentifier($this->table),
  1277.                 't2',
  1278.                 $queryBuilder->expr()->eq('t1.parent''t2.id')
  1279.             )
  1280.             ->where(
  1281.                 $queryBuilder->expr()->eq(
  1282.                     't1.action',
  1283.                     $queryBuilder->createPositionalParameter("eznode:{$locationId}")
  1284.                 )
  1285.             );
  1286.         return $queryBuilder->execute()->fetchAll(FetchMode::ASSOCIATIVE);
  1287.     }
  1288.     /**
  1289.      * Delete URL alias row by its primary composite key.
  1290.      */
  1291.     private function deleteRow(int $parentIdstring $textMD5): int
  1292.     {
  1293.         $queryBuilder $this->connection->createQueryBuilder();
  1294.         $expr $queryBuilder->expr();
  1295.         $queryBuilder
  1296.             ->delete($this->connection->quoteIdentifier($this->table))
  1297.             ->where(
  1298.                 $expr->eq(
  1299.                     'parent',
  1300.                     $queryBuilder->createPositionalParameter($parentIdParameterType::INTEGER)
  1301.                 )
  1302.             )
  1303.             ->andWhere(
  1304.                 $expr->eq(
  1305.                     'text_md5',
  1306.                     $queryBuilder->createPositionalParameter($textMD5)
  1307.                 )
  1308.             )
  1309.         ;
  1310.         return $queryBuilder->execute();
  1311.     }
  1312. }
  1313. class_alias(DoctrineDatabase::class, 'eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway\DoctrineDatabase');