User-130 3 years ago
Great article!
Introduction
If you have ever tried to create a blog-like application or something that needs unique slugs, you might run on two problems:
- How to not repeat yourself? Imagine you have articles, categories, and products, and they all need slugs.
- What to do in a case where two or more entities have the same names used to generate slug?
What is a slug, and what should it look like?
A slug is the part of a URL that identifies a particular page on a website in an easy-to-read form. For example if
a title of your article is This is an Awesome Article With Generic Name
, slug for that title would be
this-is-an-awesome-article-with-generic-name
.
I have two entities with the same names. How to generate slugs for them?
Okay, so you have two products with the same names, and the easiest solution would be that you
prepend or append entity id for generating slugs. So if they have the title of Some Generic Name
and ids of 123
and 234
respectively,
slugs will be 123-some-generic-name
and 234-some-generic-name
in case we prepend ids. This is relatively easy to do and
a fair solution, but I wanted the first product to have a some-generic-name
slug while the second one will
have some-generic-name-1
. In case that is more of them, the counter will increase.
What about not repeating yourself?
So, the idea is to create the mixing class HasSlug
so models that need this feature can extend it. It should look like
this:
@declarative_mixin
class HasSlug:
slug_target_column = "title"
slug = db.Column(
db.String,
unique=True,
nullable=False,
)
slug_target_column
is a column we target on the extended model to generate a slug. I default it to the title
,
but you can override it in your models however you like.
So your Category and Article models should look something like this:
class Category(HasSlug, db.Model):
slug_target_column = "name"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(400))
...
class Article(HasSlug, db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(256), index=True, nullable=False)
...
Notice how it is easy to change slug_target_column
as we did for the Category model.
Let's now make this work!
Implementation
def str_to_slug(string, delimeter = "-"):
slug = re.sub(r"[^\w\d\s]", "", string.strip().lower())
slug = re.sub(" +", " ", slug)
slug = slug.replace(" ", delimeter)
return slug
@event.listens_for(db.session, "before_commit")
def update_slugs(session):
new_items = [item for item in session.new if isinstance(item, HasSlug)]
dirty_items = [item for item in session.dirty if isinstance(item, HasSlug)]
all_items = new_items + dirty_items
if all_items:
slugs_map = {}
for item in all_items:
table = item.__table__
if table not in slugs_map:
slugs_map[table] = set(
c[0] for c in session.execute(db.select(table.c.slug))
)
item_slug = item.slug or ""
title = getattr(item, item.slug_target_column)
slug = str_to_slug(title)
if not item_slug.startswith(slug):
i = 1
while slug in slugs_map[table]:
slug = str_to_slug(title) + "-" + str(i)
i += 1
item.slug = slug
slugs_map[table].add(slug)
In this approach, slug generation occurs before the session commit. First, we check for all new and dirty (updated) items
in session and take only these that are the instances of HasSlug
. Then, we initialize an empty dictionary slug_map
where
keys are table names of entities, and values are all existing slugs for the respective table. We do this so we can track
the duplicates and append the counter. If the item_slug
starts with a generated slug, the title doesn't change,
and we do nothing. Otherwise, we increase the counter and check if that slug is already generated. When the condition
is met, we set the item slug and update slugs_map
so the following entity knows of a new slug.
Conclusion
I find this solution more elegant than the previous one that I've tried. I hope you will find this helpful article. Let me know what you think in the comment section below! Thanks for reading!