Skip to main content

cypherlite_query/api/
mod.rs

1// Public API: CypherLite, QueryResult, Row, Transaction, Params, Value
2//
3// Phase 3 (v0.3.0) additions:
4// - WITH clause (scope barrier + projection)
5// - UNWIND clause (list expansion)
6// - OPTIONAL MATCH (left join semantics)
7// - MERGE with ON MATCH SET / ON CREATE SET
8// - CREATE INDEX / DROP INDEX DDL
9// - Variable-length paths [*N..M] with cycle detection
10// - Query optimizer: IndexScan, LIMIT pushdown, constant folding, projection pruning
11
12use crate::executor::{Params, Record, Value};
13use cypherlite_core::{CypherLiteError, DatabaseConfig};
14use cypherlite_storage::StorageEngine;
15use std::collections::HashMap;
16
17/// Result of executing a Cypher query.
18#[derive(Debug)]
19pub struct QueryResult {
20    /// Column names in order.
21    pub columns: Vec<String>,
22    /// Rows of data.
23    pub rows: Vec<Row>,
24}
25
26/// A single row in a query result.
27#[derive(Debug)]
28pub struct Row {
29    values: HashMap<String, Value>,
30    columns: Vec<String>,
31}
32
33impl Row {
34    /// Create a new row from a map of column name -> value and an ordered column list.
35    pub fn new(values: HashMap<String, Value>, columns: Vec<String>) -> Self {
36        Self { values, columns }
37    }
38
39    /// Get a value by column name.
40    pub fn get(&self, column: &str) -> Option<&Value> {
41        self.values.get(column)
42    }
43
44    /// Get a typed value by column name.
45    pub fn get_as<T: FromValue>(&self, column: &str) -> Option<T> {
46        self.values.get(column).and_then(T::from_value)
47    }
48
49    /// Get all column names.
50    pub fn columns(&self) -> &[String] {
51        &self.columns
52    }
53}
54
55/// Trait for converting Value to concrete Rust types.
56pub trait FromValue: Sized {
57    /// Attempt to extract a typed value from a `Value` reference.
58    fn from_value(value: &Value) -> Option<Self>;
59}
60
61impl FromValue for i64 {
62    fn from_value(value: &Value) -> Option<Self> {
63        match value {
64            Value::Int64(i) => Some(*i),
65            _ => None,
66        }
67    }
68}
69
70impl FromValue for f64 {
71    fn from_value(value: &Value) -> Option<Self> {
72        match value {
73            Value::Float64(f) => Some(*f),
74            _ => None,
75        }
76    }
77}
78
79impl FromValue for String {
80    fn from_value(value: &Value) -> Option<Self> {
81        match value {
82            Value::String(s) => Some(s.clone()),
83            _ => None,
84        }
85    }
86}
87
88impl FromValue for bool {
89    fn from_value(value: &Value) -> Option<Self> {
90        match value {
91            Value::Bool(b) => Some(*b),
92            _ => None,
93        }
94    }
95}
96
97// @MX:ANCHOR: Main CypherLite database interface -- primary public API entry point
98// @MX:REASON: fan_in >= 3 (integration tests, user code, transaction wrapper)
99/// The main CypherLite database interface.
100pub struct CypherLite {
101    engine: StorageEngine,
102    #[cfg(feature = "plugin")]
103    scalar_functions:
104        cypherlite_core::plugin::PluginRegistry<dyn cypherlite_core::plugin::ScalarFunction>,
105    #[cfg(feature = "plugin")]
106    index_plugins:
107        cypherlite_core::plugin::PluginRegistry<dyn cypherlite_core::plugin::IndexPlugin>,
108    #[cfg(feature = "plugin")]
109    serializers: cypherlite_core::plugin::PluginRegistry<dyn cypherlite_core::plugin::Serializer>,
110    #[cfg(feature = "plugin")]
111    triggers: cypherlite_core::plugin::PluginRegistry<dyn cypherlite_core::plugin::Trigger>,
112}
113
114impl CypherLite {
115    /// Open or create a CypherLite database.
116    pub fn open(config: DatabaseConfig) -> Result<Self, CypherLiteError> {
117        let engine = StorageEngine::open(config)?;
118        Ok(Self {
119            engine,
120            #[cfg(feature = "plugin")]
121            scalar_functions: cypherlite_core::plugin::PluginRegistry::new(),
122            #[cfg(feature = "plugin")]
123            index_plugins: cypherlite_core::plugin::PluginRegistry::new(),
124            #[cfg(feature = "plugin")]
125            serializers: cypherlite_core::plugin::PluginRegistry::new(),
126            #[cfg(feature = "plugin")]
127            triggers: cypherlite_core::plugin::PluginRegistry::new(),
128        })
129    }
130
131    /// Execute a Cypher query string.
132    pub fn execute(&mut self, query: &str) -> Result<QueryResult, CypherLiteError> {
133        self.execute_with_params(query, Params::new())
134    }
135
136    /// Execute a Cypher query with parameters.
137    pub fn execute_with_params(
138        &mut self,
139        query: &str,
140        params: Params,
141    ) -> Result<QueryResult, CypherLiteError> {
142        // 1. Parse
143        let ast = crate::parser::parse_query(query).map_err(|e| CypherLiteError::ParseError {
144            line: e.line,
145            column: e.column,
146            message: e.message,
147        })?;
148
149        // 2. Semantic analysis
150        let mut analyzer = crate::semantic::SemanticAnalyzer::new(self.engine.catalog_mut());
151        analyzer
152            .analyze(&ast)
153            .map_err(|e| CypherLiteError::SemanticError(e.message))?;
154
155        // 3. Plan
156        let plan = crate::planner::LogicalPlanner::new(self.engine.catalog_mut())
157            .plan(&ast)
158            .map_err(|e| CypherLiteError::ExecutionError(e.message))?;
159
160        // 4. Optimize (index scan, limit pushdown, constant folding, projection pruning)
161        let plan = crate::planner::optimize::optimize(plan);
162
163        // 4.5. Inject query start time for now() function
164        let mut params = params;
165        if !params.contains_key("__query_start_ms__") {
166            let now_ms = std::time::SystemTime::now()
167                .duration_since(std::time::UNIX_EPOCH)
168                .map(|d| d.as_millis() as i64)
169                .unwrap_or(0);
170            params.insert("__query_start_ms__".to_string(), Value::Int64(now_ms));
171        }
172
173        // 5. Execute
174        #[cfg(feature = "plugin")]
175        let scalar_fns: &dyn crate::executor::ScalarFnLookup = &self.scalar_functions;
176        #[cfg(not(feature = "plugin"))]
177        let scalar_fns: &dyn crate::executor::ScalarFnLookup = &();
178        #[cfg(feature = "plugin")]
179        let trigger_fns: &dyn crate::executor::TriggerLookup = &self.triggers;
180        #[cfg(not(feature = "plugin"))]
181        let trigger_fns: &dyn crate::executor::TriggerLookup = &();
182        let records =
183            crate::executor::execute(&plan, &mut self.engine, &params, scalar_fns, trigger_fns)
184                .map_err(|e| CypherLiteError::ExecutionError(e.message))?;
185
186        // 6. Convert to QueryResult
187        let columns = extract_columns(&records);
188        let rows = records
189            .into_iter()
190            .map(|r| Row::new(r, columns.clone()))
191            .collect();
192
193        Ok(QueryResult { columns, rows })
194    }
195
196    /// Get a reference to the underlying storage engine.
197    pub fn engine(&self) -> &StorageEngine {
198        &self.engine
199    }
200
201    /// Get a mutable reference to the underlying storage engine.
202    pub fn engine_mut(&mut self) -> &mut StorageEngine {
203        &mut self.engine
204    }
205
206    /// Register a user-defined scalar function (plugin feature).
207    ///
208    /// Returns an error if a function with the same name is already registered.
209    #[cfg(feature = "plugin")]
210    pub fn register_scalar_function(
211        &mut self,
212        func: Box<dyn cypherlite_core::plugin::ScalarFunction>,
213    ) -> Result<(), CypherLiteError> {
214        self.scalar_functions
215            .register(func)
216            .map_err(|e| CypherLiteError::PluginError(e.to_string()))
217    }
218
219    /// List all registered scalar functions as `(name, version)` pairs.
220    #[cfg(feature = "plugin")]
221    pub fn list_scalar_functions(&self) -> Vec<(&str, &str)> {
222        self.scalar_functions
223            .list()
224            .filter_map(|name| self.scalar_functions.get(name).map(|f| (name, f.version())))
225            .collect()
226    }
227
228    /// Register a custom index plugin.
229    ///
230    /// Returns an error if an index plugin with the same name is already registered.
231    #[cfg(feature = "plugin")]
232    pub fn register_index_plugin(
233        &mut self,
234        plugin: Box<dyn cypherlite_core::plugin::IndexPlugin>,
235    ) -> Result<(), CypherLiteError> {
236        self.index_plugins
237            .register(plugin)
238            .map_err(|e| CypherLiteError::PluginError(e.to_string()))
239    }
240
241    /// List all registered index plugins as `(name, version, index_type)` tuples.
242    #[cfg(feature = "plugin")]
243    pub fn list_index_plugins(&self) -> Vec<(&str, &str, &str)> {
244        self.index_plugins
245            .list()
246            .filter_map(|name| {
247                self.index_plugins
248                    .get(name)
249                    .map(|p| (name, p.version(), p.index_type()))
250            })
251            .collect()
252    }
253
254    /// Get an immutable reference to a registered index plugin by name.
255    #[cfg(feature = "plugin")]
256    pub fn get_index_plugin(
257        &self,
258        name: &str,
259    ) -> Option<&dyn cypherlite_core::plugin::IndexPlugin> {
260        self.index_plugins.get(name)
261    }
262
263    /// Get a mutable reference to a registered index plugin by name.
264    ///
265    /// Useful for calling `insert()` and `remove()` which require `&mut self`.
266    #[cfg(feature = "plugin")]
267    pub fn get_index_plugin_mut(
268        &mut self,
269        name: &str,
270    ) -> Option<&mut (dyn cypherlite_core::plugin::IndexPlugin + 'static)> {
271        self.index_plugins.get_mut(name)
272    }
273
274    /// Register a custom serializer plugin.
275    ///
276    /// Returns an error if a serializer with the same name is already registered.
277    #[cfg(feature = "plugin")]
278    pub fn register_serializer(
279        &mut self,
280        serializer: Box<dyn cypherlite_core::plugin::Serializer>,
281    ) -> Result<(), CypherLiteError> {
282        self.serializers
283            .register(serializer)
284            .map_err(|e| CypherLiteError::PluginError(e.to_string()))
285    }
286
287    /// List all registered serializers as `(name, version)` pairs.
288    #[cfg(feature = "plugin")]
289    pub fn list_serializers(&self) -> Vec<(&str, &str)> {
290        self.serializers
291            .list()
292            .filter_map(|name| self.serializers.get(name).map(|s| (name, s.version())))
293            .collect()
294    }
295
296    /// Export query results through a registered serializer.
297    ///
298    /// Executes the given query, converts the resulting rows to
299    /// `Vec<HashMap<String, PropertyValue>>`, then delegates to the
300    /// serializer whose `format()` matches the requested format string.
301    #[cfg(feature = "plugin")]
302    pub fn export_data(&mut self, format: &str, query: &str) -> Result<Vec<u8>, CypherLiteError> {
303        // Validate format before executing (avoids running query for bad format).
304        if !self.has_serializer_format(format) {
305            return Err(CypherLiteError::UnsupportedFormat(format.to_string()));
306        }
307
308        // Execute the query first (requires &mut self).
309        let result = self.execute(query)?;
310
311        // Convert rows to property maps (filter out non-convertible values).
312        let data = rows_to_property_maps(&result.rows);
313
314        // Now borrow serializer (only &self needed) and export.
315        let serializer = self.find_serializer_by_format(format)?;
316        serializer.export(&data)
317    }
318
319    /// Import data through a registered serializer.
320    ///
321    /// Looks up the serializer whose `format()` matches the requested format
322    /// string, then delegates to its `import()` method.
323    #[cfg(feature = "plugin")]
324    pub fn import_data(
325        &self,
326        format: &str,
327        bytes: &[u8],
328    ) -> Result<Vec<HashMap<String, cypherlite_core::types::PropertyValue>>, CypherLiteError> {
329        let serializer = self.find_serializer_by_format(format)?;
330        serializer.import(bytes)
331    }
332
333    /// Check whether a serializer with the given format is registered.
334    #[cfg(feature = "plugin")]
335    fn has_serializer_format(&self, format: &str) -> bool {
336        self.serializers.list().any(|name| {
337            self.serializers
338                .get(name)
339                .is_some_and(|s| s.format() == format)
340        })
341    }
342
343    /// Find a registered serializer by its format identifier.
344    #[cfg(feature = "plugin")]
345    fn find_serializer_by_format(
346        &self,
347        format: &str,
348    ) -> Result<&dyn cypherlite_core::plugin::Serializer, CypherLiteError> {
349        for name in self.serializers.list() {
350            if let Some(s) = self.serializers.get(name) {
351                if s.format() == format {
352                    return Ok(s);
353                }
354            }
355        }
356        Err(CypherLiteError::UnsupportedFormat(format.to_string()))
357    }
358
359    /// Register a custom trigger plugin.
360    ///
361    /// Returns an error if a trigger with the same name is already registered.
362    #[cfg(feature = "plugin")]
363    pub fn register_trigger(
364        &mut self,
365        trigger: Box<dyn cypherlite_core::plugin::Trigger>,
366    ) -> Result<(), CypherLiteError> {
367        self.triggers
368            .register(trigger)
369            .map_err(|e| CypherLiteError::PluginError(e.to_string()))
370    }
371
372    /// List all registered triggers as `(name, version)` pairs.
373    #[cfg(feature = "plugin")]
374    pub fn list_triggers(&self) -> Vec<(&str, &str)> {
375        self.triggers
376            .list()
377            .filter_map(|name| self.triggers.get(name).map(|t| (name, t.version())))
378            .collect()
379    }
380
381    /// Begin a transaction (simplified - wraps execute calls).
382    pub fn begin(&mut self) -> Transaction<'_> {
383        Transaction {
384            db: self,
385            committed: false,
386        }
387    }
388}
389
390/// Convert query rows to property maps, filtering out non-convertible values
391/// (e.g., Node, Edge references that have no PropertyValue representation).
392#[cfg(feature = "plugin")]
393fn rows_to_property_maps(
394    rows: &[Row],
395) -> Vec<HashMap<String, cypherlite_core::types::PropertyValue>> {
396    use cypherlite_core::types::PropertyValue;
397
398    rows.iter()
399        .map(|row| {
400            row.columns()
401                .iter()
402                .filter_map(|col| {
403                    row.get(col).and_then(|v| {
404                        PropertyValue::try_from(v.clone())
405                            .ok()
406                            .map(|pv| (col.clone(), pv))
407                    })
408                })
409                .collect()
410        })
411        .collect()
412}
413
414/// Extract column names from the first record.
415fn extract_columns(records: &[Record]) -> Vec<String> {
416    if records.is_empty() {
417        return vec![];
418    }
419    let mut cols: Vec<String> = records[0].keys().cloned().collect();
420    cols.sort(); // deterministic column order
421    cols
422}
423
424/// A transaction wrapping CypherLite execute calls.
425///
426/// Phase 2: simplified transaction without WAL integration.
427/// Full rollback requires WAL integration (Phase 3).
428pub struct Transaction<'a> {
429    db: &'a mut CypherLite,
430    committed: bool,
431}
432
433impl<'a> Transaction<'a> {
434    /// Execute a query within this transaction.
435    pub fn execute(&mut self, query: &str) -> Result<QueryResult, CypherLiteError> {
436        self.db.execute(query)
437    }
438
439    /// Execute a query with parameters within this transaction.
440    pub fn execute_with_params(
441        &mut self,
442        query: &str,
443        params: Params,
444    ) -> Result<QueryResult, CypherLiteError> {
445        self.db.execute_with_params(query, params)
446    }
447
448    /// Commit the transaction.
449    pub fn commit(mut self) -> Result<(), CypherLiteError> {
450        self.committed = true;
451        Ok(())
452    }
453
454    /// Rollback the transaction (discard changes).
455    /// For Phase 2, this is a no-op since we don't have WAL integration yet.
456    pub fn rollback(mut self) -> Result<(), CypherLiteError> {
457        self.committed = true; // prevent double-rollback
458                               // Phase 2: no actual rollback - in-memory changes remain
459                               // Full rollback requires WAL integration (Phase 3)
460        Ok(())
461    }
462}
463
464impl<'a> Drop for Transaction<'a> {
465    fn drop(&mut self) {
466        if !self.committed {
467            // Auto-rollback on drop (no-op for Phase 2)
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use cypherlite_core::SyncMode;
476    use tempfile::tempdir;
477
478    fn test_config(dir: &std::path::Path) -> DatabaseConfig {
479        DatabaseConfig {
480            path: dir.join("test.cyl"),
481            wal_sync_mode: SyncMode::Normal,
482            ..Default::default()
483        }
484    }
485
486    // ======================================================================
487    // TASK-054: QueryResult, Row, FromValue tests
488    // ======================================================================
489
490    #[test]
491    fn test_row_get_existing_column() {
492        let mut values = HashMap::new();
493        values.insert("name".to_string(), Value::String("Alice".into()));
494        let row = Row::new(values, vec!["name".to_string()]);
495        assert_eq!(row.get("name"), Some(&Value::String("Alice".into())));
496    }
497
498    #[test]
499    fn test_row_get_missing_column() {
500        let row = Row::new(HashMap::new(), vec![]);
501        assert_eq!(row.get("missing"), None);
502    }
503
504    #[test]
505    fn test_row_get_as_i64() {
506        let mut values = HashMap::new();
507        values.insert("age".to_string(), Value::Int64(30));
508        let row = Row::new(values, vec!["age".to_string()]);
509        assert_eq!(row.get_as::<i64>("age"), Some(30));
510    }
511
512    #[test]
513    fn test_row_get_as_f64() {
514        let mut values = HashMap::new();
515        values.insert("score".to_string(), Value::Float64(3.15));
516        let row = Row::new(values, vec!["score".to_string()]);
517        assert_eq!(row.get_as::<f64>("score"), Some(3.15));
518    }
519
520    #[test]
521    fn test_row_get_as_string() {
522        let mut values = HashMap::new();
523        values.insert("name".to_string(), Value::String("Bob".into()));
524        let row = Row::new(values, vec!["name".to_string()]);
525        assert_eq!(row.get_as::<String>("name"), Some("Bob".to_string()));
526    }
527
528    #[test]
529    fn test_row_get_as_bool() {
530        let mut values = HashMap::new();
531        values.insert("active".to_string(), Value::Bool(true));
532        let row = Row::new(values, vec!["active".to_string()]);
533        assert_eq!(row.get_as::<bool>("active"), Some(true));
534    }
535
536    #[test]
537    fn test_row_get_as_wrong_type() {
538        let mut values = HashMap::new();
539        values.insert("age".to_string(), Value::String("thirty".into()));
540        let row = Row::new(values, vec!["age".to_string()]);
541        assert_eq!(row.get_as::<i64>("age"), None);
542    }
543
544    #[test]
545    fn test_row_columns() {
546        let row = Row::new(HashMap::new(), vec!["a".to_string(), "b".to_string()]);
547        assert_eq!(row.columns(), &["a".to_string(), "b".to_string()]);
548    }
549
550    #[test]
551    fn test_query_result_empty() {
552        let result = QueryResult {
553            columns: vec![],
554            rows: vec![],
555        };
556        assert!(result.rows.is_empty());
557        assert!(result.columns.is_empty());
558    }
559
560    #[test]
561    fn test_from_value_null_returns_none() {
562        assert_eq!(i64::from_value(&Value::Null), None);
563        assert_eq!(f64::from_value(&Value::Null), None);
564        assert_eq!(String::from_value(&Value::Null), None);
565        assert_eq!(bool::from_value(&Value::Null), None);
566    }
567
568    #[test]
569    fn test_extract_columns_empty_records() {
570        let records: Vec<Record> = vec![];
571        assert!(extract_columns(&records).is_empty());
572    }
573
574    #[test]
575    fn test_extract_columns_deterministic_order() {
576        let mut r = Record::new();
577        r.insert("b".to_string(), Value::Int64(1));
578        r.insert("a".to_string(), Value::Int64(2));
579        let cols = extract_columns(&[r]);
580        assert_eq!(cols, vec!["a".to_string(), "b".to_string()]);
581    }
582
583    // ======================================================================
584    // TASK-055: CypherLite::open(), execute() tests
585    // ======================================================================
586
587    #[test]
588    fn test_cypherlite_open() {
589        let dir = tempdir().expect("tempdir");
590        let db = CypherLite::open(test_config(dir.path()));
591        assert!(db.is_ok());
592    }
593
594    #[test]
595    fn test_cypherlite_engine_accessors() {
596        let dir = tempdir().expect("tempdir");
597        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
598        assert_eq!(db.engine().node_count(), 0);
599        assert_eq!(db.engine_mut().edge_count(), 0);
600    }
601
602    // ======================================================================
603    // TASK-056: Transaction tests
604    // ======================================================================
605
606    #[test]
607    fn test_transaction_commit() {
608        let dir = tempdir().expect("tempdir");
609        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
610        let tx = db.begin();
611        assert!(tx.commit().is_ok());
612    }
613
614    #[test]
615    fn test_transaction_rollback() {
616        let dir = tempdir().expect("tempdir");
617        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
618        let tx = db.begin();
619        assert!(tx.rollback().is_ok());
620    }
621
622    #[test]
623    fn test_transaction_auto_rollback_on_drop() {
624        let dir = tempdir().expect("tempdir");
625        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
626        {
627            let _tx = db.begin();
628            // dropped without commit or rollback -- should not panic
629        }
630    }
631
632    // ======================================================================
633    // TASK-057: End-to-end integration tests
634    // ======================================================================
635
636    // INT-T001: open -> CREATE -> MATCH
637    #[test]
638    fn int_t001_create_then_match() {
639        let dir = tempdir().expect("tempdir");
640        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
641
642        // Create a node
643        db.execute("CREATE (n:Person {name: 'Alice', age: 30})")
644            .expect("create");
645
646        // Query the node
647        let result = db
648            .execute("MATCH (n:Person) RETURN n.name, n.age")
649            .expect("match");
650        assert_eq!(result.rows.len(), 1);
651        assert_eq!(
652            result.rows[0].get_as::<String>("n.name"),
653            Some("Alice".to_string())
654        );
655        assert_eq!(result.rows[0].get_as::<i64>("n.age"), Some(30));
656    }
657
658    // INT-T002: Parameter binding $name
659    #[test]
660    fn int_t002_parameter_binding() {
661        let dir = tempdir().expect("tempdir");
662        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
663
664        db.execute("CREATE (n:Person {name: 'Alice'})")
665            .expect("create");
666
667        let mut params = Params::new();
668        params.insert("name".to_string(), Value::String("Alice".into()));
669
670        let result = db
671            .execute_with_params(
672                "MATCH (n:Person) WHERE n.name = $name RETURN n.name",
673                params,
674            )
675            .expect("match with params");
676        assert_eq!(result.rows.len(), 1);
677        assert_eq!(
678            result.rows[0].get_as::<String>("n.name"),
679            Some("Alice".to_string())
680        );
681    }
682
683    // INT-T003: Transaction commit
684    #[test]
685    fn int_t003_transaction_commit() {
686        let dir = tempdir().expect("tempdir");
687        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
688
689        {
690            let mut tx = db.begin();
691            tx.execute("CREATE (n:Person {name: 'Bob'})")
692                .expect("create in tx");
693            tx.commit().expect("commit");
694        }
695
696        // Verify data persists after commit
697        let result = db
698            .execute("MATCH (n:Person) RETURN n.name")
699            .expect("match after commit");
700        assert_eq!(result.rows.len(), 1);
701        assert_eq!(
702            result.rows[0].get_as::<String>("n.name"),
703            Some("Bob".to_string())
704        );
705    }
706
707    // INT-T004: Invalid Cypher -> ParseError (no panic)
708    #[test]
709    fn int_t004_invalid_cypher_parse_error() {
710        let dir = tempdir().expect("tempdir");
711        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
712
713        let result = db.execute("INVALID QUERY @#$");
714        assert!(result.is_err());
715        let err = result.expect_err("should fail");
716        // Should be a parse error, not a panic
717        assert!(
718            matches!(err, CypherLiteError::ParseError { .. }),
719            "expected ParseError, got: {err}"
720        );
721    }
722
723    // INT-T005: MATCH non-existent label -> empty result (not error)
724    #[test]
725    fn int_t005_match_nonexistent_label_empty() {
726        let dir = tempdir().expect("tempdir");
727        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
728
729        let result = db
730            .execute("MATCH (n:NonExistent) RETURN n")
731            .expect("should succeed with empty result");
732        assert!(result.rows.is_empty());
733    }
734
735    // INT-T006: SET then MATCH to verify change
736    #[test]
737    fn int_t006_set_then_match() {
738        let dir = tempdir().expect("tempdir");
739        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
740
741        db.execute("CREATE (n:Person {name: 'Alice', age: 25})")
742            .expect("create");
743
744        db.execute("MATCH (n:Person) SET n.age = 30").expect("set");
745
746        let result = db
747            .execute("MATCH (n:Person) RETURN n.age")
748            .expect("match after set");
749        assert_eq!(result.rows.len(), 1);
750        assert_eq!(result.rows[0].get_as::<i64>("n.age"), Some(30));
751    }
752
753    // INT-T007: DETACH DELETE
754    #[test]
755    fn int_t007_detach_delete() {
756        let dir = tempdir().expect("tempdir");
757        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
758
759        db.execute("CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'})")
760            .expect("create");
761
762        // Verify nodes exist
763        let result = db
764            .execute("MATCH (n:Person) RETURN n.name")
765            .expect("match before delete");
766        assert_eq!(result.rows.len(), 2);
767
768        // Detach delete all Person nodes
769        db.execute("MATCH (n:Person) DETACH DELETE n")
770            .expect("detach delete");
771
772        // Verify no nodes remain
773        let result = db
774            .execute("MATCH (n:Person) RETURN n.name")
775            .expect("match after delete");
776        assert!(result.rows.is_empty());
777    }
778
779    // AC-001: MATCH (n:Person) RETURN n.name with 3 Person nodes
780    #[test]
781    fn ac_001_match_return_three_persons() {
782        let dir = tempdir().expect("tempdir");
783        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
784
785        db.execute("CREATE (n:Person {name: 'Alice'})").expect("c1");
786        db.execute("CREATE (n:Person {name: 'Bob'})").expect("c2");
787        db.execute("CREATE (n:Person {name: 'Charlie'})")
788            .expect("c3");
789
790        let result = db.execute("MATCH (n:Person) RETURN n.name").expect("match");
791        assert_eq!(result.rows.len(), 3);
792
793        let mut names: Vec<String> = result
794            .rows
795            .iter()
796            .filter_map(|r| r.get_as::<String>("n.name"))
797            .collect();
798        names.sort();
799        assert_eq!(names, vec!["Alice", "Bob", "Charlie"]);
800    }
801
802    // AC-002: CREATE (a:Person {name: "Alice"}) then MATCH verify
803    #[test]
804    fn ac_002_create_then_match_verify() {
805        let dir = tempdir().expect("tempdir");
806        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
807
808        db.execute("CREATE (a:Person {name: 'Alice'})")
809            .expect("create");
810
811        let result = db.execute("MATCH (n:Person) RETURN n.name").expect("match");
812        assert_eq!(result.rows.len(), 1);
813        assert_eq!(
814            result.rows[0].get_as::<String>("n.name"),
815            Some("Alice".to_string())
816        );
817    }
818
819    // AC-003: CREATE relationship then traverse
820    #[test]
821    fn ac_003_create_relationship_then_traverse() {
822        let dir = tempdir().expect("tempdir");
823        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
824
825        db.execute("CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'})")
826            .expect("create relationship");
827
828        let result = db
829            .execute("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN b.name")
830            .expect("traverse");
831        assert_eq!(result.rows.len(), 1);
832        assert_eq!(
833            result.rows[0].get_as::<String>("b.name"),
834            Some("Bob".to_string())
835        );
836    }
837
838    // AC-004: WHERE n.age > 28 filter
839    #[test]
840    fn ac_004_where_filter() {
841        let dir = tempdir().expect("tempdir");
842        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
843
844        db.execute("CREATE (n:Person {name: 'Alice', age: 30})")
845            .expect("c1");
846        db.execute("CREATE (n:Person {name: 'Bob', age: 25})")
847            .expect("c2");
848        db.execute("CREATE (n:Person {name: 'Charlie', age: 35})")
849            .expect("c3");
850
851        let result = db
852            .execute("MATCH (n:Person) WHERE n.age > 28 RETURN n.name")
853            .expect("filter");
854        assert_eq!(result.rows.len(), 2);
855
856        let mut names: Vec<String> = result
857            .rows
858            .iter()
859            .filter_map(|r| r.get_as::<String>("n.name"))
860            .collect();
861        names.sort();
862        assert_eq!(names, vec!["Alice", "Charlie"]);
863    }
864
865    // AC-006: Syntax error detection with position
866    #[test]
867    fn ac_006_syntax_error_detection() {
868        let dir = tempdir().expect("tempdir");
869        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
870
871        let result = db.execute("MATCH (n:Person RETURN n");
872        assert!(result.is_err());
873        let err = result.expect_err("should fail");
874        match err {
875            CypherLiteError::ParseError {
876                line,
877                column,
878                message,
879            } => {
880                assert!(line >= 1, "line should be >= 1, got {line}");
881                assert!(column >= 1, "column should be >= 1, got {column}");
882                assert!(!message.is_empty(), "error message should not be empty");
883            }
884            other => panic!("expected ParseError, got: {other}"),
885        }
886    }
887
888    // AC-007: Type mismatch error (undefined variable as semantic error)
889    #[test]
890    fn ac_007_semantic_error() {
891        let dir = tempdir().expect("tempdir");
892        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
893
894        // Reference undefined variable 'm' instead of 'n'
895        let result = db.execute("MATCH (n:Person) RETURN m.name");
896        assert!(result.is_err());
897        let err = result.expect_err("should fail");
898        assert!(
899            matches!(err, CypherLiteError::SemanticError(_)),
900            "expected SemanticError, got: {err}"
901        );
902    }
903
904    // AC-010: NULL handling (IS NOT NULL, missing property returns NULL)
905    #[test]
906    fn ac_010_null_handling() {
907        let dir = tempdir().expect("tempdir");
908        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
909
910        // Create nodes: one with email, one without
911        db.execute("CREATE (n:Person {name: 'Alice', email: 'alice@example.com'})")
912            .expect("c1");
913        db.execute("CREATE (n:Person {name: 'Bob'})").expect("c2");
914
915        // Query for a property that may be missing
916        let result = db
917            .execute("MATCH (n:Person) RETURN n.name, n.email")
918            .expect("match");
919        assert_eq!(result.rows.len(), 2);
920
921        // One row should have email, one should have Null
922        let mut found_null = false;
923        let mut found_email = false;
924        for row in &result.rows {
925            match row.get("n.email") {
926                Some(Value::String(s)) if !s.is_empty() => found_email = true,
927                Some(Value::Null) | None => found_null = true,
928                _ => {}
929            }
930        }
931        assert!(found_email, "should find at least one row with email");
932        assert!(found_null, "should find at least one row with null email");
933    }
934
935    // Additional: IS NOT NULL filter
936    #[test]
937    fn ac_010_is_not_null_filter() {
938        let dir = tempdir().expect("tempdir");
939        let mut db = CypherLite::open(test_config(dir.path())).expect("open");
940
941        db.execute("CREATE (n:Person {name: 'Alice', email: 'alice@example.com'})")
942            .expect("c1");
943        db.execute("CREATE (n:Person {name: 'Bob'})").expect("c2");
944
945        let result = db
946            .execute("MATCH (n:Person) WHERE n.email IS NOT NULL RETURN n.name")
947            .expect("filter not null");
948        assert_eq!(result.rows.len(), 1);
949        assert_eq!(
950            result.rows[0].get_as::<String>("n.name"),
951            Some("Alice".to_string())
952        );
953    }
954}