Relational Queries in Applications
Learning Objectives
- You can explain how a one-to-many relationship becomes a visible application feature.
- You can write routes that load one deck together with the cards that belong to it.
- You can add a new card to one chosen deck through a form.
So far in this part, the focus has been on relational SQL itself. That raises a practical next question: how does a relationship between tables become something a user can actually use in an application?
In this chapter, the same study tracker project grows from one-table deck pages toward a small relational feature. A deck can contain many cards, and the application should be able to show one chosen deck together with its cards.
The Cards Table
To add cards to the project, the schema needs one new table.
CREATE TABLE cards (
id SERIAL PRIMARY KEY,
deck_id INTEGER NOT NULL REFERENCES decks(id),
question TEXT NOT NULL,
answer TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
The important column here is deck_id.
That column connects one card row to one deck row. In ordinary language, it means that this card belongs to this deck. That is the one-to-many relationship in the project: one deck can have many cards, but each card belongs to one deck.
The
REFERENCES decks(id)part is a foreign key constraint. It tells the database thatdeck_idmust always point to an existing row indecks. We will look into the syntax for foreign key constraints more carefully later; for now, the important reading is that the constraint makes the relationship between the two tables explicit.
By this point, the project should already include the earlier tags migration. In this chapter’s project work, add the cards table through a new migration file called 004_cards.sql.
It is also practical to insert a few seed card rows in that same migration so that the new deck detail page has visible content immediately after the migrations run.
Showing One Deck with Its Cards
Once the cards table exists, the next useful page is a deck detail page. That page should show one chosen deck and the cards that belong to it.
A single JOIN query could combine the two tables, but it would repeat the deck information in every result row — once for each card. The deck-detail page, however, has two visible parts with different shapes: one deck (name, description) at the top, and then a list of cards below. It is easier to match the query shape to the page shape by using two smaller queries: one fetchone() for the deck and one fetchall() for its cards.
One route can do that in two small steps. First load the chosen deck, and then load the cards whose deck_id points to that deck.
@app.get("/decks/{deck_id}")
def show_deck(request: Request, deck_id: int):
with get_connection() as conn:
deck = conn.execute(t"""
SELECT id, name, description
FROM decks
WHERE id = {deck_id}
""").fetchone()
if deck is None:
return RedirectResponse("/decks", status_code=303)
cards = conn.execute(t"""
SELECT id, question, answer
FROM cards
WHERE deck_id = {deck_id}
ORDER BY id
""").fetchall()
return render(request, "deck_detail.html", deck=deck, cards=cards)
This is still a relational feature even though the page uses two queries instead of one join. The second query only makes sense because the relationship between decks.id and cards.deck_id exists: the route is using that relationship to fetch the cards that belong to this chosen deck.
Notice also that the deck is loaded first, and the route returns early if no such deck exists. That way, the cards query does not run for a deck that is not there, and the template never has to handle a missing deck.
A good question to ask after reading the route is what one row in cards represents here. The answer is: one card that belongs to the chosen deck.
That interpretation keeps the query and the page behavior connected.
Rendering the Deck Detail Page
The matching template can show both the deck information and the list of cards.
For example, app/templates/deck_detail.html could look like this:
<!doctype html>
<html lang="en">
<body>
<h1>{{ deck.name }}</h1>
{% if deck.description %}
<p>{{ deck.description }}</p>
{% endif %}
<h2>Cards</h2>
{% if cards %}
<ul>
{% for card in cards %}
<li>
<strong>{{ card.question }}</strong>
- {{ card.answer }}
</li>
{% endfor %}
</ul>
{% else %}
<p>No cards yet.</p>
{% endif %}
</body>
</html>
The template is not finding the related rows by itself. The route already did that. The template is only rendering the deck and cards values that it received. In other words, the route owns the database work, and the template owns the visible page structure.
The {% if cards %} branch is what the two-query design pays off for: an empty list of cards renders as a simple “No cards yet.” message, because cards is just an empty list, not a NULL from an outer-joined row.
Linking to the Deck Page
If the application has a deck detail page, the user should have a clear way to open it.
One simple approach is to add a View deck link to each deck row on /decks.
More concretely, place the link inside the existing {% for deck in decks %} loop in app/templates/decks.html, that is, in the part of the template that already renders one visible item for each deck:
{% for deck in decks %}
<li>
{{ deck.name }}
<a href="/decks/{{ deck.id }}">View deck</a>
</li>
{% endfor %}
That link is useful for two reasons: it gives the user a visible path to the relational page, and it makes the deck_id in the URL concrete instead of abstract.
Adding a Card to One Chosen Deck
The next natural question is how a user adds a new card to this chosen deck.
The simplest answer is a form on the deck detail page.
This form belongs in app/templates/deck_detail.html, below the card list:
<h2>Add card</h2>
<form action="/decks/{{ deck.id }}/cards" method="post">
<label for="question">Question</label>
<input id="question" name="question" type="text" />
<label for="answer">Answer</label>
<textarea id="answer" name="answer"></textarea>
<button type="submit">Add card</button>
</form>
This form is worth reading carefully. The URL already contains deck.id, so the chosen deck comes from the path, while the new card content comes from the form fields. That separation is one of the important application patterns in this chapter.
The Route That Creates the Card
The matching route can then read the deck identifier from the path and the card content from the form.
@app.post("/decks/{deck_id}/cards")
def create_card(
deck_id: int,
question: str = Form(...),
answer: str = Form(...),
):
with get_connection() as conn:
conn.execute(t"""
INSERT INTO cards (deck_id, question, answer)
VALUES ({deck_id}, {question}, {answer})
""")
return RedirectResponse(f"/decks/{deck_id}", status_code=303)
That route can be read in small steps. It receives the chosen deck_id from the URL, receives question and answer from the form, inserts a new row into cards, stores the chosen deck through deck_id, and then redirects back to the same deck page.
The insert uses the parameterized t-string form introduced in the previous part. The three interpolated values — deck_id, question, and answer — are all passed as data, not as SQL text.
This is the point where the one-to-many relationship becomes concrete in the application. Adding a card to a deck really means:
- create a
cardsrow whosedeck_idpoints to the chosen deck.
What to Check
When this feature is complete, it helps to check a few things through the browser:
- does each deck row on
/decksinclude aView decklink? - does
/decks/{deck_id}show the chosen deck and only its cards? - does the page show
No cards yet.when no related rows exist yet? - after submitting the form, does the new card appear on the same deck page?
- if you open another deck, is the new card absent there?
Those checks help verify both sides of the feature:
- the read side, where related rows are shown on the correct page,
- and the create side, where a new related row is attached to the correct deck.
Check Your Understanding
- What does
cards.deck_idmean in ordinary language? - Why does
/decks/{deck_id}use two queries instead of one join here? - Where do
deck_id,question, andanswercome from when a new card is created?
Programming Exercise
This chapter’s programming exercise builds on the project checkpoint from the previous part. Your task is to extend that existing deck CRUD project with a first relational feature: cards that belong to decks.