From ec3541bb9ef9e975deeb243363f57785f4fcf8e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Mon, 1 Jun 2026 11:35:45 +0200 Subject: [PATCH] fix(sql): allow JOINs without an explicit alias on the joined table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #95. `Join.validate()` rejected any join whose joined table had no alias — but standard SQL doesn't require one. The bare table name is a valid qualifier (PostgreSQL, MySQL, DuckDB, ClickHouse, SQL Server). Removing the rule surfaces two latent issues that have to be fixed together: 1. `From.scala` — `Join.validate()` Drop the two unconditional alias-required branches (the explicit alias match + the redundant `case j if alias.isEmpty` arm in the second match). Keep the ON-clause guard for non-CROSS joins. The downstream sites that key off the join alias already fall back to the source name when no alias is set (`From.tableAliases`, `From.joinAliases`, `From.unnestAliases`, `JoinKey.apply`, `Unnest.innerHitsName`), so no call site needs to change. 2. `From.scala` — `StandardJoin.update()` Stop calling `source.update(request)`. The source of a JOIN is a TABLE name, not a column expression. With the alias requirement gone, `From.tableAliases` now contains a `customers → customers` self-mapping for the alias-less join — which made `GenericIdentifier.update` treat `name="customers"` as `alias.column`, take `parts.tail.mkString(".")` of a single-part name, and end up with `name=""`. The ON clause still gets `.update(request)` so column resolution within ON works. 3. `Parser.scala` — `reservedKeywords` Add `inner`, `left`, `right`, `full`, `cross`, `outer`, `on`. The alias regex's negative-lookahead filter excluded `from`/`join`/etc. but not the join-type keywords — so without an alias on `orders`, the regex greedily consumed `LEFT` as the alias, leaving the `join` rule to parse a junk JOIN with no joinType. Functions like `LEFT(x, 5)` and `RIGHT(x, 3)` are parsed by their dedicated function parsers (not via `identifierRegex`/`regexAlias`), so they continue to work — verified by ParserSpec line 225's SELECT that exercises both. ParserSpec gets three regression cases: - parses `LEFT JOIN customers ON orders.customer_id = customers.id` and exposes `join.alias = None` and `join.source.name = "customers"`. - alias-less joined table is reachable in `From.tableAliases` by its bare name (`"customers" -> "customers"`). - a JOIN with no ON clause still fails — only the alias rule was relaxed, the ON rule was not. --- .../elastic/sql/parser/Parser.scala | 7 +++++ .../softnetwork/elastic/sql/query/From.scala | 15 +++-------- .../elastic/sql/parser/ParserSpec.scala | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/parser/Parser.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/parser/Parser.scala index c684e445..b0c3ee84 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/parser/Parser.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/parser/Parser.scala @@ -1205,6 +1205,13 @@ trait Parser "column", "from", "join", + "inner", + "left", + "right", + "full", + "cross", + "outer", + "on", "where", "group", "having", diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala index 4566ccbc..b4c106b1 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala @@ -167,15 +167,9 @@ sealed trait Join extends Updateable { override def validate(): Either[String, Unit] = for { _ <- source.validate() - _ <- alias match { - case Some(a) if a.alias.nonEmpty => Right(()) - case _ => Left(s"JOIN $this requires an alias") - } _ <- this match { case j if joinType.isDefined && on.isEmpty && joinType.get != CrossJoin => Left(s"JOIN $j requires an ON clause") - case j if alias.isEmpty => - Left(s"JOIN $j requires an alias") case _ => Right(()) } } yield () @@ -246,11 +240,10 @@ case class StandardJoin( alias: Option[Alias] = None ) extends Join { override def update(request: SingleSearch): StandardJoin = { - val updated = this.copy( - source = source.update(request), - on = on.map(_.update(request)) - ) - updated + // The source of a JOIN is a TABLE name, not a column expression — `Identifier.update` + // would treat the bare table name as `alias.column` and strip the (nonexistent) column + // part, leaving `name=""`. Only the ON clause needs to be updated for column resolution. + this.copy(on = on.map(_.update(request))) } override def validate(): Either[String, Unit] = { diff --git a/sql/src/test/scala/app/softnetwork/elastic/sql/parser/ParserSpec.scala b/sql/src/test/scala/app/softnetwork/elastic/sql/parser/ParserSpec.scala index 0ee5ad4b..e7bde455 100644 --- a/sql/src/test/scala/app/softnetwork/elastic/sql/parser/ParserSpec.scala +++ b/sql/src/test/scala/app/softnetwork/elastic/sql/parser/ParserSpec.scala @@ -3106,6 +3106,33 @@ class ParserSpec extends AnyFlatSpec with Matchers { firstStandardJoinType(long) shouldBe Some(FullJoin) } + // ── Alias-less JOIN (Issue #95) ───────────────────────────────────────────── + + it should "parse a JOIN without an alias on the joined table" in { + val sql = + "SELECT * FROM orders LEFT JOIN customers ON orders.customer_id = customers.id" + val result = Parser(sql) + result.isRight shouldBe true + val ss = result.toOption.get.asInstanceOf[SingleSearch] + val join = ss.from.mainTable.joins.head.asInstanceOf[StandardJoin] + join.alias shouldBe None + join.source.name shouldBe "customers" + } + + it should "expose an alias-less joined table by its bare name in From.tableAliases" in { + val sql = + "SELECT * FROM orders LEFT JOIN customers ON orders.customer_id = customers.id" + val ss = Parser(sql).toOption.get.asInstanceOf[SingleSearch] + ss.from.tableAliases should contain("customers" -> "customers") + ss.from.joinAliases.keySet should contain("customers") + } + + it should "still reject a JOIN with no ON clause when joinType is set" in { + // INNER/LEFT/RIGHT/FULL JOIN still require ON — only the alias rule is relaxed. + val sql = "SELECT * FROM orders LEFT JOIN customers" + Parser(sql).isLeft shouldBe true + } + behavior of "Parser Cluster" it should "parse SHOW CLUSTER NAME" in {