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.
Files changed (200) hide show
  1. data/.document +5 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +2 -0
  4. data/Gemfile.lock +63 -0
  5. data/LICENSE +1 -0
  6. data/README.rdoc +20 -0
  7. data/Rakefile +1 -0
  8. data/bin/yodel +4 -0
  9. data/lib/yodel.rb +22 -0
  10. data/lib/yodel/application/application.rb +44 -0
  11. data/lib/yodel/application/extension.rb +59 -0
  12. data/lib/yodel/application/request_handler.rb +48 -0
  13. data/lib/yodel/application/yodel.rb +25 -0
  14. data/lib/yodel/command/command.rb +94 -0
  15. data/lib/yodel/command/deploy.rb +67 -0
  16. data/lib/yodel/command/dns_server.rb +16 -0
  17. data/lib/yodel/command/installer.rb +229 -0
  18. data/lib/yodel/config/config.rb +30 -0
  19. data/lib/yodel/config/environment.rb +16 -0
  20. data/lib/yodel/config/yodel.rb +21 -0
  21. data/lib/yodel/exceptions/destroyed_record.rb +2 -0
  22. data/lib/yodel/exceptions/domain_not_found.rb +16 -0
  23. data/lib/yodel/exceptions/duplicate_layout.rb +2 -0
  24. data/lib/yodel/exceptions/exceptions.rb +3 -0
  25. data/lib/yodel/exceptions/inconsistent_lock_state.rb +2 -0
  26. data/lib/yodel/exceptions/invalid_field.rb +2 -0
  27. data/lib/yodel/exceptions/invalid_index.rb +2 -0
  28. data/lib/yodel/exceptions/invalid_mixin.rb +2 -0
  29. data/lib/yodel/exceptions/invalid_model_field.rb +2 -0
  30. data/lib/yodel/exceptions/layout_not_found.rb +2 -0
  31. data/lib/yodel/exceptions/mass_assignment.rb +2 -0
  32. data/lib/yodel/exceptions/missing_migration.rb +2 -0
  33. data/lib/yodel/exceptions/missing_root_directory.rb +15 -0
  34. data/lib/yodel/exceptions/unable_to_acquire_lock.rb +2 -0
  35. data/lib/yodel/exceptions/unauthorised.rb +2 -0
  36. data/lib/yodel/exceptions/unknown_field.rb +2 -0
  37. data/lib/yodel/middleware/development_server.rb +180 -0
  38. data/lib/yodel/middleware/error_pages.rb +72 -0
  39. data/lib/yodel/middleware/public_assets.rb +78 -0
  40. data/lib/yodel/middleware/request.rb +16 -0
  41. data/lib/yodel/middleware/site_detector.rb +22 -0
  42. data/lib/yodel/mime_types/default_mime_set.rb +28 -0
  43. data/lib/yodel/mime_types/mime_type.rb +68 -0
  44. data/lib/yodel/mime_types/mime_type_set.rb +41 -0
  45. data/lib/yodel/mime_types/mime_types.rb +6 -0
  46. data/lib/yodel/mime_types/yodel.rb +15 -0
  47. data/lib/yodel/models/api/api.rb +1 -0
  48. data/lib/yodel/models/api/api_call.rb +87 -0
  49. data/lib/yodel/models/core/associations/association.rb +37 -0
  50. data/lib/yodel/models/core/associations/associations.rb +22 -0
  51. data/lib/yodel/models/core/associations/counts/many_association.rb +18 -0
  52. data/lib/yodel/models/core/associations/counts/one_association.rb +22 -0
  53. data/lib/yodel/models/core/associations/embedded/embedded_association.rb +47 -0
  54. data/lib/yodel/models/core/associations/embedded/embedded_record_array.rb +12 -0
  55. data/lib/yodel/models/core/associations/embedded/many_embedded_association.rb +62 -0
  56. data/lib/yodel/models/core/associations/embedded/one_embedded_association.rb +49 -0
  57. data/lib/yodel/models/core/associations/query/many_query_association.rb +10 -0
  58. data/lib/yodel/models/core/associations/query/one_query_association.rb +10 -0
  59. data/lib/yodel/models/core/associations/query/query_association.rb +64 -0
  60. data/lib/yodel/models/core/associations/record_association.rb +38 -0
  61. data/lib/yodel/models/core/associations/store/many_store_association.rb +32 -0
  62. data/lib/yodel/models/core/associations/store/one_store_association.rb +14 -0
  63. data/lib/yodel/models/core/associations/store/store_association.rb +51 -0
  64. data/lib/yodel/models/core/attachments/attachment.rb +73 -0
  65. data/lib/yodel/models/core/attachments/image.rb +38 -0
  66. data/lib/yodel/models/core/core.rb +15 -0
  67. data/lib/yodel/models/core/fields/alias_field.rb +32 -0
  68. data/lib/yodel/models/core/fields/array_field.rb +64 -0
  69. data/lib/yodel/models/core/fields/attachment_field.rb +42 -0
  70. data/lib/yodel/models/core/fields/boolean_field.rb +28 -0
  71. data/lib/yodel/models/core/fields/change_sensitive_array.rb +96 -0
  72. data/lib/yodel/models/core/fields/change_sensitive_hash.rb +53 -0
  73. data/lib/yodel/models/core/fields/color_field.rb +4 -0
  74. data/lib/yodel/models/core/fields/date_field.rb +35 -0
  75. data/lib/yodel/models/core/fields/decimal_field.rb +19 -0
  76. data/lib/yodel/models/core/fields/email_field.rb +10 -0
  77. data/lib/yodel/models/core/fields/enum_field.rb +33 -0
  78. data/lib/yodel/models/core/fields/field.rb +154 -0
  79. data/lib/yodel/models/core/fields/fields.rb +29 -0
  80. data/lib/yodel/models/core/fields/fields_field.rb +31 -0
  81. data/lib/yodel/models/core/fields/filter_mixin.rb +9 -0
  82. data/lib/yodel/models/core/fields/filtered_string_field.rb +5 -0
  83. data/lib/yodel/models/core/fields/filtered_text_field.rb +5 -0
  84. data/lib/yodel/models/core/fields/function_field.rb +28 -0
  85. data/lib/yodel/models/core/fields/hash_field.rb +54 -0
  86. data/lib/yodel/models/core/fields/html_field.rb +15 -0
  87. data/lib/yodel/models/core/fields/image_field.rb +11 -0
  88. data/lib/yodel/models/core/fields/integer_field.rb +25 -0
  89. data/lib/yodel/models/core/fields/password_field.rb +21 -0
  90. data/lib/yodel/models/core/fields/self_field.rb +27 -0
  91. data/lib/yodel/models/core/fields/string_field.rb +15 -0
  92. data/lib/yodel/models/core/fields/tags_field.rb +7 -0
  93. data/lib/yodel/models/core/fields/text_field.rb +7 -0
  94. data/lib/yodel/models/core/fields/time_field.rb +36 -0
  95. data/lib/yodel/models/core/functions/function.rb +471 -0
  96. data/lib/yodel/models/core/functions/functions.rb +2 -0
  97. data/lib/yodel/models/core/functions/trigger.rb +14 -0
  98. data/lib/yodel/models/core/log/log.rb +33 -0
  99. data/lib/yodel/models/core/log/log_entry.rb +12 -0
  100. data/lib/yodel/models/core/model/abstract_model.rb +59 -0
  101. data/lib/yodel/models/core/model/model.rb +460 -0
  102. data/lib/yodel/models/core/model/mongo_model.rb +25 -0
  103. data/lib/yodel/models/core/model/site_model.rb +17 -0
  104. data/lib/yodel/models/core/mongo/mongo.rb +3 -0
  105. data/lib/yodel/models/core/mongo/primary_key_factory.rb +12 -0
  106. data/lib/yodel/models/core/mongo/query.rb +68 -0
  107. data/lib/yodel/models/core/mongo/record_index.rb +89 -0
  108. data/lib/yodel/models/core/record/abstract_record.rb +411 -0
  109. data/lib/yodel/models/core/record/embedded_record.rb +47 -0
  110. data/lib/yodel/models/core/record/mongo_record.rb +83 -0
  111. data/lib/yodel/models/core/record/record.rb +386 -0
  112. data/lib/yodel/models/core/record/section.rb +21 -0
  113. data/lib/yodel/models/core/record/site_record.rb +31 -0
  114. data/lib/yodel/models/core/site/migration.rb +52 -0
  115. data/lib/yodel/models/core/site/remote.rb +61 -0
  116. data/lib/yodel/models/core/site/site.rb +202 -0
  117. data/lib/yodel/models/core/validations/email_address_validation.rb +24 -0
  118. data/lib/yodel/models/core/validations/embedded_records_validation.rb +31 -0
  119. data/lib/yodel/models/core/validations/errors.rb +51 -0
  120. data/lib/yodel/models/core/validations/excluded_from_validation.rb +10 -0
  121. data/lib/yodel/models/core/validations/excludes_combinations_validation.rb +18 -0
  122. data/lib/yodel/models/core/validations/format_validation.rb +10 -0
  123. data/lib/yodel/models/core/validations/included_in_validation.rb +10 -0
  124. data/lib/yodel/models/core/validations/includes_combinations_validation.rb +14 -0
  125. data/lib/yodel/models/core/validations/length_validation.rb +28 -0
  126. data/lib/yodel/models/core/validations/password_confirmation_validation.rb +11 -0
  127. data/lib/yodel/models/core/validations/required_validation.rb +9 -0
  128. data/lib/yodel/models/core/validations/unique_validation.rb +9 -0
  129. data/lib/yodel/models/core/validations/validation.rb +39 -0
  130. data/lib/yodel/models/core/validations/validations.rb +15 -0
  131. data/lib/yodel/models/email/email.rb +79 -0
  132. data/lib/yodel/models/migrations/01_record_model.rb +29 -0
  133. data/lib/yodel/models/migrations/02_page_model.rb +45 -0
  134. data/lib/yodel/models/migrations/03_layout_model.rb +38 -0
  135. data/lib/yodel/models/migrations/04_group_model.rb +61 -0
  136. data/lib/yodel/models/migrations/05_user_model.rb +24 -0
  137. data/lib/yodel/models/migrations/06_snippet_model.rb +13 -0
  138. data/lib/yodel/models/migrations/07_search_page_model.rb +32 -0
  139. data/lib/yodel/models/migrations/08_default_site_options.rb +21 -0
  140. data/lib/yodel/models/migrations/09_security_page_models.rb +36 -0
  141. data/lib/yodel/models/migrations/10_record_proxy_page_model.rb +17 -0
  142. data/lib/yodel/models/migrations/11_email_model.rb +28 -0
  143. data/lib/yodel/models/migrations/12_api_call_model.rb +23 -0
  144. data/lib/yodel/models/migrations/13_redirect_page_model.rb +13 -0
  145. data/lib/yodel/models/migrations/14_menu_model.rb +20 -0
  146. data/lib/yodel/models/models.rb +8 -0
  147. data/lib/yodel/models/pages/form_builder.rb +379 -0
  148. data/lib/yodel/models/pages/html_decorator.rb +132 -0
  149. data/lib/yodel/models/pages/layout.rb +120 -0
  150. data/lib/yodel/models/pages/menu.rb +32 -0
  151. data/lib/yodel/models/pages/page.rb +378 -0
  152. data/lib/yodel/models/pages/pages.rb +7 -0
  153. data/lib/yodel/models/pages/record_proxy_page.rb +188 -0
  154. data/lib/yodel/models/pages/redirect_page.rb +11 -0
  155. data/lib/yodel/models/search/search.rb +1 -0
  156. data/lib/yodel/models/search/search_page.rb +58 -0
  157. data/lib/yodel/models/security/facebook_login_page.rb +55 -0
  158. data/lib/yodel/models/security/group.rb +10 -0
  159. data/lib/yodel/models/security/guests_group.rb +5 -0
  160. data/lib/yodel/models/security/login_page.rb +20 -0
  161. data/lib/yodel/models/security/logout_page.rb +13 -0
  162. data/lib/yodel/models/security/noone_group.rb +5 -0
  163. data/lib/yodel/models/security/owner_group.rb +8 -0
  164. data/lib/yodel/models/security/password.rb +5 -0
  165. data/lib/yodel/models/security/password_reset_page.rb +47 -0
  166. data/lib/yodel/models/security/security.rb +10 -0
  167. data/lib/yodel/models/security/user.rb +33 -0
  168. data/lib/yodel/public/core/css/core.css +257 -0
  169. data/lib/yodel/public/core/css/reset.css +48 -0
  170. data/lib/yodel/public/core/images/cross.png +0 -0
  171. data/lib/yodel/public/core/images/spinner.gif +0 -0
  172. data/lib/yodel/public/core/images/tick.png +0 -0
  173. data/lib/yodel/public/core/images/yodel.png +0 -0
  174. data/lib/yodel/public/core/js/jquery.min.js +18 -0
  175. data/lib/yodel/public/core/js/json2.js +480 -0
  176. data/lib/yodel/public/core/js/yodel_jquery.js +238 -0
  177. data/lib/yodel/request/authentication.rb +76 -0
  178. data/lib/yodel/request/flash.rb +28 -0
  179. data/lib/yodel/request/request.rb +4 -0
  180. data/lib/yodel/requires.rb +47 -0
  181. data/lib/yodel/task_queue/queue_daemon.rb +33 -0
  182. data/lib/yodel/task_queue/queue_worker.rb +32 -0
  183. data/lib/yodel/task_queue/stats_thread.rb +27 -0
  184. data/lib/yodel/task_queue/task.rb +62 -0
  185. data/lib/yodel/task_queue/task_queue.rb +40 -0
  186. data/lib/yodel/types/date.rb +5 -0
  187. data/lib/yodel/types/object_id.rb +11 -0
  188. data/lib/yodel/types/time.rb +5 -0
  189. data/lib/yodel/version.rb +3 -0
  190. data/system/Library/LaunchDaemons/com.yodelcms.dns.plist +26 -0
  191. data/system/Library/LaunchDaemons/com.yodelcms.server.plist +26 -0
  192. data/system/etc/resolver/yodel +2 -0
  193. data/system/usr/local/bin/yodel_command_runner +2 -0
  194. data/system/usr/local/etc/yodel/development_settings.rb +28 -0
  195. data/system/usr/local/etc/yodel/production_settings.rb +27 -0
  196. data/system/var/log/yodel.log +0 -0
  197. data/test/helper.rb +18 -0
  198. data/test/test_yodel.rb +4 -0
  199. data/yodel.gemspec +47 -0
  200. 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,3 @@
1
+ require './mongo/primary_key_factory'
2
+ require './mongo/query'
3
+ require './mongo/record_index'
@@ -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