knuckles 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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