spira 0.0.12 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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