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.
Files changed (53) hide show
  1. sqldim/__init__.py +86 -0
  2. sqldim/cli.py +82 -0
  3. sqldim/config.py +22 -0
  4. sqldim/core/__init__.py +0 -0
  5. sqldim/core/facts.py +26 -0
  6. sqldim/core/fields.py +54 -0
  7. sqldim/core/graph.py +135 -0
  8. sqldim/core/mixins.py +73 -0
  9. sqldim/core/models.py +18 -0
  10. sqldim/dimensions/__init__.py +0 -0
  11. sqldim/dimensions/date.py +55 -0
  12. sqldim/dimensions/junk.py +60 -0
  13. sqldim/dimensions/time.py +53 -0
  14. sqldim/exceptions.py +61 -0
  15. sqldim/graph/__init__.py +4 -0
  16. sqldim/graph/registry.py +326 -0
  17. sqldim/graph/schema_graph.py +252 -0
  18. sqldim/graph/traversal.py +168 -0
  19. sqldim/loaders/__init__.py +0 -0
  20. sqldim/loaders/accumulating.py +78 -0
  21. sqldim/loaders/array_metric.py +53 -0
  22. sqldim/loaders/bitmask.py +53 -0
  23. sqldim/loaders/cumulative.py +102 -0
  24. sqldim/loaders/dimensional.py +110 -0
  25. sqldim/loaders/edge_projection.py +47 -0
  26. sqldim/loaders/resolution.py +81 -0
  27. sqldim/loaders/snapshot.py +49 -0
  28. sqldim/loaders/strategies.py +73 -0
  29. sqldim/migrations/__init__.py +0 -0
  30. sqldim/migrations/context.py +103 -0
  31. sqldim/migrations/generator.py +98 -0
  32. sqldim/migrations/guards.py +37 -0
  33. sqldim/migrations/ops.py +37 -0
  34. sqldim/models/__init__.py +3 -0
  35. sqldim/models/graph.py +119 -0
  36. sqldim/narwhals/__init__.py +16 -0
  37. sqldim/narwhals/adapter.py +122 -0
  38. sqldim/narwhals/backfill.py +100 -0
  39. sqldim/narwhals/scd_engine.py +266 -0
  40. sqldim/narwhals/sk_resolver.py +165 -0
  41. sqldim/narwhals/transforms.py +224 -0
  42. sqldim/query/__init__.py +0 -0
  43. sqldim/query/builder.py +121 -0
  44. sqldim/scd/__init__.py +0 -0
  45. sqldim/scd/audit.py +54 -0
  46. sqldim/scd/backfill.py +87 -0
  47. sqldim/scd/detection.py +53 -0
  48. sqldim/scd/handler.py +150 -0
  49. sqldim/session.py +32 -0
  50. sqldim-0.1.0.dist-info/METADATA +60 -0
  51. sqldim-0.1.0.dist-info/RECORD +53 -0
  52. sqldim-0.1.0.dist-info/WHEEL +5 -0
  53. 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_"}
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