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.
Files changed (55) hide show
  1. bitemporalorm-0.1.0/.github/workflows/ci.yml +146 -0
  2. bitemporalorm-0.1.0/.gitignore +10 -0
  3. bitemporalorm-0.1.0/PKG-INFO +12 -0
  4. bitemporalorm-0.1.0/README.md +0 -0
  5. bitemporalorm-0.1.0/docs/api/connection.md +195 -0
  6. bitemporalorm-0.1.0/docs/api/entity.md +156 -0
  7. bitemporalorm-0.1.0/docs/api/fields.md +128 -0
  8. bitemporalorm-0.1.0/docs/api/filter.md +114 -0
  9. bitemporalorm-0.1.0/docs/api/index.md +33 -0
  10. bitemporalorm-0.1.0/docs/api/migrations.md +354 -0
  11. bitemporalorm-0.1.0/docs/concepts/bitemporal.md +93 -0
  12. bitemporalorm-0.1.0/docs/concepts/entities.md +148 -0
  13. bitemporalorm-0.1.0/docs/concepts/filter.md +180 -0
  14. bitemporalorm-0.1.0/docs/concepts/index.md +29 -0
  15. bitemporalorm-0.1.0/docs/concepts/migrations.md +232 -0
  16. bitemporalorm-0.1.0/docs/concepts/save.md +167 -0
  17. bitemporalorm-0.1.0/docs/concepts/tables.md +151 -0
  18. bitemporalorm-0.1.0/docs/getting-started.md +149 -0
  19. bitemporalorm-0.1.0/docs/index.md +94 -0
  20. bitemporalorm-0.1.0/docs/stylesheets/extra.css +43 -0
  21. bitemporalorm-0.1.0/docs/tutorial.md +264 -0
  22. bitemporalorm-0.1.0/examples/business_entity/__init__.py +0 -0
  23. bitemporalorm-0.1.0/examples/business_entity/main.py +115 -0
  24. bitemporalorm-0.1.0/examples/business_entity/models.py +26 -0
  25. bitemporalorm-0.1.0/examples/hierarchy/__init__.py +0 -0
  26. bitemporalorm-0.1.0/examples/hierarchy/main.py +106 -0
  27. bitemporalorm-0.1.0/examples/hierarchy/models.py +41 -0
  28. bitemporalorm-0.1.0/mkdocs.yml +55 -0
  29. bitemporalorm-0.1.0/pyproject.toml +71 -0
  30. bitemporalorm-0.1.0/src/bitemporalorm/__init__.py +37 -0
  31. bitemporalorm-0.1.0/src/bitemporalorm/cli/__init__.py +0 -0
  32. bitemporalorm-0.1.0/src/bitemporalorm/cli/main.py +136 -0
  33. bitemporalorm-0.1.0/src/bitemporalorm/connection/__init__.py +0 -0
  34. bitemporalorm-0.1.0/src/bitemporalorm/connection/config.py +29 -0
  35. bitemporalorm-0.1.0/src/bitemporalorm/connection/pool.py +56 -0
  36. bitemporalorm-0.1.0/src/bitemporalorm/entity.py +201 -0
  37. bitemporalorm-0.1.0/src/bitemporalorm/fields.py +167 -0
  38. bitemporalorm-0.1.0/src/bitemporalorm/migration/__init__.py +0 -0
  39. bitemporalorm-0.1.0/src/bitemporalorm/migration/differ.py +157 -0
  40. bitemporalorm-0.1.0/src/bitemporalorm/migration/loader.py +89 -0
  41. bitemporalorm-0.1.0/src/bitemporalorm/migration/ops.py +268 -0
  42. bitemporalorm-0.1.0/src/bitemporalorm/migration/runner.py +75 -0
  43. bitemporalorm-0.1.0/src/bitemporalorm/migration/state.py +90 -0
  44. bitemporalorm-0.1.0/src/bitemporalorm/migration/writer.py +101 -0
  45. bitemporalorm-0.1.0/src/bitemporalorm/query/__init__.py +0 -0
  46. bitemporalorm-0.1.0/src/bitemporalorm/query/builder.py +243 -0
  47. bitemporalorm-0.1.0/src/bitemporalorm/query/executor.py +308 -0
  48. bitemporalorm-0.1.0/src/bitemporalorm/registry.py +43 -0
  49. bitemporalorm-0.1.0/tests/__init__.py +0 -0
  50. bitemporalorm-0.1.0/tests/conftest.py +12 -0
  51. bitemporalorm-0.1.0/tests/test_entity.py +142 -0
  52. bitemporalorm-0.1.0/tests/test_fields.py +113 -0
  53. bitemporalorm-0.1.0/tests/test_migrations.py +297 -0
  54. bitemporalorm-0.1.0/tests/test_query.py +121 -0
  55. 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,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -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` |