Skip to main content

ursa_sql/
database.rs

1//! Statements in a session with a `.ursa` database.
2//!
3//! The engine's [`Database`] keeps the catalog file: the statements that
4//! defined each object, in replay order. This module decides what a statement
5//! does to that list, and replays it when a database is opened.
6//!
7//! - `CREATE VIEW`, `CREATE EXTERNAL TABLE` and `CREATE PROPERTY GRAPH` add
8//!   their statement once they succeed. An object already in the catalog
9//!   (`CREATE OR REPLACE`) has its statement replaced in place, except that
10//!   with `IF NOT EXISTS` it keeps its statement.
11//! - `DROP VIEW`, `DROP TABLE` and `DROP PROPERTY GRAPH` remove the object's
12//!   statement.
13//! - Tables with data (`CREATE TABLE`, `INSERT` and other DML) are refused:
14//!   version 1 of the file does not store rows. So is any other DDL
15//!   (`CREATE SCHEMA`, `CREATE FUNCTION`, `ALTER …`), which the file could not
16//!   replay.
17//! - `TEMPORARY` views and external tables run and are not stored.
18//! - In a read-only session, every statement that defines or drops anything
19//!   is refused.
20//!
21//! A statement is stored as its `Display` form when that parses back to the
22//! same statement, and otherwise as the text the user wrote (DataFusion's
23//! `Display` for `CREATE EXTERNAL TABLE` leaves out columns and options).
24//!
25//! **Paths.** A relative path resolves against the current directory when a
26//! statement runs, as in DuckDB. Before a definition is stored, each local
27//! file path in it (see [`crate::paths`]) is rewritten relative to the
28//! database's directory, or made absolute when it is outside that directory;
29//! URLs are kept. In the stored-as-written case the paths are replaced in the
30//! text token by token, and the statement is refused if the result does not
31//! parse back to the rewritten statement. Replay resolves each stored relative
32//! path against the database's directory, so the catalog finds the same files
33//! from any directory, and a database moved with its data still does. (M3a
34//! stored paths as written and resolved them against the database's
35//! directory, so its files replay unchanged.)
36//!
37//! Views and tables share one namespace, keyed by their name resolved against
38//! the default catalog and schema; graphs have their own.
39
40use std::path::Path;
41use std::sync::Arc;
42
43use datafusion::dataframe::DataFrame;
44use datafusion::error::{DataFusionError, Result};
45use datafusion::prelude::SessionContext;
46use datafusion::sql::parser::Statement as DFStatement;
47use datafusion::sql::planner::object_name_to_table_reference;
48use datafusion::sql::sqlparser::ast::{ObjectName, ObjectType, Statement as SQLStatement};
49use ursa_engine::database::{self as db, Database, OpenOptions};
50use ursa_engine::graph;
51
52use crate::parser::{Attach, UrsaStatement};
53use crate::{graph_ddl, parse_sql_for, paths, run};
54
55/// What a statement does to the catalog.
56#[derive(Debug)]
57enum Effect {
58    /// Nothing: a query, `COPY`, `SET`, `SHOW`, `EXPLAIN`, `DESCRIBE`.
59    None,
60    /// Defines an object (added to, or replacing, its catalog entry).
61    Define { key: Key, if_not_exists: bool },
62    /// Drops objects (removing their catalog entries).
63    Drop { keys: Vec<Key> },
64    /// Defines or drops a session-only object (`TEMPORARY`): not stored.
65    Session,
66    /// Not allowed in a session with a database: the message says why.
67    Refused(String),
68}
69
70/// An object in the catalog.
71#[derive(Debug, Clone, PartialEq, Eq)]
72enum Key {
73    /// A view or table, by its fully resolved name.
74    Table(String),
75    /// A graph, by name.
76    Graph(String),
77}
78
79/// Open a new session on the database at `path`: open the file (creating it
80/// unless read-only), then replay its catalog.
81pub async fn open_session(path: impl AsRef<Path>, options: &OpenOptions) -> Result<SessionContext> {
82    let ctx = ursa_engine::new_session();
83    open_into(&ctx, path.as_ref(), options).await?;
84    Ok(ctx)
85}
86
87/// Open the database at `path` for `ctx` and replay its catalog.
88async fn open_into(ctx: &SessionContext, path: &Path, options: &OpenOptions) -> Result<()> {
89    let path = path.to_path_buf();
90    let options = options.clone();
91    // Opening may wait for another process's lock.
92    let database = ursa_engine::run_blocking(move || Database::open(path, &options))
93        .await
94        .map_err(|e| DataFusionError::External(Box::new(e)))??;
95    let database = Arc::new(database);
96    db::slot(&ctx.state()).set(Arc::clone(&database))?;
97    replay(ctx, &database).await
98}
99
100/// Run the catalog's statements on `ctx`, in order. A statement that fails is
101/// retried after the others, in case an edit of the file put it before what it
102/// depends on; the first one that still fails is the error.
103async fn replay(ctx: &SessionContext, database: &Database) -> Result<()> {
104    let mut pending: Vec<(usize, String)> = database.statements().into_iter().enumerate().collect();
105    while !pending.is_empty() {
106        let mut failed = Vec::new();
107        let before = pending.len();
108        for (index, sql) in pending {
109            if let Err(e) = replay_one(ctx, database, &sql).await {
110                failed.push((index, sql, e));
111            }
112        }
113        if failed.len() == before {
114            let (index, sql, err) = failed.swap_remove(0);
115            return Err(DataFusionError::Execution(format!(
116                "cannot open {}: statement {} of its catalog failed: {}\n  the statement: {sql}",
117                database.name(),
118                index + 1,
119                ursa_engine::user_message(&err)
120            )));
121        }
122        pending = failed.into_iter().map(|(i, sql, _)| (i, sql)).collect();
123    }
124    Ok(())
125}
126
127/// Run one stored statement, its relative paths resolved against the
128/// database's directory, wherever the process runs.
129async fn replay_one(ctx: &SessionContext, database: &Database, sql: &str) -> Result<()> {
130    let mut statements = parse_sql_for(ctx, sql)?;
131    if statements.len() != 1 {
132        return Err(DataFusionError::Plan(
133            "a catalog entry must be exactly one statement".into(),
134        ));
135    }
136    let mut statement = statements.remove(0);
137    paths::rewrite_locations(ctx, &mut statement, |location| {
138        database.replayed_location(location)
139    });
140    if !matches!(effect(ctx, &statement)?, Effect::Define { .. }) {
141        return Err(DataFusionError::Plan(format!(
142            "a catalog entry must define a view, an external table or a graph, not run {}",
143            statement.kind()
144        )));
145    }
146    run(ctx, statement).await.map(|_| ())
147}
148
149/// `ATTACH 'club.ursa'` in a session without a database.
150pub(crate) async fn attach(ctx: &SessionContext, attach: Attach) -> Result<DataFrame> {
151    if let Some(alias) = &attach.alias {
152        return Err(DataFusionError::NotImplemented(format!(
153            "ATTACH … AS {alias} is not supported yet: a session has one database in this \
154             version of ursa; use ATTACH '{}' without AS",
155            attach.path
156        )));
157    }
158    if defines_anything(ctx)? {
159        return Err(DataFusionError::Plan(format!(
160            "ATTACH must come before anything is defined: this session already has tables, \
161             views or graphs, which would not be in '{}'. Run ATTACH first, or open the \
162             database with: ursa {}",
163            attach.path, attach.path
164        )));
165    }
166    open_into(ctx, Path::new(&attach.path), &OpenOptions::default()).await?;
167    graph_ddl::empty(ctx)
168}
169
170/// Whether `ctx` has any graph, table or view (outside `information_schema`).
171fn defines_anything(ctx: &SessionContext) -> Result<bool> {
172    if !graph::registry_of(ctx).names().is_empty() {
173        return Ok(true);
174    }
175    for catalog_name in ctx.catalog_names() {
176        let Some(catalog) = ctx.catalog(&catalog_name) else {
177            continue;
178        };
179        for schema_name in catalog.schema_names() {
180            if schema_name == "information_schema" {
181                continue;
182            }
183            if let Some(schema) = catalog.schema(&schema_name) {
184                if !schema.table_names().is_empty() {
185                    return Ok(true);
186                }
187            }
188        }
189    }
190    Ok(false)
191}
192
193/// Run `statement` in a session with `database`, keeping the catalog file in
194/// step. `text` is the statement as written, if known.
195pub(crate) async fn execute(
196    ctx: &SessionContext,
197    database: &Database,
198    statement: UrsaStatement,
199    text: Option<&str>,
200) -> Result<DataFrame> {
201    if let UrsaStatement::Attach(_) = statement {
202        return Err(DataFusionError::Plan(format!(
203            "{} is already open, and a session has one database in this version of ursa",
204            database.name()
205        )));
206    }
207    let effect = effect(ctx, &statement)?;
208    if database.is_read_only() && !matches!(effect, Effect::None) {
209        return Err(DataFusionError::Plan(format!(
210            "{} is open read-only (--readonly), so {} cannot change it",
211            database.name(),
212            leading_words(&statement)
213        )));
214    }
215    match effect {
216        Effect::None | Effect::Session => run(ctx, statement).await,
217        Effect::Refused(message) => Err(DataFusionError::Plan(message)),
218        Effect::Define {
219            key, if_not_exists, ..
220        } => {
221            let stored = stored_text(ctx, database, &statement, text)?;
222            let frame = run(ctx, statement).await?;
223            let mut statements = database.statements();
224            match position(ctx, &statements, &key) {
225                Some(_) if if_not_exists => {}
226                Some(i) => statements[i] = stored,
227                None => statements.push(stored),
228            }
229            database.save(statements)?;
230            Ok(frame)
231        }
232        Effect::Drop { keys } => {
233            let frame = run(ctx, statement).await?;
234            let statements = database
235                .statements()
236                .into_iter()
237                .filter(|s| !stored_key(ctx, s).is_some_and(|k| keys.contains(&k)))
238                .collect();
239            database.save(statements)?;
240            Ok(frame)
241        }
242    }
243}
244
245/// Where the object `key` is defined in `statements`.
246fn position(ctx: &SessionContext, statements: &[String], key: &Key) -> Option<usize> {
247    statements
248        .iter()
249        .position(|s| stored_key(ctx, s).as_ref() == Some(key))
250}
251
252/// The object a stored statement defines.
253fn stored_key(ctx: &SessionContext, sql: &str) -> Option<Key> {
254    let statements = parse_sql_for(ctx, sql).ok()?;
255    match <[UrsaStatement; 1]>::try_from(statements) {
256        Ok([statement]) => match effect(ctx, &statement).ok()? {
257            Effect::Define { key, .. } => Some(key),
258            _ => None,
259        },
260        Err(_) => None,
261    }
262}
263
264/// The text to store for `statement`, with its local file paths rewritten
265/// for the catalog ([`Database::stored_location`]): relative to the
266/// database's directory, or absolute outside it. That is the rewritten
267/// statement's `Display` form if it parses back to the same statement; else
268/// the text as written, with the paths replaced token by token
269/// ([`paths::rewrite_text`]), or an error if that cannot be done exactly.
270fn stored_text(
271    ctx: &SessionContext,
272    database: &Database,
273    statement: &UrsaStatement,
274    text: Option<&str>,
275) -> Result<String> {
276    let cwd = std::env::current_dir().map_err(|e| {
277        DataFusionError::Execution(format!(
278            "cannot store this {} in {}: cannot read the current directory: {e}",
279            leading_words(statement),
280            database.name()
281        ))
282    })?;
283    let mut rewritten = statement.clone();
284    let replacements = paths::rewrite_locations(ctx, &mut rewritten, |location| {
285        database.stored_location(location, &cwd)
286    });
287    let display = rewritten.to_string();
288    let round_trips = parse_sql_for(ctx, &display)
289        .map(|parsed| parsed.len() == 1 && parsed[0] == rewritten)
290        .unwrap_or(false);
291    if round_trips {
292        return Ok(display);
293    }
294    let Some(text) = text else {
295        return Err(DataFusionError::Plan(format!(
296            "cannot store this {} in the database without the text it was written as; run it \
297             with ursa_sql::execute_with_text",
298            leading_words(statement)
299        )));
300    };
301    if replacements.is_empty() {
302        return Ok(text.to_string());
303    }
304    paths::rewrite_text(ctx, text, &replacements, &rewritten).ok_or_else(|| {
305        let (old, new) = &replacements[0];
306        DataFusionError::Plan(format!(
307            "cannot store this {} in {}: its path '{old}' is stored as '{new}' (relative to the \
308             database's directory, {}), and the statement as written could not be rewritten \
309             exactly; write each path as stored, from the database's directory",
310            leading_words(statement),
311            database.name(),
312            database.dir().display()
313        ))
314    })
315}
316
317/// What `statement` does to the catalog.
318fn effect(ctx: &SessionContext, statement: &UrsaStatement) -> Result<Effect> {
319    Ok(match statement {
320        UrsaStatement::CreatePropertyGraph(g) => Effect::Define {
321            key: Key::Graph(graph_ddl::graph_name(g.name.clone(), normalizes(ctx))?),
322            if_not_exists: g.if_not_exists,
323        },
324        UrsaStatement::DropPropertyGraph(g) => Effect::Drop {
325            keys: vec![Key::Graph(graph_ddl::graph_name(
326                g.name.clone(),
327                normalizes(ctx),
328            )?)],
329        },
330        UrsaStatement::DescribeGraph(_) | UrsaStatement::ShowGraphs | UrsaStatement::Attach(_) => {
331            Effect::None
332        }
333        UrsaStatement::DataFusion(statement) => match statement.as_ref() {
334            DFStatement::CreateExternalTable(t) if t.temporary => Effect::Session,
335            DFStatement::CreateExternalTable(t) => Effect::Define {
336                key: table_key(ctx, &t.name)?,
337                if_not_exists: t.if_not_exists,
338            },
339            DFStatement::Statement(s) => sql_effect(ctx, s, statement)?,
340            DFStatement::CopyTo(_) | DFStatement::Explain(_) | DFStatement::Reset(_) => {
341                Effect::None
342            }
343        },
344    })
345}
346
347fn sql_effect(ctx: &SessionContext, s: &SQLStatement, whole: &DFStatement) -> Result<Effect> {
348    Ok(match s {
349        SQLStatement::CreateView(v) if v.temporary => Effect::Session,
350        SQLStatement::CreateView(v) if v.materialized => Effect::Refused(not_stored(whole)),
351        SQLStatement::CreateView(v) => Effect::Define {
352            key: table_key(ctx, &v.name)?,
353            if_not_exists: v.if_not_exists,
354        },
355        SQLStatement::CreateTable(t) if t.temporary => Effect::Session,
356        SQLStatement::CreateTable(t) => Effect::Refused(tables_with_data(
357            if t.query.is_some() {
358                "CREATE TABLE … AS"
359            } else {
360                "CREATE TABLE"
361            },
362            Some(&t.name),
363        )),
364        SQLStatement::Insert(_) => Effect::Refused(tables_with_data("INSERT", None)),
365        SQLStatement::Update(_)
366        | SQLStatement::Delete(_)
367        | SQLStatement::Truncate(_)
368        | SQLStatement::Merge(_) => {
369            Effect::Refused(tables_with_data(&leading_words_df(whole), None))
370        }
371        SQLStatement::Drop {
372            object_type: ObjectType::Table | ObjectType::View,
373            names,
374            ..
375        } => Effect::Drop {
376            keys: names
377                .iter()
378                .map(|n| table_key(ctx, n))
379                .collect::<Result<_>>()?,
380        },
381        _ => {
382            let first = whole.to_string();
383            let first = first
384                .split_whitespace()
385                .next()
386                .unwrap_or_default()
387                .to_ascii_uppercase();
388            if matches!(first.as_str(), "CREATE" | "DROP" | "ALTER") {
389                Effect::Refused(not_stored(whole))
390            } else {
391                Effect::None
392            }
393        }
394    })
395}
396
397/// The refusal for a statement that would make or change a table with data.
398fn tables_with_data(kind: &str, name: Option<&ObjectName>) -> String {
399    let name = name.map_or_else(|| "name".to_string(), ToString::to_string);
400    format!(
401        "{kind} is not supported in a database file: version 1 of the .ursa file stores views, \
402         external tables and graphs, not tables with data. Define a view instead (CREATE VIEW \
403         {name} AS SELECT …), or write the rows to a file with COPY (SELECT …) TO \
404         '{name}.parquet' and define a view over it (CREATE VIEW {name} AS SELECT * FROM \
405         '{name}.parquet')"
406    )
407}
408
409/// The refusal for other DDL, which the catalog file could not replay.
410fn not_stored(statement: &DFStatement) -> String {
411    format!(
412        "{} is not supported in a database file: version 1 of the .ursa file stores views, \
413         external tables and graphs only; run it in a session without a database",
414        leading_words_df(statement)
415    )
416}
417
418/// The statement's first two words (`CREATE VIEW`, `DROP TABLE`), as messages
419/// name it.
420fn leading_words(statement: &UrsaStatement) -> String {
421    match statement {
422        UrsaStatement::DataFusion(s) => leading_words_df(s),
423        other => other.kind().to_string(),
424    }
425}
426
427fn leading_words_df(statement: &DFStatement) -> String {
428    let text = statement.to_string();
429    let mut words = text.split_whitespace().map(str::to_ascii_uppercase);
430    let first = words.next().unwrap_or_default();
431    let mut kind = first.clone();
432    // `CREATE OR REPLACE VIEW` is named `CREATE VIEW`.
433    for word in words {
434        if matches!(
435            word.as_str(),
436            "OR" | "REPLACE" | "TEMPORARY" | "EXTERNAL" | "MATERIALIZED"
437        ) && first == "CREATE"
438        {
439            if word == "EXTERNAL" || word == "MATERIALIZED" {
440                kind.push(' ');
441                kind.push_str(&word);
442            }
443            continue;
444        }
445        if matches!(first.as_str(), "CREATE" | "DROP" | "ALTER") {
446            kind.push(' ');
447            kind.push_str(&word);
448        }
449        break;
450    }
451    kind
452}
453
454/// A view or table's key: its name resolved against the default catalog and
455/// schema.
456fn table_key(ctx: &SessionContext, name: &ObjectName) -> Result<Key> {
457    let state = ctx.state();
458    let options = &state.config().options().catalog;
459    let reference = object_name_to_table_reference(name.clone(), normalizes(ctx))?;
460    Ok(Key::Table(
461        reference
462            .resolve(&options.default_catalog, &options.default_schema)
463            .to_string(),
464    ))
465}
466
467fn normalizes(ctx: &SessionContext) -> bool {
468    graph_ddl::normalizes(ctx)
469}