Skip to content

Commit

Permalink
fix(query): use exact match for findOne (#1224)
Browse files Browse the repository at this point in the history
* fix(query): use exact match for `findOne`

* test: add index test

* test: add prefix tests

* test: add more edge cases
  • Loading branch information
farnabaz committed Sep 7, 2022
1 parent 256ab84 commit ae907a8
Show file tree
Hide file tree
Showing 13 changed files with 142 additions and 45 deletions.
24 changes: 17 additions & 7 deletions src/runtime/composables/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,21 @@ import { withContentBase } from './utils'
/**
* Query fetcher
*/
export const queryFetch = <T = ParsedContent>(params: QueryBuilderParams) => {
export const createQueryFetch = <T = ParsedContent>(path?: string) => (query: QueryBuilder<T>) => {
if (path) {
if (query.params().first) {
query.where({ _path: withoutTrailingSlash(path) })
} else {
query.where({ _path: new RegExp(`^${path.replace(/[-[\]{}()*+.,^$\s/]/g, '\\$&')}`) })
}
}
// Provide default sort order
if (!query.params().sort?.length) {
query.sort({ _file: 1, $numeric: true })
}

const params = query.params()

const apiPath = withContentBase(process.dev ? '/query' : `/query/${hash(params)}`)

// Prefetch the query
Expand Down Expand Up @@ -39,12 +53,8 @@ export function queryContent<T = ParsedContent>(query: string, ...pathParts: str
export function queryContent<T = ParsedContent> (query: QueryBuilderParams): QueryBuilder<T>;
export function queryContent<T = ParsedContent> (query?: string | QueryBuilderParams, ...pathParts: string[]) {
if (typeof query === 'string') {
let path = withLeadingSlash(withoutTrailingSlash(joinURL(query, ...pathParts)))
// escape regex special chars
path = path.replace(/[-[\]{}()*+.,^$\s]/g, '\\$&')

return createQuery<T>(queryFetch).where({ _path: new RegExp(`^${path}`) })
return createQuery<T>(createQueryFetch(withLeadingSlash(joinURL(query, ...pathParts))))
}

return createQuery<T>(queryFetch, query)
return createQuery<T>(createQueryFetch(), query)
}
6 changes: 3 additions & 3 deletions src/runtime/query/match/pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { QueryBuilderParams, QueryPipe } from '../../types'
import type { QueryBuilder, QueryBuilderParams, QueryPipe } from '../../types'
import { apply, ensureArray, sortList, withoutKeys, withKeys } from './utils'
import { createMatch } from '.'

Expand Down Expand Up @@ -40,9 +40,9 @@ export function createPipelineFetcher<T> (getContentsList: () => Promise<T[]>) {
(data, params) => params.first ? data[0] : data
]

return async (params: QueryBuilderParams): Promise<T | T[]> => {
return async (query: QueryBuilder<T>): Promise<T | T[]> => {
const data = await getContentsList()

return pipelines.reduce(($data: Array<T>, pipe: any) => pipe($data, params) || $data, data)
return pipelines.reduce(($data: Array<T>, pipe: any) => pipe($data, query.params()) || $data, data)
}
}
30 changes: 18 additions & 12 deletions src/runtime/query/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ const arrayParams = ['sort', 'where', 'only', 'without']

export const createQuery = <T = ParsedContent>(
fetcher: DatabaseFetcher<T>,
queryParams?: QueryBuilderParams
intitialParams?: QueryBuilderParams
): QueryBuilder<T> => {
const params = {
...queryParams
const queryParams = {
...intitialParams
} as QueryBuilderParams

for (const key of arrayParams) {
if (params[key]) {
params[key] = ensureArray(params[key])
if (queryParams[key]) {
queryParams[key] = ensureArray(queryParams[key])
}
}

Expand All @@ -23,23 +23,29 @@ export const createQuery = <T = ParsedContent>(
*/
const $set = (key: string, fn: (...values: any[]) => any = v => v) => {
return (...values: []) => {
params[key] = fn(...values)
queryParams[key] = fn(...values)
return query
}
}

const query: QueryBuilder<T> = {
params: () => Object.freeze(params),
params: () => queryParams,
only: $set('only', ensureArray) as () => ReturnType<QueryBuilder<T>['only']>,
without: $set('without', ensureArray),
where: $set('where', (q: any) => [...ensureArray(params.where), q]),
sort: $set('sort', (sort: SortOptions) => [...ensureArray(params.sort), ...ensureArray(sort)]),
where: $set('where', (q: any) => [...ensureArray(queryParams.where), q]),
sort: $set('sort', (sort: SortOptions) => [...ensureArray(queryParams.sort), ...ensureArray(sort)]),
limit: $set('limit', v => parseInt(String(v), 10)),
skip: $set('skip', v => parseInt(String(v), 10)),
// find
findOne: () => fetcher({ ...params, first: true }) as Promise<T>,
find: () => fetcher(params) as Promise<Array<T>>,
findSurround: (query, options) => fetcher({ ...params, surround: { query, ...options } }) as Promise<Array<T>>,
find: () => fetcher(query) as Promise<Array<T>>,
findOne: () => {
queryParams.first = true
return fetcher(query) as Promise<T>
},
findSurround: (surroundQuery, options) => {
queryParams.surround = { query: surroundQuery, ...options }
return fetcher(query) as Promise<Array<T>>
},
// locale
locale: (_locale: string) => query.where({ _locale })
}
Expand Down
39 changes: 22 additions & 17 deletions src/runtime/server/storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { prefixStorage } from 'unstorage'
import { joinURL, withLeadingSlash } from 'ufo'
import { joinURL, withLeadingSlash, withoutTrailingSlash } from 'ufo'
import { hash as ohash } from 'ohash'
import type { CompatibilityEvent } from 'h3'
import type { QueryBuilderParams, ParsedContent, QueryBuilder } from '../types'
Expand Down Expand Up @@ -118,31 +118,36 @@ export const getContent = async (event: CompatibilityEvent, id: string): Promise
return parsed
}

export const createServerQueryFetch = <T = ParsedContent>(event: CompatibilityEvent, path?: string) => (query: QueryBuilder<T>) => {
if (path) {
if (query.params().first) {
query.where({ _path: withoutTrailingSlash(path) })
} else {
query.where({ _path: new RegExp(`^${path.replace(/[-[\]{}()*+.,^$\s/]/g, '\\$&')}`) })
}
}

// Provide default sort order
if (!query.params().sort?.length) {
query.sort({ _file: 1, $numeric: true })
}

return createPipelineFetcher<T>(
() => getContentsList(event) as unknown as Promise<T[]>
)(query)
}

/**
* Query contents
*/
export function serverQueryContent<T = ParsedContent>(event: CompatibilityEvent): QueryBuilder<T>;
export function serverQueryContent<T = ParsedContent>(event: CompatibilityEvent, params?: QueryBuilderParams): QueryBuilder<T>;
export function serverQueryContent<T = ParsedContent>(event: CompatibilityEvent, path?: string, ...pathParts: string[]): QueryBuilder<T>;
export function serverQueryContent<T = ParsedContent> (event: CompatibilityEvent, path?: string | QueryBuilderParams, ...pathParts: string[]) {
let params = (path || {}) as QueryBuilderParams
if (typeof path === 'string') {
path = withLeadingSlash(joinURL(path, ...pathParts))
// escape regex special chars
path = path.replace(/[-[\]{}()*+.,^$\s]/g, '\\$&')

params = {
where: [{ _path: new RegExp(`^${path}`) }]
}
}
const pipelineFetcher = createPipelineFetcher<T>(
() => getContentsList(event) as unknown as Promise<T[]>
)

// Provide default sort order
if (!params.sort?.length) {
params.sort = [{ _file: 1, $numeric: true }]
return createQuery<T>(createServerQueryFetch(event, path))
}

return createQuery<T>(pipelineFetcher, params)
return createQuery<T>(createServerQueryFetch(event), path || {})
}
2 changes: 1 addition & 1 deletion src/runtime/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export interface QueryBuilder<T = ParsedContentMeta> {

export type QueryPipe<T = any> = (data: Array<T>, param: QueryBuilderParams) => Array<T> | void

export type DatabaseFetcher<T> = (params: QueryBuilderParams) => Promise<Array<T> | T>
export type DatabaseFetcher<T> = (quey: QueryBuilder<T>) => Promise<Array<T> | T>

export type QueryMatchOperator = (item: any, condition: any) => boolean

Expand Down
9 changes: 6 additions & 3 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { testCSVParser } from './features/parser-csv'
import { testRegex } from './features/regex'
import { testMarkdownParserExcerpt } from './features/parser-markdown-excerpt'
import { testParserHooks } from './features/parser-hooks'
import { testContentQuery } from './features/content-query'

describe('fixtures:basic', async () => {
await setup({
Expand Down Expand Up @@ -44,7 +45,7 @@ describe('fixtures:basic', async () => {
assert(ids.includes('content:.dot-ignored.md') === false, 'Ignored files with `.` should not be listed')
assert(ids.includes('content:-dash-ignored.md') === false, 'Ignored files with `-` should not be listed')

assert(ids.includes('fa-ir:fa:index.md') === true, 'Files with `fa-ir` prefix should be listed')
assert(ids.includes('fa-ir:fa:hello.md') === true, 'Files with `fa-ir` prefix should be listed')
})

test('Get contents index', async () => {
Expand All @@ -64,12 +65,12 @@ describe('fixtures:basic', async () => {
test('Search contents using `locale` helper', async () => {
const fa = await $fetch('/locale-fa')

expect(fa).toContain('fa-ir:fa:index.md')
expect(fa).toContain('fa-ir:fa:hello.md')
expect(fa).not.toContain('content:index.md')

const en = await $fetch('/locale-en')

expect(en).not.toContain('fa-ir:fa:index.md')
expect(en).not.toContain('fa-ir:fa:hello.md')
expect(en).toContain('content:index.md')
})

Expand Down Expand Up @@ -104,6 +105,8 @@ describe('fixtures:basic', async () => {
expect(html).contains('Content (v2)')
})

testContentQuery()

testNavigation()

testMarkdownParser()
Expand Down
43 changes: 43 additions & 0 deletions test/features/content-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'vitest'
import { $fetch } from '@nuxt/test-utils'

export const testContentQuery = () => {
describe('content-query', () => {
test('Find index', async () => {
const content = await $fetch('/')

// Normal Prop
expect(content).includes('Index')
})

test('exact match foo not found', async () => {
const content = await $fetch('/features/query-content?path=/foo&findOne=1')

// empty
expect(content).includes('$$$$')
})

test('exact match foo/bar found', async () => {
const content = await $fetch('/features/query-content?path=/foo/bar&findOne=1')

// empty
expect(content).includes('prefix:foo:bar.md$$')
})

test('prefix queries', async () => {
const content = await $fetch('/features/query-content?path=/foo')

expect(content).includes('prefix:foo:bar.md')
expect(content).includes('prefix:foo:baz.md')
expect(content).includes('prefix:foobarbaz.md')
})

test('directory listing', async () => {
const content = await $fetch('/features/query-content?path=/foo/')

expect(content).includes('prefix:foo:bar.md')
expect(content).includes('prefix:foo:baz.md')
expect(content).not.includes('prefix:foobarbaz.md')
})
})
}
14 changes: 12 additions & 2 deletions test/features/parser-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,12 @@ export const testMarkdownParser = () => {
content: [
':hello', // valid
':hello,', // valid
':hello-world', // valid but with different name
':hello :hello', // valid
':hello{}-world', // valid
':hello:hello', // invalid
':hello-world', // valid but with different name
':hello:', // invalid
'`:hello`', // code
':rocket:' // emoji
].join('\n')
}
Expand All @@ -90,7 +93,14 @@ export const testMarkdownParser = () => {
visit(parsed.body, node => (node as any).tag === 'hello', () => {
compComponentCount += 1
})
expect(compComponentCount).toEqual(3)
expect(compComponentCount).toEqual(5)

const paragraph = parsed.body.children[0]
expect(paragraph.children[0].tag).toEqual('hello')
expect(paragraph.children[1].tag).toEqual('hello')
expect(paragraph.children[3].tag).toEqual('hello')
expect(paragraph.children[5].tag).toEqual('hello')
expect(paragraph.children[6].tag).toEqual('hello')

// Check conflict between inline compoenent and emoji
expect(parsed.body.children[0].children.pop().value).toContain('🚀')
Expand Down
File renamed without changes.
Empty file.
Empty file.
Empty file.
20 changes: 20 additions & 0 deletions test/fixtures/basic/pages/features/query-content.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<div>
<pre>$${{ data }}$$</pre>
</div>
</template>

<script setup>
const route = useRoute()
const path = route.query.path
const findOne = route.query.findOne
const { data } = await useAsyncData('foo', () => {
const q = queryContent('/_partial/prefix' + path)
return findOne ? q.findOne() : q.find()
}, {
transform: (data) => {
return findOne ? data._id : data.map(d => d._id)
}
})
</script>

0 comments on commit ae907a8

Please sign in to comment.