Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add hashtag/status search #138

Merged
merged 1 commit into from
Oct 12, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import Vapor

struct SearchResultDto {
let users: [UserDto]?
let statuses: [UserDto]?
let hashtags: [UserDto]?
let statuses: [StatusDto]?
let hashtags: [HashtagDto]?

init(users: [UserDto]? = nil, statuses: [UserDto]? = nil, hashtags: [UserDto]? = nil) {
init(users: [UserDto]? = nil, statuses: [StatusDto]? = nil, hashtags: [HashtagDto]? = nil) {
self.users = users
self.statuses = statuses
self.hashtags = hashtags
Expand Down
64 changes: 53 additions & 11 deletions Sources/VernissageServer/Services/SearchService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ protocol SearchServiceType: Sendable {
/// A service for searching in the local and remote system.
final class SearchService: SearchServiceType {
func search(query: String, searchType: SearchTypeDto, request: Request) async throws -> SearchResultDto {
let queryWithoutPrefix = String(query.trimmingPrefix("@"))

switch searchType {
case .users:
return await self.searchByUsers(query: query, on: request)
return await self.searchByUsers(query: queryWithoutPrefix, on: request)
case .statuses:
return self.searchByStatuses(query: query, on: request)
return await self.searchByStatuses(query: queryWithoutPrefix, on: request)
case .hashtags:
return self.searchByHashtags(query: query, on: request)
return await self.searchByHashtags(query: queryWithoutPrefix, on: request)
}
}

Expand Down Expand Up @@ -180,24 +182,64 @@ final class SearchService: SearchServiceType {
}
}

private func searchByStatuses(query: String, on request: Request) -> SearchResultDto {
private func searchByStatuses(query: String, on request: Request) async -> SearchResultDto {
// For empty query we don't have to retrieve anything from database and return empty list.
if query.isEmpty {
return SearchResultDto(users: [])
return SearchResultDto(statuses: [])
}

let statuses = try? await Status.query(on: request.db)
.filter(\.$note ~~ query)
.filter(\.$visibility == .public)
.with(\.$user)
.with(\.$attachments) { attachment in
attachment.with(\.$originalFile)
attachment.with(\.$smallFile)
attachment.with(\.$exif)
attachment.with(\.$license)
attachment.with(\.$location) { location in
location.with(\.$country)
}
}
.with(\.$hashtags)
.with(\.$mentions)
.with(\.$category)
.sort(\.$createdAt, .descending)
.paginate(PageRequest(page: 1, per: 20))

guard let statuses else {
return SearchResultDto(statuses: [])
}

let statusesService = request.application.services.statusesService
let statusesDtos = await statusesService.convertToDtos(on: request, statuses: statuses.items)

// TODO: Implement searching by statuses.
return SearchResultDto(statuses: [])
return SearchResultDto(statuses: statusesDtos)
}

private func searchByHashtags(query: String, on request: Request) -> SearchResultDto {
private func searchByHashtags(query: String, on request: Request) async -> SearchResultDto {
// For empty query we don't have to retrieve anything from database and return empty list.
if query.isEmpty {
return SearchResultDto(users: [])
}

// TODO: Implement searching by tags.
return SearchResultDto(hashtags: [])

let queryNormalized = query.uppercased()
let hashtags = try? await TrendingHashtag.query(on: request.db)
.filter(\.$hashtagNormalized ~~ queryNormalized)
.filter(\.$trendingPeriod == .yearly)
.sort(\.$createdAt, .descending)
.paginate(PageRequest(page: 1, per: 100))

guard let hashtags else {
return SearchResultDto(hashtags: [])
}

let baseAddress = request.application.settings.cached?.baseAddress ?? ""
let hashtagDtos = await hashtags.items.asyncMap { hashtag in
HashtagDto(url: "\(baseAddress)/tags/\(hashtag.hashtag)", name: hashtag.hashtag)
}

return SearchResultDto(hashtags: hashtagDtos)
}

private func searchByLocalUsers(query: String, on request: Request) async -> SearchResultDto {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,68 @@ extension ControllersTests {
#expect(searchResultDto.users?.first(where: { $0.userName == "admin" }) != nil, "Admin account should be returned.")
}

@Test("Search result should be returned when local account has been specidfied with @ prefix")
func searchResultShouldBeReturnedWhenLocalAccountHasBeenSpecidfiedWithAtPrefix() async throws {
// Arrange.
_ = try await application.createUser(userName: "eliaszfinder")

// Act.
let searchResultDto = try application.getResponse(
as: .user(userName: "eliaszfinder", password: "p@ssword"),
to: "/search?query=@admin",
version: .v1,
decodeTo: SearchResultDto.self
)

// Assert.
#expect(searchResultDto.users != nil, "Users should be returned.")
#expect((searchResultDto.users?.count ?? 0) > 0, "At least one user should be returned by the search.")
#expect(searchResultDto.users?.first(where: { $0.userName == "admin" }) != nil, "Admin account should be returned.")
}

@Test("Search result should be returned when existing hashtag has been specidfied")
func searchResultShouldBeReturnedWhenExistingHashtagHasBeenSpecidfied() async throws {
// Arrange.
_ = try await application.createUser(userName: "mikifinder")
try await application.createTrendingHashtag(trendingPeriod: .yearly, hashtag: "nature")
try await application.createTrendingHashtag(trendingPeriod: .yearly, hashtag: "naturePhotography")

// Act.
let searchResultDto = try application.getResponse(
as: .user(userName: "mikifinder", password: "p@ssword"),
to: "/search?query=nature&type=hashtags",
version: .v1,
decodeTo: SearchResultDto.self
)

// Assert.
#expect(searchResultDto.hashtags != nil, "Hashtags should be returned.")
#expect((searchResultDto.hashtags?.count ?? 0) >= 2, "At least two hashtags should be returned by the search.")
}

@Test("Search result should be returned when existing status has been specidfied")
func searchResultShouldBeReturnedWhenExistingStatusHasBeenSpecidfied() async throws {
// Arrange.
let user = try await application.createUser(userName: "yorkifinder")

let (_, attachments) = try await application.createStatuses(user: user, notePrefix: "This is wrocław photo", amount: 3)
defer {
application.clearFiles(attachments: attachments)
}

// Act.
let searchResultDto = try application.getResponse(
as: .user(userName: "yorkifinder", password: "p@ssword"),
to: "/search?query=wrocław&type=statuses",
version: .v1,
decodeTo: SearchResultDto.self
)

// Assert.
#expect(searchResultDto.statuses != nil, "Hashtags should be returned.")
#expect((searchResultDto.statuses?.count ?? 0) >= 3, "At least two statuses should be returned by the search.")
}

@Test("Empty search result should be returned when local account has not found")
func emptySearchResultShouldBeReturnedWhenLocalAccountHasNotFound() async throws {
// Arrange.
Expand Down