-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-25121][SQL] Supports multi-part relation names for join strategy hint resolution #27935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
52089fc
ac19ea1
70c994a
fbbe824
2444a20
f07613d
6528dad
c517c1f
61e4a95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,31 +64,54 @@ object ResolveHints { | |
| _.toUpperCase(Locale.ROOT)).contains(hintName.toUpperCase(Locale.ROOT)))) | ||
| } | ||
|
|
||
| // This method checks if given multi-part identifiers are matched with each other. | ||
| // The [[ResolveJoinStrategyHints]] rule is applied before the resolution batch | ||
| // in the analyzer and we cannot semantically compare them at this stage. | ||
| // Therefore, we follow a simple rule; they match if an identifier in a hint | ||
| // is a tail of an identifier in a relation. This process is independent of a session | ||
| // catalog (`currentDb` in [[SessionCatalog]]) and it just compares them literally. | ||
| // | ||
| // For example, | ||
| // * in a query `SELECT /*+ BROADCAST(t) */ * FROM db1.t JOIN t`, | ||
| // the broadcast hint will match both tables, `db1.t` and `t`. | ||
| // * in a query `SELECT /*+ BROADCAST(default.t) */ * FROM default.t JOIN t`, | ||
| // the broadcast hint will match the left-side table only, `default.t`. | ||
| private def matchedIdentifier(identInHint: Seq[String], identInQuery: Seq[String]): Boolean = { | ||
| if (identInHint.length <= identInQuery.length) { | ||
| identInHint.zip(identInQuery.takeRight(identInHint.length)) | ||
| .forall { case (i1, i2) => resolver(i1, i2) } | ||
| } else { | ||
| false | ||
| } | ||
| } | ||
|
|
||
| private def applyJoinStrategyHint( | ||
| plan: LogicalPlan, | ||
| relations: mutable.HashSet[String], | ||
| relationsInHint: Seq[Seq[String]], | ||
| appliedRelations: mutable.ArrayBuffer[Seq[String]], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we don't care which relations are matched, but which relation name specified by hint does not have a match.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how about and in code
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. updated in the latest commit. |
||
| hintName: String): LogicalPlan = { | ||
| // Whether to continue recursing down the tree | ||
| var recurse = true | ||
|
|
||
| val newNode = CurrentOrigin.withOrigin(plan.origin) { | ||
| plan match { | ||
| case ResolvedHint(u @ UnresolvedRelation(ident), hint) | ||
| if relations.exists(resolver(_, ident.last)) => | ||
| relations.remove(ident.last) | ||
|
cloud-fan marked this conversation as resolved.
|
||
| if relationsInHint.exists(matchedIdentifier(_, ident)) => | ||
| appliedRelations += ident | ||
| ResolvedHint(u, createHintInfo(hintName).merge(hint, hintErrorHandler)) | ||
|
|
||
| case ResolvedHint(r: SubqueryAlias, hint) | ||
| if relations.exists(resolver(_, r.alias)) => | ||
| relations.remove(r.alias) | ||
| if relationsInHint.exists(matchedIdentifier(_, r.identifier.multipartIdentifier)) => | ||
| appliedRelations += r.identifier.multipartIdentifier | ||
| ResolvedHint(r, createHintInfo(hintName).merge(hint, hintErrorHandler)) | ||
|
|
||
| case u @ UnresolvedRelation(ident) if relations.exists(resolver(_, ident.last)) => | ||
| relations.remove(ident.last) | ||
| case UnresolvedRelation(ident) if relationsInHint.exists(matchedIdentifier(_, ident)) => | ||
| appliedRelations += ident | ||
| ResolvedHint(plan, createHintInfo(hintName)) | ||
|
|
||
| case r: SubqueryAlias if relations.exists(resolver(_, r.alias)) => | ||
| relations.remove(r.alias) | ||
| case r: SubqueryAlias | ||
| if relationsInHint.exists(matchedIdentifier(_, r.identifier.multipartIdentifier)) => | ||
| appliedRelations += r.identifier.multipartIdentifier | ||
| ResolvedHint(plan, createHintInfo(hintName)) | ||
|
|
||
| case _: ResolvedHint | _: View | _: With | _: SubqueryAlias => | ||
|
|
@@ -107,7 +130,9 @@ object ResolveHints { | |
| } | ||
|
|
||
| if ((plan fastEquals newNode) && recurse) { | ||
| newNode.mapChildren(child => applyJoinStrategyHint(child, relations, hintName)) | ||
| newNode.mapChildren { child => | ||
| applyJoinStrategyHint(child, relationsInHint, appliedRelations, hintName) | ||
| } | ||
| } else { | ||
| newNode | ||
| } | ||
|
|
@@ -120,17 +145,21 @@ object ResolveHints { | |
| ResolvedHint(h.child, createHintInfo(h.name)) | ||
| } else { | ||
| // Otherwise, find within the subtree query plans to apply the hint. | ||
| val relationNames = h.parameters.map { | ||
| case tableName: String => tableName | ||
| case tableId: UnresolvedAttribute => tableId.name | ||
| val relationNamesInHint = h.parameters.map { | ||
| case tableName: String => UnresolvedAttribute.parseAttributeName(tableName) | ||
| case tableId: UnresolvedAttribute => tableId.nameParts | ||
| case unsupported => throw new AnalysisException("Join strategy hint parameter " + | ||
| s"should be an identifier or string but was $unsupported (${unsupported.getClass}") | ||
| } | ||
| val relationNameSet = new mutable.HashSet[String] | ||
| relationNames.foreach(relationNameSet.add) | ||
|
|
||
| val applied = applyJoinStrategyHint(h.child, relationNameSet, h.name) | ||
| hintErrorHandler.hintRelationsNotFound(h.name, h.parameters, relationNameSet.toSet) | ||
| val appliedNames = new mutable.ArrayBuffer[Seq[String]] | ||
| val applied = applyJoinStrategyHint(h.child, relationNamesInHint, appliedNames, h.name) | ||
| val invalidNames = relationNamesInHint.filterNot { relationName => | ||
| appliedNames.exists { ident => | ||
| relationName.size == ident.size && | ||
| relationName.zip(ident).forall { case (a, b) => resolver(a, b) } | ||
| } | ||
| } | ||
| hintErrorHandler.hintRelationsNotFound(h.name, h.parameters, invalidNames) | ||
| applied | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,17 +17,25 @@ | |
|
|
||
| package org.apache.spark.sql.catalyst | ||
|
|
||
| trait BaseIdentifier { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we add a base trait?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, we can remove it now. I'll update. |
||
| val identifier: String | ||
| val qualifier: Seq[String] | ||
|
|
||
| def multipartIdentifier: Seq[String] = qualifier :+ identifier | ||
| } | ||
|
|
||
| /** | ||
| * An identifier that optionally specifies a database. | ||
| * | ||
| * Format (unquoted): "name" or "db.name" | ||
| * Format (quoted): "`name`" or "`db`.`name`" | ||
| */ | ||
| sealed trait IdentifierWithDatabase { | ||
| val identifier: String | ||
| sealed trait IdentifierWithDatabase extends BaseIdentifier { | ||
|
|
||
| def database: Option[String] | ||
|
|
||
| override val qualifier: Seq[String] = database.map(_ :: Nil).getOrElse(Nil) | ||
|
|
||
| /* | ||
| * Escapes back-ticks within the identifier name with double-back-ticks. | ||
| */ | ||
|
|
@@ -40,9 +48,7 @@ sealed trait IdentifierWithDatabase { | |
| if (replacedDb.isDefined) s"`${replacedDb.get}`.`$replacedId`" else s"`$replacedId`" | ||
| } | ||
|
|
||
| def unquotedString: String = { | ||
| if (database.isDefined) s"${database.get}.$identifier" else identifier | ||
| } | ||
| def unquotedString: String = multipartIdentifier.mkString(".") | ||
|
|
||
| override def toString: String = quotedString | ||
| } | ||
|
|
@@ -54,12 +60,14 @@ sealed trait IdentifierWithDatabase { | |
| * @param name - Is an alias name or a table name | ||
| * @param qualifier - Is a qualifier | ||
| */ | ||
| case class AliasIdentifier(name: String, qualifier: Seq[String]) { | ||
| case class AliasIdentifier(name: String, qualifier: Seq[String]) extends BaseIdentifier { | ||
| import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ | ||
|
|
||
| override val identifier: String = name | ||
|
|
||
| def this(identifier: String) = this(identifier, Seq()) | ||
|
|
||
| override def toString: String = (qualifier :+ name).quoted | ||
| override def toString: String = multipartIdentifier.quoted | ||
| } | ||
|
|
||
| object AliasIdentifier { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -241,4 +241,52 @@ class ResolveHintsSuite extends AnalysisTest { | |
| Project(testRelation.output, testRelation), | ||
| caseSensitive = false) | ||
| } | ||
|
|
||
| test("Supports multi-part table names for broadcast hint resolution") { | ||
| // local temp table (single-part identifier case) | ||
| checkAnalysis( | ||
| UnresolvedHint("MAPJOIN", Seq("table", "table2"), | ||
| table("table").join(table("table2"))), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The following will be better because this is a - table("table").join(table("table2"))),
+ table("TaBlE").join(table("TaBlE2"))),
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, yes! |
||
| Join( | ||
| ResolvedHint(testRelation, HintInfo(strategy = Some(BROADCAST))), | ||
| ResolvedHint(testRelation2, HintInfo(strategy = Some(BROADCAST))), | ||
| Inner, | ||
| None, | ||
| JoinHint.NONE), | ||
| caseSensitive = false) | ||
|
|
||
| checkAnalysis( | ||
| UnresolvedHint("MAPJOIN", Seq("TaBlE", "table2"), | ||
| table("TaBlE").join(table("TaBlE2"))), | ||
| Join( | ||
| ResolvedHint(testRelation, HintInfo(strategy = Some(BROADCAST))), | ||
| testRelation2, | ||
| Inner, | ||
| None, | ||
| JoinHint.NONE), | ||
| caseSensitive = true) | ||
|
|
||
| // global temp table (multi-part identifier case) | ||
| checkAnalysis( | ||
| UnresolvedHint("MAPJOIN", Seq("global_temp.table4", "GlOBal_TeMP.table5"), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shall we test
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. updated. |
||
| table("global_temp", "table4").join(table("global_temp", "table5"))), | ||
| Join( | ||
| ResolvedHint(testRelation4, HintInfo(strategy = Some(BROADCAST))), | ||
| ResolvedHint(testRelation5, HintInfo(strategy = Some(BROADCAST))), | ||
| Inner, | ||
| None, | ||
| JoinHint.NONE), | ||
| caseSensitive = false) | ||
|
|
||
| checkAnalysis( | ||
| UnresolvedHint("MAPJOIN", Seq("global_temp.TaBlE4", "table5"), | ||
| table("global_temp", "TaBlE4").join(table("global_temp", "TaBlE5"))), | ||
| Join( | ||
| ResolvedHint(testRelation4, HintInfo(strategy = Some(BROADCAST))), | ||
| testRelation5, | ||
| Inner, | ||
| None, | ||
| JoinHint.NONE), | ||
| caseSensitive = true) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,10 +17,13 @@ | |
|
|
||
| package org.apache.spark.sql | ||
|
|
||
| import org.apache.spark.sql.catalyst.TableIdentifier | ||
| import org.apache.spark.sql.catalyst.plans.{Inner, InnerLike, LeftOuter, RightOuter} | ||
| import org.apache.spark.sql.catalyst.plans.logical.{Filter, Join, LogicalPlan, Project} | ||
| import org.apache.spark.sql.catalyst.plans.logical.{BROADCAST, Filter, HintInfo, Join, JoinHint, LogicalPlan, Project} | ||
| import org.apache.spark.sql.execution.FileSourceScanExec | ||
| import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper | ||
| import org.apache.spark.sql.execution.datasources.LogicalRelation | ||
| import org.apache.spark.sql.execution.exchange.BroadcastExchangeExec | ||
| import org.apache.spark.sql.execution.joins.BroadcastHashJoinExec | ||
| import org.apache.spark.sql.functions._ | ||
| import org.apache.spark.sql.internal.SQLConf | ||
|
|
@@ -322,4 +325,81 @@ class DataFrameJoinSuite extends QueryTest | |
| } | ||
| } | ||
| } | ||
|
|
||
| test("Supports multi-part names for broadcast hint resolution") { | ||
| val (table1Name, table2Name) = ("t1", "t2") | ||
|
|
||
| withTempDatabase { dbName => | ||
| withTable(table1Name, table2Name) { | ||
| withSQLConf(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") { | ||
| spark.range(50).write.saveAsTable(s"$dbName.$table1Name") | ||
| spark.range(100).write.saveAsTable(s"$dbName.$table2Name") | ||
|
|
||
| // First, makes sure a join is not broadcastable | ||
| val plan = sql(s"SELECT * FROM $dbName.$table1Name, $dbName.$table2Name " + | ||
| s"WHERE $table1Name.id = $table2Name.id") | ||
| .queryExecution.executedPlan | ||
| assert(plan.collect { case p: BroadcastHashJoinExec => p }.isEmpty) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
- .queryExecution.executedPlan
- assert(plan.collect { case p: BroadcastHashJoinExec => p }.isEmpty)
+ checkIfHintNotApplied(plan)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah... I forgot to remove the old tests... I can remove it. Thanks! |
||
|
|
||
| def checkIfHintApplied(tableName: String, hintTableName: String): Unit = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cann we test DataFrame hint API as well?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. updated. |
||
| val p = sql(s"SELECT /*+ BROADCASTJOIN($hintTableName) */ * " + | ||
| s"FROM $tableName, $dbName.$table2Name " + | ||
| s"WHERE $tableName.id = $table2Name.id") | ||
| .queryExecution.executedPlan | ||
| val broadcastHashJoins = p.collect { case p: BroadcastHashJoinExec => p } | ||
| assert(broadcastHashJoins.size == 1) | ||
| val broadcastExchanges = broadcastHashJoins.head.collect { | ||
| case p: BroadcastExchangeExec => p | ||
| } | ||
| assert(broadcastExchanges.size == 1) | ||
| val tables = broadcastExchanges.head.collect { | ||
| case FileSourceScanExec(_, _, _, _, _, _, Some(tableIdent)) => tableIdent | ||
| } | ||
| assert(tables.size == 1) | ||
| assert(tables.head === TableIdentifier(table1Name, Some(dbName))) | ||
| } | ||
|
|
||
| def checkIfHintNotApplied(tableName: String, hintTableName: String): Unit = { | ||
| val p = sql(s"SELECT /*+ BROADCASTJOIN($hintTableName) */ * " + | ||
| s"FROM $tableName, $dbName.$table2Name " + | ||
| s"WHERE $tableName.id = $table2Name.id") | ||
| .queryExecution.executedPlan | ||
| val broadcastHashJoins = p.collect { case p: BroadcastHashJoinExec => p } | ||
| assert(broadcastHashJoins.isEmpty) | ||
| } | ||
|
|
||
| sql(s"USE $dbName") | ||
| checkIfHintApplied(table1Name, table1Name) | ||
| checkIfHintApplied(s"$dbName.$table1Name", s"$dbName.$table1Name") | ||
| checkIfHintApplied(s"$dbName.$table1Name", table1Name) | ||
| checkIfHintNotApplied(table1Name, s"$dbName.$table1Name") | ||
| checkIfHintNotApplied(s"$dbName.$table1Name", s"$dbName.$table1Name.id") | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| test("The same table name exists in two databases for broadcast hint resolution") { | ||
| val (db1Name, db2Name) = ("db1", "db2") | ||
|
|
||
| withDatabase(db1Name, db2Name) { | ||
| withTable("t") { | ||
| withSQLConf(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") { | ||
| sql(s"CREATE DATABASE $db1Name") | ||
| sql(s"CREATE DATABASE $db2Name") | ||
| spark.range(1).write.saveAsTable(s"$db1Name.t") | ||
| spark.range(1).write.saveAsTable(s"$db2Name.t") | ||
|
|
||
| // Checks if a broadcast hint applied in both sides | ||
| val statement = s"SELECT /*+ BROADCASTJOIN(t) */ * FROM $db1Name.t, $db2Name.t " + | ||
| s"WHERE $db1Name.t.id = $db2Name.t.id" | ||
| sql(statement).queryExecution.optimizedPlan match { | ||
| case Join(_, _, _, _, JoinHint(Some(HintInfo(Some(BROADCAST))), | ||
| Some(HintInfo(Some(BROADCAST))))) => | ||
| case _ => fail("broadcast hint not found in both tables") | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.