bitemporalorm 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bitemporalorm-0.1.0/.github/workflows/ci.yml +146 -0
- bitemporalorm-0.1.0/.gitignore +10 -0
- bitemporalorm-0.1.0/PKG-INFO +12 -0
- bitemporalorm-0.1.0/README.md +0 -0
- bitemporalorm-0.1.0/docs/api/connection.md +195 -0
- bitemporalorm-0.1.0/docs/api/entity.md +156 -0
- bitemporalorm-0.1.0/docs/api/fields.md +128 -0
- bitemporalorm-0.1.0/docs/api/filter.md +114 -0
- bitemporalorm-0.1.0/docs/api/index.md +33 -0
- bitemporalorm-0.1.0/docs/api/migrations.md +354 -0
- bitemporalorm-0.1.0/docs/concepts/bitemporal.md +93 -0
- bitemporalorm-0.1.0/docs/concepts/entities.md +148 -0
- bitemporalorm-0.1.0/docs/concepts/filter.md +180 -0
- bitemporalorm-0.1.0/docs/concepts/index.md +29 -0
- bitemporalorm-0.1.0/docs/concepts/migrations.md +232 -0
- bitemporalorm-0.1.0/docs/concepts/save.md +167 -0
- bitemporalorm-0.1.0/docs/concepts/tables.md +151 -0
- bitemporalorm-0.1.0/docs/getting-started.md +149 -0
- bitemporalorm-0.1.0/docs/index.md +94 -0
- bitemporalorm-0.1.0/docs/stylesheets/extra.css +43 -0
- bitemporalorm-0.1.0/docs/tutorial.md +264 -0
- bitemporalorm-0.1.0/examples/business_entity/__init__.py +0 -0
- bitemporalorm-0.1.0/examples/business_entity/main.py +115 -0
- bitemporalorm-0.1.0/examples/business_entity/models.py +26 -0
- bitemporalorm-0.1.0/examples/hierarchy/__init__.py +0 -0
- bitemporalorm-0.1.0/examples/hierarchy/main.py +106 -0
- bitemporalorm-0.1.0/examples/hierarchy/models.py +41 -0
- bitemporalorm-0.1.0/mkdocs.yml +55 -0
- bitemporalorm-0.1.0/pyproject.toml +71 -0
- bitemporalorm-0.1.0/src/bitemporalorm/__init__.py +37 -0
- bitemporalorm-0.1.0/src/bitemporalorm/cli/__init__.py +0 -0
- bitemporalorm-0.1.0/src/bitemporalorm/cli/main.py +136 -0
- bitemporalorm-0.1.0/src/bitemporalorm/connection/__init__.py +0 -0
- bitemporalorm-0.1.0/src/bitemporalorm/connection/config.py +29 -0
- bitemporalorm-0.1.0/src/bitemporalorm/connection/pool.py +56 -0
- bitemporalorm-0.1.0/src/bitemporalorm/entity.py +201 -0
- bitemporalorm-0.1.0/src/bitemporalorm/fields.py +167 -0
- bitemporalorm-0.1.0/src/bitemporalorm/migration/__init__.py +0 -0
- bitemporalorm-0.1.0/src/bitemporalorm/migration/differ.py +157 -0
- bitemporalorm-0.1.0/src/bitemporalorm/migration/loader.py +89 -0
- bitemporalorm-0.1.0/src/bitemporalorm/migration/ops.py +268 -0
- bitemporalorm-0.1.0/src/bitemporalorm/migration/runner.py +75 -0
- bitemporalorm-0.1.0/src/bitemporalorm/migration/state.py +90 -0
- bitemporalorm-0.1.0/src/bitemporalorm/migration/writer.py +101 -0
- bitemporalorm-0.1.0/src/bitemporalorm/query/__init__.py +0 -0
- bitemporalorm-0.1.0/src/bitemporalorm/query/builder.py +243 -0
- bitemporalorm-0.1.0/src/bitemporalorm/query/executor.py +308 -0
- bitemporalorm-0.1.0/src/bitemporalorm/registry.py +43 -0
- bitemporalorm-0.1.0/tests/__init__.py +0 -0
- bitemporalorm-0.1.0/tests/conftest.py +12 -0
- bitemporalorm-0.1.0/tests/test_entity.py +142 -0
- bitemporalorm-0.1.0/tests/test_fields.py +113 -0
- bitemporalorm-0.1.0/tests/test_migrations.py +297 -0
- bitemporalorm-0.1.0/tests/test_query.py +121 -0
- bitemporalorm-0.1.0/uv.lock +930 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*.*.*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Lint — ruff (format + lint) + mypy
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
lint:
|
|
15
|
+
name: Lint
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
|
|
24
|
+
- name: Install uv
|
|
25
|
+
uses: astral-sh/setup-uv@v3
|
|
26
|
+
|
|
27
|
+
- name: Install dev dependencies
|
|
28
|
+
run: uv sync --group dev
|
|
29
|
+
|
|
30
|
+
- name: ruff format check
|
|
31
|
+
run: uv run ruff format --check src/ tests/
|
|
32
|
+
|
|
33
|
+
- name: ruff lint
|
|
34
|
+
run: uv run ruff check src/ tests/
|
|
35
|
+
|
|
36
|
+
- name: mypy
|
|
37
|
+
run: uv run mypy src/bitemporalorm/
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Test
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
test:
|
|
43
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
strategy:
|
|
46
|
+
fail-fast: false
|
|
47
|
+
matrix:
|
|
48
|
+
python-version: ["3.12", "3.13"]
|
|
49
|
+
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
|
|
53
|
+
- uses: actions/setup-python@v5
|
|
54
|
+
with:
|
|
55
|
+
python-version: ${{ matrix.python-version }}
|
|
56
|
+
|
|
57
|
+
- name: Install uv
|
|
58
|
+
uses: astral-sh/setup-uv@v3
|
|
59
|
+
|
|
60
|
+
- name: Install dependencies
|
|
61
|
+
run: uv sync --group dev
|
|
62
|
+
|
|
63
|
+
- name: Run tests
|
|
64
|
+
run: uv run pytest tests/ -v --tb=short
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Build docs — only on main push
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
docs:
|
|
70
|
+
name: Build & deploy docs
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
needs: [lint, test]
|
|
73
|
+
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
74
|
+
permissions:
|
|
75
|
+
contents: write
|
|
76
|
+
|
|
77
|
+
steps:
|
|
78
|
+
- uses: actions/checkout@v4
|
|
79
|
+
with:
|
|
80
|
+
fetch-depth: 0
|
|
81
|
+
|
|
82
|
+
- uses: actions/setup-python@v5
|
|
83
|
+
with:
|
|
84
|
+
python-version: "3.12"
|
|
85
|
+
|
|
86
|
+
- run: pip install mkdocs-material
|
|
87
|
+
|
|
88
|
+
- run: mkdocs gh-deploy --force
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Build package — wheel + sdist
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
build:
|
|
94
|
+
name: Build package
|
|
95
|
+
runs-on: ubuntu-latest
|
|
96
|
+
needs: [lint, test]
|
|
97
|
+
|
|
98
|
+
steps:
|
|
99
|
+
- uses: actions/checkout@v4
|
|
100
|
+
|
|
101
|
+
- uses: actions/setup-python@v5
|
|
102
|
+
with:
|
|
103
|
+
python-version: "3.12"
|
|
104
|
+
|
|
105
|
+
- name: Install uv
|
|
106
|
+
uses: astral-sh/setup-uv@v3
|
|
107
|
+
|
|
108
|
+
- name: Build wheel and sdist
|
|
109
|
+
run: uv build
|
|
110
|
+
|
|
111
|
+
- name: Upload dist artifacts
|
|
112
|
+
uses: actions/upload-artifact@v4
|
|
113
|
+
with:
|
|
114
|
+
name: dist
|
|
115
|
+
path: dist/
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Publish to PyPI — only on version tags (v*)
|
|
119
|
+
#
|
|
120
|
+
# Uses OIDC Trusted Publishing (no API token needed).
|
|
121
|
+
# One-time setup on PyPI:
|
|
122
|
+
# 1. Go to https://pypi.org/manage/account/publishing/
|
|
123
|
+
# 2. Add a new publisher:
|
|
124
|
+
# Owner: kalyansahoo
|
|
125
|
+
# Repository: bitemporalorm
|
|
126
|
+
# Workflow: ci.yml
|
|
127
|
+
# Environment: pypi
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
publish:
|
|
130
|
+
name: Publish to PyPI
|
|
131
|
+
runs-on: ubuntu-latest
|
|
132
|
+
needs: [build]
|
|
133
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
134
|
+
environment: pypi
|
|
135
|
+
permissions:
|
|
136
|
+
id-token: write # required for OIDC trusted publishing
|
|
137
|
+
|
|
138
|
+
steps:
|
|
139
|
+
- name: Download dist artifacts
|
|
140
|
+
uses: actions/download-artifact@v4
|
|
141
|
+
with:
|
|
142
|
+
name: dist
|
|
143
|
+
path: dist/
|
|
144
|
+
|
|
145
|
+
- name: Publish to PyPI
|
|
146
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bitemporalorm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bitemporal data storage ORM for PostgreSQL with Polars DataFrames
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: asyncpg>=0.30
|
|
7
|
+
Requires-Dist: connectorx>=0.3
|
|
8
|
+
Requires-Dist: polars>=1.0
|
|
9
|
+
Requires-Dist: psycopg2-binary>=2.9
|
|
10
|
+
Requires-Dist: rich>=13.0
|
|
11
|
+
Requires-Dist: sqlglot>=25.0
|
|
12
|
+
Requires-Dist: typer>=0.12
|
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Connection
|
|
2
|
+
|
|
3
|
+
## `ConnectionConfig`
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from bitemporalorm import ConnectionConfig
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Holds connection parameters for both the async write path (asyncpg) and the bulk read path (connectorx).
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
@dataclass
|
|
13
|
+
class ConnectionConfig:
|
|
14
|
+
host: str = "localhost"
|
|
15
|
+
port: int = 5432
|
|
16
|
+
database: str = "postgres"
|
|
17
|
+
user: str = "postgres"
|
|
18
|
+
password: str = ""
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Derived properties
|
|
22
|
+
|
|
23
|
+
| Property | Format | Used by |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `asyncpg_dsn` | `postgresql://user:pass@host:port/db` | `AsyncPool` |
|
|
26
|
+
| `connectorx_uri` | `postgresql://user:pass@host:port/db` | `DBExecutor.read_as_dataframe()` |
|
|
27
|
+
| `psycopg2_dsn` | `host=... port=... dbname=... user=... password=...` | CLI / sync helpers |
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
config = ConnectionConfig(
|
|
31
|
+
host="localhost",
|
|
32
|
+
port=5432,
|
|
33
|
+
database="mydb",
|
|
34
|
+
user="postgres",
|
|
35
|
+
password="secret",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
print(config.asyncpg_dsn)
|
|
39
|
+
# postgresql://postgres:secret@localhost:5432/mydb
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## `AsyncPool`
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from bitemporalorm.connection.pool import AsyncPool
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Thin wrapper around an asyncpg connection pool.
|
|
51
|
+
|
|
52
|
+
### Constructor
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
AsyncPool(config: ConnectionConfig)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Methods
|
|
59
|
+
|
|
60
|
+
| Method | Description |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `await connect()` | Open the connection pool |
|
|
63
|
+
| `await disconnect()` | Close the connection pool |
|
|
64
|
+
| `await execute(sql, *args)` | Execute a statement, discard result |
|
|
65
|
+
| `await fetch(sql, *args)` | Fetch all rows as a list of `asyncpg.Record` |
|
|
66
|
+
| `await fetchrow(sql, *args)` | Fetch one row (or `None`) |
|
|
67
|
+
| `await fetchval(sql, *args)` | Fetch the first column of the first row |
|
|
68
|
+
| `await execute_ddl(sql)` | Execute a DDL statement (no parameters) |
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
pool = AsyncPool(config)
|
|
72
|
+
await pool.connect()
|
|
73
|
+
|
|
74
|
+
rows = await pool.fetch("SELECT id FROM business_entity WHERE id = $1", 42)
|
|
75
|
+
await pool.execute("DELETE FROM business_entity WHERE id = $1", 42)
|
|
76
|
+
|
|
77
|
+
await pool.disconnect()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Context manager
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
async with AsyncPool(config) as pool:
|
|
84
|
+
rows = await pool.fetch("SELECT id FROM business_entity")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## `DBExecutor`
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from bitemporalorm import DBExecutor
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
High-level executor that handles both writes (asyncpg) and bulk reads (connectorx). All `Entity.save()` and `Entity.filter()` calls are delegated to a `DBExecutor`.
|
|
96
|
+
|
|
97
|
+
### Constructor
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
DBExecutor(pool: AsyncPool)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Methods
|
|
104
|
+
|
|
105
|
+
#### `save_entity(entity_cls, df)`
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
async def save_entity(
|
|
109
|
+
entity_cls: type[Entity],
|
|
110
|
+
df: pl.DataFrame,
|
|
111
|
+
) -> pl.DataFrame
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Persists a DataFrame of bitemporal events. Delegates to the internal save pipeline:
|
|
115
|
+
|
|
116
|
+
1. Allocate `entity_id` for rows without one.
|
|
117
|
+
2. Insert audit rows for each field column present in `df`.
|
|
118
|
+
3. Run the split-and-reinsert materialized update for each field.
|
|
119
|
+
|
|
120
|
+
Returns `df` with `entity_id` populated.
|
|
121
|
+
|
|
122
|
+
#### `read_as_dataframe(sql)`
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
def read_as_dataframe(sql: str) -> pl.DataFrame
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Executes `sql` via connectorx and returns a Polars DataFrame. Used by `Entity.filter()`.
|
|
129
|
+
|
|
130
|
+
!!! note
|
|
131
|
+
This is a synchronous method. connectorx manages its own thread pool internally.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Global executor registry
|
|
136
|
+
|
|
137
|
+
Executors are registered globally by alias so that `Entity.save()` and `Entity.filter()` can resolve the correct executor without being passed one explicitly.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from bitemporalorm import register_executor, get_executor
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### `register_executor(executor, alias="default")`
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
def register_executor(executor: DBExecutor, alias: str = "default") -> None
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Register `executor` under `alias`. The alias `"default"` is used unless overridden.
|
|
150
|
+
|
|
151
|
+
### `get_executor(alias="default")`
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
def get_executor(alias: str = "default") -> DBExecutor
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Retrieve a registered executor. Raises `KeyError` if `alias` is not registered.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Typical setup
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
import asyncio
|
|
165
|
+
from bitemporalorm import ConnectionConfig, DBExecutor, register_executor
|
|
166
|
+
from bitemporalorm.connection.pool import AsyncPool
|
|
167
|
+
|
|
168
|
+
async def startup():
|
|
169
|
+
config = ConnectionConfig(
|
|
170
|
+
host="localhost",
|
|
171
|
+
database="mydb",
|
|
172
|
+
user="postgres",
|
|
173
|
+
password="secret",
|
|
174
|
+
)
|
|
175
|
+
pool = AsyncPool(config)
|
|
176
|
+
await pool.connect()
|
|
177
|
+
|
|
178
|
+
executor = DBExecutor(pool)
|
|
179
|
+
register_executor(executor) # registered as "default"
|
|
180
|
+
|
|
181
|
+
async def shutdown():
|
|
182
|
+
get_executor().pool.disconnect()
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
After `register_executor()`, all `Entity.save()` and `Entity.filter()` calls will use the default executor automatically.
|
|
186
|
+
|
|
187
|
+
### Multiple databases
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
register_executor(executor_a, alias="primary")
|
|
191
|
+
register_executor(executor_b, alias="replica")
|
|
192
|
+
|
|
193
|
+
# Entity.filter() uses "default" unless you override it at the Entity level
|
|
194
|
+
register_executor(executor_a) # also register as default
|
|
195
|
+
```
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Entity
|
|
2
|
+
|
|
3
|
+
## class `Entity`
|
|
4
|
+
|
|
5
|
+
Base class for all bitemporal entities. Subclass it and declare fields as type annotations.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from bitemporalorm import Entity, ManyToOneField, OneToOneField, OneToManyField
|
|
9
|
+
|
|
10
|
+
class BusinessEntity(Entity):
|
|
11
|
+
city: ManyToOneField[str]
|
|
12
|
+
phone_number: OneToOneField[str]
|
|
13
|
+
director: OneToManyField[str]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
### `Entity.save()` — persist events
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
@classmethod
|
|
22
|
+
async def save(cls, df: pl.DataFrame) -> pl.DataFrame
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Persists a DataFrame of bitemporal events. For each row, inserts into the audit table and updates the materialized table. Returns the DataFrame with `entity_id` populated.
|
|
26
|
+
|
|
27
|
+
**Required columns**
|
|
28
|
+
|
|
29
|
+
| Column | Type | Description |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `as_of_start` | `datetime` | Start of the valid-time range |
|
|
32
|
+
|
|
33
|
+
**Optional columns**
|
|
34
|
+
|
|
35
|
+
| Column | Type | Default | Description |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| `as_of_end` | `datetime` | `infinity` | End of the valid-time range (exclusive) |
|
|
38
|
+
| `entity_id` | `int` | `None` | Entity to update. Absent = insert new entity. |
|
|
39
|
+
| `parent_entity_id` | `int` | — | For child entities: ID of the parent entity instance |
|
|
40
|
+
| `<field_name>` | varies | — | Any declared field. Only fields present in df are saved. |
|
|
41
|
+
|
|
42
|
+
**Returns** — `pl.DataFrame` with `entity_id` populated for all rows.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# Insert (entity_id absent → auto-assigned)
|
|
46
|
+
df = await BusinessEntity.save(pl.DataFrame({
|
|
47
|
+
"as_of_start": [datetime(2020, 1, 1, tzinfo=timezone.utc)],
|
|
48
|
+
"city": ["London"],
|
|
49
|
+
"phone_number": ["+44123456789"],
|
|
50
|
+
"director": ["Alice Smith"],
|
|
51
|
+
}))
|
|
52
|
+
entity_id = df["entity_id"][0]
|
|
53
|
+
|
|
54
|
+
# Update (provide entity_id)
|
|
55
|
+
await BusinessEntity.save(pl.DataFrame({
|
|
56
|
+
"entity_id": [entity_id],
|
|
57
|
+
"as_of_start": [datetime(2023, 1, 1, tzinfo=timezone.utc)],
|
|
58
|
+
"city": ["Manchester"],
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
# Bounded range (e.g. temporary change)
|
|
62
|
+
await BusinessEntity.save(pl.DataFrame({
|
|
63
|
+
"entity_id": [entity_id],
|
|
64
|
+
"as_of_start": [datetime(2021, 1, 1, tzinfo=timezone.utc)],
|
|
65
|
+
"as_of_end": [datetime(2022, 1, 1, tzinfo=timezone.utc)],
|
|
66
|
+
"city": ["Bristol"],
|
|
67
|
+
}))
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### `Entity.filter()` — point-in-time query
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
@classmethod
|
|
76
|
+
async def filter(cls, as_of: datetime, *exprs: pl.Expr) -> pl.DataFrame
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Returns a Polars DataFrame with the state of all entities at `as_of`.
|
|
80
|
+
|
|
81
|
+
**Parameters**
|
|
82
|
+
|
|
83
|
+
| Parameter | Type | Description |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `as_of` | `datetime` | Point-in-time to query. Required. |
|
|
86
|
+
| `*exprs` | `pl.Expr` | Additional Polars filter expressions (AND-combined) |
|
|
87
|
+
|
|
88
|
+
**Returns** — `pl.DataFrame` with columns `entity_id` + one column per declared field (own + inherited for child entities).
|
|
89
|
+
|
|
90
|
+
For `OneToManyField`: one row per `(entity, value)` combination (exploded).
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
# All entities as of a date
|
|
94
|
+
df = await BusinessEntity.filter(
|
|
95
|
+
as_of=datetime(2022, 6, 1, tzinfo=timezone.utc),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# With Polars expression filters
|
|
99
|
+
df = await BusinessEntity.filter(
|
|
100
|
+
as_of=datetime(2022, 6, 1, tzinfo=timezone.utc),
|
|
101
|
+
pl.col("city") == "London",
|
|
102
|
+
pl.col("director").is_not_null(),
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## class `EntityOptions`
|
|
109
|
+
|
|
110
|
+
Attached to every `Entity` subclass as `cls._meta`. Do not instantiate directly.
|
|
111
|
+
|
|
112
|
+
### Attributes
|
|
113
|
+
|
|
114
|
+
| Attribute | Type | Description |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `table_name` | `str` | Database table name |
|
|
117
|
+
| `fields` | `dict[str, FieldSpec]` | Own fields only (not inherited) |
|
|
118
|
+
| `parent_entity` | `type[Entity] \| None` | Direct parent entity class |
|
|
119
|
+
|
|
120
|
+
### Methods
|
|
121
|
+
|
|
122
|
+
| Method | Returns | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `all_fields()` | `dict[str, FieldSpec]` | Own fields + all inherited fields (flattened) |
|
|
125
|
+
| `hierarchy()` | `list[type[Entity]]` | `[parent, grandparent, ...]` walking up the chain |
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
RegionalOffice._meta.table_name # "regional_office"
|
|
129
|
+
RegionalOffice._meta.fields # {"branch_code": ..., "head_count": ...}
|
|
130
|
+
RegionalOffice._meta.all_fields() # {"city": ..., "phone_number": ..., "director": ...,
|
|
131
|
+
# "branch_code": ..., "head_count": ...}
|
|
132
|
+
RegionalOffice._meta.parent_entity # BusinessEntity
|
|
133
|
+
RegionalOffice._meta.hierarchy() # [BusinessEntity]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## class `EntityRegistry`
|
|
139
|
+
|
|
140
|
+
Global singleton registry. Available as `bitemporalorm.registry`.
|
|
141
|
+
|
|
142
|
+
| Method | Description |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `register(entity)` | Register a class (called automatically by `EntityMeta`) |
|
|
145
|
+
| `get(name: str)` | Look up by class name. Raises `LookupError` if not found. |
|
|
146
|
+
| `all()` | Return all registered classes |
|
|
147
|
+
| `clear()` | Remove all registrations (useful in tests) |
|
|
148
|
+
| `snapshot()` | Return a copy of the current registry dict |
|
|
149
|
+
| `restore(snap)` | Restore registry from a snapshot |
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from bitemporalorm import registry
|
|
153
|
+
|
|
154
|
+
registry.get("BusinessEntity") # → BusinessEntity class
|
|
155
|
+
registry.all() # → [BusinessEntity, RegionalOffice, ...]
|
|
156
|
+
```
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Fields
|
|
2
|
+
|
|
3
|
+
## `ManyToOneField`
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from bitemporalorm import ManyToOneField
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Many entities can share the same value; each entity has one value at any given time.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
class BusinessEntity(Entity):
|
|
13
|
+
city: ManyToOneField[str]
|
|
14
|
+
score: ManyToOneField[int]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- Exclusion constraint: **enforced** — no two rows for the same entity with overlapping `as_of`
|
|
18
|
+
- Relationship type: `"many_to_one"`
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## `OneToOneField`
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from bitemporalorm import OneToOneField
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Exactly one value per entity at any given time; semantically stricter than `ManyToOneField`.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
class BusinessEntity(Entity):
|
|
32
|
+
phone_number: OneToOneField[str]
|
|
33
|
+
registration_number: OneToOneField[str]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- Exclusion constraint: **enforced**
|
|
37
|
+
- Relationship type: `"one_to_one"`
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## `OneToManyField`
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from bitemporalorm import OneToManyField
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Multiple values per entity at the same time. Query results are exploded (one row per value).
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
class BusinessEntity(Entity):
|
|
51
|
+
director: OneToManyField[str]
|
|
52
|
+
tag: OneToManyField[str]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- Exclusion constraint: **not enforced** — multiple simultaneous values allowed
|
|
56
|
+
- Relationship type: `"one_to_many"`
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Annotation syntax
|
|
61
|
+
|
|
62
|
+
### Class-getitem (recommended)
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
city: ManyToOneField[str] # primitive
|
|
66
|
+
country: ManyToOneField["Country"] # entity reference (string forward ref)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Direct instantiation
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
city: ManyToOneField = ManyToOneField(str)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Type argument mapping
|
|
78
|
+
|
|
79
|
+
| Python type | SQL column type |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `str` | `TEXT` |
|
|
82
|
+
| `int` | `BIGINT` |
|
|
83
|
+
| `float` | `DOUBLE PRECISION` |
|
|
84
|
+
| `datetime.datetime` | `TIMESTAMPTZ` |
|
|
85
|
+
| `"EntityName"` (string) | `BIGINT` (FK to entity table) |
|
|
86
|
+
| `EntityClass` (direct ref) | `BIGINT` (FK to entity table) |
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## `FieldType` enum
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from bitemporalorm import FieldType
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| Member | SQL type |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `FieldType.TEXT` | `TEXT` |
|
|
99
|
+
| `FieldType.INT` | `BIGINT` |
|
|
100
|
+
| `FieldType.FLOAT` | `DOUBLE PRECISION` |
|
|
101
|
+
| `FieldType.DATETIME` | `TIMESTAMPTZ` |
|
|
102
|
+
| `FieldType.ENTITY_REF` | `BIGINT` |
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## class `FieldSpec`
|
|
107
|
+
|
|
108
|
+
Resolved field metadata attached to `EntityOptions.fields`. Read-only.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
fspec = BusinessEntity._meta.fields["city"]
|
|
112
|
+
|
|
113
|
+
fspec.name # "city"
|
|
114
|
+
fspec.relationship # RelationshipType.MANY_TO_ONE
|
|
115
|
+
fspec.sql_type # FieldType.TEXT
|
|
116
|
+
fspec.sql_type_str # "TEXT"
|
|
117
|
+
fspec.entity_ref # None (or "Country" for entity-reference fields)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Attributes
|
|
121
|
+
|
|
122
|
+
| Attribute | Type | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `name` | `str` | Field name (matches annotation key) |
|
|
125
|
+
| `relationship` | `RelationshipType` | `MANY_TO_ONE`, `ONE_TO_ONE`, or `ONE_TO_MANY` |
|
|
126
|
+
| `sql_type` | `FieldType` | SQL column type enum |
|
|
127
|
+
| `sql_type_str` | `str` | SQL type string (e.g. `"TEXT"`) |
|
|
128
|
+
| `entity_ref` | `str \| None` | Referenced entity class name, or `None` |
|