COMP63301 Data Engineering Concepts
Stian Soiland-Reyes
This work is licensed under a
Creative Commons Attribution 4.0 International License.
What is the current representation? What does one row represent?
What is the purpose of the transformation? What questions will we ask?
What is the target representation? What should one row now represent?
| Students | |||
|---|---|---|---|
| student_id | degree | entry_year | region |
| 1028802 | CS | 2024 | North |
| 1278181 | CS+Math | 2026 | South |
| (20,000 rows) |
| Courses | ||
|---|---|---|
| course_id | school | credits |
| COMP62301 | CS | 15 |
| COMP62452 | CS | 10 |
| (300 rows) |
| Enrollment | ||
|---|---|---|
| student_id | course_id | mark |
| 1028802 | COMP62301 | 78 |
| 1028802 | COMP62452 | 66 |
| 1278181 | COMP62301 | 81 |
| (150,000 rows) |
| Engagement | ||||||
|---|---|---|---|---|---|---|
| event_id | student_id | course_id | event_time | duration | resource_type | completed |
| 13121 | 1278181 | COMP62301 | 2025-12-06 | 50 | video | true |
| 13122 | 1028802 | COMP62452 | 2026-02-14 | 45 | video | false |
| 13123 | 1028802 | COMP62452 | 2026-02-20 | 9 | quiz | true |
| (3,000,000 rows) |
Inspect the database
mysql> SELECT * FROM Students LIMIT 2;
+------------+----------+------------+--------+
| student_id | degree | entry_year | region |
+------------+----------+------------+--------+
| 1028802 | CS | 2024 | North |
| 1278181 | CS+Math | 2026 | West |
+------------+----------+------------+--------+
2 rows in set (0.00 sec)Understanding the current schema
Students (student_id, degree, entry_year, region)
Courses (course_id, school, credits)
Enrollment (student_id, course_id, mark)
Engagement (event_id, students_id, course_id, event_time, duration,
resource_type, completed)
| Students | |||
|---|---|---|---|
| student_id | degree | entry_year | region |
| Courses | ||
|---|---|---|
| course_id | school | credits |
| Enrollment | ||
|---|---|---|
| student_id | course_id | mark |
| Engagement | ||||||
|---|---|---|---|---|---|---|
| event_id | student_id | course_id | event_time | duration | resource_type | completed |
mysql> DESCRIBE Students;
+------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+--------------+------+-----+---------+----------------+
| student_id | int | NO | PRI | NULL | auto_increment |
| degree | varchar(100) | YES | | NULL | |
| entry_year | year | YES | | NULL | |
| region | varchar(50) | YES | | NULL | |
+------------+--------------+------+-----+---------+----------------+
grain: per event per student per course
grain: per student per course
grain: per student
grain: per course
grain: one row represent what?
| Students | |||
|---|---|---|---|
| student_id | degree | entry_year | region |
| Courses | ||
|---|---|---|
| course_id | school | credits |
| Enrollment | ||
|---|---|---|
| student_id | course_id | mark |
| Engagement | ||||||
|---|---|---|---|---|---|---|
| event_id | student_id | course_id | event_time | duration | resource_type | completed |
Fact table: Append-only list of events
Dimension tables: Mostly static, attributes
Ralph Kimball, Margy Ross (2013):
The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling, Third Edition
ISBN 978-1118530801
Engagement
Enrollment
Students
Courses
Kimball star schema:
The fact table is augmented by attributes in each of the dimensions.
facts
dimension
dimension
dimension
Events
dimension
Time
dimension
Understanding the current schema:
Make/generate UML Class Diagram
How engaged was a student in a course?
How much time was spent on a course?
How many activities did each student complete?
Do engaged students achieve higher marks?
Data transformation create new data products to make questions (more easily) answerable
First: Decide on the grain of the desired table
Second: Which attributes do we wish were there?
EventSummary (student_id, course_id, event_count, total_duration, completion_rate, video_duration, reading_duration, last_activity)
StudentCourseFeatures (student_id, course_id, mark,
engagement_level, degree, entry_year, region, school, event_count,
total_duration, completion_rate, video_duration, reading_duration, last_activity)
...
conn = sqlite3.connect(DB_FILE)
students = pd.read_sql("SELECT * FROM Students", conn)
courses = pd.read_sql("SELECT * FROM Courses", conn)
enrolments = pd.read_sql("SELECT * FROM Enrolment", conn)
engagement = pd.read_sql("SELECT * FROM Engagement", conn)# Joining with Students
analytics = enrolments.merge(students,
on="student_id",
how="left"
)
# Join Courses
analytics = analytics.merge(courses,
on="course_id",
how="left"
)
Merging data frames in Pandas
joining data frames
| Enrollment | ||
|---|---|---|
| student_id | course_id | mark |
| 1028802 | COMP62301 | 78 |
| Courses | ||
|---|---|---|
| course_id | school | credits |
COMP62301 | CS | 15 |
| Students | |||
|---|---|---|---|
| student_id | degree | entry_year | region |
| 1028802 | CS | 2024 | North |
| Analytics | |||||||
|---|---|---|---|---|---|---|---|
| student_id | course_id | mark | degree | entry_year | region | school | credits |
| 1028802 | COMP62301 | 78 | CS | 2024 | North | CS | 15 |
engagements = engagements.sort_values(
["student_id", "course_id", "event_time"]
)
event_summary = (engagements
.groupby(
["student_id", "course_id"]
)
.agg(
event_count=("event_id", "count"),
total_duration=("duration", "sum"),
completion_rate=("completed", "mean")
)
)
Aggregate columns
| Engagement | ||||||
|---|---|---|---|---|---|---|
| event_id | student_id | course_id | event_time | duration | resource_type | completed |
| 13122 | 1028802 | COMP62452 | 2026-02-14 | 45 | video | false |
| 13123 | 1028802 | COMP62452 | 2026-02-20 | 9 | quiz | true |
| 13121 | 1278181 | COMP62301 | 2025-12-06 | 50 | video | true |
| Event Summary | ||||
|---|---|---|---|---|
| student_id | course_id | event_count | total_duration | completion_rate |
| 1028802 | COMP62452 | 2 | 54 | 0.5 |
| 1278181 | COMP62301 | 1 | 50 | 1 |
student_means = (
analytics
.groupby("student_id")["mark"]
.mean()
)
analytics["student_mean_mark"] = (
analytics["student_id"]
.map(student_means)
)def classify_risk(row):
if pd.isna(row["event_count"]):
return "High Risk"
if row["mark"] < 40:
return "High Risk"
if row["completion_rate"] < 0.5:
return "Medium Risk"
if row["event_count"] < 5:
return "Medium Risk"
return "Low Risk"
analytics["risk_band"] = (
analytics
.apply(
classify_risk,
axis=1
)
)Adding derived columns calculated in Python
| student_id | course_id | mark | engagement_level | degree | entry_year | region | school | credits | event_count | total_duration | completion_rate | video_duration | last_activity | reading_duration | student_mean_mark | course_mean_mark | engagement_band | risk_band | above_student_average | above_course_average | days_since_last_activity | degree_mean_mark | degree_mean_events |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 244 | 64.0 | 19.82 | Mathematics | 2025 | North | Maths | 40 | 18 | 858 | 0.611 | 259 | 2026-07-29 | 101 | 70.175 | 58.723 | Low | Low Risk | False | True | 34 | 64.593 | 19.628 |
| 1 | 10 | 83.7 | 19.82 | Mathematics | 2025 | North | Maths | 10 | 15 | 945 | 0.667 | 103 | 2026-08-11 | 480 | 70.175 | 65.867 | Low | Low Risk | True | True | 21 | 64.593 | 19.628 |
| 1 | 172 | 80.3 | 19.82 | Mathematics | 2025 | North | Maths | 20 | 23 | 1289 | 0.522 | 7 | 2026-08-15 | 699 | 70.175 | 69.430 | Medium | Low Risk | True | True | 17 | 64.593 | 19.628 |
| student_id | course_id | mark | engagement_level | degree | entry_year | region | school | credits | event_count | total_duration | completion_rate | video_duration | last_activity | reading_duration | student_mean_mark | course_mean_mark | engagement_band | risk_band | above_student_average | above_course_average | days_since_last_activity | degree_mean_mark | degree_mean_events |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 244 | 64.0 | 19.82 | Mathematics | 2025 | North | Maths | 40 | 18 | 858 | 0.611 | 259 | 2026-07-29 | 101 | 70.175 | 58.723 | Low | Low Risk | False | True | 34 | 64.593 | 19.628 |
| 1 | 10 | 83.7 | 19.82 | Mathematics | 2025 | North | Maths | 10 | 15 | 945 | 0.667 | 103 | 2026-08-11 | 480 | 70.175 | 65.867 | Low | Low Risk | True | True | 21 | 64.593 | 19.628 |
| 1 | 172 | 80.3 | 19.82 | Mathematics | 2025 | North | Maths | 20 | 23 | 1289 | 0.522 | 7 | 2026-08-15 | 699 | 70.175 | 69.430 | Medium | Low Risk | True | True | 17 | 64.593 | 19.628 |
Output statistics
----------------------------------------------------------------------
Rows: 149,983
Columns: 24
Execution time: 16.7 secondsScalability challenge (Python+Pandas):
Querying a small 200 MB database takes 17 seconds at full CPU, using GBs of RAM
| Enrollment | ||
|---|---|---|
| student_id | course_id | mark |
| 1028802 | COMP62301 | 78 |
| 1028802 | COMP62452 | 66 |
| 1278181 | COMP62301 | 81 |
| (150,000 rows) |
| Engagement | ||||||
|---|---|---|---|---|---|---|
| event_id | students_id | course_id | event_time | duration | resource_type | completed |
| 13121 | 1278181 | COMP62301 | 2025-12-06 | 50 | video | true |
| 13122 | 1028802 | COMP62452 | 2026-02-14 | 45 | video | false |
| 13123 | 1028802 | COMP62452 | 2026-02-20 | 9 | quiz | true |
| (3,000,000 rows) |
Python is not a compiled language!
150k * 3M = 450 million rows combinations.
Each row revisited for each join/group.
All is kept in memory
No query optimization, indexes.
Python limitations
CREATE TABLE StudentCourseAnalytics AS
WITH event_summary AS (
SELECT
student_id,
course_id,
COUNT(event_id) AS event_count,
SUM(duration) AS total_duration,
AVG(completed) AS completion_rate,
SUM(
CASE
WHEN resource_type = 'video'
THEN duration
END
) AS video_duration,
SUM(
CASE
WHEN resource_type = 'reading'
THEN duration
END
) AS reading_duration,
MAX(event_time) AS last_activity
FROM LearningEvents
GROUP BY student_id, course_id
),
enrolment_details AS (
SELECT
e.student_id,
e.course_id,
e.mark,
e.engagement_level,
s.degree,
s.entry_year,
s.region,
c.school,
c.credits,
es.event_count,
es.total_duration,
es.completion_rate,
es.video_duration,
es.reading_duration,
es.last_activity,
AVG(e.mark) OVER (
PARTITION BY e.student_id
) AS student_mean_mark,
AVG(e.mark) OVER (
PARTITION BY e.course_id
) AS course_mean_mark
FROM Enrolments AS e
LEFT JOIN Students AS s
ON s.student_id = e.student_id
LEFT JOIN Courses AS c
ON c.course_id = e.course_id
LEFT JOIN event_summary AS es
ON es.student_id = e.student_id
AND es.course_id = e.course_id
),
classified AS (
SELECT
*,
CASE
WHEN event_count IS NULL THEN 'No Activity'
WHEN event_count > 50 THEN 'High'
WHEN event_count > 20 THEN 'Medium'
ELSE 'Low'
END AS engagement_band,
CASE
WHEN event_count IS NULL THEN 'High Risk'
WHEN mark < 40 THEN 'High Risk'
WHEN completion_rate < 0.5 THEN 'Medium Risk'
WHEN event_count < 5 THEN 'Medium Risk'
ELSE 'Low Risk'
END AS risk_band,
mark > student_mean_mark
AS above_student_average,
mark > course_mean_mark
AS above_course_average,
CASE
WHEN last_activity IS NULL THEN 999
ELSE CAST(
julianday('2026-09-01')
- julianday(last_activity)
AS INTEGER
)
END AS days_since_last_activity,
COALESCE(event_count, 0)
AS filled_event_count,
COALESCE(total_duration, 0)
AS filled_total_duration,
COALESCE(completion_rate, 0)
AS filled_completion_rate,
COALESCE(video_duration, 0)
AS filled_video_duration,
COALESCE(reading_duration, 0)
AS filled_reading_duration
FROM enrolment_details
),
degree_statistics AS (
SELECT
degree,
AVG(mark) AS degree_mean_mark,
AVG(filled_event_count) AS degree_mean_events
FROM classified
GROUP BY degree
)
SELECT
classified.student_id,
classified.course_id,
classified.mark,
classified.engagement_level,
classified.degree,
classified.entry_year,
classified.region,
classified.school,
classified.credits,
classified.filled_event_count AS event_count,
classified.filled_total_duration AS total_duration,
classified.filled_completion_rate AS completion_rate,
classified.filled_video_duration AS video_duration,
classified.last_activity,
classified.filled_reading_duration AS reading_duration,
classified.student_mean_mark,
classified.course_mean_mark,
classified.engagement_band,
classified.risk_band,
classified.above_student_average,
classified.above_course_average,
classified.days_since_last_activity,
degree_statistics.degree_mean_mark,
degree_statistics.degree_mean_events
FROM classified
LEFT JOIN degree_statistics
ON degree_statistics.degree = classified.degree;
Using SQL WITH to create temporary tables for the big query
Aggregate columns created using AS
Rows: 149,983
Columns: 24
Execution time: 4.2 secondsPython+SQLite:
3-4 seconds, using less than 1% of RAM
~ 6x speed-up
Database: myinner_db
User: mynonsuperuser
Rows: 149,983
Columns: 24
Execution time: 2.3 seconds.. Python+PostgreSQL
2 seconds, using less than 1.6% of RAM
~ 9x speed-up
Rule of thumb:
Let the data source handle filtering, joining, aggregation if it reduces data movement or repetitive executions.
VIEWNo more mega-query
CREATE VIEW EventSummary AS
SELECT
student_id,
course_id,
COUNT(event_id) AS event_count,
SUM(duration) AS total_duration,
AVG(completed) AS completion_rate,
SUM(
CASE
WHEN resource_type = 'video' THEN duration
ELSE 0
END
) AS video_duration,
SUM(
CASE
WHEN resource_type = 'reading' THEN duration
ELSE 0
END
) AS reading_duration,
MAX(event_time) AS last_activity
FROM LearningEvents
GROUP BY
student_id,
course_id;
View containing aggregate columns
| student_id | course_id | event_count | total_duration | completion_rate | video_duration | reading_duration | last_activity |
|---|---|---|---|---|---|---|---|
| 1 | 244 | 7 | 215 | 0.86 | 125 | 40 | 2026-07-29 |
| 1 | 10 | 15 | 945 | 0.67 | 103 | 480 | 2026-08-11 |
| 2 | 104 | 13 | 1033 | 0.77 | 260 | 416 | 2026-07-19 |
CREATE VIEW EnrolmentDetails AS
SELECT
e.student_id,
e.course_id,
e.mark,
e.engagement_level,
s.degree,
s.entry_year,
s.region,
c.school,
c.credits
FROM Enrolments AS e
LEFT JOIN Students AS s
ON s.student_id = e.student_id
LEFT JOIN Courses AS c
ON c.course_id = e.course_id;View from merging (JOIN) tables
| student_id | course_id | mark | engagement_level | degree | entry_year | region | school | credits |
|---|---|---|---|---|---|---|---|---|
| 1 | 244 | 64.0 | 19.82 | Mathematics | 2025 | North | Maths | 40 |
| 1 | 10 | 83.7 | 19.82 | Mathematics | 2025 | North | Maths | 10 |
| 2 | 104 | 39.1 | 11.27 | Data Science | 2024 | South | CS | 20 |
| 3 | 109 | 48.4 | 52.27 | Data Science | 2023 | North | NULL | NULL |
CREATE VIEW StudentCourseFeatures AS
SELECT
ed.student_id,
ed.course_id,
ed.mark,
ed.engagement_level,
ed.degree,
ed.entry_year,
ed.region,
ed.school,
ed.credits,
es.event_count,
es.total_duration,
es.completion_rate,
es.video_duration,
es.reading_duration,
es.last_activity
FROM EnrolmentDetails AS ed
LEFT JOIN EventSummary AS es
ON es.student_id = ed.student_id
AND es.course_id = ed.course_id;View from merging (JOIN) with other views
| student_id | course_id | mark | engagement_level | degree | entry_year | region | school | credits | event_count | total_duration | completion_rate | video_duration | reading_duration | last_activity |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 10 | 83.7 | 19.82 | Mathematics | 2025 | North | Maths | 10 | 15 | 945 | 0.67 | 103 | 480 | 2026-08-11 |
| 1 | 101 | 79.2 | 19.82 | Mathematics | 2025 | North | Maths | 10 | 19 | 995 | 0.84 | 168 | 200 | 2026-08-13 |
| 1 | 172 | 80.3 | 19.82 | Mathematics | 2025 | North | Maths | 20 | 23 | 1289 | 0.52 | 7 | 699 | 2026-08-15 |
SELECT
student_id,
course_id,
degree,
mark,
event_count,
completion_rate,
days_since_last_activity
FROM StudentCourseAnalyticsFinal
WHERE risk_band = 'High Risk'
ORDER BY days_since_last_activity DESC
LIMIT 10Each view designed to answer a set of possible questions
CREATE VIEW StudentCourseClassified AS
SELECT
student_id,
course_id,
mark,
engagement_level,
degree,
entry_year,
region,
school,
credits,
COALESCE(event_count, 0) AS event_count,
COALESCE(total_duration, 0) AS total_duration,
COALESCE(completion_rate, 0) AS completion_rate,
COALESCE(video_duration, 0) AS video_duration,
COALESCE(reading_duration, 0) AS reading_duration,
last_activity,
student_mean_mark,
course_mean_mark,
CASE
WHEN event_count IS NULL THEN 'No Activity'
WHEN event_count > 50 THEN 'High'
WHEN event_count > 20 THEN 'Medium'
ELSE 'Low'
END AS engagement_band,
CASE
WHEN event_count IS NULL THEN 'High Risk'
WHEN mark < 40 THEN 'High Risk'
WHEN completion_rate < 0.5 THEN 'Medium Risk'
WHEN event_count < 5 THEN 'Medium Risk'
ELSE 'Low Risk'
END AS risk_band,
mark > student_mean_mark AS above_student_average,
mark > course_mean_mark AS above_course_average,
CASE
WHEN last_activity IS NULL THEN 999
ELSE CAST(
julianday('2026-09-01') - julianday(last_activity)
AS INTEGER
)
END AS days_since_last_activity
FROM StudentCourseAnalytics;
| student_id | course_id | mark | event_count | completion_rate | engagement_band | risk_band | above_student_average | above_course_average | days_since_last_activity |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 172 | 80.3 | 23 | 0.52 | Medium | Low Risk | True | True | 17 |
| 1 | 244 | 64.0 | 18 | 0.61 | Low | Low Risk | False | True | 34 |
| 2 | 104 | 39.1 | 13 | 0.77 | Low | High Risk | False | False | 44 |
Identify (implied) enumerators
video_summary = (
events[
events["resource_type"] == "video"
]
.groupby(
["student_id", "course_id"]
)
)| Engagement | ||||||
|---|---|---|---|---|---|---|
| event_id | student_id | course_id | event_time | duration | resource_type | completed |
| 13121 | 1278181 | COMP62301 | 2025-12-06 | 50 | video | true |
| 13122 | 1028802 | COMP62452 | 2026-02-14 | 45 | video | false |
| 13123 | 1028802 | COMP62452 | 2026-02-20 | 9 | quiz | true |
| (3,000,000 rows) |
myinner_db=> SELECT DISTINCT resource_type
FROM learningevents ;
resource_type
---------------
exercise
quiz
reading
videoPivot aggregation per feature
| Engagement | ||||||
|---|---|---|---|---|---|---|
| event_id | student_id | course_id | event_time | duration | resource_type | completed |
| 13121 | 1278181 | COMP62301 | 2025-12-06 | 50 | video | true |
| 13122 | 1028802 | COMP62452 | 2026-02-14 | 45 | video | false |
| 13123 | 1028802 | COMP62452 | 2026-02-20 | 9 | quiz | true |
| (3,000,000 rows) |
| EngagementFeatures | ||||||
|---|---|---|---|---|---|---|
| student_id | course_id | video_count | video_duration | video_completion | quiz_count | quiz_completion |
| 1 | 244 | 12 | 259 | 0.81 | 8 | 1.0 |
| 2 | 104 | 5 | 260 | 0.74 | 4 | 0/75 |
Columns per resource_type
Split feature tables
| Engagement | ||||||
|---|---|---|---|---|---|---|
| event_id | student_id | course_id | event_time | duration | resource_type | completed |
| 13121 | 1278181 | COMP62301 | 2025-12-06 | 50 | video | true |
| 13122 | 1028802 | COMP62452 | 2026-02-14 | 45 | video | false |
| 13123 | 1028802 | COMP62452 | 2026-02-20 | 9 | quiz | true |
| (3,000,000 rows) |
| VideoFeatures | ||||
|---|---|---|---|---|
| student_id | course_id | video_count | video_duration | video_completion |
| 1278181 | COMP62452 | 12 | 259 | 0.81 |
| 1028802 | COMP62301 | 5 | 260 | 0.74 |
| (~ 120,000 rows) |
| QuizFeatures | |||
|---|---|---|---|
| student_id | course_id | quiz_count | quiz_completion |
| 1278181 | COMP62452 | 8 | 1.0 |
| 1028802 | COMP62301 | 4 | 0.75 |
Feature tables per resource_type
There may be multiple valid feature tables!
Slowly Changing Dimension (SCD)
| Engagement | ||||||
|---|---|---|---|---|---|---|
| event_id | student_id | course_id | event_time | duration | resource_type | completed |
| 41423 | 1 | 244 | 2025-12-04 | 50 | video | false |
| 41424 | 1 | 244 | 2026-02-18 | 45 | video | true |
| (3,000,000 rows) |
| VideoFeatures | |||||
|---|---|---|---|---|---|
| student_id | course_id | video_count | video_duration | video_completion | until |
| 1 | 244 | 9 | 120 | 0.89 | 2025-12-01 |
| 1 | 244 | 10 | 170 | 0.80 | 2026-01-01 |
| 1 | 244 | 10 | 170 | 0.80 | 2026-02-01 |
| 1 | 244 | 11 | 215 | 0.82 | 2026-03-01 |
| (~ 300,000 rows) |
Feature tables per resource_type per time window (start--until)
Trends can be detected by aggregating per time window
| Time | |||
|---|---|---|---|
| month | academic_year | from | to |
| 2025-12 | 2025/2026 | 2025-12-01 | 2025-12-31 |
| 2026-01 | 2025/2026 | 2026-01-01 | 2026-01-31 |
| Courses | |||
|---|---|---|---|
| course_id | school | credits | keywords |
| COMP63301 | CS | 15 | database, data engineering |
| COMP62452 | CS & Business | 10 | data engineering,machine learning,statistics |
data engineering
database
machine learning
statistics
CS
Business
Semi-structured list of values hiding inside a string value — what is the format?
| Courses | |||
|---|---|---|---|
| course_id | school | credits | keywords |
| COMP63301 | CS | 15 | database, data engineering |
| COMP62452 | CS & Business | 10 | data engineering,machine learning, statistics |
data engineering
database
machine learning
statistics
| CourseKeywords | |
|---|---|
| course_id | keyword |
| COMP63301 | database |
| COMP63301 | data engineering |
| COMP62452 | data engineering |
| COMP62452 | machine learning |
| COMP62452 | statistics |
| Keywords | |
|---|---|
| keyword | alias |
| database | db |
| data engineering | |
| machine learning | ML |
| statistics | stats |
Clean
Validate
Split values
Clean
Match
Document concept scheme
| CourseSchools | |
|---|---|
| course_id | school |
| COMP63301 | CS |
| COMP62452 | CS |
| COMP62452 | Business |
| Schools | |
|---|---|
| school_id | full_name |
| CS | Department of Computer Science |
| Business | Alliance Manchester Business School |
CREATE VIEW StudentCourseFeatureDocument AS
SELECT
student_id,
course_id,
mark,
jsonb_build_object(
'video',
jsonb_build_object(
'duration', video_duration
),
'reading',
jsonb_build_object(
'duration', reading_duration
),
'engagement',
jsonb_build_object(
'event_count', event_count,
'completion_rate', completion_rate
)
) AS features
FROM StudentCourseFeatures;| student_id | course_id | mark | features (JSONB) |
|---|---|---|---|
| 1 | 10 | 83.7 |
{
"video": {
"duration": 103
},
"reading": {
"duration": 480
},
"engagement": {
"event_count": 15,
"completion_rate": 0.67
}
}
|
| 1 | 101 | 79.2 |
{
"video": {
"duration": 168
},
"reading": {
"duration": 200
},
"engagement": {
"event_count": 19,
"completion_rate": 0.84
}
}
|
| 1 | 168 | 67.2 |
{
"video": {
"duration": 298
},
"reading": {
"duration": 199
},
"engagement": {
"event_count": 18,
"completion_rate": 0.89
}
}
|
Building JSON from the database, prepared as data products exposed by API
SELECT
student_id,
course_id
FROM StudentCourseFeatureDocument
WHERE
(features->'video'->>'duration')::integer
> 200;| student_id | course_id | mark | features (JSONB) |
|---|---|---|---|
| 1 | 10 | 83.7 |
{
"video": {
"duration": 103
},
"reading": {
"duration": 480
},
"engagement": {
"event_count": 15,
"completion_rate": 0.67
}
}
|
| 1 | 101 | 79.2 |
{
"video": {
"duration": 168
},
"reading": {
"duration": 200
},
"engagement": {
"event_count": 19,
"completion_rate": 0.84
}
}
|
| 1 | 168 | 67.2 |
{
"video": {
"duration": 298
},
"reading": {
"duration": 199
},
"engagement": {
"event_count": 18,
"completion_rate": 0.89
}
}
|
Querying semi-structured JSON in relational databases
| student_id | course_id |
|---|---|
| 1 | 168 |
CREATE VIEW StudentCourseFlattenedFeatures AS
SELECT
student_id,
course_id,
(features->'video'->>'duration')::integer
AS video_duration,
(features->'reading'->>'duration')::integer
AS reading_duration,
(features->'engagement'->>'event_count')::integer
AS event_count,
(features->'engagement'->>'completion_rate')::numeric
AS completion_rate
FROM StudentCourseFeatureDocument;| student_id | course_id | mark | features (JSONB) |
|---|---|---|---|
| 1 | 10 | 83.7 |
{
"video": {
"duration": 103
},
"reading": {
"duration": 480
},
"engagement": {
"event_count": 15,
"completion_rate": 0.67
}
}
|
| 1 | 101 | 79.2 |
{
"video": {
"duration": 168
},
"reading": {
"duration": 200
},
"engagement": {
"event_count": 19,
"completion_rate": 0.84
}
}
|
| 1 | 168 | 67.2 |
{
"video": {
"duration": 298
},
"reading": {
"duration": 199
},
"engagement": {
"event_count": 18,
"completion_rate": 0.89
}
}
|
Transform from JSON to a feature table
| StudentCourse FlattenedFeatures | |||||
|---|---|---|---|---|---|
| student_id | course_id | video_duration | reading_duration | event_count | completion_rate |
| 1 | 10 | 103 | 480 | 15 | 0.67 |
| 2 | 104 | 168 | 200 | 19 | 0.84 |
SQL query
issued
Parsing and conversion to bytecode
Query planning and optimization
Query execution
Results returned
Adapted from Figure 8.2 in Fundamentals of Data engineering
🧑🏾💻
📃
🧐
👷🏽♀️
🧑🏿🍳
SQL instructions says what you want, not how
Relational databases are able to optimize the query
e.g. which order to join tables or which rows to examine first
Queries are (largely) independent of implementation details (e.g. file vs memory vs distributed)
Restructuring the relational table design can help/complicate query writing and/or query execution
SELECT Title, Journal_Title
FROM articles
JOIN journals;Careless use of database joins may lead to row explosion or inefficient database load
| The Fisher Thermodynamics of Quasi-Probabilities | Acta Crystallographica Section E Crystallograp... |
| The Fisher Thermodynamics of Quasi-Probabilities | Agriculture |
| The Fisher Thermodynamics of Quasi-Probabilities | Agronomy |
| The Fisher Thermodynamics of Quasi-Probabilities | Animals |
| The Fisher Thermodynamics of Quasi-Probabilities | Applied Sciences |
| ... | ... |
| Metagenomic Analysis of Upwelling-Affected Bra... | Toxics |
| Metagenomic Analysis of Upwelling-Affected Bra... | Toxins |
| Metagenomic Analysis of Upwelling-Affected Bra... | Vaccines |
| Metagenomic Analysis of Upwelling-Affected Bra... | Viruses |
| Metagenomic Analysis of Upwelling-Affected Bra... | Water |
51051 rows × 2 columns
SELECT *EXPLAIN to reveal the query plan
EXPLAIN SELECT Title, Journal_Title
FROM articles
JOIN journals
ON (articles.ISSNs = journals.ISSNs)
ORDER BY Journal_Title;| 0 | Init | 0 | 32 | 0 | None | 0 | None |
| 1 | SorterOpen | 2 | 4 | 0 | k(1,B) | 0 | None |
| 2 | OpenRead | 0 | 5 | 0 | 7 | 0 | None |
| 3 | OpenRead | 1 | 3 | 0 | 5 | 0 | None |
| 4 | Rewind | 0 | 24 | 0 | None | 0 | None |
| 5 | Once | 0 | 14 | 0 | None | 0 | None |
| 6 | OpenAutoindex | 3 | 3 | 0 | k(3,B,,) | 0 | None |
| 7 | Rewind | 1 | 14 | 0 | None | 0 | None |
| 8 | Column | 1 | 2 | 2 | None | 0 | None |
| 9 | Column | 1 | 4 | 3 | None | 0 | None |
| 10 | Rowid | 1 | 4 | 0 | None | 0 | None |
| 11 | MakeRecord | 2 | 3 | 1 | None | 0 | None |
| 12 | IdxInsert | 3 | 1 | 0 | None | 16 | None |
| 13 | Next | 1 | 8 | 0 | None | 3 | None |
| 14 | Column | 0 | 6 | 5 | None | 0 | None |
| 15 | IsNull | 5 | 23 | 0 | None | 0 | None |
| 16 | SeekGE | 3 | 23 | 5 | 1 | 0 | None |
| 17 | IdxGT | 3 | 23 | 5 | 1 | 0 | None |
| 18 | Column | 0 | 1 | 7 | None | 0 | None |
| 19 | Column | 3 | 1 | 6 | None | 0 | None |
| 20 | MakeRecord | 6 | 2 | 9 | None | 0 | None |
| 21 | SorterInsert | 2 | 9 | 6 | 2 | 0 | None |
| 22 | Next | 3 | 17 | 0 | None | 0 | None |
| 23 | Next | 0 | 5 | 0 | None | 1 | None |
| 24 | OpenPseudo | 4 | 10 | 4 | None | 0 | None |
| 25 | SorterSort | 2 | 31 | 0 | None | 0 | None |
| 26 | SorterData | 2 | 10 | 4 | None | 0 | None |
| 27 | Column | 4 | 0 | 8 | None | 0 | None |
| 28 | Column | 4 | 1 | 7 | None | 0 | None |
| 29 | ResultRow | 7 | 2 | 0 | None | 0 | None |
| 30 | SorterNext | 2 | 26 | 0 | None | 0 | None |
| 31 | Halt | 0 | 0 | 0 | None | 0 | None |
| 32 | Transaction | 0 | 0 | 38 | 0 | 1 | None |
| 33 | Goto | 0 | 1 | 0 | None | 0 | None |
| 0 | Init | 0 | 23 | 0 | None | 0 | None |
| 1 | OpenRead | 0 | 5 | 0 | 7 | 0 | None |
| 2 | OpenRead | 1 | 3 | 0 | 5 | 0 | None |
| 3 | Rewind | 0 | 22 | 0 | None | 0 | None |
| 4 | Once | 0 | 13 | 0 | None | 0 | None |
| 5 | OpenAutoindex | 2 | 3 | 0 | k(3,B,,) | 0 | None |
| 6 | Rewind | 1 | 13 | 0 | None | 0 | None |
| 7 | Column | 1 | 2 | 2 | None | 0 | None |
| 8 | Column | 1 | 4 | 3 | None | 0 | None |
| 9 | Rowid | 1 | 4 | 0 | None | 0 | None |
| 10 | MakeRecord | 2 | 3 | 1 | None | 0 | None |
| 11 | IdxInsert | 2 | 1 | 0 | None | 16 | None |
| 12 | Next | 1 | 7 | 0 | None | 3 | None |
| 13 | Column | 0 | 6 | 5 | None | 0 | None |
| 14 | IsNull | 5 | 21 | 0 | None | 0 | None |
| 15 | SeekGE | 2 | 21 | 5 | 1 | 0 | None |
| 16 | IdxGT | 2 | 21 | 5 | 1 | 0 | None |
| 17 | Column | 0 | 1 | 6 | None | 0 | None |
| 18 | Column | 2 | 1 | 7 | None | 0 | None |
| 19 | ResultRow | 6 | 2 | 0 | None | 0 | None |
| 20 | Next | 2 | 16 | 0 | None | 0 | None |
| 21 | Next | 0 | 4 | 0 | None | 1 | None |
| 22 | Halt | 0 | 0 | 0 | None | 0 | None |
| 23 | Transaction | 0 | 0 | 38 | 0 | 1 | None |
| 24 | Goto | 0 | 1 | 0 | None | 0 | None |
EXPLAIN SELECT Title, Journal_Title
FROM articles
JOIN journals
ON (articles.ISSNs = journals.ISSNs);Using EXPLAIN to reveal the query plan and compare equivalent queries
SELECT Title, Authors, ISSNs, Year
FROM Articles
WHERE ISSNs = '2056-9890' AND Month=2 AND YEAR=2015 ;pd.read_sql_query("""
SELECT Title, Authors, ISSNs, Year
FROM Articles
WHERE ISSNs=? AND Month=? AND YEAR=? ;
""", db,
params=['2056-9890', 2, 2015]).head()Figure 3.4, 3.5 from Fundamentals of Data Engineering
Stream query
Batch query
Named after λ calculus, a functional mathematical system
Calculate cost for invoicing by 30 minute readings
Predict which periods are busiest
Latency: 60 minutes (or month!)
Accuracy: Gaps aligned by next readings
Immediate feedback, what is using power?
Power generators can react quickly to sudden spikes (e.g. electric car charging)
Latency: 1 minute
Accuracy: Gaps ignored e.g. WiFi is down
Real-time data processed directly, transforming on the fly
Data streams treated like immutable logs
New and historical data handled uniformly (replay)
Simplifies processing, avoiding duality of batch/stream (ensure business logic is consistent)
Low latency, but full reprocessing can be expensive
Examples: Kafka, Flink, Storm
Greek letter κ supersedes λ
Challenge: backfilling of streaming workloads using a unified codebase.
Goal: Replay Uber rider sessions for accurate monthly business analysis
Example: Rider waits to rate a driver until their next Uber app session
Solution: Use Kappa architecture and process in Windows to allow late-arriving and out-of-order events
Benefit: Switching between streaming and batch job is just a switch of data source (live vs replay)
Text
CREATE EXTERNAL TABLE raw_stream_events (
event_id STRING,
user_id STRING,
event_type STRING,
event_timestamp TIMESTAMP
)
ROW FORMAT SERDE 'org.apache.hive.hcatalog.data.JsonSerDe'
LOCATION '/hdfs/streaming/events';Ingest streaming data from Kafka into Hive
CREATE TABLE processed_events AS
SELECT
e.event_id,
e.user_id,
e.event_type,
e.event_timestamp,
u.region
FROM raw_stream_events e
JOIN user_dim u ON e.user_id = u.user_id
WHERE e.event_timestamp IS NOT NULL;Process streaming events (~ materialised view)
| event_id | user_id | event_type | event_timestamp | region |
|---|---|---|---|---|
| 3133 | alice81 | purchase | 17:52 | Europe |
| … | not here yet! | |||
{ "event_id": 3133, "user_id": "alice81", "event_type": "purchase", … }| event_id | user_id | event_data |
|---|---|---|
| 3133 | alice81 | { … } |
| … | not here yet! | |
spark.read.json("s3n://...")
.registerTempTable("json")
results = spark.sql(
"""SELECT *
FROM people
JOIN json ...""")results = spark.sql(
"SELECT * FROM people")
names = results.map(lambda p: p.name)df.select("device").where("signal > 10")
Any Spark data source can be queried
SQL can be combined with data frame operations
DataFrames have n SQL-like methods