speedy-af 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,46 @@
1
+ require 'solr_wrapper'
2
+ require 'fcrepo_wrapper'
3
+ require 'active_fedora/rake_support'
4
+
5
+ require 'rspec/core/rake_task'
6
+ desc 'Run tests only'
7
+ RSpec::Core::RakeTask.new(:rspec) do |spec|
8
+ spec.rspec_opts = ['--backtrace'] if ENV['CI']
9
+ end
10
+
11
+ require 'rubocop/rake_task'
12
+ desc 'Run style checker'
13
+ RuboCop::RakeTask.new(:rubocop) do |task|
14
+ task.requires << 'rubocop-rspec'
15
+ task.fail_on_error = true
16
+ end
17
+
18
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
19
+ spec.rcov = true
20
+ end
21
+
22
+ desc "CI build"
23
+ task :ci do
24
+ Rake::Task['rubocop'].invoke unless ENV['NO_RUBOCOP']
25
+ ENV['environment'] = "test"
26
+ with_test_server do
27
+ Rake::Task[:coverage].invoke
28
+ end
29
+ end
30
+
31
+ desc "Execute specs with coverage"
32
+ task :coverage do
33
+ # Put spec opts in a file named .rspec in root
34
+ ruby_engine = defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby"
35
+ ENV['COVERAGE'] = 'true' unless ruby_engine == 'jruby'
36
+ Rake::Task[:spec].invoke
37
+ end
38
+
39
+ desc "Execute specs with coverage"
40
+ task :spec do
41
+ with_test_server do
42
+ Rake::Task[:rspec].invoke
43
+ end
44
+ end
45
+
46
+ task default: :ci
@@ -0,0 +1 @@
1
+ require 'speedy_af'
@@ -0,0 +1,7 @@
1
+ require 'active_fedora'
2
+ module SpeedyAF
3
+ module Proxy; end
4
+ autoload :IndexedContent, 'speedy_af/indexed_content'
5
+ autoload :Base, 'speedy_af/base'
6
+ autoload :OrderedAggregationIndex, 'speedy_af/ordered_aggregation_index'
7
+ end
@@ -0,0 +1,214 @@
1
+ require 'ostruct'
2
+
3
+ module SpeedyAF
4
+ class Base
5
+ class NotAvailable < RuntimeError; end
6
+
7
+ SOLR_ALL = 10_000_000
8
+
9
+ attr_reader :attrs, :model
10
+
11
+ def self.defaults
12
+ @defaults ||= {}
13
+ end
14
+
15
+ def self.defaults=(value)
16
+ raise ArgumentError unless value.respond_to?(:merge)
17
+ @defaults = value
18
+ end
19
+
20
+ def self.proxy_class_for(model)
21
+ klass = "::SpeedyAF::Proxy::#{model.name}".safe_constantize
22
+ if klass.nil?
23
+ namespace = model.name.deconstantize
24
+ name = model.name.demodulize
25
+ klass_module = namespace.split(/::/).inject(::SpeedyAF::Proxy) do |mod, ns|
26
+ mod.const_defined?(ns, false) ? mod.const_get(ns, false) : mod.const_set(ns, Module.new)
27
+ end
28
+ klass = klass_module.const_set(name, Class.new(self))
29
+ end
30
+ klass
31
+ end
32
+
33
+ def self.config(model, &block)
34
+ proxy_class = proxy_class_for(model) { Class.new(self) }
35
+ proxy_class.class_eval(&block) if block_given?
36
+ end
37
+
38
+ def self.find(id, opts = {})
39
+ where(%(id:"#{id}"), opts).first
40
+ end
41
+
42
+ def self.where(query, opts = {})
43
+ docs = ActiveFedora::SolrService.query(query, rows: SOLR_ALL)
44
+ from(docs, opts)
45
+ end
46
+
47
+ def self.from(docs, opts = {})
48
+ hash = docs.each_with_object({}) do |doc, h|
49
+ proxy = proxy_class_for(model_for(doc))
50
+ h[doc['id']] = proxy.new(doc, opts[:defaults])
51
+ end
52
+ return hash.values if opts[:order].nil?
53
+ opts[:order].call.collect { |id| hash[id] }.to_a
54
+ end
55
+
56
+ def self.model_for(solr_document)
57
+ solr_document[:has_model_ssim].first.safe_constantize
58
+ end
59
+
60
+ def initialize(solr_document, instance_defaults = {})
61
+ instance_defaults ||= {}
62
+ @model = Base.model_for(solr_document)
63
+ @attrs = self.class.defaults.merge(instance_defaults)
64
+ solr_document.each_pair do |k, v|
65
+ attr_name, value = parse_solr_field(k, v)
66
+ @attrs[attr_name.to_sym] = value
67
+ end
68
+ end
69
+
70
+ def real_object
71
+ if @real_object.nil?
72
+ @real_object = model.find(id)
73
+ @attrs.clear
74
+ end
75
+ @real_object
76
+ end
77
+
78
+ def real?
79
+ !@real_object.nil?
80
+ end
81
+
82
+ def reload
83
+ dup = Base.find(id)
84
+ @attrs = dup.attrs
85
+ @model = dup.model
86
+ @real_object = nil
87
+ self
88
+ end
89
+
90
+ def to_query(key)
91
+ "#{key}=#{id}"
92
+ end
93
+
94
+ def respond_to_missing?(sym, _include_private = false)
95
+ @attrs.key?(sym) ||
96
+ model.reflections[sym].present? ||
97
+ model.instance_methods.include?(sym)
98
+ end
99
+
100
+ def method_missing(sym, *args)
101
+ return real_object.send(sym, *args) if real?
102
+
103
+ return @attrs[sym] if @attrs.key?(sym)
104
+
105
+ reflection = reflection_for(sym)
106
+ unless reflection.nil?
107
+ begin
108
+ return load_from_reflection(reflection, sym.to_s =~ /_ids?$/)
109
+ rescue NotAvailable => e
110
+ ActiveFedora::Base.logger.warn(e.message)
111
+ end
112
+ end
113
+
114
+ if model.instance_methods.include?(sym)
115
+ ActiveFedora::Base.logger.warn("Reifying #{model} because #{sym} called from #{caller.first}")
116
+ return real_object.send(sym, *args)
117
+ end
118
+ super
119
+ end
120
+
121
+ protected
122
+
123
+ def reflection_for(sym)
124
+ return nil unless model.respond_to?(:reflections)
125
+ reflection_name = sym.to_s.sub(/_id(s?)$/, '\1').to_sym
126
+ model.reflections[reflection_name] || model.reflections[:"#{reflection_name.to_s.singularize}_proxies"]
127
+ end
128
+
129
+ def parse_solr_field(k, v)
130
+ # :nocov:
131
+ transforms = {
132
+ 'dt' => ->(m) { Time.parse(m) },
133
+ 'b' => ->(m) { m },
134
+ 'db' => ->(m) { m.to_f },
135
+ 'f' => ->(m) { m.to_f },
136
+ 'i' => ->(m) { m.to_i },
137
+ 'l' => ->(m) { m.to_i },
138
+ nil => ->(m) { m }
139
+ }
140
+ # :nocov:
141
+ attr_name, type, _stored, _indexed, _multi = k.scan(/^(.+)_(.+)(s)(i?)(m?)$/).first
142
+ return [k, v] if attr_name.nil?
143
+ value = Array(v).map { |m| transforms.fetch(type, transforms[nil]).call(m) }
144
+ value = value.first unless @model.respond_to?(:properties) && multiple?(@model.properties[attr_name])
145
+ [attr_name, value]
146
+ end
147
+
148
+ def multiple?(prop)
149
+ prop.present? && prop.respond_to?(:multiple?) && prop.multiple?
150
+ end
151
+
152
+ def load_from_reflection(reflection, ids_only = false)
153
+ if reflection.options.key?(:through)
154
+ return load_through_reflection(reflection, ids_only)
155
+ end
156
+ if reflection.belongs_to? && reflection.respond_to?(:predicate_for_solr)
157
+ return load_belongs_to_reflection(reflection.predicate_for_solr, ids_only)
158
+ end
159
+ if reflection.has_many? && reflection.respond_to?(:predicate_for_solr)
160
+ return load_has_many_reflection(reflection.predicate_for_solr, ids_only)
161
+ end
162
+ if reflection.is_a?(ActiveFedora::Reflection::HasSubresourceReflection)
163
+ return load_subresource_content(reflection)
164
+ end
165
+ # :nocov:
166
+ raise NotAvailable, "`#{reflection.name}' cannot be quick-loaded. Falling back to model."
167
+ # :nocov:
168
+ end
169
+
170
+ def load_through_reflection(reflection, ids_only = false)
171
+ ids = case reflection.options[:through]
172
+ when 'ActiveFedora::Aggregation::Proxy' then proxy_ids(reflection)
173
+ else subresource_ids(reflection)
174
+ end
175
+ return ids if ids_only
176
+ query = ActiveFedora::SolrQueryBuilder.construct_query_for_ids(ids)
177
+ Base.where(query, order: -> { ids })
178
+ end
179
+
180
+ def proxy_ids(reflection)
181
+ docs = ActiveFedora::SolrService.query %(id:#{id}/#{reflection.name}/*), rows: SOLR_ALL
182
+ docs.collect { |doc| doc['proxyFor_ssim'] }.flatten
183
+ end
184
+
185
+ def subresource_ids(reflection)
186
+ subresource = reflection.options[:through]
187
+ docs = ActiveFedora::SolrService.query %(id:"#{id}/#{subresource}"), rows: 1
188
+ return [] if docs.empty?
189
+ ids = docs.first['ordered_targets_ssim']
190
+ return [] if ids.nil?
191
+ ids
192
+ end
193
+
194
+ def load_belongs_to_reflection(predicate, ids_only = false)
195
+ id = @attrs[predicate.to_sym]
196
+ return id if ids_only
197
+ Base.find(id)
198
+ end
199
+
200
+ def load_has_many_reflection(predicate, ids_only = false)
201
+ query = %(#{predicate}_ssim:#{id})
202
+ return Base.where(query) unless ids_only
203
+ docs = ActiveFedora::SolrService.query query, rows: SOLR_ALL
204
+ docs.collect { |doc| doc['id'] }
205
+ end
206
+
207
+ def load_subresource_content(reflection)
208
+ subresource = reflection.name
209
+ docs = ActiveFedora::SolrService.query(%(id:"#{id}/#{subresource}"), rows: 1)
210
+ raise NotAvailable, "`#{subresource}' is not indexed" if docs.empty? || !docs.first.key?('has_model_ssim')
211
+ @attrs[subresource] = Base.from(docs).first
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,34 @@
1
+ module SpeedyAF
2
+ module IndexedContent
3
+ extend ActiveSupport::Concern
4
+ MAX_CONTENT_SIZE = 8192
5
+
6
+ included do
7
+ after_save :update_external_index
8
+ end
9
+
10
+ def to_solr(solr_doc = {}, opts = {})
11
+ return solr_doc unless opts[:external_index]
12
+ solr_doc.tap do |doc|
13
+ doc[:id] = id
14
+ doc[:has_model_ssim] = self.class.name
15
+ doc[:uri_ss] = uri.to_s
16
+ doc[:mime_type_ss] = mime_type
17
+ doc[:original_name_ss] = original_name
18
+ doc[:size_is] = content.present? ? content.size : 0
19
+ doc[:'empty?_bs'] = content.nil? || content.empty?
20
+ doc[:content_ss] = content if index_content?
21
+ end
22
+ end
23
+
24
+ def update_external_index
25
+ ActiveFedora::SolrService.add(to_solr({}, external_index: true), softCommit: true)
26
+ end
27
+
28
+ protected
29
+
30
+ def index_content?
31
+ has_content? && mime_type =~ /(^text\/)|([\/\+]xml$)/ && size < MAX_CONTENT_SIZE && content !~ /\x00/
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ module SpeedyAF
2
+ module OrderedAggregationIndex
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ def indexed_ordered_aggregation(name)
7
+ target_class = reflections[name].class_name
8
+ contains_key = reflections[:"ordered_#{name.to_s.singularize}_proxies"].options[:through]
9
+ mixin = generated_association_methods
10
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
11
+ def indexed_#{name}
12
+ ids = self.indexed_#{name.to_s.singularize}_ids
13
+ ids.lazy.collect { |id| #{target_class}.find(id) }
14
+ end
15
+
16
+ def indexed_#{name.to_s.singularize}_ids
17
+ return [] unless persisted?
18
+ docs = ActiveFedora::SolrService.query "id: \#{self.id}/#{contains_key}", rows: 1
19
+ return [] if docs.empty? or docs.first['ordered_targets_ssim'].nil?
20
+ docs.first['ordered_targets_ssim']
21
+ end
22
+ CODE
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,3 @@
1
+ module SpeedyAF
2
+ VERSION = '0.1.1'.freeze
3
+ end
@@ -0,0 +1,48 @@
1
+ class IndexedFile < ActiveFedora::File
2
+ include SpeedyAF::IndexedContent
3
+ end
4
+
5
+ class Chapter < ActiveFedora::Base
6
+ property :title, predicate: ::RDF::Vocab::DC.title, multiple: false do |index|
7
+ index.as :stored_searchable
8
+ end
9
+ property :contributor, predicate: ::RDF::Vocab::DC.contributor, multiple: true do |index|
10
+ index.as :stored_searchable
11
+ end
12
+ end
13
+
14
+ module DowncaseBehavior
15
+ def lowercase_title
16
+ title.downcase
17
+ end
18
+ end
19
+
20
+ class Book < ActiveFedora::Base
21
+ include DowncaseBehavior
22
+ include SpeedyAF::OrderedAggregationIndex
23
+
24
+ belongs_to :library, predicate: ::RDF::Vocab::DC.isPartOf
25
+ has_subresource 'indexed_file', class_name: 'IndexedFile'
26
+ has_subresource 'unindexed_file', class_name: 'ActiveFedora::File'
27
+ property :title, predicate: ::RDF::Vocab::DC.title, multiple: false do |index|
28
+ index.as :stored_searchable
29
+ end
30
+ property :publisher, predicate: ::RDF::Vocab::DC.publisher, multiple: false do |index|
31
+ index.as :stored_searchable
32
+ end
33
+ ordered_aggregation :chapters, through: :list_source
34
+ indexed_ordered_aggregation :chapters
35
+
36
+ def uppercase_title
37
+ title.upcase
38
+ end
39
+ end
40
+
41
+ class Library < ActiveFedora::Base
42
+ has_many :books, predicate: ::RDF::Vocab::DC.isPartOf
43
+ end
44
+
45
+ module SpeedySpecs
46
+ class DeepClass < ActiveFedora::Base
47
+ end
48
+ end
@@ -0,0 +1,169 @@
1
+ require 'spec_helper'
2
+ require 'rdf/vocab/dc'
3
+
4
+ describe SpeedyAF::Base do
5
+ before { load_fixture_classes! }
6
+ after { unload_fixture_classes! }
7
+
8
+ let!(:library) { Library.create }
9
+ let!(:book) { Book.new title: 'Ordered Things', publisher: 'ActiveFedora Performance LLC', library: library }
10
+ let!(:chapters) {[
11
+ Chapter.create(title: 'Chapter 3', contributor: ['Hopper', 'Lovelace', 'Johnson']),
12
+ Chapter.create(title: 'Chapter 1', contributor: ['Rogers', 'Johnson', 'Stark', 'Romanoff']),
13
+ Chapter.create(title: 'Chapter 2', contributor: ['Alice', 'Bob', 'Charlie'])
14
+ ]}
15
+ let!(:indexed_content) {
16
+ <<-IPSUM
17
+ Zombie ipsum reversus ab viral inferno, nam rick grimes malum cerebro. De carne lumbering
18
+ animata corpora quaeritis. Summus brains sit, morbo vel maleficia? De apocalypsi gorger
19
+ omero undead survivor dictum mauris. Hi mindless mortuis soulless creaturas, imo evil
20
+ stalking monstra adventus resi dentevil vultus comedat cerebella viventium.
21
+ IPSUM
22
+ }
23
+ let!(:unindexed_content) {
24
+ <<-IPSUM
25
+ Qui animated corpse, cricket bat max brucks terribilem incessu zomby. The voodoo sacerdos
26
+ flesh eater, suscitat mortuos comedere carnem virus. Zonbi tattered for solum oculi eorum
27
+ defunctis go lum cerebro. Nescio brains an Undead zombies. Sicut malus putrid voodoo horror.
28
+ Nigh tofth eliv ingdead.
29
+ IPSUM
30
+ }
31
+ let(:book_presenter) { described_class.find(book.id) }
32
+
33
+ context 'lightweight presenter' do
34
+ before do
35
+ book.indexed_file.content = indexed_content
36
+ book.unindexed_file.content = unindexed_content
37
+ book.chapters = chapters
38
+ book.ordered_chapters = chapters.sort_by(&:title)
39
+ book.save!
40
+ end
41
+
42
+ it '#respond_to?' do
43
+ expect(book_presenter).to respond_to(:title)
44
+ expect(book_presenter).to respond_to(:chapters)
45
+ expect(book_presenter).to respond_to(:indexed_file)
46
+ expect(book_presenter).to respond_to(:unindexed_file)
47
+ expect { book_presenter.fthagn }.to raise_error(NoMethodError)
48
+ end
49
+
50
+ it '.find' do
51
+ expect(book_presenter).to be_a(described_class)
52
+ expect(book_presenter.publisher).to eq(book.publisher)
53
+ end
54
+
55
+ it '.where' do
56
+ chapter_presenter = described_class.where('contributor_tesim:"Johnson"')
57
+ expect(chapter_presenter.length).to eq(2)
58
+ end
59
+
60
+ it '.to_query' do
61
+ expect(book.to_query('book_id')).to eq("book_id=#{URI.encode(book.id, /[^\-_.!~*'()a-zA-Z\d;?:@&=+$,\[\]]/)}")
62
+ end
63
+
64
+ context 'reflections' do
65
+ let!(:library_presenter) { described_class.find(library.id) }
66
+
67
+ it 'loads via indexed proxies' do
68
+ expect(book_presenter.chapter_ids).to match_array(book.chapter_ids)
69
+ end
70
+
71
+ it 'loads indexed targets' do
72
+ expect(book_presenter.ordered_chapter_ids).to eq(book.ordered_chapter_ids)
73
+ chapter_presenters = book_presenter.ordered_chapters
74
+ expect(chapter_presenters.length).to eq(chapters.length)
75
+ expect(chapter_presenters.all? { |cp| cp.is_a?(described_class) }).to be_truthy
76
+ expect(chapter_presenters.collect(&:title)).to eq(book.ordered_chapters.to_a.collect(&:title))
77
+ expect(book_presenter).not_to be_real
78
+ end
79
+
80
+ it 'loads indexed subresources' do
81
+ ipsum_presenter = book_presenter.indexed_file
82
+ expect(ipsum_presenter.model).to eq(IndexedFile)
83
+ expect(ipsum_presenter.content).to eq(indexed_content)
84
+ expect(book_presenter).not_to be_real
85
+ expect(ipsum_presenter).not_to be_real
86
+ end
87
+
88
+ it 'loads has_many reflections' do
89
+ library.books.create(title: 'Ordered Things II')
90
+ library.save
91
+ presenter = library_presenter.books
92
+ expect(presenter.length).to eq(2)
93
+ expect(presenter.all? { |bp| bp.is_a?(described_class) }).to be_truthy
94
+ expect(library_presenter.book_ids).to match_array(library.book_ids)
95
+ expect(library_presenter).not_to be_real
96
+ end
97
+
98
+ it 'loads belongs_to reflections' do
99
+ expect(book_presenter.library_id).to eq(library.id)
100
+ expect(book_presenter.library).to be_a(described_class)
101
+ expect(book_presenter.library.model).to eq(library.class)
102
+ expect(book_presenter).not_to be_real
103
+ end
104
+ end
105
+
106
+ context 'configuration' do
107
+ before do
108
+ described_class.config Book do
109
+ include DowncaseBehavior
110
+ self.defaults = { foo: 'bar!' }
111
+ end
112
+
113
+ described_class.config SpeedySpecs::DeepClass do
114
+ self.defaults = { baz: 'quux!' }
115
+ end
116
+ end
117
+
118
+ it 'adds default values' do
119
+ expect(book_presenter.foo).to eq('bar!')
120
+ end
121
+
122
+ it 'mixes in the mixins' do
123
+ expect(book_presenter.lowercase_title).to eq(book.lowercase_title)
124
+ expect(book_presenter).not_to be_real
125
+ end
126
+
127
+ it 'works with nested classes' do
128
+ expect(described_class.proxy_class_for(SpeedySpecs::DeepClass)).to eq(SpeedyAF::Proxy::SpeedySpecs::DeepClass)
129
+ end
130
+ end
131
+
132
+ context 'reification' do
133
+ it 'knows when it is real' do
134
+ expect(book_presenter).not_to be_real
135
+ expect(book_presenter.real_object).to be_a(Book)
136
+ expect(book_presenter).to be_real
137
+ end
138
+
139
+ it '#reload (Base)' do
140
+ expect(book_presenter.real_object).to be_a(Book)
141
+ book_presenter.reload
142
+ expect(book_presenter).not_to be_real
143
+ end
144
+
145
+ it '#reload (File)' do
146
+ ipsum_presenter = book_presenter.indexed_file
147
+ expect(ipsum_presenter.real_object).to be_a(IndexedFile)
148
+ ipsum_presenter.reload
149
+ expect(ipsum_presenter).not_to be_real
150
+ end
151
+
152
+ it 'reifies when it has to' do
153
+ expect(book_presenter.uppercase_title).to eq(book.title.upcase)
154
+ expect(book_presenter).to be_real
155
+ end
156
+
157
+ it 'reifies indexed subresources' do
158
+ ipsum_presenter = book_presenter.indexed_file
159
+ expect(ipsum_presenter.metadata).to be_a(ActiveFedora::WithMetadata::MetadataNode)
160
+ expect(ipsum_presenter).to be_real
161
+ end
162
+
163
+ it 'loads unindexed subresources' do
164
+ expect(book_presenter.unindexed_file.content).to eq(unindexed_content)
165
+ expect(book_presenter).to be_real
166
+ end
167
+ end
168
+ end
169
+ end