Skip to content

Commit

Permalink
implement FROM-TO
Browse files Browse the repository at this point in the history
  • Loading branch information
jerrinot committed Jan 14, 2025
1 parent badcba0 commit b4a3b21
Show file tree
Hide file tree
Showing 3 changed files with 116 additions and 1 deletion.
6 changes: 6 additions & 0 deletions src/questdb_connect/compilers.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ def visit_sample_by(self, sample_by, **kw):
else:
text = f"SAMPLE BY {sample_by.value}"

if sample_by.from_timestamp:
# Format datetime to ISO format that QuestDB expects
text += f" FROM '{sample_by.from_timestamp.isoformat()}'"
if sample_by.to_timestamp:
text += f" TO '{sample_by.to_timestamp.isoformat()}'"

# Add FILL if specified
if sample_by.fill is not None:
if isinstance(sample_by.fill, str):
Expand Down
9 changes: 8 additions & 1 deletion src/questdb_connect/dml.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from datetime import datetime, date
from typing import TYPE_CHECKING, Any, Optional, Sequence, Union

from sqlalchemy import select as sa_select
Expand All @@ -24,13 +25,17 @@ def __init__(
align_to: str = "CALENDAR", # default per docs
timezone: Optional[str] = None,
offset: Optional[str] = None,
from_timestamp: Optional[Union[datetime, date]] = None,
to_timestamp: Optional[Union[datetime, date]] = None
):
self.value = value
self.unit = unit.lower() if unit else None
self.fill = fill
self.align_to = align_to.upper()
self.timezone = timezone
self.offset = offset
self.from_timestamp = from_timestamp
self.to_timestamp = to_timestamp

def __str__(self) -> str:
if self.unit:
Expand Down Expand Up @@ -67,6 +72,8 @@ def sample_by(
align_to: str = "CALENDAR",
timezone: Optional[str] = None,
offset: Optional[str] = None,
from_timestamp: Optional[Union[datetime, date]] = None,
to_timestamp: Optional[Union[datetime, date]] = None,
) -> QDBSelect:
"""Add a SAMPLE BY clause.
Expand All @@ -84,7 +91,7 @@ def sample_by(

# Set the sample by clause
s._sample_by_clause = SampleByClause(
value, unit, fill, align_to, timezone, offset
value, unit, fill, align_to, timezone, offset, from_timestamp, to_timestamp
)
return s

Expand Down
102 changes: 102 additions & 0 deletions tests/test_dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,108 @@ def test_bulk_insert(test_engine, test_model):
assert collect_select_all_raw_connection(test_engine, expected_rows=num_rows) == expected


def test_sample_by_from_to(test_engine, test_model):
"""Test SAMPLE BY with FROM-TO extension."""
base_ts = datetime.datetime(2023, 4, 12, 0, 0, 0)
day_before = base_ts - datetime.timedelta(days=1)
day_after = base_ts + datetime.timedelta(days=1)
session = Session(test_engine)
try:
num_rows = 6 # 6 hours only
models = [
test_model(
col_int=idx,
col_ts=base_ts + datetime.timedelta(hours=idx),
) for idx in range(num_rows)
]

session.bulk_save_objects(models)
session.commit()

metadata = sqla.MetaData()
table = sqla.Table(ALL_TYPES_TABLE_NAME, metadata, autoload_with=test_engine)
wait_until_table_is_ready(test_engine, ALL_TYPES_TABLE_NAME, len(models))

with test_engine.connect() as conn:
# Test FROM-TO with FILL
query = (
questdb_connect.select(
table.c.col_ts,
sqla.func.avg(table.c.col_int).label('avg_int')
)
.sample_by(
1, 'h',
fill="NULL",
from_timestamp=day_before, # day before data starts
to_timestamp=day_after # day after data ends
)
)
result = conn.execute(query)
rows = result.fetchall()

assert len(rows) == 48 # 48 hours in total

# First rows should be NULL (before our data starts)
assert rows[0].avg_int is None
assert rows[1].avg_int is None
assert rows[2].avg_int is None
assert rows[3].avg_int is None

# Middle rows should have data
assert any(row.avg_int is not None for row in rows[4:-4])

# Last rows should be NULL (after our data ends)
assert rows[-4].avg_int is None
assert rows[-3].avg_int is None
assert rows[-2].avg_int is None
assert rows[-1].avg_int is None

# Test FROM only
query = (
questdb_connect.select(
table.c.col_ts,
sqla.func.avg(table.c.col_int).label('avg_int')
)
.sample_by(
1, 'h',
fill="NULL",
from_timestamp=day_before # day before data starts
)
)
result = conn.execute(query)
rows = result.fetchall()

# First rows should be NULL
assert rows[0].avg_int is None
assert rows[1].avg_int is None
assert rows[2].avg_int is None
assert rows[3].avg_int is None

# Test TO only
query = (
questdb_connect.select(
table.c.col_ts,
sqla.func.avg(table.c.col_int).label('avg_int')
)
.sample_by(
1, 'h',
fill="NULL",
to_timestamp=day_after # day after data ends
)
)
result = conn.execute(query)
rows = result.fetchall()

# Last rows should be NULL
assert rows[-4].avg_int is None
assert rows[-3].avg_int is None
assert rows[-2].avg_int is None
assert rows[-1].avg_int is None

finally:
if session:
session.close()

def test_sample_by_options(test_engine, test_model):
"""Test SAMPLE BY with ALIGN TO and FILL options."""
base_ts = datetime.datetime(2023, 4, 12, 0, 0, 0)
Expand Down

0 comments on commit b4a3b21

Please sign in to comment.