Lesson 05 / 13
Object–Relational Mapping
Measures the layer that maps a table row to an object and a table to a model; shows that a saved value is read by another client too and where the model of this core reaches its limit.
Published Updated
In this lesson
- Establishes the correspondence between a table row and a record, and between a table and a model
- Measures in four requests that a saved value stays persistent
- Shows that ids derive from the table and that a missing id gives nil
- Measures that the core model returns the stored object itself, which sets it apart from many mappers
Contents
The last lesson of the previous topic measured that a compiled template is shared by everyone and can go stale once compiled: a template was compiled for one client and another client ran the same compiled template. While it is rendered, a template shows a product. Where that product came from has not been asked yet. This topic is devoted to it: a user saved a product from a form, and a different user in a different browser is reading it. Is that so?
The axis of the course is the same: how many requests does this value stay for? There are four stays. A value that lasts only in the request that wrote it has the stay this request. A value that is still there only in the next request of the same client has the stay next request. A value present in all later requests of the same client but absent for another client has the stay session. A value that another client reads as well is persistent. The observation of this lesson is a product record, and the expected stay is persistent; but a label does not tell the stay, a measurement does.
The concept of the lesson is object–relational mapping (ORM) and the piece of software that implements it, the mapper. In a database, data lives in tables and tables hold rows; in a program, data lives in objects. The mapper connects the two worlds: a table corresponds to a model, and a row of the table corresponds to a record of that model. The same concept was built with PHP in M12/K04. There the measurement was “did this piece run in this request”; here the concept is the same and the measure differs: the question is not the record itself but how many requests the record stays for.
The Measurement Core
The first file is the request loop and the stay classifier, identical in every lesson of the course. This block is the source; the file is written to disk and pulled in with require_relative.
# stay.rb — a small request loop and a helper that classifies how many requests a value stays for
# Flash: a value written in this request is carried to the next request only.
class Flash
def initialize(incoming)
@incoming = incoming
@fresh = {}
end
def [](key) = @fresh.fetch(key) { @incoming[key] }
def []=(key, value)
@fresh[key] = value
end
def passed_on = @fresh
end
# Every request gets a new context: params, flash and the request store start empty each time.
class Context
attr_reader :app, :params, :session, :flash, :request_store
def initialize(app, session)
@app = app
@params = {}
@session = session
@flash = Flash.new(session.delete(:flash) || {})
@request_store = {}
end
end
# The application store is the same across all requests and all clients;
# the session is chosen per client.
class App
attr_reader :store
def initialize
@sessions = Hash.new { |h, client| h[client] = {} }
@store = { products: {}, schema: { products: %i[name price] }, templates: {},
queue: [], cache: {}, counter: 0 }
end
def request(client)
context = Context.new(self, @sessions[client])
result = yield context
context.session[:flash] = context.flash.passed_on unless context.flash.passed_on.empty?
result
end
end
# Rule: request 1 (client A) writes the value; requests 2 and 3 (A) and request 4 (B) read it.
# If B sees it, persistent; if A's third request sees it, session; if only the second
# request sees it, next; if none sees it, this_request.
def stay(write, read)
app = App.new
first = app.request("A") { |b| write.call(b); read.call(b) }
raise ArgumentError, "value could not be read in the request that wrote it" if first.nil?
seen = [["A", 2], ["A", 3], ["B", 4]].map do |client, order|
app.request(client) { |b| read.call(b) }.nil? ? "--" : "#{client}#{order}"
end
kind = if seen[2] != "--" then "persistent"
elsif seen[1] != "--" then "session"
elsif seen[0] != "--" then "next"
else "this_request"
end
[kind, ["A1", *seen].join(" "), first]
end
App carries the same store for all requests and all clients, while the session is chosen per client. Every request gets a new Context, and request_store is empty in each request. stay takes two lambdas, one that writes and one that reads: in the first request client A runs both, then two requests of A and one request of B only read. If B sees the value, the stay is persistent; if B does not but the third request of A does, session; if only the second request sees it, next; if none sees it, this request. The rule reads the value’s behavior, not where it is stored.
The file of this lesson supplies the model. The excerpt is as much of the core as the lesson needs: the Product model.
# mapping.rb — product model: a table maps to a model, a row maps to a record
require_relative "stay"
module Product
def self.insert(b, attributes)
errors = []
errors << "name cannot be blank" if attributes[:name].to_s.empty?
errors << "price cannot be negative" if attributes[:price].to_i.negative?
return { errors: } unless errors.empty?
table = b.app.store[:products]
id = table.size + 1
table[id] = attributes.merge(id:)
{ id:, errors: }
end
# The same query does not go to the database twice in the same request.
def self.lookup(b, id)
queries = (b.request_store[:queries] ||= {})
queries.fetch([:products, id]) do
b.request_store[:query_count] = b.request_store.fetch(:query_count, 0) + 1
queries[[:products, id]] = b.app.store[:products][id]
end
end
end
This block is source as well. insert writes a record: the table is the hash store[:products], whose keys are ids and whose values are rows. The id derives from the size of the table, so the first record gets 1 and the second 2, and the row is the given attributes with the id added. For now the validation lines only fill the error list; the third lesson returns to them. lookup reads a row by id; the query cache belongs to the fourth lesson, and here only the read store[:products][id] matters.
The Stay of a Record
Does another browser read the saved product? The writing lambda saves a pen, and the reading lambda takes the name of the first row from the table.
# measure.rb — stay of the record observation
require_relative "mapping"
pen = ->(b) { Product.insert(b, name: "pen", price: 12) }
read = ->(b) { b.app.store[:products][1]&.fetch(:name) }
p stay(pen, read)
["persistent", "A1 A2 A3 B4", "pen"]
Three fields are read. The stay is persistent: the rule saw the value in the fourth request of B as well. The evidence column A1 A2 A3 B4 says that all four requests read the pen; The last field is the value read in the first request. This answers the question left at the end of the previous topic: another browser does read the saved product. DL1. The measurement is within a single process. The store of the core lives in memory, and it does not cover whether the data survives a process restart.
The reason for this is where store lives. Flash and the request store are born again in every request together with the Context; the session is tied to the client; store, however, belongs to the App object, and the app serves all requests. The model writes the value to the place that lasts the longest, so the stay came out persistent.
There is one more consequence. The table is shared between clients, so the records of two clients go into the same sequence of ids. A creates a product, B creates another one, and A reads the table.
# two_clients.rb — two clients, one table
require_relative "mapping"
app = App.new
app.request("A") { |b| Product.insert(b, name: "pen", price: 12) }
app.request("B") { |b| Product.insert(b, name: "notebook", price: 30) }
app.request("A") do |b|
table = b.app.store[:products]
p table.keys
p table.values.map { |row| row[:name] }
end
[1, 2] ["pen", "notebook"]
The third request of A saw both records. The record of B received the id 2, because the id derives from the size of the table and the table is one piece together with the record of A. A persistent value does not belong only to the client that wrote it: what one client writes determines the id of another. DL5. The two requests here run one after the other; whether ids would collide when two requests arrive at the same time has not been measured, and this lesson does not claim it.
What the Model Takes On
Without a mapper, every controller would have to find its own way to the table: it would know under which key a row is written, how the id is produced, and which fields a row has. The model gathers this knowledge in one place. insert produces the id and writes the row; lookup reads a row by id; the caller uses two methods without knowing how the table’s hash is built. This is the model layer of MVC (model–view–controller): the controller interprets the request, the view draws the page, and the model is the only layer that knows where and in what form the data lives.
The gain of this separation is that a change is made in one place. If the shape of a row changes, only insert and lookup are updated, and the code that calls them stays the same. The stay measured in this lesson is also a result of the model: because it is known which method writes the record, it is also known where the record is written.
Table, Row and Id
The same model is examined during one request: two records are created, the keys of the table and the second row are printed, and a missing id is asked for.
# row.rb — table, row and id
require_relative "mapping"
app = App.new
app.request("A") do |b|
one = Product.insert(b, name: "pen", price: 12)
two = Product.insert(b, name: "notebook", price: 30)
p [one[:id], two[:id]]
p b.app.store[:products].keys
p b.app.store[:products][2]
p Product.lookup(b, 99)
end
[1, 2]
[1, 2]
{name: "notebook", price: 30, id: 2}
nil
The two insert calls gave the ids [1, 2], and the keys of the table are the same list: the id is the address of the row in the table. The second row is the union of the two given attributes and the id. The missing id did not raise an error but gave nil; the “not found” answer of the model is a value, not an exception. For this reason the code that calls lookup handles the nil case itself: in the language of the model, “absent” is said with nil, not with an exception. DL2. Because ids derive from the size of the table, this measurement holds on the assumption that nothing is ever deleted; deletion does not exist in this core and has not been measured.
Input and Row
insert builds the row not from the hash the caller gave but from a new hash made with merge. So the caller’s attributes and the row in the table do not share a stay. The measurement creates the record from a local variable and then changes the variable.
# input.rb — the caller's hash and the row in the table
require_relative "mapping"
app = App.new
app.request("A") do |b|
attributes = { name: "pen", price: 12 }
Product.insert(b, attributes)
attributes[:name] = "changed"
p attributes.key?(:id)
p b.app.store[:products][1][:name]
p attributes.equal?(b.app.store[:products][1])
end
write = lambda do |b|
attributes = { name: "pen", price: 12 }
Product.insert(b, attributes)
attributes[:name] = "changed"
end
read = ->(b) { b.app.store[:products][1]&.fetch(:name) }
p stay(write, read)
false "pen" false ["persistent", "A1 A2 A3 B4", "pen"]
The caller’s hash never received the id key, the row stayed "pen", and the two are not the same object. The changed local variable goes away with the request; the row in the table is persistent: although the writing lambda changes the local hash after the insert, the fourth request of B reads "pen". In the core model the copy is taken when writing, not when reading: in the next section lookup hands back the very object in the table. This asymmetry belongs to the model; what a real mapper does in the two directions has not been measured here.
The Bounding Measurement
The model is not the whole of a real mapper, and its limit shows in one place. Does lookup return the row in the table or a copy of it? Whether two objects are the same object is asked with equal?. Then the record that was read is changed without saving.
# boundary.rb — the model returns the stored object itself
require_relative "mapping"
app = App.new
app.request("A") do |b|
Product.insert(b, name: "pen", price: 12)
fetched = Product.lookup(b, 1)
p fetched.equal?(b.app.store[:products][1])
fetched[:name] = "changed"
p b.app.store[:products][1][:name]
end
true "changed"
lookup returned the very object in the table: equal? gave true. When the record that was read was changed, the table changed too, and there is no call that saves anything. In many mappers this is not so: every read builds a new object from the row, and a change to the object reaches the row only through an explicit save. This should be read not as a claim but as the limit of the core model. A small helper makes the difference visible; the helper is the code of this lesson and imitates the way such a mapper reads.
# copy.rb — helper that imitates the read style of a real mapper
require_relative "mapping"
def fresh_read(b, id) = b.app.store[:products][id]&.dup
def write_back(b, record)
b.app.store[:products][record[:id]] = record.dup
end
app = App.new
app.request("A") do |b|
Product.insert(b, name: "pen", price: 12)
duplicate = fresh_read(b, 1)
duplicate[:name] = "changed"
p b.app.store[:products][1][:name]
write_back(b, duplicate)
p b.app.store[:products][1][:name]
p duplicate.equal?(b.app.store[:products][1])
end
"pen" "changed" false
When the copy was changed, the table stayed "pen"; the change reached the table only with the write_back call. The last line confirms that the object in the table is not the same object as the copy that was read: false. In this helper, what stays depends on the change being written back. DL3. The helper takes a copy and writes the record back; it does not fully represent what a real mapper does, and it only separates what the table sees before and after the write.
Finally, the same two behaviors are put in front of the stay rule: a writing lambda that creates the record and then changes only the object in memory.
# same_object.rb — a record changed without saving
require_relative "mapping"
write = lambda do |b|
Product.insert(b, name: "pen", price: 12)
Product.lookup(b, 1)[:name] = "changed"
end
read = ->(b) { b.app.store[:products][1]&.fetch(:name) }
p stay(write, read)
["persistent", "A1 A2 A3 B4", "changed"]
The stay is persistent again and the value is "changed". In the core model, the object that was read and the object in the table are the same, so there is no save step: every touch of the record is written directly to the place that another client reads too. For this reason, what is persistent in the core is the creation of the record as much as every change made to the record object. This is a simplification of the model and should not be read as the behavior of a real mapper.
Holding a Record Versus Saving It
The contrasting measurement keeps the same information in a variable of the request instead of writing it to the table. The writing lambda puts the name of the product into request_store and writes nothing to the table.
# held.rb — the same information, not written to the table
require_relative "mapping"
write = ->(b) { b.request_store[:product] = { name: "pen", price: 12 } }
read = ->(b) { b.request_store[:product]&.fetch(:name) }
p stay(write, read)
["this_request", "A1 -- -- --", "pen"]
The value is the same, "pen"; the stay changed. The evidence column A1 -- -- -- shows that it was read only in the first request, and the rule says this request. The difference between the two measurements lies not in the content of the data but in where it was written: insert wrote the value to the table, and this lambda wrote it to a variable of the request. Object–relational mapping establishes exactly this difference. What the model writes goes to a place that is not erased when the request ends. A product name in a local variable of a controller, on the other hand, is lost with the request. DL4. This contrasting measurement keeps the variable in request_store; what is measured is that a value that is not written to the table in this core never goes beyond one request, not the behavior of any other place of storage.
Summary
- Object–relational mapping connects a table to a model and a row of the table to a record of that model; the piece that makes this connection is called a mapper.
- The record, the observation of this lesson, is a value whose stay is persistent: the product saved in the first request was read in the next two requests of A and in the fourth request of B.
- The cause of the persistent stay is that
storelives in the app; the request store, flash and session do not give this stay. - Ids derive from the table and a missing id gives
nil; the answer of the model is not an exception. - The core model returns the stored object itself; many mappers build a new object on every read, and a change reaches the table only through a save.
insertbuilds the row as a copy of the caller’s hash; a hash changed afterwards does not affect the row.
Next Step
The record stays persistent, but the shape of the records depends on a table schema. When a column is added to the schema later, is it present in the old rows? The next lesson reads the migrations that change the schema with the same measure.
To keep your progress and take notes, Log in
My notes
Log in to take notes.