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.
Files changed (118) hide show
  1. package/dist/api/config.d.ts +10 -3
  2. package/dist/api/config.d.ts.map +1 -1
  3. package/dist/api/config.js +2 -1
  4. package/dist/api/sonamu.d.ts +4 -0
  5. package/dist/api/sonamu.d.ts.map +1 -1
  6. package/dist/api/sonamu.js +36 -2
  7. package/dist/bin/cli.js +121 -117
  8. package/dist/database/base-model.d.ts +10 -50
  9. package/dist/database/base-model.d.ts.map +1 -1
  10. package/dist/database/base-model.js +19 -84
  11. package/dist/database/base-model.types.d.ts +4 -4
  12. package/dist/database/base-model.types.d.ts.map +1 -1
  13. package/dist/database/base-model.types.js +1 -1
  14. package/dist/database/db.d.ts +1 -0
  15. package/dist/database/db.d.ts.map +1 -1
  16. package/dist/database/db.js +24 -13
  17. package/dist/database/puri-subset.test-d.js +1 -1
  18. package/dist/database/puri-subset.types.d.ts +1 -0
  19. package/dist/database/puri-subset.types.d.ts.map +1 -1
  20. package/dist/database/puri-subset.types.js +2 -2
  21. package/dist/database/puri.d.ts +82 -3
  22. package/dist/database/puri.d.ts.map +1 -1
  23. package/dist/database/puri.js +180 -14
  24. package/dist/database/puri.types.d.ts +33 -6
  25. package/dist/database/puri.types.d.ts.map +1 -1
  26. package/dist/database/puri.types.js +1 -1
  27. package/dist/database/puri.types.test-d.js +1 -1
  28. package/dist/entity/entity-manager.d.ts +5 -4
  29. package/dist/entity/entity-manager.d.ts.map +1 -1
  30. package/dist/entity/entity-manager.js +8 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +3 -3
  34. package/dist/migration/code-generation.d.ts.map +1 -1
  35. package/dist/migration/code-generation.js +33 -2
  36. package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
  37. package/dist/migration/postgresql-schema-reader.js +53 -22
  38. package/dist/naite/messaging-types.d.ts.map +1 -1
  39. package/dist/naite/messaging-types.js +1 -1
  40. package/dist/naite/naite.js +2 -2
  41. package/dist/stream/sse.d.ts +2 -6
  42. package/dist/stream/sse.d.ts.map +1 -1
  43. package/dist/stream/sse.js +9 -3
  44. package/dist/syncer/api-parser.js +5 -1
  45. package/dist/syncer/file-patterns.d.ts +1 -1
  46. package/dist/syncer/file-patterns.d.ts.map +1 -1
  47. package/dist/syncer/file-patterns.js +6 -5
  48. package/dist/syncer/module-loader.d.ts +5 -0
  49. package/dist/syncer/module-loader.d.ts.map +1 -1
  50. package/dist/syncer/module-loader.js +17 -1
  51. package/dist/syncer/syncer.d.ts +3 -0
  52. package/dist/syncer/syncer.d.ts.map +1 -1
  53. package/dist/syncer/syncer.js +12 -2
  54. package/dist/tasks/decorator.d.ts +26 -0
  55. package/dist/tasks/decorator.d.ts.map +1 -0
  56. package/dist/tasks/decorator.js +28 -0
  57. package/dist/tasks/step-wrapper.d.ts +18 -0
  58. package/dist/tasks/step-wrapper.d.ts.map +1 -0
  59. package/dist/tasks/step-wrapper.js +38 -0
  60. package/dist/tasks/workflow-manager.d.ts +40 -0
  61. package/dist/tasks/workflow-manager.d.ts.map +1 -0
  62. package/dist/tasks/workflow-manager.js +193 -0
  63. package/dist/template/implementations/generated.template.d.ts.map +1 -1
  64. package/dist/template/implementations/generated.template.js +7 -3
  65. package/dist/types/types.d.ts +27 -11
  66. package/dist/types/types.d.ts.map +1 -1
  67. package/dist/types/types.js +15 -2
  68. package/dist/utils/formatter.d.ts.map +1 -1
  69. package/dist/utils/formatter.js +10 -2
  70. package/dist/utils/model.d.ts +9 -2
  71. package/dist/utils/model.d.ts.map +1 -1
  72. package/dist/utils/model.js +16 -1
  73. package/dist/utils/type-utils.d.ts.map +1 -1
  74. package/dist/utils/type-utils.js +3 -1
  75. package/dist/vector/embedding.d.ts +2 -5
  76. package/dist/vector/embedding.d.ts.map +1 -1
  77. package/dist/vector/embedding.js +3 -7
  78. package/dist/vector/types.d.ts.map +1 -1
  79. package/dist/vector/types.js +1 -1
  80. package/package.json +5 -3
  81. package/src/api/config.ts +15 -8
  82. package/src/api/sonamu.ts +43 -2
  83. package/src/bin/cli.ts +58 -54
  84. package/src/database/base-model.ts +21 -128
  85. package/src/database/base-model.types.ts +3 -4
  86. package/src/database/db.ts +28 -18
  87. package/src/database/puri-subset.test-d.ts +1 -0
  88. package/src/database/puri-subset.types.ts +2 -0
  89. package/src/database/puri.ts +238 -27
  90. package/src/database/puri.types.test-d.ts +1 -1
  91. package/src/database/puri.types.ts +49 -6
  92. package/src/entity/entity-manager.ts +9 -0
  93. package/src/index.ts +1 -1
  94. package/src/migration/code-generation.ts +40 -1
  95. package/src/migration/postgresql-schema-reader.ts +53 -22
  96. package/src/naite/messaging-types.ts +43 -44
  97. package/src/naite/naite.ts +1 -1
  98. package/src/shared/app.shared.ts.txt +13 -0
  99. package/src/shared/web.shared.ts.txt +13 -0
  100. package/src/stream/sse.ts +15 -3
  101. package/src/syncer/api-parser.ts +4 -0
  102. package/src/syncer/file-patterns.ts +11 -9
  103. package/src/syncer/module-loader.ts +35 -0
  104. package/src/syncer/syncer.ts +14 -0
  105. package/src/tasks/decorator.ts +71 -0
  106. package/src/tasks/step-wrapper.ts +84 -0
  107. package/src/tasks/workflow-manager.ts +330 -0
  108. package/src/template/implementations/generated.template.ts +19 -6
  109. package/src/types/types.ts +20 -4
  110. package/src/utils/formatter.ts +8 -1
  111. package/src/utils/model.ts +26 -2
  112. package/src/utils/type-utils.ts +2 -0
  113. package/src/vector/embedding.ts +2 -8
  114. package/src/vector/types.ts +1 -2
  115. package/dist/vector/vector-search.d.ts +0 -47
  116. package/dist/vector/vector-search.d.ts.map +0 -1
  117. package/dist/vector/vector-search.js +0 -176
  118. 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 { EntityManager } from "../entity/entity-manager";
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?: "list" | "count" | "both";
168
+ queryMode?: SonamuQueryMode;
286
169
  };
287
170
  debug?: boolean;
288
171
  optimizeCountQuery?: boolean;
289
172
  } & EnhancerParam<TSubsetKey, TComputedResults, TSubsetMapping>,
290
- ): Promise<ExecuteSubsetQueryResult<TSubsetMapping, T>> {
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
- return { rows, total };
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
- rows: TSubsetMapping[T][];
156
- total: number;
157
- };
155
+ LP extends { queryMode?: "list" | "count" | "both" },
156
+ > = ListResult<LP, TSubsetMapping[T]>;
@@ -56,30 +56,40 @@ export class DBClass {
56
56
  const instanceName = which === "w" ? "wdb" : "rdb";
57
57
 
58
58
  if (!this[instanceName]) {
59
- let config: Knex.Config;
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", () => {
@@ -1,3 +1,5 @@
1
+ /** biome-ignore-all lint/suspicious/noExplicitAny: Puri Subset 타입 시스템에서 any를 허용함 */
2
+
1
3
  /**
2
4
  * Puri Subset 타입 시스템
3
5
  *
@@ -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 highlight(
159
+ static tsHighlight(
154
160
  column: string,
155
161
  query: string,
156
- options?: HighlightOptions,
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
- maxWords,
162
- minWords,
163
- shortWord,
164
- highlightAll,
165
- maxFragments,
166
- startSel,
167
- stopSel,
168
- fragmentDelimiter,
197
+ normalization,
198
+ weights,
169
199
  } = options ?? {};
170
200
 
171
- const hlOptionParts: string[] = [];
201
+ const weightClause = weights ? `ARRAY[${weights.join(", ")}], ` : "";
202
+ const normalizationClause = normalization ? `, ${normalization}` : "";
172
203
 
173
- if (maxWords !== undefined) hlOptionParts.push(`MaxWords=${maxWords}`);
174
- if (minWords !== undefined) hlOptionParts.push(`MinWords=${minWords}`);
175
- if (shortWord !== undefined) hlOptionParts.push(`ShortWord=${shortWord}`);
176
- if (highlightAll !== undefined) hlOptionParts.push(`HighlightAll=${highlightAll}`);
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
- const hlOptions = hlOptionParts.length > 0 ? `, '${hlOptionParts.join(", ")}'` : "";
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
- // TODO: rawBinding 메서드 만들어서 XSS 방지
186
- return Puri.rawString(
187
- `ts_headline('${config}', ${column}, ${parser}('${config}', '${query}')${hlOptions})`,
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
- whereSearch<TColumn extends AvailableColumns<TTables> | SqlExpression<"string">>(
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"] & LeftJoinedMarker; // non-null FK → 마커 없음
111
+ company: MockSchema["companies"]; // non-null FK → 마커 없음
112
112
  };
113
113
  type Result = ExtractColumnType<Tables, "company.id">;
114
114