ActiveRecord find_each combined with limit and order - sql
I'm trying to run a query of about 50,000 records using ActiveRecord's find_each method, but it seems to be ignoring my other parameters like so:
Thing.active.order("created_at DESC").limit(50000).find_each {|t| puts t.id }
Instead of stopping at 50,000 I'd like and sorting by created_at, here's the resulting query that gets executed over the entire dataset:
Thing Load (198.8ms) SELECT "things".* FROM "things" WHERE "things"."active" = 't' AND ("things"."id" > 373343) ORDER BY "things"."id" ASC LIMIT 1000
Is there a way to get similar behavior to find_each but with a total max limit and respecting my sort criteria?
The documentation says that find_each and find_in_batches don't retain sort order and limit because:
Sorting ASC on the PK is used to make the batch ordering work.
Limit is used to control the batch sizes.
You could write your own version of this function like #rorra did. But you can get into trouble when mutating the objects. If for example you sort by created_at and save the object it might come up again in one of the next batches. Similarly you might skip objects because the order of results has changed when executing the query to get the next batch. Only use that solution with read only objects.
Now my primary concern was that I didn't want to load 30000+ objects into memory at once. My concern was not the execution time of the query itself. Therefore I used a solution that executes the original query but only caches the ID's. It then divides the array of ID's into chunks and queries/creates the objects per chunk. This way you can safely mutate the objects because the sort order is kept in memory.
Here is a minimal example similar to what I did:
batch_size = 512
ids = Thing.order('created_at DESC').pluck(:id) # Replace .order(:created_at) with your own scope
ids.each_slice(batch_size) do |chunk|
Thing.find(chunk, :order => "field(id, #{chunk.join(',')})").each do |thing|
# Do things with thing
end
end
The trade-offs to this solution are:
The complete query is executed to get the ID's
An array of all the ID's is kept in memory
Uses the MySQL specific FIELD() function
Hope this helps!
find_each uses find_in_batches under the hood.
Its not possible to select the order of the records, as described in find_in_batches, is automatically set to ascending on the primary key (“id ASC”) to make the batch ordering work.
However, the criteria is applied, what you can do is:
Thing.active.find_each(batch_size: 50000) { |t| puts t.id }
Regarding the limit, it wasn't implemented yet: https://github.com/rails/rails/pull/5696
Answering to your second question, you can create the logic yourself:
total_records = 50000
batch = 1000
(0..(total_records - batch)).step(batch) do |i|
puts Thing.active.order("created_at DESC").offset(i).limit(batch).to_sql
end
Retrieving the ids first and processing the in_groups_of
ordered_photo_ids = Photo.order(likes_count: :desc).pluck(:id)
ordered_photo_ids.in_groups_of(1000, false).each do |photo_ids|
photos = Photo.order(likes_count: :desc).where(id: photo_ids)
# ...
end
It's important to also add the ORDER BY query to the inner call.
Rails 6.1 adds support for descending order in find_each, find_in_batches and in_batches.
One option is to put an implementation tailored for your particular model into the model itself (speaking of which, id is usually a better choice for ordering records, created_at may have duplicates):
class Thing < ActiveRecord::Base
def self.find_each_desc limit
batch_size = 1000
i = 1
records = self.order(created_at: :desc).limit(batch_size)
while records.any?
records.each do |task|
yield task, i
i += 1
return if i > limit
end
records = self.order(created_at: :desc).where('id < ?', records.last.id).limit(batch_size)
end
end
end
Or else you can generalize things a bit, and make it work for all the models:
lib/active_record_extensions.rb:
ActiveRecord::Batches.module_eval do
def find_each_desc limit
batch_size = 1000
i = 1
records = self.order(id: :desc).limit(batch_size)
while records.any?
records.each do |task|
yield task, i
i += 1
return if i > limit
end
records = self.order(id: :desc).where('id < ?', records.last.id).limit(batch_size)
end
end
end
ActiveRecord::Querying.module_eval do
delegate :find_each_desc, :to => :all
end
config/initializers/extensions.rb:
require "active_record_extensions"
P.S. I'm putting the code in files according to this answer.
You can iterate backwards by standard ruby iterators:
Thing.last.id.step(0,-1000) do |i|
Thing.where(id: (i-1000+1)..i).order('id DESC').each do |thing|
#...
end
end
Note: +1 is because BETWEEN which will be in query includes both bounds but we need include only one.
Sure, with this approach there could be fetched less than 1000 records in batch because some of them are deleted already but this is ok in my case.
As remarked by #Kirk in one of the comments, find_each supports limit as of version 5.1.0.
Example from the changelog:
Post.limit(10_000).find_each do |post|
# ...
end
The documentation says:
Limits are honored, and if present there is no requirement for the batch size: it can be less than, equal to, or greater than the limit.
(setting a custom order is still not supported though)
I was looking for the same behaviour and thought up of this solution. This DOES NOT order by created_at but I thought I would post anyways.
max_records_to_retrieve = 50000
last_index = Thing.count
start_index = [(last_index - max_records_to_retrieve), 0].max
Thing.active.find_each(:start => start_index) do |u|
# do stuff
end
Drawbacks of this approach:
- You need 2 queries (first one should be fast)
- This guarantees a max of 50K records but if ids are skipped you will get less.
You can try ar-as-batches Gem.
From their documentation you can do something like this
Users.where(country_id: 44).order(:joined_at).offset(200).as_batches do |user|
user.party_all_night!
end
Using Kaminari or something other it will be easy.
Create batch loader class.
module BatchLoader
extend ActiveSupport::Concern
def batch_by_page(options = {})
options = init_batch_options!(options)
next_page = 1
loop do
next_page = yield(next_page, options[:batch_size])
break next_page if next_page.nil?
end
end
private
def default_batch_options
{
batch_size: 50
}
end
def init_batch_options!(options)
options ||= {}
default_batch_options.merge!(options)
end
end
Create Repository
class ThingRepository
include BatchLoader
# #param [Integer] per_page
# #param [Proc] block
def batch_changes(per_page=100, &block)
relation = Thing.active.order("created_at DESC")
batch_by_page do |next_page|
query = relation.page(next_page).per(per_page)
yield query if block_given?
query.next_page
end
end
end
Use the repository
repo = ThingRepository.new
repo.batch_changes(5000).each do |g|
g.each do |t|
#...
end
end
Adding find_in_batches_with_order did solve my usecase, where I was having ids already but need batching and ordering. It was inspired by #dirk-geurs solution
# Create file config/initializers/find_in_batches_with_order.rb with follwing code.
ActiveRecord::Batches.class_eval do
## Only flat order structure is supported now
## example: [:forename, :surname] is supported but [:forename, {surname: :asc}] is not supported
def find_in_batches_with_order(ids: nil, order: [], batch_size: 1000)
relation = self
arrangement = order.dup
index = order.find_index(:id)
unless index
arrangement.push(:id)
index = arrangement.length - 1
end
ids ||= relation.order(*arrangement).pluck(*arrangement).map{ |tupple| tupple[index] }
ids.each_slice(batch_size) do |chunk_ids|
chunk_relation = relation.where(id: chunk_ids).order(*order)
yield(chunk_relation)
end
end
end
Leaving Gist here https://gist.github.com/the-spectator/28b1176f98cc2f66e870755bb2334545
I had the same problem with a query with DISTINCT ON where you need an ORDER BY with that field, so this is my approach with Postgres:
def filtered_model_ids
Model.joins(:father_model)
.select('DISTINCT ON (model.field) model.id')
.order(:field)
.map(&:id)
end
def processor
filtered_model_ids.each_slice(BATCH_SIZE).lazy.each do |batch|
Model.find(batch).each do |record|
# Code
end
end
end
My code
batch_size = 100
total_count = klass.count
offset = 0
processed_count = 0
while processed_count < total_count
relation = klass.order({ active_at: :asc, created_at: :desc }).offset(offset).limit(batch_size)
relation.each do |record|
record.process
end
processed_count += batch_size
end
Do it in one query and avoid iterating:
User.offset(2).order('name DESC').last(3)
will product a query like this
SELECT "users".* FROM "users" ORDER BY name ASC LIMIT $1 OFFSET $2 [["LIMIT", 3], ["OFFSET", 2]
Related
How to make an ActiveRecord request to get an item common to several other items
I am trying to modify Sharetribe, a Ruby on Rails framework for online communities. There is this method that returns me relevant search filters. For now, it returns me a filter if it is present in any one of the categories (identified by category_ids ) . I would like it to return a filter if and only if it is present in ALL of the categories identified by category_ids. Being new to Rails and ActiveRecord, I'm a bit lost. Here is the method returning relevant filters : # Database select for "relevant" filters based on the `category_ids` # # If `category_ids` is present, returns only filter that belong to # one of the given categories. Otherwise returns all filters. # def select_relevant_filters(category_ids) relevant_filters = if category_ids.present? #current_community .custom_fields .joins(:category_custom_fields) .where("category_custom_fields.category_id": category_ids, search_filter: true) .distinct else #current_community .custom_fields.where(search_filter: true) end relevant_filters.sort end Is there a way to change the SQL request, or should I retrieve all the fields as it is doing right now and then delete the ones I am not interested in ?
Try the following def select_relevant_filters_if_all(category_ids) relevant_filters = if category_ids.present? #current_community .custom_fields .joins(:category_custom_fields) .where("category_custom_fields.category_id": category_ids, search_filter: true) .group("category_custom_fields.id") .having("count(category_custom_fields.id)=?", category_ids.count) .distinct else #current_community .custom_fields.where(search_filter: true) end relevant_filters.sort end This is a new method in your HomeController, pay attention the name is different, just to omit monkeypatching. Comments are welcome.
So I solved my problem by selecting filters that are pesent in all of the subcategories of the selected category. For that I select all filters of all subcategory, and only keep the ones that are returned a number of times exactly equal to the number of subcategory. all_relevant_filters = select_relevant_filters(m_selected_category.own_and_subcategory_ids.or_nil) nb_sub_category = m_selected_category.subcategory_ids.size if nb_sub_category.none? relevant_filters = all_relevant_filters else relevant_filters = all_relevant_filters.select{ |e| all_relevant_filters.count(e) == nb_sub_category.get }.uniq end
How to select each model which has the maximum value of an attribute for any given value of another attribute?
I have a Work model with a video_id, a user_id and some other simple fields. I need to display the last 12 works on the page, but only take 1 per user. Currently I'm trying to do it like this: def self.latest_works_one_per_user(video_id=nil) scope = self.includes(:user, :video) scope = video_id ? scope.where(video_id: video_id) : scope.where.not(video_id: nil) scope = scope.order(created_at: :desc) user_ids = works = [] scope.each do |work| next if user_ids.include? work.user_id user_ids << work.user_id works << work break if works.size == 12 end works end But I'm damn sure there is a more elegant and faster way of doing it especially when the number of works gets bigger.
Here's a solution that should work for any SQL database with minimal adjustment. Whether one thinks it's elegant or not depends on how much you enjoy SQL. def self.latest_works_one_per_user(video_id=nil) scope = includes(:user, :video) scope = video_id ? scope.where(video_id: video_id) : scope.where.not(video_id: nil) scope. joins("join (select user_id, max(created_at) created_at from works group by created at) most_recent on works.user_id = most_recent.user_id and works.created_at = most_recent.created_at"). order(created_at: :desc).limit(12) end It only works if the combination of user_id and created_at is unique, however. If that combination isn't unique you'll get more than 12 rows. It can be done more simply in MySQL. The MySQL solution doesn't work in Postgres, and I don't know a better solution in Postgres, although I'm sure there is one.
SUM operation on attributes of children of multiple parent records
I have this method in my Product model that does what I need: def self.available available = [] Product.all.each do |product| if product.quantity > product.sales.sum(:quantity) available << product end end return available end But, I am wondering if there is a more efficient way to do this, maybe with only one call to the database.
Well you might try: Product.where("products.quantity > Coalesce((select sum(s.quantity) from sales s where s.product_id = products.id), 0)")
This creates a number of queries equal to the number of products you have due to the sum query. Here is a way I though of that will reduce database queries. map = Sale.joins(:product) .group("products.id", "products.quantity").sum(:quantity).to_a Which will produce an array similar to [[[1,20],30], [[2,45], 20]] this corresponds to [[[product_id, product_quantity], sold_quantity ]] Now loop over this array and compare the values. available = [] map.each do |item| if item[0][1] > item[1] available << item[0][0] end end Now that you have the available array populated, perform another query. available = Product.where(id: available) Now you got your same output in two queries instead of Product.count (N) number of queries. This solution can be inefficient sometimes but I will be updating it regularly if I had any ideas.
Rails/Sql - order/group search results such that repetition of entities occurs only after appearance of others
In my application, say, animals have many photos. I'm querying photos of animals such that I want all photos of all animals to be displayed. However, I want each animal to appear as a photo before repetition occurs. Example: animal instance 1, 'cat', has four photos, animal instance 2, 'dog', has two photos: photos should appear ordered as so: #photo belongs to #animal tiddles.jpg , cat fido.jpg dog meow.jpg cat rover.jpg dog puss.jpg cat felix.jpg, cat (no more dogs so two consecutive cats) Pagination is required so I can't order on an array. Filename structure/convention provides no help, though the animal_id exists on each photo. Though there are two types of animal in this example this is an active record model with hundreds of records. Animals may be selectively queried. If this isn't possible with active_record then I'll happily use sql; I'm using postgresql. My brain is frazzled so if anyone can come up with a better title, please go ahead and edit it or suggest in comments.
Here is a PostgreSQL specific solution: batch_id_sql = "RANK() OVER (PARTITION BY animal_id ORDER BY id ASC)" Photo.paginate( :select => "DISTINCT photos.*, (#{batch_id_sql}) batch_id", :order => "batch_id ASC, photos.animal_id ASC", :page => 1) Here is a DB agnostic solution: batch_id_sql = " SELECT COUNT(bm.*) FROM photos bm WHERE bm.animal_id = photos.animal_id AND bm.id <= photos.id " Photo.paginate( :select => "photos.*, (#{batch_id_sql}) batch_id", :order => "batch_id ASC, photos.animal_id ASC", :page => 1) Both queries work even when you have a where condition. Benchmark the query using expected data set to check if it meets the expected throughput and latency requirements. Reference PostgreSQL Window function
Having no experience in activerecord. Using plain PostgreSQL I would try something like this: Define a window function over all previous rows which counts how many time the current animal has appeared, then order by this count. SELECT filename, animal_id, COUNT(*) OVER (PARTITION BY animal_id ORDER BY filename) AS cnt FROM photos ORDER BY cnt, animal_id, filename Filtering on certain animal_id's will work. This will always order the same way. I don't know if you want something random in there, but it should be easily added.
New solution Add an integer column called batch_id to the animals table. class AddBatchIdToPhotos < ActiveRecord::Migration def self.up add_column :photos, :batch_id, :integer set_batch_id change_column :photos, :batch_id, :integer, :nil => false add_index :photos, :batch_id end def self.down remove_column :photos, :batch_id end def self.set_batch_id # set the batch id to existing rows # implement this end end Now add a before_create on the Photo model to set the batch id. class Photo belongs_to :animal before_create :batch_photo_add after_update :batch_photo_update after_destroy :batch_photo_remove private def batch_photo_add self.batch_id = next_batch_id_for_animal(animal_id) true end def batch_photo_update return true unless animal_id_changed? batch_photo_remove(batch_id, animal_id_was) batch_photo_add end def batch_photo_remove(b_id=batch_id, a_id=animal_id) Photo.update_all("batch_id = batch_id- 1", ["animal_id = ? AND batch_id > ?", a_id, b_id]) true end def next_batch_id_for_animal(a_id) (Photo.maximum(:batch_id, :conditions => {:animal_id => a_id}) || 0) + 1 end end Now you can get the desired result by issuing simple paginate command #animal_photos = Photo.paginate(:page => 1, :per_page => 10, :order => :batch_id) How does this work? Let's consider we have data set as given below: id Photo Description Batch Id 1 Cat_photo_1 1 2 Cat_photo_2 2 3 Dog_photo_1 1 2 Cat_photo_3 3 4 Dog_photo_2 2 5 Lion_photo_1 1 6 Cat_photo_4 4 Now if we were to execute a query ordered by batch_id we get this # batch 1 (cat, dog, lion) Cat_photo_1 Dog_photo_1 Lion_photo_1 # batch 2 (cat, dog) Cat_photo_2 Dog_photo_2 # batch 3,4 (cat) Cat_photo_3 Cat_photo_4 The batch distribution is not random, the animals are filled from the top. The number of animals displayed in a page is governed by per_page parameter passed to paginate method (not the batch size). Old solution Have you tried this? If you are using the will_paginate gem: # assuming you want to order by animal name animal_photos = Photo.paginate(:include => :animal, :page => 1, :order => "animals.name") animal_photos.each do |animal_photo| puts animal_photo.file_name puts animal_photo.animal.name end
I'd recommend something hybrid/corrected based on KandadaBoggu's input. First off, the correct way to do it on paper is with row_number() over (partition by animal_id order by id). The suggested rank() will generate a global row number, but you want the one within its partition. Using a window function is also the most flexible solution (in fact, the only solution) if you want to plan to change the sort order here and there. Take note that this won't necessarily scale well, however, because in order to sort the results you'll need to: fetch the whole result set that matches your criteria sort the whole result set to create the partitions and obtain a rank_id top-n sort/limit over the result set a second time to get them in their final order The correct way to do this in practice, if your sort order is immutable, is to maintain a pre-calculated rank_id. KandadaBoggu's other suggestion points in the correct direction in this sense. When it comes to deletes (and possibly updates, if you don't want them sorted by id), you may run into issues because you end up trading faster reads for slower writes. If deleting the cat with an index of 1 leads to updating the next 50k cats, you're going to be in trouble. If you've very small sets, the overhead might be very acceptable (don't forget to index animal_id). If not, there's a workaround if you find the order in which specific animals appear is irrelevant. It goes like this: Start a transaction. If the rank_id is going to change (i.e. insert or delete), obtain an advisory lock to ensure that two sessions can't impact the rank_id of the same animal class, e.g.: SELECT pg_try_advisory_lock('the_table'::regclass, the_animal_id); (Sleep for .05s if you don't obtain it.) On insert, find max(rank_id) for that animal_id. Assign it rank_id + 1. Then insert it. On delete, select the animal with the same animal_id and the largest rank_id. Delete your animal, and assign its old rank_id to the fetched animal (unless you were deleting the last one, of course). Release the advisory lock. Commit the work. Note that the above will make good use of an index on (animal_id, rank_id) and can be done using plpgsql triggers: create trigger "__animals_rank_id__ins" before insert on animals for each row execute procedure lock_animal_id_and_assign_rank_id(); create trigger "_00_animals_rank_id__ins" after insert on animals for each row execute procedure unlock_animal_id(); create trigger "__animals_rank_id__del" before delete on animals for each row execute procedure lock_animal_id(); create trigger "_00_animals_rank_id__del" after delete on animals for each row execute procedure reassign_rank_id_and_unlock_animal_id(); You can then create a multi-column index on your sort criteria if you're not joining all over them place, e.g. (rank_id, name). And you'll end up with a snappy site for reads and writes.
You should be able to get the pictures (or filenames, anyway) using ActiveRecord, ordered by name. Then you can use Enumerable#group_by and Enumerable#zip to zip all the arrays together. If you give me more information about how your filenames are really arranged (i.e., are they all for sure with an underscore before the number and a constant name before the underscore for each "type"? etc.), then I can give you an example. I'll write one up momentarily showing how you'd do it for your current example.
You could run two sorts and build one array as follows: result1= The first of each animal type only. use the ruby "find" method for this search. result2= All animals, sorted by group. Use "find" to again find the first occurrence of each animal and then use "drop" to remove those "first occurrences" from result2. Then: markCustomResult = result1 + result2 Then: You can use willpaginate on markCustomResult
Limiting user votes in a ruby on rails app
I have an app where users can vote for entries. They are limited to a total number of votes per 24 hours, based on a configuration stored in my Setting model. Here's the code I'm using in my Vote model to check and see if they've hit their limit. def not_voted_too_much? #votes_per_period = find_settings.votes_per_period #how many votes are allowed per period #votes = Vote.find_all_by_user_id(user_id, :order => 'id DESC') #index = #votes_per_period - 1 if #votes.nil? true else if #votes.size < #votes_per_period true else if #votes[#index].created_at + find_settings.voting_period_in_hours.hours > Time.now.utc false else true end end end end When that returns, true -- they're allowed to vote. If false -- they can't. Right now, it relies on the records being retrieved in a certain order and that the one it selects is the oldest. This seems to work, but feels fragile to me. I'd like to use :order => 'created_at DESC', but when I apply a limit to the query (I'd need to only get as many records as votes are allowed for that period), it seems to always pull the oldest records instead of the latest records and I'm not sure how to go about changing the query to pull the latest votes and not the oldest. Any thoughts on the best way to go about this?
Can't you just count the user's votes which are newer than 24 hours old and check it against your limits? Am I missing something? def not_voted_too_much? votes_count = votes.where("created_at >= ?", 24.hours.ago).count votes_count < find_settings.votes_per_period end (this is assuming you've got the votes association setup correctly in the user model)