diff --git a/.gitignore b/.gitignore index b2e60a97..4af03106 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ testkit/target* *.sc _bmad* docker/data +.claude \ No newline at end of file diff --git a/build.sbt b/build.sbt index d2c2cea1..37d17813 100644 --- a/build.sbt +++ b/build.sbt @@ -69,7 +69,7 @@ ThisBuild / javaOptions ++= Seq( "--add-opens=java.base/sun.nio.ch=ALL-UNNAMED" ) -Test / javaOptions ++= (javaOptions.value) +Test / javaOptions ++= javaOptions.value ThisBuild / resolvers ++= Seq( "Softnetwork Server" at "https://softnetwork.jfrog.io/artifactory/releases/", diff --git a/core/src/main/resources/help/commands/ddl/create_table.json b/core/src/main/resources/help/commands/ddl/create_table.json index 99c71f62..90bbf3e8 100644 --- a/core/src/main/resources/help/commands/ddl/create_table.json +++ b/core/src/main/resources/help/commands/ddl/create_table.json @@ -11,7 +11,7 @@ " ...", " [PRIMARY KEY (column, ...)]", ")", - "[PARTITIONED BY (column granularity)]", + "[PARTITIONED BY column (granularity)]", "[OPTIONS (", " [settings = (setting = value, ...)],", " [mappings = (mapping = value, ...)],", diff --git a/core/src/main/scala/app/softnetwork/elastic/client/Cli.scala b/core/src/main/scala/app/softnetwork/elastic/client/Cli.scala index e68a5571..173bf175 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/Cli.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/Cli.scala @@ -75,6 +75,7 @@ object Cli extends App { var bearerToken: Option[String] = None var executeFile: Option[String] = None var executeCommand: Option[String] = None + var promptPassword = false var i = 0 while (i < args.length) { @@ -99,6 +100,10 @@ object Cli extends App { password = Some(args(i + 1)) i += 2 + case "-W" => + promptPassword = true + i += 1 + case "-k" | "--api-key" => apiKey = Some(args(i + 1)) i += 2 @@ -126,6 +131,17 @@ object Cli extends App { } } + if (promptPassword) { + val console = System.console() + if (console == null) { + System.err.println("Error: -W requires an interactive terminal") + System.exit(1) + } + System.err.print("Enter password: ") + System.err.flush() + password = Some(new String(console.readPassword())) + } + CliConfig( scheme, host, @@ -153,6 +169,7 @@ object Cli extends App { | -p, --port Elasticsearch port (default: 9200) | -u, --username Username for authentication | -P, --password Password for authentication + | -W Prompt for password interactively (input not echoed) | -k, --api-key API key for authentication | -b, --bearer-token Bearer token for authentication | -f, --file Execute SQL from file and exit diff --git a/es6/rest/src/main/scala/app/softnetwork/elastic/client/rest/RestHighLevelClientApi.scala b/es6/rest/src/main/scala/app/softnetwork/elastic/client/rest/RestHighLevelClientApi.scala index adf1dd57..ae0fe0d1 100644 --- a/es6/rest/src/main/scala/app/softnetwork/elastic/client/rest/RestHighLevelClientApi.scala +++ b/es6/rest/src/main/scala/app/softnetwork/elastic/client/rest/RestHighLevelClientApi.scala @@ -1607,9 +1607,9 @@ trait RestHighLevelClientScrollApi extends ScrollApi with RestHighLevelClientHel Some((nextSearchAfter, hits)) } } - }(system, logger).recover { case ex: Exception => + }(system, logger).recoverWith { case ex: Exception => logger.error(s"Search after failed after retries: ${ex.getMessage}", ex) - None + Future.failed(ex) } } .mapConcat(identity) diff --git a/project/Versions.scala b/project/Versions.scala index 1eff6775..5f60cb1c 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -16,7 +16,7 @@ object Versions { val scalaLogging = "3.9.2" - val logback = "1.2.3" + val logback = "1.5.32" val slf4j = "1.7.36" 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 new file mode 100644 index 00000000..de0dd50d --- /dev/null +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/type/ValueCoercion.scala @@ -0,0 +1,207 @@ +/* + * 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.sql.`type` + +/** Runtime value type inference and coercion utilities. + * + * These functions operate on plain Scala/Java runtime values (not Painless scripts) and are + * independent of any JDBC or Arrow-specific API. Both the JDBC driver and the Arrow Flight SQL + * server delegate to this object for type inference and value coercion. + * + * JDBC-specific mappings (`toJdbcType`, `toJdbcTypeName`, `coerceValue`, etc.) remain in the + * `driver` module's `TypeMapping` object, which delegates here for the general-purpose methods. + */ +object ValueCoercion { + + // ─── Type inference ───────────────────────────────────────────────────────── + + /** Infer the [[SQLType]] from a runtime value. */ + def inferType(value: Any): SQLType = value match { + case null => SQLTypes.Null + case _: Int => SQLTypes.Int + case _: Long => SQLTypes.BigInt + case _: Double => SQLTypes.Double + case _: Float => SQLTypes.Real + case _: Boolean => SQLTypes.Boolean + case _: Short => SQLTypes.SmallInt + case _: Byte => SQLTypes.TinyInt + case _: java.math.BigDecimal => SQLTypes.Double + case _: BigDecimal => SQLTypes.Double + case _: java.sql.Date => SQLTypes.Date + case _: java.sql.Time => SQLTypes.Time + case _: java.sql.Timestamp => SQLTypes.Timestamp + case _: java.time.LocalDate => SQLTypes.Date + case _: java.time.LocalTime => SQLTypes.Time + case _: java.time.LocalDateTime => SQLTypes.Timestamp + case _: java.time.Instant => SQLTypes.Timestamp + case _: java.time.ZonedDateTime => SQLTypes.Timestamp + case _: java.time.temporal.TemporalAccessor => SQLTypes.Timestamp + case _: Seq[_] => SQLTypes.Array(SQLTypes.Any) + case _: Map[_, _] => SQLTypes.Struct + case _: Array[Byte] => SQLTypes.VarBinary + case _: String => SQLTypes.Varchar + case _: Number => SQLTypes.Double + case _ => SQLTypes.Varchar + } + + // ─── Coercions ─────────────────────────────────────────────────────────────── + + def coerceToString(value: Any): String = value match { + case null => null + case s: String => s + case seq: Seq[_] => seq.mkString("[", ", ", "]") + case map: Map[_, _] => map.map { case (k, v) => s"$k: $v" }.mkString("{", ", ", "}") + case other => other.toString + } + + def coerceToInt(value: Any): java.lang.Integer = value match { + case null => null + case n: Number => n.intValue() + case s: String => java.lang.Integer.valueOf(s) + case b: Boolean => if (b) 1 else 0 + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to INT") + } + + def coerceToLong(value: Any): java.lang.Long = value match { + case null => null + case n: Number => n.longValue() + case s: String => java.lang.Long.valueOf(s) + case b: Boolean => if (b) 1L else 0L + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to BIGINT") + } + + def coerceToDouble(value: Any): java.lang.Double = value match { + case null => null + case n: Number => n.doubleValue() + case s: String => java.lang.Double.valueOf(s) + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to DOUBLE") + } + + def coerceToFloat(value: Any): java.lang.Float = value match { + case null => null + case n: Number => n.floatValue() + case s: String => java.lang.Float.valueOf(s) + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to REAL") + } + + def coerceToBoolean(value: Any): java.lang.Boolean = value match { + case null => null + case b: Boolean => b + case n: Number => n.intValue() != 0 + case s: String => java.lang.Boolean.valueOf(s) + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to BOOLEAN") + } + + def coerceToByte(value: Any): java.lang.Byte = value match { + case null => null + case n: Number => n.byteValue() + case s: String => java.lang.Byte.valueOf(s) + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to TINYINT") + } + + def coerceToShort(value: Any): java.lang.Short = value match { + case null => null + case n: Number => n.shortValue() + case s: String => java.lang.Short.valueOf(s) + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to SMALLINT") + } + + def coerceToBigDecimal(value: Any): java.math.BigDecimal = value match { + case null => null + case bd: java.math.BigDecimal => bd + case bd: BigDecimal => bd.bigDecimal + case n: Number => java.math.BigDecimal.valueOf(n.doubleValue()) + case s: String => new java.math.BigDecimal(s) + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to DECIMAL") + } + + def coerceToDate(value: Any): java.sql.Date = value match { + case null => null + case d: java.sql.Date => d + case ts: java.sql.Timestamp => new java.sql.Date(ts.getTime) + case ld: java.time.LocalDate => java.sql.Date.valueOf(ld) + case ldt: java.time.LocalDateTime => java.sql.Date.valueOf(ldt.toLocalDate) + case zdt: java.time.ZonedDateTime => java.sql.Date.valueOf(zdt.toLocalDate) + case i: java.time.Instant => new java.sql.Date(i.toEpochMilli) + case t: java.time.temporal.TemporalAccessor => + try { + java.sql.Date.valueOf(java.time.LocalDate.from(t)) + } catch { + case _: Exception => throw new java.sql.SQLException("Cannot convert temporal to DATE") + } + case s: String => + try { java.sql.Date.valueOf(s) } + catch { case _: Exception => throw new java.sql.SQLException(s"Cannot parse '$s' as DATE") } + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to DATE") + } + + def coerceToTime(value: Any): java.sql.Time = value match { + case null => null + case t: java.sql.Time => t + case lt: java.time.LocalTime => java.sql.Time.valueOf(lt) + case ldt: java.time.LocalDateTime => java.sql.Time.valueOf(ldt.toLocalTime) + case s: String => + try { java.sql.Time.valueOf(s) } + catch { case _: Exception => throw new java.sql.SQLException(s"Cannot parse '$s' as TIME") } + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to TIME") + } + + def coerceToTimestamp(value: Any): java.sql.Timestamp = value match { + case null => null + case ts: java.sql.Timestamp => ts + case d: java.sql.Date => new java.sql.Timestamp(d.getTime) + case i: java.time.Instant => java.sql.Timestamp.from(i) + case ldt: java.time.LocalDateTime => java.sql.Timestamp.valueOf(ldt) + case zdt: java.time.ZonedDateTime => java.sql.Timestamp.from(zdt.toInstant) + case ld: java.time.LocalDate => java.sql.Timestamp.valueOf(ld.atStartOfDay()) + case t: java.time.temporal.TemporalAccessor => + try { + java.sql.Timestamp.from(java.time.Instant.from(t)) + } catch { + case _: Exception => + try { + java.sql.Timestamp.valueOf(java.time.LocalDateTime.from(t)) + } catch { + case _: Exception => + throw new java.sql.SQLException("Cannot convert temporal to TIMESTAMP") + } + } + case s: String => + try { java.sql.Timestamp.valueOf(s) } + catch { + case _: Exception => + try { java.sql.Timestamp.from(java.time.Instant.parse(s)) } + catch { + case _: Exception => + throw new java.sql.SQLException(s"Cannot parse '$s' as TIMESTAMP") + } + } + case n: Number => new java.sql.Timestamp(n.longValue()) + case _ => + throw new java.sql.SQLException(s"Cannot convert ${value.getClass.getName} to TIMESTAMP") + } +}