sqldim 0.1.0__py3-none-any.whl
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.
- sqldim/__init__.py +86 -0
- sqldim/cli.py +82 -0
- sqldim/config.py +22 -0
- sqldim/core/__init__.py +0 -0
- sqldim/core/facts.py +26 -0
- sqldim/core/fields.py +54 -0
- sqldim/core/graph.py +135 -0
- sqldim/core/mixins.py +73 -0
- sqldim/core/models.py +18 -0
- sqldim/dimensions/__init__.py +0 -0
- sqldim/dimensions/date.py +55 -0
- sqldim/dimensions/junk.py +60 -0
- sqldim/dimensions/time.py +53 -0
- sqldim/exceptions.py +61 -0
- sqldim/graph/__init__.py +4 -0
- sqldim/graph/registry.py +326 -0
- sqldim/graph/schema_graph.py +252 -0
- sqldim/graph/traversal.py +168 -0
- sqldim/loaders/__init__.py +0 -0
- sqldim/loaders/accumulating.py +78 -0
- sqldim/loaders/array_metric.py +53 -0
- sqldim/loaders/bitmask.py +53 -0
- sqldim/loaders/cumulative.py +102 -0
- sqldim/loaders/dimensional.py +110 -0
- sqldim/loaders/edge_projection.py +47 -0
- sqldim/loaders/resolution.py +81 -0
- sqldim/loaders/snapshot.py +49 -0
- sqldim/loaders/strategies.py +73 -0
- sqldim/migrations/__init__.py +0 -0
- sqldim/migrations/context.py +103 -0
- sqldim/migrations/generator.py +98 -0
- sqldim/migrations/guards.py +37 -0
- sqldim/migrations/ops.py +37 -0
- sqldim/models/__init__.py +3 -0
- sqldim/models/graph.py +119 -0
- sqldim/narwhals/__init__.py +16 -0
- sqldim/narwhals/adapter.py +122 -0
- sqldim/narwhals/backfill.py +100 -0
- sqldim/narwhals/scd_engine.py +266 -0
- sqldim/narwhals/sk_resolver.py +165 -0
- sqldim/narwhals/transforms.py +224 -0
- sqldim/query/__init__.py +0 -0
- sqldim/query/builder.py +121 -0
- sqldim/scd/__init__.py +0 -0
- sqldim/scd/audit.py +54 -0
- sqldim/scd/backfill.py +87 -0
- sqldim/scd/detection.py +53 -0
- sqldim/scd/handler.py +150 -0
- sqldim/session.py +32 -0
- sqldim-0.1.0.dist-info/METADATA +60 -0
- sqldim-0.1.0.dist-info/RECORD +53 -0
- sqldim-0.1.0.dist-info/WHEEL +5 -0
- sqldim-0.1.0.dist-info/top_level.txt +1 -0
sqldim/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from sqldim.core.fields import Field
|
|
2
|
+
from sqldim.core.models import DimensionModel, FactModel, BridgeModel
|
|
3
|
+
from sqldim.core.mixins import SCD2Mixin, SCD3Mixin, DatelistMixin, CumulativeMixin
|
|
4
|
+
from sqldim.core.facts import TransactionFact, PeriodicSnapshotFact, AccumulatingFact, CumulativeFact, ActivityFact
|
|
5
|
+
from sqldim.scd.backfill import backfill_scd2
|
|
6
|
+
from sqldim.core.graph import SchemaGraph
|
|
7
|
+
from sqldim.models.graph import VertexModel, EdgeModel, Vertex
|
|
8
|
+
from sqldim.graph import GraphModel, TraversalEngine
|
|
9
|
+
from sqldim.graph.schema_graph import SchemaGraph as GraphSchemaGraph, GraphSchema
|
|
10
|
+
from sqldim.narwhals.adapter import NarwhalsAdapter
|
|
11
|
+
from sqldim.narwhals.transforms import col, TransformPipeline
|
|
12
|
+
from sqldim.narwhals.backfill import backfill_scd2_narwhals
|
|
13
|
+
from sqldim.narwhals.scd_engine import NarwhalsHashStrategy, NarwhalsSCDProcessor
|
|
14
|
+
from sqldim.loaders.cumulative import CumulativeLoader
|
|
15
|
+
from sqldim.loaders.bitmask import BitmaskerLoader
|
|
16
|
+
from sqldim.loaders.array_metric import ArrayMetricLoader
|
|
17
|
+
from sqldim.loaders.edge_projection import EdgeProjectionLoader
|
|
18
|
+
from sqldim.exceptions import TransformTypeError
|
|
19
|
+
from sqldim.scd.handler import SCDHandler, SCDResult
|
|
20
|
+
from sqldim.loaders.dimensional import DimensionalLoader, SKResolver
|
|
21
|
+
from sqldim.session import AsyncDimensionalSession
|
|
22
|
+
from sqldim.config import SqldimConfig
|
|
23
|
+
from sqldim.exceptions import (
|
|
24
|
+
SqldimError,
|
|
25
|
+
SchemaError, GrainViolationError, NaturalKeyError,
|
|
26
|
+
SCDError, DestructiveOperationError,
|
|
27
|
+
LoadError, SKResolutionError, IdempotencyError,
|
|
28
|
+
MigrationError, DestructiveMigrationError,
|
|
29
|
+
SemanticError, InvalidJoinError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Core schema
|
|
34
|
+
"Field",
|
|
35
|
+
"DimensionModel",
|
|
36
|
+
"FactModel",
|
|
37
|
+
"BridgeModel",
|
|
38
|
+
"TransactionFact",
|
|
39
|
+
"PeriodicSnapshotFact",
|
|
40
|
+
"AccumulatingFact",
|
|
41
|
+
"CumulativeFact",
|
|
42
|
+
"ActivityFact",
|
|
43
|
+
"DatelistMixin",
|
|
44
|
+
"CumulativeMixin",
|
|
45
|
+
"backfill_scd2",
|
|
46
|
+
"SCD2Mixin",
|
|
47
|
+
"SCD3Mixin",
|
|
48
|
+
"SchemaGraph",
|
|
49
|
+
# Graph extension (Phase 6)
|
|
50
|
+
"VertexModel",
|
|
51
|
+
"EdgeModel",
|
|
52
|
+
"Vertex",
|
|
53
|
+
"GraphModel",
|
|
54
|
+
"TraversalEngine",
|
|
55
|
+
"GraphSchemaGraph",
|
|
56
|
+
"GraphSchema",
|
|
57
|
+
# Narwhals integration (Phase 7)
|
|
58
|
+
"NarwhalsAdapter",
|
|
59
|
+
"col",
|
|
60
|
+
"TransformPipeline",
|
|
61
|
+
"backfill_scd2_narwhals",
|
|
62
|
+
"NarwhalsHashStrategy",
|
|
63
|
+
"NarwhalsSCDProcessor",
|
|
64
|
+
"CumulativeLoader",
|
|
65
|
+
"BitmaskerLoader",
|
|
66
|
+
"ArrayMetricLoader",
|
|
67
|
+
"EdgeProjectionLoader",
|
|
68
|
+
"TransformTypeError",
|
|
69
|
+
# SCD
|
|
70
|
+
"SCDHandler",
|
|
71
|
+
"SCDResult",
|
|
72
|
+
# Loaders
|
|
73
|
+
"DimensionalLoader",
|
|
74
|
+
"SKResolver",
|
|
75
|
+
# Session
|
|
76
|
+
"AsyncDimensionalSession",
|
|
77
|
+
# Config
|
|
78
|
+
"SqldimConfig",
|
|
79
|
+
# Exceptions
|
|
80
|
+
"SqldimError",
|
|
81
|
+
"SchemaError", "GrainViolationError", "NaturalKeyError",
|
|
82
|
+
"SCDError", "DestructiveOperationError",
|
|
83
|
+
"LoadError", "SKResolutionError", "IdempotencyError",
|
|
84
|
+
"MigrationError", "DestructiveMigrationError",
|
|
85
|
+
"SemanticError", "InvalidJoinError",
|
|
86
|
+
]
|
sqldim/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sqldim CLI — dimensional migration management.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
sqldim migrations generate "message"
|
|
6
|
+
sqldim migrations show
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cmd_migrations_generate(args: argparse.Namespace) -> None:
|
|
15
|
+
"""Generate a migration from a schema diff."""
|
|
16
|
+
print(f"[sqldim] Generating migration: '{args.message}'")
|
|
17
|
+
print("[sqldim] Tip: call generate_migration(models, current_state, message) in your project.")
|
|
18
|
+
|
|
19
|
+
def cmd_migrations_show(args: argparse.Namespace) -> None:
|
|
20
|
+
"""Show pending migration info."""
|
|
21
|
+
print("[sqldim] No pending migrations detected (run generate first).")
|
|
22
|
+
|
|
23
|
+
def cmd_migrations_init(args: argparse.Namespace) -> None:
|
|
24
|
+
"""Initialize sqldim migration environment."""
|
|
25
|
+
import os
|
|
26
|
+
migration_dir = getattr(args, "dir", "migrations")
|
|
27
|
+
os.makedirs(migration_dir, exist_ok=True)
|
|
28
|
+
init_file = os.path.join(migration_dir, "__init__.py")
|
|
29
|
+
if not os.path.exists(init_file):
|
|
30
|
+
open(init_file, "w").close()
|
|
31
|
+
print(f"[sqldim] Migration directory initialized at '{migration_dir}/'")
|
|
32
|
+
|
|
33
|
+
def cmd_schema_graph(args: argparse.Namespace) -> None:
|
|
34
|
+
"""Print schema graph as JSON or Mermaid."""
|
|
35
|
+
print("[sqldim] Pass your SchemaGraph instance to .to_dict() or .to_mermaid()")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="sqldim",
|
|
41
|
+
description="sqldim — Dimensional Modeling toolkit for Python",
|
|
42
|
+
)
|
|
43
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
44
|
+
|
|
45
|
+
# migrations subcommand
|
|
46
|
+
mig = subparsers.add_parser("migrations", help="Migration management")
|
|
47
|
+
mig_sub = mig.add_subparsers(dest="subcommand")
|
|
48
|
+
|
|
49
|
+
gen = mig_sub.add_parser("generate", help="Generate a migration from model diff")
|
|
50
|
+
gen.add_argument("message", help="Migration message")
|
|
51
|
+
gen.set_defaults(func=cmd_migrations_generate)
|
|
52
|
+
|
|
53
|
+
show = mig_sub.add_parser("show", help="Show pending migrations")
|
|
54
|
+
show.set_defaults(func=cmd_migrations_show)
|
|
55
|
+
|
|
56
|
+
init = mig_sub.add_parser("init", help="Initialize migration directory")
|
|
57
|
+
init.add_argument("--dir", default="migrations", help="Migration directory path")
|
|
58
|
+
init.set_defaults(func=cmd_migrations_init)
|
|
59
|
+
|
|
60
|
+
# schema subcommand
|
|
61
|
+
schema = subparsers.add_parser("schema", help="Schema introspection")
|
|
62
|
+
schema_sub = schema.add_subparsers(dest="subcommand")
|
|
63
|
+
graph = schema_sub.add_parser("graph", help="Print schema graph")
|
|
64
|
+
graph.set_defaults(func=cmd_schema_graph)
|
|
65
|
+
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: List[str] | None = None) -> int:
|
|
70
|
+
parser = build_parser()
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
|
|
73
|
+
if not hasattr(args, "func"):
|
|
74
|
+
parser.print_help()
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
args.func(args)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__": # pragma: no cover
|
|
82
|
+
sys.exit(main())
|
sqldim/config.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from pydantic_settings import BaseSettings
|
|
5
|
+
|
|
6
|
+
class SqldimConfig(BaseSettings):
|
|
7
|
+
# SCD defaults
|
|
8
|
+
scd_default_type: int = 2
|
|
9
|
+
scd_epoch: datetime = datetime(1970, 1, 1)
|
|
10
|
+
scd_infinity: datetime = datetime(9999, 12, 31)
|
|
11
|
+
checksum_algorithm: Literal["md5", "sha1", "sha256"] = "md5"
|
|
12
|
+
|
|
13
|
+
# Load defaults
|
|
14
|
+
default_batch_size: int = 10_000
|
|
15
|
+
sk_lookup_cache: bool = True
|
|
16
|
+
sk_lookup_cache_ttl: int = 3600 # seconds
|
|
17
|
+
|
|
18
|
+
# Migration defaults
|
|
19
|
+
allow_destructive: bool = False
|
|
20
|
+
migration_dir: str = "migrations"
|
|
21
|
+
|
|
22
|
+
model_config = {"env_prefix": "SQLDIM_"}
|
sqldim/core/__init__.py
ADDED
|
File without changes
|
sqldim/core/facts.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from sqlmodel import SQLModel
|
|
3
|
+
from typing import ClassVar, List, Optional
|
|
4
|
+
from sqldim.core.models import FactModel
|
|
5
|
+
|
|
6
|
+
class FactType(str, Enum):
|
|
7
|
+
TRANSACTION = "transaction"
|
|
8
|
+
PERIODIC_SNAPSHOT = "periodic_snapshot"
|
|
9
|
+
ACCUMULATING_SNAPSHOT = "accumulating_snapshot"
|
|
10
|
+
CUMULATIVE = "cumulative"
|
|
11
|
+
ACTIVITY = "activity"
|
|
12
|
+
|
|
13
|
+
class TransactionFact(FactModel):
|
|
14
|
+
__fact_type__ = FactType.TRANSACTION
|
|
15
|
+
|
|
16
|
+
class PeriodicSnapshotFact(FactModel):
|
|
17
|
+
__fact_type__ = FactType.PERIODIC_SNAPSHOT
|
|
18
|
+
|
|
19
|
+
class AccumulatingFact(FactModel):
|
|
20
|
+
__fact_type__ = FactType.ACCUMULATING_SNAPSHOT
|
|
21
|
+
|
|
22
|
+
class CumulativeFact(FactModel):
|
|
23
|
+
__fact_type__ = FactType.CUMULATIVE
|
|
24
|
+
|
|
25
|
+
class ActivityFact(FactModel):
|
|
26
|
+
__fact_type__ = FactType.ACTIVITY
|
sqldim/core/fields.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import Any, Optional, Type
|
|
2
|
+
from sqlmodel import Field as SQLModelField
|
|
3
|
+
|
|
4
|
+
def Field(
|
|
5
|
+
default: Any = ...,
|
|
6
|
+
*,
|
|
7
|
+
default_factory: Optional[Any] = None,
|
|
8
|
+
primary_key: bool = False,
|
|
9
|
+
foreign_key: Optional[str] = None,
|
|
10
|
+
index: bool = False,
|
|
11
|
+
nullable: Optional[bool] = None,
|
|
12
|
+
# Dimensional metadata
|
|
13
|
+
surrogate_key: bool = False,
|
|
14
|
+
natural_key: bool = False,
|
|
15
|
+
measure: bool = False,
|
|
16
|
+
additive: bool = True,
|
|
17
|
+
dimension: Optional[Type] = None,
|
|
18
|
+
role: Optional[str] = None,
|
|
19
|
+
scd: Optional[int] = None,
|
|
20
|
+
previous_column: Optional[str] = None,
|
|
21
|
+
**kwargs: Any,
|
|
22
|
+
) -> Any:
|
|
23
|
+
"""
|
|
24
|
+
Extends SQLModel's Field with dimensional metadata stored in sa_column_kwargs
|
|
25
|
+
for compatibility across SQLModel/SQLAlchemy versions.
|
|
26
|
+
"""
|
|
27
|
+
# Collect dimensional metadata
|
|
28
|
+
dim_meta = {
|
|
29
|
+
"surrogate_key": surrogate_key,
|
|
30
|
+
"natural_key": natural_key,
|
|
31
|
+
"measure": measure,
|
|
32
|
+
"additive": additive,
|
|
33
|
+
"dimension": dimension,
|
|
34
|
+
"role": role,
|
|
35
|
+
"scd": scd,
|
|
36
|
+
"previous_column": previous_column,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Store in sa_column_kwargs so it persists to the SQLAlchemy Column info
|
|
40
|
+
sa_column_kwargs = kwargs.get("sa_column_kwargs", {})
|
|
41
|
+
if "info" not in sa_column_kwargs:
|
|
42
|
+
sa_column_kwargs["info"] = {}
|
|
43
|
+
sa_column_kwargs["info"].update(dim_meta)
|
|
44
|
+
kwargs["sa_column_kwargs"] = sa_column_kwargs
|
|
45
|
+
|
|
46
|
+
return SQLModelField(
|
|
47
|
+
default,
|
|
48
|
+
default_factory=default_factory,
|
|
49
|
+
primary_key=primary_key,
|
|
50
|
+
foreign_key=foreign_key,
|
|
51
|
+
index=index,
|
|
52
|
+
nullable=nullable,
|
|
53
|
+
**kwargs,
|
|
54
|
+
)
|
sqldim/core/graph.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from typing import List, Type, Dict, Any, Optional
|
|
2
|
+
from sqldim.core.models import DimensionModel, FactModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RolePlayingRef:
|
|
6
|
+
"""Represents a dimension playing a named role in a fact table."""
|
|
7
|
+
def __init__(self, dimension: Type[DimensionModel], role: str, fk_column: str):
|
|
8
|
+
self.dimension = dimension
|
|
9
|
+
self.role = role
|
|
10
|
+
self.fk_column = fk_column
|
|
11
|
+
|
|
12
|
+
def __repr__(self) -> str:
|
|
13
|
+
return f"RolePlayingRef(dim={self.dimension.__name__}, role={self.role!r}, fk={self.fk_column!r})"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SchemaGraph:
|
|
17
|
+
def __init__(self, models: List[Type[Any]]):
|
|
18
|
+
self.models = models
|
|
19
|
+
self.dimensions = [m for m in models if issubclass(m, DimensionModel)]
|
|
20
|
+
self.facts = [m for m in models if issubclass(m, FactModel)]
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_models(cls, models: List[Type[Any]]) -> "SchemaGraph":
|
|
24
|
+
return cls(models)
|
|
25
|
+
|
|
26
|
+
def get_star_schema(self, fact_model: Type[FactModel]) -> Dict[str, Any]:
|
|
27
|
+
relationships = {}
|
|
28
|
+
for name, field in fact_model.model_fields.items():
|
|
29
|
+
dim = None
|
|
30
|
+
|
|
31
|
+
# Check for dimension in field attributes (pydantic side)
|
|
32
|
+
if hasattr(field, "json_schema_extra") and field.json_schema_extra:
|
|
33
|
+
dim = field.json_schema_extra.get("dimension")
|
|
34
|
+
|
|
35
|
+
# Check for dimension in sa_column (SQLAlchemy side)
|
|
36
|
+
if not dim and hasattr(fact_model, "__table__"):
|
|
37
|
+
column = fact_model.__table__.columns.get(name)
|
|
38
|
+
if column is not None and column.info:
|
|
39
|
+
dim = column.info.get("dimension")
|
|
40
|
+
|
|
41
|
+
if dim:
|
|
42
|
+
relationships[name] = dim
|
|
43
|
+
return {
|
|
44
|
+
"fact": fact_model,
|
|
45
|
+
"dimensions": relationships
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def get_role_playing_dimensions(self, fact_model: Type[FactModel]) -> List[RolePlayingRef]:
|
|
49
|
+
"""
|
|
50
|
+
Returns all role-playing dimension references in a fact table.
|
|
51
|
+
A role-playing dimension is when the same physical dimension is joined
|
|
52
|
+
multiple times under different logical roles (e.g. departure_date, arrival_date).
|
|
53
|
+
"""
|
|
54
|
+
refs = []
|
|
55
|
+
if not hasattr(fact_model, "__table__"):
|
|
56
|
+
return refs
|
|
57
|
+
for col in fact_model.__table__.columns:
|
|
58
|
+
if col.info:
|
|
59
|
+
role = col.info.get("role")
|
|
60
|
+
dim = col.info.get("dimension")
|
|
61
|
+
if role and dim:
|
|
62
|
+
refs.append(RolePlayingRef(
|
|
63
|
+
dimension=dim,
|
|
64
|
+
role=role,
|
|
65
|
+
fk_column=col.name,
|
|
66
|
+
))
|
|
67
|
+
return refs
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
70
|
+
"""Return a JSON-serializable representation of the full schema graph."""
|
|
71
|
+
result: Dict[str, Any] = {"facts": [], "dimensions": []}
|
|
72
|
+
|
|
73
|
+
for dim in self.dimensions:
|
|
74
|
+
result["dimensions"].append({
|
|
75
|
+
"name": dim.__name__,
|
|
76
|
+
"natural_key": getattr(dim, "__natural_key__", []),
|
|
77
|
+
"scd_type": getattr(dim, "__scd_type__", 1),
|
|
78
|
+
"columns": list(dim.model_fields.keys()),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
for fact in self.facts:
|
|
82
|
+
star = self.get_star_schema(fact)
|
|
83
|
+
roles = self.get_role_playing_dimensions(fact)
|
|
84
|
+
result["facts"].append({
|
|
85
|
+
"name": fact.__name__,
|
|
86
|
+
"grain": getattr(fact, "__grain__", None),
|
|
87
|
+
"dimensions": {
|
|
88
|
+
fk: dim.__name__ for fk, dim in star["dimensions"].items()
|
|
89
|
+
},
|
|
90
|
+
"role_playing": [
|
|
91
|
+
{"fk": r.fk_column, "dimension": r.dimension.__name__, "role": r.role}
|
|
92
|
+
for r in roles
|
|
93
|
+
],
|
|
94
|
+
"columns": list(fact.model_fields.keys()),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
def to_mermaid(self) -> str:
|
|
100
|
+
"""Render the schema as a Mermaid ER diagram string."""
|
|
101
|
+
lines = ["erDiagram"]
|
|
102
|
+
|
|
103
|
+
for dim in self.dimensions:
|
|
104
|
+
lines.append(f" {dim.__name__} {{")
|
|
105
|
+
for col_name, field in dim.model_fields.items():
|
|
106
|
+
col_type = "string"
|
|
107
|
+
annotation = dim.__annotations__.get(col_name)
|
|
108
|
+
if annotation in (int,):
|
|
109
|
+
col_type = "int"
|
|
110
|
+
elif annotation in (float,):
|
|
111
|
+
col_type = "float"
|
|
112
|
+
elif annotation in (bool,):
|
|
113
|
+
col_type = "bool"
|
|
114
|
+
lines.append(f" {col_type} {col_name}")
|
|
115
|
+
lines.append(" }")
|
|
116
|
+
|
|
117
|
+
for fact in self.facts:
|
|
118
|
+
lines.append(f" {fact.__name__} {{")
|
|
119
|
+
for col_name, field in fact.model_fields.items():
|
|
120
|
+
col_type = "string"
|
|
121
|
+
annotation = fact.__annotations__.get(col_name)
|
|
122
|
+
if annotation in (int,):
|
|
123
|
+
col_type = "int"
|
|
124
|
+
elif annotation in (float,):
|
|
125
|
+
col_type = "float"
|
|
126
|
+
lines.append(f" {col_type} {col_name}")
|
|
127
|
+
lines.append(" }")
|
|
128
|
+
|
|
129
|
+
# Relationships
|
|
130
|
+
for fact in self.facts:
|
|
131
|
+
star = self.get_star_schema(fact)
|
|
132
|
+
for fk_col, dim in star["dimensions"].items():
|
|
133
|
+
lines.append(f" {fact.__name__} }}o--|| {dim.__name__} : \"{fk_col}\"")
|
|
134
|
+
|
|
135
|
+
return "\n".join(lines)
|
sqldim/core/mixins.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from datetime import date, datetime, timezone
|
|
2
|
+
from typing import Optional, Any
|
|
3
|
+
from sqlmodel import SQLModel
|
|
4
|
+
from sqldim.core.fields import Field
|
|
5
|
+
|
|
6
|
+
class SCD2Mixin(SQLModel):
|
|
7
|
+
"""
|
|
8
|
+
Base mixin for SCD Type 2 tracking.
|
|
9
|
+
Default period columns are datetimes. For non-datetime periods (like integer seasons),
|
|
10
|
+
override valid_from/valid_to in the subclass.
|
|
11
|
+
"""
|
|
12
|
+
valid_from: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
13
|
+
valid_to: Optional[datetime] = Field(default=None, index=True, nullable=True)
|
|
14
|
+
is_current: bool = Field(default=True, index=True)
|
|
15
|
+
checksum: Optional[str] = Field(default=None, index=True, nullable=True)
|
|
16
|
+
|
|
17
|
+
class SCD3Mixin(SQLModel):
|
|
18
|
+
"""Supports SCD Type 3 (Current + Previous value columns)."""
|
|
19
|
+
# Note: Users define the specific 'previous_X' columns in their model
|
|
20
|
+
# and we handle the rotation logic in SCDHandler.
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
class CumulativeMixin(SQLModel):
|
|
24
|
+
"""
|
|
25
|
+
Mixin for Cumulative Dimensions/Facts (array-of-structs pattern).
|
|
26
|
+
Provides helpers for managing history arrays.
|
|
27
|
+
"""
|
|
28
|
+
def current_value(self, column: str) -> Any:
|
|
29
|
+
"""Returns the most recent element in the cumulative array."""
|
|
30
|
+
arr = getattr(self, column, [])
|
|
31
|
+
return arr[-1] if arr else None
|
|
32
|
+
|
|
33
|
+
def first_value(self, column: str) -> Any:
|
|
34
|
+
"""Returns the first element in the cumulative array."""
|
|
35
|
+
arr = getattr(self, column, [])
|
|
36
|
+
return arr[0] if arr else None
|
|
37
|
+
|
|
38
|
+
class DatelistMixin(SQLModel):
|
|
39
|
+
"""
|
|
40
|
+
Mixin for tracking activity via dates lists.
|
|
41
|
+
Enables bitmask-based analysis (L7, L28).
|
|
42
|
+
"""
|
|
43
|
+
def to_bitmask(self, reference_date: date, window: int = 32) -> int:
|
|
44
|
+
"""Converts a list of active dates into an integer bitmask."""
|
|
45
|
+
# Implementation based on the provided theoretical requirement
|
|
46
|
+
mask = 0
|
|
47
|
+
dates = getattr(self, "dates_active", [])
|
|
48
|
+
if not dates:
|
|
49
|
+
return mask
|
|
50
|
+
|
|
51
|
+
for active_date in dates:
|
|
52
|
+
# Handle both date objects and ISO strings (common in JSON serialization)
|
|
53
|
+
if isinstance(active_date, str):
|
|
54
|
+
active_date = date.fromisoformat(active_date)
|
|
55
|
+
diff = (reference_date - active_date).days
|
|
56
|
+
if 0 <= diff < window:
|
|
57
|
+
mask |= (1 << diff)
|
|
58
|
+
return mask
|
|
59
|
+
|
|
60
|
+
def activity_in_window(self, days: int, reference_date: date) -> bool:
|
|
61
|
+
"""Returns True if any activity occurred within the last N days."""
|
|
62
|
+
mask = self.to_bitmask(reference_date, window=days)
|
|
63
|
+
return mask > 0
|
|
64
|
+
|
|
65
|
+
def l7(self, reference_date: date) -> int:
|
|
66
|
+
"""Count of active days in the last 7 days."""
|
|
67
|
+
mask = self.to_bitmask(reference_date, window=7)
|
|
68
|
+
return bin(mask).count("1")
|
|
69
|
+
|
|
70
|
+
def l28(self, reference_date: date) -> int:
|
|
71
|
+
"""Count of active days in the last 28 days."""
|
|
72
|
+
mask = self.to_bitmask(reference_date, window=28)
|
|
73
|
+
return bin(mask).count("1")
|
sqldim/core/models.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Optional, List, ClassVar
|
|
2
|
+
from sqlmodel import SQLModel
|
|
3
|
+
from sqldim.core.fields import Field
|
|
4
|
+
|
|
5
|
+
class DimensionModel(SQLModel):
|
|
6
|
+
__natural_key__: ClassVar[List[str]] = []
|
|
7
|
+
__scd_type__: ClassVar[int] = 2
|
|
8
|
+
|
|
9
|
+
class FactModel(SQLModel):
|
|
10
|
+
__grain__: ClassVar[Optional[str]] = None
|
|
11
|
+
__strategy__: ClassVar[Optional[str]] = None # e.g. "bulk", "upsert", "merge", "accumulating"
|
|
12
|
+
|
|
13
|
+
class BridgeModel(SQLModel):
|
|
14
|
+
"""
|
|
15
|
+
Base class for Kimball Bridge Tables handling many-to-many relationships.
|
|
16
|
+
Includes weighting factors for multi-valued dimensions to prevent double-counting.
|
|
17
|
+
"""
|
|
18
|
+
weight: float = Field(default=1.0, description="Allocation factor (0.0 to 1.0)")
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from datetime import date, timedelta
|
|
2
|
+
from typing import Optional, List
|
|
3
|
+
from sqlmodel import Session
|
|
4
|
+
from sqldim import DimensionModel, Field
|
|
5
|
+
|
|
6
|
+
class DateDimension(DimensionModel, table=True):
|
|
7
|
+
"""Pre-built Date Dimension — generate a full date spine for any range."""
|
|
8
|
+
__natural_key__ = ["date_value"]
|
|
9
|
+
|
|
10
|
+
id: int = Field(primary_key=True, surrogate_key=True)
|
|
11
|
+
date_value: str = Field(natural_key=True, index=True, unique=True) # YYYY-MM-DD
|
|
12
|
+
year: int
|
|
13
|
+
quarter: int
|
|
14
|
+
month: int
|
|
15
|
+
month_name: str
|
|
16
|
+
day_of_month: int
|
|
17
|
+
day_of_week: int # 0=Monday, 6=Sunday
|
|
18
|
+
day_name: str
|
|
19
|
+
week_of_year: int
|
|
20
|
+
is_weekend: bool
|
|
21
|
+
is_leap_year: bool
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def _from_date(cls, d: date) -> "DateDimension":
|
|
25
|
+
return cls(
|
|
26
|
+
date_value=d.isoformat(),
|
|
27
|
+
year=d.year,
|
|
28
|
+
quarter=(d.month - 1) // 3 + 1,
|
|
29
|
+
month=d.month,
|
|
30
|
+
month_name=d.strftime("%B"),
|
|
31
|
+
day_of_month=d.day,
|
|
32
|
+
day_of_week=d.weekday(),
|
|
33
|
+
day_name=d.strftime("%A"),
|
|
34
|
+
week_of_year=int(d.strftime("%W")),
|
|
35
|
+
is_weekend=d.weekday() >= 5,
|
|
36
|
+
is_leap_year=d.year % 4 == 0 and (d.year % 100 != 0 or d.year % 400 == 0),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def generate(cls, start: str, end: str, session: Session) -> List["DateDimension"]:
|
|
41
|
+
"""
|
|
42
|
+
Generate and persist all DateDimension rows for [start, end] inclusive.
|
|
43
|
+
Skips dates already present (idempotent).
|
|
44
|
+
"""
|
|
45
|
+
start_date = date.fromisoformat(start)
|
|
46
|
+
end_date = date.fromisoformat(end)
|
|
47
|
+
rows = []
|
|
48
|
+
current = start_date
|
|
49
|
+
while current <= end_date:
|
|
50
|
+
row = cls._from_date(current)
|
|
51
|
+
session.add(row)
|
|
52
|
+
rows.append(row)
|
|
53
|
+
current += timedelta(days=1)
|
|
54
|
+
session.commit()
|
|
55
|
+
return rows
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Tuple, Type
|
|
2
|
+
from itertools import product
|
|
3
|
+
from sqlmodel import Session, select
|
|
4
|
+
from sqldim import DimensionModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_junk_dimension(
|
|
8
|
+
name: str,
|
|
9
|
+
flags: Dict[str, List[Any]],
|
|
10
|
+
) -> Type[DimensionModel]:
|
|
11
|
+
"""
|
|
12
|
+
Dynamically creates a JunkDimension model class for a given set of
|
|
13
|
+
low-cardinality flag columns and their possible values.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
SalesFlags = make_junk_dimension("SalesFlags", {
|
|
17
|
+
"is_promo": [True, False],
|
|
18
|
+
"is_return": [True, False],
|
|
19
|
+
"channel": ["web", "store", "phone"],
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
The resulting model has one row per unique combination of flag values.
|
|
23
|
+
"""
|
|
24
|
+
annotations: Dict[str, Any] = {
|
|
25
|
+
"id": int,
|
|
26
|
+
**{col: Optional[Any] for col in flags},
|
|
27
|
+
}
|
|
28
|
+
fields: Dict[str, Any] = {
|
|
29
|
+
"id": Field(primary_key=True, surrogate_key=True),
|
|
30
|
+
**{col: Field(default=None, nullable=True) for col in flags},
|
|
31
|
+
"__annotations__": annotations,
|
|
32
|
+
"__natural_key__": list(flags.keys()),
|
|
33
|
+
}
|
|
34
|
+
model_cls = type(name, (DimensionModel,), {**fields, "__tablename__": name.lower()})
|
|
35
|
+
return model_cls # type: ignore
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def populate_junk_dimension(
|
|
39
|
+
model: Type[DimensionModel],
|
|
40
|
+
flags: Dict[str, List[Any]],
|
|
41
|
+
session: Session,
|
|
42
|
+
) -> List[Any]:
|
|
43
|
+
"""
|
|
44
|
+
Populate a junk dimension with all combinations of flag values (idempotent).
|
|
45
|
+
Returns list of inserted rows.
|
|
46
|
+
"""
|
|
47
|
+
cols = list(flags.keys())
|
|
48
|
+
combos = list(product(*[flags[c] for c in cols]))
|
|
49
|
+
rows = []
|
|
50
|
+
for combo in combos:
|
|
51
|
+
row_data = dict(zip(cols, combo))
|
|
52
|
+
# Check if already exists
|
|
53
|
+
conditions = [model.__table__.c[col] == val for col, val in row_data.items()]
|
|
54
|
+
existing = session.exec(select(model).where(*conditions)).first()
|
|
55
|
+
if not existing:
|
|
56
|
+
row = model(**row_data)
|
|
57
|
+
session.add(row)
|
|
58
|
+
rows.append(row)
|
|
59
|
+
session.commit()
|
|
60
|
+
return rows
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from datetime import time
|
|
2
|
+
from typing import List
|
|
3
|
+
from sqlmodel import Session
|
|
4
|
+
from sqldim import DimensionModel, Field
|
|
5
|
+
|
|
6
|
+
class TimeDimension(DimensionModel, table=True):
|
|
7
|
+
"""
|
|
8
|
+
Pre-built Time-of-Day Dimension for intra-day analysis.
|
|
9
|
+
Granularity: one row per minute (1440 rows total).
|
|
10
|
+
"""
|
|
11
|
+
__natural_key__ = ["time_value"]
|
|
12
|
+
|
|
13
|
+
id: int = Field(primary_key=True, surrogate_key=True)
|
|
14
|
+
time_value: str = Field(natural_key=True, index=True, unique=True) # HH:MM
|
|
15
|
+
hour: int
|
|
16
|
+
minute: int
|
|
17
|
+
period: str # AM | PM
|
|
18
|
+
hour_12: int
|
|
19
|
+
time_of_day: str # Morning | Afternoon | Evening | Night
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def _from_time(cls, h: int, m: int) -> "TimeDimension":
|
|
23
|
+
t = time(h, m)
|
|
24
|
+
period = "AM" if h < 12 else "PM"
|
|
25
|
+
hour_12 = h % 12 or 12
|
|
26
|
+
if 5 <= h < 12:
|
|
27
|
+
tod = "Morning"
|
|
28
|
+
elif 12 <= h < 17:
|
|
29
|
+
tod = "Afternoon"
|
|
30
|
+
elif 17 <= h < 21:
|
|
31
|
+
tod = "Evening"
|
|
32
|
+
else:
|
|
33
|
+
tod = "Night"
|
|
34
|
+
return cls(
|
|
35
|
+
time_value=t.strftime("%H:%M"),
|
|
36
|
+
hour=h,
|
|
37
|
+
minute=m,
|
|
38
|
+
period=period,
|
|
39
|
+
hour_12=hour_12,
|
|
40
|
+
time_of_day=tod,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def generate(cls, session: Session) -> List["TimeDimension"]:
|
|
45
|
+
"""Generate all 1440 minute-level time rows (idempotent)."""
|
|
46
|
+
rows = []
|
|
47
|
+
for h in range(24):
|
|
48
|
+
for m in range(60):
|
|
49
|
+
row = cls._from_time(h, m)
|
|
50
|
+
session.add(row)
|
|
51
|
+
rows.append(row)
|
|
52
|
+
session.commit()
|
|
53
|
+
return rows
|