Added sql! proc macro which checks syntax errors on sql code and displays them with reasonable underline locations

Kay Simmons and Mikayla Maki created

Co-Authored-By: Mikayla Maki <mikayla@zed.dev>

Change summary

Cargo.lock                                 |  12 +
Cargo.toml                                 |   2 
crates/db/Cargo.toml                       |   1 
crates/db/src/db.rs                        | 163 +++++++++++++++--------
crates/db/src/kvp.rs                       |  21 +-
crates/editor/src/persistence.rs           |  21 +-
crates/gpui_macros/Cargo.toml              |   1 
crates/sqlez/src/connection.rs             |  63 +++++++++
crates/sqlez/src/domain.rs                 |   6 
crates/sqlez/src/statement.rs              |  73 ----------
crates/sqlez/src/thread_safe_connection.rs |   2 
crates/sqlez_macros/Cargo.toml             |  16 ++
crates/sqlez_macros/src/sqlez_macros.rs    |  78 +++++++++++
crates/terminal/src/persistence.rs         |  28 +--
crates/workspace/src/persistence.rs        |  66 ++++-----
15 files changed, 342 insertions(+), 211 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1570,6 +1570,7 @@ dependencies = [
  "parking_lot 0.11.2",
  "serde",
  "sqlez",
+ "sqlez_macros",
  "tempdir",
  "util",
 ]
@@ -5598,6 +5599,17 @@ dependencies = [
  "thread_local",
 ]
 
+[[package]]
+name = "sqlez_macros"
+version = "0.1.0"
+dependencies = [
+ "lazy_static",
+ "proc-macro2",
+ "quote",
+ "sqlez",
+ "syn",
+]
+
 [[package]]
 name = "sqlformat"
 version = "0.2.0"

Cargo.toml 🔗

@@ -45,6 +45,8 @@ members = [
     "crates/search",
     "crates/settings",
     "crates/snippet",
+    "crates/sqlez",
+    "crates/sqlez_macros",
     "crates/sum_tree",
     "crates/terminal",
     "crates/text",

crates/db/Cargo.toml 🔗

@@ -14,6 +14,7 @@ test-support = []
 collections = { path = "../collections" }
 gpui = { path = "../gpui" }
 sqlez = { path = "../sqlez" }
+sqlez_macros = { path = "../sqlez_macros" }
 util = { path = "../util" }
 anyhow = "1.0.57"
 indoc = "1.0.4"

crates/db/src/db.rs 🔗

@@ -5,6 +5,7 @@ pub use anyhow;
 pub use indoc::indoc;
 pub use lazy_static;
 pub use sqlez;
+pub use sqlez_macros;
 
 use sqlez::domain::Migrator;
 use sqlez::thread_safe_connection::ThreadSafeConnection;
@@ -76,273 +77,315 @@ macro_rules! connection {
 
 #[macro_export]
 macro_rules! query {
-    ($vis:vis fn $id:ident() -> Result<()> { $sql:expr }) => {
+    ($vis:vis fn $id:ident() -> Result<()> { $($sql:tt)+ }) => {
         $vis fn $id(&self) -> $crate::anyhow::Result<()> {
             use $crate::anyhow::Context;
 
-            self.exec($sql)?().context(::std::format!(
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.exec(sql_stmt)?().context(::std::format!(
                 "Error in {}, exec failed to execute or parse for: {}",
                 ::std::stringify!($id),
-                $sql,
+                sql_stmt,
             ))
         }
     };
-    ($vis:vis async fn $id:ident() -> Result<()> { $sql:expr }) => {
+    ($vis:vis async fn $id:ident() -> Result<()> { $($sql:tt)+ }) => {
         $vis async fn $id(&self) -> $crate::anyhow::Result<()> {
             use $crate::anyhow::Context;
 
+
             self.write(|connection| {
-                connection.exec($sql)?().context(::std::format!(
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.exec(sql_stmt)?().context(::std::format!(
                     "Error in {}, exec failed to execute or parse for: {}",
                     ::std::stringify!($id),
-                    $sql,
+                    sql_stmt
                 ))
             }).await
         }
     };
-    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<()> { $sql:expr }) => {
+    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<()> { $($sql:tt)+ }) => {
         $vis fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<()> {
             use $crate::anyhow::Context;
 
-            self.exec_bound::<($($arg_type),+)>($sql)?(($($arg),+))
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.exec_bound::<($($arg_type),+)>(sql_stmt)?(($($arg),+))
                 .context(::std::format!(
                     "Error in {}, exec_bound failed to execute or parse for: {}",
                     ::std::stringify!($id),
-                    $sql,
+                    sql_stmt
                 ))
         }
     };
-    ($vis:vis async fn $id:ident($arg:ident: $arg_type:ty) -> Result<()> { $sql:expr }) => {
+    ($vis:vis async fn $id:ident($arg:ident: $arg_type:ty) -> Result<()> { $($sql:tt)+ }) => {
         $vis async fn $id(&self, $arg: $arg_type) -> $crate::anyhow::Result<()> {
             use $crate::anyhow::Context;
 
+
             self.write(move |connection| {
-                connection.exec_bound::<$arg_type>($sql)?($arg)
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.exec_bound::<$arg_type>(sql_stmt)?($arg)
                     .context(::std::format!(
                         "Error in {}, exec_bound failed to execute or parse for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))
             }).await
         }
     };
-    ($vis:vis async fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<()> { $sql:expr }) => {
+    ($vis:vis async fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<()> { $($sql:tt)+ }) => {
         $vis async fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<()> {
             use $crate::anyhow::Context;
 
             self.write(move |connection| {
-                connection.exec_bound::<($($arg_type),+)>($sql)?(($($arg),+))
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.exec_bound::<($($arg_type),+)>(sql_stmt)?(($($arg),+))
                     .context(::std::format!(
                         "Error in {}, exec_bound failed to execute or parse for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))
             }).await
         }
     };
-    ($vis:vis fn $id:ident() ->  Result<Vec<$return_type:ty>> { $sql:expr }) => {
+    ($vis:vis fn $id:ident() ->  Result<Vec<$return_type:ty>> { $($sql:tt)+ }) => {
          $vis fn $id(&self) -> $crate::anyhow::Result<Vec<$return_type>> {
              use $crate::anyhow::Context;
 
-             self.select::<$return_type>($sql)?(())
+             let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+             self.select::<$return_type>(sql_stmt)?(())
                  .context(::std::format!(
                      "Error in {}, select_row failed to execute or parse for: {}",
                      ::std::stringify!($id),
-                     $sql,
+                     sql_stmt
                  ))
          }
     };
-    ($vis:vis async fn $id:ident() ->  Result<Vec<$return_type:ty>> { $sql:expr }) => {
+    ($vis:vis async fn $id:ident() ->  Result<Vec<$return_type:ty>> { $($sql:tt)+ }) => {
         pub async fn $id(&self) -> $crate::anyhow::Result<Vec<$return_type>> {
             use $crate::anyhow::Context;
 
             self.write(|connection| {
-                connection.select::<$return_type>($sql)?(())
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select::<$return_type>(sql_stmt)?(())
                     .context(::std::format!(
                         "Error in {}, select_row failed to execute or parse for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))
             }).await
         }
     };
-    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<Vec<$return_type:ty>> { $sql:expr }) => {
+    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<Vec<$return_type:ty>> { $($sql:tt)+ }) => {
          $vis fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<Vec<$return_type>> {
              use $crate::anyhow::Context;
 
-             self.select_bound::<($($arg_type),+), $return_type>($sql)?(($($arg),+))
+             let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+             self.select_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
                  .context(::std::format!(
                      "Error in {}, exec_bound failed to execute or parse for: {}",
                      ::std::stringify!($id),
-                     $sql,
+                     sql_stmt
                  ))
          }
     };
-    ($vis:vis async fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<Vec<$return_type:ty>> { $sql:expr }) => {
+    ($vis:vis async fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<Vec<$return_type:ty>> { $($sql:tt)+ }) => {
         $vis async fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<Vec<$return_type>> {
             use $crate::anyhow::Context;
 
             self.write(|connection| {
-                connection.select_bound::<($($arg_type),+), $return_type>($sql)?(($($arg),+))
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
                     .context(::std::format!(
                         "Error in {}, exec_bound failed to execute or parse for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))
             }).await
         }
     };
-    ($vis:vis fn $id:ident() ->  Result<Option<$return_type:ty>> { $sql:expr }) => {
+    ($vis:vis fn $id:ident() ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
          $vis fn $id(&self) -> $crate::anyhow::Result<Option<$return_type>> {
              use $crate::anyhow::Context;
 
-             self.select_row::<$return_type>($sql)?()
+             let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+             self.select_row::<$return_type>(sql_stmt)?()
                  .context(::std::format!(
                      "Error in {}, select_row failed to execute or parse for: {}",
                      ::std::stringify!($id),
-                     $sql,
+                     sql_stmt
                  ))
          }
     };
-    ($vis:vis async fn $id:ident() ->  Result<Option<$return_type:ty>> { $sql:expr }) => {
+    ($vis:vis async fn $id:ident() ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
         $vis async fn $id(&self) -> $crate::anyhow::Result<Option<$return_type>> {
             use $crate::anyhow::Context;
 
             self.write(|connection| {
-                connection.select_row::<$return_type>($sql)?()
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select_row::<$return_type>(sql_stmt)?()
                     .context(::std::format!(
                         "Error in {}, select_row failed to execute or parse for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))
             }).await
         }
     };
-    ($vis:vis fn $id:ident($arg:ident: $arg_type:ty) ->  Result<Option<$return_type:ty>> { $sql:expr }) => {
+    ($vis:vis fn $id:ident($arg:ident: $arg_type:ty) ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
         $vis fn $id(&self, $arg: $arg_type) -> $crate::anyhow::Result<Option<$return_type>>  {
             use $crate::anyhow::Context;
 
-            self.select_row_bound::<$arg_type, $return_type>($sql)?($arg)
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select_row_bound::<$arg_type, $return_type>(sql_stmt)?($arg)
                 .context(::std::format!(
                     "Error in {}, select_row_bound failed to execute or parse for: {}",
                     ::std::stringify!($id),
-                    $sql,
+                    sql_stmt
                 ))
 
         }
     };
-    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<Option<$return_type:ty>> { $sql:expr }) => {
+    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
          $vis fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<Option<$return_type>>  {
              use $crate::anyhow::Context;
 
-             self.select_row_bound::<($($arg_type),+), $return_type>($sql)?(($($arg),+))
+             let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+             self.select_row_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
                  .context(::std::format!(
                      "Error in {}, select_row_bound failed to execute or parse for: {}",
                      ::std::stringify!($id),
-                     $sql,
+                     sql_stmt
                  ))
 
          }
     };
-    ($vis:vis async fn $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<Option<$return_type:ty>> { $sql:expr }) => {
+    ($vis:vis async fn $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
         $vis async fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<Option<$return_type>>  {
             use $crate::anyhow::Context;
 
+
             self.write(|connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
                 connection.select_row_bound::<($($arg_type),+), $return_type>(indoc! { $sql })?(($($arg),+))
                     .context(::std::format!(
                         "Error in {}, select_row_bound failed to execute or parse for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))
             }).await
         }
     };
-    ($vis:vis fn $id:ident() ->  Result<$return_type:ty> { $sql:expr }) => {
+    ($vis:vis fn $id:ident() ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
          $vis fn $id(&self) ->  $crate::anyhow::Result<$return_type>  {
              use $crate::anyhow::Context;
 
+             let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
              self.select_row::<$return_type>(indoc! { $sql })?()
                  .context(::std::format!(
                      "Error in {}, select_row_bound failed to execute or parse for: {}",
                      ::std::stringify!($id),
-                     $sql,
+                     sql_stmt
                  ))?
                  .context(::std::format!(
                      "Error in {}, select_row_bound expected single row result but found none for: {}",
                      ::std::stringify!($id),
-                     $sql,
+                     sql_stmt
                  ))
          }
     };
-    ($vis:vis async fn $id:ident() ->  Result<$return_type:ty> { $sql:expr }) => {
+    ($vis:vis async fn $id:ident() ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
         $vis async fn $id(&self) ->  $crate::anyhow::Result<$return_type>  {
             use $crate::anyhow::Context;
 
             self.write(|connection| {
-                connection.select_row::<$return_type>($sql)?()
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select_row::<$return_type>(sql_stmt)?()
                     .context(::std::format!(
                         "Error in {}, select_row_bound failed to execute or parse for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))?
                     .context(::std::format!(
                         "Error in {}, select_row_bound expected single row result but found none for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))
             }).await
         }
     };
-    ($vis:vis fn $id:ident($arg:ident: $arg_type:ty) ->  Result<$return_type:ty> { $sql:expr }) => {
+    ($vis:vis fn $id:ident($arg:ident: $arg_type:ty) ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
         pub fn $id(&self, $arg: $arg_type) ->  $crate::anyhow::Result<$return_type>  {
             use $crate::anyhow::Context;
 
-            self.select_row_bound::<$arg_type, $return_type>($sql)?($arg)
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select_row_bound::<$arg_type, $return_type>(sql_stmt)?($arg)
                 .context(::std::format!(
                     "Error in {}, select_row_bound failed to execute or parse for: {}",
                     ::std::stringify!($id),
-                    $sql,
+                    sql_stmt
                 ))?
                 .context(::std::format!(
                     "Error in {}, select_row_bound expected single row result but found none for: {}",
                     ::std::stringify!($id),
-                    $sql,
+                    sql_stmt
                 ))
         }
     };
-    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<$return_type:ty> { $sql:expr }) => {
+    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
          $vis fn $id(&self, $($arg: $arg_type),+) ->  $crate::anyhow::Result<$return_type>  {
              use $crate::anyhow::Context;
 
-             self.select_row_bound::<($($arg_type),+), $return_type>($sql)?(($($arg),+))
+             let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+             self.select_row_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
                  .context(::std::format!(
                      "Error in {}, select_row_bound failed to execute or parse for: {}",
                      ::std::stringify!($id),
-                     $sql,
+                     sql_stmt
                  ))?
                  .context(::std::format!(
                      "Error in {}, select_row_bound expected single row result but found none for: {}",
                      ::std::stringify!($id),
-                     $sql,
+                     sql_stmt
                  ))
          }
     };
-    ($vis:vis fn async $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<$return_type:ty> { $sql:expr }) => {
+    ($vis:vis fn async $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
         $vis async fn $id(&self, $($arg: $arg_type),+) ->  $crate::anyhow::Result<$return_type>  {
             use $crate::anyhow::Context;
 
+
             self.write(|connection| {
-                connection.select_row_bound::<($($arg_type),+), $return_type>($sql)?(($($arg),+))
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select_row_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
                     .context(::std::format!(
                         "Error in {}, select_row_bound failed to execute or parse for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))?
                     .context(::std::format!(
                         "Error in {}, select_row_bound expected single row result but found none for: {}",
                         ::std::stringify!($id),
-                        $sql,
+                        sql_stmt
                     ))
             }).await
         }

crates/db/src/kvp.rs 🔗

@@ -1,6 +1,5 @@
-use indoc::indoc;
-
 use sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection};
+use sqlez_macros::sql;
 
 use crate::{open_file_db, open_memory_db, query};
 
@@ -28,31 +27,31 @@ impl Domain for KeyValueStore {
     }
 
     fn migrations() -> &'static [&'static str] {
-        &[indoc! {"
-           CREATE TABLE kv_store(
-               key TEXT PRIMARY KEY,
-               value TEXT NOT NULL
-           ) STRICT;
-       "}]
+        &[sql!(
+            CREATE TABLE kv_store(
+                key TEXT PRIMARY KEY,
+                value TEXT NOT NULL
+            ) STRICT;
+        )]
     }
 }
 
 impl KeyValueStore {
     query! {
         pub fn read_kvp(key: &str) -> Result<Option<String>> {
-            "SELECT value FROM kv_store WHERE key = (?)"
+            SELECT value FROM kv_store WHERE key = (?)
         }
     }
 
     query! {
         pub async fn write_kvp(key: String, value: String) -> Result<()> {
-            "INSERT OR REPLACE INTO kv_store(key, value) VALUES ((?), (?))"
+            INSERT OR REPLACE INTO kv_store(key, value) VALUES ((?), (?))
         }
     }
 
     query! {
         pub async fn delete_kvp(key: String) -> Result<()> {
-            "DELETE FROM kv_store WHERE key = (?)"
+            DELETE FROM kv_store WHERE key = (?)
         }
     }
 }

crates/editor/src/persistence.rs 🔗

@@ -1,12 +1,11 @@
 use std::path::PathBuf;
 
+use crate::Editor;
+use db::sqlez_macros::sql;
 use db::{connection, query};
-use indoc::indoc;
 use sqlez::domain::Domain;
 use workspace::{ItemId, Workspace, WorkspaceId};
 
-use crate::Editor;
-
 connection!(DB: EditorDb<(Workspace, Editor)>);
 
 impl Domain for Editor {
@@ -15,7 +14,7 @@ impl Domain for Editor {
     }
 
     fn migrations() -> &'static [&'static str] {
-        &[indoc! {"
+        &[sql! (
             CREATE TABLE editors(
                 item_id INTEGER NOT NULL,
                 workspace_id INTEGER NOT NULL,
@@ -26,26 +25,22 @@ impl Domain for Editor {
                     ON UPDATE CASCADE
 
             ) STRICT;
-        "}]
+        )]
     }
 }
 
 impl EditorDb {
     query! {
         pub fn get_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result<PathBuf> {
-            indoc!{"
-                SELECT path FROM editors
-                WHERE item_id = ? AND workspace_id = ?
-            "}
+            SELECT path FROM editors
+            WHERE item_id = ? AND workspace_id = ?
         }
     }
 
     query! {
         pub async fn save_path(item_id: ItemId, workspace_id: WorkspaceId, path: PathBuf) -> Result<()> {
-            indoc!{"
-                INSERT OR REPLACE INTO editors(item_id, workspace_id, path)
-                VALUES (?, ?, ?)
-            "}
+            INSERT OR REPLACE INTO editors(item_id, workspace_id, path)
+            VALUES (?, ?, ?)
         }
     }
 }

crates/sqlez/src/connection.rs 🔗

@@ -2,6 +2,7 @@ use std::{
     ffi::{CStr, CString},
     marker::PhantomData,
     path::Path,
+    ptr,
 };
 
 use anyhow::{anyhow, Result};
@@ -85,6 +86,45 @@ impl Connection {
         self.backup_main(&destination)
     }
 
+    pub fn sql_has_syntax_error(&self, sql: &str) -> Option<(String, usize)> {
+        let sql = CString::new(sql).unwrap();
+        let mut remaining_sql = sql.as_c_str();
+        let sql_start = remaining_sql.as_ptr();
+
+        unsafe {
+            while {
+                let remaining_sql_str = remaining_sql.to_str().unwrap().trim();
+                remaining_sql_str != ";" && !remaining_sql_str.is_empty()
+            } {
+                let mut raw_statement = 0 as *mut sqlite3_stmt;
+                let mut remaining_sql_ptr = ptr::null();
+                sqlite3_prepare_v2(
+                    self.sqlite3,
+                    remaining_sql.as_ptr(),
+                    -1,
+                    &mut raw_statement,
+                    &mut remaining_sql_ptr,
+                );
+
+                let res = sqlite3_errcode(self.sqlite3);
+                let offset = sqlite3_error_offset(self.sqlite3);
+
+                if res == 1 && offset >= 0 {
+                    let message = sqlite3_errmsg(self.sqlite3);
+                    let err_msg =
+                        String::from_utf8_lossy(CStr::from_ptr(message as *const _).to_bytes())
+                            .into_owned();
+                    let sub_statement_correction =
+                        remaining_sql.as_ptr() as usize - sql_start as usize;
+
+                    return Some((err_msg, offset as usize + sub_statement_correction));
+                }
+                remaining_sql = CStr::from_ptr(remaining_sql_ptr);
+            }
+        }
+        None
+    }
+
     pub(crate) fn last_error(&self) -> Result<()> {
         unsafe {
             let code = sqlite3_errcode(self.sqlite3);
@@ -259,10 +299,31 @@ mod test {
 
         assert_eq!(
             connection
-                .select_row::<usize>("SELECt * FROM test")
+                .select_row::<usize>("SELECT * FROM test")
                 .unwrap()()
             .unwrap(),
             Some(2)
         );
     }
+
+    #[test]
+    fn test_sql_has_syntax_errors() {
+        let connection = Connection::open_memory(Some("test_sql_has_syntax_errors"));
+        let first_stmt =
+            "CREATE TABLE kv_store(key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT ;";
+        let second_stmt = "SELECT FROM";
+
+        let second_offset = connection.sql_has_syntax_error(second_stmt).unwrap().1;
+
+        let res = connection
+            .sql_has_syntax_error(&format!("{}\n{}", first_stmt, second_stmt))
+            .map(|(_, offset)| offset);
+
+        assert_eq!(
+            res,
+            Some(first_stmt.len() + second_offset + 1) // TODO: This value is wrong!
+        );
+
+        panic!("{:?}", res)
+    }
 }

crates/sqlez/src/domain.rs 🔗

@@ -9,6 +9,12 @@ pub trait Migrator {
     fn migrate(connection: &Connection) -> anyhow::Result<()>;
 }
 
+impl Migrator for () {
+    fn migrate(_connection: &Connection) -> anyhow::Result<()> {
+        Ok(()) // Do nothing
+    }
+}
+
 impl<D: Domain> Migrator for D {
     fn migrate(connection: &Connection) -> anyhow::Result<()> {
         connection.migrate(Self::name(), Self::migrations())

crates/sqlez/src/statement.rs 🔗

@@ -489,76 +489,3 @@ mod test {
         );
     }
 }
-
-mod syntax_check {
-    use std::{
-        ffi::{CStr, CString},
-        ptr,
-    };
-
-    use libsqlite3_sys::{
-        sqlite3_close, sqlite3_errmsg, sqlite3_error_offset, sqlite3_extended_errcode,
-        sqlite3_extended_result_codes, sqlite3_finalize, sqlite3_open_v2, sqlite3_prepare_v2,
-        sqlite3_stmt, SQLITE_OPEN_CREATE, SQLITE_OPEN_NOMUTEX, SQLITE_OPEN_READWRITE,
-    };
-
-    fn syntax_errors(sql: &str) -> Option<(String, i32)> {
-        let mut sqlite3 = 0 as *mut _;
-        let mut raw_statement = 0 as *mut sqlite3_stmt;
-
-        let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE;
-        unsafe {
-            let memory_str = CString::new(":memory:").unwrap();
-            sqlite3_open_v2(memory_str.as_ptr(), &mut sqlite3, flags, 0 as *const _);
-
-            let sql = CString::new(sql).unwrap();
-
-            // Turn on extended error codes
-            sqlite3_extended_result_codes(sqlite3, 1);
-
-            sqlite3_prepare_v2(
-                sqlite3,
-                sql.as_c_str().as_ptr(),
-                -1,
-                &mut raw_statement,
-                &mut ptr::null(),
-            );
-
-            let res = sqlite3_extended_errcode(sqlite3);
-            let offset = sqlite3_error_offset(sqlite3);
-
-            if res == 1 && offset != -1 {
-                let message = sqlite3_errmsg(sqlite3);
-                let err_msg =
-                    String::from_utf8_lossy(CStr::from_ptr(message as *const _).to_bytes())
-                        .into_owned();
-
-                sqlite3_finalize(*&mut raw_statement);
-                sqlite3_close(sqlite3);
-
-                return Some((err_msg, offset));
-            } else {
-                sqlite3_finalize(*&mut raw_statement);
-                sqlite3_close(sqlite3);
-
-                None
-            }
-        }
-    }
-
-    #[cfg(test)]
-    mod test {
-        use super::syntax_errors;
-
-        #[test]
-        fn test_check_syntax() {
-            assert!(syntax_errors("SELECT FROM").is_some());
-
-            assert!(syntax_errors("SELECT col FROM table_t;").is_none());
-
-            assert!(syntax_errors("CREATE TABLE t(col TEXT,) STRICT;").is_some());
-
-            assert!(syntax_errors("CREATE TABLE t(col TEXT) STRICT;").is_none());
-        }
-    }
-}

crates/sqlez/src/thread_safe_connection.rs 🔗

@@ -17,7 +17,7 @@ lazy_static! {
         Default::default();
 }
 
-pub struct ThreadSafeConnection<M: Migrator> {
+pub struct ThreadSafeConnection<M: Migrator = ()> {
     uri: Arc<str>,
     persistent: bool,
     initialize_query: Option<&'static str>,

crates/sqlez_macros/Cargo.toml 🔗

@@ -0,0 +1,16 @@
+[package]
+name = "sqlez_macros"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/sqlez_macros.rs"
+proc-macro = true
+doctest = false
+
+[dependencies]
+syn = "1.0"
+quote = "1.0"
+proc-macro2 = "1.0"
+lazy_static = "1.4"
+sqlez = { path = "../sqlez" }

crates/sqlez_macros/src/sqlez_macros.rs 🔗

@@ -0,0 +1,78 @@
+use proc_macro::{Delimiter, Span, TokenStream, TokenTree};
+use sqlez::thread_safe_connection::ThreadSafeConnection;
+use syn::Error;
+
+lazy_static::lazy_static! {
+    static ref SQLITE: ThreadSafeConnection = ThreadSafeConnection::new(":memory:", false);
+}
+
+#[proc_macro]
+pub fn sql(tokens: TokenStream) -> TokenStream {
+    let mut sql_tokens = vec![];
+    flatten_stream(tokens.clone(), &mut sql_tokens);
+
+    // Lookup of spans by offset at the end of the token
+    let mut spans: Vec<(usize, Span)> = Vec::new();
+    let mut sql = String::new();
+    for (token_text, span) in sql_tokens {
+        sql.push_str(&token_text);
+        spans.push((sql.len(), span));
+    }
+
+    let error = SQLITE.sql_has_syntax_error(sql.trim());
+
+    if let Some((error, error_offset)) = error {
+        let error_span = spans
+            .into_iter()
+            .skip_while(|(offset, _)| offset <= &error_offset)
+            .map(|(_, span)| span)
+            .next()
+            .unwrap_or(Span::call_site());
+
+        let error_text = format!("Sql Error: {}\nFor Query: {}", error, sql);
+        TokenStream::from(Error::new(error_span.into(), error_text).into_compile_error())
+    } else {
+        format!("r#\"{}\"#", &sql).parse().unwrap()
+    }
+}
+
+/// This method exists to normalize the representation of groups
+/// to always include spaces between tokens. This is why we don't use the usual .to_string().
+/// This allows our token search in token_at_offset to resolve
+/// ambiguity of '(tokens)' vs. '( token )', due to sqlite requiring byte offsets
+fn flatten_stream(tokens: TokenStream, result: &mut Vec<(String, Span)>) {
+    for token_tree in tokens.into_iter() {
+        match token_tree {
+            TokenTree::Group(group) => {
+                // push open delimiter
+                result.push((open_delimiter(group.delimiter()), group.span()));
+                // recurse
+                flatten_stream(group.stream(), result);
+                // push close delimiter
+                result.push((close_delimiter(group.delimiter()), group.span()));
+            }
+            TokenTree::Ident(ident) => {
+                result.push((format!("{} ", ident.to_string()), ident.span()));
+            }
+            leaf_tree => result.push((leaf_tree.to_string(), leaf_tree.span())),
+        }
+    }
+}
+
+fn open_delimiter(delimiter: Delimiter) -> String {
+    match delimiter {
+        Delimiter::Parenthesis => "(".to_string(),
+        Delimiter::Brace => "[".to_string(),
+        Delimiter::Bracket => "{".to_string(),
+        Delimiter::None => "".to_string(),
+    }
+}
+
+fn close_delimiter(delimiter: Delimiter) -> String {
+    match delimiter {
+        Delimiter::Parenthesis => ")".to_string(),
+        Delimiter::Brace => "]".to_string(),
+        Delimiter::Bracket => "}".to_string(),
+        Delimiter::None => "".to_string(),
+    }
+}

crates/terminal/src/persistence.rs 🔗

@@ -1,6 +1,6 @@
 use std::path::PathBuf;
 
-use db::{connection, indoc, query, sqlez::domain::Domain};
+use db::{connection, query, sqlez::domain::Domain, sqlez_macros::sql};
 
 use workspace::{ItemId, Workspace, WorkspaceId};
 
@@ -14,7 +14,7 @@ impl Domain for Terminal {
     }
 
     fn migrations() -> &'static [&'static str] {
-        &[indoc! {"
+        &[sql!(
             CREATE TABLE terminals (
                 workspace_id INTEGER,
                 item_id INTEGER UNIQUE,
@@ -23,7 +23,7 @@ impl Domain for Terminal {
                 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
                     ON DELETE CASCADE
             ) STRICT;
-        "}]
+        )]
     }
 }
 
@@ -34,11 +34,9 @@ impl TerminalDb {
             old_id: WorkspaceId,
             item_id: ItemId
         ) -> Result<()> {
-            indoc!{"
-                UPDATE terminals
-                SET workspace_id = ?
-                WHERE workspace_id = ? AND item_id = ?
-            "}
+            UPDATE terminals
+            SET workspace_id = ?
+            WHERE workspace_id = ? AND item_id = ?
         }
     }
 
@@ -48,20 +46,16 @@ impl TerminalDb {
             workspace_id: WorkspaceId,
             working_directory: PathBuf
         ) -> Result<()> {
-            indoc!{"
-                INSERT OR REPLACE INTO terminals(item_id, workspace_id, working_directory)
-                VALUES (?1, ?2, ?3)
-            "}
+            INSERT OR REPLACE INTO terminals(item_id, workspace_id, working_directory)
+            VALUES (?, ?, ?)
         }
     }
 
     query! {
         pub fn get_working_directory(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
-            indoc!{"
-                SELECT working_directory
-                FROM terminals
-                WHERE item_id = ? AND workspace_id = ?
-            "}
+            SELECT working_directory
+            FROM terminals
+            WHERE item_id = ? AND workspace_id = ?
         }
     }
 }

crates/workspace/src/persistence.rs 🔗

@@ -5,7 +5,7 @@ pub mod model;
 use std::path::Path;
 
 use anyhow::{anyhow, bail, Context, Result};
-use db::{connection, query, sqlez::connection::Connection};
+use db::{connection, query, sqlez::connection::Connection, sqlez_macros::sql};
 use gpui::Axis;
 use indoc::indoc;
 
@@ -30,49 +30,49 @@ impl Domain for Workspace {
     }
 
     fn migrations() -> &'static [&'static str] {
-        &[indoc! {"
+        &[sql!(
             CREATE TABLE workspaces(
                 workspace_id INTEGER PRIMARY KEY,
                 workspace_location BLOB UNIQUE,
-                dock_visible INTEGER, -- Boolean
-                dock_anchor TEXT, -- Enum: 'Bottom' / 'Right' / 'Expanded'
-                dock_pane INTEGER, -- NULL indicates that we don't have a dock pane yet
+                dock_visible INTEGER, // Boolean
+                dock_anchor TEXT, // Enum: 'Bottom' / 'Right' / 'Expanded'
+                dock_pane INTEGER, // NULL indicates that we don't have a dock pane yet
                 timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
                 FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
             ) STRICT;
-            
+
             CREATE TABLE pane_groups(
                 group_id INTEGER PRIMARY KEY,
                 workspace_id INTEGER NOT NULL,
-                parent_group_id INTEGER, -- NULL indicates that this is a root node
-                position INTEGER, -- NULL indicates that this is a root node
-                axis TEXT NOT NULL, -- Enum:  'Vertical' / 'Horizontal'
-                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) 
-                    ON DELETE CASCADE 
+                parent_group_id INTEGER, // NULL indicates that this is a root node
+                position INTEGER, // NULL indicates that this is a root node
+                axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal'
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                    ON DELETE CASCADE
                     ON UPDATE CASCADE,
                 FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
             ) STRICT;
-            
+
             CREATE TABLE panes(
                 pane_id INTEGER PRIMARY KEY,
                 workspace_id INTEGER NOT NULL,
-                active INTEGER NOT NULL, -- Boolean
-                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) 
-                    ON DELETE CASCADE 
+                active INTEGER NOT NULL, // Boolean
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                    ON DELETE CASCADE
                     ON UPDATE CASCADE
             ) STRICT;
-            
+
             CREATE TABLE center_panes(
                 pane_id INTEGER PRIMARY KEY,
-                parent_group_id INTEGER, -- NULL means that this is a root pane
-                position INTEGER, -- NULL means that this is a root pane
-                FOREIGN KEY(pane_id) REFERENCES panes(pane_id) 
+                parent_group_id INTEGER, // NULL means that this is a root pane
+                position INTEGER, // NULL means that this is a root pane
+                FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
                     ON DELETE CASCADE,
                 FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
             ) STRICT;
-                            
+
             CREATE TABLE items(
-                item_id INTEGER NOT NULL, -- This is the item's view id, so this is not unique
+                item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique
                 workspace_id INTEGER NOT NULL,
                 pane_id INTEGER NOT NULL,
                 kind TEXT NOT NULL,
@@ -84,7 +84,7 @@ impl Domain for Workspace {
                     ON DELETE CASCADE,
                 PRIMARY KEY(item_id, workspace_id)
             ) STRICT;
-        "}]
+        )]
     }
 }
 
@@ -158,26 +158,22 @@ impl WorkspaceDb {
                 .context("clearing out old locations")?;
 
                 // Upsert
-                conn.exec_bound(indoc! {"
+                conn.exec_bound(sql!(
                         INSERT INTO workspaces(
-                            workspace_id, 
-                            workspace_location, 
-                            dock_visible, 
-                            dock_anchor, 
+                            workspace_id,
+                            workspace_location,
+                            dock_visible,
+                            dock_anchor,
                             timestamp
-                        ) 
+                        )
                         VALUES (?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)
                         ON CONFLICT DO
-                            UPDATE SET 
+                            UPDATE SET
                             workspace_location = ?2,
                             dock_visible = ?3,
                             dock_anchor = ?4,
                             timestamp = CURRENT_TIMESTAMP
-                    "})?((
-                    workspace.id,
-                    &workspace.location,
-                    workspace.dock_position,
-                ))
+                ))?((workspace.id, &workspace.location, workspace.dock_position))
                 .context("Updating workspace")?;
 
                 // Save center pane group and dock pane
@@ -203,7 +199,7 @@ impl WorkspaceDb {
 
     query! {
         pub async fn next_id() -> Result<WorkspaceId> {
-            "INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id"
+            INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id
         }
     }