hyperdb_compile_check/
validate.rs1use crate::db::get_or_init;
27use crate::diagnostic::ValidationError;
28use crate::dry_run::dry_run;
29use crate::error_extract::{classify, ErrorClass};
30use crate::registry::{self, Registry};
31
32pub fn validate_query_as(struct_name: &str, sql: &str) -> Result<(), ValidationError> {
45 let (_table_name, entry) = registry::get_by_struct(struct_name).ok_or_else(|| {
47 ValidationError::StructNotRegistered {
48 struct_name: struct_name.to_owned(),
49 }
50 })?;
51
52 let mut db = get_or_init().lock();
53
54 let schema = run_dry_run_with_seed(sql, &mut db)?;
56
57 drop(db);
58
59 finish_name_check(struct_name, &entry.fields, &schema)
61}
62
63pub fn validate_scalar_sql(sql: &str) -> Result<(), ValidationError> {
77 let mut db = get_or_init().lock();
78
79 let schema = run_dry_run_with_seed(sql, &mut db)?;
80
81 drop(db);
82
83 let col_count = schema.column_count();
84 if col_count != 1 {
85 return Err(ValidationError::HyperError {
86 message: format!(
87 "query_scalar! requires exactly one projected column, but the query projects {col_count}"
88 ),
89 });
90 }
91
92 Ok(())
93}
94
95fn run_dry_run_with_seed(
105 sql: &str,
106 db: &mut crate::db::CompileTimeDb,
107) -> Result<hyperdb_api::ResultSchema, ValidationError> {
108 const MAX_SEED_ROUNDS: usize = 8;
111
112 for _ in 0..MAX_SEED_ROUNDS {
113 match dry_run(db, sql) {
114 Ok(schema) => return Ok(schema),
115 Err(e) => match classify(&e) {
116 ErrorClass::MissingTable(t) => match Registry::seed_if_known(&t, db) {
117 Ok(true) => {} Ok(false) => {
119 return Err(ValidationError::TablesNotRegistered { tables: vec![t] })
120 }
121 Err(seed_err) => {
122 return Err(ValidationError::HyperError {
123 message: format!("{seed_err}"),
124 })
125 }
126 },
127 ErrorClass::SyntaxError(msg) => {
128 return Err(ValidationError::SqlSyntaxError { message: msg })
129 }
130 ErrorClass::MissingColumn(col) => {
131 return Err(ValidationError::UnknownColumn { column: col })
132 }
133 ErrorClass::Other(msg) => return Err(ValidationError::HyperError { message: msg }),
134 },
135 }
136 }
137
138 Err(ValidationError::HyperError {
139 message: format!(
140 "compile-time validation exceeded {MAX_SEED_ROUNDS} seed-and-retry rounds; \
141 ensure all tables referenced by this query are registered via \
142 `#[derive(Table)] #[hyperdb(register)]`"
143 ),
144 })
145}
146
147fn finish_name_check(
150 struct_name: &str,
151 struct_fields: &[String],
152 schema: &hyperdb_api::ResultSchema,
153) -> Result<(), ValidationError> {
154 let result_cols: std::collections::HashSet<&str> = schema
155 .columns()
156 .iter()
157 .map(hyperdb_api::ResultColumn::name)
158 .collect();
159
160 let missing: Vec<String> = struct_fields
161 .iter()
162 .filter(|f| !result_cols.contains(f.as_str()))
163 .cloned()
164 .collect();
165
166 if missing.is_empty() {
167 Ok(())
168 } else {
169 Err(ValidationError::MissingColumns {
170 struct_name: struct_name.to_owned(),
171 missing,
172 })
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn setup_users() {
181 registry::register(
182 "User",
183 "users",
184 "CREATE TABLE IF NOT EXISTS users (id BIGINT, name TEXT, email TEXT)",
185 vec!["id".into(), "name".into(), "email".into()],
186 );
187 }
188
189 #[test]
190 fn struct_not_registered_error() {
191 let err = validate_query_as("Ghost", "SELECT 1").unwrap_err();
192 assert!(
193 matches!(err, ValidationError::StructNotRegistered { .. }),
194 "expected StructNotRegistered, got: {err}"
195 );
196 }
197
198 #[test]
199 #[ignore = "requires HYPERD_PATH; run manually"]
200 fn valid_query_passes() {
201 setup_users();
202 validate_query_as("User", "SELECT id, name, email FROM users").unwrap();
203 }
204
205 #[test]
206 #[ignore = "requires HYPERD_PATH; run manually"]
207 fn extra_column_in_result_is_ok() {
208 registry::register(
209 "SlimUser",
210 "slim_users",
211 "CREATE TABLE IF NOT EXISTS slim_users (id BIGINT, name TEXT, extra TEXT)",
212 vec!["id".into(), "name".into()],
213 );
214 validate_query_as("SlimUser", "SELECT * FROM slim_users").unwrap();
215 }
216
217 #[test]
218 #[ignore = "requires HYPERD_PATH; run manually"]
219 fn missing_column_error() {
220 setup_users();
221 let err = validate_query_as("User", "SELECT id, name FROM users").unwrap_err();
222 assert!(
223 matches!(err, ValidationError::MissingColumns { .. }),
224 "expected MissingColumns, got: {err}"
225 );
226 let msg = err.to_diagnostic();
227 assert!(
228 msg.contains("email"),
229 "missing column name in message: {msg}"
230 );
231 }
232
233 #[test]
234 #[ignore = "requires HYPERD_PATH; run manually"]
235 fn seed_and_retry_on_missing_table() {
236 registry::register(
237 "Order",
238 "orders",
239 "CREATE TABLE IF NOT EXISTS orders (id BIGINT, total DOUBLE PRECISION)",
240 vec!["id".into(), "total".into()],
241 );
242 validate_query_as("Order", "SELECT id, total FROM orders").unwrap();
243 validate_query_as("Order", "SELECT id, total FROM orders").unwrap();
244 }
245
246 #[test]
247 #[ignore = "requires HYPERD_PATH; run manually"]
248 fn unregistered_table_in_sql_error() {
249 registry::register(
250 "Known",
251 "known",
252 "CREATE TABLE IF NOT EXISTS known (id BIGINT)",
253 vec!["id".into()],
254 );
255 let err = validate_query_as("Known", "SELECT * FROM nonexistent_xyz").unwrap_err();
256 assert!(
257 matches!(err, ValidationError::TablesNotRegistered { .. }),
258 "expected TablesNotRegistered, got: {err}"
259 );
260 }
261}