mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(smart-scope): allow filtering by custom metadata fields
Address custom metadata fields as custom:<id> rules, mirroring the existing custom sort fields, and resolve the value column from the operator so buildWhere stays synchronous for every caller. Closes #855
This commit is contained in:
@@ -3,15 +3,24 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Plus, Trash2, X } from '@lucide/vue'
|
||||
import {
|
||||
COMMUNITY_RATING_PROVIDER_KEYS,
|
||||
CUSTOM_FIELD_OPERATORS,
|
||||
CUSTOM_FIELD_TYPE_OPERATORS,
|
||||
FIELD_OPERATORS,
|
||||
RULE_FIELDS,
|
||||
customRuleField,
|
||||
isCustomRuleField,
|
||||
parseCustomRuleFieldId,
|
||||
type CommunityRatingProvider,
|
||||
type CustomMetadataFieldType,
|
||||
type CustomRuleField,
|
||||
type GroupRule,
|
||||
type Rule,
|
||||
type RuleField,
|
||||
type RuleOperator,
|
||||
type StaticRuleField,
|
||||
} from '@bookorbit/types'
|
||||
import { READ_STATUSES } from '@bookorbit/types'
|
||||
import { useActiveCustomFields } from '@/features/book/composables/useActiveCustomFields'
|
||||
import { fieldLabel, operatorLabel } from '@/features/book/lib/filter-labels'
|
||||
import { providerIconPathSafe } from '@/features/book/lib/provider-icons'
|
||||
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
||||
@@ -47,6 +56,8 @@ const NO_VALUE_OPERATORS: RuleOperator[] = [
|
||||
'isLocked',
|
||||
'isUnlocked',
|
||||
'isUpNext',
|
||||
'isTrue',
|
||||
'isFalse',
|
||||
]
|
||||
const BETWEEN_OPERATORS: RuleOperator[] = ['between']
|
||||
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
|
||||
@@ -97,6 +108,36 @@ onMounted(() => {
|
||||
void fetchLibraries()
|
||||
})
|
||||
|
||||
const { fields: customFields } = useActiveCustomFields()
|
||||
|
||||
const activeCustomFields = computed(() =>
|
||||
customFields.value.filter((f) => !f.archivedAt).sort((a, b) => a.displayOrder - b.displayOrder || a.label.localeCompare(b.label)),
|
||||
)
|
||||
|
||||
const customFieldTypes = computed(() => new Map(activeCustomFields.value.map((f) => [f.id, f.type])))
|
||||
|
||||
function customFieldType(field: RuleField): CustomMetadataFieldType | null {
|
||||
const fieldId = parseCustomRuleFieldId(field)
|
||||
return fieldId === null ? null : (customFieldTypes.value.get(fieldId) ?? null)
|
||||
}
|
||||
|
||||
// A custom field's type decides which operators make sense for it. A rule pointing at a field
|
||||
// that is no longer active keeps every custom operator selectable so the saved one still shows.
|
||||
function operatorsForField(field: RuleField): RuleOperator[] {
|
||||
const type = customFieldType(field)
|
||||
if (type) return CUSTOM_FIELD_TYPE_OPERATORS[type]
|
||||
if (isCustomRuleField(field)) return CUSTOM_FIELD_OPERATORS
|
||||
return FIELD_OPERATORS[field as StaticRuleField]
|
||||
}
|
||||
|
||||
function isNumericField(field: RuleField): boolean {
|
||||
return NUMERIC_FIELDS.includes(field) || customFieldType(field) === 'number'
|
||||
}
|
||||
|
||||
function isDateField(field: RuleField): boolean {
|
||||
return DATE_FIELDS.includes(field) || customFieldType(field) === 'date'
|
||||
}
|
||||
|
||||
type WithinLastUnit = 'days' | 'weeks' | 'months'
|
||||
|
||||
interface EditableRule {
|
||||
@@ -149,8 +190,8 @@ function toLocalNodes(group: GroupRule | undefined): LocalNode[] {
|
||||
function parseValue(field: RuleField, operator: RuleOperator, raw: string, chips: string[], unit: WithinLastUnit): Rule['value'] {
|
||||
if (NO_VALUE_OPERATORS.includes(operator)) return undefined
|
||||
if (COLLECTION_OPERATORS.includes(operator)) return chips
|
||||
if (NUMERIC_FIELDS.includes(field)) return raw === '' ? undefined : Number(raw)
|
||||
if (DATE_FIELDS.includes(field) && operator === 'withinLast') {
|
||||
if (isNumericField(field)) return raw === '' ? undefined : Number(raw)
|
||||
if (isDateField(field) && operator === 'withinLast') {
|
||||
if (raw === '') return undefined
|
||||
const n = Number(raw)
|
||||
const multiplier = unit === 'weeks' ? 7 : unit === 'months' ? 30 : 1
|
||||
@@ -161,6 +202,13 @@ function parseValue(field: RuleField, operator: RuleOperator, raw: string, chips
|
||||
|
||||
const nodes = ref<LocalNode[]>(toLocalNodes(props.modelValue))
|
||||
const join = ref<'AND' | 'OR'>(props.modelValue?.join ?? 'AND')
|
||||
|
||||
// Active fields, plus any custom field an existing rule already points at so a scope built on a
|
||||
// since-archived field still shows its own selection instead of an empty dropdown.
|
||||
const customFieldOptions = computed<CustomRuleField[]>(() => {
|
||||
const selected = nodes.value.flatMap((node) => (node.kind === 'rule' && isCustomRuleField(node.rule.field) ? [node.rule.field] : []))
|
||||
return [...new Set([...activeCustomFields.value.map((f) => customRuleField(f.id)), ...selected])]
|
||||
})
|
||||
let selfEmitting = false
|
||||
|
||||
watch(
|
||||
@@ -199,7 +247,7 @@ function emitUpdate() {
|
||||
value: parseValue(n.rule.field, n.rule.operator, n.rule.value, n.rule.valueChips, n.rule.valueUnit),
|
||||
valueTo:
|
||||
BETWEEN_OPERATORS.includes(n.rule.operator) && n.rule.valueTo !== ''
|
||||
? NUMERIC_FIELDS.includes(n.rule.field)
|
||||
? isNumericField(n.rule.field)
|
||||
? Number(n.rule.valueTo)
|
||||
: n.rule.valueTo
|
||||
: undefined,
|
||||
@@ -242,7 +290,7 @@ function removeNode(index: number) {
|
||||
function onFieldChange(index: number) {
|
||||
const node = nodes.value[index]
|
||||
if (node?.kind !== 'rule') return
|
||||
const validOps = FIELD_OPERATORS[node.rule.field]
|
||||
const validOps = operatorsForField(node.rule.field)
|
||||
if (!validOps.includes(node.rule.operator)) {
|
||||
node.rule.operator = validOps[0]!
|
||||
}
|
||||
@@ -332,8 +380,8 @@ function removeStatusChip(index: number, status: string) {
|
||||
|
||||
function valueInputType(field: RuleField, operator: RuleOperator): string {
|
||||
if (NO_VALUE_OPERATORS.includes(operator)) return 'none'
|
||||
if (DATE_FIELDS.includes(field)) return operator === 'withinLast' ? 'number' : 'date'
|
||||
if (NUMERIC_FIELDS.includes(field)) return 'number'
|
||||
if (isDateField(field)) return operator === 'withinLast' ? 'number' : 'date'
|
||||
if (isNumericField(field)) return 'number'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
@@ -395,6 +443,9 @@ function showValueToInput(operator: RuleOperator): boolean {
|
||||
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 shrink-0"
|
||||
>
|
||||
<option v-for="field in RULE_FIELDS" :key="field" :value="field">{{ fieldLabel(field) }}</option>
|
||||
<optgroup v-if="customFieldOptions.length > 0" :label="t('book.filter.customFieldsGroup')">
|
||||
<option v-for="field in customFieldOptions" :key="field" :value="field">{{ fieldLabel(field) }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
<select
|
||||
@@ -402,7 +453,7 @@ function showValueToInput(operator: RuleOperator): boolean {
|
||||
@change="onOperatorChange(index)"
|
||||
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 shrink-0"
|
||||
>
|
||||
<option v-for="op in FIELD_OPERATORS[node.rule.field]" :key="op" :value="op">{{ operatorLabel(op) }}</option>
|
||||
<option v-for="op in operatorsForField(node.rule.field)" :key="op" :value="op">{{ operatorLabel(op) }}</option>
|
||||
</select>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GroupRule } from '@bookorbit/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CustomMetadataFieldSummary, GroupRule } from '@bookorbit/types'
|
||||
import BookFilterBuilder from '../BookFilterBuilder.vue'
|
||||
|
||||
vi.mock('@/features/library/composables/useLibraries', () => ({
|
||||
@@ -12,6 +12,23 @@ vi.mock('@/features/library/composables/useLibraries', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const customFields = ref<CustomMetadataFieldSummary[]>([])
|
||||
|
||||
vi.mock('@/features/book/composables/useActiveCustomFields', () => ({
|
||||
useActiveCustomFields: () => ({ fields: customFields }),
|
||||
activeCustomFieldLabel: (fieldId: number) => customFields.value.find((f) => f.id === fieldId)?.label ?? null,
|
||||
}))
|
||||
|
||||
function makeCustomField(overrides: Partial<CustomMetadataFieldSummary> = {}): CustomMetadataFieldSummary {
|
||||
return { id: 7, label: 'Shelf Location', type: 'text', displayOrder: 0, archivedAt: null, enabledLibraryIds: [], ...overrides }
|
||||
}
|
||||
|
||||
const titleFilter: GroupRule = {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }],
|
||||
}
|
||||
|
||||
const incompleteFilter: GroupRule = {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
@@ -24,6 +41,10 @@ function lastUpdate(wrapper: ReturnType<typeof mount<typeof BookFilterBuilder>>)
|
||||
}
|
||||
|
||||
describe('BookFilterBuilder', () => {
|
||||
beforeEach(() => {
|
||||
customFields.value = []
|
||||
})
|
||||
|
||||
it('emits undefined for an incomplete root filter by default', async () => {
|
||||
const wrapper = mount(BookFilterBuilder, {
|
||||
props: {
|
||||
@@ -186,3 +207,177 @@ describe('BookFilterBuilder', () => {
|
||||
expect((wrapper.get('select[aria-label="Community rating provider"]').element as HTMLSelectElement).value).toBe('any')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BookFilterBuilder custom metadata fields', () => {
|
||||
beforeEach(() => {
|
||||
customFields.value = []
|
||||
})
|
||||
|
||||
function mountWithCustomFields(fields: CustomMetadataFieldSummary[], modelValue: GroupRule = titleFilter) {
|
||||
customFields.value = fields
|
||||
return mount(BookFilterBuilder, { props: { modelValue } })
|
||||
}
|
||||
|
||||
function fieldOptions(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.findAll('select')[0]!
|
||||
}
|
||||
|
||||
function operatorOptions(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper
|
||||
.findAll('select')[1]!
|
||||
.findAll('option')
|
||||
.map((option) => option.text())
|
||||
}
|
||||
|
||||
it('offers no custom field group when none are active', () => {
|
||||
const wrapper = mountWithCustomFields([])
|
||||
|
||||
expect(wrapper.find('optgroup').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('lists active custom fields in their own group, ordered and labelled verbatim', () => {
|
||||
const wrapper = mountWithCustomFields([
|
||||
makeCustomField({ id: 7, label: 'Shelf Location', displayOrder: 1 }),
|
||||
makeCustomField({ id: 3, label: 'Condition', displayOrder: 0 }),
|
||||
])
|
||||
|
||||
const group = wrapper.get('optgroup')
|
||||
expect(group.attributes('label')).toBe('Custom fields')
|
||||
expect(group.findAll('option').map((option) => [option.attributes('value'), option.text()])).toEqual([
|
||||
['custom:3', 'Condition'],
|
||||
['custom:7', 'Shelf Location'],
|
||||
])
|
||||
})
|
||||
|
||||
it('omits archived custom fields', () => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ archivedAt: '2026-01-01T00:00:00.000Z' })])
|
||||
|
||||
expect(wrapper.find('optgroup').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a rule pointing at a since-archived field selectable', () => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ id: 3, label: 'Condition' })], {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'custom:7', operator: 'contains', value: 'A3' }],
|
||||
})
|
||||
|
||||
const values = wrapper
|
||||
.get('optgroup')
|
||||
.findAll('option')
|
||||
.map((option) => option.attributes('value'))
|
||||
expect(values).toEqual(['custom:3', 'custom:7'])
|
||||
expect((fieldOptions(wrapper).element as HTMLSelectElement).value).toBe('custom:7')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['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']],
|
||||
['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']],
|
||||
['boolean', ['is yes', 'is no', 'is empty', 'is not empty']],
|
||||
] as const)('offers only the operators a %s custom field supports', async (type, expected) => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ type })])
|
||||
|
||||
await fieldOptions(wrapper).setValue('custom:7')
|
||||
|
||||
expect(operatorOptions(wrapper)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('emits a text custom field rule with its string value', async () => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ type: 'text' })])
|
||||
|
||||
await fieldOptions(wrapper).setValue('custom:7')
|
||||
await wrapper.get('input').setValue('A3')
|
||||
|
||||
expect(lastUpdate(wrapper)).toEqual({
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'custom:7', operator: 'contains', value: 'A3', valueTo: undefined }],
|
||||
})
|
||||
})
|
||||
|
||||
it('emits a number custom field rule with numeric bounds', async () => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ type: 'number' })])
|
||||
|
||||
await fieldOptions(wrapper).setValue('custom:7')
|
||||
await wrapper.findAll('select')[1]!.setValue('between')
|
||||
const inputs = wrapper.findAll('input[type="number"]')
|
||||
await inputs[0]!.setValue('2')
|
||||
await inputs[1]!.setValue('9')
|
||||
|
||||
expect(lastUpdate(wrapper)).toEqual({
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'custom:7', operator: 'between', value: 2, valueTo: 9 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('emits a date custom field rule with a date value', async () => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ type: 'date' })])
|
||||
|
||||
await fieldOptions(wrapper).setValue('custom:7')
|
||||
const valueInput = wrapper.get('input')
|
||||
expect(valueInput.attributes('type')).toBe('date')
|
||||
await valueInput.setValue('2024-05-01')
|
||||
|
||||
expect(lastUpdate(wrapper)).toEqual({
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'custom:7', operator: 'before', value: '2024-05-01', valueTo: undefined }],
|
||||
})
|
||||
})
|
||||
|
||||
it('converts a within last unit to days for a date custom field', async () => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ type: 'date' })])
|
||||
|
||||
await fieldOptions(wrapper).setValue('custom:7')
|
||||
await wrapper.findAll('select')[1]!.setValue('withinLast')
|
||||
await wrapper.get('input[type="number"]').setValue('2')
|
||||
await wrapper.findAll('select')[2]!.setValue('weeks')
|
||||
|
||||
expect(lastUpdate(wrapper)).toEqual({
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'custom:7', operator: 'withinLast', value: 14, valueTo: undefined }],
|
||||
})
|
||||
})
|
||||
|
||||
it('emits a boolean custom field rule with no value input at all', async () => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ type: 'boolean' })])
|
||||
|
||||
await fieldOptions(wrapper).setValue('custom:7')
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
expect(lastUpdate(wrapper)).toEqual({
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'custom:7', operator: 'isTrue', value: undefined, valueTo: undefined }],
|
||||
})
|
||||
})
|
||||
|
||||
it('resets an operator that does not carry over when switching to a custom field', async () => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ type: 'number' })])
|
||||
|
||||
await fieldOptions(wrapper).setValue('custom:7')
|
||||
|
||||
expect((wrapper.findAll('select')[1]!.element as HTMLSelectElement).value).toBe('eq')
|
||||
})
|
||||
|
||||
it('never sends a provider on a custom field rule', async () => {
|
||||
const wrapper = mountWithCustomFields([makeCustomField({ type: 'text' })], {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'communityRating', operator: 'gte', value: 4, provider: 'amazon' }],
|
||||
})
|
||||
|
||||
await fieldOptions(wrapper).setValue('custom:7')
|
||||
await wrapper.get('input').setValue('A3')
|
||||
|
||||
expect(lastUpdate(wrapper)).toEqual({
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'custom:7', operator: 'contains', value: 'A3', valueTo: undefined }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Rule, RuleField, TableViewType } from '@bookorbit/types'
|
||||
import type { Rule, StaticRuleField, TableViewType } from '@bookorbit/types'
|
||||
|
||||
type QuickFilterField = Exclude<RuleField, 'communityRating'>
|
||||
type QuickFilterField = Exclude<StaticRuleField, 'communityRating'>
|
||||
|
||||
const TEXT_FIELD_MAP: Partial<Record<string, QuickFilterField>> = {
|
||||
title: 'title',
|
||||
|
||||
@@ -50,6 +50,49 @@ describe('custom metadata sort fields', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom metadata filter fields', () => {
|
||||
it('shows the user-authored field label verbatim', () => {
|
||||
mockCustomFieldLabel('Shelf Location')
|
||||
|
||||
expect(fieldLabel('custom:7')).toBe('Shelf Location')
|
||||
expect(activeCustomFieldLabel).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('falls back to a translated placeholder when the field no longer resolves', () => {
|
||||
mockCustomFieldLabel(null)
|
||||
|
||||
expect(fieldLabel('custom:7')).toBe('Custom field')
|
||||
})
|
||||
|
||||
it('does not treat a malformed custom reference as a custom field', () => {
|
||||
mockCustomFieldLabel('Shelf Location')
|
||||
vi.mocked(activeCustomFieldLabel).mockClear()
|
||||
|
||||
expect(fieldLabel('custom:0' as never)).toBe('book.filter.fields.custom:0')
|
||||
expect(activeCustomFieldLabel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a custom field rule summary with its label and value', () => {
|
||||
mockCustomFieldLabel('Shelf Location')
|
||||
|
||||
expect(ruleToParts({ type: 'rule', field: 'custom:7', operator: 'contains', value: 'A3' } as Rule)).toEqual({
|
||||
field: 'Shelf Location',
|
||||
operator: 'contains',
|
||||
value: 'A3',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders boolean custom field rules without a value', () => {
|
||||
mockCustomFieldLabel('Signed')
|
||||
|
||||
expect(ruleToParts({ type: 'rule', field: 'custom:7', operator: 'isTrue' } as Rule)).toEqual({
|
||||
field: 'Signed',
|
||||
operator: 'is yes',
|
||||
value: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('active locale', () => {
|
||||
afterEach(() => {
|
||||
activateI18nLocale('en')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Rule, RuleField, RuleOperator, SortField } from '@bookorbit/types'
|
||||
import { parseCustomSortFieldId } from '@bookorbit/types'
|
||||
import { parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
|
||||
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
||||
@@ -17,7 +17,10 @@ export function sortFieldLabel(field: SortField): string {
|
||||
}
|
||||
|
||||
export function fieldLabel(field: RuleField): string {
|
||||
return t(`book.filter.fields.${field}`)
|
||||
const customFieldId = parseCustomRuleFieldId(field)
|
||||
if (customFieldId === null) return t(`book.filter.fields.${field}`)
|
||||
// Custom field labels are user-authored, so they are shown as-is rather than translated.
|
||||
return activeCustomFieldLabel(customFieldId) ?? t('book.filter.customFieldFallback')
|
||||
}
|
||||
|
||||
export function operatorLabel(operator: RuleOperator): string {
|
||||
@@ -35,6 +38,8 @@ const NO_VALUE_OPS: RuleOperator[] = [
|
||||
'isLocked',
|
||||
'isUnlocked',
|
||||
'isUpNext',
|
||||
'isTrue',
|
||||
'isFalse',
|
||||
]
|
||||
|
||||
function communityRatingProviderLabel(rule: Rule): string {
|
||||
|
||||
@@ -34,13 +34,18 @@ function messageValues(message: string, count: number): Record<string, string |
|
||||
return Object.fromEntries(argumentNames.map((name) => [name, name === 'count' ? count : 2]))
|
||||
}
|
||||
|
||||
// Target catalogs hold only the keys Crowdin has translated, so they are a subset of the English
|
||||
// catalog rather than an exact match. Typing them against `typeof en` would fail a branch that
|
||||
// authors a new English key before its translations exist.
|
||||
type MessageTree = { [key: string]: string | MessageTree }
|
||||
|
||||
// Mirrors the production setup in `@/i18n`, where the English catalog is always
|
||||
// loaded and `fallbackLocale` points at it. Without that, a key absent from a
|
||||
// target catalog resolves to the key name rather than English, so a catalog
|
||||
// carrying only translated keys would fail these tests instead of falling back
|
||||
// the way the running application does.
|
||||
function createCatalogI18n(locale: Locale, catalog: typeof en) {
|
||||
const messages: Record<string, typeof en> = {
|
||||
function createCatalogI18n(locale: Locale, catalog: MessageTree) {
|
||||
const messages: Record<string, MessageTree> = {
|
||||
en: compileIcuCatalog(en, 'en'),
|
||||
[locale]: compileIcuCatalog(catalog, locale),
|
||||
}
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "je dokončeno",
|
||||
"isLocked": "je uzamčen",
|
||||
"isUnlocked": "je odemčeno",
|
||||
"isUpNext": "další"
|
||||
"isUpNext": "další",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Jakýkoliv poskytovatel",
|
||||
"communityRatingProvider": "Poskytovatel hodnocení Společenství",
|
||||
"loadingLibraries": "Načítám knihovny...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "er færdig",
|
||||
"isLocked": "er låst",
|
||||
"isUnlocked": "er låst op",
|
||||
"isUpNext": "er oppe næste"
|
||||
"isUpNext": "er oppe næste",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Enhver udbyder",
|
||||
"communityRatingProvider": "Community rating provider",
|
||||
"loadingLibraries": "Indlæser biblioteker...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "is finished",
|
||||
"isLocked": "is locked",
|
||||
"isUnlocked": "is unlocked",
|
||||
"isUpNext": "is up next"
|
||||
"isUpNext": "is up next",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Beliebiger Anbieter",
|
||||
"communityRatingProvider": "Anbieter der Community-Bewertung",
|
||||
"loadingLibraries": "Bibliotheken werden geladen ...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "is finished",
|
||||
"isLocked": "is locked",
|
||||
"isUnlocked": "is unlocked",
|
||||
"isUpNext": "is up next"
|
||||
"isUpNext": "is up next",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Any provider",
|
||||
"communityRatingProvider": "Community rating provider",
|
||||
"loadingLibraries": "Loading libraries...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "está terminado",
|
||||
"isLocked": "está bloqueado",
|
||||
"isUnlocked": "está desbloqueado",
|
||||
"isUpNext": "está arriba siguiente"
|
||||
"isUpNext": "está arriba siguiente",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Cualquier proveedor",
|
||||
"communityRatingProvider": "Proveedor de calificación comunitaria",
|
||||
"loadingLibraries": "Cargando bibliotecas...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "on valmis",
|
||||
"isLocked": "on lukittu",
|
||||
"isUnlocked": "on avattu",
|
||||
"isUpNext": "on ylös seuraava"
|
||||
"isUpNext": "on ylös seuraava",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Mikä tahansa tarjoaja",
|
||||
"communityRatingProvider": "Yhteisön luokituspalvelujen tarjoaja",
|
||||
"loadingLibraries": "Ladataan kirjastoja...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "est terminé",
|
||||
"isLocked": "est verrouillé",
|
||||
"isUnlocked": "n'est pas verrouillé",
|
||||
"isUpNext": "est à la suite"
|
||||
"isUpNext": "est à la suite",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "N'importe quel fournisseur",
|
||||
"communityRatingProvider": "Fournisseur de notation communautaire",
|
||||
"loadingLibraries": "Chargement des bibliothèques...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "is finished",
|
||||
"isLocked": "is locked",
|
||||
"isUnlocked": "is unlocked",
|
||||
"isUpNext": "is up next"
|
||||
"isUpNext": "is up next",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Qualsiasi fornitore",
|
||||
"communityRatingProvider": "Fornitore di valutazioni della comunità",
|
||||
"loadingLibraries": "Caricamento librerie...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "is finished",
|
||||
"isLocked": "is locked",
|
||||
"isUnlocked": "is unlocked",
|
||||
"isUpNext": "is up next"
|
||||
"isUpNext": "is up next",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Elke aanbieder",
|
||||
"communityRatingProvider": "Aanbieder van communitybeoordeling",
|
||||
"loadingLibraries": "Bibliotheken laden...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "is finished",
|
||||
"isLocked": "is locked",
|
||||
"isUnlocked": "is unlocked",
|
||||
"isUpNext": "is up next"
|
||||
"isUpNext": "is up next",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Dowolny dostawca",
|
||||
"communityRatingProvider": "Dostawca ocen społecznościowych",
|
||||
"loadingLibraries": "Ładowanie bibliotek...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "está finalizado",
|
||||
"isLocked": "está bloqueado",
|
||||
"isUnlocked": "está desbloqueado",
|
||||
"isUpNext": "é a próxima"
|
||||
"isUpNext": "é a próxima",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Qualquer provedor",
|
||||
"communityRatingProvider": "Provedor de avaliação da comunidade",
|
||||
"loadingLibraries": "Carregando bibliotecas...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "завершён",
|
||||
"isLocked": "заблокирован",
|
||||
"isUnlocked": "разблокирован",
|
||||
"isUpNext": "дальше"
|
||||
"isUpNext": "дальше",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Любой провайдер",
|
||||
"communityRatingProvider": "Поставщик рейтинга сообщества",
|
||||
"loadingLibraries": "Загрузка библиотек...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "je zaključeno",
|
||||
"isLocked": "je zaklenjeno",
|
||||
"isUnlocked": "je odklenjeno",
|
||||
"isUpNext": "je naslednje"
|
||||
"isUpNext": "je naslednje",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Kateri koli ponudnik",
|
||||
"communityRatingProvider": "Ponudnik ocen skupnosti",
|
||||
"loadingLibraries": "Nalaganje knjižnic ...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "är klar",
|
||||
"isLocked": "är låst",
|
||||
"isUnlocked": "är upplåst",
|
||||
"isUpNext": "är uppe nästa"
|
||||
"isUpNext": "är uppe nästa",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Valfri leverantör",
|
||||
"communityRatingProvider": "Gemenskapens betygsleverantör",
|
||||
"loadingLibraries": "Laddar bibliotek...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "завершено",
|
||||
"isLocked": "заблокований",
|
||||
"isUnlocked": "розблоковано",
|
||||
"isUpNext": "є наступним"
|
||||
"isUpNext": "є наступним",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "Будь-який постачальник",
|
||||
"communityRatingProvider": "Постачальник рейтингу спільноти",
|
||||
"loadingLibraries": "Завантаження бібліотеки...",
|
||||
|
||||
@@ -3332,8 +3332,12 @@
|
||||
"isFinished": "已完成",
|
||||
"isLocked": "已锁定",
|
||||
"isUnlocked": "已解锁",
|
||||
"isUpNext": "是下一个"
|
||||
"isUpNext": "是下一个",
|
||||
"isTrue": "is yes",
|
||||
"isFalse": "is no"
|
||||
},
|
||||
"customFieldsGroup": "Custom fields",
|
||||
"customFieldFallback": "Custom field",
|
||||
"anyProvider": "任何提供商",
|
||||
"communityRatingProvider": "社区评分提供商",
|
||||
"loadingLibraries": "Loading libraries...",
|
||||
|
||||
+83
-10
@@ -1,3 +1,4 @@
|
||||
import type { CustomMetadataFieldType } from "./custom-metadata";
|
||||
import type { CommunityRatingProviderKey } from "./metadata-fetch";
|
||||
|
||||
/**
|
||||
@@ -20,8 +21,10 @@ import type { CommunityRatingProviderKey } from "./metadata-fetch";
|
||||
* - `publishedDate` - uses full dates when available and falls back to published year
|
||||
* - `lockStatus` - derived from `book_metadata.locked_fields` (non-empty array = locked)
|
||||
* - `seriesStatus` - computed per-user: "up next in series" (next unstarted book whose earlier series entries are all finished)
|
||||
*
|
||||
* User-defined custom metadata fields are filterable too, as `CustomRuleField`.
|
||||
*/
|
||||
export type RuleField =
|
||||
export type StaticRuleField =
|
||||
| "title"
|
||||
| "publisher"
|
||||
| "language"
|
||||
@@ -51,6 +54,19 @@ export type RuleField =
|
||||
| "lockStatus"
|
||||
| "seriesStatus";
|
||||
|
||||
/**
|
||||
* A user-defined custom metadata field, referenced by its numeric id.
|
||||
*
|
||||
* Mirrors `CustomSortField`. Values live in `book_custom_metadata_values`, which stores one
|
||||
* typed column per value kind; the column a rule targets follows from its operator (and, for
|
||||
* the operators shared between types, the value's runtime type) rather than a database lookup,
|
||||
* because a field's type is fixed at creation. Ids that no longer resolve to an active field
|
||||
* simply match nothing, so a saved filter survives the field being archived or deleted.
|
||||
*/
|
||||
export type CustomRuleField = `custom:${number}`;
|
||||
|
||||
export type RuleField = StaticRuleField | CustomRuleField;
|
||||
|
||||
export type RuleOperator =
|
||||
| "contains"
|
||||
| "notContains"
|
||||
@@ -78,9 +94,11 @@ export type RuleOperator =
|
||||
| "isFinished"
|
||||
| "isLocked"
|
||||
| "isUnlocked"
|
||||
| "isUpNext";
|
||||
| "isUpNext"
|
||||
| "isTrue"
|
||||
| "isFalse";
|
||||
|
||||
export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
|
||||
export const FIELD_OPERATORS: Record<StaticRuleField, RuleOperator[]> = {
|
||||
title: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
|
||||
publisher: ["contains", "notContains", "eq", "notEq", "includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
|
||||
language: ["eq", "notEq", "includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
|
||||
@@ -111,7 +129,31 @@ export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
|
||||
seriesStatus: ["isUpNext"],
|
||||
};
|
||||
|
||||
export const RULE_FIELDS = Object.keys(FIELD_OPERATORS) as RuleField[];
|
||||
export const RULE_FIELDS = Object.keys(FIELD_OPERATORS) as StaticRuleField[];
|
||||
|
||||
/**
|
||||
* Operators offered for a custom metadata field, keyed by the field's type.
|
||||
*
|
||||
* `url` shares the text operators because both kinds store their value in `value_text`.
|
||||
* Every list ends with the two presence operators, which are answered without reference
|
||||
* to the field's type.
|
||||
*/
|
||||
export const CUSTOM_FIELD_TYPE_OPERATORS: Record<CustomMetadataFieldType, RuleOperator[]> = {
|
||||
text: ["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"],
|
||||
date: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"],
|
||||
boolean: ["isTrue", "isFalse", "isEmpty", "isNotEmpty"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Every operator any custom field type accepts. The server validates saved rules against this
|
||||
* union because narrowing to the field's own type would need a database lookup; a rule whose
|
||||
* operator does not suit its field matches nothing instead of being rejected.
|
||||
*/
|
||||
export const CUSTOM_FIELD_OPERATORS: RuleOperator[] = [
|
||||
...new Set(Object.values(CUSTOM_FIELD_TYPE_OPERATORS).flat()),
|
||||
];
|
||||
|
||||
export const RULE_OPERATORS: RuleOperator[] = [
|
||||
"contains",
|
||||
@@ -141,6 +183,8 @@ export const RULE_OPERATORS: RuleOperator[] = [
|
||||
"isLocked",
|
||||
"isUnlocked",
|
||||
"isUpNext",
|
||||
"isTrue",
|
||||
"isFalse",
|
||||
];
|
||||
|
||||
export type CommunityRatingProvider = CommunityRatingProviderKey | "any";
|
||||
@@ -148,7 +192,7 @@ export type RuleValue = string | number | string[] | number[];
|
||||
|
||||
export type StandardRule = {
|
||||
type: "rule";
|
||||
field: Exclude<RuleField, "communityRating">;
|
||||
field: Exclude<StaticRuleField, "communityRating">;
|
||||
operator: RuleOperator;
|
||||
value?: RuleValue;
|
||||
valueTo?: string | number;
|
||||
@@ -163,7 +207,15 @@ export type CommunityRatingRule = {
|
||||
valueTo?: string | number;
|
||||
};
|
||||
|
||||
export type Rule = StandardRule | CommunityRatingRule;
|
||||
export type CustomFieldRule = {
|
||||
type: "rule";
|
||||
field: CustomRuleField;
|
||||
operator: RuleOperator;
|
||||
value?: RuleValue;
|
||||
valueTo?: string | number;
|
||||
};
|
||||
|
||||
export type Rule = StandardRule | CommunityRatingRule | CustomFieldRule;
|
||||
|
||||
export type GroupRule = {
|
||||
type: "group";
|
||||
@@ -259,26 +311,47 @@ export type SortSpec = {
|
||||
};
|
||||
|
||||
// Bounded to 9 digits so a parsed id always fits the int4 field id column.
|
||||
const CUSTOM_SORT_FIELD_PATTERN = /^custom:([1-9]\d{0,8})$/;
|
||||
const CUSTOM_FIELD_PATTERN = /^custom:([1-9]\d{0,8})$/;
|
||||
|
||||
function parseCustomFieldId(field: string): number | null {
|
||||
const match = CUSTOM_FIELD_PATTERN.exec(field);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
export function customSortField(fieldId: number): CustomSortField {
|
||||
return `custom:${fieldId}`;
|
||||
}
|
||||
|
||||
export function isCustomSortField(field: string): field is CustomSortField {
|
||||
return CUSTOM_SORT_FIELD_PATTERN.test(field);
|
||||
return CUSTOM_FIELD_PATTERN.test(field);
|
||||
}
|
||||
|
||||
/** Returns the custom metadata field id a sort field references, or null when it is not a custom sort. */
|
||||
export function parseCustomSortFieldId(field: string): number | null {
|
||||
const match = CUSTOM_SORT_FIELD_PATTERN.exec(field);
|
||||
return match ? Number(match[1]) : null;
|
||||
return parseCustomFieldId(field);
|
||||
}
|
||||
|
||||
export function isSortField(field: string): field is SortField {
|
||||
return (SORT_FIELDS as string[]).includes(field) || isCustomSortField(field);
|
||||
}
|
||||
|
||||
export function customRuleField(fieldId: number): CustomRuleField {
|
||||
return `custom:${fieldId}`;
|
||||
}
|
||||
|
||||
export function isCustomRuleField(field: string): field is CustomRuleField {
|
||||
return CUSTOM_FIELD_PATTERN.test(field);
|
||||
}
|
||||
|
||||
/** Returns the custom metadata field id a rule field references, or null when it is not a custom field rule. */
|
||||
export function parseCustomRuleFieldId(field: string): number | null {
|
||||
return parseCustomFieldId(field);
|
||||
}
|
||||
|
||||
export function isRuleField(field: string): field is RuleField {
|
||||
return (RULE_FIELDS as string[]).includes(field) || isCustomRuleField(field);
|
||||
}
|
||||
|
||||
export function customSortFieldIds(sort: SortSpec[]): number[] {
|
||||
const ids = new Set<number>();
|
||||
for (const { field } of sort) {
|
||||
|
||||
@@ -33,9 +33,18 @@ vi.mock('../../common/utils/accent-insensitive-search.utils', () => ({
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { EMPTY_CONTENT_FILTER_RULES, FIELD_OPERATORS, type RuleField, type RuleOperator } from '@bookorbit/types';
|
||||
import {
|
||||
CUSTOM_FIELD_TYPE_OPERATORS,
|
||||
EMPTY_CONTENT_FILTER_RULES,
|
||||
FIELD_OPERATORS,
|
||||
customRuleField,
|
||||
type CustomMetadataFieldType,
|
||||
type RuleField,
|
||||
type RuleOperator,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { bookCustomMetadataValues, books } from '../../db/schema';
|
||||
import { BookQueryBuilder } from './book-query-builder.service';
|
||||
import { BookSortBuilder } from './book-sort-builder.service';
|
||||
|
||||
@@ -80,12 +89,32 @@ function collectSqlText(node: unknown, acc: string[] = []): string[] {
|
||||
const arr = n[key];
|
||||
if (Array.isArray(arr)) arr.forEach((child) => collectSqlText(child, acc));
|
||||
}
|
||||
for (const key of ['value', 'left', 'right'] as const) {
|
||||
for (const key of ['value', 'left', 'right', 'whereClause', 'subquery'] as const) {
|
||||
if (n[key]) collectSqlText(n[key], acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the database columns a mocked clause tree compares against, in visit order.
|
||||
* Column objects are leaves here: descending into one would reach its table and from there
|
||||
* every sibling column, which would make any column look referenced.
|
||||
*/
|
||||
function collectColumnNames(node: unknown, acc: string[] = []): string[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
const n = node as Record<string, unknown>;
|
||||
if (typeof n.columnType === 'string' && typeof n.name === 'string') {
|
||||
acc.push(n.name);
|
||||
return acc;
|
||||
}
|
||||
for (const key of ['values', 'clauses', 'value', 'left', 'right', 'whereClause', 'subquery'] as const) {
|
||||
const child = n[key];
|
||||
if (Array.isArray(child)) child.forEach((item) => collectColumnNames(item, acc));
|
||||
else if (child) collectColumnNames(child, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function expectPostgresSafeDateArithmetic(node: unknown): void {
|
||||
const unsafeFragments = collectSqlText(node).filter((text) => {
|
||||
const normalized = text.toLowerCase().replace(/\s+/g, ' ');
|
||||
@@ -121,6 +150,8 @@ function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unkn
|
||||
case 'isLocked':
|
||||
case 'isUnlocked':
|
||||
case 'isUpNext':
|
||||
case 'isTrue':
|
||||
case 'isFalse':
|
||||
return {};
|
||||
case 'contains':
|
||||
case 'notContains':
|
||||
@@ -153,6 +184,22 @@ function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unkn
|
||||
}
|
||||
}
|
||||
|
||||
/** The value a client sends for a custom field of the given type, which is what picks the value column. */
|
||||
function buildValueForCustomType(operator: RuleOperator, type: CustomMetadataFieldType): { value?: unknown; valueTo?: unknown } {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return operator === 'between' ? { value: 10, valueTo: 20 } : { value: 10 };
|
||||
case 'date':
|
||||
if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-12-31' };
|
||||
return operator === 'withinLast' ? { value: 7 } : { value: '2024-01-01' };
|
||||
case 'boolean':
|
||||
return {};
|
||||
case 'text':
|
||||
case 'url':
|
||||
return { value: 'test' };
|
||||
}
|
||||
}
|
||||
|
||||
describe('BookQueryBuilder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -604,6 +651,26 @@ describe('field coverage smoke tests', () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(Object.entries(CUSTOM_FIELD_TYPE_OPERATORS) as [CustomMetadataFieldType, RuleOperator[]][])(
|
||||
'custom %s field: every operator the type offers resolves without throwing',
|
||||
(type, operators) => {
|
||||
const { builder } = makeBuilder();
|
||||
const field = customRuleField(4);
|
||||
for (const operator of operators) {
|
||||
const { value, valueTo } = buildValueForCustomType(operator, type);
|
||||
const rule: Record<string, unknown> = { type: 'rule', field, operator };
|
||||
if (value !== undefined) rule.value = value;
|
||||
if (valueTo !== undefined) rule.valueTo = valueTo;
|
||||
|
||||
let where: unknown;
|
||||
expect(() => {
|
||||
where = builder.buildWhere(wrapRule(rule) as never, USER_CTX);
|
||||
}, `custom ${type} field operator '${operator}' should not throw`).not.toThrow();
|
||||
expectPostgresSafeDateArithmetic(where);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildQuickSearch', () => {
|
||||
@@ -1135,6 +1202,170 @@ describe('publishedDateRuleToSql', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('customFieldRuleToSql', () => {
|
||||
const CUSTOM_FIELD = customRuleField(7);
|
||||
|
||||
function buildCustomRule(rule: Record<string, unknown>) {
|
||||
const { builder } = makeBuilder();
|
||||
const where = builder.buildWhere(wrapRule({ type: 'rule', field: CUSTOM_FIELD, ...rule }) as never, USER_CTX) as any;
|
||||
return getRuleSql(where);
|
||||
}
|
||||
|
||||
/** The value predicate an EXISTS carries, after the book id and field id scope clauses. */
|
||||
function valuePredicate(existsClause: any) {
|
||||
return existsClause.values[0].whereClause.clauses[2];
|
||||
}
|
||||
|
||||
it('scopes every custom field rule to the book and the field id', () => {
|
||||
const clause = buildCustomRule({ operator: 'contains', value: 'signed' });
|
||||
|
||||
const scope = clause.values[0].whereClause;
|
||||
expect(scope).toMatchObject({ type: 'and' });
|
||||
expect(scope.clauses[0]).toMatchObject({ type: 'eq', left: bookCustomMetadataValues.bookId, right: books.id });
|
||||
expect(scope.clauses[1]).toMatchObject({ type: 'eq', left: bookCustomMetadataValues.fieldId, right: 7 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['contains', 'signed', 'value_text'],
|
||||
['startsWith', 'signed', 'value_text'],
|
||||
['endsWith', 'signed', 'value_text'],
|
||||
['eq', 'signed', 'value_text'],
|
||||
['eq', 12, 'value_number'],
|
||||
['gt', 12, 'value_number'],
|
||||
['gte', 12, 'value_number'],
|
||||
['lt', 12, 'value_number'],
|
||||
['lte', 12, 'value_number'],
|
||||
['before', '2024-01-01', 'value_date'],
|
||||
['after', '2024-01-01', 'value_date'],
|
||||
['withinLast', 30, 'value_date'],
|
||||
] as const)('reads %s with a %s value from %s', (operator, value, column) => {
|
||||
const clause = buildCustomRule({ operator, value });
|
||||
|
||||
expect(collectColumnNames(clause)).toContain(column);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['numeric bounds', 12, 20, 'value_number'],
|
||||
['date bounds', '2024-01-01', '2024-12-31', 'value_date'],
|
||||
] as const)('routes between with %s to %s', (_label, value, valueTo, column) => {
|
||||
const clause = buildCustomRule({ operator: 'between', value, valueTo });
|
||||
|
||||
expect(collectColumnNames(clause)).toContain(column);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['isTrue', true],
|
||||
['isFalse', false],
|
||||
] as const)('matches %s against the boolean column', (operator, expected) => {
|
||||
const clause = buildCustomRule({ operator });
|
||||
|
||||
expect(valuePredicate(clause)).toMatchObject({
|
||||
type: 'eq',
|
||||
left: bookCustomMetadataValues.valueBoolean,
|
||||
right: expected,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers isNotEmpty from all four value columns so it works whatever the field type is', () => {
|
||||
const clause = buildCustomRule({ operator: 'isNotEmpty' });
|
||||
|
||||
const presence = valuePredicate(clause);
|
||||
expect(presence).toMatchObject({ type: 'or' });
|
||||
expect(collectColumnNames(presence)).toEqual(['value_text', 'value_number', 'value_date', 'value_boolean']);
|
||||
});
|
||||
|
||||
it('negates the whole EXISTS for isEmpty so books with no value for the field match', () => {
|
||||
const clause = buildCustomRule({ operator: 'isEmpty' });
|
||||
|
||||
expect(clause).toMatchObject({ type: 'not' });
|
||||
expect(collectColumnNames(valuePredicate(clause.value))).toEqual(['value_text', 'value_number', 'value_date', 'value_boolean']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['notContains', 'signed'],
|
||||
['notEq', 'signed'],
|
||||
['notEq', 12],
|
||||
] as const)('negates the whole EXISTS for %s so books with no value for the field match', (operator, value) => {
|
||||
const clause = buildCustomRule({ operator, value });
|
||||
|
||||
expect(clause).toMatchObject({ type: 'not' });
|
||||
expect(clause.value).toMatchObject({ type: 'sql' });
|
||||
});
|
||||
|
||||
it('compares notContains against the positive pattern inside the negated EXISTS', () => {
|
||||
const clause = buildCustomRule({ operator: 'notContains', value: 'signed' });
|
||||
|
||||
expect(valuePredicate(clause.value)).toMatchObject({
|
||||
type: 'accentInsensitiveIlike',
|
||||
left: bookCustomMetadataValues.valueText,
|
||||
pattern: '%signed%',
|
||||
});
|
||||
});
|
||||
|
||||
it('escapes LIKE metacharacters in custom field text values', () => {
|
||||
const clause = buildCustomRule({ operator: 'contains', value: '100%_x' });
|
||||
|
||||
expect(valuePredicate(clause)).toMatchObject({ pattern: '%100\\%\\_x%' });
|
||||
});
|
||||
|
||||
it('keeps date arithmetic Postgres-safe for withinLast', () => {
|
||||
const clause = buildCustomRule({ operator: 'withinLast', value: 30 });
|
||||
|
||||
expectPostgresSafeDateArithmetic(clause);
|
||||
expect(collectSqlText(clause).join(' ')).toContain('timezone(');
|
||||
});
|
||||
|
||||
it('rejects list values that no custom field operator accepts', () => {
|
||||
expect(() => buildCustomRule({ operator: 'includesAny', value: ['a'] })).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a numeric operator without a numeric value', () => {
|
||||
expect(() => buildCustomRule({ operator: 'gt', value: 'not a number' })).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a date operator without a parseable date', () => {
|
||||
expect(() => buildCustomRule({ operator: 'before', value: 'not a date' })).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('builds independent predicates for two different custom fields in one group', () => {
|
||||
const { builder } = makeBuilder();
|
||||
|
||||
const where = builder.buildWhere(
|
||||
{
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [
|
||||
{ type: 'rule', field: customRuleField(7), operator: 'eq', value: 'signed' },
|
||||
{ type: 'rule', field: customRuleField(9), operator: 'gt', value: 3 },
|
||||
],
|
||||
} as never,
|
||||
USER_CTX,
|
||||
) as any;
|
||||
|
||||
const [first, second] = where.clauses[1].clauses;
|
||||
expect(first.values[0].whereClause.clauses[1]).toMatchObject({ right: 7 });
|
||||
expect(second.values[0].whereClause.clauses[1]).toMatchObject({ right: 9 });
|
||||
expect(collectColumnNames(valuePredicate(first))).toEqual(['value_text']);
|
||||
expect(collectColumnNames(valuePredicate(second))).toEqual(['value_number']);
|
||||
});
|
||||
|
||||
it('does not require an authenticated user', () => {
|
||||
const { builder } = makeBuilder();
|
||||
|
||||
expect(() =>
|
||||
builder.buildWhere(wrapRule({ type: 'rule', field: CUSTOM_FIELD, operator: 'contains', value: 'signed' }) as never, BASE_CTX),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('treats a field reference with trailing SQL as an unknown field rather than a custom one', () => {
|
||||
const { builder } = makeBuilder();
|
||||
|
||||
expect(() =>
|
||||
builder.buildWhere(wrapRule({ type: 'rule', field: 'custom:7; DROP TABLE books', operator: 'contains', value: 'x' }) as never, USER_CTX),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read status date filters (startedAt / finishedAt)', () => {
|
||||
it('requires an authenticated user', () => {
|
||||
const { builder } = makeBuilder();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AnyColumn, SQL, and, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { parseCustomSortFieldId } from '@bookorbit/types';
|
||||
import { parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types';
|
||||
import type {
|
||||
CommunityRatingProvider,
|
||||
ContentFilterRules,
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
GroupRule,
|
||||
ReadStatus,
|
||||
Rule,
|
||||
RuleValue,
|
||||
SortSpec,
|
||||
} from '@bookorbit/types';
|
||||
import { DB } from '../../db';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
audiobookProgress,
|
||||
authors,
|
||||
bookAuthors,
|
||||
bookCustomMetadataValues,
|
||||
bookFiles,
|
||||
bookGenres,
|
||||
bookCommunityRatings,
|
||||
@@ -141,6 +143,10 @@ export class BookQueryBuilder {
|
||||
|
||||
private ruleToSql(rule: Rule, userId?: number, timeZone = 'UTC'): SQL {
|
||||
const { field, operator, value, valueTo } = rule;
|
||||
const customFieldId = parseCustomRuleFieldId(field);
|
||||
if (customFieldId !== null) {
|
||||
return this.customFieldRuleToSql(customFieldId, operator, value, valueTo, timeZone);
|
||||
}
|
||||
switch (field) {
|
||||
case 'title':
|
||||
return this.textRuleToSql(bookMetadata.title, operator, value as string);
|
||||
@@ -280,6 +286,92 @@ export class BookQueryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-defined custom metadata fields, addressed as `custom:<id>`.
|
||||
*
|
||||
* Negated operators negate the whole EXISTS so that books carrying no value for the field
|
||||
* still match, the same way `notContains` treats a null column on a built-in field.
|
||||
*/
|
||||
private customFieldRuleToSql(
|
||||
fieldId: number,
|
||||
operator: string,
|
||||
value: RuleValue | undefined,
|
||||
valueTo: string | number | undefined,
|
||||
timeZone: string,
|
||||
): SQL {
|
||||
const existsValue = (whereClause: SQL) => {
|
||||
const sq = this.db
|
||||
.select({ one: sql`1` })
|
||||
.from(bookCustomMetadataValues)
|
||||
.where(and(eq(bookCustomMetadataValues.bookId, books.id), eq(bookCustomMetadataValues.fieldId, fieldId), whereClause)!);
|
||||
return sql`exists (${sq})`;
|
||||
};
|
||||
|
||||
switch (operator) {
|
||||
case 'isEmpty':
|
||||
return not(existsValue(this.customValuePresentSql()));
|
||||
case 'isNotEmpty':
|
||||
return existsValue(this.customValuePresentSql());
|
||||
case 'notContains':
|
||||
return not(existsValue(this.customValueMatchSql('contains', value, valueTo, timeZone)));
|
||||
case 'notEq':
|
||||
return not(existsValue(this.customValueMatchSql('eq', value, valueTo, timeZone)));
|
||||
default:
|
||||
return existsValue(this.customValueMatchSql(operator, value, valueTo, timeZone));
|
||||
}
|
||||
}
|
||||
|
||||
/** True for a row holding a value of any kind, so presence is answered without knowing the field's type. */
|
||||
private customValuePresentSql(): SQL {
|
||||
return or(
|
||||
isNotNull(bookCustomMetadataValues.valueText),
|
||||
isNotNull(bookCustomMetadataValues.valueNumber),
|
||||
isNotNull(bookCustomMetadataValues.valueDate),
|
||||
isNotNull(bookCustomMetadataValues.valueBoolean),
|
||||
)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate over a single `book_custom_metadata_values` row.
|
||||
*
|
||||
* `book_custom_metadata_values` keeps one typed column per value kind, and a field's type is
|
||||
* fixed at creation, so the column to compare follows from the operator. The operators more
|
||||
* than one type offers (`eq`, `between`) split on the value's runtime type instead. A rule
|
||||
* whose operator does not suit its field therefore reads a column that is always null and
|
||||
* matches nothing, which is how an archived or deleted field behaves too.
|
||||
*/
|
||||
private customValueMatchSql(operator: string, value: RuleValue | undefined, valueTo: string | number | undefined, timeZone: string): SQL {
|
||||
switch (operator) {
|
||||
case 'contains':
|
||||
case 'startsWith':
|
||||
case 'endsWith':
|
||||
return this.textRuleToSql(bookCustomMetadataValues.valueText, operator, value as string);
|
||||
case 'eq':
|
||||
return typeof value === 'number'
|
||||
? this.numericRuleToSql(bookCustomMetadataValues.valueNumber, operator, value)
|
||||
: this.textRuleToSql(bookCustomMetadataValues.valueText, operator, value as string);
|
||||
case 'gt':
|
||||
case 'gte':
|
||||
case 'lt':
|
||||
case 'lte':
|
||||
return this.numericRuleToSql(bookCustomMetadataValues.valueNumber, operator, value as number, valueTo as number | undefined);
|
||||
case 'between':
|
||||
return typeof value === 'number'
|
||||
? this.numericRuleToSql(bookCustomMetadataValues.valueNumber, operator, value, valueTo as number | undefined)
|
||||
: this.dateExprRuleToSql(sql`${bookCustomMetadataValues.valueDate}`, operator, value as string, valueTo, timeZone, 'custom metadata');
|
||||
case 'before':
|
||||
case 'after':
|
||||
case 'withinLast':
|
||||
return this.dateExprRuleToSql(sql`${bookCustomMetadataValues.valueDate}`, operator, value as string, valueTo, timeZone, 'custom metadata');
|
||||
case 'isTrue':
|
||||
return eq(bookCustomMetadataValues.valueBoolean, true);
|
||||
case 'isFalse':
|
||||
return eq(bookCustomMetadataValues.valueBoolean, false);
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for custom metadata field`);
|
||||
}
|
||||
}
|
||||
|
||||
private seriesRuleToSql(operator: string, value?: string | string[]): SQL {
|
||||
const existsSeries = (whereClause?: SQL) => {
|
||||
const predicates: SQL[] = [eq(bookSeriesMemberships.bookId, books.id)];
|
||||
@@ -606,6 +698,25 @@ export class BookQueryBuilder {
|
||||
|
||||
private publishedDateRuleToSql(operator: string, value: string | number | undefined, valueTo: string | number | undefined, timeZone: string): SQL {
|
||||
const dateExpr = sql`coalesce(${bookMetadata.publishedDate}, make_date(${bookMetadata.publishedYear}, 1, 1))`;
|
||||
switch (operator) {
|
||||
case 'isEmpty':
|
||||
return and(isNull(bookMetadata.publishedDate), isNull(bookMetadata.publishedYear))!;
|
||||
case 'isNotEmpty':
|
||||
return or(isNotNull(bookMetadata.publishedDate), isNotNull(bookMetadata.publishedYear))!;
|
||||
default:
|
||||
return this.dateExprRuleToSql(dateExpr, operator, value, valueTo, timeZone, 'publishedDate');
|
||||
}
|
||||
}
|
||||
|
||||
/** Date comparisons shared by every date-valued expression: `publishedDate` and custom date fields. */
|
||||
private dateExprRuleToSql(
|
||||
dateExpr: SQL,
|
||||
operator: string,
|
||||
value: string | number | undefined,
|
||||
valueTo: string | number | undefined,
|
||||
timeZone: string,
|
||||
fieldLabel: string,
|
||||
): SQL {
|
||||
switch (operator) {
|
||||
case 'before':
|
||||
return sql`${dateExpr} < ${this.parseDateKey(value, operator, 'value', timeZone)}::date`;
|
||||
@@ -621,12 +732,8 @@ export class BookQueryBuilder {
|
||||
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
|
||||
return sql`${dateExpr} >= (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
|
||||
}
|
||||
case 'isEmpty':
|
||||
return and(isNull(bookMetadata.publishedDate), isNull(bookMetadata.publishedYear))!;
|
||||
case 'isNotEmpty':
|
||||
return or(isNotNull(bookMetadata.publishedDate), isNotNull(bookMetadata.publishedYear))!;
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for publishedDate field`);
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for ${fieldLabel} field`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { COMMUNITY_RATING_PROVIDER_KEYS, FIELD_OPERATORS, RULE_OPERATORS, type RuleField, type RuleOperator } from '@bookorbit/types';
|
||||
import {
|
||||
COMMUNITY_RATING_PROVIDER_KEYS,
|
||||
CUSTOM_FIELD_TYPE_OPERATORS,
|
||||
FIELD_OPERATORS,
|
||||
RULE_OPERATORS,
|
||||
customRuleField,
|
||||
type CustomMetadataFieldType,
|
||||
type RuleField,
|
||||
type RuleOperator,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { validateGroupRule, groupRuleSchema } from './group-rule.validator';
|
||||
|
||||
@@ -28,6 +37,8 @@ function validRuleValue(operator: RuleOperator, field: RuleField): { value?: unk
|
||||
case 'isLocked':
|
||||
case 'isUnlocked':
|
||||
case 'isUpNext':
|
||||
case 'isTrue':
|
||||
case 'isFalse':
|
||||
return {};
|
||||
case 'before':
|
||||
case 'after':
|
||||
@@ -58,6 +69,21 @@ function validRuleValue(operator: RuleOperator, field: RuleField): { value?: unk
|
||||
}
|
||||
}
|
||||
|
||||
/** The value a client sends for a custom field of the given type. */
|
||||
function validCustomRuleValue(operator: RuleOperator, type: CustomMetadataFieldType): { value?: unknown; valueTo?: unknown } {
|
||||
if (operator === 'isEmpty' || operator === 'isNotEmpty' || type === 'boolean') return {};
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return operator === 'between' ? { value: 1, valueTo: 5 } : { value: 1 };
|
||||
case 'date':
|
||||
if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-06-01' };
|
||||
return operator === 'withinLast' ? { value: 7 } : { value: '2024-01-01' };
|
||||
case 'text':
|
||||
case 'url':
|
||||
return { value: 'test' };
|
||||
}
|
||||
}
|
||||
|
||||
describe('validateGroupRule', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(validateGroupRule(null)).toBeNull();
|
||||
@@ -455,3 +481,95 @@ describe('field × operator exhaustive validation', () => {
|
||||
expect(() => validateGroupRule({ type: 'group', join: 'AND', rules: [rule] })).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom metadata field rules', () => {
|
||||
function validate(rule: Record<string, unknown>) {
|
||||
return validateGroupRule({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: customRuleField(7), ...rule }] });
|
||||
}
|
||||
|
||||
it.each(Object.entries(CUSTOM_FIELD_TYPE_OPERATORS) as [CustomMetadataFieldType, RuleOperator[]][])(
|
||||
'accepts every operator a custom %s field offers',
|
||||
(type, operators) => {
|
||||
for (const operator of operators) {
|
||||
const { value, valueTo } = validCustomRuleValue(operator, type);
|
||||
const rule: Record<string, unknown> = { operator };
|
||||
if (value !== undefined) rule.value = value;
|
||||
if (valueTo !== undefined) rule.valueTo = valueTo;
|
||||
|
||||
expect(() => validate(rule), `custom ${type} field should accept operator '${operator}'`).not.toThrow();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('round-trips a custom field rule unchanged', () => {
|
||||
const rule = {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'custom:12', operator: 'contains', value: 'signed' }],
|
||||
};
|
||||
|
||||
expect(validateGroupRule(rule)).toEqual(rule);
|
||||
});
|
||||
|
||||
it('accepts custom field rules nested inside groups alongside built-in rules', () => {
|
||||
const rule = {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [
|
||||
{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' },
|
||||
{ type: 'group', join: 'OR', rules: [{ type: 'rule', field: 'custom:3', operator: 'isTrue' }] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateGroupRule(rule)).toEqual(rule);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['custom:0', 'a zero id'],
|
||||
['custom:', 'no id'],
|
||||
['custom:abc', 'a non-numeric id'],
|
||||
['custom:1234567890', 'an id wider than the field id column'],
|
||||
['custom:1; DROP TABLE books', 'trailing SQL'],
|
||||
['custom:1.5', 'a fractional id'],
|
||||
['custom: 1', 'a leading space'],
|
||||
])('rejects %s (%s)', (field) => {
|
||||
expect(() => validateGroupRule({ type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'isNotEmpty' }] })).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects operators no custom field type offers', () => {
|
||||
expect(() => validate({ operator: 'includesAny', value: ['a'] })).toThrow(BadRequestException);
|
||||
expect(() => validate({ operator: 'isUpNext' })).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects list values, which would not resolve to a single typed column', () => {
|
||||
expect(() => validate({ operator: 'eq', value: ['a', 'b'] })).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects numeric comparisons whose value is not a number', () => {
|
||||
for (const operator of ['gt', 'gte', 'lt', 'lte']) {
|
||||
expect(() => validate({ operator, value: '12' }), `operator '${operator}' should require a number`).toThrow(BadRequestException);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects date comparisons whose value is not a date', () => {
|
||||
expect(() => validate({ operator: 'before', value: 'yesterday' })).toThrow(BadRequestException);
|
||||
expect(() => validate({ operator: 'after', value: '2024-13-45' })).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts a between range whose bounds suit the same column', () => {
|
||||
expect(() => validate({ operator: 'between', value: 1, valueTo: 5 })).not.toThrow();
|
||||
expect(() => validate({ operator: 'between', value: '2024-01-01', valueTo: '2024-06-01' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a between range whose bounds would read different columns', () => {
|
||||
expect(() => validate({ operator: 'between', value: 1, valueTo: '2024-06-01' })).toThrow(BadRequestException);
|
||||
expect(() => validate({ operator: 'between', value: '2024-01-01', valueTo: 'not a date' })).toThrow(BadRequestException);
|
||||
expect(() => validate({ operator: 'between', value: '2024-01-01' })).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a provider on a custom field rule', () => {
|
||||
expect(() => validate({ operator: 'contains', value: 'signed', provider: 'any' })).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,25 @@ import { z } from 'zod';
|
||||
|
||||
import {
|
||||
COMMUNITY_RATING_PROVIDER_KEYS,
|
||||
CUSTOM_FIELD_OPERATORS,
|
||||
FIELD_OPERATORS,
|
||||
RULE_FIELDS,
|
||||
RULE_OPERATORS,
|
||||
isCustomRuleField,
|
||||
isRuleField,
|
||||
type CommunityRatingProvider,
|
||||
type GroupRule,
|
||||
type Rule,
|
||||
type RuleField,
|
||||
type RuleOperator,
|
||||
type StaticRuleField,
|
||||
} from '@bookorbit/types';
|
||||
import { isDateKey } from '../../../common/utils/timezone.utils';
|
||||
|
||||
const COMMUNITY_RATING_PROVIDER_VALUES = ['any', ...COMMUNITY_RATING_PROVIDER_KEYS] as const;
|
||||
|
||||
const DATE_VALUE_FIELDS = new Set<RuleField>(['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 CUSTOM_NUMERIC_OPERATORS = new Set<RuleOperator>(['gt', 'gte', 'lt', 'lte']);
|
||||
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})$/;
|
||||
|
||||
function isValidRuleDateValue(value: unknown): boolean {
|
||||
@@ -38,23 +42,65 @@ function isValidRuleDateValue(value: unknown): boolean {
|
||||
return hour <= 23 && minute <= 59 && second <= 59 && !Number.isNaN(new Date(normalized).getTime());
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
type UnvalidatedRule = { field: string; operator: string; value?: unknown; valueTo?: unknown };
|
||||
|
||||
/**
|
||||
* A custom field rule carries no field type, so the value's own shape is what tells the query
|
||||
* builder which typed column to read. Reject the shapes that would otherwise silently read a
|
||||
* column the rule did not mean, rather than leaving them to return no rows.
|
||||
*/
|
||||
function checkCustomFieldValue(rule: UnvalidatedRule, ctx: z.RefinementCtx): void {
|
||||
const { operator, value, valueTo } = rule;
|
||||
if (Array.isArray(value)) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Custom metadata field rules do not accept list values', path: ['value'] });
|
||||
return;
|
||||
}
|
||||
if (CUSTOM_NUMERIC_OPERATORS.has(operator as RuleOperator) && !isFiniteNumber(value)) {
|
||||
ctx.addIssue({ code: 'custom', message: `Operator '${operator}' requires a numeric value`, path: ['value'] });
|
||||
return;
|
||||
}
|
||||
if (CUSTOM_DATE_OPERATORS.has(operator as RuleOperator) && !isValidRuleDateValue(value)) {
|
||||
ctx.addIssue({ code: 'custom', message: `Operator '${operator}' requires a valid date value`, path: ['value'] });
|
||||
return;
|
||||
}
|
||||
if (operator === 'between') {
|
||||
// `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);
|
||||
if (!rangeIsValid) {
|
||||
ctx.addIssue({ code: 'custom', message: "Operator 'between' requires two numbers or two dates", path: ['value'] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ruleSchema: z.ZodType<Rule> = z
|
||||
.object({
|
||||
type: z.literal('rule'),
|
||||
field: z.enum(RULE_FIELDS as unknown as [string, ...string[]]),
|
||||
field: z.string().refine(isRuleField, { message: 'Unknown filter field' }),
|
||||
operator: z.enum(RULE_OPERATORS as unknown as [string, ...string[]]),
|
||||
value: z.union([z.string(), z.number(), z.array(z.string().min(1)).min(1).max(20), z.array(z.number()).min(1).max(20)]).optional(),
|
||||
valueTo: z.union([z.string(), z.number()]).optional(),
|
||||
provider: z.enum(COMMUNITY_RATING_PROVIDER_VALUES as unknown as [CommunityRatingProvider, ...CommunityRatingProvider[]]).optional(),
|
||||
})
|
||||
.superRefine((rule, ctx) => {
|
||||
if (!FIELD_OPERATORS[rule.field as keyof typeof FIELD_OPERATORS]?.includes(rule.operator as never)) {
|
||||
const custom = isCustomRuleField(rule.field);
|
||||
// Narrowing a custom rule to its field's own operators would need a database lookup, so it is
|
||||
// checked against every operator a custom field can take; the query builder degrades the rest.
|
||||
const allowedOperators = custom ? CUSTOM_FIELD_OPERATORS : FIELD_OPERATORS[rule.field as StaticRuleField];
|
||||
if (!allowedOperators?.includes(rule.operator as RuleOperator)) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Operator is not valid for this field', path: ['operator'] });
|
||||
}
|
||||
if (rule.provider !== undefined && rule.field !== 'communityRating') {
|
||||
ctx.addIssue({ code: 'custom', message: 'Provider is only valid for community rating rules', path: ['provider'] });
|
||||
}
|
||||
if (DATE_VALUE_FIELDS.has(rule.field as RuleField) && DATE_VALUE_OPERATORS.has(rule.operator as RuleOperator)) {
|
||||
if (custom) {
|
||||
checkCustomFieldValue(rule, ctx);
|
||||
return;
|
||||
}
|
||||
if (DATE_VALUE_FIELDS.has(rule.field as StaticRuleField) && DATE_VALUE_OPERATORS.has(rule.operator as RuleOperator)) {
|
||||
if (!isValidRuleDateValue(rule.value)) {
|
||||
ctx.addIssue({ code: 'custom', message: `Operator '${rule.operator}' requires a valid date value`, path: ['value'] });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user