@mantiq/search 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +60 -0
- package/src/SearchBuilder.ts +149 -0
- package/src/SearchManager.ts +95 -0
- package/src/SearchObserver.ts +62 -0
- package/src/SearchServiceProvider.ts +59 -0
- package/src/Searchable.ts +89 -0
- package/src/commands/SearchDeleteIndexCommand.ts +22 -0
- package/src/commands/SearchFlushCommand.ts +44 -0
- package/src/commands/SearchImportCommand.ts +45 -0
- package/src/commands/SearchIndexCommand.ts +22 -0
- package/src/contracts/SearchConfig.ts +30 -0
- package/src/contracts/SearchEngine.ts +33 -0
- package/src/drivers/AlgoliaEngine.ts +145 -0
- package/src/drivers/CollectionEngine.ts +176 -0
- package/src/drivers/DatabaseEngine.ts +139 -0
- package/src/drivers/ElasticsearchEngine.ts +188 -0
- package/src/drivers/MeilisearchEngine.ts +136 -0
- package/src/drivers/TypesenseEngine.ts +181 -0
- package/src/errors/SearchError.ts +9 -0
- package/src/helpers/search.ts +26 -0
- package/src/index.ts +39 -0
- package/src/jobs/MakeSearchableJob.ts +22 -0
- package/src/testing/SearchFake.ts +136 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
|
|
2
|
+
import type { SearchBuilder } from '../SearchBuilder.ts'
|
|
3
|
+
import { SearchError } from '../errors/SearchError.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Meilisearch engine driver using REST API.
|
|
7
|
+
*/
|
|
8
|
+
export class MeilisearchEngine implements SearchEngine {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly host: string,
|
|
11
|
+
private readonly apiKey: string,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
async update(models: any[]): Promise<void> {
|
|
15
|
+
if (models.length === 0) return
|
|
16
|
+
const indexName = this.resolveIndexName(models[0])
|
|
17
|
+
const documents = models.map((m) => ({
|
|
18
|
+
id: this.resolveKey(m),
|
|
19
|
+
...this.resolveData(m),
|
|
20
|
+
}))
|
|
21
|
+
await this.request('POST', `/indexes/${encodeURIComponent(indexName)}/documents`, documents)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async delete(models: any[]): Promise<void> {
|
|
25
|
+
if (models.length === 0) return
|
|
26
|
+
const indexName = this.resolveIndexName(models[0])
|
|
27
|
+
const ids = models.map((m) => this.resolveKey(m))
|
|
28
|
+
await this.request('POST', `/indexes/${encodeURIComponent(indexName)}/documents/delete-batch`, ids)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async search(builder: SearchBuilder): Promise<SearchResult> {
|
|
32
|
+
const indexName = this.resolveModelIndexName(builder.model)
|
|
33
|
+
const body: Record<string, any> = { q: builder.query }
|
|
34
|
+
|
|
35
|
+
if (builder.getLimit() !== null) body.limit = builder.getLimit()
|
|
36
|
+
if (builder.getOffset() !== null) body.offset = builder.getOffset()
|
|
37
|
+
|
|
38
|
+
const filter = this.buildFilter(builder)
|
|
39
|
+
if (filter.length > 0) body.filter = filter
|
|
40
|
+
|
|
41
|
+
const sort = builder.orders.map((o) => `${o.column}:${o.direction}`)
|
|
42
|
+
if (sort.length > 0) body.sort = sort
|
|
43
|
+
|
|
44
|
+
const res = await this.request('POST', `/indexes/${encodeURIComponent(indexName)}/search`, body)
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
raw: res,
|
|
48
|
+
keys: (res.hits ?? []).map((h: any) => h.id),
|
|
49
|
+
total: res.estimatedTotalHits ?? res.totalHits ?? 0,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
|
|
54
|
+
const indexName = this.resolveModelIndexName(builder.model)
|
|
55
|
+
const body: Record<string, any> = {
|
|
56
|
+
q: builder.query,
|
|
57
|
+
hitsPerPage: perPage,
|
|
58
|
+
page,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const filter = this.buildFilter(builder)
|
|
62
|
+
if (filter.length > 0) body.filter = filter
|
|
63
|
+
|
|
64
|
+
const sort = builder.orders.map((o) => `${o.column}:${o.direction}`)
|
|
65
|
+
if (sort.length > 0) body.sort = sort
|
|
66
|
+
|
|
67
|
+
const res = await this.request('POST', `/indexes/${encodeURIComponent(indexName)}/search`, body)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
raw: res,
|
|
71
|
+
keys: (res.hits ?? []).map((h: any) => h.id),
|
|
72
|
+
total: res.totalHits ?? res.estimatedTotalHits ?? 0,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async flush(indexName: string): Promise<void> {
|
|
77
|
+
await this.request('DELETE', `/indexes/${encodeURIComponent(indexName)}/documents`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async createIndex(name: string, options?: Record<string, any>): Promise<void> {
|
|
81
|
+
await this.request('POST', '/indexes', { uid: name, primaryKey: options?.primaryKey ?? 'id' })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async deleteIndex(name: string): Promise<void> {
|
|
85
|
+
await this.request('DELETE', `/indexes/${encodeURIComponent(name)}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private buildFilter(builder: SearchBuilder): string[] {
|
|
89
|
+
const filters: string[] = []
|
|
90
|
+
for (const { field, value } of builder.wheres) {
|
|
91
|
+
filters.push(`${field} = ${JSON.stringify(value)}`)
|
|
92
|
+
}
|
|
93
|
+
for (const { field, values } of builder.whereIns) {
|
|
94
|
+
const parts = values.map((v) => `${field} = ${JSON.stringify(v)}`)
|
|
95
|
+
filters.push(parts.join(' OR '))
|
|
96
|
+
}
|
|
97
|
+
return filters
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async request(method: string, path: string, body?: any): Promise<any> {
|
|
101
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
102
|
+
if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}`
|
|
103
|
+
|
|
104
|
+
const res = await fetch(`${this.host}${path}`, {
|
|
105
|
+
method,
|
|
106
|
+
headers,
|
|
107
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const text = await res.text()
|
|
112
|
+
throw new SearchError(`Meilisearch API error: ${res.status} ${text}`, { status: res.status })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const contentType = res.headers.get('content-type')
|
|
116
|
+
if (contentType?.includes('application/json')) return res.json()
|
|
117
|
+
return {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private resolveIndexName(model: any): string {
|
|
121
|
+
const MC = model.constructor
|
|
122
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private resolveModelIndexName(MC: any): string {
|
|
126
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private resolveKey(model: any): string | number {
|
|
130
|
+
return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private resolveData(model: any): Record<string, any> {
|
|
134
|
+
return typeof model.toSearchableArray === 'function' ? model.toSearchableArray() : { ...model.attributes }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
|
|
2
|
+
import type { SearchBuilder } from '../SearchBuilder.ts'
|
|
3
|
+
import { SearchError } from '../errors/SearchError.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Typesense engine driver using REST API.
|
|
7
|
+
*/
|
|
8
|
+
export class TypesenseEngine implements SearchEngine {
|
|
9
|
+
private readonly baseUrl: string
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
host: string,
|
|
13
|
+
port: number,
|
|
14
|
+
protocol: 'http' | 'https',
|
|
15
|
+
private readonly apiKey: string,
|
|
16
|
+
) {
|
|
17
|
+
this.baseUrl = `${protocol}://${host}:${port}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async update(models: any[]): Promise<void> {
|
|
21
|
+
if (models.length === 0) return
|
|
22
|
+
const collection = this.resolveIndexName(models[0])
|
|
23
|
+
const documents = models.map((m) => ({
|
|
24
|
+
...this.resolveData(m),
|
|
25
|
+
id: String(this.resolveKey(m)),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
// JSONL import
|
|
29
|
+
const jsonl = documents.map((d) => JSON.stringify(d)).join('\n')
|
|
30
|
+
await this.request('POST', `/collections/${collection}/documents/import?action=upsert`, jsonl, 'text/plain')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async delete(models: any[]): Promise<void> {
|
|
34
|
+
if (models.length === 0) return
|
|
35
|
+
const collection = this.resolveIndexName(models[0])
|
|
36
|
+
const ids = models.map((m) => String(this.resolveKey(m)))
|
|
37
|
+
|
|
38
|
+
// Delete individually (Typesense doesn't have batch delete by ID)
|
|
39
|
+
for (const id of ids) {
|
|
40
|
+
try {
|
|
41
|
+
await this.request('DELETE', `/collections/${collection}/documents/${id}`)
|
|
42
|
+
} catch {
|
|
43
|
+
// Ignore 404s
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async search(builder: SearchBuilder): Promise<SearchResult> {
|
|
49
|
+
const collection = this.resolveModelIndexName(builder.model)
|
|
50
|
+
const params = new URLSearchParams({
|
|
51
|
+
q: builder.query || '*',
|
|
52
|
+
query_by: this.resolveQueryBy(builder.model),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (builder.getLimit() !== null) params.set('per_page', String(builder.getLimit()))
|
|
56
|
+
if (builder.getOffset() !== null) params.set('offset', String(builder.getOffset()))
|
|
57
|
+
|
|
58
|
+
const filter = this.buildFilter(builder)
|
|
59
|
+
if (filter) params.set('filter_by', filter)
|
|
60
|
+
|
|
61
|
+
const sort = builder.orders.map((o) => `${o.column}:${o.direction}`).join(',')
|
|
62
|
+
if (sort) params.set('sort_by', sort)
|
|
63
|
+
|
|
64
|
+
const res = await this.request('GET', `/collections/${collection}/documents/search?${params}`)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
raw: res,
|
|
68
|
+
keys: (res.hits ?? []).map((h: any) => h.document?.id),
|
|
69
|
+
total: res.found ?? 0,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
|
|
74
|
+
const collection = this.resolveModelIndexName(builder.model)
|
|
75
|
+
const params = new URLSearchParams({
|
|
76
|
+
q: builder.query || '*',
|
|
77
|
+
query_by: this.resolveQueryBy(builder.model),
|
|
78
|
+
per_page: String(perPage),
|
|
79
|
+
page: String(page),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const filter = this.buildFilter(builder)
|
|
83
|
+
if (filter) params.set('filter_by', filter)
|
|
84
|
+
|
|
85
|
+
const sort = builder.orders.map((o) => `${o.column}:${o.direction}`).join(',')
|
|
86
|
+
if (sort) params.set('sort_by', sort)
|
|
87
|
+
|
|
88
|
+
const res = await this.request('GET', `/collections/${collection}/documents/search?${params}`)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
raw: res,
|
|
92
|
+
keys: (res.hits ?? []).map((h: any) => h.document?.id),
|
|
93
|
+
total: res.found ?? 0,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async flush(indexName: string): Promise<void> {
|
|
98
|
+
try {
|
|
99
|
+
await this.request('DELETE', `/collections/${indexName}`)
|
|
100
|
+
} catch {
|
|
101
|
+
// Collection may not exist
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async createIndex(name: string, options?: Record<string, any>): Promise<void> {
|
|
106
|
+
const schema = {
|
|
107
|
+
name,
|
|
108
|
+
fields: options?.fields ?? [{ name: '.*', type: 'auto' }],
|
|
109
|
+
...options,
|
|
110
|
+
}
|
|
111
|
+
await this.request('POST', '/collections', schema)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async deleteIndex(name: string): Promise<void> {
|
|
115
|
+
await this.request('DELETE', `/collections/${name}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private buildFilter(builder: SearchBuilder): string {
|
|
119
|
+
const parts: string[] = []
|
|
120
|
+
for (const { field, value } of builder.wheres) {
|
|
121
|
+
parts.push(`${field}:=${JSON.stringify(value)}`)
|
|
122
|
+
}
|
|
123
|
+
for (const { field, values } of builder.whereIns) {
|
|
124
|
+
parts.push(`${field}:[${values.map((v) => JSON.stringify(v)).join(',')}]`)
|
|
125
|
+
}
|
|
126
|
+
return parts.join(' && ')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private resolveQueryBy(ModelClass: any): string {
|
|
130
|
+
if (typeof ModelClass.searchableColumns === 'function') {
|
|
131
|
+
return ModelClass.searchableColumns().join(',')
|
|
132
|
+
}
|
|
133
|
+
if (ModelClass.fillable && ModelClass.fillable.length > 0) {
|
|
134
|
+
return ModelClass.fillable.join(',')
|
|
135
|
+
}
|
|
136
|
+
return '*'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async request(method: string, path: string, body?: any, contentType?: string): Promise<any> {
|
|
140
|
+
const headers: Record<string, string> = {
|
|
141
|
+
'X-TYPESENSE-API-KEY': this.apiKey,
|
|
142
|
+
}
|
|
143
|
+
if (contentType) {
|
|
144
|
+
headers['Content-Type'] = contentType
|
|
145
|
+
} else if (body && typeof body !== 'string') {
|
|
146
|
+
headers['Content-Type'] = 'application/json'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
150
|
+
method,
|
|
151
|
+
headers,
|
|
152
|
+
body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
const text = await res.text()
|
|
157
|
+
throw new SearchError(`Typesense API error: ${res.status} ${text}`, { status: res.status })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ct = res.headers.get('content-type')
|
|
161
|
+
if (ct?.includes('application/json')) return res.json()
|
|
162
|
+
return {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private resolveIndexName(model: any): string {
|
|
166
|
+
const MC = model.constructor
|
|
167
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private resolveModelIndexName(MC: any): string {
|
|
171
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private resolveKey(model: any): string | number {
|
|
175
|
+
return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private resolveData(model: any): Record<string, any> {
|
|
179
|
+
return typeof model.toSearchableArray === 'function' ? model.toSearchableArray() : { ...model.attributes }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SearchEngine } from '../contracts/SearchEngine.ts'
|
|
2
|
+
import type { SearchManager } from '../SearchManager.ts'
|
|
3
|
+
|
|
4
|
+
export const SEARCH_MANAGER = Symbol('SearchManager')
|
|
5
|
+
|
|
6
|
+
let _manager: SearchManager | null = null
|
|
7
|
+
|
|
8
|
+
export function setSearchManager(manager: SearchManager): void {
|
|
9
|
+
_manager = manager
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getSearchManager(): SearchManager {
|
|
13
|
+
if (!_manager) {
|
|
14
|
+
throw new Error('SearchManager has not been initialized. Register SearchServiceProvider first.')
|
|
15
|
+
}
|
|
16
|
+
return _manager
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Get the SearchManager or a specific engine. */
|
|
20
|
+
export function search(): SearchManager
|
|
21
|
+
export function search(engine: string): SearchEngine
|
|
22
|
+
export function search(engine?: string): SearchManager | SearchEngine {
|
|
23
|
+
const manager = getSearchManager()
|
|
24
|
+
if (engine === undefined) return manager
|
|
25
|
+
return manager.driver(engine)
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @mantiq/search — public API exports
|
|
2
|
+
|
|
3
|
+
// Contracts
|
|
4
|
+
export type { SearchEngine, SearchResult } from './contracts/SearchEngine.ts'
|
|
5
|
+
export type { SearchConfig, EngineConfig } from './contracts/SearchConfig.ts'
|
|
6
|
+
export { DEFAULT_CONFIG } from './contracts/SearchConfig.ts'
|
|
7
|
+
|
|
8
|
+
// Core
|
|
9
|
+
export { SearchManager } from './SearchManager.ts'
|
|
10
|
+
export { SearchBuilder, type PaginatedSearchResult, type WhereClause, type WhereInClause, type OrderClause } from './SearchBuilder.ts'
|
|
11
|
+
export { SearchServiceProvider } from './SearchServiceProvider.ts'
|
|
12
|
+
export { makeSearchable } from './Searchable.ts'
|
|
13
|
+
export { SearchObserver } from './SearchObserver.ts'
|
|
14
|
+
|
|
15
|
+
// Drivers
|
|
16
|
+
export { CollectionEngine } from './drivers/CollectionEngine.ts'
|
|
17
|
+
export { DatabaseEngine } from './drivers/DatabaseEngine.ts'
|
|
18
|
+
export { AlgoliaEngine } from './drivers/AlgoliaEngine.ts'
|
|
19
|
+
export { MeilisearchEngine } from './drivers/MeilisearchEngine.ts'
|
|
20
|
+
export { TypesenseEngine } from './drivers/TypesenseEngine.ts'
|
|
21
|
+
export { ElasticsearchEngine } from './drivers/ElasticsearchEngine.ts'
|
|
22
|
+
|
|
23
|
+
// Commands
|
|
24
|
+
export { SearchImportCommand } from './commands/SearchImportCommand.ts'
|
|
25
|
+
export { SearchFlushCommand } from './commands/SearchFlushCommand.ts'
|
|
26
|
+
export { SearchIndexCommand } from './commands/SearchIndexCommand.ts'
|
|
27
|
+
export { SearchDeleteIndexCommand } from './commands/SearchDeleteIndexCommand.ts'
|
|
28
|
+
|
|
29
|
+
// Jobs
|
|
30
|
+
export { MakeSearchableJob } from './jobs/MakeSearchableJob.ts'
|
|
31
|
+
|
|
32
|
+
// Helpers
|
|
33
|
+
export { search, SEARCH_MANAGER, setSearchManager, getSearchManager } from './helpers/search.ts'
|
|
34
|
+
|
|
35
|
+
// Testing
|
|
36
|
+
export { SearchFake } from './testing/SearchFake.ts'
|
|
37
|
+
|
|
38
|
+
// Errors
|
|
39
|
+
export { SearchError } from './errors/SearchError.ts'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getSearchManager } from '../helpers/search.ts'
|
|
2
|
+
|
|
3
|
+
export class MakeSearchableJob {
|
|
4
|
+
readonly jobName = 'MakeSearchableJob'
|
|
5
|
+
tries = 3
|
|
6
|
+
backoff = 10
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly models: any[],
|
|
10
|
+
private readonly action: 'update' | 'delete',
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
async handle(): Promise<void> {
|
|
14
|
+
const engine = getSearchManager().driver()
|
|
15
|
+
|
|
16
|
+
if (this.action === 'update') {
|
|
17
|
+
await engine.update(this.models)
|
|
18
|
+
} else {
|
|
19
|
+
await engine.delete(this.models)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
|
|
2
|
+
import type { SearchBuilder } from '../SearchBuilder.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fake search engine for testing. Stores all indexed records in memory
|
|
6
|
+
* and provides assertion methods.
|
|
7
|
+
*/
|
|
8
|
+
export class SearchFake implements SearchEngine {
|
|
9
|
+
private indexed = new Map<string, Map<string | number, Record<string, any>>>()
|
|
10
|
+
private deleted = new Map<string, Set<string | number>>()
|
|
11
|
+
private flushed = new Set<string>()
|
|
12
|
+
|
|
13
|
+
async update(models: any[]): Promise<void> {
|
|
14
|
+
for (const model of models) {
|
|
15
|
+
const index = this.resolveIndexName(model)
|
|
16
|
+
if (!this.indexed.has(index)) this.indexed.set(index, new Map())
|
|
17
|
+
const key = this.resolveKey(model)
|
|
18
|
+
const data = typeof model.toSearchableArray === 'function'
|
|
19
|
+
? model.toSearchableArray()
|
|
20
|
+
: { ...model.attributes }
|
|
21
|
+
this.indexed.get(index)!.set(key, data)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async delete(models: any[]): Promise<void> {
|
|
26
|
+
for (const model of models) {
|
|
27
|
+
const index = this.resolveIndexName(model)
|
|
28
|
+
if (!this.deleted.has(index)) this.deleted.set(index, new Set())
|
|
29
|
+
const key = this.resolveKey(model)
|
|
30
|
+
this.deleted.get(index)!.add(key)
|
|
31
|
+
this.indexed.get(index)?.delete(key)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async search(builder: SearchBuilder): Promise<SearchResult> {
|
|
36
|
+
const index = this.resolveModelIndexName(builder.model)
|
|
37
|
+
const records = this.indexed.get(index)
|
|
38
|
+
if (!records) return { raw: [], keys: [], total: 0 }
|
|
39
|
+
|
|
40
|
+
const keys = Array.from(records.keys())
|
|
41
|
+
return { raw: Array.from(records.values()), keys, total: keys.length }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
|
|
45
|
+
const result = await this.search(builder)
|
|
46
|
+
const start = (page - 1) * perPage
|
|
47
|
+
const keys = result.keys.slice(start, start + perPage)
|
|
48
|
+
return { raw: result.raw.slice(start, start + perPage), keys, total: result.total }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async flush(indexName: string): Promise<void> {
|
|
52
|
+
this.indexed.delete(indexName)
|
|
53
|
+
this.flushed.add(indexName)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async createIndex(_name: string): Promise<void> {}
|
|
57
|
+
async deleteIndex(_name: string): Promise<void> {}
|
|
58
|
+
|
|
59
|
+
// ── Assertions ────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
assertIndexed(modelClass: any, count?: number): void {
|
|
62
|
+
const index = this.resolveStaticIndexName(modelClass)
|
|
63
|
+
const records = this.indexed.get(index)
|
|
64
|
+
const actual = records?.size ?? 0
|
|
65
|
+
if (actual === 0) {
|
|
66
|
+
throw new Error(`Expected [${modelClass.name}] to be indexed, but it was not.`)
|
|
67
|
+
}
|
|
68
|
+
if (count !== undefined && actual !== count) {
|
|
69
|
+
throw new Error(`Expected [${modelClass.name}] to be indexed ${count} time(s), but was indexed ${actual} time(s).`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
assertNotIndexed(modelClass: any): void {
|
|
74
|
+
const index = this.resolveStaticIndexName(modelClass)
|
|
75
|
+
const records = this.indexed.get(index)
|
|
76
|
+
if (records && records.size > 0) {
|
|
77
|
+
throw new Error(`Expected [${modelClass.name}] to not be indexed, but ${records.size} record(s) found.`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
assertNothingIndexed(): void {
|
|
82
|
+
let total = 0
|
|
83
|
+
for (const records of this.indexed.values()) total += records.size
|
|
84
|
+
if (total > 0) {
|
|
85
|
+
throw new Error(`Expected nothing to be indexed, but ${total} record(s) found.`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
assertDeleted(modelClass: any, count?: number): void {
|
|
90
|
+
const index = this.resolveStaticIndexName(modelClass)
|
|
91
|
+
const deletedKeys = this.deleted.get(index)
|
|
92
|
+
const actual = deletedKeys?.size ?? 0
|
|
93
|
+
if (actual === 0) {
|
|
94
|
+
throw new Error(`Expected [${modelClass.name}] to have deletions, but none found.`)
|
|
95
|
+
}
|
|
96
|
+
if (count !== undefined && actual !== count) {
|
|
97
|
+
throw new Error(`Expected ${count} deletion(s) for [${modelClass.name}], but found ${actual}.`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
assertFlushed(modelClass: any): void {
|
|
102
|
+
const index = this.resolveStaticIndexName(modelClass)
|
|
103
|
+
if (!this.flushed.has(index)) {
|
|
104
|
+
throw new Error(`Expected [${modelClass.name}] index to be flushed, but it was not.`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Get all indexed records for a model class. */
|
|
109
|
+
getIndexed(modelClass: any): Map<string | number, Record<string, any>> {
|
|
110
|
+
const index = this.resolveStaticIndexName(modelClass)
|
|
111
|
+
return this.indexed.get(index) ?? new Map()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
reset(): void {
|
|
115
|
+
this.indexed.clear()
|
|
116
|
+
this.deleted.clear()
|
|
117
|
+
this.flushed.clear()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private resolveIndexName(model: any): string {
|
|
121
|
+
const MC = model.constructor
|
|
122
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private resolveModelIndexName(MC: any): string {
|
|
126
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private resolveStaticIndexName(MC: any): string {
|
|
130
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private resolveKey(model: any): string | number {
|
|
134
|
+
return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
|
|
135
|
+
}
|
|
136
|
+
}
|