Skip to content

Commit 4d4c9f4

Browse files
committed
Implement server side glob support #1133
1 parent d3c6c2b commit 4d4c9f4

2 files changed

Lines changed: 167 additions & 36 deletions

File tree

src/backend/model/database/SearchManager.ts

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -905,13 +905,35 @@ export class SearchManager {
905905
}
906906

907907
return new Brackets((q: WhereExpression) => {
908+
const convertGlobToLike = (str: string): string => {
909+
// Match either an escaped character \\(.) or any unescaped SQL/glob special character
910+
return str.replace(/\\(.)|([*?%_])/g, (_, escaped, special) => {
911+
if (escaped) {
912+
if (escaped === '*' || escaped === '?') return escaped; // \* -> *, \? -> ?
913+
if (escaped === '\\') return '\\\\'; // \\ -> \\
914+
return '\\' + escaped; // e.g., \% -> \%
915+
}
916+
917+
// Handle unescaped special characters
918+
if (special === '*') return '%';
919+
if (special === '?') return '_';
920+
return '\\' + special; // % -> \%, _ -> \_
921+
});
922+
};
923+
908924
const createMatchString = (str: string): string => {
909925
if (
910926
(query as TextSearch).matchType ===
911927
TextSearchQueryMatchTypes.exact_match
912928
) {
913929
return str;
914930
}
931+
if (
932+
(query as TextSearch).matchType ===
933+
TextSearchQueryMatchTypes.globMatch
934+
) {
935+
return convertGlobToLike(str);
936+
}
915937
// MySQL uses C escape syntax in strings, details:
916938
// https://stackoverflow.com/questions/14926386/how-to-search-for-slash-in-mysql-and-why-escaping-not-required-for-wher
917939
if (Config.Database.type === DatabaseType.mysql) {
@@ -921,11 +943,19 @@ export class SearchManager {
921943
return `%${str}%`;
922944
};
923945

924-
const LIKE = (query as TextSearch).negate ? 'NOT LIKE' : 'LIKE';
946+
const isGlob = (query as TextSearch).matchType === TextSearchQueryMatchTypes.globMatch;
925947
// if the expression is negated, we use AND instead of OR as nowhere should that match
926948
const whereFN = (query as TextSearch).negate ? 'andWhere' : 'orWhere';
927949
const whereFNRev = (query as TextSearch).negate ? 'orWhere' : 'andWhere';
928950

951+
const getLikeExpr = (fieldName: string, paramName: string): string => {
952+
const op = (query as TextSearch).negate ? 'NOT LIKE' : 'LIKE';
953+
if (isGlob) {
954+
return `${fieldName} ${op} :${paramName}${queryId} ESCAPE '\\' COLLATE ${SQL_COLLATE}`;
955+
}
956+
return `${fieldName} ${op} :${paramName}${queryId} COLLATE ${SQL_COLLATE}`;
957+
};
958+
929959
const textParam: { [key: string]: unknown } = {};
930960
textParam['text' + queryId] = createMatchString(
931961
(query as TextSearch).value
@@ -942,7 +972,7 @@ export class SearchManager {
942972
const alias = aliases['directory'] ?? 'directory';
943973
textParam['fullPath' + queryId] = createMatchString(dirPathStr);
944974
q[whereFN](
945-
`${alias}.path ${LIKE} :fullPath${queryId} COLLATE ` + SQL_COLLATE,
975+
getLikeExpr(`${alias}.path`, 'fullPath'),
946976
textParam
947977
);
948978

@@ -953,15 +983,15 @@ export class SearchManager {
953983
directoryPath.name
954984
);
955985
dq[whereFNRev](
956-
`${alias}.name ${LIKE} :dirName${queryId} COLLATE ${SQL_COLLATE}`,
986+
getLikeExpr(`${alias}.name`, 'dirName'),
957987
textParam
958988
);
959989
if (dirPathStr.includes('/')) {
960990
textParam['parentName' + queryId] = createMatchString(
961991
directoryPath.parent
962992
);
963993
dq[whereFNRev](
964-
`${alias}.path ${LIKE} :parentName${queryId} COLLATE ${SQL_COLLATE}`,
994+
getLikeExpr(`${alias}.path`, 'parentName'),
965995
textParam
966996
);
967997
}
@@ -975,7 +1005,7 @@ export class SearchManager {
9751005
query.type === SearchQueryTypes.file_name
9761006
) {
9771007
q[whereFN](
978-
`media.name ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1008+
getLikeExpr('media.name', 'text'),
9791009
textParam
9801010
);
9811011
}
@@ -985,7 +1015,7 @@ export class SearchManager {
9851015
query.type === SearchQueryTypes.caption
9861016
) {
9871017
q[whereFN](
988-
`media.metadata.caption ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1018+
getLikeExpr('media.metadata.caption', 'text'),
9891019
textParam
9901020
);
9911021
}
@@ -995,13 +1025,13 @@ export class SearchManager {
9951025
query.type === SearchQueryTypes.position
9961026
) {
9971027
q[whereFN](
998-
`media.metadata.positionData.country ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1028+
getLikeExpr('media.metadata.positionData.country', 'text'),
9991029
textParam
10001030
)[whereFN](
1001-
`media.metadata.positionData.state ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1031+
getLikeExpr('media.metadata.positionData.state', 'text'),
10021032
textParam
10031033
)[whereFN](
1004-
`media.metadata.positionData.city ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1034+
getLikeExpr('media.metadata.positionData.city', 'text'),
10051035
textParam
10061036
);
10071037
}
@@ -1010,44 +1040,39 @@ export class SearchManager {
10101040
const matchArrayField = (fieldName: string): void => {
10111041
q[whereFN](
10121042
new Brackets((qbr): void => {
1013-
if (
1014-
(query as TextSearch).matchType !==
1015-
TextSearchQueryMatchTypes.exact_match
1016-
) {
1043+
const isExact = (query as TextSearch).matchType === TextSearchQueryMatchTypes.exact_match;
1044+
1045+
if (!isExact && !isGlob) {
10171046
qbr[whereFN](
1018-
`${fieldName} ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1047+
getLikeExpr(fieldName, 'text'),
10191048
textParam
10201049
);
10211050
} else {
10221051
qbr[whereFN](
10231052
new Brackets((qb): void => {
1024-
textParam['CtextC' + queryId] = `%,${
1025-
(query as TextSearch).value
1026-
},%`;
1027-
textParam['Ctext' + queryId] = `%,${
1028-
(query as TextSearch).value
1029-
}`;
1030-
textParam['textC' + queryId] = `${
1031-
(query as TextSearch).value
1032-
},%`;
1033-
textParam['text_exact' + queryId] = `${
1034-
(query as TextSearch).value
1035-
}`;
1053+
const globPattern = isGlob ? convertGlobToLike((query as TextSearch).value) : (query as TextSearch).value;
1054+
const esc = isGlob ? " ESCAPE '\\'" : "";
1055+
const op = (query as TextSearch).negate ? 'NOT LIKE' : 'LIKE';
1056+
1057+
textParam['CtextC' + queryId] = `%,${globPattern},%`;
1058+
textParam['Ctext' + queryId] = `%,${globPattern}`;
1059+
textParam['textC' + queryId] = `${globPattern},%`;
1060+
textParam['text_exact' + queryId] = `${globPattern}`;
10361061

10371062
qb[whereFN](
1038-
`${fieldName} ${LIKE} :CtextC${queryId} COLLATE ${SQL_COLLATE}`,
1063+
`${fieldName} ${op} :CtextC${queryId}${esc} COLLATE ${SQL_COLLATE}`,
10391064
textParam
10401065
);
10411066
qb[whereFN](
1042-
`${fieldName} ${LIKE} :Ctext${queryId} COLLATE ${SQL_COLLATE}`,
1067+
`${fieldName} ${op} :Ctext${queryId}${esc} COLLATE ${SQL_COLLATE}`,
10431068
textParam
10441069
);
10451070
qb[whereFN](
1046-
`${fieldName} ${LIKE} :textC${queryId} COLLATE ${SQL_COLLATE}`,
1071+
`${fieldName} ${op} :textC${queryId}${esc} COLLATE ${SQL_COLLATE}`,
10471072
textParam
10481073
);
10491074
qb[whereFN](
1050-
`${fieldName} ${LIKE} :text_exact${queryId} COLLATE ${SQL_COLLATE}`,
1075+
`${fieldName} ${op} :text_exact${queryId}${esc} COLLATE ${SQL_COLLATE}`,
10511076
textParam
10521077
);
10531078
})

test/backend/unit/model/sql/SearchManager.spec.ts

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,6 @@ describe('SearchManager', (sqlHelper: DBTestHelper) => {
149149
it('should get autocomplete', async () => {
150150
const sm = new SearchManager();
151151

152-
const cmp = (a: AutoCompleteItem, b: AutoCompleteItem) => {
153-
if (a.value === b.value) {
154-
return a.type - b.type;
155-
}
156-
return a.value.localeCompare(b.value);
157-
};
158152

159153
expect((await sm.autocomplete(DBTestHelper.defaultSession, 'tat', SearchQueryTypes.any_text))).to.deep.equalInAnyOrder([
160154
new AutoCompleteItem('Tatooine', SearchQueryTypes.position)]);
@@ -1614,6 +1608,118 @@ describe('SearchManager', (sqlHelper: DBTestHelper) => {
16141608

16151609
});
16161610

1611+
it('with globMatch', async () => {
1612+
const sm = new SearchManager();
1613+
1614+
// Test 1: Match filenames starting with sw
1615+
let query = {
1616+
value: 'sw*',
1617+
type: SearchQueryTypes.file_name,
1618+
matchType: TextSearchQueryMatchTypes.globMatch
1619+
} as TextSearch;
1620+
1621+
const res = await sm.search(DBTestHelper.defaultSession, query);
1622+
1623+
expect(Utils.clone(res)).to.deep.equalInAnyOrder(removeDir({
1624+
searchQuery: query,
1625+
directories: [],
1626+
media: [p, p2, pFaceLess, v, p4],
1627+
metaFile: [],
1628+
resultOverflow: false
1629+
} as SearchResultDTO));
1630+
1631+
// Test 2: Match filenames ending with .jpg
1632+
query = {
1633+
value: '*.jpg',
1634+
type: SearchQueryTypes.file_name,
1635+
matchType: TextSearchQueryMatchTypes.globMatch
1636+
} as TextSearch;
1637+
1638+
expect(Utils.clone(await sm.search(DBTestHelper.defaultSession, query))).to.deep.equalInAnyOrder(removeDir({
1639+
searchQuery: query,
1640+
directories: [],
1641+
media: [p, p2, pFaceLess, p4],
1642+
metaFile: [],
1643+
resultOverflow: false
1644+
} as SearchResultDTO));
1645+
1646+
// Test 3: Match Mos Eis* city under position
1647+
query = {
1648+
value: 'Mos Eis*',
1649+
type: SearchQueryTypes.position,
1650+
matchType: TextSearchQueryMatchTypes.globMatch
1651+
} as TextSearch;
1652+
1653+
expect(Utils.clone(await sm.search(DBTestHelper.defaultSession, query))).to.deep.equalInAnyOrder(removeDir({
1654+
searchQuery: query,
1655+
directories: [],
1656+
media: [p],
1657+
metaFile: [],
1658+
resultOverflow: false
1659+
} as SearchResultDTO));
1660+
1661+
// Test 4: Negated glob match (no jpg)
1662+
query = {
1663+
value: '*.jpg',
1664+
type: SearchQueryTypes.file_name,
1665+
negate: true,
1666+
matchType: TextSearchQueryMatchTypes.globMatch
1667+
} as TextSearch;
1668+
1669+
expect(Utils.clone(await sm.search(DBTestHelper.defaultSession, query))).to.deep.equalInAnyOrder(removeDir({
1670+
searchQuery: query,
1671+
directories: [],
1672+
media: [v],
1673+
metaFile: [],
1674+
resultOverflow: false
1675+
} as SearchResultDTO));
1676+
1677+
// Test 5: Exact match using glob (no wildcards)
1678+
query = {
1679+
value: 'sw1.jpg',
1680+
type: SearchQueryTypes.file_name,
1681+
matchType: TextSearchQueryMatchTypes.globMatch
1682+
} as TextSearch;
1683+
1684+
expect(Utils.clone(await sm.search(DBTestHelper.defaultSession, query))).to.deep.equalInAnyOrder(removeDir({
1685+
searchQuery: query,
1686+
directories: [],
1687+
media: [p],
1688+
metaFile: [],
1689+
resultOverflow: false
1690+
} as SearchResultDTO));
1691+
1692+
// Test 6: Wildcard ? (single character)
1693+
query = {
1694+
value: 'sw?.jpg',
1695+
type: SearchQueryTypes.file_name,
1696+
matchType: TextSearchQueryMatchTypes.globMatch
1697+
} as TextSearch;
1698+
1699+
expect(Utils.clone(await sm.search(DBTestHelper.defaultSession, query))).to.deep.equalInAnyOrder(removeDir({
1700+
searchQuery: query,
1701+
directories: [],
1702+
media: [p, p2, pFaceLess, p4],
1703+
metaFile: [],
1704+
resultOverflow: false
1705+
} as SearchResultDTO));
1706+
1707+
// Test 7: Escaped wildcard (should not act as wildcard)
1708+
query = {
1709+
value: 'sw\\*.jpg',
1710+
type: SearchQueryTypes.file_name,
1711+
matchType: TextSearchQueryMatchTypes.globMatch
1712+
} as TextSearch;
1713+
1714+
expect(Utils.clone(await sm.search(DBTestHelper.defaultSession, query))).to.deep.equalInAnyOrder(removeDir({
1715+
searchQuery: query,
1716+
directories: [],
1717+
media: [],
1718+
metaFile: [],
1719+
resultOverflow: false
1720+
} as SearchResultDTO));
1721+
});
1722+
16171723
});
16181724

16191725
describe('search date pattern', () => {

0 commit comments

Comments
 (0)