databricks360 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.
- databricks360/__init__.py +67 -0
- databricks360/_catalog.py +99 -0
- databricks360/_install.py +162 -0
- databricks360/_notebook.py +107 -0
- databricks360/courses/__init__.py +0 -0
- databricks360/courses/genie_agents/01_catalog_and_schemas.sql +31 -0
- databricks360/courses/genie_agents/02_dimensions.sql +291 -0
- databricks360/courses/genie_agents/03_facts.sql +295 -0
- databricks360/courses/genie_agents/__init__.py +0 -0
- databricks360/courses/genie_agents/manifest.json +41 -0
- databricks360-0.1.0.dist-info/METADATA +176 -0
- databricks360-0.1.0.dist-info/RECORD +14 -0
- databricks360-0.1.0.dist-info/WHEEL +4 -0
- databricks360-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Lakehouse Academy — install Databricks course lab environments.
|
|
2
|
+
|
|
3
|
+
Run inside a Databricks notebook:
|
|
4
|
+
|
|
5
|
+
%pip install databricks360
|
|
6
|
+
dbutils.library.restartPython()
|
|
7
|
+
|
|
8
|
+
import databricks360 as academy
|
|
9
|
+
academy.list_courses()
|
|
10
|
+
academy.install('genie-agents')
|
|
11
|
+
|
|
12
|
+
`install` writes the lab notebooks into your workspace. You then run them in
|
|
13
|
+
order. Nothing is executed for you: generating the data is real work on your
|
|
14
|
+
warehouse, and watching it happen is part of the lesson.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ._catalog import Course, available_courses, get_course
|
|
20
|
+
from ._install import Installation, build_notebook_source, install
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"install",
|
|
25
|
+
"list_courses",
|
|
26
|
+
"get_course",
|
|
27
|
+
"available_courses",
|
|
28
|
+
"build_notebook_source",
|
|
29
|
+
"Course",
|
|
30
|
+
"Installation",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def list_courses() -> None:
|
|
36
|
+
"""Print the available course labs."""
|
|
37
|
+
courses = available_courses()
|
|
38
|
+
if not courses:
|
|
39
|
+
print("No courses bundled in this build.")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
for course in courses:
|
|
43
|
+
print(f"\n{course.id}")
|
|
44
|
+
print(f" {course.title}")
|
|
45
|
+
if course.description:
|
|
46
|
+
for line in _wrap(course.description, 76):
|
|
47
|
+
print(f" {line}")
|
|
48
|
+
print(f" catalog: {course.default_catalog} notebooks: {len(course.notebooks)}")
|
|
49
|
+
if course.tiers:
|
|
50
|
+
print(" tiers:")
|
|
51
|
+
for name, tier in course.tiers.items():
|
|
52
|
+
marker = " (default)" if name == course.default_tier else ""
|
|
53
|
+
print(f" {name}{marker} — {tier.description}")
|
|
54
|
+
print(f"\nInstall with: academy.install('{courses[0].id}')\n")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _wrap(text: str, width: int) -> list[str]:
|
|
58
|
+
words, lines, current = text.split(), [], ""
|
|
59
|
+
for word in words:
|
|
60
|
+
if len(current) + len(word) + 1 > width:
|
|
61
|
+
lines.append(current)
|
|
62
|
+
current = word
|
|
63
|
+
else:
|
|
64
|
+
current = f"{current} {word}".strip()
|
|
65
|
+
if current:
|
|
66
|
+
lines.append(current)
|
|
67
|
+
return lines
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Course discovery: reads the manifests bundled with the package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Notebook:
|
|
13
|
+
"""One notebook in a course's lab, in run order."""
|
|
14
|
+
|
|
15
|
+
order: int
|
|
16
|
+
name: str
|
|
17
|
+
sql: str
|
|
18
|
+
title: str
|
|
19
|
+
intro: str = ""
|
|
20
|
+
requires_admin: bool = False
|
|
21
|
+
slow: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Tier:
|
|
26
|
+
name: str
|
|
27
|
+
values: dict
|
|
28
|
+
description: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Course:
|
|
33
|
+
id: str
|
|
34
|
+
title: str
|
|
35
|
+
description: str
|
|
36
|
+
default_catalog: str
|
|
37
|
+
notebooks: list = field(default_factory=list)
|
|
38
|
+
tiers: dict = field(default_factory=dict)
|
|
39
|
+
default_tier: str = "small"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def package(self) -> str:
|
|
43
|
+
return f"databricks360.courses.{self.id.replace('-', '_')}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _course_packages() -> list:
|
|
47
|
+
root = resources.files("databricks360.courses")
|
|
48
|
+
return sorted(
|
|
49
|
+
p.name
|
|
50
|
+
for p in root.iterdir()
|
|
51
|
+
if p.is_dir() and not p.name.startswith("_") and (p / "manifest.json").is_file()
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load(pkg_name: str) -> Course:
|
|
56
|
+
raw: dict = json.loads(
|
|
57
|
+
(resources.files(f"databricks360.courses.{pkg_name}") / "manifest.json")
|
|
58
|
+
.read_text(encoding="utf-8")
|
|
59
|
+
)
|
|
60
|
+
return Course(
|
|
61
|
+
id=raw["id"],
|
|
62
|
+
title=raw["title"],
|
|
63
|
+
description=raw.get("description", ""),
|
|
64
|
+
default_catalog=raw.get("default_catalog", "main"),
|
|
65
|
+
default_tier=raw.get("default_tier", "small"),
|
|
66
|
+
notebooks=[
|
|
67
|
+
Notebook(
|
|
68
|
+
order=n["order"],
|
|
69
|
+
name=n["name"],
|
|
70
|
+
sql=n["sql"],
|
|
71
|
+
title=n["title"],
|
|
72
|
+
intro=n.get("intro", ""),
|
|
73
|
+
requires_admin=n.get("requires_admin", False),
|
|
74
|
+
slow=n.get("slow", False),
|
|
75
|
+
)
|
|
76
|
+
for n in sorted(raw["notebooks"], key=lambda n: n["order"])
|
|
77
|
+
],
|
|
78
|
+
tiers={
|
|
79
|
+
name: Tier(name=name, values=t["values"], description=t.get("description", ""))
|
|
80
|
+
for name, t in raw.get("tiers", {}).items()
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def available_courses() -> list:
|
|
86
|
+
return [_load(p) for p in _course_packages()]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_course(course_id: str) -> Course:
|
|
90
|
+
wanted = course_id.replace("-", "_")
|
|
91
|
+
for pkg in _course_packages():
|
|
92
|
+
if pkg == wanted:
|
|
93
|
+
return _load(pkg)
|
|
94
|
+
known = ", ".join(c.id for c in available_courses()) or "none"
|
|
95
|
+
raise ValueError(f"Unknown course {course_id!r}. Available: {known}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_sql(course: Course, filename: str) -> str:
|
|
99
|
+
return (resources.files(course.package) / filename).read_text(encoding="utf-8")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Materialise a course's lab notebooks into a Databricks workspace.
|
|
2
|
+
|
|
3
|
+
Designed to run from inside a Databricks notebook, where databricks-sdk picks up
|
|
4
|
+
the notebook's own identity — no host, token or profile to configure. It also
|
|
5
|
+
works from a laptop if a Databricks CLI profile is present.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ._catalog import Course, Notebook, read_sql
|
|
14
|
+
from ._notebook import render_template, sql_to_notebook, unresolved_placeholders
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class InstalledNotebook:
|
|
19
|
+
order: int
|
|
20
|
+
name: str
|
|
21
|
+
path: str
|
|
22
|
+
slow: bool
|
|
23
|
+
requires_admin: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Installation:
|
|
28
|
+
course_id: str
|
|
29
|
+
folder: str
|
|
30
|
+
catalog: str
|
|
31
|
+
tier: str
|
|
32
|
+
notebooks: list[InstalledNotebook]
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str: # what a notebook cell shows
|
|
35
|
+
lines = [
|
|
36
|
+
f"Installed '{self.course_id}' → {self.folder}",
|
|
37
|
+
f" catalog: {self.catalog} tier: {self.tier}",
|
|
38
|
+
"",
|
|
39
|
+
" Run these in order:",
|
|
40
|
+
]
|
|
41
|
+
for nb in self.notebooks:
|
|
42
|
+
flags = []
|
|
43
|
+
if nb.slow:
|
|
44
|
+
flags.append("slow")
|
|
45
|
+
if nb.requires_admin:
|
|
46
|
+
flags.append("needs admin")
|
|
47
|
+
suffix = f" ({', '.join(flags)})" if flags else ""
|
|
48
|
+
lines.append(f" {nb.order}. {nb.name}{suffix}")
|
|
49
|
+
return "\n".join(lines)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _workspace_client():
|
|
53
|
+
try:
|
|
54
|
+
from databricks.sdk import WorkspaceClient
|
|
55
|
+
except ImportError as exc: # pragma: no cover
|
|
56
|
+
raise ImportError(
|
|
57
|
+
"databricks-sdk is required. Inside a Databricks notebook run:\n"
|
|
58
|
+
" %pip install databricks360\n"
|
|
59
|
+
" dbutils.library.restartPython()"
|
|
60
|
+
) from exc
|
|
61
|
+
return WorkspaceClient()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _default_folder(client, course: Course) -> str:
|
|
65
|
+
"""Default to the caller's home folder, matching how dbdemos behaves."""
|
|
66
|
+
try:
|
|
67
|
+
user = client.current_user.me().user_name
|
|
68
|
+
except Exception: # pragma: no cover - offline / unauthenticated
|
|
69
|
+
user = None
|
|
70
|
+
base = f"/Workspace/Users/{user}" if user else "/Workspace/Shared"
|
|
71
|
+
return f"{base}/databricks360/{course.id}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_notebook_source(
|
|
75
|
+
course: Course,
|
|
76
|
+
notebook: Notebook,
|
|
77
|
+
*,
|
|
78
|
+
catalog: str,
|
|
79
|
+
tier: str,
|
|
80
|
+
) -> str:
|
|
81
|
+
"""Render one notebook's source. Pure — no workspace calls, so it is testable."""
|
|
82
|
+
if tier not in course.tiers and course.tiers:
|
|
83
|
+
known = ", ".join(sorted(course.tiers))
|
|
84
|
+
raise ValueError(f"Unknown tier {tier!r} for {course.id}. Available: {known}")
|
|
85
|
+
|
|
86
|
+
values = {"CATALOG": catalog, "TIER": tier}
|
|
87
|
+
if course.tiers:
|
|
88
|
+
values.update(course.tiers[tier].values)
|
|
89
|
+
|
|
90
|
+
sql = render_template(read_sql(course, notebook.sql), values)
|
|
91
|
+
intro = render_template(notebook.intro, values)
|
|
92
|
+
source = sql_to_notebook(sql, title=notebook.title, intro=intro or None)
|
|
93
|
+
|
|
94
|
+
leftover = unresolved_placeholders(source)
|
|
95
|
+
if leftover:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"{notebook.sql}: unresolved placeholders {leftover}. "
|
|
98
|
+
"Add them to the tier values in manifest.json, or fix the typo."
|
|
99
|
+
)
|
|
100
|
+
return source
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def install(
|
|
104
|
+
course_id: str,
|
|
105
|
+
*,
|
|
106
|
+
path: str | None = None,
|
|
107
|
+
catalog: str | None = None,
|
|
108
|
+
tier: str | None = None,
|
|
109
|
+
overwrite: bool = False,
|
|
110
|
+
dry_run: bool = False,
|
|
111
|
+
) -> Installation:
|
|
112
|
+
"""Create the lab notebooks for a course in the workspace.
|
|
113
|
+
|
|
114
|
+
Notebooks are written but never executed. Data generation is deliberate work
|
|
115
|
+
on the learner's warehouse, and Module 0 of the course is partly about
|
|
116
|
+
watching it happen rather than having it appear.
|
|
117
|
+
"""
|
|
118
|
+
from ._catalog import get_course
|
|
119
|
+
|
|
120
|
+
course = get_course(course_id)
|
|
121
|
+
catalog = catalog or course.default_catalog
|
|
122
|
+
tier = tier or course.default_tier
|
|
123
|
+
|
|
124
|
+
client = None if dry_run else _workspace_client()
|
|
125
|
+
folder = path or (
|
|
126
|
+
f"/Workspace/Shared/databricks360/{course.id}"
|
|
127
|
+
if dry_run
|
|
128
|
+
else _default_folder(client, course)
|
|
129
|
+
)
|
|
130
|
+
folder = folder.rstrip("/")
|
|
131
|
+
|
|
132
|
+
installed: list[InstalledNotebook] = []
|
|
133
|
+
|
|
134
|
+
if not dry_run:
|
|
135
|
+
from databricks.sdk.service.workspace import ImportFormat, Language
|
|
136
|
+
|
|
137
|
+
client.workspace.mkdirs(folder)
|
|
138
|
+
|
|
139
|
+
for nb in course.notebooks:
|
|
140
|
+
source = build_notebook_source(course, nb, catalog=catalog, tier=tier)
|
|
141
|
+
target = f"{folder}/{nb.name}"
|
|
142
|
+
|
|
143
|
+
if not dry_run:
|
|
144
|
+
client.workspace.import_(
|
|
145
|
+
path=target,
|
|
146
|
+
content=base64.b64encode(source.encode("utf-8")).decode("ascii"),
|
|
147
|
+
format=ImportFormat.SOURCE,
|
|
148
|
+
language=Language.SQL,
|
|
149
|
+
overwrite=overwrite,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
installed.append(
|
|
153
|
+
InstalledNotebook(
|
|
154
|
+
order=nb.order, name=nb.name, path=target,
|
|
155
|
+
slow=nb.slow, requires_admin=nb.requires_admin,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return Installation(
|
|
160
|
+
course_id=course.id, folder=folder, catalog=catalog,
|
|
161
|
+
tier=tier, notebooks=installed,
|
|
162
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Turn a plain .sql file into Databricks notebook source.
|
|
2
|
+
|
|
3
|
+
Databricks notebooks are stored as a flat source file: a header comment naming
|
|
4
|
+
the language, cells separated by a magic delimiter, and markdown cells prefixed
|
|
5
|
+
with a MAGIC marker. Generating that here means the SQL files stay ordinary,
|
|
6
|
+
runnable SQL that anyone can open in a text editor or paste into an editor tab.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
CELL_DELIMITER = "-- COMMAND ----------"
|
|
14
|
+
SQL_HEADER = "-- Databricks notebook source"
|
|
15
|
+
|
|
16
|
+
# A run of dashes at least this long marks a section heading in the SQL files.
|
|
17
|
+
_BANNER = re.compile(r"^-- =={2,}\s*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _md_cell(lines: list[str]) -> str:
|
|
21
|
+
"""Render markdown as a %md cell."""
|
|
22
|
+
body = "\n".join(f"-- MAGIC {line}" if line else "-- MAGIC" for line in lines)
|
|
23
|
+
return f"-- MAGIC %md\n{body}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def split_sql_sections(sql: str) -> list[tuple[str, str]]:
|
|
27
|
+
"""Split a lab SQL file into (title, body) sections on its banner comments.
|
|
28
|
+
|
|
29
|
+
The lab files use a banner of the form:
|
|
30
|
+
|
|
31
|
+
-- ====================================
|
|
32
|
+
-- dim_date — FLAW #2: ...
|
|
33
|
+
-- more description
|
|
34
|
+
-- ====================================
|
|
35
|
+
|
|
36
|
+
Each banner starts a new notebook cell, with the banner text becoming a
|
|
37
|
+
markdown cell above the SQL. Files without banners return a single section.
|
|
38
|
+
"""
|
|
39
|
+
lines = sql.splitlines()
|
|
40
|
+
sections: list[tuple[str, list[str]]] = []
|
|
41
|
+
current_title = ""
|
|
42
|
+
current_body: list[str] = []
|
|
43
|
+
i = 0
|
|
44
|
+
|
|
45
|
+
while i < len(lines):
|
|
46
|
+
if _BANNER.match(lines[i]):
|
|
47
|
+
# Collect the comment block until the closing banner.
|
|
48
|
+
j = i + 1
|
|
49
|
+
header: list[str] = []
|
|
50
|
+
while j < len(lines) and not _BANNER.match(lines[j]):
|
|
51
|
+
header.append(re.sub(r"^--\s?", "", lines[j]))
|
|
52
|
+
j += 1
|
|
53
|
+
if j < len(lines): # closing banner found
|
|
54
|
+
if current_body or current_title:
|
|
55
|
+
sections.append((current_title, current_body))
|
|
56
|
+
current_title = "\n".join(header).strip()
|
|
57
|
+
current_body = []
|
|
58
|
+
i = j + 1
|
|
59
|
+
continue
|
|
60
|
+
current_body.append(lines[i])
|
|
61
|
+
i += 1
|
|
62
|
+
|
|
63
|
+
if current_body or current_title:
|
|
64
|
+
sections.append((current_title, current_body))
|
|
65
|
+
|
|
66
|
+
return [(t, "\n".join(b).strip()) for t, b in sections]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sql_to_notebook(
|
|
70
|
+
sql: str,
|
|
71
|
+
*,
|
|
72
|
+
title: str,
|
|
73
|
+
intro: str | None = None,
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Build Databricks SQL notebook source from a lab SQL file."""
|
|
76
|
+
cells: list[str] = []
|
|
77
|
+
|
|
78
|
+
heading = [f"# {title}"]
|
|
79
|
+
if intro:
|
|
80
|
+
heading += ["", *intro.splitlines()]
|
|
81
|
+
cells.append(_md_cell(heading))
|
|
82
|
+
|
|
83
|
+
for section_title, body in split_sql_sections(sql):
|
|
84
|
+
if section_title:
|
|
85
|
+
first, *rest = section_title.splitlines()
|
|
86
|
+
md = [f"## {first.strip()}"]
|
|
87
|
+
if rest:
|
|
88
|
+
md += ["", *[line.strip() for line in rest]]
|
|
89
|
+
cells.append(_md_cell(md))
|
|
90
|
+
if body:
|
|
91
|
+
cells.append(body)
|
|
92
|
+
|
|
93
|
+
return f"{SQL_HEADER}\n" + f"\n\n{CELL_DELIMITER}\n\n".join(cells) + "\n"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_template(sql: str, values: dict[str, str]) -> str:
|
|
97
|
+
"""Substitute {{PLACEHOLDER}} tokens. Unknown tokens are left untouched so a
|
|
98
|
+
typo shows up in the notebook rather than silently becoming an empty string."""
|
|
99
|
+
out = sql
|
|
100
|
+
for key, value in values.items():
|
|
101
|
+
out = out.replace(f"{{{{{key}}}}}", str(value))
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def unresolved_placeholders(text: str) -> list[str]:
|
|
106
|
+
"""Any {{TOKEN}} left after substitution — a bug worth failing loudly on."""
|
|
107
|
+
return sorted(set(re.findall(r"\{\{([A-Z0-9_]+)\}\}", text)))
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Meridian Financial Group — 01. Catalog, schemas, volume
|
|
3
|
+
-- Run first. Idempotent.
|
|
4
|
+
-- ============================================================================
|
|
5
|
+
|
|
6
|
+
CREATE CATALOG IF NOT EXISTS {{CATALOG}}
|
|
7
|
+
COMMENT 'Meridian Financial Group — synthetic teaching dataset for the Genie Agents course. Contains deliberate data-quality flaws; not a reference implementation.';
|
|
8
|
+
|
|
9
|
+
-- Curated business data. Everything a Genie Agent is pointed at lives here.
|
|
10
|
+
CREATE SCHEMA IF NOT EXISTS {{CATALOG}}.core
|
|
11
|
+
COMMENT 'Core banking facts and dimensions for Meridian Financial Group.';
|
|
12
|
+
|
|
13
|
+
-- Reference material: unstructured documents for Agent mode and Knowledge Assistant.
|
|
14
|
+
CREATE SCHEMA IF NOT EXISTS {{CATALOG}}.ref
|
|
15
|
+
COMMENT 'Reference and unstructured material: credit committee memos, branch notes, complaint letters.';
|
|
16
|
+
|
|
17
|
+
-- Staging holds the deliberately awful objects used in Modules 7 and 13.
|
|
18
|
+
-- Kept in a separate schema so the "before" and "after" states are visibly distinct.
|
|
19
|
+
CREATE SCHEMA IF NOT EXISTS {{CATALOG}}.staging
|
|
20
|
+
COMMENT 'Deliberately unfit-for-purpose objects used to teach scoping and latency. Never point a production agent here.';
|
|
21
|
+
|
|
22
|
+
CREATE VOLUME IF NOT EXISTS {{CATALOG}}.ref.documents
|
|
23
|
+
COMMENT 'PDFs attached to the agent for Agent mode: credit committee memos, branch manager notes, customer complaint letters.';
|
|
24
|
+
|
|
25
|
+
-- ----------------------------------------------------------------------------
|
|
26
|
+
-- Verify
|
|
27
|
+
-- ----------------------------------------------------------------------------
|
|
28
|
+
SELECT 'catalog' AS object, '{{CATALOG}}' AS name
|
|
29
|
+
UNION ALL SELECT 'schema', schema_name FROM {{CATALOG}}.information_schema.schemata
|
|
30
|
+
WHERE catalog_name = '{{CATALOG}}'
|
|
31
|
+
ORDER BY object, name;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Meridian Financial Group — 02. Dimensions
|
|
3
|
+
--
|
|
4
|
+
-- Plants four of the nine flaws:
|
|
5
|
+
-- #2 fiscal year starts 1 October -> dim_date
|
|
6
|
+
-- #3 region and state are CODES, not names -> dim_branch
|
|
7
|
+
-- #5 two competing product hierarchies -> dim_product
|
|
8
|
+
-- #8 PII columns present and unmasked -> dim_customer
|
|
9
|
+
-- #9 multi-currency needs as-of-date FX -> dim_fx_rate
|
|
10
|
+
--
|
|
11
|
+
-- All values derive from hash() of the row key, never rand(), so every learner
|
|
12
|
+
-- gets byte-identical data and benchmark ground-truth SQL stays valid.
|
|
13
|
+
--
|
|
14
|
+
-- ANCHOR DATE: 2026-09-30 (last day of FY2026). Ages and tenures are computed
|
|
15
|
+
-- from this fixed date rather than current_date, or the data would drift and
|
|
16
|
+
-- benchmark answers would rot.
|
|
17
|
+
-- ============================================================================
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
-- ============================================================================
|
|
21
|
+
-- dim_date — FLAW #2: fiscal year begins 1 October
|
|
22
|
+
-- FY2025 = 2024-10-01 .. 2025-09-30
|
|
23
|
+
-- FY2026 = 2025-10-01 .. 2026-09-30
|
|
24
|
+
-- Two complete fiscal years, so "this year vs last year" questions work.
|
|
25
|
+
-- ============================================================================
|
|
26
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_date
|
|
27
|
+
COMMENT 'Calendar with Meridian fiscal attributes. Meridian fiscal year starts 1 October: FY2026 = 2025-10-01 to 2026-09-30. Fiscal and calendar columns are both present and will disagree.'
|
|
28
|
+
AS
|
|
29
|
+
WITH d AS (
|
|
30
|
+
SELECT explode(sequence(DATE'2024-10-01', DATE'2026-09-30', INTERVAL 1 DAY)) AS date_key
|
|
31
|
+
),
|
|
32
|
+
calc AS (
|
|
33
|
+
SELECT
|
|
34
|
+
date_key,
|
|
35
|
+
year(date_key) AS cal_y,
|
|
36
|
+
month(date_key) AS cal_m,
|
|
37
|
+
-- Oct-Dec belong to the NEXT fiscal year
|
|
38
|
+
year(date_key) + CASE WHEN month(date_key) >= 10 THEN 1 ELSE 0 END AS fy_num,
|
|
39
|
+
-- Fiscal quarter: Q1=Oct-Dec, Q2=Jan-Mar, Q3=Apr-Jun, Q4=Jul-Sep
|
|
40
|
+
(pmod(month(date_key) + 2, 12) DIV 3) + 1 AS fq_num,
|
|
41
|
+
-- Fiscal month 1..12 where 1 = October
|
|
42
|
+
pmod(month(date_key) + 2, 12) + 1 AS fm_num
|
|
43
|
+
FROM d
|
|
44
|
+
)
|
|
45
|
+
SELECT
|
|
46
|
+
date_key,
|
|
47
|
+
concat('FY', cast(fy_num AS STRING)) AS fiscal_year,
|
|
48
|
+
concat('FY', cast(fy_num AS STRING), '-Q', cast(fq_num AS STRING)) AS fiscal_quarter,
|
|
49
|
+
fm_num AS fiscal_month,
|
|
50
|
+
cal_y AS calendar_year,
|
|
51
|
+
cal_m AS calendar_month,
|
|
52
|
+
date_format(date_key, 'EEEE') AS day_name,
|
|
53
|
+
-- Approximate US banking calendar: weekends plus the fixed-date federal
|
|
54
|
+
-- holidays. Good enough to make "business days" a meaningful filter; not a
|
|
55
|
+
-- substitute for a real holiday calendar.
|
|
56
|
+
CASE
|
|
57
|
+
WHEN dayofweek(date_key) IN (1, 7) THEN false
|
|
58
|
+
WHEN date_format(date_key, 'MM-dd') IN
|
|
59
|
+
('01-01','06-19','07-04','11-11','12-25') THEN false
|
|
60
|
+
ELSE true
|
|
61
|
+
END AS is_business_day
|
|
62
|
+
FROM calc;
|
|
63
|
+
|
|
64
|
+
ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN date_key
|
|
65
|
+
COMMENT 'Calendar date. One row per day.';
|
|
66
|
+
ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN fiscal_year
|
|
67
|
+
COMMENT 'Meridian fiscal year, format FY2026. The fiscal year STARTS 1 OCTOBER, so FY2026 runs 2025-10-01 to 2026-09-30. "Last year" means the prior FISCAL year unless the user says "calendar year".';
|
|
68
|
+
ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN fiscal_quarter
|
|
69
|
+
COMMENT 'Meridian fiscal quarter, format FY2026-Q3. Q1 = Oct-Dec, Q2 = Jan-Mar, Q3 = Apr-Jun, Q4 = Jul-Sep.';
|
|
70
|
+
ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN calendar_year
|
|
71
|
+
COMMENT 'Calendar year. Use ONLY when the user explicitly asks for calendar periods; otherwise use fiscal_year.';
|
|
72
|
+
ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN is_business_day
|
|
73
|
+
COMMENT 'False for weekends and fixed-date US federal holidays.';
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
-- ============================================================================
|
|
77
|
+
-- dim_branch — FLAW #3: region and state are stored as CODES
|
|
78
|
+
-- Users say "Northeast", "the West Coast", "California".
|
|
79
|
+
-- The table holds NE, WEST, CA. Without entity matching, Genie filters on the
|
|
80
|
+
-- spoken form, matches nothing, and returns a confident zero.
|
|
81
|
+
-- ============================================================================
|
|
82
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_branch
|
|
83
|
+
COMMENT 'Meridian branch network, 340 branches. Region and state are stored as SHORT CODES, not the names people say out loud.'
|
|
84
|
+
AS
|
|
85
|
+
WITH b AS (SELECT id AS n FROM range(1, 341)),
|
|
86
|
+
assigned AS (
|
|
87
|
+
SELECT
|
|
88
|
+
n,
|
|
89
|
+
-- Deterministic region assignment, weighted toward NE and WEST
|
|
90
|
+
CASE pmod(hash(concat('branch-region-', n)), 10)
|
|
91
|
+
WHEN 0 THEN 'NE' WHEN 1 THEN 'NE' WHEN 2 THEN 'NE'
|
|
92
|
+
WHEN 3 THEN 'SE' WHEN 4 THEN 'SE'
|
|
93
|
+
WHEN 5 THEN 'MW' WHEN 6 THEN 'MW'
|
|
94
|
+
ELSE 'WEST'
|
|
95
|
+
END AS region,
|
|
96
|
+
pmod(hash(concat('branch-state-', n)), 4) AS state_pick,
|
|
97
|
+
pmod(hash(concat('branch-chan-', n)), 10) AS chan_pick,
|
|
98
|
+
pmod(hash(concat('branch-open-', n)), 7300) AS open_offset
|
|
99
|
+
FROM b
|
|
100
|
+
)
|
|
101
|
+
SELECT
|
|
102
|
+
concat('BR', lpad(cast(n AS STRING), 4, '0')) AS branch_id,
|
|
103
|
+
concat('Branch ', lpad(cast(n AS STRING), 4, '0')) AS branch_name,
|
|
104
|
+
region,
|
|
105
|
+
-- States are real US codes, mapped to their actual region
|
|
106
|
+
CASE region
|
|
107
|
+
WHEN 'NE' THEN element_at(array('NY','MA','NJ','PA'), state_pick + 1)
|
|
108
|
+
WHEN 'SE' THEN element_at(array('FL','GA','NC','TN'), state_pick + 1)
|
|
109
|
+
WHEN 'MW' THEN element_at(array('IL','OH','MI','MN'), state_pick + 1)
|
|
110
|
+
ELSE element_at(array('CA','WA','AZ','CO'), state_pick + 1)
|
|
111
|
+
END AS state,
|
|
112
|
+
CASE WHEN chan_pick < 7 THEN 'RETAIL'
|
|
113
|
+
WHEN chan_pick < 9 THEN 'COMMERCIAL'
|
|
114
|
+
ELSE 'PRIVATE_CLIENT' END AS channel,
|
|
115
|
+
date_add(DATE'2006-01-01', open_offset) AS opened_date
|
|
116
|
+
FROM assigned;
|
|
117
|
+
|
|
118
|
+
ALTER TABLE {{CATALOG}}.core.dim_branch ALTER COLUMN region
|
|
119
|
+
COMMENT 'Sales region CODE. Values: NE, SE, MW, WEST. Users say "Northeast" (NE), "Southeast" (SE), "Midwest" (MW), "the West" or "West Coast" (WEST).';
|
|
120
|
+
ALTER TABLE {{CATALOG}}.core.dim_branch ALTER COLUMN state
|
|
121
|
+
COMMENT 'Two-letter US state CODE, e.g. CA, NY, TX. Users say the full state name ("California"). Enable entity matching on this column.';
|
|
122
|
+
ALTER TABLE {{CATALOG}}.core.dim_branch ALTER COLUMN channel
|
|
123
|
+
COMMENT 'Branch servicing model. Values: RETAIL, COMMERCIAL, PRIVATE_CLIENT.';
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
-- ============================================================================
|
|
127
|
+
-- dim_product — FLAW #5: two competing hierarchies
|
|
128
|
+
-- product_category = how the business talks about products
|
|
129
|
+
-- regulatory_product_class = how they roll up for Basel/regulatory reporting
|
|
130
|
+
-- Both are correct. Leaving both visible produces two different answers to
|
|
131
|
+
-- "revenue by product", and nobody can tell which one they got.
|
|
132
|
+
-- ============================================================================
|
|
133
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_product
|
|
134
|
+
COMMENT 'Product master. Carries TWO hierarchies that do not align: product_category (business view) and regulatory_product_class (Basel reporting view). Expose only one to an agent.'
|
|
135
|
+
AS
|
|
136
|
+
SELECT * FROM VALUES
|
|
137
|
+
('P01','Everyday Checking', 'DEPOSITS','RETAIL_DEPOSIT'),
|
|
138
|
+
('P02','Premier Checking', 'DEPOSITS','RETAIL_DEPOSIT'),
|
|
139
|
+
('P03','High-Yield Savings', 'DEPOSITS','RETAIL_DEPOSIT'),
|
|
140
|
+
('P04','12-Month CD', 'DEPOSITS','RETAIL_DEPOSIT'),
|
|
141
|
+
('P05','Money Market', 'DEPOSITS','RETAIL_DEPOSIT'),
|
|
142
|
+
('P06','Business Checking', 'DEPOSITS','CORPORATE'),
|
|
143
|
+
('P07','Rewards Credit Card', 'CARDS','QUALIFYING_REVOLVING'),
|
|
144
|
+
('P08','Cashback Credit Card', 'CARDS','QUALIFYING_REVOLVING'),
|
|
145
|
+
('P09','Secured Credit Card', 'CARDS','OTHER_RETAIL'),
|
|
146
|
+
('P10','Debit Card', 'CARDS','RETAIL_DEPOSIT'),
|
|
147
|
+
('P11','Commercial Card', 'CARDS','CORPORATE'),
|
|
148
|
+
('P12','30-Year Fixed Mortgage', 'LENDING','RESIDENTIAL_MORTGAGE'),
|
|
149
|
+
('P13','15-Year Fixed Mortgage', 'LENDING','RESIDENTIAL_MORTGAGE'),
|
|
150
|
+
('P14','HELOC', 'LENDING','RESIDENTIAL_MORTGAGE'),
|
|
151
|
+
('P15','Auto Loan', 'LENDING','OTHER_RETAIL'),
|
|
152
|
+
('P16','Personal Loan', 'LENDING','OTHER_RETAIL'),
|
|
153
|
+
('P17','Small Business Term Loan', 'LENDING','CORPORATE'),
|
|
154
|
+
('P18','Managed Portfolio', 'WEALTH','OFF_BALANCE_SHEET'),
|
|
155
|
+
('P19','Advisory Account', 'WEALTH','OFF_BALANCE_SHEET'),
|
|
156
|
+
('P20','Traditional IRA', 'WEALTH','OFF_BALANCE_SHEET')
|
|
157
|
+
AS t(product_id, product_name, product_category, regulatory_product_class);
|
|
158
|
+
|
|
159
|
+
ALTER TABLE {{CATALOG}}.core.dim_product ALTER COLUMN product_category
|
|
160
|
+
COMMENT 'BUSINESS product hierarchy. Values: DEPOSITS, CARDS, LENDING, WEALTH. This is the one business users mean by "product". Prefer this and hide regulatory_product_class.';
|
|
161
|
+
ALTER TABLE {{CATALOG}}.core.dim_product ALTER COLUMN regulatory_product_class
|
|
162
|
+
COMMENT 'REGULATORY (Basel) product hierarchy used for capital reporting. Does NOT align with product_category - a Debit Card is CARDS here but RETAIL_DEPOSIT there. Never mix the two in one rollup.';
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
-- ============================================================================
|
|
166
|
+
-- dim_customer — FLAW #8: PII columns, deliberately unprotected
|
|
167
|
+
-- ssn_last4, email, dob and annual_income all sit here in the clear so that
|
|
168
|
+
-- Module 6 can demonstrate column masks doing real work, and demonstrate a
|
|
169
|
+
-- text instruction ("never show PII") failing to.
|
|
170
|
+
--
|
|
171
|
+
-- Every value is synthetic. Emails use example.com (RFC 2606 reserved) and
|
|
172
|
+
-- ssn_last4 is a hashed 4-digit string, not derived from anything real.
|
|
173
|
+
-- ============================================================================
|
|
174
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_customer
|
|
175
|
+
COMMENT 'Retail and commercial customers, 2.1M rows. CONTAINS PII: ssn_last4, email, dob, annual_income. All values are synthetic. Protect with Unity Catalog column masks, never with agent instructions.'
|
|
176
|
+
AS
|
|
177
|
+
WITH c AS (SELECT id AS n FROM range(1, 2100001)),
|
|
178
|
+
calc AS (
|
|
179
|
+
SELECT
|
|
180
|
+
n,
|
|
181
|
+
pmod(hash(concat('cust-seg-', n)), 100) AS seg_pick,
|
|
182
|
+
pmod(hash(concat('cust-tenure-', n)), 240) AS tenure_m,
|
|
183
|
+
pmod(hash(concat('cust-branch-', n)), 340) AS branch_n,
|
|
184
|
+
pmod(hash(concat('cust-ssn-', n)), 10000) AS ssn4,
|
|
185
|
+
pmod(hash(concat('cust-age-', n)), 67) AS age_offset,
|
|
186
|
+
pmod(hash(concat('cust-inc-', n)), 240) AS inc_pick
|
|
187
|
+
FROM c
|
|
188
|
+
)
|
|
189
|
+
SELECT
|
|
190
|
+
concat('C', lpad(cast(n AS STRING), 8, '0')) AS customer_id,
|
|
191
|
+
CASE WHEN seg_pick < 62 THEN 'MASS'
|
|
192
|
+
WHEN seg_pick < 84 THEN 'AFFLUENT'
|
|
193
|
+
WHEN seg_pick < 94 THEN 'PRIVATE'
|
|
194
|
+
ELSE 'COMMERCIAL' END AS segment,
|
|
195
|
+
tenure_m AS tenure_months,
|
|
196
|
+
concat('BR', lpad(cast(branch_n + 1 AS STRING), 4, '0')) AS home_branch_id,
|
|
197
|
+
lpad(cast(ssn4 AS STRING), 4, '0') AS ssn_last4,
|
|
198
|
+
concat('customer', cast(n AS STRING), '@example.com') AS email,
|
|
199
|
+
-- Ages 18-84 as of the 2026-09-30 anchor
|
|
200
|
+
date_add(DATE'2026-09-30', -1 * ((18 + age_offset) * 365 + pmod(hash(concat('cust-dob-', n)), 365))) AS dob,
|
|
201
|
+
cast(25000 + inc_pick * 1250 AS DECIMAL(12,2)) AS annual_income
|
|
202
|
+
FROM calc;
|
|
203
|
+
|
|
204
|
+
ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN segment
|
|
205
|
+
COMMENT 'Customer segment. Values: MASS, AFFLUENT, PRIVATE, COMMERCIAL.';
|
|
206
|
+
ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN tenure_months
|
|
207
|
+
COMMENT 'Months since the customer relationship began, as of 2026-09-30.';
|
|
208
|
+
ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN ssn_last4
|
|
209
|
+
COMMENT 'PII. Last four digits of tax identifier (synthetic). Must be masked. Never expose in an answer.';
|
|
210
|
+
ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN email
|
|
211
|
+
COMMENT 'PII. Contact email (synthetic, example.com). Must be masked. Never expose in an answer.';
|
|
212
|
+
ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN dob
|
|
213
|
+
COMMENT 'PII. Date of birth (synthetic). Must be masked. Use age bands for analysis instead.';
|
|
214
|
+
ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN annual_income
|
|
215
|
+
COMMENT 'PII. Self-reported annual income in USD (synthetic). Restricted - finance roles only.';
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
-- ============================================================================
|
|
219
|
+
-- dim_account — the join hub
|
|
220
|
+
-- Every fact joins to a dimension THROUGH this table. Getting its cardinality
|
|
221
|
+
-- wrong is how "revenue by region" quietly multiplies (Module 9).
|
|
222
|
+
-- ============================================================================
|
|
223
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_account
|
|
224
|
+
COMMENT 'Account master, ~2.9M rows. The join hub: transactions and balances reach customer, branch and product through this table.'
|
|
225
|
+
AS
|
|
226
|
+
WITH a AS (SELECT id AS n FROM range(1, 2900001)),
|
|
227
|
+
calc AS (
|
|
228
|
+
SELECT
|
|
229
|
+
n,
|
|
230
|
+
pmod(hash(concat('acct-cust-', n)), 2100000) AS cust_n,
|
|
231
|
+
pmod(hash(concat('acct-prod-', n)), 20) AS prod_n,
|
|
232
|
+
pmod(hash(concat('acct-branch-', n)), 340) AS branch_n,
|
|
233
|
+
pmod(hash(concat('acct-open-', n)), 5475) AS open_offset,
|
|
234
|
+
pmod(hash(concat('acct-status-', n)), 100) AS status_pick
|
|
235
|
+
FROM a
|
|
236
|
+
)
|
|
237
|
+
SELECT
|
|
238
|
+
concat('A', lpad(cast(n AS STRING), 9, '0')) AS account_id,
|
|
239
|
+
concat('C', lpad(cast(cust_n + 1 AS STRING), 8, '0')) AS customer_id,
|
|
240
|
+
concat('P', lpad(cast(prod_n + 1 AS STRING), 2, '0')) AS product_id,
|
|
241
|
+
concat('BR', lpad(cast(branch_n + 1 AS STRING), 4, '0')) AS branch_id,
|
|
242
|
+
date_add(DATE'2011-10-01', open_offset) AS opened_date,
|
|
243
|
+
CASE WHEN status_pick < 8
|
|
244
|
+
THEN date_add(DATE'2011-10-01', open_offset + 400 + pmod(hash(concat('acct-close-', n)), 1500))
|
|
245
|
+
ELSE NULL END AS closed_date,
|
|
246
|
+
CASE WHEN status_pick < 8 THEN 'CLOSED' ELSE 'OPEN' END AS status
|
|
247
|
+
FROM calc;
|
|
248
|
+
|
|
249
|
+
ALTER TABLE {{CATALOG}}.core.dim_account ALTER COLUMN status
|
|
250
|
+
COMMENT 'Account status. Values: OPEN, CLOSED. Roughly 8% are CLOSED. Exclude CLOSED accounts from "active customer" style questions.';
|
|
251
|
+
ALTER TABLE {{CATALOG}}.core.dim_account ALTER COLUMN closed_date
|
|
252
|
+
COMMENT 'Date the account was closed, NULL while open.';
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
-- ============================================================================
|
|
256
|
+
-- dim_fx_rate — FLAW #9: multi-currency needs an as-of-date join
|
|
257
|
+
-- Commercial transactions settle in CAD and GBP. Summing amount across
|
|
258
|
+
-- currencies without converting produces a number that does not tie to finance.
|
|
259
|
+
-- ============================================================================
|
|
260
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_fx_rate
|
|
261
|
+
COMMENT 'Daily FX rates to USD. Transactions in CAD or GBP must be converted using the rate AS OF the transaction date, not the latest rate.'
|
|
262
|
+
AS
|
|
263
|
+
WITH d AS (
|
|
264
|
+
SELECT explode(sequence(DATE'2024-10-01', DATE'2026-09-30', INTERVAL 1 DAY)) AS rate_date
|
|
265
|
+
),
|
|
266
|
+
cur AS (SELECT explode(array('USD','CAD','GBP')) AS currency)
|
|
267
|
+
SELECT
|
|
268
|
+
cur.currency,
|
|
269
|
+
d.rate_date,
|
|
270
|
+
CASE cur.currency
|
|
271
|
+
WHEN 'USD' THEN cast(1.0 AS DECIMAL(12,6))
|
|
272
|
+
-- Small deterministic drift around a plausible central rate
|
|
273
|
+
WHEN 'CAD' THEN cast(0.730 + (pmod(hash(concat('fx-cad-', d.rate_date)), 40) - 20) / 2000.0 AS DECIMAL(12,6))
|
|
274
|
+
ELSE cast(1.265 + (pmod(hash(concat('fx-gbp-', d.rate_date)), 60) - 30) / 2000.0 AS DECIMAL(12,6))
|
|
275
|
+
END AS usd_rate
|
|
276
|
+
FROM d CROSS JOIN cur;
|
|
277
|
+
|
|
278
|
+
ALTER TABLE {{CATALOG}}.core.dim_fx_rate ALTER COLUMN usd_rate
|
|
279
|
+
COMMENT 'Multiply a native-currency amount by this to get USD. Join on BOTH currency AND the transaction date.';
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
-- ----------------------------------------------------------------------------
|
|
283
|
+
-- Row counts
|
|
284
|
+
-- ----------------------------------------------------------------------------
|
|
285
|
+
SELECT 'dim_date' AS table_name, count(*) AS rows FROM {{CATALOG}}.core.dim_date
|
|
286
|
+
UNION ALL SELECT 'dim_branch', count(*) FROM {{CATALOG}}.core.dim_branch
|
|
287
|
+
UNION ALL SELECT 'dim_product', count(*) FROM {{CATALOG}}.core.dim_product
|
|
288
|
+
UNION ALL SELECT 'dim_customer', count(*) FROM {{CATALOG}}.core.dim_customer
|
|
289
|
+
UNION ALL SELECT 'dim_account', count(*) FROM {{CATALOG}}.core.dim_account
|
|
290
|
+
UNION ALL SELECT 'dim_fx_rate', count(*) FROM {{CATALOG}}.core.dim_fx_rate
|
|
291
|
+
ORDER BY table_name;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Meridian Financial Group — 03. Facts
|
|
3
|
+
--
|
|
4
|
+
-- Plants the four remaining flaws:
|
|
5
|
+
-- #1 fee_revenue is GROSS; reversals live in a separate table
|
|
6
|
+
-- #4 DECLINED rows inflate transaction COUNTS while contributing no revenue
|
|
7
|
+
-- #6 fct_loan_balances is a DAILY SNAPSHOT — SUM() across dates is ~30x wrong
|
|
8
|
+
-- #7 "delinquent" / "seriously delinquent" / "default" / "charge-off" are
|
|
9
|
+
-- four different things, all present, all defensible
|
|
10
|
+
--
|
|
11
|
+
-- TIER: the line marked -- << TIER >> controls scale.
|
|
12
|
+
-- Small 20000000 default. Every module except 13.
|
|
13
|
+
-- Large 900000000 Module 13 only (latency). Leave unclustered on purpose.
|
|
14
|
+
-- ============================================================================
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
-- ============================================================================
|
|
18
|
+
-- fct_transactions — FLAWS #1, #4, #9
|
|
19
|
+
-- ============================================================================
|
|
20
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_transactions
|
|
21
|
+
COMMENT 'Transaction-level fact. fee_revenue is GROSS - it does NOT deduct reversals, which live in fct_reversals. Only status = POSTED is revenue; DECLINED rows exist and will inflate transaction counts if not excluded.'
|
|
22
|
+
AS
|
|
23
|
+
WITH t AS (
|
|
24
|
+
SELECT id AS n FROM range(1, {{TXN_COUNT}} + 1) -- << TIER >> set by the tier in manifest.json
|
|
25
|
+
),
|
|
26
|
+
calc AS (
|
|
27
|
+
SELECT
|
|
28
|
+
n,
|
|
29
|
+
pmod(hash(concat('txn-acct-', n)), 2900000) AS acct_n,
|
|
30
|
+
pmod(hash(concat('txn-day-', n)), 730) AS day_offset,
|
|
31
|
+
pmod(hash(concat('txn-amt-', n)), 100000) AS amt_pick,
|
|
32
|
+
pmod(hash(concat('txn-stat-', n)), 1000) AS stat_pick,
|
|
33
|
+
pmod(hash(concat('txn-mcc-', n)), 12) AS mcc_pick,
|
|
34
|
+
pmod(hash(concat('txn-cur-', n)), 100) AS cur_pick
|
|
35
|
+
FROM t
|
|
36
|
+
),
|
|
37
|
+
shaped AS (
|
|
38
|
+
SELECT
|
|
39
|
+
n,
|
|
40
|
+
concat('T', lpad(cast(n AS STRING), 12, '0')) AS txn_id,
|
|
41
|
+
concat('A', lpad(cast(acct_n + 1 AS STRING), 9, '0')) AS account_id,
|
|
42
|
+
date_add(DATE'2024-10-01', day_offset) AS txn_date,
|
|
43
|
+
-- Amounts are log-ish: many small, few large
|
|
44
|
+
cast(
|
|
45
|
+
CASE WHEN amt_pick < 70000 THEN 5 + amt_pick / 1000.0
|
|
46
|
+
WHEN amt_pick < 95000 THEN 120 + amt_pick / 200.0
|
|
47
|
+
ELSE 900 + amt_pick / 50.0
|
|
48
|
+
END AS DECIMAL(14,2)) AS amount,
|
|
49
|
+
-- FLAW #4: only POSTED is revenue, but DECLINED is 6% of rows
|
|
50
|
+
CASE WHEN stat_pick < 880 THEN 'POSTED'
|
|
51
|
+
WHEN stat_pick < 920 THEN 'PENDING'
|
|
52
|
+
WHEN stat_pick < 980 THEN 'DECLINED'
|
|
53
|
+
ELSE 'REVERSED'
|
|
54
|
+
END AS status,
|
|
55
|
+
element_at(array(
|
|
56
|
+
'GROCERY','RESTAURANT','FUEL','AIRLINE','HOTEL','RETAIL',
|
|
57
|
+
'UTILITIES','HEALTHCARE','ENTERTAINMENT','TRANSFER','ATM','PROFESSIONAL_SERVICES'
|
|
58
|
+
), mcc_pick + 1) AS merchant_category,
|
|
59
|
+
cur_pick
|
|
60
|
+
FROM calc
|
|
61
|
+
)
|
|
62
|
+
SELECT
|
|
63
|
+
s.txn_id,
|
|
64
|
+
s.account_id,
|
|
65
|
+
s.txn_date,
|
|
66
|
+
s.amount,
|
|
67
|
+
-- FLAW #1: GROSS fee revenue. Reversals are NOT deducted here.
|
|
68
|
+
-- Only POSTED transactions earn anything.
|
|
69
|
+
CASE WHEN s.status = 'POSTED'
|
|
70
|
+
THEN cast(round(s.amount * 0.0185, 2) AS DECIMAL(14,2))
|
|
71
|
+
ELSE cast(0 AS DECIMAL(14,2)) END AS fee_revenue,
|
|
72
|
+
CASE WHEN s.status = 'POSTED'
|
|
73
|
+
THEN cast(round(s.amount * 0.0110, 2) AS DECIMAL(14,2))
|
|
74
|
+
ELSE cast(0 AS DECIMAL(14,2)) END AS interchange,
|
|
75
|
+
s.merchant_category,
|
|
76
|
+
-- FLAW #9: COMMERCIAL customers settle in CAD/GBP. Everyone else is USD.
|
|
77
|
+
-- Summing amount across currencies without converting is silently wrong.
|
|
78
|
+
CASE WHEN c.segment = 'COMMERCIAL' AND s.cur_pick < 40
|
|
79
|
+
THEN CASE WHEN s.cur_pick < 25 THEN 'CAD' ELSE 'GBP' END
|
|
80
|
+
ELSE 'USD' END AS currency,
|
|
81
|
+
s.status
|
|
82
|
+
FROM shaped s
|
|
83
|
+
JOIN {{CATALOG}}.core.dim_account a ON a.account_id = s.account_id
|
|
84
|
+
JOIN {{CATALOG}}.core.dim_customer c ON c.customer_id = a.customer_id;
|
|
85
|
+
|
|
86
|
+
ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN fee_revenue
|
|
87
|
+
COMMENT 'GROSS fee revenue in the transaction currency, before reversals and chargebacks. Do NOT sum this column alone and call it revenue - subtract fct_reversals.reversal_amount. Zero for non-POSTED rows.';
|
|
88
|
+
ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN status
|
|
89
|
+
COMMENT 'Transaction status. Values: POSTED, PENDING, DECLINED, REVERSED. ONLY POSTED counts as revenue. DECLINED rows are ~6% of the table and will inflate transaction counts if not filtered out.';
|
|
90
|
+
ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN currency
|
|
91
|
+
COMMENT 'Settlement currency. Values: USD, CAD, GBP. COMMERCIAL customers transact in all three. Convert with dim_fx_rate joined on currency AND txn_date before summing across currencies.';
|
|
92
|
+
ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN amount
|
|
93
|
+
COMMENT 'Transaction amount in the settlement currency (see currency column), not USD.';
|
|
94
|
+
ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN merchant_category
|
|
95
|
+
COMMENT 'Merchant category. 12 distinct values - a good candidate for entity matching.';
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
-- ============================================================================
|
|
99
|
+
-- fct_reversals — the other half of FLAW #1
|
|
100
|
+
-- Deliberately a SEPARATE table. If reversals were a column on
|
|
101
|
+
-- fct_transactions nobody would ever get gross vs net wrong.
|
|
102
|
+
-- ============================================================================
|
|
103
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_reversals
|
|
104
|
+
COMMENT 'Reversals and chargebacks. The amount here must be SUBTRACTED from fct_transactions.fee_revenue to get net fee revenue. One row per reversed transaction.'
|
|
105
|
+
AS
|
|
106
|
+
SELECT
|
|
107
|
+
t.txn_id,
|
|
108
|
+
date_add(t.txn_date, 3 + pmod(hash(concat('rev-lag-', t.txn_id)), 25)) AS reversal_date,
|
|
109
|
+
cast(round(t.fee_revenue, 2) AS DECIMAL(14,2)) AS reversal_amount,
|
|
110
|
+
element_at(array('DISPUTE','FRAUD','DUPLICATE','MERCHANT_ERROR','AUTHORISATION_FAIL'),
|
|
111
|
+
pmod(hash(concat('rev-reason-', t.txn_id)), 5) + 1) AS reason_code
|
|
112
|
+
FROM {{CATALOG}}.core.fct_transactions t
|
|
113
|
+
-- ~3.5% of POSTED transactions get reversed, which is why "revenue" is
|
|
114
|
+
-- overstated by a few percent and nobody notices.
|
|
115
|
+
WHERE t.status = 'POSTED'
|
|
116
|
+
AND pmod(hash(concat('rev-flag-', t.txn_id)), 1000) < 35;
|
|
117
|
+
|
|
118
|
+
ALTER TABLE {{CATALOG}}.core.fct_reversals ALTER COLUMN reversal_amount
|
|
119
|
+
COMMENT 'Fee revenue clawed back. Subtract the sum of this from gross fee_revenue to get NET fee revenue.';
|
|
120
|
+
ALTER TABLE {{CATALOG}}.core.fct_reversals ALTER COLUMN reason_code
|
|
121
|
+
COMMENT 'Reversal reason. Values: DISPUTE, FRAUD, DUPLICATE, MERCHANT_ERROR, AUTHORISATION_FAIL.';
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
-- ============================================================================
|
|
125
|
+
-- fct_loan_balances — FLAWS #6 and #7
|
|
126
|
+
--
|
|
127
|
+
-- ONE ROW PER ACCOUNT PER DAY. This is the single most dangerous table in the
|
|
128
|
+
-- dataset: SUM(principal_balance) over any period longer than a day produces a
|
|
129
|
+
-- number that looks entirely plausible and is roughly 30x too large.
|
|
130
|
+
--
|
|
131
|
+
-- 45,000 lending accounts x 730 days ~ 32.8M rows (~1.4M per month).
|
|
132
|
+
-- ============================================================================
|
|
133
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_loan_balances
|
|
134
|
+
COMMENT 'DAILY SNAPSHOT of the loan book: one row per account per day. NEVER SUM principal_balance across dates - use the latest snapshot for a point-in-time balance, or an average for a period. Also carries the four distinct delinquency concepts.'
|
|
135
|
+
AS
|
|
136
|
+
WITH loan_accounts AS (
|
|
137
|
+
SELECT a.account_id, a.opened_date
|
|
138
|
+
FROM {{CATALOG}}.core.dim_account a
|
|
139
|
+
JOIN {{CATALOG}}.core.dim_product p ON p.product_id = a.product_id
|
|
140
|
+
WHERE p.product_category = 'LENDING'
|
|
141
|
+
AND pmod(hash(concat('loan-pick-', a.account_id)), 100) < 6 -- ~6% sample
|
|
142
|
+
),
|
|
143
|
+
days AS (
|
|
144
|
+
SELECT explode(sequence(DATE'2024-10-01', DATE'2026-09-30', INTERVAL 1 DAY)) AS snapshot_date
|
|
145
|
+
),
|
|
146
|
+
grid AS (
|
|
147
|
+
SELECT
|
|
148
|
+
la.account_id,
|
|
149
|
+
d.snapshot_date,
|
|
150
|
+
datediff(d.snapshot_date, DATE'2024-10-01') AS day_n,
|
|
151
|
+
pmod(hash(concat('loan-orig-', la.account_id)), 400) AS orig_pick,
|
|
152
|
+
pmod(hash(concat('loan-risk-', la.account_id)), 1000) AS risk_pick
|
|
153
|
+
FROM loan_accounts la CROSS JOIN days d
|
|
154
|
+
WHERE d.snapshot_date >= la.opened_date
|
|
155
|
+
),
|
|
156
|
+
shaped AS (
|
|
157
|
+
SELECT
|
|
158
|
+
account_id,
|
|
159
|
+
snapshot_date,
|
|
160
|
+
-- Original principal, amortising slowly over the window
|
|
161
|
+
cast(round((15000 + orig_pick * 850) * (1 - day_n / 4000.0), 2) AS DECIMAL(14,2)) AS principal_balance,
|
|
162
|
+
-- Riskier accounts accumulate days past due over time
|
|
163
|
+
CASE
|
|
164
|
+
WHEN risk_pick < 880 THEN 0
|
|
165
|
+
WHEN risk_pick < 940 THEN pmod(day_n, 45)
|
|
166
|
+
WHEN risk_pick < 980 THEN 30 + pmod(day_n, 60)
|
|
167
|
+
ELSE 90 + pmod(day_n, 120)
|
|
168
|
+
END AS days_past_due
|
|
169
|
+
FROM grid
|
|
170
|
+
)
|
|
171
|
+
SELECT
|
|
172
|
+
account_id,
|
|
173
|
+
snapshot_date,
|
|
174
|
+
principal_balance,
|
|
175
|
+
cast(round(principal_balance * 0.0625 / 365 * 30, 2) AS DECIMAL(14,2)) AS interest_accrued,
|
|
176
|
+
days_past_due,
|
|
177
|
+
-- FLAW #7, part 1: the bucket
|
|
178
|
+
CASE WHEN days_past_due = 0 THEN 'CURRENT'
|
|
179
|
+
WHEN days_past_due < 30 THEN '1-29'
|
|
180
|
+
WHEN days_past_due < 60 THEN '30-59'
|
|
181
|
+
WHEN days_past_due < 90 THEN '60-89'
|
|
182
|
+
ELSE '90+'
|
|
183
|
+
END AS dpd_bucket,
|
|
184
|
+
-- FLAW #7, part 2: a SEPARATE lifecycle status. "Default" and "charge-off"
|
|
185
|
+
-- are accounting events, not DPD thresholds - so "how many delinquent
|
|
186
|
+
-- loans?" has four defensible answers depending on which you mean.
|
|
187
|
+
CASE WHEN days_past_due = 0 THEN 'PERFORMING'
|
|
188
|
+
WHEN days_past_due < 90 THEN 'DELINQUENT'
|
|
189
|
+
WHEN days_past_due < 180 THEN 'DEFAULT'
|
|
190
|
+
ELSE 'CHARGED_OFF'
|
|
191
|
+
END AS loan_status
|
|
192
|
+
FROM shaped;
|
|
193
|
+
|
|
194
|
+
ALTER TABLE {{CATALOG}}.core.fct_loan_balances ALTER COLUMN principal_balance
|
|
195
|
+
COMMENT 'Outstanding principal AS OF snapshot_date. This table has ONE ROW PER ACCOUNT PER DAY - never SUM across dates. For a point-in-time loan book, filter to the latest snapshot_date.';
|
|
196
|
+
ALTER TABLE {{CATALOG}}.core.fct_loan_balances ALTER COLUMN days_past_due
|
|
197
|
+
COMMENT 'Days past due as of snapshot_date. 0 means current.';
|
|
198
|
+
ALTER TABLE {{CATALOG}}.core.fct_loan_balances ALTER COLUMN dpd_bucket
|
|
199
|
+
COMMENT 'Delinquency bucket. Values: CURRENT, 1-29, 30-59, 60-89, 90+. Meridian defines "delinquent" as 30+ days past due and "seriously delinquent" as 90+. Ask which one the user means.';
|
|
200
|
+
ALTER TABLE {{CATALOG}}.core.fct_loan_balances ALTER COLUMN loan_status
|
|
201
|
+
COMMENT 'Accounting lifecycle status, DISTINCT from dpd_bucket. Values: PERFORMING, DELINQUENT, DEFAULT, CHARGED_OFF. "Default" and "charge-off" are accounting events and are NOT the same as being 30+ or 90+ days past due.';
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
-- ============================================================================
|
|
205
|
+
-- fct_applications — approval funnel and cycle time
|
|
206
|
+
-- ============================================================================
|
|
207
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_applications
|
|
208
|
+
COMMENT 'Credit application funnel: submitted -> decisioned -> funded. Approval rate is decisions of APPROVED over all applications. Not every approved application is funded.'
|
|
209
|
+
AS
|
|
210
|
+
WITH ap AS (SELECT id AS n FROM range(1, 420001)),
|
|
211
|
+
calc AS (
|
|
212
|
+
SELECT
|
|
213
|
+
n,
|
|
214
|
+
pmod(hash(concat('app-cust-', n)), 2100000) AS cust_n,
|
|
215
|
+
pmod(hash(concat('app-prod-', n)), 20) AS prod_n,
|
|
216
|
+
pmod(hash(concat('app-day-', n)), 730) AS day_offset,
|
|
217
|
+
pmod(hash(concat('app-dec-', n)), 100) AS dec_pick,
|
|
218
|
+
pmod(hash(concat('app-chan-', n)), 100) AS chan_pick,
|
|
219
|
+
pmod(hash(concat('app-lag-', n)), 14) AS decision_lag,
|
|
220
|
+
pmod(hash(concat('app-fund-', n)), 100) AS fund_pick
|
|
221
|
+
FROM ap
|
|
222
|
+
)
|
|
223
|
+
SELECT
|
|
224
|
+
concat('APP', lpad(cast(n AS STRING), 8, '0')) AS app_id,
|
|
225
|
+
concat('C', lpad(cast(cust_n + 1 AS STRING), 8, '0')) AS customer_id,
|
|
226
|
+
concat('P', lpad(cast(prod_n + 1 AS STRING), 2, '0')) AS product_id,
|
|
227
|
+
cast(date_add(DATE'2024-10-01', day_offset) AS TIMESTAMP) AS submitted_ts,
|
|
228
|
+
cast(date_add(DATE'2024-10-01', day_offset + decision_lag) AS TIMESTAMP) AS decision_ts,
|
|
229
|
+
CASE WHEN dec_pick < 58 AND fund_pick < 82
|
|
230
|
+
THEN cast(date_add(DATE'2024-10-01', day_offset + decision_lag + 2) AS TIMESTAMP)
|
|
231
|
+
ELSE NULL END AS funded_ts,
|
|
232
|
+
CASE WHEN dec_pick < 58 THEN 'APPROVED'
|
|
233
|
+
WHEN dec_pick < 88 THEN 'DECLINED'
|
|
234
|
+
ELSE 'WITHDRAWN' END AS decision,
|
|
235
|
+
CASE WHEN chan_pick < 46 THEN 'DIGITAL'
|
|
236
|
+
WHEN chan_pick < 78 THEN 'BRANCH'
|
|
237
|
+
WHEN chan_pick < 92 THEN 'CALL_CENTRE'
|
|
238
|
+
ELSE 'BROKER' END AS channel
|
|
239
|
+
FROM calc;
|
|
240
|
+
|
|
241
|
+
ALTER TABLE {{CATALOG}}.core.fct_applications ALTER COLUMN decision
|
|
242
|
+
COMMENT 'Underwriting decision. Values: APPROVED, DECLINED, WITHDRAWN. Approval rate = APPROVED / all applications, including WITHDRAWN.';
|
|
243
|
+
ALTER TABLE {{CATALOG}}.core.fct_applications ALTER COLUMN funded_ts
|
|
244
|
+
COMMENT 'When the loan was actually funded. NULL if never funded - roughly 18% of APPROVED applications are never drawn down, so funded count < approved count.';
|
|
245
|
+
ALTER TABLE {{CATALOG}}.core.fct_applications ALTER COLUMN channel
|
|
246
|
+
COMMENT 'Origination channel. Values: DIGITAL, BRANCH, CALL_CENTRE, BROKER.';
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
-- ============================================================================
|
|
250
|
+
-- fct_fraud_cases
|
|
251
|
+
-- ============================================================================
|
|
252
|
+
CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_fraud_cases
|
|
253
|
+
COMMENT 'Fraud and financial-crime cases. loss_amount is the realised loss in USD, zero for cases closed without loss.'
|
|
254
|
+
AS
|
|
255
|
+
WITH f AS (SELECT id AS n FROM range(1, 14001)),
|
|
256
|
+
calc AS (
|
|
257
|
+
SELECT
|
|
258
|
+
n,
|
|
259
|
+
pmod(hash(concat('frd-acct-', n)), 2900000) AS acct_n,
|
|
260
|
+
pmod(hash(concat('frd-day-', n)), 730) AS day_offset,
|
|
261
|
+
pmod(hash(concat('frd-type-', n)), 6) AS type_pick,
|
|
262
|
+
pmod(hash(concat('frd-loss-', n)), 100) AS loss_pick,
|
|
263
|
+
pmod(hash(concat('frd-stat-', n)), 100) AS stat_pick,
|
|
264
|
+
pmod(hash(concat('frd-close-',n)), 60) AS close_lag
|
|
265
|
+
FROM f
|
|
266
|
+
)
|
|
267
|
+
SELECT
|
|
268
|
+
concat('FC', lpad(cast(n AS STRING), 7, '0')) AS case_id,
|
|
269
|
+
concat('A', lpad(cast(acct_n + 1 AS STRING), 9, '0')) AS account_id,
|
|
270
|
+
date_add(DATE'2024-10-01', day_offset) AS opened_date,
|
|
271
|
+
CASE WHEN stat_pick < 84
|
|
272
|
+
THEN date_add(DATE'2024-10-01', day_offset + close_lag)
|
|
273
|
+
ELSE NULL END AS closed_date,
|
|
274
|
+
cast(CASE WHEN loss_pick < 38 THEN 0
|
|
275
|
+
ELSE round(150 + loss_pick * 96.5, 2) END AS DECIMAL(14,2)) AS loss_amount,
|
|
276
|
+
element_at(array('CARD_NOT_PRESENT','ACCOUNT_TAKEOVER','APPLICATION_FRAUD',
|
|
277
|
+
'CHECK_FRAUD','WIRE_FRAUD','FIRST_PARTY'), type_pick + 1) AS fraud_type,
|
|
278
|
+
CASE WHEN stat_pick < 84 THEN 'CLOSED' ELSE 'OPEN' END AS status
|
|
279
|
+
FROM calc;
|
|
280
|
+
|
|
281
|
+
ALTER TABLE {{CATALOG}}.core.fct_fraud_cases ALTER COLUMN fraud_type
|
|
282
|
+
COMMENT 'Fraud typology. Values: CARD_NOT_PRESENT, ACCOUNT_TAKEOVER, APPLICATION_FRAUD, CHECK_FRAUD, WIRE_FRAUD, FIRST_PARTY.';
|
|
283
|
+
ALTER TABLE {{CATALOG}}.core.fct_fraud_cases ALTER COLUMN loss_amount
|
|
284
|
+
COMMENT 'Realised loss in USD. Zero for cases closed with no loss - about 38% of cases.';
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
-- ----------------------------------------------------------------------------
|
|
288
|
+
-- Row counts
|
|
289
|
+
-- ----------------------------------------------------------------------------
|
|
290
|
+
SELECT 'fct_transactions' AS table_name, count(*) AS rows FROM {{CATALOG}}.core.fct_transactions
|
|
291
|
+
UNION ALL SELECT 'fct_reversals', count(*) FROM {{CATALOG}}.core.fct_reversals
|
|
292
|
+
UNION ALL SELECT 'fct_loan_balances', count(*) FROM {{CATALOG}}.core.fct_loan_balances
|
|
293
|
+
UNION ALL SELECT 'fct_applications', count(*) FROM {{CATALOG}}.core.fct_applications
|
|
294
|
+
UNION ALL SELECT 'fct_fraud_cases', count(*) FROM {{CATALOG}}.core.fct_fraud_cases
|
|
295
|
+
ORDER BY table_name;
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "genie-agents",
|
|
3
|
+
"title": "Genie Agents — Meridian Financial Group lab",
|
|
4
|
+
"description": "A synthetic US bank with nine deliberate data-quality flaws. Every flaw is the raw material for a specific module: gross vs net revenue, a fiscal year starting 1 October, region codes users never say out loud, DECLINED rows inflating counts, two competing product hierarchies, a daily-snapshot table that invites a 30x fan-out, four meanings of 'delinquent', unmasked PII, and multi-currency needing an as-of-date join.",
|
|
5
|
+
"default_catalog": "mfg",
|
|
6
|
+
"default_tier": "small",
|
|
7
|
+
"tiers": {
|
|
8
|
+
"small": {
|
|
9
|
+
"description": "20M transactions. Every module except 13. Start here.",
|
|
10
|
+
"values": { "TXN_COUNT": "20000000" }
|
|
11
|
+
},
|
|
12
|
+
"large": {
|
|
13
|
+
"description": "900M transactions, left unclustered on purpose. Module 13 (latency) only.",
|
|
14
|
+
"values": { "TXN_COUNT": "900000000" }
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"notebooks": [
|
|
18
|
+
{
|
|
19
|
+
"order": 1,
|
|
20
|
+
"name": "01_catalog_and_schemas",
|
|
21
|
+
"sql": "01_catalog_and_schemas.sql",
|
|
22
|
+
"title": "01 · Catalog and schemas",
|
|
23
|
+
"intro": "Creates the `{{CATALOG}}` catalog, the core / ref / staging schemas, and the documents volume.\n\nRun this first. Everything else depends on it."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"order": 2,
|
|
27
|
+
"name": "02_dimensions",
|
|
28
|
+
"sql": "02_dimensions.sql",
|
|
29
|
+
"title": "02 · Dimensions",
|
|
30
|
+
"intro": "Six dimensions, planting five of the nine flaws: the 1 October fiscal year, region and state stored as codes, two competing product hierarchies, unmasked PII, and multi-currency FX rates.\n\nEvery value derives from `hash()` of the row key rather than `rand()`, so your data is byte-identical to everyone else's and the benchmark answers in Module 11 stay valid."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"order": 3,
|
|
34
|
+
"name": "03_facts",
|
|
35
|
+
"sql": "03_facts.sql",
|
|
36
|
+
"title": "03 · Facts",
|
|
37
|
+
"intro": "Five fact tables carrying the four remaining flaws.\n\n**This is the slow one** — it generates {{TXN_COUNT}} transactions plus roughly 35M daily loan-balance snapshots. Expect several minutes on a small warehouse.",
|
|
38
|
+
"slow": true
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: databricks360
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Install Databricks course lab environments: notebooks, catalogs, datasets and governance objects.
|
|
5
|
+
Project-URL: Homepage, https://github.com/databrickslms/dbxdemos
|
|
6
|
+
Project-URL: Source, https://github.com/databrickslms/dbxdemos
|
|
7
|
+
Project-URL: Issues, https://github.com/databrickslms/dbxdemos/issues
|
|
8
|
+
Author: Lakehouse Academy
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: databricks,genie,lakehouse,training,unity-catalog
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Education
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Education
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: databricks-sdk>=0.38.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# databricks360
|
|
23
|
+
|
|
24
|
+
Installs Databricks course lab environments — notebooks, catalogs, datasets and
|
|
25
|
+
governance objects — into your own workspace.
|
|
26
|
+
|
|
27
|
+
Modelled on `dbdemos`: you run it **inside a Databricks notebook**, so
|
|
28
|
+
`databricks-sdk` picks up the notebook's own identity. There is no host, token or
|
|
29
|
+
profile to configure.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
Not on PyPI yet, so install from the repo. In a **Databricks notebook**:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
%pip install git+https://github.com/databrickslms/dbxdemos.git
|
|
37
|
+
dbutils.library.restartPython()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Once published, that becomes `%pip install databricks360`.
|
|
41
|
+
|
|
42
|
+
To pin a version, append a tag or commit:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
%pip install git+https://github.com/databrickslms/dbxdemos.git@v0.1.0
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import databricks360 as academy
|
|
52
|
+
|
|
53
|
+
academy.list_courses()
|
|
54
|
+
academy.install('genie-agents')
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`install` writes the lab notebooks into your workspace and prints the run order.
|
|
58
|
+
Then you open them and run each in turn.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
Installed 'genie-agents' → /Workspace/Users/you@corp.com/databricks360/genie-agents
|
|
62
|
+
catalog: mfg tier: small
|
|
63
|
+
|
|
64
|
+
Run these in order:
|
|
65
|
+
1. 01_catalog_and_schemas
|
|
66
|
+
2. 02_dimensions
|
|
67
|
+
3. 03_facts (slow)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Options
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
academy.install(
|
|
74
|
+
'genie-agents',
|
|
75
|
+
path='/Workspace/Shared/labs', # default: your home folder
|
|
76
|
+
catalog='training_v2', # default: the course's own catalog
|
|
77
|
+
tier='large', # default: 'small'
|
|
78
|
+
overwrite=True, # replace existing notebooks
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Why it does not run the notebooks for you
|
|
83
|
+
|
|
84
|
+
`dbdemos` starts a job and loads the data on your behalf. This deliberately does
|
|
85
|
+
not. Generating the data is the substance of Module 0 — the point is to watch a
|
|
86
|
+
warehouse chew through 20M rows and see the flaws appear, not to have a finished
|
|
87
|
+
catalog materialise. It also means nothing consumes your DBUs without you asking.
|
|
88
|
+
|
|
89
|
+
## Tiers
|
|
90
|
+
|
|
91
|
+
| Tier | Transactions | Use |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `small` | 20M | default. Every module except 13 |
|
|
94
|
+
| `large` | 900M | Module 13 (latency) only. Left unclustered on purpose |
|
|
95
|
+
|
|
96
|
+
Start small. The large tier exists because you cannot measure query latency on a
|
|
97
|
+
toy dataset, and nowhere else needs it.
|
|
98
|
+
|
|
99
|
+
## Adding a course
|
|
100
|
+
|
|
101
|
+
Each course is a subpackage under `databricks360/courses/`:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
databricks360/courses/<course_id>/
|
|
105
|
+
__init__.py
|
|
106
|
+
manifest.json # title, default catalog, tiers, notebooks in run order
|
|
107
|
+
*.sql # synced from content/courses/<id>/assets/lab/
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Placeholders available in the SQL: `{{CATALOG}}`, plus anything declared under a
|
|
111
|
+
tier's `values` (currently `{{TXN_COUNT}}`). An unresolved placeholder raises rather
|
|
112
|
+
than silently rendering empty.
|
|
113
|
+
|
|
114
|
+
Dataset documentation lives in [`docs/`](docs/).
|
|
115
|
+
|
|
116
|
+
## Publishing
|
|
117
|
+
|
|
118
|
+
Releases go to PyPI via **Trusted Publishing** — GitHub Actions authenticates to
|
|
119
|
+
PyPI with a short-lived OIDC identity, so no API token exists in repo secrets or on
|
|
120
|
+
anyone's laptop. A leaked token is the usual way a package supply chain gets
|
|
121
|
+
compromised; the safest token is one that was never created.
|
|
122
|
+
|
|
123
|
+
### One-time PyPI setup
|
|
124
|
+
|
|
125
|
+
1. Sign in at [pypi.org](https://pypi.org) → **Your account → Publishing**
|
|
126
|
+
2. Under *Add a new pending publisher*, choose **GitHub** and enter exactly:
|
|
127
|
+
|
|
128
|
+
| Field | Value |
|
|
129
|
+
|---|---|
|
|
130
|
+
| PyPI Project Name | `databricks360` |
|
|
131
|
+
| Owner | `databrickslms` |
|
|
132
|
+
| Repository name | `dbxdemos` |
|
|
133
|
+
| Workflow name | `publish.yml` |
|
|
134
|
+
| Environment name | `pypi` |
|
|
135
|
+
|
|
136
|
+
3. In GitHub → **Settings → Environments → New environment** → name it `pypi`.
|
|
137
|
+
Add yourself as a required reviewer if you want to approve each release.
|
|
138
|
+
|
|
139
|
+
"Pending" publisher is correct — the project does not exist on PyPI yet, and the
|
|
140
|
+
first successful run creates it.
|
|
141
|
+
|
|
142
|
+
### Cutting a release
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# bump version in pyproject.toml, commit, then:
|
|
146
|
+
git tag v0.1.0
|
|
147
|
+
git push origin v0.1.0
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The workflow runs the tests, builds, checks the tag matches `pyproject.toml`, and
|
|
151
|
+
publishes. A mismatched tag fails before anything reaches the index — versions on
|
|
152
|
+
PyPI are immutable, so a wrong number cannot be taken back, only yanked.
|
|
153
|
+
|
|
154
|
+
### Publishing by hand instead
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
python -m pip install build twine
|
|
158
|
+
python -m build
|
|
159
|
+
twine check dist/*
|
|
160
|
+
twine upload dist/* # prompts for an API token
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Test it against TestPyPI first if you want a dry run:
|
|
164
|
+
`twine upload --repository testpypi dist/*`.
|
|
165
|
+
|
|
166
|
+
## Tests
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
python3 run_tests.py
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Eleven tests, no workspace required: manifest loading, notebook cell structure,
|
|
173
|
+
catalog substitution, tier switching, unresolved-placeholder detection, and a
|
|
174
|
+
`dry_run` install. It also asserts the flaw-teaching column comments survive into
|
|
175
|
+
the generated notebooks — those comments are the curriculum, so losing them in
|
|
176
|
+
rendering would be a silent failure.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
databricks360/__init__.py,sha256=wpwQrBwBMNxoM7msAY5NyCXnDW_9P862E6tzkGVeBLw,2028
|
|
2
|
+
databricks360/_catalog.py,sha256=xBnIho0B8MZfAn-f5ggFnESIiucMOz6U6QNbpBSnfDA,2703
|
|
3
|
+
databricks360/_install.py,sha256=dYKW5JMlwTAX3PQMYyFmeefxVszrRUg0yN3hTndxX3s,5019
|
|
4
|
+
databricks360/_notebook.py,sha256=Y9pezGq_bI4ns1ceqChwY52gvBZU0VmEURYN84dzfN4,3653
|
|
5
|
+
databricks360/courses/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
databricks360/courses/genie_agents/01_catalog_and_schemas.sql,sha256=qLsiR0HmbCNM-jw6e0GRZkl6xmDMiwuBVQA1q8Iv4Sc,1778
|
|
7
|
+
databricks360/courses/genie_agents/02_dimensions.sql,sha256=VsWXSA4QQqW9DUd1au3JyWpZZ_GR8HsEo3-Pa2fStO8,16186
|
|
8
|
+
databricks360/courses/genie_agents/03_facts.sql,sha256=sryXtQDW-ztdDSUZCxwWKJLy6cSW1iRqPJWHjjQmyW0,15877
|
|
9
|
+
databricks360/courses/genie_agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
databricks360/courses/genie_agents/manifest.json,sha256=CSta9aQ9ObfuMC2PyjEnk36DDk_m-fnv4FvbAQYaBeE,2129
|
|
11
|
+
databricks360-0.1.0.dist-info/METADATA,sha256=iJjLnmmUdHh-yOlJfmSUBKkJQ5Wd0ao53Lv2OaDaVLI,5549
|
|
12
|
+
databricks360-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
13
|
+
databricks360-0.1.0.dist-info/licenses/LICENSE,sha256=NVi-ya6tGvg2-QS_3ThB4CzlyPDnWdGSsE09Ru_zn5k,1074
|
|
14
|
+
databricks360-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lakehouse Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|