connectionPool->getConnectionForTable($tableName); $connection->insert($tableName, $fieldValues, $this->getTypesForDataset($tableName, $fieldValues)); } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1470230766, $e); } $uid = 0; if (!$isRelation) { // Relation tables have no auto_increment column, so no retrieval must be tried. $uid = (int)$connection->lastInsertId(); $this->cacheService->clearCacheForRecord($tableName, $uid); } return $uid; } /** * Updates a row in the storage * * @param string $tableName The database table name * @param array $fieldValues The row to be updated * @param bool $isRelation TRUE if we are currently inserting into a relation table, FALSE by default * @throws \InvalidArgumentException * @throws SqlErrorException */ public function updateRow(string $tableName, array $fieldValues, bool $isRelation = false): void { if (!isset($fieldValues['uid'])) { throw new \InvalidArgumentException('The given row must contain a value for "uid".', 1476045164); } $uid = (int)$fieldValues['uid']; unset($fieldValues['uid']); try { $connection = $this->connectionPool->getConnectionForTable($tableName); $connection->update($tableName, $fieldValues, ['uid' => $uid], $this->getTypesForDataset($tableName, $fieldValues)); } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1470230767, $e); } if (!$isRelation) { $this->cacheService->clearCacheForRecord($tableName, $uid); } } /** * Updates a relation row in the storage. * * @param string $tableName The database relation table name * @param array $fieldValues The row to be updated * @throws SqlErrorException * @throws \InvalidArgumentException */ public function updateRelationTableRow(string $tableName, array $fieldValues): void { if (!isset($fieldValues['uid_local']) && !isset($fieldValues['uid_foreign'])) { throw new \InvalidArgumentException( 'The given fieldValues must contain a value for "uid_local" and "uid_foreign".', 1360500126 ); } $where = []; $where['uid_local'] = (int)$fieldValues['uid_local']; $where['uid_foreign'] = (int)$fieldValues['uid_foreign']; unset($fieldValues['uid_local']); unset($fieldValues['uid_foreign']); if (!empty($fieldValues['tablenames'])) { $where['tablenames'] = $fieldValues['tablenames']; unset($fieldValues['tablenames']); } if (!empty($fieldValues['fieldname'])) { $where['fieldname'] = $fieldValues['fieldname']; unset($fieldValues['fieldname']); } try { $this->connectionPool->getConnectionForTable($tableName)->update( $tableName, $fieldValues, $where, $this->getTypesForDataset($tableName, $fieldValues), ); } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1470230768, $e); } } /** * Deletes a row in the storage * * @param string $tableName The database table name * @param array $where An array of where array('fieldname' => value). * @param bool $isRelation TRUE if we are currently manipulating a relation table, FALSE by default * @throws SqlErrorException */ public function removeRow(string $tableName, array $where, bool $isRelation = false): void { try { $this->connectionPool->getConnectionForTable($tableName)->delete($tableName, $where); } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1470230769, $e); } if (!$isRelation && isset($where['uid'])) { $this->cacheService->clearCacheForRecord($tableName, (int)$where['uid']); } } /** * Returns the object data matching the $query. * * @throws SqlErrorException */ public function getObjectDataByQuery(QueryInterface $query): array { $statement = $query->getStatement(); // A custom query is needed for the language, so a custom context is cloned /** @var Context $context */ $context = clone GeneralUtility::makeInstance(Context::class); $context->setAspect('language', $query->getQuerySettings()->getLanguageAspect()); if ($statement instanceof Statement && !$statement->getStatement() instanceof QueryBuilder) { $rows = $this->getObjectDataByRawQuery($statement); } else { $queryParser = GeneralUtility::makeInstance(Typo3DbQueryParser::class); if ($statement instanceof Statement && $statement->getStatement() instanceof QueryBuilder ) { $queryBuilder = $statement->getStatement(); } else { $queryBuilder = $queryParser->convertQueryToDoctrineQueryBuilder($query); } $selectParts = $queryBuilder->getSelect(); if ($queryParser->isDistinctQuerySuggested() && !empty($selectParts)) { $selectParts[0] = 'DISTINCT ' . $selectParts[0]; $queryBuilder->selectLiteral(...$selectParts); } if ($query->getOffset()) { $queryBuilder->setFirstResult($query->getOffset()); } if ($query->getLimit()) { // Only set the "real" limit in LIVE workspace, as we do not need to make WS overlays here // And can calculate with the direct result from the RDBMS without needing to calculate this in // PHP (see below). // What we do in workspace, is making a "best guess". Why do we do this? If we have content that // is hidden in a workspace, we need to get the "next" record in line, but we cannot do this // with overlays in SQL. So we use the "best guess" by adding twice the limit. Imagine you have // 2000 news records, and we need to manually calculate the first 10 records, we just take 20 records // from SQL and hope that this matches for "most" usecases (Pareto Principle). if ($context->getAspect('workspace')->isLive()) { $queryBuilder->setMaxResults($query->getLimit()); } else { $queryBuilder->setMaxResults($query->getLimit() * 2); } } try { $rows = $queryBuilder->executeQuery()->fetchAllAssociative(); } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1472074485, $e); } } if (!empty($rows)) { $rows = $this->overlayLanguageAndWorkspace($query->getSource(), $rows, $query, $context); if ($this->autoTagging) { $source = $query->getSource(); if ($source instanceof JoinInterface) { $source = $source->getRight(); } if (!$source instanceof SelectorInterface) { throw new \RuntimeException(get_class($source) . ' must implement SelectorInterface at this point.', 1726753183); } $tableName = $source->getSelectorName(); $this->addCacheTagsForRows($tableName, $rows); } } return $rows; } /** * Returns the object data using a custom statement * * @throws SqlErrorException when the raw SQL statement fails in the database */ protected function getObjectDataByRawQuery(Statement $statement): array { $realStatement = $statement->getStatement(); $parameters = $statement->getBoundVariables(); // The real statement is an instance of the Doctrine DBAL QueryBuilder, so fetching // this directly is possible if ($realStatement instanceof QueryBuilder) { try { $result = $realStatement->executeQuery(); } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1472064721, $e); } $rows = $result->fetchAllAssociative(); // Prepared Doctrine DBAL statement } elseif ($realStatement instanceof \Doctrine\DBAL\Statement) { try { foreach ($parameters as $parameterIdentifier => $parameterValue) { $realStatement->bindValue($parameterIdentifier, $parameterValue); } $result = $realStatement->executeQuery(); } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1481281404, $e); } $rows = $result->fetchAllAssociative(); } else { // Do a real raw query. This is very stupid, as it does not allow to use DBAL's real power if // several tables are on different databases, so this is used with caution and could be removed // in the future try { $connection = $this->connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME); $statement = $connection->executeQuery($realStatement, $parameters); } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1472064775, $e); } $rows = $statement->fetchAllAssociative(); } return $rows; } /** * Returns the number of tuples matching the query. * * @return int The number of matching tuples * @throws BadConstraintException * @throws SqlErrorException */ public function getObjectCountByQuery(QueryInterface $query): int { if ($query->getConstraint() instanceof Statement) { throw new BadConstraintException('Could not execute count on queries with a constraint of type TYPO3\\CMS\\Extbase\\Persistence\\Generic\\Qom\\Statement', 1256661045); } $statement = $query->getStatement(); if ($statement instanceof Statement && !$statement->getStatement() instanceof QueryBuilder ) { $rows = $this->getObjectDataByQuery($query); $count = count($rows); } else { $queryParser = GeneralUtility::makeInstance(Typo3DbQueryParser::class); $queryBuilder = $queryParser ->convertQueryToDoctrineQueryBuilder($query) ->resetOrderBy(); if ($queryParser->isDistinctQuerySuggested()) { $source = $queryBuilder->getFrom()[0]; // Tablename is already quoted for the DBMS, we need to treat table and field names separately $tableName = $source->alias ?: $source->table; $fieldName = $queryBuilder->quoteIdentifier('uid'); $queryBuilder ->resetGroupBy() ->selectLiteral(sprintf('COUNT(DISTINCT %s.%s)', $tableName, $fieldName)); } else { $queryBuilder->count('*'); } // Ensure to count only records in the current workspace $context = GeneralUtility::makeInstance(Context::class); $workspaceUid = (int)$context->getPropertyFromAspect('workspace', 'id'); $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceUid)); try { $count = $queryBuilder->executeQuery()->fetchOne(); } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1472074379, $e); } if ($query->getOffset()) { $count -= $query->getOffset(); } if ($query->getLimit()) { $count = min($count, $query->getLimit()); } } return (int)max(0, $count); } /** * Checks if a Value Object equal to the given Object exists in the database * * @param AbstractValueObject $object The Value Object * @return int|null The matching uid if an object was found, else FALSE * @throws SqlErrorException */ public function getUidOfAlreadyPersistedValueObject(AbstractValueObject $object): ?int { $className = get_class($object); /** @var DataMapper $dataMapper */ $dataMapper = GeneralUtility::makeInstance(DataMapper::class); $dataMap = $dataMapper->getDataMap($className); $queryBuilder = $this->connectionPool->getQueryBuilderForTable($dataMap->tableName); if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() ) { $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); } $whereClause = []; // loop over all properties of the object to exactly set the values of each database field $classSchema = $this->reflectionService->getClassSchema($className); foreach ($classSchema->getDomainObjectProperties() as $property) { $propertyName = $property->getName(); // @todo We couple the Backend to the Entity implementation (uid, isClone); changes there breaks this method if ($dataMap->isPersistableProperty($propertyName) && $propertyName !== AbstractDomainObject::PROPERTY_UID && $propertyName !== AbstractDomainObject::PROPERTY_PID && $propertyName !== 'isClone') { $propertyValue = $object->_getProperty($propertyName); $columnMap = $dataMap->getColumnMap($propertyName); $fieldName = $columnMap->columnName; if ($propertyValue === null) { $whereClause[] = $queryBuilder->expr()->isNull($fieldName); } else { $whereClause[] = $queryBuilder->expr()->eq($fieldName, $queryBuilder->createNamedParameter($dataMapper->getPlainValue($propertyValue, $columnMap))); } } } $queryBuilder ->select('uid') ->from($dataMap->tableName) ->where(...$whereClause); try { $uid = (int)$queryBuilder ->executeQuery() ->fetchOne(); if ($uid > 0) { return $uid; } return null; } catch (DBALException $e) { throw new SqlErrorException($e->getMessage(), 1470231748, $e); } } /** * Performs workspace and language overlay on the given row array. The language and workspace id is automatically * detected (depending on FE or BE context). You can also explicitly set the language/workspace id. */ protected function overlayLanguageAndWorkspace(SourceInterface $source, array $rows, QueryInterface $query, Context $context): array { $workspaceUid = (int)$context->getPropertyFromAspect('workspace', 'id'); $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context); if ($source instanceof SelectorInterface) { $tableName = $source->getSelectorName(); $rows = $this->resolveMovedRecordsInWorkspace($tableName, $rows, $workspaceUid); return $this->overlayLanguageAndWorkspaceForSelect($tableName, $rows, $pageRepository, $query, $context); } if ($source instanceof JoinInterface) { $tableName = $source->getRight()->getSelectorName(); // Special handling of joined select is only needed when doing workspace overlays, which does not happen // in live workspace if ($workspaceUid === 0) { return $this->overlayLanguageAndWorkspaceForSelect($tableName, $rows, $pageRepository, $query, $context); } return $this->overlayLanguageAndWorkspaceForJoinedSelect($tableName, $rows, $pageRepository, $query, $context); } // No proper source, so we do not have a table name here // we cannot do an overlay and return the original rows instead. return $rows; } /** * If the result is a plain SELECT (no JOIN) then the regular overlay process works for tables * - overlay workspace * - overlay language of versioned record again */ protected function overlayLanguageAndWorkspaceForSelect(string $tableName, array $rows, PageRepository $pageRepository, QueryInterface $query, Context $context): array { $limit = 0; $overlaidRows = []; $countOverlaidRows = 0; if ($query->getLimit() && !$context->getAspect('workspace')->isLive()) { $limit = $query->getLimit(); } foreach ($rows as $row) { $row = $this->overlayLanguageAndWorkspaceForSingleRecord($tableName, $row, $pageRepository, $query); if (is_array($row)) { $overlaidRows[] = $row; $countOverlaidRows++; // We need to calculate the number of overlaid rows manually in PHP // (via the is_array() above), because some overlays do not exist in a Workspace if ($limit === $countOverlaidRows) { return $overlaidRows; } } } return $overlaidRows; } /** * If the result consists of a JOIN (usually happens if a property is a relation with a MM table) then it is necessary * to only do overlays for the fields that are contained in the main database table, otherwise a SQL error is thrown. * In order to make this happen, a single SQL query is made to fetch all possible field names (= array keys) of * a record (TCA[$tableName][columns] does not contain all needed information), which is then used to compute * a separate subset of the row which can be overlaid properly. */ protected function overlayLanguageAndWorkspaceForJoinedSelect(string $tableName, array $rows, PageRepository $pageRepository, QueryInterface $query, Context $context): array { // No valid rows, so this is skipped if (!isset($rows[0]['uid'])) { return $rows; } $limit = 0; $overlaidRows = []; $countOverlaidRows = 0; if ($query->getLimit() && !$context->getAspect('workspace')->isLive()) { $limit = $query->getLimit(); } // First, find out the fields that belong to the "main" selected table which is defined by TCA, and take the first // record to find out all possible fields in this database table $fieldsOfMainTable = $pageRepository->getRawRecord($tableName, (int)$rows[0]['uid']); if (is_array($fieldsOfMainTable)) { foreach ($rows as $row) { $mainRow = array_intersect_key($row, $fieldsOfMainTable); $joinRow = array_diff_key($row, $mainRow); $mainRow = $this->overlayLanguageAndWorkspaceForSingleRecord($tableName, $mainRow, $pageRepository, $query); if (is_array($mainRow)) { $overlaidRows[] = array_replace($joinRow, $mainRow); $countOverlaidRows++; // We need to calculate the number of overlaid rows manually in PHP // (via the is_array() above), because some overlays do not exist in a Workspace if ($limit === $countOverlaidRows) { return $overlaidRows; } } } } return $overlaidRows; } /** * Takes one specific row, as defined in TCA and does all overlays. * * @return array|int|mixed|null the overlaid row or false or null if overlay failed. */ protected function overlayLanguageAndWorkspaceForSingleRecord(string $tableName, array $row, PageRepository $pageRepository, QueryInterface $query) { $querySettings = $query->getQuerySettings(); $languageAspect = $querySettings->getLanguageAspect(); $languageUid = $languageAspect->getContentId(); $schema = $this->tcaSchemaFactory->get($tableName); $languageOfCurrentRecord = 0; $languageField = null; $translationParentPointerField = null; // If current row is a translation select its parent if ($schema->isLanguageAware()) { $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); $languageField = $languageCapability->getLanguageField()->getName(); $translationParentPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); } if ($languageField && ($row[$languageField] ?? false)) { $languageOfCurrentRecord = $row[$languageField]; } // Note #1: In case of ->findByUid([uid-of-translated-record]) the translated record should be fetched at all times // Example: you've fetched a translation directly via findByUid(11) which is a translated record, but the // request was to do overlays. In this case, the default record is loaded again, and then reapplied again. // Note #2: We cannot use $languageAspect->doOverlays() as it also checks for ID > 0 $fetchLocalizedRecord = $languageAspect->getOverlayType() !== LanguageAspect::OVERLAYS_OFF; // We have a translated record from the DB, but we do overlays, so let's take the default language record // and do overlays again later-on if ($languageOfCurrentRecord > 0 && $fetchLocalizedRecord && ($row[$translationParentPointerField] ?? 0) > 0 ) { $row = $pageRepository->getRawRecord( $tableName, (int)$row[$translationParentPointerField] ); $languageUid = $languageOfCurrentRecord; } // Handle workspace overlays $pageRepository->versionOL($tableName, $row, true, $querySettings->getIgnoreEnableFields()); if (is_array($row) && $fetchLocalizedRecord) { if ($tableName === 'pages') { $row = $pageRepository->getLanguageOverlay($tableName, $row); } else { if (!$querySettings->getRespectSysLanguage() && $languageOfCurrentRecord > 0 && (!$query instanceof Query || !$query->getParentQuery()) ) { // No parent query means we're processing the aggregate root. // respectSysLanguage is false which means that records returned by the query // might be from different languages (which is desired). // So we must set the language used for overlay to the language of the current record $languageUid = $languageOfCurrentRecord; } if ($translationParentPointerField && ($row[$translationParentPointerField] ?? 0) > 0 && $languageOfCurrentRecord > 0 ) { // Force overlay by faking default language record, as getRecordOverlay can only handle default language records $row['uid'] = $row[$translationParentPointerField]; $row[$languageField] = 0; } // The overlay type (and fallback chain) of the language aspect is respected, so translation // behavior is consistent with the regular page / content rendering. The content language // however may have been adjusted above to the language of the actually fetched record // (see Note #1 and the respectSysLanguage handling), so a custom aspect is passed here. $customLanguageAspect = new LanguageAspect( $languageAspect->getId(), $languageUid, $languageAspect->getOverlayType(), $languageAspect->getFallbackChain() ); $row = $pageRepository->getLanguageOverlay($tableName, $row, $customLanguageAspect); } } elseif (is_array($row)) { // If an already localized record is fetched, the "uid" of the default language is used // as the record is re-fetched in the DataMapper if ($translationParentPointerField && ($row[$translationParentPointerField] ?? 0) > 0 && $languageOfCurrentRecord > 0 ) { $row['_LOCALIZED_UID'] = (int)$row['uid']; $row['uid'] = $row[$translationParentPointerField]; } } return $row; } /** * Fetches the moved record in case it is supported * by the table and if there's only one row in the result set * (applying this to all rows does not work, since the sorting * order would be destroyed and possible limits are not met anymore) * The move pointers are later unset (see versionOL() last argument) */ protected function resolveMovedRecordsInWorkspace(string $tableName, array $rows, int $workspaceUid): array { if ($workspaceUid === 0) { return $rows; } if (!$this->tcaSchemaFactory->has($tableName) || !$this->tcaSchemaFactory->get($tableName)->hasCapability(TcaSchemaCapability::Workspace)) { return $rows; } if (count($rows) !== 1) { return $rows; } $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); $queryBuilder->getRestrictions()->removeAll(); $movedRecords = $queryBuilder ->select('*') ->from($tableName) ->where( $queryBuilder->expr()->eq('t3ver_state', $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter($workspaceUid, Connection::PARAM_INT)), $queryBuilder->expr()->eq('t3ver_oid', $queryBuilder->createNamedParameter($rows[0]['uid'], Connection::PARAM_INT)) ) ->setMaxResults(1) ->executeQuery() ->fetchAllAssociative(); if (!empty($movedRecords)) { $rows = $movedRecords; } return $rows; } protected function addCacheTagsForRows(string $tableName, array $rows): void { foreach ($rows as $row) { $lifetime = $this->cacheLifetimeCalculator->calculateLifetimeForRow($tableName, $row); $this->eventDispatcher->dispatch( new AddCacheTagEvent( new CacheTag(sprintf('%s_%s', $tableName, ($row['uid'] ?? 0)), $lifetime) ) ); } } /** * @param array $fieldValues * @return array */ private function getTypesForDataset(string $tableName, array $fieldValues): array { $connection = $this->connectionPool->getConnectionForTable($tableName); $tableInfo = $connection->getSchemaInformation()->getTableInfo($tableName); $types = []; foreach ($fieldValues as $key => $value) { if (!$tableInfo->hasColumnInfo($key)) { // Field is not part of the database schema information, therefore no type is set here and // Doctrine DBAL handles the value with its default binding type (ParameterType::STRING). continue; } try { // `ColumnInfo->getType()` returns the Doctrine type (e.g. JsonType), which carries the // `PHP value <-> database value` conversion methods applied by Doctrine DBAL. Each Doctrine // type maps to a plain binding type (e.g. ParameterType::STRING for VARCHAR/CHAR/TEXT/...), // which binds the value as-is without applying any conversion. Extbase already performs that // conversion itself, which is why the plain binding type is enforced here. This additionally // prevents `Connection::ensureDatabaseValueTypes()` from adding the Doctrine type looked up // from the database schema. $types[$key] = $tableInfo->getColumnInfo($key)->getType()->getBindingType(); } catch (TypesException) { // Ignore, no type to be set } } return $types; } }