@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,33 @@
|
|
|
1
|
+
import type { SearchBuilder } from '../SearchBuilder.ts'
|
|
2
|
+
|
|
3
|
+
export interface SearchResult {
|
|
4
|
+
/** Raw results from the engine (format varies by driver) */
|
|
5
|
+
raw: any
|
|
6
|
+
/** Mapped model keys from the results */
|
|
7
|
+
keys: (string | number)[]
|
|
8
|
+
/** Total number of matching records (for pagination) */
|
|
9
|
+
total: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SearchEngine {
|
|
13
|
+
/** Upsert models into the search index */
|
|
14
|
+
update(models: any[]): Promise<void>
|
|
15
|
+
|
|
16
|
+
/** Remove models from the search index */
|
|
17
|
+
delete(models: any[]): Promise<void>
|
|
18
|
+
|
|
19
|
+
/** Perform a search query */
|
|
20
|
+
search(builder: SearchBuilder): Promise<SearchResult>
|
|
21
|
+
|
|
22
|
+
/** Perform a paginated search query */
|
|
23
|
+
paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult>
|
|
24
|
+
|
|
25
|
+
/** Remove all records for a model from its index */
|
|
26
|
+
flush(indexName: string): Promise<void>
|
|
27
|
+
|
|
28
|
+
/** Create a search index */
|
|
29
|
+
createIndex(name: string, options?: Record<string, any>): Promise<void>
|
|
30
|
+
|
|
31
|
+
/** Delete a search index */
|
|
32
|
+
deleteIndex(name: string): Promise<void>
|
|
33
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
* Algolia search engine driver using REST API (no SDK).
|
|
7
|
+
*/
|
|
8
|
+
export class AlgoliaEngine implements SearchEngine {
|
|
9
|
+
private readonly baseUrl: string
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly applicationId: string,
|
|
13
|
+
private readonly apiKey: string,
|
|
14
|
+
private readonly indexSettings?: Record<string, any>,
|
|
15
|
+
) {
|
|
16
|
+
this.baseUrl = `https://${applicationId}-dsn.algolia.net`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async update(models: any[]): Promise<void> {
|
|
20
|
+
if (models.length === 0) return
|
|
21
|
+
|
|
22
|
+
const indexName = this.resolveIndexName(models[0])
|
|
23
|
+
const objects = models.map((m) => ({
|
|
24
|
+
objectID: String(this.resolveKey(m)),
|
|
25
|
+
...this.resolveData(m),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/batch`, {
|
|
29
|
+
requests: objects.map((obj) => ({ action: 'updateObject', body: obj })),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async delete(models: any[]): Promise<void> {
|
|
34
|
+
if (models.length === 0) return
|
|
35
|
+
|
|
36
|
+
const indexName = this.resolveIndexName(models[0])
|
|
37
|
+
const objectIDs = models.map((m) => String(this.resolveKey(m)))
|
|
38
|
+
|
|
39
|
+
await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/batch`, {
|
|
40
|
+
requests: objectIDs.map((id) => ({ action: 'deleteObject', body: { objectID: id } })),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async search(builder: SearchBuilder): Promise<SearchResult> {
|
|
45
|
+
const indexName = this.resolveModelIndexName(builder.model)
|
|
46
|
+
|
|
47
|
+
const params: Record<string, any> = { query: builder.query }
|
|
48
|
+
if (builder.getLimit() !== null) params.hitsPerPage = builder.getLimit()
|
|
49
|
+
|
|
50
|
+
const filters = this.buildFilters(builder)
|
|
51
|
+
if (filters) params.filters = filters
|
|
52
|
+
|
|
53
|
+
const res = await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/query`, params)
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
raw: res,
|
|
57
|
+
keys: (res.hits ?? []).map((h: any) => h.objectID),
|
|
58
|
+
total: res.nbHits ?? 0,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
|
|
63
|
+
const indexName = this.resolveModelIndexName(builder.model)
|
|
64
|
+
|
|
65
|
+
const params: Record<string, any> = {
|
|
66
|
+
query: builder.query,
|
|
67
|
+
hitsPerPage: perPage,
|
|
68
|
+
page: page - 1, // Algolia is 0-indexed
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const filters = this.buildFilters(builder)
|
|
72
|
+
if (filters) params.filters = filters
|
|
73
|
+
|
|
74
|
+
const res = await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/query`, params)
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
raw: res,
|
|
78
|
+
keys: (res.hits ?? []).map((h: any) => h.objectID),
|
|
79
|
+
total: res.nbHits ?? 0,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async flush(indexName: string): Promise<void> {
|
|
84
|
+
await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/clear`, {})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async createIndex(name: string, options?: Record<string, any>): Promise<void> {
|
|
88
|
+
const settings = { ...this.indexSettings, ...options }
|
|
89
|
+
if (Object.keys(settings).length > 0) {
|
|
90
|
+
await this.request('PUT', `/1/indexes/${encodeURIComponent(name)}/settings`, settings)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async deleteIndex(name: string): Promise<void> {
|
|
95
|
+
await this.request('DELETE', `/1/indexes/${encodeURIComponent(name)}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private buildFilters(builder: SearchBuilder): string {
|
|
99
|
+
const parts: string[] = []
|
|
100
|
+
for (const { field, value } of builder.wheres) {
|
|
101
|
+
parts.push(`${field}:${JSON.stringify(value)}`)
|
|
102
|
+
}
|
|
103
|
+
for (const { field, values } of builder.whereIns) {
|
|
104
|
+
const orParts = values.map((v) => `${field}:${JSON.stringify(v)}`)
|
|
105
|
+
parts.push(`(${orParts.join(' OR ')})`)
|
|
106
|
+
}
|
|
107
|
+
return parts.join(' AND ')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async request(method: string, path: string, body?: any): Promise<any> {
|
|
111
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
112
|
+
method,
|
|
113
|
+
headers: {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
'X-Algolia-Application-Id': this.applicationId,
|
|
116
|
+
'X-Algolia-API-Key': this.apiKey,
|
|
117
|
+
},
|
|
118
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const text = await res.text()
|
|
123
|
+
throw new SearchError(`Algolia API error: ${res.status} ${text}`, { status: res.status })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return res.json()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private resolveIndexName(model: any): string {
|
|
130
|
+
const MC = model.constructor
|
|
131
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private resolveModelIndexName(MC: any): string {
|
|
135
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private resolveKey(model: any): string | number {
|
|
139
|
+
return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private resolveData(model: any): Record<string, any> {
|
|
143
|
+
return typeof model.toSearchableArray === 'function' ? model.toSearchableArray() : { ...model.attributes }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
|
|
2
|
+
import type { SearchBuilder } from '../SearchBuilder.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory search engine for development and testing.
|
|
6
|
+
* Stores indexed records in a Map and searches with string matching.
|
|
7
|
+
*/
|
|
8
|
+
export class CollectionEngine implements SearchEngine {
|
|
9
|
+
private readonly store = new Map<string, Map<string | number, Record<string, any>>>()
|
|
10
|
+
|
|
11
|
+
async update(models: any[]): Promise<void> {
|
|
12
|
+
for (const model of models) {
|
|
13
|
+
const indexName = this.resolveIndexName(model)
|
|
14
|
+
if (!this.store.has(indexName)) this.store.set(indexName, new Map())
|
|
15
|
+
const index = this.store.get(indexName)!
|
|
16
|
+
|
|
17
|
+
const key = this.resolveKey(model)
|
|
18
|
+
const data = typeof model.toSearchableArray === 'function'
|
|
19
|
+
? model.toSearchableArray()
|
|
20
|
+
: { ...model.attributes }
|
|
21
|
+
|
|
22
|
+
index.set(key, data)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async delete(models: any[]): Promise<void> {
|
|
27
|
+
for (const model of models) {
|
|
28
|
+
const indexName = this.resolveIndexName(model)
|
|
29
|
+
const index = this.store.get(indexName)
|
|
30
|
+
if (!index) continue
|
|
31
|
+
index.delete(this.resolveKey(model))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async search(builder: SearchBuilder): Promise<SearchResult> {
|
|
36
|
+
const indexName = this.resolveModelIndexName(builder.model)
|
|
37
|
+
const index = this.store.get(indexName)
|
|
38
|
+
|
|
39
|
+
if (!index) return { raw: [], keys: [], total: 0 }
|
|
40
|
+
|
|
41
|
+
let records = Array.from(index.entries()).map(([key, data]) => ({ key, data }))
|
|
42
|
+
|
|
43
|
+
// Full-text filter
|
|
44
|
+
if (builder.query) {
|
|
45
|
+
const q = builder.query.toLowerCase()
|
|
46
|
+
records = records.filter(({ data }) =>
|
|
47
|
+
Object.values(data).some((v) =>
|
|
48
|
+
v !== null && v !== undefined && String(v).toLowerCase().includes(q),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Where clauses
|
|
54
|
+
for (const { field, value } of builder.wheres) {
|
|
55
|
+
records = records.filter(({ data }) => data[field] === value)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// WhereIn clauses
|
|
59
|
+
for (const { field, values } of builder.whereIns) {
|
|
60
|
+
records = records.filter(({ data }) => values.includes(data[field]))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const total = records.length
|
|
64
|
+
|
|
65
|
+
// Order
|
|
66
|
+
for (const { column, direction } of [...builder.orders].reverse()) {
|
|
67
|
+
records.sort((a, b) => {
|
|
68
|
+
const av = a.data[column]
|
|
69
|
+
const bv = b.data[column]
|
|
70
|
+
if (av < bv) return direction === 'asc' ? -1 : 1
|
|
71
|
+
if (av > bv) return direction === 'asc' ? 1 : -1
|
|
72
|
+
return 0
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Offset
|
|
77
|
+
const offset = builder.getOffset()
|
|
78
|
+
if (offset !== null && offset > 0) {
|
|
79
|
+
records = records.slice(offset)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Limit
|
|
83
|
+
const limit = builder.getLimit()
|
|
84
|
+
if (limit !== null) {
|
|
85
|
+
records = records.slice(0, limit)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
raw: records.map((r) => r.data),
|
|
90
|
+
keys: records.map((r) => r.key),
|
|
91
|
+
total,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
|
|
96
|
+
const indexName = this.resolveModelIndexName(builder.model)
|
|
97
|
+
const index = this.store.get(indexName)
|
|
98
|
+
|
|
99
|
+
if (!index) return { raw: [], keys: [], total: 0 }
|
|
100
|
+
|
|
101
|
+
let records = Array.from(index.entries()).map(([key, data]) => ({ key, data }))
|
|
102
|
+
|
|
103
|
+
// Apply filters (same as search)
|
|
104
|
+
if (builder.query) {
|
|
105
|
+
const q = builder.query.toLowerCase()
|
|
106
|
+
records = records.filter(({ data }) =>
|
|
107
|
+
Object.values(data).some((v) =>
|
|
108
|
+
v !== null && v !== undefined && String(v).toLowerCase().includes(q),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
for (const { field, value } of builder.wheres) {
|
|
113
|
+
records = records.filter(({ data }) => data[field] === value)
|
|
114
|
+
}
|
|
115
|
+
for (const { field, values } of builder.whereIns) {
|
|
116
|
+
records = records.filter(({ data }) => values.includes(data[field]))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const total = records.length
|
|
120
|
+
|
|
121
|
+
for (const { column, direction } of [...builder.orders].reverse()) {
|
|
122
|
+
records.sort((a, b) => {
|
|
123
|
+
const av = a.data[column]
|
|
124
|
+
const bv = b.data[column]
|
|
125
|
+
if (av < bv) return direction === 'asc' ? -1 : 1
|
|
126
|
+
if (av > bv) return direction === 'asc' ? 1 : -1
|
|
127
|
+
return 0
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const start = (page - 1) * perPage
|
|
132
|
+
records = records.slice(start, start + perPage)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
raw: records.map((r) => r.data),
|
|
136
|
+
keys: records.map((r) => r.key),
|
|
137
|
+
total,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async flush(indexName: string): Promise<void> {
|
|
142
|
+
this.store.delete(indexName)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async createIndex(name: string): Promise<void> {
|
|
146
|
+
if (!this.store.has(name)) this.store.set(name, new Map())
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async deleteIndex(name: string): Promise<void> {
|
|
150
|
+
this.store.delete(name)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Get the raw store for testing/debugging. */
|
|
154
|
+
getStore(): Map<string, Map<string | number, Record<string, any>>> {
|
|
155
|
+
return this.store
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private resolveIndexName(model: any): string {
|
|
159
|
+
const ModelClass = model.constructor
|
|
160
|
+
return typeof ModelClass.searchableAs === 'function'
|
|
161
|
+
? ModelClass.searchableAs()
|
|
162
|
+
: ModelClass.table ?? ModelClass.name.toLowerCase() + 's'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private resolveModelIndexName(ModelClass: any): string {
|
|
166
|
+
return typeof ModelClass.searchableAs === 'function'
|
|
167
|
+
? ModelClass.searchableAs()
|
|
168
|
+
: ModelClass.table ?? ModelClass.name.toLowerCase() + 's'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private resolveKey(model: any): string | number {
|
|
172
|
+
return typeof model.searchableKey === 'function'
|
|
173
|
+
? model.searchableKey()
|
|
174
|
+
: model.getAttribute?.('id') ?? model.id
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
|
|
2
|
+
import type { SearchBuilder } from '../SearchBuilder.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Database-backed search engine using SQL full-text capabilities.
|
|
6
|
+
* - SQLite: LIKE '%query%' across searchable columns
|
|
7
|
+
* - PostgreSQL: to_tsvector() @@ plainto_tsquery()
|
|
8
|
+
* - MySQL: MATCH ... AGAINST (if FULLTEXT index exists, falls back to LIKE)
|
|
9
|
+
*/
|
|
10
|
+
export class DatabaseEngine implements SearchEngine {
|
|
11
|
+
constructor(private readonly connectionName?: string) {}
|
|
12
|
+
|
|
13
|
+
async update(_models: any[]): Promise<void> {
|
|
14
|
+
// No-op: data lives in the database already.
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async delete(_models: any[]): Promise<void> {
|
|
18
|
+
// No-op: data lives in the database already.
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async search(builder: SearchBuilder): Promise<SearchResult> {
|
|
22
|
+
const ModelClass = builder.model
|
|
23
|
+
let query = ModelClass.query()
|
|
24
|
+
|
|
25
|
+
if (this.connectionName) {
|
|
26
|
+
query = query.connection(this.connectionName)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Apply full-text search
|
|
30
|
+
if (builder.query) {
|
|
31
|
+
query = this.applySearchQuery(query, builder)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Apply where clauses
|
|
35
|
+
for (const { field, value } of builder.wheres) {
|
|
36
|
+
query = query.where(field, value)
|
|
37
|
+
}
|
|
38
|
+
for (const { field, values } of builder.whereIns) {
|
|
39
|
+
query = query.whereIn(field, values)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Apply ordering
|
|
43
|
+
for (const { column, direction } of builder.orders) {
|
|
44
|
+
query = query.orderBy(column, direction)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Apply offset
|
|
48
|
+
const offset = builder.getOffset()
|
|
49
|
+
if (offset !== null && offset > 0) {
|
|
50
|
+
query = query.offset(offset)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Apply limit
|
|
54
|
+
const limit = builder.getLimit()
|
|
55
|
+
if (limit !== null) {
|
|
56
|
+
query = query.limit(limit)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const models = await query.get()
|
|
60
|
+
const pk = ModelClass.primaryKey ?? 'id'
|
|
61
|
+
const keys = models.map((m: any) => m.getAttribute(pk))
|
|
62
|
+
|
|
63
|
+
return { raw: models, keys, total: keys.length }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
|
|
67
|
+
const ModelClass = builder.model
|
|
68
|
+
let query = ModelClass.query()
|
|
69
|
+
|
|
70
|
+
if (this.connectionName) {
|
|
71
|
+
query = query.connection(this.connectionName)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (builder.query) {
|
|
75
|
+
query = this.applySearchQuery(query, builder)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const { field, value } of builder.wheres) {
|
|
79
|
+
query = query.where(field, value)
|
|
80
|
+
}
|
|
81
|
+
for (const { field, values } of builder.whereIns) {
|
|
82
|
+
query = query.whereIn(field, values)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const { column, direction } of builder.orders) {
|
|
86
|
+
query = query.orderBy(column, direction)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Get total count
|
|
90
|
+
const countQuery = ModelClass.query()
|
|
91
|
+
if (builder.query) this.applySearchQuery(countQuery, builder)
|
|
92
|
+
for (const { field, value } of builder.wheres) countQuery.where(field, value)
|
|
93
|
+
for (const { field, values } of builder.whereIns) countQuery.whereIn(field, values)
|
|
94
|
+
const total = await countQuery.count()
|
|
95
|
+
|
|
96
|
+
// Paginate
|
|
97
|
+
const models = await query.limit(perPage).offset((page - 1) * perPage).get()
|
|
98
|
+
const pk = ModelClass.primaryKey ?? 'id'
|
|
99
|
+
const keys = models.map((m: any) => m.getAttribute(pk))
|
|
100
|
+
|
|
101
|
+
return { raw: models, keys, total }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async flush(_indexName: string): Promise<void> {
|
|
105
|
+
// No-op: we don't manage a separate index.
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async createIndex(_name: string): Promise<void> {
|
|
109
|
+
// No-op: database tables are the index.
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async deleteIndex(_name: string): Promise<void> {
|
|
113
|
+
// No-op
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private applySearchQuery(query: any, builder: SearchBuilder): any {
|
|
117
|
+
const searchTerm = builder.query
|
|
118
|
+
const ModelClass = builder.model
|
|
119
|
+
|
|
120
|
+
// Get searchable columns from the model, or fall back to fillable
|
|
121
|
+
const columns: string[] = typeof ModelClass.searchableColumns === 'function'
|
|
122
|
+
? ModelClass.searchableColumns()
|
|
123
|
+
: ModelClass.fillable ?? []
|
|
124
|
+
|
|
125
|
+
if (columns.length === 0) return query
|
|
126
|
+
|
|
127
|
+
// Use LIKE for broad compatibility (works on SQLite, Postgres, MySQL)
|
|
128
|
+
return query.where((q: any) => {
|
|
129
|
+
for (let i = 0; i < columns.length; i++) {
|
|
130
|
+
const col = columns[i]!
|
|
131
|
+
if (i === 0) {
|
|
132
|
+
q.where(col, 'LIKE', `%${searchTerm}%`)
|
|
133
|
+
} else {
|
|
134
|
+
q.orWhere(col, 'LIKE', `%${searchTerm}%`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
* Elasticsearch engine driver using REST API.
|
|
7
|
+
*/
|
|
8
|
+
export class ElasticsearchEngine implements SearchEngine {
|
|
9
|
+
private readonly host: string
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
hosts: string[],
|
|
13
|
+
private readonly apiKey?: string,
|
|
14
|
+
private readonly username?: string,
|
|
15
|
+
private readonly password?: string,
|
|
16
|
+
) {
|
|
17
|
+
this.host = hosts[0] ?? 'http://127.0.0.1:9200'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async update(models: any[]): Promise<void> {
|
|
21
|
+
if (models.length === 0) return
|
|
22
|
+
const indexName = this.resolveIndexName(models[0])
|
|
23
|
+
|
|
24
|
+
// Bulk API
|
|
25
|
+
const lines: string[] = []
|
|
26
|
+
for (const model of models) {
|
|
27
|
+
const id = String(this.resolveKey(model))
|
|
28
|
+
lines.push(JSON.stringify({ index: { _index: indexName, _id: id } }))
|
|
29
|
+
lines.push(JSON.stringify(this.resolveData(model)))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await this.request('POST', '/_bulk', lines.join('\n') + '\n', 'application/x-ndjson')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async delete(models: any[]): Promise<void> {
|
|
36
|
+
if (models.length === 0) return
|
|
37
|
+
const indexName = this.resolveIndexName(models[0])
|
|
38
|
+
|
|
39
|
+
const lines: string[] = []
|
|
40
|
+
for (const model of models) {
|
|
41
|
+
const id = String(this.resolveKey(model))
|
|
42
|
+
lines.push(JSON.stringify({ delete: { _index: indexName, _id: id } }))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await this.request('POST', '/_bulk', lines.join('\n') + '\n', 'application/x-ndjson')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async search(builder: SearchBuilder): Promise<SearchResult> {
|
|
49
|
+
const indexName = this.resolveModelIndexName(builder.model)
|
|
50
|
+
const body = this.buildSearchBody(builder)
|
|
51
|
+
|
|
52
|
+
const res = await this.request('POST', `/${indexName}/_search`, body)
|
|
53
|
+
const hits = res.hits?.hits ?? []
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
raw: res,
|
|
57
|
+
keys: hits.map((h: any) => h._id),
|
|
58
|
+
total: res.hits?.total?.value ?? res.hits?.total ?? 0,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
|
|
63
|
+
const indexName = this.resolveModelIndexName(builder.model)
|
|
64
|
+
const body = this.buildSearchBody(builder)
|
|
65
|
+
body.size = perPage
|
|
66
|
+
body.from = (page - 1) * perPage
|
|
67
|
+
|
|
68
|
+
const res = await this.request('POST', `/${indexName}/_search`, body)
|
|
69
|
+
const hits = res.hits?.hits ?? []
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
raw: res,
|
|
73
|
+
keys: hits.map((h: any) => h._id),
|
|
74
|
+
total: res.hits?.total?.value ?? res.hits?.total ?? 0,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async flush(indexName: string): Promise<void> {
|
|
79
|
+
try {
|
|
80
|
+
await this.request('POST', `/${indexName}/_delete_by_query`, {
|
|
81
|
+
query: { match_all: {} },
|
|
82
|
+
})
|
|
83
|
+
} catch {
|
|
84
|
+
// Index may not exist
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async createIndex(name: string, options?: Record<string, any>): Promise<void> {
|
|
89
|
+
const body = options ? { settings: options.settings, mappings: options.mappings } : {}
|
|
90
|
+
await this.request('PUT', `/${name}`, body)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async deleteIndex(name: string): Promise<void> {
|
|
94
|
+
try {
|
|
95
|
+
await this.request('DELETE', `/${name}`)
|
|
96
|
+
} catch {
|
|
97
|
+
// Index may not exist
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private buildSearchBody(builder: SearchBuilder): Record<string, any> {
|
|
102
|
+
const body: Record<string, any> = {}
|
|
103
|
+
const must: any[] = []
|
|
104
|
+
const filter: any[] = []
|
|
105
|
+
|
|
106
|
+
// Full-text query
|
|
107
|
+
if (builder.query) {
|
|
108
|
+
must.push({ multi_match: { query: builder.query, type: 'best_fields', fuzziness: 'AUTO' } })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Where clauses as term filters
|
|
112
|
+
for (const { field, value } of builder.wheres) {
|
|
113
|
+
filter.push({ term: { [field]: value } })
|
|
114
|
+
}
|
|
115
|
+
for (const { field, values } of builder.whereIns) {
|
|
116
|
+
filter.push({ terms: { [field]: values } })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (must.length > 0 || filter.length > 0) {
|
|
120
|
+
body.query = { bool: {} }
|
|
121
|
+
if (must.length > 0) body.query.bool.must = must
|
|
122
|
+
if (filter.length > 0) body.query.bool.filter = filter
|
|
123
|
+
} else {
|
|
124
|
+
body.query = { match_all: {} }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Sorting
|
|
128
|
+
if (builder.orders.length > 0) {
|
|
129
|
+
body.sort = builder.orders.map((o) => ({ [o.column]: { order: o.direction } }))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Limit / offset
|
|
133
|
+
const limit = builder.getLimit()
|
|
134
|
+
if (limit !== null) body.size = limit
|
|
135
|
+
const offset = builder.getOffset()
|
|
136
|
+
if (offset !== null) body.from = offset
|
|
137
|
+
|
|
138
|
+
return body
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async request(method: string, path: string, body?: any, contentType?: string): Promise<any> {
|
|
142
|
+
const headers: Record<string, string> = {}
|
|
143
|
+
|
|
144
|
+
if (contentType) {
|
|
145
|
+
headers['Content-Type'] = contentType
|
|
146
|
+
} else if (body) {
|
|
147
|
+
headers['Content-Type'] = 'application/json'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.apiKey) {
|
|
151
|
+
headers['Authorization'] = `ApiKey ${this.apiKey}`
|
|
152
|
+
} else if (this.username && this.password) {
|
|
153
|
+
headers['Authorization'] = `Basic ${btoa(`${this.username}:${this.password}`)}`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const res = await fetch(`${this.host}${path}`, {
|
|
157
|
+
method,
|
|
158
|
+
headers,
|
|
159
|
+
body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
const text = await res.text()
|
|
164
|
+
throw new SearchError(`Elasticsearch API error: ${res.status} ${text}`, { status: res.status })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ct = res.headers.get('content-type')
|
|
168
|
+
if (ct?.includes('application/json')) return res.json()
|
|
169
|
+
return {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private resolveIndexName(model: any): string {
|
|
173
|
+
const MC = model.constructor
|
|
174
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private resolveModelIndexName(MC: any): string {
|
|
178
|
+
return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private resolveKey(model: any): string | number {
|
|
182
|
+
return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private resolveData(model: any): Record<string, any> {
|
|
186
|
+
return typeof model.toSearchableArray === 'function' ? model.toSearchableArray() : { ...model.attributes }
|
|
187
|
+
}
|
|
188
|
+
}
|