From a95bd5379a11407ee1ec06ef081fce5378d86803 Mon Sep 17 00:00:00 2001 From: Bit Nimble Date: Mon, 8 Jun 2026 23:33:38 +1000 Subject: [PATCH] filters: add difficulty count filters --- src/app/filter_builder.tsx | 19 ++++++++++-- src/app/filter_modes.ts | 14 +++++++-- src/app/tests/filter_modes.unit.test.ts | 11 +++++++ src/schema/map_filter.ts | 19 ++++++++---- src/schema/tests/map_filter.unit.test.ts | 30 +++++++++++++++++++ .../maps/tests/maps_repo_filters.test.ts | 30 +++++++++++++++++++ src/services/search/filter_compiler.ts | 15 +++++++++- .../search/tests/filter_compiler.unit.test.ts | 15 ++++++++++ src/ui/base/textbox/textbox.tsx | 2 +- 9 files changed, 143 insertions(+), 12 deletions(-) diff --git a/src/app/filter_builder.tsx b/src/app/filter_builder.tsx index 5ae9dba..54ce526 100644 --- a/src/app/filter_builder.tsx +++ b/src/app/filter_builder.tsx @@ -36,6 +36,7 @@ const OP_LABELS: Record = { lte: '≤', before: 'before', after: 'after', + count: 'count', }; const FIELD_LABELS: Record = { @@ -47,6 +48,7 @@ const FIELD_LABELS: Record = { tags: 'Tags', downloadCount: 'Downloads', submissionDate: 'Upload date', + difficulties: 'Difficulties', }; // `tags` stays in the filter schema but is hidden from the builder until the tag write-path and its @@ -62,6 +64,17 @@ const simpleFieldLabel = (simpleField: SimpleField) => ? `Uploaded ${OP_LABELS[simpleField.op]}` : FIELD_LABELS[simpleField.field]; +const simpleInputType = (simpleField: SimpleField) => { + const kind = FILTER_FIELDS[simpleField.field].kind; + if (kind === 'date') { + return 'date'; + } + if (kind === 'number' || kind === 'countable') { + return 'number'; + } + return 'text'; +}; + export const FilterBuilder = observer((props: { store: MapListStore; onSearch: () => void }) => { const { store, onSearch } = props; return ( @@ -152,7 +165,7 @@ const SimpleBuilder = observer((props: { store: MapListStore; onSearch: () => vo key={simpleFieldKey(simpleField)} label={simpleFieldLabel(simpleField)} error={undefined} - inputType={FILTER_FIELDS[simpleField.field].kind === 'date' ? 'date' : 'text'} + inputType={simpleInputType(simpleField)} value={getFieldValue(store.filter, simpleField)} onChange={(v) => setField(simpleField, v)} onSubmit={onSearch} @@ -290,7 +303,7 @@ const CmpEditor = (props: { type: 'cmp', field, op: OPS_BY_KIND[newKind][0], - value: newKind === 'number' ? 0 : '', + value: newKind === 'number' || newKind === 'countable' ? 0 : '', }); }; @@ -334,7 +347,7 @@ const CmpEditor = (props: { const ValueWidget = (props: { node: CmpNode; onChange: (n: CmpNode) => void }) => { const { node, onChange } = props; const kind = FILTER_FIELDS[node.field].kind; - if (kind === 'number') { + if (kind === 'number' || kind === 'countable') { return ( = 0) { children[idx] = cmp; } else { diff --git a/src/app/tests/filter_modes.unit.test.ts b/src/app/tests/filter_modes.unit.test.ts index 1317a77..0851525 100644 --- a/src/app/tests/filter_modes.unit.test.ts +++ b/src/app/tests/filter_modes.unit.test.ts @@ -10,6 +10,7 @@ import { const artist: SimpleField = { field: 'artist', op: 'contains' }; const author: SimpleField = { field: 'author', op: 'contains' }; const after: SimpleField = { field: 'submissionDate', op: 'after' }; +const difficulties: SimpleField = { field: 'difficulties', op: 'count' }; describe('isSimpleFilter', () => { it('treats an empty filter as simple', () => { @@ -115,6 +116,16 @@ describe('getFieldValue / setFieldValue', () => { }); }); + it('coerces a numeric simple field value to a number in the AST', () => { + const filter = setFieldValue(undefined, difficulties, '4'); + expect(filter).toEqual({ + type: 'and', + children: [{ type: 'cmp', field: 'difficulties', op: 'count', value: 4 }], + }); + // Reads back as a string for display in the widget. + expect(getFieldValue(filter, difficulties)).toBe('4'); + }); + it('clears a field when set to blank, and drops the filter when none remain', () => { let filter = setFieldValue(undefined, artist, 'Smash'); filter = setFieldValue(filter, author, 'anon'); diff --git a/src/schema/map_filter.ts b/src/schema/map_filter.ts index 45de36a..ce8f3f6 100644 --- a/src/schema/map_filter.ts +++ b/src/schema/map_filter.ts @@ -1,4 +1,4 @@ -import { maps } from 'zapatos/schema'; +import { difficulties, maps } from 'zapatos/schema'; import { z } from 'zod'; /** @@ -8,11 +8,13 @@ import { z } from 'zod'; * which are forced server-side. */ -type FieldKind = 'string' | 'stringArray' | 'number' | 'date'; +type FieldKind = 'string' | 'stringArray' | 'number' | 'date' | 'countable'; /** - * Single source of truth for filterable fields. `column` references the real `maps` column so the - * compiler can use it directly; the `maps.Column` constraint guarantees only real columns appear. + * Single source of truth for filterable fields. Column-backed fields reference the real `maps` + * column so the compiler can use it directly; the `maps.Column` constraint guarantees only real + * columns appear. `countable` fields instead reference a related table (`relation`) and the foreign + * key back to `maps`, so the compiler can emit a `count(*)` correlated subquery (see `compileCmp`). */ export const FILTER_FIELDS = { title: { kind: 'string', column: 'title' }, @@ -23,7 +25,12 @@ export const FILTER_FIELDS = { tags: { kind: 'stringArray', column: 'tags' }, downloadCount: { kind: 'number', column: 'download_count' }, submissionDate: { kind: 'date', column: 'submission_date' }, -} as const satisfies Record; + difficulties: { kind: 'countable', relation: 'difficulties', foreignKey: 'map_id' }, +} as const satisfies Record< + string, + | { kind: 'string' | 'stringArray' | 'number' | 'date'; column: maps.Column } + | { kind: 'countable'; relation: difficulties.Table; foreignKey: difficulties.Column } +>; export type FilterableField = keyof typeof FILTER_FIELDS; @@ -32,6 +39,7 @@ export const OPS_BY_KIND = { stringArray: ['has'], number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], date: ['before', 'after', 'gte', 'lte'], + countable: ['count'], } as const satisfies Record; export type FilterOp = (typeof OPS_BY_KIND)[keyof typeof OPS_BY_KIND][number]; @@ -106,6 +114,7 @@ function validateCmp(node: CmpNode, ctx: z.RefinementCtx) { } switch (field.kind) { case 'number': + case 'countable': if (typeof node.value !== 'number') { ctx.addIssue({ code: 'custom', message: `Field "${node.field}" requires a numeric value` }); } diff --git a/src/schema/tests/map_filter.unit.test.ts b/src/schema/tests/map_filter.unit.test.ts index 490e8a1..0fffdc3 100644 --- a/src/schema/tests/map_filter.unit.test.ts +++ b/src/schema/tests/map_filter.unit.test.ts @@ -117,6 +117,36 @@ describe('map_filter schema', () => { expect(result.success).toBe(true); }); + it('accepts a count comparison on a countable field', () => { + const result = FilterNode.safeParse({ + type: 'cmp', + field: 'difficulties', + op: 'count', + value: 4, + }); + expect(result.success).toBe(true); + }); + + it('rejects a non-numeric value for a countable field', () => { + const result = FilterNode.safeParse({ + type: 'cmp', + field: 'difficulties', + op: 'count', + value: 'four', + }); + expect(result.success).toBe(false); + }); + + it('rejects the count op on a non-countable field', () => { + const result = FilterNode.safeParse({ + type: 'cmp', + field: 'downloadCount', + op: 'count', + value: 4, + }); + expect(result.success).toBe(false); + }); + it('rejects an unknown field', () => { const result = FilterNode.safeParse({ type: 'cmp', diff --git a/src/services/maps/tests/maps_repo_filters.test.ts b/src/services/maps/tests/maps_repo_filters.test.ts index 8c7d6e6..9f89b20 100644 --- a/src/services/maps/tests/maps_repo_filters.test.ts +++ b/src/services/maps/tests/maps_repo_filters.test.ts @@ -146,6 +146,36 @@ describe('maps repo search filters', () => { expect(ids).toEqual(['400']); }); + it('filters by the number of difficulties', async () => { + // Seed maps '1' and '2' each have 4 difficulties and are public; '3' has 1 and is hidden. + const ids = await searchIds({ type: 'cmp', field: 'difficulties', op: 'count', value: 4 }); + expect(ids).toEqual(['1', '2']); + }); + + it('counts difficulties per map without bleeding across maps', async () => { + const { pool } = await getServerContext(); + await insertMap({ id: '600', artist: 'DiffCount' }); + await pool.query( + `INSERT INTO difficulties (map_id, difficulty_name) VALUES ('600', 'Easy'), ('600', 'Hard')` + ); + const matchesTwo = await searchIds({ + type: 'and', + children: [ + { type: 'cmp', field: 'artist', op: 'contains', value: 'DiffCount' }, + { type: 'cmp', field: 'difficulties', op: 'count', value: 2 }, + ], + }); + expect(matchesTwo).toEqual(['600']); + const matchesThree = await searchIds({ + type: 'and', + children: [ + { type: 'cmp', field: 'artist', op: 'contains', value: 'DiffCount' }, + { type: 'cmp', field: 'difficulties', op: 'count', value: 3 }, + ], + }); + expect(matchesThree).toEqual([]); + }); + it('matches array membership for tags', async () => { // Seed maps '1' and '2' both carry the "Rock" tag; '3' does too but is hidden. const ids = await searchIds({ type: 'cmp', field: 'tags', op: 'has', value: 'Rock' }); diff --git a/src/services/search/filter_compiler.ts b/src/services/search/filter_compiler.ts index db13e8c..40971eb 100644 --- a/src/services/search/filter_compiler.ts +++ b/src/services/search/filter_compiler.ts @@ -52,9 +52,19 @@ function toUtcInstant(value: string | number): string | number { function compileCmp(node: Extract): db.SQLFragment { const field = FILTER_FIELDS[node.field]; - const column = field.column; const { op, value } = node; + // `countable` fields live in a related table, so compare against a correlated `count(*)` rather + // than a `maps` column. `parentTable` is forced to `maps` so `db.parent('id')` resolves to + // `maps.id` here in the WHERE clause (it would otherwise only be set inside a lateral). + if (field.kind === 'countable') { + const counted = db.count(field.relation, { [field.foreignKey]: db.parent('id') }); + counted.parentTable = 'maps'; + return db.sql`(${counted}) = ${db.param(value)}`; + } + + const column = field.column; + // `submission_date` is `timestamptz`. Bind date values as an absolute instant (anchoring a bare // `YYYY-MM-DD` to midnight UTC) so comparisons are independent of the server session's timezone. const param = @@ -89,5 +99,8 @@ function compileCmp(node: Extract): db.SQLFragment< )} ESCAPE '\\'`; case 'has': return db.sql`${param} = ANY(${column})`; + case 'count': + // Unreachable: `count` is only valid for `countable` fields, handled above. + throw new Error(`'count' operator is only valid for countable fields`); } } diff --git a/src/services/search/tests/filter_compiler.unit.test.ts b/src/services/search/tests/filter_compiler.unit.test.ts index 6819933..7cf9579 100644 --- a/src/services/search/tests/filter_compiler.unit.test.ts +++ b/src/services/search/tests/filter_compiler.unit.test.ts @@ -104,6 +104,21 @@ describe('compileFilter', () => { expect(values).toEqual(['Rock']); }); + it('compiles a countable count as a correlated subquery against the related table', () => { + const { text, values } = compile({ + type: 'cmp', + field: 'difficulties', + op: 'count', + value: 4, + }); + expect(text).toContain('count(*)'); + expect(text).toContain('"difficulties"'); + // Correlated back to the outer maps row. + expect(text).toContain('"maps"."id"'); + expect(text).toContain('='); + expect(values).toEqual([4]); + }); + it('compiles an AND group', () => { const { text, values } = compile({ type: 'and', diff --git a/src/ui/base/textbox/textbox.tsx b/src/ui/base/textbox/textbox.tsx index 8af1206..782d9ef 100644 --- a/src/ui/base/textbox/textbox.tsx +++ b/src/ui/base/textbox/textbox.tsx @@ -14,7 +14,7 @@ export type TextboxProps = { placeholder?: string; borderColor?: TextboxBorderColor; borderWidth?: number; - inputType?: 'text' | 'password' | 'area' | 'date'; + inputType?: 'text' | 'password' | 'area' | 'date' | 'number'; error: string | undefined; value: string; // Optional adornment rendered inside the box, right-aligned after the input.