Skip to content

Commit 27ddc59

Browse files
committed
Updates to hypertable creation, compression, retention
1 parent a582a07 commit 27ddc59

23 files changed

Lines changed: 540 additions & 112 deletions

README.md

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,101 @@ Looking for Django? [Check out django-timescaledb](https://github.com/jamessewel
1010
pip install timescaledb
1111
```
1212

13-
## Usage
13+
## Quickstart
1414

15+
The timescaledb python package provides helpers for creating hypertables, configuring compression, retention policies, and more.
1516

16-
### Create a TimescaleDB Model
17+
## Two ways to create a TimescaleDB Model
1718

18-
The `TimescaleModel` class inherits from `SQLModel` but incldues a `time` field for TimescaleDB hypertables and an `id` field for primary keys.
19+
- Automatically via `TimescaleModel`
20+
- Manually via `create_hypertable` on any table with a `time` column
21+
22+
Let's take a look at the manual way first.
23+
24+
25+
### Manually Create a Hypertable
1926

20-
`models.py`
27+
```python
28+
from sqlmodel import create_engine, Field, SQLModel
29+
import timescaledb
30+
31+
TIMESCALE_DATABASE_URL = "postgresql://user:password@localhost:5432/timescaledb"
32+
engine = create_engine(TIMESCALE_DATABASE_URL)
33+
34+
class Sensor(SQLModel, table=True):
35+
id: int = Field(default=None, primary_key=True)
36+
time: datetime = Field(default=None, primary_key=True)
37+
sensor_id: int = Field(index=True)
38+
value: float
39+
40+
__tablename__ = "my_time_series_table"
41+
42+
43+
hypertable_options = {
44+
"time_column": "time",
45+
"compress_orderby": "time DESC",
46+
"compress_segmentby": "sensor_id",
47+
"chunk_time_interval": "7 days",
48+
"drop_after": "1 year",
49+
"migrate_data": True,
50+
"if_not_exists": True,
51+
}
52+
53+
# Create the table and the hypertable
54+
with Session(engine) as session:
55+
# Create the table in the database
56+
SQLModel.metadata.create_all(engine)
57+
# Create the hypertable
58+
table_name="my_time_series_table",
59+
timescaledb.create_hypertable(session, commit=True, table_name=table_name, hypertable_options=hypertable_options)
60+
# Add compression policy
61+
timescaledb.add_compression_policy(session, commit=True, table_name=table_name, interval="7 days")
62+
# Add retention policy
63+
timescaledb.add_retention_policy(session, commit=True, table_name=table_name, drop_after=hypertable_options.get('drop_after'))
64+
```
65+
66+
67+
### Automatically via `TimescaleModel`
68+
69+
70+
```python
71+
from sqlmodel import Field
72+
73+
import timescaledb
74+
from timescaledb import create_engine, TimescaleModel
75+
76+
TIMESCALE_DATABASE_URL = "postgresql://user:password@localhost:5432/timescaledb"
77+
engine = create_engine(TIMESCALE_DATABASE_URL, timezone="UTC")
78+
79+
class SensorDos(TimescaleModel, table=True):
80+
sensor_id: int = Field(index=True)
81+
value: float
82+
83+
__enable_compression__ = True
84+
__compress_orderby__ = "time DESC"
85+
__compress_segmentby__ = "sensor_id"
86+
__chunk_time_interval__ = "7 days"
87+
__drop_after__ = "1 year"
88+
__migrate_data__ = True
89+
__if_not_exists__ = True
90+
91+
92+
# Create the table and the hypertable
93+
with Session(engine) as session:
94+
# Create the table in the database
95+
SQLModel.metadata.create_all(engine)
96+
# Creates all hypertable, add compression policies, and add retention policy
97+
timescaledb.metadata.create_all(engine)
98+
```
99+
100+
101+
102+
103+
## Sample Usage
104+
105+
Below is a sample of using `timescaledb` in a FastAPI app much like the example in [./sample_project](./sample_project).
106+
107+
`src/models.py`
21108
```python
22109
from sqlmodel import Field, SQLModel
23110

@@ -27,6 +114,10 @@ from timescaledb import TimescaleModel
27114
class Metric(TimescaleModel, table=True):
28115
temp: float
29116

117+
__enable_compression__ = True
118+
__chunk_time_interval__ = "2 weeks"
119+
__drop_after__ = "1 year"
120+
30121

31122
class MetricCreate(Metric):
32123
# not a table but a Pydantic model
@@ -45,7 +136,7 @@ class MetricRead(Metric):
45136

46137
The `timescaledb.create_engine` is a wrapper around `sqlmodel.create_engine` (which is a wrapper around `sqlalchemy.create_engine`) that ensures a timezone is set for your database.
47138

48-
`database.py`
139+
`src/database.py`
49140
```python
50141
import timescaledb
51142
from sqlmodel import Session, SQLModel
@@ -78,7 +169,7 @@ def init_db():
78169

79170
Put it all together in a FastAPI app.
80171

81-
`main.py`
172+
`src/main.py`
82173
```python
83174
from fastapi import FastAPI
84175

@@ -114,14 +205,3 @@ def list_metrics(session: Session = Depends(get_session)):
114205
return metrics
115206
```
116207

117-
### Review the Sample Project
118-
119-
120-
In [./sample_project](./sample_project) you can review a complete example that includes:
121-
- A TimescaleDB model
122-
- A FastAPI app
123-
- Sample queries (`time_bucket_gapfill_query`, `time_bucket_query`)
124-
125-
126-
127-

src/timescaledb/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from .models import TimescaleModel
1414
from .queries import time_bucket_gapfill_query, time_bucket_query
15+
from .retention import add_retention_policy
1516

1617
__all__ = [
1718
"metadata",

src/timescaledb/cleaners.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from datetime import timedelta
2+
3+
4+
def clean_interval(interval: str | int | timedelta) -> tuple[str | int, bool]:
5+
"""
6+
Given an interval, return the interval type and a valid internal value
7+
8+
Args:
9+
interval: The interval to extract the type and value from
10+
11+
Returns:
12+
If valid, a tuple containing the interval type and a valid internal value
13+
If invalid, the original interval and the string "INVALID"
14+
"""
15+
if isinstance(interval, timedelta):
16+
# Convert to microseconds
17+
cleaned_interval = int(interval.total_seconds())
18+
return cleaned_interval, "INTEGER"
19+
elif isinstance(interval, int):
20+
# Microseconds
21+
cleaned_interval = interval
22+
return cleaned_interval, "INTEGER"
23+
elif isinstance(interval, str):
24+
# Such as INTERVAL 1 day
25+
# or INTERVAL '2 weeks'
26+
# pop the term "INTERVAL"
27+
cleaned_interval = interval.replace("INTERVAL", "").strip()
28+
# remove any extra quotes
29+
cleaned_interval = cleaned_interval.replace("'", "").replace('"', "")
30+
return cleaned_interval, "INTERVAL"
31+
else:
32+
return interval, "INVALID"

src/timescaledb/compression/add.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313

1414
def add_compression_policy(
15-
session: Session, model: Type[SQLModel], commit: bool = True
15+
session: Session, model: Type[SQLModel], commit: bool = True, auto_enable=False
1616
) -> None:
1717
"""
1818
Enable compression for a hypertable

src/timescaledb/hypertables/chunks/__init__.py

Lines changed: 0 additions & 3 deletions
This file was deleted.

src/timescaledb/hypertables/chunks/show.py

Lines changed: 0 additions & 23 deletions
This file was deleted.

src/timescaledb/hypertables/chunks/sql_statements.py

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/timescaledb/hypertables/create.py

Lines changed: 72 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,62 +4,88 @@
44
import sqlalchemy
55
from sqlmodel import Session, SQLModel
66

7-
from timescaledb.hypertables import sql_statements as sql
8-
from timescaledb.hypertables import validators
7+
from timescaledb.hypertables.extractors import extract_model_hypertable_params
8+
from timescaledb.hypertables.schemas import HypertableCreateSchema
99

10-
HYPERTABLE_INTERVAL_TYPE_SQL = {
11-
"INTERVAL": sql.CREATE_HYPERTABLE_SQL_VIA_INTERVAL,
12-
"TIMESTAMP": sql.CREATE_HYPERTABLE_SQL_VIA_TIMESTAMP,
13-
}
10+
# from timescaledb.hypertables.schemas import HypertableParams
1411

1512

1613
def create_hypertable(
1714
session: Session,
18-
model: Type[SQLModel],
19-
if_not_exists: bool = True,
20-
migrate_data: bool = True,
2115
commit: bool = True,
16+
model: Type[SQLModel] = None,
17+
table_name: str = None,
18+
hypertable_options: dict = {
19+
"if_not_exists": True,
20+
"migrate_data": True,
21+
},
22+
overwrite_model_params: bool = False,
2223
) -> None:
23-
"""
24-
Create a hypertable from a SQLModel class
25-
"""
26-
time_column = getattr(model, "__time_column__", None)
27-
validators.validate_time_column(model, time_column)
28-
interval = getattr(model, "__chunk_time_interval__", None)
29-
validators.validate_chunk_time_interval(model, time_column, interval)
30-
sql_template = None
31-
cleaned_interval = None
32-
if isinstance(interval, timedelta):
33-
# Convert to microseconds
34-
cleaned_interval = int(interval.total_seconds())
35-
sql_template = HYPERTABLE_INTERVAL_TYPE_SQL["TIMESTAMP"]
36-
elif isinstance(interval, int):
37-
# Microseconds
38-
cleaned_interval = interval
39-
sql_template = HYPERTABLE_INTERVAL_TYPE_SQL["TIMESTAMP"]
40-
elif isinstance(interval, str):
41-
# Such as INTERVAL 1 day
42-
# or INTERVAL '2 weeks'
43-
# pop the term "INTERVAL"
44-
cleaned_interval = interval.replace("INTERVAL", "").strip()
45-
# remove any extra quotes
46-
cleaned_interval = cleaned_interval.replace("'", "").replace('"', "")
47-
sql_template = HYPERTABLE_INTERVAL_TYPE_SQL["INTERVAL"]
48-
else:
49-
raise ValueError("Invalid interval type")
50-
if sql_template is None or cleaned_interval is None:
51-
raise ValueError("Invalid interval type")
24+
"""Create a TimescaleDB hypertable from a SQLModel class.
25+
26+
This function converts a regular table into a TimescaleDB hypertable. Hypertable parameters
27+
can be specified either through model configuration or directly via hypertable_options.
28+
29+
Args:
30+
session (Session): SQLAlchemy session instance
31+
commit (bool, optional): Whether to commit the transaction after creating the hypertable.
32+
Defaults to True.
33+
model (Type[SQLModel]): The SQLModel class to convert into a hypertable. Must have
34+
hypertable parameters defined either in the model or via hypertable_options.
35+
table_name (str, optional): Override the table name. If None, uses the model's table name.
36+
hypertable_options (dict, optional): Additional hypertable configuration options. Defaults to:
37+
{
38+
"if_not_exists": True, # Skip if hypertable already exists
39+
"migrate_data": True, # Migrate existing data to the hypertable
40+
}
41+
overwrite_model_params (bool, optional): If True, hypertable_options will override any
42+
conflicting parameters defined in the model. If False, model parameters take precedence.
43+
Defaults to False.
44+
45+
Raises:
46+
ValueError: If model parameter is None.
5247
53-
table_name = getattr(model, "__tablename__", None)
48+
Example:
49+
```python
50+
from sqlmodel import SQLModel
51+
52+
class Metrics(SQLModel, table=True):
53+
__hypertable_params__ = {
54+
"time_column": "timestamp",
55+
"chunk_time_interval": timedelta(days=7)
56+
}
57+
# ... model fields ...
58+
59+
# Create hypertable using model parameters
60+
create_hypertable(session, model=Metrics)
61+
62+
# Create hypertable with custom options
63+
create_hypertable(
64+
session,
65+
model=Metrics,
66+
hypertable_options={
67+
"chunk_time_interval": timedelta(days=1),
68+
"if_not_exists": True
69+
},
70+
overwrite_model_params=True
71+
)
72+
```
73+
"""
74+
if model is None and table_name is None:
75+
raise ValueError("model or table_name is required")
5476
params = {
77+
"model": None,
5578
"table_name": table_name,
56-
"time_column": time_column,
57-
"chunk_time_interval": cleaned_interval,
58-
"if_not_exists": "true" if if_not_exists else "false",
59-
"migrate_data": "true" if migrate_data else "false",
79+
**hypertable_options,
6080
}
61-
query = sqlalchemy.text(sql_template).bindparams(**params)
62-
compiled_query = str(query.compile(compile_kwargs={"literal_binds": True}))
63-
session.execute(sqlalchemy.text(compiled_query))
81+
if model is not None:
82+
model_params = extract_model_hypertable_params(model)
83+
params = {**model_params}
84+
if overwrite_model_params:
85+
params.update(**hypertable_options)
86+
87+
schema = HypertableCreateSchema(**params)
88+
query = schema.to_sql_query()
89+
session.execute(sqlalchemy.text(query))
6490
if commit:
6591
session.commit()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Type
2+
3+
from sqlmodel import SQLModel
4+
5+
6+
def extract_model_hypertable_params(
7+
model: Type[SQLModel],
8+
) -> dict:
9+
"""
10+
Format the SQL query based on the model's retention policy
11+
12+
Returns:
13+
str: The formatted SQL query ready for execution
14+
"""
15+
time_column = getattr(model, "__time_column__", None)
16+
chunk_time_interval = getattr(model, "__chunk_time_interval__", None)
17+
table_name = getattr(model, "__tablename__", None)
18+
if_not_exists = getattr(model, "__if_not_exists__", False)
19+
migrate_data = getattr(model, "__migrate_data__", False)
20+
return {
21+
"model": model,
22+
"table_name": table_name,
23+
"time_column": time_column,
24+
"chunk_time_interval": chunk_time_interval,
25+
"if_not_exists": if_not_exists,
26+
"migrate_data": migrate_data,
27+
}

0 commit comments

Comments
 (0)