oai 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -4
  3. data/Rakefile +7 -0
  4. data/bin/oai +0 -2
  5. data/examples/models/file_model.rb +2 -2
  6. data/lib/oai/client/response.rb +8 -8
  7. data/lib/oai/client.rb +34 -10
  8. data/lib/oai/exception.rb +46 -38
  9. data/lib/oai/harvester/config.rb +1 -1
  10. data/lib/oai/harvester/harvest.rb +37 -25
  11. data/lib/oai/harvester/logging.rb +3 -5
  12. data/lib/oai/harvester.rb +4 -1
  13. data/lib/oai/provider/model/activerecord_caching_wrapper.rb +5 -8
  14. data/lib/oai/provider/model/activerecord_wrapper.rb +41 -25
  15. data/lib/oai/provider/model.rb +1 -1
  16. data/lib/oai/provider/response/list_records.rb +12 -0
  17. data/lib/oai/provider/response.rb +7 -4
  18. data/lib/oai/provider/resumption_token.rb +70 -21
  19. data/lib/oai/provider.rb +129 -7
  20. data/test/activerecord_provider/database/0001_oaipmh_tables.rb +7 -1
  21. data/test/activerecord_provider/helpers/providers.rb +10 -1
  22. data/test/activerecord_provider/helpers/transactional_test_case.rb +2 -1
  23. data/test/activerecord_provider/models/dc_field.rb +8 -0
  24. data/test/activerecord_provider/models/dc_lang.rb +3 -0
  25. data/test/activerecord_provider/models/exclusive_set_dc_field.rb +6 -0
  26. data/test/activerecord_provider/tc_activerecord_wrapper.rb +63 -0
  27. data/test/activerecord_provider/tc_ar_provider.rb +54 -26
  28. data/test/activerecord_provider/tc_ar_sets_provider.rb +10 -9
  29. data/test/activerecord_provider/tc_caching_paging_provider.rb +9 -7
  30. data/test/activerecord_provider/tc_simple_paging_provider.rb +28 -7
  31. data/test/client/tc_exception.rb +1 -1
  32. data/test/client/tc_get_record.rb +1 -1
  33. data/test/client/tc_http_client.rb +2 -2
  34. data/test/client/tc_libxml.rb +1 -1
  35. data/test/client/tc_utf8_escaping.rb +8 -1
  36. data/test/harvester/tc_harvest.rb +42 -0
  37. data/test/harvester/test_helper_harvester.rb +6 -0
  38. data/test/provider/models.rb +3 -3
  39. data/test/provider/tc_functional_tokens.rb +17 -11
  40. data/test/provider/tc_instance_provider.rb +41 -0
  41. data/test/provider/tc_provider.rb +26 -0
  42. data/test/provider/tc_resumption_tokens.rb +6 -0
  43. data/test/provider/test_helper_provider.rb +17 -0
  44. metadata +28 -17
@@ -32,12 +32,12 @@ module OAI::Provider
32
32
  end
33
33
 
34
34
  def earliest
35
- earliest_obj = model.order("#{timestamp_field} asc").first
35
+ earliest_obj = model.order("#{model.base_class.table_name}.#{timestamp_field} asc").first
36
36
  earliest_obj.nil? ? Time.at(0) : earliest_obj.send(timestamp_field)
37
37
  end
38
38
 
39
39
  def latest
40
- latest_obj = model.order("#{timestamp_field} desc").first
40
+ latest_obj = model.order("#{model.base_class.table_name}.#{timestamp_field} desc").first
41
41
  latest_obj.nil? ? Time.now : latest_obj.send(timestamp_field)
42
42
  end
43
43
  # A model class is expected to provide a method Model.sets that
@@ -61,7 +61,7 @@ module OAI::Provider
61
61
  find_scope.where(conditions)
62
62
  end
63
63
  else
64
- find_scope.where(conditions).find_by!(identifier_field => selector)
64
+ find_scope.where(conditions).where(identifier_field => selector).first
65
65
  end
66
66
  end
67
67
 
@@ -129,15 +129,7 @@ module OAI::Provider
129
129
  raise OAI::ResumptionTokenException.new unless @limit
130
130
 
131
131
  token = ResumptionToken.parse(token_string)
132
- total = find_scope.where(token_conditions(token)).count
133
-
134
- if @limit < total
135
- select_partial(find_scope, token)
136
- else # end of result set
137
- find_scope.where(token_conditions(token))
138
- .limit(@limit)
139
- .order("#{identifier_field} asc")
140
- end
132
+ select_partial(find_scope, token)
141
133
  end
142
134
 
143
135
  # select a subset of the result set, and return it with a
@@ -145,10 +137,12 @@ module OAI::Provider
145
137
  def select_partial(find_scope, token)
146
138
  records = find_scope.where(token_conditions(token))
147
139
  .limit(@limit)
148
- .order("#{identifier_field} asc")
140
+ .order("#{model.base_class.table_name}.#{identifier_field} asc")
149
141
  raise OAI::ResumptionTokenException.new unless records
150
- offset = records.last.send(identifier_field)
151
142
 
143
+ total = find_scope.where(token_conditions(token)).count
144
+ # token offset should be nil if this is the last set
145
+ offset = (@limit >= total) ? nil : records.last.send(identifier_field)
152
146
  PartialResult.new(records, token.next(offset))
153
147
  end
154
148
 
@@ -159,13 +153,13 @@ module OAI::Provider
159
153
  # the last 'id' of the previous set is used as the
160
154
  # filter to the next set.
161
155
  def token_conditions(token)
162
- last = token.last
156
+ last_id = token.last_str
163
157
  sql = sql_conditions token.to_conditions_hash
164
158
 
165
- return sql if 0 == last
159
+ return sql if "0" == last_id
166
160
  # Now add last id constraint
167
- sql.first << " AND #{identifier_field} > :id"
168
- sql.last[:id] = last
161
+ sql.first << " AND #{model.base_class.table_name}.#{identifier_field} > :id"
162
+ sql.last[:id] = last_id
169
163
 
170
164
  return sql
171
165
  end
@@ -175,27 +169,49 @@ module OAI::Provider
175
169
  sql = []
176
170
  esc_values = {}
177
171
  if opts.has_key?(:from)
178
- sql << "#{timestamp_field} >= :from"
172
+ sql << "#{model.base_class.table_name}.#{timestamp_field} >= :from"
179
173
  esc_values[:from] = parse_to_local(opts[:from])
180
174
  end
181
175
  if opts.has_key?(:until)
182
176
  # Handle databases which store fractions of a second by rounding up
183
- sql << "#{timestamp_field} < :until"
177
+ sql << "#{model.base_class.table_name}.#{timestamp_field} < :until"
184
178
  esc_values[:until] = parse_to_local(opts[:until]) { |t| t + 1 }
185
179
  end
180
+
186
181
  return [sql.join(" AND "), esc_values]
187
182
  end
188
183
 
189
184
  private
190
185
 
191
186
  def parse_to_local(time)
192
- time_obj = Time.parse(time.to_s)
187
+ if time.respond_to?(:strftime)
188
+ time_obj = time
189
+ else
190
+ begin
191
+ if time[-1] == "Z"
192
+ time_obj = Time.strptime(time, "%Y-%m-%dT%H:%M:%S%Z")
193
+ else
194
+ time_obj = Date.strptime(time, "%Y-%m-%d")
195
+ end
196
+ rescue
197
+ raise OAI::ArgumentException.new, "unparsable date: '#{time}'"
198
+ end
199
+ end
200
+
193
201
  time_obj = yield(time_obj) if block_given?
194
- # Convert to same as DB - :local => :getlocal, :utc => :getutc
195
- tzconv = "get#{model.default_timezone.to_s}".to_sym
196
- time_obj.send(tzconv).strftime("%Y-%m-%d %H:%M:%S")
202
+
203
+ if time_obj.kind_of?(Date)
204
+ time_obj.strftime("%Y-%m-%d")
205
+ else
206
+ # Convert to same as DB - :local => :getlocal, :utc => :getutc
207
+ if ActiveRecord::VERSION::MAJOR >= 7
208
+ tzconv = "get#{ActiveRecord.default_timezone.to_s}".to_sym
209
+ else
210
+ tzconv = "get#{model.default_timezone.to_s}".to_sym
211
+ end
212
+ time_obj.send(tzconv).strftime("%Y-%m-%d %H:%M:%S")
213
+ end
197
214
  end
198
215
 
199
216
  end
200
217
  end
201
-
@@ -29,7 +29,7 @@ module OAI::Provider
29
29
  # see the ResumptionToken class for more details.
30
30
  #
31
31
  class Model
32
- attr_reader :timestamp_field, :identifier_field
32
+ attr_reader :timestamp_field, :identifier_field, :limit
33
33
 
34
34
  def initialize(limit = nil, timestamp_field = 'updated_at', identifier_field = 'id')
35
35
  @limit = limit
@@ -2,6 +2,18 @@ module OAI::Provider::Response
2
2
 
3
3
  class ListRecords < RecordResponse
4
4
  required_parameters :metadata_prefix
5
+
6
+ def valid?
7
+ super && matching_granularity?
8
+ end
9
+
10
+ def matching_granularity?
11
+ if options[:from].nil? == false && options[:until].nil? == false && options[:from].class.name != options[:until].class.name
12
+ raise OAI::ArgumentException.new, "The 'from' and 'until' options specified must have the same granularity"
13
+ else
14
+ true
15
+ end
16
+ end
5
17
 
6
18
  def to_xml
7
19
  result = provider.model.find(:all, options)
@@ -90,10 +90,13 @@ module OAI
90
90
 
91
91
  def parse_date(value)
92
92
  return value if value.respond_to?(:strftime)
93
-
94
- Date.parse(value) # This will raise an exception for badly formatted dates
95
- Time.parse(value).utc # -- UTC Bug fix hack 8/08 not in core
96
- rescue
93
+
94
+ if value[-1] == "Z"
95
+ Time.strptime(value, "%Y-%m-%dT%H:%M:%S%Z").utc
96
+ else
97
+ Date.strptime(value, "%Y-%m-%d")
98
+ end
99
+ rescue ArgumentError => e
97
100
  raise OAI::ArgumentException.new, "unparsable date: '#{value}'"
98
101
  end
99
102
 
@@ -1,5 +1,4 @@
1
1
  require 'time'
2
- require 'enumerator'
3
2
  require File.dirname(__FILE__) + "/partial_result"
4
3
 
5
4
  module OAI::Provider
@@ -8,16 +7,41 @@ module OAI::Provider
8
7
  # The ResumptionToken class forms the basis of paging query results. It
9
8
  # provides several helper methods for dealing with resumption tokens.
10
9
  #
10
+ # OAI-PMH spec does not specify anything about resumptionToken format, they can
11
+ # be purely opaque tokens.
12
+ #
13
+ # Our implementation however encodes everything needed to construct the next page
14
+ # inside the resumption token.
15
+ #
16
+ # == The 'last' component: offset or ID/pk to resume from
17
+ #
18
+ # The `#last` component is an offset or ID to resume from. In the case of it being
19
+ # an ID to resume from, this assumes that ID's are sortable and results are returned
20
+ # in ID order, so that the 'last' ID can be used as the place to resume from.
21
+ #
22
+ # Originally it was assumed that #last was always an integer, but since existing
23
+ # implementations (like ActiveRecordWrapper) used it as an ID, and identifiers and
24
+ # primary keys are _not_ always integers (can be UUID etc), we have expanded to allow
25
+ # any string value.
26
+ #
27
+ # However, for backwards compatibility #last always returns an integer (sometimes 0 if
28
+ # actual last component is not an integer), and #last_str returns the full string version.
29
+ # Trying to change #last itself to be string broke a lot of existing code in this gem
30
+ # in mysterious ways.
31
+ #
32
+ # Also beware that in some cases the value 0/"0" seems to be a special value used
33
+ # to signify some special case. A lot of "code archeology" going on here after significant
34
+ # period of no maintenance to this gem.
11
35
  class ResumptionToken
12
- attr_reader :prefix, :set, :from, :until, :last, :expiration, :total
36
+ attr_reader :prefix, :set, :from, :until, :last, :last_str, :expiration, :total
13
37
 
14
38
  # parses a token string and returns a ResumptionToken
15
- def self.parse(token_string)
39
+ def self.parse(token_string, expiration = nil, total = nil)
16
40
  begin
17
41
  options = {}
18
- matches = /(.+):(\d+)$/.match(token_string)
19
- options[:last] = matches.captures[1].to_i
20
-
42
+ matches = /(.+):([^ :]+)$/.match(token_string)
43
+ options[:last] = matches.captures[1]
44
+
21
45
  parts = matches.captures[0].split('.')
22
46
  options[:metadata_prefix] = parts.shift
23
47
  parts.each do |part|
@@ -30,12 +54,12 @@ module OAI::Provider
30
54
  options[:until] = Time.parse(part.sub(/^u\(/, '').sub(/\)$/, '')).localtime
31
55
  end
32
56
  end
33
- self.new(options)
57
+ self.new(options, expiration, total)
34
58
  rescue => err
35
59
  raise OAI::ResumptionTokenException.new
36
60
  end
37
61
  end
38
-
62
+
39
63
  # extracts the metadata prefix from a token string
40
64
  def self.extract_format(token_string)
41
65
  return token_string.split('.')[0]
@@ -44,32 +68,32 @@ module OAI::Provider
44
68
  def initialize(options, expiration = nil, total = nil)
45
69
  @prefix = options[:metadata_prefix]
46
70
  @set = options[:set]
47
- @last = options[:last]
71
+ self.last = options[:last]
48
72
  @from = options[:from] if options[:from]
49
73
  @until = options[:until] if options[:until]
50
74
  @expiration = expiration if expiration
51
75
  @total = total if total
52
76
  end
53
-
77
+
54
78
  # convenience method for setting the offset of the next set of results
55
79
  def next(last)
56
- @last = last
80
+ self.last = last
57
81
  self
58
82
  end
59
-
83
+
60
84
  def ==(other)
61
85
  prefix == other.prefix and set == other.set and from == other.from and
62
- self.until == other.until and last == other.last and
86
+ self.until == other.until and last == other.last and
63
87
  expiration == other.expiration and total == other.total
64
88
  end
65
-
89
+
66
90
  # output an xml resumption token
67
91
  def to_xml
68
92
  xml = Builder::XmlMarkup.new
69
93
  xml.resumptionToken(encode_conditions, hash_of_attributes)
70
94
  xml.target!
71
95
  end
72
-
96
+
73
97
  # return a hash containing just the model selection parameters
74
98
  def to_conditions_hash
75
99
  conditions = {:metadata_prefix => self.prefix }
@@ -78,20 +102,45 @@ module OAI::Provider
78
102
  conditions[:until] = self.until if self.until
79
103
  conditions
80
104
  end
81
-
82
- # return the a string representation of the token minus the offset
105
+
106
+ # return the a string representation of the token minus the offset/ID
107
+ #
108
+ # Q: Why does it eliminate the offset/id "last" on the end? Doesn't fully
109
+ # represent state without it, which is confusing. Not sure, but
110
+ # other code seems to rely on it, tests break if not.
83
111
  def to_s
84
112
  encode_conditions.gsub(/:\w+?$/, '')
85
113
  end
86
114
 
87
115
  private
88
-
116
+
117
+ # take care of our logic to store an integer and a str version, for backwards
118
+ # compat where it was assumed to be an integer, as well as supporting string.
119
+ def last=(value)
120
+ @last = value.to_i
121
+ @last_str = value.to_s
122
+ end
123
+
89
124
  def encode_conditions
125
+ return "" if last_str.nil? || last_str.to_s.strip.eql?("")
126
+
90
127
  encoded_token = @prefix.to_s.dup
91
128
  encoded_token << ".s(#{set})" if set
92
- encoded_token << ".f(#{self.from.utc.xmlschema})" if self.from
93
- encoded_token << ".u(#{self.until.utc.xmlschema})" if self.until
94
- encoded_token << ":#{last}"
129
+ if self.from
130
+ if self.from.respond_to?(:utc)
131
+ encoded_token << ".f(#{self.from.utc.xmlschema})"
132
+ else
133
+ encoded_token << ".f(#{self.from.xmlschema})"
134
+ end
135
+ end
136
+ if self.until
137
+ if self.until.respond_to?(:utc)
138
+ encoded_token << ".u(#{self.until.utc.xmlschema})"
139
+ else
140
+ encoded_token << ".u(#{self.until.xmlschema})"
141
+ end
142
+ end
143
+ encoded_token << ":#{last_str}"
95
144
  end
96
145
 
97
146
  def hash_of_attributes
data/lib/oai/provider.rb CHANGED
@@ -139,6 +139,35 @@ end
139
139
  #
140
140
  # Special thanks to Jose Hales-Garcia for this solution.
141
141
  #
142
+ # ### Leverage the Provider instance
143
+ #
144
+ # The traditional implementation of the OAI::Provider would pass the OAI::Provider
145
+ # class to the different resposnes. This made it hard to inject context into a
146
+ # common provider. Consider that we might have different request headers that
147
+ # change the scope of the OAI::Provider queries.
148
+ #
149
+ # ```ruby
150
+ # class InstanceProvider
151
+ # def initialize(options = {})
152
+ # super({ :provider_context => :instance_based })
153
+ # @controller = options.fetch(:controller)
154
+ # end
155
+ # attr_reader :controller
156
+ # end
157
+ #
158
+ # class OaiController < ApplicationController
159
+ # def index
160
+ # provider = InstanceProvider.new({ :controller => self })
161
+ # request_body = provider.process_request(oai_params.to_h)
162
+ # render :body => request_body, :content_type => 'text/xml'
163
+ # end
164
+ # ```
165
+ #
166
+ # In the above example, the underlying response object will now receive an
167
+ # instance of the InstanceProvider. Without the `super({ :provider_context => :instance_based })`
168
+ # the response objects would have received the class InstanceProvider as the
169
+ # given provider.
170
+ #
142
171
  # ## Supporting custom metadata formats
143
172
  #
144
173
  # See {OAI::MetadataFormat} for details.
@@ -292,39 +321,132 @@ module OAI::Provider
292
321
 
293
322
  Base.register_format(OAI::Provider::Metadata::DublinCore.instance)
294
323
 
324
+ PROVIDER_CONTEXTS = {
325
+ :class_based => :class_based,
326
+ :instance_based => :instance_based
327
+ }
328
+
329
+ def initialize(options = {})
330
+ provider_context = options.fetch(:provider_context) { :class_based }
331
+ @provider_context = PROVIDER_CONTEXTS.fetch(provider_context)
332
+ end
333
+
334
+ # @note These are the accessor methods on the class. If you need to overwrite
335
+ # them on the instance level you can do that. However, an instance of this
336
+ # class won't be used unless you initialize with:
337
+ # { :provider_context => :instance_based }
338
+ attr_writer :name, :url, :prefix, :email, :delete_support, :granularity, :model, :identifier, :description
339
+
340
+ # The traditional interaction of a Provider has been to:
341
+ #
342
+ # 1) Assign attributes to the Provider class
343
+ # 2) Instantiate the Provider class
344
+ # 3) Call response instance methods for theProvider which pass
345
+ # the Provider class and not the instance.
346
+ #
347
+ # The above behavior continues unless you initialize the Provider with
348
+ # { :provider_context => :instance_based }. If you do that, then the
349
+ # Provider behavior will be:
350
+ #
351
+ # 1) Assign attributes to Provider class
352
+ # 2) Instantiate the Provider class
353
+ # 3) Call response instance methods for theProvider which pass an
354
+ # instance of the Provider to those response objects.
355
+ # a) The instance will mirror all of the assigned Provider class
356
+ # attributes, but allows for overriding and extending on a
357
+ # case by case basis.
358
+ # (Dear reader, please note the second behavior is something most
359
+ # of us would've assumed to be the case, but for historic now lost
360
+ # reasons is not the case.)
361
+ def provider_context
362
+ if @provider_context == :instance_based
363
+ self
364
+ else
365
+ self.class
366
+ end
367
+ end
368
+
369
+ def format_supported?(*args)
370
+ self.class.format_supported?(*args)
371
+ end
372
+
373
+ def format(*args)
374
+ self.class.format(*args)
375
+ end
376
+
377
+ def formats
378
+ self.class.formats
379
+ end
380
+
381
+ def name
382
+ @name || self.class.name
383
+ end
384
+
385
+ def url
386
+ @url || self.class.url
387
+ end
388
+
389
+ def prefix
390
+ @prefix || self.class.prefix
391
+ end
392
+
393
+ def email
394
+ @email || self.class.email
395
+ end
396
+
397
+ def delete_support
398
+ @delete_support || self.class.delete_support
399
+ end
400
+
401
+ def granularity
402
+ @granularity || self.class.granularity
403
+ end
404
+
405
+ def model
406
+ @model || self.class.model
407
+ end
408
+
409
+ def identifier
410
+ @identifier || self.class.identifier
411
+ end
412
+
413
+ def description
414
+ @description || self.class.description
415
+ end
416
+
295
417
  # Equivalent to '&verb=Identify', returns information about the repository
296
418
  def identify(options = {})
297
- Response::Identify.new(self.class, options).to_xml
419
+ Response::Identify.new(provider_context, options).to_xml
298
420
  end
299
421
 
300
422
  # Equivalent to '&verb=ListSets', returns a list of sets that are supported
301
423
  # by the repository or an error if sets are not supported.
302
424
  def list_sets(options = {})
303
- Response::ListSets.new(self.class, options).to_xml
425
+ Response::ListSets.new(provider_context, options).to_xml
304
426
  end
305
427
 
306
428
  # Equivalent to '&verb=ListMetadataFormats', returns a list of metadata formats
307
429
  # supported by the repository.
308
430
  def list_metadata_formats(options = {})
309
- Response::ListMetadataFormats.new(self.class, options).to_xml
431
+ Response::ListMetadataFormats.new(provider_context, options).to_xml
310
432
  end
311
433
 
312
434
  # Equivalent to '&verb=ListIdentifiers', returns a list of record headers that
313
435
  # meet the supplied criteria.
314
436
  def list_identifiers(options = {})
315
- Response::ListIdentifiers.new(self.class, options).to_xml
437
+ Response::ListIdentifiers.new(provider_context, options).to_xml
316
438
  end
317
439
 
318
440
  # Equivalent to '&verb=ListRecords', returns a list of records that meet the
319
441
  # supplied criteria.
320
442
  def list_records(options = {})
321
- Response::ListRecords.new(self.class, options).to_xml
443
+ Response::ListRecords.new(provider_context, options).to_xml
322
444
  end
323
445
 
324
446
  # Equivalent to '&verb=GetRecord', returns a record matching the required
325
447
  # :identifier option
326
448
  def get_record(options = {})
327
- Response::GetRecord.new(self.class, options).to_xml
449
+ Response::GetRecord.new(provider_context, options).to_xml
328
450
  end
329
451
 
330
452
  # xml_response = process_verb('ListRecords', :from => 'October 1, 2005',
@@ -336,7 +458,7 @@ module OAI::Provider
336
458
  begin
337
459
 
338
460
  # Allow the request to pass in a url
339
- self.class.url = params['url'] ? params.delete('url') : self.class.url
461
+ provider_context.url = params['url'] ? params.delete('url') : self.url
340
462
 
341
463
  verb = params.delete('verb') || params.delete(:verb)
342
464
 
@@ -10,6 +10,12 @@ class OaipmhTables < ActiveRecord::Migration[5.2]
10
10
  t.column :oai_token_id, :integer, :null => false
11
11
  end
12
12
 
13
+ create_table :dc_langs do |t|
14
+ t.column :name, :string
15
+ t.column :updated_at, :datetime
16
+ t.column :created_at, :datetime
17
+ end
18
+
13
19
  dc_fields = proc do |t|
14
20
  t.column :title, :string
15
21
  t.column :creator, :string
@@ -21,7 +27,7 @@ class OaipmhTables < ActiveRecord::Migration[5.2]
21
27
  t.column :type, :string
22
28
  t.column :format, :string
23
29
  t.column :source, :string
24
- t.column :language, :string
30
+ t.column :dc_lang_id, :integer
25
31
  t.column :relation, :string
26
32
  t.column :coverage, :string
27
33
  t.column :rights, :string
@@ -35,6 +35,13 @@ class SimpleResumptionProvider < OAI::Provider::Base
35
35
  source_model ActiveRecordWrapper.new(DCField, :limit => 25)
36
36
  end
37
37
 
38
+ class SimpleResumptionProviderWithNonIntegerID < OAI::Provider::Base
39
+ repository_name 'ActiveRecord Resumption Provider With Non-Integer ID'
40
+ repository_url 'http://localhost'
41
+ record_prefix 'oai:test'
42
+ source_model ActiveRecordWrapper.new(DCField, :limit => 25, identifier_field: "source")
43
+ end
44
+
38
45
  class CachingResumptionProvider < OAI::Provider::Base
39
46
  repository_name 'ActiveRecord Caching Resumption Provider'
40
47
  repository_url 'http://localhost'
@@ -49,11 +56,13 @@ class ARLoader
49
56
  File.join(File.dirname(__FILE__), '..', 'fixtures', 'dc.yml')
50
57
  )
51
58
  fixtures.keys.sort.each do |key|
52
- DCField.create(fixtures[key])
59
+ lang = DCLang.create(name: fixtures[key].delete('language'))
60
+ DCField.create(fixtures[key].merge(dc_lang: lang))
53
61
  end
54
62
  end
55
63
 
56
64
  def self.unload
57
65
  DCField.delete_all
66
+ DCLang.delete_all
58
67
  end
59
68
  end
@@ -19,7 +19,8 @@ class TransactionalTestCase < Test::Unit::TestCase
19
19
  )
20
20
  disable_logging do
21
21
  fixtures.keys.sort.each do |key|
22
- DCField.create(fixtures[key])
22
+ lang = DCLang.create(name: fixtures[key].delete('language'))
23
+ DCField.create(fixtures[key].merge(dc_lang: lang))
23
24
  end
24
25
  end
25
26
  end
@@ -4,4 +4,12 @@ class DCField < ActiveRecord::Base
4
4
  :join_table => "dc_fields_dc_sets",
5
5
  :foreign_key => "dc_field_id",
6
6
  :class_name => "DCSet"
7
+
8
+ belongs_to :dc_lang, class_name: "DCLang", optional: true
9
+
10
+ default_scope -> { left_outer_joins(:dc_lang) }
11
+
12
+ def language
13
+ dc_lang&.name
14
+ end
7
15
  end
@@ -0,0 +1,3 @@
1
+ class DCLang < ActiveRecord::Base
2
+ has_many :dc_fields
3
+ end
@@ -8,4 +8,10 @@ class ExclusiveSetDCField < ActiveRecord::Base
8
8
  end
9
9
  end
10
10
 
11
+ belongs_to :dc_lang, class_name: "DCLang", optional: true
12
+
13
+ def language
14
+ dc_lang&.name
15
+ end
16
+
11
17
  end
@@ -0,0 +1,63 @@
1
+ require 'test_helper_ar_provider'
2
+
3
+ class ActiveRecordWrapperTest < TransactionalTestCase
4
+ def test_sql_conditions_from_date
5
+ input = "2005-12-25"
6
+ expected = input.dup
7
+ sql_template, sql_opts = sql_conditions(from: input)
8
+ assert_equal "dc_fields.updated_at >= :from", sql_template
9
+ assert_equal expected, sql_opts[:from]
10
+ sql_template, sql_opts = sql_conditions(from: Date.strptime(input, "%Y-%m-%d"))
11
+ assert_equal "dc_fields.updated_at >= :from", sql_template
12
+ assert_equal expected, sql_opts[:from]
13
+ end
14
+
15
+ def test_sql_conditions_from_time
16
+ input = "2005-12-25T00:00:00Z"
17
+ expected = "2005-12-25 00:00:00"
18
+ sql_template, sql_opts = sql_conditions(from: input)
19
+ assert_equal "dc_fields.updated_at >= :from", sql_template
20
+ assert_equal expected, sql_opts[:from]
21
+ sql_template, sql_opts = sql_conditions(from: Time.strptime(input, "%Y-%m-%dT%H:%M:%S%Z"))
22
+ assert_equal "dc_fields.updated_at >= :from", sql_template
23
+ assert_equal expected, sql_opts[:from]
24
+ end
25
+
26
+ def test_sql_conditions_until_date
27
+ input = "2005-12-25"
28
+ expected = "2005-12-26"
29
+ sql_template, sql_opts = sql_conditions(until: input)
30
+ assert_equal "dc_fields.updated_at < :until", sql_template
31
+ assert_equal expected, sql_opts[:until]
32
+ sql_template, sql_opts = sql_conditions(until: Date.strptime(input, "%Y-%m-%d"))
33
+ assert_equal "dc_fields.updated_at < :until", sql_template
34
+ assert_equal expected, sql_opts[:until]
35
+ end
36
+
37
+ def test_sql_conditions_until_time
38
+ input = "2005-12-25T00:00:00Z"
39
+ expected = "2005-12-25 00:00:01"
40
+ sql_template, sql_opts = sql_conditions(until: input)
41
+ assert_equal "dc_fields.updated_at < :until", sql_template
42
+ assert_equal expected, sql_opts[:until]
43
+ sql_template, sql_opts = sql_conditions(until: Time.strptime(input, "%Y-%m-%dT%H:%M:%S%Z"))
44
+ assert_equal "dc_fields.updated_at < :until", sql_template
45
+ assert_equal expected, sql_opts[:until]
46
+ end
47
+
48
+ def test_sql_conditions_both
49
+ input = "2005-12-25"
50
+ sql_template, sql_opts = sql_conditions(from: input, until: input)
51
+ assert_equal "dc_fields.updated_at >= :from AND dc_fields.updated_at < :until", sql_template
52
+ end
53
+
54
+ def setup
55
+ @wrapper = OAI::Provider::ActiveRecordWrapper.new(DCField)
56
+ end
57
+
58
+ def sql_conditions(opts)
59
+ @wrapper.send :sql_conditions, opts
60
+ end
61
+ end
62
+
63
+