Coverage for app / services / search_service.py: 69%
52 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-06 04:49 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-06 04:49 +0000
1"""
2This module provides search functionality for books in the database.
4It includes methods to search books by categories, authors, or titles, with optional
5filters for user-specific status and feedback. The queries are tailored to return
6sorted results, refined to meet various search criteria. If a user is authenticated,
7search results will include personalized status and feedback data.
8"""
9from flask_security import current_user
10from sqlalchemy import asc, and_, exists, select, literal
11from sqlalchemy.orm import contains_eager, noload, make_transient
13from app import db
14from app.models import Book, ReadingStatus, Feedback, TagBook, Tag
17def search_by_categories(categories, status_filter: str = None,
18 feedback_filter: str = None, tag_filter: list[str] = None) -> list[Book]:
19 """
20 Searches for books based on categories and optional filters for status and feedback.
22 This retrieves a list of books matching one or more categories. Results are sorted
23 alphabetically by title. Optional filters can refine search results by user status
24 or feedback.
25 """
26 if not categories: 26 ↛ 27line 26 didn't jump to line 27 because the condition on line 26 was never true
27 return [] # Return an empty list if no categories are provided
29 # Query to search and sort books based on the provided requirements
30 query = ((db.session.query(Book)
31 .filter(Book.categories_flat.in_(categories))) # match in one of the categories
32 .order_by(asc(Book.title))) # sort by title
34 query = _add_user_status_and_feedback_joins(query)
36 return _finish_building_query_and_execute(feedback_filter, query, status_filter, tag_filter)
39def search_by_author(author: str, status_filter: str,
40 feedback_filter: str, tag_filter: list[str]) -> list[Book]:
41 """Search for books by author's name."""
42 return _search_by_attribute("author", author, status_filter, feedback_filter, tag_filter)
45def search_by_title(title, status_filter: str, feedback_filter: str,
46 tag_filter: list[str]) -> list[Book]:
47 """Search for books by title."""
48 return _search_by_attribute("title", title, status_filter, feedback_filter, tag_filter)
51_VALID_SEARCH_BY_ATTRIBUTES = {"author", "title"}
54def _search_by_attribute(attribute: str, value: str, status_filter: str = None,
55 feedback_filter: str = None, tag_filter: list[str] = None) -> list[Book]:
56 """
57 Searches for books in the database by a specified attribute and value. Filters
58 for status and feedback can also be applied.
60 This function queries the `Book` model, using a case-insensitive partial
61 match for the attribute's value. Filters can refine results by book status
62 or user feedback. When the value is "*", all books are returned sorted
63 by the attribute.
65 :return: A list of `Book` instances that meet the filters. An empty list is
66 returned if no matches are found.
67 :rtype: list[Book]
68 :raises ValueError: If the attribute provided is not valid.
69 """
70 if attribute not in _VALID_SEARCH_BY_ATTRIBUTES: 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true
71 raise ValueError(
72 f"Invalid attribute '{attribute}'. Must be one of {_VALID_SEARCH_BY_ATTRIBUTES}."
73 )
74 if not value: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true
75 return []
77 query = _add_user_status_and_feedback_joins(db.session.query(Book))
79 # Order by the selected attribute
80 query = query.order_by(asc(getattr(Book, attribute)))
82 # Handle the special case for "*" to return all books sorted by the attribute
83 if value != "*":
84 # Perform a case-insensitive partial match (using ilike)
85 query = query.filter(getattr(Book, attribute).ilike(f"%{value}%"))
87 return _finish_building_query_and_execute(feedback_filter, query, status_filter, tag_filter)
90def _finish_building_query_and_execute(feedback_filter, query, status_filter, tag_filter):
91 query = _add_status_and_feedback_filters(query, status_filter, feedback_filter)
92 # Add the tag filter if provided
93 query = query.filter(Book.tags.any(Tag.name.in_(tag_filter))) if tag_filter else query
94 # execute the query
95 books = query.all()
96 for book in books:
97 make_transient(book)
98 db.session.expire_all() # expire all books to prevent stale data from being returned
99 return books
102def _add_status_and_feedback_filters(query, status_filter, feedback_filter):
103 if status_filter: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 if status_filter != "none":
105 # handles read and up_next
106 query = query.filter(ReadingStatus.status == status_filter)
107 else:
108 # finds only books without a status set
109 query = query.filter(ReadingStatus.status.is_(None))
110 if feedback_filter: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true
111 if feedback_filter != "none":
112 # handles like and dislike
113 query = query.filter(Feedback.feedback == feedback_filter)
114 else:
115 # finds only books without a feedback set
116 query = query.filter(Feedback.feedback.is_(None))
117 return query
120def _add_user_status_and_feedback_joins(query):
121 user_id = current_user.id if current_user.is_authenticated else None
122 if user_id: 122 ↛ 126line 122 didn't jump to line 126 because the condition on line 122 was never true
123 # Define a constant for EXISTS check
124 # pylint: disable=invalid-name
125 # noinspection PyPep8Naming
126 EXISTS_CHECK = literal(1).label('exists_check')
128 # Define the subquery to check for user's tags
129 user_tag_exists = (
130 select(EXISTS_CHECK)
131 .select_from(Tag)
132 .where(
133 and_(
134 Tag.id == TagBook.tag_id,
135 Tag.owner_id == user_id
136 )
137 )
138 .correlate(TagBook)
139 .alias('user_tag_check')
140 )
142 query = (
143 query
144 # Join with ReadingStatus
145 .outerjoin(
146 ReadingStatus,
147 and_(
148 ReadingStatus.book_id == Book.id,
149 ReadingStatus.user_id == user_id
150 )
151 )
152 # Join with Feedback
153 .outerjoin(
154 Feedback,
155 and_(
156 Feedback.book_id == Book.id,
157 Feedback.user_id == user_id
158 )
159 )
160 # Join with TagBook using the exists subquery
161 .outerjoin(
162 TagBook,
163 and_(
164 TagBook.book_id == Book.id,
165 exists(user_tag_exists)
166 )
167 )
168 # Join with Tag
169 .outerjoin(
170 Tag,
171 and_(
172 Tag.id == TagBook.tag_id,
173 Tag.owner_id == user_id
174 )
175 )
176 .options(
177 contains_eager(Book.reading_statuses).load_only(
178 ReadingStatus.id,
179 ReadingStatus.status
180 ).noload(ReadingStatus.user),
181 contains_eager(Book.feedbacks).load_only(
182 Feedback.id,
183 Feedback.feedback
184 ).noload(Feedback.user),
185 contains_eager(Book.tags).contains_eager(TagBook.tag),
186 contains_eager(Book.tags).load_only(
187 TagBook.id,
188 TagBook.tag_id
189 ),
190 contains_eager(Book.tags).contains_eager(TagBook.tag).load_only(
191 Tag.id,
192 Tag.name,
193 Tag.color
194 ),
195 contains_eager(Book.tags).noload(TagBook.book),
196 contains_eager(Book.tags).contains_eager(TagBook.tag).noload(Tag.owner)
197 )
198 )
199 else:
200 # If no user logged in, load no relationships
201 query = query.options(
202 noload(Book.reading_statuses),
203 noload(Book.feedbacks),
204 noload(Book.tags)
205 )
207 return query