octiron 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.codeclimate.yml +31 -0
- data/.gitignore +39 -0
- data/.rspec +2 -0
- data/.rubocop.yml +78 -0
- data/.travis.yml +11 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +70 -0
- data/LICENSE +30 -0
- data/README.md +159 -0
- data/Rakefile +25 -0
- data/lib/octiron.rb +11 -0
- data/lib/octiron/events/bus.rb +161 -0
- data/lib/octiron/support/camel_case.rb +27 -0
- data/lib/octiron/support/constantize.rb +43 -0
- data/lib/octiron/support/identifiers.rb +45 -0
- data/lib/octiron/transmogrifiers/registry.rb +201 -0
- data/lib/octiron/version.rb +12 -0
- data/lib/octiron/world.rb +94 -0
- data/octiron.gemspec +50 -0
- data/spec/events_bus_spec.rb +322 -0
- data/spec/spec_helper.rb +5 -0
- data/spec/support_camel_case_spec.rb +24 -0
- data/spec/support_constantize_spec.rb +64 -0
- data/spec/support_identifiers_spec.rb +70 -0
- data/spec/transmogrifiers_registry_spec.rb +311 -0
- data/spec/world_spec.rb +58 -0
- metadata +191 -0
data/lib/octiron.rb
ADDED
@@ -0,0 +1,161 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
#
|
3
|
+
# octiron
|
4
|
+
# https://github.com/jfinkhaeuser/octiron
|
5
|
+
#
|
6
|
+
# Copyright (c) 2016 Jens Finkhaeuser and other octiron contributors.
|
7
|
+
# All rights reserved.
|
8
|
+
#
|
9
|
+
|
10
|
+
require 'octiron/support/identifiers'
|
11
|
+
|
12
|
+
require 'collapsium/recursive_sort'
|
13
|
+
require 'collapsium/prototype_match'
|
14
|
+
|
15
|
+
module Octiron::Events
|
16
|
+
|
17
|
+
##
|
18
|
+
# Implements and in-process pub-sub events broadcaster allowing multiple
|
19
|
+
# observers to subscribe to different events.
|
20
|
+
class Bus
|
21
|
+
# @return (String) the default namespace to search for events
|
22
|
+
attr_reader :default_namespace
|
23
|
+
|
24
|
+
##
|
25
|
+
# @param default_namespace (Symbol) The default namespace to look in for
|
26
|
+
# Event classes.
|
27
|
+
def initialize(default_namespace = ::Octiron::Events)
|
28
|
+
@default_namespace = default_namespace.to_s
|
29
|
+
@handlers = {}
|
30
|
+
end
|
31
|
+
|
32
|
+
##
|
33
|
+
# Subscribe an event handler to an event.
|
34
|
+
# @param event_id (Class, String, other) A class or String naming an event
|
35
|
+
# class.
|
36
|
+
# @param handler_object (Object) Handler object that must implement a
|
37
|
+
# `#call` method accepting an instance of the event class provided in
|
38
|
+
# the first parameter. If nil, a block needs to be provided.
|
39
|
+
# @param handler_proc (Proc) Handler block that accepts an instance of the
|
40
|
+
# event class provided in the first parameter. If nil, a handler object
|
41
|
+
# must be provided.
|
42
|
+
# @return The class represented by the event_id, as a String name.
|
43
|
+
def subscribe(event_id, handler_object = nil, &handler_proc)
|
44
|
+
handler = resolve_handler(handler_object, &handler_proc)
|
45
|
+
event_name = identify(event_id)
|
46
|
+
|
47
|
+
handlers_for(event_name, true) << handler
|
48
|
+
|
49
|
+
return event_name
|
50
|
+
end
|
51
|
+
alias register subscribe
|
52
|
+
|
53
|
+
##
|
54
|
+
# Unsubscribe an event handler from an event.
|
55
|
+
# @param event_id (Class, String, other) A class or String naming an event
|
56
|
+
# class.
|
57
|
+
# @param handler_object (Object) Handler object that must implement a
|
58
|
+
# `#call` method accepting an instance of the event class provided in
|
59
|
+
# the first parameter. If nil, a block needs to be provided.
|
60
|
+
# @param handler_proc (Proc) Handler block that accepts an instance of the
|
61
|
+
# event class provided in the first parameter. If nil, a handler object
|
62
|
+
# must be provided.
|
63
|
+
# @return The class represented by the event_id, as a String name.
|
64
|
+
def unsubscribe(event_id, handler_object = nil, &handler_proc)
|
65
|
+
handler = resolve_handler(handler_object, &handler_proc)
|
66
|
+
event_name = identify(event_id)
|
67
|
+
|
68
|
+
handlers_for(event_name, true).delete(handler)
|
69
|
+
|
70
|
+
return event_name
|
71
|
+
end
|
72
|
+
|
73
|
+
##
|
74
|
+
# Broadcast an event. This is an instance of a class provided to #subscribe
|
75
|
+
# previously.
|
76
|
+
# @param event (Object) the event to publish
|
77
|
+
def publish(event)
|
78
|
+
event_name = event
|
79
|
+
if not event.is_a?(Hash)
|
80
|
+
event_name = event.class.to_s
|
81
|
+
end
|
82
|
+
handlers_for(event_name, false).each do |handler|
|
83
|
+
handler.call(event)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
alias broadcast publish
|
87
|
+
alias notify publish
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
# The first parameters is the event class or event name. The second parameter
|
92
|
+
# is whether this is for write or read access. We don't want to pollute
|
93
|
+
# @handlers with data from the #publish call.
|
94
|
+
def handlers_for(name, write)
|
95
|
+
# Use prototype matching for Hash names
|
96
|
+
if name.is_a?(Hash)
|
97
|
+
name.extend(::Collapsium::PrototypeMatch)
|
98
|
+
|
99
|
+
# The prototype hash logic is a little complex. A hash event can match
|
100
|
+
# multiple prototypes, e.g. { a: 42 } should match { a: nil } as well as
|
101
|
+
# { a: 42 }.
|
102
|
+
# When writing, we want to be as precise as possible and find the best
|
103
|
+
# match. When reading, we want to merge all matches, ideally.
|
104
|
+
if write
|
105
|
+
best_score = -1
|
106
|
+
best_proto = nil
|
107
|
+
|
108
|
+
# Find the best matching prototype
|
109
|
+
@handlers.keys.each do |proto|
|
110
|
+
score = name.prototype_match_score(proto)
|
111
|
+
if score > best_score
|
112
|
+
best_score = score
|
113
|
+
best_proto = proto
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
if not best_proto.nil?
|
118
|
+
return @handlers[best_proto]
|
119
|
+
end
|
120
|
+
else
|
121
|
+
merged = []
|
122
|
+
|
123
|
+
@handlers.keys.each do |proto|
|
124
|
+
if name.prototype_match(proto)
|
125
|
+
merged += @handlers[proto]
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
if not merged.empty?
|
130
|
+
return merged
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# No prototype matches. That means if write is true, we need to treat
|
135
|
+
# name as a new prototype to regiser. Otherwise, we need to return an
|
136
|
+
# empty list. That happens to be the same logic as for the simple key
|
137
|
+
# below.
|
138
|
+
end
|
139
|
+
|
140
|
+
# If we're in write access, make sure to store an empty list as well as
|
141
|
+
# returning one (if necessary).
|
142
|
+
if write
|
143
|
+
@handlers[name] ||= []
|
144
|
+
end
|
145
|
+
|
146
|
+
# In read access, want to either return an empty list, or the registered
|
147
|
+
# handlers, but not ovewrite the registered handlers.
|
148
|
+
return @handlers[name] || []
|
149
|
+
end
|
150
|
+
|
151
|
+
def resolve_handler(handler_object = nil, &handler_proc)
|
152
|
+
handler = handler_proc || handler_object
|
153
|
+
if not handler
|
154
|
+
raise ArgumentError, "Please pass either an object or a handler block"
|
155
|
+
end
|
156
|
+
return handler
|
157
|
+
end
|
158
|
+
|
159
|
+
include ::Octiron::Support::Identifiers
|
160
|
+
end # class Bus
|
161
|
+
end # module Octiron::Events
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
#
|
3
|
+
# octiron
|
4
|
+
# https://github.com/jfinkhaeuser/octiron
|
5
|
+
#
|
6
|
+
# Copyright (c) 2016 Jens Finkhaeuser and other octiron contributors.
|
7
|
+
# All rights reserved.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Octiron::Support
|
11
|
+
##
|
12
|
+
# @see CamelCase::camel_case
|
13
|
+
module CamelCase
|
14
|
+
|
15
|
+
##
|
16
|
+
# Takes an underscored name and turns it into a CamelCase String
|
17
|
+
# @param underscored_name (String, Symbol) the underscored name to turn
|
18
|
+
# into camel case.
|
19
|
+
# @return (String) CamelCased version of the underscored_name
|
20
|
+
def camel_case(underscored_name)
|
21
|
+
return underscored_name.to_s.split("_").map do |word|
|
22
|
+
word.upcase[0] + word[1..-1]
|
23
|
+
end.join
|
24
|
+
end
|
25
|
+
|
26
|
+
end # module CamelCase
|
27
|
+
end # module Octiron::Support
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
#
|
3
|
+
# octiron
|
4
|
+
# https://github.com/jfinkhaeuser/octiron
|
5
|
+
#
|
6
|
+
# Copyright (c) 2016 Jens Finkhaeuser and other octiron contributors.
|
7
|
+
# All rights reserved.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Octiron::Support
|
11
|
+
##
|
12
|
+
# @see Constantize::constantize
|
13
|
+
module Constantize
|
14
|
+
|
15
|
+
##
|
16
|
+
# Takes a String containing a constant name, and returns the canonical path
|
17
|
+
# of the constant (i.e. where it's defined, even if it's accessed
|
18
|
+
# differently.). If the constant does not exist, a NameError is thrown.
|
19
|
+
# @param constant_name (String) the constant name
|
20
|
+
# @return (Object) the actual named constant.
|
21
|
+
def constantize(constant_name)
|
22
|
+
names = constant_name.split('::')
|
23
|
+
|
24
|
+
# Trigger a built-in NameError exception including the ill-formed
|
25
|
+
# constant in the message.
|
26
|
+
if names.empty?
|
27
|
+
Object.const_get(constant_name, false)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Remove the first blank element in case of '::ClassName' notation.
|
31
|
+
if names.size > 1 && names.first.empty?
|
32
|
+
names.shift
|
33
|
+
end
|
34
|
+
|
35
|
+
# Note: this would be much more complex in Ruby < 1.9.3, so yay for not
|
36
|
+
# bothering to support these!
|
37
|
+
return names.inject(Object) do |constant, name|
|
38
|
+
next constant.const_get(name)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end # module Constantize
|
43
|
+
end # module Octiron::Support
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
#
|
3
|
+
# octiron
|
4
|
+
# https://github.com/jfinkhaeuser/octiron
|
5
|
+
#
|
6
|
+
# Copyright (c) 2016 Jens Finkhaeuser and other octiron contributors.
|
7
|
+
# All rights reserved.
|
8
|
+
#
|
9
|
+
|
10
|
+
require_relative 'camel_case'
|
11
|
+
require_relative 'constantize'
|
12
|
+
|
13
|
+
module Octiron::Support
|
14
|
+
##
|
15
|
+
# @see Identifiers::identify
|
16
|
+
module Identifiers
|
17
|
+
include ::Octiron::Support::CamelCase
|
18
|
+
include ::Octiron::Support::Constantize
|
19
|
+
|
20
|
+
##
|
21
|
+
# This function "identifies" an object, i.e. returns a unique ID according
|
22
|
+
# to the octiron logic. That means that:
|
23
|
+
# - For classes, a stringified version is returned
|
24
|
+
# - For hashes, the hash itself is returned
|
25
|
+
# - Strings are interpreted as constant names, and are returned fully
|
26
|
+
# qualified.
|
27
|
+
# - Everything else is considered to be a constant name in the default
|
28
|
+
# namespace. This requires access to a @default_namespace variable in
|
29
|
+
# the including class.
|
30
|
+
def identify(name)
|
31
|
+
case name
|
32
|
+
when Class
|
33
|
+
return name.to_s
|
34
|
+
when Hash
|
35
|
+
return name
|
36
|
+
when String
|
37
|
+
return constantize(name).to_s
|
38
|
+
when nil
|
39
|
+
raise NameError, "Can't identify 'nil'!"
|
40
|
+
else
|
41
|
+
return constantize("#{@default_namespace}::#{camel_case(name)}").to_s
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end # module Identifiers
|
45
|
+
end # module Octiron::Support
|
@@ -0,0 +1,201 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
#
|
3
|
+
# octiron
|
4
|
+
# https://github.com/jfinkhaeuser/octiron
|
5
|
+
#
|
6
|
+
# Copyright (c) 2016 Jens Finkhaeuser and other octiron contributors.
|
7
|
+
# All rights reserved.
|
8
|
+
#
|
9
|
+
|
10
|
+
require 'rgl/adjacency'
|
11
|
+
require 'rgl/dijkstra'
|
12
|
+
|
13
|
+
require 'octiron/support/identifiers'
|
14
|
+
|
15
|
+
# require 'collapsium/recursive_sort'
|
16
|
+
require 'collapsium/prototype_match'
|
17
|
+
|
18
|
+
module Octiron::Transmogrifiers
|
19
|
+
|
20
|
+
##
|
21
|
+
# Registers transmogrifiers between one (event) class and another.
|
22
|
+
#
|
23
|
+
# A transmogrifier is an object with a call method or a block that accepts
|
24
|
+
# an instance of one (event) class and produces an instance of another
|
25
|
+
# (event) class.
|
26
|
+
#
|
27
|
+
# The registry also exposes a #transmogrify function which uses any
|
28
|
+
# registered transmogrifier, or raises an error if there is no
|
29
|
+
# transmogrification possible.
|
30
|
+
#
|
31
|
+
# One piece of magic makes this particularly powerful: the registry creates
|
32
|
+
# a graph of how to transmogrify an object to another by chaining
|
33
|
+
# transmogrifiers. That is, if there is a transmogrifier that turns A into B,
|
34
|
+
# and one that turns B into C, then by chaining both the registry can also
|
35
|
+
# turn A into C directly. (This is done via the 'rgl' gem).
|
36
|
+
class Registry
|
37
|
+
# @return (String) the default namespace to search for transmogrifiers
|
38
|
+
attr_reader :default_namespace
|
39
|
+
|
40
|
+
##
|
41
|
+
# @param default_namespace (Symbol) The default namespace to look in for
|
42
|
+
# Transmogrifier classes.
|
43
|
+
def initialize(default_namespace = ::Octiron::Transmogrifiers)
|
44
|
+
@default_namespace = default_namespace.to_s
|
45
|
+
@graph = RGL::DirectedAdjacencyGraph.new
|
46
|
+
@visitor = RGL::DijkstraVisitor.new(@graph)
|
47
|
+
@map_data = {}
|
48
|
+
@map = nil
|
49
|
+
@transmogrifiers = {}
|
50
|
+
end
|
51
|
+
|
52
|
+
##
|
53
|
+
# Register transmogrifier
|
54
|
+
#
|
55
|
+
# @param from (Class, String, other) A class or String nameing a
|
56
|
+
# transmogrifier source. With prototype Hash matching, this would be
|
57
|
+
# a prototype.
|
58
|
+
# @param to (Class, String, other) Transmogrifier target.
|
59
|
+
# @param overwrite (Boolean) The registry can only hold one transmogrifier
|
60
|
+
# per from -> to pair. If overwrite is true, registering a
|
61
|
+
# transmogrifier where one already exists overwrites the old one,
|
62
|
+
# otherwise an exception is raised.
|
63
|
+
# @param transmogrifier_object (Object) Transmogrifier object that must
|
64
|
+
# implement a `#call` method accepting an instance of the event class
|
65
|
+
# provided in the first parameter. If nil, a block needs to be provided.
|
66
|
+
# @param transmogrifier_proc (Proc) Transmogrifier block that accepts an
|
67
|
+
# instance of the event class provided in the first parameter. If nil, a
|
68
|
+
# transmogrifier object must be provided.
|
69
|
+
def register(from, to, overwrite = false, transmogrifier_object = nil,
|
70
|
+
&transmogrifier_proc)
|
71
|
+
transmogrifier = transmogrifier_proc || transmogrifier_object
|
72
|
+
if not transmogrifier
|
73
|
+
raise ArgumentError, "Please pass either an object or a transmogrifier "\
|
74
|
+
"block"
|
75
|
+
end
|
76
|
+
|
77
|
+
# Convert to canonical names
|
78
|
+
from_name = identify(from)
|
79
|
+
to_name = identify(to)
|
80
|
+
key = [from_name, to_name]
|
81
|
+
|
82
|
+
# We treat the graph as authoritative for what transmogrifiers exist.
|
83
|
+
if @graph.has_edge?(from_name, to_name)
|
84
|
+
if not overwrite
|
85
|
+
raise ArgumentError, "Registry already knows a transmogrifier for "\
|
86
|
+
"#{key}, aborting!"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Add edges and map data for the shortest path search. We treat all paths
|
91
|
+
# as equally weighted.
|
92
|
+
@graph.add_edge(from_name, to_name)
|
93
|
+
@map_data[key] = 1
|
94
|
+
@map = RGL::EdgePropertiesMap.new(@map_data, true)
|
95
|
+
|
96
|
+
# Finally, register transmogrifier
|
97
|
+
@transmogrifiers[key] = transmogrifier
|
98
|
+
end
|
99
|
+
|
100
|
+
##
|
101
|
+
# Deregister transmogrifier
|
102
|
+
#
|
103
|
+
# @param from (Class, String, other) A class or String nameing a
|
104
|
+
# transmogrifier source. With prototype Hash matching, this would be
|
105
|
+
# a prototype.
|
106
|
+
# @param to (Class, String, other) Transmogrifier target.
|
107
|
+
def deregister(from, to)
|
108
|
+
# Convert to canonical names
|
109
|
+
from_name = identify(from)
|
110
|
+
to_name = identify(to)
|
111
|
+
key = [from_name, to_name]
|
112
|
+
|
113
|
+
# Graph, map data and transmogrifiers need to be modified
|
114
|
+
@graph.remove_edge(from_name, to_name)
|
115
|
+
@map_data.delete(key)
|
116
|
+
@map = RGL::EdgePropertiesMap.new(@map_data, true)
|
117
|
+
|
118
|
+
@transmogrifiers.delete(key)
|
119
|
+
end
|
120
|
+
|
121
|
+
alias unregister deregister
|
122
|
+
|
123
|
+
##
|
124
|
+
# Transmogrify an object of one class into another class.
|
125
|
+
def transmogrify(from, to)
|
126
|
+
# Get lookup keys
|
127
|
+
from_name = from.class.to_s
|
128
|
+
if from.is_a?(Hash)
|
129
|
+
# Finding the correct from_name is tricky, because from is not a
|
130
|
+
# prototype, but the graph and all intermediate
|
131
|
+
from_name = best_matching_hash_prototype(from)
|
132
|
+
end
|
133
|
+
to_name = identify(to)
|
134
|
+
|
135
|
+
# We'll ask the graph for the shortest path. If there is none, we can't
|
136
|
+
# transmogrify. (Note: the @map changes with each registration/
|
137
|
+
# deregistration, so we instanciate the algorithm here).
|
138
|
+
algo = RGL::DijkstraAlgorithm.new(@graph, @map, @visitor)
|
139
|
+
path = algo.shortest_path(from_name, to_name)
|
140
|
+
|
141
|
+
if path.nil?
|
142
|
+
raise ArgumentError, "No transmogrifiers for #{[from_name, to_name]} "\
|
143
|
+
"found, aborting!"
|
144
|
+
end
|
145
|
+
|
146
|
+
# Transmogrify for each part of the path
|
147
|
+
input = from
|
148
|
+
result = nil
|
149
|
+
path.inject do |step_from, step_to|
|
150
|
+
# Call transmogrifier
|
151
|
+
key = [step_from, step_to]
|
152
|
+
result = @transmogrifiers[key].call(input)
|
153
|
+
|
154
|
+
# Verify result
|
155
|
+
if step_to.is_a?(Hash)
|
156
|
+
result.extend(::Collapsium::PrototypeMatch)
|
157
|
+
if not result.prototype_match(step_to)
|
158
|
+
raise "Transmogrifier returned Hash that did not match prototype "\
|
159
|
+
"#{step_to}, aborting!"
|
160
|
+
end
|
161
|
+
elsif result.class.to_s != step_to
|
162
|
+
raise "Transmogrifier returned result of invalid class "\
|
163
|
+
"#{result.class}, aborting!"
|
164
|
+
end
|
165
|
+
|
166
|
+
# Result is input for the next transmogrifier in the chain
|
167
|
+
input = result
|
168
|
+
|
169
|
+
# Make step_to the next step_from
|
170
|
+
next step_to
|
171
|
+
end
|
172
|
+
return result
|
173
|
+
end
|
174
|
+
|
175
|
+
private
|
176
|
+
|
177
|
+
include ::Octiron::Support::Identifiers
|
178
|
+
|
179
|
+
def best_matching_hash_prototype(value)
|
180
|
+
value.extend(::Collapsium::PrototypeMatch)
|
181
|
+
best_score = -1
|
182
|
+
best_proto = nil
|
183
|
+
|
184
|
+
@transmogrifiers.each do |key, _|
|
185
|
+
proto = key[0]
|
186
|
+
|
187
|
+
if not proto.is_a?(Hash)
|
188
|
+
next
|
189
|
+
end
|
190
|
+
|
191
|
+
score = value.prototype_match_score(proto)
|
192
|
+
if score > best_score
|
193
|
+
best_score = score
|
194
|
+
best_proto = proto
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
return best_proto
|
199
|
+
end
|
200
|
+
end # class Registry
|
201
|
+
end # module Octiron::Transmogrifiers
|