norman 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 +104 -0
- data/Rakefile +39 -0
- data/extras/bench.rb +107 -0
- data/extras/cookie_demo.rb +111 -0
- data/extras/countries.rb +70 -0
- data/lib/generators/norman_generator.rb +22 -0
- data/lib/norman.rb +54 -0
- data/lib/norman/abstract_key_set.rb +106 -0
- data/lib/norman/active_model.rb +122 -0
- data/lib/norman/adapter.rb +53 -0
- data/lib/norman/adapters/cookie.rb +55 -0
- data/lib/norman/adapters/file.rb +38 -0
- data/lib/norman/adapters/yaml.rb +17 -0
- data/lib/norman/hash_proxy.rb +55 -0
- data/lib/norman/mapper.rb +66 -0
- data/lib/norman/model.rb +164 -0
- data/lib/norman/version.rb +9 -0
- data/lib/rack/norman.rb +21 -0
- data/norman.gemspec +25 -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 +136 -0
data/Rakefile
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require "rake"
|
2
|
+
require "rake/testtask"
|
3
|
+
require "rake/clean"
|
4
|
+
require "rubygems/package_task"
|
5
|
+
|
6
|
+
task :default => :spec
|
7
|
+
task :test => :spec
|
8
|
+
|
9
|
+
CLEAN << %w[pkg doc coverage .yardoc]
|
10
|
+
|
11
|
+
begin
|
12
|
+
desc "Run SimpleCov"
|
13
|
+
task :coverage do
|
14
|
+
ENV["coverage"] = "true"
|
15
|
+
Rake::Task["spec"].execute
|
16
|
+
end
|
17
|
+
rescue LoadError
|
18
|
+
end
|
19
|
+
|
20
|
+
gemspec = File.expand_path("../norman.gemspec", __FILE__)
|
21
|
+
if File.exist? gemspec
|
22
|
+
Gem::PackageTask.new(eval(File.read(gemspec))) { |pkg| }
|
23
|
+
end
|
24
|
+
|
25
|
+
Rake::TestTask.new(:spec) { |t| t.pattern = "spec/**/*_spec.rb" }
|
26
|
+
|
27
|
+
begin
|
28
|
+
require "yard"
|
29
|
+
YARD::Rake::YardocTask.new do |t|
|
30
|
+
t.options = ["--output-dir=doc"]
|
31
|
+
t.options << "--files" << ["Guide.md", "Changelog.md"].join(",")
|
32
|
+
end
|
33
|
+
rescue LoadError
|
34
|
+
end
|
35
|
+
|
36
|
+
desc "Run benchmarks"
|
37
|
+
task :bench do
|
38
|
+
require File.expand_path("../extras/bench", __FILE__)
|
39
|
+
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 "norman"
|
6
|
+
|
7
|
+
N = 500
|
8
|
+
|
9
|
+
Norman::Adapter.new
|
10
|
+
|
11
|
+
class Person
|
12
|
+
extend Norman::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 "norman"
|
10
|
+
require "norman/adapters/cookie"
|
11
|
+
require "rack/norman"
|
12
|
+
|
13
|
+
set :session, false
|
14
|
+
|
15
|
+
use Rack::Cookies
|
16
|
+
use Rack::Norman, :name => :cookie, :secret => "Sssshhhh! This is a secret."
|
17
|
+
|
18
|
+
class Book
|
19
|
+
extend Norman::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 Norman 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 Norman's filters
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "norman"
|
5
|
+
|
6
|
+
Norman::Adapter.new
|
7
|
+
|
8
|
+
class Country
|
9
|
+
extend Norman::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
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/actions'
|
3
|
+
|
4
|
+
# This generator adds an initializer and default empty database to your Rails
|
5
|
+
# application. It can be invoked on the command line like:
|
6
|
+
#
|
7
|
+
# rails generate norman
|
8
|
+
#
|
9
|
+
class NormanGenerator < Rails::Generators::Base
|
10
|
+
|
11
|
+
# Create the initializer and empty database.
|
12
|
+
def create_files
|
13
|
+
initializer("norman.rb") do
|
14
|
+
<<-EOI
|
15
|
+
require "norman/adapters/yaml"
|
16
|
+
require "norman/active_model"
|
17
|
+
Norman::Adapters::YAML.new :file => Rails.root.join('db', 'norman.yml')
|
18
|
+
EOI
|
19
|
+
end
|
20
|
+
create_file("db/norman.yml", '')
|
21
|
+
end
|
22
|
+
end
|
data/lib/norman.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
require "thread"
|
3
|
+
require "norman/adapter"
|
4
|
+
require "norman/abstract_key_set"
|
5
|
+
require "norman/mapper"
|
6
|
+
require "norman/model"
|
7
|
+
require "norman/hash_proxy"
|
8
|
+
require "norman/adapters/file"
|
9
|
+
|
10
|
+
# Norman is a database and ORM replacement for small, mostly static models.
|
11
|
+
#
|
12
|
+
# Norman is free software released under the terms of the MIT License.
|
13
|
+
# @author Norman Clarke
|
14
|
+
module Norman
|
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 Norman adapters.
|
24
|
+
attr_reader :adapters
|
25
|
+
@adapters = {}
|
26
|
+
|
27
|
+
# Registers an adapter with Norman. 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 Norman::Model::ClassMethods#use
|
32
|
+
def register_adapter(adapter)
|
33
|
+
name = adapter.name.to_sym
|
34
|
+
if adapters[name]
|
35
|
+
raise NormanError, "Adapter #{name.inspect} already registered"
|
36
|
+
end
|
37
|
+
@lock.synchronize do
|
38
|
+
adapters[name] = adapter
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Base error for Norman.
|
43
|
+
class NormanError < StandardError ; end
|
44
|
+
|
45
|
+
# Raised when a single instance is expected but could not be found.
|
46
|
+
class NotFoundError < NormanError
|
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 Norman
|
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
|