1use 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#[derive(Debug)]
57enum Effect {
58 None,
60 Define { key: Key, if_not_exists: bool },
62 Drop { keys: Vec<Key> },
64 Session,
66 Refused(String),
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72enum Key {
73 Table(String),
75 Graph(String),
77}
78
79pub 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
87async fn open_into(ctx: &SessionContext, path: &Path, options: &OpenOptions) -> Result<()> {
89 let path = path.to_path_buf();
90 let options = options.clone();
91 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
100async 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
127async 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
149pub(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
170fn 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
193pub(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
245fn 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
252fn 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
264fn 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
317fn 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
397fn 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
409fn 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
418fn 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 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
454fn 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}