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.
- 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
|