Data in Multiple Tables

Tags for Cards in Applications


Learning Objectives

  • You can explain how a many-to-many relationship becomes a visible application feature.
  • You can use a linking table to show which tags belong to one chosen card.
  • You can add an existing tag to one chosen card through a form.

The previous chapter introduced one-to-many application work with decks and cards. A sensible next question is how the same project can express a richer relationship: what if one card can have many tags, and one tag can belong to many cards?

That is a many-to-many relationship. In this chapter, the study tracker grows one step further by attaching the tags (built in the previous part) to cards from the previous chapter. In other words, the project keeps the earlier tag functionality and reuses it in a richer relational feature.

The Linking Table

The existing project already has cards and tags. To connect them, the schema needs one more table.

CREATE TABLE card_tags (
card_id INTEGER NOT NULL REFERENCES cards(id),
tag_id INTEGER NOT NULL REFERENCES tags(id),
PRIMARY KEY (card_id, tag_id)
);

This table is different from cards and tags because it does not store a new main entity of its own. Instead, it stores the relationship rows. One row says that one chosen card is connected to one chosen tag.

For example, if the table contains the row (7, 3), that can be read in ordinary language as: card 7 has tag 3.

Once there are many such rows, one card may have many tags and one tag may appear on many cards.

The composite PRIMARY KEY (card_id, tag_id) is new here. It says that the pair of values together uniquely identifies one relationship row, which also prevents the same card-tag link from being stored twice.

In the project, add this table through a new migration file called 005_card_tags.sql. That migration should also insert a few seed relationships so the many-to-many structure is visible immediately.

What one linking-table row means

0 / 5 points

A table card_tags(card_id, tag_id) contains the row (7, 3).

What is the best interpretation of that row?

Showing One Card with Its Tags

Once the linking table exists, the next useful page is a card detail page. That page can show the card itself and the tags that belong to it.

One route can do that in three small steps. First load the chosen card, then load the tags already attached to it, and finally load the tags that are still available to attach.

@app.get("/cards/{card_id}")
def show_card(request: Request, card_id: int):
with get_connection() as conn:
card = conn.execute(t"""
SELECT
c.id,
c.question,
c.answer,
d.id AS deck_id,
d.name AS deck_name
FROM cards AS c
INNER JOIN decks AS d ON d.id = c.deck_id
WHERE c.id = {card_id}
""").fetchone()
if card is None:
return RedirectResponse("/decks", status_code=303)
tags = conn.execute(t"""
SELECT
t.id,
t.name
FROM card_tags AS ct
INNER JOIN tags AS t ON t.id = ct.tag_id
WHERE ct.card_id = {card_id}
ORDER BY t.name
""").fetchall()
available_tags = conn.execute(t"""
SELECT
t.id,
t.name
FROM tags AS t
WHERE NOT EXISTS (
SELECT 1
FROM card_tags AS ct
WHERE ct.card_id = {card_id}
AND ct.tag_id = t.id
)
ORDER BY t.name
""").fetchall()
return render(
request,
"card_detail.html",
card=card,
tags=tags,
available_tags=available_tags,
)

This route is a good worked example because each query has a different job. The first query loads one card together with the deck it belongs to, using an inner join because every card belongs to exactly one deck. The second query follows the many-to-many path from card_tags to tags. The third query uses NOT EXISTS to find the tags that are not yet attached to the chosen card, so the form can offer only those.

As in the previous chapter, the card is loaded first, and the route returns early if no such card exists. The later queries then run only for a card that is really there.

After reading the route, it helps to ask what one row in card_tags represents. The answer is simple: one relationship between one chosen card and one chosen tag.

What the card page route loads

0 / 5 points

A route for /cards/{card_id} loads:

  • one card row,
  • the tags already attached to that card,
  • and the tags still available to attach.

Why is that a sensible structure?

Linking to the Card Page

If the application has a card detail page, the user needs a clear way to open it.

The most direct place for that link is the existing deck detail page from Chapter 9. Each listed card can include a View card link.

More concretely, place the link inside the existing {% for card in cards %} loop in app/templates/deck_detail.html, that is, in the part of the template that already renders one visible item for each card:

{% for card in cards %}
<li>
<strong>{{ card.question }}</strong>
- {{ card.answer }}
<a href="/cards/{{ card.id }}">View card</a>
</li>
{% endfor %}

That keeps the relationship path visible in the application itself. The user starts from one deck, sees the cards that belong to it, and then moves to one chosen card.

Rendering the Card Detail Page

The matching template can show the card, its deck, its tags, and the add-tag form.

For example, app/templates/card_detail.html could look like this:

<!doctype html>
<html lang="en">
<body>
<h1>{{ card.question }}</h1>
<p>{{ card.answer }}</p>
<p>
Deck:
<a href="/decks/{{ card.deck_id }}">{{ card.deck_name }}</a>
</p>
<h2>Tags</h2>
{% if tags %}
<ul>
{% for tag in tags %}
<li>{{ tag.name }}</li>
{% endfor %}
</ul>
{% else %}
<p>No tags yet.</p>
{% endif %}
{% if available_tags %}
<h2>Add tag</h2>
<form method="post" action="/cards/{{ card.id }}/tags">
<label for="tag_id">Tag</label>
<select id="tag_id" name="tag_id">
{% for tag in available_tags %}
<option value="{{ tag.id }}">{{ tag.name }}</option>
{% endfor %}
</select>
<button type="submit">Add tag</button>
</form>
{% endif %}
</body>
</html>

The same separation of concerns applies here as in Chapter 9. The route is still responsible for choosing and loading the right rows. The template is only responsible for showing the values it receives in a readable page.

The page also makes the many-to-many relationship visible in ordinary language. A card page can list several tags, and those tags come from the linking table rather than from extra columns stored directly on the card row.

Path values and form values in a many-to-many route

0 / 5 points

Consider this route:

@app.post("/cards/{card_id}/tags")
def add_tag_to_card(card_id: int, tag_id: int = Form(...)):
...

Where do the values come from?

The Route That Adds a Tag to a Card

The next natural question is how a user can attach an existing tag to one chosen card.

The matching route reads the chosen card from the path and the chosen tag from the form:

@app.post("/cards/{card_id}/tags")
def add_tag_to_card(card_id: int, tag_id: int = Form(...)):
with get_connection() as conn:
conn.execute(t"""
INSERT INTO card_tags (card_id, tag_id)
VALUES ({card_id}, {tag_id})
ON CONFLICT DO NOTHING
""")
return RedirectResponse(f"/cards/{card_id}", status_code=303)

This route is worth reading carefully. The card_id comes from the URL path because the page is already about one chosen card. The tag_id comes from the form because the user selects one existing tag from the dropdown.

So the route does not create a new tag. Instead, it creates one new relationship row in card_tags. That is the key difference between creating an entity and creating a many-to-many connection between two existing entities.

The ON CONFLICT DO NOTHING clause makes the operation safe to retry: if the same card-tag link already exists, the composite primary key on card_tags would normally prevent the insert with an error, and this clause tells PostgreSQL to quietly skip the insert instead.

Why a linking table is needed

0 / 5 points

Why is a linking table such as card_tags(card_id, tag_id) useful here instead of adding one tag_id column directly to cards?

What to Check

When this feature is complete, it helps to check a few things through the browser:

  • does the deck detail page include a View card link for each listed card?
  • does /cards/{card_id} show the chosen card and its current tags?
  • does the page show No tags yet. when no tags are attached yet?
  • does the add-tag form use the label Tag and the button text Add tag?
  • after submitting the form, does the chosen tag appear on that same card page?

These checks verify both sides of the many-to-many feature. The read side shows how tags are reconstructed through the linking table. The write side shows how one new relationship row changes the visible page.

Check Your Understanding

  1. What does one row in card_tags represent?
  2. Why is a linking table needed here instead of adding one tag_id column directly to cards?
  3. Where do card_id and tag_id come from when a tag is added to a card?

Programming Exercise

This chapter’s programming exercise builds on the finished Chapter 9 project state. Your task is to extend that project with a many-to-many feature by connecting existing tags to cards.

Tags for cards

0 / 40 points

Return the next checkpoint of the study tracker project as a zip. The returned project must continue the finished Chapter 9 project state and then extend it with a many-to-many feature: existing tags attached to cards.

In this exercise, you should build on top of the existing project. Do not start from scratch. The grader starts the application, applies the SQL migrations, and then checks the visible browser behavior.

Your task is to:

  • keep the existing tag functionality working,
  • keep the existing migrations through migrations/004_cards.sql,
  • add a new migration file migrations/005_card_tags.sql,
  • create a linking table that stores at least card_id and tag_id,
  • insert a few seed card-tag relationships into that table,
  • add a View card link for each card row on the existing deck detail page,
  • add a card detail page at /cards/{card_id},
  • show the tags that belong to the chosen card on that page,
  • show No tags yet. when the chosen card has no tags,
  • and add a form that lets the user attach one existing tag to the chosen card.

The root of the returned zip must contain:

  • requirements.txt
  • the folder app
  • the folder migrations

You may also include convenience files such as compose.yaml, a Dockerfile, and a startup script for local development. These are useful for your own workflow, but the grader does not depend on them.

The grader checks the following:

  • The application starts successfully from a clean database using the SQL files in migrations.
  • The project includes migrations/005_card_tags.sql.
  • The database contains a card_tags table after the migrations run.
  • The existing /tags page still works.
  • The existing /decks/{deck_id} page includes a View card link for each listed card.
  • The View card link leads to /cards/{card_id} for the chosen card.
  • The card detail page shows the chosen card and the tags that belong to it.
  • The card detail page shows No tags yet. when a chosen card has no tags.
  • The add-tag form on the card detail page includes the label Tag and the submit button text Add tag.
  • A user can add one existing tag to one chosen card through the browser, and the new tag then appears on that same card page.

Return the project as a zip so that the root of the zip contains the files and folders listed above, not an extra enclosing project directory.