Chapter 02 - Data Model & Query Languages
Talk about what's SQL, NoSQL. How SQL has mismatch and we have to use ORM.
Many to one relationship
In SQL we can do something like
User table
| user_id | country_id | name |
|---|---|---|
| 1234 | 3 | Austin |
Country table
| country_id | name |
|---|---|
| 3 | Australia |
Why dont we just use country_name directly in user table.
- it's for ease of updating and searching. Consistent between all users
- This technique is called normalising in db since it remove duplication (i.e every user have a
country_nameinstead ofcountry_id)
In NoSQL database, we're emulate join in the Application layer code
Document DB
DocumentDB advantage: closer to application code
- Use if your application data has a tree like (mostly one-to-many) datastructure
- If it's mostly one-to-many relationship, bad support in
joinquery might not be a problem
Relational Database: better support for join, many-to-one and many-to-many - Use if your applicaiton needs a many to many relationship that need join.
- For highly interconnected data, Relational or Graph datamodel is preferred.
[!NOTE]
We specificly only discuss about document db in this section, since now NoSQL has column db, the following only applicable to document db
Schema in DocumentDB
The schema in DocumentDB is called schema on read — the applicaiton itself enforces the schema. Whereas in Relational database is the schema on write — the database enforces the schema.
For example, if your application now instead of storing the full name, we need to store the first name and last name separately:
DocumentDB: we fix in the application code to correct it when reading
if (user && user.name && !user.first_name) {
// legacy behavior:
user.first_name = user.name.split(" ")[0];
}
SQL: you run a migration and fix it once. However it might requires some down time
ALTER TABLE users ADD COLUMN first_name text;
UPDATE users SET first_name = split_part(name, ' ', 1);
Data locality
Data Locality means storing related data close together in the same record/document so that it can fetch everything in one read.
- This is very good in document database where everything is closed together.
- In relational database, we need to do multiple joins since the data is spread out, which becomes not good
However, this is only good when we want to read the whole object at once. It's inefficient if you often update or read only small part of a large document:
- Reading or updating the small part of the document still requires fetching/overwriting to the whole document.
- Because of this, we often try to keep the size of the document small
Nowadays, there are options to allow locality that's not limited to just document db i.e
- Google Spanner: the schema allow the table row should be nested within parent table
- Column family (Cassandra, HBase): also allow locality
MapReduce querying
The map reduce concept is popularised by Google, the idea is you map your dataset first and then reduce to what you want.
Imagine you're biologist and you want to add to your record everytime you see an animal in the ocean, SQL will look something like
SELECT date_trunc('month', observation_timestamp) AS observation_month, sum(num_animals) AS total_animals
FROM observations
WHERE family = 'Sharks'
GROUP BY observation_month;
[!note]
Thedate_trunc('month', timestamp)here will return a timestamp representing the start of the month
To write this in map reduce, we can do
db.observations.mapReduce(
function map() {
var year = this.observationTimestamp.getFullYear();
var month = this.observationTimestamp.getFullMonth() + 1;
emit(year + '-' + month, this.numAnimals);
},
function reduce(key, values) {
return Array.sum(values);
},
{
query: {family: "Sharks"},
out: "monthlySharkReport"
}
);
The map function will emit the year-month, numAnimals and then consumed by the reduce function. Output will look like
{
observationTimeStamp: Date.parse("Mon, 25 Dec 1995..."),
family: "Shark",
species: "Shark X",
numAnimals: 3
},
{
observationTimeStamp: Date.parse("Tue, 12k Dec 1995..."),
family: "Shark",
species: "Shark Y",
numAnimals: 4
}
However this is Imperative language, which might not be optimised. As a result, mongodb write aggregate function which is a Declarative language version of this
db.obersvations.aggregate({
{ $match: { family: "Sharks" }},
{ $group: {
_id: {
year: { $year: "$observationTimestamp"},
month: { $month: "$observationTimstamp"},
},
totalAnimals: { $sum: "$numAnimals" }
}
})
GraphLike data Model
There are 2 types:
- Property graph: Neo4J, Titan, Infinite Graph
- Tripe-stores: Datomic AllegroGraph
Languages
- Declarative language queries for graph: Cypher, SPARQL and Datalog
- Imperative language: Gremlin, Pregel
Property graph
graph LR
%% ── Nodes (vertices): Label + properties ──
alice["Person<br/><b>Alice</b><br/>age: 32"]
bob["Person<br/><b>Bob</b><br/>age: 29"]
acme["Company<br/><b>Acme Inc</b><br/>founded: 1999"]
berlin["City<br/><b>Berlin</b><br/>country: DE"]
%% ── Relationships (edges): TYPE + properties ──
alice -->|"KNOWS<br/>since: 2015"| bob
alice -->|"WORKS_AT<br/>role: Engineer"| acme
bob -->|"WORKS_AT<br/>role: Designer"| acme
alice -->|"LIVES_IN"| berlin
acme -->|"LOCATED_IN"| berlin
%% ── Style nodes by label ──
classDef person fill:#e3f2fd,stroke:#1976d2,color:#0d47a1;
classDef company fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c;
classDef city fill:#e8f5e9,stroke:#388e3c,color:#1b5e20;
class alice,bob person;
class acme company;
class berlin city;
Vertex(node) consists of
- Unique identifier
- Set of outgoing edges
- Set of incoming edges
- Collection of properties (key/value storage)
Edge consists of
- Unique identifier
- Tail vertex (where the edge starts)
- Head vertex (where the edge ends)
- Label description
- Collection of properties (key/value storage)
We can technically represent like this in SQL
CREATE TABLE vertices (
vertex_id integer PRIMARY_KEY,
properties json
)
CREATE TABLE edges (
edge_id integer PRIMARY_KEY,
tail_vertex integer REFERENCES vertices (vetex_id),
head_vertex integer REFERENCES vertices (vetex_id),
label text,
properties json
)
CREATE INDEX edges_tails ON edges (tail_vertex)
CREATE INDEX edges_heads ON edges (head_vertex)
- Any vertex can have an edge connecting with any other vertex, no shcema that restrict it
- Given any vertex, you can find both incoming and outgoing edges and traverse the graph
- By using different labels for different kinds of relationship, we can combine different kind of information for a single graph while maintain a clean data model
Cypher query language
CREATE CONSTRAINT person_name IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE;
CREATE CONSTRAINT company_name IF NOT EXISTS FOR (c:Company) REQUIRE c.name IS UNIQUE;
CREATE INDEX person_age IF NOT EXISTS FOR (p:Person) ON (p.age);
CREATE
(alice:Person {name: 'Alice', age: 32}),
(bob:Person {name: 'Bob', age: 29}),
(acme:Company {name: 'Acme Inc', founded: 1999}),
(berlin:City {name: 'Berlin', country: 'DE'}),
(alice)-[:KNOWS {since: 2015}]->(bob),
(alice)-[:WORKS_AT {role: 'Engineer'}]->(acme),
(bob) -[:WORKS_AT {role: 'Designer'}]->(acme),
(alice)-[:LIVES_IN]->(berlin),
(acme) -[:LOCATED_IN]->(berlin);
Query example
// Who works at Acme, and in what role?
MATCH (p:Person)-[w:WORKS_AT]->(c:Company {name: 'Acme Inc'})
RETURN p.name AS person, w.role AS role;
// Alice's contacts (edge property filter)
MATCH (:Person {name: 'Alice'})-[k:KNOWS]->(friend)
WHERE k.since <= 2015
RETURN friend.name, friend.age;
// Multi-hop: people who live in the same city as their employer
MATCH (p:Person)-[:WORKS_AT]->(c:Company)-[:LOCATED_IN]->(city:City),
(p)-[:LIVES_IN]->(city)
RETURN p.name AS person, c.name AS company, city.name AS city;
[!NOTE]
Graph database can technically present as a SQL query however it's very complicated and need nested recursion
Triple-stores
In triple store we store the data in terms of (subject, predicate, object).
subjectis like a vertexpredicateis similar to edgeobjectcould be another vertex or a value
For example:
(Jim, likes, bananas)
Summary
- Document databases: use for self-cotnained document
- Graph database: many-to-many heavy relationship
- Graph database & document database dont enforce the data, however most application will enforce some schema. It's either we want
- Enforce on write (relational database)
- Enforce on read (Document database, Graph database)