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
72 changes: 72 additions & 0 deletions core/src/main/scala/app/softnetwork/elastic/client/DateRoute.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2025 SOFTNETWORK
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package app.softnetwork.elastic.client

import app.softnetwork.elastic.sql.`type`.{SQLType, SQLTypes}

/** How ONE date position of a set operation's leg is read, decided BEFORE any value: from the type
* its branch declares, its date field's mapping `format` and the Elasticsearch major the statement
* runs on -- never from a value, so the answer cannot depend on the order the rows come in.
*
* - [[DateRoute.Typed]]: the declared type, every row -- a date read by its format, as that
* major indexed it, or a computed value. A value core cannot read there fails the statement,
* at any row (`SetOperationValues.Unreadable.Fail`).
* - [[DateRoute.AsStored]]: `VARCHAR`, the value as stored, every row -- where core cannot read
* every value of the format for sure, which the format alone says
* ([[MappedDateFormat.unread]]), or where the major is not known.
*/
private[client] sealed trait DateRoute

private[client] object DateRoute {

/** Read by its format, or as a computed value: the declared type, every row. */
case object Typed extends DateRoute

/** `VARCHAR` as stored, every row, decided before any row; `reason` names the format and why. */
final case class AsStored(reason: String) extends DateRoute

/** `Typed` for a type that is not a date read by its format -- the positions
* `SetOperationValues.readAs` reads with one: a `DATE`, a `TIMESTAMP` or a `TIME` that has a
* format, Elasticsearch's default included -- else by the major `dates` names (its `dialect`,
* asked here, once per statement) and what the format reads ([[MappedDateFormat.unread]]):
* `AsStored` when that major is not known, or when core does not read every value of the format
* for sure.
*/
def of(
sqlType: SQLType,
format: Option[String],
dates: SetOperationValues.DateReading
): DateRoute =
(sqlType, format) match {
case (SQLTypes.Date | SQLTypes.Timestamp | SQLTypes.Time, Some(spec)) =>
dates.dialect match {
case None =>
AsStored(
s"the date format '$spec' is read as the Elasticsearch version does, " +
dates.elasticVersion.fold("which is not known")(v => s"and core reads dates of no $v")
)
case Some(dialect) =>
MappedDateFormat(spec, dialect, dates.clock).unread.fold[DateRoute](Typed) { why =>
AsStored(
s"core cannot read every value of the date format '$spec' as ${dialect.label} " +
s"does: $why"
)
}
}
case _ => Typed
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ trait ElasticClientDelegator extends ElasticClientApi with BulkTypes {
override private[client] def executeVersion(): ElasticResult[String] =
delegate.executeVersion()

override private[client] def buildElasticMajor: Option[Int] =
delegate.buildElasticMajor

/** The delegate's: every wrapper of a client reads dates as ONE version, asked once. */
override private[elastic] lazy val dateDialectVersion: Option[String] =
delegate.dateDialectVersion

/** Get Elasticsearch cluster name.
*
* @return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,25 @@ private[client] object JavaDatePattern {
/** Why the pattern is not read, if it is not. */
def unread: Option[String] = compiled.left.toOption

/** The kind of its zone letter, when it reads zone data: `"id"` (`VV`) or `"name"` (`z`, `v`).
*/
override private[client] def zoneData: Option[String] =
compiled.toOption.map(_.zone).filter(kind => kind == "id" || kind == "name")

/** On a major resolving SMARTLY (Elasticsearch 6.8's `8`-prefixed mode), why the pattern reads
* an AM/PM with no hour, decided from ITS letters (quoted text aside) before any value --
* `None` where it does not. The rule is the one of [[shape]], applied to the two classes it
* reads there: the AM/PM and the hour, any hour letter (`h`, `K`, `k`, `H`) standing as `h`
* (the measured `ah`). [[shape]] applies it to the letters a text carried, which a pattern
* read in a lexical pass (weeks of a week-based year on 6.8: `YYYY-ww a`) meets only value by
* value.
*/
private[client] lazy val amPmWithoutHour: Option[String] =
tokens(pattern).toOption.flatMap { ts =>
val classes = ts.collect { case Letters(c, n) => letterClass(c, n) }.toSet
shape(classes.collect { case 'a' => 'a'; case 'h' | 'K' | 'k' | 'H' => 'h' }, "")
}

def parse(text: String): Parse =
compiled match {
case Left(reason) => Unmeasured(s"'$pattern' is not a date format core reads: $reason")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,10 @@ private[client] object JodaDatePattern {
/** Why the pattern is not read, if it is not. */
def unread: Option[String] = compiled.left.toOption

/** The kind of its zone letter, when it reads zone data: `"id"` (`ZZZ`) or `"name"` (`z`). */
override private[client] def zoneData: Option[String] =
compiled.toOption.map(_._2).filter(kind => kind == "id" || kind == "name")

def parse(text: String): Parse =
compiled match {
case Left(reason) => Unmeasured(s"'$pattern' is not a date format core reads: $reason")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,35 @@ private[client] final case class MappedDateFormat(

private def parses(text: String): Boolean =
alternatives.exists(_.parse(text) != NotParsed)

/** Why core does not read EVERY value of the format for sure, decided from the format alone,
* before any value -- `None` when every value it reads, it reads for sure: an alternative core
* does not read on this major ([[Unknown]]: a letter, a width or a combination no measurement
* covers, a name of Elasticsearch 6.8's `8`-prefixed mode); a custom pattern whose zone letter
* reads zone names or region ids ([[Alternative.zoneData]]), whose offset comes from the zone
* data of the JVM reading it, at any row; a pattern of Elasticsearch 6.8's `8`-prefixed mode
* reading an AM/PM with no hour ([[JavaDatePattern.Pattern.amPmWithoutHour]]). The first such
* alternative names the reason.
*/
private[client] lazy val unread: Option[String] =
alternatives.iterator.map(unreadBy).collectFirst { case Some(reason) => reason }

private def unreadBy(alternative: Alternative): Option[String] =
alternative match {
case unknown: Unknown =>
Some(unknown.parse(spec) match {
case Unmeasured(reason) => reason
case _ => s"'${unknown.name}' is not a date format core reads"
})
case pattern: JavaDatePattern.Pattern if pattern.amPmWithoutHour.isDefined =>
pattern.amPmWithoutHour
case other =>
other.zoneData.map { kind =>
s"its zone letter reads ${if (kind == "id") "region ids" else "zone names"}, whose " +
"offset comes from the zone data of the JVM reading it, not Elasticsearch's: core " +
"reads only Z, an offset, UTC, GMT and UT"
}
}
}

private[client] object MappedDateFormat {
Expand Down Expand Up @@ -286,6 +315,12 @@ private[client] object MappedDateFormat {

/** A name core reads, as opposed to a custom pattern or an unknown name. */
def isName: Boolean = true

/** The zone data its zone letter reads, decided from the format alone: `"name"` (a zone name:
* `java.time`'s `z` and `v`, Joda-Time's `z` and `zzzz`) or `"id"` (a region id: `java.time`'s
* `VV`, Joda-Time's `ZZZ`) -- `None` for no zone, or an offset.
*/
private[client] def zoneData: Option[String] = None
}

/** A name or a pattern core does not read on this major: a value it decides is undecidable. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2458,14 +2458,16 @@ trait SearchApi extends ElasticConversion with ElasticClientHelpers with SchemaC
}
}

/** The Elasticsearch version the statement runs on (`VersionApi.version`, cached per client): a
* set operation reads a date field's values as THAT major reads the field's mapping format
* (`SetOperationValues.converters`, which asks for it at most once per statement, and only when
* a converting position reads a date field). `None` for a client that cannot tell it.
/** The Elasticsearch version the statement reads dates as (`VersionApi.dateDialectVersion`, asked
* once per client): a set operation reads a date field's values as THAT major reads the field's
* mapping format (`SetOperationValues.converters`, which asks for it at most once per statement,
* and only when a position reads a date field: a converting position, or one whose branches
* agree and hold a date field with a mapping format of its own). For a user denied `GET /`, the
* major the client module is built for; `None` for a client that can tell neither.
*/
private def setOperationVersion: Option[String] =
this match {
case api: VersionApi => api.version.toOption
case api: VersionApi => api.dateDialectVersion
case _ => None
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ private[elastic] object SetOperationValues {
*
* `version` is the Elasticsearch version the statement runs on (`VersionApi.version`): a date
* field's values are read as THAT major reads its mapping format ([[MappedDateFormat]]). It is
* asked at most ONCE per statement, and only when a converting position reads a date field.
* asked at most ONCE per statement, and only when a position reads a date field: a converting
* position, or one whose branches agree and hold a date field with a mapping format of its own
* ([[readsMappedDates]]).
*/
private[client] def converters(
multiple: MultiSearch,
Expand Down Expand Up @@ -160,6 +162,174 @@ private[elastic] object SetOperationValues {
}
}

/** ONE column of ONE leg of a set operation, typed BEFORE any value is read ([[legSchemas]]).
*
* @param sqlType
* the type the leg's values have there, at every depth: the branch's declared type, a date
* position whose format core cannot read for sure `VARCHAR` ([[DateRoute.AsStored]])
* @param read
* the reader of one raw value there, answering `sqlType`'s class ([[legReaders]]' classes;
* `VARCHAR` a `String`, the text as stored)
* @param asStored
* the date positions answered as stored -- their path (`tsz`, `oz.z`) and why
*/
private[elastic] final case class LegSlot(
sqlType: SetOperationType,
read: Any => Any,
asStored: Seq[LegSlot.AsStored]
)

private[elastic] object LegSlot {

/** A date position answered as stored: its `path` -- the field, `.` before a struct's field --
* and the `reason`, naming its format.
*/
final case class AsStored(path: String, reason: String)
}

/** A leg's slots, by position; `None` at a position whose values are typed as they come. */
private[elastic] final case class LegSchema(slots: IndexedSeq[Option[LegSlot]])

/** THE SCHEMA of each leg of a set operation, decided BEFORE any value is read -- in leg order,
* `None` for a leg that declares no projection (`SELECT *`) -- or, `Left`, the statement's
* refusal (`MultiSearch.branchTypes`' message word for word). Same inputs as [[legReaders]]; the
* version is asked at most ONCE, and only when a position holds a date read by its format (the
* default one too).
*
* Each position of a leg is a [[LegSlot]] -- the leg's type there at every depth and the reader
* of its values -- or `None` where its values are typed as they come:
*
* - A date read by its mapping format is routed before any row ([[DateRoute.of]]): read as the
* branch declares it, as [[legReaders]] reads it with [[Unreadable.Fail]] -- a value core
* cannot read fails, at any row; or, where core cannot read every value of the format for
* sure (a zone name or a region id, a letter no measurement covers) or the major is not
* known, `VARCHAR`, the text as stored, every row -- at the column, in a struct's field and
* in a list's element, where the branches agree or not. The routed type is read by the SAME
* reader as every other position ([[legReader]]): a `VARCHAR` position is the text leg's.
* - `None` where [[legReader]] reads nothing (a column whose type is not known, `ANY` -- a
* `WITH` or a derived table aliasing the column --, a `NULL` literal); where the leg's OR
* the column's type holds, at any depth, a type not known (`ANY`, a `STRUCT` whose fields
* are not known) ([[unknownInside]]); and where the leg's type holds, at any depth, one read
* as it comes until the engine types it (`NUMERIC`, `TEMPORAL`, `VARBINARY`, `REAL`)
* ([[valueTyped]]).
*
* Built ONCE per statement; no decision is made per row.
*/
private[elastic] def legSchemas(
requests: Seq[SingleSearch],
operators: Seq[SetOperator],
version: => Option[String]
): Either[String, Seq[Option[LegSchema]]] =
MultiSearch.columnTypes(requests, operators).map { columns =>
val names = columnNames(requests, columns.size)
val dates = DateReading(version)
requests.indices.map { leg =>
val search = requests(leg)
// an opaque `SELECT *` -- a projection of another width, or the branch
// `MultiSearch.columnTypes` types at no column -- has no position to type
if (
search.select.fieldsWithComputedAliases.size != columns.size ||
columns.forall(_.branches.lift(leg).flatten.isEmpty)
) None
else
Some(LegSchema(columns.indices.map { position =>
legSlot(columns(position), leg, names(position), fieldOf(search, position), dates)
}))
}
}

/** Leg `index`'s slot at ONE column ([[legSchemas]]): its branch's type routed ([[routed]]), read
* by [[legReader]] over the column whose branch `index` is the routed type -- `None` where
* [[legReader]] reads nothing, where the branch's type is read as it comes ([[valueTyped]]), or
* where the column's type is not known at some depth ([[unknownInside]]).
*/
private[client] def legSlot(
column: SetOperationColumn,
index: Int,
name: String,
field: String,
dates: DateReading
): Option[LegSlot] =
column.branches
.lift(index)
.flatten
.filterNot(branch => valueTyped(branch) || unknownInside(column.result))
// whether the slot exists is [[legReader]]'s to say over the column AS DECLARED, before any
// date is routed (routing asks the version): routing makes a date `VARCHAR`, never `NULL`,
// and leaves the column's type alone, so [[legReader]] answers the same over the routed one
.filter(_ => Option(legReader(column, index, name, field, dates, Unreadable.Fail)).isDefined)
.flatMap { branch =>
val (slotType, asStored) = routed(branch, if (field.isEmpty) name else field, dates)
Option(
legReader(
column.copy(branches = column.branches.updated(index, Some(slotType))),
index,
name,
field,
dates,
Unreadable.Fail
)
).map(LegSlot(slotType, _, asStored))
}

/** A branch's type with every date position [[DateRoute.of]] answers as stored made `VARCHAR`, at
* every depth -- a struct's field (`path.field`), a list's element (the list's path) -- and
* those positions.
*/
private def routed(
t: SetOperationType,
path: String,
dates: DateReading
): (SetOperationType, Seq[LegSlot.AsStored]) =
t.sqlType match {
case SQLTypes.Struct =>
val fields = t.fields.map { f =>
val (valueType, asStored) = routed(f.valueType, s"$path.${f.name}", dates)
(f.copy(valueType = valueType), asStored)
}
(t.copy(fields = fields.map(_._1)), fields.flatMap(_._2))
case SQLTypes.Array(_) =>
t.element.fold((t, Seq.empty[LegSlot.AsStored])) { e =>
val (element, asStored) = routed(e, path, dates)
(t.copy(sqlType = SQLTypes.Array(element.sqlType), element = Some(element)), asStored)
}
case _ =>
DateRoute.of(t.sqlType, t.format, dates) match {
case DateRoute.Typed => (t, Nil)
case DateRoute.AsStored(reason) =>
(SetOperationType(SQLTypes.Varchar), Seq(LegSlot.AsStored(path, reason)))
}
}

/** Whether a leg's type holds, at any depth, a type whose values are typed as they come: one not
* known ([[unknownInside]]), or one the relational engine types from its values until it reads
* it (`NUMERIC`, `TEMPORAL`, `VARBINARY`, `REAL`).
*/
private def valueTyped(t: SetOperationType): Boolean =
holds(t) {
case SQLTypes.Any | SQLTypes.Numeric | SQLTypes.Temporal | SQLTypes.VarBinary |
SQLTypes.Real =>
true
case _ => false
}

/** Whether a type holds, at any depth, a type not known: `ANY`, or a `STRUCT` whose fields are
* not known. Where a COLUMN's type does, [[legReader]] reads every leg's value there as it
* comes, not as the leg's own type, which another leg left unknown there: no leg has a slot.
*/
private def unknownInside(t: SetOperationType): Boolean =
holds(t)(_ == SQLTypes.Any)

/** Whether `t` holds, at the column or in a struct's field or a list's element at any depth, a
* scalar `leaf` answers for, or a `STRUCT` whose fields are not known.
*/
private def holds(t: SetOperationType)(leaf: SQLType => Boolean): Boolean =
t.sqlType match {
case SQLTypes.Struct => t.fields.isEmpty || t.fields.exists(f => holds(f.valueType)(leaf))
case SQLTypes.Array(_) => t.element.forall(e => holds(e)(leaf))
case scalar => leaf(scalar)
}

/** What a leg's reader ([[legReaders]]) answers for a DATE it cannot read -- one its mapping
* format does not read, or does not read without guessing (a zone name, a pattern letter no
* measurement covers), or any date of a format when the Elasticsearch version is not known -- at
Expand Down Expand Up @@ -213,8 +383,9 @@ private[elastic] object SetOperationValues {
mapped(t) || t.fields.exists(f => mappedInside(f.valueType)) || t.element.exists(mappedInside)

/** How a statement reads its date fields: as the Elasticsearch major it runs on reads their
* mapping format ([[MappedDateFormat.Dialect]]). The version is asked once, the first time a
* converting position reads a date field, and never for a statement that has none.
* mapping format ([[MappedDateFormat.Dialect]]). The version is asked at most ONCE per
* statement, and only when a position reads a date field -- which positions do, each caller
* says: [[converters]], [[legReaders]], [[legSchemas]].
*/
private[client] final class DateReading(
version: => Option[String],
Expand Down
Loading
Loading