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.
@@ -0,0 +1,122 @@
1
+ require "active_model"
2
+
3
+ module Norman
4
+ # Extend this module if you want {Active Model}[http://github.com/rails/rails/tree/master/activemodel]
5
+ # support. Active Model is an API provided by Rails to make any Ruby object
6
+ # behave like an Active Record model instance. You can read an older writeup
7
+ # about it {here}[http://yehudakatz.com/2010/01/10/activemodel-make-any-ruby-object-feel-like-activerecord/].
8
+ module ActiveModel
9
+ def self.extended(base)
10
+ base.instance_eval do
11
+ extend ClassMethods
12
+ include InstanceMethods
13
+ extend ::ActiveModel::Naming
14
+ extend ::ActiveModel::Translation
15
+ include ::ActiveModel::Validations
16
+ include ::ActiveModel::Serializers::JSON
17
+ include ::ActiveModel::Serializers::Xml
18
+ extend ::ActiveModel::Callbacks
19
+ define_model_callbacks :save, :destroy
20
+ end
21
+ end
22
+
23
+ # Custom validations.
24
+ module Validations
25
+ # A uniqueness validator, similar to the one provided by Active Record.
26
+ class Uniqueness< ::ActiveModel::EachValidator
27
+ def validate_each(record, attribute, value)
28
+ return if record.persisted?
29
+ if attribute.to_sym == record.class.id_method
30
+ begin
31
+ if record.class.mapper[value]
32
+ record.errors[attribute] << "must be unique"
33
+ end
34
+ rescue Norman::NotFoundError
35
+ end
36
+ else
37
+ if record.class.all.detect {|x| x.send(attribute) == value}
38
+ record.errors[attribute] << "must be unique"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ module ClassMethods
46
+ # Create and save a model instance, raising an exception if any errors
47
+ # occur.
48
+ def create!(*args)
49
+ new(*args).save!
50
+ end
51
+
52
+ # Validate the uniqueness of a field's value in a model instance.
53
+ def validates_uniqueness_of(*attr_names)
54
+ validates_with Validations::Uniqueness, _merge_attributes(attr_names)
55
+ end
56
+
57
+ def model_name
58
+ @model_name ||= ::ActiveModel::Name.new(self)
59
+ end
60
+ end
61
+
62
+ module InstanceMethods
63
+ def initialize(*args)
64
+ @new_record = true
65
+ super
66
+ end
67
+
68
+ def attributes
69
+ hash = to_hash
70
+ hash.keys.each {|k| hash[k.to_s] = hash.delete(k)}
71
+ hash
72
+ end
73
+
74
+ def keys
75
+ self.class.attribute_names
76
+ end
77
+
78
+ def to_model
79
+ self
80
+ end
81
+
82
+ def new_record?
83
+ @new_record
84
+ end
85
+
86
+ def persisted?
87
+ !new_record?
88
+ end
89
+
90
+ def save
91
+ run_callbacks(:save) do
92
+ @new_record = false
93
+ super
94
+ end
95
+ end
96
+
97
+ def save!
98
+ if !valid?
99
+ raise Norman::NormanError, errors.to_a.join(", ")
100
+ else
101
+ save
102
+ end
103
+ end
104
+
105
+ def to_param
106
+ to_id if persisted?
107
+ end
108
+
109
+ def to_key
110
+ [to_param] if persisted?
111
+ end
112
+
113
+ def destroy
114
+ run_callbacks(:destroy) { delete }
115
+ end
116
+
117
+ def update_attributes
118
+ run_callbacks(:save) { update }
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,53 @@
1
+ module Norman
2
+
3
+ # Adapters are responsible for persisting the database. This base adapter
4
+ # offers no persistence, all IO operations are just stubs. Adapters must also
5
+ # present the full database as a Hash to the mapper, and provide a `key`
6
+ # method that returns an array with all the keys for the specified model
7
+ # class.
8
+ class Adapter
9
+
10
+ attr_reader :name
11
+ attr_reader :db
12
+
13
+ # @option options [String] :name The adapter name. Defaults to {#Norman.default_adapter_name}.
14
+ def initialize(options = {})
15
+ @name = options[:name] || Norman.default_adapter_name
16
+ load_database
17
+ Norman.register_adapter(self)
18
+ end
19
+
20
+ # Get a hash of all the data for the specified model class.
21
+ # @param klass [#to_s] The model class whose data to return.
22
+ def db_for(klass)
23
+ @db[klass.to_s] ||= {}
24
+ end
25
+
26
+ # Loads the database. For this adapter, that means simply creating a new
27
+ # hash.
28
+ def load_database
29
+ @db = {}
30
+ end
31
+
32
+ # These are all just noops for this adapter, which uses an in-memory hash
33
+ # and offers no persistence.
34
+
35
+ # Inheriting adapters can overload this method to export the data to a
36
+ # String.
37
+ def export_data
38
+ true
39
+ end
40
+
41
+ # Inheriting adapters can overload this method to load the data from some
42
+ # kind of storage.
43
+ def import_data
44
+ true
45
+ end
46
+
47
+ # Inheriting adapters can overload this method to persist the data to some
48
+ # kind of storage.
49
+ def save_database
50
+ true
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,55 @@
1
+ require "active_support"
2
+ require "active_support/message_verifier"
3
+ require "zlib"
4
+
5
+ module Norman
6
+ module Adapters
7
+
8
+ # Norman's cookie adapter allows you to store a Norman database inside
9
+ # a zipped and signed string suitable for setting as a cookie. This can be
10
+ # useful for modelling things like basic shopping carts or form wizards.
11
+ # Keep in mind the data is signed, so it can't be tampered with. However,
12
+ # the data is not *encrypted*, so somebody that wanted to could unzip and
13
+ # load the cookie data to see what's inside. So don't send this data
14
+ # client-side if it's at all sensitive.
15
+ class Cookie < Adapter
16
+
17
+ attr :verifier
18
+ attr_accessor :data
19
+
20
+ MAX_DATA_LENGTH = 4096
21
+
22
+ def self.max_data_length
23
+ MAX_DATA_LENGTH
24
+ end
25
+
26
+ def initialize(options)
27
+ @data = options[:data]
28
+ @verifier = ActiveSupport::MessageVerifier.new(options[:secret])
29
+ super
30
+ end
31
+
32
+ def export_data
33
+ cookie = verifier.generate(Zlib::Deflate.deflate(Marshal.dump(db)))
34
+ length = cookie.bytesize
35
+ if length > Cookie.max_data_length
36
+ raise(NormanError, "Data is %s bytes, cannot exceed %s" % [length, Cookie.max_data_length])
37
+ end
38
+ cookie
39
+ end
40
+
41
+ def import_data
42
+ data.blank? ? {} : Marshal.load(Zlib::Inflate.inflate(verifier.verify(data)))
43
+ end
44
+
45
+ def load_database
46
+ @db = import_data
47
+ @db.map(&:freeze)
48
+ end
49
+
50
+ def save_database
51
+ @data = export_data
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,38 @@
1
+ module Norman
2
+ module Adapters
3
+ # Loads and saves hash database from a Marshal.dump file.
4
+ class File < Adapter
5
+
6
+ attr_reader :file_path
7
+ attr :lock
8
+
9
+ def initialize(options)
10
+ @file_path = options[:file]
11
+ @lock = Mutex.new
12
+ super
13
+ end
14
+
15
+ def load_database
16
+ @db = import_data
17
+ @db.blank? ? @db = {} : @db.map(&:freeze)
18
+ rescue Errno::ENOENT
19
+ # @TODO warn via logger when file doesn't exist
20
+ @db = {}
21
+ end
22
+
23
+ def export_data
24
+ Marshal.dump(db)
25
+ end
26
+
27
+ def import_data
28
+ Marshal.load(::File.read(file_path))
29
+ end
30
+
31
+ def save_database
32
+ @lock.synchronize do
33
+ ::File.open(file_path, "w") {|f| f.write(export_data)}
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ require "yaml"
2
+
3
+ module Norman
4
+ module Adapters
5
+ # An Adapter that uses YAML for its storage.
6
+ class YAML < File
7
+
8
+ def import_data
9
+ data = ::YAML.load(::File.read(file_path))
10
+ end
11
+
12
+ def export_data
13
+ db.to_yaml
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,55 @@
1
+ module Norman
2
+ # Wrapper around hash instances that allows values to be accessed as symbols,
3
+ # strings or method invocations. It behaves similary to OpenStruct, with the
4
+ # fundamental difference being that you instantiate *one* HashProxy instance
5
+ # and reassign its Hash during a loop in order to avoid creating garbage.
6
+ class HashProxy
7
+ attr :hash
8
+
9
+ # Allows accessing a hash attribute as a method.
10
+ def method_missing(symbol)
11
+ hash[symbol] or raise NoMethodError
12
+ end
13
+
14
+ # Allows accessing a hash attribute as hash key, either a string or symbol.
15
+ def [](value)
16
+ hash[value || value.to_sym || value.to_s]
17
+ end
18
+
19
+ # Remove the hash.
20
+ def clear
21
+ @hash = nil
22
+ end
23
+
24
+ # Assign the value to hash and return self.
25
+ def using(hash)
26
+ @hash = hash ; self
27
+ end
28
+
29
+ # Set the hash to use while calling the block. When the block ends, the
30
+ # hash is unset.
31
+ def with(hash, &block)
32
+ yield using hash ensure clear
33
+ end
34
+ end
35
+
36
+ # Like HashProxy, but proxies access to two or more Hash instances.
37
+ class HashProxySet
38
+
39
+ attr :proxies
40
+
41
+ def initialize
42
+ @proxies = []
43
+ end
44
+
45
+ def using(*args)
46
+ args.size.times { proxies.push HashProxy.new } if proxies.empty?
47
+ proxies.each_with_index {|proxy, index| proxy.using args[index] }
48
+ end
49
+
50
+ def clear
51
+ proxies.map(&:clear)
52
+ end
53
+ end
54
+
55
+ end
@@ -0,0 +1,66 @@
1
+ module Norman
2
+
3
+ # Mappers provide the middle ground between models and adapters. Mappers are
4
+ # responsible for performing finds and moving objects in and out of the
5
+ # hash.
6
+ class Mapper
7
+ extend Forwardable
8
+ attr :hash
9
+ attr_accessor :adapter_name, :klass, :indexes, :options
10
+ def_delegators :hash, :clear, :delete
11
+ def_delegators :key_set, :all, :count, :find, :find_by_key, :first, :keys
12
+
13
+ def initialize(klass, adapter_name = nil, options = {})
14
+ @klass = klass
15
+ @adapter_name = adapter_name || Norman.default_adapter_name
16
+ @indexes = {}
17
+ @lock = Mutex.new
18
+ @options = options
19
+ @hash = adapter.db_for(klass)
20
+ end
21
+
22
+ # Returns a hash or model attributes corresponding to the provided key.
23
+ def [](key)
24
+ hash[key] or raise NotFoundError.new(klass, key)
25
+ end
26
+
27
+ # Sets a hash by key.
28
+ def []=(key, value)
29
+ @lock.synchronize do
30
+ @indexes = {}
31
+ if value.id_changed?
32
+ hash.delete value.to_id(true)
33
+ end
34
+ saved = hash[key] = value.to_hash.freeze
35
+ adapter.save_database if @options[:sync]
36
+ saved
37
+ end
38
+ end
39
+
40
+ # Memoize the output of a find in a threadsafe manner.
41
+ def add_index(name, indexable)
42
+ @lock.synchronize do
43
+ @indexes[name] = indexable
44
+ end
45
+ end
46
+
47
+ # Get the adapter.
48
+ def adapter
49
+ Norman.adapters[adapter_name]
50
+ end
51
+
52
+ # Get an instance by key
53
+ def get(key)
54
+ klass.send :from_hash, self[key]
55
+ end
56
+
57
+ def key_set
58
+ klass.key_class.new(hash.keys.freeze, self)
59
+ end
60
+
61
+ # Sets an instance, invoking its to_id method
62
+ def put(instance)
63
+ self[instance.to_id] = instance
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,164 @@
1
+ module Norman
2
+
3
+ module Model
4
+ def self.extended(base)
5
+ base.instance_eval do
6
+ @lock = Mutex.new
7
+ @attribute_names = []
8
+ @key_class = Class.new(Norman::AbstractKeySet)
9
+ extend ClassMethods
10
+ include InstanceMethods
11
+ include Comparable
12
+ end
13
+ end
14
+
15
+ module ClassMethods
16
+ extend Forwardable
17
+ attr_accessor :attribute_names, :id_method, :mapper
18
+ attr_reader :key_class
19
+ def_delegators(*[:find, Enumerable.public_instance_methods(false)].flatten)
20
+ def_delegators(:mapper, :[], :all, :delete, :first, :get, :count, :find, :find_by_key, :keys)
21
+ alias id_field id_method=
22
+
23
+ def field(*names)
24
+ names.each do |name|
25
+ # First attribute added is the default id
26
+ id_field name if attribute_names.empty?
27
+ attribute_names << name.to_sym
28
+ class_eval(<<-EOM, __FILE__, __LINE__ + 1)
29
+ def #{name}
30
+ @#{name} or (@attributes[:#{name}] if @attributes)
31
+ end
32
+
33
+ def #{name}=(value)
34
+ @#{name} = value
35
+ end
36
+ EOM
37
+ end
38
+ end
39
+
40
+ def use(adapter_name, options = {})
41
+ @mapper = nil
42
+ @adapter_name = adapter_name
43
+ @mapper_options = options
44
+ end
45
+
46
+ # Memoize the output of the method call invoked in the block.
47
+ # @param [#to_s] name If not given, the name of the method calling with_index will be used.
48
+ def with_index(name = nil, &block)
49
+ name ||= caller(1)[0].match(/in `(.*)'\z/)[1]
50
+ mapper.indexes[name.to_s] or begin
51
+ indexable = yield
52
+ mapper.add_index(name, indexable)
53
+ end
54
+ end
55
+
56
+ def create(hash)
57
+ new(hash).save
58
+ end
59
+
60
+ # The point of this method is to provide a fast way to get model instances
61
+ # based on the hash attributes managed by the mapper and adapter.
62
+ #
63
+ # The hash arg gets frozen, which can be a nasty side-effect, but helps
64
+ # avoid hard-to-track-down bugs if the hash is updated somewhere outside
65
+ # the model. This should only be used internally to Norman, which is why
66
+ # it's private.
67
+ def from_hash(hash)
68
+ instance = allocate
69
+ instance.instance_variable_set :@attributes, hash.freeze
70
+ instance
71
+ end
72
+ private :from_hash
73
+
74
+ def filters(&block)
75
+ key_class.class_eval(&block)
76
+ key_class.instance_methods(false).each do |name|
77
+ instance_eval(<<-EOM, __FILE__, __LINE__ + 1)
78
+ def #{name}(*args)
79
+ mapper.key_set.#{name}(*args)
80
+ end
81
+ EOM
82
+ end
83
+ end
84
+
85
+ def mapper
86
+ @mapper or @lock.synchronize do
87
+ name = @adapter_name || Norman.default_adapter_name
88
+ options = @mapper_options || {}
89
+ @mapper ||= Mapper.new(self, name, options)
90
+ end
91
+ end
92
+ end
93
+
94
+ module InstanceMethods
95
+
96
+ # Norman models can be instantiated with a hash of attribures, a block,
97
+ # or both. If both a hash and block are given, then the values set inside
98
+ # the block will take precedence over those set in the hash.
99
+ #
100
+ # @example
101
+ # Person.new :name => "Joe"
102
+ # Person.new {|p| p.name = "Joe"}
103
+ # Person.new(params[:person]) {|p| p.age = 38}
104
+ #
105
+ def initialize(attributes = nil, &block)
106
+ @attributes = {}.freeze
107
+ return unless attributes || block_given?
108
+ if attributes
109
+ self.class.attribute_names.each do |name|
110
+ value = attributes[name] || attributes[name.to_s]
111
+ send("#{name}=", value) if value
112
+ end
113
+ end
114
+ yield self if block_given?
115
+ end
116
+
117
+ # Norman models implement the <=> method and mix in Comparable to provide
118
+ # sorting methods. This default implementation compares the result of
119
+ # #to_id. If the items being compared are not of the same kind.
120
+ def <=>(instance)
121
+ to_id <=> instance.to_id if instance.kind_of? self.class
122
+ end
123
+
124
+ # Get a hash of the instance's model attributes.
125
+ def to_hash
126
+ self.class.attribute_names.inject({}) do |hash, key|
127
+ hash[key] = self.send(key); hash
128
+ end
129
+ end
130
+
131
+ # Returns true is the model's id field has been updated.
132
+ def id_changed?
133
+ to_id != @attributes[self.class.id_method]
134
+ end
135
+
136
+ # Invoke the model's id method to return this instance's unique key. If
137
+ # true is passed, then the id will be read from the attributes hash rather
138
+ # than from an instance variable. This allows you to retrieve the old id,
139
+ # in the event that the id has been changed.
140
+ def to_id(use_old = false)
141
+ use_old ? @attributes[self.class.id_method] : send(self.class.id_method)
142
+ end
143
+
144
+ # Tell the mapper to save the data for this model instance.
145
+ def save
146
+ self.class.mapper.put(self)
147
+ end
148
+
149
+ # Update this instance's attributes and invoke #save.
150
+ def update(attributes)
151
+ self.class.attribute_names.each do |name|
152
+ value = attributes[name] || attributes[name.to_s]
153
+ send("#{name}=", value) if value
154
+ end
155
+ save
156
+ end
157
+
158
+ # Tell the mapper to delete the data for this instance.
159
+ def delete
160
+ self.class.delete(self.to_id)
161
+ end
162
+ end
163
+ end
164
+ end