Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ ThisBuild / organization := "app.softnetwork"

name := "softclient4es"

ThisBuild / version := "0.20.0"
ThisBuild / version := "0.20.1"

ThisBuild / scalaVersion := scala213

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ class CoreDqlExtension extends ExtensionSpi {

override def canHandle(statement: Statement): Boolean = statement match {
case _: DqlStatement => true
case _ => false
// Issue #157 — also claim write-with-JOIN statements (INSERT … SELECT / CREATE TABLE … AS
// SELECT with a cross-index JOIN, mirroring the arrow JoinExtension's canHandle) so they
// fail loudly in execute instead of falling through to the core DML/DDL executors, which
// would silently run the inner SELECT as a single-index search and write wrong data.
case other => joinRequired(other)
}

override def execute(
Expand All @@ -100,6 +104,26 @@ class CoreDqlExtension extends ExtensionSpi {
implicit val ec: ExecutionContext = system.dispatcher

statement match {
// Issue #157 — this baseline handler (and the closed EnforcedDqlExtension inheriting it)
// translates only the first FROM table: a cross-index JOIN reaching it would silently
// execute as a single-index search (joined columns NULL, orphans kept) — or, for
// INSERT … SELECT / CTAS, write that wrong data. With a join-capable extension
// registered ahead (priority < 100) join statements never get here; without one, fail
// loudly instead of returning wrong data.
case s if joinRequired(s) =>
Future.successful(
ElasticFailure(
ElasticError(
message =
"Cross-index JOIN requires the softclient4es-arrow-extensions jar (Java 11+). " +
"Re-run the installer, or run with --no-extensions removed. " +
"See documentation/client/repl.md#extensions.",
statusCode = Some(400),
operation = Some("join")
)
)
)

case dql: DqlStatement =>
licenseManager match {
case Some(manager) =>
Expand All @@ -126,6 +150,19 @@ class CoreDqlExtension extends ExtensionSpi {
"SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... LIMIT ..."
)

/** True when the statement carries a cross-index JOIN (`from.enrichmentRequired`), including
* embedded in INSERT … SELECT / CREATE TABLE … AS SELECT. UNNEST joins are excluded by
* construction — `joinedTables` collects `StandardJoin` sources only.
*/
private[this] def joinRequired(statement: Statement): Boolean = statement match {
case single: SingleSearch => single.from.enrichmentRequired
case multi: MultiSearch => multi.requests.exists(_.from.enrichmentRequired)
case select: SelectStatement => select.statement.exists(joinRequired)
case insert: Insert => insert.values.left.exists(joinRequired)
case create: CreateTable => create.ddl.left.exists(joinRequired)
case _ => false
}

// ════════════════════════════════════════════════════════════════════
// ✅ QUOTA CHECK LOGIC (in extension, not in core)
// ════════════════════════════════════════════════════════════════════
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,89 @@ class CoreDqlExtensionSpec extends AnyFlatSpec with Matchers {
truncationOf(res) shouldBe None
}

// ---- Issue #157: cross-index JOIN must fail loudly on a join-incapable handler ----

behavior of "CoreDqlExtension cross-index JOIN guard (#157)"

it should "reject a cross-index JOIN with a clear 400 instead of silently executing the left table only" in {
val (client, res) = run(
"SELECT e.name, d.dept_name FROM emp e JOIN dept d ON e.dept_id = d.dept_id",
Quota.Community
)
res shouldBe a[ElasticFailure]
val err = res.asInstanceOf[ElasticFailure].elasticError
err.statusCode shouldBe Some(400)
err.operation shouldBe Some("join")
err.message should include("softclient4es-arrow-extensions")
// never forwarded to any execution seam — no silent left-table-only search.
client.scrolledStatement.get() shouldBe null
client.searchedStatement.get() shouldBe null
}

it should "reject a UNION whose leg carries a cross-index JOIN" in {
val (client, res) = run(
"SELECT e.name FROM emp e JOIN dept d ON e.dept_id = d.dept_id UNION SELECT name FROM others",
Quota.Community
)
res shouldBe a[ElasticFailure]
val err = res.asInstanceOf[ElasticFailure].elasticError
err.statusCode shouldBe Some(400)
err.operation shouldBe Some("join")
client.scrolledStatement.get() shouldBe null
client.searchedStatement.get() shouldBe null
}

it should "NOT reject a JOIN UNNEST (nested-field unnest, handled natively)" in {
val (_, res) = run(
"SELECT o.id, oi.quantity FROM orders o JOIN UNNEST(o.items) AS oi",
Quota.Community
)
res shouldBe a[ElasticSuccess[_]]
}

it should "reject an INSERT ... SELECT whose inner SELECT carries a cross-index JOIN" in {
val (client, res) = run(
"INSERT INTO target SELECT e.name, d.dept_name FROM emp e JOIN dept d ON e.dept_id = d.dept_id",
Quota.Community
)
res shouldBe a[ElasticFailure]
val err = res.asInstanceOf[ElasticFailure].elasticError
err.statusCode shouldBe Some(400)
err.operation shouldBe Some("join")
client.scrolledStatement.get() shouldBe null
client.searchedStatement.get() shouldBe null
}

it should "reject a CREATE TABLE ... AS SELECT whose inner SELECT carries a cross-index JOIN" in {
val (client, res) = run(
"CREATE TABLE target AS SELECT e.name, d.dept_name FROM emp e JOIN dept d ON e.dept_id = d.dept_id",
Quota.Community
)
res shouldBe a[ElasticFailure]
val err = res.asInstanceOf[ElasticFailure].elasticError
err.statusCode shouldBe Some(400)
err.operation shouldBe Some("join")
client.scrolledStatement.get() shouldBe null
client.searchedStatement.get() shouldBe null
}

it should "claim write-with-JOIN statements in canHandle but leave join-less writes to core executors" in {
val ext = new CoreDqlExtension()
def parsed(sql: String) = Parser(sql) match {
case Right(s) => s
case Left(e) => fail(s"parse failed: ${e.msg}")
}
ext.canHandle(
parsed("INSERT INTO target SELECT e.name FROM emp e JOIN dept d ON e.dept_id = d.dept_id")
) shouldBe true
ext.canHandle(
parsed("CREATE TABLE target AS SELECT e.name FROM emp e JOIN dept d ON e.dept_id = d.dept_id")
) shouldBe true
// join-less writes keep their existing routing (core DML/DDL executors).
ext.canHandle(parsed("INSERT INTO target SELECT id, name FROM old_users")) shouldBe false
ext.canHandle(parsed("CREATE TABLE target AS SELECT id, name FROM old_users")) shouldBe false
}

// ---- Story P0.6: QueryResults cap-hit recorded on BOTH reject branches ----

behavior of "CoreDqlExtension cap-hit instrumentation (P0.6)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,23 @@ case class FieldSort(
lazy val functions: List[Function] = field.functions
lazy val direction: SortOrder = order.getOrElse(Asc)
lazy val name: String = field.identifierName
// Render via field.sql (not identifierName) so the table-alias qualifier
// survives the AST → SQL round-trip — consumers such as the join planner
// re-parse this output and reject unqualified columns as ambiguous (#158).
override def sql: String =
s"$name $direction${nullOrdering.map(n => s" ${n.sql}").getOrElse("")}"
s"${field.sql} $direction${nullOrdering.map(n => s" ${n.sql}").getOrElse("")}"
override def update(request: SingleSearch): FieldSort = this.copy(
field = field.update(request)
)
// A sort identifier colliding with a table alias (e.g. `ORDER BY e` where `e`
// aliases a FROM table) leaves the alias-split with an empty column name —
// reject it instead of rendering a dangling qualifier (#159).
override def validate(): Either[String, Unit] =
field.tableAlias match {
case Some(alias) if field.name.isEmpty || field.name.endsWith(".") =>
Left(s"Column name expected after table alias '$alias'")
case _ => super.validate()
}
def isScriptSort: Boolean = functions.nonEmpty && !hasAggregation && field.fieldAlias.isEmpty

def isBucketScript: Boolean = functions.nonEmpty && !isAggregation && hasAggregation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,21 @@ object Queries {
"SELECT * FROM Table ORDER BY a DESC NULLS LAST, b ASC NULLS FIRST"
val orderByLowerNulls = "SELECT * FROM Table ORDER BY id desc nulls first"
val orderByNullsNoDirection = "SELECT * FROM Table ORDER BY identifier NULLS LAST"
val joinWithOrderBy =
"SELECT e.name, e.salary, d.dept_name FROM emp e JOIN dept d ON e.dept_id = d.dept_id ORDER BY e.salary DESC"
val joinWithMixedOrderBy =
"SELECT e.name, e.salary, d.dept_name FROM emp e JOIN dept d ON e.dept_id = d.dept_id ORDER BY e.salary DESC, dept_name ASC"
val unnestWithOrderBy =
"SELECT o.id, oi.quantity FROM orders o JOIN UNNEST(o.items) AS oi ORDER BY oi.quantity DESC"
val groupByOrderByCountDistinct =
"SELECT country, COUNT(DISTINCT customer_id) AS cnt FROM orders GROUP BY country ORDER BY COUNT(DISTINCT customer_id) DESC"
val joinWithOrderByNulls =
"SELECT e.name, e.salary FROM emp e JOIN dept d ON e.dept_id = d.dept_id ORDER BY e.salary DESC NULLS LAST"
val orderByBareTableAlias = "SELECT e.name FROM emp e ORDER BY e"
val orderByBareUnnestAlias =
"SELECT o.id, oi.quantity FROM orders o JOIN UNNEST(o.items) AS oi ORDER BY oi"
val rowNumberUnaliasedQualified =
"SELECT e.name, ROW_NUMBER() OVER (ORDER BY e.salary DESC) FROM emp e"
val limit = "SELECT * FROM Table LIMIT 10 OFFSET 2"
val groupByWithOrderByAndLimit: String =
"""SELECT identifier, COUNT(identifier2)
Expand Down Expand Up @@ -734,6 +749,84 @@ class ParserSpec extends AnyFlatSpec with Matchers {
result.isLeft shouldBe true
}

// ── ORDER BY qualifier preservation in SQL round-trip (Issue #158) ─────────

it should "preserve the table-alias qualifier in ORDER BY on a JOIN round-trip" in {
// REPL JOIN path re-renders the parsed statement via .sql before handing it
// to the join planner — losing the qualifier manufactures an "Ambiguous
// column" failure downstream.
val result = Parser(joinWithOrderBy)
result.isRight shouldBe true
val rendered = result.toOption.get.sql
rendered should include("ORDER BY e.salary DESC")
Parser(rendered).isRight shouldBe true
}

it should "keep qualified sorts qualified and bare sorts bare on round-trip" in {
val result = Parser(joinWithMixedOrderBy)
result.isRight shouldBe true
val rendered = result.toOption.get.sql
rendered should include("ORDER BY e.salary DESC, dept_name ASC")
Parser(rendered).isRight shouldBe true
}

it should "render an UNNEST sort with its unnest alias on round-trip" in {
// The unnest alias is re-attached in place of the resolved nested path head
// (oi.quantity, not items.quantity) — mirroring Identifier.sql nested
// handling, so the rendered SQL matches what the user typed.
val result = Parser(unnestWithOrderBy)
result.isRight shouldBe true
val rendered = result.toOption.get.sql
rendered should include("ORDER BY oi.quantity DESC")
Parser(rendered).isRight shouldBe true
}

it should "preserve DISTINCT in an ORDER BY aggregate on round-trip" in {
// FieldSort.sql renders via Identifier.sql, which emits DISTINCT — the
// re-rendered SQL keeps COUNT(DISTINCT x) semantics on re-parse.
val result = Parser(groupByOrderByCountDistinct)
result.isRight shouldBe true
val rendered = result.toOption.get.sql
rendered should include("ORDER BY COUNT(DISTINCT customer_id) DESC")
Parser(rendered).isRight shouldBe true
}

it should "compose qualifier, direction and NULLS ordering on round-trip" in {
val result = Parser(joinWithOrderByNulls)
result.isRight shouldBe true
val rendered = result.toOption.get.sql
rendered should include("ORDER BY e.salary DESC NULLS LAST")
Parser(rendered).isRight shouldBe true
}

it should "reject ORDER BY on a bare table alias (dangling qualifier, Issue #159)" in {
Parser(orderByBareTableAlias) match {
case Left(err) => err.msg should include("Column name expected after table alias 'e'")
case Right(r) => fail(s"expected rejection, got: ${r.sql}")
}
}

it should "reject ORDER BY on a bare UNNEST alias (Issue #159)" in {
Parser(orderByBareUnnestAlias) match {
case Left(err) => err.msg should include("Column name expected after table alias 'oi'")
case Right(r) => fail(s"expected rejection, got: ${r.sql}")
}
}

it should "derive a qualified column name for an unaliased ranking window" in {
// Since #158 the OVER-sort renders qualifier-preserving, so the derived
// name of an unaliased window column embeds the table alias.
val result = Parser(rowNumberUnaliasedQualified)
result.isRight shouldBe true
result.toOption.get match {
case ss: SingleSearch =>
ss.select.fields.map(_.sourceField) should contain(
"row_number_over_order_by_e_salary_desc"
)
case other => fail(s"Expected SingleSearch, got: $other")
}
}

it should "parse LIMIT" in {
val result = Parser(limit)
result.toOption
Expand Down
Loading