I denne workshopen skal vi jobbe med å transformere operasjonelle data til analyseklar data ved hjelp av medaljongarkitektur (Bronze-Silver-Gold) på Google Cloud Platform. Dere vil få praktisk erfaring med:
- Datakvalitetsutfordringer: Forstå hvordan rotete data ser ut i virkeligheten
- Datarensing: Standardisere formater, håndtere duplikater og NULL-verdier
- Datamodellering: Bygge et stjerneskjema for analytiske formål
- BigQuery: Bruke SQL for transformasjoner og datakvalitetsmålinger
Workshopen er designet for å være hands-on med valgfrie beslutningspunkter underveis, slik at dere får erfaring med de avveiningene man må ta i virkelige dataprosjekter.
- Introduksjon til medaljongarkitektur (10 min): Operasjonelle vs. analytiske behov, Bronze-Silver-Gold, gruppefordeling
- Miljøsjekk og oppsett (10 min): Autentisering, opprett datasets for bronze, silver, gold
- Datagenerering (10 min): Kjør Python-generator for CSV-datasett
- Bronse + Data Profiling (25 min): Last data til BigQuery, kjør profiling queries for å forstå datakvalitetsutfordringer
- Sølv (40 min): Rens og standardiser felter, deduplisering med fingerprint, partisjonering og klustring
- Gull (15 min): Opprett faktatabell og KPI-views per dag, produkt, betalingsmetode
- Oppsummering (10 min): Refleksjon, kostnadsbevissthet, hver gruppe deler ett innsikt
Vi jobber alle i samme prosjekt i BigQuery (på Kantega sin GCP konto). For å hindre at vi skriver til og endrer hverandres datasett følger vi denne navnekonvensjonen. Alle script og spørringer i workshopen tar høyde for dette, og dere må bytte ut gruppenavnet manuelt i SQL-spørringer.
- Datasets:
<gruppenavn>_bronze,<gruppenavn>_silver,<gruppenavn>_gold - Eksempel for gruppe "lambda":
lambda_bronze,lambda_silver,lambda_gold
- lambda - Lambda-arkitektur (batch + streaming)
- kappa - Kappa-arkitektur (kun streaming)
- delta - Delta Lake-arkitektur
- lakehouse - Lakehouse-arkitektur
- mesh - Data Mesh-arkitektur
- fabric - Data Fabric-arkitektur
- vault - Data Vault-modellering
- star - Star schema-modellering
VIKTIG: Før dere kan begynne med øvelsene må dere ha installert nødvendig programvare og satt opp prosjektet.
👉 Gå til SETUP.md for fullstendig installasjonsveiledning
SETUP.md inneholder:
- 📦 Installasjonsinstruksjoner for Mac, Linux og Windows
- 🛠️ Oppsett av Python, Poetry og gcloud CLI
- ✅ Verifisering av at alt er korrekt installert
Når dere har fullført oppsettet i SETUP.md, kom tilbake hit og fortsett med øvelsene nedenfor.
Autentiser deg mot GCP (hvis ikke allerede gjort).
VIKTIG: Logg deg inn med din Kantega-konto ([email protected]). Brukeren din har blitt lagt til i GCP, slik at du har tilgang til prosjektet.
gcloud auth login
gcloud auth application-default loginSett ditt prosjekt:
gcloud config set project loft-473909VIKTIG: Sett miljøvariabel for deres gruppenavn. Erstatt <gruppenavn> med faktisk gruppenavn (lambda, kappa, delta, etc.):
export GROUP_NAME=<gruppenavn>Verifiser at miljøvariabelen er satt:
echo "Mitt gruppenavn er: ${GROUP_NAME}"Opprett BigQuery datasets for deres gruppe:
Opprett bronze dataset:
bq --location=europe-west1 mk -d ${GROUP_NAME}_bronzeOpprett silver dataset:
bq --location=europe-west1 mk -d ${GROUP_NAME}_silverOpprett gold dataset:
bq --location=europe-west1 mk -d ${GROUP_NAME}_goldVerifiser at datasets er opprettet:
bq ls | grep ${GROUP_NAME}✅ Suksesskriterium: Dere skal se tre linjer med deres gruppenavn: <gruppenavn>_bronze, <gruppenavn>_silver, og <gruppenavn>_gold.
Sjekk at datasets er i riktig region:
bq show --format=prettyjson ${GROUP_NAME}_bronze | grep location
bq show --format=prettyjson ${GROUP_NAME}_silver | grep location
bq show --format=prettyjson ${GROUP_NAME}_gold | grep location✅ Suksesskriterium: Alle tre datasets skal vise "location": "europe-west1".
🎯 Hva skal vi gjøre: Generere et realistisk datasett med 1 million salgstransaksjoner som inneholder typiske datakvalitetsutfordringer.
💡 Hvorfor: I virkelige dataprosjekter møter vi alltid rotete data - forskjellige formater, typos, duplikater, og inkonsistente verdier. Dette datasettet simulerer slike utfordringer slik at dere får praktisk erfaring med datarensing. Ved å se på dataene FØR dere laster dem til BigQuery, får dere en forståelse av hva som må fikses.
Opprett output-mappe:
mkdir -p data_gen/outGenerer 1.000.000 rader med salgsdata i CSV-format med realistisk scenario (dere kan også generere et annet antall om dere vil):
poetry run python generate_sales_data.py --rows 1000000 --format csv --config configs/realistic_scenario.json --output data_gen/out/sales_sample_${GROUP_NAME}Sjekk at filen ble generert:
ls -lh data_gen/out/✅ Suksesskriterium: Dere skal se en fil sales_sample_<gruppenavn>.csv som er ca. 100-150 MB stor (for 1 million rader).
📊 Utforsk dataene før dere laster dem til BigQuery:
Se på de første 20 linjene i CSV-filen:
head -20 data_gen/out/sales_sample_${GROUP_NAME}.csvTell antall linjer i CSV-filen:
wc -l data_gen/out/sales_sample_${GROUP_NAME}.csv💡 Reflekter over datakvaliteten:
Ta 2-3 minutter i gruppen for å se på dataene. Diskuter:
- Hvilke forskjellige formater ser dere for datoer? (ISO 8601, US format, EU format, Unix timestamp?)
- Ser dere variasjon i
product_id? (PROD-0001, 001, P001, product_001?) - Hvordan ser
sales_amountut? (Rene tall, valutasymboler, tekstverdier?) - Finnes det typos eller ekstra mellomrom i
product_nameellerpayment_method? - Hva tror dere vil være de største utfordringene når dere skal rense disse dataene?
Dette er viktig! Å forstå datakvalitetsutfordringene FØR dere begynner med rensing gjør det lettere å ta gode beslutninger i Silver-laget.
🎯 Hva skal vi gjøre: Laste rådata fra CSV-filen direkte inn i BigQuery Bronze-laget uten noen transformasjoner.
💡 Hvorfor: Bronze-laget er det første steget i medaljongarkitekturen. Her lagrer vi data nøyaktig slik de kommer fra kilden - ingen rensing, ingen transformasjoner. Dette gir oss:
- Sporbarhet: Vi kan alltid gå tilbake til originaldataene
- Repeterbarhet: Vi kan kjøre transformasjoner på nytt hvis noe går galt
- Datakvalitetsinnsikt: Vi kan sammenligne rådata med rensede data
BigQuery sin --autodetect funksjon gjetter datatyper automatisk basert på innholdet i CSV-filen.
Last data direkte fra lokal fil til BigQuery (deres gruppes bronze dataset):
bq --location=europe-west1 load \
--autodetect \
--source_format=CSV \
--replace \
${GROUP_NAME}_bronze.sales \
data_gen/out/sales_sample_${GROUP_NAME}.csvVerifiser at tabellen er opprettet og har data i BigQuery Console:
- Gå til BigQuery Console
- I venstre panel, finn deres dataset
<gruppenavn>_bronze(f.eks.lambda_bronze) - Klikk på
salestabellen - Se på "Schema" fanen for å se kolonnene
- Se på "Details" fanen for å se antall rader og tabellstørrelse
- Klikk på "Preview" fanen for å se de første radene av data
Alternativt: Kjør en query for å verifisere:
- Klikk på "Compose New Query" eller "+"-knappen
- Lim inn følgende SQL (bytt ut
<gruppenavn>med deres gruppenavn):
-- Tell antall rader
SELECT COUNT(*) as row_count
FROM `<gruppenavn>_bronze.sales`;
-- Se de første 10 radene
SELECT *
FROM `<gruppenavn>_bronze.sales`
LIMIT 10;- Klikk "Run" eller trykk Ctrl+Enter (Cmd+Enter på Mac) for å kjøre queryen
🎯 Hva skal vi gjøre: Kjøre SQL-spørringer for å systematisk analysere datakvaliteten i Bronze-laget.
💡 Hvorfor: Data profiling er et kritisk steg som ofte hoppes over i dataprosjekter. Ved å forstå datakvalitetsutfordringene FØR vi begynner med transformasjoner, kan vi:
- Planlegge bedre: Vite hvilke rensesteg som trengs
- Ta informerte beslutninger: Forstå konsekvensene av ulike valg
- Måle forbedring: Sammenligne datakvalitet før og etter rensing
- Unngå overraskelser: Oppdage problemer tidlig i prosessen
Dere vil se konkrete eksempler på NULL-verdier, formatvariasjoner, duplikater og inkonsistente data - akkurat som i virkelige datasett.
💡 Hvordan kjøre SQL-queries:
Du har to alternativer for å kjøre SQL-queries:
Alternativ 1: Bruk
bq queryi terminalenbq query --nouse_legacy_sql "SELECT COUNT(*) FROM \`${GROUP_NAME}_bronze.sales\`"Alternativ 2: Bruk BigQuery Console (Web UI)
- Gå til BigQuery Console
- Klikk på "Compose New Query" eller "+"-knappen
- Lim inn SQL-koden nedenfor
- Bytt ut
<gruppenavn>med ditt faktiske gruppenavn (f.eks.lambda)- Klikk "Run" eller trykk Ctrl+Enter (Cmd+Enter på Mac)
Tips: Bruk Console for lengre queries og utforskning,
bq queryfor raske sjekker og automatisering.
SQL 1: Tell totalt antall records
SELECT COUNT(*) as total_records
FROM `<gruppenavn>_bronze.sales`;SQL 2: Sjekk for NULL og tomme verdier per kolonne
SELECT
COUNTIF(product_id IS NULL OR CAST(product_id AS STRING) = '') as null_product_id,
COUNTIF(product_name IS NULL OR CAST(product_name AS STRING) = '') as null_product_name,
COUNTIF(sales_timestamp IS NULL OR CAST(sales_timestamp AS STRING) = '') as null_timestamp,
COUNTIF(sales_amount IS NULL OR CAST(sales_amount AS STRING) = '') as null_amount,
COUNTIF(payment_method IS NULL OR CAST(payment_method AS STRING) = '') as null_payment_method,
ROUND(COUNTIF(product_id IS NULL OR CAST(product_id AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_product_id,
ROUND(COUNTIF(product_name IS NULL OR CAST(product_name AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_product_name,
ROUND(COUNTIF(sales_timestamp IS NULL OR CAST(sales_timestamp AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_timestamp,
ROUND(COUNTIF(sales_amount IS NULL OR CAST(sales_amount AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_amount,
ROUND(COUNTIF(payment_method IS NULL OR CAST(payment_method AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_payment_method
FROM `<gruppenavn>_bronze.sales`;SQL 3: Analyser product_id formater (se variasjon i formatering)
SELECT
CAST(product_id AS STRING) as product_id,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) as percentage
FROM `<gruppenavn>_bronze.sales`
WHERE product_id IS NOT NULL AND CAST(product_id AS STRING) != ''
GROUP BY product_id
ORDER BY count DESC
LIMIT 20;SQL 4: Analyser product_name variasjoner (se hvor mange varianter av samme produkt)
SELECT
LOWER(TRIM(CAST(product_name AS STRING))) as normalized_name,
COUNT(DISTINCT CAST(product_name AS STRING)) as variations,
ARRAY_AGG(DISTINCT CAST(product_name AS STRING) LIMIT 5) as examples
FROM `<gruppenavn>_bronze.sales`
WHERE product_name IS NOT NULL AND CAST(product_name AS STRING) != ''
GROUP BY normalized_name
HAVING variations > 1
ORDER BY variations DESC;SQL 5: Analyser timestamp formater (datageneratoren produserer 5 hovedformater)
SELECT
CASE
WHEN REGEXP_CONTAINS(CAST(sales_timestamp AS STRING), r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}') THEN 'ISO 8601 (Clean)'
WHEN REGEXP_CONTAINS(CAST(sales_timestamp AS STRING), r'^\d{2}/\d{2}/\d{4} \d{1,2}:\d{2} [AP]M') THEN 'US Format (MM/DD/YYYY HH:MM AM/PM)'
WHEN REGEXP_CONTAINS(CAST(sales_timestamp AS STRING), r'^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$') THEN 'EU Format (DD/MM/YYYY HH:MM)'
WHEN REGEXP_CONTAINS(CAST(sales_timestamp AS STRING), r'^\d{4}-\d{2}-\d{2}$') THEN 'Date Only (YYYY-MM-DD)'
WHEN REGEXP_CONTAINS(CAST(sales_timestamp AS STRING), r'^\d{10}$') THEN 'Unix Timestamp (seconds)'
ELSE 'Invalid/Other'
END as format_type,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) as percentage,
ARRAY_AGG(CAST(sales_timestamp AS STRING) LIMIT 3) as examples
FROM `<gruppenavn>_bronze.sales`
WHERE sales_timestamp IS NOT NULL AND CAST(sales_timestamp AS STRING) != ''
GROUP BY format_type
ORDER BY count DESC;SQL 6: Analyser sales_amount formater (se valutaforskjeller og tekstverdier)
SELECT
CASE
WHEN REGEXP_CONTAINS(CAST(sales_amount AS STRING), r'^\d+\.?\d*$') THEN 'Clean Number'
WHEN REGEXP_CONTAINS(CAST(sales_amount AS STRING), r'^\$') THEN 'Dollar Sign'
WHEN REGEXP_CONTAINS(CAST(sales_amount AS STRING), r'USD') THEN 'USD Text'
WHEN REGEXP_CONTAINS(CAST(sales_amount AS STRING), r'€') THEN 'Euro'
WHEN REGEXP_CONTAINS(CAST(sales_amount AS STRING), r'£') THEN 'Pound'
WHEN CAST(sales_amount AS STRING) IN ('free', 'N/A', 'pending', '0') THEN 'Text Value'
ELSE 'Other'
END as format_type,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) as percentage,
ARRAY_AGG(CAST(sales_amount AS STRING) LIMIT 3) as examples
FROM `<gruppenavn>_bronze.sales`
WHERE sales_amount IS NOT NULL AND CAST(sales_amount AS STRING) != ''
GROUP BY format_type
ORDER BY count DESC;SQL 7: Finn duplikate records
SELECT
CAST(product_id AS STRING) as product_id,
CAST(product_name AS STRING) as product_name,
COUNT(*) as duplicate_count,
CAST(sales_timestamp AS STRING) as sales_timestamp,
CAST(sales_amount AS STRING) as sales_amount,
CAST(payment_method AS STRING) as payment_method
FROM `<gruppenavn>_bronze.sales`
GROUP BY product_id, product_name, sales_timestamp, sales_amount, payment_method
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC
LIMIT 20;SQL 8: Analyser payment_method variasjoner
SELECT
CAST(payment_method AS STRING) as payment_method,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) as percentage
FROM `<gruppenavn>_bronze.sales`
WHERE payment_method IS NOT NULL AND CAST(payment_method AS STRING) != ''
GROUP BY payment_method
ORDER BY count DESC;💭 Refleksjonsspørsmål etter data profiling:
Ta 2-3 minutter i gruppen for å diskutere:
- Hvor mange forskjellige formater av
sales_timestampfant dere? - Hvor stor prosentandel av
product_namehar variasjoner (typos, whitespace)? - Hvor mange duplikater finnes i datasettet?
- Hvilke datakvalitetsutfordringer ser dere som mest kritiske?
🎯 Hva skal vi gjøre: Transformere rotete rådata fra Bronze til rene, standardiserte data i Silver-laget gjennom systematisk rensing av hvert felt.
💡 Hvorfor: Silver-laget er hjertet av medaljongarkitekturen. Her gjør vi dataene:
- Konsistente: Alle datoer i samme format, alle beløp som tall, alle produkt-IDer standardisert
- Pålitelige: Fjerner duplikater og håndterer NULL-verdier
- Effektive: Partisjonering og klustring gjør queries raskere og billigere
- Klare for analyse: Data er nå i en form som kan brukes til forretningsanalyse
I denne seksjonen skal vi gradvis bygge opp en Silver-tabell ved å løse datakvalitetsproblemer ett felt om gangen. Dere vil få ta valg underveis om hvordan dere vil håndtere ulike utfordringer - akkurat som i virkelige dataprosjekter hvor det sjelden finnes én "riktig" løsning.
Agenda for Sølv-seksjonen:
- Utforsk Bronze-data
- Rens product_id
- Rens product_name
- Standardiser payment_method
- Parse sales_amount
- Parse sales_timestamp
- Deduplisering med fingerprinting
- Sett sammen alt med partisjonering og klustring
Opprett SQL-mappe for silver:
mkdir -p sql/silver💡 Hvordan kjøre SQL-queries:
Dere har to alternativer for å kjøre SQL-queries:
Alternativ 1: Bruk
bq queryi terminalenbq query --nouse_legacy_sql "SELECT * FROM \`${GROUP_NAME}_bronze.sales\` LIMIT 10"Alternativ 2: Bruk BigQuery Console (Web UI) - Anbefalt for denne interaktive seksjonen!
- Gå til BigQuery Console
- Klikk på "Compose New Query" eller "+"-knappen
- Lim inn SQL-koden nedenfor
- Bytt ut
<gruppenavn>med deres faktiske gruppenavn (f.eks.lambda)- Klikk "Run" eller trykk Ctrl+Enter (Cmd+Enter på Mac)
- Viktig: Kjør hver query og diskuter resultatet før dere går videre til neste steg
Tips: For denne interaktive seksjonen anbefaler vi å bruke BigQuery Console slik at hele gruppen kan se resultatene og diskutere sammen. Bruk
bq queryfor raske verifikasjoner.
La oss først se på dataene fra Bronze-tabellen for å forstå hva vi jobber med:
-- Steg 1: Les data fra Bronze-tabellen og konverter alle felt til tekst
-- (WITH base AS... er en måte å lage en midlertidig tabell i SQL)
WITH base AS (
SELECT
SAFE_CAST(product_id AS STRING) AS product_id_raw,
SAFE_CAST(product_name AS STRING) AS product_name_raw,
SAFE_CAST(payment_method AS STRING) AS payment_method_raw,
SAFE_CAST(sales_amount AS STRING) AS sales_amount_raw,
SAFE_CAST(sales_timestamp AS STRING) AS sales_timestamp_raw,
SAFE_CAST(notes AS STRING) AS notes,
SAFE_CAST(customer_id AS STRING) AS customer_id,
SAFE_CAST(location AS STRING) AS location
FROM `<gruppenavn>_bronze.sales`
)
SELECT * FROM base
LIMIT 20;Kjør denne queryen for å se hvordan dataene ser ut før rensing.
Fra data profiling vet vi at product_id har mange formater: PROD-0001, 001, P001, product_001, etc.
🤔 Diskusjonsspørsmål:
- Hvilket format skal være standarden?
- Hva gjør vi med product_id som ikke inneholder tall?
💡 Foreslått standard: PROD-XXXX (f.eks. PROD-0001, PROD-0042)
Oppgave: Bygg opp SQL for å standardisere product_id steg-for-steg:
Steg 2a: Trekk ut tall fra product_id
Test først denne spørringen for å se hvordan vi kan hente ut tall fra tekst:
-- 💡 REGEXP_EXTRACT: Henter ut tekst som matcher et mønster
-- r'(\d+)' betyr: finn én eller flere siffer (\d = siffer, + = én eller flere)
WITH base AS (
SELECT SAFE_CAST(product_id AS STRING) AS product_id_raw
FROM `<gruppenavn>_bronze.sales`
)
SELECT
product_id_raw,
REGEXP_EXTRACT(product_id_raw, r'(\d+)') as extracted_number
FROM base
WHERE product_id_raw IS NOT NULL
LIMIT 10;✅ Suksesskriterium: Dere skal se at extracted_number inneholder kun tallene fra product_id_raw (f.eks. "PROD-0042" → "42").
Steg 2b: Fyll ut med nuller (padding)
Nå skal vi sørge for at alle tall har 4 siffer (f.eks. 1 blir 0001):
-- 💡 LPAD: "Left Pad" - fyller ut med tegn på venstre side
-- LPAD(tekst, lengde, fylltegn) - f.eks. LPAD('42', 4, '0') → '0042'
WITH base AS (
SELECT SAFE_CAST(product_id AS STRING) AS product_id_raw
FROM `<gruppenavn>_bronze.sales`
)
SELECT
product_id_raw,
REGEXP_EXTRACT(product_id_raw, r'(\d+)') as extracted_number,
LPAD(REGEXP_EXTRACT(product_id_raw, r'(\d+)'), 4, '0') as padded_number
FROM base
WHERE product_id_raw IS NOT NULL
LIMIT 10;✅ Suksesskriterium: padded_number skal alltid ha 4 siffer (f.eks. "1" → "0001", "42" → "0042").
Steg 2c: Legg til PROD- prefix
-- 💡 CONCAT: Slår sammen tekst - CONCAT('A', 'B') → 'AB'
WITH base AS (
SELECT SAFE_CAST(product_id AS STRING) AS product_id_raw
FROM `<gruppenavn>_bronze.sales`
)
SELECT
product_id_raw,
CONCAT('PROD-', LPAD(REGEXP_EXTRACT(product_id_raw, r'(\d+)'), 4, '0')) as product_id
FROM base
WHERE product_id_raw IS NOT NULL
LIMIT 10;✅ Suksesskriterium: Alle product_id skal ha formatet PROD-XXXX (f.eks. "PROD-0001", "PROD-0042").
🎯 VALG 1: Hva gjør vi med product_id uten tall?
Diskuter i gruppen og velg én tilnærming.
💡 Anbefaling: Alternativ A (NULL) gir strengest datakvalitet.
Alternativ A: Sett til NULL
CASE
WHEN REGEXP_CONTAINS(product_id_raw, r'\d+') THEN
CONCAT('PROD-', LPAD(REGEXP_EXTRACT(product_id_raw, r'(\d+)'), 4, '0'))
ELSE NULL
END AS product_idAlternativ B: Behold original verdi (for sporbarhet)
CASE
WHEN REGEXP_CONTAINS(product_id_raw, r'\d+') THEN
CONCAT('PROD-', LPAD(REGEXP_EXTRACT(product_id_raw, r'(\d+)'), 4, '0'))
ELSE product_id_raw
END AS product_idAlternativ C: Sett til 'UNKNOWN' (for å unngå NULL)
CASE
WHEN REGEXP_CONTAINS(product_id_raw, r'\d+') THEN
CONCAT('PROD-', LPAD(REGEXP_EXTRACT(product_id_raw, r'(\d+)'), 4, '0'))
ELSE 'PROD-UNKNOWN'
END AS product_idVelg ett alternativ og opprett Silver-tabellen:
Nå skal vi opprette Silver-tabellen for første gang med product_id-rensing:
CREATE OR REPLACE TABLE `<gruppenavn>_silver.sales` AS
WITH base AS (
SELECT
SAFE_CAST(product_id AS STRING) AS product_id_raw,
SAFE_CAST(product_name AS STRING) AS product_name_raw,
SAFE_CAST(payment_method AS STRING) AS payment_method_raw,
SAFE_CAST(sales_amount AS STRING) AS sales_amount_raw,
SAFE_CAST(sales_timestamp AS STRING) AS sales_timestamp_raw,
SAFE_CAST(notes AS STRING) AS notes,
SAFE_CAST(customer_id AS STRING) AS customer_id,
SAFE_CAST(location AS STRING) AS location
FROM `<gruppenavn>_bronze.sales`
)
SELECT
-- SETT INN DITT VALGTE ALTERNATIV HER
CASE
WHEN REGEXP_CONTAINS(product_id_raw, r'\d+') THEN
CONCAT('PROD-', LPAD(REGEXP_EXTRACT(product_id_raw, r'(\d+)'), 4, '0'))
ELSE NULL -- ELLER ditt valgte alternativ
END AS product_id,
product_name_raw as product_name, -- Vi fikser dette i neste steg
payment_method_raw as payment_method,
sales_amount_raw as sales_amount,
sales_timestamp_raw as sales_timestamp,
notes,
customer_id,
location
FROM base;Verifiser at tabellen ble opprettet:
bq show ${GROUP_NAME}_silver.sales✅ Suksesskriterium: Kommandoen skal vise tabelldetaljer uten feilmeldinger.
Verifiser product_id-rensingen:
SELECT
product_id,
COUNT(*) as count
FROM `<gruppenavn>_silver.sales`
GROUP BY product_id
ORDER BY count DESC
LIMIT 10;✅ Suksesskriterium: Alle product_id skal ha formatet PROD-XXXX eller være NULL (avhengig av deres valg i steg 2).
Fra profiling så vi typos, ekstra whitespace, og case-variasjoner.
🤔 Diskusjonsspørsmål:
- Skal vi bruke Title Case, UPPER CASE, eller lower case?
- Hvordan håndterer vi ekstra mellomrom?
Oppgave: Bygg opp rensing av product_name:
Steg 3a: Fjern ekstra whitespace
Test først:
-- 💡 TRIM: Fjerner whitespace fra start og slutt
-- 💡 REGEXP_REPLACE: Erstatter tekst som matcher et mønster
-- r'\s+' betyr: ett eller flere whitespace-tegn (\s = whitespace, + = én eller flere)
WITH base AS (
SELECT SAFE_CAST(product_name AS STRING) AS product_name_raw
FROM `<gruppenavn>_bronze.sales`
)
SELECT
product_name_raw,
TRIM(product_name_raw) as trimmed,
REGEXP_REPLACE(TRIM(product_name_raw), r'\s+', ' ') as cleaned
FROM base
WHERE product_name_raw LIKE '% %' -- Finn de med doble mellomrom
LIMIT 10;✅ Suksesskriterium: cleaned skal ikke ha doble mellomrom eller whitespace i start/slutt.
Steg 3b: Standardiser case
Test ulike alternativer:
-- 💡 UPPER: Konverterer til store bokstaver
-- 💡 LOWER: Konverterer til små bokstaver
-- 💡 INITCAP: Title Case - første bokstav i hvert ord blir stor
WITH base AS (
SELECT SAFE_CAST(product_name AS STRING) AS product_name_raw
FROM `<gruppenavn>_bronze.sales`
)
SELECT
product_name_raw,
UPPER(product_name_raw) as upper_case,
LOWER(product_name_raw) as lower_case,
INITCAP(product_name_raw) as title_case
FROM base
LIMIT 10;✅ Suksesskriterium: Dere skal se forskjellen mellom de tre formatene og kunne velge hvilken som passer best.
🎯 VALG 2: Hvilken case-standard velger dere?
💡 Anbefaling: Alternativ A (Title Case) gir best lesbarhet i rapporter og dashboards.
Alternativ A: Title Case
INITCAP(REGEXP_REPLACE(TRIM(product_name_raw), r'\s+', ' ')) AS product_nameAlternativ B: UPPER CASE (for konsistens)
UPPER(REGEXP_REPLACE(TRIM(product_name_raw), r'\s+', ' ')) AS product_nameAlternativ C: lower case (for enklere matching)
LOWER(REGEXP_REPLACE(TRIM(product_name_raw), r'\s+', ' ')) AS product_nameOppdater tabellen med ditt valg:
CREATE OR REPLACE TABLE `<gruppenavn>_silver.sales` AS
WITH base AS (
SELECT
SAFE_CAST(product_id AS STRING) AS product_id_raw,
SAFE_CAST(product_name AS STRING) AS product_name_raw,
SAFE_CAST(payment_method AS STRING) AS payment_method_raw,
SAFE_CAST(sales_amount AS STRING) AS sales_amount_raw,
SAFE_CAST(sales_timestamp AS STRING) AS sales_timestamp_raw,
SAFE_CAST(notes AS STRING) AS notes,
SAFE_CAST(customer_id AS STRING) AS customer_id,
SAFE_CAST(location AS STRING) AS location
FROM `<gruppenavn>_bronze.sales`
)
SELECT
-- STEG 2: product_id (fra forrige steg)
CASE
WHEN REGEXP_CONTAINS(product_id_raw, r'\d+') THEN
CONCAT('PROD-', LPAD(REGEXP_EXTRACT(product_id_raw, r'(\d+)'), 4, '0'))
ELSE NULL
END AS product_id,
-- STEG 3: product_name (SETT INN DITT VALG HER)
INITCAP(REGEXP_REPLACE(TRIM(product_name_raw), r'\s+', ' ')) AS product_name,
payment_method_raw as payment_method, -- Vi fikser dette i neste steg
sales_amount_raw as sales_amount,
sales_timestamp_raw as sales_timestamp,
notes,
customer_id,
location
FROM base;Dette er en klassisk "mapping"-oppgave. Fra profiling vet vi at det er mange variasjoner.
🤔 Diskusjonsspørsmål:
- Hvilke standardkategorier skal vi ha?
- Hva gjør vi med ukjente betalingsmetoder?
Oppgave: Lag en CASE-statement for å mappe alle variasjoner
Steg 4a: Identifiser alle unike verdier
WITH base AS (
SELECT SAFE_CAST(payment_method AS STRING) AS payment_method_raw
FROM `<gruppenavn>_bronze.sales`
)
SELECT
LOWER(TRIM(payment_method_raw)) as normalized,
COUNT(*) as count
FROM base
WHERE payment_method_raw IS NOT NULL
GROUP BY normalized
ORDER BY count DESC;Steg 4b: Bygg opp mapping-logikk gradvis
Start med én kategori (CASE-statement er SQL sin måte å si "hvis-så-ellers"):
WITH base AS (
SELECT SAFE_CAST(payment_method AS STRING) AS payment_method_raw
FROM `<gruppenavn>_bronze.sales`
)
SELECT
payment_method_raw,
CASE
WHEN LOWER(TRIM(payment_method_raw)) IN ('cc', 'credit card', 'visa', 'mastercard')
THEN 'Credit Card'
ELSE payment_method_raw
END as payment_method
FROM base
LIMIT 20;Steg 4c: Legg til flere kategorier
Utvid mapping-logikken:
CASE
WHEN LOWER(TRIM(payment_method_raw)) IN ('cc','credit card','credit card','cradit card','visa','mastercard')
THEN 'Credit Card'
WHEN LOWER(TRIM(payment_method_raw)) IN ('dc','debit card','debit','debit crd')
THEN 'Debit Card'
WHEN LOWER(TRIM(payment_method_raw)) IN ('pp','paypal','pay pal')
THEN 'PayPal'
WHEN LOWER(TRIM(payment_method_raw)) IN ('cash','csh','cach')
THEN 'Cash'
WHEN LOWER(TRIM(payment_method_raw)) IN ('bt','bank transfer','wire transfer','bank trnsfr')
THEN 'Bank Transfer'
ELSE NULL
END AS payment_method🎯 VALG 3: Hva gjør vi med ukjente betalingsmetoder?
💡 Anbefaling: Alternativ A (NULL) sikrer at kun validerte betalingsmetoder kommer videre til Gold-laget.
Alternativ A: Sett til NULL (strengt)
ELSE NULLAlternativ B: Behold original (for analyse)
ELSE payment_method_rawAlternativ C: Sett til 'Other' (for rapportering)
ELSE 'Other'Oppdater tabellen med ditt valg:
CREATE OR REPLACE TABLE `<gruppenavn>_silver.sales` AS
WITH base AS (
SELECT
SAFE_CAST(product_id AS STRING) AS product_id_raw,
SAFE_CAST(product_name AS STRING) AS product_name_raw,
SAFE_CAST(payment_method AS STRING) AS payment_method_raw,
SAFE_CAST(sales_amount AS STRING) AS sales_amount_raw,
SAFE_CAST(sales_timestamp AS STRING) AS sales_timestamp_raw,
SAFE_CAST(notes AS STRING) AS notes,
SAFE_CAST(customer_id AS STRING) AS customer_id,
SAFE_CAST(location AS STRING) AS location
FROM `<gruppenavn>_bronze.sales`
)
SELECT
-- STEG 2: product_id
CASE
WHEN REGEXP_CONTAINS(product_id_raw, r'\d+') THEN
CONCAT('PROD-', LPAD(REGEXP_EXTRACT(product_id_raw, r'(\d+)'), 4, '0'))
ELSE NULL
END AS product_id,
-- STEG 3: product_name
INITCAP(REGEXP_REPLACE(TRIM(product_name_raw), r'\s+', ' ')) AS product_name,
-- STEG 4: payment_method (SETT INN DITT VALG HER)
CASE
WHEN LOWER(TRIM(payment_method_raw)) IN ('cc','credit card','credit card','cradit card','visa','mastercard') THEN 'Credit Card'
WHEN LOWER(TRIM(payment_method_raw)) IN ('dc','debit card','debit','debit crd') THEN 'Debit Card'
WHEN LOWER(TRIM(payment_method_raw)) IN ('pp','paypal','pay pal') THEN 'PayPal'
WHEN LOWER(TRIM(payment_method_raw)) IN ('cash','csh','cach') THEN 'Cash'
WHEN LOWER(TRIM(payment_method_raw)) IN ('bt','bank transfer','wire transfer','bank trnsfr') THEN 'Bank Transfer'
ELSE NULL -- ELLER ditt valgte alternativ
END AS payment_method,
sales_amount_raw as sales_amount, -- Vi fikser dette i neste steg
sales_timestamp_raw as sales_timestamp,
notes,
customer_id,
location
FROM base;Oppgave: Fjern valutasymboler og konverter til NUMERIC
Steg 5a: Fjern valutasymboler
-- 💡 REGEXP_REPLACE med flere alternativer: r'USD|EUR|£|€|\$'
-- | betyr "eller" - matcher USD ELLER EUR ELLER £ osv.
-- \$ betyr dollartegn (\ er escape-tegn fordi $ har spesiell betydning i regex)
SELECT
sales_amount_raw,
REGEXP_REPLACE(sales_amount_raw, r'USD|EUR|£|€|\$', '') as without_currency,
REGEXP_REPLACE(REGEXP_REPLACE(sales_amount_raw, r'USD|EUR|£|€|\$', ''), r',', '') as cleaned
FROM `<gruppenavn>_silver.sales`
WHERE sales_amount_raw LIKE '%$%' OR sales_amount_raw LIKE '%EUR%'
LIMIT 10;✅ Suksesskriterium: cleaned skal kun inneholde tall og punktum (ingen valutasymboler eller komma).
Steg 5b: Konverter til NUMERIC
-- 💡 SAFE_CAST: Konverterer datatype, returnerer NULL hvis konvertering feiler
-- NUMERIC er BigQuery sin datatype for presise tall med desimaler
SELECT
sales_amount_raw,
SAFE_CAST(
REGEXP_REPLACE(REGEXP_REPLACE(TRIM(sales_amount_raw), r'USD|EUR|£|€|\$', ''), r',', '')
AS NUMERIC
) AS sales_amount
FROM `<gruppenavn>_silver.sales`
LIMIT 10;✅ Suksesskriterium: sales_amount skal være et tall (ikke tekst), og verdier som ikke kan konverteres skal være NULL.
Oppgave: Håndter multiple timestamp-formater med COALESCE
Fra profiling vet vi at det er 5 hovedformater. Vi bruker COALESCE for å prøve dem i rekkefølge:
-- 💡 COALESCE: Returnerer første ikke-NULL verdi i listen
-- Prøver å parse timestamp i ulike formater til vi finner ett som fungerer
-- 💡 SAFE.PARSE_TIMESTAMP: Prøver å konvertere tekst til timestamp, returnerer NULL hvis det feiler
-- 💡 Format-koder: %Y=år, %m=måned, %d=dag, %H=time, %M=minutt, %S=sekund
SELECT
sales_timestamp_raw,
COALESCE(
SAFE.PARSE_TIMESTAMP('%Y-%m-%dT%H:%M:%E*S%Ez', sales_timestamp_raw), -- ISO 8601
SAFE.PARSE_TIMESTAMP('%m/%d/%Y %I:%M %p', sales_timestamp_raw), -- US format
SAFE.PARSE_TIMESTAMP('%d/%m/%Y %H:%M', sales_timestamp_raw), -- EU format
SAFE.PARSE_TIMESTAMP('%Y-%m-%d', sales_timestamp_raw), -- Date only
CASE
WHEN REGEXP_CONTAINS(sales_timestamp_raw, r'^\d{10}$')
THEN TIMESTAMP_SECONDS(SAFE_CAST(sales_timestamp_raw AS INT64)) -- Unix timestamp
ELSE NULL
END
) AS sales_ts
FROM `<gruppenavn>_silver.sales`
LIMIT 20;✅ Suksesskriterium: sales_ts skal være en gyldig timestamp for alle rader som hadde et gjenkjennelig format.
Legg også til sales_date:
DATE(COALESCE(...)) AS sales_date🤔 Diskusjonsspørsmål:
- Hvordan identifiserer vi duplikater?
- Hvis vi har duplikater, hvilken rad skal vi beholde?
Oppgave: Bruk fingerprinting for deduplisering
Hva er fingerprinting? En fingerprint er en unik hash-verdi (MD5) som genereres fra kombinasjonen av alle viktige felt i en rad. Hvis to rader har samme fingerprint, er de identiske duplikater. MD5 er en kryptografisk hash-funksjon som konverterer tekst til en 32-tegns heksadesimal streng.
Steg 7a: Lag fingerprint
-- 💡 MD5: Lager en unik hash-verdi (fingerprint) fra tekst
-- 💡 CONCAT: Slår sammen alle feltene til én lang tekst
-- 💡 COALESCE: Erstatter NULL med tom streng ('') slik at CONCAT fungerer
SELECT
product_id,
product_name,
sales_ts,
sales_amount,
payment_method,
MD5(CONCAT(
COALESCE(product_id, ''),
COALESCE(product_name, ''),
COALESCE(CAST(sales_ts AS STRING), ''),
COALESCE(CAST(sales_amount AS STRING), ''),
COALESCE(payment_method, '')
)) as fingerprint
FROM `<gruppenavn>_silver.sales`
LIMIT 10;✅ Suksesskriterium: Hver rad skal ha en unik fingerprint (32-tegns heksadesimal streng). Identiske rader vil ha samme fingerprint.
Steg 7b: Finn duplikater
WITH fingerprints AS (
SELECT
*,
MD5(CONCAT(
COALESCE(product_id, ''),
COALESCE(product_name, ''),
COALESCE(CAST(sales_ts AS STRING), ''),
COALESCE(CAST(sales_amount AS STRING), ''),
COALESCE(payment_method, '')
)) as fp
FROM `<gruppenavn>_silver.sales`
)
SELECT
fp,
COUNT(*) as duplicate_count
FROM fingerprints
GROUP BY fp
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC
LIMIT 10;Steg 7c: Behold kun én rad per fingerprint
-- 💡 ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...): Nummererer rader innenfor hver gruppe
-- PARTITION BY: Grupperer rader med samme fingerprint
-- ORDER BY sales_ts DESC: Sorterer nyeste først (DESC = descending)
-- WHERE rn = 1: Beholder kun første rad i hver gruppe (den nyeste)
WITH fingerprints AS (
SELECT
*,
MD5(CONCAT(...)) as fp,
ROW_NUMBER() OVER (PARTITION BY MD5(CONCAT(...)) ORDER BY sales_ts DESC) AS rn
FROM `<gruppenavn>_silver.sales`
)
SELECT * EXCEPT(fp, rn)
FROM fingerprints
WHERE rn = 1;✅ Suksesskriterium: Antall rader skal være redusert hvis det fantes duplikater. Hver unik fingerprint skal kun ha én rad.
Nå skal vi sette sammen alle stegene i én komplett query.
Hva er partisjonering og klustring?
- PARTITION BY DATE(sales_ts): Deler tabellen i separate partisjoner per dato. Dette gjør queries som filtrerer på dato mye raskere og billigere, fordi BigQuery kun trenger å skanne relevante partisjoner.
- CLUSTER BY product_id: Sorterer data innenfor hver partisjon etter product_id. Dette gjør queries som filtrerer eller grupperer på product_id raskere.
Hvorfor er dette viktig? Med 1 million rader kan en query uten partisjonering skanne hele tabellen (dyrt). Med partisjonering på dato kan en query som ser på én dag kun skanne 1/365 av dataene (mye billigere og raskere).
CREATE OR REPLACE TABLE `<gruppenavn>_silver.sales`
PARTITION BY DATE(sales_ts)
CLUSTER BY product_id AS
WITH base AS (
SELECT
SAFE_CAST(product_id AS STRING) AS product_id_raw,
SAFE_CAST(product_name AS STRING) AS product_name_raw,
SAFE_CAST(payment_method AS STRING) AS payment_method_raw,
SAFE_CAST(sales_amount AS STRING) AS sales_amount_raw,
SAFE_CAST(sales_timestamp AS STRING) AS sales_timestamp_raw,
SAFE_CAST(notes AS STRING) AS notes,
SAFE_CAST(customer_id AS STRING) AS customer_id,
SAFE_CAST(location AS STRING) AS location
FROM `<gruppenavn>_bronze.sales`
),
norm AS (
SELECT
-- STEG 2: product_id (SETT INN DITT VALG)
CASE
WHEN REGEXP_CONTAINS(product_id_raw, r'\d+') THEN
CONCAT('PROD-', LPAD(REGEXP_EXTRACT(product_id_raw, r'(\d+)'), 4, '0'))
ELSE NULL
END AS product_id,
-- STEG 3: product_name (SETT INN DITT VALG)
INITCAP(REGEXP_REPLACE(TRIM(product_name_raw), r'\s+', ' ')) AS product_name,
-- STEG 4: payment_method (SETT INN DITT VALG)
CASE
WHEN LOWER(TRIM(payment_method_raw)) IN ('cc','credit card','credit card','cradit card','visa','mastercard') THEN 'Credit Card'
WHEN LOWER(TRIM(payment_method_raw)) IN ('dc','debit card','debit','debit crd') THEN 'Debit Card'
WHEN LOWER(TRIM(payment_method_raw)) IN ('pp','paypal','pay pal') THEN 'PayPal'
WHEN LOWER(TRIM(payment_method_raw)) IN ('cash','csh','cach') THEN 'Cash'
WHEN LOWER(TRIM(payment_method_raw)) IN ('bt','bank transfer','wire transfer','bank trnsfr') THEN 'Bank Transfer'
ELSE NULL
END AS payment_method,
-- STEG 5: sales_amount
SAFE_CAST(
REGEXP_REPLACE(REGEXP_REPLACE(TRIM(sales_amount_raw), r'USD|EUR|£|€|\$', ''), r',', '')
AS NUMERIC
) AS sales_amount,
-- STEG 6: sales_timestamp
COALESCE(
SAFE.PARSE_TIMESTAMP('%Y-%m-%dT%H:%M:%E*S%Ez', sales_timestamp_raw),
SAFE.PARSE_TIMESTAMP('%m/%d/%Y %I:%M %p', sales_timestamp_raw),
SAFE.PARSE_TIMESTAMP('%d/%m/%Y %H:%M', sales_timestamp_raw),
SAFE.PARSE_TIMESTAMP('%Y-%m-%d', sales_timestamp_raw),
CASE
WHEN REGEXP_CONTAINS(sales_timestamp_raw, r'^\d{10}$')
THEN TIMESTAMP_SECONDS(SAFE_CAST(sales_timestamp_raw AS INT64))
ELSE NULL
END
) AS sales_ts,
DATE(COALESCE(
SAFE.PARSE_TIMESTAMP('%Y-%m-%dT%H:%M:%E*S%Ez', sales_timestamp_raw),
SAFE.PARSE_TIMESTAMP('%m/%d/%Y %I:%M %p', sales_timestamp_raw),
SAFE.PARSE_TIMESTAMP('%d/%m/%Y %H:%M', sales_timestamp_raw),
SAFE.PARSE_TIMESTAMP('%Y-%m-%d', sales_timestamp_raw),
CASE
WHEN REGEXP_CONTAINS(sales_timestamp_raw, r'^\d{10}$')
THEN TIMESTAMP_SECONDS(SAFE_CAST(sales_timestamp_raw AS INT64))
ELSE NULL
END
)) AS sales_date,
notes,
customer_id,
location
FROM base
),
-- STEG 7: Deduplisering
dedup AS (
SELECT *,
MD5(CONCAT(
COALESCE(product_id, ''),
COALESCE(product_name, ''),
COALESCE(CAST(sales_ts AS STRING), ''),
COALESCE(CAST(sales_amount AS STRING), ''),
COALESCE(payment_method, '')
)) AS fp
FROM norm
)
SELECT * EXCEPT(fp)
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY fp ORDER BY sales_ts DESC) AS rn
FROM dedup
)
WHERE rn = 1;Kjør denne komplette queryen!
Sjekk partisjonering og klustring:
bq show --format=prettyjson ${GROUP_NAME}_silver.salesTell antall rader:
bq query --nouse_legacy_sql "SELECT COUNT(*) as row_count FROM \`${GROUP_NAME}_silver.sales\`"✅ Suksesskriterium: Antall rader skal være mindre enn Bronze (pga. deduplisering) men fortsatt i millioner.
Se de første 10 radene:
bq query --nouse_legacy_sql --max_rows=10 "SELECT * FROM \`${GROUP_NAME}_silver.sales\` LIMIT 10"✅ Suksesskriterium: Alle felt skal være rensede og standardiserte (product_id som PROD-XXXX, sales_amount som tall, sales_ts som timestamp).
SQL 1: Null-rate per felt (sammenlignet med bronze)
SELECT
'bronze' as medallion,
COUNT(*) AS total_rows,
COUNTIF(product_id IS NULL OR CAST(product_id AS STRING) = '') as null_product_id,
COUNTIF(product_name IS NULL OR CAST(product_name AS STRING) = '') as null_product_name,
COUNTIF(sales_timestamp IS NULL OR CAST(sales_timestamp AS STRING) = '') as null_timestamp,
COUNTIF(sales_amount IS NULL OR CAST(sales_amount AS STRING) = '') as null_amount,
COUNTIF(payment_method IS NULL OR CAST(payment_method AS STRING) = '') as null_payment_method,
ROUND(COUNTIF(product_id IS NULL OR CAST(product_id AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_product_id,
ROUND(COUNTIF(product_name IS NULL OR CAST(product_name AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_product_name,
ROUND(COUNTIF(sales_timestamp IS NULL OR CAST(sales_timestamp AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_timestamp,
ROUND(COUNTIF(sales_amount IS NULL OR CAST(sales_amount AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_amount,
ROUND(COUNTIF(payment_method IS NULL OR CAST(payment_method AS STRING) = '') * 100.0 / COUNT(*), 2) as pct_null_payment_method
FROM `<gruppenavn>_bronze.sales`
UNION ALL
SELECT
'silver' as medallion,
COUNT(*) AS total_rows,
COUNTIF(product_id IS NULL OR CAST(product_id AS STRING) = '') AS null_product_id,
COUNTIF(product_name IS NULL OR CAST(product_name AS STRING) = '') AS null_product_name,
COUNTIF(sales_ts IS NULL OR CAST(sales_ts AS STRING) = '') AS null_timestamp,
COUNTIF(sales_amount IS NULL OR CAST(sales_amount AS STRING) = '') AS null_amount,
COUNTIF(payment_method IS NULL OR CAST(payment_method AS STRING) = '') AS null_payment_method,
ROUND(COUNTIF(product_id IS NULL OR CAST(product_id AS STRING) = '') * 100.0 / COUNT(*), 2) AS pct_null_product_id,
ROUND(COUNTIF(product_name IS NULL OR CAST(product_name AS STRING) = '') * 100.0 / COUNT(*), 2) AS pct_null_product_name,
ROUND(COUNTIF(sales_ts IS NULL OR CAST(sales_ts AS STRING) = '') * 100.0 / COUNT(*), 2) AS pct_null_timestamp,
ROUND(COUNTIF(sales_amount IS NULL OR CAST(sales_amount AS STRING) = '') * 100.0 / COUNT(*), 2) AS pct_null_amount,
ROUND(COUNTIF(payment_method IS NULL OR CAST(payment_method AS STRING) = '') * 100.0 / COUNT(*), 2) AS pct_null_payment_method
FROM `<gruppenavn>_silver.sales`;SQL 2: Distribusjon av payment_method (standardiseringseffekt)
SELECT
payment_method,
COUNT(*) AS cnt,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) as percentage
FROM `<gruppenavn>_silver.sales`
GROUP BY payment_method
ORDER BY cnt DESC;SQL 3: Distribusjon av produkter (se standardisering av product_id)
SELECT
product_id,
product_name,
COUNT(*) AS cnt
FROM `<gruppenavn>_silver.sales`
WHERE product_id IS NOT NULL
GROUP BY product_id, product_name
ORDER BY cnt DESC
LIMIT 20;SQL 4: Statistikk for sales_amount
SELECT
MIN(sales_amount) AS min_amount,
MAX(sales_amount) AS max_amount,
AVG(sales_amount) AS avg_amount,
APPROX_QUANTILES(sales_amount, 100)[OFFSET(50)] AS median_amount,
STDDEV(sales_amount) AS stddev_amount,
COUNT(*) as total_transactions
FROM `<gruppenavn>_silver.sales`
WHERE sales_amount IS NOT NULL;Ta 5 minutter i gruppen for å diskutere:
-
Valgene dere tok:
- Hvilke valg tok dere for NULL-håndtering?
- Hvorfor valgte dere den case-standarden for product_name?
- Hva var fordeler og ulemper med deres tilnærming?
-
Datakvalitet:
- Hvor mange duplikater ble fjernet?
- Hvor mye bedre er datakvaliteten (null-rate) i silver vs bronze?
- Hvor mange unike betalingsmetoder har dere nå vs. før standardisering?
-
Ytelse:
- Hvorfor bruker vi partisjonering på
sales_date? - Hvorfor klustrerer vi på
product_id? - Hvor mange GB spares ved å bruke partisjonfilter?
- Hvorfor bruker vi partisjonering på
-
Læring:
- Hvilke SQL-funksjoner var nye for dere?
- Hva var mest utfordrende?
- Hva ville dere gjort annerledes neste gang?
🎉 Gratulerer! Dere har nå bygget en komplett Silver-tabell med:
- ✅ Standardiserte product_id
- ✅ Rensede product_name
- ✅ Mappede payment_method
- ✅ Parsede sales_amount
- ✅ Konverterte sales_timestamp
- ✅ Dedupliserte records
- ✅ Partisjonering og klustring for ytelse
Neste steg: Gå videre til Gold-laget for å lage forretningsklare tabeller og KPI-er!
🎯 Hva skal vi gjøre: Transformere rensede data fra Silver til forretningsklare tabeller og KPI-views i Gold-laget ved hjelp av stjerneskjema.
💡 Hvorfor: Gold-laget er det siste steget hvor vi lager data som er optimalisert for forretningsbrukere og BI-verktøy:
- Forretningsfokus: Tabeller og views som svarer på konkrete forretningsspørsmål
- Ytelse: Forhåndsaggregerte KPI-er gir raske svar
- Enkelhet: Stjerneskjema gjør det lett for analytikere å jobbe med dataene
- Konsistens: Dimensjonstabeller sikrer at alle bruker samme definisjoner
Hva er et stjerneskjema (star schema)?
Før vi begynner med SQL, la oss forstå hva vi skal bygge. Et stjerneskjema er en av de mest populære datamodelleringsteknikker for analytiske databaser. Det består av:
- Faktatabell (fact table):
fact_sales- inneholder målbare forretningshendelser (transaksjoner) med numeriske verdier (sales_amount) og fremmednøkler til dimensjoner - Dimensjonstabeller (dimension tables):
dim_productogdim_payment_method- inneholder beskrivende attributter som produktnavn, betalingsmetoder, etc.
Hvorfor er stjerneskjema nyttig?
- Enklere queries: Analytikere kan enkelt jobbe med data uten å måtte forstå komplekse relasjoner
- Bedre ytelse: Færre joins betyr raskere spørringer
- Konsistens: Dimensjonstabeller sikrer én kilde til sannhet
- BI-verktøy: De fleste BI-verktøy (Looker, Tableau, Power BI) er optimalisert for stjerneskjemaer
I vårt eksempel:
fact_salesinneholder alle salgstransaksjoner med beløp, tidspunkt og referanser til produkt og betalingsmetodedim_productinneholder unike produkter med ID og navndim_payment_methodinneholder standardiserte betalingsmetoder
Opprett SQL-mappe for gold:
mkdir -p sql/gold💡 Tips: Bruk BigQuery Console for lengre queries, eller
bq queryi terminalen for raske sjekker.
SQL: Opprett gull-tabeller (faktatabell og dimensjoner)
Lagre denne SQL-en i sql/gold/create_gold_tables.sql:
CREATE OR REPLACE TABLE `<gruppenavn>_gold.fact_sales` AS
SELECT
product_id,
product_name,
sales_ts,
sales_date,
sales_amount,
payment_method,
customer_id,
location
FROM `<gruppenavn>_silver.sales`
WHERE sales_amount IS NOT NULL AND sales_ts IS NOT NULL;
CREATE OR REPLACE TABLE `<gruppenavn>_gold.dim_product` AS
SELECT DISTINCT
product_id,
product_name
FROM `<gruppenavn>_silver.sales`
WHERE product_id IS NOT NULL AND product_name IS NOT NULL
ORDER BY product_id;
CREATE OR REPLACE TABLE `<gruppenavn>_gold.dim_payment_method` AS
SELECT DISTINCT
payment_method
FROM `<gruppenavn>_silver.sales`
WHERE payment_method IS NOT NULL
ORDER BY payment_method;Kjør SQL for å opprette gull-tabeller:
Hvis dere bruker fil (erstatt <gruppenavn> først):
bq query --nouse_legacy_sql < sql/gold/create_gold_tables.sqlELLER kjør hver CREATE statement separat:
Faktatabell:
bq query --nouse_legacy_sql "
CREATE OR REPLACE TABLE \`${GROUP_NAME}_gold.fact_sales\` AS
SELECT
product_id,
product_name,
sales_ts,
sales_date,
sales_amount,
payment_method,
customer_id,
location
FROM \`${GROUP_NAME}_silver.sales\`
WHERE sales_amount IS NOT NULL AND sales_ts IS NOT NULL;"Produktdimensjon:
bq query --nouse_legacy_sql "
CREATE OR REPLACE TABLE \`${GROUP_NAME}_gold.dim_product\` AS
SELECT DISTINCT
product_id,
product_name
FROM \`${GROUP_NAME}_silver.sales\`
WHERE product_id IS NOT NULL AND product_name IS NOT NULL
ORDER BY product_id;"Betalingsmetodedimensjon:
bq query --nouse_legacy_sql "
CREATE OR REPLACE TABLE \`${GROUP_NAME}_gold.dim_payment_method\` AS
SELECT DISTINCT
payment_method
FROM \`${GROUP_NAME}_silver.sales\`
WHERE payment_method IS NOT NULL
ORDER BY payment_method;"Verifiser gull-tabeller:
List tabeller i gold dataset:
bq ls ${GROUP_NAME}_gold✅ Suksesskriterium: Dere skal se minst 3 tabeller: fact_sales, dim_product, dim_payment_method.
Tell antall rader i hver gull-tabell:
bq query --nouse_legacy_sql "SELECT COUNT(*) as row_count FROM \`${GROUP_NAME}_gold.fact_sales\`"
bq query --nouse_legacy_sql "SELECT COUNT(*) as product_count FROM \`${GROUP_NAME}_gold.dim_product\`"
bq query --nouse_legacy_sql "SELECT COUNT(*) as payment_method_count FROM \`${GROUP_NAME}_gold.dim_payment_method\`"✅ Suksesskriterium:
fact_salesskal ha omtrent samme antall rader som Silver (minus NULL-verdier)dim_productskal ha 50-100 unike produkterdim_payment_methodskal ha 5 betalingsmetoder (Credit Card, Debit Card, PayPal, Cash, Bank Transfer)
KPI-visninger:
Lagre denne SQL-en i sql/gold/v_daily_kpi.sql:
CREATE OR REPLACE VIEW `<gruppenavn>_gold.v_daily_kpi` AS
SELECT
sales_date,
product_id,
product_name,
payment_method,
COUNT(*) AS txn_count,
SUM(sales_amount) AS total_amount,
AVG(sales_amount) AS avg_amount,
MIN(sales_amount) AS min_amount,
MAX(sales_amount) AS max_amount
FROM `<gruppenavn>_gold.fact_sales`
GROUP BY sales_date, product_id, product_name, payment_method
ORDER BY sales_date DESC, total_amount DESC;
CREATE OR REPLACE VIEW `<gruppenavn>_gold.v_product_performance` AS
SELECT
product_id,
product_name,
COUNT(*) AS total_transactions,
SUM(sales_amount) AS total_revenue,
AVG(sales_amount) AS avg_transaction_value,
MIN(sales_date) AS first_sale_date,
MAX(sales_date) AS last_sale_date
FROM `<gruppenavn>_gold.fact_sales`
GROUP BY product_id, product_name
ORDER BY total_revenue DESC;
CREATE OR REPLACE VIEW `<gruppenavn>_gold.v_payment_analysis` AS
SELECT
payment_method,
COUNT(*) AS transaction_count,
SUM(sales_amount) AS total_amount,
AVG(sales_amount) AS avg_amount,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS pct_of_transactions,
ROUND(SUM(sales_amount) * 100.0 / SUM(SUM(sales_amount)) OVER(), 2) AS pct_of_revenue
FROM `<gruppenavn>_gold.fact_sales`
GROUP BY payment_method
ORDER BY total_amount DESC;
CREATE OR REPLACE VIEW `<gruppenavn>_gold.v_daily_trends` AS
SELECT
sales_date,
COUNT(*) AS daily_transactions,
SUM(sales_amount) AS daily_revenue,
AVG(sales_amount) AS daily_avg_transaction,
COUNT(DISTINCT product_id) AS unique_products_sold
FROM `<gruppenavn>_gold.fact_sales`
GROUP BY sales_date
ORDER BY sales_date DESC;Kjør SQL for å opprette KPI-visninger:
Fra fil (erstatt <gruppenavn> først):
bq query --nouse_legacy_sql < sql/gold/v_daily_kpi.sqlELLER kjør hver CREATE VIEW separat:
Daglig KPI:
bq query --nouse_legacy_sql "
CREATE OR REPLACE VIEW \`${GROUP_NAME}_gold.v_daily_kpi\` AS
SELECT
sales_date,
product_id,
product_name,
payment_method,
COUNT(*) AS txn_count,
SUM(sales_amount) AS total_amount,
AVG(sales_amount) AS avg_amount,
MIN(sales_amount) AS min_amount,
MAX(sales_amount) AS max_amount
FROM \`${GROUP_NAME}_gold.fact_sales\`
GROUP BY sales_date, product_id, product_name, payment_method
ORDER BY sales_date DESC, total_amount DESC;"Produkt-ytelse:
bq query --nouse_legacy_sql "
CREATE OR REPLACE VIEW \`${GROUP_NAME}_gold.v_product_performance\` AS
SELECT
product_id,
product_name,
COUNT(*) AS total_transactions,
SUM(sales_amount) AS total_revenue,
AVG(sales_amount) AS avg_transaction_value,
MIN(sales_date) AS first_sale_date,
MAX(sales_date) AS last_sale_date
FROM \`${GROUP_NAME}_gold.fact_sales\`
GROUP BY product_id, product_name
ORDER BY total_revenue DESC;"Betalingsmetode-analyse:
bq query --nouse_legacy_sql "
CREATE OR REPLACE VIEW \`${GROUP_NAME}_gold.v_payment_analysis\` AS
SELECT
payment_method,
COUNT(*) AS transaction_count,
SUM(sales_amount) AS total_amount,
AVG(sales_amount) AS avg_amount,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS pct_of_transactions,
ROUND(SUM(sales_amount) * 100.0 / SUM(SUM(sales_amount)) OVER(), 2) AS pct_of_revenue
FROM \`${GROUP_NAME}_gold.fact_sales\`
GROUP BY payment_method
ORDER BY total_amount DESC;"Daglig trendanalyse:
bq query --nouse_legacy_sql "
CREATE OR REPLACE VIEW \`${GROUP_NAME}_gold.v_daily_trends\` AS
SELECT
sales_date,
COUNT(*) AS daily_transactions,
SUM(sales_amount) AS daily_revenue,
AVG(sales_amount) AS daily_avg_transaction,
COUNT(DISTINCT product_id) AS unique_products_sold
FROM \`${GROUP_NAME}_gold.fact_sales\`
GROUP BY sales_date
ORDER BY sales_date DESC;"Utforsk KPI-data:
💡 Tips: Bruk BigQuery Console eller
bq queryi terminalen.
Query 1: Se daglig KPI (siste 7 dager)
SELECT *
FROM `<gruppenavn>_gold.v_daily_kpi`
WHERE sales_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
ORDER BY sales_date DESC, total_amount DESC;Query 2: Topp 10 produkter etter omsetning
SELECT *
FROM `<gruppenavn>_gold.v_product_performance`
ORDER BY total_revenue DESC
LIMIT 10;Query 3: Betalingsmetode-analyse
SELECT *
FROM `<gruppenavn>_gold.v_payment_analysis`;Query 4: Daglige trender (siste 30 dager)
SELECT *
FROM `<gruppenavn>_gold.v_daily_trends`
ORDER BY sales_date DESC
LIMIT 30;Query 5: Finn beste produkt per betalingsmetode
SELECT
payment_method,
product_id,
product_name,
SUM(total_amount) as revenue
FROM `<gruppenavn>_gold.v_daily_kpi`
GROUP BY payment_method, product_id, product_name
QUALIFY ROW_NUMBER() OVER (PARTITION BY payment_method ORDER BY SUM(total_amount) DESC) = 1
ORDER BY revenue DESC;Kjør queries fra terminalen:
Daglig KPI (siste 7 dager):
bq query --nouse_legacy_sql "
SELECT *
FROM \`${GROUP_NAME}_gold.v_daily_kpi\`
WHERE sales_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
ORDER BY sales_date DESC, total_amount DESC"Topp 10 produkter etter omsetning:
bq query --nouse_legacy_sql --max_rows=10 "
SELECT *
FROM \`${GROUP_NAME}_gold.v_product_performance\`
ORDER BY total_revenue DESC
LIMIT 10"Betalingsmetode-analyse:
bq query --nouse_legacy_sql "
SELECT *
FROM \`${GROUP_NAME}_gold.v_payment_analysis\`"Daglige trender:
bq query --nouse_legacy_sql --max_rows=30 "
SELECT *
FROM \`${GROUP_NAME}_gold.v_daily_trends\`
ORDER BY sales_date DESC
LIMIT 30"Verifiser hele dataflyt:
Sammenlign antall rader på tvers av alle lag:
bq query --nouse_legacy_sql "
SELECT 'Bronze' as layer, COUNT(*) as row_count FROM \`${GROUP_NAME}_bronze.sales\`
UNION ALL
SELECT 'Silver' as layer, COUNT(*) as row_count FROM \`${GROUP_NAME}_silver.sales\`
UNION ALL
SELECT 'Gold (fact)' as layer, COUNT(*) as row_count FROM \`${GROUP_NAME}_gold.fact_sales\`
ORDER BY
CASE layer
WHEN 'Bronze' THEN 1
WHEN 'Silver' THEN 2
WHEN 'Gold (fact)' THEN 3
END"✅ Suksesskriterium: Bronze skal ha flest rader, Silver litt færre (pga. deduplisering), og Gold omtrent samme som Silver (minus NULL-verdier).
Oversikt over dimensjoner og antall unike verdier:
bq query --nouse_legacy_sql "
SELECT
'Produkter' as dimension,
COUNT(*) as unique_count
FROM \`${GROUP_NAME}_gold.dim_product\`
UNION ALL
SELECT
'Betalingsmetoder' as dimension,
COUNT(*) as unique_count
FROM \`${GROUP_NAME}_gold.dim_payment_method\`"Datakvalitetsrapport - Før og etter:
💡 Tips: Bruk BigQuery Console eller
bq queryi terminalen.
SQL: Sammenlign datakvalitet Bronze vs Silver
Sammenlign NULL-rate: Bronze vs Silver:
WITH bronze_nulls AS (
SELECT
'Bronze' as layer,
COUNTIF(product_id IS NULL OR CAST(product_id AS STRING) = '') as null_product_id,
COUNTIF(product_name IS NULL OR CAST(product_name AS STRING) = '') as null_product_name,
COUNTIF(payment_method IS NULL OR CAST(payment_method AS STRING) = '') as null_payment_method,
COUNTIF(sales_amount IS NULL OR CAST(sales_amount AS STRING) = '') as null_sales_amount,
COUNTIF(sales_timestamp IS NULL OR CAST(sales_timestamp AS STRING) = '') as null_sales_timestamp,
COUNT(*) as total_rows
FROM `<gruppenavn>_bronze.sales`
),
silver_nulls AS (
SELECT
'Silver' as layer,
COUNTIF(product_id IS NULL) as null_product_id,
COUNTIF(product_name IS NULL) as null_product_name,
COUNTIF(payment_method IS NULL) as null_payment_method,
COUNTIF(sales_amount IS NULL) as null_sales_amount,
COUNTIF(sales_ts IS NULL) as null_sales_timestamp,
COUNT(*) as total_rows
FROM `<gruppenavn>_silver.sales`
)
SELECT
layer,
total_rows,
null_product_id,
ROUND(null_product_id * 100.0 / total_rows, 2) as pct_null_product_id,
null_product_name,
ROUND(null_product_name * 100.0 / total_rows, 2) as pct_null_product_name,
null_payment_method,
ROUND(null_payment_method * 100.0 / total_rows, 2) as pct_null_payment_method,
null_sales_amount,
ROUND(null_sales_amount * 100.0 / total_rows, 2) as pct_null_sales_amount
FROM bronze_nulls
UNION ALL
SELECT
layer,
total_rows,
null_product_id,
ROUND(null_product_id * 100.0 / total_rows, 2) as pct_null_product_id,
null_product_name,
ROUND(null_product_name * 100.0 / total_rows, 2) as pct_null_product_name,
null_payment_method,
ROUND(null_payment_method * 100.0 / total_rows, 2) as pct_null_payment_method,
null_sales_amount,
ROUND(null_sales_amount * 100.0 / total_rows, 2) as pct_null_sales_amount
FROM silver_nulls;Kjør fra terminalen:
bq query --nouse_legacy_sql "
WITH bronze_nulls AS (
SELECT
'Bronze' as layer,
COUNTIF(product_id IS NULL OR CAST(product_id AS STRING) = '') as null_product_id,
COUNTIF(product_name IS NULL OR CAST(product_name AS STRING) = '') as null_product_name,
COUNTIF(payment_method IS NULL OR CAST(payment_method AS STRING) = '') as null_payment_method,
COUNTIF(sales_amount IS NULL OR CAST(sales_amount AS STRING) = '') as null_sales_amount,
COUNTIF(sales_timestamp IS NULL OR CAST(sales_timestamp AS STRING) = '') as null_sales_timestamp,
COUNT(*) as total_rows
FROM \`${GROUP_NAME}_bronze.sales\`
),
silver_nulls AS (
SELECT
'Silver' as layer,
COUNTIF(product_id IS NULL) as null_product_id,
COUNTIF(product_name IS NULL) as null_product_name,
COUNTIF(payment_method IS NULL) as null_payment_method,
COUNTIF(sales_amount IS NULL) as null_sales_amount,
COUNTIF(sales_ts IS NULL) as null_sales_timestamp,
COUNT(*) as total_rows
FROM \`${GROUP_NAME}_silver.sales\`
)
SELECT
layer,
total_rows,
null_product_id,
ROUND(null_product_id * 100.0 / total_rows, 2) as pct_null_product_id,
null_product_name,
ROUND(null_product_name * 100.0 / total_rows, 2) as pct_null_product_name,
null_payment_method,
ROUND(null_payment_method * 100.0 / total_rows, 2) as pct_null_payment_method,
null_sales_amount,
ROUND(null_sales_amount * 100.0 / total_rows, 2) as pct_null_sales_amount
FROM bronze_nulls
UNION ALL
SELECT
layer,
total_rows,
null_product_id,
ROUND(null_product_id * 100.0 / total_rows, 2) as pct_null_product_id,
null_product_name,
ROUND(null_product_name * 100.0 / total_rows, 2) as pct_null_product_name,
null_payment_method,
ROUND(null_payment_method * 100.0 / total_rows, 2) as pct_null_payment_method,
null_sales_amount,
ROUND(null_sales_amount * 100.0 / total_rows, 2) as pct_null_sales_amount
FROM silver_nulls"💭 Refleksjonsspørsmål for gruppen:
Ta 3-4 minutter i gruppen for å diskutere:
-
Datakvalitet:
- Hvor mange duplikater ble fjernet mellom Bronze og Silver?
- Hvilke datakvalitetsutfordringer var mest overraskende?
- Hvordan påvirker dårlig datakvalitet forretningsbeslutninger?
-
Transformasjoner:
- Hvilke felt var vanskeligst å standardisere?
- Hvorfor er det viktig å ha kanoniske verdier for
payment_method? - Hva ville skje hvis vi ikke håndterte ulike timestamp-formater?
-
Forretningsverdi:
- Hvilke innsikter fant dere i Gold-laget som ikke var synlige i Bronze?
- Hvordan kan
v_payment_analysisbrukes av forretningen? - Hvilke andre KPI-er ville vært nyttige?
-
Flere timestamp-formater:
- Utvid
COALESCEi Silver-transformasjon for å håndtere Unix millisekunder - Legg til støtte for ISO 8601 med ulike timesoner
- Utvid
-
Mer robust amount-parsing:
- Håndter komma vs. punktum som desimalskilletegn
- Støtte for flere valutaer (NOK, SEK, DKK)
-
Datakvalitets-alerting:
- Sett opp enkle BigQuery scheduled queries som sjekker null-rate
- Send varsel hvis datakvalitet forverres
-
dbt for modellering:
- Migrer Silver/Gold transformasjoner til dbt-models
- Få innebygd testing, dokumentasjon og lineage
-
Inkrementell lasting:
- Bytt fra
--replacetil inkrementell append/merge - Håndter CDC (Change Data Capture) for oppdateringer
- Bytt fra
-
Orkestrering:
- GitHub Actions for daglig kjøring av ingest + transformasjoner
- Cloud Scheduler → Cloud Functions for enkel scheduling
- Vurder Apache Airflow/Cloud Composer for komplekse workflows
-
Datakatalog:
- Bruk BigQuery Data Catalog for å tagge PII-felt
- Dokumenter forretningsbegreper (business glossary)