Alteryx、Tableau、PySpark 常用寫法,同埋同一個 use case 喺三個工具點做。考試前掃一次,做題時當字典查。
同一個操作喺三個工具點做。PySpark 例子假設已經 from pyspark.sql import functions as F, Window。Tableau 以 Desktop 為準,有啲操作應該喺上游做,會註明。
| 操作 | 解釋 | Alteryx | PySpark | Tableau |
|---|---|---|---|---|
| 讀取 / 輸出 | ||||
| 讀 CSV | 讀一個檔做 table。DE 做法係全部欄先當 string 讀,之後自己轉 type,避免 infer 錯。 | Input Data → 揀檔 → ✅ First Row Contains Field Names | df = (spark.read
.option("header", True)
.csv("/data/claims.csv")) |
Connect → Text file → 揀檔 |
| 讀多個檔 + 記來源 | 每月一個檔,一次過讀,加一欄記住每行嚟自邊個檔,方便追查。 | Input Data 路徑用 *.csv;Options → Output File Name as Field。Schema 唔同就分開讀再 Union by name |
df = (spark.read.option("header", True)
.csv("/data/pos/*.csv")
.withColumn("_src",
F.col("_metadata.file_path"))) |
Data Source → 拉 New Union → Wildcard;自動多咗 Path 欄 |
| 讀 JSON | JSONL 每行一個 object,入面可能有 nested array。 | Input(CSV,delimiter \0,加大 field length)→ JSON Parse |
js = spark.read.json("/data/events.jsonl")
js.printSchema() |
Connect → JSON file → 揀要嘅 schema level |
| 寫輸出 | 寫落檔或者 table。重跑要 overwrite,唔好 append 兩次。 | Output Data:out.xlsx|||Sheet1,Overwrite Sheet;或者 .hyper |
(df.write.format("delta")
.mode("overwrite")
.saveAsTable("silver.claims")) |
Worksheet → Export → Data;Data Source → Extract(.hyper) |
| 揀欄 / 改名 / Type | ||||
| 揀欄 + 改名 | 只留需要嘅欄,改做清楚嘅名。 | Select:取消勾選、改 Rename 格 | df = df.select(
"claim_id",
F.col("amt").alias("claim_amt")) |
欄位右掣 → Hide / Rename |
| 改 data type | String 轉數字 / 日期先可以計數同做時間軸。 | Select → Type 揀 Double / Date;或者 Formula 新欄揀 type | df = df.withColumn("paid",
F.col("paid").cast("double")) |
FLOAT([paid]) DATE([claim_date]) STR([store_id]) |
| 排欄次序 | 整理輸出,ID 放前,衍生欄放後。 | Select → ▲▼ 或拖行 | df = df.select("claim_id", "policy_id",
"claim_dt", "status") |
唔需要,view 自己決定 |
| Filter | ||||
| 條件 filter | 只留符合條件嘅行。Alteryx 有 T / F 兩個 output,唔符合嘅都攞得返。 | [status] = "Approved" AND [claim_amt] > 1000 |
df.filter((F.col("status") == "Approved")
& (F.col("claim_amt") > 1000)) |
拉欄落 Filters shelf;或者 calc filter:[status] = "Approved" |
| Filter Null | 攞走或者搵出空值。 | !IsNull([claim_amt]) IsEmpty([x]) // null 或 "" |
df.filter(F.col("claim_amt").isNotNull()) |
Filter → Special → Non-null values |
| IN list | 值喺一個清單入面。 | [district] IN ("Wan Chai", "Eastern") |
F.col("district").isin("Wan Chai", "Eastern") |
[district] IN ("Wan Chai", "Eastern") |
| 清理 | ||||
| Trim + 大小寫 | 統一 " APPROVED "、approved 做 Approved。 |
TitleCase(Trim([status]))或者 Data Cleansing |
F.initcap(F.trim("status")) |
PROPER(TRIM([status])) |
| Replace / Regex | 刪走貨幣符號、逗號,淨返數字。 | ToNumber(REGEX_Replace( [amt], "[^0-9.]", "")) |
F.expr("try_cast(regexp_replace("
"amt,'[^0-9.]','') AS DOUBLE)") |
FLOAT(REGEXP_REPLACE( [amt], "[^0-9.]", "")) |
| Null 填值 | Count 類可以補 0。金額未知就保持 Null,唔好亂填。 | IIF(IsNull([cnt]), 0, [cnt])或者 Data Cleansing → Replace Nulls |
df.fillna(0, subset=["cnt"])
F.coalesce("cnt", F.lit(0)) |
ZN(SUM([cnt])) IFNULL([x], "Unknown") |
| 去重 | 同一個 key 出現幾次,只留一行。 | Unique(揀 key)→ U = 留低,D = 重複 | df.dropDuplicates(["claim_id"]) |
上游做;計數用 COUNTD([claim_id]) |
| 新欄 / 條件 | ||||
| 計算欄 | 由現有欄計出新欄。 | // Formula,新欄 type Double [paid_amt] / [claim_amt] |
df.withColumn("pay_rate",
F.col("paid_amt") / F.col("claim_amt")) |
// Row level [paid_amt] / [claim_amt] // Aggregate level(KPI 用呢個) SUM([paid_amt]) / SUM([claim_amt]) |
| IF / CASE | 按條件分類。 | IF [amt] >= 10000 THEN "High" ELSEIF [amt] >= 1000 THEN "Mid" ELSE "Low" ENDIF |
F.when(F.col("amt") >= 10000, "High")
.when(F.col("amt") >= 1000, "Mid")
.otherwise("Low") |
IF [amt] >= 10000 THEN "High" ELSEIF [amt] >= 1000 THEN "Mid" ELSE "Low" END |
| Mapping / lookup | District → Region 呢類對照。值多就用對照表 join。 | Switch([district], "NT", "Wan Chai", "HK Island", "Kwun Tong", "Kowloon")或者 Find Replace / Join 對照表 |
m = {"Wan Chai": "HK Island",
"Kwun Tong": "Kowloon"}
mp = F.create_map([F.lit(x) for kv
in m.items() for x in kv])
df.withColumn("region",
F.coalesce(mp[F.col("district")],
F.lit("NT"))) |
欄位右掣 → Create → Group;或者:CASE [district] WHEN "Wan Chai" THEN "HK Island" ELSE "NT" END |
| 日期 / 時間 | ||||
| Parse 日期 | String 轉 Date。多個 format 就逐個試。 | DateTimeParse([d], "%d/%m/%Y") |
F.coalesce(
F.expr("try_to_date(d,'yyyy-MM-dd')"),
F.expr("try_to_date(d,'dd/MM/yyyy')")) |
DATEPARSE("dd/MM/yyyy", [d]) |
| 攞日期部分 | 年、月、鐘數、星期幾,用嚟分組。 | DateTimeYear([dt]) DateTimeMonth([dt]) DateTimeHour([dt]) DateTimeFormat([dt], "%a") // Mon |
F.year("dt"); F.month("dt")
F.hour("ts")
F.date_format("dt", "E") # Mon |
YEAR([dt]) MONTH([dt])
DATEPART('hour', [ts])
DATENAME('weekday', [dt]) |
| 日期差 | 兩個日期相隔幾耐。注意次序:遲嗰個放前。 | DateTimeDiff([end], [start], "days") |
F.datediff("end", "start")
# 帶小數日數
(F.unix_timestamp("end")
- F.unix_timestamp("start")) / 86400 |
DATEDIFF('day', [start], [end]) |
| 日期加減 | 例如 due date = 開單日 + 30 日。 | DateTimeAdd([dt], 30, "days") |
F.date_add("dt", 30)
F.add_months("dt", 1) |
DATEADD('day', 30, [dt]) |
| Truncate 到月 | 將日期變做該月第一日,做月度分組。 | DateTimeTrim([dt], "month") DateTimeFormat([dt], "%Y-%m") |
F.date_trunc("month", "dt")
F.date_format("dt", "yyyy-MM") |
DATETRUNC('month', [dt])或者日期右掣 → Month(continuous) |
| Epoch / 時區 | Epoch 秒數轉時間;UTC 轉香港時間(+8,冇夏令時間)。 | DateTimeAdd("1970-01-01 00:00:00",
[epoch], "seconds")
DateTimeAdd([utc_ts], 8, "hours") |
F.from_utc_timestamp(
F.timestamp_seconds("epoch"),
"Asia/Hong_Kong") |
DATEADD('hour', 8,
DATEADD('second', [epoch],
#1970-01-01#)) |
| Aggregate | ||||
| Group by + sum / count | 按 dimension 分組計總數。 | Summarize:Group By product;Sum paid_amt;Count claim_id |
df.groupBy("product").agg(
F.sum("paid_amt").alias("paid"),
F.count("*").alias("n")) |
product 拉落 Rows,SUM([paid_amt]) 拉落 Columns |
| Count distinct | 唯一值數量,例如 order 數(唔係 line 數)。 | Summarize → Count Distinct | F.countDistinct("order_id") |
COUNTD([order_id]) |
| % of total | 每組佔總數幾多。 | Summarize 計 total → Append Fields → Formula[paid] / [total_paid] |
w = Window.partitionBy()
df.withColumn("pct",
F.col("paid") / F.sum("paid").over(w)) |
SUM([paid]) / TOTAL(SUM([paid]))或者 Quick Table Calc → Percent of Total |
| 固定 grain 計算 | 喺指定層級計,唔受 view 影響。例如每單 order 總額。 | Summarize 按 order_id → Join 返原 data | w = Window.partitionBy("order_id")
df.withColumn("order_total",
F.sum("amt").over(w)) |
{FIXED [order_id] : SUM([amt])} |
| 合併 | ||||
| Inner join | 兩邊都有嘅 key 先留。 | Join → By Specific Fields → 用 J output | c.join(p, "policy_id", "inner") |
Data Source:拖兩個 table,揀 Inner,設 key |
| Left join | 左邊全部保留,右邊對唔到就係 Null。 | Join → Union(J + L) | c.join(p, "policy_id", "left") |
Join 揀 Left;或者用 Relationship(預設) |
| 搵孤兒(anti join) | 左邊有、右邊冇嘅 key,係 DQ 問題。 | Join 嘅 L output | c.join(p, "policy_id", "left_anti") |
Left join 後 filter:ISNULL([policy_id (policies)]) |
| Union | 上下疊埋。一定要按欄名對齊,唔好按位置。 | Union → Auto Config by Name | a.unionByName(b, allowMissingColumns=True) |
Data Source → New Union |
| Cross join | 每個組合都出一行,用嚟整 scaffold(product × 月份)。 | Append Fields(T = 月份,S = product) | months.crossJoin(products) |
Join calculation 兩邊都填 1 |
| Range join | 按時間範圍對版本,例如買嘢嗰刻嘅會員 tier。 | Join(member_id)→ Filter:[ts] >= [start] AND ([ts] < [end] OR IsNull([end])) |
cond = ((f.mid == d.mid)
& (f.ts >= d.start)
& (f.ts < F.coalesce(d.end,
F.lit("9999-12-31")
.cast("timestamp"))))
f.join(d, cond, "left") |
上游做;Tableau 做好慢 |
| 排序 / Window | ||||
| Sort | 排序。Unique、Multi-Row 之前一定要做。 | Sort:policy_id ↑,claim_dt ↑ |
df.orderBy("policy_id",
F.desc("claim_dt")) |
撳軸上 sort icon;或者右掣 → Sort |
| Rank / 每組 Top N | 每個 region 攞頭 3 名。 | Sort → Sample:First 3,Group By region | w = (Window.partitionBy("region")
.orderBy(F.desc("paid")))
top3 = (df
.withColumn("rk", F.dense_rank().over(w))
.filter("rk <= 3")) |
RANK(SUM([paid]))Compute Using 設 adjuster,filter ≤ 3 |
| 上一行 / 下一行 | 同上一單比較,例如相隔幾多日。 | Multi-Row Formula(Group By policy_id):DateTimeDiff([claim_dt], [Row-1:claim_dt], "days") |
w = (Window.partitionBy("policy_id")
.orderBy("claim_dt"))
df.withColumn("prev",
F.lag("claim_dt").over(w)) |
LOOKUP(SUM([sales]), -1) |
| 累計 | Running total,由頭加到而家。 | Running Total tool(Group By 可選) | w = (Window.partitionBy("store")
.orderBy("dt")
.rowsBetween(Window.unboundedPreceding, 0))
df.withColumn("cum", F.sum("sales").over(w)) |
RUNNING_SUM(SUM([sales])) |
| 移動平均 | 例如 7 日平均,令趨勢平滑啲。 | Multi-Row Formula:([Row-0:x]+[Row-1:x]+[Row-2:x])/3 |
w = (Window.partitionBy("store")
.orderBy("dt").rowsBetween(-6, 0))
df.withColumn("ma7", F.avg("sales").over(w)) |
WINDOW_AVG(SUM([sales]), -6, 0) |
| 重塑 | ||||
| Pivot(長 → 闊) | 行 = product,欄 = 年份。 | Cross Tab:Group By product,Header year,Value paid,Sum | df.groupBy("product").pivot("year")
.sum("paid") |
product → Rows,YEAR → Columns,SUM(paid) → Text |
| Unpivot(闊 → 長) | 將幾個月份欄變返做行。 | Transpose:Key = product,Data = 2024、2025 | df.unpivot(["product"], ["2024","2025"], "year", "paid") |
Data Source:揀欄 → 右掣 Pivot |
| Explode array | 一行有幾個 item,拆做每個 item 一行。 | JSON Parse → Text To Columns(拆 items.0.sku)→ Cross Tab |
js.select("order_id",
F.explode("items").alias("it"))
.select("order_id", "it.sku", "it.qty") |
JSON connector 揀 items level,自動拆 |
| Split 字串 | 例如 "HK-001" 拆做地區同編號。 |
Text To Columns:delimiter -,Split to columns |
F.split("code", "-").getItem(0) |
SPLIT([code], "-", 1) |
| 其他 | ||||
| 生日期序列 | 整一張完整月份表,用嚟補 0。 | Generate Rows:Init: "2024-01-01" Cond: [m] <= "2025-12-01" Loop: DateTimeAdd([m],1,"months") |
spark.sql("""SELECT explode(sequence(
DATE'2024-01-01', DATE'2025-12-01',
INTERVAL 1 MONTH)) AS m""") |
日期右掣 → Show Missing Values |
| Sample / Limit | 睇頭幾行或者抽樣。 | Sample:First N / Random % | df.limit(10); df.sample(0.1, seed=42) |
Filter → Top N;Extract → Sample |
| 數行數 | 每一步驗證用。 | Browse 睇 record 數;或者 Count Records tool | df.count() |
COUNT([claim_id])或者 claims (Count) 欄 |
| 寫 SQL | 熟 SQL 就直接用。 | In-DB tools;或者 Input Data 寫 SQL query(連 DB 時) | df.createOrReplaceTempView("c")
spark.sql("SELECT product, SUM(paid) "
"FROM c GROUP BY product") |
Data Source → New Custom SQL |
同一個問題,三個工具嘅做法。考試通常要求三邊數字一樣,所以每個都要識。
| Use case | Alteryx | PySpark | Tableau |
|---|---|---|---|
| Profile / 搵 DQ 問題null、unique、format 唔一致 | Browse → Profile tab;Basic Data Profile(CountNull、CountUnique、MinLength) | df.summary();null 數:df.select([F.sum(F.col(c).isNull().cast("int")).alias(c) for c in df.columns]) |
Data Source tab 睇 type;拉欄落 Rows 睇 distinct 值 |
| 去重exact duplicate | Unique(按 key)→ U / D;勾晒所有欄再 Unique 驗證 | df.dropDuplicates(["order_id","line_no"]) |
唔適合喺 Tableau 做,上游處理 |
| 同一 key 留最新版本upsert / CDC | Union → Sort updated_at ↓ → Unique(key) |
row_number().over(Window.partitionBy("id").orderBy(F.desc("updated_at"))) → rn==1;或 Delta MERGE |
{FIXED [id] : MAX([updated_at])} = [updated_at] 做 filter |
| 清文字大小寫、空格 | TitleCase(Trim([x])) 或 Data Cleansing |
F.initcap(F.trim("x"));F.upper / F.lower |
PROPER(TRIM([x]))(Tableau 2023+ 有 PROPER) |
| 多 format 日期yyyy-mm-dd、dd/mm/yyyy | IF Contains([d],"/") THEN DateTimeParse([d],"%d/%m/%Y") ELSE DateTimeParse(Left([d],10),"%Y-%m-%d") ENDIF |
F.coalesce(F.expr("try_to_date(d,'yyyy-MM-dd')"), F.expr("try_to_date(d,'dd/MM/yyyy')")) |
IFNULL(DATEPARSE("yyyy-MM-dd",[d]), DATEPARSE("dd/MM/yyyy",[d])) |
| Epoch / 時區UTC → 香港 | DateTimeAdd("1970-01-01",[epoch],"seconds") → DateTimeAdd([dt],8,"hours") |
F.from_utc_timestamp(F.timestamp_seconds("epoch"),"Asia/Hong_Kong") |
DATEADD('second',[epoch],#1970-01-01#),再 DATEADD('hour',8,…) |
| 文字金額 → 數字HKD 1,200 / $1,200 | ToNumber(REGEX_Replace([x],"[^0-9.]","")),type 揀 Double |
F.expr("try_cast(regexp_replace(x,'[^0-9.]','') AS DOUBLE)") |
FLOAT(REGEXP_REPLACE([x],"[^0-9.]","")) |
| Join + 搵孤兒FK 對唔上 | Join → J / L / R;J + L = 左邊總數 | how="inner";孤兒:how="left_anti" |
Relationship;孤兒用 Join(left)+ ISNULL([right_key]) |
| Ratio 喺啱嘅 grainloss ratio、avg ticket | 兩邊各自 Summarize 到同一層 → Join | 兩邊各自 groupBy().agg() → join |
SUM([a]) / SUM([b]);relationship 唔會重複計 dim |
| Pivot 長 → 闊 | Cross Tab(Group By、Header、Value、Sum) | .groupBy("store").pivot("year").sum("sales") |
Dimension 放 Columns 就係 pivot |
| Unpivot 闊 → 長 | Transpose(Key 欄 + Data 欄) | df.unpivot(["store"], ["m1","m2"], "month", "sales")(Spark 3.4+) |
Data Source:揀欄 → 右掣 Pivot |
| 上一行比較repeat within N 日 | Sort → Multi-Row(Group By)DateTimeDiff([d],[Row-1:d],"days") |
F.datediff("d", F.lag("d").over(Window.partitionBy("id").orderBy("d"))) |
Table calc:DATEDIFF('day', LOOKUP(MIN([d]),-1), MIN([d])),Compute Using 設好 |
| 每組 Top N | Summarize → Sort → Sample(First N,Group By) | row_number() 或 dense_rank() over partition → filter(rn<=3) |
INDEX() 或 RANK() table calc ≤ 3;單層用 Filter → Top N |
| 累計 / 移動平均 | Running Total tool;Multi-Row 做 moving avg | F.sum("x").over(w.rowsBetween(Window.unboundedPreceding, 0));rowsBetween(-6,0) |
Quick Table Calc → Running Total / Moving Average |
| YoY / MoM | Cross Tab 按年 → Formula ([2025]-[2024])/[2024] |
F.lag("sales",12).over(w) 或 pivot 後相除 |
Quick Table Calc → Year over Year Growth |
| % of total | Summarize total → Append Fields → Formula 相除 | F.col("x") / F.sum("x").over(Window.partitionBy()) |
Quick Table Calc → Percent of Total |
| 補 0 / 冇 data 嘅期間scaffold | Generate Rows(月份)+ Append Fields(dim)→ Join → Union L → 補 0 | sequence() + explode → crossJoin(dim) → left join → fillna(0) |
Show Missing Values;ZN(SUM([x])) |
| Schema 唔同嘅多個檔 | 分開 Input + Output File Name as Field → Union Auto Config by Name | df1.unionByName(df2, allowMissingColumns=True);_metadata.file_path |
Union(Data Source),同名欄自動對齊 |
| Nested JSONarray of structs | Input(\0 delimiter)→ Record ID → JSON Parse → Text To Columns → Cross Tab |
spark.read.json() → F.explode("items") → col("item.sku") |
直接連 JSON file,揀要嘅 schema level |
| SCD Type 2保留維度歷史 | Sort → Multi-Row:end = [Row+1:start];is_current = IsNull([end]) |
lead("start").over(w) 做 end;增量用 Delta MERGE(見下) |
用 is_current = TRUE filter 睇現況 |
| Point-in-time join交易嗰刻嘅 tier / 價錢 | Join(key)→ Filter [ts] >= [start] AND ([ts] < [end] OR IsNull([end])) |
join 條件加 ts >= start AND ts < coalesce(end, '9999-12-31') |
喺上游做好;Tableau 做 range join 好慢 |
| Order-level 指標avg ticket、attach rate | Summarize 按 order(Sum、Max(is_food))→ 再 Summarize 整體 | 先 groupBy("order_id"),再 groupBy("store") |
{FIXED [order_id] : MAX(IIF([cat]="Food",1,0))} |
| 繁忙時段weekday × hour | DateTimeFormat([ts],"%a")、DateTimeHour([ts]) → Cross Tab |
F.date_format("ts","E")、F.hour("ts") → groupBy |
Rows WEEKDAY、Columns HOUR、Marks Square(heatmap) |
[Row-1:x]、[Row+1:x],要 Group Byfile.xlsx|||Sheet;.hyper 俾 Tableau// 文字
Trim([x]) TitleCase([x]) Uppercase([x])
Contains([x],"HKD") StartsWith([x],"P")
Left([x],10) Substring([x],3,2) Length([x])
REGEX_Replace([x],"[^0-9.]","")
REGEX_Match([x],"^CL\d{6}$")
// 數字 / Null
ToNumber([x]) ToString([n]) Round([n],0.01)
IsNull([x]) IsEmpty([x]) Null()
IIF([x] > 0, "Y", "N")
// 日期
DateTimeParse([d],"%d/%m/%Y")
DateTimeFormat([d],"%Y-%m")
DateTimeDiff([d2],[d1],"days")
DateTimeAdd([d],1,"months")
DateTimeYear([d]) DateTimeHour([d])
DateTimeToday() DateTimeNow()
// 條件
IF [a] THEN x ELSEIF [b] THEN y ELSE z ENDIF
[x] IN ("A","B","C")
IsNull(),數要等於原本 blank// Ratio:喺入面 aggregate
SUM([Sales]) / COUNTD([Order ID]) // avg ticket
SUM([Paid]) / SUM([Premium]) // loss ratio
// 條件
IF [Channel] = "Mobile" THEN "Digital" ELSE "Store" END
IIF([Sales] > 100, "High", "Low")
ZN(SUM([Sales])) IFNULL([x], 0)
// 日期
DATETRUNC('month', [Order Date])
DATEPART('hour', [Order TS])
DATEDIFF('day', [Start], [End])
DATEPARSE("dd/MM/yyyy", [Raw Date])
// 文字
TRIM([x]) UPPER([x]) CONTAINS([x],"Latte")
SPLIT([x], "-", 1)
// FIXED:唔理 view,固定 grain
{FIXED [Order ID] : SUM([Sales])} // order total
{FIXED [Customer] : MIN([Order Date])} // 首次購買
{FIXED [Order ID] : MAX(IIF([Category]="Food",1,0))}
// → attach rate = SUM(上面) / COUNTD([Order ID])
// INCLUDE:加細 grain 再 aggregate
AVG({INCLUDE [Order ID] : SUM([Sales])})
// EXCLUDE:去走某個 dimension
SUM([Sales]) / SUM({EXCLUDE [Store] : SUM([Sales])})
FIXED 喺 dimension filter 之前計。要佢跟 filter,就將 filter 右掣 Add to Context。
RUNNING_SUM(SUM([Sales]))RANK(SUM([Sales]));INDEX()LOOKUP(SUM([Sales]), -1)(ZN(SUM([S])) - LOOKUP(ZN(SUM([S])),-1)) / ABS(LOOKUP(ZN(SUM([S])),-1))from pyspark.sql import functions as F, Window
from pyspark.sql.types import *
df = (spark.read.option("header", True)
.option("inferSchema", False)
.csv("/path/pos/*.csv"))
js = spark.read.json("/path/mobile/") # JSONL 一行一個
df = df.withColumn("_src", F.col("_metadata.file_path"))
# 明確 schema(production 做法)
schema = StructType([
StructField("order_id", StringType()),
StructField("amount", StringType()),
])
(df.write.format("delta").mode("overwrite")
.saveAsTable("silver.pos"))
df.write.mode("overwrite").partitionBy("dt").parquet("/out")
df = (df
.withColumn("channel", F.initcap(F.trim("channel")))
.withColumn("amt", F.expr(
"try_cast(regexp_replace(amount,'[^0-9.]','') AS DOUBLE)"))
.withColumn("ts", F.coalesce(
F.expr("try_to_timestamp(ts_raw,'yyyy-MM-dd HH:mm:ss')"),
F.expr("try_to_timestamp(ts_raw,'dd/MM/yyyy HH:mm')"),
F.timestamp_seconds(F.expr("try_cast(ts_raw AS BIGINT)"))))
.withColumn("ts_hk", F.from_utc_timestamp("ts", "Asia/Hong_Kong"))
.withColumn("tier", F.when(F.col("amt") > 100, "High")
.otherwise("Low"))
.filter(F.col("amt").isNotNull())
.dropDuplicates(["order_id", "line_no"])
.withColumnRenamed("sku", "product_id")
.drop("amount"))
agg = (df.groupBy("store_id", F.to_date("ts_hk").alias("dt"))
.agg(F.sum("amt").alias("sales"),
F.countDistinct("order_id").alias("orders"),
F.avg("amt").alias("avg_line")))
f = fact.join(F.broadcast(dim_store), "store_id", "left")
orphans = fact.join(dim_store, "store_id", "left_anti")
# 唔同名 key
a.join(b, a.pid == b.policy_id, "inner")
# pivot
df.groupBy("store_id").pivot("channel").sum("amt")
w = Window.partitionBy("member_id").orderBy("ts")
df = (df
.withColumn("prev_ts", F.lag("ts").over(w))
.withColumn("next_ts", F.lead("ts").over(w))
.withColumn("gap_days", F.datediff("ts", "prev_ts"))
.withColumn("rn", F.row_number().over(w))
.withColumn("running", F.sum("amt").over(
w.rowsBetween(Window.unboundedPreceding, 0))))
# 每組 Top 3
wr = Window.partitionBy("region").orderBy(F.desc("sales"))
top3 = agg.withColumn("rk", F.dense_rank().over(wr)).filter("rk <= 3")
# 留最新版本
wl = Window.partitionBy("id").orderBy(F.desc("updated_at"))
latest = df.withColumn("rn", F.row_number().over(wl)).filter("rn = 1")
lines = (js
.select("order_id", "ts_utc", F.explode("items").alias("it"))
.select("order_id", "ts_utc",
F.col("it.sku").alias("sku"),
F.col("it.qty").alias("qty"),
F.col("it.price").alias("price")))
# explode_outer:空 array 都保留一行
# higher-order function(唔使 explode)
js = js.withColumn("order_total", F.aggregate(
"items", F.lit(0.0), lambda acc, x: acc + x["qty"] * x["price"]))
js = js.withColumn("n_custom", F.size(F.filter(
"items", lambda x: F.size(x["customizations"]) > 0)))
months = spark.sql("""
SELECT explode(sequence(DATE'2025-01-01', DATE'2025-12-01',
INTERVAL 1 MONTH)) AS m""")
grid = months.crossJoin(stores.select("store_id"))
full = (grid.join(monthly, ["m", "store_id"], "left")
.fillna(0, subset=["orders"]))
# 隨時轉 SQL
df.createOrReplaceTempView("pos")
spark.sql("""SELECT store_id, SUM(amt) sales
FROM pos GROUP BY store_id ORDER BY sales DESC""")
df.explain() # 睇 plan:BroadcastHashJoin?
df.count(); df.printSchema(); df.show(5, truncate=False)
_source_file、_ingest_ts,全部 string,唔清理MERGE INTO silver.dim_member t
USING (
SELECT c.member_id AS mk, c.* FROM changes c
UNION ALL
SELECT NULL AS mk, c.* FROM changes c
JOIN silver.dim_member d
ON c.member_id = d.member_id
AND d.is_current AND c.tier <> d.tier
) s
ON t.member_id = s.mk AND t.is_current
WHEN MATCHED AND t.tier <> s.tier THEN UPDATE
SET is_current = false, end_ts = s.change_ts
WHEN NOT MATCHED THEN INSERT
(member_id, tier, start_ts, end_ts, is_current)
VALUES (s.member_id, s.tier, s.change_ts, NULL, true);
MERGE INTO silver.orders t
USING updates s ON t.order_id = s.order_id
WHEN MATCHED AND s.updated_at > t.updated_at
THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
# 覆寫某段分區,重跑唔重複
(df.write.format("delta").mode("overwrite")
.option("replaceWhere", "dt >= '2025-10-01' AND dt < '2025-11-01'")
.saveAsTable("silver.pos"))
(spark.readStream.format("cloudFiles")
.option("cloudFiles.format", "json")
.option("cloudFiles.schemaLocation", CHK + "/schema")
.load(RAW + "/mobile/")
.withColumn("_src", F.col("_metadata.file_path"))
.writeStream
.option("checkpointLocation", CHK + "/mobile")
.trigger(availableNow=True)
.toTable("bronze.mobile"))
Colab / OSS Spark 冇 cloudFiles:改用 spark.readStream.schema(s).json(path),checkpoint 一樣記住讀過嘅檔。
DESCRIBE HISTORY silver.pos;
SELECT * FROM silver.pos VERSION AS OF 3;
SELECT * FROM silver.pos TIMESTAMP AS OF '2025-10-01';
RESTORE TABLE silver.pos TO VERSION AS OF 3;
ALTER TABLE silver.pos ADD CONSTRAINT amt_ok CHECK (amt >= 0);
ALTER TABLE silver.pos ALTER COLUMN order_id SET NOT NULL;
CREATE TABLE gold.fact_sales_line (...) CLUSTER BY (store_id, dt);
OPTIMIZE gold.fact_sales_line;
CREATE OR REPLACE VIEW gold.v_store_daily AS
SELECT store_id, dt, SUM(amt) sales, COUNT(DISTINCT order_id) orders
FROM gold.fact_sales_line GROUP BY store_id, dt;
!pip install -q pyspark==3.5.3 delta-spark==3.2.1
from pyspark.sql import SparkSession
from delta import configure_spark_with_delta_pip
b = (SparkSession.builder
.config("spark.sql.extensions",
"io.delta.sql.DeltaSparkSessionExtension")
.config("spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog")
.config("spark.sql.ansi.enabled", "true"))
spark = configure_spark_with_delta_pip(b).getOrCreate()
Databricks serverless 預設 ANSI:parse / cast 失敗會報錯,所以用 try_cast、try_to_timestamp。Serverless 冇 .cache()、RDD。
union() 係按位置對欄。用 by name。mode("append") 重跑兩次就兩倍。用 MERGE、replaceWhere 或 checkpoint。AVG(a/b) ≠ SUM(a)/SUM(b)。KPI 通常要後者。countDistinct(order_id),唔係 line 行數。