Schema Validation
Every statement checked against the catalog before it touches a row
Put a validation pass between the parser and the planner. Check every INSERT, SELECT, UPDATE and DELETE against its table's catalog entry (the table, each column, each value's type, range and length) in one fixed order, so a statement that does not fit its schema prints exactly one error and changes nothing.
- Syntax and semantics: a statement can parse and still mean nothing, because it names a column its table does not have
- Binding: resolving each name in a statement to what the catalog says it is, before anything runs
- One error per statement, chosen by a fixed order of checks, so the same mistake always gets the same code
- Validate, then act: a statement either passes every check or changes nothing
- Types from the schema: the limits come from each column, not from the program
Why this stage exists
The parser answers one question: is this statement well formed? insert into products (id, colour) values (1, 'red'); is. It has the right keywords in the right places, a column list and a value list of the same length. It still means nothing, because products has no column called colour. Whether a statement makes sense depends on what the database holds, and the parser does not know that. The catalog does.
Until the previous stage there was one table, and its three columns were part of the program. Checking a statement meant comparing a name with id, name or email and a string's length with 27. Now tables come from CREATE TABLE. products has a VARCHAR(40) where users has a VARCHAR(1000), orders is keyed by a column called oid, and each INT column has the same range as the key. Every one of those limits is in the catalog. Some of these checks were already there, scattered through the executor, written against users; this stage puts all of them in one place and has them read the catalog.
Real databases have the same step. PostgreSQL calls it parse analysis, and SQLite name resolution. Textbooks call it binding: each name in the statement is bound to the table or column the catalog says it is, and each value is checked against that column's type. What comes out is a statement the planner can trust. The planner never has to wonder whether a column exists or whether a value fits.
What changes: a pass between the parser and the planner
A line now goes through four steps before anything is read: the lexer, the parser, validation, the planner. Each can stop the line, and the first one that stops it prints the only error. Validation reads the parsed statement and the catalog, and never a page of the table, so however large the table is, a statement is rejected before any row is read.
explain is a planner, so it sits after validation too: a statement that would fail prints its code and no [PLAN] line. The checks are only the ones that can be decided from the statement and the catalog. An INSERT of a key the table already has is only found by reading the tree, so it is still the executor's 00701, and explain still prints the plan for it.
What changes: one order for every check
A statement can break several rules at once. insert into products (id, price, colour) values (1, 'x', 2); gives a string to an INT column and names a column that does not exist. It prints one error, and it must be the same one on every implementation, so the order of the checks is part of the contract.
The rule is to read the statement from left to right and check each piece fully before moving on. For an INSERT:
n bytesSo (id, price, colour) with (1, 'x', 2) is 00403: price is checked, name and value, before colour is looked at. Swap the two, (id, colour, price) with (1, 2, 'x'), and it is 00402. The key comes after the columns, because an INSERT that leaves it out may still have broken something earlier. The size comes last, because it depends on every value.
UPDATE reads its assignments the same way: each one's column, then that it is not assigned twice, then that it is not the key (00801, whatever the key is called), then its value's type, range and length. Then comes its WHERE. SELECT checks the table, its select list, then its WHERE, and DELETE checks the table, then its WHERE. A WHERE checks its column, the value's type and, for an INT, its range.
A WHERE does not check a string's length. It stores nothing, so a string longer than its column only compares: where title = '<41 bytes>' matches no row, and where title < '<41 bytes>' matches every row that sorts before it.
PostgreSQL does the same checks in the same place, and more: it resolves functions, casts values between types, and expands
*into the column list before planning. droid's types are too few to need casts, so a value of the wrong type is an error, not a conversion.
The algorithm in words
Where validation sits
A pass of its own, after the parser and before the planner.
- 1.The lexer and the parser run first. Their codes, 00303 for a value list of the wrong length included, win over anything validation would say.
- 2.Validation reads the parsed statement and the catalog, and nothing else: no page of the table, no row.
- 3.The planner and the executor run only on a statement that passed, and explain is a planner: a statement that fails validation prints its code and no [PLAN] line.
- 4.Invariant: when a statement fails validation, the file, the tree and every row are exactly what they were.
Check an INSERT
Column by column in the order written, then the key, then the size.
- 1.Find the table by name in the catalog (00401).
- 2.For each column of the INSERT, in the order written: it is a column of that table (00402) and not one already named in the list (00408); then its value is the column's type (00403), a number from 0 to 4294967295 for an INT (00404), and no more bytes than the n of a VARCHAR(n) (00405).
- 3.Then the table's first column is among them (00406). Then the encoded row is at most 1000 bytes (00407).
- 4.serialize checks its INSERT the same way.
Check a SELECT, an UPDATE, a DELETE
The table first, then what the statement changes or returns, then the WHERE.
- 1.SELECT: the table (00401), each column of the select list (00402), then the WHERE.
- 2.UPDATE: the table, then each assignment in the order written: its column (00402), not assigned earlier in the statement (00408), not the first column (00801), then its value's type, range and length (00403, 00404, 00405). Then the WHERE.
- 3.DELETE: the table, then the WHERE.
- 4.The WHERE: its column (00402), its value's type (00403) and, for an INT column, its range (00404). A string longer than its column is not an error in a WHERE: it compares, and matches no row with =.
Output contract for this stage
The full definition is on the output contract page, sections 10, 12, 14 and 19. This stage adds no code: it applies the codes that users already had to every table, in one order.
| Input | Output |
|---|---|
| a table that is not in the catalog | [ERROR:00401] <message> |
| a column its table does not have, anywhere in the statement | [ERROR:00402] <message> |
| a string for an INT column, a number for a VARCHAR column | [ERROR:00403] <message> |
| a number outside 0..4294967295 for any INT column | [ERROR:00404] <message> |
a string longer than its column's VARCHAR(n), in bytes, in an INSERT or an assignment | [ERROR:00405] <message> |
| an INSERT without the table's first column | [ERROR:00406] <message> |
| an INSERT whose row would be over 1000 bytes | [ERROR:00407] <message> |
| a column named twice in an INSERT, or assigned twice in an UPDATE | [ERROR:00408] <message> |
| an UPDATE that assigns the first column | [ERROR:00801] <message> |
| a value list of the wrong length, whatever the table | [ERROR:00303] <message> at col N |
A statement that fails prints its one line, and an optional HINT: line, and nothing else. It changes nothing, and explain prints no plan for it.
What you should see when done
On a new file:
Welcome to droid!>create table products (id int, title varchar(40), price int);Create table statement executed successfully>insert into products (id, colour) values (1, 'red');[ERROR:00402] Unknown column: colour>insert into products (id, price, colour) values (1, 'x', 2);[ERROR:00403] Type mismatch: price is INT>insert into products (id, title) values (1, 'abcdefghijabcdefghijabcdefghijabcdefghijk');[ERROR:00405] String too long: title is VARCHAR(40)>insert into products (title) values ('lamp');[ERROR:00406] Missing key column: id>insert into products (id, price) values (1, 4294967296);[ERROR:00404] Integer out of range: price>select * from products;id | title | price(0 rows)[EXEC] rows_examined=0>insert into products (id, title, price) values (1, 'lamp', 30);Insert statement executed successfully (1 rows)>update products set price = 40, id = 2;[ERROR:00801] Cannot update the key column: id>explain select * from products where price = 'x';[ERROR:00403] Type mismatch: price is INT>select * from products where title = 'abcdefghijabcdefghijabcdefghijabcdefghijk';id | title | price(0 rows)[EXEC] rows_examined=1>select * from products;id | title | price1 | lamp | 30(1 rows)[EXEC] rows_examined=1>.exitexiting.. good bye!
Things to notice. Five rejected INSERTs left the table empty. The UPDATE did not set price to 40 before it reached id: the whole statement failed. explain printed an error and no plan. The 41-byte string that could not be stored could still be compared in a WHERE.
Common bugs in this stage
- Checking while doing. An executor that checks each value as it writes it has already written the first when the second fails. Check the whole statement, then run it.
- Checking all the names, then all the values. The order is per column:
(id, price, colour)with(1, 'x', 2)is 00403, not 00402. - The key checked first. 00406 comes after every column has been checked; an INSERT without the key and with a bad value reports the bad value.
- Limits from the program. A VARCHAR's limit is its own
n: 40 fortitle, 5 for aVARCHAR(5), not 1000 or 27. An INT that is not the key has the key's range. - Characters, not bytes.
éis two bytes. AVARCHAR(40)holds 20 of them, not 40. - Looking for
id. The key is the first column.update orders set oid = 5is 00801, andupdate orders set id = 5is 00402 whenordershas noid. - Validating the WHERE like an INSERT. A string in a WHERE is compared, not stored, so its length is never an error.
- explain that plans first. A plan printed for a statement that cannot run tells the user it could.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C and C++: validation needs the parsed statement and a table's schema, and nothing else: give it no pager and no tree, and a check that reads a row cannot creep in. Return the first code found and let the caller print it.
- Rust: a validation step that takes the parsed statement and the catalog and returns
Result<(), Error>makes the order of checks the order of?s. A validated statement can be a type of its own, so the planner cannot be given one that skipped the step. - Zig: an error union returned from the validation step does the same; the first
trythat fails is the one reported.
Checklist
One line per thing the tests check. The tags name the test cases.
- A table the catalog does not have is 00401, whatever the statementvalidate-table-not-exists
- A value list of the wrong length is the parser's 00303, before the table is looked upvalidate-count-is-the-parsers
- A column its table does not have is 00402, wherever it is namedvalidate-wrong-column-name
- A string for an INT or a number for a VARCHAR is 00403validate-type-mismatch
- Every INT column takes 0 to 4294967295: 00404validate-int-range
- A string longer than its column's VARCHAR(n), in bytes, is 00405validate-varchar-too-long
- A long string in a WHERE is not an errorvalidate-where-long-string
- A column named or assigned twice is 00408validate-duplicate-column
- The key, whatever its name: 00406 and 00801validate-first-column
- An INSERT is checked in the contract's ordervalidate-insert-order
- A row over 1000 bytes is 00407, checked after its keyvalidate-insert-size-last
- An UPDATE is checked in the contract's ordervalidate-update-order
- A SELECT and a DELETE are checked in the contract's ordervalidate-select-delete-order
- A statement that fails changes nothingvalidate-no-side-effect
- explain validates before it plansvalidate-explain-before-planning
- serialize validates its INSERTvalidate-serialize
- Statements that fit the schema pass, at its limitsvalidate-correct-insert
- After a restart, the checks read the catalog in the filevalidate-after-restart