yodel 0.0.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/.document +5 -0
- data/.gitignore +9 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +63 -0
- data/LICENSE +1 -0
- data/README.rdoc +20 -0
- data/Rakefile +1 -0
- data/bin/yodel +4 -0
- data/lib/yodel.rb +22 -0
- data/lib/yodel/application/application.rb +44 -0
- data/lib/yodel/application/extension.rb +59 -0
- data/lib/yodel/application/request_handler.rb +48 -0
- data/lib/yodel/application/yodel.rb +25 -0
- data/lib/yodel/command/command.rb +94 -0
- data/lib/yodel/command/deploy.rb +67 -0
- data/lib/yodel/command/dns_server.rb +16 -0
- data/lib/yodel/command/installer.rb +229 -0
- data/lib/yodel/config/config.rb +30 -0
- data/lib/yodel/config/environment.rb +16 -0
- data/lib/yodel/config/yodel.rb +21 -0
- data/lib/yodel/exceptions/destroyed_record.rb +2 -0
- data/lib/yodel/exceptions/domain_not_found.rb +16 -0
- data/lib/yodel/exceptions/duplicate_layout.rb +2 -0
- data/lib/yodel/exceptions/exceptions.rb +3 -0
- data/lib/yodel/exceptions/inconsistent_lock_state.rb +2 -0
- data/lib/yodel/exceptions/invalid_field.rb +2 -0
- data/lib/yodel/exceptions/invalid_index.rb +2 -0
- data/lib/yodel/exceptions/invalid_mixin.rb +2 -0
- data/lib/yodel/exceptions/invalid_model_field.rb +2 -0
- data/lib/yodel/exceptions/layout_not_found.rb +2 -0
- data/lib/yodel/exceptions/mass_assignment.rb +2 -0
- data/lib/yodel/exceptions/missing_migration.rb +2 -0
- data/lib/yodel/exceptions/missing_root_directory.rb +15 -0
- data/lib/yodel/exceptions/unable_to_acquire_lock.rb +2 -0
- data/lib/yodel/exceptions/unauthorised.rb +2 -0
- data/lib/yodel/exceptions/unknown_field.rb +2 -0
- data/lib/yodel/middleware/development_server.rb +180 -0
- data/lib/yodel/middleware/error_pages.rb +72 -0
- data/lib/yodel/middleware/public_assets.rb +78 -0
- data/lib/yodel/middleware/request.rb +16 -0
- data/lib/yodel/middleware/site_detector.rb +22 -0
- data/lib/yodel/mime_types/default_mime_set.rb +28 -0
- data/lib/yodel/mime_types/mime_type.rb +68 -0
- data/lib/yodel/mime_types/mime_type_set.rb +41 -0
- data/lib/yodel/mime_types/mime_types.rb +6 -0
- data/lib/yodel/mime_types/yodel.rb +15 -0
- data/lib/yodel/models/api/api.rb +1 -0
- data/lib/yodel/models/api/api_call.rb +87 -0
- data/lib/yodel/models/core/associations/association.rb +37 -0
- data/lib/yodel/models/core/associations/associations.rb +22 -0
- data/lib/yodel/models/core/associations/counts/many_association.rb +18 -0
- data/lib/yodel/models/core/associations/counts/one_association.rb +22 -0
- data/lib/yodel/models/core/associations/embedded/embedded_association.rb +47 -0
- data/lib/yodel/models/core/associations/embedded/embedded_record_array.rb +12 -0
- data/lib/yodel/models/core/associations/embedded/many_embedded_association.rb +62 -0
- data/lib/yodel/models/core/associations/embedded/one_embedded_association.rb +49 -0
- data/lib/yodel/models/core/associations/query/many_query_association.rb +10 -0
- data/lib/yodel/models/core/associations/query/one_query_association.rb +10 -0
- data/lib/yodel/models/core/associations/query/query_association.rb +64 -0
- data/lib/yodel/models/core/associations/record_association.rb +38 -0
- data/lib/yodel/models/core/associations/store/many_store_association.rb +32 -0
- data/lib/yodel/models/core/associations/store/one_store_association.rb +14 -0
- data/lib/yodel/models/core/associations/store/store_association.rb +51 -0
- data/lib/yodel/models/core/attachments/attachment.rb +73 -0
- data/lib/yodel/models/core/attachments/image.rb +38 -0
- data/lib/yodel/models/core/core.rb +15 -0
- data/lib/yodel/models/core/fields/alias_field.rb +32 -0
- data/lib/yodel/models/core/fields/array_field.rb +64 -0
- data/lib/yodel/models/core/fields/attachment_field.rb +42 -0
- data/lib/yodel/models/core/fields/boolean_field.rb +28 -0
- data/lib/yodel/models/core/fields/change_sensitive_array.rb +96 -0
- data/lib/yodel/models/core/fields/change_sensitive_hash.rb +53 -0
- data/lib/yodel/models/core/fields/color_field.rb +4 -0
- data/lib/yodel/models/core/fields/date_field.rb +35 -0
- data/lib/yodel/models/core/fields/decimal_field.rb +19 -0
- data/lib/yodel/models/core/fields/email_field.rb +10 -0
- data/lib/yodel/models/core/fields/enum_field.rb +33 -0
- data/lib/yodel/models/core/fields/field.rb +154 -0
- data/lib/yodel/models/core/fields/fields.rb +29 -0
- data/lib/yodel/models/core/fields/fields_field.rb +31 -0
- data/lib/yodel/models/core/fields/filter_mixin.rb +9 -0
- data/lib/yodel/models/core/fields/filtered_string_field.rb +5 -0
- data/lib/yodel/models/core/fields/filtered_text_field.rb +5 -0
- data/lib/yodel/models/core/fields/function_field.rb +28 -0
- data/lib/yodel/models/core/fields/hash_field.rb +54 -0
- data/lib/yodel/models/core/fields/html_field.rb +15 -0
- data/lib/yodel/models/core/fields/image_field.rb +11 -0
- data/lib/yodel/models/core/fields/integer_field.rb +25 -0
- data/lib/yodel/models/core/fields/password_field.rb +21 -0
- data/lib/yodel/models/core/fields/self_field.rb +27 -0
- data/lib/yodel/models/core/fields/string_field.rb +15 -0
- data/lib/yodel/models/core/fields/tags_field.rb +7 -0
- data/lib/yodel/models/core/fields/text_field.rb +7 -0
- data/lib/yodel/models/core/fields/time_field.rb +36 -0
- data/lib/yodel/models/core/functions/function.rb +471 -0
- data/lib/yodel/models/core/functions/functions.rb +2 -0
- data/lib/yodel/models/core/functions/trigger.rb +14 -0
- data/lib/yodel/models/core/log/log.rb +33 -0
- data/lib/yodel/models/core/log/log_entry.rb +12 -0
- data/lib/yodel/models/core/model/abstract_model.rb +59 -0
- data/lib/yodel/models/core/model/model.rb +460 -0
- data/lib/yodel/models/core/model/mongo_model.rb +25 -0
- data/lib/yodel/models/core/model/site_model.rb +17 -0
- data/lib/yodel/models/core/mongo/mongo.rb +3 -0
- data/lib/yodel/models/core/mongo/primary_key_factory.rb +12 -0
- data/lib/yodel/models/core/mongo/query.rb +68 -0
- data/lib/yodel/models/core/mongo/record_index.rb +89 -0
- data/lib/yodel/models/core/record/abstract_record.rb +411 -0
- data/lib/yodel/models/core/record/embedded_record.rb +47 -0
- data/lib/yodel/models/core/record/mongo_record.rb +83 -0
- data/lib/yodel/models/core/record/record.rb +386 -0
- data/lib/yodel/models/core/record/section.rb +21 -0
- data/lib/yodel/models/core/record/site_record.rb +31 -0
- data/lib/yodel/models/core/site/migration.rb +52 -0
- data/lib/yodel/models/core/site/remote.rb +61 -0
- data/lib/yodel/models/core/site/site.rb +202 -0
- data/lib/yodel/models/core/validations/email_address_validation.rb +24 -0
- data/lib/yodel/models/core/validations/embedded_records_validation.rb +31 -0
- data/lib/yodel/models/core/validations/errors.rb +51 -0
- data/lib/yodel/models/core/validations/excluded_from_validation.rb +10 -0
- data/lib/yodel/models/core/validations/excludes_combinations_validation.rb +18 -0
- data/lib/yodel/models/core/validations/format_validation.rb +10 -0
- data/lib/yodel/models/core/validations/included_in_validation.rb +10 -0
- data/lib/yodel/models/core/validations/includes_combinations_validation.rb +14 -0
- data/lib/yodel/models/core/validations/length_validation.rb +28 -0
- data/lib/yodel/models/core/validations/password_confirmation_validation.rb +11 -0
- data/lib/yodel/models/core/validations/required_validation.rb +9 -0
- data/lib/yodel/models/core/validations/unique_validation.rb +9 -0
- data/lib/yodel/models/core/validations/validation.rb +39 -0
- data/lib/yodel/models/core/validations/validations.rb +15 -0
- data/lib/yodel/models/email/email.rb +79 -0
- data/lib/yodel/models/migrations/01_record_model.rb +29 -0
- data/lib/yodel/models/migrations/02_page_model.rb +45 -0
- data/lib/yodel/models/migrations/03_layout_model.rb +38 -0
- data/lib/yodel/models/migrations/04_group_model.rb +61 -0
- data/lib/yodel/models/migrations/05_user_model.rb +24 -0
- data/lib/yodel/models/migrations/06_snippet_model.rb +13 -0
- data/lib/yodel/models/migrations/07_search_page_model.rb +32 -0
- data/lib/yodel/models/migrations/08_default_site_options.rb +21 -0
- data/lib/yodel/models/migrations/09_security_page_models.rb +36 -0
- data/lib/yodel/models/migrations/10_record_proxy_page_model.rb +17 -0
- data/lib/yodel/models/migrations/11_email_model.rb +28 -0
- data/lib/yodel/models/migrations/12_api_call_model.rb +23 -0
- data/lib/yodel/models/migrations/13_redirect_page_model.rb +13 -0
- data/lib/yodel/models/migrations/14_menu_model.rb +20 -0
- data/lib/yodel/models/models.rb +8 -0
- data/lib/yodel/models/pages/form_builder.rb +379 -0
- data/lib/yodel/models/pages/html_decorator.rb +132 -0
- data/lib/yodel/models/pages/layout.rb +120 -0
- data/lib/yodel/models/pages/menu.rb +32 -0
- data/lib/yodel/models/pages/page.rb +378 -0
- data/lib/yodel/models/pages/pages.rb +7 -0
- data/lib/yodel/models/pages/record_proxy_page.rb +188 -0
- data/lib/yodel/models/pages/redirect_page.rb +11 -0
- data/lib/yodel/models/search/search.rb +1 -0
- data/lib/yodel/models/search/search_page.rb +58 -0
- data/lib/yodel/models/security/facebook_login_page.rb +55 -0
- data/lib/yodel/models/security/group.rb +10 -0
- data/lib/yodel/models/security/guests_group.rb +5 -0
- data/lib/yodel/models/security/login_page.rb +20 -0
- data/lib/yodel/models/security/logout_page.rb +13 -0
- data/lib/yodel/models/security/noone_group.rb +5 -0
- data/lib/yodel/models/security/owner_group.rb +8 -0
- data/lib/yodel/models/security/password.rb +5 -0
- data/lib/yodel/models/security/password_reset_page.rb +47 -0
- data/lib/yodel/models/security/security.rb +10 -0
- data/lib/yodel/models/security/user.rb +33 -0
- data/lib/yodel/public/core/css/core.css +257 -0
- data/lib/yodel/public/core/css/reset.css +48 -0
- data/lib/yodel/public/core/images/cross.png +0 -0
- data/lib/yodel/public/core/images/spinner.gif +0 -0
- data/lib/yodel/public/core/images/tick.png +0 -0
- data/lib/yodel/public/core/images/yodel.png +0 -0
- data/lib/yodel/public/core/js/jquery.min.js +18 -0
- data/lib/yodel/public/core/js/json2.js +480 -0
- data/lib/yodel/public/core/js/yodel_jquery.js +238 -0
- data/lib/yodel/request/authentication.rb +76 -0
- data/lib/yodel/request/flash.rb +28 -0
- data/lib/yodel/request/request.rb +4 -0
- data/lib/yodel/requires.rb +47 -0
- data/lib/yodel/task_queue/queue_daemon.rb +33 -0
- data/lib/yodel/task_queue/queue_worker.rb +32 -0
- data/lib/yodel/task_queue/stats_thread.rb +27 -0
- data/lib/yodel/task_queue/task.rb +62 -0
- data/lib/yodel/task_queue/task_queue.rb +40 -0
- data/lib/yodel/types/date.rb +5 -0
- data/lib/yodel/types/object_id.rb +11 -0
- data/lib/yodel/types/time.rb +5 -0
- data/lib/yodel/version.rb +3 -0
- data/system/Library/LaunchDaemons/com.yodelcms.dns.plist +26 -0
- data/system/Library/LaunchDaemons/com.yodelcms.server.plist +26 -0
- data/system/etc/resolver/yodel +2 -0
- data/system/usr/local/bin/yodel_command_runner +2 -0
- data/system/usr/local/etc/yodel/development_settings.rb +28 -0
- data/system/usr/local/etc/yodel/production_settings.rb +27 -0
- data/system/var/log/yodel.log +0 -0
- data/test/helper.rb +18 -0
- data/test/test_yodel.rb +4 -0
- data/yodel.gemspec +47 -0
- metadata +501 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require './model/abstract_model'
|
|
2
|
+
|
|
3
|
+
module MongoModel
|
|
4
|
+
include AbstractModel
|
|
5
|
+
extend Forwardable
|
|
6
|
+
def_delegators :scoped, :where, :limit, :skip, :sort, :count,
|
|
7
|
+
:last, :first, :all, :paginate, :find,
|
|
8
|
+
:find!, :exists?, :exist?, :find_each
|
|
9
|
+
|
|
10
|
+
def scoped(scope={})
|
|
11
|
+
Query.new(self, nil, collection, scope)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def load(values)
|
|
15
|
+
new(values, false)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def collection(*name)
|
|
19
|
+
if name.size == 1
|
|
20
|
+
@collection = Yodel.db.collection(name.first, pk: PrimaryKeyFactory)
|
|
21
|
+
else
|
|
22
|
+
@collection
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require './model/mongo_model'
|
|
2
|
+
|
|
3
|
+
module SiteModel
|
|
4
|
+
include MongoModel
|
|
5
|
+
|
|
6
|
+
def scoped_for(site, scope={})
|
|
7
|
+
scoped(site, self, scope.merge({_site_id: site.id}))
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def scoped(site, constructor, scope={})
|
|
11
|
+
Query.new(constructor, site, collection, scope)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def load(site, values)
|
|
15
|
+
new(site, values)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module PrimaryKeyFactory
|
|
2
|
+
# The default mongo primary key factory (BSON::ObjectId) creates ids
|
|
3
|
+
# with symbol keys. Yodel uses string keys (since records are retrieved
|
|
4
|
+
# with string keys) so Yodel mongo collections use this pk factory instead.
|
|
5
|
+
def self.create_pk(doc)
|
|
6
|
+
doc.has_key?('_id') ? doc : doc.merge!('_id' => self.pk)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.pk
|
|
10
|
+
BSON::ObjectId.new
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
class Query < Plucky::Query
|
|
2
|
+
attr_reader :constructor, :site
|
|
3
|
+
|
|
4
|
+
# construct a default scope for queries on a resource
|
|
5
|
+
def initialize(constructor, site, collection, scope={})
|
|
6
|
+
@site = site
|
|
7
|
+
@constructor = constructor
|
|
8
|
+
super(collection, scope)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def distinct(key)
|
|
12
|
+
record = collection.distinct(key, criteria.to_hash)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# TODO: we only cache queries where _id, _site_id, and model are present; _id on
|
|
16
|
+
# its own is a strong enough restriction, so why can't we cache all queries with id?
|
|
17
|
+
# the query may not match (extra restrictions), but for quries that do match, we
|
|
18
|
+
# should save id-> recrd in cached_records
|
|
19
|
+
# TODO: cache any new records, so they can be matched by single lookups in the future
|
|
20
|
+
def find_each(opts={})
|
|
21
|
+
if @site
|
|
22
|
+
super.collect do |values|
|
|
23
|
+
record = @site.cached_records[values['_id']]
|
|
24
|
+
record ? record : @constructor.load(@site, values)
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
super.collect {|values| @constructor.load(values)}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# TODO: extract this out to a collection sub class; yodel collection subclass
|
|
32
|
+
# will override 'find' itself, so it doesn't need to be done here
|
|
33
|
+
|
|
34
|
+
# override find_one to find objects via an identity hash
|
|
35
|
+
def find_one(opts={})
|
|
36
|
+
unless @site
|
|
37
|
+
document = super
|
|
38
|
+
return nil if document.nil?
|
|
39
|
+
@constructor.load(document)
|
|
40
|
+
else
|
|
41
|
+
query = clone.amend(opts)
|
|
42
|
+
|
|
43
|
+
# construct the criteria hash, and remove the keys allowed by a cacheable lookup
|
|
44
|
+
criteria_hash = query.criteria.to_hash
|
|
45
|
+
id = criteria_hash[:_id]
|
|
46
|
+
keys = criteria_hash.keys
|
|
47
|
+
keys -= [:_id, :_site_id, :model]
|
|
48
|
+
|
|
49
|
+
# queries are cacheable if they are looking for a single ID
|
|
50
|
+
cacheable = !id.nil? && id.is_a?(BSON::ObjectId) && keys.empty?
|
|
51
|
+
|
|
52
|
+
# lookup the record in the cache
|
|
53
|
+
if cacheable
|
|
54
|
+
record = @site.cached_records[id]
|
|
55
|
+
return record unless record.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# lookup failed, so perform a query
|
|
59
|
+
record = query.collection.find_one(criteria_hash, query.options.to_hash)
|
|
60
|
+
if record
|
|
61
|
+
record = @constructor.load(@site, record)
|
|
62
|
+
@site.cached_records[id] = record if cacheable
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
record
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require './record/mongo_record'
|
|
2
|
+
require './record/record'
|
|
3
|
+
require './model/mongo_model'
|
|
4
|
+
|
|
5
|
+
class RecordIndex < MongoRecord
|
|
6
|
+
extend MongoModel
|
|
7
|
+
collection :record_indexes
|
|
8
|
+
field :spec, :array, of: :strings, required: true
|
|
9
|
+
field :references, :array, of: :strings, required: true
|
|
10
|
+
field :name, :string, required: true
|
|
11
|
+
|
|
12
|
+
# ----------------------------------------
|
|
13
|
+
# Index creation is handled by RecordIndex
|
|
14
|
+
# ----------------------------------------
|
|
15
|
+
def self.add_index(model_reference, spec)
|
|
16
|
+
name = index_name(spec)
|
|
17
|
+
index = self.scoped.where(name: name).first
|
|
18
|
+
|
|
19
|
+
if index.nil?
|
|
20
|
+
index = new
|
|
21
|
+
index.spec = spec
|
|
22
|
+
index.name = name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
index.references << model_reference
|
|
26
|
+
index.save
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.remove_index(model_reference)
|
|
30
|
+
index = self.scoped.where(references: model_reference).first
|
|
31
|
+
return false if index.nil?
|
|
32
|
+
index.references.delete(model_reference)
|
|
33
|
+
|
|
34
|
+
if index.references.empty?
|
|
35
|
+
index.destroy
|
|
36
|
+
else
|
|
37
|
+
index.save
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ----------------------------------------
|
|
43
|
+
# Helper methods
|
|
44
|
+
# ----------------------------------------
|
|
45
|
+
def self.add_index_for_field(model, field)
|
|
46
|
+
name = model_index_name(model, field.name)
|
|
47
|
+
spec = [[field.name, Mongo::ASCENDING]]
|
|
48
|
+
add_index(name, spec)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.add_index_for_model(model, name, spec)
|
|
52
|
+
name = model_index_name(model, name)
|
|
53
|
+
add_index(name, spec)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.remove_index_for_field(model, field)
|
|
57
|
+
name = model_index_name(model, field.name)
|
|
58
|
+
remove_index(name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.remove_index_for_model(model, name)
|
|
62
|
+
name = model_index_name(model, name)
|
|
63
|
+
remove_index(name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ----------------------------------------
|
|
68
|
+
# Actual index construction/deletion
|
|
69
|
+
# ----------------------------------------
|
|
70
|
+
after_destroy :remove_index
|
|
71
|
+
def remove_index
|
|
72
|
+
Record.collection.drop_index(name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
before_create :create_index
|
|
76
|
+
def create_index
|
|
77
|
+
Record.collection.create_index(spec, name: name, background: true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
def self.model_index_name(model, name)
|
|
83
|
+
"#{model.site.id.to_s}_#{model.name}_#{name}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.index_name(spec)
|
|
87
|
+
spec.collect {|field| field.collect(&:to_s).join('_')}.join('_')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# Record objects must implement these methods:
|
|
2
|
+
# fields
|
|
3
|
+
# perform_save
|
|
4
|
+
# perform_destroy
|
|
5
|
+
# perform_reload
|
|
6
|
+
|
|
7
|
+
class AbstractRecord
|
|
8
|
+
attr_reader :values, :typecast, :changed, :errors, :stash
|
|
9
|
+
|
|
10
|
+
def initialize(values={}, new_record=true)
|
|
11
|
+
@values = default_values.merge(values.stringify_keys) # FIXME: don't merge here; default || values
|
|
12
|
+
@typecast = {} # typecast versions of original document values
|
|
13
|
+
@changed = {} # typecast versions of changed values
|
|
14
|
+
@stash = {} # values of unknown fields set by from_json
|
|
15
|
+
@errors = Errors.new
|
|
16
|
+
@new = new_record
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ----------------------------------------
|
|
21
|
+
# Equality
|
|
22
|
+
# ----------------------------------------
|
|
23
|
+
def eql?(other)
|
|
24
|
+
other.respond_to?(:id) && other.id == self.id &&
|
|
25
|
+
other.is_a?(AbstractRecord)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
alias :== :eql?
|
|
29
|
+
|
|
30
|
+
def hash
|
|
31
|
+
id.hash
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ----------------------------------------
|
|
36
|
+
# Modelling
|
|
37
|
+
# ----------------------------------------
|
|
38
|
+
def fields
|
|
39
|
+
{}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def field(name)
|
|
43
|
+
fields[name]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def field?(name)
|
|
47
|
+
fields.key?(name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def default_values
|
|
51
|
+
fields.each_with_object({}) do |(name, field), defaults|
|
|
52
|
+
default_value = field.default
|
|
53
|
+
unless default_value.nil? && field.strip_nil?
|
|
54
|
+
defaults[name] = default_value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ----------------------------------------
|
|
61
|
+
# Representations
|
|
62
|
+
# ----------------------------------------
|
|
63
|
+
def inspect_hash
|
|
64
|
+
fields.each_with_object({}) do |(name, field), hash|
|
|
65
|
+
hash[name] = get(name)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def inspect
|
|
70
|
+
values = inspect_hash.collect do |name, value|
|
|
71
|
+
if value.is_a?(Array) || value.is_a?(ChangeSensitiveArray)
|
|
72
|
+
value = "[#{value.collect {|element| inspect_value(element)}.join(', ')}]"
|
|
73
|
+
elsif value.is_a?(Hash) || value.is_a?(ChangeSensitiveHash)
|
|
74
|
+
value = "{#{value.to_hash.collect {|key, value| "#{key.to_s}: #{inspect_value(value)}"}.join(', ')}}"
|
|
75
|
+
else
|
|
76
|
+
value = inspect_value(value)
|
|
77
|
+
end
|
|
78
|
+
"#{name}: #{value}"
|
|
79
|
+
end
|
|
80
|
+
"#<#{self.class.name} #{values.join(', ')}>"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inspect_value(value)
|
|
84
|
+
value.respond_to?(:to_str) && !value.is_a?(String) ? value.to_str : value.inspect.to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_str
|
|
88
|
+
"#<#{self.class.name}: #{id}>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
alias :to_s :to_str
|
|
92
|
+
|
|
93
|
+
def to_json(*a)
|
|
94
|
+
@values.to_json(*a)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def from_json(values)
|
|
98
|
+
values.each do |name, value|
|
|
99
|
+
if field?(name)
|
|
100
|
+
current_field = field(name)
|
|
101
|
+
raise MassAssignment, "Cannot mass assign #{field}" if current_field.protected?
|
|
102
|
+
else
|
|
103
|
+
@stash[name] = value
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# action hashes allow operations on fields such as append, increment
|
|
108
|
+
if value.is_a?(Hash) && value.key?('_action')
|
|
109
|
+
current_field.json_action(value.delete('_action'), value.delete('_value'), self)
|
|
110
|
+
else
|
|
111
|
+
catch :ignore_value do
|
|
112
|
+
processed_value = current_field.from_json(value, self)
|
|
113
|
+
set(name, processed_value)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
save
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ----------------------------------------
|
|
123
|
+
# Accessors
|
|
124
|
+
# ----------------------------------------
|
|
125
|
+
def id
|
|
126
|
+
object_id
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def clear_key(name)
|
|
130
|
+
@changed.delete(name)
|
|
131
|
+
@typecast.delete(name)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def get(name)
|
|
135
|
+
ensure_field_is_valid(name)
|
|
136
|
+
return @changed[name] if @changed.key?(name)
|
|
137
|
+
return @typecast[name] if @typecast.key?(name)
|
|
138
|
+
typecast_value(name)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def get_raw(name)
|
|
142
|
+
ensure_field_is_valid(name)
|
|
143
|
+
@values[name]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def get_meta(name)
|
|
147
|
+
@values[name]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def set(name, value)
|
|
151
|
+
ensure_field_is_valid(name)
|
|
152
|
+
@changed[name] = value
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def set_raw(name, value)
|
|
156
|
+
ensure_field_is_valid(name)
|
|
157
|
+
@values[name] = value
|
|
158
|
+
@changed.delete(name)
|
|
159
|
+
@typecast.delete(name)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def set_meta(name, value)
|
|
163
|
+
@values[name] = value
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def present?(name)
|
|
167
|
+
# FIXME: this doesn't work for many/one store: false
|
|
168
|
+
ensure_field_is_valid(name)
|
|
169
|
+
!get(name).blank?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def changed?(name)
|
|
173
|
+
ensure_field_is_valid(name)
|
|
174
|
+
@changed.key?(name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def changed!(name)
|
|
178
|
+
ensure_field_is_valid(name)
|
|
179
|
+
return if @changed.key?(name)
|
|
180
|
+
@changed[name] = get(name).dup
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def field_was(name)
|
|
184
|
+
ensure_field_is_valid(name)
|
|
185
|
+
if @typecast.key?(name)
|
|
186
|
+
@typecast[name]
|
|
187
|
+
else
|
|
188
|
+
typecast_value(name)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def increment!(name, value=1)
|
|
193
|
+
ensure_field_is_valid(name)
|
|
194
|
+
current = get(name)
|
|
195
|
+
set(name, current + value)
|
|
196
|
+
save_without_validation
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def method_missing(name, *args, &block)
|
|
200
|
+
# Catch a "fun" ruby 1.9 implemention detail. Calls to flatten blindly call
|
|
201
|
+
# to_ary on items in an array rather than checking it they really support
|
|
202
|
+
# the method with respond_to? Catch, and raise the expected exception.
|
|
203
|
+
raise NoMethodError if name == :to_ary
|
|
204
|
+
field_name = name.to_s
|
|
205
|
+
|
|
206
|
+
if field_name.end_with?('_changed?')
|
|
207
|
+
changed?(field_name[0...-9])
|
|
208
|
+
elsif field_name.end_with?('=')
|
|
209
|
+
set(field_name[0...-1], args.first)
|
|
210
|
+
elsif field_name.end_with?('?')
|
|
211
|
+
present?(field_name[0...-1])
|
|
212
|
+
elsif field_name.end_with?('_was')
|
|
213
|
+
field_was(field_name[0...-4])
|
|
214
|
+
else
|
|
215
|
+
get(field_name)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ----------------------------------------
|
|
221
|
+
# Persistence
|
|
222
|
+
# ----------------------------------------
|
|
223
|
+
def new?
|
|
224
|
+
!!@new
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def destroyed?
|
|
228
|
+
!!@destroyed
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def save
|
|
232
|
+
valid? ? save_without_validation : false
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def save_without_validation
|
|
236
|
+
raise DestroyedRecord if destroyed?
|
|
237
|
+
callback = "run_#{new? ? 'create' : 'update'}_callbacks"
|
|
238
|
+
succeeded = false
|
|
239
|
+
|
|
240
|
+
run_save_callbacks do
|
|
241
|
+
send(callback) do
|
|
242
|
+
|
|
243
|
+
# untypecast all changed values to construct an up to date values hash
|
|
244
|
+
changed.each do |name, value|
|
|
245
|
+
changed_field = field(name)
|
|
246
|
+
untypecast_value = changed_field.untypecast(value, self)
|
|
247
|
+
if untypecast_value.nil? && changed_field.strip_nil?
|
|
248
|
+
values.delete(name)
|
|
249
|
+
else
|
|
250
|
+
values[name] = untypecast_value
|
|
251
|
+
end
|
|
252
|
+
typecast[name] = value
|
|
253
|
+
end
|
|
254
|
+
succeeded = perform_save
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if succeeded
|
|
259
|
+
@new = false
|
|
260
|
+
@changed.clear
|
|
261
|
+
@stash.clear
|
|
262
|
+
end
|
|
263
|
+
succeeded
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def destroy
|
|
267
|
+
return if new? || destroyed?
|
|
268
|
+
succeeded = false
|
|
269
|
+
run_destroy_callbacks do
|
|
270
|
+
succeeded = perform_destroy
|
|
271
|
+
end
|
|
272
|
+
@destroyed = succeeded
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def update(values, do_save=true)
|
|
276
|
+
raise DestroyedRecord if destroyed?
|
|
277
|
+
values.stringify_keys!
|
|
278
|
+
values.each do |name, value|
|
|
279
|
+
ensure_field_is_valid(name)
|
|
280
|
+
if field(name).protected?
|
|
281
|
+
raise MassAssignment, "Cannot mass assign #{field}"
|
|
282
|
+
else
|
|
283
|
+
set(name, value)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
save if do_save
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def reload
|
|
290
|
+
return if new? || destroyed?
|
|
291
|
+
reload_params = prepare_reload_params
|
|
292
|
+
|
|
293
|
+
# remove all instance variables and re-initialise
|
|
294
|
+
instance_variables.each {|var| remove_instance_variable(var)}
|
|
295
|
+
perform_reload(reload_params)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def prepare_reload_params
|
|
299
|
+
{id: id}
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ----------------------------------------
|
|
304
|
+
# Callbacks & Validation
|
|
305
|
+
# ----------------------------------------
|
|
306
|
+
CALLBACKS = %w{save create update destroy validation}
|
|
307
|
+
ORDERS = %w{before after}
|
|
308
|
+
CALLBACKS.each do |callback|
|
|
309
|
+
ORDERS.each do |order|
|
|
310
|
+
eval "
|
|
311
|
+
@_#{order}_#{callback}_callbacks = []
|
|
312
|
+
|
|
313
|
+
def self._#{order}_#{callback}_callbacks
|
|
314
|
+
@_#{order}_#{callback}_callbacks
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def self.#{order}_#{callback}(*callbacks)
|
|
318
|
+
@_#{order}_#{callback}_callbacks += callbacks
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def run_#{order}_#{callback}_callbacks
|
|
322
|
+
self.class._#{order}_#{callback}_callbacks.each {|method| send method}
|
|
323
|
+
end
|
|
324
|
+
"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
eval "
|
|
328
|
+
def run_#{callback}_callbacks(&block)
|
|
329
|
+
run_before_#{callback}_callbacks
|
|
330
|
+
yield if block_given?
|
|
331
|
+
run_after_#{callback}_callbacks
|
|
332
|
+
end
|
|
333
|
+
"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def self.inherited(child)
|
|
337
|
+
super(child)
|
|
338
|
+
CALLBACKS.each do |callback|
|
|
339
|
+
ORDERS.each do |order|
|
|
340
|
+
callbacks = instance_variable_get("@_#{order}_#{callback}_callbacks")
|
|
341
|
+
child.instance_variable_set("@_#{order}_#{callback}_callbacks", callbacks)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def valid?
|
|
347
|
+
# validate all fields for new records; we know saved records should be
|
|
348
|
+
# valid so we can limit testing to the set of changed fields only
|
|
349
|
+
run_validation_callbacks do
|
|
350
|
+
@errors.clear
|
|
351
|
+
unless new?
|
|
352
|
+
@changed.each {|name, value| field(name).validate(self, @errors)}
|
|
353
|
+
else
|
|
354
|
+
fields.each {|name, field| field.validate(self, @errors)}
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
@errors.empty?
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def errors?
|
|
361
|
+
!@errors.blank?
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Field callbacks
|
|
365
|
+
FIELD_CALLBACKS = %w{save create update destroy}
|
|
366
|
+
FIELD_CALLBACKS.each do |callback|
|
|
367
|
+
ORDERS.each do |order|
|
|
368
|
+
eval "
|
|
369
|
+
#{order}_#{callback} :trigger_field_#{order}_#{callback}_callbacks
|
|
370
|
+
def trigger_field_#{order}_#{callback}_callbacks
|
|
371
|
+
trigger_field_callback(:#{order}, :#{callback})
|
|
372
|
+
end
|
|
373
|
+
"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def trigger_field_callback(order, action)
|
|
378
|
+
method = "#{order}_#{action}"
|
|
379
|
+
fields.each do |name, field|
|
|
380
|
+
field.send(method, self) if field.respond_to?(method)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ----------------------------------------
|
|
386
|
+
# Search
|
|
387
|
+
# ----------------------------------------
|
|
388
|
+
def search_terms
|
|
389
|
+
search_terms = Set.new
|
|
390
|
+
|
|
391
|
+
fields.each do |name, field|
|
|
392
|
+
# TODO: we should cache somewhere which types do and do not contain the search_terms_set
|
|
393
|
+
# method; this can also be used to automatically populate the searchable option on fields
|
|
394
|
+
next unless field.searchable? && field.respond_to?(:search_terms_set)
|
|
395
|
+
search_terms.merge(field.search_terms_set(self).collect(&:downcase))
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
search_terms.to_a
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
private
|
|
403
|
+
def ensure_field_is_valid(name)
|
|
404
|
+
raise UnknownField, "Unknown field <#{name}>" unless field?(name)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def typecast_value(name)
|
|
408
|
+
value = field(name).typecast(@values[name], self)
|
|
409
|
+
@typecast[name] = value
|
|
410
|
+
end
|
|
411
|
+
end
|