-
Notifications
You must be signed in to change notification settings - Fork 43
/
activerecord_wrapper.rb
executable file
·217 lines (187 loc) · 6.91 KB
/
activerecord_wrapper.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
require 'active_record'
module OAI::Provider
# This class wraps an ActiveRecord model and delegates all of the record
# selection/retrieval to the AR model. It accepts options for specifying
# the update timestamp field, a timeout, and a limit. The limit option
# is used for doing pagination with resumption tokens. The
# expiration timeout is ignored, since all necessary information is
# encoded in the token.
#
class ActiveRecordWrapper < Model
attr_reader :model, :timestamp_field, :identifier_field
# If custom 'timestamp_field' is used, be aware this will be an ActiveRecord
# attribute that we will limit on, so perhaps should be indexe appropriately.
#
# If custom `identifier_field` is used, be aware this will be an ActiveRecord
# attribute that we will sort on, and use in WHERE clauses with `=` as well as
# greater than/less than, so should be indexed appropriately.
def initialize(model, options={})
@model = model
@timestamp_field = options.delete(:timestamp_field) || 'updated_at'
@identifier_field = options.delete(:identifier_field) || model.primary_key || "id"
@limit = options.delete(:limit) || 100
unless options.empty?
raise ArgumentError.new(
"Unsupported options [#{options.keys.join(', ')}]"
)
end
end
def earliest
earliest_obj = model.order("#{model.base_class.table_name}.#{timestamp_field} asc").first
earliest_obj.nil? ? Time.at(0) : earliest_obj.send(timestamp_field)
end
def latest
latest_obj = model.order("#{model.base_class.table_name}.#{timestamp_field} desc").first
latest_obj.nil? ? Time.now : latest_obj.send(timestamp_field)
end
# A model class is expected to provide a method Model.sets that
# returns all the sets the model supports. See the
# activerecord_provider tests for an example.
def sets
model.sets if model.respond_to?(:sets)
end
def find(selector, options={})
find_scope = find_scope(options)
return next_set(find_scope,
options[:resumption_token]) if options[:resumption_token]
conditions = sql_conditions(options)
if :all == selector
total = find_scope.where(conditions).count
if @limit && total > @limit
select_partial(find_scope,
ResumptionToken.new(options.merge({:last => 0})))
else
find_scope.where(conditions)
end
else
find_scope.where(conditions).where(identifier_field => selector).first
end
end
def deleted?(record)
if record.respond_to?(:deleted_at)
return record.deleted_at
elsif record.respond_to?(:deleted)
return record.deleted
end
false
end
def respond_to?(m, *args)
if m =~ /^map_/
model.respond_to?(m, *args)
else
super
end
end
def method_missing(m, *args, &block)
if m =~ /^map_/
model.send(m, *args, &block)
else
super
end
end
protected
def find_scope(options)
return model unless options.key?(:set)
# Find the set or return an empty scope
set = find_set_by_spec(options[:set])
return model.limit(0) if set.nil?
# If the set has a backward relationship, we'll use it
if set.class.respond_to?(:reflect_on_all_associations)
set.class.reflect_on_all_associations.each do |assoc|
return set.send(assoc.name) if assoc.klass == model
end
end
# Search the attributes for 'set'
if model.column_names.include?('set')
# Scope using the set attribute as the spec
model.where(set: options[:set])
else
# Default to empty set, as we've tried everything else
model.none
end
end
def find_set_by_spec(spec)
if sets.class == ActiveRecord::Relation
sets.find_by_spec(spec)
else
sets.detect {|set| set.spec == spec}
end
end
# Request the next set in this sequence.
def next_set(find_scope, token_string)
raise OAI::ResumptionTokenException.new unless @limit
token = ResumptionToken.parse(token_string)
select_partial(find_scope, token)
end
# select a subset of the result set, and return it with a
# resumption token to get the next subset
def select_partial(find_scope, token)
records = find_scope.where(token_conditions(token))
.limit(@limit)
.order("#{model.base_class.table_name}.#{identifier_field} asc")
raise OAI::ResumptionTokenException.new unless records
total = find_scope.where(token_conditions(token)).count
# token offset should be nil if this is the last set
offset = (@limit >= total) ? nil : records.last.send(identifier_field)
PartialResult.new(records, token.next(offset))
end
# build a sql conditions statement from the content
# of a resumption token. It is very important not to
# miss any changes as records may change scope as the
# harvest is in progress. To avoid loosing any changes
# the last 'id' of the previous set is used as the
# filter to the next set.
def token_conditions(token)
last_id = token.last_str
sql = sql_conditions token.to_conditions_hash
return sql if "0" == last_id
# Now add last id constraint
sql.first << " AND #{model.base_class.table_name}.#{identifier_field} > :id"
sql.last[:id] = last_id
return sql
end
# build a sql conditions statement from an OAI options hash
def sql_conditions(opts)
sql = []
esc_values = {}
if opts.has_key?(:from)
sql << "#{model.base_class.table_name}.#{timestamp_field} >= :from"
esc_values[:from] = parse_to_local(opts[:from])
end
if opts.has_key?(:until)
# Handle databases which store fractions of a second by rounding up
sql << "#{model.base_class.table_name}.#{timestamp_field} < :until"
esc_values[:until] = parse_to_local(opts[:until]) { |t| t + 1 }
end
return [sql.join(" AND "), esc_values]
end
private
def parse_to_local(time)
if time.respond_to?(:strftime)
time_obj = time
else
begin
if time[-1] == "Z"
time_obj = Time.strptime(time, "%Y-%m-%dT%H:%M:%S%Z")
else
time_obj = Date.strptime(time, "%Y-%m-%d")
end
rescue
raise OAI::ArgumentException.new, "unparsable date: '#{time}'"
end
end
time_obj = yield(time_obj) if block_given?
if time_obj.kind_of?(Date)
time_obj.strftime("%Y-%m-%d")
else
# Convert to same as DB - :local => :getlocal, :utc => :getutc
if ActiveRecord::VERSION::MAJOR >= 7
tzconv = "get#{ActiveRecord.default_timezone.to_s}".to_sym
else
tzconv = "get#{model.default_timezone.to_s}".to_sym
end
time_obj.send(tzconv).strftime("%Y-%m-%d %H:%M:%S")
end
end
end
end