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 {