diff --git a/documentation/client/adbc_driver.md b/documentation/client/adbc_driver.md index 532a0117..7a6db0c0 100644 --- a/documentation/client/adbc_driver.md +++ b/documentation/client/adbc_driver.md @@ -122,13 +122,13 @@ adbc.elastic { } elastic.credentials { - host = "localhost" # env: ES_HOST - port = 9200 # env: ES_PORT + host = "localhost" # env: ELASTIC_HOST + port = 9200 # env: ELASTIC_PORT # Choose one authentication method: - user = "elastic" # env: ES_USER (username/password) - password = "changeme" # env: ES_PASSWORD - # api-key = "" # env: ES_API_KEY (API key) - # bearer = "" # env: ES_BEARER (bearer token) + username = "elastic" # env: ELASTIC_CREDENTIALS_USERNAME (username/password) + password = "changeme" # env: ELASTIC_CREDENTIALS_PASSWORD + # api-key = "" # env: ELASTIC_CREDENTIALS_API_KEY (API key) + # bearer-token = "" # env: ELASTIC_CREDENTIALS_BEARER_TOKEN (bearer token) } ``` diff --git a/documentation/client/arrow_flight_sql.md b/documentation/client/arrow_flight_sql.md index f05dae29..22eb922c 100644 --- a/documentation/client/arrow_flight_sql.md +++ b/documentation/client/arrow_flight_sql.md @@ -30,10 +30,10 @@ There are two Flight SQL servers, and these quickstarts cover only the first: ```bash docker run -p 32010:32010 \ - -e ES_HOST=elasticsearch \ - -e ES_PORT=9200 \ - -e ES_USER=elastic \ - -e ES_PASSWORD=changeme \ + -e ELASTIC_HOST=elasticsearch \ + -e ELASTIC_PORT=9200 \ + -e ELASTIC_CREDENTIALS_USERNAME=elastic \ + -e ELASTIC_CREDENTIALS_PASSWORD=changeme \ softnetwork/softclient4es8-arrow-flight-sql:latest ``` @@ -133,10 +133,10 @@ arrow.flight { } elastic.credentials { - host = "localhost" # env: ES_HOST - port = 9200 # env: ES_PORT - user = "elastic" # env: ES_USER - password = "changeme" # env: ES_PASSWORD + host = "localhost" # env: ELASTIC_HOST (ELASTIC_IP wins when both are set) + port = 9200 # env: ELASTIC_PORT + username = "elastic" # env: ELASTIC_CREDENTIALS_USERNAME + password = "changeme" # env: ELASTIC_CREDENTIALS_PASSWORD } ``` diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/type/ValueCoercion.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/type/ValueCoercion.scala index de0dd50d..2e058f9b 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/type/ValueCoercion.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/type/ValueCoercion.scala @@ -137,6 +137,22 @@ object ValueCoercion { throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to DECIMAL") } + /** Coerce a runtime value to a `java.sql.Date`. + * + * A `Number` is read as **epoch milliseconds**, the shape Elasticsearch returns for a `date` + * field whose `_source` stores the value numerically (`"event_ts": 1735689600000`) — ES's + * default `date` format is `strict_date_optional_time||epoch_millis`, and + * `ElasticConversion.jsonNodeToAny` only attempts a temporal parse on TEXTUAL nodes, so a + * numeric date arrives here as a `java.lang.Long`. Without this arm every such value fell to the + * catch-all and threw `Cannot convert java.lang.Long to DATE`, which took down the whole + * cross-index JOIN leg (softclient4es-arrow#168) and `ResultSet.getDate` with it. The sibling + * [[coerceToTimestamp]] has always had this arm; the asymmetry was unintentional. + * + * Deliberately NOT mirrored in [[coerceToTime]]: a bare number in a TIME position is ambiguous + * (epoch millis vs millis-of-day), Elasticsearch has no `time` mapping token — `SQLTypes.Time` + * is unreachable from an ES mapping, which resolves `"date"` to `SQLTypes.Date` — and no defect + * has been reported there. Add it when a real value shape demands it, not by symmetry. + */ def coerceToDate(value: Any): java.sql.Date = value match { case null => null case d: java.sql.Date => d @@ -154,6 +170,7 @@ object ValueCoercion { case s: String => try { java.sql.Date.valueOf(s) } catch { case _: Exception => throw new java.sql.SQLException(s"Cannot parse '$s' as DATE") } + case n: Number => new java.sql.Date(n.longValue()) case _ => throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to DATE") } diff --git a/sql/src/test/scala/app/softnetwork/elastic/sql/type/ValueCoercionSpec.scala b/sql/src/test/scala/app/softnetwork/elastic/sql/type/ValueCoercionSpec.scala new file mode 100644 index 00000000..7bf9a71a --- /dev/null +++ b/sql/src/test/scala/app/softnetwork/elastic/sql/type/ValueCoercionSpec.scala @@ -0,0 +1,79 @@ +package app.softnetwork.elastic.sql.`type` + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.time.{Instant, LocalDate, ZoneOffset} + +/** softclient4es-arrow#168 — `coerceToDate` had no `Number` arm while `coerceToTimestamp` did, so + * an Elasticsearch `date` field stored as epoch millis threw `Cannot convert java.lang.Long to + * DATE` and took down every cross-index JOIN leg that projected it (and `ResultSet.getDate` with + * it). + */ +class ValueCoercionSpec extends AnyFlatSpec with Matchers { + + // 2026-01-01T00:00:00Z plus 13h 45m — a value with a real time-of-day, so a naive + // implementation that truncated (or failed to) is visible. + private val EpochMillis: Long = 1767225600000L + (13L * 3600 + 45L * 60) * 1000L + private val ExpectedDate: LocalDate = + Instant.ofEpochMilli(EpochMillis).atZone(ZoneOffset.UTC).toLocalDate + + "coerceToDate" should "read a java.lang.Long as epoch milliseconds (arrow#168)" in { + val d = ValueCoercion.coerceToDate(java.lang.Long.valueOf(EpochMillis)) + d should not be null + d.getTime shouldBe EpochMillis + } + + it should "read every other Number shape as epoch milliseconds" in { + ValueCoercion.coerceToDate(java.lang.Integer.valueOf(0)).getTime shouldBe 0L + ValueCoercion + .coerceToDate(java.math.BigDecimal.valueOf(EpochMillis)) + .getTime shouldBe EpochMillis + ValueCoercion.coerceToDate(EpochMillis.toDouble).getTime shouldBe EpochMillis + } + + it should "keep every pre-existing conversion working" in { + ValueCoercion.coerceToDate(null) shouldBe null + val ld = LocalDate.of(2026, 1, 2) + ValueCoercion.coerceToDate(ld) shouldBe java.sql.Date.valueOf(ld) + ValueCoercion.coerceToDate("2026-01-02") shouldBe java.sql.Date.valueOf("2026-01-02") + ValueCoercion.coerceToDate(Instant.ofEpochMilli(EpochMillis)).getTime shouldBe EpochMillis + } + + it should "still reject a value with no date meaning" in { + a[java.sql.SQLException] should be thrownBy ValueCoercion.coerceToDate(new Object) + } + + // Negative control for the DELIBERATE asymmetry recorded in coerceToDate's scaladoc: a bare + // number in a TIME position is ambiguous, so coerceToTime must NOT grow a Number arm by + // symmetry. If someone "fixes" that for consistency, this test tells them it was a decision. + "coerceToTime" should "deliberately reject a Number" in { + a[java.sql.SQLException] should be thrownBy ValueCoercion.coerceToTime( + java.lang.Long.valueOf(EpochMillis) + ) + } + + "coerceToTimestamp" should "remain the reference for the Number arm" in { + ValueCoercion + .coerceToTimestamp(java.lang.Long.valueOf(EpochMillis)) + .getTime shouldBe EpochMillis + } + + // Documents the ES-mapping consequence that made arrow#168 reachable at all: an ES `date` + // mapping resolves to SQLTypes.Date (typeId "DATE"), which arrow-core turns into a Date64 + // vector — while the _source value may be a plain number. + "SQLTypes" should "resolve the ES 'date' mapping token to SQLTypes.Date" in { + SQLTypes("date") shouldBe SQLTypes.Date + SQLTypes.Date.typeId shouldBe "DATE" + } + + // Subject restated on purpose — this is about coerceToDate, not about SQLTypes. + "coerceToDate" should "map an epoch-millis Long to the same UTC calendar date in every JVM zone" in { + // Derived from Instant + ZoneOffset.UTC, so the expectation carries no dependency on the + // machine's default zone — the assertion holds in every CI timezone. + Instant + .ofEpochMilli(ValueCoercion.coerceToDate(java.lang.Long.valueOf(EpochMillis)).getTime) + .atZone(ZoneOffset.UTC) + .toLocalDate shouldBe ExpectedDate + } +}