1#![allow(clippy::unused_unit, deprecated)]
2
3use crate::api::error::ApiError;
15use leptos::prelude::*;
16use serde::{Deserialize, Serialize};
17
18#[cfg(feature = "server")]
20use crate::api::auth::get_current_admin_user;
21#[cfg(feature = "server")]
22use crate::api::database::read_only::{executed_statements, is_read_only_statement};
23#[cfg(feature = "server")]
24use crate::api::error::AppError;
25#[cfg(feature = "server")]
26use crate::db::pool::get_conn;
27
28#[derive(Deserialize, Serialize, Debug, Clone, Copy)]
29pub struct ExecuteSqlOpts {
30 pub allow_multi: bool,
32 pub confirm_dangerous: bool,
34 pub with_explain: bool,
36}
37
38#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
39pub struct SqlResult {
40 pub columns: Vec<String>,
41 pub rows: Vec<Vec<serde_json::Value>>,
43 pub affected_rows: u64,
44 pub elapsed_ms: u64,
45 pub statement_type: String,
47 pub explain: Option<String>,
48 pub error: Option<String>,
50 pub truncated: bool,
52}
53
54#[cfg(feature = "server")]
59const MAX_ROWS: usize = 500;
60
61#[cfg(feature = "server")]
70const ABSOLUTELY_FORBIDDEN: &[&[&str]] = &[
71 &["drop", "database"],
72 &["drop", "schema"],
73 &["create", "database"],
74];
75
76#[cfg(feature = "server")]
84fn is_absolutely_forbidden(sql: &str) -> Option<&'static str> {
85 let lowered = sql.to_lowercase();
87 let tokens: Vec<&str> = lowered
88 .split_whitespace()
89 .map(|t| t.trim_end_matches([',', ';', '(', ')']))
90 .collect();
91 for forbidden in ABSOLUTELY_FORBIDDEN {
92 for window in tokens.windows(forbidden.len()) {
94 if window == *forbidden {
95 return Some(match forbidden {
96 ["drop", "database"] => "DROP DATABASE",
97 ["drop", "schema"] => "DROP SCHEMA",
98 ["create", "database"] => "CREATE DATABASE",
99 _ => "未知高危操作",
100 });
101 }
102 }
103 }
104 None
105}
106
107#[cfg(feature = "server")]
109#[derive(Debug)]
110enum GuardResult {
111 Allowed,
112 NeedsConfirm,
114 Forbidden(String),
116}
117
118#[cfg(feature = "server")]
123fn check_guards(asts: &[sqlparser::ast::Statement], confirm_dangerous: bool) -> GuardResult {
124 use sqlparser::ast::{ObjectType, Statement};
125
126 for stmt in asts.iter().flat_map(executed_statements) {
127 match stmt {
128 Statement::Drop {
131 object_type: ObjectType::Schema,
132 ..
133 } => {
134 return GuardResult::Forbidden("禁止 DROP SCHEMA".to_string());
135 }
136 Statement::Drop {
137 object_type: ObjectType::Database,
138 ..
139 } => {
140 return GuardResult::Forbidden("禁止 DROP DATABASE".to_string());
141 }
142 Statement::CreateDatabase { .. } => {
146 return GuardResult::Forbidden("禁止 CREATE DATABASE".to_string());
147 }
148 Statement::Drop { .. } | Statement::Truncate { .. } | Statement::AlterTable { .. } => {
150 if !confirm_dangerous {
151 return GuardResult::NeedsConfirm;
152 }
153 }
154 Statement::Update(sqlparser::ast::Update {
156 selection: None, ..
157 }) => {
158 return GuardResult::Forbidden(
159 "UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
160 );
161 }
162 Statement::Delete(sqlparser::ast::Delete {
164 selection: None, ..
165 }) => {
166 return GuardResult::Forbidden(
167 "DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
168 );
169 }
170 _ => {}
171 }
172 }
173 GuardResult::Allowed
174}
175
176#[cfg(feature = "server")]
178fn statement_type_name(stmt: &sqlparser::ast::Statement) -> String {
179 use sqlparser::ast::Statement;
180 let name = match stmt {
181 Statement::Query(_) => "Select",
182 Statement::Insert(_) => "Insert",
183 Statement::Update(_) => "Update",
184 Statement::Delete(_) => "Delete",
185 Statement::CreateTable { .. } => "CreateTable",
186 Statement::AlterTable { .. } => "AlterTable",
187 Statement::Drop { .. } => "Drop",
188 Statement::Truncate { .. } => "Truncate",
189 Statement::Explain { .. } => "Explain",
190 _ => "Other",
191 };
192 name.to_string()
193}
194
195#[cfg(feature = "server")]
200fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
201 use sqlparser::ast::Statement;
202 is_read_only_statement(stmt)
203 || matches!(
204 stmt,
205 Statement::ShowVariable { .. }
207 | Statement::ShowVariables { .. }
208 | Statement::ShowStatus { .. }
209 | Statement::ShowCreate { .. }
210 | Statement::ShowColumns { .. }
211 | Statement::ShowCatalogs { .. }
212 | Statement::ShowDatabases { .. }
213 | Statement::ShowProcessList { .. }
214 | Statement::ShowSchemas { .. }
215 | Statement::ShowCharset(_)
216 | Statement::ShowObjects(_)
217 | Statement::ShowTables { .. }
218 | Statement::ShowViews { .. }
219 | Statement::ShowFunctions { .. }
220 | Statement::ShowCollation { .. }
221 )
222}
223
224#[cfg(feature = "server")]
230fn writes_affect_cache(stmts: &[sqlparser::ast::Statement]) -> bool {
231 stmts.iter().any(|s| !is_read_only(s))
232}
233
234#[cfg(feature = "server")]
236fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
237 use serde_json::json;
238 let ty = row
239 .columns()
240 .get(idx)
241 .map(|c| c.type_().name())
242 .unwrap_or("");
243 match ty {
244 "int2" => row
245 .try_get::<_, Option<i16>>(idx)
246 .ok()
247 .flatten()
248 .map(|v| json!(v))
249 .unwrap_or(serde_json::Value::Null),
250 "int4" => row
251 .try_get::<_, Option<i32>>(idx)
252 .ok()
253 .flatten()
254 .map(|v| json!(v))
255 .unwrap_or(serde_json::Value::Null),
256 "int8" => row
257 .try_get::<_, Option<i64>>(idx)
258 .ok()
259 .flatten()
260 .map(|v| json!(v))
261 .unwrap_or(serde_json::Value::Null),
262 "float4" => row
263 .try_get::<_, Option<f32>>(idx)
264 .ok()
265 .flatten()
266 .map(|v| json!(v))
267 .unwrap_or(serde_json::Value::Null),
268 "float8" => row
269 .try_get::<_, Option<f64>>(idx)
270 .ok()
271 .flatten()
272 .map(|v| json!(v))
273 .unwrap_or(serde_json::Value::Null),
274 "bool" => row
275 .try_get::<_, Option<bool>>(idx)
276 .ok()
277 .flatten()
278 .map(|v| json!(v))
279 .unwrap_or(serde_json::Value::Null),
280 _ => row
282 .try_get::<_, Option<String>>(idx)
283 .ok()
284 .flatten()
285 .map(|v| json!(v))
286 .unwrap_or(serde_json::Value::Null),
287 }
288}
289
290#[server(ExecuteSql, endpoint = "execute_sql", input = leptos::server_fn::codec::Json)]
292pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result<SqlResult, ApiError> {
293 let _user = get_current_admin_user().await?;
294
295 #[cfg(feature = "server")]
296 {
297 use sqlparser::dialect::PostgreSqlDialect;
298 use sqlparser::parser::Parser;
299
300 if let Some(name) = is_absolutely_forbidden(&sql) {
303 return Err(AppError::BadRequest(format!("禁止的操作:{}", name)).into());
304 }
305
306 let dialect = PostgreSqlDialect {};
308 let asts = Parser::parse_sql(&dialect, &sql)
309 .map_err(|e| AppError::BadRequest(format!("SQL 解析失败:{e}")))?;
310 if asts.is_empty() {
311 return Err(AppError::BadRequest("空的 SQL 语句".into()).into());
312 }
313
314 if asts.len() > 1 && !opts.allow_multi {
316 return Err(AppError::BadRequest(
317 "检测到多条语句,请勾选「允许多语句」后再执行".into(),
318 )
319 .into());
320 }
321
322 match check_guards(&asts, opts.confirm_dangerous) {
324 GuardResult::Forbidden(msg) => {
325 return Err(AppError::BadRequest(msg).into());
326 }
327 GuardResult::NeedsConfirm => {
328 return Err(AppError::BadRequest(
329 "高危操作(DROP/TRUNCATE/ALTER),需勾选「我了解后果」".into(),
330 )
331 .into());
332 }
333 GuardResult::Allowed => {}
334 }
335
336 let client = get_conn().await.map_err(AppError::db_conn)?;
337 let start = std::time::Instant::now;
338
339 let mut last_result = SqlResult::default();
343 for stmt in &asts {
344 let stmt_sql = stmt.to_string();
346 last_result = execute_one(&client, stmt, &stmt_sql, opts.with_explain, start).await?;
347 if last_result.error.is_some() {
348 break;
349 }
350 }
351 let has_write = writes_affect_cache(&asts);
353 if has_write {
357 crate::cache::invalidate_all_post_caches();
358 crate::cache::invalidate_search_results();
359 crate::cache::invalidate_all_comments();
360 crate::ssr_cache::invalidate_ssr_all_public();
361 crate::ssr_cache::bump_global_generation();
362 }
363 Ok(last_result)
364 }
365 #[cfg(not(feature = "server"))]
366 {
367 let _ = (sql, opts);
368 Ok(SqlResult::default())
369 }
370}
371
372#[cfg(feature = "server")]
378fn format_sql_error(error: impl std::fmt::Display) -> String {
379 error.to_string().chars().take(2_000).collect()
380}
381
382#[cfg(feature = "server")]
383async fn execute_one(
384 client: &deadpool_postgres::Object,
385 stmt: &sqlparser::ast::Statement,
386 stmt_sql: &str,
387 with_explain: bool,
388 start: impl Fn() -> std::time::Instant + Copy,
389) -> Result<SqlResult, ApiError> {
390 let statement_type = statement_type_name(stmt);
391 let read_only = is_read_only(stmt);
392 let is_query = matches!(stmt, sqlparser::ast::Statement::Query(_));
395 let stmt_sql = stmt_sql.trim_end_matches(';');
396
397 if with_explain && read_only && is_query {
398 let explain_sql = format!("EXPLAIN {stmt_sql}");
400 let rows = match client.query(&explain_sql, &[]).await {
401 Ok(rows) => rows,
402 Err(error) => {
403 return Ok(SqlResult {
404 statement_type,
405 error: Some(format_sql_error(error)),
406 elapsed_ms: start().elapsed().as_millis() as u64,
407 ..Default::default()
408 });
409 }
410 };
411 let explain = rows
412 .iter()
413 .filter_map(|r| r.try_get::<_, String>(0).ok())
414 .collect::<Vec<_>>()
415 .join("\n");
416 return Ok(SqlResult {
417 statement_type,
418 explain: Some(explain),
419 elapsed_ms: start().elapsed().as_millis() as u64,
420 ..Default::default()
421 });
422 }
423
424 if read_only {
425 let limited_sql = if is_query {
432 format!("SELECT * FROM ({stmt_sql}) AS q LIMIT {}", MAX_ROWS + 1)
433 } else {
434 stmt_sql.to_string()
435 };
436 let rows = match client.query(&limited_sql, &[]).await {
437 Ok(rows) => rows,
438 Err(error) => {
439 return Ok(SqlResult {
440 statement_type,
441 error: Some(format_sql_error(error)),
442 elapsed_ms: start().elapsed().as_millis() as u64,
443 ..Default::default()
444 });
445 }
446 };
447 let columns: Vec<String> = rows
448 .first()
449 .map(|r| r.columns().iter().map(|c| c.name().to_string()).collect())
450 .unwrap_or_default();
451 let mut data: Vec<Vec<serde_json::Value>> = Vec::new();
452 let mut truncated = false;
453 for r in &rows {
454 if data.len() >= MAX_ROWS {
455 truncated = true;
456 break;
457 }
458 let row: Vec<serde_json::Value> = (0..r.len()).map(|i| col_to_json(r, i)).collect();
459 data.push(row);
460 }
461 Ok(SqlResult {
462 columns,
463 rows: data,
464 truncated,
465 statement_type,
466 elapsed_ms: start().elapsed().as_millis() as u64,
467 ..Default::default()
468 })
469 } else {
470 let affected = match client.execute(stmt_sql, &[]).await {
472 Ok(affected) => affected,
473 Err(error) => {
474 return Ok(SqlResult {
475 statement_type,
476 error: Some(format_sql_error(error)),
477 elapsed_ms: start().elapsed().as_millis() as u64,
478 ..Default::default()
479 });
480 }
481 };
482 Ok(SqlResult {
483 affected_rows: affected,
484 statement_type,
485 elapsed_ms: start().elapsed().as_millis() as u64,
486 ..Default::default()
487 })
488 }
489}
490
491#[cfg(all(test, feature = "server"))]
492mod tests {
493 use super::*;
494
495 fn parse(sql: &str) -> Vec<sqlparser::ast::Statement> {
497 use sqlparser::dialect::PostgreSqlDialect;
498 use sqlparser::parser::Parser;
499 Parser::parse_sql(&PostgreSqlDialect {}, sql).unwrap_or_default()
500 }
501
502 #[test]
507 fn absolutely_forbidden_targets_database_and_schema() {
508 assert_eq!(
510 ABSOLUTELY_FORBIDDEN,
511 &[
512 &["drop", "database"],
513 &["drop", "schema"],
514 &["create", "database"],
515 ]
516 );
517 }
518
519 #[test]
520 fn precheck_catches_forbidden_regardless_of_case() {
521 for sql in [
522 "DROP DATABASE yggdrasil",
523 "drop schema public",
524 "CREATE DATABASE evil",
525 "Drop Database x",
526 ] {
527 assert!(is_absolutely_forbidden(sql).is_some(), "应拦截: {sql:?}");
528 }
529 }
530
531 #[test]
532 fn precheck_catches_multi_space_bypass() {
533 for sql in [
537 "DROP DATABASE x",
538 "drop\tdatabase\tx",
539 "DROP\nDATABASE\nx",
540 "DROP\t\tDATABASE x;",
541 ] {
542 assert!(
543 is_absolutely_forbidden(sql).is_some(),
544 "多空格绕过应被拦截: {sql:?}"
545 );
546 }
547 }
548
549 #[test]
550 fn precheck_returns_canonical_name_for_error_message() {
551 assert_eq!(
552 is_absolutely_forbidden("DROP DATABASE x"),
553 Some("DROP DATABASE")
554 );
555 assert_eq!(
556 is_absolutely_forbidden("drop schema public"),
557 Some("DROP SCHEMA")
558 );
559 assert_eq!(
560 is_absolutely_forbidden("CREATE DATABASE evil"),
561 Some("CREATE DATABASE")
562 );
563 }
564
565 #[test]
566 fn create_database_is_blocked_at_both_string_and_ast_layers() {
567 assert!(is_absolutely_forbidden("CREATE DATABASE x").is_some());
573 let asts = parse("CREATE DATABASE x");
574 assert!(!asts.is_empty(), "CREATE DATABASE 应可被 sqlparser 解析");
575 assert!(matches!(
576 check_guards(&asts, true),
577 GuardResult::Forbidden(_)
578 ));
579 }
580
581 #[test]
582 fn drop_database_is_guarded_by_both_precheck_and_ast_check() {
583 let asts = parse("DROP DATABASE x");
584 assert!(!asts.is_empty(), "DROP DATABASE 应可被 sqlparser 解析");
585 assert!(matches!(
586 check_guards(&asts, true),
587 GuardResult::Forbidden(_)
588 ));
589 assert!(is_absolutely_forbidden("DROP DATABASE x").is_some());
590 }
591
592 #[test]
593 fn precheck_ignores_benign_statements() {
594 for sql in [
595 "SELECT * FROM users",
596 "DROP TABLE old_logs",
597 "CREATE TABLE t (id int)",
598 "DELETE FROM t WHERE id = 1",
599 "CREATE INDEX idx ON t (col)",
600 ] {
601 assert!(is_absolutely_forbidden(sql).is_none(), "不应误拦: {sql:?}");
602 }
603 }
604
605 #[test]
608 fn guard_forbids_drop_schema_even_though_string_precheck_is_bypassable() {
609 let asts = parse("DROP SCHEMA public");
611 match check_guards(&asts, true) {
612 GuardResult::Forbidden(msg) => {
613 assert!(msg.contains("SCHEMA"), "DROP SCHEMA 应被禁止, 得到: {msg}")
614 }
615 other => panic!("DROP SCHEMA 应 Forbidden, 得到 {other:?}"),
616 }
617 }
618
619 #[test]
620 fn guard_drop_schema_ignores_confirm_flag() {
621 let asts = parse("DROP SCHEMA public");
623 assert!(matches!(
624 check_guards(&asts, true),
625 GuardResult::Forbidden(_)
626 ));
627 }
628
629 #[test]
632 fn guard_drop_table_needs_confirm() {
633 let asts = parse("DROP TABLE old_logs");
634 assert!(matches!(
635 check_guards(&asts, false),
636 GuardResult::NeedsConfirm
637 ));
638 }
639
640 #[test]
641 fn guard_drop_table_allowed_with_confirm() {
642 let asts = parse("DROP TABLE old_logs");
643 assert!(matches!(check_guards(&asts, true), GuardResult::Allowed));
644 }
645
646 #[test]
647 fn guard_truncate_needs_confirm() {
648 let asts = parse("TRUNCATE TABLE sessions");
649 assert!(matches!(
650 check_guards(&asts, false),
651 GuardResult::NeedsConfirm
652 ));
653 }
654
655 #[test]
656 fn guard_alter_table_needs_confirm() {
657 let asts = parse("ALTER TABLE posts ADD COLUMN foo text");
658 assert!(matches!(
659 check_guards(&asts, false),
660 GuardResult::NeedsConfirm
661 ));
662 }
663
664 #[test]
665 fn guard_alter_table_allowed_with_confirm() {
666 let asts = parse("ALTER TABLE posts ADD COLUMN foo text");
667 assert!(matches!(check_guards(&asts, true), GuardResult::Allowed));
668 }
669
670 #[test]
673 fn guard_update_without_where_is_forbidden() {
674 let asts = parse("UPDATE posts SET title = 'x'");
675 match check_guards(&asts, true) {
676 GuardResult::Forbidden(msg) => assert!(msg.contains("WHERE")),
677 other => panic!("无 WHERE 的 UPDATE 应 Forbidden, 得到 {other:?}"),
678 }
679 }
680
681 #[test]
682 fn guard_delete_without_where_is_forbidden() {
683 let asts = parse("DELETE FROM posts");
684 match check_guards(&asts, true) {
685 GuardResult::Forbidden(msg) => assert!(msg.contains("WHERE")),
686 other => panic!("无 WHERE 的 DELETE 应 Forbidden, 得到 {other:?}"),
687 }
688 }
689
690 #[test]
691 fn guard_update_with_where_allowed() {
692 let asts = parse("UPDATE posts SET title = 'x' WHERE id = 1");
693 assert!(matches!(check_guards(&asts, false), GuardResult::Allowed));
694 }
695
696 #[test]
697 fn guard_delete_with_where_allowed() {
698 let asts = parse("DELETE FROM posts WHERE id = 1");
699 assert!(matches!(check_guards(&asts, false), GuardResult::Allowed));
700 }
701
702 #[test]
703 fn guard_update_with_where_not_rescued_by_confirm() {
704 let asts = parse("UPDATE posts SET title = 'x'");
706 assert!(matches!(
707 check_guards(&asts, true),
708 GuardResult::Forbidden(_)
709 ));
710 }
711
712 #[test]
715 fn guard_allows_select_insert_create() {
716 for sql in [
717 "SELECT * FROM posts",
718 "INSERT INTO posts (title) VALUES ('x')",
719 "CREATE TABLE t (id int)",
720 ] {
721 let asts = parse(sql);
722 assert!(
723 matches!(check_guards(&asts, false), GuardResult::Allowed),
724 "应放行: {sql}"
725 );
726 }
727 }
728
729 #[test]
732 fn guard_checks_all_statements_in_batch() {
733 let asts = parse("SELECT 1; DELETE FROM posts");
735 assert!(matches!(
736 check_guards(&asts, false),
737 GuardResult::Forbidden(_)
738 ));
739 }
740
741 #[test]
742 fn guard_first_dangerous_short_circuits() {
743 let asts = parse("DROP TABLE a; SELECT 1");
745 assert!(matches!(
746 check_guards(&asts, false),
747 GuardResult::NeedsConfirm
748 ));
749 }
750
751 #[test]
754 fn statement_type_name_maps_variants() {
755 assert_eq!(statement_type_name(&parse("SELECT 1")[0]), "Select");
756 assert_eq!(
757 statement_type_name(&parse("INSERT INTO t (a) VALUES (1)")[0]),
758 "Insert"
759 );
760 assert_eq!(
761 statement_type_name(&parse("UPDATE t SET a = 1 WHERE id = 1")[0]),
762 "Update"
763 );
764 assert_eq!(
765 statement_type_name(&parse("DELETE FROM t WHERE id = 1")[0]),
766 "Delete"
767 );
768 assert_eq!(
769 statement_type_name(&parse("CREATE TABLE t (id int)")[0]),
770 "CreateTable"
771 );
772 assert_eq!(
773 statement_type_name(&parse("ALTER TABLE t ADD COLUMN x int")[0]),
774 "AlterTable"
775 );
776 assert_eq!(statement_type_name(&parse("DROP TABLE t")[0]), "Drop");
777 assert_eq!(statement_type_name(&parse("TRUNCATE t")[0]), "Truncate");
778 }
779
780 #[test]
783 fn is_read_only_classifies_correctly() {
784 assert!(is_read_only(&parse("SELECT 1")[0]));
785 assert!(is_read_only(&parse("EXPLAIN SELECT 1")[0]));
786 assert!(!is_read_only(&parse("UPDATE t SET a = 1 WHERE id = 1")[0]));
787 assert!(!is_read_only(&parse("DELETE FROM t WHERE id = 1")[0]));
788 assert!(!is_read_only(&parse("INSERT INTO t (a) VALUES (1)")[0]));
789 }
790
791 #[test]
792 fn is_read_only_keeps_show_statements_read_only() {
793 assert!(is_read_only(&parse("SHOW search_path")[0]));
794 }
795
796 #[test]
797 fn guard_forbids_unbounded_delete_hidden_behind_explain_analyze_or_cte() {
798 for sql in [
801 "EXPLAIN ANALYZE DELETE FROM posts",
802 "EXPLAIN (ANALYZE) UPDATE posts SET title = 'x'",
803 "WITH d AS (DELETE FROM posts RETURNING *) SELECT * FROM d",
804 "WITH x AS (SELECT 1) DELETE FROM posts",
805 ] {
806 match check_guards(&parse(sql), true) {
807 GuardResult::Forbidden(msg) => assert!(msg.contains("WHERE"), "{sql}: {msg}"),
808 other => panic!("{sql} 应被 WHERE 护栏拦截, got {other:?}"),
809 }
810 }
811 }
812
813 #[test]
817 fn writes_affect_cache_true_for_write_statements() {
818 for sql in [
819 "UPDATE posts SET deleted_at = NOW() WHERE id = 648",
820 "DELETE FROM posts WHERE id = 648",
821 "INSERT INTO posts (title) VALUES ('x')",
822 "TRUNCATE posts",
823 "ALTER TABLE posts ADD COLUMN x int",
824 "DROP TABLE posts",
825 "EXPLAIN ANALYZE DELETE FROM posts WHERE id = 648",
826 "EXPLAIN (ANALYZE) UPDATE posts SET title = 'x' WHERE id = 648",
827 "WITH d AS (DELETE FROM posts WHERE id = 648 RETURNING *) SELECT * FROM d",
828 "SELECT * INTO posts_copy FROM posts",
829 ] {
830 assert!(
831 writes_affect_cache(&parse(sql)),
832 "写语句应触发兜底失效:{sql:?}"
833 );
834 }
835 }
836
837 #[test]
838 fn writes_affect_cache_false_for_read_only_statements() {
839 for sql in [
840 "SELECT 1",
841 "EXPLAIN SELECT * FROM posts",
842 "SELECT id FROM posts WHERE id = 1",
843 ] {
844 assert!(
845 !writes_affect_cache(&parse(sql)),
846 "只读语句不应触发兜底失效:{sql:?}"
847 );
848 }
849 }
850
851 #[test]
852 fn writes_affect_cache_mixed_statements_flagged() {
853 let stmts = parse("SELECT 1; UPDATE posts SET deleted_at = NULL WHERE id = 648");
855 assert!(writes_affect_cache(&stmts));
856 }
857 #[test]
858 fn format_sql_error_truncates_long_messages() {
859 let message = format_sql_error("x".repeat(2_500));
860 assert_eq!(message.chars().count(), 2_000);
861 }
862
863 #[test]
864 #[ignore = "requires DATABASE_URL"]
865 fn sql_console_database_runs_show_and_explain_statements() {
866 crate::db::TEST_DATABASE_RUNTIME.block_on(show_and_explain_impl());
867 }
868
869 async fn show_and_explain_impl() {
871 let client = get_conn().await.unwrap();
872 let run = |sql: &'static str, with_explain: bool| {
873 let client = &client;
874 async move {
875 let stmt = parse(sql).remove(0);
876 execute_one(
877 client,
878 &stmt,
879 &stmt.to_string(),
880 with_explain,
881 std::time::Instant::now,
882 )
883 .await
884 .unwrap()
885 }
886 };
887
888 for with_explain in [false, true] {
889 let show = run("SHOW search_path", with_explain).await;
890 assert_eq!(show.error, None);
891 assert_eq!(show.columns, ["search_path"]);
892 assert_eq!(show.rows.len(), 1);
893
894 let all = run("SHOW ALL;", with_explain).await;
895 assert_eq!(all.error, None);
896 assert_eq!(all.columns, ["name", "setting", "description"]);
897 assert!(all.rows.len() > 1);
898
899 let plan = run("EXPLAIN SELECT 1", with_explain).await;
900 assert_eq!(plan.error, None);
901 assert_eq!(plan.columns, ["QUERY PLAN"]);
902 assert_eq!(plan.explain, None);
903 }
904
905 let explained = run("SELECT 1 AS one", true).await;
906 assert_eq!(explained.error, None);
907 assert!(explained.explain.unwrap().contains("Result"));
908
909 let selected = run("SELECT generate_series(1, 600) AS n", false).await;
910 assert_eq!(selected.error, None);
911 assert_eq!(selected.columns, ["n"]);
912 assert_eq!(selected.rows.len(), MAX_ROWS);
913 assert!(selected.truncated);
914 }
915}