spira 0.0.12 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/AUTHORS +2 -0
- data/CHANGES.md +6 -3
- data/{README → README.md} +91 -120
- data/lib/rdf/ext/uri.rb +37 -0
- data/lib/spira.rb +47 -86
- data/lib/spira/association_reflection.rb +25 -0
- data/lib/spira/base.rb +353 -4
- data/lib/spira/exceptions.rb +13 -7
- data/lib/spira/persistence.rb +531 -0
- data/lib/spira/reflections.rb +19 -0
- data/lib/spira/resource.rb +165 -60
- data/lib/spira/serialization.rb +27 -0
- data/lib/spira/type.rb +7 -7
- data/lib/spira/types.rb +8 -11
- data/lib/spira/types/boolean.rb +3 -3
- data/lib/spira/types/date.rb +4 -5
- data/lib/spira/types/dateTime.rb +26 -0
- data/lib/spira/types/decimal.rb +2 -2
- data/lib/spira/types/float.rb +3 -3
- data/lib/spira/types/gYear.rb +26 -0
- data/lib/spira/types/int.rb +27 -0
- data/lib/spira/types/integer.rb +3 -3
- data/lib/spira/types/long.rb +27 -0
- data/lib/spira/types/negativeInteger.rb +27 -0
- data/lib/spira/types/nonNegativeInteger.rb +27 -0
- data/lib/spira/types/nonPositiveInteger.rb +27 -0
- data/lib/spira/types/positiveInteger.rb +27 -0
- data/lib/spira/types/string.rb +1 -1
- data/lib/spira/utils.rb +29 -0
- data/lib/spira/validations.rb +77 -0
- data/lib/spira/validations/uniqueness.rb +33 -0
- data/lib/spira/version.rb +3 -6
- metadata +218 -123
- data/lib/spira/errors.rb +0 -94
- data/lib/spira/extensions.rb +0 -14
- data/lib/spira/resource/class_methods.rb +0 -260
- data/lib/spira/resource/dsl.rb +0 -279
- data/lib/spira/resource/instance_methods.rb +0 -567
- data/lib/spira/resource/validations.rb +0 -47
@@ -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
|
5
|
-
#
|
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
|
-
|
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
|
data/lib/spira/exceptions.rb
CHANGED
@@ -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 <
|
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 <
|
12
|
+
class NoRepositoryError < SpiraError ; end
|
15
13
|
|
16
14
|
##
|
17
15
|
# For errors in the DSL, such as invalid predicates
|
18
|
-
class ResourceDeclarationError <
|
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
|