Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 @@ -132,7 +132,7 @@ describe('DSOSelectorComponent', () => {

expect(searchService.search).toHaveBeenCalledWith(
jasmine.objectContaining({
query: undefined,
query: '',
sort: jasmine.objectContaining({
field: 'dc.title',
direction: SortDirection.ASC,
Expand All @@ -158,6 +158,81 @@ describe('DSOSelectorComponent', () => {
});
});

describe('query processing', () => {
beforeEach(() => {
spyOn(searchService, 'search').and.callThrough();
});

describe('for COMMUNITY/COLLECTION types', () => {
beforeEach(() => {
component.types = [DSpaceObjectType.COMMUNITY];
});

it('should create title field query with escaping and wildcards', () => {
component.search('test+query [with] special:chars/paths', 1);

expect(searchService.search).toHaveBeenCalledWith(
jasmine.objectContaining({
query: 'dc.title:("test\\+query" AND "\\[with\\]" AND special\\:chars\\/paths*)'
}),
null,
true
);
});

it('should pass through internal resource ID queries unchanged', () => {
const resourceIdQuery = component.getCurrentDSOQuery();
component.search(resourceIdQuery, 1);

expect(searchService.search).toHaveBeenCalledWith(
jasmine.objectContaining({
query: resourceIdQuery
}),
null,
true
);
});
});

describe('for ITEM types', () => {
beforeEach(() => {
component.types = [DSpaceObjectType.ITEM];
});

it('should pass through queries unchanged', () => {
component.search('test query', 1);

expect(searchService.search).toHaveBeenCalledWith(
jasmine.objectContaining({
query: 'test query'
}),
null,
true
);
});
});

describe('edge cases', () => {
beforeEach(() => {
component.types = [DSpaceObjectType.COMMUNITY];
});

it('should handle whitespace-only query with raw semantics', () => {
component.sort = new SortOptions('dc.title', SortDirection.ASC);
component.search(' ', 1);

expect(searchService.search).toHaveBeenCalledWith(
jasmine.objectContaining({
query: ' ',
sort: null
}),
null,
true
);
});
Comment thread
Paurikova2 marked this conversation as resolved.
Outdated
});
});

describe('when search returns an error', () => {
beforeEach(() => {
spyOn(searchService, 'search').and.returnValue(createFailedRemoteDataObject$());
Expand Down
47 changes: 43 additions & 4 deletions src/app/shared/dso-selector/dso-selector/dso-selector.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,16 +227,43 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
* @param useCache Whether or not to use the cache
*/
search(query: string, page: number, useCache: boolean = true): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
// default sort is only used when there is not query
let efectiveSort = query ? null : this.sort;
const rawQuery = query ?? '';
const trimmedQuery = rawQuery.trim();
const hasQuery = isNotEmpty(rawQuery);

let effectiveSort = hasQuery ? null : this.sort;

let processedQuery = rawQuery;
if (isNotEmpty(trimmedQuery)) {
if (this.types.includes(DSpaceObjectType.COMMUNITY) || this.types.includes(DSpaceObjectType.COLLECTION)) {
// This searches specifically in the title field for words that start with the query
// Properly escape and group multi-term queries to ensure all terms are scoped to dc.title
const escapedQuery = this.escapeLuceneSpecialCharacters(trimmedQuery);
const terms = escapedQuery.split(/\s+/).filter(term => term.length > 0);

if (terms.length === 1) {
// Single term: apply wildcard directly
processedQuery = `dc.title:${terms[0]}*`;
} else {
// Multiple terms: group all terms and apply wildcard only to the last term
const allButLast = terms.slice(0, -1).map(term => `"${term}"`).join(' AND ');
const lastTerm = terms[terms.length - 1];
processedQuery = `dc.title:(${allButLast} AND ${lastTerm}*)`;
}
Comment thread
Paurikova2 marked this conversation as resolved.
Outdated
} else {
// For items and other types, use the trimmed query as-is without wildcard modification
processedQuery = trimmedQuery;
}
}
Comment thread
Paurikova2 marked this conversation as resolved.

return this.searchService.search(
new PaginatedSearchOptions({
query: query,
query: processedQuery,
dsoTypes: this.types,
pagination: Object.assign({}, this.defaultPagination, {
currentPage: page
}),
sort: efectiveSort
sort: effectiveSort
}),
null,
useCache,
Expand Down Expand Up @@ -301,6 +328,18 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
}
}

/**
* Escapes special Lucene/Solr characters in user input to prevent query syntax errors
* @param query The user input query to escape
* @returns The escaped query string
*/
private escapeLuceneSpecialCharacters(query: string): string {
// Escape special Lucene characters: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ /
return query.replace(/[+\-!(){}[\]^"~*?:\\\/]/g, '\\$&')
.replace(/&&/g, '\\&&')
.replace(/\|\|/g, '\\||');
Comment thread
Paurikova2 marked this conversation as resolved.
Outdated
}

getName(listableObject: ListableObject): string {
return hasValue((listableObject as SearchResult<DSpaceObject>).indexableObject) ?
this.dsoNameService.getName((listableObject as SearchResult<DSpaceObject>).indexableObject) : null;
Expand Down
Loading