Skip to content

Commit 66fb01f

Browse files
authored
Fix load dump with triggers (#2151)
Resolves #2150. Test cases implemented as well.
2 parents df98f19 + fcfde9e commit 66fb01f

File tree

4 files changed

+264
-77
lines changed

4 files changed

+264
-77
lines changed

libsql-server/src/namespace/configurator/helpers.rs

Lines changed: 79 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ use anyhow::Context as _;
77
use bottomless::replicator::Options;
88
use bytes::Bytes;
99
use enclose::enclose;
10+
use fallible_iterator::FallibleIterator;
1011
use futures::Stream;
1112
use libsql_sys::EncryptionConfig;
1213
use rusqlite::hooks::{AuthAction, AuthContext, Authorization};
13-
use tokio::io::AsyncBufReadExt as _;
14+
use sqlite3_parser::ast::{Cmd, Stmt};
15+
use sqlite3_parser::lexer::sql::{Parser, ParserError};
16+
use tokio::io::AsyncReadExt;
1417
use tokio::task::JoinSet;
1518
use tokio_util::io::StreamReader;
1619

@@ -33,9 +36,6 @@ use crate::{StatsSender, BLOCKING_RT, DB_CREATE_TIMEOUT, DEFAULT_AUTO_CHECKPOINT
3336

3437
use super::{BaseNamespaceConfig, PrimaryConfig};
3538

36-
const WASM_TABLE_CREATE: &str =
37-
"CREATE TABLE libsql_wasm_func_table (name text PRIMARY KEY, body text) WITHOUT ROWID;";
38-
3939
#[tracing::instrument(skip_all)]
4040
pub(super) async fn make_primary_connection_maker(
4141
primary_config: &PrimaryConfig,
@@ -295,84 +295,89 @@ where
295295
S: Stream<Item = std::io::Result<Bytes>> + Unpin,
296296
{
297297
let mut reader = tokio::io::BufReader::new(StreamReader::new(dump));
298-
let mut curr = String::new();
299-
let mut line = String::new();
298+
let mut dump_content = String::new();
299+
reader
300+
.read_to_string(&mut dump_content)
301+
.await
302+
.map_err(|e| LoadDumpError::Internal(format!("Failed to read dump content: {}", e)))?;
303+
304+
if dump_content.to_lowercase().contains("attach") {
305+
return Err(LoadDumpError::InvalidSqlInput(
306+
"attach statements are not allowed in dumps".to_string(),
307+
));
308+
}
309+
310+
let mut parser = Box::new(Parser::new(dump_content.as_bytes()));
300311
let mut skipped_wasm_table = false;
301312
let mut n_stmt = 0;
302-
let mut line_id = 0;
303313

304-
while let Ok(n) = reader.read_line(&mut curr).await {
305-
line_id += 1;
306-
if n == 0 {
307-
break;
308-
}
309-
let trimmed = curr.trim();
310-
if trimmed.is_empty() || trimmed.starts_with("--") {
311-
curr.clear();
312-
continue;
313-
}
314-
// FIXME: it's well known bug that comment ending with semicolon will be handled incorrectly by currend dump processing code
315-
let statement_end = trimmed.ends_with(';');
316-
317-
// we want to concat original(non-trimmed) lines as trimming will join all them in one
318-
// single-line statement which is incorrect if comments in the end are present
319-
line.push_str(&curr);
320-
curr.clear();
321-
322-
// This is a hack to ignore the libsql_wasm_func_table table because it is already created
323-
// by the system.
324-
if !skipped_wasm_table && line.trim() == WASM_TABLE_CREATE {
325-
skipped_wasm_table = true;
326-
line.clear();
327-
continue;
328-
}
314+
loop {
315+
match parser.next() {
316+
Ok(Some(cmd)) => {
317+
n_stmt += 1;
318+
319+
if !skipped_wasm_table {
320+
if let Cmd::Stmt(Stmt::CreateTable { tbl_name, .. }) = &cmd {
321+
if tbl_name.name.0 == "libsql_wasm_func_table" {
322+
skipped_wasm_table = true;
323+
tracing::debug!("Skipping WASM table creation");
324+
continue;
325+
}
326+
}
327+
}
328+
329+
if n_stmt > 2 && conn.is_autocommit().await.unwrap() {
330+
return Err(LoadDumpError::NoTxn);
331+
}
329332

330-
if statement_end {
331-
n_stmt += 1;
332-
// dump must be performd within a txn
333-
if n_stmt > 2 && conn.is_autocommit().await.unwrap() {
334-
return Err(LoadDumpError::NoTxn);
333+
let stmt_sql = cmd.to_string();
334+
tokio::task::spawn_blocking({
335+
let conn = conn.clone();
336+
move || -> crate::Result<(), LoadDumpError> {
337+
conn.with_raw(|conn| {
338+
conn.authorizer(Some(|auth: AuthContext<'_>| match auth.action {
339+
AuthAction::Attach { filename: _ } => Authorization::Deny,
340+
_ => Authorization::Allow,
341+
}));
342+
conn.execute(&stmt_sql, ())
343+
})
344+
.map_err(|e| match e {
345+
rusqlite::Error::SqlInputError {
346+
msg, sql, offset, ..
347+
} => LoadDumpError::InvalidSqlInput(format!(
348+
"msg: {}, sql: {}, offset: {}",
349+
msg, sql, offset
350+
)),
351+
e => LoadDumpError::Internal(format!(
352+
"statement: {}, error: {}",
353+
n_stmt, e
354+
)),
355+
})?;
356+
Ok(())
357+
}
358+
})
359+
.await??;
335360
}
361+
Ok(None) => break,
362+
Err(e) => {
363+
let error_msg = match e {
364+
sqlite3_parser::lexer::sql::Error::ParserError(
365+
ParserError::SyntaxError { token_type, found },
366+
Some((line, col)),
367+
) => {
368+
let near_token = found.as_deref().unwrap_or(&token_type);
369+
format!(
370+
"syntax error near '{}' at line {}, column {}",
371+
near_token, line, col
372+
)
373+
}
374+
_ => format!("parse error: {}", e),
375+
};
336376

337-
line = tokio::task::spawn_blocking({
338-
let conn = conn.clone();
339-
move || -> crate::Result<String, LoadDumpError> {
340-
conn.with_raw(|conn| {
341-
conn.authorizer(Some(|auth: AuthContext<'_>| match auth.action {
342-
AuthAction::Attach { filename: _ } => Authorization::Deny,
343-
_ => Authorization::Allow,
344-
}));
345-
conn.execute(&line, ())
346-
})
347-
.map_err(|e| match e {
348-
rusqlite::Error::SqlInputError {
349-
msg, sql, offset, ..
350-
} => {
351-
let msg = if sql.to_lowercase().contains("attach") {
352-
format!(
353-
"attach statements are not allowed in dumps, msg: {}, sql: {}, offset: {}",
354-
msg,
355-
sql,
356-
offset
357-
)
358-
} else {
359-
format!("msg: {}, sql: {}, offset: {}", msg, sql, offset)
360-
};
361-
362-
LoadDumpError::InvalidSqlInput(msg)
363-
}
364-
e => LoadDumpError::Internal(format!("line: {}, error: {}", line_id, e)),
365-
})?;
366-
Ok(line)
367-
}
368-
})
369-
.await??;
370-
line.clear();
371-
} else {
372-
line.push(' ');
377+
return Err(LoadDumpError::InvalidSqlInput(error_msg));
378+
}
373379
}
374380
}
375-
tracing::debug!("loaded {} lines from dump", line_id);
376381

377382
if !conn.is_autocommit().await.unwrap() {
378383
tokio::task::spawn_blocking({

libsql-server/tests/namespaces/dumps.rs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,186 @@ fn load_dump_with_invalid_sql() {
425425

426426
sim.run().unwrap();
427427
}
428+
429+
#[test]
430+
fn load_dump_with_trigger() {
431+
const DUMP: &str = r#"
432+
BEGIN TRANSACTION;
433+
CREATE TABLE test (x);
434+
CREATE TRIGGER simple_trigger
435+
AFTER INSERT ON test
436+
BEGIN
437+
INSERT INTO test VALUES (999);
438+
END;
439+
INSERT INTO test VALUES (1);
440+
COMMIT;"#;
441+
442+
let mut sim = Builder::new()
443+
.simulation_duration(Duration::from_secs(1000))
444+
.build();
445+
let tmp = tempdir().unwrap();
446+
let tmp_path = tmp.path().to_path_buf();
447+
448+
std::fs::write(tmp_path.join("dump.sql"), DUMP).unwrap();
449+
450+
make_primary(&mut sim, tmp.path().to_path_buf());
451+
452+
sim.client("client", async move {
453+
let client = Client::new();
454+
455+
let resp = client
456+
.post(
457+
"http://primary:9090/v1/namespaces/debug_test/create",
458+
json!({ "dump_url": format!("file:{}", tmp_path.join("dump.sql").display())}),
459+
)
460+
.await
461+
.unwrap();
462+
assert_eq!(resp.status(), StatusCode::OK);
463+
464+
let db = Database::open_remote_with_connector(
465+
"http://debug_test.primary:8080",
466+
"",
467+
TurmoilConnector,
468+
)?;
469+
let conn = db.connect()?;
470+
471+
// Original INSERT: 1, Trigger INSERT: 999 = 2 total rows
472+
let mut rows = conn.query("SELECT COUNT(*) FROM test", ()).await?;
473+
let row = rows.next().await?.unwrap();
474+
assert_eq!(row.get::<i64>(0)?, 2);
475+
476+
Ok(())
477+
});
478+
479+
sim.run().unwrap();
480+
}
481+
482+
#[test]
483+
fn load_dump_with_case_trigger() {
484+
const DUMP: &str = r#"
485+
BEGIN TRANSACTION;
486+
CREATE TABLE test (id INTEGER, rate REAL DEFAULT 0.0);
487+
CREATE TRIGGER case_trigger
488+
AFTER INSERT ON test
489+
BEGIN
490+
UPDATE test
491+
SET rate =
492+
CASE
493+
WHEN NEW.id = 1
494+
THEN 0.1
495+
ELSE 0.0
496+
END
497+
WHERE id = NEW.id;
498+
END;
499+
500+
INSERT INTO test (id) VALUES (1);
501+
COMMIT;"#;
502+
503+
let mut sim = Builder::new()
504+
.simulation_duration(Duration::from_secs(1000))
505+
.build();
506+
let tmp = tempdir().unwrap();
507+
let tmp_path = tmp.path().to_path_buf();
508+
509+
std::fs::write(tmp_path.join("dump.sql"), DUMP).unwrap();
510+
511+
make_primary(&mut sim, tmp.path().to_path_buf());
512+
513+
sim.client("client", async move {
514+
let client = Client::new();
515+
516+
let resp = client
517+
.post(
518+
"http://primary:9090/v1/namespaces/case_test/create",
519+
json!({ "dump_url": format!("file:{}", tmp_path.join("dump.sql").display())}),
520+
)
521+
.await
522+
.unwrap();
523+
assert_eq!(resp.status(), StatusCode::OK);
524+
525+
let db = Database::open_remote_with_connector(
526+
"http://case_test.primary:8080",
527+
"",
528+
TurmoilConnector,
529+
)?;
530+
let conn = db.connect()?;
531+
532+
let mut rows = conn.query("SELECT id, rate FROM test", ()).await?;
533+
let row = rows.next().await?.unwrap();
534+
assert_eq!(row.get::<i64>(0)?, 1);
535+
assert!((row.get::<f64>(1)? - 0.1).abs() < 0.001);
536+
537+
Ok(())
538+
});
539+
540+
sim.run().unwrap();
541+
}
542+
543+
#[test]
544+
fn load_dump_with_nested_case() {
545+
const DUMP: &str = r#"
546+
BEGIN TRANSACTION;
547+
CREATE TABLE orders (id INTEGER, amount REAL, status TEXT);
548+
CREATE TRIGGER nested_trigger
549+
AFTER UPDATE ON orders
550+
BEGIN
551+
UPDATE orders
552+
SET amount =
553+
CASE
554+
WHEN NEW.status = 'completed'
555+
THEN
556+
CASE
557+
WHEN OLD.id = 1
558+
THEN OLD.amount * 0.9
559+
ELSE OLD.amount * 0.8
560+
END
561+
ELSE OLD.amount
562+
END
563+
WHERE id = NEW.id;
564+
END;
565+
566+
INSERT INTO orders (id, amount, status) VALUES (1, 100.0, 'pending');
567+
COMMIT;"#;
568+
569+
let mut sim = Builder::new()
570+
.simulation_duration(Duration::from_secs(1000))
571+
.build();
572+
let tmp = tempdir().unwrap();
573+
let tmp_path = tmp.path().to_path_buf();
574+
575+
std::fs::write(tmp_path.join("dump.sql"), DUMP).unwrap();
576+
577+
make_primary(&mut sim, tmp.path().to_path_buf());
578+
579+
sim.client("client", async move {
580+
let client = Client::new();
581+
582+
let resp = client
583+
.post(
584+
"http://primary:9090/v1/namespaces/nested_test/create",
585+
json!({ "dump_url": format!("file:{}", tmp_path.join("dump.sql").display())}),
586+
)
587+
.await
588+
.unwrap();
589+
assert_eq!(resp.status(), StatusCode::OK);
590+
591+
let db = Database::open_remote_with_connector(
592+
"http://nested_test.primary:8080",
593+
"",
594+
TurmoilConnector,
595+
)?;
596+
let conn = db.connect()?;
597+
598+
conn.execute("UPDATE orders SET status = 'completed' WHERE id = 1", ())
599+
.await?;
600+
let mut rows = conn
601+
.query("SELECT amount FROM orders WHERE id = 1", ())
602+
.await?;
603+
let row = rows.next().await?.unwrap();
604+
assert!((row.get::<f64>(0)? - 90.0).abs() < 0.001);
605+
606+
Ok(())
607+
});
608+
609+
sim.run().unwrap();
610+
}
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
---
22
source: libsql-server/tests/namespaces/dumps.rs
33
expression: resp.body_string().await?
4-
snapshot_kind: text
54
---
6-
{"error":"The passed dump sql is invalid: attach statements are not allowed in dumps, msg: near \"COMMIT\": syntax error, sql: ATTACH foo/bar.sql\n COMMIT;, offset: 28"}
5+
{"error":"The passed dump sql is invalid: attach statements are not allowed in dumps"}

libsql-server/tests/namespaces/snapshots/tests__namespaces__dumps__load_dump_with_invalid_sql.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ source: libsql-server/tests/namespaces/dumps.rs
33
expression: resp.body_string().await?
44
snapshot_kind: text
55
---
6-
{"error":"The passed dump sql is invalid: msg: near \"COMMIT\": syntax error, sql: SELECT abs(-9223372036854775808) \n COMMIT;, offset: 43"}
6+
{"error":"The passed dump sql is invalid: syntax error near 'COMMIT' at line 7, column 11"}

0 commit comments

Comments
 (0)