feat: impl python binding

refactor: pep-0249

refactor: rust comment and requirements-dev.txt

fix: name conflict
This commit is contained in:
JeanArhancet 2024-08-06 09:21:06 +02:00
parent fc1f61acf9
commit 7c362b129f
16 changed files with 861 additions and 4 deletions

20
.gitignore vendored
View file

@ -1,2 +1,20 @@
/target
/.idea
/.idea
*.so
*.ipynb
# Python
.mypy_cache/
.pytest_cache/
.ruff_cache/
.venv*/
__pycache__/
.coverage
venv
env
.env
.venv
# OS
.DS_Store

112
Cargo.lock generated
View file

@ -926,6 +926,12 @@ dependencies = [
"serde",
]
[[package]]
name = "indoc"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
[[package]]
name = "inferno"
version = "0.11.19"
@ -1175,6 +1181,15 @@ dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mimalloc"
version = "0.1.43"
@ -1502,6 +1517,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "portable-atomic"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265"
[[package]]
name = "pprof"
version = "0.12.1"
@ -1565,6 +1586,81 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "py-limbo"
version = "0.0.3"
dependencies = [
"anyhow",
"limbo_core",
"pyo3",
"pyo3-build-config",
"version_check",
]
[[package]]
name = "pyo3"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831e8e819a138c36e212f3af3fd9eeffed6bf1510a805af35b0edee5ffa59433"
dependencies = [
"anyhow",
"cfg-if",
"indoc",
"libc",
"memoffset",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8730e591b14492a8945cdff32f089250b05f5accecf74aeddf9e8272ce1fa8"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e97e919d2df92eb88ca80a037969f44e5e70356559654962cbb3316d00300c6"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb57983022ad41f9e683a599f2fd13c3664d7063a3ac5714cae4b7bee7d3f206"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.69",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec480c0c51ddec81019531705acac51bcdbeae563557c982aa8263bb96880372"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn 2.0.69",
]
[[package]]
name = "quick-xml"
version = "0.26.0"
@ -1994,6 +2090,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.10.1"
@ -2140,6 +2242,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
[[package]]
name = "unindent"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce"
[[package]]
name = "utf8parse"
version = "0.2.2"
@ -2160,9 +2268,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"

View file

@ -3,6 +3,7 @@
[workspace]
resolver = "2"
members = [
"bindings/python",
"bindings/wasm",
"cli",
"sqlite3",

View file

@ -0,0 +1,25 @@
[package]
name = "py-limbo"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[lib]
name = "_limbo"
crate-type = ["cdylib"]
[features]
# must be enabled when building with `cargo build`, maturin enables this automatically
extension-module = ["pyo3/extension-module"]
[dependencies]
anyhow = "1.0"
limbo_core = { path = "../../core" }
pyo3 = { version = "0.22.2", features = ["anyhow", "auto-initialize"] }
[build-dependencies]
version_check = "0.9.5"
# used where logic has to be version/distribution specific, e.g. pypy
pyo3-build-config = { version = "0.22.0" }

4
bindings/python/build.rs Normal file
View file

@ -0,0 +1,4 @@
fn main() {
pyo3_build_config::use_pyo3_cfgs();
println!("cargo::rustc-check-cfg=cfg(allocator, values(\"default\", \"mimalloc\"))");
}

View file

@ -0,0 +1,29 @@
from _limbo import (
Connection,
Cursor,
DatabaseError,
DataError,
IntegrityError,
InterfaceError,
InternalError,
NotSupportedError,
OperationalError,
ProgrammingError,
__version__,
connect,
)
__all__ = [
"__version__",
"Connection",
"Cursor",
"InterfaceError",
"DatabaseError",
"DataError",
"OperationalError",
"IntegrityError",
"InternalError",
"ProgrammingError",
"NotSupportedError",
"connect",
]

View file

@ -0,0 +1,176 @@
from typing import Any, List, Optional, Tuple
__version__: str
class Connection:
def cursor(self) -> "Cursor":
"""
Creates a new cursor object using this connection.
:return: A new Cursor object.
:raises InterfaceError: If the cursor cannot be created.
"""
...
def close(self) -> None:
"""
Closes the connection to the database.
:raises OperationalError: If there is an error closing the connection.
"""
...
def commit(self) -> None:
"""
Commits the current transaction.
:raises OperationalError: If there is an error during commit.
"""
...
def rollback(self) -> None:
"""
Rolls back the current transaction.
:raises OperationalError: If there is an error during rollback.
"""
...
class Cursor:
arraysize: int
description: Optional[
Tuple[
str,
str,
Optional[str],
Optional[str],
Optional[str],
Optional[str],
Optional[str],
]
]
rowcount: int
def execute(
self, sql: str, parameters: Optional[Tuple[Any, ...]] = None
) -> "Cursor":
"""
Prepares and executes a SQL statement using the connection.
:param sql: The SQL query to execute.
:param parameters: The parameters to substitute into the SQL query.
:raises ProgrammingError: If there is an error in the SQL query.
:raises OperationalError: If there is an error executing the query.
:return: The cursor object.
"""
...
def executemany(
self, sql: str, parameters: Optional[List[Tuple[Any, ...]]] = None
) -> None:
"""
Executes a SQL command against all parameter sequences or mappings found in the sequence `parameters`.
:param sql: The SQL command to execute.
:param parameters: A list of parameter sequences or mappings.
:raises ProgrammingError: If there is an error in the SQL query.
:raises OperationalError: If there is an error executing the query.
"""
...
def fetchone(self) -> Optional[Tuple[Any, ...]]:
"""
Fetches the next row from the result set.
:return: A tuple representing the next row, or None if no more rows are available.
:raises OperationalError: If there is an error fetching the row.
"""
...
def fetchall(self) -> List[Tuple[Any, ...]]:
"""
Fetches all remaining rows from the result set.
:return: A list of tuples, each representing a row in the result set.
:raises OperationalError: If there is an error fetching the rows.
"""
...
def fetchmany(self, size: Optional[int] = None) -> List[Tuple[Any, ...]]:
"""
Fetches the next set of rows of a size specified by the `arraysize` property.
:param size: Optional integer to specify the number of rows to fetch.
:return: A list of tuples, each representing a row in the result set.
:raises OperationalError: If there is an error fetching the rows.
"""
...
def close(self) -> None:
"""
Closes the cursor.
:raises OperationalError: If there is an error closing the cursor.
"""
...
# Exception classes
class Warning(Exception):
"""Exception raised for important warnings like data truncations while inserting."""
...
class Error(Exception):
"""Base class for all other error exceptions. Catch all database-related errors using this class."""
...
class InterfaceError(Error):
"""Exception raised for errors related to the database interface rather than the database itself."""
...
class DatabaseError(Error):
"""Exception raised for errors that are related to the database."""
...
class DataError(DatabaseError):
"""Exception raised for errors due to problems with the processed data like division by zero, numeric value out of range, etc."""
...
class OperationalError(DatabaseError):
"""Exception raised for errors related to the databases operation, not necessarily under the programmer's control."""
...
class IntegrityError(DatabaseError):
"""Exception raised when the relational integrity of the database is affected, e.g., a foreign key check fails."""
...
class InternalError(DatabaseError):
"""Exception raised when the database encounters an internal error, e.g., cursor is not valid anymore, transaction out of sync."""
...
class ProgrammingError(DatabaseError):
"""Exception raised for programming errors, e.g., table not found, syntax error in SQL, wrong number of parameters specified."""
...
class NotSupportedError(DatabaseError):
"""Exception raised when a method or database API is used which is not supported by the database."""
...
def connect(path: str) -> Connection:
"""
Connects to a database at the specified path.
:param path: The path to the database file.
:return: A Connection object to the database.
:raises InterfaceError: If the database cannot be connected.
"""
...

View file

View file

@ -0,0 +1,85 @@
[build-system]
requires = ['maturin>=1,<2', 'typing_extensions']
build-backend = 'maturin'
[project]
name = 'limbo'
description = "Limbo is a work-in-progress, in-process OLTP database management system, compatible with SQLite."
requires-python = '>=3.8'
classifiers = [
'Development Status :: 3 - Alpha',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Rust',
'License :: OSI Approved :: MIT License',
'Operating System :: POSIX :: Linux',
'Operating System :: Microsoft :: Windows',
'Operating System :: MacOS',
'Topic :: Database',
'Topic :: Software Development :: Libraries',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Database :: Database Engines/Servers',
]
dependencies = ['typing-extensions >=4.6.0,!=4.7.0']
dynamic = [
'readme',
'version'
]
[project.optional-dependencies]
dev = [
"maturin==1.7.0",
"black==24.4.2",
"isort==5.13.2",
"mypy==1.11.0",
"pytest==8.3.1",
"pytest-cov==5.0.0",
"ruff==0.5.4"
]
[project.urls]
Homepage = "https://github.com/penberg/limbo"
Source = "https://github.com/penberg/limbo"
[tool.maturin]
bindings = 'pyo3'
[tool.ruff]
line-length = 120
[tool.ruff.lint]
extend-select = ['Q', 'RUF100', 'C90', 'I']
extend-ignore = [
'E721', # using type() instead of isinstance() - we use this in tests
]
flake8-quotes = { inline-quotes = 'single', multiline-quotes = 'double' }
mccabe = { max-complexity = 13 }
isort = { known-first-party = ['pydantic_core', 'tests'] }
[tool.ruff.format]
quote-style = 'single'
[tool.pytest.ini_options]
testpaths = 'tests'
log_format = '%(name)s %(levelname)s: %(message)s'
[tool.coverage.run]
source = ['limbo']
branch = true
[tool.coverage.report]
precision = 2
exclude_lines = [
'pragma: no cover',
'raise NotImplementedError',
'if TYPE_CHECKING:',
'@overload',
]

View file

@ -0,0 +1,46 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --extra=dev --output-file=requirements-dev.txt --strip-extras pyproject.toml
#
black==24.4.2
# via limbo (pyproject.toml)
click==8.1.7
# via black
coverage==7.6.1
# via pytest-cov
iniconfig==2.0.0
# via pytest
isort==5.13.2
# via limbo (pyproject.toml)
maturin==1.7.0
# via limbo (pyproject.toml)
mypy==1.11.0
# via limbo (pyproject.toml)
mypy-extensions==1.0.0
# via
# black
# mypy
packaging==24.1
# via
# black
# pytest
pathspec==0.12.1
# via black
platformdirs==4.2.2
# via black
pluggy==1.5.0
# via pytest
pytest==8.3.1
# via
# limbo (pyproject.toml)
# pytest-cov
pytest-cov==5.0.0
# via limbo (pyproject.toml)
ruff==0.5.4
# via limbo (pyproject.toml)
typing-extensions==4.12.2
# via
# limbo (pyproject.toml)
# mypy

View file

@ -0,0 +1,35 @@
use pyo3::create_exception;
use pyo3::exceptions::PyException;
create_exception!(
limbo,
Warning,
PyException,
"Exception raised for important warnings like data truncations while inserting."
);
create_exception!(limbo, Error, PyException, "Base class for all other error exceptions. Catch all database-related errors using this class.");
create_exception!(
limbo,
InterfaceError,
Error,
"Raised for errors related to the database interface rather than the database itself."
);
create_exception!(
limbo,
DatabaseError,
Error,
"Raised for errors that are related to the database."
);
create_exception!(limbo, DataError, DatabaseError, "Raised for errors due to problems with the processed data like division by zero, numeric value out of range, etc.");
create_exception!(limbo, OperationalError, DatabaseError, "Raised for errors related to the databases operation, not necessarily under the programmer's control.");
create_exception!(limbo, IntegrityError, DatabaseError, "Raised when the relational integrity of the database is affected, e.g., a foreign key check fails.");
create_exception!(limbo, InternalError, DatabaseError, "Raised when the database encounters an internal error, e.g., cursor is not valid anymore, transaction out of sync.");
create_exception!(limbo, ProgrammingError, DatabaseError, "Raised for programming errors, e.g., table not found, syntax error in SQL, wrong number of parameters specified.");
create_exception!(
limbo,
NotSupportedError,
DatabaseError,
"Raised when a method or database API is used which is not supported by the database."
);

264
bindings/python/src/lib.rs Normal file
View file

@ -0,0 +1,264 @@
use anyhow::Result;
use errors::*;
use limbo_core::IO;
use pyo3::prelude::*;
use pyo3::types::PyList;
use pyo3::types::PyTuple;
use std::sync::{Arc, Mutex};
mod errors;
#[pyclass]
#[derive(Clone, Debug)]
struct Description {
#[pyo3(get)]
name: String,
#[pyo3(get)]
type_code: String,
#[pyo3(get)]
display_size: Option<String>,
#[pyo3(get)]
internal_size: Option<String>,
#[pyo3(get)]
precision: Option<String>,
#[pyo3(get)]
scale: Option<String>,
#[pyo3(get)]
null_ok: Option<String>,
}
impl IntoPy<Py<PyTuple>> for Description {
fn into_py(self, py: Python<'_>) -> Py<PyTuple> {
PyTuple::new_bound(
py,
vec![
self.name.into_py(py),
self.type_code.into_py(py),
self.display_size.into_py(py),
self.internal_size.into_py(py),
self.precision.into_py(py),
self.scale.into_py(py),
self.null_ok.into_py(py),
],
)
.into()
}
}
#[pyclass]
pub struct Cursor {
/// This read/write attribute specifies the number of rows to fetch at a time with `.fetchmany()`.
/// It defaults to `1`, meaning it fetches a single row at a time.
#[pyo3(get)]
arraysize: i64,
conn: Connection,
/// The `.description` attribute is a read-only sequence of 7-item, each describing a column in the result set:
///
/// - `name`: The column's name (always present).
/// - `type_code`: The data type code (always present).
/// - `display_size`: Column's display size (optional).
/// - `internal_size`: Column's internal size (optional).
/// - `precision`: Numeric precision (optional).
/// - `scale`: Numeric scale (optional).
/// - `null_ok`: Indicates if null values are allowed (optional).
///
/// The `name` and `type_code` fields are mandatory; others default to `None` if not applicable.
///
/// This attribute is `None` for operations that do not return rows or if no `.execute*()` method has been invoked.
#[pyo3(get)]
description: Option<Description>,
/// Read-only attribute that provides the number of modified rows for `INSERT`, `UPDATE`, `DELETE`,
/// and `REPLACE` statements; it is `-1` for other statements, including CTE queries.
/// It is only updated by the `execute()` and `executemany()` methods after the statement has run to completion.
/// This means any resulting rows must be fetched for `rowcount` to be updated.
#[pyo3(get)]
rowcount: i64,
smt: Option<Arc<Mutex<limbo_core::Statement>>>,
}
// SAFETY: The limbo_core crate guarantees that `Cursor` is thread-safe.
unsafe impl Send for Cursor {}
#[pymethods]
impl Cursor {
#[pyo3(signature = (sql, parameters=None))]
pub fn execute(&mut self, sql: &str, parameters: Option<Py<PyTuple>>) -> Result<Self> {
let stmt_is_dml = stmt_is_dml(sql);
let conn_lock =
self.conn.conn.lock().map_err(|_| {
PyErr::new::<OperationalError, _>("Failed to acquire connection lock")
})?;
let statement = conn_lock.prepare(sql).map_err(|e| {
PyErr::new::<ProgrammingError, _>(format!("Failed to prepare statement: {:?}", e))
})?;
self.smt = Some(Arc::new(Mutex::new(statement)));
// TODO: use stmt_is_dml to set rowcount
if stmt_is_dml {
todo!()
}
Ok(Cursor {
smt: self.smt.clone(),
conn: self.conn.clone(),
description: self.description.clone(),
rowcount: self.rowcount,
arraysize: self.arraysize,
})
}
pub fn fetchone(&mut self, py: Python) -> Result<Option<PyObject>> {
if let Some(smt) = &self.smt {
let mut smt_lock = smt.lock().map_err(|_| {
PyErr::new::<OperationalError, _>("Failed to acquire statement lock")
})?;
match smt_lock
.step()
.map_err(|e| PyErr::new::<OperationalError, _>(format!("Step error: {:?}", e)))?
{
limbo_core::RowResult::Row(row) => {
let py_row = row_to_py(py, &row);
Ok(Some(py_row))
}
limbo_core::RowResult::IO => {
self.conn.io.run_once().map_err(|e| {
PyErr::new::<OperationalError, _>(format!("IO error: {:?}", e))
})?;
Ok(None)
}
limbo_core::RowResult::Done => Ok(None),
}
} else {
Err(PyErr::new::<ProgrammingError, _>("No statement prepared for execution").into())
}
}
pub fn fetchall(&mut self, py: Python) -> Result<Vec<PyObject>> {
let mut results = Vec::new();
while let Some(row) = self.fetchone(py)? {
results.push(row);
}
Ok(results)
}
pub fn close(&self) -> Result<()> {
todo!()
}
#[pyo3(signature = (sql, parameters=None))]
pub fn executemany(&self, sql: &str, parameters: Option<Py<PyList>>) {
todo!()
}
#[pyo3(signature = (size=None))]
pub fn fetchmany(&self, size: Option<i64>) {
todo!()
}
}
fn stmt_is_dml(sql: &str) -> bool {
let sql = sql.trim();
let sql = sql.to_uppercase();
sql.starts_with("INSERT") || sql.starts_with("UPDATE") || sql.starts_with("DELETE")
}
#[pyclass]
#[derive(Clone)]
pub struct Connection {
conn: Arc<Mutex<limbo_core::Connection>>,
io: Arc<limbo_core::PlatformIO>,
}
// SAFETY: The limbo_core crate guarantees that `Connection` is thread-safe.
unsafe impl Send for Connection {}
#[pymethods]
impl Connection {
pub fn cursor(&self) -> Result<Cursor> {
Ok(Cursor {
arraysize: 1,
conn: self.clone(),
description: None,
rowcount: -1,
smt: None,
})
}
pub fn close(&self) {
drop(self.conn.clone());
}
pub fn commit(&self) {
todo!()
}
pub fn rollback(&self) {
todo!()
}
}
#[pyfunction]
pub fn connect(path: &str) -> Result<Connection> {
let io = Arc::new(limbo_core::PlatformIO::new().map_err(|e| {
PyErr::new::<InterfaceError, _>(format!("IO initialization failed: {:?}", e))
})?);
let db = limbo_core::Database::open_file(io.clone(), path)
.map_err(|e| PyErr::new::<DatabaseError, _>(format!("Failed to open database: {:?}", e)))?;
let conn: limbo_core::Connection = db.connect();
Ok(Connection {
conn: Arc::new(Mutex::new(conn)),
io,
})
}
fn row_to_py(py: Python, row: &limbo_core::Row) -> PyObject {
let py_values: Vec<PyObject> = row
.values
.iter()
.map(|value| match value {
limbo_core::Value::Null => py.None(),
limbo_core::Value::Integer(i) => i.to_object(py),
limbo_core::Value::Float(f) => f.to_object(py),
limbo_core::Value::Text(s) => s.to_object(py),
limbo_core::Value::Blob(b) => b.to_object(py),
})
.collect();
PyTuple::new_bound(py, &py_values).to_object(py)
}
#[pymodule]
fn _limbo(m: &Bound<PyModule>) -> PyResult<()> {
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
m.add_class::<Connection>()?;
m.add_class::<Cursor>()?;
m.add_function(wrap_pyfunction!(connect, m)?)?;
m.add("Warning", m.py().get_type_bound::<Warning>())?;
m.add("Error", m.py().get_type_bound::<Error>())?;
m.add("InterfaceError", m.py().get_type_bound::<InterfaceError>())?;
m.add("DatabaseError", m.py().get_type_bound::<DatabaseError>())?;
m.add("DataError", m.py().get_type_bound::<DataError>())?;
m.add(
"OperationalError",
m.py().get_type_bound::<OperationalError>(),
)?;
m.add("IntegrityError", m.py().get_type_bound::<IntegrityError>())?;
m.add("InternalError", m.py().get_type_bound::<InternalError>())?;
m.add(
"ProgrammingError",
m.py().get_type_bound::<ProgrammingError>(),
)?;
m.add(
"NotSupportedError",
m.py().get_type_bound::<NotSupportedError>(),
)?;
Ok(())
}

View file

Binary file not shown.

View file

@ -0,0 +1,66 @@
import sqlite3
import pytest
import limbo
@pytest.mark.parametrize("provider", ["sqlite3", "limbo"])
def test_fetchall_select_all_users(provider):
conn = connect(provider, "tests/database.db")
cursor = conn.cursor()
cursor.execute("SELECT * FROM users")
users = cursor.fetchall()
assert users
assert users == [(1, "alice"), (2, "bob")]
@pytest.mark.parametrize(
"provider",
[
"sqlite3",
],
)
def test_fetchall_select_user_ids(provider):
conn = connect(provider, "tests/database.db")
cursor = conn.cursor()
cursor.execute("SELECT id FROM users")
user_ids = cursor.fetchall()
assert user_ids
assert user_ids == [(1,), (2,)]
@pytest.mark.parametrize("provider", ["sqlite3", "limbo"])
def test_fetchone_select_all_users(provider):
conn = connect(provider, "tests/database.db")
cursor = conn.cursor()
cursor.execute("SELECT * FROM users")
alice = cursor.fetchone()
assert alice
assert alice == (1, "alice")
bob = cursor.fetchone()
assert bob
assert bob == (2, "bob")
@pytest.mark.parametrize("provider", ["sqlite3", "limbo"])
def test_fetchone_select_max_user_id(provider):
conn = connect(provider, "tests/database.db")
cursor = conn.cursor()
cursor.execute("SELECT MAX(id) FROM users")
max_id = cursor.fetchone()
assert max_id
assert max_id == (2,)
def connect(provider, database):
if provider == "limbo":
return limbo.connect(database)
if provider == "sqlite3":
return sqlite3.connect(database)
raise Exception(f"Provider `{provider}` is not supported")

View file

@ -246,4 +246,4 @@ int sqlite3_libversion_number(void);
} // extern "C"
#endif // __cplusplus
#endif /* LIMBO_SQLITE3_H */
#endif /* LIMBO_SQLITE3_H */