UNPKG

6.93 kBJavaScriptView Raw
1const pick = (obj, keys = []) => {
2 return Object.keys(obj)
3 .filter(key => keys.includes(key))
4 .reduce((newObj, key) => Object.assign(newObj, { [key]: obj[key] }), {})
5}
6
7const omit = (obj, keys = []) => {
8 return Object.keys(obj)
9 .filter(key => !keys.includes(key))
10 .reduce((newObj, key) => Object.assign(newObj, { [key]: obj[key] }), {})
11}
12
13class QueryBuilder {
14 constructor ({ query, path, init, text, postprocess = [] }, options) {
15 this.query = query
16 this.path = path
17 this.init = init
18 this.postprocess = postprocess
19 this.options = options || {}
20 this.onlyKeys = null
21 this.withoutKeys = null
22 this.sortKeys = []
23 this.limitN = null
24 this.skipN = null
25
26 if (!text) {
27 // Remove text field from response
28 this.postprocess.unshift(data => data.map(item => omit(item, ['text'])))
29 }
30 }
31
32 /**
33 * Select a subset of fields
34 * @param {Array} keys - Array of fields to be picked.
35 * @returns {QueryBuilder} Returns current instance to be chained
36 */
37 only (keys) {
38 // Assign keys to this.onlyKeys to be processed in fetch
39 this.onlyKeys = Array.isArray(keys) ? keys : [keys]
40 // Return current instance
41 return this
42 }
43
44 /**
45 * Remove a subset of fields
46 * @param {Array} keys - Array of fields to be picked.
47 * @returns {QueryBuilder} Returns current instance to be chained
48 */
49 without (keys) {
50 // Assign keys to this.withoutKeys to be processed in fetch
51 this.withoutKeys = Array.isArray(keys) ? keys : [keys]
52 // Return current instance
53 return this
54 }
55
56 /**
57 * Sort results
58 * @param {string} field - Field key to sort on.
59 * @param {string} direction - Direction of sort (asc / desc).
60 * @returns {QueryBuilder} Returns current instance to be chained
61 */
62 sortBy (field, direction) {
63 this.sortKeys.push([field, direction === 'desc'])
64 return this
65 }
66
67 /**
68 * Filter results
69 * @param {object} query - Where query.
70 * @returns {QueryBuilder} Returns current instance to be chained
71 */
72 where (query) {
73 this.query = this.query.find(query)
74 return this
75 }
76
77 /**
78 * Search results
79 * @param {(Object|string)} query - Search query object or field or search value.
80 * @param {string} value - Value of search (means query equals to field).
81 * @returns {QueryBuilder} Returns current instance to be chained
82 */
83 search (query, value) {
84 // Passing an empty or falsey value as query will avoid triggering a search to allow optional chaining
85 if (!query) { return this }
86
87 let $fts
88
89 if (typeof query === 'object') {
90 $fts = query
91 } else if (value) {
92 $fts = {
93 query: {
94 type: 'match',
95 field: query,
96 value,
97 prefix_length: 1,
98 fuzziness: 1,
99 extended: true,
100 minimum_should_match: 1
101 }
102 }
103 } else {
104 $fts = {
105 query: {
106 type: 'bool',
107 should: this.options.fullTextSearchFields.map(field => ({
108 type: 'match',
109 field,
110 value: query,
111 prefix_length: 1,
112 operator: 'and',
113 minimum_should_match: 1,
114 fuzziness: 1,
115 extended: true
116 }))
117 }
118 }
119 }
120
121 this.query = this.query.find({ $fts }).sortByScoring()
122
123 return this
124 }
125
126 /**
127 * Surround results
128 * @param {string} slugOrPath - Slug or path of the file to surround.
129 * @param {Object} options - Options to surround (before / after).
130 * @returns {QueryBuilder} Returns current instance to be chained
131 */
132 surround (slugOrPath, { before = 1, after = 1 } = {}) {
133 const _key = slugOrPath.indexOf('/') === 0 ? 'path' : 'slug'
134
135 // Add slug or path to onlyKeys if only method has been called before
136 if (this.onlyKeys) {
137 this.onlyKeys.push(_key)
138 }
139 // Remove slug or path from withoutKeys if without method has been called before
140 if (this.withoutKeys) {
141 this.withoutKeys = this.withoutKeys.filter(key => key !== _key)
142 }
143
144 const fn = (data) => {
145 const index = data.findIndex(item => item[_key] === slugOrPath)
146 const slice = new Array(before + after).fill(null, 0)
147 if (index === -1) {
148 return slice
149 }
150
151 const prevSlice = data.slice(index - before, index)
152 const nextSlice = data.slice(index + 1, index + 1 + after)
153
154 let prevIndex = 0
155 for (let i = before - 1; i >= 0; i--) {
156 slice[i] = prevSlice[prevIndex] || null
157 prevIndex++
158 }
159
160 let nextIndex = 0
161 for (let i = before; i <= after; i++) {
162 slice[i] = nextSlice[nextIndex] || null
163 nextIndex++
164 }
165
166 return slice
167 }
168
169 this.postprocess.push(fn)
170 return this
171 }
172
173 /**
174 * Limit number of results
175 * @param {number} n - Limit number.
176 * @returns {QueryBuilder} Returns current instance to be chained
177 */
178 limit (n) {
179 if (typeof n === 'string') { n = parseInt(n) }
180
181 this.limitN = n
182 return this
183 }
184
185 /**
186 * Skip number of results
187 * @param {number} n - Skip number.
188 * @returns {QueryBuilder} Returns current instance to be chained
189 */
190 skip (n) {
191 if (typeof n === 'string') { n = parseInt(n) }
192
193 this.skipN = n
194 return this
195 }
196
197 /**
198 * Collect data and apply process filters
199 * @returns {(Object|Array)} Returns processed data
200 */
201 // eslint-disable-next-line require-await
202 async fetch () {
203 if (this.sortKeys && this.sortKeys.length) {
204 this.query = this.query.compoundsort(this.sortKeys)
205 }
206 if (this.skipN) {
207 this.query = this.query.offset(this.skipN)
208 }
209 if (this.limitN) {
210 this.query = this.query.limit(this.limitN)
211 }
212 // Collect data without meta fields
213 let data = this.query.data({ removeMeta: true })
214 // Handle only keys
215 if (this.onlyKeys) {
216 // Add `path` and `extension` to onlyKeys if watch to ensure live edit
217 if (this.options.watch) {
218 this.onlyKeys.push('path', 'extension')
219 }
220 // Map data and returns object picked by keys
221 const fn = data => data.map(item => pick(item, this.onlyKeys))
222 // Apply pick during postprocess
223 this.postprocess.unshift(fn)
224 }
225 // Handle without keys
226 if (this.withoutKeys) {
227 // Remove `path` and `extension` from withoutKeys if watch to ensure live edit
228 if (this.options.watch) {
229 this.withoutKeys = this.withoutKeys.filter(key => !['path', 'extension'].includes(key))
230 }
231 // Map data and returns object picked by keys
232 const fn = data => data.map(item => omit(item, this.withoutKeys))
233 // Apply pick during postprocess
234 this.postprocess.unshift(fn)
235 }
236 // Apply postprocess fns to data
237 for (const fn of this.postprocess) {
238 data = fn(data)
239 }
240
241 if (!data) {
242 throw new Error(`${this.path} not found`)
243 }
244
245 return JSON.parse(JSON.stringify(data))
246 }
247}
248
249module.exports = QueryBuilder