sonamu 0.7.11 → 0.7.12
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/dist/api/config.d.ts +10 -3
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +2 -1
- package/dist/api/sonamu.d.ts +4 -0
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +36 -2
- package/dist/bin/cli.js +121 -117
- package/dist/database/base-model.d.ts +10 -50
- package/dist/database/base-model.d.ts.map +1 -1
- package/dist/database/base-model.js +19 -84
- package/dist/database/base-model.types.d.ts +4 -4
- package/dist/database/base-model.types.d.ts.map +1 -1
- package/dist/database/base-model.types.js +1 -1
- package/dist/database/db.d.ts +1 -0
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +24 -13
- package/dist/database/puri-subset.test-d.js +1 -1
- package/dist/database/puri-subset.types.d.ts +1 -0
- package/dist/database/puri-subset.types.d.ts.map +1 -1
- package/dist/database/puri-subset.types.js +2 -2
- package/dist/database/puri.d.ts +82 -3
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +180 -14
- package/dist/database/puri.types.d.ts +33 -6
- package/dist/database/puri.types.d.ts.map +1 -1
- package/dist/database/puri.types.js +1 -1
- package/dist/database/puri.types.test-d.js +1 -1
- package/dist/entity/entity-manager.d.ts +5 -4
- package/dist/entity/entity-manager.d.ts.map +1 -1
- package/dist/entity/entity-manager.js +8 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/migration/code-generation.d.ts.map +1 -1
- package/dist/migration/code-generation.js +33 -2
- package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
- package/dist/migration/postgresql-schema-reader.js +53 -22
- package/dist/naite/messaging-types.d.ts.map +1 -1
- package/dist/naite/messaging-types.js +1 -1
- package/dist/naite/naite.js +2 -2
- package/dist/stream/sse.d.ts +2 -6
- package/dist/stream/sse.d.ts.map +1 -1
- package/dist/stream/sse.js +9 -3
- package/dist/syncer/api-parser.js +5 -1
- package/dist/syncer/file-patterns.d.ts +1 -1
- package/dist/syncer/file-patterns.d.ts.map +1 -1
- package/dist/syncer/file-patterns.js +6 -5
- package/dist/syncer/module-loader.d.ts +5 -0
- package/dist/syncer/module-loader.d.ts.map +1 -1
- package/dist/syncer/module-loader.js +17 -1
- package/dist/syncer/syncer.d.ts +3 -0
- package/dist/syncer/syncer.d.ts.map +1 -1
- package/dist/syncer/syncer.js +12 -2
- package/dist/tasks/decorator.d.ts +26 -0
- package/dist/tasks/decorator.d.ts.map +1 -0
- package/dist/tasks/decorator.js +28 -0
- package/dist/tasks/step-wrapper.d.ts +18 -0
- package/dist/tasks/step-wrapper.d.ts.map +1 -0
- package/dist/tasks/step-wrapper.js +38 -0
- package/dist/tasks/workflow-manager.d.ts +40 -0
- package/dist/tasks/workflow-manager.d.ts.map +1 -0
- package/dist/tasks/workflow-manager.js +193 -0
- package/dist/template/implementations/generated.template.d.ts.map +1 -1
- package/dist/template/implementations/generated.template.js +7 -3
- package/dist/types/types.d.ts +27 -11
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +15 -2
- package/dist/utils/formatter.d.ts.map +1 -1
- package/dist/utils/formatter.js +10 -2
- package/dist/utils/model.d.ts +9 -2
- package/dist/utils/model.d.ts.map +1 -1
- package/dist/utils/model.js +16 -1
- package/dist/utils/type-utils.d.ts.map +1 -1
- package/dist/utils/type-utils.js +3 -1
- package/dist/vector/embedding.d.ts +2 -5
- package/dist/vector/embedding.d.ts.map +1 -1
- package/dist/vector/embedding.js +3 -7
- package/dist/vector/types.d.ts.map +1 -1
- package/dist/vector/types.js +1 -1
- package/package.json +5 -3
- package/src/api/config.ts +15 -8
- package/src/api/sonamu.ts +43 -2
- package/src/bin/cli.ts +58 -54
- package/src/database/base-model.ts +21 -128
- package/src/database/base-model.types.ts +3 -4
- package/src/database/db.ts +28 -18
- package/src/database/puri-subset.test-d.ts +1 -0
- package/src/database/puri-subset.types.ts +2 -0
- package/src/database/puri.ts +238 -27
- package/src/database/puri.types.test-d.ts +1 -1
- package/src/database/puri.types.ts +49 -6
- package/src/entity/entity-manager.ts +9 -0
- package/src/index.ts +1 -1
- package/src/migration/code-generation.ts +40 -1
- package/src/migration/postgresql-schema-reader.ts +53 -22
- package/src/naite/messaging-types.ts +43 -44
- package/src/naite/naite.ts +1 -1
- package/src/shared/app.shared.ts.txt +13 -0
- package/src/shared/web.shared.ts.txt +13 -0
- package/src/stream/sse.ts +15 -3
- package/src/syncer/api-parser.ts +4 -0
- package/src/syncer/file-patterns.ts +11 -9
- package/src/syncer/module-loader.ts +35 -0
- package/src/syncer/syncer.ts +14 -0
- package/src/tasks/decorator.ts +71 -0
- package/src/tasks/step-wrapper.ts +84 -0
- package/src/tasks/workflow-manager.ts +330 -0
- package/src/template/implementations/generated.template.ts +19 -6
- package/src/types/types.ts +20 -4
- package/src/utils/formatter.ts +8 -1
- package/src/utils/model.ts +26 -2
- package/src/utils/type-utils.ts +2 -0
- package/src/vector/embedding.ts +2 -8
- package/src/vector/types.ts +1 -2
- package/dist/vector/vector-search.d.ts +0 -47
- package/dist/vector/vector-search.d.ts.map +0 -1
- package/dist/vector/vector-search.js +0 -176
- package/src/vector/vector-search.ts +0 -261
|
@@ -2,24 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Knex } from "knex";
|
|
4
4
|
import { group, isObject, omit, set } from "radashi";
|
|
5
|
+
import type { ListResult } from "..";
|
|
5
6
|
import { Sonamu } from "../api";
|
|
6
|
-
import {
|
|
7
|
-
import type { DatabaseSchemaExtend } from "../types/types";
|
|
7
|
+
import type { DatabaseSchemaExtend, SonamuQueryMode } from "../types/types";
|
|
8
8
|
import { getJoinTables, getTableNamesFromWhere } from "../utils/sql-parser";
|
|
9
9
|
import { chunk } from "../utils/utils";
|
|
10
|
-
import type {
|
|
11
|
-
EmbeddingItem,
|
|
12
|
-
EmbeddingProvider,
|
|
13
|
-
HybridSearchOptions,
|
|
14
|
-
HybridSearchResult,
|
|
15
|
-
ProgressCallback,
|
|
16
|
-
VectorSearchOptions,
|
|
17
|
-
VectorSearchResult,
|
|
18
|
-
} from "../vector/types";
|
|
19
|
-
import { VectorSearch } from "../vector/vector-search";
|
|
20
10
|
import type {
|
|
21
11
|
EnhancerMap,
|
|
22
|
-
ExecuteSubsetQueryResult,
|
|
23
12
|
ResolveSubsetIntersection,
|
|
24
13
|
UnionExtractedTTables,
|
|
25
14
|
} from "./base-model.types";
|
|
@@ -69,118 +58,7 @@ export class BaseModelClass<
|
|
|
69
58
|
return new PuriWrapper(db, new UpsertBuilder());
|
|
70
59
|
}
|
|
71
60
|
|
|
72
|
-
// VectorSearch 인스턴스 캐시
|
|
73
|
-
private _vectorSearch: VectorSearch<any> | null = null;
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* 벡터 검색 인스턴스 반환
|
|
77
|
-
* - 기본 provider: voyage
|
|
78
|
-
* - 기본 dimensions: 1024 (DEFAULT_VECTOR_CONFIG 사용)
|
|
79
|
-
*/
|
|
80
|
-
getVector<T = Record<string, unknown>>(): VectorSearch<T> {
|
|
81
|
-
if (this._vectorSearch) {
|
|
82
|
-
return this._vectorSearch as VectorSearch<T>;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const entity = EntityManager.get(this.modelName);
|
|
86
|
-
|
|
87
|
-
this._vectorSearch = new VectorSearch<T>(this.getDB("w"), entity.table);
|
|
88
|
-
|
|
89
|
-
return this._vectorSearch as VectorSearch<T>;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* 벡터 검색 (코사인 유사도)
|
|
94
|
-
* @param query - 검색어
|
|
95
|
-
* @param options - 검색 옵션
|
|
96
|
-
*/
|
|
97
|
-
async vectorSearch<T = Record<string, unknown>>(
|
|
98
|
-
query: string,
|
|
99
|
-
options: VectorSearchOptions & { provider?: EmbeddingProvider } = {},
|
|
100
|
-
): Promise<VectorSearchResult<T>[]> {
|
|
101
|
-
const entity = EntityManager.get(this.modelName);
|
|
102
|
-
const vectorProp = entity.getVectorColumn();
|
|
103
|
-
if (!vectorProp) {
|
|
104
|
-
throw new Error(`${this.modelName} Entity에 vector 컬럼이 정의되지 않았습니다.`);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const vs = new VectorSearch<T>(this.getDB("w"), entity.table);
|
|
108
|
-
return vs.search(query, options.provider ?? "voyage", {
|
|
109
|
-
...options,
|
|
110
|
-
embeddingColumn: options.embeddingColumn ?? vectorProp.name,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* 하이브리드 검색 (Vector + FTS)
|
|
116
|
-
* @param query - 검색어
|
|
117
|
-
* @param options - 검색 옵션
|
|
118
|
-
*/
|
|
119
|
-
async hybridSearch<T = Record<string, unknown>>(
|
|
120
|
-
query: string,
|
|
121
|
-
options: HybridSearchOptions & { provider?: EmbeddingProvider } = {},
|
|
122
|
-
): Promise<HybridSearchResult<T>[]> {
|
|
123
|
-
const entity = EntityManager.get(this.modelName);
|
|
124
|
-
const vectorProp = entity.getVectorColumn();
|
|
125
|
-
if (!vectorProp) {
|
|
126
|
-
throw new Error(`${this.modelName} Entity에 vector 컬럼이 정의되지 않았습니다.`);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const vs = new VectorSearch<T>(this.getDB("w"), entity.table);
|
|
130
|
-
return vs.hybridSearch(query, options.provider ?? "voyage", {
|
|
131
|
-
...options,
|
|
132
|
-
embeddingColumn: options.embeddingColumn ?? vectorProp.name,
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* 단일 레코드에 임베딩 저장
|
|
138
|
-
* @param id - 레코드 ID
|
|
139
|
-
* @param text - 임베딩할 텍스트
|
|
140
|
-
* @param options - provider, embeddingColumn 옵션
|
|
141
|
-
*/
|
|
142
|
-
async saveEmbedding(
|
|
143
|
-
id: number,
|
|
144
|
-
text: string,
|
|
145
|
-
options: { provider?: EmbeddingProvider; embeddingColumn?: string } = {},
|
|
146
|
-
): Promise<void> {
|
|
147
|
-
const entity = EntityManager.get(this.modelName);
|
|
148
|
-
const vectorProp = entity.getVectorColumn(options.embeddingColumn);
|
|
149
|
-
if (!vectorProp) {
|
|
150
|
-
throw new Error(`${this.modelName} Entity에 vector 컬럼이 정의되지 않았습니다.`);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const { provider = "voyage" } = options;
|
|
154
|
-
const vs = this.getVector();
|
|
155
|
-
return vs.saveEmbedding(id, text, provider, vectorProp.name);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* 여러 레코드에 임베딩 일괄 저장
|
|
160
|
-
* @param items - { id, text } 배열
|
|
161
|
-
* @param options - provider, embeddingColumn, onProgress 옵션
|
|
162
|
-
*/
|
|
163
|
-
async saveEmbeddingsBatch(
|
|
164
|
-
items: EmbeddingItem[],
|
|
165
|
-
options: {
|
|
166
|
-
provider?: EmbeddingProvider;
|
|
167
|
-
embeddingColumn?: string;
|
|
168
|
-
onProgress?: ProgressCallback;
|
|
169
|
-
} = {},
|
|
170
|
-
): Promise<void> {
|
|
171
|
-
const entity = EntityManager.get(this.modelName);
|
|
172
|
-
const vectorProp = entity.getVectorColumn(options.embeddingColumn);
|
|
173
|
-
if (!vectorProp) {
|
|
174
|
-
throw new Error(`${this.modelName} Entity에 vector 컬럼이 정의되지 않았습니다.`);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const { provider = "voyage", onProgress } = options;
|
|
178
|
-
const vs = this.getVector();
|
|
179
|
-
return vs.saveEmbeddingsBatch(items, provider, vectorProp.name, onProgress);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
61
|
async destroy() {
|
|
183
|
-
this._vectorSearch = null;
|
|
184
62
|
return DB.destroy();
|
|
185
63
|
}
|
|
186
64
|
|
|
@@ -275,6 +153,11 @@ export class BaseModelClass<
|
|
|
275
153
|
async executeSubsetQuery<
|
|
276
154
|
T extends TSubsetKey,
|
|
277
155
|
TComputedResults extends InferAllSubsets<TSubsetQueries, TLoaderQueries>,
|
|
156
|
+
LP extends {
|
|
157
|
+
num?: number;
|
|
158
|
+
page?: number;
|
|
159
|
+
queryMode?: SonamuQueryMode;
|
|
160
|
+
},
|
|
278
161
|
>(
|
|
279
162
|
params: {
|
|
280
163
|
subset: T;
|
|
@@ -282,12 +165,12 @@ export class BaseModelClass<
|
|
|
282
165
|
params: {
|
|
283
166
|
num: number;
|
|
284
167
|
page: number;
|
|
285
|
-
queryMode?:
|
|
168
|
+
queryMode?: SonamuQueryMode;
|
|
286
169
|
};
|
|
287
170
|
debug?: boolean;
|
|
288
171
|
optimizeCountQuery?: boolean;
|
|
289
172
|
} & EnhancerParam<TSubsetKey, TComputedResults, TSubsetMapping>,
|
|
290
|
-
): Promise<
|
|
173
|
+
): Promise<ListResult<LP, TSubsetMapping[T]>> {
|
|
291
174
|
const { subset, qb, params: queryParams, debug = false, optimizeCountQuery = false } = params;
|
|
292
175
|
|
|
293
176
|
if (!this.loaderQueries) {
|
|
@@ -296,9 +179,13 @@ export class BaseModelClass<
|
|
|
296
179
|
|
|
297
180
|
const { num, page } = queryParams;
|
|
298
181
|
|
|
299
|
-
// COUNT 쿼리 실행
|
|
182
|
+
// COUNT 쿼리 실행 (queryMode: list일 때는 0 리턴)
|
|
300
183
|
const total = await this.executeCountQuery(qb, queryParams, debug, optimizeCountQuery);
|
|
301
184
|
|
|
185
|
+
if (queryParams?.queryMode === "count") {
|
|
186
|
+
return { total } as ListResult<LP, TSubsetMapping[T]>;
|
|
187
|
+
}
|
|
188
|
+
|
|
302
189
|
// LIST 쿼리 실행
|
|
303
190
|
const computedRows = await this.executeListQuery(subset, qb, queryParams, num, page, debug);
|
|
304
191
|
|
|
@@ -308,7 +195,13 @@ export class BaseModelClass<
|
|
|
308
195
|
computedRows.map((row) => enhancer?.(row) ?? row),
|
|
309
196
|
)) as TSubsetMapping[T][];
|
|
310
197
|
|
|
311
|
-
|
|
198
|
+
if (queryParams.queryMode === "list") {
|
|
199
|
+
// 리스트만 리턴
|
|
200
|
+
return { rows } as ListResult<LP, TSubsetMapping[T]>;
|
|
201
|
+
} else {
|
|
202
|
+
// 둘다 리턴
|
|
203
|
+
return { rows, total } as ListResult<LP, TSubsetMapping[T]>;
|
|
204
|
+
}
|
|
312
205
|
}
|
|
313
206
|
|
|
314
207
|
/**
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Enhancer, SubsetQuery 교집합 등 Model 계층에서 필요한 타입 정의.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { ListResult } from "..";
|
|
10
11
|
import type { DatabaseSchemaExtend } from "../types/types";
|
|
11
12
|
import type { Puri } from "./puri";
|
|
12
13
|
import type { PuriSubsetFn } from "./puri-subset.types";
|
|
@@ -151,7 +152,5 @@ export type ExecuteSubsetQueryParams<
|
|
|
151
152
|
export type ExecuteSubsetQueryResult<
|
|
152
153
|
TSubsetMapping extends Record<string, any>,
|
|
153
154
|
T extends string,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
total: number;
|
|
157
|
-
};
|
|
155
|
+
LP extends { queryMode?: "list" | "count" | "both" },
|
|
156
|
+
> = ListResult<LP, TSubsetMapping[T]>;
|
package/src/database/db.ts
CHANGED
|
@@ -56,30 +56,40 @@ export class DBClass {
|
|
|
56
56
|
const instanceName = which === "w" ? "wdb" : "rdb";
|
|
57
57
|
|
|
58
58
|
if (!this[instanceName]) {
|
|
59
|
-
|
|
60
|
-
switch (process.env.NODE_ENV ?? "development") {
|
|
61
|
-
case "development":
|
|
62
|
-
case "staging":
|
|
63
|
-
config =
|
|
64
|
-
which === "w"
|
|
65
|
-
? dbConfig.development_master
|
|
66
|
-
: (dbConfig.development_slave ?? dbConfig.development_master);
|
|
67
|
-
break;
|
|
68
|
-
case "production":
|
|
69
|
-
config =
|
|
70
|
-
which === "w"
|
|
71
|
-
? dbConfig.production_master
|
|
72
|
-
: (dbConfig.production_slave ?? dbConfig.production_master);
|
|
73
|
-
break;
|
|
74
|
-
default:
|
|
75
|
-
throw new Error(`현재 ENV ${process.env.NODE_ENV}에는 설정 가능한 DB설정이 없습니다.`);
|
|
76
|
-
}
|
|
59
|
+
const config = this.getDBConfig(which);
|
|
77
60
|
this[instanceName] = knex(config);
|
|
78
61
|
}
|
|
79
62
|
|
|
80
63
|
return this[instanceName];
|
|
81
64
|
}
|
|
82
65
|
|
|
66
|
+
getDBConfig(which: DBPreset): Knex.Config {
|
|
67
|
+
const dbConfig = Sonamu.dbConfig;
|
|
68
|
+
if (process.env.NODE_ENV === "test") {
|
|
69
|
+
return {
|
|
70
|
+
...dbConfig.test,
|
|
71
|
+
// 단일 풀
|
|
72
|
+
pool: {
|
|
73
|
+
min: 1,
|
|
74
|
+
max: 1,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
switch (process.env.NODE_ENV ?? "development") {
|
|
79
|
+
case "development":
|
|
80
|
+
case "staging":
|
|
81
|
+
return which === "w"
|
|
82
|
+
? dbConfig.development_master
|
|
83
|
+
: (dbConfig.development_slave ?? dbConfig.development_master);
|
|
84
|
+
case "production":
|
|
85
|
+
return which === "w"
|
|
86
|
+
? dbConfig.production_master
|
|
87
|
+
: (dbConfig.production_slave ?? dbConfig.production_master);
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`현재 ENV ${process.env.NODE_ENV}에는 설정 가능한 DB설정이 없습니다.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
async destroy(): Promise<void> {
|
|
84
94
|
if (this.wdb !== undefined) {
|
|
85
95
|
await this.wdb.destroy();
|
|
@@ -4,6 +4,7 @@ import type { Puri } from "./puri";
|
|
|
4
4
|
import type { Hydrate, InferAllSubsets, LoadersResult } from "./puri-subset.types";
|
|
5
5
|
import type { PuriWrapper } from "./puri-wrapper";
|
|
6
6
|
|
|
7
|
+
/** biome-ignore lint/suspicious/noExplicitAny: Puri Subset 타입 시스템에서 any를 허용함 */
|
|
7
8
|
type MockPuri<T> = Puri<DatabaseSchemaExtend, any, T>;
|
|
8
9
|
|
|
9
10
|
describe("Hydrate", () => {
|
package/src/database/puri.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import assert from "assert";
|
|
5
5
|
import chalk from "chalk";
|
|
6
|
+
import inflection from "inflection";
|
|
6
7
|
import type { Knex } from "knex";
|
|
7
8
|
import { Naite } from "../naite/naite";
|
|
8
9
|
import type {
|
|
@@ -12,7 +13,6 @@ import type {
|
|
|
12
13
|
Expand,
|
|
13
14
|
ExtractColumnType,
|
|
14
15
|
FulltextColumns,
|
|
15
|
-
HighlightOptions,
|
|
16
16
|
InsertData,
|
|
17
17
|
InsertResult,
|
|
18
18
|
LeftJoinedMarker,
|
|
@@ -25,8 +25,11 @@ import type {
|
|
|
25
25
|
SelectObject,
|
|
26
26
|
SingleTableValue,
|
|
27
27
|
SqlExpression,
|
|
28
|
+
TsHighlightOptions,
|
|
28
29
|
TsQueryConfig,
|
|
29
30
|
TsQueryOptions,
|
|
31
|
+
TsRankOptions,
|
|
32
|
+
VectorColumns,
|
|
30
33
|
WhereCondition,
|
|
31
34
|
WhereOperator,
|
|
32
35
|
} from "./puri.types";
|
|
@@ -127,6 +130,9 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
127
130
|
static rawString(sql: string): SqlExpression<"string"> {
|
|
128
131
|
return { _type: "sql_expression", _return: "string", _sql: sql };
|
|
129
132
|
}
|
|
133
|
+
static rawStringArray(sql: string): SqlExpression<"string[]"> {
|
|
134
|
+
return { _type: "sql_expression", _return: "string[]", _sql: sql };
|
|
135
|
+
}
|
|
130
136
|
static rawNumber(sql: string): SqlExpression<"number"> {
|
|
131
137
|
return { _type: "sql_expression", _return: "number", _sql: sql };
|
|
132
138
|
}
|
|
@@ -150,41 +156,93 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
150
156
|
* }),
|
|
151
157
|
* })
|
|
152
158
|
*/
|
|
153
|
-
static
|
|
159
|
+
static tsHighlight(
|
|
154
160
|
column: string,
|
|
155
161
|
query: string,
|
|
156
|
-
|
|
162
|
+
_options?: TsHighlightOptions,
|
|
157
163
|
): SqlExpression<"string"> {
|
|
164
|
+
const { parser = "websearch_to_tsquery", config = "simple", ...options } = _options ?? {};
|
|
165
|
+
|
|
166
|
+
const hlOptionParts = Object.entries(options).map(([key, value]) => {
|
|
167
|
+
return `${inflection.camelize(key)}=${value}`;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const hlOptions = hlOptionParts.length > 0 ? `, '${hlOptionParts.join(", ")}'` : "";
|
|
171
|
+
|
|
172
|
+
// TODO: rawBinding 메서드 만들어서 XSS 방지
|
|
173
|
+
return Puri.rawString(
|
|
174
|
+
`ts_headline('${config}', ${column}, ${parser}('${config}', '${query}')${hlOptions})`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ts_rank
|
|
179
|
+
static tsRank(column: string, query: string, options?: TsRankOptions): SqlExpression<"number"> {
|
|
180
|
+
return Puri._tsRank("ts_rank", column, query, options);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ts_rank_cd
|
|
184
|
+
static tsRankCd(column: string, query: string, options?: TsRankOptions): SqlExpression<"number"> {
|
|
185
|
+
return Puri._tsRank("ts_rank_cd", column, query, options);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
static _tsRank(
|
|
189
|
+
type: "ts_rank" | "ts_rank_cd",
|
|
190
|
+
column: string,
|
|
191
|
+
query: string,
|
|
192
|
+
options?: TsRankOptions,
|
|
193
|
+
): SqlExpression<"number"> {
|
|
158
194
|
const {
|
|
159
195
|
parser = "websearch_to_tsquery",
|
|
160
196
|
config = "simple",
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
shortWord,
|
|
164
|
-
highlightAll,
|
|
165
|
-
maxFragments,
|
|
166
|
-
startSel,
|
|
167
|
-
stopSel,
|
|
168
|
-
fragmentDelimiter,
|
|
197
|
+
normalization,
|
|
198
|
+
weights,
|
|
169
199
|
} = options ?? {};
|
|
170
200
|
|
|
171
|
-
const
|
|
201
|
+
const weightClause = weights ? `ARRAY[${weights.join(", ")}], ` : "";
|
|
202
|
+
const normalizationClause = normalization ? `, ${normalization}` : "";
|
|
172
203
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (maxFragments !== undefined) hlOptionParts.push(`MaxFragments=${maxFragments}`);
|
|
178
|
-
if (startSel !== undefined) hlOptionParts.push(`StartSel="${startSel}"`);
|
|
179
|
-
if (stopSel !== undefined) hlOptionParts.push(`StopSel="${stopSel}"`);
|
|
180
|
-
if (fragmentDelimiter !== undefined)
|
|
181
|
-
hlOptionParts.push(`FragmentDelimiter="${fragmentDelimiter}"`);
|
|
204
|
+
return Puri.rawNumber(
|
|
205
|
+
`${type}(${weightClause}${column}, ${parser}('${config}', '${query}')${normalizationClause})`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
182
208
|
|
|
183
|
-
|
|
209
|
+
/**
|
|
210
|
+
* PGroonga FullText 인덱스 검색 점수
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* .select({
|
|
214
|
+
* score: Puri.score(),
|
|
215
|
+
* })
|
|
216
|
+
*/
|
|
217
|
+
static score(): SqlExpression<"number"> {
|
|
218
|
+
return Puri.rawNumber("pgroonga_score(tableoid, ctid)");
|
|
219
|
+
}
|
|
184
220
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
221
|
+
/**
|
|
222
|
+
* PGroonga FullText 인덱스 검색 하이라이팅
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* .select({
|
|
226
|
+
* title: Puri.highlight("posts.title", search),
|
|
227
|
+
* })
|
|
228
|
+
*/
|
|
229
|
+
static highlight(column: string, query: string | string[]): SqlExpression<"string">;
|
|
230
|
+
static highlight(columns: string[], query: string | string[]): SqlExpression<"string[]">;
|
|
231
|
+
|
|
232
|
+
static highlight(
|
|
233
|
+
columnOrColumns: string | string[],
|
|
234
|
+
query: string | string[],
|
|
235
|
+
): SqlExpression<"string"> | SqlExpression<"string[]"> {
|
|
236
|
+
const queryClause = `ARRAY[${(Array.isArray(query) ? query : [query]).map((q) => `'${q}'`).join(",")}]`;
|
|
237
|
+
|
|
238
|
+
// 단일 컬럼인 경우
|
|
239
|
+
if (typeof columnOrColumns === "string") {
|
|
240
|
+
return Puri.rawString(`pgroonga_highlight_html(${columnOrColumns}, ${queryClause})`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 컬럼 배열인 경우
|
|
244
|
+
return Puri.rawStringArray(
|
|
245
|
+
`pgroonga_highlight_html(ARRAY[${columnOrColumns.join(",")}], ${queryClause})`,
|
|
188
246
|
);
|
|
189
247
|
}
|
|
190
248
|
|
|
@@ -200,7 +258,7 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
200
258
|
for (const [alias, columnOrFunction] of Object.entries(flatSelect)) {
|
|
201
259
|
if (typeof columnOrFunction === "object" && columnOrFunction._type === "sql_expression") {
|
|
202
260
|
// SQL 함수인 경우
|
|
203
|
-
selectClauses.push(this.knex.raw(`${columnOrFunction._sql} as ${alias}`));
|
|
261
|
+
selectClauses.push(this.knex.raw(`${columnOrFunction._sql} as "${alias}"`));
|
|
204
262
|
} else {
|
|
205
263
|
// 일반 컬럼인 경우
|
|
206
264
|
const columnPath = columnOrFunction as string;
|
|
@@ -526,8 +584,40 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
526
584
|
return this;
|
|
527
585
|
}
|
|
528
586
|
|
|
587
|
+
/**
|
|
588
|
+
* PGroonga FullText 인덱스 검색
|
|
589
|
+
* - 사용할 PGroonga 인덱스와 동일한 컬럼 구성으로 검색해야 인덱스가 사용됩니다.
|
|
590
|
+
*
|
|
591
|
+
* 단일 컬럼 검색:
|
|
592
|
+
* ```sql
|
|
593
|
+
* WHERE name &@~ 'search'
|
|
594
|
+
* ```
|
|
595
|
+
*
|
|
596
|
+
* 복합 컬럼 검색:
|
|
597
|
+
* ```sql
|
|
598
|
+
* WHERE ARRAY[name::text, description::text] &@~ 'search'
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
whereSearch<TColumn extends AvailableColumns<TTables>>(
|
|
602
|
+
column: TColumn | TColumn[],
|
|
603
|
+
value: string,
|
|
604
|
+
options?: {
|
|
605
|
+
weights?: number[]; // 정수 배열
|
|
606
|
+
},
|
|
607
|
+
): this {
|
|
608
|
+
const { weights } = options ?? {};
|
|
609
|
+
const columnExpr = Array.isArray(column)
|
|
610
|
+
? `ARRAY[${column.map((c) => `${c}::text`).join(",")}]`
|
|
611
|
+
: column;
|
|
612
|
+
const pgroongaCondition = `pgroonga_condition(?${weights?.length ? `, weights => ARRAY[${weights.join(",")}]` : ""})`;
|
|
613
|
+
|
|
614
|
+
this.knexQuery.whereRaw(`${columnExpr} &@~ ${pgroongaCondition}`, [value]);
|
|
615
|
+
|
|
616
|
+
return this;
|
|
617
|
+
}
|
|
618
|
+
|
|
529
619
|
// WHERE FULLTEXT
|
|
530
|
-
|
|
620
|
+
whereTsSearch<TColumn extends AvailableColumns<TTables> | SqlExpression<"string">>(
|
|
531
621
|
column: TColumn,
|
|
532
622
|
value: string,
|
|
533
623
|
options?: TsQueryOptions | TsQueryConfig,
|
|
@@ -578,6 +668,127 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
578
668
|
return this;
|
|
579
669
|
}
|
|
580
670
|
|
|
671
|
+
/**
|
|
672
|
+
* 벡터 유사도 검색 설정
|
|
673
|
+
*
|
|
674
|
+
* - SELECT에 similarity 컬럼 추가
|
|
675
|
+
* - WHERE col IS NOT NULL 추가
|
|
676
|
+
* - threshold가 있으면 WHERE 조건 추가
|
|
677
|
+
* - 기존 ORDER BY를 clear하고 원시 연산자로 정렬 (HNSW 인덱스 최적화)
|
|
678
|
+
*
|
|
679
|
+
* @param column 벡터 컬럼 경로
|
|
680
|
+
* @param embedding 쿼리 임베딩 벡터
|
|
681
|
+
* @param options method, threshold, as 등 옵션
|
|
682
|
+
*
|
|
683
|
+
* @example
|
|
684
|
+
* ```typescript
|
|
685
|
+
* // cosine similarity (기본값)
|
|
686
|
+
* qb.vectorSimilarity("columnName", queryVector, {
|
|
687
|
+
* method: "cosine",
|
|
688
|
+
* threshold: 0.5
|
|
689
|
+
* });
|
|
690
|
+
*
|
|
691
|
+
* // L2 distance
|
|
692
|
+
* qb.vectorSimilarity("columnName", queryVector, {
|
|
693
|
+
* method: "l2",
|
|
694
|
+
* threshold: 1.5 // 거리가 1.5 이하인 결과만
|
|
695
|
+
* });
|
|
696
|
+
*
|
|
697
|
+
* // Inner product
|
|
698
|
+
* qb.vectorSimilarity("columnName", queryVector, {
|
|
699
|
+
* method: "inner_product",
|
|
700
|
+
* threshold: 0.7
|
|
701
|
+
* });
|
|
702
|
+
* ```
|
|
703
|
+
*/
|
|
704
|
+
vectorSimilarity(
|
|
705
|
+
column: VectorColumns<TTables>,
|
|
706
|
+
embedding: number[],
|
|
707
|
+
options: {
|
|
708
|
+
method?: "cosine" | "l2" | "inner_product";
|
|
709
|
+
threshold?: number;
|
|
710
|
+
} = {},
|
|
711
|
+
): Puri<TSchema, TTables, TResult & { similarity: number }> {
|
|
712
|
+
const { method = "cosine", threshold } = options;
|
|
713
|
+
if (
|
|
714
|
+
!Array.isArray(embedding) ||
|
|
715
|
+
embedding.length === 0 ||
|
|
716
|
+
embedding.some((v) => !Number.isFinite(v))
|
|
717
|
+
) {
|
|
718
|
+
throw new Error("Invalid embedding vector: expected a non-empty array of finite numbers");
|
|
719
|
+
}
|
|
720
|
+
const vectorLiteral = JSON.stringify(embedding.map((v) => Number(v)));
|
|
721
|
+
|
|
722
|
+
// method별 연산자 및 similarity 계산식
|
|
723
|
+
// - cosine: <=> (cosine distance, 0~2), similarity = 1 - distance
|
|
724
|
+
// - l2: <-> (euclidean distance), similarity = distance (낮을수록 유사)
|
|
725
|
+
// - inner_product: <#> (negative inner product), similarity = -distance (높을수록 유사)
|
|
726
|
+
const operatorMap = {
|
|
727
|
+
cosine: "<=>",
|
|
728
|
+
l2: "<->",
|
|
729
|
+
inner_product: "<#>",
|
|
730
|
+
} as const;
|
|
731
|
+
const operator = operatorMap[method];
|
|
732
|
+
|
|
733
|
+
// SELECT에 similarity 추가
|
|
734
|
+
if (method === "cosine") {
|
|
735
|
+
// cosine: similarity = 1 - cosine_distance (0~1, 높을수록 유사)
|
|
736
|
+
this.knexQuery.select(
|
|
737
|
+
this.knex.raw(`1 - (?? <=> ?::vector) as similarity`, [column, vectorLiteral]),
|
|
738
|
+
);
|
|
739
|
+
} else if (method === "l2") {
|
|
740
|
+
// l2: distance 그대로 반환 (낮을수록 유사)
|
|
741
|
+
this.knexQuery.select(
|
|
742
|
+
this.knex.raw(`?? <-> ?::vector as similarity`, [column, vectorLiteral]),
|
|
743
|
+
);
|
|
744
|
+
} else {
|
|
745
|
+
// inner_product: pgvector는 음수 반환하므로 부호 반전 (높을수록 유사)
|
|
746
|
+
this.knexQuery.select(
|
|
747
|
+
this.knex.raw(`-(?? <#> ?::vector) as similarity`, [column, vectorLiteral]),
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// WHERE col IS NOT NULL
|
|
752
|
+
this.knexQuery.whereNotNull(column);
|
|
753
|
+
|
|
754
|
+
// threshold가 있으면 WHERE 추가
|
|
755
|
+
if (typeof threshold === "number") {
|
|
756
|
+
if (!Number.isFinite(threshold)) {
|
|
757
|
+
throw new Error(`Invalid vectorSimilarity threshold: ${threshold}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (method === "cosine") {
|
|
761
|
+
// similarity >= threshold <=> cosine_distance <= (1 - threshold)
|
|
762
|
+
this.knexQuery.whereRaw(`?? ${operator} ?::vector <= ?`, [
|
|
763
|
+
column,
|
|
764
|
+
vectorLiteral,
|
|
765
|
+
1 - threshold,
|
|
766
|
+
]);
|
|
767
|
+
} else if (method === "l2") {
|
|
768
|
+
// distance <= threshold (거리가 threshold 이하)
|
|
769
|
+
this.knexQuery.whereRaw(`?? ${operator} ?::vector <= ?`, [
|
|
770
|
+
column,
|
|
771
|
+
vectorLiteral,
|
|
772
|
+
threshold,
|
|
773
|
+
]);
|
|
774
|
+
} else {
|
|
775
|
+
// inner_product: -distance >= threshold <=> distance <= -threshold
|
|
776
|
+
this.knexQuery.whereRaw(`?? ${operator} ?::vector <= ?`, [
|
|
777
|
+
column,
|
|
778
|
+
vectorLiteral,
|
|
779
|
+
-threshold,
|
|
780
|
+
]);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// 기존 ORDER BY clear 후 원시 연산자로 정렬 (HNSW 인덱스 최적화)
|
|
785
|
+
// 모든 method에서 ASC: 거리/음수값이 작을수록 유사
|
|
786
|
+
this.knexQuery.clear("order");
|
|
787
|
+
this.knexQuery.orderByRaw(`?? ${operator} ?::vector`, [column, vectorLiteral]);
|
|
788
|
+
|
|
789
|
+
return this as any;
|
|
790
|
+
}
|
|
791
|
+
|
|
581
792
|
// 기본 쿼리 메서드들
|
|
582
793
|
limit(count: number): this {
|
|
583
794
|
if (count < 0) {
|
|
@@ -108,7 +108,7 @@ describe("ExtractColumnType", () => {
|
|
|
108
108
|
type Tables = {
|
|
109
109
|
users: MockSchema["users"];
|
|
110
110
|
department: MockSchema["departments"] & LeftJoinedMarker; // nullable FK
|
|
111
|
-
company: MockSchema["companies"]
|
|
111
|
+
company: MockSchema["companies"]; // non-null FK → 마커 없음
|
|
112
112
|
};
|
|
113
113
|
type Result = ExtractColumnType<Tables, "company.id">;
|
|
114
114
|
|