Skip to content

kantega/loeft

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

56 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Workshop: Fra operasjonelle data til analyseklar data på GCP med medaljongstruktur

Om workshopen

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.


Agenda (2 timer)

  • 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

Navnekonvensjon i BigQuery

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

Foreslåtte gruppenavn (eller velg ditt eget):

  1. lambda - Lambda-arkitektur (batch + streaming)
  2. kappa - Kappa-arkitektur (kun streaming)
  3. delta - Delta Lake-arkitektur
  4. lakehouse - Lakehouse-arkitektur
  5. mesh - Data Mesh-arkitektur
  6. fabric - Data Fabric-arkitektur
  7. vault - Data Vault-modellering
  8. star - Star schema-modellering

📋 Før dere starter: Oppsett og installasjon

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.


Øvelser

1) Miljøsjekk og oppsett (10 min)

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 login

Sett ditt prosjekt:

gcloud config set project loft-473909

VIKTIG: 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}_bronze

Opprett silver dataset:

bq --location=europe-west1 mk -d ${GROUP_NAME}_silver

Opprett gold dataset:

bq --location=europe-west1 mk -d ${GROUP_NAME}_gold

Verifiser 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".

2) Generer datasett (10 min)

🎯 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/out

Generer 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}.csv

Tell 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_amount ut? (Rene tall, valutasymboler, tekstverdier?)
  • Finnes det typos eller ekstra mellomrom i product_name eller payment_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.

3) Bronse: ingest direkte til BigQuery (10 min)

🎯 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}.csv

Verifiser at tabellen er opprettet og har data i BigQuery Console:

  1. Gå til BigQuery Console
  2. I venstre panel, finn deres dataset <gruppenavn>_bronze (f.eks. lambda_bronze)
  3. Klikk på sales tabellen
  4. Se på "Schema" fanen for å se kolonnene
  5. Se på "Details" fanen for å se antall rader og tabellstørrelse
  6. Klikk på "Preview" fanen for å se de første radene av data

Alternativt: Kjør en query for å verifisere:

  1. Klikk på "Compose New Query" eller "+"-knappen
  2. 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;
  1. Klikk "Run" eller trykk Ctrl+Enter (Cmd+Enter på Mac) for å kjøre queryen

4) Data Profiling - Forstå datakvalitetsutfordringer (15 min)

🎯 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 query i terminalen

bq query --nouse_legacy_sql "SELECT COUNT(*) FROM \`${GROUP_NAME}_bronze.sales\`"

Alternativ 2: Bruk BigQuery Console (Web UI)

  1. Gå til BigQuery Console
  2. Klikk på "Compose New Query" eller "+"-knappen
  3. Lim inn SQL-koden nedenfor
  4. Bytt ut <gruppenavn> med ditt faktiske gruppenavn (f.eks. lambda)
  5. Klikk "Run" eller trykk Ctrl+Enter (Cmd+Enter på Mac)

Tips: Bruk Console for lengre queries og utforskning, bq query for 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_timestamp fant dere?
  • Hvor stor prosentandel av product_name har variasjoner (typos, whitespace)?
  • Hvor mange duplikater finnes i datasettet?
  • Hvilke datakvalitetsutfordringer ser dere som mest kritiske?

5) Sølv: rensing og standardisering (40 min)

🎯 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:

  1. Utforsk Bronze-data
  2. Rens product_id
  3. Rens product_name
  4. Standardiser payment_method
  5. Parse sales_amount
  6. Parse sales_timestamp
  7. Deduplisering med fingerprinting
  8. Sett sammen alt med partisjonering og klustring

Forberedelser

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 query i terminalen

bq query --nouse_legacy_sql "SELECT * FROM \`${GROUP_NAME}_bronze.sales\` LIMIT 10"

Alternativ 2: Bruk BigQuery Console (Web UI) - Anbefalt for denne interaktive seksjonen!

  1. Gå til BigQuery Console
  2. Klikk på "Compose New Query" eller "+"-knappen
  3. Lim inn SQL-koden nedenfor
  4. Bytt ut <gruppenavn> med deres faktiske gruppenavn (f.eks. lambda)
  5. Klikk "Run" eller trykk Ctrl+Enter (Cmd+Enter på Mac)
  6. 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 query for raske verifikasjoner.


Steg 1: Utforsk Bronze-data

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.


Steg 2: Rens product_id

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_id

Alternativ 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_id

Alternativ 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_id

Velg 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).


Steg 3: Rens product_name

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_name

Alternativ B: UPPER CASE (for konsistens)

UPPER(REGEXP_REPLACE(TRIM(product_name_raw), r'\s+', ' ')) AS product_name

Alternativ C: lower case (for enklere matching)

LOWER(REGEXP_REPLACE(TRIM(product_name_raw), r'\s+', ' ')) AS product_name

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 (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;

Steg 4: Standardiser payment_method

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 NULL

Alternativ B: Behold original (for analyse)

ELSE payment_method_raw

Alternativ 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;

Steg 5: Parse sales_amount

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.


Steg 6: Parse sales_timestamp

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

Steg 7: Deduplisering

🤔 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.


Steg 8: Sett sammen alt + Partisjonering

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!


Verifiser sølv-tabell

Sjekk partisjonering og klustring:

bq show --format=prettyjson ${GROUP_NAME}_silver.sales

Tell 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).


Datakvalitetsmålinger etter rensing

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;


💭 Refleksjonsspørsmål etter sølv-transformasjon

Ta 5 minutter i gruppen for å diskutere:

  1. 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?
  2. 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?
  3. Ytelse:

    • Hvorfor bruker vi partisjonering på sales_date?
    • Hvorfor klustrerer vi på product_id?
    • Hvor mange GB spares ved å bruke partisjonfilter?
  4. 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!

6) Gull: forretningsklart (15 min)

🎯 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:

  1. Faktatabell (fact table): fact_sales - inneholder målbare forretningshendelser (transaksjoner) med numeriske verdier (sales_amount) og fremmednøkler til dimensjoner
  2. Dimensjonstabeller (dimension tables): dim_product og dim_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_sales inneholder alle salgstransaksjoner med beløp, tidspunkt og referanser til produkt og betalingsmetode
  • dim_product inneholder unike produkter med ID og navn
  • dim_payment_method inneholder standardiserte betalingsmetoder

Opprett SQL-mappe for gold:

mkdir -p sql/gold

💡 Tips: Bruk BigQuery Console for lengre queries, eller bq query i 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.sql

ELLER 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_sales skal ha omtrent samme antall rader som Silver (minus NULL-verdier)
  • dim_product skal ha 50-100 unike produkter
  • dim_payment_method skal 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.sql

ELLER 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 query i 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"

7) Oppsummering og refleksjon (10 min)

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 query i 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:

  1. Datakvalitet:

    • Hvor mange duplikater ble fjernet mellom Bronze og Silver?
    • Hvilke datakvalitetsutfordringer var mest overraskende?
    • Hvordan påvirker dårlig datakvalitet forretningsbeslutninger?
  2. 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?
  3. Forretningsverdi:

    • Hvilke innsikter fant dere i Gold-laget som ikke var synlige i Bronze?
    • Hvordan kan v_payment_analysis brukes av forretningen?
    • Hvilke andre KPI-er ville vært nyttige?

Neste steg og videre arbeid

Umiddelbare forbedringer (kortsiktig):

  1. Flere timestamp-formater:

    • Utvid COALESCE i Silver-transformasjon for å håndtere Unix millisekunder
    • Legg til støtte for ISO 8601 med ulike timesoner
  2. Mer robust amount-parsing:

    • Håndter komma vs. punktum som desimalskilletegn
    • Støtte for flere valutaer (NOK, SEK, DKK)
  3. Datakvalitets-alerting:

    • Sett opp enkle BigQuery scheduled queries som sjekker null-rate
    • Send varsel hvis datakvalitet forverres

Mellomlangsiktige utvidelser:

  1. dbt for modellering:

    • Migrer Silver/Gold transformasjoner til dbt-models
    • Få innebygd testing, dokumentasjon og lineage
  2. Inkrementell lasting:

    • Bytt fra --replace til inkrementell append/merge
    • Håndter CDC (Change Data Capture) for oppdateringer
  3. 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
  4. Datakatalog:

    • Bruk BigQuery Data Catalog for å tagge PII-felt
    • Dokumenter forretningsbegreper (business glossary)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages