Skip to content

Commit 0136776

Browse files
committed
highlight syntax errors
1 parent e705a71 commit 0136776

File tree

6 files changed

+90
-62
lines changed

6 files changed

+90
-62
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
```
3838
- Before, you would usually build the link manually with `CONCAT('/product.sql?product=', product_name)`, which would fail if the product name contained special characters like '&'. The new `sqlpage.link` function takes care of encoding the parameters correctly.
3939
- Calls to `json_object` are now accepted as arguments to SQLPage functions. This allows you to pass complex data structures to functions such as `sqlpage.fetch`, `sqlpage.run_sql`, and `sqlpage.link`.
40+
- Better syntax error messages, with a short quotation of the part of the SQL file that caused the error:
41+
- ![syntax error](https://github.com/user-attachments/assets/86ab5628-87bd-4dea-b6fe-64ea19afcdc3)
4042

4143
## 0.24.0 (2024-06-23)
4244
- in the form component, searchable `select` fields now support more than 50 options. They used to display only the first 50 options.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
use std::fmt::Write;
2+
3+
/// Display a database error with a highlighted line and character offset.
4+
#[must_use]
5+
pub fn display_db_error(context: &str, query: &str, db_err: sqlx::error::Error) -> anyhow::Error {
6+
let mut msg = format!("{context}:\n");
7+
let offset = if let sqlx::error::Error::Database(db_err) = &db_err {
8+
db_err.offset()
9+
} else {
10+
None
11+
};
12+
if let Some(mut offset) = offset {
13+
for (line_no, line) in query.lines().enumerate() {
14+
if offset > line.len() {
15+
offset -= line.len() + 1;
16+
} else {
17+
highlight_line_offset(&mut msg, line, offset);
18+
write!(msg, "line {}, character {offset}", line_no + 1).unwrap();
19+
break;
20+
}
21+
}
22+
} else {
23+
write!(msg, "{}", query.lines().next().unwrap_or_default()).unwrap();
24+
}
25+
anyhow::Error::new(db_err).context(msg)
26+
}
27+
28+
/// Highlight a line with a character offset.
29+
pub fn highlight_line_offset(msg: &mut String, line: &str, offset: usize) {
30+
writeln!(msg, "{line}").unwrap();
31+
writeln!(msg, "{}⬆️", " ".repeat(offset)).unwrap();
32+
}
33+
34+
/// Highlight an error given a line and a character offset
35+
/// line and `col_num` are 1-based
36+
pub fn quote_source_with_highlight(source: &str, line_num: u64, col_num: u64) -> String {
37+
let mut msg = String::new();
38+
let mut current_line_num: u64 = 1; // 1-based line number
39+
let col_num_usize = usize::try_from(col_num)
40+
.unwrap_or_default()
41+
.saturating_sub(1);
42+
for line in source.lines() {
43+
current_line_num += 1;
44+
if current_line_num + 1 == line_num || current_line_num == line_num + 1 {
45+
writeln!(msg, "{line}").unwrap();
46+
} else if current_line_num == line_num {
47+
highlight_line_offset(&mut msg, line, col_num_usize);
48+
} else if current_line_num > line_num + 1 {
49+
break;
50+
}
51+
}
52+
msg
53+
}
54+
55+
#[test]
56+
fn test_quote_source_with_highlight() {
57+
let source = "SELECT *\nFROM table\nWHERE <syntax error>";
58+
let expected = "FROM table\nWHERE <syntax error>\n ⬆️\n";
59+
assert_eq!(quote_source_with_highlight(source, 3, 6), expected);
60+
}

src/webserver/database/execute_queries.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use crate::webserver::http::SingleOrVec;
1616
use crate::webserver::http_request_info::RequestInfo;
1717

1818
use super::syntax_tree::{extract_req_param, StmtParam};
19-
use super::{highlight_sql_error, Database, DbItem};
19+
use super::{error_highlighting::display_db_error, Database, DbItem};
2020
use sqlx::any::{AnyArguments, AnyQueryResult, AnyRow, AnyStatement, AnyTypeInfo};
2121
use sqlx::pool::PoolConnection;
2222
use sqlx::{Any, Arguments, Column, Either, Executor, Row as _, Statement, ValueRef};
@@ -33,7 +33,7 @@ impl Database {
3333
.prepare_with(query, param_types)
3434
.await
3535
.map(|s| s.to_owned())
36-
.map_err(|e| highlight_sql_error("Failed to prepare SQL statement", query, e))
36+
.map_err(|e| display_db_error("Failed to prepare SQL statement", query, e))
3737
}
3838
}
3939

@@ -214,7 +214,7 @@ fn parse_single_sql_result(sql: &str, res: sqlx::Result<Either<AnyQueryResult, A
214214
log::debug!("Finished query with result: {:?}", res);
215215
DbItem::FinishedQuery
216216
}
217-
Err(err) => DbItem::Error(highlight_sql_error(
217+
Err(err) => DbItem::Error(display_db_error(
218218
"Failed to execute SQL statement",
219219
sql,
220220
err,

src/webserver/database/migrations.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
use super::error_highlighting::display_db_error;
12
use super::Database;
2-
use crate::webserver::database::highlight_sql_error;
33
use crate::MIGRATIONS_DIR;
44
use anyhow;
55
use anyhow::Context;
@@ -35,7 +35,7 @@ pub async fn apply(config: &crate::app_config::AppConfig, db: &Database) -> anyh
3535
match err {
3636
MigrateError::Execute(n, source) => {
3737
let migration = migrator.iter().find(|&m| m.version == n).unwrap();
38-
highlight_sql_error("Error in the SQL migration", &migration.sql, source).context(
38+
display_db_error("Error in the SQL migration", &migration.sql, source).context(
3939
format!("Failed to apply migration {}", DisplayMigration(migration)),
4040
)
4141
}

src/webserver/database/mod.rs

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod sql;
66
mod sqlpage_functions;
77
mod syntax_tree;
88

9+
mod error_highlighting;
910
mod sql_to_json;
1011

1112
pub use sql::{make_placeholder, ParsedSqlFile};
@@ -21,36 +22,6 @@ pub enum DbItem {
2122
Error(anyhow::Error),
2223
}
2324

24-
#[must_use]
25-
pub fn highlight_sql_error(
26-
context: &str,
27-
query: &str,
28-
db_err: sqlx::error::Error,
29-
) -> anyhow::Error {
30-
use std::fmt::Write;
31-
let mut msg = format!("{context}:\n");
32-
let offset = if let sqlx::error::Error::Database(db_err) = &db_err {
33-
db_err.offset()
34-
} else {
35-
None
36-
};
37-
if let Some(mut offset) = offset {
38-
for (line_no, line) in query.lines().enumerate() {
39-
if offset > line.len() {
40-
offset -= line.len() + 1;
41-
} else {
42-
writeln!(msg, "{line}").unwrap();
43-
writeln!(msg, "{}⬆️", " ".repeat(offset)).unwrap();
44-
write!(msg, "line {}, character {offset}", line_no + 1).unwrap();
45-
break;
46-
}
47-
}
48-
} else {
49-
write!(msg, "{}", query.lines().next().unwrap_or_default()).unwrap();
50-
}
51-
anyhow::Error::new(db_err).context(msg)
52-
}
53-
5425
impl std::fmt::Display for Database {
5526
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5627
write!(f, "{:?}", self.connection.any_kind())

src/webserver/database/sql.rs

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ use super::sqlpage_functions::functions::SqlPageFunctionName;
33
use super::sqlpage_functions::{are_params_extractable, func_call_to_param};
44
use super::syntax_tree::StmtParam;
55
use crate::file_cache::AsyncFromStrWithState;
6+
use crate::webserver::database::error_highlighting::quote_source_with_highlight;
67
use crate::{AppState, Database};
7-
use anyhow::Context;
88
use async_trait::async_trait;
99
use sqlparser::ast::{
1010
BinaryOperator, CastKind, CharacterLength, DataType, Expr, Function, FunctionArg,
@@ -16,7 +16,6 @@ use sqlparser::parser::{Parser, ParserError};
1616
use sqlparser::tokenizer::Token::{SemiColon, EOF};
1717
use sqlparser::tokenizer::Tokenizer;
1818
use sqlx::any::AnyKind;
19-
use std::fmt::Write;
2019
use std::ops::ControlFlow;
2120
use std::str::FromStr;
2221

@@ -92,21 +91,28 @@ fn parse_sql<'a>(
9291
log::trace!("Parsing SQL: {sql}");
9392
let tokens = Tokenizer::new(dialect, sql)
9493
.tokenize_with_location()
95-
.with_context(|| "SQLPage's SQL parser could not tokenize the sql file")?;
94+
.map_err(|err| {
95+
let location = err.location;
96+
anyhow::Error::new(err).context(format!("The SQLPage parser couldn't understand the SQL file. Tokenization failed. Please check for syntax errors:\n{}", quote_source_with_highlight(sql, location.line, location.column)))
97+
})?;
9698
let mut parser = Parser::new(dialect).with_tokens_with_locations(tokens);
9799
let db_kind = kind_of_dialect(dialect);
98100
Ok(std::iter::from_fn(move || {
99-
parse_single_statement(&mut parser, db_kind)
101+
parse_single_statement(&mut parser, db_kind, sql)
100102
}))
101103
}
102104

103-
fn parse_single_statement(parser: &mut Parser<'_>, db_kind: AnyKind) -> Option<ParsedStatement> {
105+
fn parse_single_statement(
106+
parser: &mut Parser<'_>,
107+
db_kind: AnyKind,
108+
source_sql: &str,
109+
) -> Option<ParsedStatement> {
104110
if parser.peek_token() == EOF {
105111
return None;
106112
}
107113
let mut stmt = match parser.parse_statement() {
108114
Ok(stmt) => stmt,
109-
Err(err) => return Some(syntax_error(err, parser)),
115+
Err(err) => return Some(syntax_error(err, parser, source_sql)),
110116
};
111117
log::debug!("Parsed statement: {stmt}");
112118
let mut semicolon = false;
@@ -145,25 +151,13 @@ fn parse_single_statement(parser: &mut Parser<'_>, db_kind: AnyKind) -> Option<P
145151
}))
146152
}
147153

148-
fn syntax_error(err: ParserError, parser: &mut Parser) -> ParsedStatement {
149-
let mut err_msg = String::with_capacity(128);
150-
parser.prev_token(); // go back to the token that caused the error
151-
for i in 0..32 {
152-
let next_token = parser.next_token();
153-
if i == 0 {
154-
writeln!(
155-
&mut err_msg,
156-
"SQLPage found a syntax error on line {}, character {}:",
157-
next_token.location.line, next_token.location.column
158-
)
159-
.unwrap();
160-
}
161-
if next_token == EOF {
162-
break;
163-
}
164-
write!(&mut err_msg, "{next_token} ").unwrap();
165-
}
166-
ParsedStatement::Error(anyhow::Error::from(err).context(err_msg))
154+
fn syntax_error(err: ParserError, parser: &Parser, sql: &str) -> ParsedStatement {
155+
dbg!((&err, &parser.peek_token_no_skip(), &sql));
156+
let location = parser.peek_token_no_skip().location;
157+
ParsedStatement::Error(anyhow::Error::from(err).context(format!(
158+
"The SQLPage parser couldn't understand the SQL file. Parsing failed. Please check for syntax errors:\n{}",
159+
quote_source_with_highlight(sql, location.line, location.column)
160+
)))
167161
}
168162

169163
fn dialect_for_db(db_kind: AnyKind) -> Box<dyn Dialect> {
@@ -856,7 +850,8 @@ mod test {
856850
#[test]
857851
fn test_sqlpage_function_with_argument() {
858852
for &(dialect, kind) in ALL_DIALECTS {
859-
let mut ast = parse_stmt("select sqlpage.fetch($x)", dialect);
853+
let sql = "select sqlpage.fetch($x)";
854+
let mut ast = parse_stmt(sql, dialect);
860855
let parameters = ParameterExtractor::extract_parameters(&mut ast, kind);
861856
assert_eq!(
862857
parameters,
@@ -874,7 +869,7 @@ mod test {
874869
let sql = "set x = $y";
875870
for &(dialect, db_kind) in ALL_DIALECTS {
876871
let mut parser = Parser::new(dialect).try_with_sql(sql).unwrap();
877-
let stmt = parse_single_statement(&mut parser, db_kind);
872+
let stmt = parse_single_statement(&mut parser, db_kind, sql);
878873
if let Some(ParsedStatement::SetVariable {
879874
variable,
880875
value: StmtWithParams { query, params, .. },

0 commit comments

Comments
 (0)