Is there a way to create a Ruby model scope for ordering books? (none numeric, none alphabetic)

61 Views Asked by At

I have an app where there are quotes from books with notes or thoughts on the quotes. I want to list out the quotes in order of the book order, chapter order and then page order.

Is there a way to move this to a scope within the model in order to keep the ActiveRelation?

like

class Quote
  scope :sorted, ->(order_of_books|book|) { where("reference_book = ?", book) }
end

I have code like the following in my Controller that does the job of ordering the quotes by book order.

# /app/controllers/quotes_controller.rb

def quotes
    @quotes = Quotes.all
    @sorted_quotes = []
        
    order_of_books.each do |book|
        @temp_array = []
        if @quotes.any? { |quote| quote[:reference_book] == book}
            @temp_array << @quotes.detect { |quote| quote[:reference_book] == book}
            # @temp_array.sort_by! { |quote| quote.reference_paragraph, quote.reference_sentence }
            @sorted_quotes << @temp_array
        end
    end
end

# /app/models/concerns/book_concern.rb

def order_of_books
   [ 
    "Book A",
    "Book B",
    "Book C",
   ]
end

This is the database table for reference.

# db/schema.rb
create_table "quotes", force: :cascade do |t|
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.string "text", null: false
    t.string "reference_book", null: false
    t.integer "reference_chapter", null: false
    t.integer "reference_paragraph", null: false
    t.integer "reference_sentence", null: false
    t.string "image"
    t.text "notes"
end

Errors

The issue is now that I am trying to sort the quotes, all my other code is breaking when in my views when I try to call something like quote.image and I get this error:

undefined method `image' for [#<Quote id: 4, created_at: "2023-11-21 15:19:....

Side Note

The line in my controller where I try to sort_by! the paragraph and sentence isn't working so I just commented it out. Right now that isn't as important for me.

`

2

There are 2 best solutions below

2
CWarrington On

I found a workaround. It does not answer my initial question of creating a scope, but it does solve the issue of my code not working for instances like quote.image

Solution

In my controller when I coded this:

@temp_array << @quotes.select { |quote| quote[:reference_book] == book}
@sorted_quotes << @temp_array

it would put the ActiveRelation inside an array element and then save it to the @temp_array; this is because select pulls all of the instances out of one array that matches the search terms. So, I just needed to undo this by iterating through the @temp_array and adding each element to the @sorted_quotes instead of adding the entire thing.

Here are my changes (also changing some variables to not be 'global' as that's just not needed in this situation.

def quotes
    quotes = Quote.all
    @sorted_quotes = []
    
    order_of_books.each do |book|
        temp_array = []
        if quotes.any? { |quote| quote[:reference_book] == book}
            temp_array << quotes.select { |quote| quote[:reference_book] == book}
            temp_array[0].each do |v|
                @sorted_quotes << v
            end
        end
    end
end

This fixed the issue. I can leave the question open as this solution does not technically answer the initial question about making a scope to do this same thing.

0
Ollie Bennett On

Assuming you may have multiple quotes per book (and will be adding more books over time - without wanting to add new lines to your code), you might want to normalise your data a little better by having a books table, storing the book sort field to capture the preferred sort order for the books (instead of that living in code directly);

# models/book.rb
class Book < ApplicationRecord
  validates :sort, presence: true
end

# models/quote.rb
class Quote < ApplicationRecord
  belongs_to :book
end

# db/schema.rb
create_table "books", force: :cascade do |t|
    t.string "title", null: false
    t.string "author"
    t.integer "sort", null: false
end
create_table "quotes", force: :cascade do |t|
    t.string "text", null: false
    t.bigint "book_id", null: false # < new column
    t.integer "reference_chapter", null: false
    t.integer "reference_paragraph", null: false
    t.integer "reference_sentence", null: false
    t.string "image"
    t.text "notes"
    # TODO: you will also want a foreign key constraint here for quote.book_id
end

Then you could use this relationship to sort quotes, passing multiple columns to the order method - i.e. sort by book.sort first, then by reference_chapter (for any quotes in the same book), then by reference_paragraph (for any quotes in the same chapter) etc. As a best practice, I would suggest using quotes.id as a final disambiguator in case there were multiple quotes from the same book+chapter+paragraph+sentence.

# models/quote.rb
class Quote < ApplicationRecord
  scope :sorted, -> { joins(:book).order('books.sort ASC, reference_chapter ASC, reference_paragraph ASC, reference_sentence ASC, id ASC') }
end