lexis-cli 0.4.2__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.
- lexis/__init__.py +10 -0
- lexis/_vendor/ossie/NOTICE.md +14 -0
- lexis/_vendor/ossie/__init__.py +44 -0
- lexis/_vendor/ossie/models.py +224 -0
- lexis/cli.py +298 -0
- lexis/demo_data.py +87 -0
- lexis/dispatch.py +66 -0
- lexis/mcp_server.py +158 -0
- lexis/parser.py +19 -0
- lexis/resolved_model.py +128 -0
- lexis/retail_demo_data.py +615 -0
- lexis/sml/__init__.py +10 -0
- lexis/sml/_common.py +224 -0
- lexis/sml/emit.py +403 -0
- lexis/sml/models.py +172 -0
- lexis/sml/parse.py +382 -0
- lexis/transpilers/base.py +11 -0
- lexis/transpilers/cube.py +119 -0
- lexis/transpilers/dbt_ossie.py +67 -0
- lexis/transpilers/mcp.py +182 -0
- lexis/transpilers/snowflake_semantic_view.py +126 -0
- lexis/transpilers/sql/__init__.py +22 -0
- lexis/transpilers/sql/base.py +172 -0
- lexis/transpilers/sql/bigquery.py +8 -0
- lexis/transpilers/sql/databricks.py +8 -0
- lexis/transpilers/sql/duckdb.py +8 -0
- lexis/transpilers/sql/postgres.py +10 -0
- lexis/transpilers/sql/snowflake.py +8 -0
- lexis_cli-0.4.2.dist-info/METADATA +883 -0
- lexis_cli-0.4.2.dist-info/RECORD +33 -0
- lexis_cli-0.4.2.dist-info/WHEEL +4 -0
- lexis_cli-0.4.2.dist-info/entry_points.txt +2 -0
- lexis_cli-0.4.2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""A larger, deliberately *diverse* demo dataset: a multi-fact retail star schema
|
|
2
|
+
(``fct_store_sales`` + ``fct_store_returns`` sharing conformed dimensions) with
|
|
3
|
+
**10,000 generated sales facts**, ~1,500 return facts, and fully populated
|
|
4
|
+
dimensions. Companion to :mod:`lexis.demo_data`'s tiny fixed TPC-DS fixture - that
|
|
5
|
+
one stays small so emitter/HTTP tests can assert exact aggregates against it; this
|
|
6
|
+
one exists to actually *demo* analytics (segmentation, seasonality, basket
|
|
7
|
+
analysis, returns, promo lift) against the bundled ``retail_analytics`` model
|
|
8
|
+
(``src/lexis_api/sample_data/retail_analytics_model.yaml``).
|
|
9
|
+
|
|
10
|
+
Everything is generated from a fixed ``random.Random`` seed, so the row set - and
|
|
11
|
+
therefore every aggregate computed from it - is fully reproducible; tests freeze
|
|
12
|
+
the exact numbers.
|
|
13
|
+
|
|
14
|
+
Like :mod:`lexis.demo_data`, requires the optional ``duckdb`` dependency and is
|
|
15
|
+
imported lazily by its callers (the CLI, ``lexis_api``) rather than by the
|
|
16
|
+
``lexis`` package itself.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import csv
|
|
22
|
+
import os
|
|
23
|
+
import random
|
|
24
|
+
import tempfile
|
|
25
|
+
from datetime import date, timedelta
|
|
26
|
+
from itertools import accumulate
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import duckdb
|
|
30
|
+
|
|
31
|
+
# Catalog/schema the bundled ``retail_analytics`` model's ``source`` values use;
|
|
32
|
+
# ``ATTACH ':memory:' AS retail`` + ``CREATE SCHEMA retail.public`` makes the model's
|
|
33
|
+
# ``retail.public.*``-qualified SQL run unmodified (same trick as demo_data.py).
|
|
34
|
+
RETAIL_DEMO_CATALOG = "retail"
|
|
35
|
+
|
|
36
|
+
RETAIL_DEMO_SOURCES = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"retail.public.fct_store_sales",
|
|
39
|
+
"retail.public.fct_store_returns",
|
|
40
|
+
"retail.public.dim_date",
|
|
41
|
+
"retail.public.dim_customer",
|
|
42
|
+
"retail.public.dim_item",
|
|
43
|
+
"retail.public.dim_store",
|
|
44
|
+
"retail.public.dim_promotion",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
SALES_ROW_COUNT = 10_000
|
|
49
|
+
_SEED = 20_240_907
|
|
50
|
+
_START_DATE = date(2022, 1, 1)
|
|
51
|
+
_END_DATE = date(2024, 12, 31)
|
|
52
|
+
_TAX_RATE = 0.08
|
|
53
|
+
|
|
54
|
+
_FIRST_NAMES = [
|
|
55
|
+
"Ava", "Liam", "Noah", "Emma", "Olivia", "Mia", "Sophia", "Ethan", "Lucas", "Amara",
|
|
56
|
+
"Priya", "Rohan", "Wei", "Ling", "Diego", "Sofía", "Yusuf", "Fatima", "Kenji", "Hana",
|
|
57
|
+
"Isabella", "Mateo", "Chloe", "Elijah", "Nadia", "Omar", "Grace", "Henry", "Zara", "Aiden",
|
|
58
|
+
"Layla", "Caleb", "Naomi", "Ivan", "Marta", "Tariq", "Aisha", "Sven", "Ingrid", "Kofi",
|
|
59
|
+
]
|
|
60
|
+
_LAST_NAMES = [
|
|
61
|
+
"Nguyen", "Patel", "Kim", "Garcia", "Johnson", "Okafor", "Silva", "Müller", "Rossi", "Haddad",
|
|
62
|
+
"Andersson", "Cohen", "Novak", "Popescu", "Ivanov", "Tanaka", "Chen", "Diallo", "Reyes", "Brown",
|
|
63
|
+
"Fischer", "Kowalski", "Santos", "Baker", "Hoffman", "Petrov", "Wagner", "Meyer", "Costa", "Larsen",
|
|
64
|
+
]
|
|
65
|
+
_GENDERS = (("Female", 0.48), ("Male", 0.48), ("Nonbinary", 0.04))
|
|
66
|
+
_MARITAL = ("Single", "Married", "Divorced", "Widowed", "Domestic Partnership")
|
|
67
|
+
_EDUCATION = ("High School", "Some College", "Associate", "Bachelors", "Masters", "Doctorate", "Unknown")
|
|
68
|
+
_INCOME_BANDS = ("<30k", "30-60k", "60-90k", "90-120k", "120-160k", "160k+")
|
|
69
|
+
_LOYALTY_TIERS = (("Bronze", 0.5), ("Silver", 0.28), ("Gold", 0.16), ("Platinum", 0.06))
|
|
70
|
+
_CHANNELS = ("In-Store", "Online", "Mobile App", "Catalog")
|
|
71
|
+
_CITIES = [
|
|
72
|
+
("Seattle", "WA", "USA"), ("Portland", "OR", "USA"), ("San Francisco", "CA", "USA"),
|
|
73
|
+
("Los Angeles", "CA", "USA"), ("Denver", "CO", "USA"), ("Austin", "TX", "USA"),
|
|
74
|
+
("Dallas", "TX", "USA"), ("Chicago", "IL", "USA"), ("Minneapolis", "MN", "USA"),
|
|
75
|
+
("Detroit", "MI", "USA"), ("Atlanta", "GA", "USA"), ("Miami", "FL", "USA"),
|
|
76
|
+
("Charlotte", "NC", "USA"), ("Boston", "MA", "USA"), ("New York", "NY", "USA"),
|
|
77
|
+
("Philadelphia", "PA", "USA"), ("Washington", "DC", "USA"), ("Phoenix", "AZ", "USA"),
|
|
78
|
+
("Toronto", "ON", "Canada"), ("Vancouver", "BC", "Canada"),
|
|
79
|
+
]
|
|
80
|
+
_DIVISIONS = ("Northeast", "Southeast", "Midwest", "West", "Southwest", "Pacific")
|
|
81
|
+
|
|
82
|
+
_CATEGORY_CLASSES: dict[str, tuple[str, ...]] = {
|
|
83
|
+
"Electronics": ("Audio", "Computers", "Mobile", "Wearables", "Accessories", "Cameras"),
|
|
84
|
+
"Home & Kitchen": ("Cookware", "Small Appliances", "Storage", "Bedding", "Decor", "Lighting"),
|
|
85
|
+
"Apparel": ("Tops", "Bottoms", "Outerwear", "Footwear", "Activewear", "Accessories"),
|
|
86
|
+
"Sports & Outdoors": ("Camping", "Cycling", "Fitness", "Team Sports", "Water Sports", "Hiking"),
|
|
87
|
+
"Books": ("Fiction", "Nonfiction", "Children", "Reference", "Comics", "Cookbooks"),
|
|
88
|
+
"Beauty": ("Skincare", "Haircare", "Makeup", "Fragrance", "Bath & Body", "Tools"),
|
|
89
|
+
"Toys & Games": ("Building Sets", "Board Games", "Dolls", "Puzzles", "Outdoor Play", "Educational"),
|
|
90
|
+
"Grocery": ("Snacks", "Beverages", "Pantry", "Breakfast", "Frozen", "Confectionery"),
|
|
91
|
+
"Office": ("Paper", "Writing", "Organization", "Furniture", "Technology", "Supplies"),
|
|
92
|
+
"Automotive": ("Interior", "Exterior", "Tools", "Electronics", "Fluids", "Tires"),
|
|
93
|
+
}
|
|
94
|
+
_CATEGORIES = tuple(_CATEGORY_CLASSES)
|
|
95
|
+
_BRANDS = [
|
|
96
|
+
"SoundWave", "Northwind Press", "Peak Forge", "Aurora Labs", "Hearthstone", "TrailForge",
|
|
97
|
+
"LumenTech", "Cobalt & Co", "Willowbrook", "Ridgeline", "Nimbus", "Vantage",
|
|
98
|
+
"Kettle & Co", "BrightLeaf", "Momentum", "Harborview", "Copperfield", "Solstice",
|
|
99
|
+
"Meridian", "Ironwood", "Pinecrest", "Cascade", "Everforge", "Marlowe",
|
|
100
|
+
"Tidewater", "Grainhouse", "Quill & Co", "Alpine Peak", "Verdant", "Sablefish",
|
|
101
|
+
"Larksong", "Foundry 9", "Brightwater", "Sterling Row", "Nova Craft", "Field & Study",
|
|
102
|
+
"Ember", "Halcyon", "Driftwood", "Kestrel",
|
|
103
|
+
]
|
|
104
|
+
_MANUFACTURERS = [
|
|
105
|
+
"Global Consumer Goods", "Pacific Rim Manufacturing", "Atlas Industries", "Vertex Products",
|
|
106
|
+
"Continental Makers", "Summit Fabrication", "Keystone Supply", "Horizon Works",
|
|
107
|
+
"Union Assembly", "Delta Provisions", "Anchor Holdings", "Beacon Manufacturing",
|
|
108
|
+
]
|
|
109
|
+
_COLORS = ("Black", "White", "Silver", "Slate", "Navy", "Crimson", "Forest", "Sand", "Charcoal", "Assorted")
|
|
110
|
+
_SIZES = ("XS", "S", "M", "L", "XL", "One Size", "N/A")
|
|
111
|
+
_UNITS = ("Each", "Pack of 2", "Pack of 4", "Pack of 6", "Dozen", "Case of 24")
|
|
112
|
+
_PRODUCT_ADJECTIVES = (
|
|
113
|
+
"Pro", "Everyday", "Deluxe", "Compact", "Ultra", "Classic", "Essential", "Premium", "Lite", "Max",
|
|
114
|
+
)
|
|
115
|
+
_PRODUCT_NOUNS = (
|
|
116
|
+
"Kit", "Set", "Bundle", "Edition", "Series", "Collection", "Pack", "System", "Model", "Line",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
_STORE_TYPES = (
|
|
120
|
+
# (name, floor_space range, employee range, weight)
|
|
121
|
+
("Flagship", (28_000, 55_000), (85, 180), 0.12),
|
|
122
|
+
("Standard", (14_000, 28_000), (35, 90), 0.48),
|
|
123
|
+
("Express", (3_500, 9_000), (10, 30), 0.28),
|
|
124
|
+
("Outlet", (18_000, 40_000), (25, 70), 0.12),
|
|
125
|
+
)
|
|
126
|
+
_COMPANY_NAME = "Northwind Retail Group"
|
|
127
|
+
|
|
128
|
+
_PROMO_CHANNELS = ("Email", "TV", "Radio", "Social", "In-Store Display", "Search", "Catalog", "Affiliate")
|
|
129
|
+
_PROMO_THEMES = (
|
|
130
|
+
"Spring Refresh", "Summer Blowout", "Back to School", "Fall Finds", "Black Friday",
|
|
131
|
+
"Cyber Monday", "Holiday Cheer", "New Year Reset", "Clearance Event", "Member Exclusive",
|
|
132
|
+
"Weekend Flash", "Loyalty Bonus", "Bundle & Save", "Doorbuster", "Early Access",
|
|
133
|
+
)
|
|
134
|
+
_RETURN_REASONS = (
|
|
135
|
+
("Defective / damaged", 0.22),
|
|
136
|
+
("Wrong size or fit", 0.24),
|
|
137
|
+
("Changed mind", 0.20),
|
|
138
|
+
("Not as described", 0.12),
|
|
139
|
+
("Found better price", 0.09),
|
|
140
|
+
("Arrived too late", 0.06),
|
|
141
|
+
("Gift return", 0.07),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
_PROMO_COUNT = 40
|
|
145
|
+
_CUSTOMER_COUNT = 800
|
|
146
|
+
_ITEM_COUNT = 300
|
|
147
|
+
_STORE_COUNT = 25
|
|
148
|
+
_RETURN_FRACTION = 0.15
|
|
149
|
+
_FIRST_TICKET_NUMBER = 100_000
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _weighted_choice(rng: random.Random, pairs: tuple[tuple[str, float], ...]) -> str:
|
|
153
|
+
values = [v for v, _ in pairs]
|
|
154
|
+
weights = [w for _, w in pairs]
|
|
155
|
+
return rng.choices(values, weights=weights, k=1)[0]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _age_band(birth_year: int) -> str:
|
|
159
|
+
age = _END_DATE.year - birth_year
|
|
160
|
+
for cutoff, label in ((25, "Under 25"), (35, "25-34"), (45, "35-44"), (55, "45-54"), (65, "55-64")):
|
|
161
|
+
if age < cutoff:
|
|
162
|
+
return label
|
|
163
|
+
return "65+"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --- dimension generators -------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
_FIXED_HOLIDAYS = {
|
|
169
|
+
(1, 1): "New Year's Day",
|
|
170
|
+
(7, 4): "Independence Day",
|
|
171
|
+
(11, 11): "Veterans Day",
|
|
172
|
+
(12, 25): "Christmas Day",
|
|
173
|
+
(12, 31): "New Year's Eve",
|
|
174
|
+
}
|
|
175
|
+
_MONTH_NAMES = (
|
|
176
|
+
"January", "February", "March", "April", "May", "June",
|
|
177
|
+
"July", "August", "September", "October", "November", "December",
|
|
178
|
+
)
|
|
179
|
+
_DAY_NAMES = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _thanksgiving(year: int) -> date:
|
|
183
|
+
"""Fourth Thursday of November."""
|
|
184
|
+
d = date(year, 11, 1)
|
|
185
|
+
d += timedelta(days=(3 - d.weekday()) % 7) # first Thursday
|
|
186
|
+
return d + timedelta(weeks=3)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def gen_dates() -> list[tuple]:
|
|
190
|
+
"""One row per calendar day in ``[_START_DATE, _END_DATE]``.
|
|
191
|
+
|
|
192
|
+
Columns: ``d_date_sk, d_date, d_year, d_quarter, d_quarter_name, d_month,
|
|
193
|
+
d_month_name, d_day_of_month, d_day_of_week, d_day_name, d_is_weekend, d_week,
|
|
194
|
+
d_holiday_name``.
|
|
195
|
+
"""
|
|
196
|
+
rows: list[tuple] = []
|
|
197
|
+
thanksgivings = {_thanksgiving(y): "Thanksgiving Day" for y in range(_START_DATE.year, _END_DATE.year + 1)}
|
|
198
|
+
day = _START_DATE
|
|
199
|
+
while day <= _END_DATE:
|
|
200
|
+
quarter = (day.month - 1) // 3 + 1
|
|
201
|
+
iso_dow = day.isoweekday() # 1=Mon .. 7=Sun
|
|
202
|
+
holiday = _FIXED_HOLIDAYS.get((day.month, day.day)) or thanksgivings.get(day)
|
|
203
|
+
rows.append(
|
|
204
|
+
(
|
|
205
|
+
day.year * 10_000 + day.month * 100 + day.day,
|
|
206
|
+
day,
|
|
207
|
+
day.year,
|
|
208
|
+
quarter,
|
|
209
|
+
f"{day.year}Q{quarter}",
|
|
210
|
+
day.month,
|
|
211
|
+
_MONTH_NAMES[day.month - 1],
|
|
212
|
+
day.day,
|
|
213
|
+
iso_dow,
|
|
214
|
+
_DAY_NAMES[iso_dow - 1],
|
|
215
|
+
iso_dow >= 6,
|
|
216
|
+
day.isocalendar().week,
|
|
217
|
+
holiday,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
day += timedelta(days=1)
|
|
221
|
+
return rows
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def gen_customers(rng: random.Random, date_sks: list[int]) -> list[tuple]:
|
|
225
|
+
"""``_CUSTOMER_COUNT`` rows. Columns: ``c_customer_sk, c_customer_id,
|
|
226
|
+
c_first_name, c_last_name, c_email, c_gender, c_marital_status, c_birth_year,
|
|
227
|
+
c_age_band, c_education_status, c_income_band, c_city, c_state, c_country,
|
|
228
|
+
c_loyalty_tier, c_preferred_channel, c_signup_date_sk``."""
|
|
229
|
+
rows: list[tuple] = []
|
|
230
|
+
for sk in range(1, _CUSTOMER_COUNT + 1):
|
|
231
|
+
first = rng.choice(_FIRST_NAMES)
|
|
232
|
+
last = rng.choice(_LAST_NAMES)
|
|
233
|
+
birth_year = rng.randint(1946, 2005)
|
|
234
|
+
city, state, country = rng.choice(_CITIES)
|
|
235
|
+
rows.append(
|
|
236
|
+
(
|
|
237
|
+
sk,
|
|
238
|
+
f"CUST-{sk:06d}",
|
|
239
|
+
first,
|
|
240
|
+
last,
|
|
241
|
+
f"{first}.{last}{sk}@example.com".lower(),
|
|
242
|
+
_weighted_choice(rng, _GENDERS),
|
|
243
|
+
rng.choice(_MARITAL),
|
|
244
|
+
birth_year,
|
|
245
|
+
_age_band(birth_year),
|
|
246
|
+
rng.choice(_EDUCATION),
|
|
247
|
+
rng.choice(_INCOME_BANDS),
|
|
248
|
+
city,
|
|
249
|
+
state,
|
|
250
|
+
country,
|
|
251
|
+
_weighted_choice(rng, _LOYALTY_TIERS),
|
|
252
|
+
rng.choice(_CHANNELS),
|
|
253
|
+
rng.choice(date_sks),
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
return rows
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def gen_items(rng: random.Random) -> list[tuple]:
|
|
260
|
+
"""``_ITEM_COUNT`` rows. Columns: ``i_item_sk, i_item_id, i_product_name,
|
|
261
|
+
i_category, i_class, i_brand, i_manufacturer, i_color, i_size, i_units,
|
|
262
|
+
i_current_price, i_wholesale_cost``."""
|
|
263
|
+
rows: list[tuple] = []
|
|
264
|
+
for sk in range(1, _ITEM_COUNT + 1):
|
|
265
|
+
category = _CATEGORIES[sk % len(_CATEGORIES)]
|
|
266
|
+
item_class = rng.choice(_CATEGORY_CLASSES[category])
|
|
267
|
+
brand = rng.choice(_BRANDS)
|
|
268
|
+
price = round(rng.uniform(4.5, 480.0) * (1.4 if category == "Electronics" else 1.0), 2)
|
|
269
|
+
rows.append(
|
|
270
|
+
(
|
|
271
|
+
sk,
|
|
272
|
+
f"ITEM-{sk:06d}",
|
|
273
|
+
f"{brand} {rng.choice(_PRODUCT_ADJECTIVES)} {item_class} {rng.choice(_PRODUCT_NOUNS)}",
|
|
274
|
+
category,
|
|
275
|
+
item_class,
|
|
276
|
+
brand,
|
|
277
|
+
rng.choice(_MANUFACTURERS),
|
|
278
|
+
rng.choice(_COLORS),
|
|
279
|
+
rng.choice(_SIZES),
|
|
280
|
+
rng.choice(_UNITS),
|
|
281
|
+
price,
|
|
282
|
+
round(price * rng.uniform(0.38, 0.68), 2),
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
return rows
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def gen_stores(rng: random.Random, date_sks: list[int]) -> list[tuple]:
|
|
289
|
+
"""``_STORE_COUNT`` rows. Columns: ``s_store_sk, s_store_id, s_store_name,
|
|
290
|
+
s_store_type, s_number_employees, s_floor_space, s_market_id,
|
|
291
|
+
s_market_manager, s_company_name, s_division_name, s_city, s_state, s_country,
|
|
292
|
+
s_zip, s_open_date_sk``."""
|
|
293
|
+
type_names = [t[0] for t in _STORE_TYPES]
|
|
294
|
+
type_weights = [t[3] for t in _STORE_TYPES]
|
|
295
|
+
by_name = {t[0]: t for t in _STORE_TYPES}
|
|
296
|
+
rows: list[tuple] = []
|
|
297
|
+
for sk in range(1, _STORE_COUNT + 1):
|
|
298
|
+
store_type = rng.choices(type_names, weights=type_weights, k=1)[0]
|
|
299
|
+
_, floor_range, emp_range, _ = by_name[store_type]
|
|
300
|
+
city, state, country = rng.choice(_CITIES)
|
|
301
|
+
manager = f"{rng.choice(_FIRST_NAMES)} {rng.choice(_LAST_NAMES)}"
|
|
302
|
+
rows.append(
|
|
303
|
+
(
|
|
304
|
+
sk,
|
|
305
|
+
f"STORE-{sk:03d}",
|
|
306
|
+
f"{_COMPANY_NAME} - {city} {store_type}",
|
|
307
|
+
store_type,
|
|
308
|
+
rng.randint(*emp_range),
|
|
309
|
+
rng.randint(*floor_range),
|
|
310
|
+
rng.randint(1, 8),
|
|
311
|
+
manager,
|
|
312
|
+
_COMPANY_NAME,
|
|
313
|
+
rng.choice(_DIVISIONS),
|
|
314
|
+
city,
|
|
315
|
+
state,
|
|
316
|
+
country,
|
|
317
|
+
f"{rng.randint(10_000, 99_999):05d}",
|
|
318
|
+
rng.choice(date_sks),
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
return rows
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def gen_promotions(rng: random.Random, date_sks: list[int]) -> list[tuple]:
|
|
325
|
+
"""``_PROMO_COUNT`` rows plus a ``p_promo_sk = 0`` "No Promotion" sentinel that
|
|
326
|
+
non-promoted sales lines point at (keeps the sales->promotion join an inner
|
|
327
|
+
join with no orphan rows). Columns: ``p_promo_sk, p_promo_id, p_promo_name,
|
|
328
|
+
p_channel, p_discount_pct, p_start_date_sk, p_end_date_sk, p_cost``."""
|
|
329
|
+
lo, hi = min(date_sks), max(date_sks)
|
|
330
|
+
rows: list[tuple] = [(0, "PROMO-000000", "No Promotion", "None", 0.0, lo, hi, 0.0)]
|
|
331
|
+
for sk in range(1, _PROMO_COUNT + 1):
|
|
332
|
+
start_sk, end_sk = sorted(rng.sample(date_sks, 2))
|
|
333
|
+
theme = rng.choice(_PROMO_THEMES)
|
|
334
|
+
rows.append(
|
|
335
|
+
(
|
|
336
|
+
sk,
|
|
337
|
+
f"PROMO-{sk:06d}",
|
|
338
|
+
f"{theme} {rng.choice(('Sale', 'Event', 'Deal', 'Special'))}",
|
|
339
|
+
rng.choice(_PROMO_CHANNELS),
|
|
340
|
+
round(rng.uniform(0.05, 0.45), 2),
|
|
341
|
+
start_sk,
|
|
342
|
+
end_sk,
|
|
343
|
+
round(rng.uniform(500.0, 30_000.0), 2),
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
return rows
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# --- fact generators -----------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
def _date_sampler_weights(date_rows: list[tuple]) -> list[float]:
|
|
352
|
+
"""Skew sales toward recent years, Q4, and weekends - so time-series and
|
|
353
|
+
seasonality demos actually show a shape."""
|
|
354
|
+
year_factor = {2022: 0.7, 2023: 1.0, 2024: 1.4}
|
|
355
|
+
weights: list[float] = []
|
|
356
|
+
for row in date_rows:
|
|
357
|
+
_, _, year, _, _, month, *_rest = row
|
|
358
|
+
is_weekend = row[10]
|
|
359
|
+
w = year_factor.get(year, 1.0)
|
|
360
|
+
if month in (11, 12):
|
|
361
|
+
w *= 1.9
|
|
362
|
+
elif month in (1, 2):
|
|
363
|
+
w *= 0.75
|
|
364
|
+
if is_weekend:
|
|
365
|
+
w *= 1.25
|
|
366
|
+
weights.append(w)
|
|
367
|
+
return weights
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def gen_sales(
|
|
371
|
+
rng: random.Random,
|
|
372
|
+
date_rows: list[tuple],
|
|
373
|
+
items: list[tuple],
|
|
374
|
+
customers: list[tuple],
|
|
375
|
+
stores: list[tuple],
|
|
376
|
+
promotions: list[tuple],
|
|
377
|
+
) -> list[tuple]:
|
|
378
|
+
"""Exactly ``SALES_ROW_COUNT`` rows, grain = one sales line item. 1-6 line
|
|
379
|
+
items share a ``ss_ticket_number`` (a basket). Columns: ``ss_sold_date_sk,
|
|
380
|
+
ss_item_sk, ss_customer_sk, ss_store_sk, ss_promo_sk, ss_ticket_number,
|
|
381
|
+
ss_quantity, ss_wholesale_cost, ss_list_price, ss_sales_price,
|
|
382
|
+
ss_ext_sales_price, ss_ext_discount_amt, ss_ext_wholesale_cost, ss_ext_tax,
|
|
383
|
+
ss_coupon_amt, ss_net_paid, ss_net_profit``."""
|
|
384
|
+
date_sks = [r[0] for r in date_rows]
|
|
385
|
+
cum_weights = list(accumulate(_date_sampler_weights(date_rows)))
|
|
386
|
+
customer_sks = [r[0] for r in customers]
|
|
387
|
+
store_sks = [r[0] for r in stores]
|
|
388
|
+
real_promos = [p for p in promotions if p[0] != 0] # (sk, ..., discount_pct at idx 4, ...)
|
|
389
|
+
|
|
390
|
+
basket_sizes = (1, 2, 3, 4, 5, 6)
|
|
391
|
+
basket_weights = (30, 26, 20, 12, 8, 4)
|
|
392
|
+
qty_choices = (1, 2, 3, 4, 5, 6, 8, 10, 12)
|
|
393
|
+
qty_weights = (46, 22, 13, 7, 4, 3, 2, 2, 1)
|
|
394
|
+
|
|
395
|
+
rows: list[tuple] = []
|
|
396
|
+
ticket_number = _FIRST_TICKET_NUMBER
|
|
397
|
+
while len(rows) < SALES_ROW_COUNT:
|
|
398
|
+
ticket_number += 1
|
|
399
|
+
sold_date_sk = rng.choices(date_sks, cum_weights=cum_weights, k=1)[0]
|
|
400
|
+
customer_sk = rng.choice(customer_sks)
|
|
401
|
+
store_sk = rng.choice(store_sks)
|
|
402
|
+
lines = rng.choices(basket_sizes, weights=basket_weights, k=1)[0]
|
|
403
|
+
for _ in range(lines):
|
|
404
|
+
if len(rows) >= SALES_ROW_COUNT:
|
|
405
|
+
break
|
|
406
|
+
item = rng.choice(items)
|
|
407
|
+
list_price = item[10]
|
|
408
|
+
wholesale_cost = item[11]
|
|
409
|
+
quantity = rng.choices(qty_choices, weights=qty_weights, k=1)[0]
|
|
410
|
+
|
|
411
|
+
if rng.random() < 0.35:
|
|
412
|
+
promo = rng.choice(real_promos)
|
|
413
|
+
promo_sk, discount_pct = promo[0], promo[4]
|
|
414
|
+
else:
|
|
415
|
+
promo_sk, discount_pct = 0, 0.0
|
|
416
|
+
|
|
417
|
+
sales_price = round(list_price * (1.0 - discount_pct), 2)
|
|
418
|
+
ext_sales_price = round(sales_price * quantity, 2)
|
|
419
|
+
ext_discount_amt = round((list_price - sales_price) * quantity, 2)
|
|
420
|
+
ext_wholesale_cost = round(wholesale_cost * quantity, 2)
|
|
421
|
+
ext_tax = round(ext_sales_price * _TAX_RATE, 2)
|
|
422
|
+
coupon_amt = round(rng.uniform(1.0, 15.0), 2) if rng.random() < 0.12 else 0.0
|
|
423
|
+
net_paid = round(ext_sales_price - coupon_amt, 2)
|
|
424
|
+
net_profit = round(net_paid - ext_wholesale_cost, 2)
|
|
425
|
+
|
|
426
|
+
rows.append(
|
|
427
|
+
(
|
|
428
|
+
sold_date_sk,
|
|
429
|
+
item[0],
|
|
430
|
+
customer_sk,
|
|
431
|
+
store_sk,
|
|
432
|
+
promo_sk,
|
|
433
|
+
ticket_number,
|
|
434
|
+
quantity,
|
|
435
|
+
wholesale_cost,
|
|
436
|
+
list_price,
|
|
437
|
+
sales_price,
|
|
438
|
+
ext_sales_price,
|
|
439
|
+
ext_discount_amt,
|
|
440
|
+
ext_wholesale_cost,
|
|
441
|
+
ext_tax,
|
|
442
|
+
coupon_amt,
|
|
443
|
+
net_paid,
|
|
444
|
+
net_profit,
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
return rows
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def gen_returns(rng: random.Random, sales: list[tuple], date_rows: list[tuple]) -> list[tuple]:
|
|
451
|
+
"""A ``_RETURN_FRACTION`` sample of sales lines come back, 0-30 days later.
|
|
452
|
+
Columns: ``sr_returned_date_sk, sr_item_sk, sr_customer_sk, sr_store_sk,
|
|
453
|
+
sr_ticket_number, sr_return_quantity, sr_return_amt, sr_return_tax,
|
|
454
|
+
sr_return_fee, sr_net_loss, sr_reason``."""
|
|
455
|
+
sk_by_ordinal = [r[0] for r in date_rows] # date_sks in calendar order
|
|
456
|
+
ordinal_by_sk = {sk: i for i, sk in enumerate(sk_by_ordinal)}
|
|
457
|
+
sample_size = int(len(sales) * _RETURN_FRACTION)
|
|
458
|
+
returned_lines = rng.sample(sales, sample_size)
|
|
459
|
+
|
|
460
|
+
rows: list[tuple] = []
|
|
461
|
+
for line in returned_lines:
|
|
462
|
+
(
|
|
463
|
+
sold_date_sk, item_sk, customer_sk, store_sk, _promo_sk, ticket_number,
|
|
464
|
+
quantity, _wholesale, _list_price, sales_price, ext_sales_price, *_rest,
|
|
465
|
+
) = line
|
|
466
|
+
start = ordinal_by_sk[sold_date_sk]
|
|
467
|
+
returned_ordinal = min(start + rng.randint(1, 30), len(sk_by_ordinal) - 1)
|
|
468
|
+
return_quantity = rng.randint(1, quantity)
|
|
469
|
+
return_amt = round(sales_price * return_quantity, 2)
|
|
470
|
+
return_tax = round(return_amt * _TAX_RATE, 2)
|
|
471
|
+
return_fee = round(rng.uniform(2.0, 9.0), 2) if rng.random() < 0.3 else 0.0
|
|
472
|
+
net_loss = round(return_fee + return_amt * 0.08, 2)
|
|
473
|
+
rows.append(
|
|
474
|
+
(
|
|
475
|
+
sk_by_ordinal[returned_ordinal],
|
|
476
|
+
item_sk,
|
|
477
|
+
customer_sk,
|
|
478
|
+
store_sk,
|
|
479
|
+
ticket_number,
|
|
480
|
+
return_quantity,
|
|
481
|
+
return_amt,
|
|
482
|
+
return_tax,
|
|
483
|
+
return_fee,
|
|
484
|
+
net_loss,
|
|
485
|
+
_weighted_choice(rng, _RETURN_REASONS),
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
return rows
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# --- assembly ------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
_DDL = {
|
|
494
|
+
"dim_date": (
|
|
495
|
+
"d_date_sk INT, d_date DATE, d_year INT, d_quarter INT, d_quarter_name VARCHAR, "
|
|
496
|
+
"d_month INT, d_month_name VARCHAR, d_day_of_month INT, d_day_of_week INT, "
|
|
497
|
+
"d_day_name VARCHAR, d_is_weekend BOOLEAN, d_week INT, d_holiday_name VARCHAR"
|
|
498
|
+
),
|
|
499
|
+
"dim_customer": (
|
|
500
|
+
"c_customer_sk INT, c_customer_id VARCHAR, c_first_name VARCHAR, c_last_name VARCHAR, "
|
|
501
|
+
"c_email VARCHAR, c_gender VARCHAR, c_marital_status VARCHAR, c_birth_year INT, "
|
|
502
|
+
"c_age_band VARCHAR, c_education_status VARCHAR, c_income_band VARCHAR, c_city VARCHAR, "
|
|
503
|
+
"c_state VARCHAR, c_country VARCHAR, c_loyalty_tier VARCHAR, c_preferred_channel VARCHAR, "
|
|
504
|
+
"c_signup_date_sk INT"
|
|
505
|
+
),
|
|
506
|
+
"dim_item": (
|
|
507
|
+
"i_item_sk INT, i_item_id VARCHAR, i_product_name VARCHAR, i_category VARCHAR, "
|
|
508
|
+
"i_class VARCHAR, i_brand VARCHAR, i_manufacturer VARCHAR, i_color VARCHAR, "
|
|
509
|
+
"i_size VARCHAR, i_units VARCHAR, i_current_price DOUBLE, i_wholesale_cost DOUBLE"
|
|
510
|
+
),
|
|
511
|
+
"dim_store": (
|
|
512
|
+
"s_store_sk INT, s_store_id VARCHAR, s_store_name VARCHAR, s_store_type VARCHAR, "
|
|
513
|
+
"s_number_employees INT, s_floor_space INT, s_market_id INT, s_market_manager VARCHAR, "
|
|
514
|
+
"s_company_name VARCHAR, s_division_name VARCHAR, s_city VARCHAR, s_state VARCHAR, "
|
|
515
|
+
"s_country VARCHAR, s_zip VARCHAR, s_open_date_sk INT"
|
|
516
|
+
),
|
|
517
|
+
"dim_promotion": (
|
|
518
|
+
"p_promo_sk INT, p_promo_id VARCHAR, p_promo_name VARCHAR, p_channel VARCHAR, "
|
|
519
|
+
"p_discount_pct DOUBLE, p_start_date_sk INT, p_end_date_sk INT, p_cost DOUBLE"
|
|
520
|
+
),
|
|
521
|
+
"fct_store_sales": (
|
|
522
|
+
"ss_sold_date_sk INT, ss_item_sk INT, ss_customer_sk INT, ss_store_sk INT, "
|
|
523
|
+
"ss_promo_sk INT, ss_ticket_number BIGINT, ss_quantity INT, ss_wholesale_cost DOUBLE, "
|
|
524
|
+
"ss_list_price DOUBLE, ss_sales_price DOUBLE, ss_ext_sales_price DOUBLE, "
|
|
525
|
+
"ss_ext_discount_amt DOUBLE, ss_ext_wholesale_cost DOUBLE, ss_ext_tax DOUBLE, "
|
|
526
|
+
"ss_coupon_amt DOUBLE, ss_net_paid DOUBLE, ss_net_profit DOUBLE"
|
|
527
|
+
),
|
|
528
|
+
"fct_store_returns": (
|
|
529
|
+
"sr_returned_date_sk INT, sr_item_sk INT, sr_customer_sk INT, sr_store_sk INT, "
|
|
530
|
+
"sr_ticket_number BIGINT, sr_return_quantity INT, sr_return_amt DOUBLE, "
|
|
531
|
+
"sr_return_tax DOUBLE, sr_return_fee DOUBLE, sr_net_loss DOUBLE, sr_reason VARCHAR"
|
|
532
|
+
),
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def generate_retail_demo_tables(seed: int = _SEED) -> dict[str, list[tuple]]:
|
|
537
|
+
"""All seven tables as ``{table_name: rows}``, fully deterministic for a given
|
|
538
|
+
``seed`` - separated from the DuckDB wiring so it's unit-testable without a
|
|
539
|
+
connection."""
|
|
540
|
+
rng = random.Random(seed)
|
|
541
|
+
dates = gen_dates()
|
|
542
|
+
date_sks = [r[0] for r in dates]
|
|
543
|
+
customers = gen_customers(rng, date_sks)
|
|
544
|
+
items = gen_items(rng)
|
|
545
|
+
stores = gen_stores(rng, date_sks)
|
|
546
|
+
promotions = gen_promotions(rng, date_sks)
|
|
547
|
+
sales = gen_sales(rng, dates, items, customers, stores, promotions)
|
|
548
|
+
returns = gen_returns(rng, sales, dates)
|
|
549
|
+
return {
|
|
550
|
+
"dim_date": dates,
|
|
551
|
+
"dim_customer": customers,
|
|
552
|
+
"dim_item": items,
|
|
553
|
+
"dim_store": stores,
|
|
554
|
+
"dim_promotion": promotions,
|
|
555
|
+
"fct_store_sales": sales,
|
|
556
|
+
"fct_store_returns": returns,
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _csv_cell(value: object) -> object:
|
|
561
|
+
if value is None:
|
|
562
|
+
return ""
|
|
563
|
+
if value is True:
|
|
564
|
+
return "true"
|
|
565
|
+
if value is False:
|
|
566
|
+
return "false"
|
|
567
|
+
if isinstance(value, date):
|
|
568
|
+
return value.isoformat()
|
|
569
|
+
return value
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _bulk_load(con: duckdb.DuckDBPyConnection, table: str, rows: list[tuple]) -> None:
|
|
573
|
+
"""Load ``rows`` via a temp-file ``COPY ... FROM`` - DuckDB's ``executemany``
|
|
574
|
+
is pathologically slow for tens of thousands of rows, and a single giant
|
|
575
|
+
multi-row ``VALUES`` isn't much better; a CSV bulk load is ~200x faster and
|
|
576
|
+
needs no pandas/pyarrow. The CSV is written with the DuckDB defaults an empty
|
|
577
|
+
field means SQL NULL, ``true``/``false`` for booleans, ISO dates."""
|
|
578
|
+
fd, path = tempfile.mkstemp(suffix=".csv")
|
|
579
|
+
try:
|
|
580
|
+
with os.fdopen(fd, "w", newline="", encoding="utf-8") as handle:
|
|
581
|
+
writer = csv.writer(handle)
|
|
582
|
+
writer.writerows([_csv_cell(v) for v in row] for row in rows)
|
|
583
|
+
con.execute(
|
|
584
|
+
f"COPY {table} FROM '{path}' (FORMAT csv, HEADER false, NULLSTR '')"
|
|
585
|
+
)
|
|
586
|
+
finally:
|
|
587
|
+
os.unlink(path)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def build_retail_demo_connection(target: str = ":memory:") -> duckdb.DuckDBPyConnection:
|
|
591
|
+
"""Attach ``target`` under catalog ``retail`` and populate ``retail.public.*``
|
|
592
|
+
with the generated dataset, so the bundled ``retail_analytics`` model's SQL
|
|
593
|
+
runs against it unmodified. For a file ``target``, closing the returned
|
|
594
|
+
connection leaves the data on disk (see :func:`export_retail_demo_dataset`)."""
|
|
595
|
+
tables = generate_retail_demo_tables()
|
|
596
|
+
con = duckdb.connect()
|
|
597
|
+
con.execute(f"ATTACH '{target}' AS {RETAIL_DEMO_CATALOG}")
|
|
598
|
+
con.execute(f"CREATE SCHEMA {RETAIL_DEMO_CATALOG}.public")
|
|
599
|
+
for name, columns in _DDL.items():
|
|
600
|
+
con.execute(f"CREATE TABLE {RETAIL_DEMO_CATALOG}.public.{name} ({columns})")
|
|
601
|
+
_bulk_load(con, f"{RETAIL_DEMO_CATALOG}.public.{name}", tables[name])
|
|
602
|
+
return con
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def export_retail_demo_dataset(path: str | Path, *, overwrite: bool = False) -> None:
|
|
606
|
+
"""Write the retail demo dataset to a real ``.duckdb`` file - the same schema
|
|
607
|
+
and rows :func:`build_retail_demo_connection` builds in-memory, so the file can
|
|
608
|
+
be re-uploaded or registered as a ``duckdb_file`` connection and produce
|
|
609
|
+
identical results. Mirrors :func:`lexis.demo_data.export_demo_dataset`."""
|
|
610
|
+
path = Path(path)
|
|
611
|
+
if path.exists():
|
|
612
|
+
if not overwrite:
|
|
613
|
+
raise FileExistsError(f"{path} already exists")
|
|
614
|
+
path.unlink()
|
|
615
|
+
build_retail_demo_connection(target=str(path)).close()
|
lexis/sml/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Ossie <-> SML (Semantic Modeling Language, github.com/semanticdatalayer/SML)
|
|
2
|
+
bidirectional converter.
|
|
3
|
+
|
|
4
|
+
See SML_OSSIE_CONVERTER_PLAN.md at the repo root for the full design: the
|
|
5
|
+
documented "supported subset" scope, the data-model mapping table, and the
|
|
6
|
+
edge cases that cannot be losslessly resolved.
|
|
7
|
+
|
|
8
|
+
Phase 1 (this module set, so far): Ossie -> SML only (`emit.py`), wired into
|
|
9
|
+
`lexis.dispatch`'s `--target sml`. Phase 2 will add `parse.py` for SML -> Ossie.
|
|
10
|
+
"""
|