From f74b29098503a5a0830d8b0e278379b8e6e7174e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 29 Jul 2026 07:36:55 +0200 Subject: [PATCH 1/5] fix(sql): preserve table-alias qualifier in FieldSort SQL rendering (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldSort.sql rendered via identifierName, which drops the tableAlias re-attached by Identifier.sql — so a parsed JOIN query re-rendered via SingleSearch.sql lost the ORDER BY qualifier (e.salary -> salary) and the join planner's re-parse failed with 400 'Ambiguous column'. Render via field.sql instead; FieldSort.name / identifierName / aliasOrName are untouched (they key the ES-side sort maps). Story: _bmad-output/implementation-artifacts/bug-158-repl-join-order-by-qualifier.md Co-Authored-By: Claude Fable 5 --- .../elastic/sql/query/OrderBy.scala | 5 ++- .../elastic/sql/parser/ParserSpec.scala | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/OrderBy.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/OrderBy.scala index 910f2ec5..a854fb62 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/OrderBy.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/OrderBy.scala @@ -42,8 +42,11 @@ 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) ) 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 3412872f..d1ac2d98 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 @@ -114,6 +114,14 @@ 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 limit = "SELECT * FROM Table LIMIT 10 OFFSET 2" val groupByWithOrderByAndLimit: String = """SELECT identifier, COUNT(identifier2) @@ -734,6 +742,40 @@ 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 + result.toOption.get.sql should include("ORDER BY e.salary DESC") + } + + it should "keep qualified sorts qualified and bare sorts bare on round-trip" in { + val result = Parser(joinWithMixedOrderBy) + result.isRight shouldBe true + result.toOption.get.sql should include("ORDER BY e.salary DESC, dept_name ASC") + } + + 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 + result.toOption.get.sql should include("ORDER BY oi.quantity DESC") + } + + 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 + result.toOption.get.sql should include("ORDER BY COUNT(DISTINCT customer_id) DESC") + } + it should "parse LIMIT" in { val result = Parser(limit) result.toOption From 877ce62abccbe5a363007c86d524994b5062ed7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 29 Jul 2026 10:09:10 +0200 Subject: [PATCH 2/5] test(sql): harden #158 round-trip coverage after code review - re-parse assertions on all 4 qualifier round-trip tests (pin the join-planner re-parse failure mode at unit level) - qualified sort + NULLS LAST composition round-trip test - pin unaliased ranking-window derived column name with qualified OVER-sort (row_number_over_order_by_e_salary_desc) per review decision Story: bug-158-repl-join-order-by-qualifier (code review) Co-Authored-By: Claude Fable 5 --- .../elastic/sql/parser/ParserSpec.scala | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) 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 d1ac2d98..023c1ea4 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 @@ -122,6 +122,10 @@ object Queries { "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 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) @@ -750,13 +754,17 @@ class ParserSpec extends AnyFlatSpec with Matchers { // column" failure downstream. val result = Parser(joinWithOrderBy) result.isRight shouldBe true - result.toOption.get.sql should include("ORDER BY e.salary DESC") + 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 - result.toOption.get.sql should include("ORDER BY e.salary DESC, dept_name ASC") + 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 { @@ -765,7 +773,9 @@ class ParserSpec extends AnyFlatSpec with Matchers { // handling, so the rendered SQL matches what the user typed. val result = Parser(unnestWithOrderBy) result.isRight shouldBe true - result.toOption.get.sql should include("ORDER BY oi.quantity DESC") + 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 { @@ -773,7 +783,31 @@ class ParserSpec extends AnyFlatSpec with Matchers { // re-rendered SQL keeps COUNT(DISTINCT x) semantics on re-parse. val result = Parser(groupByOrderByCountDistinct) result.isRight shouldBe true - result.toOption.get.sql should include("ORDER BY COUNT(DISTINCT customer_id) DESC") + 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 "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 { From 1a8b7788d44c17758ee1492301d1fd2ddc851659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 29 Jul 2026 10:30:12 +0200 Subject: [PATCH 3/5] fix(core): fail loudly on cross-index JOIN without a join-capable extension (#157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoreDqlExtension (inherited by EnforcedDqlExtension) translates only the first FROM table — a cross-index JOIN reaching it silently executed as a single-index search (joined columns NULL, orphans kept), or worse wrote that wrong data via INSERT ... SELECT / CREATE TABLE ... AS SELECT. - guard in execute: any statement whose (embedded) SELECT has from.enrichmentRequired now fails with a clear 400 pointing at the softclient4es-arrow-extensions jar (Java 11+) - canHandle additionally claims write-with-JOIN statements (mirroring the arrow JoinExtension) so they cannot fall through to the core DML/DDL executors; join-less writes keep their existing routing - UNNEST joins excluded by construction (joinedTables = StandardJoin only) - 6 new CoreDqlExtensionSpec tests (SELECT, UNION leg, UNNEST carve-out, INSERT...SELECT, CTAS, canHandle claims) With the arrow JoinExtension registered at priority 40 join statements never reach this guard; without it users get a clear error instead of wrong results. Co-Authored-By: Claude Fable 5 --- .../client/extensions/CoreDqlExtension.scala | 39 ++++++++- .../extensions/CoreDqlExtensionSpec.scala | 83 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDqlExtension.scala b/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDqlExtension.scala index 9fb84a8b..94f7d62e 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDqlExtension.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDqlExtension.scala @@ -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( @@ -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) => @@ -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) // ════════════════════════════════════════════════════════════════════ diff --git a/core/src/test/scala/app/softnetwork/elastic/client/extensions/CoreDqlExtensionSpec.scala b/core/src/test/scala/app/softnetwork/elastic/client/extensions/CoreDqlExtensionSpec.scala index c5b509eb..6bf1cec1 100644 --- a/core/src/test/scala/app/softnetwork/elastic/client/extensions/CoreDqlExtensionSpec.scala +++ b/core/src/test/scala/app/softnetwork/elastic/client/extensions/CoreDqlExtensionSpec.scala @@ -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)" From c5fcc8b370267f9e83039ad2d68f83edf49991bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 29 Jul 2026 10:30:20 +0200 Subject: [PATCH 4/5] fix(sql): reject ORDER BY on a bare table alias instead of a dangling qualifier (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sort identifier colliding with a FROM alias (ORDER BY e where e aliases a table, or ORDER BY oi on an UNNEST alias) left GenericIdentifier.update's alias-split with an empty column name, silently rendering malformed SQL (ORDER BY e. after #158; ORDER BY ASC before it). FieldSort.validate now rejects the empty/dangling column name with "Column name expected after table alias ''" — surfaced through OrderBy.validate at Parser.apply, chaining to the FunctionChain aggregation-chain validation otherwise. 2 new ParserSpec tests (table alias, UNNEST alias). Co-Authored-By: Claude Fable 5 --- .../softnetwork/elastic/sql/query/OrderBy.scala | 9 +++++++++ .../elastic/sql/parser/ParserSpec.scala | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/OrderBy.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/OrderBy.scala index a854fb62..7905fb77 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/OrderBy.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/OrderBy.scala @@ -50,6 +50,15 @@ case class FieldSort( 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 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 023c1ea4..984121b0 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 @@ -124,6 +124,9 @@ object Queries { "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" @@ -796,6 +799,20 @@ class ParserSpec extends AnyFlatSpec with Matchers { 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. From 1c0098a9dc43e4e1e8e58f518a5f7475ddc5d84f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 29 Jul 2026 11:11:35 +0200 Subject: [PATCH 5/5] fix(build): bump version to 0.20.1 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index c2f5614f..807c6178 100644 --- a/build.sbt +++ b/build.sbt @@ -20,7 +20,7 @@ ThisBuild / organization := "app.softnetwork" name := "softclient4es" -ThisBuild / version := "0.20.0" +ThisBuild / version := "0.20.1" ThisBuild / scalaVersion := scala213