ambry 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.
- 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
|