Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 16 additions & 3 deletions src/app/filter_builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const OP_LABELS: Record<FilterOp, string> = {
lte: '≤',
before: 'before',
after: 'after',
count: 'count',
};

const FIELD_LABELS: Record<FilterableField, string> = {
Expand All @@ -47,6 +48,7 @@ const FIELD_LABELS: Record<FilterableField, string> = {
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
Expand All @@ -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 (
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 : '',
});
};

Expand Down Expand Up @@ -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 (
<input
type="number"
Expand Down
14 changes: 12 additions & 2 deletions src/app/filter_modes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CmpNode, FilterNode, FilterOp, FilterableField } from 'schema/map_filter';
import { CmpNode, FILTER_FIELDS, FilterNode, FilterOp, FilterableField } from 'schema/map_filter';

/**
* Simple and advanced filtering differ only in the UI: both edit the same {@link FilterNode}. Simple
Expand All @@ -12,6 +12,7 @@ export const SIMPLE_FIELDS: SimpleField[] = [
{ field: 'artist', op: 'contains' },
{ field: 'author', op: 'contains' },
{ field: 'description', op: 'contains' },
{ field: 'difficulties', op: 'count' },
{ field: 'submissionDate', op: 'after' },
{ field: 'submissionDate', op: 'before' },
];
Expand Down Expand Up @@ -83,7 +84,16 @@ export function setFieldValue(
children.splice(idx, 1);
}
} else {
const cmp: CmpNode = { type: 'cmp', field: simpleField.field, op: simpleField.op, value };
// Numeric kinds carry a `number` in the AST; the simple-mode widget hands us its raw string, so
// coerce here (the field's blank state was already handled above).
const kind = FILTER_FIELDS[simpleField.field].kind;
const coerced = kind === 'number' || kind === 'countable' ? Number(value) : value;
const cmp: CmpNode = {
type: 'cmp',
field: simpleField.field,
op: simpleField.op,
value: coerced,
};
if (idx >= 0) {
children[idx] = cmp;
} else {
Expand Down
11 changes: 11 additions & 0 deletions src/app/tests/filter_modes.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
19 changes: 14 additions & 5 deletions src/schema/map_filter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { maps } from 'zapatos/schema';
import { difficulties, maps } from 'zapatos/schema';
import { z } from 'zod';

/**
Expand All @@ -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' },
Expand All @@ -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<string, { kind: FieldKind; column: maps.Column }>;
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;

Expand All @@ -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<FieldKind, readonly string[]>;

export type FilterOp = (typeof OPS_BY_KIND)[keyof typeof OPS_BY_KIND][number];
Expand Down Expand Up @@ -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` });
}
Expand Down
30 changes: 30 additions & 0 deletions src/schema/tests/map_filter.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions src/services/maps/tests/maps_repo_filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
15 changes: 14 additions & 1 deletion src/services/search/filter_compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,19 @@ function toUtcInstant(value: string | number): string | number {

function compileCmp(node: Extract<FilterNode, { type: 'cmp' }>): db.SQLFragment<boolean> {
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<maps.SQL, boolean>`(${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 =
Expand Down Expand Up @@ -89,5 +99,8 @@ function compileCmp(node: Extract<FilterNode, { type: 'cmp' }>): db.SQLFragment<
)} ESCAPE '\\'`;
case 'has':
return db.sql<maps.SQL, boolean>`${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`);
}
}
15 changes: 15 additions & 0 deletions src/services/search/tests/filter_compiler.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/ui/base/textbox/textbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading