caruby-core 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/History.txt +4 -0
- data/LEGAL +5 -0
- data/LICENSE +22 -0
- data/README.md +51 -0
- data/doc/website/css/site.css +1 -5
- data/doc/website/images/avatar.png +0 -0
- data/doc/website/images/favicon.ico +0 -0
- data/doc/website/images/logo.png +0 -0
- data/doc/website/index.html +82 -0
- data/doc/website/install.html +87 -0
- data/doc/website/quick_start.html +87 -0
- data/doc/website/tissue.html +85 -0
- data/doc/website/uom.html +10 -0
- data/lib/caruby.rb +3 -0
- data/lib/caruby/active_support/README.txt +2 -0
- data/lib/caruby/active_support/core_ext/string.rb +7 -0
- data/lib/caruby/active_support/core_ext/string/inflections.rb +167 -0
- data/lib/caruby/active_support/inflections.rb +55 -0
- data/lib/caruby/active_support/inflector.rb +398 -0
- data/lib/caruby/cli/application.rb +36 -0
- data/lib/caruby/cli/command.rb +169 -0
- data/lib/caruby/csv/csv_mapper.rb +157 -0
- data/lib/caruby/csv/csvio.rb +185 -0
- data/lib/caruby/database.rb +252 -0
- data/lib/caruby/database/fetched_matcher.rb +66 -0
- data/lib/caruby/database/persistable.rb +432 -0
- data/lib/caruby/database/persistence_service.rb +162 -0
- data/lib/caruby/database/reader.rb +599 -0
- data/lib/caruby/database/saved_merger.rb +131 -0
- data/lib/caruby/database/search_template_builder.rb +59 -0
- data/lib/caruby/database/sql_executor.rb +75 -0
- data/lib/caruby/database/store_template_builder.rb +200 -0
- data/lib/caruby/database/writer.rb +469 -0
- data/lib/caruby/domain/annotatable.rb +25 -0
- data/lib/caruby/domain/annotation.rb +23 -0
- data/lib/caruby/domain/attribute_metadata.rb +447 -0
- data/lib/caruby/domain/java_attribute_metadata.rb +160 -0
- data/lib/caruby/domain/merge.rb +91 -0
- data/lib/caruby/domain/properties.rb +95 -0
- data/lib/caruby/domain/reference_visitor.rb +289 -0
- data/lib/caruby/domain/resource_attributes.rb +528 -0
- data/lib/caruby/domain/resource_dependency.rb +205 -0
- data/lib/caruby/domain/resource_introspection.rb +159 -0
- data/lib/caruby/domain/resource_metadata.rb +117 -0
- data/lib/caruby/domain/resource_module.rb +285 -0
- data/lib/caruby/domain/uniquify.rb +38 -0
- data/lib/caruby/import/annotatable_class.rb +28 -0
- data/lib/caruby/import/annotation_class.rb +27 -0
- data/lib/caruby/import/annotation_module.rb +67 -0
- data/lib/caruby/import/java.rb +338 -0
- data/lib/caruby/migration/migratable.rb +167 -0
- data/lib/caruby/migration/migrator.rb +533 -0
- data/lib/caruby/migration/resource.rb +8 -0
- data/lib/caruby/migration/resource_module.rb +11 -0
- data/lib/caruby/migration/uniquify.rb +20 -0
- data/lib/caruby/resource.rb +969 -0
- data/lib/caruby/util/attribute_path.rb +46 -0
- data/lib/caruby/util/cache.rb +53 -0
- data/lib/caruby/util/class.rb +99 -0
- data/lib/caruby/util/collection.rb +1053 -0
- data/lib/caruby/util/controlled_value.rb +35 -0
- data/lib/caruby/util/coordinate.rb +75 -0
- data/lib/caruby/util/domain_extent.rb +49 -0
- data/lib/caruby/util/file_separator.rb +65 -0
- data/lib/caruby/util/inflector.rb +20 -0
- data/lib/caruby/util/log.rb +95 -0
- data/lib/caruby/util/math.rb +12 -0
- data/lib/caruby/util/merge.rb +59 -0
- data/lib/caruby/util/module.rb +34 -0
- data/lib/caruby/util/options.rb +92 -0
- data/lib/caruby/util/partial_order.rb +36 -0
- data/lib/caruby/util/person.rb +119 -0
- data/lib/caruby/util/pretty_print.rb +184 -0
- data/lib/caruby/util/properties.rb +112 -0
- data/lib/caruby/util/stopwatch.rb +66 -0
- data/lib/caruby/util/topological_sync_enumerator.rb +53 -0
- data/lib/caruby/util/transitive_closure.rb +45 -0
- data/lib/caruby/util/tree.rb +48 -0
- data/lib/caruby/util/trie.rb +37 -0
- data/lib/caruby/util/uniquifier.rb +30 -0
- data/lib/caruby/util/validation.rb +48 -0
- data/lib/caruby/util/version.rb +56 -0
- data/lib/caruby/util/visitor.rb +351 -0
- data/lib/caruby/util/weak_hash.rb +36 -0
- data/lib/caruby/version.rb +3 -0
- metadata +186 -0
@@ -0,0 +1,252 @@
|
|
1
|
+
require 'generator'
|
2
|
+
require 'caruby/util/log'
|
3
|
+
require 'caruby/util/collection'
|
4
|
+
require 'caruby/util/validation'
|
5
|
+
require 'caruby/util/options'
|
6
|
+
require 'caruby/util/visitor'
|
7
|
+
require 'caruby/util/inflector'
|
8
|
+
require 'caruby/database/persistable'
|
9
|
+
require 'caruby/database/reader'
|
10
|
+
require 'caruby/database/writer'
|
11
|
+
require 'caruby/database/persistence_service'
|
12
|
+
|
13
|
+
# the caBIG client classes
|
14
|
+
import 'gov.nih.nci.system.applicationservice.ApplicationServiceProvider'
|
15
|
+
import 'gov.nih.nci.system.comm.client.ClientSession'
|
16
|
+
|
17
|
+
module CaRuby
|
18
|
+
# Database operation error.
|
19
|
+
class DatabaseError < RuntimeError; end
|
20
|
+
|
21
|
+
# A Database mediates access to a caBIG database. Database is a facade for caBIG application service
|
22
|
+
# database operations. Database supports the query, create, update and delete operations supported by
|
23
|
+
# the application service.
|
24
|
+
#
|
25
|
+
# Database strives to provide a simple WYEIWYG (What You Expect Is What You Get) API, consisting of
|
26
|
+
# the following workhorse methods:
|
27
|
+
# * {Query#query} - fetch domain objects which match a template
|
28
|
+
# * {Store#find} - fetch a specific domain object by key
|
29
|
+
# * {Store#store} - if a domain object exists in the database, then update it, otherwise create it
|
30
|
+
#
|
31
|
+
# Any domain object can serve as a query argument. If an optional attribute path is specified, then
|
32
|
+
# that path is followed to the result, e.g.:
|
33
|
+
# database.query(study, :coordinator)
|
34
|
+
# returns the coordinators of studies which match the +study+ template.
|
35
|
+
#
|
36
|
+
# A domain object find argument must contain enough data to determine whether it exists in the database,
|
37
|
+
# i.e. the find argument has a database identifier or a complete secondary key.
|
38
|
+
#
|
39
|
+
# The {Store#store} method creates or updates references as necessary to persist its argument domain object.
|
40
|
+
# It is not necessary to fetch references first or follow dependency ordering rules, which can be
|
41
|
+
# implicit and tortuous in caBIG applications. Build the object you want to persist and call the
|
42
|
+
# store method. CaRuby::Resource sets reasonable default values, recognizes application dependencies and steers
|
43
|
+
# around caBIG idiosyncracies to the extent possible.
|
44
|
+
class Database
|
45
|
+
include Reader, Writer, Validation
|
46
|
+
|
47
|
+
attr_reader :operations
|
48
|
+
|
49
|
+
# Creates a new Database with the specified service name and options.
|
50
|
+
#
|
51
|
+
# @param [String] service_name the name of the default {PersistenceService}
|
52
|
+
# @param [{Symbol => String}] opts access options
|
53
|
+
# @option opts [String] :login application service login user
|
54
|
+
# @option opts [String] :password application service login password
|
55
|
+
# @example
|
56
|
+
# Database.new(:user => 'perdita', :password => 'changeMe')
|
57
|
+
def initialize(service_name, opts)
|
58
|
+
super()
|
59
|
+
# the fetched object cache
|
60
|
+
@cache = create_cache
|
61
|
+
@defaults = {}
|
62
|
+
@user = Options.get(:user, opts)
|
63
|
+
@password = Options.get(:password, opts)
|
64
|
+
@host = Options.get(:host, opts)
|
65
|
+
# class => service hash; default is the catissuecore app service
|
66
|
+
@def_persist_svc = PersistenceService.new(service_name, :host => @host)
|
67
|
+
@cls_svc_hash = Hash.new(@def_persist_svc)
|
68
|
+
# the create/update nested operations
|
69
|
+
@operations = []
|
70
|
+
# the objects for which exists? is unsuccessful in the context of a nested operation
|
71
|
+
@transients = Set.new
|
72
|
+
end
|
73
|
+
|
74
|
+
# Calls the block given to this method with this database as an argument, and closes the
|
75
|
+
# database when done.
|
76
|
+
#
|
77
|
+
# @yield [database] the operation to perform on the database
|
78
|
+
# @yieldparam [Database] database self
|
79
|
+
def open
|
80
|
+
# reset the execution timers
|
81
|
+
persistence_services.each { |svc| svc.timer.reset }
|
82
|
+
# call the block and close when done
|
83
|
+
yield(self) ensure close
|
84
|
+
end
|
85
|
+
|
86
|
+
# Releases database resources. This method should be called when database interaction
|
87
|
+
# is completed.
|
88
|
+
def close
|
89
|
+
return if @session.nil?
|
90
|
+
begin
|
91
|
+
@session.terminate_session
|
92
|
+
rescue Exception => e
|
93
|
+
logger.error("Session termination unsuccessful - #{e.message}")
|
94
|
+
end
|
95
|
+
# clear the cache
|
96
|
+
@cache.clear
|
97
|
+
logger.info("Disconnected from application server.")
|
98
|
+
@session = nil
|
99
|
+
end
|
100
|
+
|
101
|
+
# Returns the execution time spent since the last open.
|
102
|
+
def execution_time
|
103
|
+
persistence_services.inject(0) do |total, svc|
|
104
|
+
st = svc.timer.elapsed
|
105
|
+
total + st
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Returns the PersistanceService to use for the given domain object obj,
|
110
|
+
# or the default service if obj is nil.
|
111
|
+
def persistence_service(obj=nil)
|
112
|
+
start_session if @session.nil?
|
113
|
+
return @def_persist_svc if obj.nil?
|
114
|
+
klass = Class === obj ? obj : obj.class
|
115
|
+
@cls_svc_hash[klass]
|
116
|
+
end
|
117
|
+
|
118
|
+
# Returns all PersistanceServices used by this database.
|
119
|
+
def persistence_services
|
120
|
+
[@def_persist_svc].to_set.merge!(@cls_svc_hash.values)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Returns the database operation elapsed real time since the last open.
|
124
|
+
def database_time
|
125
|
+
persistence_services.inject(0) do |total, svc|
|
126
|
+
st = svc.timer.elapsed
|
127
|
+
# reset the timer for the next test case
|
128
|
+
svc.timer.reset
|
129
|
+
total + st
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
# A mergeable autogenerated operation is recursively defined as:
|
134
|
+
# * a create
|
135
|
+
# * an update in the context of a mergeable autogenerated operation
|
136
|
+
#
|
137
|
+
# @return whether the innermost operation conforms to the above criterion
|
138
|
+
def mergeable_autogenerated_operation?
|
139
|
+
# the inner operation subject
|
140
|
+
inner = nil
|
141
|
+
@operations.reverse_each do |op|
|
142
|
+
if inner and op.subject != inner.owner then
|
143
|
+
# not a dependent
|
144
|
+
return false
|
145
|
+
end
|
146
|
+
if op.type == :create then
|
147
|
+
# innermost or owner create
|
148
|
+
return true
|
149
|
+
elsif op.type != :update then
|
150
|
+
# not a save
|
151
|
+
return false
|
152
|
+
end
|
153
|
+
# iterate to the scoping operation
|
154
|
+
inner = op.subject
|
155
|
+
end
|
156
|
+
false
|
157
|
+
end
|
158
|
+
|
159
|
+
alias :to_s :print_class_and_id
|
160
|
+
|
161
|
+
alias :inspect :to_s
|
162
|
+
|
163
|
+
private
|
164
|
+
|
165
|
+
## Utility classes and methods, used by Query and Store mix-ins ##
|
166
|
+
|
167
|
+
# Database CRUD operation.
|
168
|
+
class Operation
|
169
|
+
attr_reader :type, :subject, :attribute
|
170
|
+
|
171
|
+
def initialize(type, subject, attribute=nil)
|
172
|
+
@type = type
|
173
|
+
@subject = subject
|
174
|
+
@attribute = attribute
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
# Performs the operation given by the given op symbol on obj by calling the block given to this method.
|
179
|
+
# Returns the result of calling the block.
|
180
|
+
# Valid op symbols are described in {Operation#initialize}.
|
181
|
+
def perform(op, obj, attribute=nil)
|
182
|
+
op_s = op.to_s.capitalize_first
|
183
|
+
attr_s = " #{attribute}" if attribute
|
184
|
+
ctxt_s = " in context #{print_operations}" unless @operations.empty?
|
185
|
+
logger.info(">> #{op_s} #{obj.pp_s(:single_line)}#{attr_s}#{ctxt_s}...")
|
186
|
+
@operations.push(Operation.new(op, obj, attribute))
|
187
|
+
begin
|
188
|
+
# perform the operation
|
189
|
+
result = yield
|
190
|
+
ensure
|
191
|
+
# the operation is done
|
192
|
+
@operations.pop
|
193
|
+
# If this is a top-level operation, then clear the cache and transient set.
|
194
|
+
if @operations.empty? then
|
195
|
+
@cache.clear
|
196
|
+
@transients.clear
|
197
|
+
end
|
198
|
+
end
|
199
|
+
logger.info("<< Completed #{obj.qp}#{attr_s} #{op}.")
|
200
|
+
result
|
201
|
+
end
|
202
|
+
|
203
|
+
# @return [Cache] a new object cache.
|
204
|
+
def create_cache
|
205
|
+
# JRuby alert - identifier is not a stable object when fetched from the database, i.e.:
|
206
|
+
# obj.identifier.equal?(obj.identifier) #=> false
|
207
|
+
# This is probably an artifact of jRuby Numeric - Java Long conversion interaction
|
208
|
+
# combined with hash access use of the eql? method. Work-around is to make a Ruby Integer.
|
209
|
+
# the fetched object copier
|
210
|
+
copier = Proc.new do |src|
|
211
|
+
copy = src.copy
|
212
|
+
logger.debug { "Fetched #{src.qp} copied to #{copy.qp}." }
|
213
|
+
copy
|
214
|
+
end
|
215
|
+
# the fetched object cache
|
216
|
+
Cache.new(copier) do |obj|
|
217
|
+
raise ArgumentError.new("Can't cache object without identifier: #{obj}") unless obj.identifier
|
218
|
+
obj.identifier.to_s.to_i
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# Initializes the default application service.
|
223
|
+
def start_session
|
224
|
+
raise DatabaseError.new('Application user option missing') if @user.nil?
|
225
|
+
raise DatabaseError.new('Application password option missing') if @password.nil?
|
226
|
+
# caCORE alert - obtaining a caCORE session instance mysteriously depends on referencing the application service first
|
227
|
+
@def_persist_svc.app_service
|
228
|
+
@session = ClientSession.instance()
|
229
|
+
connect(@user, @password)
|
230
|
+
end
|
231
|
+
|
232
|
+
# Returns the current database operation stack as a String.
|
233
|
+
def print_operations
|
234
|
+
ops = @operations.reverse.map do |op|
|
235
|
+
attr_s = " #{op.attribute}" if op.attribute
|
236
|
+
"#{op.type.to_s.capitalize_first} #{op.subject.qp}#{attr_s}"
|
237
|
+
end
|
238
|
+
ops.qp
|
239
|
+
end
|
240
|
+
|
241
|
+
# Connects to the database.
|
242
|
+
def connect(user, password)
|
243
|
+
logger.debug { "Connecting to application server with login id #{user}..." }
|
244
|
+
begin
|
245
|
+
@session.start_session(user, password)
|
246
|
+
rescue Exception => e
|
247
|
+
logger.error("Login of #{user} unsuccessful - #{e.message}") and raise
|
248
|
+
end
|
249
|
+
logger.info("Connected to application server.")
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'caruby/util/options'
|
2
|
+
require 'caruby/util/collection'
|
3
|
+
require 'caruby/util/cache'
|
4
|
+
require 'caruby/util/pretty_print'
|
5
|
+
require 'caruby/domain/reference_visitor'
|
6
|
+
require 'caruby/database/search_template_builder'
|
7
|
+
|
8
|
+
module CaRuby
|
9
|
+
class Database
|
10
|
+
# Proc that matches fetched sources to targets.
|
11
|
+
class FetchedMatcher < Proc
|
12
|
+
# Initializes a new FetchedMatcher.
|
13
|
+
#
|
14
|
+
# @param [{Symbol => Object}, Symbol, nil] opts the match options
|
15
|
+
# @option [Boolean] opts :relaxed flag indicating whether a {Resource#minimal_match?} is
|
16
|
+
# used in the match on the fetched content
|
17
|
+
def initialize(opts=nil)
|
18
|
+
super() { |srcs, tgts| match_fetched(srcs, tgts) }
|
19
|
+
@relaxed = Options.get(:relaxed, opts)
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
# Returns a target => source match hash for the given targets and sources.
|
25
|
+
def match_fetched(sources, targets)
|
26
|
+
return Hash::EMPTY_HASH if sources.empty? or targets.empty?
|
27
|
+
logger.debug { "Matching database content #{sources.qp} to #{targets.qp}..." }
|
28
|
+
|
29
|
+
# match source => target based on the key
|
30
|
+
unmatched = Set === sources ? sources.dup : sources.to_set
|
31
|
+
matches = {}
|
32
|
+
targets.each do |tgt|
|
33
|
+
src = tgt.match_in_owner_scope(unmatched)
|
34
|
+
next unless src
|
35
|
+
matches[src] = tgt
|
36
|
+
unmatched.delete(src)
|
37
|
+
end
|
38
|
+
|
39
|
+
# match residual targets, if any, on a relaxed criterion
|
40
|
+
if @relaxed and matches.size != targets.size then
|
41
|
+
unmtchd_tgts = targets.to_set - matches.keys.delete_if { |tgt| tgt.identifier }
|
42
|
+
unmtchd_srcs = sources.to_set - matches.values
|
43
|
+
min_mtchs = match_minimal(unmtchd_srcs, unmtchd_tgts)
|
44
|
+
matches.merge!(min_mtchs)
|
45
|
+
end
|
46
|
+
|
47
|
+
logger.debug { "Matched database sources to targets #{matches.qp}." } unless matches.empty?
|
48
|
+
matches
|
49
|
+
end
|
50
|
+
|
51
|
+
#@param [<Resource>] sources the source objects to match
|
52
|
+
#@param [<Resource>] targets the potential match target objects
|
53
|
+
# @return (see #match_saved)
|
54
|
+
def match_minimal(sources, targets)
|
55
|
+
matches = {}
|
56
|
+
unmatched = Set === sources ? sources.to_set : sources.dup
|
57
|
+
targets.each do |tgt|
|
58
|
+
src = unmatched.detect { |src| tgt.minimal_match?(src) } || next
|
59
|
+
matches[src] = tgt
|
60
|
+
unmatched.delete(src)
|
61
|
+
end
|
62
|
+
matches
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,432 @@
|
|
1
|
+
require 'caruby/util/log'
|
2
|
+
require 'caruby/util/pretty_print'
|
3
|
+
require 'caruby/util/inflector'
|
4
|
+
require 'caruby/util/collection'
|
5
|
+
require 'caruby/util/validation'
|
6
|
+
|
7
|
+
module CaRuby
|
8
|
+
# The Persistable mixin adds persistance capability.
|
9
|
+
module Persistable
|
10
|
+
include Validation
|
11
|
+
|
12
|
+
# @return [LazyLoader] the loader which fetches references on demand
|
13
|
+
attr_reader :lazy_loader
|
14
|
+
|
15
|
+
# @return [{Symbol => Object}] the content value hash at the point of the last
|
16
|
+
# take_snapshot call
|
17
|
+
attr_reader :snapshot
|
18
|
+
|
19
|
+
# @return [#query, #find, #store, #create, #update, #delete] the data access mediator
|
20
|
+
# for this Persistable
|
21
|
+
# @raise [NotImplementedError] if the subclass does not define this method
|
22
|
+
def database
|
23
|
+
raise NotImplementedError.new("Database operations are not available for #{self}")
|
24
|
+
end
|
25
|
+
|
26
|
+
# Fetches the domain objects which match this template from the {#database}.
|
27
|
+
#
|
28
|
+
# @param path (see Reader#query)
|
29
|
+
# @return (see Reader#query)
|
30
|
+
# @raise (see #database)
|
31
|
+
# @raise (see Reader#query)
|
32
|
+
# @see Reader#query
|
33
|
+
def query(*path)
|
34
|
+
path.empty? ? database.query(self) : database.query(self, *path)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Fetches this domain object from the {#database}.
|
38
|
+
#
|
39
|
+
# @param opts (see Reader#find)
|
40
|
+
# @option (see Reader#find)
|
41
|
+
# @return (see Reader#find)
|
42
|
+
# @raise (see #database)
|
43
|
+
# @raise (see Reader#find)
|
44
|
+
# @see Reader#find
|
45
|
+
def find(opts=nil)
|
46
|
+
database.find(self, opts)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Creates this domain object in the {#database}.
|
50
|
+
#
|
51
|
+
# @return (see Writer#create)
|
52
|
+
# @raise (see #database)
|
53
|
+
# @raise (see Writer#create)
|
54
|
+
# @see Writer#create
|
55
|
+
def create
|
56
|
+
database.create(self)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Saves this domain object in the {#database}.
|
60
|
+
#
|
61
|
+
# @return (see Writer#save)
|
62
|
+
# @raise (see #database)
|
63
|
+
# @raise (see Writer#save)
|
64
|
+
# @see Writer#save
|
65
|
+
def save
|
66
|
+
database.save(self)
|
67
|
+
end
|
68
|
+
|
69
|
+
alias :store :save
|
70
|
+
|
71
|
+
# Updates this domain object in the {#database}.
|
72
|
+
#
|
73
|
+
# @return (see Writer#update)
|
74
|
+
# @raise (see #database)
|
75
|
+
# @raise (see Writer#update)
|
76
|
+
# @see Writer#update
|
77
|
+
def update
|
78
|
+
database.update(self)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Deletes this domain object from the {#database}.
|
82
|
+
#
|
83
|
+
# @return (see Writer#delete)
|
84
|
+
# @raise (see #database)
|
85
|
+
# @raise (see Writer#delete)
|
86
|
+
# @see Writer#delete
|
87
|
+
def delete
|
88
|
+
database.delete(self)
|
89
|
+
end
|
90
|
+
|
91
|
+
alias :== :equal?
|
92
|
+
|
93
|
+
alias :eql? :==
|
94
|
+
|
95
|
+
# Captures the Persistable's updatable attribute base values.
|
96
|
+
# The snapshot is subsequently accessible using the {#snapshot} method.
|
97
|
+
#
|
98
|
+
# @return [{Symbol => Object}] the snapshot value hash
|
99
|
+
def take_snapshot
|
100
|
+
@snapshot = value_hash(self.class.updatable_attributes)
|
101
|
+
end
|
102
|
+
|
103
|
+
# @return [Boolean] whether this Persistable has a {#snapshot}
|
104
|
+
def snapshot_taken?
|
105
|
+
not @snapshot.nil?
|
106
|
+
end
|
107
|
+
|
108
|
+
# Returns whether this Persistable either doesn't have a snapshot or has changed since the last snapshot.
|
109
|
+
# This is a conservative condition test that returns false if there is no snaphsot for this Persistable
|
110
|
+
# and therefore no basis to determine whether the content changed.
|
111
|
+
#
|
112
|
+
# @return [Boolean] whether this Persistable's content differs from its snapshot
|
113
|
+
def changed?
|
114
|
+
@snapshot.nil? or not snapshot_equal_content?
|
115
|
+
end
|
116
|
+
|
117
|
+
# @return [<Symbol>] the attributes which differ between the {#snapshot} and current content
|
118
|
+
def changed_attributes
|
119
|
+
if @snapshot then
|
120
|
+
ovh = value_hash(self.class.updatable_attributes)
|
121
|
+
@snapshot.diff(ovh).keys
|
122
|
+
else
|
123
|
+
self.class.updatable_attributes
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Lazy loads the attributes. If a block is given to this method, then the attributes are determined
|
128
|
+
# by calling the block with this Persistable as a parameter. Otherwise, the default attributes
|
129
|
+
# are the unfetched domain attributes.
|
130
|
+
#
|
131
|
+
# Each of the attributes which does not already hold a non-nil or non-empty value
|
132
|
+
# will be loaded from the database on demand.
|
133
|
+
# This method injects attribute value initialization into each loadable attribute reader.
|
134
|
+
# The initializer is given by either the loader Proc argument or the block provided
|
135
|
+
# to this method. The loader takes two arguments, the target object and the attribute to load.
|
136
|
+
# If this Persistable already has a lazy loader, then this method is a no-op.
|
137
|
+
#
|
138
|
+
# Lazy loading is disabled on an attribute after it is invoked on that attribute or when the
|
139
|
+
# attribute setter method is called.
|
140
|
+
#
|
141
|
+
# @param loader [LazyLoader] the lazy loader to add
|
142
|
+
# @yield [sources, targets] source => target matcher
|
143
|
+
# @yieldparam [<Resource>] sources the fetched domain object match candidates
|
144
|
+
# @yieldparam [<Resource>] targets the search target domain objects to match
|
145
|
+
# @raise [ValidationError] if this domain object does not have an identifier
|
146
|
+
def add_lazy_loader(loader, &matcher)
|
147
|
+
# guard against invalid call
|
148
|
+
raise ValidationError.new("Cannot add lazy loader to an unfetched domain object: #{self}") if identifier.nil?
|
149
|
+
# no-op if there is already a loader
|
150
|
+
return if @lazy_loader
|
151
|
+
|
152
|
+
# the attributes to lazy-load
|
153
|
+
attrs = self.class.loadable_attributes
|
154
|
+
return if attrs.empty?
|
155
|
+
|
156
|
+
# make the lazy loader
|
157
|
+
@lazy_loader = LazyLoader.new(self, loader, &matcher)
|
158
|
+
# define the reader and writer method overrides for the missing attributes
|
159
|
+
loaded = attrs.select { |attr| persistable__add_loader(attr) }
|
160
|
+
logger.debug { "Lazy loader added to #{qp} attributes #{loaded.to_series}." } unless loaded.empty?
|
161
|
+
end
|
162
|
+
|
163
|
+
# Returns the attributes to load on demand. The base attribute list is given by
|
164
|
+
# {ResourceAttributes#loadable_attributes}. In additon, if this Persistable has
|
165
|
+
# more than one {ResourceDependency#owner_attributes} and one is non-nil, then
|
166
|
+
# none of the owner attributes are loaded on demand, since there can be at most
|
167
|
+
# one owner and ownership cannot change.
|
168
|
+
#
|
169
|
+
# @return [<Symbol>] the attributes to load on demand
|
170
|
+
def loadable_attributes
|
171
|
+
ownr_attrs = self.class.owner_attributes
|
172
|
+
if ownr_attrs.size == 2 and ownr_attrs.detect { |attr| send(ownr_attr) } then
|
173
|
+
self.class.loadable_attributes - ownr_attrs
|
174
|
+
else
|
175
|
+
self.class.loadable_attributes
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
# Disables this Persistable's lazy loader, if one exists. If a block is given to this
|
180
|
+
# method, then the loader is only disabled while the block is executed.
|
181
|
+
#
|
182
|
+
# @yield the block to call while the loader is suspended
|
183
|
+
# @return the result of calling the block, or self if no block is given
|
184
|
+
def suspend_lazy_loader
|
185
|
+
unless @lazy_loader and @lazy_loader.enabled? then
|
186
|
+
return block_given? ? yield : self
|
187
|
+
end
|
188
|
+
@lazy_loader.disable
|
189
|
+
return self unless block_given?
|
190
|
+
begin
|
191
|
+
yield
|
192
|
+
ensure
|
193
|
+
@lazy_loader.enable
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
# Enables this Persistable's lazy loader, if one exists. If a block is given to this
|
198
|
+
# method, then the loader is only enabled while the block is executed.
|
199
|
+
#
|
200
|
+
# @yield the block to call while the loader is enabled
|
201
|
+
# @return the result of calling the block, or self if no block is given
|
202
|
+
def resume_lazy_loader
|
203
|
+
unless @lazy_loader and @lazy_loader.disabled? then
|
204
|
+
return block_given? ? yield : self
|
205
|
+
end
|
206
|
+
@lazy_loader.enable
|
207
|
+
return self unless block_given?
|
208
|
+
begin
|
209
|
+
yield
|
210
|
+
ensure
|
211
|
+
@lazy_loader.disable
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Disables lazy loading of the specified attribute. Lazy loaded is disabled for all attributes
|
216
|
+
# if no attribute is specified. This method is a no-op if this Persistable does not have a lazy
|
217
|
+
# loader.
|
218
|
+
#
|
219
|
+
# @param [Symbol] the attribute to remove from the load list, or nil if to remove all attributes
|
220
|
+
def remove_lazy_loader(attribute=nil)
|
221
|
+
return if @lazy_loader.nil?
|
222
|
+
if attribute.nil? then
|
223
|
+
self.class.domain_attributes.each { |attr| remove_lazy_loader(attr) }
|
224
|
+
@lazy_loader = nil
|
225
|
+
return
|
226
|
+
end
|
227
|
+
|
228
|
+
# the modified accessor method
|
229
|
+
reader, writer = self.class.attribute_metadata(attribute).accessors
|
230
|
+
# remove the reader override
|
231
|
+
disable_singleton_method(reader)
|
232
|
+
# remove the writer override
|
233
|
+
disable_singleton_method(writer)
|
234
|
+
end
|
235
|
+
|
236
|
+
# Returns whether this domain object must be fetched to reflect the database state.
|
237
|
+
# This default implementation returns whether there are any autogenerated attributes.
|
238
|
+
# Subclasses can override with more restrictive conditions.
|
239
|
+
#
|
240
|
+
# caBIG alert - the auto-generated criterion is a sufficient but not necessary condition
|
241
|
+
# to determine whether a save caCORE result does not necessarily accurately reflect the
|
242
|
+
# database state. Examples:
|
243
|
+
# * caTissue SCG name is auto-generated on SCG create but not SCG update.
|
244
|
+
# * caTissue SCG event parameters are not auto-generated on SCG create if the SCG collection
|
245
|
+
# status is Pending, but are auto-generated on SCG update if the SCG status is changed
|
246
|
+
# to Complete.
|
247
|
+
# The caBIG application can override this method in a Database subclass to fine-tune the
|
248
|
+
# fetch criteria. Adding a more restrictive {#fetch_saved?} condition will will improve
|
249
|
+
# performance but not change functionality.
|
250
|
+
#
|
251
|
+
# @return [Boolean] whether this domain object must be fetched to reflect the database state
|
252
|
+
def fetch_saved?
|
253
|
+
not self.class.autogenerated_attributes.empty?
|
254
|
+
end
|
255
|
+
|
256
|
+
# Sets the {ResourceAttributes#volatile_nondomain_attributes} to the other fetched value,
|
257
|
+
# if different.
|
258
|
+
#
|
259
|
+
# @param [Resource] other the fetched domain object reflecting the database state
|
260
|
+
def copy_volatile_attributes(other)
|
261
|
+
self.class.volatile_nondomain_attributes.each do |attr|
|
262
|
+
val = send(attr)
|
263
|
+
oval = other.send(attr)
|
264
|
+
# set the attribute to the other value if it differs from the current value
|
265
|
+
unless oval == val then
|
266
|
+
# if this error occurs, then there is a serious match-merge flaw
|
267
|
+
if val and attr == :identifier then
|
268
|
+
raise DatabaseError.new("Can't copy #{other} to #{self} with different identifier")
|
269
|
+
end
|
270
|
+
# overwrite the current attribute value
|
271
|
+
set_attribute(attr, oval)
|
272
|
+
logger.debug { "Set #{qp} volatile #{attr} to the fetched #{other.qp} database value #{oval.qp}." }
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
private
|
278
|
+
|
279
|
+
# Returns whether the {#snapshot} and current content are equal.
|
280
|
+
# The attribute values _v_ and _ov_ of the snapshot and current content, resp., are
|
281
|
+
# compared with equality determined by {Resource.value_equal?}.
|
282
|
+
#
|
283
|
+
# @return [Boolean] whether the {#snapshot} and current content are equal
|
284
|
+
def snapshot_equal_content?
|
285
|
+
vh = @snapshot
|
286
|
+
ovh = value_hash(self.class.updatable_attributes)
|
287
|
+
if vh.size < ovh.size then
|
288
|
+
attr, oval = ovh.detect { |a, v| not vh.has_key?(a) }
|
289
|
+
logger.debug { "#{qp} is missing snapshot #{attr} compared to the current value #{oval.qp}." }
|
290
|
+
false
|
291
|
+
elsif vh.size > ovh.size then
|
292
|
+
attr, value = vh.detect { |a, v| not ovh.has_key?(a) }
|
293
|
+
logger.debug { "#{qp} has snapshot #{attr} value #{value.qp} not found in current content." }
|
294
|
+
false
|
295
|
+
else
|
296
|
+
vh.all? do |attr, value|
|
297
|
+
oval = ovh[attr]
|
298
|
+
eq = Resource.value_equal?(oval, value)
|
299
|
+
unless eq then
|
300
|
+
logger.debug { "#{qp} #{attr} snapshot value #{value.qp} differs from the current value #{oval.qp}." }
|
301
|
+
end
|
302
|
+
eq
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
# Adds this Persistable lazy loader to the given attribute unless the attribute already holds a fetched reference.
|
308
|
+
# Returns the loader if the loader was added to attribute.
|
309
|
+
def persistable__add_loader(attribute)
|
310
|
+
# bail if there is already a fetched reference
|
311
|
+
return if send(attribute).to_enum.any? { |ref| ref.identifier }
|
312
|
+
|
313
|
+
# the accessor methods to modify
|
314
|
+
attr_md = self.class.attribute_metadata(attribute)
|
315
|
+
reader, writer = attr_md.accessors
|
316
|
+
raise NotImplementedError.new("Missing writer method for #{self.class.qp} attribute #{attribute}") if writer.nil?
|
317
|
+
|
318
|
+
# define the singleton attribute reader method
|
319
|
+
instance_eval "def #{reader}; @lazy_loader ? persistable__load_reference(:#{attribute}) : super; end"
|
320
|
+
# define the singleton attribute writer method
|
321
|
+
instance_eval "def #{writer}(value); remove_lazy_loader(:#{attribute}); super; end"
|
322
|
+
|
323
|
+
@lazy_loader
|
324
|
+
end
|
325
|
+
|
326
|
+
# Loads the reference attribute database value into this Persistable.
|
327
|
+
#
|
328
|
+
# @param [Symbol] attribute the attribute to load
|
329
|
+
# @return the attribute value merged from the database value
|
330
|
+
def persistable__load_reference(attribute)
|
331
|
+
attr_md = self.class.attribute_metadata(attribute)
|
332
|
+
# bypass the singleton method and call the class instance method if the lazy loader is disabled
|
333
|
+
unless @lazy_loader.enabled? then
|
334
|
+
# the modified accessor method
|
335
|
+
reader, writer = attr_md.accessors
|
336
|
+
return self.class.instance_method(reader).bind(self).call
|
337
|
+
end
|
338
|
+
|
339
|
+
# Disable lazy loading first for the attribute, since the reader method might be called in
|
340
|
+
# the sequel, resulting in an infinite loop when the lazy loader is retriggered.
|
341
|
+
remove_lazy_loader(attribute)
|
342
|
+
logger.debug { "Lazy-loading #{qp} #{attribute}..." }
|
343
|
+
# the current value
|
344
|
+
oldval = send(attribute)
|
345
|
+
# load the fetched value
|
346
|
+
fetched = @lazy_loader.load(attribute)
|
347
|
+
# nothing to do if nothing fetched
|
348
|
+
return oldval if fetched.nil_or_empty?
|
349
|
+
|
350
|
+
# merge the fetched into the attribute
|
351
|
+
logger.debug { "Merging #{qp} fetched #{attribute} value #{fetched.qp}#{' into ' + oldval.qp if oldval}..." }
|
352
|
+
matcher = @lazy_loader.matcher
|
353
|
+
merged = merge_attribute_value(attribute, oldval, fetched, &matcher)
|
354
|
+
# update the snapshot of dependents
|
355
|
+
if attr_md.dependent? then
|
356
|
+
# the owner attribute
|
357
|
+
oattr = attr_md.inverse
|
358
|
+
if oattr then
|
359
|
+
# update dependent snapshot with the owner, since the owner snapshot is taken when fetched but the
|
360
|
+
# owner might be set when the fetched dependent is merged into the owner dependent attribute.
|
361
|
+
merged.enumerate do |dep|
|
362
|
+
if dep.snapshot_taken? then
|
363
|
+
dep.snapshot[oattr] = self
|
364
|
+
logger.debug { "Updated #{qp} #{attribute} fetched dependent #{dep.qp} snapshot with #{oattr} value #{qp}." }
|
365
|
+
end
|
366
|
+
end
|
367
|
+
end
|
368
|
+
end
|
369
|
+
merged
|
370
|
+
end
|
371
|
+
|
372
|
+
# Disables the given singleton attribute accessor method.
|
373
|
+
#
|
374
|
+
# @param [String, Symbol] name_or_sym the accessor method to disable
|
375
|
+
def disable_singleton_method(name_or_sym)
|
376
|
+
return unless singleton_methods.include?(name_or_sym.to_s)
|
377
|
+
# dissociate the method from this instance
|
378
|
+
method = self.method(name_or_sym.to_sym)
|
379
|
+
method.unbind
|
380
|
+
# JRuby alert - Unbind doesn't work in JRuby 1.1.6. In that case, redefine the singleton method to delegate
|
381
|
+
# to the class instance method.
|
382
|
+
if singleton_methods.include?(name_or_sym.to_s) then
|
383
|
+
args = (1..method.arity).map { |argnum| "arg#{argnum}" }.join(', ')
|
384
|
+
instance_eval "def #{name_or_sym}(#{args}); super; end"
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
class LazyLoader
|
389
|
+
# @return [Proc] the source => target matcher
|
390
|
+
attr_reader :matcher
|
391
|
+
|
392
|
+
# Creates a new LazyLoader which calls the loader Proc on the subject.
|
393
|
+
#
|
394
|
+
# @raise [ArgumentError] if the loader is not given to this initializer
|
395
|
+
def initialize(subject, loader=nil, &matcher)
|
396
|
+
@subject = subject
|
397
|
+
# the loader proc from either the argument or the block
|
398
|
+
@loader = loader
|
399
|
+
@matcher = matcher
|
400
|
+
raise ArgumentError.new("Neither a loader nor a block is given to the LazyLoader initializer") if @loader.nil?
|
401
|
+
@enabled = true
|
402
|
+
end
|
403
|
+
|
404
|
+
# Returns whether this loader is enabled.
|
405
|
+
def enabled?
|
406
|
+
@enabled
|
407
|
+
end
|
408
|
+
|
409
|
+
# Returns whether this loader is disabled.
|
410
|
+
def disabled?
|
411
|
+
not @enabled
|
412
|
+
end
|
413
|
+
|
414
|
+
# Disable this loader.
|
415
|
+
def disable
|
416
|
+
@enabled = false
|
417
|
+
end
|
418
|
+
|
419
|
+
# Enables this loader.
|
420
|
+
def enable
|
421
|
+
@enabled = true
|
422
|
+
end
|
423
|
+
|
424
|
+
# Returns the attribute value loaded from the database.
|
425
|
+
# Raises DatabaseError if this loader is disabled.
|
426
|
+
def load(attribute)
|
427
|
+
raise DatabaseError.new("#{qp} lazy load called on disabled loader") unless enabled?
|
428
|
+
@loader.call(@subject, attribute)
|
429
|
+
end
|
430
|
+
end
|
431
|
+
end
|
432
|
+
end
|