spira 0.0.12 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ class AssociationReflection
2
+ attr_reader :macro
3
+ attr_reader :name
4
+ attr_reader :options
5
+
6
+ def initialize(macro, name, options = {})
7
+ @macro = macro
8
+ @name = name
9
+ @options = options
10
+ end
11
+
12
+ def class_name
13
+ @class_name ||= (options[:type] || derive_class_name).to_s
14
+ end
15
+
16
+ def klass
17
+ @klass ||= class_name.constantize
18
+ end
19
+
20
+ private
21
+
22
+ def derive_class_name
23
+ name.to_s.camelize
24
+ end
25
+ end
data/lib/spira/base.rb CHANGED
@@ -1,11 +1,360 @@
1
+ require "set"
2
+ require "active_model"
3
+ require "rdf/isomorphic"
4
+ require "active_support/core_ext/hash/indifferent_access"
5
+
6
+ require "spira/resource"
7
+ require "spira/persistence"
8
+ require "spira/validations"
9
+ require "spira/reflections"
10
+ require "spira/serialization"
11
+
1
12
  module Spira
2
13
 
3
14
  ##
4
- # Spira::Base does nothing but include Spira::Resource, if it's more your
5
- # style to do inheritance than module inclusion.
15
+ # Spira::Base aims to perform similar to ActiveRecord::Base
16
+ # You should inherit your models from it.
6
17
  #
7
- # @see Spira::Resource
8
18
  class Base
9
- include Spira::Resource
19
+ extend ActiveModel::Callbacks
20
+ extend ActiveModel::Naming
21
+ include ActiveModel::Conversion
22
+ include ActiveModel::Dirty
23
+
24
+ include ::RDF, ::RDF::Enumerable, ::RDF::Queryable
25
+
26
+ define_model_callbacks :save, :destroy, :create, :update
27
+
28
+ ##
29
+ # This instance's URI.
30
+ #
31
+ # @return [RDF::URI]
32
+ attr_reader :subject
33
+
34
+ attr_accessor :attributes
35
+
36
+ class << self
37
+ attr_reader :reflections, :properties
38
+
39
+ def types
40
+ Set.new
41
+ end
42
+
43
+ ##
44
+ # The base URI for this class. Attempts to create instances for non-URI
45
+ # objects will be appended to this base URI.
46
+ #
47
+ # @return [Void]
48
+ def base_uri
49
+ # should be redefined in children, if required
50
+ # see also Spira::Resource.configure :base_uri option
51
+ nil
52
+ end
53
+
54
+ ##
55
+ # The default vocabulary for this class. Setting a default vocabulary
56
+ # will allow properties to be defined without a `:predicate` option.
57
+ # Predicates will instead be created by appending the property name to
58
+ # the given string.
59
+ #
60
+ # @return [Void]
61
+ def default_vocabulary
62
+ # should be redefined in children, if required
63
+ # see also Spira::Resource.configure :default_vocabulary option
64
+ nil
65
+ end
66
+
67
+ def serialize(node, options = {})
68
+ if node.respond_to?(:subject)
69
+ node.subject
70
+ elsif node.respond_to?(:blank?) && node.blank?
71
+ nil
72
+ else
73
+ raise TypeError, "cannot serialize #{node.inspect} as a Spira resource"
74
+ end
75
+ end
76
+
77
+ def unserialize(value, options = {})
78
+ if value.respond_to?(:blank?) && value.blank?
79
+ nil
80
+ else
81
+ # Spira resources are instantiated as "promised"
82
+ # to avoid instantiation loops in case of resource-to-resource relations.
83
+ promise { instantiate_record(value) }
84
+ end
85
+ end
86
+
87
+
88
+ private
89
+
90
+ def inherited(child)
91
+ child.instance_variable_set :@properties, @properties.dup
92
+ child.instance_variable_set :@reflections, @reflections.dup
93
+ super
94
+ end
95
+
96
+ def instantiate_record(subj)
97
+ new(:_subject => id_for(subj))
98
+ end
99
+
100
+ end # class methods
101
+
102
+
103
+ def id
104
+ new_record? ? nil : subject.path.split(/\//).last
105
+ end
106
+
107
+ ##
108
+ # Initialize a new Spira::Base instance of this resource class using
109
+ # a new blank node subject. Accepts a hash of arguments for initial
110
+ # attributes. To use a URI or existing blank node as a subject, use
111
+ # {Spira.for} instead.
112
+ #
113
+ # @param [Hash{Symbol => Any}] props Default attributes for this instance
114
+ # @yield [self] Executes a given block
115
+ # @yieldparam [self] self The newly created instance
116
+ # @see Spira.for
117
+ # @see RDF::URI#as
118
+ # @see RDF::Node#as
119
+ def initialize(props = {}, options = {})
120
+ @subject = props.delete(:_subject) || RDF::Node.new
121
+
122
+ @attributes = {}
123
+ reload props
124
+
125
+ yield self if block_given?
126
+ end
127
+
128
+ # Freeze the attributes hash such that associations are still accessible, even on destroyed records.
129
+ def freeze
130
+ @attributes.freeze; self
131
+ end
132
+
133
+ # Returns +true+ if the attributes hash has been frozen.
134
+ def frozen?
135
+ @attributes.frozen?
136
+ end
137
+
138
+ ##
139
+ # The `RDF.type` associated with this class.
140
+ #
141
+ # This just takes a first type from "types" list,
142
+ # so make sure you know what you're doing if you use it.
143
+ #
144
+ # @return [nil,RDF::URI] The RDF type associated with this instance's class.
145
+ def type
146
+ self.class.type
147
+ end
148
+
149
+ ##
150
+ # All `RDF.type` nodes associated with this class.
151
+ #
152
+ # @return [nil,RDF::URI] The RDF type associated with this instance's class.
153
+ def types
154
+ self.class.types
155
+ end
156
+
157
+ ##
158
+ # Assign all attributes from the given hash.
159
+ #
160
+ def reload(props = {})
161
+ reset_changes
162
+ super
163
+ assign_attributes(props)
164
+ self
165
+ end
166
+
167
+ ##
168
+ # Returns the RDF representation of this resource.
169
+ #
170
+ # @return [RDF::Enumerable]
171
+ def to_rdf
172
+ self
173
+ end
174
+
175
+ ##
176
+ # A developer-friendly view of this projection
177
+ #
178
+ def inspect
179
+ "<#{self.class}:#{self.object_id} @subject: #{@subject}>"
180
+ end
181
+
182
+ ##
183
+ # Compare this instance with another instance. The comparison is done on
184
+ # an RDF level, and will work across subclasses as long as the attributes
185
+ # are the same.
186
+ #
187
+ # @see http://rdf.rubyforge.org/isomorphic/
188
+ def ==(other)
189
+ # TODO: define behavior for equality on subclasses.
190
+ # TODO: should we compare attributes here?
191
+ if self.class == other.class
192
+ subject == other.uri
193
+ elsif other.is_a?(RDF::Enumerable)
194
+ self.isomorphic_with?(other)
195
+ else
196
+ false
197
+ end
198
+ end
199
+
200
+ ##
201
+ # Returns true for :to_uri if this instance's subject is a URI, and false if it is not.
202
+ # Returns true for :to_node if this instance's subject is a Node, and false if it is not.
203
+ # Calls super otherwise.
204
+ #
205
+ def respond_to?(*args)
206
+ case args[0]
207
+ when :to_uri
208
+ subject.respond_to?(:to_uri)
209
+ when :to_node
210
+ subject.node?
211
+ else
212
+ super(*args)
213
+ end
214
+ end
215
+
216
+ ##
217
+ # Returns the RDF::URI associated with this instance if this instance's
218
+ # subject is an RDF::URI, and nil otherwise.
219
+ #
220
+ # @return [RDF::URI,nil]
221
+ def uri
222
+ subject.respond_to?(:to_uri) ? subject : nil
223
+ end
224
+
225
+ ##
226
+ # Returns the URI representation of this resource, if available. If this
227
+ # resource's subject is a BNode, raises a NoMethodError.
228
+ #
229
+ # @return [RDF::URI]
230
+ # @raise [NoMethodError]
231
+ def to_uri
232
+ uri || (raise NoMethodError, "No such method: :to_uri (this instance's subject is not a URI)")
233
+ end
234
+
235
+ ##
236
+ # Returns true if the subject associated with this instance is a blank node.
237
+ #
238
+ # @return [true, false]
239
+ def node?
240
+ subject.node?
241
+ end
242
+
243
+ ##
244
+ # Returns the Node subject of this resource, if available. If this
245
+ # resource's subject is a URI, raises a NoMethodError.
246
+ #
247
+ # @return [RDF::Node]
248
+ # @raise [NoMethodError]
249
+ def to_node
250
+ subject.node? ? subject : (raise NoMethodError, "No such method: :to_uri (this instance's subject is not a URI)")
251
+ end
252
+
253
+ ##
254
+ # Returns a new instance of this class with the new subject instead of self.subject
255
+ #
256
+ # @param [RDF::Resource] new_subject
257
+ # @return [Spira::Base] copy
258
+ def copy(new_subject)
259
+ self.class.new(attributes.merge(:_subject => new_subject))
260
+ end
261
+
262
+ ##
263
+ # Returns a new instance of this class with the new subject instead of
264
+ # self.subject after saving the new copy to the repository.
265
+ #
266
+ # @param [RDF::Resource] new_subject
267
+ # @return [Spira::Base, String] copy
268
+ def copy!(new_subject)
269
+ copy(new_subject).save!
270
+ end
271
+
272
+ ##
273
+ # Assign attributes to the resource
274
+ # without persisting it.
275
+ def assign_attributes(attrs)
276
+ attrs.each do |name, value|
277
+ attribute_will_change!(name.to_s)
278
+ send "#{name}=", value
279
+ end
280
+ end
281
+
282
+
283
+ private
284
+
285
+ def reset_changes
286
+ @previously_changed = changes
287
+ @changed_attributes.clear
288
+ end
289
+
290
+ def write_attribute(name, value)
291
+ name = name.to_s
292
+ if self.class.properties[name]
293
+ if attributes[name].is_a?(Promise)
294
+ changed_attributes[name] = attributes[name] unless changed_attributes.include?(name)
295
+ attributes[name] = value
296
+ else
297
+ if value != read_attribute(name)
298
+ attribute_will_change!(name)
299
+ attributes[name] = value
300
+ end
301
+ end
302
+ else
303
+ raise Spira::PropertyMissingError, "attempt to assign a value to a non-existing property '#{name}'"
304
+ end
305
+ end
306
+
307
+ ##
308
+ # Get the current value for the given attribute
309
+ #
310
+ def read_attribute(name)
311
+ value = attributes[name.to_s]
312
+
313
+ refl = self.class.reflections[name]
314
+ if refl && !value
315
+ # yield default values for empty reflections
316
+ case refl.macro
317
+ when :has_many
318
+ # TODO: this should be actually handled by the reflection class
319
+ []
320
+ end
321
+ else
322
+ value
323
+ end
324
+ end
325
+
326
+ # Build a Ruby value from an RDF value.
327
+ def build_value(node, type)
328
+ klass = classize_resource(type)
329
+ if klass.respond_to?(:unserialize)
330
+ klass.unserialize(node)
331
+ else
332
+ raise TypeError, "Unable to unserialize #{node} as #{type}"
333
+ end
334
+ end
335
+
336
+ # Build an RDF value from a Ruby value for a property
337
+ def build_rdf_value(value, type)
338
+ klass = classize_resource(type)
339
+ if klass.respond_to?(:serialize)
340
+ klass.serialize(value)
341
+ else
342
+ raise TypeError, "Unable to serialize #{value} as #{type}"
343
+ end
344
+ end
345
+
346
+ def valid_object?(node)
347
+ node && (!node.literal? || node.valid?)
348
+ end
349
+
350
+ extend Resource
351
+ extend Reflections
352
+ include Types
353
+ include Persistence
354
+ include Validations
355
+ include Serialization
356
+
357
+ @reflections = HashWithIndifferentAccess.new
358
+ @properties = HashWithIndifferentAccess.new
10
359
  end
11
360
  end
@@ -1,19 +1,25 @@
1
1
  module Spira
2
2
 
3
+ class SpiraError < StandardError ; end
4
+
3
5
  ##
4
6
  # For cases when a method is called which requires a `type` method be
5
7
  # declared on a Spira class.
6
- class NoTypeError < StandardError ; end
7
-
8
- ##
9
- # For cases when a projection fails a validation check
10
- class ValidationError < StandardError ; end
8
+ class NoTypeError < SpiraError ; end
11
9
 
12
10
  ##
13
11
  # For cases in which a repository is required but none has been given
14
- class NoRepositoryError < StandardError ; end
12
+ class NoRepositoryError < SpiraError ; end
15
13
 
16
14
  ##
17
15
  # For errors in the DSL, such as invalid predicates
18
- class ResourceDeclarationError < StandardError ; end
16
+ class ResourceDeclarationError < SpiraError ; end
17
+
18
+ ##
19
+ # Raised when user tries to assign a non-existing property
20
+ class PropertyMissingError < SpiraError ; end
21
+
22
+ ##
23
+ # Raised when record cannot be persisted
24
+ class RecordNotSaved < SpiraError ; end
19
25
  end
@@ -0,0 +1,531 @@
1
+ module Spira
2
+ module Persistence
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ ##
7
+ # Repository name for this class
8
+ #
9
+ # @return [Symbol]
10
+ def repository_name
11
+ # should be redefined in children, if required
12
+ # see also Spira::Resource.configure :repository option
13
+ :default
14
+ end
15
+
16
+ ##
17
+ # The current repository for this class
18
+ #
19
+ # @return [RDF::Repository, nil]
20
+ def repository
21
+ Spira.repository(repository_name)
22
+ end
23
+
24
+ ##
25
+ # Simple finder method.
26
+ #
27
+ # @param [Symbol, ID] scope
28
+ # scope can be :all, :first or an ID
29
+ # @param [Hash] args
30
+ # args can contain:
31
+ # :conditions - Hash of properties and values
32
+ # :limit - Fixnum, limiting the amount of returned records
33
+ # @return [Spira::Base, Array]
34
+ def find(scope, *args)
35
+ case scope
36
+ when :first
37
+ find_each(*args).first
38
+ when :all
39
+ find_all(*args)
40
+ else
41
+ instantiate_record(scope)
42
+ end
43
+ end
44
+
45
+ def all(*args)
46
+ find(:all, *args)
47
+ end
48
+
49
+ def first(*args)
50
+ find(:first, *args)
51
+ end
52
+
53
+ ##
54
+ # Enumerate over all resources projectable as this class. This method is
55
+ # only valid for classes which declare a `type` with the `type` method in
56
+ # the Resource.
57
+ #
58
+ # Note that the instantiated records are "promises" not real instances.
59
+ #
60
+ # @raise [Spira::NoTypeError] if the resource class does not have an RDF type declared
61
+ # @overload each
62
+ # @yield [instance] A block to perform for each available projection of this class
63
+ # @yieldparam [self] instance
64
+ # @yieldreturn [Void]
65
+ # @return [Void]
66
+ #
67
+ # @overload each
68
+ # @return [Enumerator]
69
+ def each(*args)
70
+ raise Spira::NoTypeError, "Cannot count a #{self} without a reference type URI" unless type
71
+
72
+ options = args.extract_options!
73
+ conditions = options.delete(:conditions) || {}
74
+
75
+ raise Spira::SpiraError, "Cannot accept :type in query conditions" if conditions.delete(:type) || conditions.delete("type")
76
+
77
+ if block_given?
78
+ limit = options[:limit] || -1
79
+ offset = options[:offset] || 0
80
+ # TODO: ideally, all types should be joined in a conjunction
81
+ # within "conditions_to_query", but since RDF::Query
82
+ # cannot handle such patterns, we iterate across types "manually"
83
+ types.each do |tp|
84
+ break if limit.zero?
85
+ q = conditions_to_query(conditions.merge(:type => tp))
86
+ repository.query(q) do |solution|
87
+ break if limit.zero?
88
+ if offset.zero?
89
+ yield unserialize(solution[:subject])
90
+ limit -= 1
91
+ else
92
+ offset -= 1
93
+ end
94
+ end
95
+ end
96
+ else
97
+ enum_for(:each, *args)
98
+ end
99
+ end
100
+ alias_method :find_each, :each
101
+
102
+ ##
103
+ # The number of URIs projectable as a given class in the repository.
104
+ # This method is only valid for classes which declare a `type` with the
105
+ # `type` method in the Resource.
106
+ #
107
+ # @raise [Spira::NoTypeError] if the resource class does not have an RDF type declared
108
+ # @return [Integer] the count
109
+ def count
110
+ each.count
111
+ end
112
+
113
+ # Creates an object (or multiple objects) and saves it to the database, if validations pass.
114
+ # The resulting object is returned whether the object was saved successfully to the database or not.
115
+ #
116
+ # The +attributes+ parameter can be either be a Hash or an Array of Hashes. These Hashes describe the
117
+ # attributes on the objects that are to be created.
118
+ #
119
+ # +create+ respects mass-assignment security and accepts either +:as+ or +:without_protection+ options
120
+ # in the +options+ parameter.
121
+ #
122
+ # ==== Examples
123
+ # # Create a single new object
124
+ # User.create(:first_name => 'Jamie')
125
+ #
126
+ # # Create a single new object using the :admin mass-assignment security role
127
+ # User.create({ :first_name => 'Jamie', :is_admin => true }, :as => :admin)
128
+ #
129
+ # # Create a single new object bypassing mass-assignment security
130
+ # User.create({ :first_name => 'Jamie', :is_admin => true }, :without_protection => true)
131
+ #
132
+ # # Create an Array of new objects
133
+ # User.create([{ :first_name => 'Jamie' }, { :first_name => 'Jeremy' }])
134
+ #
135
+ # # Create a single object and pass it into a block to set other attributes.
136
+ # User.create(:first_name => 'Jamie') do |u|
137
+ # u.is_admin = false
138
+ # end
139
+ #
140
+ # # Creating an Array of new objects using a block, where the block is executed for each object:
141
+ # User.create([{ :first_name => 'Jamie' }, { :first_name => 'Jeremy' }]) do |u|
142
+ # u.is_admin = false
143
+ # end
144
+ def create(attributes = nil, options = {}, &block)
145
+ if attributes.is_a?(Array)
146
+ attributes.collect { |attr| create(attr, options, &block) }
147
+ else
148
+ object = new(attributes, options, &block)
149
+ object.save
150
+ object
151
+ end
152
+ end
153
+
154
+ ##
155
+ # Create a new projection instance of this class for the given URI. If a
156
+ # class has a base_uri given, and the argument is not an `RDF::URI`, the
157
+ # given identifier will be appended to the base URI.
158
+ #
159
+ # Spira does not have 'find' or 'create' functions. As RDF identifiers
160
+ # are globally unique, they all simply 'are'.
161
+ #
162
+ # On calling `for`, a new projection is created for the given URI. The
163
+ # first time access is attempted on a field, the repository will be
164
+ # queried for existing attributes, which will be used for the given URI.
165
+ # Underlying repositories are not accessed at the time of calling `for`.
166
+ #
167
+ # A class with a base URI may still be projected for any URI, whether or
168
+ # not it uses the given resource class' base URI.
169
+ #
170
+ # @raise [TypeError] if an RDF type is given in the attributes and one is
171
+ # given in the attributes.
172
+ # @raise [ArgumentError] if a non-URI is given and the class does not
173
+ # have a base URI.
174
+ # @overload for(uri, attributes = {})
175
+ # @param [RDF::URI] uri The URI to create an instance for
176
+ # @param [Hash{Symbol => Any}] attributes Initial attributes
177
+ # @overload for(identifier, attributes = {})
178
+ # @param [Any] uri The identifier to append to the base URI for this class
179
+ # @param [Hash{Symbol => Any}] attributes Initial attributes
180
+ # @yield [self] Executes a given block and calls `#save!`
181
+ # @yieldparam [self] self The newly created instance
182
+ # @return [Spira::Base] The newly created instance
183
+ # @see http://rdf.rubyforge.org/RDF/URI.html
184
+ def for(identifier, attributes = {}, &block)
185
+ self.project(id_for(identifier), attributes, &block)
186
+ end
187
+ alias_method :[], :for
188
+
189
+ ##
190
+ # Create a new instance with the given subject without any modification to
191
+ # the given subject at all. This method exists to provide an entry point
192
+ # for implementing classes that want to create a more intelligent .for
193
+ # and/or .id_for for their given use cases, such as simple string
194
+ # appending to base URIs or calculated URIs from other representations.
195
+ #
196
+ # @example Using simple string concatentation with base_uri in .for instead of joining delimiters
197
+ # def for(identifier, attributes = {}, &block)
198
+ # self.project(RDF::URI(self.base_uri.to_s + identifier.to_s), attributes, &block)
199
+ # end
200
+ # @param [RDF::URI, RDF::Node] subject
201
+ # @param [Hash{Symbol => Any}] attributes Initial attributes
202
+ # @return [Spira::Base] the newly created instance
203
+ def project(subject, attributes = {}, &block)
204
+ new(attributes.merge(:_subject => subject), &block)
205
+ end
206
+
207
+ ##
208
+ # Creates a URI or RDF::Node based on a potential base_uri and string,
209
+ # URI, or Node, or Addressable::URI. If not a URI or Node, the given
210
+ # identifier should be a string representing an absolute URI, or
211
+ # something responding to to_s which can be appended to a base URI, which
212
+ # this class must have.
213
+ #
214
+ # @param [Any] identifier
215
+ # @return [RDF::URI, RDF::Node]
216
+ # @raise [ArgumentError] If this class cannot create an identifier from the given argument
217
+ # @see http://rdf.rubyforge.org/RDF/URI.html
218
+ # @see Spira.base_uri
219
+ # @see Spira.for
220
+ def id_for(identifier)
221
+ case
222
+ # Absolute URI's go through unchanged
223
+ when identifier.is_a?(RDF::URI) && identifier.absolute?
224
+ identifier
225
+ # We don't have a base URI to join this fragment with, so go ahead and instantiate it as-is.
226
+ when identifier.is_a?(RDF::URI) && self.base_uri.nil?
227
+ identifier
228
+ # Blank nodes go through unchanged
229
+ when identifier.respond_to?(:node?) && identifier.node?
230
+ identifier
231
+ # Anything that can be an RDF::URI, we re-run this case statement
232
+ # on it for the fragment logic above.
233
+ when identifier.respond_to?(:to_uri) && !identifier.is_a?(RDF::URI)
234
+ id_for(identifier.to_uri)
235
+ # see comment with #to_uri above, this might be a fragment
236
+ when identifier.is_a?(Addressable::URI)
237
+ id_for(RDF::URI.intern(identifier))
238
+ # This is a #to_s or a URI fragment with a base uri. We'll treat them the same.
239
+ # FIXME: when #/ makes it into RDF.rb proper, this can all be wrapped
240
+ # into the one case statement above.
241
+ else
242
+ uri = identifier.is_a?(RDF::URI) ? identifier : RDF::URI.intern(identifier.to_s)
243
+ case
244
+ when uri.absolute?
245
+ uri
246
+ when self.base_uri.nil?
247
+ raise ArgumentError, "Cannot create identifier for #{self} by String without base_uri; an RDF::URI is required"
248
+ else
249
+ separator = self.base_uri.to_s[-1,1] =~ /(\/|#)/ ? '' : '/'
250
+ RDF::URI.intern(self.base_uri.to_s + separator + identifier.to_s)
251
+ end
252
+ end
253
+ end
254
+
255
+
256
+ private
257
+
258
+ def find_all(*args)
259
+ [].tap do |records|
260
+ find_each(*args) do |record|
261
+ records << record
262
+ end
263
+ end
264
+ end
265
+
266
+ def conditions_to_query(conditions)
267
+ patterns = []
268
+ conditions.each do |name, value|
269
+ if name.to_s == "type"
270
+ patterns << [:subject, RDF.type, value]
271
+ else
272
+ patterns << [:subject, properties[name][:predicate], value]
273
+ end
274
+ end
275
+
276
+ RDF::Query.new do
277
+ patterns.each { |pat| pattern(pat) }
278
+ end
279
+ end
280
+ end
281
+
282
+ # A resource is considered to be new if the repository
283
+ # does not have statements where subject == resource type
284
+ def new_record?
285
+ !self.class.repository.has_subject?(subject)
286
+ end
287
+
288
+ def destroyed?
289
+ @destroyed
290
+ end
291
+
292
+ def persisted?
293
+ # FIXME: an object should be considered persisted
294
+ # when its attributes (and their exact values) are all available in the storage.
295
+ # This should check for !(changed? || new_record? || destroyed?) actually.
296
+ !(new_record? || destroyed?)
297
+ end
298
+
299
+ def save(*)
300
+ create_or_update
301
+ end
302
+
303
+ def save!(*)
304
+ create_or_update || raise(RecordNotSaved)
305
+ end
306
+
307
+ def destroy(*args)
308
+ run_callbacks :destroy do
309
+ destroy_model_data(*args)
310
+ end
311
+ end
312
+
313
+ def destroy!(*args)
314
+ destroy(*args) || raise(RecordNotSaved)
315
+ end
316
+
317
+ ##
318
+ # Enumerate each RDF statement that makes up this projection. This makes
319
+ # each instance an `RDF::Enumerable`, with all of the nifty benefits
320
+ # thereof. See <http://rdf.rubyforge.org/RDF/Enumerable.html> for
321
+ # information on arguments.
322
+ #
323
+ # @see http://rdf.rubyforge.org/RDF/Enumerable.html
324
+ def each
325
+ if block_given?
326
+ self.class.properties.each do |name, property|
327
+ if value = read_attribute(name)
328
+ if self.class.reflect_on_association(name)
329
+ value.each do |val|
330
+ node = build_rdf_value(val, property[:type])
331
+ yield RDF::Statement.new(subject, property[:predicate], node) if valid_object?(node)
332
+ end
333
+ else
334
+ node = build_rdf_value(value, property[:type])
335
+ yield RDF::Statement.new(subject, property[:predicate], node) if valid_object?(node)
336
+ end
337
+ end
338
+ end
339
+ self.class.types.each do |t|
340
+ yield RDF::Statement.new(subject, RDF.type, t)
341
+ end
342
+ else
343
+ enum_for(:each)
344
+ end
345
+ end
346
+
347
+ ##
348
+ # The number of RDF::Statements this projection has.
349
+ #
350
+ # @see http://rdf.rubyforge.org/RDF/Enumerable.html#count
351
+ def count
352
+ each.count
353
+ end
354
+
355
+ ##
356
+ # Update multiple attributes of this repository.
357
+ #
358
+ # @example Update multiple attributes
359
+ # person.update_attributes(:name => 'test', :age => 10)
360
+ # #=> person
361
+ # person.name
362
+ # #=> 'test'
363
+ # person.age
364
+ # #=> 10
365
+ # person.dirty?
366
+ # #=> true
367
+ # @param [Hash{Symbol => Any}] properties
368
+ # @param [Hash{Symbol => Any}] options
369
+ # @return [self]
370
+ def update_attributes(properties, options = {})
371
+ assign_attributes properties
372
+ save options
373
+ end
374
+
375
+ ##
376
+ # Reload all attributes for this instance.
377
+ # This resource will block if the underlying repository
378
+ # blocks the next time it accesses attributes.
379
+ #
380
+ # If repository is not defined, the attributes are just not set,
381
+ # instead of raising a Spira::NoRepositoryError.
382
+ #
383
+ # NB: "props" argument is ignored, it is handled in Base
384
+ #
385
+ def reload(props = {})
386
+ sts = self.class.repository && self.class.repository.query(:subject => subject)
387
+ self.class.properties.each do |name, options|
388
+ name = name.to_s
389
+ if sts
390
+ objects = sts.select { |s| s.predicate == options[:predicate] }
391
+ attributes[name] = retrieve_attribute(name, options, objects)
392
+ end
393
+ end
394
+ end
395
+
396
+
397
+ private
398
+
399
+ def create_or_update
400
+ run_callbacks :save do
401
+ # "create" callback is triggered only when persisting a resource definition
402
+ persistance_callback = new_record? && type ? :create : :update
403
+ run_callbacks persistance_callback do
404
+ materizalize
405
+ persist!
406
+ reset_changes
407
+ end
408
+ end
409
+ self
410
+ end
411
+
412
+ ##
413
+ # Save changes to the repository
414
+ #
415
+ def persist!
416
+ repo = self.class.repository
417
+ self.class.properties.each do |name, property|
418
+ value = read_attribute name
419
+ if self.class.reflect_on_association(name)
420
+ # TODO: for now, always persist associations,
421
+ # as it's impossible to reliably determine
422
+ # whether the "association property" was changed
423
+ # (e.g. for "in-place" changes like "association << 1")
424
+ # This should be solved by splitting properties
425
+ # into "true attributes" and associations
426
+ # and not mixing the both in @properties.
427
+ repo.delete [subject, property[:predicate], nil]
428
+ value.each do |val|
429
+ store_attribute(name, val, property[:predicate], repo)
430
+ end
431
+ else
432
+ if attribute_changed?(name.to_s)
433
+ repo.delete [subject, property[:predicate], nil]
434
+ store_attribute(name, value, property[:predicate], repo)
435
+ end
436
+ end
437
+ end
438
+ types.each do |type|
439
+ # NB: repository won't accept duplicates,
440
+ # but this should be avoided anyway, for performance
441
+ repo.insert RDF::Statement.new(subject, RDF.type, type)
442
+ end
443
+ end
444
+
445
+ # "Materialize" the resource:
446
+ # assign a persistable subject to a non-persisted resource,
447
+ # so that it can be properly stored.
448
+ def materizalize
449
+ if new_record? && subject.anonymous? && type
450
+ # TODO: doesn't subject.anonymous? imply subject.id == nil ???
451
+ @subject = self.class.id_for(subject.id)
452
+ end
453
+ end
454
+
455
+ def store_attribute(property, value, predicate, repository)
456
+ unless value.nil?
457
+ val = build_rdf_value(value, self.class.properties[property][:type])
458
+ repository.insert RDF::Statement.new(subject, predicate, val) if valid_object?(val)
459
+ end
460
+ end
461
+
462
+ # Directly retrieve an attribute value from the storage
463
+ def retrieve_attribute(name, options, sts)
464
+ if self.class.reflections[name]
465
+ sts.inject([]) do |values, statement|
466
+ if statement.predicate == options[:predicate]
467
+ values << build_value(statement.object, options[:type])
468
+ else
469
+ values
470
+ end
471
+ end
472
+ else
473
+ sts.first ? build_value(sts.first.object, options[:type]) : nil
474
+ end
475
+ end
476
+
477
+ # Destroy all model data
478
+ # AND non-model data, where this resource is referred to as object.
479
+ def destroy_model_data(*args)
480
+ if self.class.repository.delete(*statements) && self.class.repository.delete([nil, nil, subject])
481
+ @destroyed = true
482
+ freeze
483
+ end
484
+ end
485
+
486
+ # Return the appropriate class object for a string or symbol
487
+ # representation. Throws errors correctly if the given class cannot be
488
+ # located, or if it is not a Spira::Base
489
+ #
490
+ def classize_resource(type)
491
+ return type unless type.is_a?(Symbol) || type.is_a?(String)
492
+
493
+ klass = nil
494
+ begin
495
+ klass = qualified_const_get(type.to_s)
496
+ rescue NameError
497
+ raise NameError, "Could not find relation class #{type} (referenced as #{type} by #{self})"
498
+ end
499
+ klass
500
+ end
501
+
502
+ # Resolve a constant from a string, relative to this class' namespace, if
503
+ # available, and from root, otherwise.
504
+ #
505
+ # FIXME: this is not really 'qualified', but it's one of those
506
+ # impossible-to-name functions. Open to suggestions.
507
+ #
508
+ # @author njh
509
+ # @private
510
+ def qualified_const_get(str)
511
+ path = str.to_s.split('::')
512
+ from_root = path[0].empty?
513
+ if from_root
514
+ from_root = []
515
+ path = path[1..-1]
516
+ else
517
+ start_ns = ((Class === self)||(Module === self)) ? self : self.class
518
+ from_root = start_ns.to_s.split('::')
519
+ end
520
+ until from_root.empty?
521
+ begin
522
+ return (from_root+path).inject(Object) { |ns,name| ns.const_get(name) }
523
+ rescue NameError
524
+ from_root.delete_at(-1)
525
+ end
526
+ end
527
+ path.inject(Object) { |ns,name| ns.const_get(name) }
528
+ end
529
+
530
+ end
531
+ end