Skip to content

Commit 610064f

Browse files
authored
Merge pull request #24 from SOFTNETWORK-APP/feature/checkSQLAtCompileTime
check SQL queries at compile time using macros
2 parents 66480b1 + a824d33 commit 610064f

File tree

21 files changed

+2599
-86
lines changed

21 files changed

+2599
-86
lines changed

README.md

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ val searchResult = client.search(SQLQuery("SELECT * FROM users WHERE age > 25"))
9999
case class Product(id: String, name: String, price: Double, category: String, obsolete: Boolean)
100100

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

180180
---
181181

182-
### **3. SQL to Elasticsearch Query Translation**
182+
### **3. SQL compatible **
183+
184+
### **3.1 SQL to Elasticsearch Query DSL**
183185

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

@@ -464,6 +466,125 @@ val results = client.search(SQLQuery(sqlQuery))
464466
}
465467
}
466468
```
469+
---
470+
471+
### **3.2. Compile-Time SQL Query Validation**
472+
473+
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.
474+
475+
#### **Why Compile-Time Validation?**
476+
477+
-**Catch Errors Early**: Detect missing fields, typos, and type mismatches at compile-time
478+
-**Type Safety**: Ensure SQL queries match your domain models
479+
-**Better Developer Experience**: Get helpful error messages with suggestions
480+
-**Prevent Runtime Failures**: No more Jackson deserialization exceptions in production
481+
482+
#### **Validated Operations**
483+
484+
| Validation | Description | Level |
485+
|------------------------|--------------------------------------------------------|------------|
486+
| **SELECT * Rejection** | Prohibits `SELECT *` to ensure compile-time validation | ❌ ERROR |
487+
| **Required Fields** | Verifies that all required fields are selected | ❌ ERROR |
488+
| **Unknown Fields** | Detects fields that don't exist in the case class | ⚠️ WARNING |
489+
| **Nested Objects** | Validates the structure of nested objects | ❌ ERROR |
490+
| **Nested Collections** | Validates the use of UNNEST for collections | ❌ ERROR |
491+
| **Type Compatibility** | Checks compatibility between SQL and Scala types | ❌ ERROR |
492+
493+
#### **Example 1: Missing Required Field with Nested Object**
494+
495+
```scala
496+
case class Address(
497+
street: String,
498+
city: String,
499+
country: String
500+
)
501+
502+
case class User(
503+
id: String,
504+
name: String,
505+
address: Address // ❌ Required nested object
506+
)
507+
508+
// ❌ COMPILE ERROR: Missing required field 'address'
509+
client.searchAs[User]("SELECT id, name FROM users")
510+
```
511+
512+
**Compile Error:**
513+
514+
```
515+
❌ SQL query does not select the required field: address
516+
517+
Example query:
518+
SELECT id, name, address FROM ...
519+
520+
To fix this, either:
521+
1. Add it to the SELECT clause
522+
2. Make it Option[T] in the case class
523+
3. Provide a default value in the case class definition
524+
```
525+
526+
**✅ Solution:**
527+
528+
```scala
529+
// Option 1: Select the entire nested object (recommended)
530+
client.searchAs[User]("SELECT id, name, address FROM users")
531+
532+
// Option 2: Make the field optional
533+
case class User(
534+
id: String,
535+
name: String,
536+
address: Option[Address] = None
537+
)
538+
client.searchAs[User]("SELECT id, name FROM users")
539+
```
540+
541+
#### **Example 2: Typo Detection with Smart Suggestions**
542+
543+
```scala
544+
case class Product(
545+
id: String,
546+
name: String,
547+
price: Double,
548+
stock: Int
549+
)
550+
551+
// ❌ COMPILE ERROR: Typo in 'name' -> 'nam'
552+
client.searchAs[Product]("SELECT id, nam, price, stock FROM products")
553+
```
554+
555+
**Compile Error:**
556+
```
557+
❌ SQL query does not select the required field: name
558+
You have selected unknown field "nam", did you mean "name"?
559+
560+
Example query:
561+
SELECT id, price, stock, name FROM ...
562+
563+
To fix this, either:
564+
1. Add it to the SELECT clause
565+
2. Make it Option[T] in the case class
566+
3. Provide a default value in the case class definition
567+
```
568+
569+
**✅ Solution:**
570+
```scala
571+
// Fix the typo
572+
client.searchAs[Product]("SELECT id, name, price, stock FROM products")
573+
```
574+
575+
#### **Dynamic Queries (Skip Validation)**
576+
577+
For dynamic SQL queries where validation isn't possible, use the `*Unchecked` variants:
578+
579+
```scala
580+
val dynamicQuery = buildQueryAtRuntime()
581+
582+
// ✅ Skip compile-time validation for dynamic queries
583+
client.searchAsUnchecked[Product](SQLQuery(dynamicQuery))
584+
client.scrollAsUnchecked[Product](dynamicQuery)
585+
```
586+
587+
📖 **[Full SQL Validation Documentation](documentation/sql/validation.md)**
467588

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

build.sbt

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ ThisBuild / organization := "app.softnetwork"
1919

2020
name := "softclient4es"
2121

22-
ThisBuild / version := "0.11.0"
22+
ThisBuild / version := "0.12.0"
2323

2424
ThisBuild / scalaVersion := scala213
2525

@@ -103,19 +103,69 @@ lazy val sql = project
103103
.in(file("sql"))
104104
.configs(IntegrationTest)
105105
.settings(
106+
Defaults.itSettings
107+
)
108+
109+
lazy val macros = project
110+
.in(file("macros"))
111+
.configs(IntegrationTest)
112+
.settings(
113+
name := "softclient4es-macros",
114+
115+
libraryDependencies ++= Seq(
116+
"org.scala-lang" % "scala-reflect" % scalaVersion.value,
117+
"org.json4s" %% "json4s-native" % Versions.json4s
118+
),
106119
Defaults.itSettings,
107-
moduleSettings
120+
moduleSettings,
121+
scalacOptions ++= Seq(
122+
"-language:experimental.macros",
123+
"-Ymacro-annotations",
124+
"-Ymacro-debug-lite", // Debug macros
125+
"-Xlog-implicits" // Debug implicits
126+
)
127+
)
128+
.dependsOn(sql)
129+
130+
lazy val macrosTests = project
131+
.in(file("macros-tests"))
132+
.configs(IntegrationTest)
133+
.settings(
134+
name := "softclient4es-macros-tests",
135+
Publish.noPublishSettings,
136+
137+
libraryDependencies ++= Seq(
138+
"org.scalatest" %% "scalatest" % Versions.scalatest % Test
139+
),
140+
141+
Defaults.itSettings,
142+
moduleSettings,
143+
144+
scalacOptions ++= Seq(
145+
"-language:experimental.macros",
146+
"-Ymacro-debug-lite"
147+
),
148+
149+
Test / scalacOptions += "-Xlog-free-terms"
150+
)
151+
.dependsOn(
152+
macros % "compile->compile",
153+
sql % "compile->compile"
108154
)
109155

110156
lazy val core = project
111157
.in(file("core"))
112158
.configs(IntegrationTest)
113159
.settings(
114160
Defaults.itSettings,
115-
moduleSettings
161+
moduleSettings,
162+
scalacOptions ++= Seq(
163+
"-language:experimental.macros",
164+
"-Ymacro-debug-lite"
165+
)
116166
)
117167
.dependsOn(
118-
sql % "compile->compile;test->test;it->it"
168+
macros % "compile->compile;test->test;it->it"
119169
)
120170

121171
lazy val persistence = project
@@ -167,6 +217,10 @@ def testkitProject(esVersion: String, ss: Def.SettingsDefinition*): Project = {
167217
Defaults.itSettings,
168218
app.softnetwork.Info.infoSettings,
169219
moduleSettings,
220+
scalacOptions ++= Seq(
221+
"-language:experimental.macros",
222+
"-Ymacro-debug-lite"
223+
),
170224
elasticSearchVersion := esVersion,
171225
buildInfoKeys += BuildInfoKey("elasticVersion" -> elasticSearchVersion.value),
172226
buildInfoObject := "SoftClient4esCoreTestkitBuildInfo",
@@ -432,6 +486,8 @@ lazy val root = project
432486
)
433487
.aggregate(
434488
sql,
489+
macros,
490+
macrosTests,
435491
bridge,
436492
core,
437493
persistence,

core/build.sbt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,6 @@ val mockito = Seq(
3232

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

core/src/main/scala/app/softnetwork/elastic/client/ElasticClientDelegator.scala

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -521,9 +521,9 @@ trait ElasticClientDelegator extends ElasticClientApi with BulkTypes {
521521
* true if the entity was indexed successfully, false otherwise
522522
*/
523523
override def index(
524-
index: JSONResults,
525-
id: JSONResults,
526-
source: JSONResults,
524+
index: String,
525+
id: String,
526+
source: String,
527527
wait: Boolean = false
528528
): ElasticResult[Boolean] =
529529
delegate.index(index, id, source, wait)
@@ -990,9 +990,10 @@ trait ElasticClientDelegator extends ElasticClientApi with BulkTypes {
990990
* @return
991991
* the entities matching the query
992992
*/
993-
override def searchAs[U](
993+
override def searchAsUnchecked[U](
994994
sqlQuery: SQLQuery
995-
)(implicit m: Manifest[U], formats: Formats): ElasticResult[Seq[U]] = delegate.searchAs(sqlQuery)
995+
)(implicit m: Manifest[U], formats: Formats): ElasticResult[Seq[U]] =
996+
delegate.searchAsUnchecked(sqlQuery)
996997

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

10371038
/** Asynchronous search with conversion to typed entities.
1039+
*
1040+
* @note
1041+
* This method is a variant of searchAsyncAs without compile-time SQL validation.
10381042
*
10391043
* @param sqlQuery
10401044
* the SQL query
@@ -1043,11 +1047,12 @@ trait ElasticClientDelegator extends ElasticClientApi with BulkTypes {
10431047
* @return
10441048
* a Future containing the entities
10451049
*/
1046-
override def searchAsyncAs[U](sqlQuery: SQLQuery)(implicit
1050+
override def searchAsyncAsUnchecked[U](sqlQuery: SQLQuery)(implicit
10471051
m: Manifest[U],
10481052
ec: ExecutionContext,
10491053
formats: Formats
1050-
): Future[ElasticResult[Seq[U]]] = delegate.searchAsyncAs(sqlQuery)
1054+
): Future[ElasticResult[Seq[U]]] =
1055+
delegate.searchAsyncAsUnchecked(sqlQuery)
10511056

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

1153-
/** Typed scroll source
1158+
/** Scroll and convert results into typed entities from an SQL query.
1159+
*
1160+
* @note
1161+
* This method is a variant of scrollAs without compile-time SQL validation.
1162+
*
1163+
* @param sql
1164+
* - SQL query
1165+
* @param config
1166+
* - Scroll configuration
1167+
* @param system
1168+
* - Actor system
1169+
* @param m
1170+
* - Manifest for type T
1171+
* @param formats
1172+
* - JSON formats
1173+
* @tparam T
1174+
* - Target type
1175+
* @return
1176+
* - Source of tuples (T, ScrollMetrics)
11541177
*/
1155-
override def scrollAs[T](sql: SQLQuery, config: ScrollConfig)(implicit
1178+
override def scrollAsUnchecked[T](sql: SQLQuery, config: ScrollConfig)(implicit
11561179
system: ActorSystem,
11571180
m: Manifest[T],
11581181
formats: Formats
1159-
): Source[(T, ScrollMetrics), NotUsed] = delegate.scrollAs(sql, config)
1182+
): Source[(T, ScrollMetrics), NotUsed] =
1183+
delegate.scrollAsUnchecked(sql, config)
11601184

11611185
override private[client] def scrollClassic(
11621186
elasticQuery: ElasticQuery,

0 commit comments

Comments
 (0)