knuckles 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.
@@ -0,0 +1,14 @@
1
+ require_relative "./bench_helper"
2
+
3
+ require "fileutils"
4
+ require "stackprof"
5
+
6
+ FileUtils.mkdir_p("tmp")
7
+
8
+ models = BenchHelper.submissions
9
+
10
+ StackProf.run(mode: :wall, interval: 500, out: "tmp/stackprof-wall.dump") do
11
+ 100.times do
12
+ Knuckles.new.call(models, view: SubmissionView)
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "./bench_helper"
2
+
3
+ models = BenchHelper.submissions
4
+
5
+ Benchmark.ips do |x|
6
+ x.report("serialize.realistic") do
7
+ Knuckles.new.call(models, view: SubmissionView)
8
+ end
9
+ end
data/bench/simple.rb ADDED
@@ -0,0 +1,25 @@
1
+ require_relative "./bench_helper"
2
+
3
+ Post = Struct.new(:id, :title, :updated_at)
4
+
5
+ module PostView
6
+ extend Knuckles::View
7
+
8
+ def self.root
9
+ :posts
10
+ end
11
+
12
+ def self.data(object, _)
13
+ {id: object.id,
14
+ title: object.title,
15
+ updated_at: object.updated_at}
16
+ end
17
+ end
18
+
19
+ models = 100.times.map { |i| Post.new(i, "title", Time.new) }
20
+
21
+ Benchmark.ips do |x|
22
+ x.report("serialize.main") do
23
+ Knuckles.new.call(models, view: PostView)
24
+ end
25
+ end
data/bin/rspec ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # This file was generated by Bundler.
4
+ #
5
+ # The application 'rspec' is installed as part of a gem, and
6
+ # this file is here to facilitate running it.
7
+ #
8
+
9
+ require "pathname"
10
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
11
+ Pathname.new(__FILE__).realpath)
12
+
13
+ require "rubygems"
14
+ require "bundler/setup"
15
+
16
+ load Gem.bin_path("rspec-core", "rspec")
data/knuckles.gemspec ADDED
@@ -0,0 +1,24 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "knuckles/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "knuckles"
8
+ spec.version = Knuckles::VERSION
9
+ spec.authors = ["Parker Selbert"]
10
+ spec.email = ["parker@sorentwo.com"]
11
+ spec.summary = "Simple performance aware data serialization"
12
+ spec.homepage = ""
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = []
17
+ spec.test_files = spec.files.grep(%r{^(spec)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_development_dependency "activesupport"
21
+ spec.add_development_dependency "bundler"
22
+ spec.add_development_dependency "rake"
23
+ spec.add_development_dependency "rspec"
24
+ end
@@ -0,0 +1,26 @@
1
+ require "set"
2
+
3
+ module Knuckles
4
+ module Combiner
5
+ extend self
6
+
7
+ def name
8
+ "combiner".freeze
9
+ end
10
+
11
+ def call(prepared, _)
12
+ prepared.each_with_object(set_backed_hash) do |hash, memo|
13
+ hash[:result].each do |root, values|
14
+ case values
15
+ when Hash then memo[root] << values
16
+ when Array then memo[root] += values
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def set_backed_hash
23
+ Hash.new { |hash, key| hash[key] = Set.new }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ module Knuckles
2
+ module Dumper
3
+ extend self
4
+
5
+ def name
6
+ "dumper".freeze
7
+ end
8
+
9
+ def call(objects, _options)
10
+ Knuckles.serializer.dump(keys_to_arrays(objects))
11
+ end
12
+
13
+ private
14
+
15
+ def keys_to_arrays(objects)
16
+ objects.each do |key, value|
17
+ objects[key] = value.to_a if value.is_a?(Set)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ module Knuckles
2
+ module Fetcher
3
+ extend self
4
+
5
+ def name
6
+ "fetcher".freeze
7
+ end
8
+
9
+ def call(prepared, options)
10
+ results = get_cached(prepared, options)
11
+
12
+ prepared.each do |hash|
13
+ result = results[hash[:key]]
14
+ hash[:cached?] = !result.nil?
15
+ hash[:result] = result
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def get_cached(prepared, options)
22
+ kgen = options.fetch(:keygen, Knuckles.keygen)
23
+ keys = prepared.map do |hash|
24
+ hash[:key] = kgen.expand_key(hash[:object])
25
+ end
26
+
27
+ Knuckles.cache.read_multi(*keys)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ module Knuckles
2
+ module Hydrator
3
+ extend self
4
+
5
+ def name
6
+ "hydrator".freeze
7
+ end
8
+
9
+ def call(prepared, options)
10
+ hydrate = options[:hydrate]
11
+
12
+ if hydrate && any_missing?(prepared)
13
+ hydrate.call(hydratable(prepared))
14
+ end
15
+
16
+ prepared
17
+ end
18
+
19
+ private
20
+
21
+ def any_missing?(prepared)
22
+ prepared.any? { |hash| !hash[:cached?] }
23
+ end
24
+
25
+ def hydratable(prepared)
26
+ prepared.reject { |hash| hash[:cached?] }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ module Knuckles
2
+ module Keygen
3
+ extend self
4
+
5
+ def expand_key(object)
6
+ if object.respond_to?(:cache_key)
7
+ object.cache_key
8
+ else
9
+ "#{object.class.name}/#{object.id}/#{object.updated_at.to_i}"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,56 @@
1
+ module Knuckles
2
+ class Pipeline
3
+ def self.default_stages
4
+ [Knuckles::Fetcher,
5
+ Knuckles::Hydrator,
6
+ Knuckles::Renderer,
7
+ Knuckles::Writer,
8
+ Knuckles::Combiner,
9
+ Knuckles::Dumper]
10
+ end
11
+
12
+ attr_reader :stages
13
+
14
+ def initialize(stages: self.class.default_stages)
15
+ @stages = stages
16
+ end
17
+
18
+ def delete(stage)
19
+ stages.delete(stage)
20
+ end
21
+
22
+ def insert_after(stage, new_stage)
23
+ index = stages.index(stage)
24
+
25
+ stages.insert(index + 1, new_stage)
26
+ end
27
+
28
+ def insert_before(stage, new_stage)
29
+ index = stages.index(stage)
30
+
31
+ stages.insert(index, new_stage)
32
+ end
33
+
34
+ def call(objects, options)
35
+ prepared = prepare(objects)
36
+
37
+ stages.reduce(prepared) do |results, stage|
38
+ instrument("knuckles.stage", stage: stage.name) do
39
+ stage.call(results, options)
40
+ end
41
+ end
42
+ end
43
+
44
+ def prepare(objects)
45
+ objects.map do |object|
46
+ {object: object, key: nil, cached?: false, result: nil}
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def instrument(operation, payload, &block)
53
+ Knuckles.notifications.instrument(operation, payload, &block)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,27 @@
1
+ module Knuckles
2
+ module Renderer
3
+ extend self
4
+
5
+ def name
6
+ "renderer".freeze
7
+ end
8
+
9
+ def call(objects, options)
10
+ view = options.fetch(:view)
11
+
12
+ objects.each do |hash|
13
+ unless hash[:cached?]
14
+ hash[:result] = do_render(hash[:object], view, options)
15
+ end
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def do_render(object, view, options)
22
+ view.relations(object, options).merge!(
23
+ view.root => [view.data(object, options)]
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,3 @@
1
+ module Knuckles
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,28 @@
1
+ module Knuckles
2
+ module View
3
+ extend self
4
+
5
+ ## Callbacks
6
+
7
+ def root
8
+ end
9
+
10
+ def data(_object, _options = {})
11
+ {}
12
+ end
13
+
14
+ def relations(_object, _options = {})
15
+ {}
16
+ end
17
+
18
+ ## Relations
19
+
20
+ def has_one(object, view, options = {})
21
+ [view.data(object, options)]
22
+ end
23
+
24
+ def has_many(objects, view, options = {})
25
+ objects.map { |object| view.data(object, options) }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,41 @@
1
+ module Knuckles
2
+ module Writer
3
+ extend self
4
+
5
+ def name
6
+ "writer".freeze
7
+ end
8
+
9
+ def call(objects, _)
10
+ if cache.respond_to?(:write_multi)
11
+ write_multi(objects)
12
+ else
13
+ write_each(objects)
14
+ end
15
+
16
+ objects
17
+ end
18
+
19
+ private
20
+
21
+ def cache
22
+ Knuckles.cache
23
+ end
24
+
25
+ def write_each(objects)
26
+ objects.each do |hash|
27
+ cache.write(hash[:key], hash[:result]) unless hash[:cached?]
28
+ end
29
+ end
30
+
31
+ def write_multi(objects)
32
+ writable = objects.each_with_object({}) do |hash, memo|
33
+ next if hash[:cached?]
34
+
35
+ memo[hash[:key]] = hash[:result]
36
+ end
37
+
38
+ cache.write_multi(writable) if writable.any?
39
+ end
40
+ end
41
+ end
data/lib/knuckles.rb ADDED
@@ -0,0 +1,50 @@
1
+ require "active_support/notifications"
2
+ require "active_support/cache"
3
+ require "json"
4
+
5
+ module Knuckles
6
+ autoload :Combiner, "knuckles/combiner"
7
+ autoload :Dumper, "knuckles/dumper"
8
+ autoload :Fetcher, "knuckles/fetcher"
9
+ autoload :Hydrator, "knuckles/hydrator"
10
+ autoload :Keygen, "knuckles/keygen"
11
+ autoload :Pipeline, "knuckles/pipeline"
12
+ autoload :Renderer, "knuckles/renderer"
13
+ autoload :View, "knuckles/view"
14
+ autoload :Writer, "knuckles/writer"
15
+
16
+ extend self
17
+
18
+ attr_writer :cache, :keygen, :notifications, :serializer
19
+
20
+ def new(*args)
21
+ Knuckles::Pipeline.new(*args)
22
+ end
23
+
24
+ def cache
25
+ @cache ||= ActiveSupport::Cache::MemoryStore.new
26
+ end
27
+
28
+ def keygen
29
+ @keygen ||= Knuckles::Keygen
30
+ end
31
+
32
+ def notifications
33
+ @notifications ||= ActiveSupport::Notifications
34
+ end
35
+
36
+ def serializer
37
+ @serializer ||= JSON
38
+ end
39
+
40
+ def configure
41
+ yield self
42
+ end
43
+
44
+ def reset!
45
+ @cache = nil
46
+ @keygen = nil
47
+ @notifications = nil
48
+ @serializer = nil
49
+ end
50
+ end
@@ -0,0 +1,36 @@
1
+ RSpec.describe Knuckles::Combiner do
2
+ describe ".call" do
3
+ it "merges all results into a single object" do
4
+ prepared = [
5
+ {
6
+ result: {
7
+ author: {id: 1, name: "Michael"},
8
+ posts: [{id: 1, title: "hello", tag_ids: [1, 2]}],
9
+ tags: [{id: 1, name: "alpha"}, {id: 2, name: "gamma"}]
10
+ }
11
+ }, {
12
+ result: {
13
+ posts: [{id: 2, title: "there", tag_ids: [1]}],
14
+ tags: [{id: 1, name: "alpha"}]
15
+ }
16
+ }
17
+ ]
18
+
19
+ combined = Knuckles::Combiner.call(prepared, {})
20
+
21
+ expect(combined).to eq(
22
+ author: Set.new([
23
+ {id: 1, name: "Michael"}
24
+ ]),
25
+ posts: Set.new([
26
+ {id: 1, title: "hello", tag_ids: [1, 2]},
27
+ {id: 2, title: "there", tag_ids: [1]}
28
+ ]),
29
+ tags: Set.new([
30
+ {id: 1, name: "alpha"},
31
+ {id: 2, name: "gamma"}
32
+ ])
33
+ )
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ RSpec.describe Knuckles::Dumper do
2
+ describe ".call" do
3
+ it "dumps a tree of objects" do
4
+ objects = {
5
+ author: {id: 1, name: "Ernest"},
6
+ posts: Set.new([
7
+ {id: 1, title: "great"},
8
+ {id: 2, title: "stuff"}
9
+ ]),
10
+ tags: Set.new([
11
+ {id: 1, name: "alpha"},
12
+ {id: 2, name: "gamma"}
13
+ ])
14
+ }
15
+
16
+ dumped = Knuckles::Dumper.call(objects, {})
17
+
18
+ expect(dumped).to eq(
19
+ JSON.dump(
20
+ author: {id: 1, name: "Ernest"},
21
+ posts: [
22
+ {id: 1, title: "great"},
23
+ {id: 2, title: "stuff"}
24
+ ],
25
+ tags: [
26
+ {id: 1, name: "alpha"},
27
+ {id: 2, name: "gamma"}
28
+ ]
29
+ )
30
+ )
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ RSpec.describe Knuckles::Fetcher do
2
+ describe ".call" do
3
+ it "fetches all cached data for top level objects" do
4
+ objects = [Tag.new(1, "alpha"), Tag.new(2, "gamma")]
5
+
6
+ Knuckles.cache.write(Knuckles.keygen.expand_key(objects.first), "result")
7
+
8
+ objects = prepare(objects)
9
+ results = Knuckles::Fetcher.call(objects, {})
10
+
11
+ expect(pluck(results, :result)).to eq(["result", nil])
12
+ expect(pluck(results, :cached?)).to eq([true, false])
13
+ end
14
+
15
+ it "allows passing a custom keygen" do
16
+ keygen = Module.new do
17
+ def self.expand_key(object)
18
+ object.name
19
+ end
20
+ end
21
+
22
+ objects = prepare([Tag.new(1, "alpha")])
23
+ results = Knuckles::Fetcher.call(objects, keygen: keygen)
24
+
25
+ expect(pluck(results, :key)).to eq(["alpha"])
26
+ end
27
+ end
28
+
29
+ def pluck(enum, key)
30
+ enum.map { |hash| hash[key] }
31
+ end
32
+ end
@@ -0,0 +1,22 @@
1
+ RSpec.describe Knuckles::Hydrator do
2
+ describe ".call" do
3
+ it "is a noop without a hydrate lambda" do
4
+ objects = [Tag.new(1, "alpha")]
5
+
6
+ expect(Knuckles::Hydrator.call(objects, {})).to eq(objects)
7
+ end
8
+
9
+ it "refines the object collection using hydrate" do
10
+ objects = [Tag.new(1, "alpha"), Tag.new(2, "gamma")]
11
+ prepared = prepare(objects)
12
+
13
+ hydrate = lambda do |hashes|
14
+ hashes.each { |hash| hash[:object] = :updated }
15
+ end
16
+
17
+ hydrated = Knuckles::Hydrator.call(prepared, hydrate: hydrate)
18
+
19
+ expect(hydrated.map { |hash| hash[:object] }).to eq([:updated, :updated])
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ RSpec.describe Knuckles::Keygen do
2
+ FakeKeyModel = Struct.new(:id, :updated_at)
3
+
4
+ describe ".cache_key" do
5
+ it "provides a simple default cache key" do
6
+ cache_key = Knuckles::Keygen.expand_key(FakeKeyModel.new(123, Time.now))
7
+
8
+ expect(cache_key).to match(%r{FakeKeyModel/123/\d+})
9
+ end
10
+
11
+ it "respects an object with a cache_key method" do
12
+ model = double(:model, cache_key: "abcdefg")
13
+
14
+ cache_key = Knuckles::Keygen.expand_key(model)
15
+
16
+ expect(cache_key).to eq("abcdefg")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,96 @@
1
+ RSpec.describe Knuckles::Pipeline do
2
+ describe "#stages" do
3
+ it "sets the default stages" do
4
+ pipeline = Knuckles::Pipeline.new
5
+
6
+ expect(pipeline.stages).not_to be_empty
7
+ end
8
+
9
+ it "allows stages to be customeized on initialization" do
10
+ pipeline = Knuckles::Pipeline.new(stages: [])
11
+
12
+ expect(pipeline.stages).to be_empty
13
+ end
14
+ end
15
+
16
+ describe "#delete" do
17
+ it "removes an existing stage" do
18
+ pipeline = Knuckles::Pipeline.new
19
+
20
+ pipeline.delete(Knuckles::Writer)
21
+
22
+ expect(pipeline.stages).not_to include(Knuckles::Writer)
23
+ end
24
+ end
25
+
26
+ describe "#insert_after" do
27
+ it "adds a stage after an existing stage" do
28
+ custom = Module.new
29
+ pipeline = Knuckles::Pipeline.new
30
+
31
+ pipeline.insert_after(Knuckles::Fetcher, custom)
32
+
33
+ expect(pipeline.stages).to include(custom)
34
+ expect(pipeline.stages.take(2)).to eq([
35
+ Knuckles::Fetcher,
36
+ custom
37
+ ])
38
+ end
39
+ end
40
+
41
+ describe "#insert_before" do
42
+ it "adds a stage after an existing stage" do
43
+ custom = Module.new
44
+ pipeline = Knuckles::Pipeline.new
45
+
46
+ pipeline.insert_before(Knuckles::Fetcher, custom)
47
+
48
+ expect(pipeline.stages).to include(custom)
49
+ expect(pipeline.stages.take(2)).to eq([
50
+ custom,
51
+ Knuckles::Fetcher
52
+ ])
53
+ end
54
+ end
55
+
56
+ describe "#call" do
57
+ it "aggregates the result of all stages" do
58
+ filter_a = Module.new do
59
+ def self.name
60
+ "strip"
61
+ end
62
+
63
+ def self.call(objects, _)
64
+ objects.each { |hash| hash[:object].strip! }
65
+ end
66
+ end
67
+
68
+ filter_b = Module.new do
69
+ def self.name
70
+ "downcase"
71
+ end
72
+
73
+ def self.call(objects, _)
74
+ objects.each { |hash| hash[:object].downcase! }
75
+ end
76
+ end
77
+
78
+ pipeline = Knuckles::Pipeline.new(stages: [filter_a, filter_b])
79
+
80
+ expect(pipeline.call([" KNUCKLES "], {}))
81
+ .to eq([{object: "knuckles", cached?: false, key: nil, result: nil}])
82
+ end
83
+ end
84
+
85
+ describe "#prepare" do
86
+ it "wraps all objects in entities" do
87
+ object = Object.new
88
+ prepared, = Knuckles::Pipeline.new.prepare([object])
89
+
90
+ expect(prepared[:object]).to be(object)
91
+ expect(prepared[:key]).to be_nil
92
+ expect(prepared[:result]).to be_nil
93
+ expect(prepared[:cached?]).to be_falsey
94
+ end
95
+ end
96
+ end