Skip to content

Commit 38fa59c

Browse files
committed
fix: review findings before 0.5.0, bump to 0.5.0-beta.2
AsyncTransaction.__aexit__ now delegates to Transaction.__exit__, so a manual commit or rollback inside an async with block no longer raises on exit or replaces the block's own exception. Transaction.rollback() is a no-op once the transaction has ended, and a failed commit() leaves the transaction active, so the documented except: tx.rollback(); raise handler never hides the real error. Database.open(':memory:') gives each open a private in-memory database; memory://name keeps the shared one. A DSN with any scheme is passed to the engine untouched instead of becoming a relative directory named after the scheme. open() defaults to ':memory:' and releases the GIL, as does prepare(). execute_batch accepts any iterable of parameter sets, not only a list. Type stubs gain Vector, the four *_prepared methods on Transaction and AsyncTransaction, and the open() default. CI and publish build wheels for CPython 3.9 through 3.13 instead of 3.12 only, CI tests 3.9, 3.12 and 3.13, and a pre-release tag creates a pre-release on GitHub.
1 parent 1c7136e commit 38fa59c

15 files changed

Lines changed: 188 additions & 38 deletions

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
uses: PyO3/maturin-action@v1
5353
with:
5454
target: ${{ matrix.target }}
55-
args: --release --out dist -i python3.12
55+
args: --release --out dist -i python3.9 python3.10 python3.11 python3.12 python3.13
5656
manylinux: auto
5757

5858
- name: Upload wheels
@@ -69,7 +69,7 @@ jobs:
6969
fail-fast: false
7070
matrix:
7171
os: [ubuntu-latest, macos-latest, windows-latest]
72-
python: ['3.12']
72+
python: ['3.9', '3.12', '3.13']
7373
runs-on: ${{ matrix.os }}
7474
steps:
7575
- uses: actions/checkout@v4
@@ -99,7 +99,7 @@ jobs:
9999
- name: Install wheel and test dependencies
100100
shell: bash
101101
run: |
102-
pip install dist/*.whl
102+
pip install --no-index --find-links dist stoolap-python
103103
pip install pytest pytest-asyncio
104104
105105
- name: Run tests

‎.github/workflows/publish.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
uses: PyO3/maturin-action@v1
5050
with:
5151
target: ${{ matrix.target }}
52-
args: --release --out dist -i python3.12
52+
args: --release --out dist -i python3.9 python3.10 python3.11 python3.12 python3.13
5353
manylinux: auto
5454

5555
- name: Upload wheels
@@ -110,3 +110,4 @@ jobs:
110110
uses: softprops/action-gh-release@v2
111111
with:
112112
generate_release_notes: true
113+
prerelease: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }}

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stoolap-python"
3-
version = "0.5.0-beta.1"
3+
version = "0.5.0-beta.2"
44
edition = "2021"
55
authors = ["Stoolap Contributors"]
66
license = "Apache-2.0"

‎README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ raw = db.query_raw("SELECT id, name FROM users ORDER BY id")
5555
db.close()
5656
```
5757

58+
Each `Database.open(":memory:")` is a private database. To share one in-memory database between opens in the same process, give it a name: `Database.open("memory://name")`.
59+
60+
`query()` and `query_one()` key each row by column name, so two result columns with the same name (for example `a.id` and `b.id` in a join) collapse into one key. Alias them, or use `query_raw()`, when that matters.
61+
5862
## Prepared Statements
5963

6064
Parse SQL once, execute many times with different parameters:
@@ -99,7 +103,7 @@ except:
99103
raise
100104
```
101105

102-
Transactions support `execute()`, `query()`, `query_one()`, `query_raw()`, and `execute_batch()` with both positional (`$1, $2`) and named (`:key`) parameters.
106+
Transactions support `execute()`, `query()`, `query_one()`, `query_raw()`, and `execute_batch()` with both positional (`$1, $2`) and named (`:key`) parameters. A statement prepared on the database runs inside a transaction with `execute_prepared(stmt, params)`, `query_prepared`, `query_one_prepared` and `query_raw_prepared`. `rollback()` is a no-op once the transaction has ended, so the handler above never hides the original error.
103107

104108
## Batch Execution
105109

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "stoolap-python"
7-
version = "0.5.0b1"
7+
version = "0.5.0b2"
88
description = "High-performance Python driver for Stoolap embedded SQL database"
99
readme = "README.md"
1010
license = { text = "Apache-2.0" }

‎python/stoolap/__init__.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,9 @@ async def __aenter__(self) -> "AsyncTransaction":
144144
return self
145145

146146
async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool:
147-
if exc_type is not None:
148-
await self.rollback()
149-
else:
150-
await self.commit()
151-
return False
147+
# Same rules as the sync context manager: commit on clean exit,
148+
# rollback on exception, no-op if the block already ended the tx.
149+
return await asyncio.to_thread(self._tx.__exit__, exc_type, exc_val, exc_tb)
152150

153151
def __repr__(self) -> str:
154152
return repr(self._tx)

‎python/stoolap/__init__.pyi‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ __all__: list[str]
2121

2222
class StoolapError(RuntimeError): ...
2323

24+
class Vector:
25+
def __init__(self, data: Sequence[float]) -> None: ...
26+
def to_list(self) -> List[float]: ...
27+
def __len__(self) -> int: ...
28+
2429
class Database:
2530
@staticmethod
2631
def open(path: str = ":memory:") -> "Database": ...
@@ -40,6 +45,10 @@ class Transaction:
4045
def query_one(self, sql: str, params: Params = None) -> Optional[Dict[str, Any]]: ...
4146
def query_raw(self, sql: str, params: Params = None) -> Dict[str, Any]: ...
4247
def execute_batch(self, sql: str, params_list: Sequence[ParamSet]) -> int: ...
48+
def execute_prepared(self, stmt: "PreparedStatement", params: Params = None) -> int: ...
49+
def query_prepared(self, stmt: "PreparedStatement", params: Params = None) -> List[Dict[str, Any]]: ...
50+
def query_one_prepared(self, stmt: "PreparedStatement", params: Params = None) -> Optional[Dict[str, Any]]: ...
51+
def query_raw_prepared(self, stmt: "PreparedStatement", params: Params = None) -> Dict[str, Any]: ...
4352
def commit(self) -> None: ...
4453
def rollback(self) -> None: ...
4554
def __enter__(self) -> "Transaction": ...
@@ -73,6 +82,10 @@ class AsyncTransaction:
7382
async def query_one(self, sql: str, params: Params = None) -> Optional[Dict[str, Any]]: ...
7483
async def query_raw(self, sql: str, params: Params = None) -> Dict[str, Any]: ...
7584
async def execute_batch(self, sql: str, params_list: Sequence[ParamSet]) -> int: ...
85+
async def execute_prepared(self, stmt: Union["AsyncPreparedStatement", PreparedStatement], params: Params = None) -> int: ...
86+
async def query_prepared(self, stmt: Union["AsyncPreparedStatement", PreparedStatement], params: Params = None) -> List[Dict[str, Any]]: ...
87+
async def query_one_prepared(self, stmt: Union["AsyncPreparedStatement", PreparedStatement], params: Params = None) -> Optional[Dict[str, Any]]: ...
88+
async def query_raw_prepared(self, stmt: Union["AsyncPreparedStatement", PreparedStatement], params: Params = None) -> Dict[str, Any]: ...
7689
async def commit(self) -> None: ...
7790
async def rollback(self) -> None: ...
7891
async def __aenter__(self) -> "AsyncTransaction": ...

‎src/database.rs‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
use pyo3::prelude::*;
1616
use pyo3::types::{PyDict, PyList, PyString};
17+
use std::sync::atomic::{AtomicU64, Ordering};
1718
use std::sync::Arc;
1819

1920
use stoolap::api::Database as ApiDatabase;
@@ -36,13 +37,14 @@ impl Database {
3637
/// Open a database connection.
3738
///
3839
/// Accepts:
39-
/// - `:memory:` or empty string for in-memory database
40-
/// - `memory://` for in-memory database
40+
/// - `:memory:` or empty string for a private in-memory database
41+
/// - `memory://name` for an in-memory database shared by every open of that name
4142
/// - `./mydb` or `file:///path/to/db` for file-based database
4243
#[staticmethod]
43-
fn open(path: &str) -> PyResult<Self> {
44+
#[pyo3(signature = (path=":memory:"))]
45+
fn open(py: Python<'_>, path: &str) -> PyResult<Self> {
4446
let dsn = translate_path(path);
45-
let db = ApiDatabase::open(&dsn).map_err(to_py)?;
47+
let db = py.allow_threads(|| ApiDatabase::open(&dsn)).map_err(to_py)?;
4648
Ok(Self { db: Arc::new(db) })
4749
}
4850

@@ -137,13 +139,14 @@ impl Database {
137139
&self,
138140
py: Python<'_>,
139141
sql: &str,
140-
params_list: &Bound<'_, PyList>,
142+
params_list: &Bound<'_, PyAny>,
141143
) -> PyResult<i64> {
142144
let sql = sql.to_string();
143145

144146
// Parse all param sets on the Python thread (need GIL)
145-
let mut all_params = Vec::with_capacity(params_list.len());
146-
for item in params_list.iter() {
147+
let mut all_params = Vec::with_capacity(params_list.len().unwrap_or(0));
148+
for item in params_list.try_iter()? {
149+
let item = item?;
147150
let bind = parse_params(Some(&item))?;
148151
match bind {
149152
BindParams::Positional(p) => all_params.push(p),
@@ -171,8 +174,8 @@ impl Database {
171174
/// Create a prepared statement.
172175
///
173176
/// Parses SQL once and caches the execution plan.
174-
fn prepare(&self, sql: &str) -> PyResult<PreparedStatement> {
175-
PreparedStatement::new(Arc::clone(&self.db), sql)
177+
fn prepare(&self, py: Python<'_>, sql: &str) -> PyResult<PreparedStatement> {
178+
py.allow_threads(|| PreparedStatement::new(Arc::clone(&self.db), sql))
176179
}
177180

178181
/// Begin a transaction.
@@ -192,17 +195,39 @@ impl Database {
192195
}
193196

194197
/// Translate user-friendly paths to Stoolap DSN format.
198+
///
199+
/// `:memory:` gets a DSN unique to this open, so two `:memory:` databases in
200+
/// one process never share tables and closing one leaves the other open.
201+
/// Anything that already carries a scheme is passed through untouched and
202+
/// left to the engine to validate; a bare path becomes `file://path`.
195203
fn translate_path(path: &str) -> String {
204+
static MEMORY_OPENS: AtomicU64 = AtomicU64::new(0);
205+
196206
let trimmed = path.trim();
197207
if trimmed.is_empty() || trimmed == ":memory:" {
198-
"memory://".to_string()
199-
} else if trimmed.starts_with("memory://") || trimmed.starts_with("file://") {
208+
let n = MEMORY_OPENS.fetch_add(1, Ordering::Relaxed);
209+
format!("memory://py-{}-{n}", std::process::id())
210+
} else if has_scheme(trimmed) {
200211
trimmed.to_string()
201212
} else {
202213
format!("file://{trimmed}")
203214
}
204215
}
205216

217+
/// True when `s` starts with an RFC 3986 scheme followed by `://`.
218+
fn has_scheme(s: &str) -> bool {
219+
match s.find("://") {
220+
Some(idx) if idx > 0 => {
221+
let scheme = &s[..idx];
222+
scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
223+
&& scheme
224+
.chars()
225+
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
226+
}
227+
_ => false,
228+
}
229+
}
230+
206231
/// Convert named params to stoolap NamedParams.
207232
pub fn to_named_params(named: &[(String, stoolap::core::Value)]) -> stoolap::api::NamedParams {
208233
let mut np = stoolap::api::NamedParams::new();

‎src/statement.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
// limitations under the License.
1414

1515
use pyo3::prelude::*;
16-
use pyo3::types::PyList;
1716
use std::sync::Arc;
1817

1918
use stoolap::api::{Database as ApiDatabase, Statement as ApiStatement};
@@ -99,10 +98,11 @@ impl PreparedStatement {
9998
///
10099
/// Auto-wraps in a transaction. Returns total rows affected.
101100
#[pyo3(signature = (params_list,))]
102-
fn execute_batch(&self, py: Python<'_>, params_list: &Bound<'_, PyList>) -> PyResult<i64> {
101+
fn execute_batch(&self, py: Python<'_>, params_list: &Bound<'_, PyAny>) -> PyResult<i64> {
103102
// Parse all param sets while holding GIL
104-
let mut all_params = Vec::with_capacity(params_list.len());
105-
for item in params_list.iter() {
103+
let mut all_params = Vec::with_capacity(params_list.len().unwrap_or(0));
104+
for item in params_list.try_iter()? {
105+
let item = item?;
106106
let bind = parse_params(Some(&item))?;
107107
match bind {
108108
BindParams::Positional(p) => all_params.push(p),

0 commit comments

Comments
 (0)