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
12 changes: 6 additions & 6 deletions documentation/client/adbc_driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
```

Expand Down
16 changes: 8 additions & 8 deletions documentation/client/arrow_flight_sql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading