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
125 changes: 123 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ val searchResult = client.search(SQLQuery("SELECT * FROM users WHERE age > 25"))
case class Product(id: String, name: String, price: Double, category: String, obsolete: Boolean)

// Scroll through large datasets
val obsoleteProducts: Source[Product, NotUsed] = client.scrollAs[Product](
val obsoleteProducts: Source[Product, NotUsed] = client.scrollAsUnchecked[Product](
"""
|SELECT uuid AS id, name, price, category, outdated AS obsolete FROM products WHERE outdated = true
|""".stripMargin
Expand Down Expand Up @@ -179,7 +179,9 @@ result match {

---

### **3. SQL to Elasticsearch Query Translation**
### **3. SQL compatible **

### **3.1 SQL to Elasticsearch Query DSL**

SoftClient4ES includes a powerful SQL parser that translates standard SQL `SELECT` queries into native Elasticsearch queries.

Expand Down Expand Up @@ -464,6 +466,125 @@ val results = client.search(SQLQuery(sqlQuery))
}
}
```
---

### **3.2. Compile-Time SQL Query Validation**

SoftClient4ES provides **compile-time validation** for SQL queries used with type-safe methods like `searchAs[T]` and `scrollAs[T]`. This ensures that your queries are compatible with your Scala case classes **before your code even runs**, preventing runtime deserialization errors.

#### **Why Compile-Time Validation?**

- ✅ **Catch Errors Early**: Detect missing fields, typos, and type mismatches at compile-time
- ✅ **Type Safety**: Ensure SQL queries match your domain models
- ✅ **Better Developer Experience**: Get helpful error messages with suggestions
- ✅ **Prevent Runtime Failures**: No more Jackson deserialization exceptions in production

#### **Validated Operations**

| Validation | Description | Level |
|------------------------|--------------------------------------------------------|------------|
| **SELECT * Rejection** | Prohibits `SELECT *` to ensure compile-time validation | ❌ ERROR |
| **Required Fields** | Verifies that all required fields are selected | ❌ ERROR |
| **Unknown Fields** | Detects fields that don't exist in the case class | ⚠️ WARNING |
| **Nested Objects** | Validates the structure of nested objects | ❌ ERROR |
| **Nested Collections** | Validates the use of UNNEST for collections | ❌ ERROR |
| **Type Compatibility** | Checks compatibility between SQL and Scala types | ❌ ERROR |

#### **Example 1: Missing Required Field with Nested Object**

```scala
case class Address(
street: String,
city: String,
country: String
)

case class User(
id: String,
name: String,
address: Address // ❌ Required nested object
)

// ❌ COMPILE ERROR: Missing required field 'address'
client.searchAs[User]("SELECT id, name FROM users")
```

**Compile Error:**

```
❌ SQL query does not select the required field: address

Example query:
SELECT id, name, address FROM ...

To fix this, either:
1. Add it to the SELECT clause
2. Make it Option[T] in the case class
3. Provide a default value in the case class definition
```

**✅ Solution:**

```scala
// Option 1: Select the entire nested object (recommended)
client.searchAs[User]("SELECT id, name, address FROM users")

// Option 2: Make the field optional
case class User(
id: String,
name: String,
address: Option[Address] = None
)
client.searchAs[User]("SELECT id, name FROM users")
```

#### **Example 2: Typo Detection with Smart Suggestions**

```scala
case class Product(
id: String,
name: String,
price: Double,
stock: Int
)

// ❌ COMPILE ERROR: Typo in 'name' -> 'nam'
client.searchAs[Product]("SELECT id, nam, price, stock FROM products")
```

**Compile Error:**
```
❌ SQL query does not select the required field: name
You have selected unknown field "nam", did you mean "name"?

Example query:
SELECT id, price, stock, name FROM ...

To fix this, either:
1. Add it to the SELECT clause
2. Make it Option[T] in the case class
3. Provide a default value in the case class definition
```

**✅ Solution:**
```scala
// Fix the typo
client.searchAs[Product]("SELECT id, name, price, stock FROM products")
```

#### **Dynamic Queries (Skip Validation)**

For dynamic SQL queries where validation isn't possible, use the `*Unchecked` variants:

```scala
val dynamicQuery = buildQueryAtRuntime()

// ✅ Skip compile-time validation for dynamic queries
client.searchAsUnchecked[Product](SQLQuery(dynamicQuery))
client.scrollAsUnchecked[Product](dynamicQuery)
```

📖 **[Full SQL Validation Documentation](documentation/sql/validation.md)**

📖 **[Full SQL Documentation](documentation/sql/README.md)**

Expand Down
64 changes: 60 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ ThisBuild / organization := "app.softnetwork"

name := "softclient4es"

ThisBuild / version := "0.11.0"
ThisBuild / version := "0.12.0"

ThisBuild / scalaVersion := scala213

Expand Down Expand Up @@ -103,19 +103,69 @@ lazy val sql = project
.in(file("sql"))
.configs(IntegrationTest)
.settings(
Defaults.itSettings
)

lazy val macros = project
.in(file("macros"))
.configs(IntegrationTest)
.settings(
name := "softclient4es-macros",

libraryDependencies ++= Seq(
"org.scala-lang" % "scala-reflect" % scalaVersion.value,
"org.json4s" %% "json4s-native" % Versions.json4s
),
Defaults.itSettings,
moduleSettings
moduleSettings,
scalacOptions ++= Seq(
"-language:experimental.macros",
"-Ymacro-annotations",
"-Ymacro-debug-lite", // Debug macros
"-Xlog-implicits" // Debug implicits
)
)
.dependsOn(sql)

lazy val macrosTests = project
.in(file("macros-tests"))
.configs(IntegrationTest)
.settings(
name := "softclient4es-macros-tests",
Publish.noPublishSettings,

libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % Versions.scalatest % Test
),

Defaults.itSettings,
moduleSettings,

scalacOptions ++= Seq(
"-language:experimental.macros",
"-Ymacro-debug-lite"
),

Test / scalacOptions += "-Xlog-free-terms"
)
.dependsOn(
macros % "compile->compile",
sql % "compile->compile"
)

lazy val core = project
.in(file("core"))
.configs(IntegrationTest)
.settings(
Defaults.itSettings,
moduleSettings
moduleSettings,
scalacOptions ++= Seq(
"-language:experimental.macros",
"-Ymacro-debug-lite"
)
)
.dependsOn(
sql % "compile->compile;test->test;it->it"
macros % "compile->compile;test->test;it->it"
)

lazy val persistence = project
Expand Down Expand Up @@ -167,6 +217,10 @@ def testkitProject(esVersion: String, ss: Def.SettingsDefinition*): Project = {
Defaults.itSettings,
app.softnetwork.Info.infoSettings,
moduleSettings,
scalacOptions ++= Seq(
"-language:experimental.macros",
"-Ymacro-debug-lite"
),
elasticSearchVersion := esVersion,
buildInfoKeys += BuildInfoKey("elasticVersion" -> elasticSearchVersion.value),
buildInfoObject := "SoftClient4esCoreTestkitBuildInfo",
Expand Down Expand Up @@ -432,6 +486,8 @@ lazy val root = project
)
.aggregate(
sql,
macros,
macrosTests,
bridge,
core,
persistence,
Expand Down
4 changes: 3 additions & 1 deletion core/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ val mockito = Seq(

libraryDependencies ++= akka ++ typesafeConfig ++ http ++
json4s ++ mockito :+ "com.google.code.gson" % "gson" % Versions.gson :+
"com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging
"com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging :+
"org.scalatest" %% "scalatest" % Versions.scalatest % Test

Original file line number Diff line number Diff line change
Expand Up @@ -521,9 +521,9 @@ trait ElasticClientDelegator extends ElasticClientApi with BulkTypes {
* true if the entity was indexed successfully, false otherwise
*/
override def index(
index: JSONResults,
id: JSONResults,
source: JSONResults,
index: String,
id: String,
source: String,
wait: Boolean = false
): ElasticResult[Boolean] =
delegate.index(index, id, source, wait)
Expand Down Expand Up @@ -990,9 +990,10 @@ trait ElasticClientDelegator extends ElasticClientApi with BulkTypes {
* @return
* the entities matching the query
*/
override def searchAs[U](
override def searchAsUnchecked[U](
sqlQuery: SQLQuery
)(implicit m: Manifest[U], formats: Formats): ElasticResult[Seq[U]] = delegate.searchAs(sqlQuery)
)(implicit m: Manifest[U], formats: Formats): ElasticResult[Seq[U]] =
delegate.searchAsUnchecked(sqlQuery)

/** Searches and converts results into typed entities.
*
Expand Down Expand Up @@ -1035,6 +1036,9 @@ trait ElasticClientDelegator extends ElasticClientApi with BulkTypes {
delegate.multisearchAs(elasticQueries, fieldAliases, aggregations)

/** Asynchronous search with conversion to typed entities.
*
* @note
* This method is a variant of searchAsyncAs without compile-time SQL validation.
*
* @param sqlQuery
* the SQL query
Expand All @@ -1043,11 +1047,12 @@ trait ElasticClientDelegator extends ElasticClientApi with BulkTypes {
* @return
* a Future containing the entities
*/
override def searchAsyncAs[U](sqlQuery: SQLQuery)(implicit
override def searchAsyncAsUnchecked[U](sqlQuery: SQLQuery)(implicit
m: Manifest[U],
ec: ExecutionContext,
formats: Formats
): Future[ElasticResult[Seq[U]]] = delegate.searchAsyncAs(sqlQuery)
): Future[ElasticResult[Seq[U]]] =
delegate.searchAsyncAsUnchecked(sqlQuery)

/** Asynchronous search with conversion to typed entities.
*
Expand Down Expand Up @@ -1150,13 +1155,32 @@ trait ElasticClientDelegator extends ElasticClientApi with BulkTypes {
system: ActorSystem
): Source[(Map[String, Any], ScrollMetrics), NotUsed] = delegate.scroll(sql, config)

/** Typed scroll source
/** Scroll and convert results into typed entities from an SQL query.
*
* @note
* This method is a variant of scrollAs without compile-time SQL validation.
*
* @param sql
* - SQL query
* @param config
* - Scroll configuration
* @param system
* - Actor system
* @param m
* - Manifest for type T
* @param formats
* - JSON formats
* @tparam T
* - Target type
* @return
* - Source of tuples (T, ScrollMetrics)
*/
override def scrollAs[T](sql: SQLQuery, config: ScrollConfig)(implicit
override def scrollAsUnchecked[T](sql: SQLQuery, config: ScrollConfig)(implicit
system: ActorSystem,
m: Manifest[T],
formats: Formats
): Source[(T, ScrollMetrics), NotUsed] = delegate.scrollAs(sql, config)
): Source[(T, ScrollMetrics), NotUsed] =
delegate.scrollAsUnchecked(sql, config)

override private[client] def scrollClassic(
elasticQuery: ElasticQuery,
Expand Down
Loading