Skip to content

Commit 4443e52

Browse files
authored
Merge pull request #11 from qaspen-python/feature/add_convertion_to_class_support
Added support for any custom python class in QueryResult
2 parents 22e9c87 + a38415c commit 4443e52

File tree

6 files changed

+237
-17
lines changed

6 files changed

+237
-17
lines changed

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,48 @@ As connection can be closed in different situations on various sides you can sel
119119
`DEALLOCATE ALL` and `DISCARD PLAN`, so that the statement cache is not
120120
rendered ineffective.
121121

122+
## Results from querying
123+
You have some options to get results from the query.
124+
`execute()` method, for example, returns `QueryResult` and this class can be converted into `list` of `dict`s - `list[dict[Any, Any]]` or into any Python class (`pydantic` model, as an example).
125+
126+
Let's see some code:
127+
```python
128+
from typing import Any
129+
130+
from pydantic import BaseModel
131+
from psqlpy import PSQLPool, QueryResult
132+
133+
134+
class ExampleModel(BaseModel):
135+
id: int
136+
username: str
137+
138+
139+
db_pool = PSQLPool(
140+
dsn="postgres://postgres:postgres@localhost:5432/postgres",
141+
max_db_pool_size=2,
142+
)
143+
144+
async def main() -> None:
145+
await db_pool.startup()
146+
147+
res: QueryResult = await db_pool.execute(
148+
"SELECT * FROM users",
149+
)
150+
151+
pydantic_res: list[ExampleModel] = res.as_class(
152+
as_class=ExampleModel,
153+
)
154+
```
155+
122156
## Query parameters
123157

124158
You can pass parameters into queries.
125159
Parameters can be passed in any `execute` method as the second parameter, it must be a list.
126160
Any placeholder must be marked with `$< num>`.
127161

128162
```python
129-
res: list[dict[str, Any]] = await db_pool.execute(
163+
res: QueryResult = await db_pool.execute(
130164
"SELECT * FROM users WHERE user_id = $1 AND first_name = $2",
131165
[100, "RustDriver"],
132166
)

python/psqlpy/_internal/__init__.pyi

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,101 @@
11
import types
22
from enum import Enum
3-
from typing import Any, Optional
3+
from typing import Any, Callable, Optional, TypeVar
44

55
from typing_extensions import Self
66

7+
_CustomClass = TypeVar(
8+
"_CustomClass",
9+
)
10+
711
class QueryResult:
812
"""Result."""
913

1014
def result(self: Self) -> list[dict[Any, Any]]:
1115
"""Return result from database as a list of dicts."""
16+
def as_class(
17+
self: Self,
18+
as_class: Callable[..., _CustomClass],
19+
) -> list[_CustomClass]:
20+
"""Convert results to passed class.
21+
22+
The main goal of this method is pydantic,
23+
msgspec and dataclasses support.
24+
25+
### Parameters:
26+
- `as_class`: Any callable python class for the results.
27+
28+
### Example:
29+
```python
30+
import asyncio
31+
32+
from psqlpy import PSQLPool, QueryResult
33+
34+
35+
class ExampleOfAsClass:
36+
def __init__(self, username: str) -> None:
37+
self.username = username
38+
39+
40+
async def main() -> None:
41+
db_pool = PSQLPool()
42+
await db_pool.startup()
43+
44+
query_result: QueryResult = await db_pool.execute(
45+
"SELECT username FROM users WHERE id = $1",
46+
[100],
47+
)
48+
class_results: List[ExampleOfAsClass] = query_result.as_class(
49+
as_class=ExampleOfAsClass,
50+
)
51+
```
52+
"""
1253

1354
class SingleQueryResult:
1455
"""Single result."""
1556

1657
def result(self: Self) -> dict[Any, Any]:
1758
"""Return result from database as a dict."""
59+
def as_class(
60+
self: Self,
61+
as_class: Callable[..., _CustomClass],
62+
) -> list[_CustomClass]:
63+
"""Convert results to passed class.
64+
65+
The main goal of this method is pydantic,
66+
msgspec and dataclasses support.
67+
68+
### Parameters:
69+
- `as_class`: Any callable python class for the results.
70+
71+
### Example:
72+
```python
73+
import asyncio
74+
75+
from psqlpy import PSQLPool, QueryResult
76+
77+
78+
class ExampleOfAsClass:
79+
def __init__(self, username: str) -> None:
80+
self.username = username
81+
82+
83+
async def main() -> None:
84+
db_pool = PSQLPool()
85+
await db_pool.startup()
86+
87+
connection = await db_pool.connection()
88+
async with connection.transaction() as trans:
89+
query_result: SingleQueryResult = await trans.fetch_row(
90+
"SELECT username FROM users WHERE id = $1",
91+
[100],
92+
)
93+
94+
class_result: ExampleOfAsClass = query_result.as_class(
95+
as_class=ExampleOfAsClass,
96+
)
97+
```
98+
"""
1899

19100
class IsolationLevel(Enum):
20101
"""Class for Isolation Level for transactions."""

python/tests/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,26 @@
33
from typing import AsyncGenerator
44

55
import pytest
6+
from pydantic import BaseModel
67

78
from psqlpy import Cursor, PSQLPool
89

910

11+
class DefaultPydanticModel(BaseModel):
12+
"""Validation model for test data based on Pydantic."""
13+
14+
id: int
15+
name: str
16+
17+
18+
class DefaultPythonModelClass:
19+
"""Validation model for test data based on default Python class."""
20+
21+
def __init__(self, id: int, name: str) -> None:
22+
self.id = id
23+
self.name = name
24+
25+
1026
@pytest.fixture()
1127
def anyio_backend() -> str:
1228
"""

python/tests/test_value_converter.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import pytest
2+
from tests.conftest import DefaultPydanticModel, DefaultPythonModelClass
3+
4+
from psqlpy import PSQLPool
5+
6+
pytestmark = pytest.mark.anyio
7+
8+
9+
async def test_as_class(
10+
psql_pool: PSQLPool,
11+
table_name: str,
12+
number_database_records: int,
13+
) -> None:
14+
"""Test `as_class()` method."""
15+
select_result = await psql_pool.execute(
16+
f"SELECT * FROM {table_name}",
17+
)
18+
19+
as_pydantic = select_result.as_class(
20+
as_class=DefaultPydanticModel,
21+
)
22+
assert len(as_pydantic) == number_database_records
23+
24+
for single_record in as_pydantic:
25+
assert isinstance(single_record, DefaultPydanticModel)
26+
27+
as_py_class = select_result.as_class(
28+
as_class=DefaultPythonModelClass,
29+
)
30+
31+
assert len(as_py_class) == number_database_records
32+
33+
for single_py_record in as_py_class:
34+
assert isinstance(single_py_record, DefaultPythonModelClass)

src/query_result.rs

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
use pyo3::{pyclass, pymethods, types::PyDict, Py, PyAny, Python, ToPyObject};
22
use tokio_postgres::Row;
33

4-
use crate::{exceptions::rust_errors::RustPSQLDriverPyResult, value_converter::postgres_to_py};
4+
use crate::{
5+
exceptions::rust_errors::{RustPSQLDriverError, RustPSQLDriverPyResult},
6+
value_converter::postgres_to_py,
7+
};
8+
9+
/// Convert postgres `Row` into Python Dict.
10+
///
11+
/// # Errors
12+
///
13+
/// May return Err Result if can not convert
14+
/// postgres type to python or set new key-value pair
15+
/// in python dict.
16+
fn row_to_dict<'a>(py: Python<'a>, postgres_row: &'a Row) -> RustPSQLDriverPyResult<&'a PyDict> {
17+
let python_dict = PyDict::new(py);
18+
for (column_idx, column) in postgres_row.columns().iter().enumerate() {
19+
let python_type = postgres_to_py(py, postgres_row, column, column_idx)?;
20+
python_dict.set_item(column.name().to_object(py), python_type)?;
21+
}
22+
Ok(python_dict)
23+
}
524

625
#[pyclass(name = "QueryResult")]
726
#[allow(clippy::module_name_repetitions)]
@@ -33,15 +52,31 @@ impl PSQLDriverPyQueryResult {
3352
pub fn result(&self, py: Python<'_>) -> RustPSQLDriverPyResult<Py<PyAny>> {
3453
let mut result: Vec<&PyDict> = vec![];
3554
for row in &self.inner {
36-
let python_dict = PyDict::new(py);
37-
for (column_idx, column) in row.columns().iter().enumerate() {
38-
let python_type = postgres_to_py(py, row, column, column_idx)?;
39-
python_dict.set_item(column.name().to_object(py), python_type)?;
40-
}
41-
result.push(python_dict);
55+
result.push(row_to_dict(py, row)?);
4256
}
4357
Ok(result.to_object(py))
4458
}
59+
60+
/// Convert result from database to any class passed from Python.
61+
///
62+
/// # Errors
63+
///
64+
/// May return Err Result if can not convert
65+
/// postgres type to python or create new Python class.
66+
pub fn as_class<'a>(
67+
&'a self,
68+
py: Python<'a>,
69+
as_class: &'a PyAny,
70+
) -> RustPSQLDriverPyResult<Py<PyAny>> {
71+
let mut res: Vec<&PyAny> = vec![];
72+
for row in &self.inner {
73+
let pydict: &PyDict = row_to_dict(py, row)?;
74+
let convert_class_inst = as_class.call((), Some(pydict))?;
75+
res.push(convert_class_inst);
76+
}
77+
78+
Ok(res.to_object(py))
79+
}
4580
}
4681

4782
#[pyclass(name = "SingleQueryResult")]
@@ -68,16 +103,35 @@ impl PSQLDriverSinglePyQueryResult {
68103
/// # Errors
69104
///
70105
/// May return Err Result if can not convert
71-
/// postgres type to python or set new key-value pair
72-
/// in python dict.
106+
/// postgres type to python, can not set new key-value pair
107+
/// in python dict or there are no result.
73108
pub fn result(&self, py: Python<'_>) -> RustPSQLDriverPyResult<Py<PyAny>> {
74-
let python_dict = PyDict::new(py);
75109
if let Some(row) = self.inner.first() {
76-
for (column_idx, column) in row.columns().iter().enumerate() {
77-
let python_type = postgres_to_py(py, row, column, column_idx)?;
78-
python_dict.set_item(column.name().to_object(py), python_type)?;
79-
}
110+
return Ok(row_to_dict(py, row)?.to_object(py));
111+
}
112+
Err(RustPSQLDriverError::RustToPyValueConversionError(
113+
"There are not results from the query, can't return first row.".into(),
114+
))
115+
}
116+
117+
/// Convert result from database to any class passed from Python.
118+
///
119+
/// # Errors
120+
///
121+
/// May return Err Result if can not convert
122+
/// postgres type to python, can not create new Python class
123+
/// or there are no results.
124+
pub fn as_class<'a>(
125+
&'a self,
126+
py: Python<'a>,
127+
as_class: &'a PyAny,
128+
) -> RustPSQLDriverPyResult<&'a PyAny> {
129+
if let Some(row) = self.inner.first() {
130+
let pydict: &PyDict = row_to_dict(py, row)?;
131+
return Ok(as_class.call((), Some(pydict))?);
80132
}
81-
Ok(python_dict.to_object(py))
133+
Err(RustPSQLDriverError::RustToPyValueConversionError(
134+
"There are not results from the query, can't convert first row.".into(),
135+
))
82136
}
83137
}

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ deps =
2121
pytest>=7,<8
2222
anyio>=3,<4
2323
maturin>=1,<2
24+
pydantic>=2
2425
allowlist_externals = maturin
2526
commands_pre =
2627
maturin develop

0 commit comments

Comments
 (0)