Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ crate-type = ["cdylib"]
# with (ADR 0002), so a revision is the honest way to say which one.
# A local checkout is used instead with a `paths` override in
# `.cargo/config.toml`, which is untracked on purpose.
zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "a1f310cf7be9bd422b6f4c8307b11eb97c2a23be" }
zu-common = { git = "https://github.com/tamnd/zu", rev = "a1f310cf7be9bd422b6f4c8307b11eb97c2a23be" }
zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "95c7c9909f3a2624515d27eb436da52936016960" }
zu-common = { git = "https://github.com/tamnd/zu", rev = "95c7c9909f3a2624515d27eb436da52936016960" }
# `extension-module` is asked for by maturin, in pyproject.toml, and
# not here. Only the build backend knows how an extension is linked on
# the platform it is building for, and a crate that turns the feature
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ The interesting parts:
- **`import zudb` costs about 4 ms** on this machine and is gated at 50, and pandas, polars and pyarrow are imported when you ask for one and not before. Importing pandas costs 700 ms, which is most of why none of them is a dependency.
- **Graph values are real classes.** `Node`, `Rel`, and `Path` have `.labels`, `.id`, `.properties`, and an HTML repr. Not dicts, because a dict cannot tell a property named `labels` apart from the label set.

## A database with no file

`connect()` with nothing after it is a database in memory, and it makes no file anywhere.

```python
import zudb

with zudb.connect() as conn:
conn.execute("INSERT (p:person {uid: 1, name: 'ada'})")
print(conn.execute("MATCH (p:person) RETURN p.name AS name").fetchall())
```

`connect(":memory:")` is the same thing spelled the way every embedded database spells it, and it no longer makes a file called `:memory:`, which is what it used to do and which was the worst of both worlds. It is the whole engine and not a reduced one: writes, transactions, the appender, `register`, streams, all of it, on bytes that are not a file. `conn.memory` says which kind you have, since `path` cannot quite answer it on a filesystem that allows a colon in a name. Nothing survives the last connection, which is the point: a notebook cell, a test, or five minutes with the language costs no cleanup and leaves no `social.zu1` in a directory somebody has to notice later.

## Building a graph

A statement writes one row at a time, which is the wrong shape for loading data and cannot make a rel table at all. `load` is the other shape: a table's columns whole, the edges between them whole, one file written once.
Expand Down
3 changes: 2 additions & 1 deletion python/zudb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

The engine is compiled into the wheel, so there is nothing to install,
nothing to run, and no server to connect to. Statements are ISO/IEC
39075 GQL.
39075 GQL. `connect()` with no path is a database in memory, which
makes no file anywhere and is gone when the last connection to it is.

On an event loop the same calls are awaited, from `zudb.aio`. Code
written against PEP 249 gets what it expects from `zudb.dbapi`. Both
Expand Down
8 changes: 6 additions & 2 deletions python/zudb/_zudb.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ __abi_version__: str
__engine_version__: str

def connect(
path: str | os.PathLike[str],
path: str | os.PathLike[str] | None = None,
*,
read_only: bool = False,
memory_limit: int | None = None,
threads: int | None = None,
) -> Connection:
"""Opens the database at `path` and connects to it."""
"""Opens the database at `path`, or in memory when there is none."""

def load(
path: str | os.PathLike[str],
Expand All @@ -55,6 +55,10 @@ class Connection:
def path(self) -> pathlib.Path:
"""The file this connection was opened on."""

@property
def memory(self) -> bool:
"""Whether the database behind it is in memory."""

@property
def read_only(self) -> bool:
"""Whether it was opened read-only."""
Expand Down
14 changes: 10 additions & 4 deletions python/zudb/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@


def connect(
path: str | os.PathLike[str],
path: str | os.PathLike[str] | None = None,
*,
read_only: bool = False,
memory_limit: int | None = None,
Expand All @@ -93,8 +93,9 @@ def connect(

The arguments are `zudb.connect`'s, and so is the behaviour: a path
holding nothing becomes a new database unless the connection is
read-only. Opening reads the file, so it happens on the connection's
own thread like everything else.
read-only, and no path at all, or `":memory:"`, is a database in
memory that makes no file. Opening reads the file, so it happens on
the connection's own thread like everything else.

Await it for a connection to close yourself, or open it with `async
with` for one that closes at the end of the block:
Expand All @@ -115,7 +116,7 @@ def connect(


async def _open(
path: str | os.PathLike[str],
path: str | os.PathLike[str] | None,
*,
read_only: bool,
memory_limit: int | None,
Expand Down Expand Up @@ -203,6 +204,11 @@ def path(self) -> pathlib.Path:
"""The file this connection was opened on."""
return self._conn.path

@property
def memory(self) -> bool:
"""Whether the database behind it is in memory."""
return self._conn.memory

@property
def read_only(self) -> bool:
"""Whether it was opened read-only."""
Expand Down
11 changes: 6 additions & 5 deletions python/zudb/dbapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,7 @@ def __repr__(self) -> str:


def connect(
path: str | os.PathLike[str],
path: str | os.PathLike[str] | None = None,
*,
read_only: bool = False,
memory_limit: int | None = None,
Expand All @@ -802,10 +802,11 @@ def connect(
) -> Connection:
"""Opens the database at `path` and connects to it.

The same arguments `zudb.connect` takes, and one more: with
`autocommit` every statement stands alone the way it does on the
native client, instead of joining a transaction that runs until
`commit` or `rollback`.
The same arguments `zudb.connect` takes, no path or `":memory:"`
for a database in memory included, and one more: with `autocommit`
every statement stands alone the way it does on the native client,
instead of joining a transaction that runs until `commit` or
`rollback`.
"""
with _translating():
conn = zudb.connect(path, read_only=read_only, memory_limit=memory_limit, threads=threads)
Expand Down
38 changes: 34 additions & 4 deletions src/conn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! a program which shares one by accident waits rather than corrupts.

use std::ffi::CStr;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};

Expand Down Expand Up @@ -92,6 +92,11 @@ pub struct Connection {
path: PathBuf,
#[pyo3(get)]
read_only: bool,
/// Whether the database behind it is in memory, which is the one
/// thing `path` cannot quite say: a file could be called
/// `:memory:` on any filesystem that allows a colon.
#[pyo3(get)]
memory: bool,
}

#[pymethods]
Expand Down Expand Up @@ -439,15 +444,28 @@ impl Connection {
}

impl Connection {
/// The name a database in memory is asked for by, and answers to.
///
/// The spelling every embedded database has used for thirty years,
/// which is the reason it is this and not something better: a
/// caller who types it has already been taught what it means
/// somewhere else.
const MEMORY: &'static str = ":memory:";

/// Opens `path`, creating a database there when there is none.
///
/// Creating is what every Python database module does and what a
/// notebook expects, and it can only ever create where nothing
/// was: a path that holds a database is opened, and a read-only
/// connection never creates anything at all.
///
/// No path, or `":memory:"`, is a database in memory. It used to
/// be a file called `:memory:` in the working directory, which was
/// the worst of both worlds: the name said nothing was on disk and
/// something was.
pub fn open(
py: Python<'_>,
path: PathBuf,
path: Option<PathBuf>,
read_only: bool,
memory_limit: Option<usize>,
threads: Option<usize>,
Expand All @@ -459,9 +477,20 @@ impl Connection {
if let Some(threads) = threads {
config = config.threads(threads);
}
let missing = !path.exists();
// The name is reported back as it was asked for rather than as
// the engine spells it. The engine mints a unique one per
// database so that two of them never share a writer, and that
// counter is its business and not a caller's.
let memory = path.as_deref().is_none_or(|p| p == Path::new(Self::MEMORY));
let path = match memory {
true => PathBuf::from(Self::MEMORY),
false => path.expect("a path that is not the memory name"),
};
let missing = !memory && !path.exists();
let opened = py.detach(|| {
if missing && !read_only {
if memory {
Database::memory_with(config.clone())
} else if missing && !read_only {
Database::create_with(&path, config.clone())
} else {
Database::open_with(&path, config.clone())
Expand All @@ -470,6 +499,7 @@ impl Connection {
});
let opened = opened.map_err(|err| to_py_err(py, err))?;
Ok(Connection {
memory,
// Taken here, once, because every later reader of it wants
// it while the connection is busy and taking it then would
// mean waiting for the statement it is there to stop.
Expand Down
10 changes: 8 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,19 @@ use pyo3::prelude::*;
/// connection never creates anything, so a mistyped path there is an
/// error rather than an empty database.
///
/// With no path, or with `":memory:"`, the database is in memory and
/// no file is made anywhere. It is the whole engine and not a reduced
/// one, so it takes writes and transactions and the appender exactly
/// as a database on disk does, and it is gone when the last connection
/// to it is.
///
/// `memory_limit` is in bytes and `threads` is how many the executor
/// may use; both default to what the engine decides for the machine.
#[pyfunction]
#[pyo3(signature = (path, *, read_only = false, memory_limit = None, threads = None))]
#[pyo3(signature = (path = None, *, read_only = false, memory_limit = None, threads = None))]
fn connect(
py: Python<'_>,
path: PathBuf,
path: Option<PathBuf>,
read_only: bool,
memory_limit: Option<usize>,
threads: Option<usize>,
Expand Down
13 changes: 13 additions & 0 deletions tests/test_aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,3 +494,16 @@ async def test_a_stream_that_fails_raises_at_the_row_it_failed_on(tmp_path: Path
rows = await conn.stream("MATCH (")
with pytest.raises(zudb.SyntaxError):
await rows.__anext__()


@run
async def test_connect_with_no_path_is_a_database_in_memory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
async with zudb.aio.connect() as conn:
assert conn.memory is True
await conn.execute("INSERT (p:person {uid: 10, name: 'ada'})")
rows = await conn.execute("MATCH (p:person) RETURN p.name AS n")
assert rows.fetchall() == [("ada",)]
assert list(tmp_path.iterdir()) == []
Loading
Loading