diff --git a/packages/core/src/generators/arguments.ts b/packages/core/src/generators/arguments.ts deleted file mode 100644 index 939f329a..00000000 --- a/packages/core/src/generators/arguments.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Generates the accepted arguments for an 'All' query on a content type. - * - * @param pluralType plural name of the content type - */ -export const generateArgsForAllItemQuery = (pluralType: string) => ({ - ...skip(), - ...limit(pluralType), - ...order(pluralType, 'ASC'), - ...sortBy(pluralType), - ...filter(pluralType), -}); - -/** - * Generates the accepted arguments for a 'many-item' query on a content type. - * - * @param pluralType plural name of the content type - */ -export const generateArgsForManyItemQuery = (pluralType: string) => ({ - ids: { - type: '[ID]', - }, - ...skip(), - ...limit(pluralType), - ...order(pluralType, 'ASC'), - ...sortBy(pluralType), -}); - -/** - * Generates the accepted arguments for a 'single-item' query on a content type. - * - */ -export const generateArgsForSingleItemQuery = () => ({ - id: { - type: 'ID', - }, -}); - -/** - * Argument for skipping the first `n` items from the query results. - */ -export const skip = () => ({ - skip: { - description: 'Skip the first `n` results', - type: 'Int', - }, -}); - -/** - * Argument for limiting the maximum number of items in the query results. - * - * @param pluralType plural name of the content type - */ -export const limit = (pluralType: string) => ({ - limit: { - description: `The maximum number of ${pluralType} to return`, - type: 'Int', - }, -}); - -/** - * Argument for ordering the direction of sorting items in the query results. - * - * @param pluralType plural name of the content type - * @param defaultValue default order to use if not explicitly specified in the query - */ -export const order = ( - pluralType: string, - defaultValue: 'ASC' | 'DESC' = 'ASC' -) => ({ - order: { - description: `Which order to return ${pluralType} in`, - type: `enum Order { ASC DESC }`, - defaultValue, - }, -}); - -/** - * Argument for the field to sort items by in the query results. - * - * @param pluralType plural name of the content type - */ -export const sortBy = (pluralType: string) => ({ - sortBy: { - description: `The field to sort ${pluralType} by`, - type: 'String', - }, -}); - -/** - * Argument for the deep filter to apply to items in the query results. - * - * @param pluralType plural name of the content type - */ -export const filter = (pluralType: string) => ({ - filter: { - description: `Filter ${pluralType} by a JSON object`, - type: 'JSON', - }, -}); diff --git a/packages/core/src/generators/schema.ts b/packages/core/src/generators/schema.ts index 34aa9a45..0a2bf810 100644 --- a/packages/core/src/generators/schema.ts +++ b/packages/core/src/generators/schema.ts @@ -1,14 +1,14 @@ import { schemaComposer } from 'graphql-compose'; import { composeWithJson } from 'graphql-compose-json'; -import { cloneDeep, merge } from 'lodash-es'; +import { merge } from 'lodash-es'; import plur from 'plur'; import { cacheSchema, checkCacheForSchema } from '../cache/cache'; import { generateArgsForAllItemQuery, generateArgsForManyItemQuery, generateArgsForSingleItemQuery, -} from '../generators/arguments'; -import resolveQueryArgs from '../resolvers/arguments'; +} from '../query-executor/graphql-arguments'; +import { createQueryExecutor } from '../query-executor'; import { ConfigResult, ContentGraphSnapshot, @@ -16,11 +16,6 @@ import { EntryNode, LoadedFlatbreadConfig, } from '../types'; -import { - getNodeIdentifier, - normalizeIdentifier, - normalizeOptionalIdentifier, -} from '../utils/ids'; import { produceRecords, validateRecords } from '../records'; import { generateCollection } from './generateCollection'; @@ -80,6 +75,17 @@ export async function generateSchema( schemaComposer.clear(); const preknownSchemaFragments = fetchPreknownSchemaFragments(config); + const executor = createQueryExecutor({ + collections: contentNodesByCollection, + relations: Object.fromEntries( + config.content.map(({ collection, refs }) => [collection, refs ?? {}]) + ), + fieldNameTransform: config.fieldNameTransform, + preknownSchemaFragments, + overridesByCollection: Object.fromEntries( + config.content.map(({ collection, overrides }) => [collection, overrides]) + ), + }); /** * For each content collection, reduce the nodes therein to one singular node containing the set of all fields in the collection. This reduced node for each collection then is fed into a GraphQL collection composer to recursively generate a GraphQL schema. @@ -113,6 +119,8 @@ export async function generateSchema( // Main builder loop - iterate through each content type and generate query resolvers + relationships for it for (const [type, schema] of Object.entries(schemaArray)) { + const refs = + config.content.find((content) => content.collection === type)?.refs ?? {}; const pluralType = plur(type, 2); const pluralTypeQueryName = 'all' + pluralType; @@ -136,68 +144,27 @@ export async function generateSchema( name: 'findById', type: () => schema, description: `Find one ${type} by its ID`, - args: generateArgsForSingleItemQuery(), - resolve: (rp: ResolverPayload) => { - const idToFind = normalizeOptionalIdentifier( - rp.args.id, - `${type} query argument "id"` - ); - - if (idToFind === undefined) { - return undefined; - } - - return cloneDeep(contentNodesByCollection[type]).find( - (node: ContentNode) => getNodeIdentifier(node, type) === idToFind - ); - }, + args: generateArgsForSingleItemQuery() as never, + resolve: (rp: ResolverPayload) => + executor.findById({ name: type, refs }, rp.args.id), }); schema.addResolver({ name: 'findMany', type: () => [schema], description: `Find many ${pluralType} by their IDs`, - args: generateArgsForManyItemQuery(pluralType), - resolve: (rp: ResolverPayload) => { - if (rp.args.ids !== undefined && !Array.isArray(rp.args.ids)) { - throw new Error( - `${type} query argument "ids" must be an array of identifiers.` - ); - } - const idsArg = rp.args.ids ?? []; - const idsToFind = idsArg.map((id: unknown): string => - normalizeIdentifier(id, `${type} query argument "ids"`) - ); - const matches = - cloneDeep(contentNodesByCollection[type])?.filter( - (node: ContentNode) => - idsToFind?.includes(getNodeIdentifier(node, type)) - ) ?? []; - return resolveQueryArgs(matches, rp.args, config, { - type: { - name: type, - pluralName: pluralType, - pluralQueryName: pluralTypeQueryName, - }, - }); - }, + args: generateArgsForManyItemQuery(pluralType) as never, + resolve: (rp: ResolverPayload) => + executor.findMany({ name: type, refs }, rp.args.ids, rp.args), }); schema.addResolver({ name: 'all', - args: generateArgsForAllItemQuery(pluralType), + args: generateArgsForAllItemQuery(pluralType) as never, type: () => [schema], description: `Return a set of ${pluralType}`, - resolve: (rp: ResolverPayload) => { - const nodes = cloneDeep(contentNodesByCollection[type]); - return resolveQueryArgs(nodes, rp.args, config, { - type: { - name: type, - pluralName: pluralType, - pluralQueryName: pluralTypeQueryName, - }, - }); - }, + resolve: (rp: ResolverPayload) => + executor.all({ name: type, refs }, rp.args), }); schemaComposer.Query.addFields({ diff --git a/packages/core/src/query-executor/graphql-arguments.ts b/packages/core/src/query-executor/graphql-arguments.ts new file mode 100644 index 00000000..b677c6d6 --- /dev/null +++ b/packages/core/src/query-executor/graphql-arguments.ts @@ -0,0 +1,119 @@ +/** + * Generates the accepted arguments for an 'All' query on a content type. + * + * @param pluralType plural name of the content type + */ +export function generateArgsForAllItemQuery( + pluralType: string +): Record { + return { + ...skip(), + ...limit(pluralType), + ...order(pluralType, 'ASC'), + ...sortBy(pluralType), + ...filter(pluralType), + }; +} + +/** + * Generates the accepted arguments for a 'many-item' query on a content type. + * + * @param pluralType plural name of the content type + */ +export function generateArgsForManyItemQuery( + pluralType: string +): Record { + return { + ids: { + type: '[ID]', + }, + ...skip(), + ...limit(pluralType), + ...order(pluralType, 'ASC'), + ...sortBy(pluralType), + }; +} + +/** + * Generates the accepted arguments for a 'single-item' query on a content type. + */ +export function generateArgsForSingleItemQuery(): Record { + return { + id: { + type: 'ID', + }, + }; +} + +/** + * Argument for skipping the first `n` items from the query results. + */ +function skip(): Record { + return { + skip: { + description: 'Skip the first `n` results', + type: 'Int', + }, + }; +} + +/** + * Argument for limiting the maximum number of items in the query results. + * + * @param pluralType plural name of the content type + */ +function limit(pluralType: string): Record { + return { + limit: { + description: `The maximum number of ${pluralType} to return`, + type: 'Int', + }, + }; +} + +/** + * Argument for ordering the direction of sorting items in the query results. + * + * @param pluralType plural name of the content type + * @param defaultValue default order to use if not explicitly specified in the query + */ +function order( + pluralType: string, + defaultValue: 'ASC' | 'DESC' = 'ASC' +): Record { + return { + order: { + description: `Which order to return ${pluralType} in`, + type: `enum Order { ASC DESC }`, + defaultValue, + }, + }; +} + +/** + * Argument for the field to sort items by in the query. + * + * @param pluralType plural name of the content type + */ +function sortBy(pluralType: string): Record { + return { + sortBy: { + description: `The field to sort ${pluralType} by`, + type: 'String', + }, + }; +} + +/** + * Argument for the deep filter to apply to items in the query results. + * + * @param pluralType plural name of the content type + */ +function filter(pluralType: string): Record { + return { + filter: { + description: `Filter ${pluralType} by a JSON object`, + type: 'JSON', + }, + }; +} diff --git a/packages/core/src/query-executor/index.ts b/packages/core/src/query-executor/index.ts new file mode 100644 index 00000000..99f4d3d5 --- /dev/null +++ b/packages/core/src/query-executor/index.ts @@ -0,0 +1,455 @@ +import { cloneDeep, get, set } from 'lodash-es'; +import type { ContentNode, Override } from '../types'; +import createFilterFunction, { + generateFilterSetManifest, + type TargetAndComparator, +} from '../utils/sift'; +import { + getNodeIdentifier, + normalizeIdentifier, + normalizeOptionalIdentifier, +} from '../utils/ids'; + +export type QueryArgs = Readonly<{ + filter?: Record; + limit?: number; + order?: 'ASC' | 'DESC'; + skip?: number; + sortBy?: string; + [key: string]: unknown; +}>; + +export type QueryExecutorCollection = Readonly<{ + name: string; + refs?: Readonly>; +}>; + +export type QueryExecutorRelations = Readonly< + Record>> +>; + +export type QueryExecutorOptions = Readonly<{ + collections: Readonly>; + relations: QueryExecutorRelations; + fieldNameTransform: (field: string) => string; + preknownSchemaFragments: Record; + overridesByCollection: Readonly< + Record + >; +}>; + +export interface QueryExecutor { + all( + collection: QueryExecutorCollection, + args: QueryArgs + ): Promise; + findMany( + collection: QueryExecutorCollection, + ids: unknown, + args: QueryArgs + ): Promise; + findById( + collection: QueryExecutorCollection, + id: unknown + ): Promise; +} + +type DerivedField = { + kind: 'fragment' | 'override'; + value: unknown; + override?: Override; + rawPath?: string[]; +}; + +type FilterView = { + node: ContentNode; + existential: Map; +}; + +export function createQueryExecutor( + options: QueryExecutorOptions +): QueryExecutor { + const idMaps = new Map>(); + const derived = compileDerivedFields(options); + + const recordsFor = ( + collection: QueryExecutorCollection + ): readonly ContentNode[] => options.collections[collection.name] ?? []; + + const lookup = ( + collection: string, + value: unknown + ): ContentNode | undefined => { + const id = normalizeIdentifier(value, `${collection} reference value`); + let map = idMaps.get(collection); + if (!map) { + map = new Map( + (options.collections[collection] ?? []).map((node) => [ + getNodeIdentifier(node, collection), + node, + ]) + ); + idMaps.set(collection, map); + } + return map.get(id); + }; + + const filterNodes = async ( + collection: QueryExecutorCollection, + nodes: readonly ContentNode[], + filter: Record | undefined + ): Promise => { + if (!filter) return [...nodes]; + const manifest = generateFilterSetManifest(filter); + const matcher = createFilterFunction(filter, manifest); + const result: ContentNode[] = []; + for (const canonical of nodes) { + const view = await materializeFilterView( + collection.name, + canonical, + manifest, + options, + derived, + lookup + ); + const existentialPaths = [...view.existential.entries()]; + if (existentialPaths.length === 0) { + if (matcher(view.node)) result.push(canonical); + continue; + } + let candidates: ContentNode[] = [view.node]; + for (const [path, values] of existentialPaths) { + candidates = values.flatMap((value) => + candidates.map((candidate) => { + const copy = cloneDeep(candidate); + set(copy, path.split('.'), value); + return copy; + }) + ); + } + if (candidates.some((candidate) => matcher(candidate))) + result.push(canonical); + } + return result; + }; + + const finish = async ( + collection: QueryExecutorCollection, + selected: readonly ContentNode[], + args: QueryArgs + ): Promise => { + const nodes = await filterNodes(collection, selected, args.filter); + const privateNodes = [...nodes]; + if (args.sortBy) sortBy(args.sortBy, privateNodes); + if (args.order === 'DESC') privateNodes.reverse(); + return cloneDeep( + privateNodes.slice(args.skip ?? 0, args.limit ?? undefined) + ); + }; + + return { + async all(collection, args) { + return finish(collection, recordsFor(collection), args); + }, + async findMany(collection, ids, args) { + if (ids !== undefined && !Array.isArray(ids)) { + throw new Error( + `${collection.name} query argument "ids" must be an array of identifiers.` + ); + } + const idsToFind = (ids ?? []).map((id) => + normalizeIdentifier(id, `${collection.name} query argument "ids"`) + ); + const selected = recordsFor(collection).filter((node) => + idsToFind.includes(getNodeIdentifier(node, collection.name)) + ); + return finish(collection, selected, { ...args, filter: undefined }); + }, + async findById(collection, id) { + const idToFind = normalizeOptionalIdentifier( + id, + `${collection.name} query argument "id"` + ); + if (idToFind === undefined) return undefined; + const node = recordsFor(collection).find( + (candidate) => + getNodeIdentifier(candidate, collection.name) === idToFind + ); + return node === undefined ? undefined : cloneDeep(node); + }, + }; +} + +function sortBy(sortByField: string, nodes: ContentNode[]): void { + nodes.sort((nodeA, nodeB) => { + const fieldA = nodeA[sortByField]; + const fieldB = nodeB[sortByField]; + if (isSortable(fieldA) && isSortable(fieldB) && fieldA < fieldB) return -1; + if (isSortable(fieldA) && isSortable(fieldB) && fieldA > fieldB) return 1; + return 0; + }); +} + +function isSortable(value: unknown): value is string | number | boolean { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +function compileDerivedFields( + options: QueryExecutorOptions +): Map> { + const result = new Map>(); + const flatten = ( + collection: string, + value: unknown, + path: string[], + kind: 'fragment' | 'override', + override?: Override, + rawPath?: string[] + ): void => { + if (kind === 'fragment' && typeof value === 'function') { + const fields = result.get(collection) ?? new Map(); + fields.set(path.join('.'), { kind, value, rawPath }); + result.set(collection, fields); + return; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return; + } + for (const [key, child] of Object.entries(value)) { + flatten( + collection, + child, + [...path, options.fieldNameTransform(key)], + kind, + override, + rawPath + ); + } + }; + for (const [collection, overrides] of Object.entries( + options.overridesByCollection + )) { + for (const override of overrides ?? []) { + const normalizedPath = override.field.replace(/\[\]/g, '[0]'); + const rawPath = ( + normalizedPath.endsWith('[0]') + ? normalizedPath.slice(0, -3) + : normalizedPath + ).split(/(?:\.|\[0\])/); + const path = rawPath.map(options.fieldNameTransform); + const fields = result.get(collection) ?? new Map(); + fields.set(path.join('.'), { + kind: 'override', + value: override, + override, + rawPath, + }); + result.set(collection, fields); + } + } + for (const [rootKey, fragment] of Object.entries( + options.preknownSchemaFragments + )) { + for (const collection of Object.keys(options.collections)) { + flatten( + collection, + fragment, + [options.fieldNameTransform(rootKey)], + 'fragment' + ); + } + } + return result; +} + +async function materializeFilterView( + collection: string, + canonical: ContentNode, + manifest: TargetAndComparator, + options: QueryExecutorOptions, + derived: Map>, + lookup: (collection: string, value: unknown) => ContentNode | undefined +): Promise { + const node = cloneDeep(canonical); + const existential = new Map(); + for (const entry of manifest) { + const values = await materializePath( + collection, + node, + entry.path, + options, + derived, + lookup, + node, + [] + ); + if (values !== undefined) { + set(node, entry.path, values.value); + if (values.existential) + existential.set(entry.path.join('.'), values.values); + } + } + return { node, existential }; +} + +async function materializePath( + collection: string, + parent: Record, + path: string[], + options: QueryExecutorOptions, + derived: Map>, + lookup: (collection: string, value: unknown) => ContentNode | undefined, + root: Record, + rootPath: string[] +): Promise< + { value: unknown; existential: boolean; values: unknown[] } | undefined +> { + if (path.length === 0) return undefined; + if (!isPlainRecord(parent)) return undefined; + const visibleField = path[0]; + const refs = options.relations[collection] ?? {}; + const refEntry = Object.entries(refs).find( + ([rawField]) => options.fieldNameTransform(rawField) === visibleField + ); + if (refEntry) { + const [rawField, targetCollection] = refEntry; + const reference = parent[rawField]; + if (Array.isArray(reference)) { + const values: unknown[] = []; + for (const id of reference) { + const target = lookup(targetCollection, id); + if (!target) continue; + const targetCopy = cloneDeep(target); + const child = path.slice(1); + if (child.length === 0) values.push(targetCopy); + else { + const resolved = await materializePath( + targetCollection, + targetCopy, + child, + options, + derived, + lookup, + targetCopy, + [] + ); + values.push(resolved?.value); + } + } + return { value: values, existential: childHasLeaf(path), values }; + } + const target = + reference == null ? undefined : lookup(targetCollection, reference); + if (!target) return { value: undefined, existential: false, values: [] }; + const targetCopy = cloneDeep(target); + if (path.length === 1) + return { value: targetCopy, existential: false, values: [] }; + const resolved = await materializePath( + targetCollection, + targetCopy, + path.slice(1), + options, + derived, + lookup, + targetCopy, + [] + ); + return resolved ?? { value: undefined, existential: false, values: [] }; + } + const fullPath = [...rootPath, ...path]; + if ( + visibleField === '_collection' && + path.length === 1 && + rootPath.length === 0 + ) { + return { value: collection, existential: false, values: [] }; + } + const derivedField = derived.get(collection)?.get(fullPath.join('.')); + if (derivedField) { + const parentPath = fullPath.slice(0, -1); + const resolverParent = + parentPath.length === 0 ? root : get(root, parentPath); + if (!isPlainRecord(resolverParent)) { + return { value: undefined, existential: false, values: [] }; + } + const config = + derivedField.kind === 'fragment' && + typeof derivedField.value === 'function' + ? (derivedField.value as () => unknown)() + : undefined; + const fieldConfig = config as + | { + resolve?: ( + parent: unknown, + args: Record, + context?: unknown, + info?: unknown + ) => unknown; + args?: Record; + } + | undefined; + const declaredArgs = + derivedField.kind === 'override' + ? derivedField.override?.args ?? {} + : fieldConfig?.args ?? {}; + const args = Object.fromEntries( + Object.entries(declaredArgs).map(([key, value]) => [ + key, + (value as { defaultValue?: unknown }).defaultValue, + ]) + ); + let value: unknown; + if (derivedField.kind === 'override' && derivedField.override) { + const rawPath = [...(derivedField.rawPath ?? [])]; + const terminal = rawPath.pop(); + const source = cloneDeep(resolverParent); + const rawSource = resolverParent; + const rawValue = + terminal === undefined ? rawSource : get(rawSource, terminal); + value = await derivedField.override.resolve(rawValue, { + source, + context: undefined, + args, + }); + } else { + value = await fieldConfig?.resolve?.( + resolverParent, + args, + undefined, + undefined + ); + } + set(parent, path, value); + return { value, existential: false, values: [] }; + } + if (path.length === 1) + return { value: parent[visibleField], existential: false, values: [] }; + const child = parent[visibleField]; + if (child === null || typeof child !== 'object') return undefined; + return materializePath( + collection, + child as Record, + path.slice(1), + options, + derived, + lookup, + root, + [...rootPath, visibleField] + ); +} + +function childHasLeaf(path: string[]): boolean { + return path.length > 1; +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/core/src/query-executor/tests/query-executor.test.ts b/packages/core/src/query-executor/tests/query-executor.test.ts new file mode 100644 index 00000000..a5192c41 --- /dev/null +++ b/packages/core/src/query-executor/tests/query-executor.test.ts @@ -0,0 +1,292 @@ +import test from 'ava'; +import { createQueryExecutor } from '../index'; +import type { ContentNode, Override } from '../../types'; + +const authors: ContentNode[] = [ + { id: 1, name: 'Eva' }, + { id: '2', name: 'Noah' }, +]; +const posts: ContentNode[] = [ + { + id: ' 10 ', + title: 'Beta', + rank: 2, + nested: { label: 'second' }, + friend: 1, + authors: [1, '2'], + _content: { raw: 'Hello world' }, + }, + { + id: 11, + title: 'Alpha', + rank: 1, + nested: { label: 'first' }, + friend: 2, + authors: [2], + _content: { raw: 'Other words' }, + }, +]; + +const executor = ( + collections: Record = { + Post: posts, + Author: authors, + }, + overridesByCollection: Record = {}, + preknownSchemaFragments: Record = {} +) => + createQueryExecutor({ + collections, + relations: { Post: { friend: 'Author', authors: 'Author' } }, + fieldNameTransform: (field) => field, + preknownSchemaFragments, + overridesByCollection, + }); + +test('1. filters plain and nested record fields', async (t) => { + const result = await executor().all( + { name: 'Post' }, + { filter: { nested: { label: { eq: 'first' } } } } + ); + t.deepEqual( + result.map((node) => node.id), + [11] + ); +}); + +test('2. filters root id with normalized eq including numeric IDs', async (t) => { + const result = await executor().all( + { name: 'Post' }, + { filter: { id: { eq: 11 } } } + ); + t.deepEqual( + result.map((node) => node.id), + [11] + ); +}); + +test('3. traverses a scalar reference filter', async (t) => { + const result = await executor().all( + { name: 'Post' }, + { filter: { friend: { name: { eq: 'Eva' } } } } + ); + t.deepEqual( + result.map((node) => node.id), + [' 10 '] + ); +}); + +test('4. traverses list references existentially', async (t) => { + const result = await executor().all( + { name: 'Post' }, + { filter: { authors: { name: { eq: 'Eva' } } } } + ); + t.deepEqual( + result.map((node) => node.id), + [' 10 '] + ); +}); + +test('5. filters preknown derived fields with default args without memoizing canonical data', async (t) => { + let receivedSpeed: number | undefined; + let receivedRaw: unknown; + const fragments = { + _content: { + timeToRead: () => ({ + args: { speed: { defaultValue: 230 } }, + resolve: async ( + parent: Record, + args: { speed: number } + ) => { + receivedSpeed = args.speed; + receivedRaw = parent.raw; + parent.html = 'memoized'; + return String(parent.raw).length; + }, + }), + }, + }; + const snapshot = [{ id: 'post', _content: { raw: 'content' } }]; + const result = await executor({ Post: snapshot }, {}, fragments).all( + { name: 'Post' }, + { filter: { _content: { timeToRead: { eq: 7 } } } } + ); + t.is(receivedSpeed, 230); + t.is(receivedRaw, 'content'); + t.is(result.length, 1); + t.false('html' in (snapshot[0]._content as Record)); +}); + +test('14. skips derived resolvers when the fragment parent is missing', async (t) => { + let invoked = false; + const fragments = { + _content: { + timeToRead: () => ({ + resolve: async () => { + invoked = true; + return 1; + }, + }), + }, + }; + const result = await executor( + { Post: [{ id: 'plain', title: 'YAML' }] }, + {}, + fragments + ).all({ name: 'Post' }, { filter: { _content: { timeToRead: { eq: 1 } } } }); + t.false(invoked); + t.is(result.length, 0); +}); + +test('6. filters config overrides with source/value convention and defaults', async (t) => { + let received: + | { value: unknown; source: unknown; args: Record } + | undefined; + const override: Override = { + field: 'metadata.score', + type: 'Int', + args: { factor: { type: 'Int' as never, defaultValue: 2 } }, + resolve: (value, extended) => { + received = { value, source: extended.source, args: extended.args }; + return Number(value) * Number(extended.args.factor); + }, + }; + const result = await executor( + { Post: [{ id: 'post', metadata: { score: 4 } }] }, + { Post: [override] } + ).all({ name: 'Post' }, { filter: { metadata: { score: { eq: 8 } } } }); + t.is(result.length, 1); + t.is(received?.value, 4); + t.deepEqual(received?.source, { score: 4 }); + t.deepEqual(received?.args, { factor: 2 }); +}); + +test('7. filters the collection meta field', async (t) => { + const result = await executor().all( + { name: 'Post' }, + { filter: { _collection: { eq: 'Post' } } } + ); + t.is(result.length, 2); +}); + +test('8. sorts stably and does not mutate the input snapshot', async (t) => { + const snapshot = [ + { id: 'a', rank: 1 }, + { id: 'b', rank: 1 }, + { id: 'c', rank: 2 }, + ]; + const result = await executor({ Post: snapshot }).all( + { name: 'Post' }, + { sortBy: 'rank' } + ); + t.deepEqual( + result.map((node) => node.id), + ['a', 'b', 'c'] + ); + t.deepEqual( + snapshot.map((node) => node.id), + ['a', 'b', 'c'] + ); +}); + +test('9. DESC reverses collection order without sortBy', async (t) => { + const result = await executor().all({ name: 'Post' }, { order: 'DESC' }); + t.deepEqual( + result.map((node) => node.id), + [11, ' 10 '] + ); +}); + +test('10. uses limit as the slice end index', async (t) => { + const result = await executor().all({ name: 'Post' }, { skip: 1, limit: 2 }); + t.deepEqual( + result.map((node) => node.id), + [11] + ); +}); + +test('11. finds IDs with normalization and preserves missing/error behavior', async (t) => { + const query = executor(); + t.is((await query.findById({ name: 'Post' }, 11))?.id, 11); + t.is(await query.findById({ name: 'Post' }, 'missing'), undefined); + const error = await t.throwsAsync(() => + query.findById({ name: 'Post' }, false) + ); + t.is( + error?.message, + 'Post query argument "id" must be a non-empty string or finite number identifier.' + ); +}); + +test('12. findMany preserves collection order, deduplicates IDs, and validates ids', async (t) => { + const query = executor(); + const result = await query.findMany({ name: 'Post' }, [11, 11, '10'], {}); + t.deepEqual( + result.map((node) => node.id), + [' 10 ', 11] + ); + const error = await t.throwsAsync(() => + query.findMany({ name: 'Post' }, '11', {}) + ); + t.is( + error?.message, + 'Post query argument "ids" must be an array of identifiers.' + ); +}); + +test('13. output mutation cannot change the canonical snapshot or later queries', async (t) => { + const snapshot = [{ id: 'post', nested: { values: [1] } }]; + const query = executor({ Post: snapshot }); + const all = await query.all({ name: 'Post' }, {}); + all[0].nested = { values: [9] }; + const many = await query.findMany({ name: 'Post' }, ['post'], {}); + (many[0].nested as { values: number[] }).values.push(8); + const one = await query.findById({ name: 'Post' }, 'post'); + t.deepEqual(snapshot, [{ id: 'post', nested: { values: [1] } }]); + t.deepEqual(one, { id: 'post', nested: { values: [1] } }); +}); + +test('15. resolves trailing-array overrides against the whole raw array', async (t) => { + let received: unknown; + const override: Override = { + field: 'array[]', + type: 'String', + resolve: (value) => { + received = value; + return (value as string[]).map((item) => item.toUpperCase()); + }, + }; + const rawArray = ['x', 'y']; + const result = await executor( + { Post: [{ id: 'post', array: rawArray }] }, + { Post: [override] } + ).all({ name: 'Post' }, { filter: { array: { includes: 'X' } } }); + t.deepEqual(received, rawArray); + t.is(result.length, 1); +}); + +test('16. keeps nested paths root-relative when they shadow derived fields', async (t) => { + let invoked = false; + const fragments = { + _content: { + html: () => ({ + resolve: () => { + invoked = true; + return 'derived-value'; + }, + }), + }, + }; + const result = await executor( + { Post: [{ id: 'post', x: { _content: { html: 'raw-value' } } }] }, + {}, + fragments + ).all( + { name: 'Post' }, + { + filter: { x: { _content: { html: { eq: 'raw-value' } } } }, + } + ); + t.is(result.length, 1); + t.false(invoked); +}); diff --git a/packages/core/src/resolvers/arguments.ts b/packages/core/src/resolvers/arguments.ts deleted file mode 100644 index 19230cd8..00000000 --- a/packages/core/src/resolvers/arguments.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { keyBy } from 'lodash-es'; -import sift, { - generateFilterSetManifest, - TargetAndComparator, -} from '../utils/sift'; -import { ContentNode, FlatbreadConfig } from '../types'; -import { FlatbreadProvider } from '../providers/base'; -import { getNodeIdentifier, normalizeIdentifier } from '../utils/ids'; -interface ResolveQueryArgsOptions { - type: { - name: string; - pluralName: string; - pluralQueryName: string; - }; -} - -interface QueryArgs { - filter?: Record; - limit?: number; - order?: 'ASC' | 'DESC'; - skip?: number; - sortBy?: string; - [key: string]: unknown; -} - -/** - * Resolvers for query arguments. - */ -const resolveQueryArgs = async ( - nodes: ContentNode[], - args: QueryArgs, - config: FlatbreadConfig, - options: ResolveQueryArgsOptions -): Promise => { - const { skip, limit, order, sortBy, filter } = args; - - if (filter) { - // Place the nodes into a keyed object by ID so we can easily filter by ID without doing tons of looping. - // TODO: store all nodes in an ID-keyed object. - const nodeById = keyBy(nodes, (node: ContentNode) => - getNodeIdentifier(node, options.type.name) - ); - - // Turn the filter into a GraphQL subquery that returns an array of matching content node IDs. - const listOfNodeIDsToFilter = await resolveFilter(filter, config, options); - - nodes = listOfNodeIDsToFilter.map( - (desiredNodeId) => nodeById[desiredNodeId] - ); - } - - if (sortBy) { - resolveSortBy(sortBy, nodes); - } - - if (order === 'DESC') { - nodes.reverse(); - } - - return nodes.slice(skip ?? 0, limit ?? undefined); -}; - -/** - * Builds a GraphQL query fragment from a `filterSetManifest`. - * This is useful for building a GraphQL query which resolves a set of content nodes which include the fields specified in the filter set. - * That result can then be used to filter the nodes by the filter argument in the `sift` function, obtaining a list of matching nodes. - * - * @example - * // Consider this query with a complex filter: - * query AllPosts { - * allPosts(filter: {_content: {timeToRead: {gte: 0}}, title: {wildcard: "Test*"}}) { - * id - * title - * } - * } - * - * // The filter is converted to: - * filterSetManifest = [ - * { - * path: [ '_content', 'timeToRead' ], - * comparator: { operation: 'gte', value: 0 } - * }, - * { - * path: [ 'title' ], - * comparator: { operation: 'wildcard', value: 'Test*' } - * } - * ] - * - * // ...which is passed into this function to then become this fragment: - * _content { - * timeToRead - * } - * title - * - */ -function buildFilterQueryFragment(filterSetManifest: TargetAndComparator) { - let filterToQuery = []; - - for (const filter of filterSetManifest) { - let graphQLFieldAccessor = ''; - - for (let i = 0; i < filter.path.length; i++) { - const field = filter.path[i]; - const lastFieldIndex = filter.path.length - 1; - - // Build a partial GraphQL query's field shape - if (i === lastFieldIndex && filter.path.length === 1) { - // If the filter path is a leaf-field, just add the field name. - graphQLFieldAccessor += `${field}`; - } else if (i !== lastFieldIndex) { - // If the filter is not a leaf-field, we need to add the field name and open it to contain child fields. - graphQLFieldAccessor += `${field} {`; - } else { - // If the current field is the last leaf-field of a nested accessor, we need to add the field name and close it with the same number of opening brackets it took to reach this depth. - graphQLFieldAccessor += `${field} ${[lastFieldIndex] - .map(() => '}') - .join('')}`; - } - } - - filterToQuery.push(graphQLFieldAccessor); - } - - return filterToQuery.join('\n'); -} - -/** - * Deeply resolves a filter argument as a subquery, and returns a set of content node IDs that satisfy the filter. - * - * @param filter the filter argument - */ -export const resolveFilter = async ( - filter: Record, - config: FlatbreadConfig, - options: ResolveQueryArgsOptions -): Promise => { - // Seperate the filter into its parts: - // - the path leading to the field we want to compare - // - the comparator expression. - const filterSetManifest = generateFilterSetManifest(filter); - - // Run Flatbread as a function to execute a subquery - const flatbread = new FlatbreadProvider(config); - - // Build a GraphQL query fragment that will be used to resolve content nodes in a structure expected by the sift function, for the given filter. - const filterQueryFragment = buildFilterQueryFragment(filterSetManifest); - - const queryString = ` - query ${options.type.pluralQueryName}_FilterSubquery { - ${options.type.pluralQueryName} { - id - ${filterQueryFragment} - } - } - `; - - const { data } = await flatbread.query({ - source: queryString, - }); - - const result = data?.[options.type.pluralQueryName] as ContentNode[]; - - return result - .filter(sift(filter)) - .map((node) => - normalizeIdentifier( - node.id, - `${options.type.name} filter subquery result id` - ) - ); -}; - -/** - * Mutably sort a list of nodes by a given field. - * - * @param sortBy the field to sort by - * @param nodes the array of nodes to sort - */ -export const resolveSortBy = (sortBy: string, nodes: ContentNode[]): void => { - nodes.sort((nodeA, nodeB) => { - const fieldA = nodeA[sortBy]; - const fieldB = nodeB[sortBy]; - - if (isSortable(fieldA) && isSortable(fieldB) && fieldA < fieldB) { - return -1; - } - if (isSortable(fieldA) && isSortable(fieldB) && fieldA > fieldB) { - return 1; - } - // fields must be equal - return 0; - }); -}; - -function isSortable(value: unknown): value is string | number | boolean { - return ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ); -} - -export default resolveQueryArgs;