ohm 1.3.0 → 1.3.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +14 -6
- data/.gems +4 -0
- data/.gitignore +4 -0
- data/CHANGELOG +52 -0
- data/benchmarks/common.rb +33 -0
- data/benchmarks/create.rb +21 -0
- data/benchmarks/delete.rb +13 -0
- data/examples/activity-feed.rb +162 -0
- data/examples/chaining.rb +203 -0
- data/examples/json-hash.rb +102 -0
- data/examples/one-to-many.rb +124 -0
- data/examples/philosophy.rb +149 -0
- data/examples/redis-logging.txt +179 -0
- data/examples/slug.rb +149 -0
- data/examples/tagging.rb +234 -0
- data/lib/ohm.rb +38 -4
- data/ohm.gemspec +18 -0
- data/test/model.rb +2 -2
- metadata +30 -15
- data/test/ranks.rb +0 -21
- data/test/setup.rb +0 -48
checksums.yaml
CHANGED
@@ -1,7 +1,15 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
ZDVlZWJhMjlkNWUzM2Y2NzY3ODVmN2E3YmIxYjQ3ZmU3OGQ3N2JiOA==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
ZGMyODFlYTAwYzU0MzE4MTQ0Y2FhZTNlZTdhODU0YzgwYTgyZjA4Mw==
|
7
|
+
!binary "U0hBNTEy":
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
NGY2YmU4YTE0NWQ3N2E4Y2QyYWViZDBiOWUwM2NmMTFhNjY5YjNmMTIxNmE3
|
10
|
+
OTRhNmJiMjQ2ZjcwNDQwMjU4YTQ3ZWRjZmJjMmZlOTIxMTY3OTA5ZGJkNDYy
|
11
|
+
Yjg0ZDllODdkNjM4NGFmNWM4YzY5MTQ0ODBlYTNkYjhkODhmOGE=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
ZTJiYWNjNGUxZTk1ZTUxNTQ5Nzk2NTZmOGQ1YTAzY2QzY2Y2NDk4MGE5MDU3
|
14
|
+
Zjk1ODY3MThlYTdmOGZhZWFiY2QyNzg1MTg2YjA5NjFlODE4NDNjNmE4N2Mx
|
15
|
+
MmJlNjg3ZjViYzc4NmU5ZGNlNmU1MmEwMmNkMDBhOTg1ZDUyZjk=
|
data/.gems
ADDED
data/.gitignore
ADDED
data/CHANGELOG
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
1.3.1
|
2
|
+
|
3
|
+
- Improve memory consumption when indexing persisted attributes.
|
4
|
+
|
5
|
+
No migration is needed and old indices will be cleaned up as you save
|
6
|
+
instances.
|
7
|
+
|
8
|
+
1.3.0
|
9
|
+
|
10
|
+
- Add Model.attributes.
|
11
|
+
|
12
|
+
1.2.0
|
13
|
+
|
14
|
+
- Enumerable fix.
|
15
|
+
- Merge Ohm::PipelinedFetch into Ohm::Collection.
|
16
|
+
- Fix Set, MultiSet, and List enumerable behavior.
|
17
|
+
- Change dependencies to use latest cutest.
|
18
|
+
|
19
|
+
1.1.0
|
20
|
+
|
21
|
+
- Compatible with redis-rb 3.
|
22
|
+
|
23
|
+
1.0.0
|
24
|
+
|
25
|
+
- Fetching a batch of objects is now done through one pipeline, effectively
|
26
|
+
reducing the IO to just 2 operations (one for SMEMBERS / LRANGE, one for
|
27
|
+
the actual HGET of all the individual HASHes.)
|
28
|
+
- write_remote / read_remote have been replaced with set / get respectively.
|
29
|
+
- Ohm::Model.unique has been added.
|
30
|
+
- Ohm::Model::Set has been renamed to Ohm::Set
|
31
|
+
- Ohm::Model::List has been renamed to Ohm::List
|
32
|
+
- Ohm::Model::Collection is gone.
|
33
|
+
- Ohm::Validations is gone. Ohm now uses Scrivener::Validations.
|
34
|
+
- Ohm::Key is gone. Ohm now uses Nest directly.
|
35
|
+
- No more concept of volatile keys.
|
36
|
+
- Ohm::Model::Wrapper is gone.
|
37
|
+
- Use Symbols for constants instead of relying on Ohm::Model.const_missing.
|
38
|
+
- #sort / #sort_by now uses `limit` as it's used in redis-rb, e.g. you
|
39
|
+
have to pass in an array like so: sort(limit: [0, 1]).
|
40
|
+
- Set / List have been trimmed to contain only the minimum number
|
41
|
+
of necessary methods.
|
42
|
+
- You can no longer mutate a collection / set as before, e.g. doing
|
43
|
+
User.find(...).add(User[1]) will throw an error.
|
44
|
+
- The #union operation has been added. You can now chain it with your filters.
|
45
|
+
- Temporary keys when doing finds are now automatically cleaned up.
|
46
|
+
- Counters are now stored in their own key instead, i.e. in
|
47
|
+
User:<id>:counters.
|
48
|
+
- JSON support has to be explicitly required by doing `require
|
49
|
+
"ohm/json"`.
|
50
|
+
- All save / delete / update operations are now done using
|
51
|
+
transactions (see http://redis.io/topics/transactions).
|
52
|
+
- All indices are now stored without converting the values to base64.
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require "bench"
|
2
|
+
require_relative "../lib/ohm"
|
3
|
+
|
4
|
+
Ohm.connect(:port => 6379, :db => 15)
|
5
|
+
Ohm.flush
|
6
|
+
|
7
|
+
class Event < Ohm::Model
|
8
|
+
attribute :name
|
9
|
+
attribute :location
|
10
|
+
|
11
|
+
index :name
|
12
|
+
index :location
|
13
|
+
|
14
|
+
def validate
|
15
|
+
assert_present :name
|
16
|
+
assert_present :location
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class Sequence
|
21
|
+
def initialize
|
22
|
+
@value = 0
|
23
|
+
end
|
24
|
+
|
25
|
+
def succ!
|
26
|
+
Thread.exclusive { @value += 1 }
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.[](name)
|
30
|
+
@@sequences ||= Hash.new { |hash, key| hash[key] = Sequence.new }
|
31
|
+
@@sequences[name]
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require_relative "common"
|
2
|
+
|
3
|
+
benchmark "Create Events" do
|
4
|
+
i = Sequence[:events].succ!
|
5
|
+
|
6
|
+
Event.create(:name => "Redis Meetup #{i}", :location => "London #{i}")
|
7
|
+
end
|
8
|
+
|
9
|
+
benchmark "Find by indexed attribute" do
|
10
|
+
Event.find(:name => "Redis Meetup 1").first
|
11
|
+
end
|
12
|
+
|
13
|
+
benchmark "Mass update" do
|
14
|
+
Event[1].update(:name => "Redis Meetup II")
|
15
|
+
end
|
16
|
+
|
17
|
+
benchmark "Load events" do
|
18
|
+
Event[1].name
|
19
|
+
end
|
20
|
+
|
21
|
+
run 5000
|
@@ -0,0 +1,162 @@
|
|
1
|
+
### Building an activity feed
|
2
|
+
|
3
|
+
#### Common solutions using a relational design
|
4
|
+
|
5
|
+
# When faced with this application requirement, the most common approach by
|
6
|
+
# far have been to create an *activities* table, and rows in this table would
|
7
|
+
# reference a *user*. Activities would typically be generated for each
|
8
|
+
# follower (or friend) when a certain user performs an action, like posting a
|
9
|
+
# new status update.
|
10
|
+
|
11
|
+
#### Problems
|
12
|
+
|
13
|
+
# The biggest issue with this design, is that the *activities* table will
|
14
|
+
# quickly get very huge, at which point you would need to shard it on
|
15
|
+
# *user_id*. Also, inserting thousands of entries per second would quickly
|
16
|
+
# bring your database to its knees.
|
17
|
+
|
18
|
+
#### Ohm Solution
|
19
|
+
|
20
|
+
# As always we need to require `Ohm`.
|
21
|
+
require "ohm"
|
22
|
+
|
23
|
+
# We create a `User` class, with a `set` for all the other users he
|
24
|
+
# would be `following`, and another `set` for all his `followers`.
|
25
|
+
class User < Ohm::Model
|
26
|
+
set :followers, User
|
27
|
+
set :following, User
|
28
|
+
|
29
|
+
# Because a `User` literally has a `list` of activities, using a Redis
|
30
|
+
# `list` to model the activities would be a good choice. We default to
|
31
|
+
# getting the first 100 activities, and use
|
32
|
+
# [lrange](http://code.google.com/p/redis/wiki/LrangeCommand) directly.
|
33
|
+
def activities(start = 0, limit = 100)
|
34
|
+
key[:activities].lrange(start, start + limit)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Broadcasting a message to all the `followers` of a user would simply
|
38
|
+
# be prepending the message for each if his `followers`. We also use
|
39
|
+
# the Redis command
|
40
|
+
# [lpush](http://code.google.com/p/redis/wiki/RpushCommand) directly.
|
41
|
+
def broadcast(str)
|
42
|
+
followers.each do |user|
|
43
|
+
user.key[:activities].lpush(str)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Given that *Jane* wants to follow *John*, we simply do the following
|
48
|
+
# steps:
|
49
|
+
#
|
50
|
+
# 1. *John* is added to *Jane*'s `following` list.
|
51
|
+
# 2. *Jane* is added to *John*'s `followers` list.
|
52
|
+
def follow(other)
|
53
|
+
following << other
|
54
|
+
other.followers << self
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
#### Testing
|
60
|
+
|
61
|
+
# We'll use cutest for our testing framework.
|
62
|
+
require "cutest"
|
63
|
+
|
64
|
+
# The database is flushed before each test.
|
65
|
+
prepare { Ohm.flush }
|
66
|
+
|
67
|
+
# We define two users, `john` and `jane`, and yield them so all
|
68
|
+
# other tests are given access to these 2 users.
|
69
|
+
setup do
|
70
|
+
john = User.create
|
71
|
+
jane = User.create
|
72
|
+
|
73
|
+
[john, jane]
|
74
|
+
end
|
75
|
+
|
76
|
+
# Let's verify our model for `follow`. When `jane` follows `john`,
|
77
|
+
# the following conditions should hold:
|
78
|
+
#
|
79
|
+
# 1. The followers list of `john` is comprised *only* of `jane`.
|
80
|
+
# 2. The list of users `jane` is following is comprised *only* of `john`.
|
81
|
+
test "jane following john" do |john, jane|
|
82
|
+
jane.follow(john)
|
83
|
+
|
84
|
+
assert [john] == jane.following.to_a
|
85
|
+
assert [jane] == john.followers.to_a
|
86
|
+
end
|
87
|
+
|
88
|
+
# Broadcasting a message should simply notify all the followers of the
|
89
|
+
# `broadcaster`.
|
90
|
+
test "john broadcasting a message" do |john, jane|
|
91
|
+
jane.follow(john)
|
92
|
+
john.broadcast("Learning about Redis and Ohm")
|
93
|
+
|
94
|
+
assert jane.activities.include?("Learning about Redis and Ohm")
|
95
|
+
end
|
96
|
+
|
97
|
+
#### Total Denormalization: Adding HTML
|
98
|
+
|
99
|
+
# This may be a real edge case design decision, but for some scenarios this
|
100
|
+
# may work. The beauty of this solution is that you only have to generate the
|
101
|
+
# output once, and successive refreshes of the end user will help you save
|
102
|
+
# some CPU cycles.
|
103
|
+
#
|
104
|
+
# This example of course assumes that the code that generates this does all
|
105
|
+
# the conditional checks (possibly changing the point of view like *Me:*
|
106
|
+
# instead of *John says:*).
|
107
|
+
test "broadcasting the html directly" do |john, jane|
|
108
|
+
jane.follow(john)
|
109
|
+
|
110
|
+
snippet = '<a href="/1">John</a> says: How\'s it going ' +
|
111
|
+
'<a href="/user/2">jane</a>?'
|
112
|
+
|
113
|
+
john.broadcast(snippet)
|
114
|
+
|
115
|
+
assert jane.activities.include?(snippet)
|
116
|
+
end
|
117
|
+
|
118
|
+
#### Saving Space
|
119
|
+
|
120
|
+
# In most cases, users don't really care about keeping their entire activity
|
121
|
+
# history. This application requirement would be fairly trivial to implement.
|
122
|
+
|
123
|
+
# Let's reopen our `User` class and define a new broadcast method.
|
124
|
+
class User
|
125
|
+
# We define a constant where we set the maximum number of activity entries.
|
126
|
+
MAX = 10
|
127
|
+
|
128
|
+
# Using `MAX` as the reference, we check if the number of activities exceeds
|
129
|
+
# `MAX`, and use
|
130
|
+
# [ltrim](http://code.google.com/p/redis/wiki/LtrimCommand) to truncate
|
131
|
+
# the activities.
|
132
|
+
def broadcast(str)
|
133
|
+
followers.each do |user|
|
134
|
+
user.key[:activities].lpush(str)
|
135
|
+
|
136
|
+
if user.key[:activities].llen > MAX
|
137
|
+
user.key[:activities].ltrim(0, MAX - 1)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Now let's verify that this new behavior is enforced.
|
144
|
+
test "pushing 11 activities maintains the list to 10" do |john, jane|
|
145
|
+
jane.follow(john)
|
146
|
+
|
147
|
+
11.times { john.broadcast("Flooding your feed!") }
|
148
|
+
|
149
|
+
assert 10 == jane.activities.size
|
150
|
+
end
|
151
|
+
|
152
|
+
|
153
|
+
#### Conclusion
|
154
|
+
|
155
|
+
# As you can see, choosing a more straightforward approach (in this case,
|
156
|
+
# actually having a list per user, instead of maintaining a separate
|
157
|
+
# `activities` table) will greatly simplify the design of your system.
|
158
|
+
#
|
159
|
+
# As a final note, keep in mind that the Ohm solution would still need
|
160
|
+
# sharding for large datasets, but that would be again trivial to implement
|
161
|
+
# using [redis-rb](http://github.com/ezmobius/redis-rb)'s distributed support
|
162
|
+
# and sharding it against the *user_id*.
|
@@ -0,0 +1,203 @@
|
|
1
|
+
### Chaining Ohm Sets
|
2
|
+
|
3
|
+
#### Doing the straight forward approach
|
4
|
+
|
5
|
+
# Let's design our example around the following requirements:
|
6
|
+
#
|
7
|
+
# 1. a `User` has many orders.
|
8
|
+
# 2. an `Order` can be pending, authorized or captured.
|
9
|
+
# 3. a `Product` is referenced by an `Order`.
|
10
|
+
|
11
|
+
#### Doing it the normal way
|
12
|
+
|
13
|
+
# Let's first require `Ohm`.
|
14
|
+
require "ohm"
|
15
|
+
|
16
|
+
# A `User` has a `collection` of *orders*. Note that a collection
|
17
|
+
# is actually just a convenience, which implemented simply will look like:
|
18
|
+
#
|
19
|
+
# def orders
|
20
|
+
# Order.find(:user_id => self.id)
|
21
|
+
# end
|
22
|
+
#
|
23
|
+
class User < Ohm::Model
|
24
|
+
collection :orders, Order
|
25
|
+
end
|
26
|
+
|
27
|
+
# The product for our purposes will only contain a name.
|
28
|
+
class Product < Ohm::Model
|
29
|
+
attribute :name
|
30
|
+
end
|
31
|
+
|
32
|
+
# We define an `Order` with just a single `attribute` called state, and
|
33
|
+
# also add an `index` so we can search an order given its state.
|
34
|
+
#
|
35
|
+
# The `reference` to the `User` is actually required for the `collection`
|
36
|
+
# of *orders* in the `User` declared above, because the `reference` defines
|
37
|
+
# an index called `:user_id`.
|
38
|
+
#
|
39
|
+
# We also define a `reference` to a `Product`.
|
40
|
+
class Order < Ohm::Model
|
41
|
+
attribute :state
|
42
|
+
index :state
|
43
|
+
|
44
|
+
reference :user, User
|
45
|
+
reference :product, Product
|
46
|
+
end
|
47
|
+
|
48
|
+
##### Testing what we have so far.
|
49
|
+
|
50
|
+
# For the purposes of this tutorial, we'll use cutest for our test framework.
|
51
|
+
require "cutest"
|
52
|
+
|
53
|
+
# Make sure that every run of our test suite has a clean Redis instance.
|
54
|
+
prepare { Ohm.flush }
|
55
|
+
|
56
|
+
# Let's create a *user*, a *pending*, *authorized* and a captured order.
|
57
|
+
# We also create two products named *iPod* and *iPad*.
|
58
|
+
setup do
|
59
|
+
@user = User.create
|
60
|
+
|
61
|
+
@ipod = Product.create(:name => "iPod")
|
62
|
+
@ipad = Product.create(:name => "iPad")
|
63
|
+
|
64
|
+
@pending = Order.create(:user => @user, :state => "pending",
|
65
|
+
:product => @ipod)
|
66
|
+
@authorized = Order.create(:user => @user, :state => "authorized",
|
67
|
+
:product => @ipad)
|
68
|
+
@captured = Order.create(:user => @user, :state => "captured",
|
69
|
+
:product => @ipad)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Now let's try and grab all pending orders, and also pending
|
73
|
+
# *iPad* and *iPod* ones.
|
74
|
+
test "finding pending orders" do
|
75
|
+
assert @user.orders.find(state: "pending").include?(@pending)
|
76
|
+
|
77
|
+
assert @user.orders.find(:state => "pending",
|
78
|
+
:product_id => @ipod.id).include?(@pending)
|
79
|
+
|
80
|
+
assert @user.orders.find(:state => "pending",
|
81
|
+
:product_id => @ipad.id).empty?
|
82
|
+
end
|
83
|
+
|
84
|
+
# Now we try and find captured and authorized orders. The tricky part
|
85
|
+
# is trying to find an order that is either *captured* or *authorized*,
|
86
|
+
# since `Ohm` as of this writing doesn't support unions in its
|
87
|
+
# finder syntax.
|
88
|
+
test "finding authorized and/or captured orders" do
|
89
|
+
assert @user.orders.find(:state => "authorized").include?(@authorized)
|
90
|
+
assert @user.orders.find(:state => "captured").include?(@captured)
|
91
|
+
|
92
|
+
assert @user.orders.find(:state => ["authorized", "captured"]).empty?
|
93
|
+
|
94
|
+
auth_or_capt = @user.orders.key.volatile[:auth_or_capt]
|
95
|
+
auth_or_capt.sunionstore(
|
96
|
+
@user.orders.find(:state => "authorized").key,
|
97
|
+
@user.orders.find(:state => "captured").key
|
98
|
+
)
|
99
|
+
|
100
|
+
assert auth_or_capt.smembers.include?(@authorized.id)
|
101
|
+
assert auth_or_capt.smembers.include?(@captured.id)
|
102
|
+
end
|
103
|
+
|
104
|
+
#### Creating shortcuts
|
105
|
+
|
106
|
+
# You can of course define methods to make that code more readable.
|
107
|
+
class User < Ohm::Model
|
108
|
+
def authorized_orders
|
109
|
+
orders.find(:state => "authorized")
|
110
|
+
end
|
111
|
+
|
112
|
+
def captured_orders
|
113
|
+
orders.find(:state => "captured")
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# And we can now test these new methods.
|
118
|
+
test "finding authorized and/or captured orders" do
|
119
|
+
assert @user.authorized_orders.include?(@authorized)
|
120
|
+
assert @user.captured_orders.include?(@captured)
|
121
|
+
end
|
122
|
+
|
123
|
+
# In most cases this is fine, but if you want to have a little fun,
|
124
|
+
# then we can play around with some chainability.
|
125
|
+
|
126
|
+
#### Chaining Kung-Fu
|
127
|
+
|
128
|
+
# The `Ohm::Model::Set` takes a *Redis* key and a *class monad*
|
129
|
+
# for its arguments.
|
130
|
+
#
|
131
|
+
# We can simply subclass it and define the monad to always be an
|
132
|
+
# `Order` so we don't have to manually set it everytime.
|
133
|
+
class UserOrders < Ohm::Model::Set
|
134
|
+
def initialize(key)
|
135
|
+
super key, Ohm::Model::Wrapper.wrap(Order)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Here is the crux of the chaining pattern. Instead of
|
139
|
+
# just doing a straight up `find(:state => "pending")`, we return
|
140
|
+
# `UserOrders` again.
|
141
|
+
def pending
|
142
|
+
self.class.new(model.index_key_for(:state, "pending"))
|
143
|
+
end
|
144
|
+
|
145
|
+
def authorized
|
146
|
+
self.class.new(model.index_key_for(:state, "authorized"))
|
147
|
+
end
|
148
|
+
|
149
|
+
def captured
|
150
|
+
self.class.new(model.index_key_for(:state, "captured"))
|
151
|
+
end
|
152
|
+
|
153
|
+
# Now we wrap the implementation of doing an `SUNIONSTORE` and also
|
154
|
+
# make it return a `UserOrders` object.
|
155
|
+
#
|
156
|
+
# NOTE: `volatile` just returns the key prepended with a `~:`, so in
|
157
|
+
# this case it would be `~:Order:accepted`.
|
158
|
+
def accepted
|
159
|
+
model.key.volatile[:accepted].sunionstore(
|
160
|
+
authorized.key, captured.key
|
161
|
+
)
|
162
|
+
|
163
|
+
self.class.new(model.key.volatile[:accepted])
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
# Now let's re-open the `User` class and add a customized `orders` method.
|
168
|
+
class User < Ohm::Model
|
169
|
+
def orders
|
170
|
+
UserOrders.new(Order.index_key_for(:user_id, id))
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# Ok! Let's put all of that chaining code to good use.
|
175
|
+
test "finding pending orders using a chainable style" do
|
176
|
+
assert @user.orders.pending.include?(@pending)
|
177
|
+
assert @user.orders.pending.find(:product_id => @ipod.id).include?(@pending)
|
178
|
+
|
179
|
+
assert @user.orders.pending.find(:product_id => @ipad.id).empty?
|
180
|
+
end
|
181
|
+
|
182
|
+
test "finding authorized and/or captured orders using a chainable style" do
|
183
|
+
assert @user.orders.authorized.include?(@authorized)
|
184
|
+
assert @user.orders.captured.include?(@captured)
|
185
|
+
|
186
|
+
assert @user.orders.accepted.include?(@authorized)
|
187
|
+
assert @user.orders.accepted.include?(@captured)
|
188
|
+
|
189
|
+
accepted = @user.orders.accepted
|
190
|
+
|
191
|
+
assert accepted.find(:product_id => @ipad.id).include?(@authorized)
|
192
|
+
assert accepted.find(:product_id => @ipad.id).include?(@captured)
|
193
|
+
end
|
194
|
+
|
195
|
+
#### Conclusion
|
196
|
+
|
197
|
+
# This design pattern is something that really depends upon the situation. In
|
198
|
+
# the example above, you can add more complicated querying on the `UserOrders`
|
199
|
+
# class.
|
200
|
+
#
|
201
|
+
# The most important takeaway here is the ease of which we can weild the
|
202
|
+
# different components of Ohm, and mold it accordingly to our preferences,
|
203
|
+
# without having to monkey-patch anything.
|