octiron 0.1.0
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/.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
|