congo 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +23 -0
- data/LICENSE +20 -0
- data/README.rdoc +17 -0
- data/Rakefile +51 -0
- data/VERSION +1 -0
- data/config/locales/en.yml +18 -0
- data/config/locales/fr.yml +18 -0
- data/init.rb +2 -0
- data/lib/congo.rb +16 -0
- data/lib/congo/content_type.rb +107 -0
- data/lib/congo/grip/attachment.rb +22 -0
- data/lib/congo/grip/has_attachment.rb +99 -0
- data/lib/congo/list.rb +64 -0
- data/lib/congo/metadata/association.rb +24 -0
- data/lib/congo/metadata/key.rb +40 -0
- data/lib/congo/metadata/validation.rb +42 -0
- data/lib/congo/migration.rb +143 -0
- data/lib/congo/proxy_scoper.rb +18 -0
- data/lib/congo/scoper.rb +81 -0
- data/lib/congo/support.rb +19 -0
- data/lib/congo/types.rb +64 -0
- data/lib/congo/validation.rb +86 -0
- data/spec/assets/avatar.jpeg +0 -0
- data/spec/assets/dhh.jpg +0 -0
- data/spec/functional/content_type_spec.rb +152 -0
- data/spec/functional/functional_spec_helper.rb +11 -0
- data/spec/functional/grip_spec.rb +47 -0
- data/spec/functional/key_spec.rb +94 -0
- data/spec/functional/list_spec.rb +67 -0
- data/spec/functional/migration_spec.rb +167 -0
- data/spec/functional/scoper_spec.rb +128 -0
- data/spec/functional/validation_spec.rb +90 -0
- data/spec/models.rb +41 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/unit/key_spec.rb +63 -0
- data/spec/unit/scoper_spec.rb +19 -0
- data/spec/unit/unit_spec_helper.rb +3 -0
- metadata +142 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
module Congo
|
2
|
+
module Metadata
|
3
|
+
class Association
|
4
|
+
include MongoMapper::EmbeddedDocument
|
5
|
+
|
6
|
+
key :name, String
|
7
|
+
key :type, String, :default => 'many'
|
8
|
+
|
9
|
+
validates_presence_of :name, :type,
|
10
|
+
:message => lambda { I18n.t('congo.errors.messages.empty') }
|
11
|
+
|
12
|
+
validates_inclusion_of :type,
|
13
|
+
:within => ['many', 'belongs_to'],
|
14
|
+
:message => lambda { I18n.t('congo.errors.messages.inclusion') }
|
15
|
+
|
16
|
+
def apply(klass, scope)
|
17
|
+
klass.send(type, name, :class => scope.content_type_as_const(name.classify))
|
18
|
+
if type.to_sym == :belongs_to
|
19
|
+
klass.key name.foreign_key, String
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Congo
|
2
|
+
module Metadata
|
3
|
+
class Key
|
4
|
+
include MongoMapper::EmbeddedDocument
|
5
|
+
|
6
|
+
## keys
|
7
|
+
key :name, String
|
8
|
+
key :label, String
|
9
|
+
key :type, String, :default => 'String'
|
10
|
+
|
11
|
+
## validations
|
12
|
+
validates_presence_of :name, :type,
|
13
|
+
:message => lambda { I18n.t('congo.errors.messages.empty') }
|
14
|
+
|
15
|
+
## callbacks
|
16
|
+
before_validation { |c| c.send(:normalize_name) }
|
17
|
+
|
18
|
+
def apply(klass, scope)
|
19
|
+
if type == 'File'
|
20
|
+
klass.send(:include, Congo::Grip::HasAttachment) unless klass.include?(Congo::Grip::HasAttachment) # do not add the module twice
|
21
|
+
klass.has_grid_attachment name.to_sym, :path => ":name/:id"
|
22
|
+
else
|
23
|
+
ctype = scope.content_type_as_const(type)
|
24
|
+
klass.key name.to_sym, ctype
|
25
|
+
klass.include_errors_from name.to_sym if ctype.instance_methods.include?('valid?')
|
26
|
+
ctype.apply(klass, scope, name) if ctype.respond_to?('apply')
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def normalize_name
|
33
|
+
# TODO: find something more robust to convert label to name
|
34
|
+
self.name = self.label.underscore.gsub(' ', '_') if self.name.blank? && self.label
|
35
|
+
self.name.downcase! if self.name
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Congo
|
2
|
+
module Metadata
|
3
|
+
class Validation
|
4
|
+
include MongoMapper::EmbeddedDocument
|
5
|
+
|
6
|
+
key :type, String
|
7
|
+
key :key, String
|
8
|
+
key :argument, String
|
9
|
+
|
10
|
+
validates_presence_of :key,
|
11
|
+
:message => lambda { I18n.t('congo.errors.messages.empty') }
|
12
|
+
|
13
|
+
validates_inclusion_of :type,
|
14
|
+
:within => methods.grep(/^validates_/).map { |m| m.gsub(/^validates_/, '') },
|
15
|
+
:message => lambda { I18n.t('congo.errors.messages.inclusion') }
|
16
|
+
|
17
|
+
def apply(klass, scope)
|
18
|
+
options = { :message => type_to_i18n }
|
19
|
+
case self.type
|
20
|
+
when 'format_of'
|
21
|
+
options.merge! :with => /#{self.argument}/
|
22
|
+
end
|
23
|
+
klass.send("validates_#{type}", key, options)
|
24
|
+
end
|
25
|
+
|
26
|
+
protected
|
27
|
+
|
28
|
+
def type_to_i18n
|
29
|
+
keyword = (case self.type
|
30
|
+
when 'presence_of' then 'empty'
|
31
|
+
when 'acceptance_of' then 'accepted'
|
32
|
+
when 'associated' then 'invalid'
|
33
|
+
when 'format_of' then 'invalid'
|
34
|
+
when 'length_of' then 'wrong_length'
|
35
|
+
when 'numericality_of' then 'not_a_number'
|
36
|
+
else self.type.gsub('_of', '')
|
37
|
+
end)
|
38
|
+
I18n.t("congo.errors.messages.#{keyword}")
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
module Congo
|
2
|
+
module Migration
|
3
|
+
|
4
|
+
def self.included(model)
|
5
|
+
model.class_eval do
|
6
|
+
include InstanceMethods
|
7
|
+
|
8
|
+
key :version, Integer, :default => 0
|
9
|
+
|
10
|
+
many :migrations, :class_name => 'Congo::Migration::Migration', :dependent => :destroy
|
11
|
+
|
12
|
+
before_save :increment_version_and_build_migration
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
module InstanceMethods
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def apply_migration(klass)
|
21
|
+
klass.key :_version, Integer, :default => version
|
22
|
+
|
23
|
+
klass.class_eval <<-EOV
|
24
|
+
def initialize_with_version(attrs={}, from_database = false)
|
25
|
+
initialize_without_version(attrs, from_database)
|
26
|
+
|
27
|
+
self.migrate!
|
28
|
+
end
|
29
|
+
|
30
|
+
alias_method_chain :initialize, :version
|
31
|
+
|
32
|
+
def out_dated?
|
33
|
+
self._version < content_type.version
|
34
|
+
end
|
35
|
+
|
36
|
+
def migrate!
|
37
|
+
content_type.send(:migrate!, self)
|
38
|
+
end
|
39
|
+
|
40
|
+
protected
|
41
|
+
|
42
|
+
def rename_key(old_key_name, new_key_name)
|
43
|
+
@_mongo_doc ||= self.class.collection.find({ '_id' => self._id }).first
|
44
|
+
@_mongo_doc[new_key_name] = @_mongo_doc[old_key_name]
|
45
|
+
|
46
|
+
@_mongo_doc.delete(old_key_name)
|
47
|
+
end
|
48
|
+
|
49
|
+
def drop_key(key_name)
|
50
|
+
@_mongo_doc ||= self.class.collection.find({ '_id' => self._id }).first
|
51
|
+
@_mongo_doc.delete(key_name)
|
52
|
+
self.send(:keys).delete(key_name) rescue nil
|
53
|
+
end
|
54
|
+
EOV
|
55
|
+
end
|
56
|
+
|
57
|
+
def migrate!(content)
|
58
|
+
return false unless content.out_dated?
|
59
|
+
|
60
|
+
doc = content.class.collection.find({ '_id' => content._id }).first
|
61
|
+
content.instance_variable_set '@_mongo_doc', doc
|
62
|
+
|
63
|
+
migrations.each do |migration|
|
64
|
+
if doc['_version'] < migration.version
|
65
|
+
# logger.debug "running migration #{migration.version} / #{migration.inspect}"
|
66
|
+
|
67
|
+
migration.tasks.each do |task|
|
68
|
+
# logger.debug "...running task #{task['action']}"
|
69
|
+
|
70
|
+
case task['action'].to_sym
|
71
|
+
when :rename
|
72
|
+
content.send(:rename_key, task['previous'], task['next'])
|
73
|
+
when :drop
|
74
|
+
content.send(:drop_key, task['previous'])
|
75
|
+
else
|
76
|
+
# unknown action
|
77
|
+
end
|
78
|
+
end
|
79
|
+
doc['_version'] = migration.version
|
80
|
+
# logger.debug "finishing migration (#{content.version}) / #{doc.inspect}"
|
81
|
+
# puts "finishing migration (#{migration.version}) / #{doc.inspect}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
content.class.collection.save(doc)
|
85
|
+
|
86
|
+
content.attributes = doc
|
87
|
+
end
|
88
|
+
|
89
|
+
def increment_version_and_build_migration
|
90
|
+
return if self.to_const.count == 0
|
91
|
+
|
92
|
+
current_ids = metadata_keys.collect { |k| k['_id'] }.compact
|
93
|
+
previous_ids = metadata_keys.previous.collect { |k| k['_id'] }.compact
|
94
|
+
|
95
|
+
migration = Migration.new(:version => self.version + 1, :tasks => [])
|
96
|
+
|
97
|
+
# renamed keys ?
|
98
|
+
(previous_ids & current_ids).each do |key_id|
|
99
|
+
current, previous = metadata_keys.find(key_id), metadata_keys.previous.detect { |k| k['_id'] == key_id }
|
100
|
+
if previous['name'] != current['name']
|
101
|
+
migration.tasks << { :action => 'rename', :previous => previous['name'], :next => current['name'] }
|
102
|
+
|
103
|
+
# check for potential validations relative to the modified key
|
104
|
+
metadata_validations.each do |validation|
|
105
|
+
validation.key = current['name'] if validation.key == previous['name']
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# dropped keys ?
|
111
|
+
(previous_ids - current_ids).each do |key_id|
|
112
|
+
previous = metadata_keys.previous.detect { |k| k['_id'] == key_id }
|
113
|
+
migration.tasks << { :action => 'drop', :previous => previous['name'] }
|
114
|
+
|
115
|
+
# check for potential validations relative to the dropped key
|
116
|
+
metadata_validations.delete_if { |v| v.key == previous['name'] }
|
117
|
+
end
|
118
|
+
|
119
|
+
unless migration.empty?
|
120
|
+
self.version += 1
|
121
|
+
self.migrations << migration
|
122
|
+
# logger.debug "incrementing version #{self.version}"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
end
|
127
|
+
|
128
|
+
class Migration
|
129
|
+
include MongoMapper::EmbeddedDocument
|
130
|
+
|
131
|
+
## keys
|
132
|
+
key :version, Integer, :required => true
|
133
|
+
key :tasks, Array, :required => true
|
134
|
+
|
135
|
+
## methods
|
136
|
+
|
137
|
+
def empty?
|
138
|
+
self.tasks.empty?
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Congo
|
2
|
+
class ProxyScoper
|
3
|
+
|
4
|
+
include MongoMapper::Document
|
5
|
+
|
6
|
+
acts_as_congo_scoper
|
7
|
+
|
8
|
+
## keys
|
9
|
+
key :ext_type, String
|
10
|
+
key :ext_id, Integer
|
11
|
+
|
12
|
+
## validations
|
13
|
+
validates_true_for :ext,
|
14
|
+
:logic => lambda { Congo::ProxyScoper.find_by_ext_id_and_ext_type(ext_id, ext_type).nil? },
|
15
|
+
:message => lambda { I18n.t('congo.errors.messages.taken') }
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
data/lib/congo/scoper.rb
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
module Congo
|
2
|
+
module Scoper #:nodoc:
|
3
|
+
def self.included(base)
|
4
|
+
base.extend(ClassMethods)
|
5
|
+
end
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
def acts_as_congo_scoper
|
9
|
+
if !self.included_modules.include?(MongoMapper::Document)
|
10
|
+
class_eval <<-EOV
|
11
|
+
|
12
|
+
include Congo::Scoper::InstanceMethods
|
13
|
+
|
14
|
+
def scoper_instance
|
15
|
+
@proxy_scoper ||= Congo::ProxyScoper.find_by_ext_id_and_ext_type(self.id, self.class.name)
|
16
|
+
if @proxy_scoper.nil?
|
17
|
+
@proxy_scoper = Congo::ProxyScoper.create!(:ext_type => self.class.name, :ext_id => self.id)
|
18
|
+
end
|
19
|
+
@proxy_scoper
|
20
|
+
end
|
21
|
+
|
22
|
+
def content_types
|
23
|
+
scoper_instance.content_types
|
24
|
+
end
|
25
|
+
EOV
|
26
|
+
else
|
27
|
+
class_eval <<-EOV
|
28
|
+
|
29
|
+
include Congo::Scoper::InstanceMethods
|
30
|
+
|
31
|
+
many :content_types, :class_name => 'Congo::ContentType', :as => :scope
|
32
|
+
|
33
|
+
def scoper_instance
|
34
|
+
nil
|
35
|
+
end
|
36
|
+
EOV
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
module InstanceMethods
|
42
|
+
|
43
|
+
def proxy_scoper?
|
44
|
+
!scoper_instance.nil?
|
45
|
+
end
|
46
|
+
|
47
|
+
def consts
|
48
|
+
@consts ||= {}
|
49
|
+
end
|
50
|
+
|
51
|
+
def content_type_as_const(name, method_name = nil)
|
52
|
+
return nil if (Congo::Types.const_defined?(name) rescue nil).nil?
|
53
|
+
return Congo::Types.const_get(name) if Congo::Types.const_defined?(name)
|
54
|
+
return name.constantize if Object.const_defined?(name)
|
55
|
+
|
56
|
+
unless consts[name]
|
57
|
+
if (type = content_types.find_by_name(name)).nil?
|
58
|
+
type = content_types.find_by_slug(method_name) if method_name
|
59
|
+
end
|
60
|
+
consts[name] = type.to_const rescue nil # This doesn't work because of different instances being used
|
61
|
+
end
|
62
|
+
|
63
|
+
consts[name]
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def method_missing(method, *args)
|
69
|
+
if ctype = content_type_as_const(method.to_s.classify, method.to_s)
|
70
|
+
meta = proxy_scoper? ? scoper_instance.metaclass : metaclass
|
71
|
+
meta.many method, :class => ctype
|
72
|
+
(proxy_scoper? ? scoper_instance : self).send(method, *args)
|
73
|
+
else
|
74
|
+
super
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
Object.class_eval { include Congo::Scoper }
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module MongoMapper
|
2
|
+
module Plugins
|
3
|
+
module Associations
|
4
|
+
class ManyEmbeddedProxy < EmbeddedCollection
|
5
|
+
|
6
|
+
def replace_with_dirty_mode(values)
|
7
|
+
@_previous_values = @_values
|
8
|
+
replace_without_dirty_mode(values)
|
9
|
+
end
|
10
|
+
|
11
|
+
def previous
|
12
|
+
@_previous_values || []
|
13
|
+
end
|
14
|
+
|
15
|
+
alias_method_chain :replace, :dirty_mode
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
data/lib/congo/types.rb
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
module Congo
|
2
|
+
module Types
|
3
|
+
|
4
|
+
class Email < String
|
5
|
+
|
6
|
+
def self.apply(klass, scope, name)
|
7
|
+
klass.class_eval do
|
8
|
+
validates_format_of name.to_sym,
|
9
|
+
:with => /\A[\w\._%-]+@[\w\.-]+\.[a-zA-Z]{2,4}\z/,
|
10
|
+
:message => lambda { I18n.t('congo.errors.messages.invalid') }
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
|
16
|
+
class Text < String
|
17
|
+
end
|
18
|
+
|
19
|
+
class Date < Date
|
20
|
+
|
21
|
+
def self.apply(klass, scope, name)
|
22
|
+
klass.class_eval <<-EOV
|
23
|
+
|
24
|
+
def localized_#{name}
|
25
|
+
if self.#{name}
|
26
|
+
self.#{name}.strftime(I18n.t('congo.date.formats.default'))
|
27
|
+
else
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def localized_#{name}=(value)
|
33
|
+
self.#{name} = value
|
34
|
+
end
|
35
|
+
|
36
|
+
EOV
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.to_mongo(value)
|
40
|
+
date = self.parse_with_i18n(value.to_s)
|
41
|
+
Time.utc(date[:year], date[:mon], date[:mday])
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.from_mongo(value)
|
45
|
+
value.to_date if value.present?
|
46
|
+
end
|
47
|
+
|
48
|
+
# Patch from http://gist.github.com/179712
|
49
|
+
def self.parse_with_i18n(str)
|
50
|
+
date = ::Date._strptime(str, I18n.t('congo.date.formats.default')) || self._parse(str)
|
51
|
+
date[:year] += self.increment_year(date[:year].to_i) if date[:year]
|
52
|
+
date
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.increment_year(year)
|
56
|
+
if year < 100
|
57
|
+
year < 30 ? 2000 : 1900
|
58
|
+
else
|
59
|
+
0
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module Congo
|
2
|
+
module Validation
|
3
|
+
|
4
|
+
def self.included(model)
|
5
|
+
model.class_eval do
|
6
|
+
include InstanceMethods
|
7
|
+
|
8
|
+
validates_format_of :name,
|
9
|
+
:with => /^[a-zA-Z][\w\s]*$/,
|
10
|
+
:message => lambda { I18n.t('congo.errors.messages.invalid') }
|
11
|
+
|
12
|
+
validates_presence_of :name, :scope,
|
13
|
+
:message => lambda { I18n.t('congo.errors.messages.empty') }
|
14
|
+
|
15
|
+
validates_true_for :name,
|
16
|
+
:logic => :check_uniqueness_of_name,
|
17
|
+
:message => lambda { I18n.t('congo.errors.messages.taken') }
|
18
|
+
|
19
|
+
validates_true_for :name,
|
20
|
+
:key => :allowed_name,
|
21
|
+
:logic => :check_allowed_name,
|
22
|
+
:message => lambda { I18n.t('congo.errors.messages.exclusion') }
|
23
|
+
|
24
|
+
validates_true_for :metadata_keys,
|
25
|
+
:logic => :validate_metadata_keys,
|
26
|
+
:message => lambda { I18n.t('congo.errors.messages.invalid') }
|
27
|
+
|
28
|
+
validates_true_for :collection_name,
|
29
|
+
:logic => lambda { name.present? },
|
30
|
+
:message => lambda { I18n.t('congo.errors.messages.empty') }
|
31
|
+
|
32
|
+
validates_true_for :collection_name,
|
33
|
+
:key => :unique_collection_name,
|
34
|
+
:logic => :check_uniqueness_of_collection_name,
|
35
|
+
:message => lambda { I18n.t('congo.errors.messages.taken') }
|
36
|
+
|
37
|
+
validates_true_for :collection_name,
|
38
|
+
:key => :allowed_allowed_name,
|
39
|
+
:logic => :check_allowed_name,
|
40
|
+
:message => lambda { I18n.t('congo.errors.messages.exclusion') }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
module InstanceMethods
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def validate_metadata_keys
|
49
|
+
return false if metadata_keys.empty?
|
50
|
+
|
51
|
+
# keys should be valid and unique
|
52
|
+
found_errors, duplicates = false, {}
|
53
|
+
metadata_keys.each do |key|
|
54
|
+
found_errors ||= !key.valid?
|
55
|
+
if duplicates.key?(key.name)
|
56
|
+
key.errors.add(:name, I18n.t('activerecord.errors.messages.taken'))
|
57
|
+
found_errors = true
|
58
|
+
else
|
59
|
+
duplicates[key.name] = key
|
60
|
+
end
|
61
|
+
end
|
62
|
+
!found_errors
|
63
|
+
end
|
64
|
+
|
65
|
+
def check_uniqueness_of(attribute, value)
|
66
|
+
type = self.class.first(:conditions => { :scope_type => scope_type, :scope_id => scope_id, attribute => value })
|
67
|
+
type.nil? || (type && type == self)
|
68
|
+
end
|
69
|
+
|
70
|
+
def check_uniqueness_of_name
|
71
|
+
check_uniqueness_of(:name, name)
|
72
|
+
end
|
73
|
+
|
74
|
+
def check_uniqueness_of_collection_name
|
75
|
+
collection_name.blank? || (collection_name.present? && check_uniqueness_of(:slug, slug))
|
76
|
+
end
|
77
|
+
|
78
|
+
def check_allowed_name
|
79
|
+
return true if name.blank? || scope.nil?
|
80
|
+
methods = scope.is_a?(Congo::ProxyScoper) ? scope.ext_type.constantize.instance_methods : scope.methods
|
81
|
+
!(methods.include?(name.tableize.to_s) || (slug.present? && methods.include?(slug)))
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|