Skip to content

Commit d578c0b

Browse files
authored
Merge pull request #111 from psqlpy-python/feature/add-support-datetime-with-zone-info
Add support datetime zone info
2 parents cecec4a + 5cba858 commit d578c0b

File tree

3 files changed

+117
-14
lines changed

3 files changed

+117
-14
lines changed

python/tests/test_value_converter.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import sys
23
import uuid
34
from decimal import Decimal
45
from enum import Enum
@@ -57,6 +58,7 @@
5758

5859
from tests.conftest import DefaultPydanticModel, DefaultPythonModelClass
5960

61+
uuid_ = uuid.uuid4()
6062
pytestmark = pytest.mark.anyio
6163
now_datetime = datetime.datetime.now() # noqa: DTZ005
6264
now_datetime_with_tz = datetime.datetime(
@@ -69,7 +71,30 @@
6971
142574,
7072
tzinfo=datetime.timezone.utc,
7173
)
72-
uuid_ = uuid.uuid4()
74+
75+
now_datetime_with_tz_in_asia_jakarta = datetime.datetime(
76+
2024,
77+
4,
78+
13,
79+
17,
80+
3,
81+
46,
82+
142574,
83+
tzinfo=datetime.timezone.utc,
84+
)
85+
if sys.version_info >= (3, 9):
86+
import zoneinfo
87+
88+
now_datetime_with_tz_in_asia_jakarta = datetime.datetime(
89+
2024,
90+
4,
91+
13,
92+
17,
93+
3,
94+
46,
95+
142574,
96+
tzinfo=zoneinfo.ZoneInfo(key="Asia/Jakarta"),
97+
)
7398

7499

75100
async def test_as_class(
@@ -125,6 +150,7 @@ async def test_as_class(
125150
("TIME", now_datetime.time(), now_datetime.time()),
126151
("TIMESTAMP", now_datetime, now_datetime),
127152
("TIMESTAMPTZ", now_datetime_with_tz, now_datetime_with_tz),
153+
("TIMESTAMPTZ", now_datetime_with_tz_in_asia_jakarta, now_datetime_with_tz_in_asia_jakarta),
128154
("UUID", uuid_, str(uuid_)),
129155
("INET", IPv4Address("192.0.0.1"), IPv4Address("192.0.0.1")),
130156
(
@@ -287,6 +313,11 @@ async def test_as_class(
287313
[now_datetime_with_tz, now_datetime_with_tz],
288314
[now_datetime_with_tz, now_datetime_with_tz],
289315
),
316+
(
317+
"TIMESTAMPTZ ARRAY",
318+
[now_datetime_with_tz, now_datetime_with_tz_in_asia_jakarta],
319+
[now_datetime_with_tz, now_datetime_with_tz_in_asia_jakarta],
320+
),
290321
(
291322
"TIMESTAMPTZ ARRAY",
292323
[[now_datetime_with_tz], [now_datetime_with_tz]],

src/driver/connection.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,6 @@ impl Connection {
127127

128128
#[pymethods]
129129
impl Connection {
130-
#[must_use]
131-
pub fn __aiter__(self_: Py<Self>) -> Py<Self> {
132-
self_
133-
}
134-
135-
fn __await__(self_: Py<Self>) -> Py<Self> {
136-
self_
137-
}
138-
139130
async fn __aenter__<'a>(self_: Py<Self>) -> RustPSQLDriverPyResult<Py<Self>> {
140131
let (db_client, db_pool) = pyo3::Python::with_gil(|gil| {
141132
let self_ = self_.borrow(gil);

src/value_converter.rs

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use chrono::{self, DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime};
1+
use chrono::{self, DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
2+
use chrono_tz::Tz;
23
use geo_types::{coord, Coord, Line as LineSegment, LineString, Point, Rect};
34
use itertools::Itertools;
45
use macaddr::{MacAddr6, MacAddr8};
@@ -626,8 +627,7 @@ impl ToSql for PythonDTO {
626627
#[allow(clippy::needless_pass_by_value)]
627628
pub fn convert_parameters(parameters: Py<PyAny>) -> RustPSQLDriverPyResult<Vec<PythonDTO>> {
628629
let mut result_vec: Vec<PythonDTO> = vec![];
629-
630-
result_vec = Python::with_gil(|gil| {
630+
Python::with_gil(|gil| {
631631
let params = parameters.extract::<Vec<Py<PyAny>>>(gil).map_err(|_| {
632632
RustPSQLDriverError::PyToRustValueConversionError(
633633
"Cannot convert you parameters argument into Rust type, please use List/Tuple"
@@ -637,8 +637,9 @@ pub fn convert_parameters(parameters: Py<PyAny>) -> RustPSQLDriverPyResult<Vec<P
637637
for parameter in params {
638638
result_vec.push(py_to_rust(parameter.bind(gil))?);
639639
}
640-
Ok::<Vec<PythonDTO>, RustPSQLDriverError>(result_vec)
640+
Ok::<(), RustPSQLDriverError>(())
641641
})?;
642+
642643
Ok(result_vec)
643644
}
644645

@@ -744,6 +745,81 @@ pub fn py_sequence_into_postgres_array(
744745
}
745746
}
746747

748+
/// Extract a value from a Python object, raising an error if missing or invalid
749+
///
750+
/// # Errors
751+
/// This function will return `Err` in the following cases:
752+
/// - The Python object does not have the specified attribute
753+
/// - The attribute exists but cannot be extracted into the specified Rust type
754+
fn extract_value_from_python_object_or_raise<'py, T>(
755+
parameter: &'py pyo3::Bound<'_, PyAny>,
756+
attr_name: &str,
757+
) -> Result<T, RustPSQLDriverError>
758+
where
759+
T: FromPyObject<'py>,
760+
{
761+
parameter
762+
.getattr(attr_name)
763+
.ok()
764+
.and_then(|attr| attr.extract::<T>().ok())
765+
.ok_or_else(|| {
766+
RustPSQLDriverError::PyToRustValueConversionError("Invalid attribute".into())
767+
})
768+
}
769+
770+
/// Extract a timezone-aware datetime from a Python object.
771+
/// This function retrieves various datetime components (`year`, `month`, `day`, etc.)
772+
/// from a Python object and constructs a `DateTime<FixedOffset>`
773+
///
774+
/// # Errors
775+
/// This function will return `Err` in the following cases:
776+
/// - The Python object does not contain or support one or more required datetime attributes
777+
/// - The retrieved values are invalid for constructing a date, time, or datetime (e.g., invalid month or day)
778+
/// - The timezone information (`tzinfo`) is not available or cannot be parsed
779+
/// - The resulting datetime is ambiguous or invalid (e.g., due to DST transitions)
780+
fn extract_datetime_from_python_object_attrs(
781+
parameter: &pyo3::Bound<'_, PyAny>,
782+
) -> Result<DateTime<FixedOffset>, RustPSQLDriverError> {
783+
let year = extract_value_from_python_object_or_raise::<i32>(parameter, "year")?;
784+
let month = extract_value_from_python_object_or_raise::<u32>(parameter, "month")?;
785+
let day = extract_value_from_python_object_or_raise::<u32>(parameter, "day")?;
786+
let hour = extract_value_from_python_object_or_raise::<u32>(parameter, "hour")?;
787+
let minute = extract_value_from_python_object_or_raise::<u32>(parameter, "minute")?;
788+
let second = extract_value_from_python_object_or_raise::<u32>(parameter, "second")?;
789+
let microsecond = extract_value_from_python_object_or_raise::<u32>(parameter, "microsecond")?;
790+
791+
let date = NaiveDate::from_ymd_opt(year, month, day)
792+
.ok_or_else(|| RustPSQLDriverError::PyToRustValueConversionError("Invalid date".into()))?;
793+
let time = NaiveTime::from_hms_micro_opt(hour, minute, second, microsecond)
794+
.ok_or_else(|| RustPSQLDriverError::PyToRustValueConversionError("Invalid time".into()))?;
795+
let naive_datetime = NaiveDateTime::new(date, time);
796+
797+
let raw_timestamp_tz = parameter
798+
.getattr("tzinfo")
799+
.ok()
800+
.and_then(|tzinfo| tzinfo.getattr("key").ok())
801+
.and_then(|key| key.extract::<String>().ok())
802+
.ok_or_else(|| {
803+
RustPSQLDriverError::PyToRustValueConversionError("Invalid timezone info".into())
804+
})?;
805+
806+
let fixed_offset_datetime = raw_timestamp_tz
807+
.parse::<Tz>()
808+
.map_err(|_| {
809+
RustPSQLDriverError::PyToRustValueConversionError("Failed to parse TZ".into())
810+
})?
811+
.from_local_datetime(&naive_datetime)
812+
.single()
813+
.ok_or_else(|| {
814+
RustPSQLDriverError::PyToRustValueConversionError(
815+
"Ambiguous or invalid datetime".into(),
816+
)
817+
})?
818+
.fixed_offset();
819+
820+
Ok(fixed_offset_datetime)
821+
}
822+
747823
/// Convert single python parameter to `PythonDTO` enum.
748824
///
749825
/// # Errors
@@ -849,6 +925,11 @@ pub fn py_to_rust(parameter: &pyo3::Bound<'_, PyAny>) -> RustPSQLDriverPyResult<
849925
return Ok(PythonDTO::PyDateTime(pydatetime_no_tz));
850926
}
851927

928+
let timestamp_tz = extract_datetime_from_python_object_attrs(parameter);
929+
if let Ok(pydatetime_tz) = timestamp_tz {
930+
return Ok(PythonDTO::PyDateTimeTz(pydatetime_tz));
931+
}
932+
852933
return Err(RustPSQLDriverError::PyToRustValueConversionError(
853934
"Can not convert you datetime to rust type".into(),
854935
));

0 commit comments

Comments
 (0)