Skip to content

Commit

Permalink
SavedSearch refactored and fixed with more tests. Almost works for sq…
Browse files Browse the repository at this point in the history
…lite. Unfortunately is db-specific due to date/time queries.

modified:   Gemfile.lock
modified:   app/models/audio_recording.rb
new file:   app/models/saved_search/saved_search_store.rb
new file:   app/models/saved_search/saved_search_store_body.rb
new file:   app/models/saved_search/saved_search_store_post.rb
new file:   app/models/saved_search/saved_search_store_pre.rb
modified:   lib/modules/hash.rb
deleted:    lib/search.rb
deleted:    spec/models/class_search_spec.rb
modified:   spec/requests/saved_search_request_spec.rb
  • Loading branch information
cofiem committed Feb 13, 2013
1 parent 4f21c1e commit ea67511
Show file tree
Hide file tree
Showing 7 changed files with 247 additions and 336 deletions.
62 changes: 48 additions & 14 deletions app/models/audio_recording.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,6 @@ def status=(new_status)
# these need to be left outer joins. includes should do this, but does not.
# use joins with the join in sql text :(
# http://guides.rubyonrails.org/active_record_querying.html#specifying-conditions-on-eager-loaded-associations
def self.recording_projects(project_ids)
includes(:site => :projects).where(:projects => {:id => project_ids})
end

def self.recording_sites(site_ids)
includes(:site).where(:sites => {:id => site_ids})
end

def self.recording_ids(recording_ids)
where(:id => recording_ids)
Expand All @@ -92,22 +85,49 @@ def self.recording_uuids(recording_ids)
where(:uuid => recording_ids)
end

# only one of these can be included.
def self.recording_projects(project_ids)
includes(:site => :projects).where(:projects => {:id => project_ids})
end

def self.recording_sites(site_ids)
includes(:site).where(:sites => {:id => site_ids})
end

def self.recording_within_date(start_date, end_date)
rel_query = scoped
if start_date.kind_of?(DateTime)
if start_date.kind_of?(Time)
sqlite_calculation = "datetime(recorded_date, '+' || duration_seconds || ' seconds') >= :start_date"
formatted_start_date = start_date.utc.midnight.strftime('%Y-%m-%d %H:%M:%S')
formatted_start_date = start_date.utc.strftime('%Y-%m-%d %H:%M:%S')
rel_query = rel_query.where(sqlite_calculation, {:start_date => formatted_start_date})
else
raise ArgumentError, "Expected start_date to be a DateTime, given #{start_date.class} type."
raise ArgumentError, "Expected start_date to be a Time, given #{start_date.class} type, with value #{start_date}."
end

if end_date.kind_of?(DateTime)
formatted_end_date = end_date.utc.advance({days: 1}).midnight.strftime('%Y-%m-%d %H:%M:%S')
if end_date.kind_of?(Time)
formatted_end_date = end_date.utc.strftime('%Y-%m-%d %H:%M:%S')
rel_query = rel_query.where('recorded_date < :end_date', {:end_date => formatted_end_date})
else
raise ArgumentError, "Expected end_date to be a DateTime, given #{start_date.class} type."
raise ArgumentError, "Expected end_date to be a Time, given #{end_date.class} type, with value #{end_date}."
end

rel_query
end

def self.recording_within_time(start_time, end_time)
rel_query = scoped
if start_time.kind_of?(Time)
sqlite_calculation = "time(recorded_date, '+' || duration_seconds || ' seconds') >= :start_time"
formatted_start_time = start_time.utc.strftime('%H:%M:%S')
rel_query = rel_query.where(sqlite_calculation, {:start_time => formatted_start_time})
else
raise ArgumentError, "Expected start_time to be a Time, given #{start_time.class} type, with value #{start_time}."
end

if end_time.kind_of?(Time)
formatted_end_time = end_time.utc.strftime('%H:%M:%S')
rel_query = rel_query.where('time(recorded_date) < :end_time', {:end_time => formatted_end_time})
else
raise ArgumentError, "Expected end_time to be a Time, given #{end_time.class} type, with value #{end_time}."
end

rel_query
Expand All @@ -117,6 +137,7 @@ def self.recording_tags(tags)
rel_query = includes(:audio_events => :tags)

tags.each do |tag|
#Tag.matching(:text, tag)
rel_query = rel_query.where(Tag.arel_table[:text].matches("%#{tag}%"))
end

Expand Down Expand Up @@ -153,6 +174,19 @@ def self.recording_time_ranges(time_ranges)
rel_query
end



# from: http://stackoverflow.com/questions/7051062/whats-the-best-way-to-include-a-like-clause-in-a-rails-query
def self.match_scope_condition(col, query)
arel_table[col].matches("%#{query}%")
end

scope :matching, lambda {|*args|
col, opts = args.shift, args.extract_options!
op = opts[:operator] || :or
where args.flatten.map {|query| match_scope_condition(col, query) }.inject(&op)
}

private

# default values
Expand Down
104 changes: 104 additions & 0 deletions app/models/saved_search/saved_search_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
class SavedSearchStore
#http://edgeguides.rubyonrails.org/active_model_basics.html#validations
include ActiveModel::Validations

# example:
# test = Search.new( { :pre => { :created_by_id => 1 }, :body => { :project_ids => [1,2,3,4] } } )

# does not store/cache results. Should it?

attr_accessor :pre_params, :body_params, :post_params

def initialize(args)
args.each do |k, v|
instance_variable_set("@#{k}", v) unless v.nil?
end
end

validate :params_are_hashes

def params_are_hashes
self.pre_params = SavedSearchStorePre.new(self.pre_params) if self.pre_params.is_a?(Hash)
self.body_params = SavedSearchStoreBody.new(self.body_params) if self.body_params.is_a?(Hash)
self.post_params = SavedSearchStorePost.new(self.post_params) if self.post_params.is_a?(Hash)

self.errors.add(:pre_params, "must be a SavedSearchPre, given #{self.pre_params.class} with value #{self.pre_params}.") unless self.pre_params.is_a?(SavedSearchStorePre) || self.pre_params.blank?
self.errors.add(:body_params, "must be a SavedSearchBody, given #{self.body_params.class} with value #{self.body_params}.") unless self.body_params.is_a?(SavedSearchStoreBody) || self.body_params.blank?
self.errors.add(:post_params, "must be a SavedSearchPost, given #{self.post_params.class} with value #{self.post_params}.") unless self.post_params.is_a?(SavedSearchStorePost) || self.post_params.blank?
end

# create query with deterministic ordering
def create_query
create_raw_query.select('audio_recordings.id, audio_recordings.uuid').order('audio_recordings.recorded_date')
end

# create a query using the state of this Search instance.
def create_raw_query

recordings_search = AudioRecording.scoped

if self.invalid?
raise ArgumentError, "SavedSearchStore has errors: #{self.errors.to_json}."
end

unless self.pre_params.blank?
if self.pre_params.invalid?
raise ArgumentError, "SavedSearchStorePre has errors: #{self.pre_params.errors.to_json}."
end
end

unless self.body_params.blank?

if self.body_params.invalid?
raise ArgumentError, "SavedSearchStoreBody has errors: #{self.body_params.errors.to_json}."
end

# these are in a specific order, from the ones that will filter the most, to those that will filter the least.
recordings_search = recordings_search.recording_ids(self.body_params.audio_recording_id) if self.body_params.audio_recording_id
recordings_search = recordings_search.recording_uuids(self.body_params.audio_recording_uuid) if self.body_params.audio_recording_uuid

recordings_search = recordings_search.recording_projects(self.body_params.project_id) if self.body_params.project_id
recordings_search = recordings_search.recording_sites(self.body_params.site_id) if self.body_params.site_id

if self.body_params.date_start && self.body_params.date_end
recordings_search = recordings_search.recording_within_date(self.body_params.date_start, self.body_params.date_end)
elsif self.body_params.date_start
recordings_search = recordings_search.recording_within_date(self.body_params.date_start, self.body_params.date_start)
elsif self.body_params.date_end
recordings_search = recordings_search.recording_within_date(self.body_params.date_end, self.body_params.date_end)
end

if self.body_params.time_start && self.body_params.time_end
recordings_search = recordings_search.recording_within_time(self.body_params.time_start, self.body_params.time_end)
elsif self.body_params.time_start
recordings_search = recordings_search.recording_within_time(self.body_params.time_start, self.body_params.time_start)
elsif self.body_params.time_end
recordings_search = recordings_search.recording_within_time(self.body_params.time_end, self.body_params.time_end)
end

recordings_search = recordings_search.recording_tags(self.body_params.tags) if self.body_params.tags
end


unless self.post_params.blank?
if self.post_params.invalid?
raise ArgumentError, "SavedSearchStorePost has errors: #{self.post_params.errors}."
end
end

recordings_search
end

# execute a query to get the dataset
def execute_query
the_query = create_query

#to do: start and end offsets
results = the_query.all.collect { |result|
the_result = {id: result.id, uuid: result.uuid, start_offset_seconds: nil, end_offset_seconds: nil}
OpenStruct.new(the_result)
}
to_return = {:search => self, :query => the_query, :items => results}
OpenStruct.new(to_return)
end
end
68 changes: 68 additions & 0 deletions app/models/saved_search/saved_search_store_body.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
class SavedSearchStoreBody
include ActiveModel::Validations

# attr_accessor is a convenience method to autogenerate getters and setters

# to add later:
#:geo_latitude, :geo_longitude, # location (one lat/long point)
#,
# allow multiple ids, date ranges, time ranges

attr_accessor :audio_recording_uuid, # guid
:audio_recording_id, :project_id, :site_id, # integer id
:date_start, :date_end, # date range (absolute: date and time)
:time_start, :time_end, # time range (in hours:minutes)
:tags # array of text that will be matched using SQL: LIKE '%'+tag_text+'%'

def initialize(args)
args.each do |k, v|
instance_variable_set("@#{k}", v) unless v.nil?
end
end

validates :audio_recording_id, allow_nil: true, allow_blank: true, numericality: {only_integer: true, greater_than_or_equal_to: 1}
validates :audio_recording_uuid, allow_nil: true, allow_blank: true, length: {is: 36}

validates :project_id, allow_nil: true, allow_blank: true, numericality: {only_integer: true, greater_than_or_equal_to: 1}
validates :site_id, allow_nil: true, allow_blank: true, numericality: {only_integer: true, greater_than_or_equal_to: 1}

# these are times because they need the time component of the date. Don't use DateTime.current, but do use type: datetime. Geez, this is confusing.
# https://github.com/adzap/validates_timeliness
validates :date_start, allow_nil: true, allow_blank: true, timeliness: {on_or_before: lambda { Time.current }, type: :datetime}
validates :date_end, allow_nil: true, allow_blank: true, timeliness: {on_or_before: lambda { Time.current }, type: :datetime}

validate :date_start_before_end

validates :time_start, allow_nil: true, allow_blank: true, timeliness: {type: :time}
validates :time_end, allow_nil: true, allow_blank: true, timeliness: {type: :time}

validate :time_start_before_end

validate :tags_is_array, :tags_are_strings

def date_start_before_end
if !self.date_start.blank? && !self.date_end.blank? && self.date_start >= self.date_end
self.errors.add(:date_start, "must be before end date")
end
end

def time_start_before_end
if !self.time_start.blank? && !self.time_end.blank? && self.time_start >= self.time_end
self.errors.add(:time_start, "must be before end time")
end
end

def tags_is_array
self.errors.add(:tags, "must be an array, given #{self.tags.class} with value #{self.tags}.") unless self.tags.is_a?(Array) || self.tags.blank?
end

def tags_are_strings
unless self.tags.blank?
self.tags.each do |tag|
unless tag.is_a?(String)
errors.add(:tags, "#{tag} of type #{tag.class} is not valid.")
end
end
end
end
end
8 changes: 8 additions & 0 deletions app/models/saved_search/saved_search_store_post.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class SavedSearchStorePost
include ActiveModel::Validations

attr_accessor :display_tags_species, :display_tags_common,
:display_tags_looks_like, :display_tags_sounds_like,
:display_tags_reference, :display_tags_auto

end
8 changes: 8 additions & 0 deletions app/models/saved_search/saved_search_store_pre.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class SavedSearchStorePre
include ActiveModel::Validations

validates :created_by_id, numericality: {only_integer: true, greater_than_or_equal_to: 1}, allow_nil: true
validates :is_temporary, :inclusion => { :in => [true, false] }, allow_nil: true


end
11 changes: 11 additions & 0 deletions lib/modules/hash.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,15 @@ def deep_merge(second)
merger = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
self.merge(second, &merger)
end

# http://stackoverflow.com/questions/1753336/hashkey-to-hash-key-in-ruby
def method_missing(method, *opts)
m = method.to_s
if self.has_key?(m)
return self[m]
elsif self.has_key?(m.to_sym)
return self[m.to_sym]
end
super
end
end
Loading

0 comments on commit ea67511

Please sign in to comment.