ambry 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Changelog.md +5 -0
- data/Gemfile +2 -0
- data/Guide.md +320 -0
- data/MIT-LICENSE +18 -0
- data/README.md +97 -0
- data/Rakefile +39 -0
- data/ambry.gemspec +25 -0
- data/extras/bench.rb +107 -0
- data/extras/cookie_demo.rb +111 -0
- data/extras/countries.rb +70 -0
- data/lib/ambry.rb +54 -0
- data/lib/ambry/abstract_key_set.rb +106 -0
- data/lib/ambry/active_model.rb +122 -0
- data/lib/ambry/adapter.rb +53 -0
- data/lib/ambry/adapters/cookie.rb +55 -0
- data/lib/ambry/adapters/file.rb +38 -0
- data/lib/ambry/adapters/yaml.rb +17 -0
- data/lib/ambry/hash_proxy.rb +55 -0
- data/lib/ambry/mapper.rb +66 -0
- data/lib/ambry/model.rb +164 -0
- data/lib/ambry/version.rb +9 -0
- data/lib/generators/norman_generator.rb +22 -0
- data/lib/rack/norman.rb +21 -0
- data/spec/active_model_spec.rb +115 -0
- data/spec/adapter_spec.rb +48 -0
- data/spec/cookie_adapter_spec.rb +81 -0
- data/spec/file_adapter_spec.rb +48 -0
- data/spec/fixtures.yml +18 -0
- data/spec/key_set_spec.rb +104 -0
- data/spec/mapper_spec.rb +97 -0
- data/spec/model_spec.rb +162 -0
- data/spec/spec_helper.rb +38 -0
- metadata +147 -0
data/ambry.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
require File.expand_path("../lib/ambry/version", __FILE__)
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.authors = "Norman Clarke"
|
5
|
+
s.email = "norman@njclarke.com"
|
6
|
+
s.files = `git ls-files`.split("\n").reject {|f| f =~ /^\./}
|
7
|
+
s.homepage = "http://github.com/norman/ambry"
|
8
|
+
s.name = "ambry"
|
9
|
+
s.platform = Gem::Platform::RUBY
|
10
|
+
s.rubyforge_project = "[none]"
|
11
|
+
s.summary = "An ActiveModel-compatible ORM-like library for storing model instances in an in-memory Hash."
|
12
|
+
s.test_files = Dir.glob "test/**/*_test.rb"
|
13
|
+
s.version = Ambry::Version::STRING
|
14
|
+
s.description = <<-EOD
|
15
|
+
Ambry is not an ORM, man! It's a database and ORM replacement for (mostly)
|
16
|
+
static models and small datasets. It provides ActiveModel compatibility, and
|
17
|
+
flexible searching and storage.
|
18
|
+
EOD
|
19
|
+
s.add_development_dependency "ffaker"
|
20
|
+
s.add_development_dependency "minitest", "~> 2.2.2"
|
21
|
+
s.add_development_dependency "mocha"
|
22
|
+
s.add_development_dependency "activesupport", "~> 3.0"
|
23
|
+
s.add_development_dependency "activemodel", "~> 3.0"
|
24
|
+
s.add_development_dependency "rake"
|
25
|
+
end
|
data/extras/bench.rb
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "bundler/setup"
|
3
|
+
require "benchmark"
|
4
|
+
require "ffaker"
|
5
|
+
require "ambry"
|
6
|
+
|
7
|
+
N = 500
|
8
|
+
|
9
|
+
Ambry::Adapter.new
|
10
|
+
|
11
|
+
class Person
|
12
|
+
extend Ambry::Model
|
13
|
+
field :email, :name, :age
|
14
|
+
|
15
|
+
def self.younger_than(age)
|
16
|
+
with_index("younger_than_#{age}") do
|
17
|
+
find {|person| person[:age] < age}
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
filters do
|
22
|
+
def older_than(age)
|
23
|
+
find {|person| person[:age] > age}
|
24
|
+
end
|
25
|
+
|
26
|
+
def email_matches(regexp)
|
27
|
+
find {|person| person[:email] =~ regexp}
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
until Person.count == 1000 do
|
34
|
+
Person.create \
|
35
|
+
:name => Faker::Name.name,
|
36
|
+
:email => Faker::Internet.email,
|
37
|
+
:age => rand(100)
|
38
|
+
end
|
39
|
+
|
40
|
+
keys = Person.all.keys.sort do |a, b|
|
41
|
+
rand(100) <=> rand(100)
|
42
|
+
end[0,10]
|
43
|
+
|
44
|
+
Benchmark.bmbm do |x|
|
45
|
+
|
46
|
+
puts "Benchmarking #{N} times:\n\n"
|
47
|
+
|
48
|
+
x.report("Count records") do
|
49
|
+
N.times do
|
50
|
+
Person.count {|p| p[:email] =~ /\.com/}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
x.report("Count scoped records") do
|
55
|
+
N.times do
|
56
|
+
Person.older_than(50).count
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
x.report("Get 10 random keys") do
|
61
|
+
N.times do
|
62
|
+
keys.each {|k| Person.get(k)}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
x.report("Find records iterating on values") do
|
67
|
+
N.times do
|
68
|
+
Person.find {|p| p[:email] =~ /\.com/}
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
x.report("Find records using proxy method") do
|
73
|
+
N.times do
|
74
|
+
Person.find {|p| p.email =~ /\.com/}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
x.report("Find records iterating on keys") do
|
79
|
+
N.times do
|
80
|
+
Person.find_by_key {|k| k =~ /\.com/}
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
x.report("Find scoped people without index") do
|
85
|
+
N.times do
|
86
|
+
Person.find {|p| p[:age] < 50 && p[:email] =~ /\.com/}
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
x.report("Find scoped people with index") do
|
91
|
+
N.times do
|
92
|
+
Person.younger_than(50).find {|p| p[:email] =~ /\.com/}
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
x.report("Find with chained filters") do
|
97
|
+
N.times do
|
98
|
+
Person.older_than(50).email_matches(/\.com/)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
x.report("Find records iterating on keys and using scope") do
|
103
|
+
N.times do
|
104
|
+
Person.find_by_key {|k| k =~ /\.com/}.older_than(50)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
$LOAD_PATH << File.expand_path("../../lib", __FILE__)
|
2
|
+
$LOAD_PATH.uniq!
|
3
|
+
|
4
|
+
require "rubygems"
|
5
|
+
require "sinatra"
|
6
|
+
require "haml"
|
7
|
+
require "babosa"
|
8
|
+
require "date"
|
9
|
+
require "ambry"
|
10
|
+
require "ambry/adapters/cookie"
|
11
|
+
require "rack/ambry"
|
12
|
+
|
13
|
+
set :session, false
|
14
|
+
|
15
|
+
use Rack::Cookies
|
16
|
+
use Rack::Ambry, :name => :cookie, :secret => "Sssshhhh! This is a secret."
|
17
|
+
|
18
|
+
class Book
|
19
|
+
extend Ambry::Model
|
20
|
+
field :slug, :title, :author
|
21
|
+
use :cookie
|
22
|
+
|
23
|
+
def title=(value)
|
24
|
+
@slug = value.to_slug.normalize.to_s
|
25
|
+
@title = value
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
get "/" do
|
30
|
+
@header = "Books"
|
31
|
+
@books = Book.all
|
32
|
+
haml :index
|
33
|
+
end
|
34
|
+
|
35
|
+
get "/books/new" do
|
36
|
+
@header = "Add a Book"
|
37
|
+
@action = "/books"
|
38
|
+
haml :new
|
39
|
+
end
|
40
|
+
|
41
|
+
get "/books/:slug/edit" do |slug|
|
42
|
+
@book = Book.get(slug)
|
43
|
+
@action = "/books"
|
44
|
+
@header = @book.title
|
45
|
+
params[:title] = @book.title
|
46
|
+
params[:author] = @book.author
|
47
|
+
haml :edit
|
48
|
+
end
|
49
|
+
|
50
|
+
get "/books/:slug" do |slug|
|
51
|
+
@book = Book.get(slug)
|
52
|
+
@header = @book.title
|
53
|
+
haml :book
|
54
|
+
end
|
55
|
+
|
56
|
+
post "/books" do
|
57
|
+
Book.delete params[:slug] unless params[:slug].blank?
|
58
|
+
@book = Book.create params unless params[:title].blank?
|
59
|
+
redirect "/"
|
60
|
+
end
|
61
|
+
|
62
|
+
__END__
|
63
|
+
@@layout
|
64
|
+
!!! 5
|
65
|
+
%html
|
66
|
+
%head
|
67
|
+
%meta(http-equiv="Content-Type" content="text/html; charset=utf-8")
|
68
|
+
%title Ambry Cookie Adapter Demo
|
69
|
+
%body
|
70
|
+
%h2= @header
|
71
|
+
= yield
|
72
|
+
|
73
|
+
@@edit
|
74
|
+
= haml(:form, :layout => false)
|
75
|
+
|
76
|
+
@@index
|
77
|
+
%ul
|
78
|
+
- @books.each do |book|
|
79
|
+
%li= '<a href="/books/%s">%s</a>' % [book.slug, book.title, book.author]
|
80
|
+
%p.controls
|
81
|
+
<a href="/books/new">New book</a>
|
82
|
+
|
83
|
+
@@new
|
84
|
+
= haml(:form, :layout => false)
|
85
|
+
%p.controls
|
86
|
+
<a href="/">Books</a>
|
87
|
+
|
88
|
+
@@book
|
89
|
+
by #{@book.author}
|
90
|
+
%p.controls
|
91
|
+
<a href="/">Books</a>
|
92
|
+
<a href="/books/#{@book.slug}/edit">Edit</a>
|
93
|
+
|
94
|
+
@@form
|
95
|
+
%form(method="post" enctype="utf-8" action=@action)
|
96
|
+
%p
|
97
|
+
- if @book
|
98
|
+
%input#slug{:type => "hidden", :value => @book.slug, :name => "slug"}
|
99
|
+
%label(for="title") Title:
|
100
|
+
%br
|
101
|
+
%input#title{:type => "text", :value => params[:title], :name => "title"}
|
102
|
+
%p
|
103
|
+
%label(for="author") Author:
|
104
|
+
%br
|
105
|
+
%input#author{:type => "text", :value => params[:author], :name => "author"}
|
106
|
+
%p
|
107
|
+
%input(type="submit" value="save it")
|
108
|
+
- if @book
|
109
|
+
%form{:method => "post", :enctype => "utf-8", :action => "/books"}
|
110
|
+
%input#slug{:type => "hidden", :value => @book.slug, :name => "slug"}
|
111
|
+
%input(type="submit" value="or delete it")
|
data/extras/countries.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# A demo of Ambry's filters
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "ambry"
|
5
|
+
|
6
|
+
Ambry::Adapter.new
|
7
|
+
|
8
|
+
class Country
|
9
|
+
extend Ambry::Model
|
10
|
+
field :tld, :name, :population, :region
|
11
|
+
|
12
|
+
filters do
|
13
|
+
def african
|
14
|
+
find {|p| p.region == :africa}
|
15
|
+
end
|
16
|
+
|
17
|
+
def european
|
18
|
+
find {|p| p.region == :europe}
|
19
|
+
end
|
20
|
+
|
21
|
+
def population(op, num)
|
22
|
+
find {|p| p.population.send(op, num)}.sort {|a, b| b.population <=> a.population}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Population data from: http://en.wikipedia.org/wiki/List_of_countries_by_population
|
28
|
+
[
|
29
|
+
{:tld => "br", :name => "Brazil", :population => 190_732_694, :region => :america},
|
30
|
+
{:tld => "bw", :name => "Botswana", :population => 1_839_833, :region => :africa},
|
31
|
+
{:tld => "cn", :name => "China", :population => 1_342_740_000, :region => :asia},
|
32
|
+
{:tld => "dz", :name => "Algeria", :population => 33_333_216, :region => :africa},
|
33
|
+
{:tld => "eg", :name => "Egypt", :population => 80_335_036, :region => :africa},
|
34
|
+
{:tld => "et", :name => "Ethiopia", :population => 85_237_338, :region => :africa},
|
35
|
+
{:tld => "fr", :name => "France", :population => 65_821_885, :region => :europe},
|
36
|
+
{:tld => "ma", :name => "Morocco", :population => 33_757_175, :region => :africa},
|
37
|
+
{:tld => "mc", :name => "Monaco", :population => 33_000, :region => :europe},
|
38
|
+
{:tld => "mz", :name => "Mozambique", :population => 20_366_795, :region => :africa},
|
39
|
+
{:tld => "ng", :name => "Nigeria", :population => 154_729_000, :region => :africa},
|
40
|
+
{:tld => "sc", :name => "Seychelles", :population => 80_654, :region => :africa}
|
41
|
+
].each {|c| Country.create(c)}
|
42
|
+
|
43
|
+
@african_countries = Country.african
|
44
|
+
@bigger_countries = Country.population(:>=, 50_000_000)
|
45
|
+
@smaller_countries = Country.population(:<=, 5_000_000)
|
46
|
+
@european_countries = Country.european
|
47
|
+
@bigger_african_countries = Country.african.population(:>=, 50_000_000)
|
48
|
+
@bigger_non_african_countries = @bigger_countries - @african_countries
|
49
|
+
@bigger_or_european_countries = @bigger_countries + @european_countries
|
50
|
+
@smaller_or_european_countries = @smaller_countries + @european_countries
|
51
|
+
@smaller_european_countries = @smaller_countries & @european_countries
|
52
|
+
|
53
|
+
instance_variables.each do |name|
|
54
|
+
puts "%s: %s" % [
|
55
|
+
name.to_s.gsub("_", " ").gsub("@", ""),
|
56
|
+
instance_variable_get(name).all.map(&:name).join(", ")
|
57
|
+
]
|
58
|
+
end
|
59
|
+
|
60
|
+
# Output:
|
61
|
+
#
|
62
|
+
# african countries: Algeria, Botswana, Egypt, Ethiopia, Nigeria, Seychelles, Mozambique, Morocco
|
63
|
+
# bigger countries: China, Brazil, Nigeria, Ethiopia, Egypt, France
|
64
|
+
# smaller countries: Botswana, Seychelles, Monaco
|
65
|
+
# european countries: France, Monaco
|
66
|
+
# bigger african countries: Egypt, Ethiopia, Nigeria
|
67
|
+
# bigger non african countries: China, Brazil, France
|
68
|
+
# bigger or european countries: China, Brazil, Nigeria, Ethiopia, Egypt, France, Monaco
|
69
|
+
# smaller and european countries: Botswana, Seychelles, Monaco, France
|
70
|
+
# smaller european countries: Monaco
|
data/lib/ambry.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
require "thread"
|
3
|
+
require "ambry/adapter"
|
4
|
+
require "ambry/abstract_key_set"
|
5
|
+
require "ambry/mapper"
|
6
|
+
require "ambry/model"
|
7
|
+
require "ambry/hash_proxy"
|
8
|
+
require "ambry/adapters/file"
|
9
|
+
|
10
|
+
# Ambry is a database and ORM replacement for small, mostly static models.
|
11
|
+
#
|
12
|
+
# Ambry is free software released under the terms of the MIT License.
|
13
|
+
# @author Norman Clarke
|
14
|
+
module Ambry
|
15
|
+
extend self
|
16
|
+
|
17
|
+
@lock = Mutex.new
|
18
|
+
|
19
|
+
# The default adapter name.
|
20
|
+
attr_reader :default_adapter_name
|
21
|
+
@default_adapter_name = :main
|
22
|
+
|
23
|
+
# A hash of all instantiated Ambry adapters.
|
24
|
+
attr_reader :adapters
|
25
|
+
@adapters = {}
|
26
|
+
|
27
|
+
# Registers an adapter with Ambry. This facilitates allowing models to
|
28
|
+
# specify an adapter by name rather than class or instance.
|
29
|
+
#
|
30
|
+
# @param [Symbol] adapter The adapter name.
|
31
|
+
# @see Ambry::Model::ClassMethods#use
|
32
|
+
def register_adapter(adapter)
|
33
|
+
name = adapter.name.to_sym
|
34
|
+
if adapters[name]
|
35
|
+
raise AmbryError, "Adapter #{name.inspect} already registered"
|
36
|
+
end
|
37
|
+
@lock.synchronize do
|
38
|
+
adapters[name] = adapter
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Base error for Ambry.
|
43
|
+
class AmbryError < StandardError ; end
|
44
|
+
|
45
|
+
# Raised when a single instance is expected but could not be found.
|
46
|
+
class NotFoundError < AmbryError
|
47
|
+
|
48
|
+
# @param [String] klass The class from which the error originated.
|
49
|
+
# @param [String] key The key whose lookup trigged the error.
|
50
|
+
def initialize(*args)
|
51
|
+
super('Could not find %s with key "%s"' % args)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
module Ambry
|
2
|
+
|
3
|
+
# @abstract
|
4
|
+
class AbstractKeySet
|
5
|
+
extend Forwardable
|
6
|
+
include Enumerable
|
7
|
+
|
8
|
+
attr_accessor :keys, :mapper
|
9
|
+
def_delegators :keys, :empty?, :length, :size
|
10
|
+
def_delegators :to_enum, :each
|
11
|
+
|
12
|
+
# Create a new KeySet from an array of keys and a mapper.
|
13
|
+
def initialize(keys = nil, mapper = nil)
|
14
|
+
@keys = keys || [].freeze
|
15
|
+
# Assume that if a frozen array is passed in, it's already been compacted
|
16
|
+
# and uniqued in order to improve performance.
|
17
|
+
unless @keys.frozen?
|
18
|
+
@keys.uniq!
|
19
|
+
@keys.compact!
|
20
|
+
@keys.freeze
|
21
|
+
end
|
22
|
+
@mapper = mapper
|
23
|
+
end
|
24
|
+
|
25
|
+
def +(key_set)
|
26
|
+
self.class.new(keys + key_set.keys, mapper)
|
27
|
+
end
|
28
|
+
alias | +
|
29
|
+
|
30
|
+
def -(key_set)
|
31
|
+
self.class.new((keys - key_set.keys).freeze, mapper)
|
32
|
+
end
|
33
|
+
|
34
|
+
def &(key_set)
|
35
|
+
self.class.new((keys & key_set.keys).compact.freeze, mapper)
|
36
|
+
end
|
37
|
+
|
38
|
+
# With no block, returns an instance for the first key. If a block is given,
|
39
|
+
# it returns the first instance yielding a true value.
|
40
|
+
def first(&block)
|
41
|
+
block_given? ? all.detect(&block) : all.first
|
42
|
+
end
|
43
|
+
|
44
|
+
# With no block, returns the number of keys. If a block is given, counts the
|
45
|
+
# number of elements yielding a true value.
|
46
|
+
def count(&block)
|
47
|
+
return keys.count unless block_given?
|
48
|
+
proxy = HashProxy.new
|
49
|
+
keys.inject(0) do |count, key|
|
50
|
+
proxy.with(mapper[key], &block) ? count.succ : count
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def find(id = nil, &block)
|
55
|
+
return mapper.get(id) if id
|
56
|
+
return self unless block_given?
|
57
|
+
proxy = HashProxy.new
|
58
|
+
self.class.new(keys.inject([]) do |found, key|
|
59
|
+
found << key if proxy.with(mapper[key], &block)
|
60
|
+
found
|
61
|
+
end, mapper)
|
62
|
+
end
|
63
|
+
|
64
|
+
def to_enum
|
65
|
+
KeyIterator.new(keys) {|k| @mapper.get(k)}
|
66
|
+
end
|
67
|
+
alias all to_enum
|
68
|
+
|
69
|
+
def find_by_key(&block)
|
70
|
+
return self unless block_given?
|
71
|
+
self.class.new(keys.inject([]) do |set, key|
|
72
|
+
set << key if yield(key); set
|
73
|
+
end, mapper)
|
74
|
+
end
|
75
|
+
|
76
|
+
def sort(&block)
|
77
|
+
proxies = HashProxySet.new
|
78
|
+
self.class.new(@keys.sort do |a, b|
|
79
|
+
begin
|
80
|
+
yield(*proxies.using(mapper[a], mapper[b]))
|
81
|
+
ensure
|
82
|
+
proxies.clear
|
83
|
+
end
|
84
|
+
end, mapper)
|
85
|
+
end
|
86
|
+
|
87
|
+
def limit(length)
|
88
|
+
self.class.new(@keys.first(length).freeze, mapper)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
class KeyIterator
|
93
|
+
include Enumerable
|
94
|
+
|
95
|
+
attr_reader :keys, :callable
|
96
|
+
|
97
|
+
def initialize(keys, &callable)
|
98
|
+
@keys = keys
|
99
|
+
@callable = callable
|
100
|
+
end
|
101
|
+
|
102
|
+
def each(&block)
|
103
|
+
block_given? ? keys.each {|k| yield callable.call(k)} : to_enum
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|