ohm_util 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gems +4 -0
- data/.gitignore +3 -0
- data/CHANGELOG.md +408 -0
- data/CONTRIBUTING +19 -0
- data/LICENSE +19 -0
- data/README.md +570 -0
- data/benchmarks/common.rb +28 -0
- data/benchmarks/create.rb +21 -0
- data/benchmarks/delete.rb +13 -0
- data/examples/activity-feed.rb +157 -0
- data/examples/chaining.rb +162 -0
- data/examples/json-hash.rb +75 -0
- data/examples/one-to-many.rb +118 -0
- data/examples/philosophy.rb +137 -0
- data/examples/redis-logging.txt +179 -0
- data/examples/slug.rb +149 -0
- data/examples/tagging.rb +237 -0
- data/lib/lua/delete.lua +72 -0
- data/lib/lua/save.lua +126 -0
- data/lib/ohm_util.rb +116 -0
- data/makefile +9 -0
- data/ohm-util.gemspec +14 -0
- data/test/association.rb +33 -0
- data/test/connection.rb +16 -0
- data/test/core.rb +24 -0
- data/test/counters.rb +67 -0
- data/test/enumerable.rb +79 -0
- data/test/filtering.rb +185 -0
- data/test/hash_key.rb +31 -0
- data/test/helper.rb +23 -0
- data/test/indices.rb +138 -0
- data/test/json.rb +62 -0
- data/test/list.rb +83 -0
- data/test/model.rb +819 -0
- data/test/set.rb +37 -0
- data/test/thread_safety.rb +67 -0
- data/test/to_hash.rb +29 -0
- data/test/uniques.rb +108 -0
- metadata +97 -0
@@ -0,0 +1,118 @@
|
|
1
|
+
### One to Many Ohm style
|
2
|
+
|
3
|
+
#### Problem
|
4
|
+
|
5
|
+
# Let's say you want to implement a commenting system, and you need to have
|
6
|
+
# comments on different models. In order to do this using an RDBMS you have
|
7
|
+
# one of two options:
|
8
|
+
#
|
9
|
+
# 1. Have multiple comment tables per type i.e. VideoComments, AudioComments,
|
10
|
+
# etc.
|
11
|
+
# 2. Use a polymorphic schema.
|
12
|
+
#
|
13
|
+
# The problem with option 1 is that you'll may possibly run into an explosion
|
14
|
+
# of tables.
|
15
|
+
#
|
16
|
+
# The problem with option 2 is that if you have many comments across the whole
|
17
|
+
# site, you'll quickly hit the limit on a table, and eventually need to shard.
|
18
|
+
|
19
|
+
#### Solution
|
20
|
+
|
21
|
+
# In *Redis*, possibly the best data structure to model a comment would be to
|
22
|
+
# use a *List*, mainly because comments are always presented within the
|
23
|
+
# context of the parent entity, and are typically ordered in a predefined way
|
24
|
+
# (i.e. latest at the top, or latest at the bottom).
|
25
|
+
#
|
26
|
+
|
27
|
+
# Let's start by requiring `Ohm`.
|
28
|
+
require "ohm"
|
29
|
+
|
30
|
+
# We define both a `Video` and `Audio` model, with a `list` of *comments*.
|
31
|
+
class Video < Ohm::Model
|
32
|
+
list :comments, :Comment
|
33
|
+
end
|
34
|
+
|
35
|
+
class Audio < Ohm::Model
|
36
|
+
list :comments, :Comment
|
37
|
+
end
|
38
|
+
|
39
|
+
# The `Comment` model for this example will just contain one attribute called
|
40
|
+
# `body`.
|
41
|
+
class Comment < Ohm::Model
|
42
|
+
attribute :body
|
43
|
+
end
|
44
|
+
|
45
|
+
# Now let's require the test framework we're going to use called
|
46
|
+
# [cutest](http://github.com/djanowski/cutest)
|
47
|
+
require "cutest"
|
48
|
+
|
49
|
+
# And make sure that every run of our test suite has a clean Redis instance.
|
50
|
+
prepare { Ohm.flush }
|
51
|
+
|
52
|
+
# Let's begin testing. The important thing to verify is that
|
53
|
+
# video comments and audio comments don't munge with each other.
|
54
|
+
#
|
55
|
+
# We can see that they don't since each of the `comments` list only has
|
56
|
+
# one element.
|
57
|
+
test "adding all sorts of comments" do
|
58
|
+
video = Video.create
|
59
|
+
|
60
|
+
video_comment = Comment.create(:body => "First Video Comment")
|
61
|
+
video.comments.push(video_comment)
|
62
|
+
|
63
|
+
audio = Audio.create
|
64
|
+
audio_comment = Comment.create(:body => "First Audio Comment")
|
65
|
+
audio.comments.push(audio_comment)
|
66
|
+
|
67
|
+
assert video.comments.include?(video_comment)
|
68
|
+
assert_equal video.comments.size, 1
|
69
|
+
|
70
|
+
assert audio.comments.include?(audio_comment)
|
71
|
+
assert_equal audio.comments.size, 1
|
72
|
+
end
|
73
|
+
|
74
|
+
|
75
|
+
#### Discussion
|
76
|
+
#
|
77
|
+
# As you can see above, the design is very simple, and leaves little to be
|
78
|
+
# desired.
|
79
|
+
|
80
|
+
# Latest first ordering can simply be achieved by using `unshift` instead of
|
81
|
+
# `push`.
|
82
|
+
test "latest first ordering" do
|
83
|
+
video = Video.create
|
84
|
+
|
85
|
+
first = Comment.create(:body => "First")
|
86
|
+
second = Comment.create(:body => "Second")
|
87
|
+
|
88
|
+
video.comments.unshift(first)
|
89
|
+
video.comments.unshift(second)
|
90
|
+
|
91
|
+
assert [second, first] == video.comments.to_a
|
92
|
+
end
|
93
|
+
|
94
|
+
# In addition, since Lists are optimized for doing `LRANGE` operations,
|
95
|
+
# pagination of Comments would be very fast compared to doing a LIMIT / OFFSET
|
96
|
+
# query in SQL (some sites also use `WHERE id > ? LIMIT N` and pass the
|
97
|
+
# previous last ID in the set).
|
98
|
+
test "getting paged chunks of comments" do
|
99
|
+
video = Video.create
|
100
|
+
|
101
|
+
20.times { |i| video.comments.push(Comment.create(:body => "C#{i + 1}")) }
|
102
|
+
|
103
|
+
assert_equal %w(C1 C2 C3 C4 C5), video.comments.range(0, 4).map(&:body)
|
104
|
+
assert_equal %w(C6 C7 C8 C9 C10), video.comments.range(5, 9).map(&:body)
|
105
|
+
end
|
106
|
+
|
107
|
+
#### Caveats
|
108
|
+
|
109
|
+
# Sometimes you need to be able to delete comments. For these cases, you might
|
110
|
+
# possibly need to store a reference back to the parent entity. Also, if you
|
111
|
+
# expect to store millions of comments for a single entity, it might be tricky
|
112
|
+
# to delete comments, as you need to manually loop through the entire LIST.
|
113
|
+
#
|
114
|
+
# Luckily, there is a clean alternative solution, which would be to use a
|
115
|
+
# `SORTED SET`, and to use the timestamp (or the negative of the timestamp) as
|
116
|
+
# the score to maintain the desired order. Deleting a comment from a
|
117
|
+
# `SORTED SET` would be a simple
|
118
|
+
# [ZREM](http://redis.io/commands/zrem) call.
|
@@ -0,0 +1,137 @@
|
|
1
|
+
### Internals: Nest and the Ohm Philosophy
|
2
|
+
|
3
|
+
#### Ohm does not want to hide Redis from you
|
4
|
+
|
5
|
+
# In contrast to the usual philosophy of ORMs in the wild, Ohm actually
|
6
|
+
# just provides a basic object mapping where you can safely tuck away
|
7
|
+
# attributes and declare grouping of data.
|
8
|
+
#
|
9
|
+
# Beyond that, Ohm doesn't try to hide Redis, but rather exposes it in
|
10
|
+
# a simple way, through key hierarchies provided by the library
|
11
|
+
# [Nido](http://github.com/soveran/nido).
|
12
|
+
|
13
|
+
# Let's require `Ohm`. We also require `Ohm::Contrib` so we can make
|
14
|
+
# use of its module `Ohm::Callbacks`.
|
15
|
+
require "ohm"
|
16
|
+
require "ohm/contrib"
|
17
|
+
|
18
|
+
# Let's quickly declare our `Post` model and include `Ohm::Callbacks`.
|
19
|
+
# We define an *attribute* `title` and also *index* it.
|
20
|
+
#
|
21
|
+
# In addition we specify our `Post` to have a list of *comments*.
|
22
|
+
class Post < Ohm::Model
|
23
|
+
include Ohm::Callbacks
|
24
|
+
|
25
|
+
attribute :title
|
26
|
+
index :title
|
27
|
+
|
28
|
+
list :comments, :Comment
|
29
|
+
|
30
|
+
# This is one example of using Redic simple API and
|
31
|
+
# the underlying library `Nido`.
|
32
|
+
def self.latest
|
33
|
+
fetch(redis.call("ZRANGE", key[:latest], 0, -1))
|
34
|
+
end
|
35
|
+
|
36
|
+
protected
|
37
|
+
|
38
|
+
# Here we just quickly push this instance of `Post` to our `latest`
|
39
|
+
# *SORTED SET*. We use the current time as the score.
|
40
|
+
def after_save
|
41
|
+
redis.call("ZADD", model.key[:latest], Time.now.to_i, id)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Since we add every `Post` to our *SORTED SET*, we have to make sure that
|
45
|
+
# we removed it from our `latest` *SORTED SET* as soon as we delete a
|
46
|
+
# `Post`.
|
47
|
+
def after_delete
|
48
|
+
redis.call("ZREM", model.key[:latest], id)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Now let's quickly define our `Comment` model.
|
53
|
+
class Comment < Ohm::Model
|
54
|
+
end
|
55
|
+
|
56
|
+
#### Test it out
|
57
|
+
|
58
|
+
# For this example, we'll use [Cutest](http://github.com/djanowski/cutest)
|
59
|
+
# for our testing framework.
|
60
|
+
require "cutest"
|
61
|
+
|
62
|
+
# To make it simple, we also ensure that every test run has a clean
|
63
|
+
# *Redis* instance.
|
64
|
+
prepare do
|
65
|
+
Ohm.flush
|
66
|
+
end
|
67
|
+
|
68
|
+
# Now let's create a post. `Cutest` by default yields the return value of the
|
69
|
+
# block to each and every one of the test blocks.
|
70
|
+
setup do
|
71
|
+
Post.create
|
72
|
+
end
|
73
|
+
|
74
|
+
# We then verify the behavior for our `Post:latest` ZSET. Our created
|
75
|
+
# post should automatically be part of `Post:latest`.
|
76
|
+
test "created post is inserted into latest" do |post|
|
77
|
+
assert [post.id] == Post.latest.map(&:id)
|
78
|
+
end
|
79
|
+
|
80
|
+
# And it should automatically be removed from it as soon as we delete our
|
81
|
+
# `Post`.
|
82
|
+
test "deleting the created post removes it from latest" do |post|
|
83
|
+
post.delete
|
84
|
+
|
85
|
+
assert Post.latest.empty?
|
86
|
+
end
|
87
|
+
|
88
|
+
# You might be curious what happens when we do `Post.all`. The test here
|
89
|
+
# demonstrates more or less what's happening when you do that.
|
90
|
+
test "querying Post:all using raw Redis commands" do |post|
|
91
|
+
assert [post.id] == Post.all.ids
|
92
|
+
assert [post] == Post.all.to_a
|
93
|
+
end
|
94
|
+
|
95
|
+
#### Understanding `post.comments`.
|
96
|
+
|
97
|
+
# Let's pop the hood and see how we can do *LIST* operations on our
|
98
|
+
# `post.comments` object.
|
99
|
+
|
100
|
+
# Getting the current size of our comments is just a wrapper for
|
101
|
+
# [LLEN](http://redis.io/commands/LLEN).
|
102
|
+
test "checking the number of comments for a given post" do |post|
|
103
|
+
assert_equal 0, post.comments.size
|
104
|
+
assert_equal 0, Post.redis.call("LLEN", post.comments.key)
|
105
|
+
end
|
106
|
+
|
107
|
+
# Also, pushing a comment to our `post.comments` object is equivalent
|
108
|
+
# to doing an [RPUSH](http://redis.io/commands/RPUSH) of its `id`.
|
109
|
+
test "pushing a comment manually and checking for its presence" do |post|
|
110
|
+
comment = Comment.create
|
111
|
+
|
112
|
+
Post.redis.call("RPUSH", post.comments.key, comment.id)
|
113
|
+
assert_equal comment, post.comments.last
|
114
|
+
|
115
|
+
post.comments.push(comment)
|
116
|
+
assert_equal comment, post.comments.last
|
117
|
+
end
|
118
|
+
|
119
|
+
# Now for some interesting judo.
|
120
|
+
test "now what if we want to find all Ohm or Redis posts" do
|
121
|
+
ohm = Post.create(title: "Ohm")
|
122
|
+
redis = Post.create(title: "Redis")
|
123
|
+
|
124
|
+
# Finding all *Ohm* or *Redis* posts now will just be a call to
|
125
|
+
# [SUNIONSTORE](http://redis.io/commands/UNIONSTORE).
|
126
|
+
result = Post.find(title: "Ohm").union(title: "Redis")
|
127
|
+
|
128
|
+
# And voila, they have been found!
|
129
|
+
assert_equal [ohm, redis], result.to_a
|
130
|
+
end
|
131
|
+
|
132
|
+
#### The command reference is your friend
|
133
|
+
|
134
|
+
# If you invest a little time reading through all the different
|
135
|
+
# [Redis commands](http://redis.io/commands).
|
136
|
+
# I'm pretty sure you will enjoy your experience hacking with Ohm, Nido,
|
137
|
+
# Redic and Redis a lot more.
|
@@ -0,0 +1,179 @@
|
|
1
|
+
### Understanding Ohm Internals through Logging
|
2
|
+
|
3
|
+
#### Benefits
|
4
|
+
|
5
|
+
# Ohm is actually a very thin wrapper for Redis, and most of the design
|
6
|
+
# patterns implemented in Ohm are based on the prescribed patterns for using
|
7
|
+
# Redis.
|
8
|
+
#
|
9
|
+
# If you take a little time to grok the internals of Ohm and Redis, the more
|
10
|
+
# effective you will be in weilding it efficiently.
|
11
|
+
|
12
|
+
#### Keys
|
13
|
+
#
|
14
|
+
# Most key-value stores have standardized on a structured design pattern for
|
15
|
+
# organizing data. Let's fire up a console:
|
16
|
+
#
|
17
|
+
# >> irb -r ohm -r logger
|
18
|
+
#
|
19
|
+
# Ohm.redis.client.logger = Logger.new(STDOUT)
|
20
|
+
#
|
21
|
+
# class Post < Ohm::Model
|
22
|
+
# attribute :title
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# Post.create(:title => "Grokking Ohm")
|
26
|
+
#
|
27
|
+
# After executing `Post.create(...)`, you'll see a lot of output from the
|
28
|
+
# logger. Let's go through every line in detail.
|
29
|
+
|
30
|
+
#### Post.create
|
31
|
+
|
32
|
+
# *Generate a new `Post:id`.*
|
33
|
+
INCR Post:id
|
34
|
+
|
35
|
+
# *Lock `Post:2`
|
36
|
+
# (see [SETNX](http://code.google.com/p/redis/wiki/SetnxCommand)
|
37
|
+
# for an explanation of the locking design pattern).
|
38
|
+
SETNX Post:2:_lock 1285060009.409451
|
39
|
+
|
40
|
+
# *Add the newly generated ID 2 to the `Post:all` SET.*
|
41
|
+
SADD Post:all 2
|
42
|
+
|
43
|
+
# *Start transaction.*
|
44
|
+
MULTI
|
45
|
+
|
46
|
+
# *Delete HASH `Post:2`.*
|
47
|
+
DEL Post:2
|
48
|
+
|
49
|
+
# *Set the attributes.*
|
50
|
+
HMSET Post:2 title "Grokking Ohm"
|
51
|
+
|
52
|
+
# *End transaction.*
|
53
|
+
EXEC
|
54
|
+
|
55
|
+
# *Release the lock.*
|
56
|
+
DEL Post:2:_lock
|
57
|
+
|
58
|
+
#### Sets and Lists
|
59
|
+
|
60
|
+
# Let's do a little `SET` and `LIST` manipulation and see what Ohm does.
|
61
|
+
# Here's the code for this section:
|
62
|
+
#
|
63
|
+
# class User < Ohm::Model
|
64
|
+
# collection :posts, Post
|
65
|
+
# end
|
66
|
+
#
|
67
|
+
# class Post < Ohm::Model
|
68
|
+
# attribute :title
|
69
|
+
# reference :user, User
|
70
|
+
#
|
71
|
+
# list :comments, Comment
|
72
|
+
# end
|
73
|
+
#
|
74
|
+
# class Comment < Ohm::Model
|
75
|
+
# end
|
76
|
+
|
77
|
+
#### User.create
|
78
|
+
|
79
|
+
# *Generate a new ID for the `User`.*
|
80
|
+
INCR User:id
|
81
|
+
|
82
|
+
# *Lock User:9.*
|
83
|
+
SETNX User:9:_lock 1285061032.174834
|
84
|
+
|
85
|
+
# *Add this new user to the SET User:all.*
|
86
|
+
SADD User:all 9
|
87
|
+
|
88
|
+
# *Release the lock.*
|
89
|
+
DEL User:9:_lock
|
90
|
+
|
91
|
+
#### Post.create(:title => "Foo", :user => User.create)
|
92
|
+
|
93
|
+
# *Generate an ID for this Post*
|
94
|
+
INCR Post:id
|
95
|
+
|
96
|
+
# *Lock Post:3*
|
97
|
+
SETNX Post:3:_lock 1285061032.180314
|
98
|
+
|
99
|
+
# *Add the ID 3 to the SET Post:all*
|
100
|
+
SADD Post:all 3
|
101
|
+
|
102
|
+
# *Start transaction*
|
103
|
+
MULTI
|
104
|
+
|
105
|
+
# *Remove existing Post:3 HASH*
|
106
|
+
DEL Post:3
|
107
|
+
|
108
|
+
# *Assign the attributes to Post:3*
|
109
|
+
HMSET Post:3 title Foo user_id 9
|
110
|
+
|
111
|
+
# *End transaction*
|
112
|
+
EXEC
|
113
|
+
|
114
|
+
# *Add the ID of this post to the SET index (more on this below)*
|
115
|
+
SADD Post:user_id:OQ== 3
|
116
|
+
|
117
|
+
# *Book keeping for Post:3 indices*
|
118
|
+
SADD Post:3:_indices Post:user_id:OQ==
|
119
|
+
|
120
|
+
# *Release lock*
|
121
|
+
DEL Post:3:_lock
|
122
|
+
|
123
|
+
#### post.comments << Comment.create
|
124
|
+
|
125
|
+
# *Generate an ID for this comment*
|
126
|
+
INCR Comment:id
|
127
|
+
|
128
|
+
# *Lock Comment:1*
|
129
|
+
SETNX Comment:1:_lock 1285061034.335855
|
130
|
+
|
131
|
+
# *Add this comment to the SET Comment:all*
|
132
|
+
SADD Comment:all 1
|
133
|
+
|
134
|
+
# *Release lock*
|
135
|
+
DEL Comment:1:_lock
|
136
|
+
|
137
|
+
# *Append the comment to the LIST Post:3:comments*
|
138
|
+
RPUSH Post:3:comments 1
|
139
|
+
|
140
|
+
#### Understanding indexes
|
141
|
+
|
142
|
+
# reference :user, User
|
143
|
+
#
|
144
|
+
# is actually more or less the same as:
|
145
|
+
#
|
146
|
+
# attribute :user_id
|
147
|
+
# index :user_id
|
148
|
+
#
|
149
|
+
# def user
|
150
|
+
# User[user_id]
|
151
|
+
# end
|
152
|
+
#
|
153
|
+
# def user_id=(user_id)
|
154
|
+
# self.user_id = user_id
|
155
|
+
# end
|
156
|
+
#
|
157
|
+
# To further explain the [example above](#section-29), let's
|
158
|
+
# run through the commands that was issued. Remember that
|
159
|
+
# we had a *user_id* of *9* and a *post_id* of *3*.
|
160
|
+
|
161
|
+
# *OQ== is Base64 of *9*, with newlines removed. The post_id 3 is added
|
162
|
+
# effectively to Post:user_id:9, if we ignore the encoding.*
|
163
|
+
SADD Post:user_id:OQ== 3
|
164
|
+
|
165
|
+
# *Just keep track that Post:user_id:OQ== was created*
|
166
|
+
SADD Post:3:_indices Post:user_id:OQ==
|
167
|
+
|
168
|
+
# *Get all *posts* with user_id *9**
|
169
|
+
SMEMBERS Post:user_id:OQ==
|
170
|
+
|
171
|
+
#### What's next?
|
172
|
+
|
173
|
+
# We tackled a fair bit amount regarding Ohm internals. More or less this is
|
174
|
+
# the foundation of everything else found in Ohm, and other code are just
|
175
|
+
# built around the same fundamental concepts.
|
176
|
+
#
|
177
|
+
# If there's anything else you want to know, you can hang out on #ohm at
|
178
|
+
# irc.freenode.net, or you can visit the
|
179
|
+
# [Ohm google group](http://groups.google.com/group/ohm-ruby).
|
data/examples/slug.rb
ADDED
@@ -0,0 +1,149 @@
|
|
1
|
+
### All Kinds of Slugs
|
2
|
+
|
3
|
+
# The problem of making semantic URLs have definitely been a prevalent one.
|
4
|
+
# There has been quite a lot of solutions around this theme, so we'll discuss
|
5
|
+
# a few simple ways to handle slug generation.
|
6
|
+
|
7
|
+
#### ID Prefixed slugs
|
8
|
+
|
9
|
+
# This is by far the simplest (and most cost-effective way) of generating
|
10
|
+
# slugs. Implementing this is pretty simple too.
|
11
|
+
|
12
|
+
# Let's first require `Ohm`.
|
13
|
+
require "ohm"
|
14
|
+
|
15
|
+
# Now let's define our `Post` model, with just a single
|
16
|
+
# `attribute` *title*.
|
17
|
+
class Post < Ohm::Model
|
18
|
+
attribute :title
|
19
|
+
|
20
|
+
# To make it more convenient, we override the finder syntax,
|
21
|
+
# so doing a `Post["1-my-post-title"]` will in effect just call
|
22
|
+
# `Post[1]`.
|
23
|
+
def self.[](id)
|
24
|
+
super(id.to_i)
|
25
|
+
end
|
26
|
+
|
27
|
+
# This pattern was mostly borrowed from Rails' style of generating
|
28
|
+
# URLs. Here we just concatenate the `id` and a sanitized form
|
29
|
+
# of our title.
|
30
|
+
def to_param
|
31
|
+
"#{id}-#{title.to_s.gsub(/\p{^Alnum}/u, " ").gsub(/\s+/, "-").downcase}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Let's verify our code using the
|
36
|
+
# [Cutest](http://github.com/djanowski/cutest)
|
37
|
+
# testing framework.
|
38
|
+
require "cutest"
|
39
|
+
|
40
|
+
# Also we ensure every test run is guaranteed to have a clean
|
41
|
+
# *Redis* instance.
|
42
|
+
prepare { Ohm.flush }
|
43
|
+
|
44
|
+
# For each and every test, we create a post with
|
45
|
+
# the title "ID Prefixed Slugs". Since it's the last
|
46
|
+
# line of our `setup`, it will also be yielded to
|
47
|
+
# each of our test blocks.
|
48
|
+
setup do
|
49
|
+
Post.create(:title => "ID Prefixed Slugs")
|
50
|
+
end
|
51
|
+
|
52
|
+
# Now let's verify the behavior of our `to_param` method.
|
53
|
+
# Note that we make it dash-separated and lowercased.
|
54
|
+
test "to_param" do |post|
|
55
|
+
assert "1-id-prefixed-slugs" == post.to_param
|
56
|
+
end
|
57
|
+
|
58
|
+
# We also check that our easier finder syntax works.
|
59
|
+
test "finding the post" do |post|
|
60
|
+
assert post == Post[post.to_param]
|
61
|
+
end
|
62
|
+
|
63
|
+
#### We don't have to code it everytime
|
64
|
+
|
65
|
+
# Because of the prevalence, ease of use, and efficiency of this style of slug
|
66
|
+
# generation, it has been extracted to a module in
|
67
|
+
# [Ohm::Contrib](http://github.com/cyx/ohm-contrib/) called `Ohm::Slug`.
|
68
|
+
|
69
|
+
# Let's create a different model to demonstrate how to use it.
|
70
|
+
# (Run `[sudo] gem install ohm-contrib` to install ohm-contrib).
|
71
|
+
|
72
|
+
# When using `ohm-contrib`, we simply require it, and then
|
73
|
+
# directly reference the specific module. In this case, we
|
74
|
+
# use `Ohm::Slug`.
|
75
|
+
require "ohm/contrib"
|
76
|
+
|
77
|
+
class Video < Ohm::Model
|
78
|
+
include Ohm::Slug
|
79
|
+
|
80
|
+
attribute :title
|
81
|
+
|
82
|
+
# `Ohm::Slug` just uses the value of the object's `to_s`.
|
83
|
+
def to_s
|
84
|
+
title.to_s
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Now to quickly verify that everything works similar to our
|
89
|
+
# example above!
|
90
|
+
test "video slugging" do
|
91
|
+
video = Video.create(:title => "A video about ohm")
|
92
|
+
|
93
|
+
assert "1-a-video-about-ohm" == video.to_param
|
94
|
+
assert video == Video[video.id]
|
95
|
+
end
|
96
|
+
|
97
|
+
# That's it, and it works similarly to the example above.
|
98
|
+
|
99
|
+
#### What if I want a slug without an ID prefix?
|
100
|
+
|
101
|
+
# For this case, we can still make use of `Ohm::Slug`'s ability to
|
102
|
+
# make a clean string.
|
103
|
+
|
104
|
+
# Let's create an `Article` class which has a single attribute `title`.
|
105
|
+
class Article < Ohm::Model
|
106
|
+
include Ohm::Callbacks
|
107
|
+
|
108
|
+
attribute :title
|
109
|
+
|
110
|
+
# Now before creating this object, we just call `Ohm::Slug.slug` directly.
|
111
|
+
# We also check if the generated slug exists, and repeatedly try
|
112
|
+
# appending numbers.
|
113
|
+
protected
|
114
|
+
def before_create
|
115
|
+
temp = Ohm::Slug.slug(title)
|
116
|
+
self.id = temp
|
117
|
+
|
118
|
+
counter = 0
|
119
|
+
while Article.exists?(id)
|
120
|
+
self.id = "%s-%d" % [temp, counter += 1]
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
# We now verify the behavior of our `Article` class
|
126
|
+
# by creating an article with the same title 3 times.
|
127
|
+
test "create an article with the same title" do
|
128
|
+
a1 = Article.create(:title => "All kinds of slugs")
|
129
|
+
a2 = Article.create(:title => "All kinds of slugs")
|
130
|
+
a3 = Article.create(:title => "All kinds of slugs")
|
131
|
+
|
132
|
+
assert a1.id == "all-kinds-of-slugs"
|
133
|
+
assert a2.id == "all-kinds-of-slugs-1"
|
134
|
+
assert a3.id == "all-kinds-of-slugs-2"
|
135
|
+
end
|
136
|
+
|
137
|
+
#### Conclusion
|
138
|
+
|
139
|
+
# Slug generation comes in all different flavors.
|
140
|
+
#
|
141
|
+
# 1. The first solution is good enough for most cases. The primary advantage
|
142
|
+
# of this solution is that we don't have to check for ID clashes.
|
143
|
+
#
|
144
|
+
# 2. The second solution may be needed for cases where you must make
|
145
|
+
# the URLs absolutely clean and readable, and you hate having those
|
146
|
+
# number prefixes.
|
147
|
+
#
|
148
|
+
# *NOTE:* The example we used for the second solution has potential
|
149
|
+
# race conditions. I'll leave fixing it as an exercise to you.
|