From e7dfa08756f97ac788fc7dfbf5b8898020e67689 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 9 Jun 2012 15:50:34 +0200 Subject: [PATCH] [PoC] Arbitrary join support --- lib/Doctrine/ORM/Query/AST/Join.php | 8 +- .../AST/JoinAssociationPathExpression.php | 4 +- .../ORM/Query/AST/JoinClassPathExpression.php | 47 ++++ lib/Doctrine/ORM/Query/Parser.php | 95 ++++--- lib/Doctrine/ORM/Query/SqlWalker.php | 256 +++++++++--------- .../ORM/Query/SelectSqlGenerationTest.php | 8 + 6 files changed, 253 insertions(+), 165 deletions(-) create mode 100644 lib/Doctrine/ORM/Query/AST/JoinClassPathExpression.php diff --git a/lib/Doctrine/ORM/Query/AST/Join.php b/lib/Doctrine/ORM/Query/AST/Join.php index 05eb19781..eb561a653 100644 --- a/lib/Doctrine/ORM/Query/AST/Join.php +++ b/lib/Doctrine/ORM/Query/AST/Join.php @@ -40,15 +40,13 @@ class Join extends Node const JOIN_TYPE_INNER = 3; public $joinType = self::JOIN_TYPE_INNER; - public $joinAssociationPathExpression = null; - public $aliasIdentificationVariable = null; + public $joinPathExpression = null; public $conditionalExpression = null; - public function __construct($joinType, $joinAssocPathExpr, $aliasIdentVar) + public function __construct($joinType, $joinPathExpr) { $this->joinType = $joinType; - $this->joinAssociationPathExpression = $joinAssocPathExpr; - $this->aliasIdentificationVariable = $aliasIdentVar; + $this->joinAssociationPathExpression = $joinPathExpr; } public function dispatch($sqlWalker) diff --git a/lib/Doctrine/ORM/Query/AST/JoinAssociationPathExpression.php b/lib/Doctrine/ORM/Query/AST/JoinAssociationPathExpression.php index ccc9c7fd5..6943de9d5 100644 --- a/lib/Doctrine/ORM/Query/AST/JoinAssociationPathExpression.php +++ b/lib/Doctrine/ORM/Query/AST/JoinAssociationPathExpression.php @@ -36,11 +36,13 @@ class JoinAssociationPathExpression extends Node { public $identificationVariable; public $associationField; + public $aliasIdentificationVariable = null; - public function __construct($identificationVariable, $associationField) + public function __construct($identificationVariable, $associationField, $aliasIdentVar) { $this->identificationVariable = $identificationVariable; $this->associationField = $associationField; + $this->aliasIdentificationVariable = $aliasIdentVar; } public function dispatch($sqlWalker) diff --git a/lib/Doctrine/ORM/Query/AST/JoinClassPathExpression.php b/lib/Doctrine/ORM/Query/AST/JoinClassPathExpression.php new file mode 100644 index 000000000..da7b74b23 --- /dev/null +++ b/lib/Doctrine/ORM/Query/AST/JoinClassPathExpression.php @@ -0,0 +1,47 @@ +. + */ + +namespace Doctrine\ORM\Query\AST; + +/** + * JoinClassPathExpression ::= AbstractSchemaName ["AS"] AliasIdentificationVariable + * + * @license http://www.opensource.org/licenses/lgpl-license.php LGPL + * @link www.doctrine-project.org + * @since 2.3 + * @author Alexander + */ +class JoinClassPathExpression extends Node +{ + public $abstractSchemaName; + public $aliasIdentificationVariable; + + public function __construct($abstractSchemaName, $aliasIdentificationVar) + { + $this->abstractSchemaName = $abstractSchemaName; + $this->aliasIdentificationVariable = $aliasIdentificationVar; + } + + public function dispatch($walker) + { + return $sqlWalker->walkJoinPathExpression($this); + } +} diff --git a/lib/Doctrine/ORM/Query/Parser.php b/lib/Doctrine/ORM/Query/Parser.php index 1af7a021b..0d8b82e83 100644 --- a/lib/Doctrine/ORM/Query/Parser.php +++ b/lib/Doctrine/ORM/Query/Parser.php @@ -905,11 +905,62 @@ class Parser $qComp = $this->_queryComponents[$identVariable]; $class = $qComp['metadata']; - if ( ! isset($class->associationMappings[$field])) { + if ( ! $class->hasAssociation($field)) { $this->semanticalError('Class ' . $class->name . ' has no association named ' . $field); } - return new AST\JoinAssociationPathExpression($identVariable, $field); + if ($this->_lexer->isNextToken(Lexer::T_AS)) { + $this->match(Lexer::T_AS); + } + + $token = $this->_lexer->lookahead; + $aliasIdentificationVariable = $this->AliasIdentificationVariable(); + + // Building queryComponent + $joinQueryComponent = array( + 'metadata' => $this->_em->getClassMetadata($class->associationMappings[$field]['targetEntity']), + 'parent' => $identVariable, + 'relation' => $class->getAssociationMapping($field), + 'map' => null, + 'nestingLevel' => $this->_nestingLevel, + 'token' => $this->_lexer->lookahead + ); + + $this->_queryComponents[$aliasIdentificationVariable] = $joinQueryComponent; + + return new AST\JoinAssociationPathExpression($identVariable, $field, $aliasIdentificationVariable); + } + + /** + * JoinClassPathExpression ::= Class alias + * + * @return \Doctrine\ORM\Query\AST\JoinClassPathExpression + */ + public function JoinClassPathExpression() + { + $abstractSchemaName = $this->AbstractSchemaName(); + + if ($this->_lexer->isNextToken(Lexer::T_AS)) { + $this->match(Lexer::T_AS); + } + + $token = $this->_lexer->lookahead; + $aliasIdentificationVariable = $this->AliasIdentificationVariable(); + $classMetadata = $this->_em->getClassMetadata($abstractSchemaName); + + // Building queryComponent + $queryComponent = array( + 'metadata' => $classMetadata, + 'parent' => null, + 'relation' => null, + 'map' => null, + 'nestingLevel' => $this->_nestingLevel, + 'token' => $token + ); + + $this->_queryComponents[$aliasIdentificationVariable] = $queryComponent; + + return new AST\JoinClassPathExpression($abstractSchemaName, $aliasIdentificationVariable); } /** @@ -1570,41 +1621,19 @@ class Parser $this->match(Lexer::T_JOIN); - $joinPathExpression = $this->JoinAssociationPathExpression(); + $next = $this->_lexer->glimpse(); + if ($next['type'] === Lexer::T_DOT) { + $joinPathExpression = $this->JoinAssociationPathExpression(); + } else { + $joinPathExpression = $this->JoinClassPathExpression(); - if ($this->_lexer->isNextToken(Lexer::T_AS)) { - $this->match(Lexer::T_AS); + if (!$this->_lexer->isNextToken(Lexer::T_WITH)) { + $this->syntaxError('WITH'); + } } - $token = $this->_lexer->lookahead; - $aliasIdentificationVariable = $this->AliasIdentificationVariable(); - - // Verify that the association exists. - $parentClass = $this->_queryComponents[$joinPathExpression->identificationVariable]['metadata']; - $assocField = $joinPathExpression->associationField; - - if ( ! $parentClass->hasAssociation($assocField)) { - $this->semanticalError( - "Class " . $parentClass->name . " has no association named '$assocField'." - ); - } - - $targetClassName = $parentClass->associationMappings[$assocField]['targetEntity']; - - // Building queryComponent - $joinQueryComponent = array( - 'metadata' => $this->_em->getClassMetadata($targetClassName), - 'parent' => $joinPathExpression->identificationVariable, - 'relation' => $parentClass->getAssociationMapping($assocField), - 'map' => null, - 'nestingLevel' => $this->_nestingLevel, - 'token' => $token - ); - - $this->_queryComponents[$aliasIdentificationVariable] = $joinQueryComponent; - // Create AST node - $join = new AST\Join($joinType, $joinPathExpression, $aliasIdentificationVariable); + $join = new AST\Join($joinType, $joinPathExpression); // Check for ad-hoc Join conditions if ($this->_lexer->isNextToken(Lexer::T_WITH)) { diff --git a/lib/Doctrine/ORM/Query/SqlWalker.php b/lib/Doctrine/ORM/Query/SqlWalker.php index 8412025fa..979af059c 100644 --- a/lib/Doctrine/ORM/Query/SqlWalker.php +++ b/lib/Doctrine/ORM/Query/SqlWalker.php @@ -812,31 +812,138 @@ class SqlWalker implements TreeWalker ? ' LEFT JOIN ' : ' INNER JOIN '; - if ($joinVarDecl->indexBy) { - // For Many-To-One or One-To-One associations this obviously makes no sense, but is ignored silently. - $this->_rsm->addIndexBy( - $joinVarDecl->indexBy->simpleStateFieldPathExpression->identificationVariable, - $joinVarDecl->indexBy->simpleStateFieldPathExpression->field - ); + $joinPathExpr = $join->joinAssociationPathExpression; + $joinedDqlAlias = $joinPathExpr->aliasIdentificationVariable; + + if ($joinPathExpr instanceof \Doctrine\ORM\Query\AST\JoinClassPathExpression) { + $targetClass = $this->_queryComponents[$joinedDqlAlias]['metadata']; + + $targetTableAlias = $this->getSQLTableAlias($targetClass->getTableName(), $joinedDqlAlias); + $sql .= $targetClass->getQuotedTableName($this->_platform) . ' ' + . $targetTableAlias; + + $sql .= ' ON (' . $this->walkConditionalExpression($join->conditionalExpression) . ')'; + } else { + + $relation = $this->_queryComponents[$joinedDqlAlias]['relation']; + $targetClass = $this->_em->getClassMetadata($relation['targetEntity']); + $sourceClass = $this->_em->getClassMetadata($relation['sourceEntity']); + $targetTableName = $targetClass->getQuotedTableName($this->_platform); + + $targetTableAlias = $this->getSQLTableAlias($targetClass->getTableName(), $joinedDqlAlias); + $sourceTableAlias = $this->getSQLTableAlias($sourceClass->getTableName(), $joinPathExpr->identificationVariable); + + // Ensure we got the owning side, since it has all mapping info + $assoc = ( ! $relation['isOwningSide']) ? $targetClass->associationMappings[$relation['mappedBy']] : $relation; + if ($this->_query->getHint(Query::HINT_INTERNAL_ITERATION) == true && (!$this->_query->getHint(self::HINT_DISTINCT) || isset($this->_selectedClasses[$joinedDqlAlias]))) { + if ($relation['type'] == ClassMetadata::ONE_TO_MANY || $relation['type'] == ClassMetadata::MANY_TO_MANY) { + throw QueryException::iterateWithFetchJoinNotAllowed($assoc); + } + } + + // This condition is not checking ClassMetadata::MANY_TO_ONE, because by definition it cannot + // be the owning side and previously we ensured that $assoc is always the owning side of the associations. + // The owning side is necessary at this point because only it contains the JoinColumn information. + if ($assoc['type'] & ClassMetadata::TO_ONE) { + $sql .= $targetTableName . ' ' . $targetTableAlias . ' ON '; + $first = true; + + foreach ($assoc['sourceToTargetKeyColumns'] as $sourceColumn => $targetColumn) { + if ( ! $first) $sql .= ' AND '; else $first = false; + + if ($relation['isOwningSide']) { + if ($targetClass->containsForeignIdentifier && !isset($targetClass->fieldNames[$targetColumn])) { + $quotedTargetColumn = $targetColumn; // Join columns cannot be quoted. + } else { + $quotedTargetColumn = $targetClass->getQuotedColumnName($targetClass->fieldNames[$targetColumn], $this->_platform); + } + $sql .= $sourceTableAlias . '.' . $sourceColumn . ' = ' . $targetTableAlias . '.' . $quotedTargetColumn; + } else { + if ($sourceClass->containsForeignIdentifier && !isset($sourceClass->fieldNames[$targetColumn])) { + $quotedTargetColumn = $targetColumn; // Join columns cannot be quoted. + } else { + $quotedTargetColumn = $sourceClass->getQuotedColumnName($sourceClass->fieldNames[$targetColumn], $this->_platform); + } + $sql .= $sourceTableAlias . '.' . $quotedTargetColumn . ' = ' . $targetTableAlias . '.' . $sourceColumn; + } + } + + } else if ($assoc['type'] == ClassMetadata::MANY_TO_MANY) { + // Join relation table + $joinTable = $assoc['joinTable']; + $joinTableAlias = $this->getSQLTableAlias($joinTable['name'], $joinedDqlAlias); + $sql .= $sourceClass->getQuotedJoinTableName($assoc, $this->_platform) . ' ' . $joinTableAlias . ' ON '; + + $first = true; + if ($relation['isOwningSide']) { + foreach ($assoc['relationToSourceKeyColumns'] as $relationColumn => $sourceColumn) { + if ( ! $first) $sql .= ' AND '; else $first = false; + + if ($sourceClass->containsForeignIdentifier && !isset($sourceClass->fieldNames[$sourceColumn])) { + $quotedTargetColumn = $sourceColumn; // Join columns cannot be quoted. + } else { + $quotedTargetColumn = $sourceClass->getQuotedColumnName($sourceClass->fieldNames[$sourceColumn], $this->_platform); + } + + $sql .= $sourceTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableAlias . '.' . $relationColumn; + } + } else { + foreach ($assoc['relationToTargetKeyColumns'] as $relationColumn => $targetColumn) { + if ( ! $first) $sql .= ' AND '; else $first = false; + + if ($sourceClass->containsForeignIdentifier && !isset($sourceClass->fieldNames[$targetColumn])) { + $quotedTargetColumn = $targetColumn; // Join columns cannot be quoted. + } else { + $quotedTargetColumn = $sourceClass->getQuotedColumnName($sourceClass->fieldNames[$targetColumn], $this->_platform); + } + + $sql .= $sourceTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableAlias . '.' . $relationColumn; + } + } + + // Join target table + $sql .= ($joinType == AST\Join::JOIN_TYPE_LEFT || $joinType == AST\Join::JOIN_TYPE_LEFTOUTER) ? ' LEFT JOIN ' : ' INNER JOIN '; + $sql .= $targetTableName . ' ' . $targetTableAlias . ' ON '; + + $first = true; + if ($relation['isOwningSide']) { + foreach ($assoc['relationToTargetKeyColumns'] as $relationColumn => $targetColumn) { + if ( ! $first) $sql .= ' AND '; else $first = false; + + if ($targetClass->containsForeignIdentifier && !isset($targetClass->fieldNames[$targetColumn])) { + $quotedTargetColumn = $targetColumn; // Join columns cannot be quoted. + } else { + $quotedTargetColumn = $targetClass->getQuotedColumnName($targetClass->fieldNames[$targetColumn], $this->_platform); + } + + $sql .= $targetTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableAlias . '.' . $relationColumn; + } + } else { + foreach ($assoc['relationToSourceKeyColumns'] as $relationColumn => $sourceColumn) { + if ( ! $first) $sql .= ' AND '; else $first = false; + + if ($targetClass->containsForeignIdentifier && !isset($targetClass->fieldNames[$sourceColumn])) { + $quotedTargetColumn = $sourceColumn; // Join columns cannot be quoted. + } else { + $quotedTargetColumn = $targetClass->getQuotedColumnName($targetClass->fieldNames[$sourceColumn], $this->_platform); + } + + $sql .= $targetTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableAlias . '.' . $relationColumn; + } + } + } + + // Handle WITH clause + if (($condExpr = $join->conditionalExpression) !== null) { + // Phase 2 AST optimization: Skip processment of ConditionalExpression + // if only one ConditionalTerm is defined + $sql .= ' AND (' . $this->walkConditionalExpression($condExpr) . ')'; + } } - $joinAssocPathExpr = $join->joinAssociationPathExpression; - $joinedDqlAlias = $join->aliasIdentificationVariable; - - $relation = $this->_queryComponents[$joinedDqlAlias]['relation']; - $targetClass = $this->_em->getClassMetadata($relation['targetEntity']); - $sourceClass = $this->_em->getClassMetadata($relation['sourceEntity']); - $targetTableName = $targetClass->getQuotedTableName($this->_platform); - - $targetTableAlias = $this->getSQLTableAlias($targetClass->getTableName(), $joinedDqlAlias); - $sourceTableAlias = $this->getSQLTableAlias($sourceClass->getTableName(), $joinAssocPathExpr->identificationVariable); - - // Ensure we got the owning side, since it has all mapping info - $assoc = ( ! $relation['isOwningSide']) ? $targetClass->associationMappings[$relation['mappedBy']] : $relation; - if ($this->_query->getHint(Query::HINT_INTERNAL_ITERATION) == true && (!$this->_query->getHint(self::HINT_DISTINCT) || isset($this->_selectedClasses[$joinedDqlAlias]))) { - if ($relation['type'] == ClassMetadata::ONE_TO_MANY || $relation['type'] == ClassMetadata::MANY_TO_MANY) { - throw QueryException::iterateWithFetchJoinNotAllowed($assoc); - } + // Apply the filters + if ($filterExpr = $this->generateFilterConditionSQL($targetClass, $targetTableAlias)) { + $sql .= ' AND ' . $filterExpr; } if ($joinVarDecl->indexBy) { @@ -849,109 +956,6 @@ class SqlWalker implements TreeWalker $this->_rsm->addIndexBy($joinedDqlAlias, $relation['indexBy']); } - // This condition is not checking ClassMetadata::MANY_TO_ONE, because by definition it cannot - // be the owning side and previously we ensured that $assoc is always the owning side of the associations. - // The owning side is necessary at this point because only it contains the JoinColumn information. - if ($assoc['type'] & ClassMetadata::TO_ONE) { - $sql .= $targetTableName . ' ' . $targetTableAlias . ' ON '; - $first = true; - - foreach ($assoc['sourceToTargetKeyColumns'] as $sourceColumn => $targetColumn) { - if ( ! $first) $sql .= ' AND '; else $first = false; - - if ($relation['isOwningSide']) { - if ($targetClass->containsForeignIdentifier && !isset($targetClass->fieldNames[$targetColumn])) { - $quotedTargetColumn = $targetColumn; // Join columns cannot be quoted. - } else { - $quotedTargetColumn = $targetClass->getQuotedColumnName($targetClass->fieldNames[$targetColumn], $this->_platform); - } - $sql .= $sourceTableAlias . '.' . $sourceColumn . ' = ' . $targetTableAlias . '.' . $quotedTargetColumn; - } else { - if ($sourceClass->containsForeignIdentifier && !isset($sourceClass->fieldNames[$targetColumn])) { - $quotedTargetColumn = $targetColumn; // Join columns cannot be quoted. - } else { - $quotedTargetColumn = $sourceClass->getQuotedColumnName($sourceClass->fieldNames[$targetColumn], $this->_platform); - } - $sql .= $sourceTableAlias . '.' . $quotedTargetColumn . ' = ' . $targetTableAlias . '.' . $sourceColumn; - } - } - - } else if ($assoc['type'] == ClassMetadata::MANY_TO_MANY) { - // Join relation table - $joinTable = $assoc['joinTable']; - $joinTableAlias = $this->getSQLTableAlias($joinTable['name'], $joinedDqlAlias); - $sql .= $sourceClass->getQuotedJoinTableName($assoc, $this->_platform) . ' ' . $joinTableAlias . ' ON '; - - $first = true; - if ($relation['isOwningSide']) { - foreach ($assoc['relationToSourceKeyColumns'] as $relationColumn => $sourceColumn) { - if ( ! $first) $sql .= ' AND '; else $first = false; - - if ($sourceClass->containsForeignIdentifier && !isset($sourceClass->fieldNames[$sourceColumn])) { - $quotedTargetColumn = $sourceColumn; // Join columns cannot be quoted. - } else { - $quotedTargetColumn = $sourceClass->getQuotedColumnName($sourceClass->fieldNames[$sourceColumn], $this->_platform); - } - - $sql .= $sourceTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableAlias . '.' . $relationColumn; - } - } else { - foreach ($assoc['relationToTargetKeyColumns'] as $relationColumn => $targetColumn) { - if ( ! $first) $sql .= ' AND '; else $first = false; - - if ($sourceClass->containsForeignIdentifier && !isset($sourceClass->fieldNames[$targetColumn])) { - $quotedTargetColumn = $targetColumn; // Join columns cannot be quoted. - } else { - $quotedTargetColumn = $sourceClass->getQuotedColumnName($sourceClass->fieldNames[$targetColumn], $this->_platform); - } - - $sql .= $sourceTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableAlias . '.' . $relationColumn; - } - } - - // Join target table - $sql .= ($joinType == AST\Join::JOIN_TYPE_LEFT || $joinType == AST\Join::JOIN_TYPE_LEFTOUTER) ? ' LEFT JOIN ' : ' INNER JOIN '; - $sql .= $targetTableName . ' ' . $targetTableAlias . ' ON '; - - $first = true; - if ($relation['isOwningSide']) { - foreach ($assoc['relationToTargetKeyColumns'] as $relationColumn => $targetColumn) { - if ( ! $first) $sql .= ' AND '; else $first = false; - - if ($targetClass->containsForeignIdentifier && !isset($targetClass->fieldNames[$targetColumn])) { - $quotedTargetColumn = $targetColumn; // Join columns cannot be quoted. - } else { - $quotedTargetColumn = $targetClass->getQuotedColumnName($targetClass->fieldNames[$targetColumn], $this->_platform); - } - - $sql .= $targetTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableAlias . '.' . $relationColumn; - } - } else { - foreach ($assoc['relationToSourceKeyColumns'] as $relationColumn => $sourceColumn) { - if ( ! $first) $sql .= ' AND '; else $first = false; - - if ($targetClass->containsForeignIdentifier && !isset($targetClass->fieldNames[$sourceColumn])) { - $quotedTargetColumn = $sourceColumn; // Join columns cannot be quoted. - } else { - $quotedTargetColumn = $targetClass->getQuotedColumnName($targetClass->fieldNames[$sourceColumn], $this->_platform); - } - - $sql .= $targetTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableAlias . '.' . $relationColumn; - } - } - } - - // Apply the filters - if ($filterExpr = $this->generateFilterConditionSQL($targetClass, $targetTableAlias)) { - $sql .= ' AND ' . $filterExpr; - } - - // Handle WITH clause - if (($condExpr = $join->conditionalExpression) !== null) { - // Phase 2 AST optimization: Skip processment of ConditionalExpression - // if only one ConditionalTerm is defined - $sql .= ' AND (' . $this->walkConditionalExpression($condExpr) . ')'; - } $discrSql = $this->_generateDiscriminatorColumnConditionSQL(array($joinedDqlAlias)); diff --git a/tests/Doctrine/Tests/ORM/Query/SelectSqlGenerationTest.php b/tests/Doctrine/Tests/ORM/Query/SelectSqlGenerationTest.php index 9a998580b..09fc39b9d 100644 --- a/tests/Doctrine/Tests/ORM/Query/SelectSqlGenerationTest.php +++ b/tests/Doctrine/Tests/ORM/Query/SelectSqlGenerationTest.php @@ -135,6 +135,14 @@ class SelectSqlGenerationTest extends \Doctrine\Tests\OrmTestCase ); } + public function testSupportsJoinOnMultipleComponents() + { + $this->assertSqlGeneration( + 'SELECT u, p FROM Doctrine\Tests\Models\CMS\CmsUser u JOIN Doctrine\Tests\Models\CMS\CmsPhonenumber p WITH u = p.user', + 'SELECT c0_.id AS id0, c0_.status AS status1, c0_.username AS username2, c0_.name AS name3, c1_.phonenumber AS phonenumber4 FROM cms_users c0_ INNER JOIN cms_phonenumbers c1_ ON (c0_.id = c1_.user_id)' + ); + } + public function testSupportsSelectWithCollectionAssociationJoin() { $this->assertSqlGeneration(