Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to automatically generate slug from another field #258

Merged
merged 12 commits into from
Sep 18, 2024
18 changes: 18 additions & 0 deletions docs/docs/models-and-databases/reference/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,24 @@ A `slug` field allows to persist _valid_ slug values (ie. strings that can only

The `max_size` argument is optional and defaults to 50 characters. It allows to specify the maximum size of the persisted email addresses. This maximum size is used for the corresponding column definition and when it comes to validate field values.

#### `slugify`

The `slugify` argument allows specifying the field from which the slug should be generated. This is useful when you want the slug to be automatically derived from another field.

```crystal

class Article < Marten::Model
field :title, :string
field :slug, :slug, slugify: :title
end

article = Article.create!(title: "My Article")

article.slug # => "my-article-48e810f2" - the suffix is a random string
```

When an Article object is saved, the slug field will automatically generate a slug based on the title field if no custom slug is provided.

:::info
As slug fields are usually used to query records, they are indexed by default. You can use the [`index`](#index) option (`index: false`) to disable auto-indexing.
:::
Expand Down
54 changes: 54 additions & 0 deletions spec/marten/core/sluggable_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
require "./spec_helper"

describe Marten::Core::Sluggable do
describe ".generate_slug" do
value = "Test Title 123"
max_size = 20
slug = Marten::Core::Sluggable.generate_slug(value, max_size)

it "removes non-alphanumeric characters" do
value_with_special_chars = "Test@Title#123!"
slug = Marten::Core::Sluggable.generate_slug(value_with_special_chars, max_size)
slug.starts_with?("testtitle12-").should be_true
end

it "converts the string to lowercase" do
slug.starts_with?("testtitle12-").should be_true
end

it "replaces whitespace and hyphens with a single hyphen" do
value_with_spaces_and_hyphens = "Test - Title 123"
slug = Marten::Core::Sluggable.generate_slug(value_with_spaces_and_hyphens, max_size)
slug.starts_with?("test-title-").should be_true
end

it "strips leading and trailing hyphens and underscores" do
value_with_hyphens = "-Test Title 123-"
slug = Marten::Core::Sluggable.generate_slug(value_with_hyphens, max_size)
slug.starts_with?("test-title-").should be_true
end

it "removes non-ASCII characters" do
value_with_non_ascii = "Test Títle 123"
slug = Marten::Core::Sluggable.generate_slug(value_with_non_ascii, max_size)
slug.starts_with?("test-ttle-1-").should be_true
end

it "limits the slug length to max_size" do
slug.size.should eq(max_size)
end

it "does not exceed max_size even with long input" do
long_value = "This is a very long title that should be truncated"
slug = Marten::Core::Sluggable.generate_slug(long_value, max_size)
slug.size.should eq(max_size)
end

it "does not truncate the slug when max_size is large enough" do
long_value = "This is a very long title that should not be truncated"
slug = Marten::Core::Sluggable.generate_slug(long_value, 100)

slug.starts_with?("this-is-a-very-long-title-that-should-not-be-truncated").should be_true
end
end
end
90 changes: 90 additions & 0 deletions spec/marten/db/field/slug_spec.cr
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "./spec_helper"
require "./slug_spec/**"

describe Marten::DB::Field::Slug do
describe "#max_size" do
Expand All @@ -23,6 +24,95 @@ describe Marten::DB::Field::Slug do
end
end

describe "slugify" do
with_installed_apps Marten::DB::Field::SlugSpec::App

it "automatically generates a slug from the title field and assigns it to the slug field if no slug is given" do
article = Marten::DB::Field::SlugSpec::Article.new(title: "My First Article")

article.save

article.slug.not_nil!.starts_with?("my-first-article-").should be_true
end

it "raises an error if an invalid field is targetted" do
article = Marten::DB::Field::SlugSpec::ArticleInvalidSlugField.new(title: "My First Article")

expect_raises(Marten::DB::Errors::UnknownField) do
article.save
end
end

it "automatically generating a slug does not raise an error" do
article = Marten::DB::Field::SlugSpec::Article.new(title: "My First Article")

article.save

article.errors.size.should eq(0)
end

it "automatically generating a slug with a blank slug value does not raise an error" do
article = Marten::DB::Field::SlugSpec::Article.new(title: "My First Article", slug: "")

article.save

article.errors.size.should eq(0)
end

it "truncates the slug to fit the max size of 50 and appends a random suffix" do
article = Marten::DB::Field::SlugSpec::Article.new(
title: "My First Article: Exploring the Intricacies of Quantum Mechanics"
)

article.save

article.slug.not_nil!.includes?("quantum").should_not be_true
article.slug.not_nil!.size.should eq(50)
end

it "does not truncate the slug if max size is greater than the string length" do
article = Marten::DB::Field::SlugSpec::ArticleLongSlug.new(
title: "My First Article: Exploring the Intricacies of Quantum Mechanics"
)

article.save

article.slug.not_nil!.includes?("quantum").should be_true
end

it "removes non-ASCII characters and slugifies the title" do
article = Marten::DB::Field::SlugSpec::Article.new(title: "Überraschungsmoment")

article.save

article.slug.not_nil!.starts_with?("berraschungsmoment").should be_true
end

it "removes emoji and special characters and slugifies the title" do
article = Marten::DB::Field::SlugSpec::Article.new(title: "🚀 TRAVEL & PLACES")

article.save

article.slug.not_nil!.starts_with?("travel-places").should be_true
end

it "trims leading and trailing whitespace and slugifies the title" do
article = Marten::DB::Field::SlugSpec::Article.new(title: " Test Article ")

article.save

article.slug.not_nil!.starts_with?("test-article").should be_true
end

it "retains a custom slug if provided" do
article = Marten::DB::Field::SlugSpec::Article.new(title: "My First Article", slug: "custom-slug")

article.save

article.slug.not_nil!.should eq("custom-slug")
end
end

describe "#validate" do
it "does not add an error to the record if the string contains a valid slug" do
obj = Tag.new(name: nil)
Expand Down
5 changes: 5 additions & 0 deletions spec/marten/db/field/slug_spec/app.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
require "./models/**"

class Marten::DB::Field::SlugSpec::App < Marten::App
label :marten_db_field_slug_spec
end
7 changes: 7 additions & 0 deletions spec/marten/db/field/slug_spec/models/article.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module Marten::DB::Field::SlugSpec
class Article < Marten::Model
field :id, :big_int, primary_key: true, auto: true
field :title, :string, max_size: 255
field :slug, :slug, slugify: :title
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module Marten::DB::Field::SlugSpec
class ArticleInvalidSlugField < Marten::Model
field :id, :big_int, primary_key: true, auto: true
field :title, :string, max_size: 255
field :slug, :slug, slugify: :invalid, max_size: 100
end
end
7 changes: 7 additions & 0 deletions spec/marten/db/field/slug_spec/models/article_long_slug.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module Marten::DB::Field::SlugSpec
class ArticleLongSlug < Marten::Model
field :id, :big_int, primary_key: true, auto: true
field :title, :string, max_size: 255
field :slug, :slug, slugify: :title, max_size: 100
end
end
2 changes: 2 additions & 0 deletions spec/test_project.cr
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Marten.configure :test do |config|
db.user = env_settings["MARIADB_DB_USER"].as(String)
db.password = env_settings["MARIADB_DB_PASSWORD"].as(String)
db.host = env_settings["MARIADB_DB_HOST"].as(String)
db.options = {"encoding" => "utf8mb4"}
end

config.database :other do |db|
Expand All @@ -41,6 +42,7 @@ Marten.configure :test do |config|
db.user = env_settings["MYSQL_DB_USER"].as(String)
db.password = env_settings["MYSQL_DB_PASSWORD"].as(String)
db.host = env_settings["MYSQL_DB_HOST"].as(String)
db.options = {"encoding" => "utf8mb4"}
end

config.database :other do |db|
Expand Down
31 changes: 31 additions & 0 deletions src/marten/core/sluggable.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module Marten
module Core
# The Sluggable module provides functionality for generating URL-friendly slugs from strings.
module Sluggable
extend self
treagod marked this conversation as resolved.
Show resolved Hide resolved

NON_ALPHANUMERIC_RE = /[^\w\s-]/
WHITESPACE_HYPHEN_RE = /[-\s]+/
NON_ASCII_RE = /[^\x00-\x7F]/
treagod marked this conversation as resolved.
Show resolved Hide resolved
treagod marked this conversation as resolved.
Show resolved Hide resolved

# Generates a slug from the given value, ensuring the resulting slug does not exceed the specified max_size.
#
# The slug is created by:
# 1. Removing non-alphanumeric characters (except whitespace and hyphens).
# 2. Converting the string to lowercase.
# 3. Replacing sequences of whitespace and hyphens with a single hyphen.
# 4. Removing non-ASCII characters.
# 5. Truncating the slug to fit within the max_size, minus the size of a randomly generated suffix.
# 6. Stripping trailing hyphens and underscores of the slug without suffix, and appending the suffix.
def generate_slug(value, max_size)
suffix = "-#{Random::Secure.hex(4)}"
treagod marked this conversation as resolved.
Show resolved Hide resolved

slug = value.gsub(NON_ALPHANUMERIC_RE, "").downcase
slug = slug.gsub(WHITESPACE_HYPHEN_RE, "-")
slug = slug.gsub(NON_ASCII_RE, "")

slug[...(max_size - suffix.size)].strip("-_") + suffix
end
end
end
end
14 changes: 9 additions & 5 deletions src/marten/db/field/base.cr
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,7 @@ module Marten
def perform_validation(record : Model)
value = record.get_field_value(id)

if value.nil? && !@null
record.errors.add(id, null_error_message(record), type: :null)
elsif empty_value?(value) && !@blank
record.errors.add(id, blank_error_message(record), type: :blank)
end
validate_presence(record, value)

validate(record, value)
end
Expand Down Expand Up @@ -147,6 +143,14 @@ module Marten
def validate(record, value)
end

protected def validate_presence(record : Model, value)
if value.nil? && !@null
record.errors.add(id, null_error_message(record), type: :null)
elsif empty_value?(value) && !@blank
record.errors.add(id, blank_error_message(record), type: :blank)
end
end

# :nodoc:
macro check_definition(field_id, kwargs)
end
Expand Down
24 changes: 20 additions & 4 deletions src/marten/db/field/slug.cr
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ require "./string"
module Marten
module DB
module Field
# Represents an slug field.
# Represents a slug field.
class Slug < String
private getter slugify
treagod marked this conversation as resolved.
Show resolved Hide resolved

def initialize(
@id : ::String,
@max_size : ::Int32 = 50,
Expand All @@ -14,11 +16,21 @@ module Marten
@null = false,
@unique = false,
@index = true,
@db_column = nil
@db_column = nil,
@slugify : Symbol? = nil
)
end

macro check_definition(field_id, kwargs)
# No-op max_size automatic checks...
end

def validate(record, value)
if slugify?(value)
slug = Core::Sluggable.generate_slug(record.get_field_value(slugify.not_nil!).to_s, max_size)
record.set_field_value(id, slug)
treagod marked this conversation as resolved.
Show resolved Hide resolved
end

return if !value.is_a?(::String)

# Leverage string's built-in validations (max size).
Expand All @@ -29,8 +41,12 @@ module Marten
end
end

macro check_definition(field_id, kwargs)
# No-op max_size automatic checks...
protected def validate_presence(record : Model, value)
super if slugify.nil?
end

private def slugify?(value)
slugify && (value.nil? || (value.is_a?(::String) && value.blank?))
end
end
end
Expand Down
Loading