Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/controllers/api/qdc/related_verses_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module Api::Qdc
class RelatedVersesController < ApiController
before_action :init_presenter

def by_key
render
end

private

def init_presenter
@presenter = Qdc::RelatedVersesPresenter.new(params, action_name)
end
end
end


38 changes: 38 additions & 0 deletions app/finders/qdc/related_verses_finder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

class Qdc::RelatedVersesFinder < Finder
attr_reader :verse, :language

def initialize(params)
super(params)
@language = Language.find_with_id_or_iso_code(params[:language] || 'en')
end

def find_verse
strong_memoize :verse do
@verse = Verse.find_by(verse_key: params[:verse_key])
raise RestApi::RecordNotFound.new("Verse #{params[:verse_key]} not found") unless @verse
@verse
end
end

def load_related_verses
@total_records = base_scope.count
@results = base_scope.limit(per_page).offset((current_page - 1) * per_page)
end

def chapters
strong_memoize :chapters do
other_verse_ids = @results.map { |rv| rv.other_verse_for(find_verse.id).id }
Chapter.for_related_verses(other_verse_ids, @language)
end
end

private

def base_scope
strong_memoize :base_scope do
RelatedVerse.related_to(find_verse, language: @language)
end
end
end
41 changes: 41 additions & 0 deletions app/models/chapter.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

# == Schema Information
# Schema version: 20260104000003
#
# Table name: chapters
#
Expand Down Expand Up @@ -38,4 +39,44 @@ class Chapter < ApplicationRecord
serialize :pages

default_scope { order 'chapter_number asc' }

# Load chapters with translated names for related verses display
# @param verse_ids [Array<Integer>] IDs of the "other" verses in relationships
# @param language [Language] The language for translated names
# @return [Hash] Chapters indexed by id
def self.for_related_verses(verse_ids, language = nil)
return {} if verse_ids.blank?

chapter_ids = Verse.where(id: verse_ids.uniq).pluck(:chapter_id).uniq
return {} if chapter_ids.empty?

language_ids = [language&.id, Language.default.id].compact.uniq
language_order = if language
sanitize_sql_array([
"CASE WHEN translated_names.language_id = ? THEN 0 ELSE 1 END ASC",
language.id
])
else
'translated_names.language_priority DESC'
end

unscoped
.joins(:translated_name)
.where(id: chapter_ids)
.where(translated_names: { language_id: language_ids })
.order(Arel.sql(language_order))
.includes(:translated_name)
.index_by(&:id)
end

# Get the appropriate display name based on language
# @param language [Language] The requested language
# @return [String] The chapter name (Arabic for ar/ur, simple otherwise)
def display_name_for(language)
if language&.iso_code&.in?(%w[ar ur])
name_arabic
else
name_simple
end
end
end
114 changes: 114 additions & 0 deletions app/models/related_verse.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# frozen_string_literal: true

# == Schema Information
# Schema version: 20260104000003
#
# Table name: related_verses
#
# id :bigint not null, primary key
# approved :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# related_verse_id :bigint not null
# relation_type_id :bigint not null
# verse_id :bigint not null
#
# Indexes
#
# index_related_verses_bidirectional_unique (LEAST(verse_id, related_verse_id), GREATEST(verse_id, related_verse_id), relation_type_id) UNIQUE
# index_related_verses_on_approved (approved)
# index_related_verses_on_related_verse_id (related_verse_id)
# index_related_verses_on_relation_type_id (relation_type_id)
# index_related_verses_on_verse_id (verse_id)
# index_related_verses_unique (verse_id,related_verse_id,relation_type_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_baf905b48a (relation_type_id => public.relation_types.id)
# fk_rails_baf905b48a (relation_type_id => relation_types.id)
# fk_rails_c3e5a96f90 (verse_id => public.verses.id)
# fk_rails_c3e5a96f90 (verse_id => verses.id)
# fk_rails_f9e5b3df4e (related_verse_id => public.verses.id)
# fk_rails_f9e5b3df4e (related_verse_id => verses.id)
#

class RelatedVerse < ApplicationRecord
belongs_to :verse
belongs_to :related_verse, class_name: 'Verse'
belongs_to :relation_type

validates :verse_id, uniqueness: { scope: [:related_verse_id, :relation_type_id] }
validate :verses_are_different
validate :reverse_relationship_does_not_exist

# Bidirectional scope - get all relations for a verse (as source or target)
scope :for_verse, ->(verse_id) {
where(verse_id: verse_id).or(where(related_verse_id: verse_id))
}

# Scope for approved relationships only
scope :approved, -> { where(approved: true) }

# Get all related verses for a given verse, ordered by verse_index
# Returns the "other" verse in each relationship
# @param verse [Verse] The verse to find relations for
# @param language [Language] The language for localized content
# @return [Array<Hash>] Array of related verse data with relation types
def self.related_to(verse, language: nil)
relations = for_verse(verse.id)
.approved
.includes(:relation_type, :verse, :related_verse)
.joins(
sanitize_sql_array([
"INNER JOIN verses AS other_verse ON other_verse.id = " \
"CASE WHEN related_verses.verse_id = ? " \
"THEN related_verses.related_verse_id " \
"ELSE related_verses.verse_id END",
verse.id
])
)
.order('other_verse.verse_index ASC')

if language
relations = relations.includes(relation_type: :relation_type_translations)
end

relations
end

# Returns the "other" verse in the relationship for a given verse_id
# @param current_verse_id [Integer] The verse ID to compare against
# @return [Verse] The other verse in the relationship
def other_verse_for(current_verse_id)
verse_id == current_verse_id ? related_verse : verse
end

private

def verses_are_different
if verse_id == related_verse_id
errors.add(:related_verse_id, "can't be the same as verse")
end
end

# Prevent creating reverse relationships since relationships are bidirectional
# If 1:1 -> 1:2 exists, don't allow 1:2 -> 1:1 with the same relation_type
#
# Note: This validation provides user-friendly error messages but has a race condition.
# The database-level constraint (index_related_verses_bidirectional_unique) is the
# primary protection using LEAST/GREATEST normalization.
def reverse_relationship_does_not_exist
return if verse_id.blank? || related_verse_id.blank? || relation_type_id.blank?

reverse_exists = RelatedVerse.exists?(
verse_id: related_verse_id,
related_verse_id: verse_id,
relation_type_id: relation_type_id
)

if reverse_exists
errors.add(:base, "Relationship already exists (reverse direction)")
end
end
end

54 changes: 54 additions & 0 deletions app/models/relation_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

# == Schema Information
# Schema version: 20260104000003
#
# Table name: relation_types
#
# id :bigint not null, primary key
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_relation_types_on_name (name) UNIQUE
#

class RelationType < ApplicationRecord
has_many :relation_type_translations, dependent: :destroy
has_many :related_verses, dependent: :restrict_with_error

# For eager loading a single translation
has_one :relation_type_translation

validates :name, presence: true, uniqueness: true

# Get localized name with English fallback
# @param language [Language] The requested language
# @return [String] The localized name or the default name
def localized_name_for(language)
return name&.titleize unless language

default_language_id = Language.default.id

# If translations are already loaded, find in memory
if relation_type_translations.loaded?
translation = relation_type_translations.find { |t| t.language_id == language.id }
translation ||= relation_type_translations.find { |t| t.language_id == default_language_id } if language.id != default_language_id
return translation&.name || name&.titleize
end

# Otherwise query with single DB call using fallback
translation = relation_type_translations
.where(language_id: [language.id, default_language_id].uniq)
.order(
Arel.sql(
self.class.sanitize_sql_array(["CASE WHEN language_id = ? THEN 0 ELSE 1 END", language.id])
)
)
.first

translation&.name || name&.titleize
end
end
34 changes: 34 additions & 0 deletions app/models/relation_type_translation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

# == Schema Information
# Schema version: 20260104000002
#
# Table name: relation_type_translations
#
# id :bigint not null, primary key
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# language_id :bigint not null
# relation_type_id :bigint not null
#
# Indexes
#
# index_relation_type_translations_on_language_id (language_id)
# index_relation_type_translations_on_relation_type_id (relation_type_id)
# index_relation_type_translations_unique (relation_type_id,language_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_31710e4052 (language_id => languages.id)
# fk_rails_5dd93b8fe1 (relation_type_id => relation_types.id)
#

class RelationTypeTranslation < ApplicationRecord
belongs_to :relation_type
belongs_to :language

validates :name, presence: true
validates :language_id, uniqueness: { scope: :relation_type_id }
end

8 changes: 7 additions & 1 deletion app/models/verse.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true
# == Schema Information
# Schema version: 20230313013539
# Schema version: 20251224164230
#
# Table name: verses
#
Expand Down Expand Up @@ -94,6 +94,12 @@ class Verse < ApplicationRecord
has_many :roots, through: :words
has_many :audio_files

# Related verses associations (bidirectional)
# Both associations delete their related_verses records when the verse is destroyed.
# Using delete_all for efficiency since RelatedVerse has no destroy callbacks.
has_many :related_verse_records, class_name: 'RelatedVerse', foreign_key: :verse_id, dependent: :delete_all
has_many :inverse_related_verse_records, class_name: 'RelatedVerse', foreign_key: :related_verse_id, dependent: :delete_all

# for eager loading one audio
has_one :audio_file
has_one :audio_segment, class_name: 'Audio::Segment'
Expand Down
40 changes: 40 additions & 0 deletions app/presenters/qdc/related_verses_presenter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

module Qdc
class RelatedVersesPresenter < BasePresenter
def initialize(params, action_name)
super(params)
@finder = Qdc::RelatedVersesFinder.new(params)
end

def related_verses
strong_memoize :related_verses do
finder.load_related_verses
end
end

def chapters
finder.chapters
end

def find_verse
finder.find_verse
end

def get_language
finder.language
end

def pagination
{
current_page: finder.current_page,
next_page: finder.next_page,
total_pages: finder.total_pages,
total_count: finder.total_records,
per_page: finder.per_page
}
end
end
end


Loading