mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(smart-scope): add rolling older-than date filters
Allow users to build bounded historical windows without hard-coded calendar dates. Keep the API additive and advertise support for independently released clients. Closes #1751
This commit is contained in:
@@ -74,6 +74,7 @@ const NO_VALUE_OPERATORS: RuleOperator[] = [
|
|||||||
]
|
]
|
||||||
const BETWEEN_OPERATORS: RuleOperator[] = ['between']
|
const BETWEEN_OPERATORS: RuleOperator[] = ['between']
|
||||||
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
|
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
|
||||||
|
const RELATIVE_DAY_OPERATORS: RuleOperator[] = ['withinLast', 'moreThanAgo']
|
||||||
|
|
||||||
const SCORE_PRESETS = [
|
const SCORE_PRESETS = [
|
||||||
{ label: 'outstanding', gte: 90 },
|
{ label: 'outstanding', gte: 90 },
|
||||||
@@ -215,9 +216,10 @@ function parseValue(field: RuleField, operator: RuleOperator, raw: string, chips
|
|||||||
if (NO_VALUE_OPERATORS.includes(operator)) return undefined
|
if (NO_VALUE_OPERATORS.includes(operator)) return undefined
|
||||||
if (COLLECTION_OPERATORS.includes(operator)) return chips
|
if (COLLECTION_OPERATORS.includes(operator)) return chips
|
||||||
if (isNumericField(field)) return raw === '' ? undefined : field === 'fileSize' ? Math.round(Number(raw) * BYTES_PER_MIB) : Number(raw)
|
if (isNumericField(field)) return raw === '' ? undefined : field === 'fileSize' ? Math.round(Number(raw) * BYTES_PER_MIB) : Number(raw)
|
||||||
if (isDateField(field) && operator === 'withinLast') {
|
if (isDateField(field) && RELATIVE_DAY_OPERATORS.includes(operator)) {
|
||||||
if (raw === '') return undefined
|
if (raw === '') return undefined
|
||||||
const n = Number(raw)
|
const n = Number(raw)
|
||||||
|
if (operator === 'moreThanAgo') return n
|
||||||
const multiplier = unit === 'weeks' ? 7 : unit === 'months' ? 30 : 1
|
const multiplier = unit === 'weeks' ? 7 : unit === 'months' ? 30 : 1
|
||||||
return n * multiplier
|
return n * multiplier
|
||||||
}
|
}
|
||||||
@@ -406,7 +408,7 @@ function removeStatusChip(index: number, status: string) {
|
|||||||
|
|
||||||
function valueInputType(field: RuleField, operator: RuleOperator): string {
|
function valueInputType(field: RuleField, operator: RuleOperator): string {
|
||||||
if (NO_VALUE_OPERATORS.includes(operator)) return 'none'
|
if (NO_VALUE_OPERATORS.includes(operator)) return 'none'
|
||||||
if (isDateField(field)) return operator === 'withinLast' ? 'number' : 'date'
|
if (isDateField(field)) return RELATIVE_DAY_OPERATORS.includes(operator) ? 'number' : 'date'
|
||||||
if (field === 'seriesIndex') return 'text'
|
if (field === 'seriesIndex') return 'text'
|
||||||
if (isNumericField(field)) return 'number'
|
if (isNumericField(field)) return 'number'
|
||||||
return 'text'
|
return 'text'
|
||||||
@@ -579,8 +581,8 @@ function showValueToInput(operator: RuleOperator): boolean {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- withinLast: number + unit selector -->
|
<!-- Relative date: numeric day count, with convenience units for withinLast. -->
|
||||||
<template v-else-if="node.rule.operator === 'withinLast'">
|
<template v-else-if="RELATIVE_DAY_OPERATORS.includes(node.rule.operator)">
|
||||||
<input
|
<input
|
||||||
v-model="node.rule.value"
|
v-model="node.rule.value"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -590,6 +592,7 @@ function showValueToInput(operator: RuleOperator): boolean {
|
|||||||
@input="emitUpdate"
|
@input="emitUpdate"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
|
v-if="node.rule.operator === 'withinLast'"
|
||||||
v-model="node.rule.valueUnit"
|
v-model="node.rule.valueUnit"
|
||||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary"
|
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
@change="emitUpdate"
|
@change="emitUpdate"
|
||||||
@@ -598,6 +601,7 @@ function showValueToInput(operator: RuleOperator): boolean {
|
|||||||
<option value="weeks">{{ t('book.filter.unit.weeks') }}</option>
|
<option value="weeks">{{ t('book.filter.unit.weeks') }}</option>
|
||||||
<option value="months">{{ t('book.filter.unit.months') }}</option>
|
<option value="months">{{ t('book.filter.unit.months') }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span v-else class="text-sm text-muted-foreground">{{ t('book.filter.unit.daysAgo') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<!-- Text typeahead: publisher, series, language -->
|
<!-- Text typeahead: publisher, series, language -->
|
||||||
<FilterTextTypeahead
|
<FilterTextTypeahead
|
||||||
|
|||||||
@@ -123,7 +123,26 @@ describe('BookFilterBuilder', () => {
|
|||||||
|
|
||||||
const operatorSelect = wrapper.findAll('select')[1]
|
const operatorSelect = wrapper.findAll('select')[1]
|
||||||
const operatorOptions = operatorSelect!.findAll('option').map((opt) => opt.text())
|
const operatorOptions = operatorSelect!.findAll('option').map((opt) => opt.text())
|
||||||
expect(operatorOptions).toEqual(expect.arrayContaining(['before', 'after', 'between', 'within last', 'is empty', 'is not empty']))
|
expect(operatorOptions).toEqual(expect.arrayContaining(['before', 'after', 'between', 'within last', 'more than', 'is empty', 'is not empty']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits a more-than-ago date rule as an unconverted day count', async () => {
|
||||||
|
const wrapper = mount(BookFilterBuilder, {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.findAll('select')[0]!.setValue('finishedAt')
|
||||||
|
await wrapper.findAll('select')[1]!.setValue('moreThanAgo')
|
||||||
|
await wrapper.get('input[type="number"]').setValue('30')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('days ago')
|
||||||
|
expect(lastUpdate(wrapper)).toEqual({
|
||||||
|
type: 'group',
|
||||||
|
join: 'AND',
|
||||||
|
rules: [{ type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value: 30, valueTo: undefined }],
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('includes Lock Status in field options', () => {
|
it('includes Lock Status in field options', () => {
|
||||||
@@ -358,7 +377,7 @@ describe('BookFilterBuilder custom metadata fields', () => {
|
|||||||
['text', ['contains', 'does not contain', 'starts with', 'ends with', 'is', 'is not', 'is empty', 'is not empty']],
|
['text', ['contains', 'does not contain', 'starts with', 'ends with', 'is', 'is not', 'is empty', 'is not empty']],
|
||||||
['url', ['contains', 'does not contain', 'starts with', 'ends with', 'is', 'is not', 'is empty', 'is not empty']],
|
['url', ['contains', 'does not contain', 'starts with', 'ends with', 'is', 'is not', 'is empty', 'is not empty']],
|
||||||
['number', ['is', 'is not', 'greater than', 'at least', 'less than', 'at most', 'between', 'is empty', 'is not empty']],
|
['number', ['is', 'is not', 'greater than', 'at least', 'less than', 'at most', 'between', 'is empty', 'is not empty']],
|
||||||
['date', ['before', 'after', 'between', 'within last', 'is empty', 'is not empty']],
|
['date', ['before', 'after', 'between', 'within last', 'more than', 'is empty', 'is not empty']],
|
||||||
['boolean', ['is yes', 'is no', 'is empty', 'is not empty']],
|
['boolean', ['is yes', 'is no', 'is empty', 'is not empty']],
|
||||||
] as const)('offers only the operators a %s custom field supports', async (type, expected) => {
|
] as const)('offers only the operators a %s custom field supports', async (type, expected) => {
|
||||||
const wrapper = mountWithCustomFields([makeCustomField({ type })])
|
const wrapper = mountWithCustomFields([makeCustomField({ type })])
|
||||||
@@ -427,6 +446,20 @@ describe('BookFilterBuilder custom metadata fields', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits a more-than-ago day count for a date custom field', async () => {
|
||||||
|
const wrapper = mountWithCustomFields([makeCustomField({ type: 'date' })])
|
||||||
|
|
||||||
|
await fieldOptions(wrapper).setValue('custom:7')
|
||||||
|
await wrapper.findAll('select')[1]!.setValue('moreThanAgo')
|
||||||
|
await wrapper.get('input[type="number"]').setValue('45')
|
||||||
|
|
||||||
|
expect(lastUpdate(wrapper)).toEqual({
|
||||||
|
type: 'group',
|
||||||
|
join: 'AND',
|
||||||
|
rules: [{ type: 'rule', field: 'custom:7', operator: 'moreThanAgo', value: 45, valueTo: undefined }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('emits a boolean custom field rule with no value input at all', async () => {
|
it('emits a boolean custom field rule with no value input at all', async () => {
|
||||||
const wrapper = mountWithCustomFields([makeCustomField({ type: 'boolean' })])
|
const wrapper = mountWithCustomFields([makeCustomField({ type: 'boolean' })])
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ describe('ruleToParts', () => {
|
|||||||
expect(ruleToParts(rule)).toEqual({ field: 'Added Date', operator: 'within last', value: '7 days' })
|
expect(ruleToParts(rule)).toEqual({ field: 'Added Date', operator: 'within last', value: '7 days' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders moreThanAgo rules as a day age', () => {
|
||||||
|
const rule: Rule = { type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value: 30 }
|
||||||
|
expect(ruleToParts(rule)).toEqual({ field: 'Date Finished', operator: 'more than', value: '30 days ago' })
|
||||||
|
})
|
||||||
|
|
||||||
it('joins array values with commas', () => {
|
it('joins array values with commas', () => {
|
||||||
const rule: Rule = { type: 'rule', field: 'author', operator: 'includesAny', value: ['Tolkien', 'Le Guin'] }
|
const rule: Rule = { type: 'rule', field: 'author', operator: 'includesAny', value: ['Tolkien', 'Le Guin'] }
|
||||||
expect(ruleToParts(rule)).toEqual({ field: 'Author', operator: 'includes any of', value: 'Tolkien, Le Guin' })
|
expect(ruleToParts(rule)).toEqual({ field: 'Author', operator: 'includes any of', value: 'Tolkien, Le Guin' })
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function ruleToParts(rule: Rule): { field: string; operator: string; valu
|
|||||||
const operator = operatorLabel(rule.operator)
|
const operator = operatorLabel(rule.operator)
|
||||||
if (NO_VALUE_OPS.includes(rule.operator)) return { field, operator, value: null }
|
if (NO_VALUE_OPS.includes(rule.operator)) return { field, operator, value: null }
|
||||||
if (rule.operator === 'withinLast') return { field, operator, value: `${rule.value} ${t('book.filter.unit.days')}` }
|
if (rule.operator === 'withinLast') return { field, operator, value: `${rule.value} ${t('book.filter.unit.days')}` }
|
||||||
|
if (rule.operator === 'moreThanAgo') return { field, operator, value: `${rule.value} ${t('book.filter.unit.daysAgo')}` }
|
||||||
if (rule.field === 'fileSize') {
|
if (rule.field === 'fileSize') {
|
||||||
const value = typeof rule.value === 'number' ? formatBytes(rule.value) : ''
|
const value = typeof rule.value === 'number' ? formatBytes(rule.value) : ''
|
||||||
const valueTo = typeof rule.valueTo === 'number' ? formatBytes(rule.valueTo) : null
|
const valueTo = typeof rule.valueTo === 'number' ? formatBytes(rule.valueTo) : null
|
||||||
|
|||||||
@@ -6180,6 +6180,7 @@
|
|||||||
"before": "before",
|
"before": "before",
|
||||||
"after": "after",
|
"after": "after",
|
||||||
"withinLast": "within last",
|
"withinLast": "within last",
|
||||||
|
"moreThanAgo": "more than",
|
||||||
"isMissing": "is missing",
|
"isMissing": "is missing",
|
||||||
"isPresent": "is present",
|
"isPresent": "is present",
|
||||||
"isUnread": "is unread",
|
"isUnread": "is unread",
|
||||||
@@ -6202,6 +6203,7 @@
|
|||||||
"withinLastPlaceholder": "e.g. 7",
|
"withinLastPlaceholder": "e.g. 7",
|
||||||
"unit": {
|
"unit": {
|
||||||
"days": "days",
|
"days": "days",
|
||||||
|
"daysAgo": "days ago",
|
||||||
"weeks": "weeks",
|
"weeks": "weeks",
|
||||||
"months": "months",
|
"months": "months",
|
||||||
"megabytes": "MB"
|
"megabytes": "MB"
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const SERVER_FEATURES = [
|
|||||||
* names an author but no title.
|
* names an author but no title.
|
||||||
*/
|
*/
|
||||||
"metadata-search-results-per-provider",
|
"metadata-search-results-per-provider",
|
||||||
|
/** Book and custom date filters accept the `moreThanAgo` rolling-boundary operator. */
|
||||||
|
"relative-date-ranges",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ServerFeature = (typeof SERVER_FEATURES)[number];
|
export type ServerFeature = (typeof SERVER_FEATURES)[number];
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export type RuleOperator =
|
|||||||
| "before"
|
| "before"
|
||||||
| "after"
|
| "after"
|
||||||
| "withinLast"
|
| "withinLast"
|
||||||
|
| "moreThanAgo"
|
||||||
| "isMissing"
|
| "isMissing"
|
||||||
| "isPresent"
|
| "isPresent"
|
||||||
| "isUnread"
|
| "isUnread"
|
||||||
@@ -117,13 +118,13 @@ export const FIELD_OPERATORS: Record<StaticRuleField, RuleOperator[]> = {
|
|||||||
library: ["includesAny", "excludesAll"],
|
library: ["includesAny", "excludesAll"],
|
||||||
format: ["includesAny", "excludesAll"],
|
format: ["includesAny", "excludesAll"],
|
||||||
fileSize: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
fileSize: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||||
publishedDate: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"],
|
publishedDate: ["before", "after", "between", "withinLast", "moreThanAgo", "isEmpty", "isNotEmpty"],
|
||||||
publishedYear: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
publishedYear: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||||
seriesIndex: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
seriesIndex: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||||
pageCount: ["gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
pageCount: ["gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||||
addedAt: ["before", "after", "between", "withinLast"],
|
addedAt: ["before", "after", "between", "withinLast", "moreThanAgo"],
|
||||||
startedAt: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"],
|
startedAt: ["before", "after", "between", "withinLast", "moreThanAgo", "isEmpty", "isNotEmpty"],
|
||||||
finishedAt: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"],
|
finishedAt: ["before", "after", "between", "withinLast", "moreThanAgo", "isEmpty", "isNotEmpty"],
|
||||||
fileAvailability: ["isMissing", "isPresent"],
|
fileAvailability: ["isMissing", "isPresent"],
|
||||||
rating: ["eq", "gt", "gte", "lt", "lte", "isEmpty", "isNotEmpty"],
|
rating: ["eq", "gt", "gte", "lt", "lte", "isEmpty", "isNotEmpty"],
|
||||||
communityRating: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
communityRating: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||||
@@ -152,7 +153,7 @@ export const CUSTOM_FIELD_TYPE_OPERATORS: Record<CustomMetadataFieldType, RuleOp
|
|||||||
text: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
|
text: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
|
||||||
url: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
|
url: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
|
||||||
number: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
number: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||||
date: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"],
|
date: ["before", "after", "between", "withinLast", "moreThanAgo", "isEmpty", "isNotEmpty"],
|
||||||
boolean: ["isTrue", "isFalse", "isEmpty", "isNotEmpty"],
|
boolean: ["isTrue", "isFalse", "isEmpty", "isNotEmpty"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,6 +184,7 @@ export const RULE_OPERATORS: RuleOperator[] = [
|
|||||||
"before",
|
"before",
|
||||||
"after",
|
"after",
|
||||||
"withinLast",
|
"withinLast",
|
||||||
|
"moreThanAgo",
|
||||||
"isMissing",
|
"isMissing",
|
||||||
"isPresent",
|
"isPresent",
|
||||||
"isUnread",
|
"isUnread",
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unkn
|
|||||||
case 'after':
|
case 'after':
|
||||||
return { value: '2023-01-01' };
|
return { value: '2023-01-01' };
|
||||||
case 'withinLast':
|
case 'withinLast':
|
||||||
|
case 'moreThanAgo':
|
||||||
return { value: 7 };
|
return { value: 7 };
|
||||||
case 'includesAny':
|
case 'includesAny':
|
||||||
case 'includesAll':
|
case 'includesAll':
|
||||||
@@ -206,7 +207,7 @@ function buildValueForCustomType(operator: RuleOperator, type: CustomMetadataFie
|
|||||||
return operator === 'between' ? { value: 10, valueTo: 20 } : { value: 10 };
|
return operator === 'between' ? { value: 10, valueTo: 20 } : { value: 10 };
|
||||||
case 'date':
|
case 'date':
|
||||||
if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-12-31' };
|
if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-12-31' };
|
||||||
return operator === 'withinLast' ? { value: 7 } : { value: '2024-01-01' };
|
return operator === 'withinLast' || operator === 'moreThanAgo' ? { value: 7 } : { value: '2024-01-01' };
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return {};
|
return {};
|
||||||
case 'text':
|
case 'text':
|
||||||
@@ -368,6 +369,17 @@ describe('BookQueryBuilder', () => {
|
|||||||
).toThrow("Operator 'withinLast' requires a non-negative value");
|
).toThrow("Operator 'withinLast' requires a non-negative value");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects negative moreThanAgo values', () => {
|
||||||
|
const { builder } = makeBuilder();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'moreThanAgo', value: -3 }) as never, {
|
||||||
|
accessibleLibraryIds: [1],
|
||||||
|
userId: 10,
|
||||||
|
}),
|
||||||
|
).toThrow("Operator 'moreThanAgo' requires a non-negative value");
|
||||||
|
});
|
||||||
|
|
||||||
it('requires authenticated user for readProgress filters', () => {
|
it('requires authenticated user for readProgress filters', () => {
|
||||||
const { builder } = makeBuilder();
|
const { builder } = makeBuilder();
|
||||||
|
|
||||||
@@ -1289,6 +1301,16 @@ describe('dateRuleToSql (addedAt)', () => {
|
|||||||
expect(getRuleSql(where)).toMatchObject({ type: 'sql' });
|
expect(getRuleSql(where)).toMatchObject({ type: 'sql' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('moreThanAgo produces the inverse added-date interval expression', () => {
|
||||||
|
const { builder } = makeBuilder();
|
||||||
|
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'moreThanAgo', value: 30 }) as never, BASE_CTX) as any;
|
||||||
|
const rule = getRuleSql(where);
|
||||||
|
|
||||||
|
expect(rule).toMatchObject({ type: 'sql' });
|
||||||
|
expect(rule.text).toContain('< NOW() - (');
|
||||||
|
expect(rule.values).toContain(30);
|
||||||
|
});
|
||||||
|
|
||||||
it('withinLast with zero days is valid (produces sql expression)', () => {
|
it('withinLast with zero days is valid (produces sql expression)', () => {
|
||||||
const { builder } = makeBuilder();
|
const { builder } = makeBuilder();
|
||||||
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'withinLast', value: 0 }) as never, BASE_CTX)).not.toThrow();
|
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'withinLast', value: 0 }) as never, BASE_CTX)).not.toThrow();
|
||||||
@@ -1372,6 +1394,7 @@ describe('customFieldRuleToSql', () => {
|
|||||||
['before', '2024-01-01', 'value_date'],
|
['before', '2024-01-01', 'value_date'],
|
||||||
['after', '2024-01-01', 'value_date'],
|
['after', '2024-01-01', 'value_date'],
|
||||||
['withinLast', 30, 'value_date'],
|
['withinLast', 30, 'value_date'],
|
||||||
|
['moreThanAgo', 30, 'value_date'],
|
||||||
] as const)('reads %s with a %s value from %s', (operator, value, column) => {
|
] as const)('reads %s with a %s value from %s', (operator, value, column) => {
|
||||||
const clause = buildCustomRule({ operator, value });
|
const clause = buildCustomRule({ operator, value });
|
||||||
|
|
||||||
@@ -1449,6 +1472,13 @@ describe('customFieldRuleToSql', () => {
|
|||||||
expect(collectSqlText(clause).join(' ')).toContain('timezone(');
|
expect(collectSqlText(clause).join(' ')).toContain('timezone(');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps date arithmetic Postgres-safe for moreThanAgo', () => {
|
||||||
|
const clause = buildCustomRule({ operator: 'moreThanAgo', value: 30 });
|
||||||
|
|
||||||
|
expectPostgresSafeDateArithmetic(clause);
|
||||||
|
expect(collectSqlText(clause).join(' ')).toContain('timezone(');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects list values that no custom field operator accepts', () => {
|
it('rejects list values that no custom field operator accepts', () => {
|
||||||
expect(() => buildCustomRule({ operator: 'includesAny', value: ['a'] })).toThrow(BadRequestException);
|
expect(() => buildCustomRule({ operator: 'includesAny', value: ['a'] })).toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
@@ -1562,6 +1592,22 @@ describe('read status date filters (startedAt / finishedAt)', () => {
|
|||||||
expectPostgresSafeDateArithmetic(rule);
|
expectPostgresSafeDateArithmetic(rule);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['startedAt', 'finishedAt'] as const)('uses the complementary calendar-day boundary for moreThanAgo on %s', (field) => {
|
||||||
|
const { builder } = makeBuilder();
|
||||||
|
const where = builder.buildWhere(wrapRule({ type: 'rule', field, operator: 'moreThanAgo', value: 30 }) as never, {
|
||||||
|
...USER_CTX,
|
||||||
|
timeZone: 'America/Chicago',
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const rule = getRuleSql(where);
|
||||||
|
expect(rule).toMatchObject({ type: 'sql' });
|
||||||
|
expect(rule.text.toLowerCase()).toContain('< (timezone(');
|
||||||
|
expect(rule.text.toLowerCase()).toContain('::date - ::int');
|
||||||
|
expect(rule.values[1]).toBe('America/Chicago');
|
||||||
|
expect(rule.values[2]).toBe(29);
|
||||||
|
expectPostgresSafeDateArithmetic(rule);
|
||||||
|
});
|
||||||
|
|
||||||
it('supports isEmpty / isNotEmpty operators', () => {
|
it('supports isEmpty / isNotEmpty operators', () => {
|
||||||
const { builder } = makeBuilder();
|
const { builder } = makeBuilder();
|
||||||
const emptyWhere = builder.buildWhere(wrapRule({ type: 'rule', field: 'startedAt', operator: 'isEmpty' }) as never, USER_CTX) as any;
|
const emptyWhere = builder.buildWhere(wrapRule({ type: 'rule', field: 'startedAt', operator: 'isEmpty' }) as never, USER_CTX) as any;
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ export class BookQueryBuilder {
|
|||||||
case 'before':
|
case 'before':
|
||||||
case 'after':
|
case 'after':
|
||||||
case 'withinLast':
|
case 'withinLast':
|
||||||
|
case 'moreThanAgo':
|
||||||
return this.dateExprRuleToSql(sql`${bookCustomMetadataValues.valueDate}`, operator, value as string, valueTo, timeZone, 'custom metadata');
|
return this.dateExprRuleToSql(sql`${bookCustomMetadataValues.valueDate}`, operator, value as string, valueTo, timeZone, 'custom metadata');
|
||||||
case 'isTrue':
|
case 'isTrue':
|
||||||
return eq(bookCustomMetadataValues.valueBoolean, true);
|
return eq(bookCustomMetadataValues.valueBoolean, true);
|
||||||
@@ -686,6 +687,12 @@ export class BookQueryBuilder {
|
|||||||
if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`);
|
if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`);
|
||||||
return sql`${books.addedAt} >= NOW() - (${days} * INTERVAL '1 day')`;
|
return sql`${books.addedAt} >= NOW() - (${days} * INTERVAL '1 day')`;
|
||||||
}
|
}
|
||||||
|
case 'moreThanAgo': {
|
||||||
|
const days = typeof value === 'string' ? Number(value) : value;
|
||||||
|
this.assertNumber(days, operator, 'value');
|
||||||
|
if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`);
|
||||||
|
return sql`${books.addedAt} < NOW() - (${days} * INTERVAL '1 day')`;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new BadRequestException(`Invalid operator '${operator}' for date field`);
|
throw new BadRequestException(`Invalid operator '${operator}' for date field`);
|
||||||
}
|
}
|
||||||
@@ -721,6 +728,14 @@ export class BookQueryBuilder {
|
|||||||
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
|
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
|
||||||
return sql`${dateExpr} >= (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
|
return sql`${dateExpr} >= (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
|
||||||
}
|
}
|
||||||
|
case 'moreThanAgo': {
|
||||||
|
const days = typeof value === 'string' ? Number(value) : value;
|
||||||
|
this.assertNumber(days, operator, 'value');
|
||||||
|
if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`);
|
||||||
|
const wholeDays = Math.floor(days!);
|
||||||
|
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
|
||||||
|
return sql`${dateExpr} < (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
|
||||||
|
}
|
||||||
case 'isEmpty':
|
case 'isEmpty':
|
||||||
return sql`${dateExpr} is null`;
|
return sql`${dateExpr} is null`;
|
||||||
case 'isNotEmpty':
|
case 'isNotEmpty':
|
||||||
@@ -766,6 +781,14 @@ export class BookQueryBuilder {
|
|||||||
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
|
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
|
||||||
return sql`${dateExpr} >= (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
|
return sql`${dateExpr} >= (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
|
||||||
}
|
}
|
||||||
|
case 'moreThanAgo': {
|
||||||
|
const days = typeof value === 'string' ? Number(value) : value;
|
||||||
|
this.assertNumber(days, operator, 'value');
|
||||||
|
if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`);
|
||||||
|
const wholeDays = Math.floor(days!);
|
||||||
|
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
|
||||||
|
return sql`${dateExpr} < (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new BadRequestException(`Invalid operator '${operator}' for ${fieldLabel} field`);
|
throw new BadRequestException(`Invalid operator '${operator}' for ${fieldLabel} field`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function validRuleValue(operator: RuleOperator, field: RuleField): { value?: unk
|
|||||||
case 'lt':
|
case 'lt':
|
||||||
case 'lte':
|
case 'lte':
|
||||||
case 'withinLast':
|
case 'withinLast':
|
||||||
|
case 'moreThanAgo':
|
||||||
return { value: 1 };
|
return { value: 1 };
|
||||||
case 'between':
|
case 'between':
|
||||||
return DATE_FIELDS.includes(field) ? { value: '2024-01-01', valueTo: '2024-06-01' } : { value: 1, valueTo: 2 };
|
return DATE_FIELDS.includes(field) ? { value: '2024-01-01', valueTo: '2024-06-01' } : { value: 1, valueTo: 2 };
|
||||||
@@ -77,7 +78,7 @@ function validCustomRuleValue(operator: RuleOperator, type: CustomMetadataFieldT
|
|||||||
return operator === 'between' ? { value: 1, valueTo: 5 } : { value: 1 };
|
return operator === 'between' ? { value: 1, valueTo: 5 } : { value: 1 };
|
||||||
case 'date':
|
case 'date':
|
||||||
if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-06-01' };
|
if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-06-01' };
|
||||||
return operator === 'withinLast' ? { value: 7 } : { value: '2024-01-01' };
|
return operator === 'withinLast' || operator === 'moreThanAgo' ? { value: 7 } : { value: '2024-01-01' };
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'url':
|
case 'url':
|
||||||
return { value: 'test' };
|
return { value: 'test' };
|
||||||
@@ -439,6 +440,24 @@ describe('date field value validation', () => {
|
|||||||
expect(validateGroupRule(rule)).toBeDefined();
|
expect(validateGroupRule(rule)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts a non-negative day count for moreThanAgo', () => {
|
||||||
|
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value: 30 }] };
|
||||||
|
expect(validateGroupRule(rule)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([-1, 'not-a-number', undefined])('rejects an invalid moreThanAgo day count: %s', (value) => {
|
||||||
|
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value }] };
|
||||||
|
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates moreThanAgo day counts for custom date fields', () => {
|
||||||
|
const valid = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: customRuleField(7), operator: 'moreThanAgo', value: 14 }] };
|
||||||
|
const invalid = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: customRuleField(7), operator: 'moreThanAgo', value: -1 }] };
|
||||||
|
|
||||||
|
expect(validateGroupRule(valid)).toBeDefined();
|
||||||
|
expect(() => validateGroupRule(invalid)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not apply date validation to non-date fields sharing the between operator', () => {
|
it('does not apply date validation to non-date fields sharing the between operator', () => {
|
||||||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'pageCount', operator: 'between', value: 100, valueTo: 300 }] };
|
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'pageCount', operator: 'between', value: 100, valueTo: 300 }] };
|
||||||
expect(validateGroupRule(rule)).toBeDefined();
|
expect(validateGroupRule(rule)).toBeDefined();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const COMMUNITY_RATING_PROVIDER_VALUES = ['any', ...COMMUNITY_RATING_PROVIDER_KE
|
|||||||
|
|
||||||
const DATE_VALUE_FIELDS = new Set<StaticRuleField>(['addedAt', 'startedAt', 'finishedAt', 'publishedDate']);
|
const DATE_VALUE_FIELDS = new Set<StaticRuleField>(['addedAt', 'startedAt', 'finishedAt', 'publishedDate']);
|
||||||
const DATE_VALUE_OPERATORS = new Set<RuleOperator>(['before', 'after', 'between']);
|
const DATE_VALUE_OPERATORS = new Set<RuleOperator>(['before', 'after', 'between']);
|
||||||
|
const RELATIVE_DATE_OPERATORS = new Set<RuleOperator>(['moreThanAgo']);
|
||||||
const CUSTOM_NUMERIC_OPERATORS = new Set<RuleOperator>(['gt', 'gte', 'lt', 'lte']);
|
const CUSTOM_NUMERIC_OPERATORS = new Set<RuleOperator>(['gt', 'gte', 'lt', 'lte']);
|
||||||
const CUSTOM_DATE_OPERATORS = new Set<RuleOperator>(['before', 'after']);
|
const CUSTOM_DATE_OPERATORS = new Set<RuleOperator>(['before', 'after']);
|
||||||
const ISO_DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
const ISO_DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||||
@@ -46,6 +47,11 @@ function isFiniteNumber(value: unknown): value is number {
|
|||||||
return typeof value === 'number' && Number.isFinite(value);
|
return typeof value === 'number' && Number.isFinite(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNonNegativeRelativeDayCount(value: unknown): boolean {
|
||||||
|
const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
||||||
|
return typeof number === 'number' && Number.isFinite(number) && number >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
type UnvalidatedRule = { field: string; operator: string; value?: unknown; valueTo?: unknown };
|
type UnvalidatedRule = { field: string; operator: string; value?: unknown; valueTo?: unknown };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +73,10 @@ function checkCustomFieldValue(rule: UnvalidatedRule, ctx: z.RefinementCtx): voi
|
|||||||
ctx.addIssue({ code: 'custom', message: `Operator '${operator}' requires a valid date value`, path: ['value'] });
|
ctx.addIssue({ code: 'custom', message: `Operator '${operator}' requires a valid date value`, path: ['value'] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (RELATIVE_DATE_OPERATORS.has(operator as RuleOperator) && !isNonNegativeRelativeDayCount(value)) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: `Operator '${operator}' requires a non-negative day count`, path: ['value'] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (operator === 'between') {
|
if (operator === 'between') {
|
||||||
// `value` picks the column the query builder reads, so `valueTo` has to suit that same column.
|
// `value` picks the column the query builder reads, so `valueTo` has to suit that same column.
|
||||||
const rangeIsValid = isFiniteNumber(value) ? isFiniteNumber(valueTo) : isValidRuleDateValue(value) && isValidRuleDateValue(valueTo);
|
const rangeIsValid = isFiniteNumber(value) ? isFiniteNumber(valueTo) : isValidRuleDateValue(value) && isValidRuleDateValue(valueTo);
|
||||||
@@ -112,6 +122,11 @@ const ruleSchema: z.ZodType<Rule> = z
|
|||||||
ctx.addIssue({ code: 'custom', message: `Operator '${rule.operator}' requires a valid date valueTo`, path: ['valueTo'] });
|
ctx.addIssue({ code: 'custom', message: `Operator '${rule.operator}' requires a valid date valueTo`, path: ['valueTo'] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (DATE_VALUE_FIELDS.has(rule.field as StaticRuleField) && RELATIVE_DATE_OPERATORS.has(rule.operator as RuleOperator)) {
|
||||||
|
if (!isNonNegativeRelativeDayCount(rule.value)) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: `Operator '${rule.operator}' requires a non-negative day count`, path: ['value'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
}) as z.ZodType<Rule>;
|
}) as z.ZodType<Rule>;
|
||||||
|
|
||||||
const groupRuleSchema = (maxDepth: number): z.ZodType<GroupRule> =>
|
const groupRuleSchema = (maxDepth: number): z.ZodType<GroupRule> =>
|
||||||
|
|||||||
Reference in New Issue
Block a user