speedy-af 0.1.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.
@@ -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