congo 0.1.1
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/.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
|