Data Models and Query Languages
Designing Data-Intensive Applications · Chapter 2 · Recall lab
Chapter 1 gave you three words to argue with. Chapter 2 gives you one question that decides almost everything downstream: what kinds of relationships does your data actually have? Answer “trees” and the document model fits. Answer “everything relates to everything” and you want a graph. Answer “some of both” and you get the relational model, which is why it has survived thirty years of challengers. Every widget below is the same résumé, seen through a different lens.
Layers of models, and the mismatch between two of them
Applications are built as layers of models, each hiding the one below: real-world objects → application objects and data structures → JSON/XML/tables/graph for storage → bytes on disk → electrical currents. Each layer’s job is to give the layer above a clean, usable abstraction. The one this chapter examines is the storage layer, and the fact that the layer above it usually speaks a different language.
The relational model came from Edgar Codd in 1970: data organised into relations (SQL tables), each an unordered collection of tuples (rows). It was a theoretical proposal that many doubted could be implemented efficiently, and by the mid-1980s it had won. Its roots are 1960s–70s mainframe business data processing, transaction processing and batch processing, and the goal that distinguished it from its contemporaries was to hide the internal representation of the data behind a cleaner interface. Competitors came and went: network and hierarchical models in the 70s, object databases around 1990, XML databases in the early 2000s. Each generated hype; none lasted.
The Birth of NoSQL, four driving forces
The name is unfortunate: it doesn’t refer to any technology. It began as a catchy hashtag for a 2009 meetup on open source, distributed, nonrelational databases, and was retrofitted to mean Not Only SQL.
- A need for greater scalability than relational databases easily achieve, very large datasets, very high write throughput
- A widespread preference for free and open source over commercial database products
- Specialized query operations the relational model doesn’t support well
- Frustration with the restrictiveness of relational schemas, and a wish for something more dynamic and expressive
The likely outcome isn’t a winner: relational databases used alongside a variety of nonrelational stores. That idea has a name, polyglot persistence.
The impedance mismatch
Most application code is written in object-oriented languages, so storing data in relational tables requires an awkward translation layer between application objects and tables, rows and columns. The disconnect is called an impedance mismatch, a term borrowed from electronics, where power transfer across a connection is maximized when the output and input impedances match, and a mismatch causes signal reflections and other trouble. ORMs like ActiveRecord and Hibernate cut the boilerplate, but they can’t erase the difference between the models.
The signature exhibit, one résumé, three models
A LinkedIn profile: first_name and last_name appear exactly once per user, so they’re columns.
But most people have had several positions, varying amounts of education, and any number of
pieces of contact_info. Those are one-to-many relationships, which is to say, the data is a
tree. Watch what each model does with that tree.
Normalized across four tables, joined by user_id
| users | |||
|---|---|---|---|
| user_id | first_name | last_name | region_id |
| 251 | Bill | Gates | us:91 |
| positions | ||
|---|---|---|
| id | user_id | job_title / organization |
| 1 | 251 | Co-chair · Bill & Melinda Gates Foundation |
| 2 | 251 | Co-founder, Chairman · Microsoft |
| education | |||
|---|---|---|---|
| id | user_id | school_name | start–end |
| 1 | 251 | Harvard University | 1973–1975 |
| 2 | 251 | Lakeside School, Seattle | null |
| contact_info | ||
|---|---|---|
| id | user_id | type / url |
| 1 | 251 | blog · thegatesnotes.com |
| 2 | 251 | twitter · twitter.com/BillGates |
Shredding, splitting a document-like structure across multiple tables, can lead to cumbersome schemas and unnecessarily complicated application code. Poor locality: multiple index lookups, more disk seeks.
One self-contained JSON document
{
"user_id": 251,
"first_name": "Bill",
"last_name": "Gates",
"summary": "Co-chair of the Bill & Melinda Gates... Active blogger.",
"region_id": "us:91",
"industry_id": 131,
"photo_url": "/p/7/000/253/05b/308dd6e.jpg",
"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"
}
}You cannot refer directly to a nested item, you have to say something like “the second item in the list of positions for user 251”, much like an access path in the hierarchical model. Not usually a problem unless documents nest deeply. And region_id is still an ID, so the many-to-one relationship still needs resolving somehow.
Everything is a vertex; everything else is a labelled edge
-- vertices
(251:Person {first_name:"Bill", last_name:"Gates"})
(gf:Org {name:"Bill & Melinda Gates Foundation"})
(ms:Org {name:"Microsoft"})
(hv:School {name:"Harvard University"})
(sea:Location {name:"Greater Seattle Area"})
-- edges carry properties too
(251) -[:WORKED_AT {job_title:"Co-chair"}]-> (gf)
(251) -[:WORKED_AT {job_title:"Co-founder, Chairman"}]-> (ms)
(251) -[:STUDIED_AT {start:1973, end:1975}]-> (hv)
(251) -[:LIVES_IN]-> (sea)Overkill when your data really is a tree with rare relationships. And the chapter’s ordering is deliberate: for interconnected data the document model is awkward, the relational model is acceptable, graphs are most natural.
Three ways SQL itself can hold the one-to-many part, incidentally: separate tables with foreign keys (the classic normalized form, pre-SQL:1999); structured datatypes / XML / JSON columns that hold multi-valued data inside a single row, with querying and indexing inside them, supported to varying degrees by Oracle, DB2, MS SQL Server, PostgreSQL, MySQL; or a plain text column holding an encoded document, which the application must interpret and the database cannot look inside.
Why region_id and not "Greater Seattle Area"
In the JSON profile, region and industry are given as IDs, not as the strings Greater Seattle Area and Philanthropy. If the UI has free-text fields, store strings. But if users pick from a drop-down or autocompleter, a standardized list buys you five things.
1 row to rewrite if the city is renamed rows to rewrite if the city is renamed
- Consistent style and spelling across profiles
- No ambiguity, several cities share a name
- Ease of updating, the name lives in one place, so a political renaming is one write
- Localization, the standardized list can be translated, and shown in the viewer’s language
- Better search, the region list can encode that Seattle is in Washington, so a search for philanthropists in Washington state matches. The string “Greater Seattle Area” carries none of that
The whole question is duplication. An ID has no meaning to humans, so it never needs to change; anything meaningful to humans may need to change, and if it’s duplicated every copy needs updating, write overhead plus the risk of inconsistency. Removing that duplication is the key idea behind normalization. Rule of thumb: if you’re duplicating values that could live in one place, the schema isn’t normalized. (The literature’s several normal forms are of little practical interest here.)
And here is the catch that drives the rest of the chapter: normalizing requires many-to-one relationships, many people live in one region, many work in one industry, and many-to-one doesn’t fit the document model nicely. In relational databases, referring to rows in other tables by ID is normal because joins are easy. In document databases, joins aren’t needed for one-to-many trees, and support for joins is often weak. If the database can’t join, you emulate it in application code with multiple queries, moving work out of the database and into your app.
The escalator, data gets more interconnected as you add features
Even if version one fits a join-free document model, features accumulate. Add each one and watch the relationship types change.
Base résumé · one-to-many, Positions, education and contact info hang off one user. This is a tree, the document model’s home turf, one query to load it all.
Standardized regions & industries · many-to-one, Many people live in one region; many work in one industry. Many-to-one, which is what normalization requires and what the document model handles least gracefully. The lists are small and slow-changing enough that an app could just hold them in memory, but note that the work of joining has moved from the database into your code.
Organizations & schools as entities · many-to-many, Organization and school_name were just strings. Make them references and each company or university gets its own page with a logo and news feed, and every résumé mentioning it can link through. Now it’s many-to-many: many people worked at Microsoft, and Microsoft appears on many résumés.
Recommendations between users · many-to-many, One user writes a recommendation for another, shown on the recipient’s résumé with the author’s name and photo. If the author changes their photo, every recommendation they wrote must reflect it, so the recommendation must hold a reference to the author’s profile, not a copy of their details. Another many-to-many, and this one between records of the same type.
Verdict. Pure tree. Use a document. Poor join support may never bite you, an analytics store recording which events happened when may never need a many-to-many relationship at all.
Verdict. Still workable in a document store, but you’re already emulating joins. Joins are supported in RethinkDB, absent in MongoDB, and only available through predeclared views in CouchDB.
Verdict. The data inside each dotted region can still be one document, but references to organizations, schools and other users must stay references, and require joins when queried. You can cut the joins by denormalizing, but then your application code has to keep the denormalized copies consistent. Emulating joins with multiple requests also moves complexity into the app and is usually slower than a join run by specialised code inside the database. Here a document model means more complex application code and worse performance.
The general shape of the answer: for highly interconnected data, the document model is awkward, the relational model is acceptable, and graph models are the most natural. It’s not possible to say which model yields simpler application code in general, it depends on the kinds of relationships between your data items.
This argument is older than NoSQL by forty years
The most popular database for business data processing in the 1970s was IBM’s IMS, first released 1968, originally built for stock-keeping in the Apollo space program, and still maintained today on IBM mainframes. Its data model, the hierarchical model, represented all data as a tree of records nested within records. Which is to say: remarkably like JSON.
IMS worked well for one-to-many relationships, made many-to-many difficult, and didn’t support joins. Developers had to choose between duplicating data or manually resolving references from one record to another. Those 1960s and ’70s problems are very much the problems developers hit with document databases today. Two solutions were proposed, and the “great debate” between them ran through much of the 1970s.
Click a model to inspect it
Access paths, and the one insight that decided it
In CODASYL the links between records weren’t foreign keys but something closer to pointers, stored on disk, but pointer-like. The only way to reach a record was to follow a path from a root record along those chains of links: an access path. Simple cases were like walking a linked list. But with many-to-many relationships, several different paths lead to the same record, and the programmer had to hold all of them in their head. Queries were performed by moving a cursor through the database, iterating over record lists and following access paths; even committee members admitted this was like navigating an n-dimensional data space.
Manual access-path selection made the best use of extremely limited 1970s hardware, tape drives, whose seeks are brutally slow. The cost was code that was complicated and inflexible: if you didn’t have a path to the data you wanted, you were stuck. You could add one, but then you had to rewrite a lot of handwritten query code to use it. Changing an application’s data model was hard.
Same request, two worlds, pick a new query the product team wants
CODASYL / IMS
Relational
SELECT * FROM users WHERE region_id = 'us:91'. If it’s slow, declare an index; existing queries start using it without being rewritten.CODASYL / IMS
Relational
CODASYL / IMS
Relational
ALTER TABLE, then declare an index if needed. You can insert a new row into any table without worrying about foreign key relationships to and from other tables.CODASYL / IMS
Relational
What the relational model did instead was lay all the data in the open: a relation is a collection of tuples, and that’s it. No labyrinthine nesting, no access paths. Read any or all rows matching an arbitrary condition; read a row by designating some columns as a key; insert into any table without worrying about foreign keys. The query optimizer decides which parts of the query run in which order and which indexes to use, those choices are the access path, but they’re made automatically, so you rarely think about them. Want to query in a new way? Declare a new index; existing queries start using it without being rewritten.
The key insight. You only need to build a query optimizer once, and then every application using that database benefits. Without one, hand-coding the access path for a particular query is easier than writing a general-purpose optimizer, but the general-purpose solution wins in the long run. Optimizers are complicated beasts that have consumed many years of research; that’s the point.
So, are document databases repeating history?
Yes, in exactly one aspect
They reverted to the hierarchical model by storing nested records inside their parent record rather than in a separate table, positions, education and contact_info living inside the user document.
No, in the aspect that mattered
For many-to-one and many-to-many, relational and document databases are not fundamentally different: the related item is referenced by a unique identifier, a foreign key in one, a document reference in the other, and resolved at read time by a join or follow-up query. Document databases have not followed CODASYL.
Note the timing difference that hides in that sentence: joins on foreign keys happen at query time, whereas in CODASYL the join was effectively done at insert time.
Three arguments for documents, two for relations
Setting aside fault tolerance and concurrency, the data-model case runs like this. For the document model: schema flexibility, better performance from locality, and for some applications closeness to the application’s own data structures. The relational model counters with better support for joins and for many-to-one and many-to-many relationships.
Schema flexibility, the word “schemaless” is wrong
Most document databases don’t enforce any schema, so arbitrary keys and values can be added and readers get no guarantees about which fields exist. But the code that reads the data almost always assumes some structure, there is an implicit schema that the database simply doesn’t enforce. The accurate terms are schema-on-read (structure implicit, interpreted when data is read) versus schema-on-write (schema explicit, database ensures conformance on write). The analogy is dynamic runtime type checking versus static compile-time type checking, and, like that debate, there’s no general right answer.
Migration lab, you're splitting name into first_name and last_name
// Documents written before Dec 8, 2013 don't have first_name
if (user && user.name && !user.first_name) {
user.first_name = user.name.split(" ")[0];
}ALTER TABLE users ADD COLUMN first_name text; UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
The UPDATE is the real cost: slow on any database, because every row must be rewritten. If that’s unacceptable you can leave first_name as NULL and fill it in at read time, exactly what the document database does. The two approaches meet in the middle.
When schema-on-read genuinely wins
Not “when you’re in a hurry”, when the data is genuinely heterogeneous:
- There are many different types of objects, and it isn’t practical to give each type its own table
- The structure is determined by external systems you don’t control, which may change at any time
In those cases a schema hurts more than it helps. Where all records are expected to share a structure, a schema is a useful mechanism for documenting and enforcing it.
Data locality, a real advantage with real limits
A document is normally stored as a single continuous string, JSON, XML, or a binary variant like MongoDB’s BSON. If you often need the whole document (to render a page, say), that storage locality is a genuine performance win: the shredded relational version needs multiple index lookups, more disk seeks, more time.
The locality advantage only applies if you need large parts of the document at once. The database typically loads the entire document even when you touch a small part of it.
0 kBloaded but not used, per read
And on update, the whole document usually has to be rewritten, only changes that don’t alter the encoded size can easily be done in place. Hence the standing recommendation: keep documents fairly small, and avoid writes that grow them. These limits significantly narrow the set of situations where document databases are useful.
Grouping related data for locality isn’t a document-model idea, though. Spanner offers the same property in a relational model by letting a schema declare that a table’s rows be interleaved within a parent table; Oracle has multi-table index cluster tables; Bigtable’s column families (used in Cassandra and HBase) serve the same purpose.
Convergence
Relational moving toward document
XML support since the mid-2000s in most systems other than MySQL, including local modification, indexing and querying inside documents. JSON support in PostgreSQL 9.3+, MySQL 5.7+, DB2 10.5+.
Document moving toward relational
RethinkDB supports relational-like joins. Some MongoDB drivers resolve database references automatically, effectively a client-side join, likely slower than one done in the database because of the extra round-trips and less optimization.
The models complement each other, and a hybrid looks like a good route for databases to take. Worth knowing: Codd’s original 1970 description allowed something quite like JSON documents inside a relational schema. He called them nonsimple domains, a value in a row needn’t be a primitive, it could be a nested relation, giving arbitrarily nested trees as values. SQL added roughly that, thirty years later.
Declarative beats imperative, and here's the proof
The relational model arrived with a new way of querying: SQL is declarative, where IMS and CODASYL used imperative code. Find the sharks in a list of animals, three ways.
Imperative
function getSharks() {
var sharks = [];
for (var i = 0; i < animals.length; i++) {
if (animals[i].family === "Sharks") {
sharks.push(animals[i]);
}
}
return sharks;
} Relational algebra
sharks = σfamily = "Sharks"(animals) -- σ is the selection -- operator: keep only -- tuples matching the -- condition.
SQL
SELECT * FROM animals WHERE family = 'Sharks'; -- SQL followed the -- structure of the -- relational algebra -- fairly closely.
Imperative code tells the computer to perform certain operations in a certain order. A declarative language specifies only the pattern of the data you want, what conditions results must meet, how they should be transformed, and leaves the how to the optimizer.
Three reasons this matters, in increasing order of importance
- Concision. Usually shorter and easier to work with than an imperative API.
- It hides implementation details, so the database can introduce performance improvements without any query being rewritten. Concretely: if the engine wants to reclaim disk space by moving records around, the row order changes. SQL guarantees no ordering, so it doesn’t care. With imperative code the database can never be sure whether you depended on that order. SQL’s more limited functionality is what gives the database room to optimize.
- Parallel execution. CPUs now get faster by adding cores, not clock speed. Imperative code is hard to parallelize because it specifies instructions that must run in a particular order. A declarative language specifies only the pattern of the results, so the database is free to use a parallel implementation.
The browser proof, CSS versus DOM manipulation
A site about ocean animals. The user is on the sharks page, so that nav item carries
class="selected", and you want the title of the selected page to have a blue background.
Declaratively that’s li.selected > p { background-color: blue }, all <p> elements whose direct
parent is an <li> with class selected. Imperatively, in the core DOM API, it’s a nested loop.
Both boxes below start correct. Then move the selection.
Declarative · CSS
-
Sharks
- Great White Shark
- Tiger Shark
- Hammerhead Shark
-
Whales
- Blue Whale
- Humpback Whale
- Fin Whale
Imperative · DOM API
-
Sharks
- Great White Shark
- Tiger Shark
- Hammerhead Shark
-
Whales
- Blue Whale
- Humpback Whale
- Fin Whale
The imperative version, in full, and the XSL equivalent
var liElements = document.getElementsByTagName("li");
for (var i = 0; i < liElements.length; i++) {
if (liElements[i].className === "selected") {
var children = liElements[i].childNodes;
for (var j = 0; j < children.length; j++) {
var child = children[j];
if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "P") {
child.setAttribute("style", "background-color: blue");
}
}
}
} <!-- XSL: the XPath li[@class='selected']/p is equivalent
to the CSS selector li.selected > p -->
<xsl:template match="li[@class='selected']/p">
<fo:block background-color="blue">
<xsl:apply-templates/>
</fo:block>
</xsl:template> Two concrete defects in the imperative version, beyond length. First, if the selected class is
removed, the blue isn’t removed, even on re-running, so the item stays highlighted until a full
page reload; CSS detects that the rule no longer applies and drops the background immediately.
Second, to benefit from a newer, faster API such as getElementsByClassName or
document.evaluate you must rewrite the code, whereas browser vendors can speed up CSS and XPath
without breaking compatibility. Both defects have exact analogues in databases.
MapReduce, neither one thing nor the other
You’re a marine biologist logging an observation record every time you see animals. You want sharks
sighted per month. MapReduce is neither declarative nor fully imperative but in between: query
logic expressed as snippets of code that the framework calls repeatedly, built on map (collect) and
reduce (fold, inject) from functional programming. A limited form is supported by MongoDB and
CouchDB for read-only queries across many documents.
Two documents in the collection
{
observationTimestamp: Date.parse(
"Mon, 25 Dec 1995 12:34:56 GMT"),
family: "Sharks",
species: "Carcharodon carcharias",
numAnimals: 3
}
{
observationTimestamp: Date.parse(
"Tue, 12 Dec 1995 16:17:18 GMT"),
family: "Sharks",
species: "Carcharias taurus",
numAnimals: 4
} Execution trace
emit("1995-12", 3)
emit("1995-12", 4)
The key is a string of year and month, like "2013-12" or "2014-1"; the value is the number of animals in that observation.
The reduce function adds up the number of animals across all observations in that month.
PostgreSQL
SELECT
date_trunc('month',
observation_timestamp)
AS observation_month,
sum(num_animals)
AS total_animals
FROM observations
WHERE family = 'Sharks'
GROUP BY observation_month; 8 lines · declarative MongoDB mapReduce
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"
}
); 18 lines · two coordinated functions Aggregation pipeline
db.observations.aggregate([
{ $match: {
family: "Sharks" } },
{ $group: {
_id: {
year: { $year:
"$observationTimestamp" },
month: { $month:
"$observationTimestamp" }
},
totalAnimals: {
$sum: "$numAnimals" }
} }
]); 13 lines · declarative again Why map and reduce must be pure functions. They may only use the data passed to them as input, cannot perform additional database queries, and must have no side effects. That restriction is what lets the database run them anywhere, in any order, and rerun them on failure. Within it they’re still powerful: parse strings, call libraries, compute.
The usability problem is having to write two carefully coordinated functions where one query would do — and a declarative language gives the optimizer more room. Which is why MongoDB 2.2 added the aggregation pipeline: similar in expressiveness to a subset of SQL, differing mainly in wearing JSON syntax rather than SQL’s English-sentence style. The moral, in the chapter’s words: a NoSQL system may find itself accidentally reinventing SQL, in disguise.
Two corrections the chapter makes in passing: MapReduce is a fairly low-level model for distributed execution, and while SQL can be implemented as a pipeline of MapReduce operations, plenty of distributed SQL implementations don’t use it, nothing in SQL confines it to one machine, and MapReduce has no monopoly on distributed query execution. Also, running JavaScript mid-query isn’t a MapReduce exclusive; some SQL databases can be extended with JavaScript functions too.
When anything might relate to everything
If your data is mostly one-to-many trees, or has no relationships between records, the document model fits. The relational model handles simple many-to-many. But as the connections get more complex, it becomes more natural to model the data as a graph: vertices (nodes, entities) and edges (relationships, arcs).
Social graphs
Vertices are people; edges indicate who knows whom.
The web graph
Vertices are pages; edges are HTML links. PageRank runs here.
Road / rail networks
Vertices are junctions; edges are roads or lines. Shortest-path search runs here.
In those three, every vertex is the same kind of thing. But an equally powerful use of graphs is storing completely different types of object in one datastore. Facebook keeps a single graph whose vertices are people, locations, events, checkins and comments, and whose edges say who is friends with whom, which checkin happened where, who commented on which post, who attended which event.
The working example, Lucy and Alain
Two people: Lucy from Idaho, Alain from Beaune, France. Married, living in London. Click any vertex to see how the property graph model stores it.
| vertex_id | properties (json) |
|---|---|
| 1 | {"name":"North America", "type":"continent"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 2 | 2 | 1 | within |
0 outgoing, 1 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 2 | {"name":"United States", "type":"country"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 2 | 2 | 1 | within |
| 1 | 3 | 2 | within |
1 outgoing, 1 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 3 | {"name":"Idaho", "type":"state"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 1 | 3 | 2 | within |
| 8 | 10 | 3 | born_in |
1 outgoing, 1 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 4 | {"name":"Europe", "type":"continent"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 4 | 5 | 4 | within |
| 7 | 6 | 4 | within |
0 outgoing, 2 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 5 | {"name":"England", "type":"country"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 4 | 5 | 4 | within |
| 3 | 7 | 5 | within |
1 outgoing, 1 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 6 | {"name":"France", "type":"country"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 7 | 6 | 4 | within |
| 6 | 8 | 6 | within |
1 outgoing, 1 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 7 | {"name":"London", "type":"city"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 3 | 7 | 5 | within |
| 9 | 10 | 7 | lives_in |
| 11 | 11 | 7 | lives_in |
1 outgoing, 2 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 8 | {"name":"Bourgogne", "type":"région"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 6 | 8 | 6 | within |
| 5 | 9 | 8 | within |
1 outgoing, 1 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 9 | {"name":"Beaune", "type":"city"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 5 | 9 | 8 | within |
| 10 | 11 | 9 | born_in |
1 outgoing, 1 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 10 | {"name":"Lucy"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 8 | 10 | 3 | born_in |
| 9 | 10 | 7 | lives_in |
| 12 | 10 | 11 | married_to |
3 outgoing, 0 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
| vertex_id | properties (json) |
|---|---|
| 11 | {"name":"Alain"} |
| edge_id | tail_vertex | head_vertex | label |
|---|---|---|---|
| 10 | 11 | 9 | born_in |
| 11 | 11 | 7 | lives_in |
| 12 | 10 | 11 | married_to |
2 outgoing, 1 incoming. Rows where this vertex is the tail come from the edges_tails index; rows where it is the head come from edges_heads. Having both is what lets you traverse in either direction.
Step 1 of 6.
Step 2 of 6.
Step 3 of 6.
Step 4 of 6.
Step 5 of 6.
And the point of walking it this way: nothing in the Cypher query told the database to do any of this. The description suggests scanning every person and checking their birthplace and residence, but starting from the two Location vertices and working backward through incoming edges is equally valid, and probably faster if name is indexed. The optimizer picks; you don’t specify.
Step 6 of 6.
Each vertex consists of
- A unique identifier
- A set of outgoing edges
- A set of incoming edges
- A collection of properties (key-value pairs)
Each edge consists of
- A unique identifier
- The vertex where it starts, the tail
- The vertex where it ends, the head
- A label describing the kind of relationship
- A collection of properties
You can think of a graph store as two relational tables, one for vertices, one for edges:
CREATE TABLE vertices ( vertex_id integer PRIMARY KEY, properties json ); CREATE TABLE edges ( edge_id integer PRIMARY KEY, tail_vertex integer REFERENCES vertices (vertex_id), head_vertex integer REFERENCES vertices (vertex_id), label text, properties json ); CREATE INDEX edges_tails ON edges (tail_vertex); CREATE INDEX edges_heads ON edges (head_vertex); -- both directions matter
Three properties that give this model its power
- Any vertex can have an edge to any other vertex. No schema restricts which kinds of thing may be associated.
- Given any vertex you can efficiently find both its incoming and outgoing edges, hence the two indexes above, so you can traverse the graph forward and backward.
- Different labels for different kinds of relationship let you keep several kinds of information in one graph while the data model stays clean.
Look at what the example graph absorbs without complaint: different regional structures per country (France has départements and régions, the US has counties and states), historical quirks like a country within a country, and varying granularity, Lucy’s residence is a city, her birthplace only a state. Extend it with allergens as vertices and allergy edges and you can query what’s safe for each person to eat. Graphs are good for evolvability.
The same question, four query languages
Find the names of all the people who emigrated from the United States to Europe. Precisely: every
vertex with a BORN_IN edge to a location within the US and a LIVES_IN edge to a location within
Europe, returning each one’s name.
Cypher
MATCH
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (us:Location {name:'United States'}),
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
RETURN person.nameSPARQL
PREFIX : <urn:example:>
SELECT ?personName WHERE {
?person :name ?personName.
?person :bornIn / :within* / :name "United States".
?person :livesIn / :within* / :name "Europe".
}SQL · WITH RECURSIVE
WITH RECURSIVE
-- in_usa is the set of vertex IDs of all locations within the United States
in_usa(vertex_id) AS (
SELECT vertex_id FROM vertices WHERE properties->>'name' = 'United States'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'within'
),
-- in_europe is the set of vertex IDs of all locations within Europe
in_europe(vertex_id) AS (
SELECT vertex_id FROM vertices WHERE properties->>'name' = 'Europe'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'within'
),
-- born_in_usa is the set of vertex IDs of all people born in the US
born_in_usa(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'born_in'
),
-- lives_in_europe is the set of vertex IDs of all people living in Europe
lives_in_europe(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'lives_in'
)
SELECT vertices.properties->>'name'
FROM vertices
-- join to find those people who were both born in the US *and* live in Europe
JOIN born_in_usa ON vertices.vertex_id = born_in_usa.vertex_id
JOIN lives_in_europe ON vertices.vertex_id = lives_in_europe.vertex_id;Datalog
/* facts */ name(namerica, 'North America'). type(namerica, continent). name(usa, 'United States'). type(usa, country). within(usa, namerica). name(idaho, 'Idaho'). type(idaho, state). within(idaho, usa). name(lucy, 'Lucy'). born_in(lucy, idaho).
Cypher does it in 4 lines; recursive SQL needs 29. The chapter’s reading of that gap is not that SQL is bad, it’s that different data models are designed to satisfy different use cases, so it matters that you pick one suited to your application.
Are graph databases CODASYL again?
At first glance the network model looks a lot like the graph model. The answer is no, in four specific ways.
| Dimension | CODASYL network model | Graph database |
|---|---|---|
| Schema | A schema specified which record type could be nested within which other record type | No such restriction, any vertex can have an edge to any other, so applications adapt to changing requirements |
| Reaching a record | Only by traversing one of the access paths to it | Refer directly to any vertex by unique ID, or use an index to find vertices by value |
| Ordering | A record’s children were an ordered set, so the database maintained the order, with storage-layout consequences, and inserts had to worry about position | Vertices and edges are unordered; you sort results at query time if you want to |
| Queries | All imperative, difficult to write, easily broken by schema changes | Imperative traversal if you want it, but most support declarative languages like Cypher or SPARQL |
Triple-stores, RDF, and the semantic web
The triple-store model is mostly equivalent to the property graph model with different words. All information is stored as three-part statements: (subject, predicate, object). In (Jim, likes, bananas), Jim is the subject, likes the predicate, bananas the object. The subject is a vertex. The object is one of two things:
A primitive value
Then predicate and object are the key and value of a property on the subject vertex. (lucy, age, 33) is a vertex lucy with properties {"age":33}.
Another vertex
Then the predicate is an edge, the subject is the tail and the object is the head. In (lucy, marriedTo, alain) the predicate is the edge label.
Turtle · verbose
@prefix : <urn:example:>. _:lucy a :Person. _:lucy :name "Lucy". _:lucy :bornIn _:idaho. _:idaho a :Location. _:idaho :name "Idaho". _:idaho :type "state". _:idaho :within _:usa.
Turtle · with semicolons
@prefix : <urn:example:>.
_:lucy a :Person; :name "Lucy";
:bornIn _:idaho.
_:idaho a :Location; :name "Idaho";
:type "state"; :within _:usa.
_:usa a :Location;
:name "United States";
:type "country";
:within _:namerica. The _:someName form means nothing outside the file, it exists only so you can tell which
triples refer to the same vertex. When the predicate is an edge, the object is a vertex; when it’s
a property, the object is a string literal. RDF can also be written in XML, which does the same
thing far more verbosely; Turtle/N3 is easier on the eyes, and tools like Apache Jena convert
between formats.
The semantic web, briefly and fairly
The idea is simple and reasonable: sites already publish text and pictures for humans, so why not also publish machine-readable data? RDF was meant to be the consistent format that let data from different sites be combined into a web of data, an internet-wide database of everything. It was overhyped in the early 2000s and hasn’t shown signs of being realized, which has made people cynical, and it suffered from a plethora of acronyms, overly complex standards and hubris.
But the triple-store data model is completely independent of the semantic web, Datomic is a
triple-store that claims no connection to it. Triples can be a good internal data model even if you
never publish a byte of RDF. RDF’s one visible quirk comes from being designed for internet-wide
exchange: subjects, predicates and objects are often URIs, so that your within and someone else’s
within don’t collide when datasets are merged. Declare the prefix once at the top and forget it.
The foundation, Datalog
Much older than SPARQL or Cypher, studied extensively by academics in the 1980s, and less known among engineers, but it matters because it’s the foundation the later languages build on. In practice it’s the query language of Datomic, and Cascalog is a Datalog implementation for querying large datasets in Hadoop. Its data model generalizes the triple store slightly: instead of (subject, predicate, object) you write predicate(subject, object).
Where Cypher and SPARQL jump straight in with SELECT, Datalog takes a small step at a time: you
define rules that teach the database new predicates, derived from data or from other rules. Rules
can call other rules and recurse, so complex queries are built a piece at a time. Words starting with
an uppercase letter are variables. A rule applies when the system finds a match for every predicate
on the right-hand side of :-; when it applies, it’s as though the left-hand side were added to the
database.
Rules
within_recursive(Location, Name) :-
name(Location, Name). /* Rule 1 */
within_recursive(Location, Name) :-
within(Location, Via),
within_recursive(Via, Name). /* Rule 2 */
migrated(Name, BornIn, LivingIn) :-
name(Person, Name),
born_in(Person, BornLoc),
within_recursive(BornLoc, BornIn),
lives_in(Person, LivingLoc),
within_recursive(LivingLoc, LivingIn).
/* Rule 3 */
?- migrated(Who, 'United States', 'Europe'). Derived facts
within_recursive(namerica, 'North America')
within_recursive(usa, 'North America')
within_recursive(idaho, 'North America')
By repeated application of rules 1 and 2, within_recursive can tell us every location contained in North America, or in any other named location.
By fixing BornIn and LivingIn and leaving the person as the variable Who, we ask which values can appear there.
Who = 'Lucy'.
Same answer as Cypher and SPARQL. Uppercase-initial words are variables, and a rule applies when every predicate on the right of :- finds a match, at which point it’s as though the left-hand side had been added to the database, with variables replaced by what they matched.
Datalog needs a different kind of thinking, and it’s less convenient for simple one-off queries, but rules can be combined and reused across queries, so it copes better when your data is complex.
Did it stick?
1. What is the impedance mismatch, and what does an ORM do about it?
2. Why store region_id rather than the string “Greater Seattle Area”?
3. Normalizing data requires which relationship type, and why is that awkward for document databases?
4. In what single aspect did document databases revert to the hierarchical model?
5. What was the decisive advantage of the relational model over CODASYL?
6. Why is “schemaless” a misleading description of document databases?
7. When does the document model’s locality advantage stop paying off?
8. Which is the most important reason declarative query languages beat imperative ones?
9. Why must MapReduce’s map and reduce functions be pure?
10. Cypher’s :WITHIN*0.. has which SQL counterpart, and why is one needed at all?
Answered 0 of 10 · 0 correct
The terms this chapter installs
Chapter 2 in one breath. Data began as one big tree, the hierarchical model, which couldn’t represent many-to-many relationships, so the relational model was invented to fix that. More recently some applications didn’t fit relations either, and NoSQL diverged in two directions: document databases for self-contained documents with rare relationships between them, and graph databases for the opposite case where anything is potentially related to everything. All three are widely used and each is good in its domain; one model can be emulated in another, but the result is usually awkward, which is why there’s no one-size-fits-all system. Document and graph databases typically don’t enforce a schema, but your application still assumes a structure, so the real question is only whether the schema is explicit on write or implicit on read. Each model brings its own language: SQL, MapReduce, the aggregation pipeline, Cypher, SPARQL, Datalog, plus CSS and XPath as instructive non-database parallels.
And the models it names but doesn’t cover: genome sequence-similarity search, which none of these databases handle, hence specialised software like GenBank; particle physics at the LHC, working with hundreds of petabytes on custom solutions to keep hardware cost in check; and full-text search, arguably a data model in its own right, taken up again with search indexes in Chapter 3.
Study aid built for recall practice. Concepts, code examples and the Lucy/Alain graph are Martin Kleppmann’s, from Designing Data-Intensive Applications, Chapter 2, go back to the book for his wording and the full reference list.