orm-loader 0.3.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.
- orm_loader-0.3.0/PKG-INFO +162 -0
- orm_loader-0.3.0/README.md +149 -0
- orm_loader-0.3.0/pyproject.toml +37 -0
- orm_loader-0.3.0/src/orm_loader/__init__.py +0 -0
- orm_loader-0.3.0/src/orm_loader/helpers/__init__.py +23 -0
- orm_loader-0.3.0/src/orm_loader/helpers/bootstrap.py +13 -0
- orm_loader-0.3.0/src/orm_loader/helpers/bulk.py +90 -0
- orm_loader-0.3.0/src/orm_loader/helpers/discovery.py +11 -0
- orm_loader-0.3.0/src/orm_loader/helpers/errors.py +6 -0
- orm_loader-0.3.0/src/orm_loader/helpers/logging.py +90 -0
- orm_loader-0.3.0/src/orm_loader/helpers/metadata.py +15 -0
- orm_loader-0.3.0/src/orm_loader/helpers/sqlite.py +32 -0
- orm_loader-0.3.0/src/orm_loader/loaders/__init__.py +14 -0
- orm_loader-0.3.0/src/orm_loader/loaders/data_classes.py +147 -0
- orm_loader-0.3.0/src/orm_loader/loaders/loader_interface.py +274 -0
- orm_loader-0.3.0/src/orm_loader/loaders/loading_helpers.py +136 -0
- orm_loader-0.3.0/src/orm_loader/py.typed +0 -0
- orm_loader-0.3.0/src/orm_loader/registry/__init__.py +67 -0
- orm_loader-0.3.0/src/orm_loader/registry/registry.py +167 -0
- orm_loader-0.3.0/src/orm_loader/registry/validation.py +230 -0
- orm_loader-0.3.0/src/orm_loader/registry/validation_presets.py +14 -0
- orm_loader-0.3.0/src/orm_loader/registry/validation_report.py +88 -0
- orm_loader-0.3.0/src/orm_loader/registry/validation_runner.py +36 -0
- orm_loader-0.3.0/src/orm_loader/tables/__init__.py +25 -0
- orm_loader-0.3.0/src/orm_loader/tables/base/__init__.py +15 -0
- orm_loader-0.3.0/src/orm_loader/tables/base/allocators.py +22 -0
- orm_loader-0.3.0/src/orm_loader/tables/base/loadable_table.py +420 -0
- orm_loader-0.3.0/src/orm_loader/tables/base/orm_table.py +76 -0
- orm_loader-0.3.0/src/orm_loader/tables/base/serialisable_table.py +48 -0
- orm_loader-0.3.0/src/orm_loader/tables/base/typing.py +129 -0
- orm_loader-0.3.0/src/orm_loader/tables/data/__init__.py +7 -0
- orm_loader-0.3.0/src/orm_loader/tables/data/converters.py +106 -0
- orm_loader-0.3.0/src/orm_loader/tables/data/data_type_management.py +110 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: orm-loader
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Generic base classes to handle ORM functionality for multiple downstream datamodels
|
|
5
|
+
Author: gkennos
|
|
6
|
+
Author-email: gkennos <georgina.kennedy@unsw.edu.au>
|
|
7
|
+
Requires-Dist: chardet>=5.2.0
|
|
8
|
+
Requires-Dist: pandas>=2.3.3
|
|
9
|
+
Requires-Dist: pyarrow>=23.0.0
|
|
10
|
+
Requires-Dist: sqlalchemy>=2.0.45
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
## orm-loader
|
|
15
|
+
|
|
16
|
+
A lightweight, reusable foundation for building and validating SQLAlchemy-based clinical (and non-clinical) data models.
|
|
17
|
+
|
|
18
|
+
This library provides general-purpose ORM infrastructure that sits below any specific data model (OMOP, PCORnet, custom CDMs, etc.), focusing on:
|
|
19
|
+
|
|
20
|
+
* declarative base configuration
|
|
21
|
+
* bulk ingestion patterns
|
|
22
|
+
* file-based validation & loading
|
|
23
|
+
* table introspection
|
|
24
|
+
* model-agnostic validation scaffolding
|
|
25
|
+
* safe, database-portable operational helpers
|
|
26
|
+
|
|
27
|
+
It intentionally contains no domain logic and no assumptions about a specific schema.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### What this library provides:
|
|
31
|
+
|
|
32
|
+
This library provides a small set of composable building blocks for defining, loading, inspecting, and validating SQLAlchemy-based data models.
|
|
33
|
+
All components are model-agnostic and can be selectively combined in downstream libraries.
|
|
34
|
+
|
|
35
|
+
1. A minimal, opinionated ORM table base
|
|
36
|
+
|
|
37
|
+
ORMTableBase provides structural introspection utilities for SQLAlchemy-mapped tables, without imposing any domain semantics.
|
|
38
|
+
|
|
39
|
+
It supports:
|
|
40
|
+
* mapper access and inspection
|
|
41
|
+
* primary key discovery
|
|
42
|
+
* required (non-nullable) column detection
|
|
43
|
+
* consistent primary key handling across models
|
|
44
|
+
* simple ID allocation helpers for sequence-less databases
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from orm_loader.tables import ORMTableBase
|
|
48
|
+
|
|
49
|
+
class MyTable(ORMTableBase, Base):
|
|
50
|
+
__tablename__ = "my_table"
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
This base is intended to be inherited by all ORM tables, either directly or via higher-level mixins.
|
|
54
|
+
|
|
55
|
+
2. CSV-based ingestion mixins
|
|
56
|
+
|
|
57
|
+
CSVLoadableTableInterface adds opt-in CSV loading support for ORM tables using pandas, with a focus on correctness and scalability.
|
|
58
|
+
|
|
59
|
+
Features include:
|
|
60
|
+
* chunked loading for large files
|
|
61
|
+
* optional per-table normalisation logic
|
|
62
|
+
* optional deduplication against existing database rows
|
|
63
|
+
* safe bulk inserts using SQLAlchemy sessions
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
class MyTable(CSVLoadableTableInterface, ORMTableBase, Base):
|
|
67
|
+
__tablename__ = "my_table"
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Downstream models may override:
|
|
72
|
+
* normalise_dataframe(...)
|
|
73
|
+
* dedupe_dataframe(...)
|
|
74
|
+
* csv_columns()
|
|
75
|
+
to implement table-specific ingestion policies.
|
|
76
|
+
|
|
77
|
+
3. Structured serialisation and hashing
|
|
78
|
+
|
|
79
|
+
SerialisableTableInterface adds lightweight, explicit serialisation helpers for ORM rows.
|
|
80
|
+
|
|
81
|
+
It supports:
|
|
82
|
+
* conversion to dictionaries
|
|
83
|
+
* JSON serialisation
|
|
84
|
+
* stable row-level fingerprints
|
|
85
|
+
* iterator-style access to field/value pairs
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
row = session.get(MyTable, 1)
|
|
89
|
+
row.to_dict()
|
|
90
|
+
row.to_json()
|
|
91
|
+
row.fingerprint()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This is useful for:
|
|
95
|
+
|
|
96
|
+
* debugging
|
|
97
|
+
* auditing
|
|
98
|
+
* reproducibility checks
|
|
99
|
+
* downstream APIs or exports
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
4. Model registry and validation scaffolding
|
|
103
|
+
|
|
104
|
+
The library includes model-agnostic validation infrastructure, designed to compare ORM models against external specifications.
|
|
105
|
+
|
|
106
|
+
This includes:
|
|
107
|
+
* a model registry
|
|
108
|
+
* table and field descriptors
|
|
109
|
+
* validator contracts
|
|
110
|
+
* a validation runner
|
|
111
|
+
* structured validation reports
|
|
112
|
+
Specifications can be loaded from CSV today, with support for other formats (e.g. LinkML) planned.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
registry = ModelRegistry(model_version="1.0")
|
|
116
|
+
registry.load_table_specs(table_csv, field_csv)
|
|
117
|
+
registry.register_models([MyTable])
|
|
118
|
+
|
|
119
|
+
runner = ValidationRunner(validators=always_on_validators())
|
|
120
|
+
report = runner.run(registry)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Validation output is available as:
|
|
124
|
+
* human-readable text
|
|
125
|
+
* structured dictionaries
|
|
126
|
+
* JSON (for CI/CD integration)
|
|
127
|
+
* exit codes suitable for pipelines
|
|
128
|
+
|
|
129
|
+
5. Database bootstrap helpers
|
|
130
|
+
The library provides lightweight helpers for schema creation and bootstrapping, without imposing a migration strategy.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from orm_loader.metadata import Base
|
|
134
|
+
from orm_loader.bootstrap import bootstrap
|
|
135
|
+
|
|
136
|
+
bootstrap(engine, create=True)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
6. Safe bulk-loading utilities
|
|
140
|
+
|
|
141
|
+
A reusable context manager simplifies trusted bulk ingestion workflows:
|
|
142
|
+
* temporarily disables foreign key checks where supported
|
|
143
|
+
* suppresses autoflush for performance
|
|
144
|
+
* ensures reliable rollback on failure
|
|
145
|
+
|
|
146
|
+
## Summary
|
|
147
|
+
|
|
148
|
+
This library intentionally focuses on infrastructure, not semantics.
|
|
149
|
+
|
|
150
|
+
It provides:
|
|
151
|
+
* reusable ORM mixins
|
|
152
|
+
* safe ingestion patterns
|
|
153
|
+
* validation scaffolding
|
|
154
|
+
* database-portable utilities
|
|
155
|
+
|
|
156
|
+
while leaving domain rules, business logic, and schema semantics to downstream libraries.
|
|
157
|
+
|
|
158
|
+
This makes it suitable as a shared foundation for:
|
|
159
|
+
* clinical data models
|
|
160
|
+
* research data marts
|
|
161
|
+
* registry schemas
|
|
162
|
+
* synthetic data pipelines
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
## orm-loader
|
|
2
|
+
|
|
3
|
+
A lightweight, reusable foundation for building and validating SQLAlchemy-based clinical (and non-clinical) data models.
|
|
4
|
+
|
|
5
|
+
This library provides general-purpose ORM infrastructure that sits below any specific data model (OMOP, PCORnet, custom CDMs, etc.), focusing on:
|
|
6
|
+
|
|
7
|
+
* declarative base configuration
|
|
8
|
+
* bulk ingestion patterns
|
|
9
|
+
* file-based validation & loading
|
|
10
|
+
* table introspection
|
|
11
|
+
* model-agnostic validation scaffolding
|
|
12
|
+
* safe, database-portable operational helpers
|
|
13
|
+
|
|
14
|
+
It intentionally contains no domain logic and no assumptions about a specific schema.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### What this library provides:
|
|
18
|
+
|
|
19
|
+
This library provides a small set of composable building blocks for defining, loading, inspecting, and validating SQLAlchemy-based data models.
|
|
20
|
+
All components are model-agnostic and can be selectively combined in downstream libraries.
|
|
21
|
+
|
|
22
|
+
1. A minimal, opinionated ORM table base
|
|
23
|
+
|
|
24
|
+
ORMTableBase provides structural introspection utilities for SQLAlchemy-mapped tables, without imposing any domain semantics.
|
|
25
|
+
|
|
26
|
+
It supports:
|
|
27
|
+
* mapper access and inspection
|
|
28
|
+
* primary key discovery
|
|
29
|
+
* required (non-nullable) column detection
|
|
30
|
+
* consistent primary key handling across models
|
|
31
|
+
* simple ID allocation helpers for sequence-less databases
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from orm_loader.tables import ORMTableBase
|
|
35
|
+
|
|
36
|
+
class MyTable(ORMTableBase, Base):
|
|
37
|
+
__tablename__ = "my_table"
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
This base is intended to be inherited by all ORM tables, either directly or via higher-level mixins.
|
|
41
|
+
|
|
42
|
+
2. CSV-based ingestion mixins
|
|
43
|
+
|
|
44
|
+
CSVLoadableTableInterface adds opt-in CSV loading support for ORM tables using pandas, with a focus on correctness and scalability.
|
|
45
|
+
|
|
46
|
+
Features include:
|
|
47
|
+
* chunked loading for large files
|
|
48
|
+
* optional per-table normalisation logic
|
|
49
|
+
* optional deduplication against existing database rows
|
|
50
|
+
* safe bulk inserts using SQLAlchemy sessions
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
class MyTable(CSVLoadableTableInterface, ORMTableBase, Base):
|
|
54
|
+
__tablename__ = "my_table"
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Downstream models may override:
|
|
59
|
+
* normalise_dataframe(...)
|
|
60
|
+
* dedupe_dataframe(...)
|
|
61
|
+
* csv_columns()
|
|
62
|
+
to implement table-specific ingestion policies.
|
|
63
|
+
|
|
64
|
+
3. Structured serialisation and hashing
|
|
65
|
+
|
|
66
|
+
SerialisableTableInterface adds lightweight, explicit serialisation helpers for ORM rows.
|
|
67
|
+
|
|
68
|
+
It supports:
|
|
69
|
+
* conversion to dictionaries
|
|
70
|
+
* JSON serialisation
|
|
71
|
+
* stable row-level fingerprints
|
|
72
|
+
* iterator-style access to field/value pairs
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
row = session.get(MyTable, 1)
|
|
76
|
+
row.to_dict()
|
|
77
|
+
row.to_json()
|
|
78
|
+
row.fingerprint()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This is useful for:
|
|
82
|
+
|
|
83
|
+
* debugging
|
|
84
|
+
* auditing
|
|
85
|
+
* reproducibility checks
|
|
86
|
+
* downstream APIs or exports
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
4. Model registry and validation scaffolding
|
|
90
|
+
|
|
91
|
+
The library includes model-agnostic validation infrastructure, designed to compare ORM models against external specifications.
|
|
92
|
+
|
|
93
|
+
This includes:
|
|
94
|
+
* a model registry
|
|
95
|
+
* table and field descriptors
|
|
96
|
+
* validator contracts
|
|
97
|
+
* a validation runner
|
|
98
|
+
* structured validation reports
|
|
99
|
+
Specifications can be loaded from CSV today, with support for other formats (e.g. LinkML) planned.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
registry = ModelRegistry(model_version="1.0")
|
|
103
|
+
registry.load_table_specs(table_csv, field_csv)
|
|
104
|
+
registry.register_models([MyTable])
|
|
105
|
+
|
|
106
|
+
runner = ValidationRunner(validators=always_on_validators())
|
|
107
|
+
report = runner.run(registry)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Validation output is available as:
|
|
111
|
+
* human-readable text
|
|
112
|
+
* structured dictionaries
|
|
113
|
+
* JSON (for CI/CD integration)
|
|
114
|
+
* exit codes suitable for pipelines
|
|
115
|
+
|
|
116
|
+
5. Database bootstrap helpers
|
|
117
|
+
The library provides lightweight helpers for schema creation and bootstrapping, without imposing a migration strategy.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from orm_loader.metadata import Base
|
|
121
|
+
from orm_loader.bootstrap import bootstrap
|
|
122
|
+
|
|
123
|
+
bootstrap(engine, create=True)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
6. Safe bulk-loading utilities
|
|
127
|
+
|
|
128
|
+
A reusable context manager simplifies trusted bulk ingestion workflows:
|
|
129
|
+
* temporarily disables foreign key checks where supported
|
|
130
|
+
* suppresses autoflush for performance
|
|
131
|
+
* ensures reliable rollback on failure
|
|
132
|
+
|
|
133
|
+
## Summary
|
|
134
|
+
|
|
135
|
+
This library intentionally focuses on infrastructure, not semantics.
|
|
136
|
+
|
|
137
|
+
It provides:
|
|
138
|
+
* reusable ORM mixins
|
|
139
|
+
* safe ingestion patterns
|
|
140
|
+
* validation scaffolding
|
|
141
|
+
* database-portable utilities
|
|
142
|
+
|
|
143
|
+
while leaving domain rules, business logic, and schema semantics to downstream libraries.
|
|
144
|
+
|
|
145
|
+
This makes it suitable as a shared foundation for:
|
|
146
|
+
* clinical data models
|
|
147
|
+
* research data marts
|
|
148
|
+
* registry schemas
|
|
149
|
+
* synthetic data pipelines
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "orm-loader"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Generic base classes to handle ORM functionality for multiple downstream datamodels"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "gkennos", email = "georgina.kennedy@unsw.edu.au" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"chardet>=5.2.0",
|
|
12
|
+
"pandas>=2.3.3",
|
|
13
|
+
"pyarrow>=23.0.0",
|
|
14
|
+
"sqlalchemy>=2.0.45",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.9.2,<0.10.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"mypy>=1.19.1",
|
|
24
|
+
"pytest>=9.0.2",
|
|
25
|
+
"ruff>=0.14.11",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = ["orm_loader"]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
line-length = 100
|
|
33
|
+
target-version = "py311"
|
|
34
|
+
|
|
35
|
+
[tool.mypy]
|
|
36
|
+
python_version = "3.11"
|
|
37
|
+
strict = true
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .errors import IngestError, ValidationError
|
|
2
|
+
from .logging import get_logger, configure_logging
|
|
3
|
+
from .bootstrap import bootstrap, create_db
|
|
4
|
+
from .sqlite import enable_sqlite_foreign_keys, explain_sqlite_fk_error
|
|
5
|
+
from .bulk import bulk_load_context, engine_with_replica_role
|
|
6
|
+
from .metadata import Base
|
|
7
|
+
from .discovery import get_model_by_tablename
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"IngestError",
|
|
12
|
+
"ValidationError",
|
|
13
|
+
"get_logger",
|
|
14
|
+
"configure_logging",
|
|
15
|
+
"bootstrap",
|
|
16
|
+
"create_db",
|
|
17
|
+
"enable_sqlite_foreign_keys",
|
|
18
|
+
"explain_sqlite_fk_error",
|
|
19
|
+
"bulk_load_context",
|
|
20
|
+
"engine_with_replica_role",
|
|
21
|
+
"Base",
|
|
22
|
+
"get_model_by_tablename",
|
|
23
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .metadata import Base
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
def create_db(engine):
|
|
7
|
+
logger.debug("Creating database schema")
|
|
8
|
+
Base.metadata.create_all(engine)
|
|
9
|
+
|
|
10
|
+
def bootstrap(engine, *, create: bool = True):
|
|
11
|
+
logger.info("Bootstrapping schema (create=%s)", create)
|
|
12
|
+
if create:
|
|
13
|
+
create_db(engine)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from sqlalchemy import text, Engine
|
|
3
|
+
from sqlalchemy.orm import Session
|
|
4
|
+
import sqlalchemy as sa
|
|
5
|
+
from .logging import get_logger
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
@contextmanager
|
|
10
|
+
def bulk_load_context(
|
|
11
|
+
session: Session,
|
|
12
|
+
*,
|
|
13
|
+
disable_fk: bool = True,
|
|
14
|
+
no_autoflush: bool = True,
|
|
15
|
+
):
|
|
16
|
+
engine = session.get_bind()
|
|
17
|
+
dialect = engine.dialect.name
|
|
18
|
+
fk_disabled = False
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
if disable_fk:
|
|
22
|
+
if dialect == "postgresql":
|
|
23
|
+
session.execute(text(
|
|
24
|
+
"SET session_replication_role = replica"
|
|
25
|
+
))
|
|
26
|
+
fk_disabled = True
|
|
27
|
+
elif dialect == "sqlite":
|
|
28
|
+
session.execute(text("PRAGMA foreign_keys = OFF"))
|
|
29
|
+
fk_disabled = True
|
|
30
|
+
|
|
31
|
+
logger.info("Disabled foreign key checks for bulk load")
|
|
32
|
+
|
|
33
|
+
if no_autoflush:
|
|
34
|
+
with session.no_autoflush:
|
|
35
|
+
yield
|
|
36
|
+
else:
|
|
37
|
+
yield
|
|
38
|
+
|
|
39
|
+
except Exception:
|
|
40
|
+
session.rollback()
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
finally:
|
|
44
|
+
if fk_disabled:
|
|
45
|
+
if dialect == "postgresql":
|
|
46
|
+
session.execute(text(
|
|
47
|
+
"SET session_replication_role = DEFAULT"
|
|
48
|
+
))
|
|
49
|
+
elif dialect == "sqlite":
|
|
50
|
+
session.execute(text("PRAGMA foreign_keys = ON"))
|
|
51
|
+
|
|
52
|
+
logger.info("Re-enabled foreign key checks after bulk load")
|
|
53
|
+
|
|
54
|
+
@contextmanager
|
|
55
|
+
def engine_with_replica_role(engine: Engine):
|
|
56
|
+
"""
|
|
57
|
+
Context manager that:
|
|
58
|
+
- forces session_replication_role=replica on all connections
|
|
59
|
+
- restores DEFAULT on exit
|
|
60
|
+
|
|
61
|
+
this is different to bulk_load_context manager from orm_loader.helpers
|
|
62
|
+
because this is engine scoped where that one is session scoped
|
|
63
|
+
|
|
64
|
+
postgres only
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
@sa.event.listens_for(engine, "connect") # type: ignore
|
|
68
|
+
def _set_replica_role(dbapi_conn, _):
|
|
69
|
+
cur = dbapi_conn.cursor()
|
|
70
|
+
cur.execute("SET session_replication_role = replica")
|
|
71
|
+
cur.close()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
yield engine
|
|
75
|
+
finally:
|
|
76
|
+
# Explicitly restore on a fresh connection
|
|
77
|
+
with engine.connect() as conn:
|
|
78
|
+
conn = conn.execution_options(isolation_level="AUTOCOMMIT")
|
|
79
|
+
conn.execute(text("SET session_replication_role = DEFAULT"))
|
|
80
|
+
|
|
81
|
+
role = conn.execute(
|
|
82
|
+
text("SHOW session_replication_role")
|
|
83
|
+
).scalar()
|
|
84
|
+
|
|
85
|
+
if role != "origin":
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
"Failed to restore session_replication_role"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
logger.info("session_replication_role restored to DEFAULT")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
from .metadata import Base
|
|
3
|
+
|
|
4
|
+
def get_model_by_tablename(tablename: str, base: Type[Base] | None = None) -> Type | None:
|
|
5
|
+
tablename = tablename.lower().strip()
|
|
6
|
+
if base is None:
|
|
7
|
+
base = Base
|
|
8
|
+
for cls in base.__subclasses__():
|
|
9
|
+
if getattr(cls, "__tablename__", None) == tablename:
|
|
10
|
+
return cls
|
|
11
|
+
return None
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Literal, Optional
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
SENSITIVE_KEYS = {
|
|
7
|
+
"password",
|
|
8
|
+
"passwd",
|
|
9
|
+
"secret",
|
|
10
|
+
"token",
|
|
11
|
+
"key",
|
|
12
|
+
"dsn",
|
|
13
|
+
"uri",
|
|
14
|
+
"url",
|
|
15
|
+
}
|
|
16
|
+
LOGGING_NAMESPACE = "sql_loader"
|
|
17
|
+
|
|
18
|
+
def _coerce_log_level(level: int | str) -> int:
|
|
19
|
+
if isinstance(level, int):
|
|
20
|
+
return level
|
|
21
|
+
|
|
22
|
+
if isinstance(level, str):
|
|
23
|
+
s = level.strip().upper()
|
|
24
|
+
if s.isdigit():
|
|
25
|
+
return int(s)
|
|
26
|
+
|
|
27
|
+
mapping = logging.getLevelNamesMapping()
|
|
28
|
+
if s in mapping:
|
|
29
|
+
return mapping[s]
|
|
30
|
+
|
|
31
|
+
raise ValueError(f"Invalid log level: {level!r}")
|
|
32
|
+
|
|
33
|
+
raise TypeError(f"Invalid log level type: {type(level)}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_logger(name: Optional[str] = None) -> logging.Logger:
|
|
37
|
+
"""
|
|
38
|
+
Return a namespaced logger.
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
get_logger() -> sql_loader
|
|
42
|
+
get_logger("loadable_table") -> sql_loader.loadable_table
|
|
43
|
+
"""
|
|
44
|
+
full_name = LOGGING_NAMESPACE if name is None else f"{LOGGING_NAMESPACE}.{name}"
|
|
45
|
+
return logging.getLogger(full_name)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RedactingFormatter(logging.Formatter):
|
|
49
|
+
def __init__(self, *args, **kwargs):
|
|
50
|
+
super().__init__(*args, **kwargs)
|
|
51
|
+
self._pattern = re.compile(
|
|
52
|
+
r"(?i)\\b(" + "|".join(SENSITIVE_KEYS) + r")\\b\\s*[:=]\\s*[^\\s,;]+"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def format(self, record):
|
|
56
|
+
msg = super().format(record)
|
|
57
|
+
return self._pattern.sub(r"\\1=<REDACTED>", msg)
|
|
58
|
+
|
|
59
|
+
def configure_logging(
|
|
60
|
+
*,
|
|
61
|
+
level: int | str = logging.INFO,
|
|
62
|
+
handler: Optional[logging.Handler] = None,
|
|
63
|
+
format: Optional[str] = None,
|
|
64
|
+
propagate: bool = True,
|
|
65
|
+
redact: bool = True,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Enable logging output for omop_alchemy.
|
|
69
|
+
|
|
70
|
+
Safe to call multiple times.
|
|
71
|
+
"""
|
|
72
|
+
logger = get_logger()
|
|
73
|
+
logger.setLevel(_coerce_log_level(level))
|
|
74
|
+
|
|
75
|
+
if handler is None:
|
|
76
|
+
handler = logging.StreamHandler()
|
|
77
|
+
|
|
78
|
+
if format is None:
|
|
79
|
+
format = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
|
80
|
+
|
|
81
|
+
formatter_cls = RedactingFormatter if redact else logging.Formatter
|
|
82
|
+
handler.setFormatter(formatter_cls(format))
|
|
83
|
+
|
|
84
|
+
if not any(isinstance(h, type(handler)) for h in logger.handlers):
|
|
85
|
+
logger.addHandler(handler)
|
|
86
|
+
|
|
87
|
+
logger.propagate = propagate
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
logging.getLogger(LOGGING_NAMESPACE).addHandler(logging.NullHandler())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from sqlalchemy import MetaData
|
|
2
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
3
|
+
|
|
4
|
+
NAMING_CONVENTIONS = {
|
|
5
|
+
"ix": "ix_%(column_0_label)s",
|
|
6
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
7
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
8
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
9
|
+
"pk": "pk_%(table_name)s",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
metadata = MetaData(naming_convention=NAMING_CONVENTIONS)
|
|
13
|
+
|
|
14
|
+
class Base(DeclarativeBase):
|
|
15
|
+
metadata = metadata
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from sqlalchemy import event, text
|
|
2
|
+
from sqlalchemy.engine import Engine
|
|
3
|
+
from sqlalchemy.exc import IntegrityError
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
@event.listens_for(Engine, "connect")
|
|
9
|
+
def enable_sqlite_foreign_keys(dbapi_connection, connection_record):
|
|
10
|
+
if dbapi_connection.__class__.__module__.startswith("sqlite3"):
|
|
11
|
+
logger.debug("Enabling SQLite foreign key enforcement")
|
|
12
|
+
cursor = dbapi_connection.cursor()
|
|
13
|
+
cursor.execute("PRAGMA defer_foreign_keys = ON;")
|
|
14
|
+
cursor.close()
|
|
15
|
+
|
|
16
|
+
def explain_sqlite_fk_error(session, exc: IntegrityError, raise_error: bool = True):
|
|
17
|
+
engine = session.get_bind()
|
|
18
|
+
if engine.dialect.name != "sqlite":
|
|
19
|
+
raise exc
|
|
20
|
+
|
|
21
|
+
with engine.connect() as conn:
|
|
22
|
+
rows = conn.execute(text("PRAGMA foreign_key_check")).fetchall()
|
|
23
|
+
|
|
24
|
+
if rows:
|
|
25
|
+
for r in rows:
|
|
26
|
+
logger.error(
|
|
27
|
+
"FK violation: table=%s rowid=%s references=%s fk_index=%s",
|
|
28
|
+
r[0], r[1], r[2], r[3]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if raise_error:
|
|
32
|
+
raise exc
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .loader_interface import LoaderInterface, PandasLoader, ParquetLoader
|
|
2
|
+
from .data_classes import LoaderContext, TableCastingStats
|
|
3
|
+
from .loading_helpers import infer_delim, infer_encoding, quick_load_pg
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"LoaderInterface",
|
|
7
|
+
"LoaderContext",
|
|
8
|
+
"PandasLoader",
|
|
9
|
+
"TableCastingStats",
|
|
10
|
+
"infer_delim",
|
|
11
|
+
"infer_encoding",
|
|
12
|
+
"quick_load_pg",
|
|
13
|
+
"ParquetLoader",
|
|
14
|
+
]
|