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:
neon
2026-08-02 18:13:32 +00:00
parent 49e0b12e97
commit 1f6d9527aa
27 changed files with 994 additions and 56 deletions
@@ -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 {
+7 -2
View File
@@ -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),
}
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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 ...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -3332,8 +3332,12 @@
"isFinished": "завершён",
"isLocked": "заблокирован",
"isUnlocked": "разблокирован",
"isUpNext": "дальше"
"isUpNext": "дальше",
"isTrue": "is yes",
"isFalse": "is no"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"anyProvider": "Любой провайдер",
"communityRatingProvider": "Поставщик рейтинга сообщества",
"loadingLibraries": "Загрузка библиотек...",
+5 -1
View File
@@ -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 ...",
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -3332,8 +3332,12 @@
"isFinished": "завершено",
"isLocked": "заблокований",
"isUnlocked": "розблоковано",
"isUpNext": "є наступним"
"isUpNext": "є наступним",
"isTrue": "is yes",
"isFalse": "is no"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"anyProvider": "Будь-який постачальник",
"communityRatingProvider": "Постачальник рейтингу спільноти",
"loadingLibraries": "Завантаження бібліотеки...",
+5 -1
View File
@@ -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
View File
@@ -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'] });
}