Skip to content

Commit a9fb3a9

Browse files
committed
add macros to validate SQL queries at compile-time
1 parent 66480b1 commit a9fb3a9

File tree

8 files changed

+675
-7
lines changed

8 files changed

+675
-7
lines changed

build.sbt

Lines changed: 51 additions & 3 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,8 +103,54 @@ 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
@@ -115,7 +161,7 @@ lazy val core = project
115161
moduleSettings
116162
)
117163
.dependsOn(
118-
sql % "compile->compile;test->test;it->it"
164+
macros % "compile->compile;test->test;it->it"
119165
)
120166

121167
lazy val persistence = project
@@ -432,6 +478,8 @@ lazy val root = project
432478
)
433479
.aggregate(
434480
sql,
481+
macros,
482+
macrosTests,
435483
bridge,
436484
core,
437485
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+
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package app.softnetwork.elastic.sql.macros
2+
3+
import org.scalatest.flatspec.AnyFlatSpec
4+
import org.scalatest.matchers.should.Matchers
5+
6+
class SQLQueryValidatorSpec extends AnyFlatSpec with Matchers {
7+
8+
// ============================================================
9+
// Positive Tests (Should Compile)
10+
// ============================================================
11+
12+
"SQLQueryValidator" should "validate all numeric types" in {
13+
assertCompiles("""
14+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
15+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
16+
import app.softnetwork.elastic.sql.macros.SQLQueryValidatorSpec.Numbers
17+
import app.softnetwork.elastic.sql.query.SQLQuery
18+
19+
TestElasticClientApi.searchAs[Numbers](
20+
"SELECT tiny::TINYINT as tiny, small::SMALLINT as small, normal::INT as normal, big::BIGINT as big, huge::BIGINT as huge, decimal::DOUBLE as decimal, r::REAL as r FROM numbers"
21+
)""")
22+
}
23+
24+
it should "validate string types" in {
25+
assertCompiles("""
26+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
27+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
28+
import app.softnetwork.elastic.sql.macros.SQLQueryValidatorSpec.Strings
29+
import app.softnetwork.elastic.sql.query.SQLQuery
30+
31+
TestElasticClientApi.searchAs[Strings](
32+
"SELECT vchar::VARCHAR, c::CHAR, text FROM strings"
33+
)""")
34+
}
35+
36+
it should "validate temporal types" in {
37+
assertCompiles("""
38+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
39+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
40+
import app.softnetwork.elastic.sql.macros.SQLQueryValidatorSpec.Temporal
41+
import app.softnetwork.elastic.sql.query.SQLQuery
42+
43+
TestElasticClientApi.searchAs[Temporal](
44+
"SELECT d::DATE, t::TIME, dt::DATETIME, ts::TIMESTAMP FROM temporal"
45+
)""")
46+
}
47+
48+
it should "validate Product with all fields" in {
49+
assertCompiles("""
50+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
51+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
52+
import app.softnetwork.elastic.sql.macros.SQLQueryValidatorSpec.Product
53+
import app.softnetwork.elastic.sql.query.SQLQuery
54+
55+
TestElasticClientApi.searchAs[Product](
56+
"SELECT id, name, price::DOUBLE, stock::INT, active::BOOLEAN, createdAt::DATETIME FROM products"
57+
)""")
58+
}
59+
60+
it should "validate with aliases" in {
61+
assertCompiles("""
62+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
63+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
64+
import app.softnetwork.elastic.sql.macros.SQLQueryValidatorSpec.Product
65+
import app.softnetwork.elastic.sql.query.SQLQuery
66+
67+
TestElasticClientApi.searchAs[Product](
68+
"SELECT product_id AS id, product_name AS name, product_price::DOUBLE AS price, product_stock::INT AS stock, is_active::BOOLEAN AS active, created_at::TIMESTAMP AS createdAt FROM products"
69+
)""")
70+
}
71+
72+
// ============================================================
73+
// Negative Tests (Should NOT Compile)
74+
// ============================================================
75+
76+
it should "reject missing fields" in {
77+
assertDoesNotCompile("""
78+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
79+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
80+
import app.softnetwork.elastic.sql.macros.SQLQueryValidatorSpec.Product
81+
import app.softnetwork.elastic.sql.query.SQLQuery
82+
83+
TestElasticClientApi.searchAs[Product](
84+
"SELECT id, name FROM products"
85+
)""")
86+
}
87+
88+
it should "reject invalid field names" in {
89+
assertDoesNotCompile("""
90+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
91+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
92+
import app.softnetwork.elastic.sql.macros.SQLQueryValidatorSpec.Product
93+
import app.softnetwork.elastic.sql.query.SQLQuery
94+
95+
TestElasticClientApi.searchAs[Product](
96+
"SELECT id, invalid_name, price, stock, active, createdAt FROM products"
97+
)""")
98+
}
99+
100+
it should "reject type mismatches" in {
101+
assertDoesNotCompile("""
102+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
103+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
104+
import app.softnetwork.elastic.sql.query.SQLQuery
105+
106+
case class WrongTypes(id: Int, name: Int)
107+
108+
TestElasticClientApi.searchAs[WrongTypes](
109+
"SELECT id::LONG, name FROM products"
110+
)""")
111+
}
112+
113+
it should "suggest closest field names" in {
114+
assertDoesNotCompile("""
115+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
116+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
117+
import app.softnetwork.elastic.sql.macros.SQLQueryValidatorSpec.Product
118+
import app.softnetwork.elastic.sql.query.SQLQuery
119+
120+
TestElasticClientApi.searchAs[Product](
121+
"SELECT id, nam, price, stock, active, createdAt FROM products"
122+
)""")
123+
}
124+
125+
it should "reject dynamic queries (non-literals)" in {
126+
assertDoesNotCompile("""
127+
import app.softnetwork.elastic.client.macros.TestElasticClientApi
128+
import app.softnetwork.elastic.client.macros.TestElasticClientApi.defaultFormats
129+
import app.softnetwork.elastic.sql.macros.SQLQueryValidatorSpec.Product
130+
import app.softnetwork.elastic.sql.query.SQLQuery
131+
132+
val dynamicField = "name"
133+
TestElasticClientApi.searchAs[Product](
134+
s"SELECT id, $dynamicField FROM products"
135+
)""")
136+
}
137+
}
138+
139+
object SQLQueryValidatorSpec {
140+
case class Product(
141+
id: String,
142+
name: String,
143+
price: Double,
144+
stock: Int,
145+
active: Boolean,
146+
createdAt: java.time.LocalDateTime
147+
)
148+
149+
case class Numbers(
150+
tiny: Byte,
151+
small: Short,
152+
normal: Int,
153+
big: Long,
154+
huge: BigInt,
155+
decimal: Double,
156+
r: Float
157+
)
158+
159+
case class Strings(
160+
vchar: String,
161+
c: String,
162+
text: String
163+
)
164+
165+
case class Temporal(
166+
d: java.time.LocalDate,
167+
t: java.time.LocalTime,
168+
dt: java.time.LocalDateTime,
169+
ts: java.time.Instant
170+
)
171+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package app.softnetwork.elastic.client.macros
2+
3+
import app.softnetwork.elastic.sql.macros.SQLQueryMacros
4+
import app.softnetwork.elastic.sql.query.SQLQuery
5+
import org.json4s.{DefaultFormats, Formats}
6+
7+
import scala.language.experimental.macros
8+
9+
/** Test trait that uses macros for compile-time validation.
10+
*/
11+
trait TestElasticClientApi {
12+
13+
/** Search with compile-time SQL validation (macro).
14+
*/
15+
def searchAs[T](query: String)(implicit m: Manifest[T], formats: Formats): Seq[T] =
16+
macro SQLQueryMacros.searchAsImpl[T]
17+
18+
/** Search without compile-time validation (runtime).
19+
*/
20+
def searchAsUnchecked[T](
21+
sqlQuery: SQLQuery
22+
)(implicit m: Manifest[T], formats: Formats): Seq[T] = {
23+
// Dummy implementation for tests
24+
Seq.empty[T]
25+
}
26+
}
27+
28+
object TestElasticClientApi extends TestElasticClientApi {
29+
// default implicit for the tests
30+
implicit val defaultFormats: Formats = DefaultFormats
31+
}

0 commit comments

Comments
 (0)