Skip to content

Commit

Permalink
Integrating AvramSlugify directly in. (#786)
Browse files Browse the repository at this point in the history
  • Loading branch information
jwoertink authored Jan 11, 2022
1 parent 74d5e4a commit ac281f4
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 0 deletions.
15 changes: 15 additions & 0 deletions db/migrations/20220111192510_create_articles.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class CreateArticles::V20220111192510 < Avram::Migrator::Migration::V1
def migrate
create table_for(Article) do
primary_key id : Int64
add_timestamps
add title : String
add sub_heading : String?
add slug : String
end
end

def rollback
drop table_for(Article)
end
end
3 changes: 3 additions & 0 deletions shard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ dependencies:
lucky_cache:
github: luckyframework/lucky_cache
version: ~> 0.1.0
cadmium_transliterator:
github: cadmiumcr/transliterator
branch: master

development_dependencies:
ameba:
Expand Down
134 changes: 134 additions & 0 deletions spec/avram/slugify_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
require "../spec_helper"

describe Avram::Slugify do
describe ".set" do
it "does not set anything if slug is already set" do
op = build_op(title: "Writing Specs")

slugify(op.slug, "Writing Specs")

op.slug.value.should eq("writing-specs")
end

it "skips blank slug candidates" do
op = build_op(title: "Software Developer")

slugify(op.slug, ["", op.title])

op.slug.value.should eq("software-developer")
end

describe "with a single slug candidate" do
it "it sets slug from a single attribute" do
op = build_op(title: "Software Developer")

slugify(op.slug, op.title)

op.slug.value.should eq("software-developer")
end

it "it sets slug from a single string" do
op = build_op

slugify(op.slug, "Software Developer")

op.slug.value.should eq("software-developer")
end

it "it sets slug from a single string" do
op = build_op

slugify(op.slug, "Software Developer")

op.slug.value.should eq("software-developer")
end
end

describe "with an array of slug candidates" do
describe "and there is no other record with the same slug" do
it "sets using a String" do
op = build_op

slugify(op.slug, ["Software Developer"])

op.slug.value.should eq("software-developer")
end

it "sets using an attribute" do
op = build_op(title: "Software Developer")

slugify(op.slug, [op.title])

op.slug.value.should eq("software-developer")
end

it "sets when using multiple attributes" do
op = build_op(title: "How Do Magnets Work?", sub_heading: "And Why?")

slugify(op.slug, [[op.title, op.sub_heading]])

op.slug.value.should eq("how-do-magnets-work-and-why")
end
end

describe "and the first slug candidate is not unique" do
it "chooses the first unique one in the array" do
ArticleFactory.create &.slug("music")
ArticleFactory.create &.slug("programming")
op = build_op(title: "Music", sub_heading: "Programming")

slugify(op.slug, [op.title, "programming", [op.title, op.sub_heading]])

op.slug.value.should eq("music-programming")
end
end

describe "and all of the slug candidates are used already" do
it "uses the first present candidate and appends a UUID" do
ArticleFactory.create &.slug("pizza")
ArticleFactory.create &.slug("tacos")
op = build_op(title: "Pizza", sub_heading: "Tacos")

# First string is empty. Added to make sure it is not used with
# the UUID.
slugify(op.slug, ["", op.title, op.sub_heading])

op.slug.value.to_s.should start_with("pizza-")
op.slug.value.to_s.split("-", 2).last.size.should eq(UUID.random.to_s.size)
end
end

describe "all slug candidates are blank" do
it "leaves the slug as nil" do
op = build_op(title: "")

# First string is empty. Added to make sure it is not used with
# the UUID.
slugify(op.slug, ["", op.title])

op.slug.value.should be_nil
end
end
end

it "uses the query to scope uniqueness check" do
ArticleFactory.create &.slug("the-boss").title("A")

op = build_op(title: "The Boss")
slugify(op.slug, op.title, ArticleQuery.new.title("B"))
op.slug.value.should eq("the-boss")

op = build_op(title: "The Boss")
slugify(op.slug, op.title, ArticleQuery.new.title("A"))
op.slug.value.to_s.should start_with("the-boss-") # Has UUID appended
end
end
end

private def slugify(slug, slug_candidates, query = ArticleQuery.new)
Avram::Slugify.set(slug, slug_candidates, query)
end

private def build_op(**named_args)
Article::SaveOperation.new(**named_args)
end
6 changes: 6 additions & 0 deletions spec/support/factories/article_factory.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class ArticleFactory < BaseFactory
def initialize
title "The Great Title"
slug "the-great-title"
end
end
12 changes: 12 additions & 0 deletions spec/support/models/article.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class Article < BaseModel
COLUMN_SQL = "articles.id, articles.created_at, articles.updated_at, articles.title, articles.slug"

table do
column title : String
column sub_heading : String?
column slug : String
end
end

class ArticleQuery < Article::BaseQuery
end
1 change: 1 addition & 0 deletions src/avram.cr
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require "lucky_cache"
require "db"
require "pg"
require "uuid"
require "cadmium_transliterator"

require "./ext/db/*"
require "./avram/object_extensions"
Expand Down
63 changes: 63 additions & 0 deletions src/avram/slugify.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Converts a column value to a URL safe String that
# can be used as a parameter for finding records. A
# `slug` is a `String` column you define on your model
# that will be passed through the URL instead of an `id`.
#
# e.g. /articles/1 -> /articles/how-to-slugify
#
# Use this module in your `SaveOperation#before_save`.
#
# ```
# class Article < BaseModel
# table do
# column title : String
# column slug : String
# end
# end
#
# class SaveArticle < Article::SaveOperation
# before_save do
# Avram::Slugify.set slug,
# using: title,
# query: ArticleQuery.new
# end
# end
# ```
module Avram::Slugify
extend self

def set(slug : Avram::Attribute(String),
using slug_candidate : Avram::Attribute(String) | String,
query : Avram::Queryable) : Nil
set(slug, [slug_candidate], query)
end

def set(slug : Avram::Attribute(String),
using slug_candidates : Array(String | Avram::Attribute(String) | Array(Avram::Attribute(String))),
query : Avram::Queryable) : Nil
if slug.value.blank?
slug_candidates = slug_candidates.map do |candidate|
parameterize(candidate)
end.reject(&.blank?)

slug_candidates.find { |candidate| query.where(slug.name, candidate).none? }
.tap { |candidate| slug.value = candidate }
end

if slug.value.blank? && (candidate = slug_candidates.first?)
slug.value = "#{candidate}-#{UUID.random}"
end
end

private def parameterize(value : String) : String
Cadmium::Transliterator.parameterize(value)
end

private def parameterize(value : Avram::Attribute(String)) : String
parameterize(value.value.to_s)
end

private def parameterize(values : Array(Avram::Attribute(String))) : String
values.join("-") { |value| parameterize(value) }
end
end

0 comments on commit ac281f4

Please sign in to comment.