02

Data models and query languages

Every layer of a system represents data in terms of the layer below: objects become JSON or tables, which become bytes. The data model you pick decides what is natural to express and what you end up fighting. This chapter compares the relational model, the document model and graph models on one running example, a professional profile. It then looks at how you ask questions of each: imperative code, declarative SQL, MapReduce, Cypher, SPARQL and Datalog.
The book's LinkedIn profile, as normalized tables and as a single JSON document. Hover to find the same fact in both. Below, see what each layout costs on disk.
document view
hover any row, field or node to find it in the other model

Relational: one table per kind of thing, joined by user_id

users
user_idfirst_namelast_nameregion_idindustry_id
251BillGatesus:91131
positions
iduser_idjob_titleorganization
458251Co-chairBill & Melinda Gates Foundation
457251Co-founder, ChairmanMicrosoft
education
iduser_idschool_namestartend
807251Harvard University19731975
806251Lakeside School, SeattleNULLNULL
contact_info
iduser_idtypeurl
155251bloghttp://thegatesnotes.com
156251twitterhttp://twitter.com/BillGates

Document: the whole profile is one self-contained JSON value

{
  "user_id": 251,
  "first_name": "Bill", "last_name": "Gates",
  "region_id": "us:91", "industry_id": 131,
  "positions": [
    {"job_title": "Co-chair", "organization": "Bill & Melinda Gates Foundation"},
    {"job_title": "Co-founder, Chairman", "organization": "Microsoft"},
  ],
  "education": [
    {"school_name": "Harvard University", "start": 1973, "end": 1975},
    {"school_name": "Lakeside School, Seattle", "start": null, "end": null},
  ],
  "contact_info": {
    "blog": "http://thegatesnotes.com"
    "twitter": "http://twitter.com/BillGates"
  }
}

A profile is a tree of one-to-many relationships, and JSON writes the tree down directly. Relational tables need a separate table per branch plus joins (or several queries) to put it back together, which is the "impedance mismatch" with application objects.

Data locality

Blocks on disk touched by this operation

relational: one B-tree file per tableuserspositionseducationcontact_inforegionsindustrdocument store: Bill's profile is one contiguous value (other users' docs around it)users/251 (825 bytes)region, industry

Storing the profile as one value means a single read loads everything, while the relational layout needs an index lookup per table. Locality only helps when you need most of the document: reading one field still loads the whole thing, and an update typically rewrites all of it (red). Try: 40 positions, then "update photo_id".

relational: random reads
6
740 B read
document: random reads
3
905 B read
relational: bytes written
0
document: bytes written
0

Many-to-one and many-to-many: IDs vs copies

normalizationmany-to-onemany-to-many
Should a profile store the text “Greater Seattle Area”, or an ID that points to it? Rename the region and count the writes. Then ask which profiles mention one organization.

each profile document carries its own copy of the text

Bill
region: Greater Seattle Area
org: Microsoft
Melinda
region: Greater Seattle Area
org: Gates Foundation
Satya
region: Greater Seattle Area
org: Microsoft
Jeff
region: Greater Seattle Area
org: Amazon
Grace
region: Greater Boston Area
org: Microsoft Corp.
Tim
region: Greater Boston Area
org: Amazon
Ada
region: London
org: Microsoft
Alan
region: London
org: Amazon
Mary
region: Greater Boston Area
org: Gates Foundation
Joan
region: London
org: Gates Foundation
regions
us:91 Greater Seattle Area
us:7 Greater Boston Area
uk:1 London
organizations
o1 Microsoft
o2 Gates Foundation
o3 Amazon
(not used: names live in the docs)

With IDs the human-readable name is stored once, so a rename is one write and nothing can disagree. The cost is a join (or an extra lookup in application code) on every read. Try: denormalized + crash after 2 writes, then rename.

writes for the rename
0 / 4
inconsistent copies
0
lookups to render a profile
1
the price of normalizing
docs examined by query
–
no joins: scan in app code

Schema-on-read vs schema-on-write

schema-on-readschema-migration
Change the format from a single name to first_name and last_name. A relational database migrates every existing row. A document database keeps old documents as they are and leaves the application to cope with both formats.

Change: split name into first_name + last_name

ALTER TABLE users ADD COLUMN first_name text;
ALTER TABLE users ADD COLUMN last_name  text;
UPDATE users SET
  first_name = split_part(name, ' ', 1),
  last_name  = substr(name, strpos(name, ' ') + 1);

users table

{name: "Ada Lovelace"}app reads first_name = Ada
{name: "Alan Turing"}app reads first_name = Alan
{name: "Grace Hopper"}app reads first_name = Grace
{name: "Edsger Dijkstra"}app reads first_name = Edsger
{name: "Barbara Liskov"}app reads first_name = Barbara
{name: "Donald Knuth"}app reads first_name = Donald

Amber = old format, green = new. Try: schema-on-read, deploy, write a user, then untick "handles old documents". In schema-on-write mode, write a user during the migration.

rows rewritten
0
records in old format
6
writes blocked
0
migration at that size
200 s
copying ~50k rows/s

Declarative vs imperative queries

declarative-queryimperative-query
Find the sharks. The loop commits to an algorithm. The SQL query only says what result it wants, so the database can scan, use an index or split the work across cores while the query text stays the same.

Imperative: you say how

function getSharks() {
var sharks = [];
for (var i = 0; i < animals.length; i++) {
if (animals[i].family === "Sharks") {
sharks.push(animals[i]);
}
}
return sharks;
}
the code fixes the order: one element at a time, in array order

The SQL text never changes; only the plan does. The loop can't be parallelized or use an index without rewriting it. Try: run each strategy and compare "rows examined" and the order of results.

animals

LionFeli
Great whiteShar
TigerFeli
OrcaDolp
HammerheadShar
LynxFeli
BottlenoseDolp
MakoShar
CheetahFeli
SpinnerDolp
Tiger sharkShar
OcelotFeli
DuskyDolp
Whale sharkShar
PumaFeli
Risso'sDolp
result: []
rows examined
0
steps (wall clock)
0
sharks found
0
The same idea on the web

styles.css

li.selected > p {
  background-color: blue;
}

Click a heading to move class="selected"

  • Sharksclass="selected"

    Great white, Mako

  • Whales

    Humpback, Blue

  • Dolphins

    Orca, Spinner

  • Rays

    Manta, Stingray

Try: in script mode, run the script, then select another item. The old blue background stays, because the script ran once and knows nothing about later changes. CSS keeps re-applying the rule, and the browser is free to make that fast.

MapReduce querying

mapreduce-querying
A marine biologist's monthly shark report as a MongoDB MapReduce job: filter, map each document to (month, count), group by key, reduce by summing. Step through the phases, then compare with SQL.
1/14 · start: 10 observation documents, spread over 2 shards

1 · input documents (filtered, then mapped)

shard A
1995-12-25Sharksn=3
1995-12-12Sharksn=4
1995-11-03Dolphinsn=5
1995-11-20Sharksn=2
1996-01-07Sharksn=1
shard B
1995-12-02Sharksn=6
1995-11-15Whalesn=2
1996-01-20Sharksn=5
1995-11-28Sharksn=1
1996-01-11Dolphinsn=4

2 · emitted (key, value) pairs

nothing emitted yet

3 · reduced output

empty

the query

db.observations.mapReduce(
  function map() {
    var year  = this.observationTimestamp.getFullYear();
    var month = this.observationTimestamp.getMonth() + 1;
    emit(year + "-" + month, this.numAnimals);
  },
  function reduce(key, values) {
    return Array.sum(values);
  },
  {
    query: { family: "Sharks" },
    out: "monthlySharkReport"
  }
);

map and reduce must be pure functions: no side effects, no extra queries. That lets the database run them anywhere, in any order, and rerun them after a failure. Try: step through the job, then switch to SQL or the aggregation pipeline, which state the same thing declaratively.

documents scanned
0
pairs emitted
0
output rows
0

Property graphs and Cypher

property-graphcypher
Lucy was born in Idaho and lives in London; Alain was born in Beaune and lives in London. Find everyone who emigrated from the United States to Europe. Watch the query walk BORN_IN, LIVES_IN and a variable number of WITHIN edges for each person.
born in
lives in

Property graph: vertices and labelled edges, each with properties

BORN_INLIVES_INBORN_INLIVES_INMARRIEDBORN_INLIVES_INBORN_INLIVES_INNorth AmericaEuropeUnited StatesCanadaUnited KingdomFranceGermanyIdahoMassachusettsEnglandBourgogneBostonTorontoLondonBeauneBerlinKenLucyMayaAlainunlabelled grey arrows are WITHIN

The location hierarchy has different depths: London is in England in the UK, Beaune is in Bourgogne in France. The variable-length pattern WITHIN*0.. handles that, which is awkward with SQL joins. Try: step through Lucy, then change "lives in" to North America and see Ken match instead.

Cypher

MATCH
  (person) -[:BORN_IN]->  () -[:WITHIN*0..]-> (:Location {name:'United States'}),
  (person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (:Location {name:'Europe'})
RETURN person.name
step 1/35: start at Ken
edges traversed
0
results
–

Declarative: the database may just as well start from the two location vertices and walk WITHIN edges backwards. The query says what, not how.

In SQL the same query needs a recursive common table expression of about 30 lines, because the number of joins to reach "Europe" is not known in advance. Different data models suit different uses: the graph query is natural here and awkward in SQL.

Triple-stores, SPARQL and Datalog

triple-storesparqldatalog
The same graph as (subject, predicate, object) triples, queried with SPARQL, and finally computed bottom-up with Datalog rules. Apply the recursive rule round by round until no new facts appear.

Datalog: build the answer from rules

within_recursive(Location, Name) :- name(Location, Name).          /* rule 1 */

within_recursive(Location, Name) :- within(Location, Via),               /* rule 2 */
                                    within_recursive(Via, Name).

migrated(Name, BornIn, LivingIn) :- name(Person, Name),                  /* rule 3 */
                                    born_in(Person, BornLoc),
                                    within_recursive(BornLoc, BornIn),
                                    lives_in(Person, LivingLoc),
                                    within_recursive(LivingLoc, LivingIn).

?- migrated(Who, 'United States', 'Europe').

Facts are predicate(subject, object). Rules derive new facts from existing ones, and rules can refer to themselves, so you can build complex queries a piece at a time.

Derived facts: within_recursive(Location, Name)

0 facts
North America
Europe
United States
Canada
United Kingdom
France
Germany
Idaho
Massachusetts
England
Bourgogne
Boston
Toronto
London
Beaune
Berlin
?- migrated(Who, 'United States', 'Europe'). not all facts derived yet

Each round applies rule 2 to the facts derived so far (new ones are highlighted), one more WITHIN hop each time, until nothing new appears. Try: step round by round and watch "Europe" travel down from countries to cities.

rounds applied
0 / 4
facts derived
0
new this round
0
answer
…
  • takeawayDocuments fit trees of one-to-many data that you load all at once, and they give you locality and schema flexibility. They struggle once many-to-one and many-to-many links appear, because then you need joins.
  • takeawayNormalize with IDs when facts are shared: one write per change, no drift, at the cost of joins. Schema-on-read doesn't remove the schema, it moves it into application code.
  • takeawayDeclarative languages (SQL, CSS, Cypher, SPARQL, Datalog) say what you want and leave how to the engine. That is what lets databases add indexes, parallelism and recursion without your queries changing.

Key concepts · 18

book chapter 2
  1. Relational model

    Data organized as relations (tables) of unordered tuples (rows), queried with SQL.

    It hides how the data is stored behind a clean interface. The query optimizer, not the developer, decides the access path, so new queries don't need new code paths.

    PostgreSQLMySQLSQLite
    ↑ see it in the visualization
  2. Document model

    Self-contained documents (usually JSON) that nest their one-to-many children inside.

    A good fit when data is a tree that gets loaded all at once, such as a résumé. Weak support for joins makes highly interconnected data awkward.

    MongoDBCouchDBRethinkDBEspresso
    ↑ see it in the visualization
  3. Object-relational impedance mismatch

    The awkward translation layer between objects in application code and tables of rows.

    ORMs reduce the boilerplate but don't hide the difference completely. A JSON document is closer to the objects many applications use.

    ActiveRecordHibernate
    ↑ see it in the visualization
  4. One-to-many relationships

    One record owning many children, like a user's positions, schools and contact links.

    These form a tree. Documents store the tree directly; relational schemas spread it over several tables joined by a foreign key.

    ↑ see it in the visualization
  5. Storage locality

    A document is stored as one contiguous value, so loading the whole thing is a single read.

    It only pays off when you need most of the document. Reading one field still loads all of it, and updates usually rewrite all of it, so keep documents small.

    Spanner interleaved tablesBigtable column families
    ↑ see it in the visualization
  6. Normalization

    Storing each human-meaningful fact once and referring to it by ID everywhere else.

    Avoids duplicated text that can drift out of sync and makes renames a single write. The price is a join (or an extra lookup) when reading.

    ↑ see it in the visualization
  7. Many-to-one relationships

    Many records pointing at one shared entity, such as many people living in one region.

    They need joins. Relational databases do joins well; with documents the application often has to emulate them with extra queries.

    ↑ see it in the visualization
  8. Many-to-many relationships

    Links in both directions, such as people and the organizations they worked for.

    As applications grow, data tends to become more interconnected. That pushes document models toward references and joins, or toward graphs.

    ↑ see it in the visualization
  9. Schema-on-read vs schema-on-write

    Schema-on-read: the structure is implicit and interpreted when data is read. Schema-on-write: the database enforces an explicit schema.

    It's like dynamic vs static typing. Schema-on-read lets old and new formats coexist and handles heterogeneous data; schema-on-write needs migrations but guarantees the shape.

    ↑ see it in the visualization
  10. Schema migration

    Changing a schema, e.g. ALTER TABLE followed by an UPDATE that rewrites existing rows.

    Adding a column is usually fast, but rewriting every row of a large table can take a long time and, in some databases, locks the table.

    MySQL ALTER TABLE copies the table
    ↑ see it in the visualization
  11. Declarative query languages

    You state the pattern of data you want; the database decides how to find it (SQL, CSS, Cypher, SPARQL).

    Hides implementation details, so the database can add indexes, reorder work or run in parallel without your query changing.

    ↑ see it in the visualization
  12. Imperative querying

    Code that spells out every step and its order, such as a loop over records.

    Hard to parallelize and hard for the system to optimize, because the code fixes the algorithm.

    ↑ see it in the visualization
  13. MapReduce querying

    A query written as a pure map function that emits key/value pairs and a reduce function that combines the values for each key.

    Somewhere between declarative and imperative. Pure functions can run anywhere in any order, but writing two coordinated functions is harder than one query, which is why MongoDB added the declarative aggregation pipeline.

    MongoDBCouchDBHadoop
    ↑ see it in the visualization
  14. Property graphs

    Vertices and edges, each with a label and key-value properties; any vertex can connect to any other.

    Very flexible for highly connected, evolving data. New kinds of vertices and edges need no schema change, and traversals follow edges in either direction.

    Neo4jTitanInfiniteGraph
    ↑ see it in the visualization
  15. Cypher

    Neo4j's declarative graph query language, built around arrow patterns like (a)-[:WITHIN*0..]->(b).

    Variable-length paths make walking a hierarchy of unknown depth a single line. The SQL equivalent needs a recursive common table expression.

    Neo4j
    ↑ see it in the visualization
  16. Triple-stores

    Everything stored as (subject, predicate, object) statements, where the object is either a value or another vertex.

    The same graph idea in a uniform format that merges easily. It's the basis of RDF and the semantic web.

    DatomicAllegroGraphRDF / Turtle
    ↑ see it in the visualization
  17. SPARQL

    The query language for RDF triple-stores, with property paths like :within*.

    Even shorter than Cypher for the same query. Cypher's pattern syntax borrows from it.

    ↑ see it in the visualization
  18. Datalog

    A logic language of facts and rules; rules derive new facts and can be recursive.

    Older than SQL and the foundation of later graph query languages. Rules compose like functions, so you build up complex queries a piece at a time.

    DatomicCascalog
    ↑ see it in the visualization