
At the end of Part 4 we said: the query is hardcoded; change a condition and you rewrite a chunk. Let's make that pain concrete. Now you want three different results, so you write three nearly-identical blocks:
// find price under 50
function byPrice(rows) {
return rows.filter(r => r.price < 50);
}
// find id === 2
function byId(rows) {
return rows.filter(r => r.id === 2);
}
// find title containing "Database"
function byTitle(rows) {
return rows.filter(r => r.title.includes("Database"));
}Three blocks, and the only difference is that one line r.xxx === xxx. That's "query logic hardcoded" — every new way to search is one more copied loop.
Look at those three — what actually changes is the condition itself; the rest (take an array, check each item, gather results) is all boilerplate.
So can we turn "condition" into a parameter? JS naturally supports this — a condition is "a function that takes a row and returns true/false."
// one function serves all: the condition is up to the caller
function query(rows, predicate) {
const result = [];
for (const row of rows) {
if (predicate(row)) result.push(row);
}
return result;
}
// now the three searches only write the condition, no loop:
const cheap = query(books, r => r.price < 50);
const theTwo = query(books, r => r.id === 2);
const dbTitle = query(books, r => r.title.includes("Database"));See what this little step did: "how to query (the loop)" and "what to query (the condition)" parted ways.
predicate supplied by the caller (what you want);query (how to find).You've already written a miniature query engine. It has one "engine" but serves countless queries.
This step is what a real database calls the split between "declarative" and "procedural." You just made the first crossing yourself: from "I tell you how to do it" to "I only tell you what I want."
Above, one layer is still missing. predicate is written on the fly by the caller, but "which columns does this table have, and what types" is currently buried in the data (relying on the object's own fields).
A real database declares the table schema first, so queries can know "these columns, these types." Let's do the same — first define the "table" object:
// our "table": a name + column definitions + data
function createTable(name, columns) {
return {
name,
columns, // e.g. [{ name: "id", type: "number" }, ...]
rows: []
};
}
// declare a books table
const books = createTable("books", [
{ name: "id", type: "number" },
{ name: "title", type: "string" },
{ name: "price", type: "number" }
]);With column definitions, the query engine can do "smart" things — like return only the columns you want (projection), rather than dumping whole rows at you:
// query engine: supports chosen columns (projection) + condition (predicate)
function select(table, wantedColumns, predicate) {
const result = [];
for (const row of table.rows) {
if (!predicate || predicate(row)) {
const out = {};
for (const col of wantedColumns) out[col] = row[col];
result.push(out);
}
}
return result;
}
select(books, ["title", "price"], r => r.price < 50);
// [{ title: "Database Internals", price: 59 }, { title: "A Philosophy...", price: 39 }]Note the payoff of this layer isn't just "less code," it's that the engine now "knows" the table structure — which lays the groundwork for "jumping by index later" (the engine must hold the schema to decide how to walk).
In the real world you write SELECT title, price FROM books WHERE price < 50. Our engine can already answer that — just add a thin "shell" to line up that description with the call:
function queryTable(table, { columns, where }) {
return select(table, columns, where);
}
// "SELECT title, price FROM books WHERE price < 50"
const result = queryTable(books, {
columns: ["title", "price"],
where: r => r.price < 50
});You've now "built" an engine that understands "which columns, what condition." The remaining hard part — the theme of the next few articles — is: can the "how to find" part stop being so dumb?
We worked hard to free "how to find" from the code, so that the engine gets a chance to pick a smarter search. But right now the engine's "how to find" is still that honest loop — it scans the whole table every time.
There it is — Part 4's "Problem 2," now caught. Because you've separated "what you want" from the code, you can — and eventually will — swap the implementation inside select without the caller changing a single line.
Remember this: decoupling "how to find" from "what you want" is the precondition for every database optimization. Only when decoupled can the engine secretly replace the "naive loop" with "hop along the index," while your query stays identical.
Starting next article, we really swap out that "naive loop."
createTable to declare a users table (id, name, age), and use queryTable to find "people older than 30, returning only name and age."count(table, where) that returns the count of matching rows. Think about how much code it shares with select.select implementation to "scan in a smarter order" (e.g. skip some rows in the existing array), and observe that the caller (outer queryTable) doesn't change at all.Keywords: declarative、predicate、projection、schema、query engine、decoupling
We can already say "what we want," and the engine is dutifully naive. Next, we invite the first real structure — a hash table — to solve "I know the exact id, give me that row" type key lookups. You'll build O(1) yourself, but immediately hit an awkward wall: it can find "equal to," but can't find "less than 50." That awkwardness will push you to the turning point of Part 7.