1use 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#[derive(Debug)]
64enum Effect {
65 None,
67 Define { key: Key, if_not_exists: bool },
69 Drop { keys: Vec<Key> },
71 Session,
73 Refused(String),
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79enum Key {
80 Table(String),
82 Graph(String),
84}
85
86pub 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
94async fn open_into(ctx: &SessionContext, path: &Path, options: &OpenOptions) -> Result<()> {
96 let path = path.to_path_buf();
97 let options = options.clone();
98 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
108fn 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
129fn 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
148async 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
173async 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
200async 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
222pub(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
243fn 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
266pub(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
322fn 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
329fn 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
341fn 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
394fn 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
474fn 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
486fn 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
495fn 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 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
531fn 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}