Adding Additional Features to the Repository#

While most of the functionality you need is built into the repository, there are still cases where you need to add in additional functionality. Let’s explore ways that we can add functionality on top of the repository pattern.

Tip

The full code for this tutorial can be found below in the Full Code section.

Slug Fields#

app.py#
 1from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column
 2@declarative_mixin
 3class SlugKey:
 4    """Slug unique Field Model Mixin."""
 5
 6    __abstract__ = True
 7    slug: Mapped[str] = mapped_column(String(length=100), nullable=False, unique=True, sort_order=-9)
 8
 9
10# record created, and `updated_at` is the last time the record was modified.
11class BlogPost(UUIDAuditBase, SlugKey):
12    title: Mapped[str]
13    content: Mapped[str]

In this example, we are using a BlogPost model to hold blog post titles and contents. The primary key for this model is a UUID type. UUID and int are good options for primary keys, but there are a number of reasons you may not want to use them in your routes. For instance, it can be a security problem to expose integer-based primary keys in the URL. While UUIDs don’t have this same problem, they are not user-friendly or easy-to-remember, and create complex URLs. One way to solve this is to add a user friendly unique identifier to the table that can be used for urls. This is often called a “slug”.

First, we’ll create a SlugKey field mixin that adds a text-based, URL-friendly, unique column slug to the table. We want to ensure we create a slug value based on the data passed to the title field. To demonstrate what we are trying to accomplish, we want a record that has a blog title of “Follow the Yellow Brick Road!” to have the slugified value of “follow-the-yellow-brick-road”.

app.py#
 1    """Extends the repository to include slug model features.."""
 2
 3    async def get_available_slug(
 4        self,
 5        value_to_slugify: str,
 6        **kwargs: Any,
 7    ) -> str:
 8        """Get a unique slug for the supplied value.
 9
10        If the value is found to exist, a random 4 digit character is appended to the end.
11        There may be a better way to do this, but I wanted to limit the number of
12        additional database calls.
13
14        Args:
15            value_to_slugify (str): A string that should be converted to a unique slug.
16            **kwargs: stuff
17
18        Returns:
19            str: a unique slug for the supplied value. This is safe for URLs and other
20            unique identifiers.
21        """
22        slug = self._slugify(value_to_slugify)
23        if await self._is_slug_unique(slug):
24            return slug
25        # generate a random 4 digit alphanumeric string to make the slug unique and
26        # avoid another DB lookup.
27        random_string = "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
28        return f"{slug}-{random_string}"
29
30    @staticmethod
31    def _slugify(value: str) -> str:
32        """slugify.
33
34        Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
35        dashes to single dashes. Remove characters that aren't alphanumerics,
36        underscores, or hyphens. Convert to lowercase. Also strip leading and
37        trailing whitespace, dashes, and underscores.
38
39        Args:
40            value (str): the string to slugify
41
42        Returns:
43            str: a slugified string of the value parameter
44        """
45        value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
46        value = re.sub(r"[^\w\s-]", "", value.lower())
47        return re.sub(r"[-\s]+", "-", value).strip("-_")
48
49    async def _is_slug_unique(
50        self,
51        slug: str,
52        **kwargs: Any,
53    ) -> bool:
54        return await self.get_one_or_none(slug=slug) is None

Since the BlogPost.title field is not marked as unique, this means that we’ll have to test the slug value for uniqueness before the insert. If the initial slug is found, a random set of digits are appended to the end of the slug to make it unique.

app.py#
1    """Create a new blog post."""
2    _data = data.model_dump(exclude_unset=True, by_alias=False, exclude_none=True)
3    _data["slug"] = await blog_post_repo.get_available_slug(_data["title"])

We are all set to use this in our routes now. First, we’ll convert our incoming Pydantic model to a dictionary. Next, we’ll fetch a unique slug for our text. Finally, we insert the model with the added slug.

Note

Using this method does introduce an additional query on each insert. This should be considered when determining which fields actually need this type of functionality.

Full Code#

Full Code (click to expand)
app.py#
 1from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column
 2@declarative_mixin
 3class SlugKey:
 4    """Slug unique Field Model Mixin."""
 5
 6    __abstract__ = True
 7    slug: Mapped[str] = mapped_column(String(length=100), nullable=False, unique=True, sort_order=-9)
 8
 9
10# record created, and `updated_at` is the last time the record was modified.
11class BlogPost(UUIDAuditBase, SlugKey):
12    title: Mapped[str]
13    content: Mapped[str]
14
15
16    """Extends the repository to include slug model features.."""
17
18    async def get_available_slug(
19        self,
20        value_to_slugify: str,
21        **kwargs: Any,
22    ) -> str:
23        """Get a unique slug for the supplied value.
24
25        If the value is found to exist, a random 4 digit character is appended to the end.
26        There may be a better way to do this, but I wanted to limit the number of
27        additional database calls.
28
29        Args:
30            value_to_slugify (str): A string that should be converted to a unique slug.
31            **kwargs: stuff
32
33        Returns:
34            str: a unique slug for the supplied value. This is safe for URLs and other
35            unique identifiers.
36        """
37        slug = self._slugify(value_to_slugify)
38        if await self._is_slug_unique(slug):
39            return slug
40        # generate a random 4 digit alphanumeric string to make the slug unique and
41        # avoid another DB lookup.
42        random_string = "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
43        return f"{slug}-{random_string}"
44
45    @staticmethod
46    def _slugify(value: str) -> str:
47        """slugify.
48
49        Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
50        dashes to single dashes. Remove characters that aren't alphanumerics,
51        underscores, or hyphens. Convert to lowercase. Also strip leading and
52        trailing whitespace, dashes, and underscores.
53
54        Args:
55            value (str): the string to slugify
56
57        Returns:
58            str: a slugified string of the value parameter
59        """
60        value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
61        value = re.sub(r"[^\w\s-]", "", value.lower())
62        return re.sub(r"[-\s]+", "-", value).strip("-_")
63
64    async def _is_slug_unique(
65        self,
66        slug: str,
67        **kwargs: Any,
68    ) -> bool:
69        return await self.get_one_or_none(slug=slug) is None
70
71
72    """Create a new blog post."""
73    _data = data.model_dump(exclude_unset=True, by_alias=False, exclude_none=True)
74    _data["slug"] = await blog_post_repo.get_available_slug(_data["title"])