Skip to content

Commit ac281f4

Browse files
authored
Integrating AvramSlugify directly in. (#786)
1 parent 74d5e4a commit ac281f4

File tree

7 files changed

+234
-0
lines changed

7 files changed

+234
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class CreateArticles::V20220111192510 < Avram::Migrator::Migration::V1
2+
def migrate
3+
create table_for(Article) do
4+
primary_key id : Int64
5+
add_timestamps
6+
add title : String
7+
add sub_heading : String?
8+
add slug : String
9+
end
10+
end
11+
12+
def rollback
13+
drop table_for(Article)
14+
end
15+
end

shard.yml

+3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ dependencies:
3737
lucky_cache:
3838
github: luckyframework/lucky_cache
3939
version: ~> 0.1.0
40+
cadmium_transliterator:
41+
github: cadmiumcr/transliterator
42+
branch: master
4043

4144
development_dependencies:
4245
ameba:

spec/avram/slugify_spec.cr

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
require "../spec_helper"
2+
3+
describe Avram::Slugify do
4+
describe ".set" do
5+
it "does not set anything if slug is already set" do
6+
op = build_op(title: "Writing Specs")
7+
8+
slugify(op.slug, "Writing Specs")
9+
10+
op.slug.value.should eq("writing-specs")
11+
end
12+
13+
it "skips blank slug candidates" do
14+
op = build_op(title: "Software Developer")
15+
16+
slugify(op.slug, ["", op.title])
17+
18+
op.slug.value.should eq("software-developer")
19+
end
20+
21+
describe "with a single slug candidate" do
22+
it "it sets slug from a single attribute" do
23+
op = build_op(title: "Software Developer")
24+
25+
slugify(op.slug, op.title)
26+
27+
op.slug.value.should eq("software-developer")
28+
end
29+
30+
it "it sets slug from a single string" do
31+
op = build_op
32+
33+
slugify(op.slug, "Software Developer")
34+
35+
op.slug.value.should eq("software-developer")
36+
end
37+
38+
it "it sets slug from a single string" do
39+
op = build_op
40+
41+
slugify(op.slug, "Software Developer")
42+
43+
op.slug.value.should eq("software-developer")
44+
end
45+
end
46+
47+
describe "with an array of slug candidates" do
48+
describe "and there is no other record with the same slug" do
49+
it "sets using a String" do
50+
op = build_op
51+
52+
slugify(op.slug, ["Software Developer"])
53+
54+
op.slug.value.should eq("software-developer")
55+
end
56+
57+
it "sets using an attribute" do
58+
op = build_op(title: "Software Developer")
59+
60+
slugify(op.slug, [op.title])
61+
62+
op.slug.value.should eq("software-developer")
63+
end
64+
65+
it "sets when using multiple attributes" do
66+
op = build_op(title: "How Do Magnets Work?", sub_heading: "And Why?")
67+
68+
slugify(op.slug, [[op.title, op.sub_heading]])
69+
70+
op.slug.value.should eq("how-do-magnets-work-and-why")
71+
end
72+
end
73+
74+
describe "and the first slug candidate is not unique" do
75+
it "chooses the first unique one in the array" do
76+
ArticleFactory.create &.slug("music")
77+
ArticleFactory.create &.slug("programming")
78+
op = build_op(title: "Music", sub_heading: "Programming")
79+
80+
slugify(op.slug, [op.title, "programming", [op.title, op.sub_heading]])
81+
82+
op.slug.value.should eq("music-programming")
83+
end
84+
end
85+
86+
describe "and all of the slug candidates are used already" do
87+
it "uses the first present candidate and appends a UUID" do
88+
ArticleFactory.create &.slug("pizza")
89+
ArticleFactory.create &.slug("tacos")
90+
op = build_op(title: "Pizza", sub_heading: "Tacos")
91+
92+
# First string is empty. Added to make sure it is not used with
93+
# the UUID.
94+
slugify(op.slug, ["", op.title, op.sub_heading])
95+
96+
op.slug.value.to_s.should start_with("pizza-")
97+
op.slug.value.to_s.split("-", 2).last.size.should eq(UUID.random.to_s.size)
98+
end
99+
end
100+
101+
describe "all slug candidates are blank" do
102+
it "leaves the slug as nil" do
103+
op = build_op(title: "")
104+
105+
# First string is empty. Added to make sure it is not used with
106+
# the UUID.
107+
slugify(op.slug, ["", op.title])
108+
109+
op.slug.value.should be_nil
110+
end
111+
end
112+
end
113+
114+
it "uses the query to scope uniqueness check" do
115+
ArticleFactory.create &.slug("the-boss").title("A")
116+
117+
op = build_op(title: "The Boss")
118+
slugify(op.slug, op.title, ArticleQuery.new.title("B"))
119+
op.slug.value.should eq("the-boss")
120+
121+
op = build_op(title: "The Boss")
122+
slugify(op.slug, op.title, ArticleQuery.new.title("A"))
123+
op.slug.value.to_s.should start_with("the-boss-") # Has UUID appended
124+
end
125+
end
126+
end
127+
128+
private def slugify(slug, slug_candidates, query = ArticleQuery.new)
129+
Avram::Slugify.set(slug, slug_candidates, query)
130+
end
131+
132+
private def build_op(**named_args)
133+
Article::SaveOperation.new(**named_args)
134+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class ArticleFactory < BaseFactory
2+
def initialize
3+
title "The Great Title"
4+
slug "the-great-title"
5+
end
6+
end

spec/support/models/article.cr

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class Article < BaseModel
2+
COLUMN_SQL = "articles.id, articles.created_at, articles.updated_at, articles.title, articles.slug"
3+
4+
table do
5+
column title : String
6+
column sub_heading : String?
7+
column slug : String
8+
end
9+
end
10+
11+
class ArticleQuery < Article::BaseQuery
12+
end

src/avram.cr

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require "lucky_cache"
66
require "db"
77
require "pg"
88
require "uuid"
9+
require "cadmium_transliterator"
910

1011
require "./ext/db/*"
1112
require "./avram/object_extensions"

src/avram/slugify.cr

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Converts a column value to a URL safe String that
2+
# can be used as a parameter for finding records. A
3+
# `slug` is a `String` column you define on your model
4+
# that will be passed through the URL instead of an `id`.
5+
#
6+
# e.g. /articles/1 -> /articles/how-to-slugify
7+
#
8+
# Use this module in your `SaveOperation#before_save`.
9+
#
10+
# ```
11+
# class Article < BaseModel
12+
# table do
13+
# column title : String
14+
# column slug : String
15+
# end
16+
# end
17+
#
18+
# class SaveArticle < Article::SaveOperation
19+
# before_save do
20+
# Avram::Slugify.set slug,
21+
# using: title,
22+
# query: ArticleQuery.new
23+
# end
24+
# end
25+
# ```
26+
module Avram::Slugify
27+
extend self
28+
29+
def set(slug : Avram::Attribute(String),
30+
using slug_candidate : Avram::Attribute(String) | String,
31+
query : Avram::Queryable) : Nil
32+
set(slug, [slug_candidate], query)
33+
end
34+
35+
def set(slug : Avram::Attribute(String),
36+
using slug_candidates : Array(String | Avram::Attribute(String) | Array(Avram::Attribute(String))),
37+
query : Avram::Queryable) : Nil
38+
if slug.value.blank?
39+
slug_candidates = slug_candidates.map do |candidate|
40+
parameterize(candidate)
41+
end.reject(&.blank?)
42+
43+
slug_candidates.find { |candidate| query.where(slug.name, candidate).none? }
44+
.tap { |candidate| slug.value = candidate }
45+
end
46+
47+
if slug.value.blank? && (candidate = slug_candidates.first?)
48+
slug.value = "#{candidate}-#{UUID.random}"
49+
end
50+
end
51+
52+
private def parameterize(value : String) : String
53+
Cadmium::Transliterator.parameterize(value)
54+
end
55+
56+
private def parameterize(value : Avram::Attribute(String)) : String
57+
parameterize(value.value.to_s)
58+
end
59+
60+
private def parameterize(values : Array(Avram::Attribute(String))) : String
61+
values.join("-") { |value| parameterize(value) }
62+
end
63+
end

0 commit comments

Comments
 (0)