Lesson 07 / 13
Relationships and Validations
Measures validations that stop a record before it is written and the one-to-many relationship that links records; shows that a validation error stays only in this request and differs from a notice message.
Published Updated
In this lesson
- Measures that a validation does not stop at the first error and that a failed record is not written to the table
- Builds a one-to-many relationship in plain Ruby and measures a validation leaving the orders of a deleted product orphaned
- Shows in four requests that the stay of a validation error is this request
- Measures the difference between an error that stays in this request and a notice message carried to the next request
Contents
The previous lesson showed that a column added to the schema is persistent and that it does not reach rows written before the migration. The schema and the rows were defined, but must a record accept every value when it enters the table? If a product with a negative price or an empty name is written to the table, every client that reads that row reads corrupt data. The rules that check a record before it is written are called validations. Where does the error of a rejected record stay?
The question is asked throughout the lesson with the same measure: how many requests does this value stay for? A validation error is produced to be shown to the user who tried to save the product; the user will correct the form and send it again. Does this information need to be at hand in the next request as well? The lesson measures the answer. The second concept of the lesson is the relationship: one record pointing to another record with an id field. The lesson reads the relationship with the same measure.
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
The core is not a framework but a model of a framework. The second file of the lesson takes from the core only the insert method that carries the validations. Validations run before the record is written, and the failure of each rule adds a message to the error list.
# validations.rb — product model: validations run before the record is written
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
end
This block is source as well. insert returns a hash: if the validation passes, an id and an empty errors list; if it does not, only the errors list. The lines that write the record are reached only when the error list is empty; return skips them.
The Error List of a Validation
Does a validation stop at the first error, or does it run all the rules? The measurement below creates two valid records and then tries a record with an empty name and a negative price.
# errors.rb — validation errors and the size of the table
require_relative "validations"
app = App.new
app.request("A") do |b|
p Product.insert(b, name: "pen", price: 12)
p Product.insert(b, name: "notebook", price: 30)
result = Product.insert(b, name: "", price: -1)
p result[:errors]
p result.key?(:id)
p b.app.store[:products].size
end
{id: 1, errors: []}
{id: 2, errors: []}
["name cannot be blank", "price cannot be negative"]
false
2
The valid records returned an id and an empty error list. The failed record gave two errors at once: the validation did not stop at the first error and tried every rule. This lets the user who corrects the form see all the problems in a single pass. The result has no id key and the size of the table stayed 2: the failed record never entered the table. Because the rejected record does not exist, looking for its error in the table is meaningless; the error exists only in the value that insert returned.
What a validation rejects is exactly what the written rules say. Boundary values show this: a zero price, a space-only name and a missing price.
# limits.rb — exactly what the rule says
require_relative "validations"
app = App.new
app.request("A") do |b|
p Product.insert(b, name: "pen", price: 0)[:errors]
p Product.insert(b, name: " ", price: 5)[:errors]
p Product.insert(b, name: "notebook", price: nil)[:errors]
p b.app.store[:products].size
end
[] [] [] 3
All three records were accepted and the size of the table is 3. The price rule rejects only a negative number; zero and nil pass, because nil.to_i is zero. The name rule rejects only an empty string; a single space character does not count as empty. A validation applies the written rule, not the intention: whoever wants to stop a space-only name or a missing price must write a separate rule.
The Stay of an Error
The observation of the lesson is the error of a product with an empty name. The writing lambda puts the result into request_store; the reading lambda takes the first error message.
# measure.rb — stay of the validation error observation
require_relative "validations"
write = ->(b) { b.request_store[:record] = Product.insert(b, name: "", price: 5) }
read = ->(b) { b.request_store.dig(:record, :errors, 0) }
p stay(write, read)
["this_request", "A1 -- -- --", "name cannot be blank"]
The stay is this request. The evidence column is A1 -- -- --: the error was read only in the request that wrote it; it is absent in the second and third requests of A and in the fourth request of B. The reason lies in the nature of request_store: every request gets a new Context and the request store starts empty. The error is not in the table because it was not saved; it is in the request store, so it goes away when the request ends. DL10. In this measurement the error was saved in request_store. An app that keeps the error somewhere else could give another stay; the claim of this lesson is only that the stay is this request when the error is in the request store.
A One-to-Many Relationship
A product can have more than one order. This is a one-to-many relationship; the same relationship seen from the order to the product is called the belongs-to relationship. The core has no mechanism for relationships; records are plain hashes. For this reason the relationship is built with a helper of the lesson in plain Ruby: the order record carries the id of the product in a field, and the orders of a product are found by filtering on this field.
# order.rb — one-to-many relationship: the order carries the id of the product
require_relative "validations"
module Order
def self.place(b, attributes)
errors = []
errors << "product not found" unless b.app.store[:products].key?(attributes[:product_id])
errors << "quantity must be positive" unless attributes[:quantity].to_i.positive?
return { errors: } unless errors.empty?
table = (b.app.store[:orders] ||= {})
id = table.size + 1
table[id] = attributes.merge(id:)
{ id:, errors: }
end
def self.orders_for_product(b, product_id)
(b.app.store[:orders] || {}).values.select { |o| o[:product_id] == product_id }
end
end
This block is source as well. Order.place carries a validation too: if the product that the order is placed for is not in the table, the record is rejected. A validation protects the relationship; no order is written without a product to link to. orders_for_product is the other direction of the relationship: it takes the id of a product and filters the order table by that id.
# relationship.rb — two products, three orders
require_relative "order"
app = App.new
app.request("A") do |b|
Product.insert(b, name: "pen", price: 12)
Product.insert(b, name: "notebook", price: 30)
Order.place(b, product_id: 1, quantity: 3)
Order.place(b, product_id: 1, quantity: 1)
Order.place(b, product_id: 2, quantity: 5)
p Order.orders_for_product(b, 1).map { |o| o[:quantity] }
p Order.orders_for_product(b, 2).map { |o| o[:quantity] }
p Order.orders_for_product(b, 99)
p Order.place(b, product_id: 99, quantity: 1)
p b.app.store[:orders].size
end
[3, 1]
[5]
[]
{errors: ["product not found"]}
3
The orders of the first product are [3, 1] and those of the second [5]. A product that does not exist has no orders: an empty list, not an error. An order placed for a product that does not exist was rejected and the error message was returned; the size of the order table stayed 3. The relationship is not separate data: it is the product_id field in the order record. For this reason the stay of the relationship is the stay of the record that carries it. The measurement below tests this against the rule.
# relationship_stay.rb — stay of the relationship
require_relative "order"
write = lambda do |b|
Product.insert(b, name: "pen", price: 12)
Order.place(b, product_id: 1, quantity: 3)
Order.place(b, product_id: 1, quantity: 1)
end
read = ->(b) { Order.orders_for_product(b, 1).size }
p stay(write, read)
["persistent", "A1 A2 A3 B4", 2]
The relationship is persistent: the two orders of the product were read in all four requests. The order records are in the table and the table lives in the app; because the relationship is an id in the record rather than extra data, it stays together with the record. DL11. Filtering walks the whole table; the cost of this approach when the number of orders grows has not been measured, and the claim of the lesson concerns only the stay.
Belongs-to Direction and the Orphan Record
The other direction of the relationship goes from the order to the product. Because the order carries the id of the product and not the product itself, reaching the product means looking that id up in the table. This means that when the product changes, the order will appear changed too. The measurement changes the name of the product after the order is placed and reads the name through the order.
# belongs_to.rb — reading from the order to the product
require_relative "order"
lookup = ->(b, id) { b.app.store[:products][b.app.store[:orders][id][:product_id]] }
app = App.new
app.request("A") do |b|
Product.insert(b, name: "pen", price: 12)
Order.place(b, product_id: 1, quantity: 3)
p lookup.call(b, 1)[:name]
b.app.store[:products][1][:name] = "pencil"
p lookup.call(b, 1)[:name]
p b.app.store[:orders][1].key?(:name)
end
write = lambda do |b|
Product.insert(b, name: "pen", price: 12)
Order.place(b, product_id: 1, quantity: 3)
b.app.store[:products][1][:name] = "pencil"
end
read = ->(b) { lookup.call(b, 1)[:name] }
p stay(write, read)
"pen" "pencil" false ["persistent", "A1 A2 A3 B4", "pencil"]
The order does not carry the name of the product inside itself: key?(:name) gave false. When the name changed, the name read through the order changed too, and the change is persistent: the fourth request of B reads the new name as well. In this model the relationship is an id, so the current state of the related record is what is seen; whether a loaded object in a real mapper can go stale has not been measured here. To keep a past price or name in the order, the value must be copied into the order separately; this model does not do that.
What happens if the product is deleted? The order record keeps carrying the id of the product. Because the validation runs only while an order is being written, it does not protect the orders that already exist.
# orphan.rb — an order whose product was deleted
require_relative "order"
app = App.new
app.request("A") do |b|
Product.insert(b, name: "pen", price: 12)
Order.place(b, product_id: 1, quantity: 3)
b.app.store[:products].delete(1)
p Order.orders_for_product(b, 1).size
p b.app.store[:products][1]
p Order.place(b, product_id: 1, quantity: 1)
end
1
nil
{errors: ["product not found"]}
Even though the product was deleted, the product with the id 1 still has 1 order, and in the product table that id is nil. A new order, on the other hand, was rejected with the product not found error. The validation protects the relationship for new records and does not protect it for existing ones; an order without a product, that is an orphan record, is prevented by handling the related records at the moment of deletion (refusing the deletion, deleting the related records, or emptying them); this model has no such step. What a real mapper does to related records on deletion depends on its configuration and has not been measured here.
The Bounding Measurement
A validation error is not stored with the record: a valid record is persistent, and the error stays in this request. But what if the error has to be carried to the next page? That is the job of the notice message (flash), and its stay is next request. Three mechanisms are put side by side with the same measure: keeping the error in the request store, putting the error into the notice message, and saving a valid record.
# contrast.rb — error, notice message and valid record
require_relative "validations"
failure = ->(b) { Product.insert(b, name: "", price: 5) }
in_store = ->(b) { b.request_store[:record] = failure.call(b) }
store_read = ->(b) { b.request_store.dig(:record, :errors, 0) }
p stay(in_store, store_read)
in_flash = ->(b) { b.flash[:error] = failure.call(b)[:errors].first }
flash_read = ->(b) { b.flash[:error] }
p stay(in_flash, flash_read)
in_table = ->(b) { Product.insert(b, name: "pen", price: 12) }
table_read = ->(b) { b.app.store[:products][1]&.fetch(:name) }
p stay(in_table, table_read)
["this_request", "A1 -- -- --", "name cannot be blank"] ["next", "A1 A2 -- --", "name cannot be blank"] ["persistent", "A1 A2 A3 B4", "pen"]
The same error message gave two stays. In the request store it was read only in the request that wrote it; when it was put into the notice message it was read in the second request of A as well and disappeared in the third. The valid record is persistent. The validation error itself is always the same value; its stay is determined by where you put it. The stay rule looks at the behavior of the value, not at its content.
This has a consequence for showing the form again. If the request that submits the product form is rejected, the form with errors must be drawn for the user in the same request: the error exists in that request and may not exist in the next one. If the user will be redirected to a later page, the error must be put into the notice message. The measurement below shows the first way.
# form.rb — the error is drawn in the same request, absent in the next
require_relative "validations"
app = App.new
rendered = app.request("A") do |b|
result = Product.insert(b, name: "", price: -1)
b.request_store[:record] = result
result[:errors].empty? ? "saved" : "form: #{result[:errors].join(', ')}"
end
p rendered
p app.request("A") { |b| b.request_store[:record] }
"form: name cannot be blank, price cannot be negative" nil
The first request drew the form with errors together with both errors. The very next request of the same client read nil for request_store[:record]: the error is no longer there. DL12. This drawing only produces text; it does not run a real view template. What is measured is in which request the error can be read, not the appearance of the form.
Summary
- A validation runs before the record is written, tries every rule and returns an error list; a failed record is not written to the table.
- A one-to-many relationship is an id field in a record; the stay of the relationship is persistent because it is the stay of the record that carries it.
- The validation error, the observation of this lesson, is a value whose stay is this request: the error of a product with an empty name was read only in the request that wrote it.
- The error is not stored with the record: a valid record is persistent and the error stays only in this request.
- In the belongs-to direction an order carries the id of the product, not its name; when the product name changes, the new name is read through the order. A validation runs only while writing; if the product is deleted the order is left an orphan, and the validation does not protect existing records.
- Carrying the error to the next page is the job of the notice message (flash), and its stay is next request; to show the form again, the error must be drawn in the same request.
Next Step
Records, relationships and validations can now be written. When a request reads the orders of a product, how many times does it go to the database? The next lesson measures how many times the same query goes to the database in one request.
To keep your progress and take notes, Log in
My notes
Log in to take notes.