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