feat(search): case-insensitive tag filtering (#827)

This commit is contained in:
Corentin Thomasset
2026-01-28 18:13:36 +01:00
committed by GitHub
parent 494aa5b882
commit ca2ef2866b
6 changed files with 51 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/app": patch
---
In the search queries, tag filters are now case-insensitive, so `tag:Important` and `tag:important` will match the same tag (as tags names are case-insensitive).

View File

@@ -37,7 +37,7 @@ function buildTagFilterCondition({ expression }: { expression: FilterExpression
return falseCondition;
}
return ({ document }) => document.tags.find(tag => tag.name === value || tag.id === value) !== undefined;
return ({ document }) => document.tags.find(tag => tag.name.toLowerCase() === value.toLowerCase() || tag.id === value) !== undefined;
}
function buildNameFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition {

View File

@@ -41,16 +41,14 @@ describe('database-fts5 repository models', () => {
expect(issues).to.eql([]);
expect(getSqlString(searchWhereClause)).to.eql({
sql: `(\"documents\".\"organization_id\" = ? and \"documents\".\"is_deleted\" = ? and ((\"documents\".\"id\" in (select distinct \"documents_tags\".\"document_id\" from \"documents_tags\" inner join \"tags\" on \"documents_tags\".\"tag_id\" = \"tags\".\"id\" where (\"tags\".\"organization_id\" = ? and (\"tags\".\"name\" = ? or \"tags\".\"id\" = ?))) or \"documents\".\"id\" in (select distinct \"documents_tags\".\"document_id\" from \"documents_tags\" inner join \"tags\" on \"documents_tags\".\"tag_id\" = \"tags\".\"id\" where (\"tags\".\"organization_id\" = ? and (\"tags\".\"name\" = ? or \"tags\".\"id\" = ?)))) and not \"documents\".\"id\" in (select distinct \"document_id\" from \"documents_fts\" where \"documents_fts\" = ?)))`,
sql: '("documents"."organization_id" = ? and "documents"."is_deleted" = ? and (("documents"."id" in (select distinct "documents_tags"."document_id" from "documents_tags" inner join "tags" on "documents_tags"."tag_id" = "tags"."id" where ("tags"."organization_id" = ? and "tags"."normalized_name" = ?)) or "documents"."id" in (select distinct "documents_tags"."document_id" from "documents_tags" inner join "tags" on "documents_tags"."tag_id" = "tags"."id" where ("tags"."organization_id" = ? and "tags"."normalized_name" = ?))) and not "documents"."id" in (select distinct "document_id" from "documents_fts" where "documents_fts" = ?)))',
params: [
'org_1',
0,
'org_1',
'important',
'important',
'org_1',
'urgent',
'urgent',
'organization_id:\"org_1\" {name content}:\"confidential\"*',
],
});

View File

@@ -305,6 +305,10 @@ describe('database-fts5 repository', () => {
searchQuery: 'tag:car tag:invoice',
expectedDocumentsIds: ['doc_3'],
},
{
searchQuery: 'tag:cAr tag:iNvoIce',
expectedDocumentsIds: ['doc_3'],
},
{
searchQuery: 'tag:car NOT contract',
expectedDocumentsIds: ['doc_3'],

View File

@@ -11,6 +11,7 @@ import {
handleNameFilter,
handleNotExpression,
handleOrExpression,
handleTagFilter,
handleTextExpression,
handleUnsupportedExpression,
} from './query-builder';
@@ -506,4 +507,36 @@ describe('query-builder', async () => {
});
});
});
describe('handleTagFilter', () => {
test('when the tag filter value is a proper tag id, the built query matches by id', () => {
const { sqlQuery, issues } = handleTagFilter({
expression: { type: 'filter', field: 'tag', value: 'tag_123456789123456789123456', operator: '=' },
organizationId: 'org_1',
db,
});
expect(issues).to.eql([]);
expect(getSqlString(sqlQuery)).to.eql({
sql: '"documents"."id" in (select distinct "documents_tags"."document_id" from "documents_tags" inner join "tags" on "documents_tags"."tag_id" = "tags"."id" where ("tags"."organization_id" = ? and "tags"."id" = ?))',
params: ['org_1', 'tag_123456789123456789123456'],
});
});
test('when the tag filter value is not a proper tag id, the built query matches by name', () => {
const { sqlQuery, issues } = handleTagFilter({
expression: { type: 'filter', field: 'tag', value: 'Important THING', operator: '=' },
organizationId: 'org_1',
db,
});
expect(issues).to.eql([]);
expect(getSqlString(sqlQuery)).to.eql({
sql: '"documents"."id" in (select distinct "documents_tags"."document_id" from "documents_tags" inner join "tags" on "documents_tags"."tag_id" = "tags"."id" where ("tags"."organization_id" = ? and "tags"."normalized_name" = ?))',
params: ['org_1', 'important thing'],
});
});
});
});

View File

@@ -3,6 +3,8 @@ import type { Database } from '../../../../app/database/database.types';
import type { QueryResult } from './query-builder.types';
import { and, eq, inArray, not, or, sql } from 'drizzle-orm';
import { isValidDate } from '../../../../shared/date';
import { tagIdRegex } from '../../../../tags/tags.constants';
import { normalizeTagName } from '../../../../tags/tags.repository.models';
import { documentsTagsTable, tagsTable } from '../../../../tags/tags.table';
import { documentsTable } from '../../../documents.table';
import { documentsFtsTable } from '../database-fts5.tables';
@@ -133,6 +135,10 @@ export function handleNotExpression({ expression, organizationId, db }: { expres
export function handleTagFilter({ expression, organizationId, db }: { expression: FilterExpression; organizationId: string; db: Database }): QueryResult {
const { value } = expression;
const query = tagIdRegex.test(value)
? eq(tagsTable.id, value)
: eq(tagsTable.normalizedName, normalizeTagName({ name: value }));
return {
sqlQuery: inArray(
documentsTable.id,
@@ -142,10 +148,7 @@ export function handleTagFilter({ expression, organizationId, db }: { expression
.where(and(
// Ensure tag belongs to the same organization + helps performance as there is an index on (organizationId + name)
eq(tagsTable.organizationId, organizationId),
or(
eq(tagsTable.name, value),
eq(tagsTable.id, value),
),
query,
)),
),
issues: [],