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#
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` 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”.
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.
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)
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` 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"])