From d325737f3b739aa9fbe2b04199a4462854a52bae Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 22:35:26 +0100 Subject: [PATCH 1/5] feat: add search by author along with book title --- backend/src/db/schema/books.ts | 2 + .../books/resolvers/Query/searchBooks.ts | 26 +++++++---- backend/src/schema/books/schema.graphql | 5 ++- backend/src/schema/schema.generated.graphqls | 6 ++- backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 2 +- frontend/app/api/types/graphql.generated.ts | 2 +- .../home/ForYouSection/ForYouSection.tsx | 2 +- .../SurpriseMeButton/SurpriseMeButton.tsx | 2 +- frontend/app/hooks/useSearchBooks.ts | 10 ++--- frontend/app/hooks/useSuggestedBooks.ts | 8 ++-- frontend/app/routes/book/selectedBook.tsx | 2 +- frontend/app/routes/books.test.tsx | 44 +++++++++---------- frontend/app/routes/books.tsx | 2 +- frontend/app/routes/home.test.tsx | 12 ++--- frontend/app/routes/home.tsx | 4 +- frontend/app/test/mocks.ts | 2 +- 17 files changed, 73 insertions(+), 60 deletions(-) diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index a4d5c523..1a508845 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -57,6 +57,8 @@ const books = pgTable( // Index for fuzzy title search (using the pg_trgm extension). // Also speeds up the ILIKE '%...%' title search index("books_title_trgm_idx").using("gin", t.title.op("gin_trgm_ops")), + // Same as above, for searching by author + index("books_author_trgm_idx").using("gin", t.author.op("gin_trgm_ops")), ], ); diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index b536cd6f..43f2845c 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -25,7 +25,7 @@ const MOST_RATINGS_FIRST = [ ]; const ORDER_BY: Record> = { - // The default order. Relevance has no direction. A fuzzy title search is + // The default order. Relevance has no direction. A fuzzy search is // sorted by similarity instead, see below RELEVANCE: { ASC: MOST_RATINGS_FIRST, @@ -82,11 +82,18 @@ export const searchBooks: NonNullable = async ( ) => { const conditions: SQL[] = []; - if (args.title && args.fuzzy) { - // <% is the word similarity operator using the pg_trgm extension - conditions.push(sql`${args.title} <% ${books.title}`); - } else if (args.title) { - conditions.push(ilike(books.title, `%${args.title}%`)); + // <% is the word similarity operator using the pg_trgm extension + if (args.query) { + const queryFilter = args.fuzzy + ? or( + sql`${args.query} <% ${books.title}`, + sql`${args.query} <% ${books.author}`, + ) + : or( + ilike(books.title, `%${args.query}%`), + ilike(books.author, `%${args.query}%`), + ); + if (queryFilter) conditions.push(queryFilter); } if (args.series) conditions.push(ilike(books.series, `%${args.series}%`)); @@ -128,11 +135,12 @@ export const searchBooks: NonNullable = async ( const sortBy = args.sortBy; let orderBy = ORDER_BY[sortBy][args.sortDirection]; - // The most relevant books for a fuzzy search are the most similar titles - if (sortBy === "RELEVANCE" && args.fuzzy && args.title) { + // The most relevant books for a fuzzy search are the most similar titles and + // authors. A book counts by its best match + if (sortBy === "RELEVANCE" && args.fuzzy && args.query) { orderBy = [ // Safe from SQL injection. Values are passed as parameters to the query by Drizzle. - sql`word_similarity(${args.title}, ${books.title}) DESC`, + sql`GREATEST(word_similarity(${args.query}, ${books.title}), word_similarity(${args.query}, ${books.author})) DESC`, asc(books.id), ]; } diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index 76df77b5..cd1384e3 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -40,8 +40,9 @@ extend type Query { bookFormats: [String!]! searchBooks( - title: String - "Match titles that are similar to the title, for example with typos, ordered by similarity" + "Match books where either the title or the author contains the query" + query: String + "Match titles and authors that are similar to the query, for example with typos, ordered by similarity" fuzzy: Boolean = false series: String author: String diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index e1c78b95..c3550373 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -59,7 +59,7 @@ type Query { bookFormat: String """ - Match titles that are similar to the title, for example with typos, ordered by similarity + Match titles and authors that are similar to the query, for example with typos, ordered by similarity """ fuzzy: Boolean = false genres: [String!] @@ -70,10 +70,12 @@ type Query { minRatings: Int offset: Int = 0 publisher: String + + """Match books where either the title or the author contains the query""" + query: String series: String sortBy: BookSortField! = RELEVANCE sortDirection: SortDirection! = ASC - title: String ): BookSearchResult! } diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index fa3c9100..f9e415b1 100644 --- a/backend/src/schema/typeDefs.generated.ts +++ b/backend/src/schema/typeDefs.generated.ts @@ -1,2 +1,2 @@ import type { DocumentNode } from 'graphql'; - export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}}},"defaultValue":{"kind":"EnumValue","value":"RELEVANCE"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishYear"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RELEVANCE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PUBLISH_YEAR"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file + export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match books where either the title or the author contains the query","block":false},"name":{"kind":"Name","value":"query"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles and authors that are similar to the query, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}}},"defaultValue":{"kind":"EnumValue","value":"RELEVANCE"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishYear"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RELEVANCE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PUBLISH_YEAR"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 4559a5af..ac4a8234 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -111,10 +111,10 @@ export type QuerysearchBooksArgs = { minRatings?: InputMaybe; offset?: InputMaybe; publisher?: InputMaybe; + query?: InputMaybe; series?: InputMaybe; sortBy?: BookSortField; sortDirection?: SortDirection; - title?: InputMaybe; }; export type SortDirection = diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 3ec42280..9c21ed9f 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -55,7 +55,7 @@ export type RemoveFavoriteBookMutationVariables = Exact<{ export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean }; export type SearchBooksQueryVariables = Exact<{ - title?: string | null | undefined; + query?: string | null | undefined; fuzzy?: boolean | null | undefined; genres?: Array | string | null | undefined; language?: string | null | undefined; diff --git a/frontend/app/components/home/ForYouSection/ForYouSection.tsx b/frontend/app/components/home/ForYouSection/ForYouSection.tsx index 38cd1054..4b7ee034 100644 --- a/frontend/app/components/home/ForYouSection/ForYouSection.tsx +++ b/frontend/app/components/home/ForYouSection/ForYouSection.tsx @@ -40,7 +40,7 @@ function ForYouSection() { genres: genre ? [genre] : undefined, minRatings: MIN_RATINGS, }, - title: "", + query: "", // Fetch extra, since books the user has already saved are left out. limit: RECOMMENDATION_LIMIT * 3, offset: 0, diff --git a/frontend/app/components/home/SurpriseMeButton/SurpriseMeButton.tsx b/frontend/app/components/home/SurpriseMeButton/SurpriseMeButton.tsx index cd963207..f0b3393a 100644 --- a/frontend/app/components/home/SurpriseMeButton/SurpriseMeButton.tsx +++ b/frontend/app/components/home/SurpriseMeButton/SurpriseMeButton.tsx @@ -23,7 +23,7 @@ function SurpriseMeButton({ bookCount }: SurpriseMeButtonProps) { try { const { data } = await fetchBook({ variables: { - title: "", + query: "", limit: 1, offset: Math.floor(Math.random() * bookCount), }, diff --git a/frontend/app/hooks/useSearchBooks.ts b/frontend/app/hooks/useSearchBooks.ts index 94997f9f..e7f77f07 100644 --- a/frontend/app/hooks/useSearchBooks.ts +++ b/frontend/app/hooks/useSearchBooks.ts @@ -12,7 +12,7 @@ const SEARCH_BOOKS: TypedDocumentNode< SearchBooksQueryVariables > = gql` query SearchBooks( - $title: String + $query: String $fuzzy: Boolean $genres: [String!] $language: String @@ -26,7 +26,7 @@ const SEARCH_BOOKS: TypedDocumentNode< $offset: Int ) { searchBooks( - title: $title + query: $query fuzzy: $fuzzy genres: $genres language: $language @@ -55,7 +55,7 @@ const SEARCH_BOOKS: TypedDocumentNode< function useSearchBooks({ filters, - title, + query, fuzzy, limit, offset, @@ -63,7 +63,7 @@ function useSearchBooks({ enabled = true, }: { filters: BookFilters; - title: string; + query: string; fuzzy?: boolean; limit: number; offset: number; @@ -75,7 +75,7 @@ function useSearchBooks({ enabled ? { variables: { - title, + query, fuzzy, genres: filters.genres, language: filters.language, diff --git a/frontend/app/hooks/useSuggestedBooks.ts b/frontend/app/hooks/useSuggestedBooks.ts index 618b6916..54361736 100644 --- a/frontend/app/hooks/useSuggestedBooks.ts +++ b/frontend/app/hooks/useSuggestedBooks.ts @@ -6,11 +6,11 @@ import { BOOK_PAGE_LIMIT, MIN_RATINGS } from "~/lib/constants"; /** * Suggested books when a search has no results. * - * The suggested books are books with a similar title to the search query. + * The suggested books are books with a title or author similar to the search query. * If none are found, we use top-rated books instead. * For top-rated books, we remove the filters if there is no search query, * since that means the filters are the reason there are no results. - * The sort only applies to similar titles, top-rated books are always sorted by rating. + * The sort only applies to similar books, top-rated books are always sorted by rating. */ function useSuggestedBooks({ query, @@ -29,7 +29,7 @@ function useSuggestedBooks({ const similar = useSearchBooks({ filters, - title: query, + query, fuzzy: true, limit: BOOK_PAGE_LIMIT, offset, @@ -45,7 +45,7 @@ function useSuggestedBooks({ ...keptFilters, minRatings: Math.max(keptFilters.minRatings ?? 0, MIN_RATINGS), }, - title: "", + query: "", limit: BOOK_PAGE_LIMIT, offset, sort: "rating_desc", diff --git a/frontend/app/routes/book/selectedBook.tsx b/frontend/app/routes/book/selectedBook.tsx index 85cd25f0..218e759d 100644 --- a/frontend/app/routes/book/selectedBook.tsx +++ b/frontend/app/routes/book/selectedBook.tsx @@ -34,7 +34,7 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) { const { books: searchBooks } = useSearchBooks({ filters, - title: query, + query, limit: BOOK_PAGE_LIMIT, offset: (page - 1) * BOOK_PAGE_LIMIT, sort, diff --git a/frontend/app/routes/books.test.tsx b/frontend/app/routes/books.test.tsx index 365f8e8e..680512e8 100644 --- a/frontend/app/routes/books.test.tsx +++ b/frontend/app/routes/books.test.tsx @@ -52,7 +52,7 @@ describe("BooksPage", () => { mocks: [ searchBooksMock( { - title: "blade", + query: "blade", genres: ["Fantasy", "Romance"], language: "English", bookFormat: "Paperback", @@ -80,14 +80,14 @@ describe("BooksPage", () => { renderRoutes(routes, { initialEntries: ["/books?q=zzzz"], mocks: [ - searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, []), + searchBooksMock({ query: "zzzz", limit: 12, offset: 0 }, []), searchBooksMock( - { title: "zzzz", fuzzy: true, limit: 12, offset: 0 }, + { query: "zzzz", fuzzy: true, limit: 12, offset: 0 }, [], ), searchBooksMock( { - title: "", + query: "", minRatings: 10000, sortBy: "RATING", sortDirection: "DESC", @@ -109,9 +109,9 @@ describe("BooksPage", () => { renderRoutes(routes, { initialEntries: ["/books?q=blade+itslef"], mocks: [ - searchBooksMock({ title: "blade itslef", limit: 12, offset: 0 }, []), + searchBooksMock({ query: "blade itslef", limit: 12, offset: 0 }, []), searchBooksMock({ - title: "blade itslef", + query: "blade itslef", fuzzy: true, limit: 12, offset: 0, @@ -124,7 +124,7 @@ describe("BooksPage", () => { ).toBeInTheDocument(); expect( screen.getByText( - "No books found for this search. However, we found some books with similar titles.", + "No books found for this search. However, we found some books with similar titles or authors.", ), ).toBeInTheDocument(); expect(await findBookCards()).toHaveLength(3); @@ -136,7 +136,7 @@ describe("BooksPage", () => { mocks: [ searchBooksMock( { - title: "blade itslef", + query: "blade itslef", sortBy: "RATING", sortDirection: "DESC", limit: 12, @@ -146,7 +146,7 @@ describe("BooksPage", () => { ), searchBooksMock( { - title: "blade itslef", + query: "blade itslef", fuzzy: true, sortBy: "RATING", sortDirection: "DESC", @@ -173,12 +173,12 @@ describe("BooksPage", () => { initialEntries: ["/books?q=zzzz&genres=Fantasy"], mocks: [ searchBooksMock( - { title: "zzzz", genres: ["Fantasy"], limit: 12, offset: 0 }, + { query: "zzzz", genres: ["Fantasy"], limit: 12, offset: 0 }, [], ), searchBooksMock( { - title: "zzzz", + query: "zzzz", fuzzy: true, genres: ["Fantasy"], limit: 12, @@ -187,7 +187,7 @@ describe("BooksPage", () => { [], ), searchBooksMock({ - title: "", + query: "", genres: ["Fantasy"], minRatings: 10000, sortBy: "RATING", @@ -218,7 +218,7 @@ describe("BooksPage", () => { mocks: [ searchBooksMock( { - title: "", + query: "", genres: ["Fantasy"], maxPages: 1, limit: 12, @@ -227,7 +227,7 @@ describe("BooksPage", () => { [], ), searchBooksMock({ - title: "", + query: "", minRatings: 10000, sortBy: "RATING", sortDirection: "DESC", @@ -253,12 +253,12 @@ describe("BooksPage", () => { initialEntries: ["/books?q=zzzz&minRatings=50000"], mocks: [ searchBooksMock( - { title: "zzzz", minRatings: 50000, limit: 12, offset: 0 }, + { query: "zzzz", minRatings: 50000, limit: 12, offset: 0 }, [], ), searchBooksMock( { - title: "zzzz", + query: "zzzz", fuzzy: true, minRatings: 50000, limit: 12, @@ -267,7 +267,7 @@ describe("BooksPage", () => { [], ), searchBooksMock({ - title: "", + query: "", minRatings: 50000, sortBy: "RATING", sortDirection: "DESC", @@ -286,13 +286,13 @@ describe("BooksPage", () => { initialEntries: ["/books?q=zzzz"], // The suggestions are reused from Apollo's cache on the details page. mocks: [ - searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, []), + searchBooksMock({ query: "zzzz", limit: 12, offset: 0 }, []), searchBooksMock( - { title: "zzzz", fuzzy: true, limit: 12, offset: 0 }, + { query: "zzzz", fuzzy: true, limit: 12, offset: 0 }, [], ), searchBooksMock({ - title: "", + query: "", minRatings: 10000, sortBy: "RATING", sortDirection: "DESC", @@ -332,7 +332,7 @@ describe("BooksPage", () => { { request: { query: SEARCH_BOOKS, - variables: { title: "", limit: 12, offset: 0 }, + variables: { query: "", limit: 12, offset: 0 }, }, error: new Error("Network error"), }, @@ -346,7 +346,7 @@ describe("BooksPage", () => { it("opens a book's details and goes back to the same search", async () => { const user = userEvent.setup(); - const variables = { title: "blade", limit: 12, offset: 0 }; + const variables = { query: "blade", limit: 12, offset: 0 }; renderRoutes(routes, { initialEntries: ["/books?q=blade"], // Each request is mocked once: the search results are reused from diff --git a/frontend/app/routes/books.tsx b/frontend/app/routes/books.tsx index c08e3932..3f88646a 100644 --- a/frontend/app/routes/books.tsx +++ b/frontend/app/routes/books.tsx @@ -39,7 +39,7 @@ export default function BooksPage() { const { books, totalCount, loading, error } = useSearchBooks({ filters, - title: query, + query, limit: BOOK_PAGE_LIMIT, offset: (page - 1) * BOOK_PAGE_LIMIT, sort, diff --git a/frontend/app/routes/home.test.tsx b/frontend/app/routes/home.test.tsx index 3f60554f..bfff29e2 100644 --- a/frontend/app/routes/home.test.tsx +++ b/frontend/app/routes/home.test.tsx @@ -55,7 +55,7 @@ const favoritesMocks = (favoriteGenres: string[], bookIds = [savedBook.id]) => [ const recommendationsMock = (genre: string) => searchBooksMock({ - title: "", + query: "", genres: [genre], minRatings: MIN_RATINGS, sortBy: "RATING", @@ -65,7 +65,7 @@ const recommendationsMock = (genre: string) => }); const topRatedMock = searchBooksMock({ - title: "", + query: "", minRatings: MIN_RATINGS, sortBy: "RATING", sortDirection: "DESC", @@ -75,7 +75,7 @@ const topRatedMock = searchBooksMock({ // The number of books in the whole library, for the stats and "Surprise me". const bookCountMock = searchBooksMock( - { title: "", limit: 0, offset: 0 }, + { query: "", limit: 0, offset: 0 }, [], apiSearchBooks.length, ); @@ -105,7 +105,7 @@ describe("HomePage", () => { topRatedMock, bookCountMock, bookFiltersMock(), - searchBooksMock({ title: "blade", limit: 12, offset: 0 }), + searchBooksMock({ query: "blade", limit: 12, offset: 0 }), ], }); @@ -159,7 +159,7 @@ describe("HomePage", () => { bookCountMock, bookFiltersMock(), searchBooksMock({ - title: "", + query: "", genres: ["Fantasy"], limit: 12, offset: 0, @@ -224,7 +224,7 @@ describe("HomePage", () => { bookCountMock, bookFiltersMock(), // 0.5 of 3 books is offset 1, the second book. - searchBooksMock({ title: "", limit: 1, offset: 1 }, [ + searchBooksMock({ query: "", limit: 1, offset: 1 }, [ apiSearchBooks[1], ]), ], diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index 498d4274..a74a71da 100644 --- a/frontend/app/routes/home.tsx +++ b/frontend/app/routes/home.tsx @@ -23,7 +23,7 @@ export default function HomePage() { const { books, loading } = useSearchBooks({ filters: { minRatings: MIN_RATINGS }, - title: "", + query: "", limit: TOP_RATED_LIMIT, offset: 0, sort: "rating_desc", @@ -32,7 +32,7 @@ export default function HomePage() { // Only the number of books in the whole library, for the stats and "Surprise me" const { totalCount: bookCount } = useSearchBooks({ filters: {}, - title: "", + query: "", limit: 0, offset: 0, }); diff --git a/frontend/app/test/mocks.ts b/frontend/app/test/mocks.ts index 84c580d4..859f4446 100644 --- a/frontend/app/test/mocks.ts +++ b/frontend/app/test/mocks.ts @@ -11,7 +11,7 @@ import { apiSearchBooks } from "~/test/fixtures/books"; // The variables the pages send for "/" (no search, filters, sort or page). const defaultSearchVariables: SearchBooksQueryVariables = { - title: "", + query: "", limit: BOOK_PAGE_LIMIT, offset: 0, }; From 20c53ea7311419402300572f9b83dd3db8b6fe00 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 22:35:38 +0100 Subject: [PATCH 2/5] docs: remove comment about incomplete author search --- docs/choices-and-reasoning.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/choices-and-reasoning.md b/docs/choices-and-reasoning.md index ac78cbef..47271ccc 100644 --- a/docs/choices-and-reasoning.md +++ b/docs/choices-and-reasoning.md @@ -92,4 +92,3 @@ Instead, we use CI for other tasks, such as linting and formatting. - **Filters:** We filter on genres, language, book format and number of pages. The specification also mentioned author, publisher, place and person, which we have not implemented yet. - **Login:** Instead of Google login, we support email/password and GitHub and Discord login. Google was rejected since it required https connection, which we do not provide. - **Reviews:** Reviews with a star rating are planned for the next delivery. -- **Search:** The specification mentioned search on both title and author. We currently search on titles only, and searching by author is planned for the next delivery. From 1e2d4fce08c6d17da49283756cc860098b814530 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 22:36:04 +0100 Subject: [PATCH 3/5] feat: specify "title or author" in search field --- frontend/app/components/books/SearchBar/SearchBar.tsx | 2 +- frontend/app/components/home/HeroSection/HeroSection.tsx | 2 +- .../app/components/home/HowItWorksSection/HowItWorksSection.tsx | 2 +- frontend/app/routes/books.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/app/components/books/SearchBar/SearchBar.tsx b/frontend/app/components/books/SearchBar/SearchBar.tsx index 6225530e..621a04a2 100644 --- a/frontend/app/components/books/SearchBar/SearchBar.tsx +++ b/frontend/app/components/books/SearchBar/SearchBar.tsx @@ -77,7 +77,7 @@ function SearchBar({ resultCount }: SearchBarProps) { type="search" name="search" aria-label="Search for books" - placeholder="Search books..." + placeholder="Search by title or author..." value={inputField} onChange={(e) => { newQueryFromInputRef.current = true; diff --git a/frontend/app/components/home/HeroSection/HeroSection.tsx b/frontend/app/components/home/HeroSection/HeroSection.tsx index 0cef8142..91cb3a3b 100644 --- a/frontend/app/components/home/HeroSection/HeroSection.tsx +++ b/frontend/app/components/home/HeroSection/HeroSection.tsx @@ -76,7 +76,7 @@ function HeroSection({ type="search" name={field.name} aria-label="Search for books" - placeholder="Search books..." + placeholder="Search by title or author..." value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} diff --git a/frontend/app/components/home/HowItWorksSection/HowItWorksSection.tsx b/frontend/app/components/home/HowItWorksSection/HowItWorksSection.tsx index 0c9c5d58..18c82118 100644 --- a/frontend/app/components/home/HowItWorksSection/HowItWorksSection.tsx +++ b/frontend/app/components/home/HowItWorksSection/HowItWorksSection.tsx @@ -7,7 +7,7 @@ const STEPS = [ icon: SearchIcon, title: "Search and filter", description: - "Find books by title, then narrow them down by genre, language, format and length.", + "Find books by title or author, then narrow them down by genre, language, format and length.", }, { icon: HeartIcon, diff --git a/frontend/app/routes/books.tsx b/frontend/app/routes/books.tsx index 3f88646a..d76571ce 100644 --- a/frontend/app/routes/books.tsx +++ b/frontend/app/routes/books.tsx @@ -111,7 +111,7 @@ export default function BooksPage() {

No books found for this search. However, we found some{" "} {similarTitles - ? "books with similar titles" + ? "books with similar titles or authors" : query && hasFilters ? "top-rated books matching your filters" : "top-rated books you might like"} From 6d24819d502e13561ffe34b2338654431aca65c2 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 22:36:15 +0100 Subject: [PATCH 4/5] test(e2e): searching for authors --- frontend/e2e/books.spec.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/e2e/books.spec.ts b/frontend/e2e/books.spec.ts index 2d8cd58e..5796ec09 100644 --- a/frontend/e2e/books.spec.ts +++ b/frontend/e2e/books.spec.ts @@ -16,6 +16,26 @@ test("searching updates the URL and the results", async ({ page }) => { ); }); +test("searching finds books by author", async ({ page }) => { + await page.goto("books"); + + await page + .getByRole("searchbox", { name: "Search for books" }) + .fill("tolkien"); + + await expect(page).toHaveURL("books?q=tolkien"); + await expect(bookCards(page).first()).toContainText("Tolkien"); +}); + +test("an author with a typo gives suggestions", async ({ page }) => { + await page.goto("books?q=tolkin"); + + await expect( + page.getByText("we found some books with similar titles or authors"), + ).toBeVisible(); + await expect(bookCards(page).first()).toContainText("Tolkien"); +}); + test("filtering by genre can be reset", async ({ page }) => { await page.goto("books"); From 2f496df86a2438154a3f3f88283d669512d676ea Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 22:36:32 +0100 Subject: [PATCH 5/5] chore(db): add migration for books author index --- .../20261111212139_author_index/migration.sql | 1 + .../20261111212139_author_index/snapshot.json | 1446 +++++++++++++++++ 2 files changed, 1447 insertions(+) create mode 100644 backend/drizzle/20261111212139_author_index/migration.sql create mode 100644 backend/drizzle/20261111212139_author_index/snapshot.json diff --git a/backend/drizzle/20261111212139_author_index/migration.sql b/backend/drizzle/20261111212139_author_index/migration.sql new file mode 100644 index 00000000..6bfb4f66 --- /dev/null +++ b/backend/drizzle/20261111212139_author_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX "books_author_trgm_idx" ON "books" USING gin ("author" gin_trgm_ops); \ No newline at end of file diff --git a/backend/drizzle/20261111212139_author_index/snapshot.json b/backend/drizzle/20261111212139_author_index/snapshot.json new file mode 100644 index 00000000..3352ccb5 --- /dev/null +++ b/backend/drizzle/20261111212139_author_index/snapshot.json @@ -0,0 +1,1446 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "442822f4-8d37-421a-bce7-0c72b40d8c4b", + "prevIds": [ + "cc1b05d5-b0a3-484c-bc2c-f2a57c417942" + ], + "ddl": [ + { + "isRlsEnabled": false, + "name": "account", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "session", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "user", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "verification", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "book_genres", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "book_to_genres", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "books", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "favorite_books", + "entityType": "tables", + "schema": "public" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_expires_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_address", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_agent", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "email_verified", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bio", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "identifier", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "book_genres_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "book_genres" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "genre", + "entityType": "columns", + "schema": "public", + "table": "book_genres" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "book_id", + "entityType": "columns", + "schema": "public", + "table": "book_to_genres" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "genre_id", + "entityType": "columns", + "schema": "public", + "table": "book_to_genres" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "books_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "varchar(13)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "isbn", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "series", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "book_format", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "edition", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pages", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publisher", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publish_date", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publish_year", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cover_img_url", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "real", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rating", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "num_ratings", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "favorite_books" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "book_id", + "entityType": "columns", + "schema": "public", + "table": "favorite_books" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "favorite_books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "session_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "session" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "identifier", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "verification_identifier_idx", + "entityType": "indexes", + "schema": "public", + "table": "verification" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "genre_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "book_to_genres_genre_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "book_to_genres" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "book_format", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_book_format_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "rating", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "num_ratings", + "isExpression": false, + "asc": false, + "nullsFirst": true, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_rating_num_ratings_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "title", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_title_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "num_ratings", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_num_ratings_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pages", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_pages_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "publish_year", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_publish_year_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "publish_year", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_publish_year_asc_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "title", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": { + "name": "gin_trgm_ops", + "default": false + } + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "books_title_trgm_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": { + "name": "gin_trgm_ops", + "default": false + } + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "books_author_trgm_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "favorite_books_user_id_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "favorite_books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "book_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "favorite_books_book_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "favorite_books" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "account_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "session_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "session" + }, + { + "nameExplicit": false, + "columns": [ + "book_id" + ], + "schemaTo": "public", + "tableTo": "books", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "book_to_genres_book_id_books_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "book_to_genres" + }, + { + "nameExplicit": false, + "columns": [ + "genre_id" + ], + "schemaTo": "public", + "tableTo": "book_genres", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "book_to_genres_genre_id_book_genres_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "book_to_genres" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "favorite_books_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "favorite_books" + }, + { + "nameExplicit": false, + "columns": [ + "book_id" + ], + "schemaTo": "public", + "tableTo": "books", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "favorite_books_book_id_books_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "favorite_books" + }, + { + "columns": [ + "book_id", + "genre_id" + ], + "nameExplicit": false, + "name": "book_to_genres_pkey", + "entityType": "pks", + "schema": "public", + "table": "book_to_genres" + }, + { + "columns": [ + "user_id", + "book_id" + ], + "nameExplicit": false, + "name": "favorite_books_pkey", + "entityType": "pks", + "schema": "public", + "table": "favorite_books" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pkey", + "schema": "public", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pkey", + "schema": "public", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pkey", + "schema": "public", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pkey", + "schema": "public", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "book_genres_pkey", + "schema": "public", + "table": "book_genres", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "books_pkey", + "schema": "public", + "table": "books", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "session_token_key", + "schema": "public", + "table": "session", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "user_email_key", + "schema": "public", + "table": "user", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "genre" + ], + "nullsNotDistinct": false, + "name": "book_genres_genre_key", + "schema": "public", + "table": "book_genres", + "entityType": "uniques" + } + ], + "renames": [] +} \ No newline at end of file