Skip to main content

alopex_embedded/
sql_api.rs

1use alopex_core::kv::RangeChangeJournalCapability;
2use alopex_core::kv::{any::AnyKVTransaction, KVStore, OwnedKVTransactionAdapter, ReadAtPoint};
3use alopex_core::types::TxnMode;
4use alopex_core::KVTransaction;
5use alopex_sql::catalog::TxnCatalogView;
6use alopex_sql::catalog::{Catalog, CatalogOverlay};
7use alopex_sql::executor::query::execute_query_streaming;
8use alopex_sql::executor::query::iterator::VecIterator;
9use alopex_sql::executor::query::RowIterator;
10use alopex_sql::executor::{
11    build_streaming_pipeline, ColumnInfo, ExecutionResult, Executor, QueryRowIterator, Row,
12};
13use alopex_sql::planner::typed_expr::Projection;
14use alopex_sql::storage::{LocalRangeChangeJournal, RangeChangeJournalScope, SqlValue, TxnBridge};
15use alopex_sql::AlopexDialect;
16use alopex_sql::Parser;
17use alopex_sql::Planner;
18use alopex_sql::Statement;
19use alopex_sql::StatementKind;
20use std::collections::BTreeMap;
21use std::sync::Arc;
22
23use crate::Database;
24use crate::Error;
25use crate::OwnedEmbeddedTransaction;
26use crate::Result;
27use crate::SqlResult;
28use crate::Transaction;
29
30/// Streaming row access for FR-7 compliance.
31///
32/// This struct provides access to query results in a streaming fashion,
33/// where the transaction is kept alive for the duration of row iteration.
34/// The lifetime `'a` is tied to the transaction scope.
35pub struct StreamingRows<'a> {
36    columns: Vec<ColumnInfo>,
37    iter: Box<dyn RowIterator + 'a>,
38    projection: Projection,
39    schema: Vec<alopex_sql::catalog::ColumnMetadata>,
40}
41
42impl<'a> StreamingRows<'a> {
43    /// Get column information for the query result.
44    pub fn columns(&self) -> &[ColumnInfo] {
45        &self.columns
46    }
47
48    /// Fetch the next row, returning `None` when exhausted.
49    ///
50    /// Rows are fetched on-demand from storage, enabling true streaming.
51    pub fn next_row(&mut self) -> Result<Option<Vec<SqlValue>>> {
52        match self.iter.next_row() {
53            Some(result) => {
54                let row = result.map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)))?;
55                let projected = self.project_row(&row)?;
56                Ok(Some(projected))
57            }
58            None => Ok(None),
59        }
60    }
61
62    /// Apply projection to a row.
63    fn project_row(&self, row: &Row) -> Result<Vec<SqlValue>> {
64        match &self.projection {
65            Projection::All(names) => {
66                // Return values in the order specified by names
67                let mut result = Vec::with_capacity(names.len());
68                for name in names {
69                    let idx = self
70                        .schema
71                        .iter()
72                        .position(|c| &c.name == name)
73                        .ok_or_else(|| {
74                            Error::Sql(alopex_sql::SqlError::Execution {
75                                message: format!("column not found: {}", name),
76                                code: "ALOPEX-E020",
77                            })
78                        })?;
79                    result.push(row.values.get(idx).cloned().unwrap_or(SqlValue::Null));
80                }
81                Ok(result)
82            }
83            Projection::Columns(cols) => {
84                use alopex_sql::executor::evaluator::{evaluate, EvalContext};
85                let ctx = EvalContext::new(&row.values);
86                let mut result = Vec::with_capacity(cols.len());
87                for col in cols {
88                    let value = evaluate(&col.expr, &ctx)
89                        .map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)))?;
90                    result.push(value);
91                }
92                Ok(result)
93            }
94        }
95    }
96}
97
98/// Result type for callback-based streaming query.
99pub enum StreamingQueryResult<R> {
100    /// DDL operation success.
101    Success,
102    /// DML operation with affected row count.
103    RowsAffected(u64),
104    /// Query result processed by callback.
105    QueryProcessed(R),
106}
107
108/// Streaming SQL execution result for FR-7 compliance.
109///
110/// This enum enables true streaming output for SELECT queries by returning
111/// an iterator instead of a materialized Vec.
112pub enum SqlStreamingResult {
113    /// DDL operation success (CREATE/DROP TABLE/INDEX).
114    Success,
115    /// DML operation success with affected row count.
116    RowsAffected(u64),
117    /// Query result with streaming row iterator.
118    Query(QueryRowIterator<'static>),
119}
120
121fn parse_sql(sql: &str) -> Result<Vec<Statement>> {
122    let dialect = AlopexDialect;
123    Parser::parse_sql(&dialect, sql).map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)))
124}
125
126fn stmt_requires_write(stmt: &Statement) -> bool {
127    !matches!(stmt.kind, StatementKind::Select(_))
128}
129
130fn stmt_changes_catalog(stmt: &Statement) -> bool {
131    matches!(
132        stmt.kind,
133        StatementKind::CreateTable(_)
134            | StatementKind::DropTable(_)
135            | StatementKind::CreateIndex(_)
136            | StatementKind::DropIndex(_)
137    )
138}
139
140fn stmt_changes_user_data(stmt: &Statement) -> bool {
141    matches!(
142        stmt.kind,
143        StatementKind::Insert(_) | StatementKind::Update(_) | StatementKind::Delete(_)
144    )
145}
146
147pub(crate) fn local_journal_scope<C: Catalog>(catalog: &C) -> RangeChangeJournalScope {
148    let mut index_tables = BTreeMap::new();
149    for table in catalog.list_tables() {
150        for index in catalog.get_indexes_for_table(&table.name) {
151            index_tables.insert(index.index_id, table.table_id);
152        }
153    }
154    RangeChangeJournalScope::local(index_tables)
155}
156
157fn plan_stmt<'a, S: KVStore>(
158    catalog: &'a alopex_sql::catalog::PersistentCatalog<S>,
159    overlay: &'a CatalogOverlay,
160    stmt: &Statement,
161) -> Result<alopex_sql::LogicalPlan> {
162    let view = TxnCatalogView::new(catalog, overlay);
163    let planner = Planner::new(&view);
164    planner
165        .plan(stmt)
166        .map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)))
167}
168
169/// Execute one SQL string inside an [`OwnedEmbeddedTransaction`] without committing it.
170///
171/// The legacy executor only needs a `KVTransaction` during this finite call.  We adapt the
172/// owned transaction for that scope and leave its terminal ownership with the core session.
173/// This preserves the catalog overlay and routing behaviour of [`Transaction::execute_sql`]
174/// without extending any borrow beyond the call.
175pub(crate) fn execute_sql_owned(
176    transaction: &mut OwnedEmbeddedTransaction,
177    sql: &str,
178) -> Result<SqlResult> {
179    let statements = parse_sql(sql)?;
180    if statements.is_empty() {
181        return Ok(alopex_sql::ExecutionResult::Success);
182    }
183
184    if statements.iter().any(stmt_requires_write) {
185        let mut cache = transaction
186            .db
187            .hnsw_cache
188            .write()
189            .expect("hnsw cache lock poisoned");
190        cache.clear();
191        let mut vector_cache = transaction
192            .db
193            .vector_cache
194            .write()
195            .expect("vector cache lock poisoned");
196        *vector_cache = None;
197    }
198
199    let db = Arc::clone(&transaction.db);
200    let session = transaction.session.clone();
201    let overlay = &mut transaction.overlay;
202    let catalog_modified = &mut transaction.catalog_modified;
203    let mut outcome = Ok(alopex_sql::ExecutionResult::Success);
204
205    session
206        .with_transaction(|owned| {
207            outcome = (|| {
208                let mut raw = AnyKVTransaction::Owned(OwnedKVTransactionAdapter::new(owned));
209                let mode = raw.mode();
210                let mut borrowed =
211                    TxnBridge::<alopex_core::kv::AnyKV>::wrap_external(&mut raw, mode, overlay);
212                let mut executor: Executor<_, _> =
213                    Executor::new(db.store.clone(), db.sql_catalog.clone());
214                let mut last = alopex_sql::ExecutionResult::Success;
215
216                for (statement_index, stmt) in statements.iter().enumerate() {
217                    let plan = {
218                        let catalog = db.sql_catalog.read().expect("catalog lock poisoned");
219                        let (_, overlay) = borrowed.split_parts();
220                        plan_stmt(&*catalog, &*overlay, stmt)?
221                    };
222
223                    {
224                        let catalog = db.sql_catalog.read().expect("catalog lock poisoned");
225                        let (_, overlay) = borrowed.split_parts();
226                        let view = TxnCatalogView::new(&*catalog, &*overlay);
227                        db.record_routing(&view, stmt, statement_index);
228                    }
229
230                    last = executor
231                        .execute_in_txn(plan, &mut borrowed)
232                        .map_err(|error| Error::Sql(alopex_sql::SqlError::from(error)))?;
233                }
234
235                if statements.iter().any(stmt_changes_catalog) {
236                    *catalog_modified = true;
237                }
238                Ok(last)
239            })();
240            Ok(())
241        })
242        .map_err(Error::Core)?;
243    outcome
244}
245
246/// Build column info from projection and schema.
247fn build_column_info(
248    projection: &Projection,
249    schema: &[alopex_sql::catalog::ColumnMetadata],
250) -> Result<Vec<ColumnInfo>> {
251    match projection {
252        Projection::All(names) => {
253            let mut cols = Vec::with_capacity(names.len());
254            for name in names {
255                let meta = schema.iter().find(|c| &c.name == name).ok_or_else(|| {
256                    Error::Sql(alopex_sql::SqlError::Execution {
257                        message: format!("column not found: {}", name),
258                        code: "ALOPEX-E020",
259                    })
260                })?;
261                cols.push(ColumnInfo::new(name.clone(), meta.data_type.clone()));
262            }
263            Ok(cols)
264        }
265        Projection::Columns(cols) => {
266            let mut result = Vec::with_capacity(cols.len());
267            for (i, col) in cols.iter().enumerate() {
268                let name = col
269                    .alias
270                    .clone()
271                    .or_else(|| {
272                        if let alopex_sql::planner::typed_expr::TypedExprKind::ColumnRef {
273                            column,
274                            ..
275                        } = &col.expr.kind
276                        {
277                            Some(column.clone())
278                        } else {
279                            None
280                        }
281                    })
282                    .unwrap_or_else(|| format!("col_{}", i));
283                result.push(ColumnInfo::new(name, col.expr.resolved_type.clone()));
284            }
285            Ok(result)
286        }
287    }
288}
289
290impl Database {
291    /// Opens a read-only SQL storage transaction at a cluster-issued fenced
292    /// read point. The returned transaction retains data, metadata, schema,
293    /// and index identities through [`ReadAtPoint`].
294    ///
295    /// This API never falls back to `begin(ReadOnly)`: an unavailable or
296    /// expired point returns [`Error::ReadAt`] before any SQL rows exist.
297    pub fn begin_read_at_sql(
298        &self,
299        point: ReadAtPoint,
300    ) -> Result<alopex_sql::storage::SqlTransaction<'_, alopex_core::kv::AnyKV>> {
301        let transaction = self.store.begin_read_at(&point).map_err(Error::ReadAt)?;
302        Ok(TxnBridge::from_read_at(transaction, point))
303    }
304
305    /// SQL を実行する(auto-commit)。
306    ///
307    /// - DDL/DML は ReadWrite トランザクションで実行し、成功時に自動コミットする。
308    /// - SELECT は ReadOnly トランザクションで実行する。
309    /// - 複数文はすべて同一トランザクションで実行し、最後の文の結果を返す。
310    ///   文ごとの結果が必要な場合は [`Database::execute_sql_multi`] を使用する。
311    ///
312    /// # Examples
313    ///
314    /// ```
315    /// use alopex_embedded::Database;
316    /// use alopex_sql::ExecutionResult;
317    ///
318    /// let db = Database::new();
319    /// let result = db.execute_sql(
320    ///     "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
321    /// ).unwrap();
322    /// assert!(matches!(result, ExecutionResult::Success));
323    /// ```
324    pub fn execute_sql(&self, sql: &str) -> Result<SqlResult> {
325        Ok(self
326            .execute_sql_multi(sql)?
327            .pop()
328            .unwrap_or(alopex_sql::ExecutionResult::Success))
329    }
330
331    /// SQL を実行し、文ごとの実行結果を返す(auto-commit)。
332    ///
333    /// すべての文を同一トランザクションで実行し、成功時に自動コミットする。
334    /// いずれかの文が失敗した場合はトランザクション全体がロールバックされ、
335    /// エラーを返す。入力に文が含まれない場合は空の `Vec` を返す。
336    ///
337    /// # Examples
338    ///
339    /// ```
340    /// use alopex_embedded::Database;
341    /// use alopex_sql::ExecutionResult;
342    ///
343    /// let db = Database::new();
344    /// let results = db.execute_sql_multi(
345    ///     "CREATE TABLE users (id INTEGER PRIMARY KEY); INSERT INTO users (id) VALUES (1);",
346    /// ).unwrap();
347    /// assert_eq!(results.len(), 2);
348    /// assert!(matches!(results[0], ExecutionResult::Success));
349    /// assert!(matches!(results[1], ExecutionResult::RowsAffected(1)));
350    /// ```
351    pub fn execute_sql_multi(&self, sql: &str) -> Result<Vec<SqlResult>> {
352        let stmts = parse_sql(sql)?;
353        if stmts.is_empty() {
354            return Ok(Vec::new());
355        }
356
357        // PRAGMA controls must execute against the store itself. Running them
358        // through the external-transaction bridge would hide the store from
359        // the executor and correctly reject the operation. Keep the
360        // auto-commit public API usable for a standalone PRAGMA.
361        if stmts.len() == 1 {
362            let overlay = CatalogOverlay::new();
363            let plan = {
364                let catalog = self.sql_catalog.read().expect("catalog lock poisoned");
365                plan_stmt(&*catalog, &overlay, &stmts[0])?
366            };
367            if matches!(stmts[0].kind, StatementKind::Pragma { .. })
368                || alopex_sql::executor::is_store_direct_plan(&plan)
369            {
370                let mut executor: Executor<_, _> =
371                    Executor::new(self.store.clone(), self.sql_catalog.clone());
372                return executor
373                    .execute(plan)
374                    .map(|result| vec![result])
375                    .map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)));
376            }
377        }
378
379        let requires_write = stmts.iter().any(stmt_requires_write);
380        let mode = if requires_write {
381            TxnMode::ReadWrite
382        } else {
383            TxnMode::ReadOnly
384        };
385
386        let mut txn = self.store.begin(mode).map_err(Error::Core)?;
387        let journal = if mode == TxnMode::ReadWrite
388            && stmts.iter().any(stmt_changes_user_data)
389            && self.store.range_change_journal_capability()
390                == RangeChangeJournalCapability::Supported
391        {
392            let scope = {
393                let catalog = self.sql_catalog.read().expect("catalog lock poisoned");
394                local_journal_scope(&*catalog)
395            };
396            Some(LocalRangeChangeJournal::capture(&mut txn, scope).map_err(Error::Core)?)
397        } else {
398            None
399        };
400        let mut overlay = CatalogOverlay::new();
401        let mut borrowed =
402            TxnBridge::<alopex_core::kv::AnyKV>::wrap_external(&mut txn, mode, &mut overlay);
403
404        let mut executor: Executor<_, _> =
405            Executor::new(self.store.clone(), self.sql_catalog.clone());
406
407        let mut results = Vec::with_capacity(stmts.len());
408        for (statement_index, stmt) in stmts.iter().enumerate() {
409            let plan = {
410                let catalog = self.sql_catalog.read().expect("catalog lock poisoned");
411                let (_, overlay) = borrowed.split_parts();
412                plan_stmt(&*catalog, &*overlay, stmt)?
413            };
414
415            {
416                let catalog = self.sql_catalog.read().expect("catalog lock poisoned");
417                let (_, overlay) = borrowed.split_parts();
418                let view = TxnCatalogView::new(&*catalog, &*overlay);
419                self.record_routing(&view, stmt, statement_index);
420            }
421
422            results.push(
423                executor
424                    .execute_in_txn(plan, &mut borrowed)
425                    .map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)))?,
426            );
427        }
428
429        drop(borrowed);
430
431        if let Some(journal) = journal {
432            journal.stage(&mut txn).map_err(Error::Core)?;
433        }
434
435        // `execute_in_txn()` 成功時に HNSW flush 済み(失敗時は abandon 済み)なので、
436        // ここでは KV commit と overlay 適用のみを行う。
437        //
438        // commit_self は `txn` を消費するため、失敗時に rollback はできない。
439        txn.commit_self().map_err(Error::Core)?;
440        if mode == TxnMode::ReadWrite {
441            let mut catalog = self.sql_catalog.write().expect("catalog lock poisoned");
442            catalog.apply_overlay(overlay);
443        }
444        if stmts.iter().any(stmt_changes_catalog) {
445            self.invalidate_table_info_cache();
446        }
447        if requires_write {
448            let mut cache = self.hnsw_cache.write().expect("hnsw cache lock poisoned");
449            cache.clear();
450            let mut vector_cache = self
451                .vector_cache
452                .write()
453                .expect("vector cache lock poisoned");
454            *vector_cache = None;
455        }
456        Ok(results)
457    }
458
459    /// Execute SQL with callback-based streaming for SELECT queries (FR-7).
460    ///
461    /// This method provides true streaming by keeping the transaction alive
462    /// during row iteration. The callback receives a `StreamingRows` that
463    /// yields rows on-demand from storage.
464    ///
465    /// # Type Parameters
466    ///
467    /// * `F` - Callback function that processes the streaming rows
468    /// * `R` - Return type from the callback
469    ///
470    /// # Examples
471    ///
472    /// ```
473    /// use alopex_embedded::Database;
474    ///
475    /// let db = Database::new();
476    /// db.execute_sql("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);").unwrap();
477    /// db.execute_sql("INSERT INTO users (id, name) VALUES (1, 'Alice'), (2, 'Bob');").unwrap();
478    ///
479    /// // Process rows with streaming - transaction stays alive during callback.
480    /// // Propagate `next_row` errors with `?`; swallowing them (e.g. with
481    /// // `while let Ok(Some(...))`) silently truncates the result set.
482    /// let result = db.execute_sql_with_rows("SELECT * FROM users;", |mut rows| {
483    ///     let mut names = Vec::new();
484    ///     while let Some(row) = rows.next_row()? {
485    ///         if let Some(alopex_sql::storage::SqlValue::Text(name)) = row.get(1) {
486    ///             names.push(name.clone());
487    ///         }
488    ///     }
489    ///     Ok(names)
490    /// }).unwrap();
491    /// ```
492    pub fn execute_sql_with_rows<F, R>(&self, sql: &str, f: F) -> Result<StreamingQueryResult<R>>
493    where
494        F: FnOnce(StreamingRows<'_>) -> Result<R>,
495    {
496        let stmts = parse_sql(sql)?;
497        if stmts.is_empty() {
498            return Ok(StreamingQueryResult::Success);
499        }
500
501        // For streaming SELECT, use the new pipeline
502        if stmts.len() == 1 && matches!(stmts[0].kind, StatementKind::Select(_)) {
503            let stmt = &stmts[0];
504            let mode = TxnMode::ReadOnly;
505
506            let mut txn = self.store.begin(mode).map_err(Error::Core)?;
507            let mut overlay = CatalogOverlay::new();
508            let mut borrowed =
509                TxnBridge::<alopex_core::kv::AnyKV>::wrap_external(&mut txn, mode, &mut overlay);
510
511            let plan = {
512                let catalog = self.sql_catalog.read().expect("catalog lock poisoned");
513                let (_, overlay_ref) = borrowed.split_parts();
514                plan_stmt(&*catalog, overlay_ref, stmt)?
515            };
516
517            if alopex_sql::executor::is_store_direct_plan(&plan) {
518                let mut executor: Executor<_, _> =
519                    Executor::new(self.store.clone(), self.sql_catalog.clone());
520                let result = executor
521                    .execute(plan)
522                    .map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)))?;
523                let ExecutionResult::Query(query) = result else {
524                    return Err(Error::Sql(alopex_sql::SqlError::Execution {
525                        message: "store-direct system function did not return rows".into(),
526                        code: "ALOPEX-E022",
527                    }));
528                };
529                let column_names: Vec<String> = query
530                    .columns
531                    .iter()
532                    .map(|column| column.name.clone())
533                    .collect();
534                let schema: Vec<alopex_sql::catalog::ColumnMetadata> = query
535                    .columns
536                    .iter()
537                    .map(|column| {
538                        alopex_sql::catalog::ColumnMetadata::new(
539                            &column.name,
540                            column.data_type.clone(),
541                        )
542                    })
543                    .collect();
544                let rows: Vec<Row> = query
545                    .rows
546                    .into_iter()
547                    .enumerate()
548                    .map(|(index, values)| Row::new(index as u64, values))
549                    .collect();
550                let iter = VecIterator::new(rows, schema.clone());
551                let streaming_rows = StreamingRows {
552                    columns: query.columns,
553                    iter: Box::new(iter),
554                    projection: Projection::All(column_names),
555                    schema,
556                };
557                let result = f(streaming_rows)?;
558                return Ok(StreamingQueryResult::QueryProcessed(result));
559            }
560
561            let catalog = self.sql_catalog.read().expect("catalog lock poisoned");
562            let (mut sql_txn, overlay_ref) = borrowed.split_parts();
563            let view = TxnCatalogView::new(&*catalog, overlay_ref);
564
565            // Build streaming pipeline - iterator lifetime tied to sql_txn
566            let (iter, projection, schema) = build_streaming_pipeline(&mut sql_txn, &view, plan)
567                .map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)))?;
568
569            // Build column info from projection and schema
570            let columns = build_column_info(&projection, &schema)?;
571
572            // Create StreamingRows and pass to callback
573            let streaming_rows = StreamingRows {
574                columns,
575                iter,
576                projection,
577                schema,
578            };
579
580            // Execute callback with streaming rows
581            let result = f(streaming_rows)?;
582
583            // Clean up and commit after callback completes
584            drop(catalog);
585            drop(borrowed);
586            txn.commit_self().map_err(Error::Core)?;
587
588            return Ok(StreamingQueryResult::QueryProcessed(result));
589        }
590
591        // Fall back to standard execution for non-SELECT or multi-statement
592        let exec_result = self.execute_sql(sql)?;
593        match exec_result {
594            alopex_sql::ExecutionResult::Success => Ok(StreamingQueryResult::Success),
595            alopex_sql::ExecutionResult::RowsAffected(n) => {
596                Ok(StreamingQueryResult::RowsAffected(n))
597            }
598            alopex_sql::ExecutionResult::Query(_qr) => {
599                // For non-streaming path, we can't provide true streaming
600                // Return an error indicating streaming is not available
601                Err(Error::Sql(alopex_sql::SqlError::Execution {
602                    message: "Streaming not available for multi-statement or complex queries"
603                        .into(),
604                    code: "ALOPEX-E021",
605                }))
606            }
607        }
608    }
609
610    /// Execute SQL and return a streaming result for SELECT queries (FR-7).
611    ///
612    /// This method returns a `SqlStreamingResult` that contains an iterator
613    /// for query results, enabling true streaming output without materializing
614    /// all rows upfront.
615    ///
616    /// # Note
617    ///
618    /// Only single SELECT statements are supported for streaming. Multi-statement
619    /// SQL or non-SELECT statements fall back to the standard execution path.
620    ///
621    /// # Examples
622    ///
623    /// ```
624    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
625    /// use alopex_embedded::{Database, SqlStreamingResult};
626    ///
627    /// let db = Database::new();
628    /// db.execute_sql("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);")?;
629    /// db.execute_sql("INSERT INTO users (id, name) VALUES (1, 'Alice');")?;
630    ///
631    /// // Propagate `next_row` errors with `?`; swallowing them (e.g. with
632    /// // `while let Ok(Some(...))`) silently truncates the result set.
633    /// let result = db.execute_sql_streaming("SELECT * FROM users;")?;
634    /// if let SqlStreamingResult::Query(mut iter) = result {
635    ///     while let Some(row) = iter.next_row()? {
636    ///         println!("{:?}", row);
637    ///     }
638    /// }
639    /// # Ok(())
640    /// # }
641    /// ```
642    pub fn execute_sql_streaming(&self, sql: &str) -> Result<SqlStreamingResult> {
643        let stmts = parse_sql(sql)?;
644        if stmts.is_empty() {
645            return Ok(SqlStreamingResult::Success);
646        }
647
648        // For streaming, only support single SELECT statement
649        if stmts.len() == 1 && matches!(stmts[0].kind, StatementKind::Select(_)) {
650            let stmt = &stmts[0];
651            let mode = TxnMode::ReadOnly;
652
653            let mut txn = self.store.begin(mode).map_err(Error::Core)?;
654            let mut overlay = CatalogOverlay::new();
655            let mut borrowed =
656                TxnBridge::<alopex_core::kv::AnyKV>::wrap_external(&mut txn, mode, &mut overlay);
657
658            let plan = {
659                let catalog = self.sql_catalog.read().expect("catalog lock poisoned");
660                let (_, overlay) = borrowed.split_parts();
661                plan_stmt(&*catalog, &*overlay, stmt)?
662            };
663
664            let (mut sql_txn, _overlay) = borrowed.split_parts();
665
666            let catalog = self.sql_catalog.read().expect("catalog lock poisoned");
667            let view = TxnCatalogView::new(&*catalog, _overlay);
668            let iter = execute_query_streaming(&mut sql_txn, &view, plan)
669                .map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)))?;
670
671            drop(catalog);
672            drop(borrowed);
673
674            txn.commit_self().map_err(Error::Core)?;
675
676            return Ok(SqlStreamingResult::Query(iter));
677        }
678
679        // Fall back to standard execution for non-streaming cases
680        let result = self.execute_sql(sql)?;
681        match result {
682            alopex_sql::ExecutionResult::Success => Ok(SqlStreamingResult::Success),
683            alopex_sql::ExecutionResult::RowsAffected(n) => Ok(SqlStreamingResult::RowsAffected(n)),
684            alopex_sql::ExecutionResult::Query(qr) => {
685                // Convert materialized result to streaming iterator
686                use alopex_sql::executor::query::iterator::VecIterator;
687                use alopex_sql::executor::Row;
688                use alopex_sql::planner::typed_expr::Projection;
689
690                let column_names: Vec<String> = qr.columns.iter().map(|c| c.name.clone()).collect();
691                let schema: Vec<alopex_sql::catalog::ColumnMetadata> = qr
692                    .columns
693                    .iter()
694                    .map(|c| alopex_sql::catalog::ColumnMetadata::new(&c.name, c.data_type.clone()))
695                    .collect();
696                let rows: Vec<Row> = qr
697                    .rows
698                    .into_iter()
699                    .enumerate()
700                    .map(|(i, values)| Row::new(i as u64, values))
701                    .collect();
702                let iter = VecIterator::new(rows, schema.clone());
703                let query_iter =
704                    QueryRowIterator::new(Box::new(iter), Projection::All(column_names), schema);
705                Ok(SqlStreamingResult::Query(query_iter))
706            }
707        }
708    }
709}
710
711impl<'a> Transaction<'a> {
712    /// SQL を実行する(外部トランザクション利用)。
713    ///
714    /// 同一トランザクション内の複数回呼び出しでカタログ変更が見えるよう、`CatalogOverlay` は
715    /// `Transaction` が所有して保持する。
716    ///
717    /// # Examples
718    ///
719    /// ```
720    /// use alopex_embedded::{Database, TxnMode};
721    ///
722    /// let db = Database::new();
723    /// let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
724    /// txn.execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY);").unwrap();
725    /// txn.execute_sql("INSERT INTO t (id) VALUES (1);").unwrap();
726    /// txn.commit().unwrap();
727    /// ```
728    pub fn execute_sql(&mut self, sql: &str) -> Result<SqlResult> {
729        let stmts = parse_sql(sql)?;
730        if stmts.is_empty() {
731            return Ok(alopex_sql::ExecutionResult::Success);
732        }
733
734        if stmts.iter().any(stmt_requires_write) {
735            let mut cache = self
736                .db
737                .hnsw_cache
738                .write()
739                .expect("hnsw cache lock poisoned");
740            cache.clear();
741            let mut vector_cache = self
742                .db
743                .vector_cache
744                .write()
745                .expect("vector cache lock poisoned");
746            *vector_cache = None;
747        }
748
749        let store = self.db.store.clone();
750        let sql_catalog = self.db.sql_catalog.clone();
751
752        let txn = self.inner.as_mut().ok_or(Error::TxnCompleted)?;
753        let mode = txn.mode();
754
755        let mut borrowed =
756            TxnBridge::<alopex_core::kv::AnyKV>::wrap_external(txn, mode, &mut self.overlay);
757        let mut executor: Executor<_, _> = Executor::new(store, sql_catalog.clone());
758
759        let mut last = alopex_sql::ExecutionResult::Success;
760        for (statement_index, stmt) in stmts.iter().enumerate() {
761            let plan = {
762                let catalog = sql_catalog.read().expect("catalog lock poisoned");
763                let (_, overlay) = borrowed.split_parts();
764                plan_stmt(&*catalog, &*overlay, stmt)?
765            };
766
767            {
768                let catalog = sql_catalog.read().expect("catalog lock poisoned");
769                let (_, overlay) = borrowed.split_parts();
770                let view = TxnCatalogView::new(&*catalog, &*overlay);
771                self.db.record_routing(&view, stmt, statement_index);
772            }
773
774            last = executor
775                .execute_in_txn(plan, &mut borrowed)
776                .map_err(|e| Error::Sql(alopex_sql::SqlError::from(e)))?;
777        }
778
779        if stmts.iter().any(stmt_changes_catalog) {
780            self.catalog_modified = true;
781        }
782        Ok(last)
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use alopex_core::kv::decode_range_change;
790    use alopex_core::kv::RangeChangePayload;
791
792    #[test]
793    fn auto_commit_stages_sql_row_and_index_changes_before_visibility() {
794        let db = Database::open_in_memory().unwrap();
795        db.execute_sql("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);")
796            .unwrap();
797        db.execute_sql("CREATE INDEX idx_users_name ON users (name);")
798            .unwrap();
799        db.execute_sql("INSERT INTO users (id, name) VALUES (1, 'alice');")
800            .unwrap();
801
802        let mut reader = db.store.begin(TxnMode::ReadOnly).unwrap();
803        let records = reader
804            .scan_prefix(b"\x00alopex/range-change/")
805            .unwrap()
806            .filter_map(|(_, value)| decode_range_change(&value).ok())
807            .collect::<Vec<_>>();
808        assert_eq!(records.len(), 1);
809        assert!(records[0]
810            .payload
811            .iter()
812            .any(|payload| matches!(payload, RangeChangePayload::UpsertRow { .. })));
813        assert!(records[0]
814            .payload
815            .iter()
816            .any(|payload| matches!(payload, RangeChangePayload::UpsertIndex { .. })));
817    }
818
819    #[test]
820    fn explicit_sql_transaction_stages_journal_before_commit() {
821        let db = Database::open_in_memory().unwrap();
822        db.execute_sql("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);")
823            .unwrap();
824        let mut transaction = db.begin(TxnMode::ReadWrite).unwrap();
825        transaction
826            .execute_sql("INSERT INTO users (id, name) VALUES (1, 'alice');")
827            .unwrap();
828        transaction.commit().unwrap();
829
830        let records = db
831            .snapshot()
832            .into_iter()
833            .filter_map(|(_, value)| decode_range_change(&value).ok())
834            .collect::<Vec<_>>();
835        assert_eq!(records.len(), 1);
836        assert!(records[0]
837            .payload
838            .iter()
839            .any(|payload| matches!(payload, RangeChangePayload::UpsertRow { .. })));
840    }
841
842    #[test]
843    fn embedded_read_at_returns_retention_error_before_a_sql_session_exists() {
844        let db = Database::open_in_memory().unwrap();
845        let point = ReadAtPoint::new(1, 2, 3, 4);
846
847        assert!(matches!(
848            db.begin_read_at_sql(point),
849            Err(Error::ReadAt(alopex_core::ReadAtError::Unavailable { .. }))
850        ));
851    }
852}