diff --git a/.bruno/cards/advanced-query.bru b/.bruno/cards/advanced-query.bru new file mode 100644 index 000000000..be0306014 --- /dev/null +++ b/.bruno/cards/advanced-query.bru @@ -0,0 +1,25 @@ +meta { + name: Advanced Query + type: http + seq: 1 +} + +get { + url: {{BASE_URL}}/v2/en/cards?name=eq:Pikachu&hp=gte:60&hp=lt:70&localId=5&localId=not:tg&id=neq:cel25-5 + body: none + auth: none +} + +params:query { + name: eq:Pikachu + hp: gte:60 + hp: lt:70 + localId: 5 + localId: not:tg + id: neq:cel25-5 +} + +assert { + res.status: eq 200 + res.body: length 14 +} diff --git a/.bruno/environments/Developpement.bru b/.bruno/environments/Developpement.bru index fa6654ac8..c0a0a0e4e 100644 --- a/.bruno/environments/Developpement.bru +++ b/.bruno/environments/Developpement.bru @@ -1,3 +1,3 @@ vars { - BASE_URL: http://localhost:3000 + BASE_URL: http://127.0.0.1:3000 } diff --git a/.bruno/sets/Advanced Query.bru b/.bruno/sets/Advanced Query.bru new file mode 100644 index 000000000..b07788021 --- /dev/null +++ b/.bruno/sets/Advanced Query.bru @@ -0,0 +1,21 @@ +meta { + name: Advanced Query + type: http + seq: 4 +} + +get { + url: {{BASE_URL}}/v2/en/sets?cardCount.official=gt:64&id=swsh + body: none + auth: none +} + +params:query { + cardCount.official:gt: 64 + id: swsh +} + +assert { + res.status: eq 200 + res.body: length 17 +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ce0d19f1..1344c0c35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,7 @@ jobs: - name: Install deps run: | + bun install -g @usebruno/cli bun install --frozen-lockfile cd server bun install --frozen-lockfile @@ -31,3 +32,11 @@ jobs: bun run validate cd server bun run validate + + - name: Validate some requests + run: | + cd server + bun run start & + sleep 10 + cd ../.bruno + bru run --env Developpement diff --git a/server/bun.lockb b/server/bun.lockb index 21dd12259..4f5570558 100755 Binary files a/server/bun.lockb and b/server/bun.lockb differ diff --git a/server/src/V2/Components/Card.ts b/server/src/V2/Components/Card.ts index 442a5ad92..b7d83c6b8 100644 --- a/server/src/V2/Components/Card.ts +++ b/server/src/V2/Components/Card.ts @@ -1,48 +1,47 @@ import { objectLoop } from '@dzeio/object-util' -import { CardResume, Card as SDKCard, SupportedLanguages } from '@tcgdex/sdk' -import { Query } from '../../interfaces' -import { handlePagination, handleSort, handleValidation } from '../../util' -import Set from './Set' +import type { CardResume, Card as SDKCard, SupportedLanguages } from '@tcgdex/sdk' +import { executeQuery, type Query } from '../../libs/QueryEngine/filter' +import TCGSet from './Set' +import de from '../../../generated/de/cards.json' import en from '../../../generated/en/cards.json' -import fr from '../../../generated/fr/cards.json' import es from '../../../generated/es/cards.json' +import fr from '../../../generated/fr/cards.json' +import id from '../../../generated/id/cards.json' import it from '../../../generated/it/cards.json' -import pt from '../../../generated/pt/cards.json' -import ptbr from '../../../generated/pt-br/cards.json' -import ptpt from '../../../generated/pt-pt/cards.json' -import de from '../../../generated/de/cards.json' +import ja from '../../../generated/ja/cards.json' +import ko from '../../../generated/ko/cards.json' import nl from '../../../generated/nl/cards.json' import pl from '../../../generated/pl/cards.json' +import ptbr from '../../../generated/pt-br/cards.json' +import ptpt from '../../../generated/pt-pt/cards.json' +import pt from '../../../generated/pt/cards.json' import ru from '../../../generated/ru/cards.json' -import ja from '../../../generated/ja/cards.json' -import ko from '../../../generated/ko/cards.json' -import zhtw from '../../../generated/zh-tw/cards.json' -import id from '../../../generated/id/cards.json' import th from '../../../generated/th/cards.json' import zhcn from '../../../generated/zh-cn/cards.json' +import zhtw from '../../../generated/zh-tw/cards.json' const cards = { - 'en': en, - 'fr': fr, - 'es': es, - 'it': it, - 'pt': pt, + en: en, + fr: fr, + es: es, + it: it, + pt: pt, 'pt-br': ptbr, 'pt-pt': ptpt, - 'de': de, - 'nl': nl, - 'pl': pl, - 'ru': ru, - 'ja': ja, - 'ko': ko, + de: de, + nl: nl, + pl: pl, + ru: ru, + ja: ja, + ko: ko, 'zh-tw': zhtw, - 'id': id, - 'th': th, + id: id, + th: th, 'zh-cn': zhcn, } as const -type LocalCard = Omit & {set: () => Set} +type LocalCard = Omit & {set: () => TCGSet} interface variants { normal?: boolean; @@ -93,8 +92,8 @@ export default class Card implements LocalCard { }) } - public set(): Set { - return Set.findOne(this.lang, {filters: { id: this.card.set.id }}) as Set + public set(): TCGSet { + return TCGSet.findOne(this.lang, { id: this.card.set.id }) as TCGSet } public static getAll(lang: SupportedLanguages): Array { @@ -102,16 +101,15 @@ export default class Card implements LocalCard { } public static find(lang: SupportedLanguages, query: Query) { - return handlePagination(handleSort(handleValidation(this.getAll(lang), query), query), query) - .map((it) => new Card(lang, it)) + return executeQuery(Card.getAll(lang), query).data.map((it) => new Card(lang, it)) } public static findOne(lang: SupportedLanguages, query: Query) { - const res = handleSort(handleValidation(this.getAll(lang), query), query) + const res = Card.find(lang, query) if (res.length === 0) { return undefined } - return new Card(lang, res[0]) + return res[0] } public resume(): CardResume { diff --git a/server/src/V2/Components/Serie.ts b/server/src/V2/Components/Serie.ts index edead5415..cb8d713f5 100644 --- a/server/src/V2/Components/Serie.ts +++ b/server/src/V2/Components/Serie.ts @@ -1,49 +1,48 @@ import { objectLoop } from '@dzeio/object-util' -import { Serie as SDKSerie, SerieResume, SupportedLanguages } from '@tcgdex/sdk' -import { Query } from '../../interfaces' -import { handlePagination, handleSort, handleValidation } from '../../util' -import Set from './Set' +import type { Serie as SDKSerie, SerieResume, SupportedLanguages } from '@tcgdex/sdk' +import { executeQuery, type Query } from '../../libs/QueryEngine/filter' +import TCGSet from './Set' +import de from '../../../generated/de/series.json' import en from '../../../generated/en/series.json' -import fr from '../../../generated/fr/series.json' import es from '../../../generated/es/series.json' +import fr from '../../../generated/fr/series.json' +import id from '../../../generated/id/series.json' import it from '../../../generated/it/series.json' -import pt from '../../../generated/pt/series.json' -import ptbr from '../../../generated/pt-br/series.json' -import ptpt from '../../../generated/pt-pt/series.json' -import de from '../../../generated/de/series.json' +import ja from '../../../generated/ja/series.json' +import ko from '../../../generated/ko/series.json' import nl from '../../../generated/nl/series.json' import pl from '../../../generated/pl/series.json' +import ptbr from '../../../generated/pt-br/series.json' +import ptpt from '../../../generated/pt-pt/series.json' +import pt from '../../../generated/pt/series.json' import ru from '../../../generated/ru/series.json' -import ja from '../../../generated/ja/series.json' -import ko from '../../../generated/ko/series.json' -import zhtw from '../../../generated/zh-tw/series.json' -import id from '../../../generated/id/series.json' import th from '../../../generated/th/series.json' import zhcn from '../../../generated/zh-cn/series.json' +import zhtw from '../../../generated/zh-tw/series.json' const series = { - 'en': en, - 'fr': fr, - 'es': es, - 'it': it, - 'pt': pt, + en: en, + fr: fr, + es: es, + it: it, + pt: pt, 'pt-br': ptbr, 'pt-pt': ptpt, - 'de': de, - 'nl': nl, - 'pl': pl, - 'ru': ru, - 'ja': ja, - 'ko': ko, + de: de, + nl: nl, + pl: pl, + ru: ru, + ja: ja, + ko: ko, 'zh-tw': zhtw, - 'id': id, - 'th': th, + id: id, + th: th, 'zh-cn': zhcn, } as const -type LocalSerie = Omit & {sets: () => Array} +type LocalSerie = Omit & {sets: () => Array} export default class Serie implements LocalSerie { @@ -63,8 +62,8 @@ export default class Serie implements LocalSerie { }) } - public sets(): Array { - return this.serie.sets.map((s) => Set.findOne(this.lang, {filters: { id: s.id }}) as Set) + public sets(): Array { + return this.serie.sets.map((s) => TCGSet.findOne(this.lang, { id: s.id }) as TCGSet) } public static getAll(lang: SupportedLanguages): Array { @@ -72,16 +71,15 @@ export default class Serie implements LocalSerie { } public static find(lang: SupportedLanguages, query: Query) { - return handlePagination(handleSort(handleValidation(this.getAll(lang), query), query), query) - .map((it) => new Serie(lang, it)) + return executeQuery(Serie.getAll(lang), query).data.map((it) => new Serie(lang, it)) } public static findOne(lang: SupportedLanguages, query: Query) { - const res = handleValidation(this.getAll(lang), query) + const res = Serie.find(lang, query) if (res.length === 0) { return undefined } - return new Serie(lang, res[0]) + return res[0] } public resume(): SerieResume { diff --git a/server/src/V2/Components/Set.ts b/server/src/V2/Components/Set.ts index 5f7de8745..9efb3f9e9 100644 --- a/server/src/V2/Components/Set.ts +++ b/server/src/V2/Components/Set.ts @@ -1,45 +1,44 @@ import { objectLoop } from '@dzeio/object-util' -import { Set as SDKSet, SetResume, SupportedLanguages } from '@tcgdex/sdk' -import { Query } from '../../interfaces' -import { handlePagination, handleSort, handleValidation } from '../../util' +import type { Set as SDKSet, SetResume, SupportedLanguages } from '@tcgdex/sdk' +import { executeQuery, type Query } from '../../libs/QueryEngine/filter' import Card from './Card' import Serie from './Serie' +import de from '../../../generated/de/sets.json' import en from '../../../generated/en/sets.json' -import fr from '../../../generated/fr/sets.json' import es from '../../../generated/es/sets.json' +import fr from '../../../generated/fr/sets.json' +import id from '../../../generated/id/sets.json' import it from '../../../generated/it/sets.json' -import pt from '../../../generated/pt/sets.json' -import ptbr from '../../../generated/pt-br/sets.json' -import ptpt from '../../../generated/pt-pt/sets.json' -import de from '../../../generated/de/sets.json' +import ja from '../../../generated/ja/sets.json' +import ko from '../../../generated/ko/sets.json' import nl from '../../../generated/nl/sets.json' import pl from '../../../generated/pl/sets.json' +import ptbr from '../../../generated/pt-br/sets.json' +import ptpt from '../../../generated/pt-pt/sets.json' +import pt from '../../../generated/pt/sets.json' import ru from '../../../generated/ru/sets.json' -import ja from '../../../generated/ja/sets.json' -import ko from '../../../generated/ko/sets.json' -import zhtw from '../../../generated/zh-tw/sets.json' -import id from '../../../generated/id/sets.json' import th from '../../../generated/th/sets.json' import zhcn from '../../../generated/zh-cn/sets.json' +import zhtw from '../../../generated/zh-tw/sets.json' const sets = { - 'en': en, - 'fr': fr, - 'es': es, - 'it': it, - 'pt': pt, + en: en, + fr: fr, + es: es, + it: it, + pt: pt, 'pt-br': ptbr, 'pt-pt': ptpt, - 'de': de, - 'nl': nl, - 'pl': pl, - 'ru': ru, - 'ja': ja, - 'ko': ko, + de: de, + nl: nl, + pl: pl, + ru: ru, + ja: ja, + ko: ko, 'zh-tw': zhtw, - 'id': id, - 'th': th, + id: id, + th: th, 'zh-cn': zhcn, } as const @@ -77,11 +76,11 @@ export default class Set implements LocalSet { symbol?: string | undefined public serie(): Serie { - return Serie.findOne(this.lang, {filters: { id: this.set.serie.id }}) as Serie + return Serie.findOne(this.lang, { id: this.set.serie.id }) as Serie } public cards(): Array { - return this.set.cards.map((s) => Card.findOne(this.lang, { filters: { id: s.id }}) as Card) + return this.set.cards.map((s) => Card.findOne(this.lang, { id: s.id }) as Card) } public static getAll(lang: SupportedLanguages): Array { @@ -89,16 +88,15 @@ export default class Set implements LocalSet { } public static find(lang: SupportedLanguages, query: Query) { - return handlePagination(handleSort(handleValidation(this.getAll(lang), query), query), query) - .map((it) => new Set(lang, it)) + return executeQuery(Set.getAll(lang), query).data.map((it) => new Set(lang, it)) } public static findOne(lang: SupportedLanguages, query: Query) { - const res = handleValidation(this.getAll(lang), query) + const res = Set.find(lang, query) if (res.length === 0) { return undefined } - return new Set(lang, res[0]) + return res[0] } public resume(): SetResume { diff --git a/server/src/V2/endpoints/jsonEndpoints.ts b/server/src/V2/endpoints/jsonEndpoints.ts index 7adb3998b..a0534e60a 100644 --- a/server/src/V2/endpoints/jsonEndpoints.ts +++ b/server/src/V2/endpoints/jsonEndpoints.ts @@ -1,41 +1,42 @@ -import { objectKeys, objectLoop } from '@dzeio/object-util' -import { Card as SDKCard } from '@tcgdex/sdk' +import { objectKeys } from '@dzeio/object-util' +import type { Card as SDKCard } from '@tcgdex/sdk' import apicache from 'apicache' -import express, { Request } from 'express' -import { Query } from '../../interfaces' +import express, { type Request } from 'express' import { Errors, sendError } from '../../libs/Errors' +import type { Query } from '../../libs/QueryEngine/filter' +import { recordToQuery } from '../../libs/QueryEngine/parsers' import { betterSorter, checkLanguage, unique } from '../../util' import Card from '../Components/Card' import Serie from '../Components/Serie' -import Set from '../Components/Set' +import TCGSet from '../Components/Set' type CustomRequest = Request & { /** * disable caching */ DO_NOT_CACHE?: boolean - advQuery?: Query + advQuery?: Query } const server = express.Router() const endpointToField: Record = { - "categories": 'category', + categories: 'category', 'energy-types': 'energyType', - "hp": 'hp', - 'illustrators': 'illustrator', - "rarities": 'rarity', + hp: 'hp', + illustrators: 'illustrator', + rarities: 'rarity', 'regulation-marks': 'regulationMark', - "retreats": 'retreat', - "stages": "stage", - "suffixes": "suffix", + retreats: 'retreat', + stages: "stage", + suffixes: "suffix", "trainer-types": "trainerType", // fields that need special care 'dex-ids': 'dexId', - "sets": "set", - "types": "types", - "variants": "variants", + sets: "set", + types: "types", + variants: "variants", } server @@ -66,27 +67,7 @@ server return } - const items: Query = { - filters: undefined, - sort: undefined, - pagination: undefined - } - - objectLoop(req.query as Record>, (value: string | Array, key: string) => { - if (!key.includes(':')) { - key = 'filters:' + key - } - const [cat, item] = key.split(':', 2) as ['filters', string] - if (!items[cat]) { - items[cat] = {} - } - const finalValue = Array.isArray(value) ? value.map((it) => isNaN(parseInt(it)) ? it : parseInt(it)) : isNaN(parseInt(value)) ? value : parseInt(value) - // @ts-expect-error normal behavior - items[cat][item] = finalValue - - }) - - req.advQuery = items + req.advQuery = recordToQuery(req.query as Record>) next() }) @@ -102,15 +83,16 @@ server return } + // biome-ignore lint/style/noNonNullAssertion: const query: Query = req.advQuery! - let data: Array = [] + let data: Array = [] switch (what.toLowerCase()) { case 'card': data = Card.find(lang, query) break case 'set': - data = Set.find(lang, query) + data = TCGSet.find(lang, query) break case 'serie': data = Serie.find(lang, query) @@ -132,7 +114,7 @@ server .get('/:lang/:endpoint', (req: CustomRequest, res): void => { let { lang, endpoint } = req.params - const query: Query = req.advQuery! + const query: Query = req.advQuery ?? {} if (endpoint.endsWith('.json')) { endpoint = endpoint.replace('.json', '') @@ -143,7 +125,7 @@ server return } - let result: any + let result: unknown switch (endpoint) { case 'cards': @@ -153,7 +135,7 @@ server break case 'sets': - result = Set + result = TCGSet .find(lang, query) .map((c) => c.resume()) break @@ -169,7 +151,6 @@ server case "rarities": case "regulation-marks": case "retreats": - case "series": case "stages": case "suffixes": case "trainer-types": @@ -224,26 +205,26 @@ server return sendError(Errors.LANGUAGE_INVALID, res, { lang }) } - let result: any | undefined + let result: unknown switch (endpoint) { case 'cards': - result = Card.findOne(lang, { filters: { id }, strict: true })?.full() + result = Card.findOne(lang, { id })?.full() if (!result) { - result = Card.findOne(lang, { filters: { name: id }, strict: true })?.full() + result = Card.findOne(lang, { name: id })?.full() } break case 'sets': - result = Set.findOne(lang, { filters: { id }, strict: true })?.full() + result = TCGSet.findOne(lang, { id })?.full() if (!result) { - result = Set.findOne(lang, {filters: { name: id }, strict: true })?.full() + result = TCGSet.findOne(lang, { name: id })?.full() } break case 'series': - result = Serie.findOne(lang, { filters: { id }, strict: true })?.full() + result = Serie.findOne(lang, { id })?.full() if (!result) { - result = Serie.findOne(lang, { filters: { name: id }, strict: true })?.full() + result = Serie.findOne(lang, { name: id })?.full() } break default: @@ -282,12 +263,14 @@ server return sendError(Errors.LANGUAGE_INVALID, res, { lang }) } - let result: any | undefined + let result: unknown switch (endpoint) { case 'sets': + // allow the dev to use a non prefixed value like `10` instead of `010` for newer sets result = Card - .findOne(lang, { filters: { localId: subid, set: id }, strict: true})?.full() + // @ts-expect-error normal behavior until the filtering is more fiable + .findOne(lang, { localId: { $or: [subid.padStart(3, '0'), subid]}, 'set.id': id })?.full() break } if (!result) { diff --git a/server/src/V2/graphql/index.ts b/server/src/V2/graphql/index.ts index 384a16b78..e54a177c5 100644 --- a/server/src/V2/graphql/index.ts +++ b/server/src/V2/graphql/index.ts @@ -1,6 +1,6 @@ import express from 'express' -import fs from 'fs' -import { buildSchema, GraphQLError } from 'graphql' +import fs from 'node:fs' +import { buildSchema, type GraphQLError } from 'graphql' import { createHandler } from 'graphql-http/lib/use/express' import { type ruruHTML as RuruHTML } from 'ruru/dist/server' /** @ts-expect-error typing is not correctly mapped (real type at ruru/dist/server.d.ts) */ diff --git a/server/src/V2/graphql/resolver.ts b/server/src/V2/graphql/resolver.ts index 2c05f2a91..6c095b9a4 100644 --- a/server/src/V2/graphql/resolver.ts +++ b/server/src/V2/graphql/resolver.ts @@ -1,44 +1,54 @@ -import { SupportedLanguages } from '@tcgdex/sdk' -import { Query } from '../../interfaces' +import type { SupportedLanguages } from '@tcgdex/sdk' +import { type Query, Sort } from '../../libs/QueryEngine/filter' +import { recordToQuery } from '../../libs/QueryEngine/parsers' import { checkLanguage } from '../../util' import Card from '../Components/Card' import Serie from '../Components/Serie' import Set from '../Components/Set' -const middleware = (fn: (lang: SupportedLanguages, query: Query) => any) => ( - data: Query, +// TODO: make a better way to find the language +function getLang(e: any): SupportedLanguages { + // get the locale directive + const langArgument = e?.fieldNodes?.[0]?.directives?.[0]?.arguments?.[0]?.value + + if (!langArgument) { + return 'en' + } + + if (langArgument.kind === 'Variable') { + return e.variableValues[langArgument.name.value] + } + return langArgument.value +} + +const middleware = (fn: (lang: SupportedLanguages, query: Query) => any) => ( + data: Record, _: any, e: any ) => { // get the locale directive - const langArgument = e?.fieldNodes?.[0]?.directives?.[0]?.arguments?.[0]?.value + const lang = getLang(e) - // Deprecated code handling - // @ts-expect-error count is deprectaed in the frontend - if (data.pagination?.count) { - // @ts-expect-error count is deprectaed in the frontend - data.pagination.itemsPerPage = data.pagination.count - } + const query = recordToQuery(data.filters ?? {}) - // if there is no locale directive - if (!langArgument) { - return fn('en', data) + // Deprecated code handling + if (data.pagination) { + query.$page = data.pagination.page ?? 1 + query.$limit = data.pagination.itemsPerPage ?? 100 } - // set default locale directive value - let lang = 'en' - // handle variable for directive value - if (langArgument.kind === 'Variable') { - lang = e.variableValues[langArgument.name.value] - } else { - lang = langArgument.value + if (data.sort) { + query.$sort = { + [data.sort.field]: data.sort.order === 'DESC' ? Sort.DESC : Sort.ASC + } } if (!checkLanguage(lang)) { return undefined } - return fn(lang, data) + + return fn(lang, query) } export default { diff --git a/server/src/libs/QueryEngine/filter.ts b/server/src/libs/QueryEngine/filter.ts new file mode 100644 index 000000000..9ead9e643 --- /dev/null +++ b/server/src/libs/QueryEngine/filter.ts @@ -0,0 +1,463 @@ +import { objectGet, objectKeys, objectLoop, objectSize } from '@dzeio/object-util' +import { isNull } from '../../util' + +interface QueryRootFilters { + /** + * one of the results should be true to be true + */ + $or?: Array> + /** + * every results should be false to be true + */ + $nor?: Array> + /** + * (default) make sure every sub queries return true + */ + $and?: Array> + /** + * at least one result must be false + */ + $nand?: Array> + /** + * invert the result from the following query + */ + $not?: QueryList + + /************** + * PAGINATION * + **************/ + + /** + * define a precise offset of the data you fetched + */ + $offset?: number + /** + * limit the number of elements returned from the dataset + */ + $limit?: number + + /** + * instead of using a precise offset, use a page system + */ + $page?: number + + /********** + * Sorting * + **********/ + + /** + * sort the data the way you want with each keys being priorized + * + * ex: + * {a: Sort.DESC, b: Sort.ASC} + * + * will sort first by a and if equal will sort by b + */ + $sort?: SortInterface +} + +/** + * Logical operators that can be used to filter data + */ +export type QueryLogicalOperator = { + /** + * one of the results should be true to be true + */ + $or: Array> +} | { + /** + * every results should be false to be true + */ + $nor: Array> +} | { + /** + * at least one result must be false + */ + $nand: Array> +} | { + /** + * (default) make sure every sub queries return true + */ + $and: Array> +} | { + /** + * invert the result from the following query + */ + $not: QueryValues +} + +/** + * differents comparisons operators that can be used to filter data + */ +export type QueryComparisonOperator = { + /** + * the remote source value must be absolutelly equal to the proposed value + */ + $eq: Value | null +} | { + /** + * the remote source value must be greater than the proposed value + */ + $gt: number | Date +} | { + /** + * the remote source value must be lesser than the proposed value + */ + $lt: number | Date +} | { + /** + * the remote source value must be greater or equal than the proposed value + */ + $gte: number | Date +} | { + /** + * the remote source value must be lesser or equal than the proposed value + */ + $lte: number | Date +} | { + /** + * the remote source value must be one of the proposed values + */ + $in: Array +} | { + /** + * laxist validation of the remote value + * + * for strings: remote contains value while not following casing like ($lax) `pou` === `Pouet` (remote) + * for numbers: it does a string conversion first + */ + $lax: Value | null +} | { + /** + * (for arrays only) specify a needed length of the array + */ + $len: number | { $gt: number } +} + +export type QueryList = { + [Key in keyof Obj]?: QueryValues +} + +/** + * Differents values the element can take + * if null it will check if it is NULL on the remote + * if array it will check oneOf + * if RegExp it will check if regexp match + */ +export type QueryValues = Value | + null | + Array | + RegExp | + QueryComparisonOperator | + QueryLogicalOperator + +/** + * The query element that allows you to query different elements + */ +export type Query = QueryList & QueryRootFilters + + +// biome-ignore lint/style/useEnumInitializers: +export enum Sort { + /** + * Sort the values from the lowest to the largest + */ + ASC, + /** + * Sort the values form the largest to the lowest + */ + DESC +} + +/** + * sorting interface with priority + */ +export type SortInterface = { + [Key in keyof Obj]?: Sort +} + + + +export declare type AllowedValues = string | number | bigint | boolean | null | undefined + +interface FilterResult { + data: Array + rows: number + pagination?: { + page: number + pageCount: number + totalRows: number + } | undefined +} + +/** + * + * @param data the original data + * @param query the query to filter against + * @param options additionnal execution options + * @returns the filtered/ordered/paginated {@link data} + */ +export function executeQuery>(data: Array, query: Query, options?: { debug?: boolean }): FilterResult { + if (options?.debug) { + console.log('Query', query) + } + // filter + let filtered = data.filter((it) => { + const res = objectLoop(query, (value, key) => { + if (key === '$or') { + for (const sub of value as Array>) { + const final = filterEntry(sub, it) + // eslint-disable-next-line max-depth + if (final) { + return true + } + } + return false + } + if ((key as string).startsWith('$')) { + return true + } + return filterEntry(query, it) + }) + + return res + }) + + if (options?.debug) { + console.log('postFilters', filtered) + } + + // sort + if (query.$sort && objectSize(query.$sort) >= 1) { + // temp until better solution is found + // get the first key + const firstKey = objectKeys(query.$sort)[0] + // biome-ignore lint/style/noNonNullAssertion: item is not null + const first = query.$sort[firstKey]! + + // forst by the first key + filtered = filtered.sort((objA, objB) => { + const a = objA[firstKey] + const b = objB[firstKey] + const ascend = first !== Sort.DESC // it is Ascend by default, so compare against it + if (typeof a === 'number' && typeof b === 'number') { + if (ascend) { + return b - a + } + return a - b + } + if (a instanceof Date && b instanceof Date) { + if (ascend) { + return a.getTime() - b.getTime() + } + return b.getTime() - a.getTime() + } + if (typeof a === 'string' && typeof b === 'string') { + if (ascend) { + return a.localeCompare(b) + } + return b.localeCompare(a) + + } + if (ascend) { + return a > b ? 1 : -1 + } + return a > b ? -1 : 1 + }) + } + if (options?.debug) { + console.log('postSort', filtered) + } + + // length of the query assuming a single page + const unpaginatedLength = filtered.length + let page: number | null = null + let pageCount: number | null = null + // limit + if (!isNull(query.$offset) || !isNull(query.$limit) || !isNull(query.$page)) { + let limit = query.$limit ?? -1 + if (!isNull(query.$page) && isNull(query.$offset) && isNull(query.$limit)) { + console.warn('using $page NEED a $limit too, setting limit to `10`') + limit = 10 + } + // when using $page, they start at 1 and not 0 + const offset = query.$offset ?? (query.$page ? (query.$page - 1) * limit : 0) + filtered = filtered.slice(offset, limit >= 0 ? offset + limit : undefined) + page = Math.trunc(offset / limit) + pageCount = Math.ceil(unpaginatedLength / limit) + } + if (options?.debug) { + console.log('postLimit', filtered) + } + + return { + data: filtered, + rows: filtered.length, + pagination: (!isNull(page) && !isNull(pageCount)) ? { + page: page, + pageCount: pageCount, + totalRows: unpaginatedLength + } : undefined + } +} + +/** + * + * @param query the query of the entry + * @param item the implementation of the item + * @returns if it should be kept or not + */ +export function filterEntry(query: QueryList, item: T): boolean { + // eslint-disable-next-line complexity + const res = objectLoop(query as any, (queryValue, key: keyof typeof query) => { + /** + * TODO: handle $keys + */ + if ((key as string).startsWith('$')) { + return true + } + + let value: unknown = undefined + + // handle deeply nested items + if ((key as string).includes('.')) { + value = objectGet(item, key as string) + } + + // handle if nested item does not exists + if (typeof value === 'undefined') { + value = item[key] + } + + return filterValue(value, queryValue) + }) + + return res +} + +/** + * indicate if a value should be kept by an ENTIRE query + * + * @param value the value to filter + * @param query the full query + * @returns if the query should keep the value or not + */ +function filterValue(value: unknown, query: QueryValues) { + if (typeof query !== 'object' || query === null || query instanceof RegExp || Array.isArray(query)) { + return filterItem(value, query) + } + + // loop through each keys of the query + // eslint-disable-next-line arrow-body-style + return objectLoop(query as any, (querySubValue: unknown, queryKey: string) => { + return filterItem(value, {[queryKey]: querySubValue } as QueryValues) + }) +} + +/** + * + * @param value the value to check + * @param query a SINGLE query to check against + * @returns if the value should be kept or not + */ +// eslint-disable-next-line complexity +function filterItem(value: any, query: QueryValues): boolean { + /** + * check if the value is null + */ + if (query === null) { + return typeof value === 'undefined' || value === null + } + + if (query instanceof RegExp) { + return query.test(typeof value === 'string' ? value : value.toString()) + } + + /** + * ?!? + */ + if (value === null || typeof value === 'undefined') { + return false + } + + /** + * strict value check by default + */ + if (!(typeof query === 'object')) { + return query === value + } + + /** + * Array checking and $in + */ + if (Array.isArray(query) || '$in' in query) { + const arr = Array.isArray(query) ? query : query.$in as Array + return arr.includes(value) + } + + if ('$inc' in query) { + if (typeof value === 'number' && typeof query.$inc === 'number') { + return value === query.$inc + } + return (value.toString() as string).toLowerCase().includes(query.$inc!.toString()!.toLowerCase()) + } + + if ('$eq' in query) { + return query.$eq === value + } + + /** + * numbers specific cases for numbers + */ + if ('$gt' in query) { + value = value instanceof Date ? value.getTime() : value + const comparedValue = query.$gt instanceof Date ? query.$gt.getTime() : query.$gt + return typeof value === 'number' && typeof comparedValue === 'number' && value > comparedValue + } + + if ('$lt' in query) { + value = value instanceof Date ? value.getTime() : value + const comparedValue = query.$lt instanceof Date ? query.$lt.getTime() : query.$lt + return typeof value === 'number' && typeof comparedValue === 'number' && value < comparedValue + } + + if ('$gte' in query) { + value = value instanceof Date ? value.getTime() : value + const comparedValue = query.$gte instanceof Date ? query.$gte.getTime() : query.$gte + return typeof value === 'number' && typeof comparedValue === 'number' && value >= comparedValue + } + + if ('$lte' in query) { + value = value instanceof Date ? value.getTime() : value + const comparedValue = query.$lte instanceof Date ? query.$lte.getTime() : query.$lte + return typeof value === 'number' && typeof comparedValue === 'number' && value <= comparedValue + } + + if ('$len' in query && Array.isArray(value)) { + return value.length === query.$len + } + + /** + * Logical Operators + */ + if ('$or' in query && Array.isArray(query.$or)) { + return !!query.$or.find((it) => filterValue(value, it as QueryValues)) + } + if ('$and' in query && Array.isArray(query.$and)) { + return !query.$and.find((it) => !filterValue(value, it as QueryValues)) + } + + if ('$not' in query) { + return !filterValue(value, query.$not as QueryValues) + } + + if ('$nor' in query && Array.isArray(query.$nor)) { + return !query.$nor.find((it) => filterValue(value, it as QueryValues)) + } + + if ('$nand' in query && Array.isArray(query.$nand)) { + return !!query.$nand.find((it) => !filterValue(value, it as QueryValues)) + } + + return false +} diff --git a/server/src/libs/QueryEngine/parsers.ts b/server/src/libs/QueryEngine/parsers.ts new file mode 100644 index 000000000..bf0046b01 --- /dev/null +++ b/server/src/libs/QueryEngine/parsers.ts @@ -0,0 +1,195 @@ +import { isObject, objectLoop } from '@dzeio/object-util' +import { Sort, type Query, type QueryValues } from './filter' + +/** + * List of allowed prefixes + */ +const prefixes = [ + 'like', + 'not', + 'notlike', + 'eq', + 'neq', + 'gte', + 'gt', + 'lt', + 'lte', + 'null', + 'notnull' +] as const + +type Prefix = typeof prefixes[number] + +/** + * indicate if the string is a prefix or not + * + * @param str the string to check + * @returns {boolean} true if it's a prefix, else false + */ +function isPrefix(str: string): str is Prefix { + return prefixes.includes(str as Prefix) +} + +/** + * Parse a {@link URL.searchParams} object into a {@link Query} + * + * @param searchParams the searchparams object to parse + * @param skip keys that are skipped by the transformer + * + * @returns the searchParams into a Query object + */ +export function parseSearchParams(searchParams: URLSearchParams, skip: Array = []): Query { + const query: Query> = {} + skip.push('sort:field', 'sort:order') + + const sortField = searchParams.get('sort:field') + if (sortField) { + const order = searchParams.get('sort:order') ?? 'ASC' + + query.$sort = { [sortField]: order === 'ASC' ? Sort.ASC : Sort.DESC } + } + for (const [key, value] of searchParams) { + + if (key === 'pagination:page') { + query.$page = Number.parseInt(value) + continue + } + + if (key === 'pagination:itemsPerPage') { + query.$limit = Number.parseInt(value) + continue + } + + if (skip.includes(key)) { + continue + } + + const params = parseParam(key, value) + if (!query[key]) { + query[key] = params + } else { + if (isObject(params)) { + objectLoop(params, (v, k) => { + (query[key] as any)[k] = v + return + }) + } else { + query[key] = params + } + } + + } + + console.log(query) + return query as Query +} + +/** + * parse a simple {@link Record} object into a {@link Query} + * + * @param searchParams the searchparams object to parse + * @param skip keys that are skipped by the transformer + * + * @returns the searchParams into a Query object + */ +export function recordToQuery(input: Record>, skip: Array = []): Query { + const query: Query> = {} + skip.push('sort:field', 'sort:order') + + const sortField = input['sort:field'] as string + if (sortField) { + const order = input['sort:order'] ?? 'ASC' + + query.$sort = { [sortField]: order === 'ASC' ? Sort.ASC : Sort.DESC } + } + + objectLoop(input, (value: string | Array, key) => { + + if (key === 'pagination:page') { + query.$page = Number.parseInt(value as string) + return + } + + if (key === 'pagination:itemsPerPage') { + query.$limit = Number.parseInt(value as string) + return + } + + if (skip.includes(key)) { + return + } + + if (!Array.isArray(value)) { + value = [value] + } + + for (const it of value) { + const params = parseParam(key, it) + if (!query[key]) { + query[key] = params + } else { + if (isObject(params)) { + objectLoop(params, (v, k) => { + (query[key] as any)[k] = v + return + }) + } else { + query[key] = params + } + } + } + + }) + + console.log(query) + return query as Query +} + +/** + * Parse a single element + * + * @param _key currently unused, kept for future compatibility + * @param value the value to parse + * + * @returns the parsed {@link Query} element to be added + */ +function parseParam(_key: string, value: string): QueryValues { + const colonLocation = value.indexOf(':') + let filter: Prefix = 'like' + let compared: string | number = value + if (colonLocation >= 2) { // 2 because the smallest prefix is two characters long + const prefix = value.slice(0, colonLocation) + if (isPrefix(prefix)) { + filter = prefix + compared = value.slice(colonLocation + 1) + } + } + + if (/^\d+\.?\d*$/g.test(compared)) { + compared = Number.parseFloat(compared) + } + + switch (filter) { + case 'not': + case 'notlike': + return { $not: { $inc: compared }} + case 'eq': + return compared + case 'neq': + return { $not: compared } + case 'gte': + return { $gte: compared } + case 'gt': + return { $gt: compared } + case 'lt': + return { $lt: compared } + case 'lte': + return { $lte: compared } + case 'null': + return null + case 'notnull': + return { $not: null } + default: + return { $inc: compared } + } +} diff --git a/server/src/status.ts b/server/src/status.ts index e4e97e235..8554ee57f 100644 --- a/server/src/status.ts +++ b/server/src/status.ts @@ -346,7 +346,7 @@ export default express.Router() ${objectMap(setsData, (serie, serieId) => { // Loop through every series and name them - const name = Serie.findOne('en', { filters: { id: serieId }})?.name ?? Serie.findOne('ja' as any, { filters: { id: serieId }})?.name + const name = Serie.findOne('en', { id: serieId })?.name ?? Serie.findOne('ja' as any, { id: serieId })?.name return ` @@ -364,7 +364,7 @@ export default express.Router() // loop through every sets // find the set in the first available language (Should be English globally) - const setTotal = Set.findOne(data[0] as 'en', { filters: { id: setId }}) + const setTotal = Set.findOne(data[0] as 'en', { id: setId }) let str = '' + `` // let str = '' + `` diff --git a/server/tsconfig.json b/server/tsconfig.json index a3165dfaa..33878bf06 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "./node_modules/@dzeio/config/tsconfig.base.json", "compilerOptions": { - "outDir": "dist" + "outDir": "dist", + "esModuleInterop": true }, "include": ["src"] }

${name} (${serieId})

${setTotal?.name} (${setId})
${setTotal?.cardCount.total ?? 1} cards
${setId})