Skip to main content

linkrs_server/
graph_service.rs

1mod batch_exec;
2mod command;
3mod context;
4mod cursor;
5mod error;
6mod explain;
7mod factory;
8mod guard;
9mod query;
10mod session;
11mod streaming;
12
13pub use context::QueryExecutionContext;
14pub use error::GraphServiceError;
15
16use std::sync::atomic::AtomicU64;
17use std::sync::Arc;
18
19#[cfg(feature = "vector")]
20use linkrs_api::api_core::VectorApi;
21use linkrs_api::api_core::{QueryApi, SyncApi};
22use linkrs_metrics::StatsManager;
23use linkrs_query::query_manager::QueryManager;
24use linkrs_transaction::TransactionManager;
25
26use crate::auth::PasswordAuthenticator;
27use crate::config::BootstrapConfig;
28use crate::config::SecurityConfig;
29use crate::permission::PermissionManager;
30use crate::query::executor::streaming::pool::SharedScheduler;
31use crate::query::executor::streaming::query_registry::QueryRegistry;
32use crate::session::GraphSessionManager;
33use crate::storage::StorageClient;
34
35pub struct GraphService<S: StorageClient + Clone + 'static> {
36    session_manager: Arc<GraphSessionManager>,
37    query_api: Arc<parking_lot::RwLock<QueryApi<S>>>,
38    authenticator: PasswordAuthenticator,
39    permission_manager: Arc<PermissionManager>,
40    pub stats_manager: Arc<StatsManager>,
41    storage: Arc<S>,
42    #[cfg(feature = "vector")]
43    vector_api: Option<Arc<VectorApi>>,
44    sync_api: Option<Arc<SyncApi>>,
45
46    transaction_manager: Option<Arc<TransactionManager>>,
47
48    /// Engine-level shared scheduler, created once at startup.
49    shared_scheduler: Arc<SharedScheduler>,
50    /// Process-level query registry, created once at startup.
51    query_registry: Arc<QueryRegistry>,
52    /// Process-level query manager.
53    query_manager: Arc<QueryManager>,
54
55    /// Row interval for query-progress notifications (0 disables emission).
56    progress_rows_interval: u64,
57
58    /// Monotonically increasing query ID counter (server-assigned, not hash-based).
59    next_query_id: AtomicU64,
60
61    /// Bootstrap-time configuration used for single-user mode and other
62    /// local-deployment toggles.
63    bootstrap_config: BootstrapConfig,
64
65    /// Password-policy and related security settings snapshotted at
66    /// startup. All plaintext-password write paths validate against it.
67    security_config: SecurityConfig,
68}
69
70#[cfg(test)]
71mod tests {
72    use super::GraphService;
73    use crate::storage::{GraphStorage, MockStorage};
74    use linkrs_core::types::{SpaceInfo, SpaceSummary};
75    use std::sync::Arc;
76
77    /// In-memory storage with a single space, the GraphService built over it,
78    /// and that space's identity for role grants.
79    struct MaintenanceTestService {
80        service: Arc<GraphService<GraphStorage>>,
81        space: SpaceSummary,
82        space_id: i64,
83    }
84
85    impl MaintenanceTestService {
86        async fn new() -> Self {
87            use crate::storage::{PropertyGraphConfig, StorageReader, StorageSchemaOps};
88
89            let storage = Arc::new(
90                GraphStorage::new_with_config(PropertyGraphConfig::test())
91                    .expect("in-memory storage"),
92            );
93            {
94                let mut handle = (*storage).clone();
95                let mut space = SpaceInfo::new("maint_space".to_string())
96                    .with_vid_type(linkrs_core::DataType::BigInt);
97                StorageSchemaOps::create_space(&mut handle, &mut space).expect("create space");
98            }
99            let space = storage
100                .list_spaces()
101                .expect("list spaces")
102                .into_iter()
103                .next()
104                .expect("space must exist");
105            let space_id = space.space_id as i64;
106            let service = GraphService::<GraphStorage>::new_for_test(
107                crate::config::Config::default(),
108                storage,
109            )
110            .await;
111            Self {
112                service,
113                space: SpaceSummary::from(&space),
114                space_id,
115            }
116        }
117
118        /// Create a session for `username` holding `role` in the test space.
119        async fn session_with_role(&self, username: &str, role: linkrs_core::RoleType) -> i64 {
120            self.service
121                .permission_manager
122                .grant_role(username, self.space_id, role)
123                .expect("grant role");
124            let session = self
125                .service
126                .get_session_manager()
127                .create_session(username.to_string(), "127.0.0.1".to_string())
128                .await
129                .expect("create session");
130            session.set_space(self.space.clone());
131            session.id()
132        }
133    }
134
135    #[tokio::test]
136    async fn maintenance_statements_are_classified_above_read_only() {
137        use crate::graph_service::GraphService as TestService;
138        use crate::storage::MockStorage;
139
140        fn permission(text: &str) -> Option<linkrs_core::Permission> {
141            let stmt = TestService::<MockStorage>::parse_stmt(text)?;
142            TestService::<MockStorage>::permission_for_stmt(&stmt)
143        }
144
145        // Maintenance statements must never inherit the read-only default:
146        // `PURGE EXPIRED` reclaims storage (schema-level), `CHECKPOINT`
147        // forces a snapshot (write-level).
148        assert_eq!(
149            permission("PURGE EXPIRED IN maint_space"),
150            Some(linkrs_core::Permission::Schema)
151        );
152        assert_eq!(
153            permission("purge expired in maint_space"),
154            Some(linkrs_core::Permission::Schema)
155        );
156        assert_eq!(
157            permission("CHECKPOINT"),
158            Some(linkrs_core::Permission::Write)
159        );
160        assert_eq!(
161            permission("SHOW SESSIONS"),
162            Some(linkrs_core::Permission::Admin)
163        );
164        assert_eq!(
165            permission("SHOW QUERIES"),
166            Some(linkrs_core::Permission::Read)
167        );
168        assert_eq!(
169            permission("KILL QUERY 1, 2"),
170            Some(linkrs_core::Permission::Admin)
171        );
172        assert_eq!(
173            permission("MATCH (n) RETURN n"),
174            Some(linkrs_core::Permission::Read)
175        );
176        assert_eq!(
177            permission("UPDATE 1 ON Person SET age = 2"),
178            Some(linkrs_core::Permission::Write)
179        );
180        assert_eq!(
181            permission("CREATE TAG t (id: INT)"),
182            Some(linkrs_core::Permission::Schema)
183        );
184        assert_eq!(
185            permission("CREATE SPACE s"),
186            Some(linkrs_core::Permission::Admin)
187        );
188        assert_eq!(permission("CREATE USER u WITH PASSWORD 'x'"), None);
189        assert_eq!(permission("CREATEUSER u WITH PASSWORD 'x'"), None);
190        assert_eq!(permission("SELECT 1"), None);
191    }
192
193    #[tokio::test]
194    async fn user_management_spellings_share_one_gate() {
195        let fixture = MaintenanceTestService::new().await;
196        let service = &fixture.service;
197        let guest = fixture
198            .session_with_role("gate_guest", linkrs_core::RoleType::Guest)
199            .await;
200        for stmt in [
201            "CREATE USER gate_u WITH PASSWORD 'secret123'",
202            "CREATEUSER gate_u WITH PASSWORD 'secret123'",
203            "CREATE  USER gate_u WITH PASSWORD 'secret123'",
204            "CHANGE PASSWORD guest_other 'old12345' TO 'new12345'",
205            "CHANGEPASSWORD guest_other 'old12345' TO 'new12345'",
206        ] {
207            let error = service
208                .execute(guest, stmt)
209                .await
210                .expect_err("guest must not pass the user gate");
211            assert!(
212                error.message().contains("Permission check failed"),
213                "unexpected error for {stmt}: {}",
214                error
215            );
216            let stream_error = match service.execute_stream(guest, stmt).await {
217                Ok(_) => panic!("guest must not pass the streaming gate for {stmt}"),
218                Err(error) => error,
219            };
220            assert!(
221                stream_error.message().contains("Permission check failed"),
222                "unexpected streaming error for {stmt}: {}",
223                stream_error
224            );
225        }
226    }
227
228    #[tokio::test]
229    async fn user_listing_reads_require_admin_or_self() {
230        use super::context::QueryExecutionContext;
231
232        let fixture = MaintenanceTestService::new().await;
233        let service = &fixture.service;
234        let admin = fixture
235            .session_with_role("read_admin", linkrs_core::RoleType::Admin)
236            .await;
237        let _user = fixture
238            .session_with_role("read_user", linkrs_core::RoleType::User)
239            .await;
240        let guest = fixture
241            .session_with_role("read_guest", linkrs_core::RoleType::Guest)
242            .await;
243
244        fn gate(
245            service: &GraphService<crate::storage::GraphStorage>,
246            username: &str,
247            space_id: i64,
248            stmt: &str,
249        ) -> Result<(), super::GraphServiceError> {
250            let context = QueryExecutionContext {
251                stmt,
252                parsed_ast: None,
253                space_id,
254                parameters: None,
255                session_variables: None,
256            };
257            service.check_query_permission(username, &context)
258        }
259
260        // SHOW USERS / SHOW ROLES: Admin passes, User and Guest are rejected.
261        gate(service, "read_admin", fixture.space_id, "SHOW USERS").expect("admin lists users");
262        gate(
263            service,
264            "read_admin",
265            fixture.space_id,
266            "SHOW ROLES IN maint_space",
267        )
268        .expect("admin lists roles");
269        for stmt in ["SHOW USERS", "SHOW ROLES", "SHOW ROLES IN maint_space"] {
270            for username in ["read_user", "read_guest"] {
271                let error = gate(service, username, fixture.space_id, stmt)
272                    .expect_err("non-admin must not list users/roles");
273                assert!(
274                    error.message().contains("only Admin or God"),
275                    "unexpected error for {username} {stmt}: {}",
276                    error.message()
277                );
278            }
279        }
280
281        // DESCRIBE USER: self always passes, others require Admin.
282        for username in ["read_admin", "read_user", "read_guest"] {
283            gate(
284                service,
285                username,
286                fixture.space_id,
287                &format!("DESCRIBE USER {username}"),
288            )
289            .unwrap_or_else(|_| panic!("{username} must describe self"));
290        }
291        for username in ["read_user", "read_guest"] {
292            let error = gate(
293                service,
294                username,
295                fixture.space_id,
296                "DESCRIBE USER someone_else",
297            )
298            .expect_err("non-admin must not describe others");
299            assert!(
300                error.message().contains("only Admin or God"),
301                "unexpected error: {}",
302                error.message()
303            );
304        }
305
306        // Both entries enforce the same gate.
307        let error = service
308            .execute(guest, "SHOW USERS")
309            .await
310            .expect_err("guest must not list users");
311        assert!(
312            error.message().contains("Permission check failed"),
313            "unexpected error: {}",
314            error.message()
315        );
316        service
317            .execute(admin, "SHOW USERS")
318            .await
319            .expect("admin lists users");
320        service
321            .execute_stream(admin, "SHOW ROLES")
322            .await
323            .expect("admin lists roles on the streaming entry");
324
325        // Self-description runs end to end once the account exists.
326        service
327            .execute(admin, "CREATE USER self_user WITH PASSWORD 'SelfPass123'")
328            .await
329            .expect("admin creates the account");
330        let flown = service
331            .get_session_manager()
332            .create_session("self_user".to_string(), "127.0.0.1".to_string())
333            .await
334            .expect("create session");
335        flown.set_space(fixture.space.clone());
336        service
337            .execute(flown.id(), "DESCRIBE USER self_user")
338            .await
339            .expect("self-description succeeds");
340        let denied = service
341            .execute(guest, "DESCRIBE USER self_user")
342            .await
343            .expect_err("guest must not describe others");
344        assert!(
345            denied.message().contains("Permission check failed"),
346            "unexpected error: {}",
347            denied.message()
348        );
349    }
350
351    #[tokio::test]
352    async fn purge_expired_requires_maintenance_permission_on_every_entry() {
353        let fixture = MaintenanceTestService::new().await;
354        let stmt = "PURGE EXPIRED IN maint_space";
355
356        let guest = fixture
357            .session_with_role("guest_user", linkrs_core::RoleType::Guest)
358            .await;
359        let writer = fixture
360            .session_with_role("writer_user", linkrs_core::RoleType::User)
361            .await;
362        let dba = fixture
363            .session_with_role("dba_user", linkrs_core::RoleType::Dba)
364            .await;
365
366        // Read-only and plain write roles are rejected on the unary entry.
367        for session_id in [guest, writer] {
368            let error = fixture
369                .service
370                .execute(session_id, stmt)
371                .await
372                .expect_err("role without maintenance rights must be rejected");
373            assert!(
374                error.message().contains("Permission check failed"),
375                "unexpected error: {}",
376                error
377            );
378        }
379        // The same rejection happens on the streaming entry.
380        let stream_error = match fixture.service.execute_stream(guest, stmt).await {
381            Ok(_) => panic!("read-only role must be rejected on the streaming entry"),
382            Err(error) => error,
383        };
384        assert!(
385            stream_error.message().contains("Permission check failed"),
386            "unexpected error: {}",
387            stream_error
388        );
389
390        // The maintenance role executes the purge.
391        fixture
392            .service
393            .execute(dba, stmt)
394            .await
395            .expect("dba role runs the purge");
396        fixture
397            .service
398            .execute_stream(dba, stmt)
399            .await
400            .expect("dba role runs the purge on the streaming entry");
401    }
402
403    #[tokio::test]
404    async fn checkpoint_requires_write_permission_on_every_entry() {
405        let fixture = MaintenanceTestService::new().await;
406
407        let guest = fixture
408            .session_with_role("guest_user", linkrs_core::RoleType::Guest)
409            .await;
410        let writer = fixture
411            .session_with_role("writer_user", linkrs_core::RoleType::User)
412            .await;
413
414        let unary_error = fixture
415            .service
416            .execute(guest, "CHECKPOINT")
417            .await
418            .expect_err("read-only role must be rejected");
419        assert!(
420            unary_error.message().contains("Permission check failed"),
421            "unexpected error: {}",
422            unary_error
423        );
424        let stream_error = match fixture.service.execute_stream(guest, "CHECKPOINT").await {
425            Ok(_) => panic!("read-only role must be rejected on the streaming entry"),
426            Err(error) => error,
427        };
428        assert!(
429            stream_error.message().contains("Permission check failed"),
430            "unexpected error: {}",
431            stream_error
432        );
433
434        fixture
435            .service
436            .execute(writer, "CHECKPOINT")
437            .await
438            .expect("write role runs the checkpoint");
439        fixture
440            .service
441            .execute_stream(writer, "CHECKPOINT")
442            .await
443            .expect("write role runs the checkpoint on the streaming entry");
444    }
445
446    #[tokio::test]
447    async fn use_statement_binds_session_space_from_parsed_form() {
448        let fixture = MaintenanceTestService::new().await;
449        let session = fixture
450            .session_with_role("use_user", linkrs_core::RoleType::User)
451            .await;
452
453        // Any whitespace separates `USE` from the space name, so the binding
454        // must follow the parsed statement rather than a literal text prefix.
455        for stmt in ["USE maint_space", "USE\tmaint_space", "USE\nmaint_space"] {
456            fixture
457                .service
458                .execute(session, stmt)
459                .await
460                .expect("space switch must execute");
461            let bound = fixture
462                .service
463                .get_session_manager()
464                .find_session(session)
465                .expect("session must exist")
466                .space()
467                .map(|space| space.id);
468            assert_eq!(
469                bound,
470                Some(fixture.space_id as u64),
471                "session space must bind for {stmt:?}",
472            );
473        }
474    }
475
476    #[tokio::test]
477    async fn use_statement_binds_session_space_on_the_streaming_entry() {
478        let fixture = MaintenanceTestService::new().await;
479        let session = fixture
480            .session_with_role("stream_use_user", linkrs_core::RoleType::User)
481            .await;
482
483        // The streaming entry snapshots the session space before executing, so a
484        // space switch must be routed to the materialized path that binds it.
485        fixture
486            .service
487            .execute_stream(session, "USE\tmaint_space")
488            .await
489            .expect("streaming space switch must execute");
490        let bound = fixture
491            .service
492            .get_session_manager()
493            .find_session(session)
494            .expect("session must exist")
495            .space()
496            .map(|space| space.id);
497        assert_eq!(
498            bound,
499            Some(fixture.space_id as u64),
500            "streaming space switch must bind the session space",
501        );
502    }
503
504    #[tokio::test]
505    async fn session_inspection_serves_live_registries() {
506        let fixture = MaintenanceTestService::new().await;
507        let service = &fixture.service;
508        let admin = fixture
509            .session_with_role("inspect_admin", linkrs_core::RoleType::Admin)
510            .await;
511        let user = fixture
512            .session_with_role("inspect_user", linkrs_core::RoleType::User)
513            .await;
514
515        // SHOW SESSIONS requires Admin and lists the caller's own row.
516        let sessions = service
517            .execute(admin, "SHOW SESSIONS")
518            .await
519            .expect("admin lists sessions");
520        assert!(sessions.columns().contains(&"session_id".to_string()));
521        assert!(
522            sessions.rows().iter().any(|row| row
523                .first()
524                .is_some_and(|value| *value == linkrs_core::Value::BigInt(admin))),
525            "own session row must be present"
526        );
527        let denied = service
528            .execute(user, "SHOW SESSIONS")
529            .await
530            .expect_err("user must not list sessions");
531        assert!(
532            denied.message().contains("Permission check failed"),
533            "unexpected error: {}",
534            denied.message()
535        );
536
537        // SHOW QUERIES is readable and empty while nothing runs.
538        let queries = service
539            .execute(user, "SHOW QUERIES")
540            .await
541            .expect("user lists queries");
542        assert!(queries.columns().contains(&"query_id".to_string()));
543        assert!(queries.rows().is_empty());
544
545        // A tracked stand-in is killable through the statement path; the
546        // second attempt reports the non-running state.
547        service
548            .query_manager()
549            .register_query_with_id(
550                424201,
551                admin,
552                "inspect_admin".to_string(),
553                None,
554                "MATCH (n) RETURN n".to_string(),
555            )
556            .expect("register tracked query");
557        let killed = service
558            .execute(admin, &format!("KILL QUERY {admin}, 424201"))
559            .await
560            .expect("kill tracked query");
561        assert_eq!(
562            killed.columns(),
563            &[
564                "session_id".to_string(),
565                "query_id".to_string(),
566                "status".to_string()
567            ]
568        );
569        assert_eq!(killed.rows().len(), 1);
570        let repeat = service
571            .execute(admin, &format!("KILL QUERY {admin}, 424201"))
572            .await
573            .expect_err("repeat kill must fail");
574        assert!(
575            repeat.message().contains("not running"),
576            "unexpected error: {}",
577            repeat.message()
578        );
579        let foreign = service
580            .execute(admin, "KILL QUERY 999999, 424201")
581            .await
582            .expect_err("unknown session must fail");
583        assert!(
584            foreign.message().contains("does not exist"),
585            "unexpected error: {}",
586            foreign.message()
587        );
588
589        // The streaming entry reroutes inspection to the same path.
590        service
591            .execute_stream(admin, "SHOW SESSIONS")
592            .await
593            .expect("streaming inspection must execute");
594    }
595
596    #[test]
597    fn write_and_schema_statements_map_above_read() {
598        use crate::graph_service::GraphService as TestService;
599        use crate::query::parser::ast::StmtCategory;
600        use crate::storage::MockStorage;
601
602        fn classified(text: &str) -> (Option<linkrs_core::Permission>, StmtCategory) {
603            let stmt = TestService::<MockStorage>::parse_stmt(text)
604                .unwrap_or_else(|| panic!("statement should parse: {text}"));
605            let category = stmt.category();
606            let permission = TestService::<MockStorage>::permission_for_stmt(&stmt);
607            (permission, category)
608        }
609
610        // DML writes never fall through to the read default: UPDATE and UPSERT
611        // share the Update variant (`is_upsert` only changes the write mode).
612        for (text, kind) in [
613            ("UPDATE 1 ON Person SET age = 2", "UPDATE"),
614            (
615                "UPSERT VERTEX ON Person SET name = 'Alice' WHERE id(vid) == 1",
616                "UPSERT",
617            ),
618            ("MERGE (v:Person {name: 'Alice'}) SET v.age = 30", "MERGE"),
619        ] {
620            let (permission, category) = classified(text);
621            assert_eq!(
622                permission,
623                Some(linkrs_core::Permission::Write),
624                "{kind} must require Write"
625            );
626            assert_eq!(category, StmtCategory::Dml, "{kind} must classify as DML");
627            let stmt = TestService::<MockStorage>::parse_stmt(text).expect("parses");
628            assert_eq!(stmt.kind(), kind);
629        }
630
631        // Schema objects require Schema (USER cannot create them); spaces stay Admin.
632        for (text, expected) in [
633            ("CREATE TAG t (id: INT)", linkrs_core::Permission::Schema),
634            (
635                "CREATE INDEX idx ON Person (name)",
636                linkrs_core::Permission::Schema,
637            ),
638            ("CREATE SPACE s", linkrs_core::Permission::Admin),
639            ("DROP SPACE s", linkrs_core::Permission::Admin),
640        ] {
641            let (permission, category) = classified(text);
642            assert_eq!(permission, Some(expected), "{text} misclassified");
643            assert_eq!(category, StmtCategory::Ddl, "{text} must classify as DDL");
644        }
645    }
646
647    #[test]
648    fn transaction_command_classification() {
649        let begin = GraphService::<MockStorage>::parse_command("BEGIN READ ONLY")
650            .expect("BEGIN should classify")
651            .expect("BEGIN should be a command");
652        assert!(matches!(
653            begin.ast.stmt(),
654            crate::query::parser::ast::Stmt::BeginTransaction(ref s) if s.read_only == Some(true)
655        ));
656
657        let commit = GraphService::<MockStorage>::parse_command("COMMIT")
658            .expect("COMMIT should classify")
659            .expect("COMMIT should be a command");
660        assert!(matches!(
661            commit.ast.stmt(),
662            crate::query::parser::ast::Stmt::CommitTransaction(_)
663        ));
664
665        let rollback = GraphService::<MockStorage>::parse_command("ROLLBACK TO sp1")
666            .expect("ROLLBACK TO should classify")
667            .expect("ROLLBACK TO should be a command");
668        assert!(matches!(
669            rollback.ast.stmt(),
670            crate::query::parser::ast::Stmt::RollbackTransaction(ref s)
671                if s.savepoint_name.as_deref() == Some("sp1")
672        ));
673
674        let savepoint = GraphService::<MockStorage>::parse_command("SAVEPOINT sp1")
675            .expect("SAVEPOINT should classify")
676            .expect("SAVEPOINT should be a command");
677        assert!(matches!(
678            savepoint.ast.stmt(),
679            crate::query::parser::ast::Stmt::Savepoint(ref s) if s.name == "sp1"
680        ));
681
682        let release = GraphService::<MockStorage>::parse_command("RELEASE SAVEPOINT sp1")
683            .expect("RELEASE SAVEPOINT should classify")
684            .expect("RELEASE SAVEPOINT should be a command");
685        assert!(matches!(
686            release.ast.stmt(),
687            crate::query::parser::ast::Stmt::ReleaseSavepoint(ref s) if s.name == "sp1"
688        ));
689
690        let let_stmt = GraphService::<MockStorage>::parse_command("LET $x = 1 + 2")
691            .expect("LET should classify")
692            .expect("LET should be a command");
693        assert!(matches!(
694            let_stmt.ast.stmt(),
695            crate::query::parser::ast::Stmt::AssignVariable(ref s) if s.name == "x"
696        ));
697
698        assert!(
699            GraphService::<MockStorage>::parse_command("MATCH (n) RETURN n")
700                .unwrap()
701                .is_none()
702        );
703        assert!(GraphService::<MockStorage>::parse_command("COMMIT junk")
704            .unwrap()
705            .is_none());
706
707        let err =
708            GraphService::<MockStorage>::parse_command("LET $x").expect_err("LET $x must fail");
709        assert!(
710            err.message().contains("LET requires an assignment"),
711            "unexpected error: {}",
712            err
713        );
714
715        let bare_let =
716            GraphService::<MockStorage>::parse_command("LET").expect_err("bare LET must fail");
717        assert!(
718            bare_let.message().contains("Invalid session variable name"),
719            "unexpected error: {}",
720            err
721        );
722    }
723
724    #[test]
725    fn config_statements_are_classified_for_streaming_guard() {
726        assert!(GraphService::<MockStorage>::is_config_statement(
727            "UPDATE CONFIGS database max_sessions = 100"
728        ));
729        assert!(GraphService::<MockStorage>::is_config_statement(
730            "  show configs database"
731        ));
732        assert!(!GraphService::<MockStorage>::is_config_statement(
733            "MATCH (n) RETURN n"
734        ));
735        assert!(!GraphService::<MockStorage>::is_config_statement(
736            "UPDATE 1 ON Person SET age = 2"
737        ));
738    }
739
740    #[test]
741    fn root_estimate_takes_the_last_marker() {
742        let plan = "info est_rows:10,other:1\nmore est_rows:250\ntail without marker";
743        assert_eq!(
744            GraphService::<MockStorage>::extract_root_estimate(plan),
745            Some(250)
746        );
747    }
748
749    #[test]
750    fn root_estimate_absent_without_markers() {
751        assert_eq!(
752            GraphService::<MockStorage>::extract_root_estimate("no estimates here"),
753            None
754        );
755        assert_eq!(
756            GraphService::<MockStorage>::extract_root_estimate("est_rows:"),
757            None
758        );
759    }
760}