Skip to content

Commit

Permalink
Feat/search (#5)
Browse files Browse the repository at this point in the history
* handling free text search

* user param prevalent in advert repository situations

* search restrictions

* sorting
  • Loading branch information
jlarsson authored Jul 10, 2023
1 parent c2ba568 commit 511f919
Show file tree
Hide file tree
Showing 15 changed files with 304 additions and 45 deletions.
4 changes: 2 additions & 2 deletions src/adverts/advert-mutations/cancel-advert-reservation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const countReservationsByUser = (user: HaffaUser, reservations: AdvertReservatio

export const createCancelAdvertReservation = ({ adverts, notifications }: Pick<Services, 'adverts'|'notifications'>): AdvertMutations['cancelAdvertReservation'] =>
(user, id) => txBuilder<Advert>()
.load(() => adverts.getAdvert(id))
.load(() => adverts.getAdvert(user, id))
.validate(() => undefined)
.patch((advert, {actions}) => {
actions((patched, original) => notifications.advertReservationWasCancelled(
Expand All @@ -22,7 +22,7 @@ export const createCancelAdvertReservation = ({ adverts, notifications }: Pick<S
}
})
.verify((update) => update)
.saveVersion( (versionId, advert) => adverts.saveAdvertVersion(versionId, advert))
.saveVersion( (versionId, advert) => adverts.saveAdvertVersion(user, versionId, advert))
.run()
.then(mapTxResultToAdvertMutationResult)

4 changes: 2 additions & 2 deletions src/adverts/advert-mutations/remove-advert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { mapTxResultToAdvertMutationResult } from './mappers'

export const createRemoveAdvert = ({ adverts }: Pick<Services, 'adverts'>): AdvertMutations['removeAdvert'] =>
async (user, id) => txBuilder<Advert>()
.load(() => adverts.getAdvert(id))
.load(() => adverts.getAdvert(user, id))
.validate(async (advert, {throwIf}) => throwIf(!getAdvertMeta(advert, user).canRemove, TxErrors.Unauthorized))
.patch(async (data) => data)
.verify(async (update) => update)
.saveVersion(async () => adverts.remove(id))
.saveVersion(async () => adverts.remove(user, id))
.run()
.then(mapTxResultToAdvertMutationResult)

Expand Down
4 changes: 2 additions & 2 deletions src/adverts/advert-mutations/reserve-advert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { verifyAll, verifyReservationLimits, verifyTypeIsReservation } from './v

export const createReserveAdvert = ({ adverts, notifications }: Pick<Services, 'adverts'|'notifications'>): AdvertMutations['reserveAdvert'] =>
(user, id, quantity) => txBuilder<Advert>()
.load(() => adverts.getAdvert(id))
.load(() => adverts.getAdvert(user, id))
.validate(() => {})
.patch((advert, {actions}) => {
if (quantity > 0) {
Expand All @@ -27,6 +27,6 @@ export const createReserveAdvert = ({ adverts, notifications }: Pick<Services, '
verifyTypeIsReservation,
verifyReservationLimits,
))
.saveVersion( (versionId, advert) => adverts.saveAdvertVersion(versionId, advert))
.saveVersion( (versionId, advert) => adverts.saveAdvertVersion(user, versionId, advert))
.run()
.then(mapTxResultToAdvertMutationResult)
4 changes: 2 additions & 2 deletions src/adverts/advert-mutations/update-advert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import { verifyAll, verifyQuantityAtleatOne, verifyReservationsDoesNotExceedQuan

export const createUpdateAdvert = ({ adverts, files }: Pick<Services, 'adverts'|'files'>): AdvertMutations['updateAdvert'] =>
(user, id, input) => txBuilder<Advert>()
.load(() => adverts.getAdvert(id))
.load(() => adverts.getAdvert(user, id))
.validate((advert, {throwIf}) => throwIf(!getAdvertMeta(advert, user).canEdit, TxErrors.Unauthorized))
.patch(async (advert) => ({
...advert,
...await processAdvertInput(input, files),
}))
.verify((_, ctx) => verifyAll(ctx, verifyQuantityAtleatOne, verifyReservationsDoesNotExceedQuantity))
.saveVersion((versionId, advert) => adverts.saveAdvertVersion(versionId, advert))
.saveVersion((versionId, advert) => adverts.saveAdvertVersion(user, versionId, advert))
.run()
.then(mapTxResultToAdvertMutationResult)
4 changes: 2 additions & 2 deletions src/adverts/adverts-gql-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ export const createAdvertsGqlModule = (services: Pick<Services, 'adverts'|'files
Query: {
// https://www.graphql-tools.com/docs/resolvers
adverts: async ({ ctx: { user }, args: { filter } }) => {
const l = await services.adverts.list(filter)
const l = await services.adverts.list(user, filter)
return mapAdvertsToAdvertsWithMeta(user, l)
},
getAdvert: async ({ ctx: { user }, args: { id } }) => {
const advert = await services.adverts.getAdvert(id)
const advert = await services.adverts.getAdvert(user, id)
return mapAdvertToAdvertWithMeta(user, advert)
},
},
Expand Down
34 changes: 29 additions & 5 deletions src/adverts/adverts.gql.schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const advertsGqlSchema = /* GraphQL */`
type Query {
adverts(filter: FilterAdvertsInput): [Advert]
adverts(filter: AdvertFilterInput): [Advert]
getAdvert(id: ID!): Advert
}
Expand Down Expand Up @@ -34,7 +34,13 @@ input StringFilterInput {
contains: String
}
input FilterAdvertsInput {
enum AdvertSortableFieldEnum {
id
title
createdAt
}
input AdvertFieldsFilterInput {
id: StringFilterInput
title: StringFilterInput
description: StringFilterInput
Expand All @@ -43,9 +49,27 @@ input FilterAdvertsInput {
condition: StringFilterInput
usage: StringFilterInput
and: [FilterAdvertsInput]
or: [FilterAdvertsInput]
not: FilterAdvertsInput
and: [AdvertFieldsFilterInput]
or: [AdvertFieldsFilterInput]
not: AdvertFieldsFilterInput
}
input AdvertRestrictionsInput {
canBeReserved: Boolean
reservedByMe: Boolean
createdByMe: Boolean
}
input AdvertSortingInput {
field: AdvertSortableFieldEnum
ascending: Boolean
}
input AdvertFilterInput {
search: String
field: AdvertFieldsFilterInput
restrictions: AdvertRestrictionsInput
sorting: AdvertSortingInput
}
input AdvertLocationInput {
Expand Down
136 changes: 136 additions & 0 deletions src/adverts/filters/advert-filter-predicate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {createAdvertFilterPredicate} from './advert-filter-predicate'
import {createEmptyAdvert} from './../mappers'
import { Advert } from '../types'
import { HaffaUser } from '../../login/types'
describe('createAdvertFilterPredicate', () => {

const createTestUser = (user?: Partial<HaffaUser>): HaffaUser => ({
id: 'test@testerson.com',
roles: [],
...user
})

const createSampleAdverts = (
count: number,
patches?: Record<string, Partial<Advert>>
): Advert[] => [...Array(count)].map(createEmptyAdvert)
.map((advert, index) => ({...advert, id: `advert-${index}`}))
.map(advert => ({
...advert,
...patches?.[advert.id]
}))

it('matches all by default', () => {
const adverts = createSampleAdverts(10);
expect(adverts.filter(createAdvertFilterPredicate(createTestUser())))
.toMatchObject(adverts)
})

it('does free text search in {title, description}', () => {
const p = createAdvertFilterPredicate(createTestUser(), {search: 'unicorn'})

const adverts = createSampleAdverts(100, {
'advert-10': {title: 'I like my unicorn!'},
'advert-20': {description: ' UniCorns are the best'}
});

expect(
adverts.filter(p)
.map(({id}) => id))
.toMatchObject(['advert-10', 'advert-20'])
})

it('treats search text as a list of separate words combined with or to match {title, description}', () => {
const p = createAdvertFilterPredicate(createTestUser(), {search: 'unicorn orange banana'})

const adverts = createSampleAdverts(100, {
'advert-10': {title: 'I like my unicorn!'},
'advert-20': {description: 'Oranges are the best'},
'advert-30': {description: 'my oranges brings unicorns to my yard'},
'advert-40': {description: 'the amazing banana bender'}})

expect(
adverts.filter(p)
.map(({id}) => id))
.toMatchObject(['advert-10', 'advert-20', 'advert-30', 'advert-40'])
})

it('combines search text with filter predicates with logical AND', () => {
const p = createAdvertFilterPredicate(createTestUser(), {search: 'unicorn', fields: {
description: {
'contains': 'orange'
}
}})

const adverts = createSampleAdverts(100, {
'advert-10': {title: 'I like my unicorn!'},
'advert-20': {description: 'Unicorns are the best'},
'advert-30': {title: 'I have an unicorn', description: 'I loves oranges'}})

expect(
adverts.filter(p)
.map(({id}) => id))
.toMatchObject(['advert-30'])
})

it('restricts to creator', () => {
const p = createAdvertFilterPredicate(createTestUser({id: 'a@b.com'}), {search: 'unicorn', restrictions: {createdByMe: true}})

const adverts = createSampleAdverts(100, {
'advert-10': {title: 'I like my unicorn!'},
'advert-20': {description: ' UniCorns are the best'},
'advert-30': {description: ' My very own little unicorn', createdBy: 'a@b.com'}
});

expect(
adverts.filter(p)
.map(({id}) => id))
.toMatchObject(['advert-30'])
})

it('restricts to reserved by', () => {
const p = createAdvertFilterPredicate(createTestUser({id: 'a@b.com'}), {search: 'unicorn', restrictions: {reservedByMe: true}})

const adverts = createSampleAdverts(100, {
'advert-10': {title: 'I like my unicorn!'},
'advert-20': {description: ' UniCorns are the best'},
'advert-30': {description: ' My very own little unicorn', reservations: [{
reservedBy: 'a@b.com',
reservedAt: new Date().toISOString(),
quantity: 1
}]}
});

expect(
adverts.filter(p)
.map(({id}) => id))
.toMatchObject(['advert-30'])
})

it('restricts to can be reserved', () => {
const p = createAdvertFilterPredicate(createTestUser({id: 'a@b.com'}), {search: 'unicorn', restrictions: {canBeReserved: true}})

let adverts = createSampleAdverts(100, {
'advert-10': {title: 'I like my unicorn!'},
'advert-20': {description: ' UniCorns are the best'},
'advert-30': {description: ' My very own little unicorn'}})

// reserve all adverts except advert-30
adverts = adverts.map(advert =>
advert.id === 'advert-30'
? advert
: ({
...advert,
reservations: [{
reservedBy: 'someone@else',
reservedAt: new Date().toISOString(),
quantity: advert.quantity
}]
}))

expect(
adverts.filter(p)
.map(({id}) => id))
.toMatchObject(['advert-30'])
})
})
48 changes: 48 additions & 0 deletions src/adverts/filters/advert-filter-predicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { HaffaUser } from "../../login/types"
import { getAdvertMeta } from "../advert-meta"
import type { Advert, AdvertFilterInput, AdvertRestrictionsFilterInput } from "../types"
import { createFieldFilterPredicate } from "./field-filter-predicate"
import type { Predicate } from "./types"

const regexpEscape = (s: string): string => s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')

const createFreeTextPredicate = (search: string): Predicate<Advert> => {
// extract individual words from search

const matchers = ((search||'').match(/(\w+)/g) || [])
.filter(v => v)
.filter(v => v.length >= 3)
.map(regexpEscape)
.map(re => new RegExp(re, 'ig'))

return matchers.length > 0
? advert => matchers.some(re => re.test(advert.title) || re.test(advert.description))
: () => true
}

const createRestrictionsPredicate = (user: HaffaUser, restrictions: AdvertRestrictionsFilterInput): Predicate<Advert> => {
const makeMatcher = (test: boolean|undefined, p: Predicate<Advert>): Predicate<Advert>|null =>
// eslint-disable-next-line no-nested-ternary
test === true ? p : test === false ? advert => !p(advert) : null

const matchers: (Predicate<Advert>)[] = [
makeMatcher(restrictions?.createdByMe, ({createdBy}) => createdBy === user.id),
makeMatcher(restrictions?.reservedByMe, ({reservations}) => reservations.some(({reservedBy}) => reservedBy === user.id)),
makeMatcher(restrictions?.canBeReserved, advert => getAdvertMeta(advert, user).canReserve)
]
.filter(p => p) as Predicate<Advert>[]

return matchers.length > 0
? advert => matchers.every(m => m(advert))
: () => true
}

export const createAdvertFilterPredicate = (user: HaffaUser, input?: AdvertFilterInput): Predicate<Advert> => {
const matchers = [
createFreeTextPredicate(input?.search || ''),
createFieldFilterPredicate(input?.fields),
createRestrictionsPredicate(user, input?.restrictions || {})
]
// logical AND on all matchers
return (advert) => matchers.every(matcher => matcher(advert))
}
20 changes: 20 additions & 0 deletions src/adverts/filters/advert-filter-sorter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { HaffaUser } from "../../login/types";
import type { Advert, AdvertFilterInput } from "../types";
import type { Func2 } from "./types";

export const createAdvertFilterComparer = (user: HaffaUser, input?: AdvertFilterInput): Func2<Advert, Advert, number> => {
const field = input?.sorting?.field || 'createdAt'
const ascending = input?.sorting?.ascending !== false

// NOTE: Array.toSorted() would be a better approach in the future
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted

// eslint-disable-next-line no-nested-ternary
const asc = (a: any, b: any): number => (a === b) ? 0 : a < b ? -1 : 1
// eslint-disable-next-line no-nested-ternary
const desc = (a: any, b: any): number => (a === b) ? 0 : a < b ? 1 : -1

return ascending
? (a: Advert, b: Advert) => asc(a[field], b[field])
: (a: Advert, b: Advert) => desc(a[field], b[field])
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createFilterPredicate } from './create-filter-predicate'
import { createFieldFilterPredicate } from "./field-filter-predicate"

const range = (n: number): number[] => [...Array(n).keys()]

describe('createFilterPredicate', () => {
describe('createFieldFilterPredicate', () => {
const data = range(100).map(i => ({
id: i,
title: `title ${i}`,
Expand All @@ -11,7 +11,7 @@ describe('createFilterPredicate', () => {
}))

const filteredData = (filter: any): any[] =>
data.filter(createFilterPredicate(filter))
data.filter(createFieldFilterPredicate(filter))

const testData = (filter: any, expected: any) =>
expect(filteredData(filter)).toMatchObject(expected)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const createAndPredicate = <T>(input: any): Predicate<T> | null => {
return null
}
const predicates = input
.map(inner => createFilterPredicate(inner))
.map(inner => createFieldFilterPredicate(inner))
.filter(p => p)
return value => predicates.every(p => p(value))
}
Expand All @@ -33,15 +33,15 @@ const createOrPredicate = <T>(input: any): Predicate<T> | null => {
if (!Array.isArray(input)) {
return null
}
const predicates = input.map(createFilterPredicate).filter(p => p)
const predicates = input.map(createFieldFilterPredicate).filter(p => p)
return value => predicates.some(p => p(value))
}

const createNotPredicate = <T>(input: any): Predicate<T> | null => {
if (!isObject(input)) {
return null
}
const inner = createFilterPredicate(input)
const inner = createFieldFilterPredicate(input)
return value => !inner(value)
}

Expand All @@ -53,7 +53,7 @@ const combinators: Record<string, (input: any) => Predicate<any> | null> = {

const ifSomethingThenStuffItInAnArray = <T>(v: T) => (v ? [v] : null)

export const createFilterPredicate = <T>(input: any): Predicate<T> => {
export const createFieldFilterPredicate = <T>(input: any): Predicate<T> => {
if (!input) {
return () => true
}
Expand Down
Loading

0 comments on commit 511f919

Please sign in to comment.