Skip to content

Commit

Permalink
[171] Add support for searching organizations and projects
Browse files Browse the repository at this point in the history
Bug: #171
Signed-off-by: Stéphane Bégaudeau <stephane.begaudeau@gmail.com>
  • Loading branch information
sbegaudeau committed Jun 25, 2023
1 parent e5bd073 commit 2cd103d
Show file tree
Hide file tree
Showing 14 changed files with 446 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 Stéphane Bégaudeau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.svalyn.studio.application.controllers.search;

import com.svalyn.studio.application.controllers.search.dto.SearchResultsDTO;
import com.svalyn.studio.application.services.organization.api.IOrganizationService;
import com.svalyn.studio.application.services.project.api.IProjectService;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;

import java.util.Objects;

/**
* Controller used to perform searches.
*
* @author sbegaudeau
*/
@Controller
public class SearchController {
private final IOrganizationService organizationService;

private final IProjectService projectService;

public SearchController(IOrganizationService organizationService, IProjectService projectService) {
this.organizationService = Objects.requireNonNull(organizationService);
this.projectService = Objects.requireNonNull(projectService);
}

@SchemaMapping(typeName = "Viewer")
public SearchResultsDTO search(@Argument String query) {
var organizations = this.organizationService.searchAllMatching(query);
var projects = this.projectService.searchAllMatching(query);
return new SearchResultsDTO(organizations, projects);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 Stéphane Bégaudeau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.svalyn.studio.application.controllers.search.dto;

import com.svalyn.studio.application.controllers.organization.dto.OrganizationDTO;
import com.svalyn.studio.application.controllers.project.dto.ProjectDTO;

import java.util.List;
import java.util.Objects;

/**
* The search results DTO for the GraphQL layer.
*
* @author sbegaudeau
*/
public record SearchResultsDTO(List<OrganizationDTO> organizations, List<ProjectDTO> projects) {
public SearchResultsDTO {
Objects.requireNonNull(organizations);
Objects.requireNonNull(projects);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
Expand Down Expand Up @@ -113,17 +114,23 @@ public Page<OrganizationDTO> findAll() {
@Override
@Transactional(readOnly = true)
public Optional<OrganizationDTO> findById(UUID id) {
var userId = UserIdProvider.get().getId();
return this.organizationRepository.findById(id).flatMap(this::toDTO);
}

@Override
@Transactional(readOnly = true)
public Optional<OrganizationDTO> findByIdentifier(String identifier) {
var userId = UserIdProvider.get().getId();
return this.organizationRepository.findByIdentifier(identifier).flatMap(this::toDTO);
}

@Override
@Transactional(readOnly = true)
public List<OrganizationDTO> searchAllMatching(String query) {
return this.organizationRepository.searchAllMatching(query, 0, 20).stream()
.flatMap(organization -> this.toDTO(organization).stream())
.toList();
}

@Override
@Transactional
public IPayload createOrganization(CreateOrganizationInput input) {
Expand All @@ -133,7 +140,6 @@ public IPayload createOrganization(CreateOrganizationInput input) {
if (result instanceof Failure<Organization> failure) {
payload = new ErrorPayload(input.id(), failure.message());
} else if (result instanceof Success<Organization> success) {
var userId = UserIdProvider.get().getId();
payload = new CreateOrganizationSuccessPayload(input.id(), this.toDTO(success.data()).orElse(null));
}
return payload;
Expand All @@ -147,7 +153,7 @@ public IPayload updateOrganizationName(UpdateOrganizationNameInput input) {
var result = this.organizationUpdateService.renameOrganization(input.organizationIdentifier(), input.name());
if (result instanceof Failure<Void> failure) {
payload = new ErrorPayload(input.id(), failure.message());
} else if (result instanceof Success<Void> success) {
} else if (result instanceof Success<Void>) {
payload = new SuccessPayload(input.id());
}

Expand All @@ -162,7 +168,7 @@ public IPayload leaveOrganization(LeaveOrganizationInput input) {
var result = this.organizationUpdateService.leaveOrganization(input.organizationIdentifier());
if (result instanceof Failure<Void> failure) {
payload = new ErrorPayload(input.id(), failure.message());
} else if (result instanceof Success<Void> success) {
} else if (result instanceof Success<Void>) {
payload = new SuccessPayload(input.id());
}

Expand All @@ -177,7 +183,7 @@ public IPayload deleteOrganization(DeleteOrganizationInput input) {
var result = this.organizationDeletionService.deleteOrganization(input.organizationIdentifier());
if (result instanceof Failure<Void> failure) {
payload = new ErrorPayload(input.id(), failure.message());
} else if (result instanceof Success<Void> success) {
} else if (result instanceof Success<Void>) {
payload = new SuccessPayload(input.id());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.svalyn.studio.application.controllers.organization.dto.UpdateOrganizationNameInput;
import org.springframework.data.domain.Page;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

Expand All @@ -50,4 +51,6 @@ public interface IOrganizationService {
IPayload leaveOrganization(LeaveOrganizationInput input);

IPayload deleteOrganization(DeleteOrganizationInput input);

List<OrganizationDTO> searchAllMatching(String query);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
Expand Down Expand Up @@ -121,6 +122,14 @@ public Optional<ProjectDTO> findByIdentifier(String identifier) {
return this.projectRepository.findByIdentifier(identifier).flatMap(this::toDTO);
}

@Override
@Transactional(readOnly = true)
public List<ProjectDTO> searchAllMatching(String query) {
return this.projectRepository.searchAllMatching(query, 0, 20).stream()
.flatMap(project -> this.toDTO(project).stream())
.toList();
}

@Override
@Transactional
public IPayload createProject(CreateProjectInput input) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import com.svalyn.studio.application.controllers.project.dto.UpdateProjectReadMeInput;
import org.springframework.data.domain.Page;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

Expand All @@ -52,4 +53,6 @@ public interface IProjectService {
IPayload updateProjectReadMe(UpdateProjectReadMeInput input);

IPayload deleteProject(DeleteProjectInput input);

List<ProjectDTO> searchAllMatching(String query);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Viewer {
domains(page: Int!, rowsPerPage: Int!): ViewerDomainsConnection!
domain(identifier: ID!): Domain
activityEntries(page: Int!, rowsPerPage: Int!): ViewerActivityEntriesConnection!
search(query: String!): SearchResults!
}

type ViewerInvitationsConnection {
Expand Down Expand Up @@ -57,6 +58,11 @@ type ViewerActivityEntriesEdge {
node: ActivityEntry!
}

type SearchResults {
organizations: [Organization!]!
projects: [Project!]!
}

type Profile {
name: String!
username: String!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,21 @@ SELECT count(*) FROM organization organization JOIN invitation invitation ON org
WHERE organization.id = :organizationId AND membership.member_id = :userId
""")
Optional<MembershipRole> findMembershipRole(UUID userId, UUID organizationId);

@Query("""
SELECT *, ts_rank_cd(textsearchable_generated, query) AS rank
FROM organization organization, plainto_tsquery(:query) query
WHERE textsearchable_generated @@ query
ORDER BY rank
LIMIT :limit
OFFSET :offset
""")
List<Organization> searchAllMatching(String query, long offset, int limit);

@Query("""
SELECT count(*)
FROM organization organization, plainto_tsquery(:query) query
WHERE textsearchable_generated @@ query
""")
long countAllMatching(String query);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,21 @@ SELECT count(*) FROM project project
WHERE project.organization_id = :organizationId
""")
long countAllByOrganizationId(UUID organizationId);

@Query("""
SELECT *, ts_rank_cd(textsearchable_generated, query) AS rank
FROM project project, plainto_tsquery(:query) query
WHERE textsearchable_generated @@ query
ORDER BY rank
LIMIT :limit
OFFSET :offset
""")
List<Project> searchAllMatching(String query, long offset, int limit);

@Query("""
SELECT count(*)
FROM project project, plainto_tsquery(:query) query
WHERE textsearchable_generated @@ query
""")
long countAllMatching(String query);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@
<dropTable tableName="relation" />
<dropTable tableName="attribute" />
<dropTable tableName="entity" />

<sql>ALTER TABLE organization ADD COLUMN textsearchable_generated tsvector GENERATED ALWAYS AS ( to_tsvector('english', identifier || '' || name) ) STORED</sql>
<sql>CREATE INDEX organization_search_index ON organization USING GIN(textsearchable_generated)</sql>

<sql>ALTER TABLE project ADD COLUMN textsearchable_generated tsvector GENERATED ALWAYS AS ( to_tsvector('english', identifier || '' || name || '' || description) ) STORED</sql>
<sql>CREATE INDEX project_search_index ON project USING GIN(textsearchable_generated)</sql>
</changeSet>
</databaseChangeLog>
2 changes: 2 additions & 0 deletions frontend/svalyn-studio-app/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { OrganizationView } from '../views/organization/OrganizationView';
import { ProfileView } from '../views/profile/ProfileView';
import { ProjectView } from '../views/project/ProjectView';
import { ResourceView } from '../views/resource/ResourceView';
import { SearchView } from '../views/search/SearchView';
import { SettingsView } from '../views/settings/SettingsView';
import { WorkspaceView } from '../views/workspace/WorkspaceView';
import { AuthenticationRedirectionBoundary } from './AuthenticationRedirectionBoundary';
Expand Down Expand Up @@ -67,6 +68,7 @@ export const App = () => {
<Route path="/changeproposals/:changeProposalId/files" element={<ChangeProposalView />} />
<Route path="/domains" element={<DomainsView />} />
<Route path="/domains/:domainIdentifier" element={<DomainView />} />
<Route path="/search" element={<SearchView />} />
<Route path="/settings" element={<SettingsView />} />
<Route path="/settings/authentication-tokens" element={<SettingsView />} />
<Route path="/login" element={<LoginView />} />
Expand Down
11 changes: 10 additions & 1 deletion frontend/svalyn-studio-app/src/views/home/HomeViewSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,31 +23,40 @@ import Input from '@mui/material/Input';
import InputAdornment from '@mui/material/InputAdornment';
import Paper from '@mui/material/Paper';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { HomeViewSearchState } from './HomeViewSearch.types';

export const HomeViewSearch = () => {
const [state, setState] = useState<HomeViewSearchState>({
query: '',
});

const navigate = useNavigate();

const handleQueryChange: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = (event) => {
const {
target: { value },
} = event;
setState((prevState) => ({ ...prevState, query: value }));
};

const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement | HTMLInputElement> = (event) => {
if (event.key === 'Enter') {
navigate(`/search?q=${encodeURIComponent(state.query)}`);
}
};

return (
<Paper variant="outlined" sx={{ paddingX: (theme) => theme.spacing(2) }}>
<FormControl variant="standard" fullWidth>
<Input
value={state.query}
onChange={handleQueryChange}
onKeyDown={handleKeyDown}
placeholder="Search..."
disableUnderline
autoFocus
fullWidth
disabled
startAdornment={
<InputAdornment position="start">
<SearchIcon fontSize="large" />
Expand Down
Loading

0 comments on commit 2cd103d

Please sign in to comment.