hashematics 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.editorconfig +8 -0
- data/.gitignore +4 -0
- data/.rubocop.yml +11 -0
- data/.ruby-version +1 -0
- data/.travis.yml +20 -0
- data/CHANGELOG.md +7 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +123 -0
- data/Guardfile +16 -0
- data/LICENSE +7 -0
- data/README.md +660 -0
- data/bin/benchmark +134 -0
- data/bin/console +11 -0
- data/hashematics.gemspec +32 -0
- data/lib/hashematics.rb +10 -0
- data/lib/hashematics/category.rb +67 -0
- data/lib/hashematics/configuration.rb +90 -0
- data/lib/hashematics/dictionary.rb +72 -0
- data/lib/hashematics/graph.rb +62 -0
- data/lib/hashematics/group.rb +57 -0
- data/lib/hashematics/hashematics.rb +36 -0
- data/lib/hashematics/id.rb +29 -0
- data/lib/hashematics/key.rb +76 -0
- data/lib/hashematics/object_interface.rb +35 -0
- data/lib/hashematics/record.rb +60 -0
- data/lib/hashematics/record_set.rb +29 -0
- data/lib/hashematics/type.rb +72 -0
- data/lib/hashematics/version.rb +12 -0
- data/lib/hashematics/visitor.rb +50 -0
- data/spec/examples/person.rb +36 -0
- data/spec/fixtures/config.yml +44 -0
- data/spec/fixtures/data.csv +9 -0
- data/spec/fixtures/people.yml +84 -0
- data/spec/hashematics/category_spec.rb +62 -0
- data/spec/hashematics/graph_spec.rb +572 -0
- data/spec/hashematics/key_spec.rb +37 -0
- data/spec/hashematics/object_interface_spec.rb +42 -0
- data/spec/hashematics/record_set_spec.rb +24 -0
- data/spec/hashematics/record_spec.rb +49 -0
- data/spec/hashematics/type_spec.rb +104 -0
- data/spec/spec_helper.rb +42 -0
- metadata +211 -0
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Hashematics
|
11
|
+
# A group is a node in a tree structure connected to other groups through the children
|
12
|
+
# attribute. A group essentially represents an object within the object graph and its:
|
13
|
+
# 1. Category (index) for the parent to use as a lookup
|
14
|
+
# 2. Type that describes the object properties, field mapping, etc.
|
15
|
+
class Group
|
16
|
+
attr_reader :category, :name, :type
|
17
|
+
|
18
|
+
def initialize(category:, children:, name:, type:)
|
19
|
+
@category = category
|
20
|
+
@child_dictionary = Dictionary.new.add(children, &:name)
|
21
|
+
@name = name
|
22
|
+
@type = type
|
23
|
+
|
24
|
+
freeze
|
25
|
+
end
|
26
|
+
|
27
|
+
def add(record)
|
28
|
+
category.add(record)
|
29
|
+
|
30
|
+
child_dictionary.each { |c| c.add(record) }
|
31
|
+
|
32
|
+
self
|
33
|
+
end
|
34
|
+
|
35
|
+
def children
|
36
|
+
child_dictionary.map(&:name)
|
37
|
+
end
|
38
|
+
|
39
|
+
def visit(parent_record = nil)
|
40
|
+
category.records(parent_record).map do |record|
|
41
|
+
Visitor.new(group: self, record: record)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def visit_children(name, parent_record = nil)
|
46
|
+
child_group(name)&.visit(parent_record) || []
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
attr_reader :child_dictionary
|
52
|
+
|
53
|
+
def child_group(group_name)
|
54
|
+
child_dictionary.get(group_name)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
require 'digest'
|
11
|
+
require 'forwardable'
|
12
|
+
require 'ostruct'
|
13
|
+
|
14
|
+
require_relative 'category'
|
15
|
+
require_relative 'configuration'
|
16
|
+
require_relative 'dictionary'
|
17
|
+
require_relative 'graph'
|
18
|
+
require_relative 'group'
|
19
|
+
require_relative 'key'
|
20
|
+
require_relative 'id'
|
21
|
+
require_relative 'object_interface'
|
22
|
+
require_relative 'record'
|
23
|
+
require_relative 'record_set'
|
24
|
+
require_relative 'type'
|
25
|
+
require_relative 'visitor'
|
26
|
+
|
27
|
+
# Top-level API syntactic sugar that holds the common library use(s).
|
28
|
+
module Hashematics
|
29
|
+
class << self
|
30
|
+
def graph(config: {}, rows: [])
|
31
|
+
groups = ::Hashematics::Configuration.new(config).groups
|
32
|
+
|
33
|
+
::Hashematics::Graph.new(groups).add(rows)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Hashematics
|
11
|
+
# An ID is just like a Key except its value is digested (hashed). The main rationale for this
|
12
|
+
# is ID's also contains user data, which could be unbound data, which could potentially
|
13
|
+
# consume lots of memory. To limit this, we digest it.
|
14
|
+
class Id < Key
|
15
|
+
class << self
|
16
|
+
# This method is class-level to expose the underlying hashing algorithm used.
|
17
|
+
def digest(val = '')
|
18
|
+
# MD5 was chosen for its speed, it was not chosen for security.
|
19
|
+
Digest::MD5.hexdigest(val)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def make_value
|
26
|
+
self.class.digest(parts.map(&:to_s).join(SEPARATOR))
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Hashematics
|
11
|
+
# A Key is a unique identifier and can be used for hash keys, comparison, etc.
|
12
|
+
# Essentially it is a joined and hashed list of strings.
|
13
|
+
class Key
|
14
|
+
extend Forwardable
|
15
|
+
|
16
|
+
class << self
|
17
|
+
# This class-level method allows for the caching/memoization of Key objects already
|
18
|
+
# allocated. Since Key objects will have such a high instantiation count with
|
19
|
+
# the potential of a lof of re-use, it makes sense to try to be a bit more
|
20
|
+
# memory-optimized here.
|
21
|
+
def get(parts = [])
|
22
|
+
return parts if parts.is_a?(self)
|
23
|
+
|
24
|
+
keys[parts] ||= new(parts)
|
25
|
+
end
|
26
|
+
alias default get
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def keys
|
31
|
+
@keys ||= {}
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
SEPARATOR = '::'
|
36
|
+
|
37
|
+
private_constant :SEPARATOR
|
38
|
+
|
39
|
+
def_delegators :parts, :each_with_object, :map, :any?
|
40
|
+
|
41
|
+
attr_reader :parts, :value
|
42
|
+
|
43
|
+
def initialize(parts = [])
|
44
|
+
@parts = Array(parts)
|
45
|
+
@value = make_value
|
46
|
+
|
47
|
+
freeze
|
48
|
+
end
|
49
|
+
|
50
|
+
# We can compare a Key object to a non-Key object since its constructor is rather pliable.
|
51
|
+
# This means we can do things like this:
|
52
|
+
# - Key.make(['id', :name]) == ['id', 'name']
|
53
|
+
# - Key.make(:id) == 'id'
|
54
|
+
# - Key.make(['id']) == :id
|
55
|
+
# Those are all equivalent and should return true.
|
56
|
+
def eql?(other)
|
57
|
+
return eql?(self.class.get(other)) unless other.is_a?(self.class)
|
58
|
+
|
59
|
+
value == other.value
|
60
|
+
end
|
61
|
+
|
62
|
+
def ==(other)
|
63
|
+
eql?(other)
|
64
|
+
end
|
65
|
+
|
66
|
+
def hash
|
67
|
+
value.hash
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def make_value
|
73
|
+
parts.map(&:to_s).join(SEPARATOR)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Hashematics
|
11
|
+
# ObjectInterface allows us to interact with external objects in a more standardized manner.
|
12
|
+
# For example: configuration and objects passed into the module can be a little more liberal
|
13
|
+
# in their specific types and key types.
|
14
|
+
class ObjectInterface
|
15
|
+
class << self
|
16
|
+
def get(object, key)
|
17
|
+
if object.is_a?(Hash)
|
18
|
+
indifferent_hash_get(object, key)
|
19
|
+
elsif object.respond_to?(key)
|
20
|
+
object.send(key)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def indifferent_hash_get(hash, key)
|
27
|
+
if hash.key?(key.to_s)
|
28
|
+
hash[key.to_s]
|
29
|
+
elsif hash.key?(key.to_s.to_sym)
|
30
|
+
hash[key.to_s.to_sym]
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Hashematics
|
11
|
+
# A Record object is composed of an inner object (most likely a hash) and provides extra
|
12
|
+
# methods for the library.
|
13
|
+
class Record
|
14
|
+
extend Forwardable
|
15
|
+
|
16
|
+
def_delegators :data, :keys, :hash
|
17
|
+
|
18
|
+
attr_reader :data
|
19
|
+
|
20
|
+
def initialize(data = {})
|
21
|
+
@data = data
|
22
|
+
|
23
|
+
freeze
|
24
|
+
end
|
25
|
+
|
26
|
+
def id?(key)
|
27
|
+
Key.get(key).any? { |p| data[p].to_s.length.positive? }
|
28
|
+
end
|
29
|
+
|
30
|
+
def id(key)
|
31
|
+
Id.get(id_parts(key))
|
32
|
+
end
|
33
|
+
|
34
|
+
def [](key)
|
35
|
+
ObjectInterface.get(data, key)
|
36
|
+
end
|
37
|
+
|
38
|
+
# This should allow for Record objects to be compared to:
|
39
|
+
# - Other Record objects
|
40
|
+
# - Other data payload objects (most likely Hash objects)
|
41
|
+
def eql?(other)
|
42
|
+
return eql?(self.class.new(other)) unless other.is_a?(self.class)
|
43
|
+
|
44
|
+
data == other.data
|
45
|
+
end
|
46
|
+
|
47
|
+
def ==(other)
|
48
|
+
eql?(other)
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def id_parts(key)
|
54
|
+
Key.get(key).each_with_object([]) do |p, arr|
|
55
|
+
arr << p
|
56
|
+
arr << data[p]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Hashematics
|
11
|
+
# A RecordSet creates Records and maintains a master list of Records.
|
12
|
+
class RecordSet
|
13
|
+
attr_reader :records
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@records = []
|
17
|
+
|
18
|
+
freeze
|
19
|
+
end
|
20
|
+
|
21
|
+
def rows
|
22
|
+
records.map(&:data)
|
23
|
+
end
|
24
|
+
|
25
|
+
def add(object)
|
26
|
+
Record.new(object).tap { |r| records << r }
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Hashematics
|
11
|
+
# A Type defines an object.
|
12
|
+
class Type
|
13
|
+
class << self
|
14
|
+
def null_type
|
15
|
+
@null_type ||= new
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
HASH_VALUE = 'hash'
|
20
|
+
OPEN_STRUCT_VALUE = 'open_struct'
|
21
|
+
|
22
|
+
attr_reader :name, :object_class, :properties
|
23
|
+
|
24
|
+
def initialize(name: '', properties: nil, object_class: nil)
|
25
|
+
@name = name
|
26
|
+
@properties = make_properties(properties)
|
27
|
+
@object_class = object_class || HASH_VALUE
|
28
|
+
|
29
|
+
freeze
|
30
|
+
end
|
31
|
+
|
32
|
+
def convert(object, child_hash = {})
|
33
|
+
make_object(to_hash(object).merge(child_hash))
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def to_hash(object)
|
39
|
+
(properties || default_properties(object)).map do |property, key|
|
40
|
+
[property, ObjectInterface.get(object, key)]
|
41
|
+
end.to_h
|
42
|
+
end
|
43
|
+
|
44
|
+
def default_properties(object)
|
45
|
+
if object.respond_to?(:keys)
|
46
|
+
make_properties(object.keys)
|
47
|
+
else
|
48
|
+
[]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def make_object(hash)
|
53
|
+
if object_class.to_s == HASH_VALUE
|
54
|
+
hash
|
55
|
+
elsif object_class.to_s == OPEN_STRUCT_VALUE
|
56
|
+
OpenStruct.new(hash)
|
57
|
+
elsif object_class.is_a?(Proc)
|
58
|
+
object_class.call(hash)
|
59
|
+
else
|
60
|
+
object_class.new(hash)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def make_properties(val)
|
65
|
+
if val.is_a?(Array) || val.is_a?(String) || val.is_a?(Symbol)
|
66
|
+
Array(val).map { |v| [v, v] }.to_h
|
67
|
+
else
|
68
|
+
val
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Hashematics
|
11
|
+
VERSION = '1.0.0'
|
12
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Hashematics
|
11
|
+
# A Visitor is a Record found in the context of a Group. When traversing the object
|
12
|
+
# graph (group tree), it will provide these Visitor objects instead of Record objects
|
13
|
+
# that allows you to view the Record in the context of the graph, while a Record is more of just
|
14
|
+
# the raw payload provided by the initial flat data set.
|
15
|
+
class Visitor
|
16
|
+
extend Forwardable
|
17
|
+
|
18
|
+
def_delegators :group, :children, :type
|
19
|
+
|
20
|
+
attr_reader :group, :record
|
21
|
+
|
22
|
+
def initialize(group:, record:)
|
23
|
+
@group = group
|
24
|
+
@record = record
|
25
|
+
|
26
|
+
freeze
|
27
|
+
end
|
28
|
+
|
29
|
+
def data(include_children = false)
|
30
|
+
child_hash = include_children ? make_child_hash : {}
|
31
|
+
|
32
|
+
type.convert(record.data, child_hash)
|
33
|
+
end
|
34
|
+
|
35
|
+
def visit(name)
|
36
|
+
group.visit_children(name, record)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def make_child_hash
|
42
|
+
children.map do |name|
|
43
|
+
[
|
44
|
+
name,
|
45
|
+
visit(name).map { |v| v.data(true) }
|
46
|
+
]
|
47
|
+
end.to_h
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|