digital hedgehog illustration

How to Manage Slugs for Database Entities with Flask and SqlAlchemy

This tutorial is about the technique I use to manage slugs.

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!


Comments

User-130

User-130 2 years ago
Great article!

StefanJeremic

StefanJeremic 2 years ago
@User-130 Glad you liked it :)

Leave a Comment

Please Log In to post a comment.