supalite 0.5.5 → 0.5.7
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/CHANGELOG.md +8 -0
- package/README.md +14 -2
- package/dist/postgres-client.d.ts +1 -0
- package/dist/postgres-client.js +15 -0
- package/dist/query-builder.d.ts +3 -1
- package/dist/query-builder.js +52 -9
- package/docs/changelog/2025-12-17-embed-many-to-one.md +30 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.7] - 2026-01-14
|
|
4
|
+
|
|
5
|
+
### ✨ Added
|
|
6
|
+
- `upsert()`에 `onConflict` 다중 컬럼 지정 지원을 추가했습니다. 이제 콤마 구분 문자열 또는 문자열 배열을 사용할 수 있습니다. (예: `'set_id, name'`, `['set_id', 'name']`)
|
|
7
|
+
|
|
8
|
+
### 🐞 Fixed
|
|
9
|
+
- `select()`의 PostgREST-style embed(`related_table(*)`)가 **양방향 FK**를 지원하도록 개선했습니다. 이제 1:N 관계는 배열(`[]` 기본값), N:1 관계는 객체(또는 `null`)로 반환합니다. (See [docs/changelog/2025-12-17-embed-many-to-one.md](docs/changelog/2025-12-17-embed-many-to-one.md))
|
|
10
|
+
|
|
3
11
|
## [0.5.5] - 2025-11-26
|
|
4
12
|
|
|
5
13
|
### ✨ Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# SupaLite
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/supalite)
|
|
4
4
|
|
|
5
5
|
가볍고 효율적인 PostgreSQL 클라이언트 라이브러리입니다. Supabase와 동일한 API를 제공하면서도 더 가볍고 빠른 구현을 제공합니다.
|
|
6
6
|
|
|
@@ -213,6 +213,17 @@ const { data, error } = await client
|
|
|
213
213
|
.from('users')
|
|
214
214
|
.ilike('email', '%@example.com');
|
|
215
215
|
|
|
216
|
+
// 관계 테이블 조회 (PostgREST-style embed)
|
|
217
|
+
// 1:N 관계는 배열로 반환됩니다 (기본값: [])
|
|
218
|
+
const { data: authors } = await client
|
|
219
|
+
.from('authors')
|
|
220
|
+
.select('*, books(*)');
|
|
221
|
+
|
|
222
|
+
// N:1 관계는 객체로 반환됩니다 (또는 null)
|
|
223
|
+
const { data: books } = await client
|
|
224
|
+
.from('books')
|
|
225
|
+
.select('*, authors(*)');
|
|
226
|
+
|
|
216
227
|
// 정확한 카운트와 함께 조회
|
|
217
228
|
const { data, count, error } = await client
|
|
218
229
|
.from('users')
|
|
@@ -270,10 +281,11 @@ const { data, error } = await client
|
|
|
270
281
|
- `select(columns?: string, options?: { count?: 'exact' | 'planned' | 'estimated', head?: boolean })`: 조회할 컬럼 지정
|
|
271
282
|
- `options.count`: `'exact'`로 설정하면 `limit`의 영향을 받지 않는 전체 결과의 개수를 `count` 속성으로 반환합니다.
|
|
272
283
|
- `options.head`: `true`로 설정하면 데이터 없이 `count`만 가져옵니다. `count` 옵션과 함께 사용하면 효율적으로 전체 개수만 조회할 수 있습니다.
|
|
284
|
+
- PostgREST-style embed: `select('*, related_table(*)')` 또는 `select('col, related_table(col1, col2)')`
|
|
273
285
|
- `insert(data: T['Tables'][K]['Insert'] | T['Tables'][K]['Insert'][])`: 단일 또는 다중 레코드 삽입
|
|
274
286
|
- `update(data: T['Tables'][K]['Update'])`: 레코드 업데이트
|
|
275
287
|
- `delete()`: 레코드 삭제
|
|
276
|
-
- `upsert(data: T['Tables'][K]['Insert'], options?: { onConflict: string })`: 삽입 또는 업데이트
|
|
288
|
+
- `upsert(data: T['Tables'][K]['Insert'], options?: { onConflict: string | string[] })`: 삽입 또는 업데이트
|
|
277
289
|
|
|
278
290
|
### 필터 메소드
|
|
279
291
|
|
|
@@ -67,6 +67,7 @@ export declare class SupaLitePG<T extends {
|
|
|
67
67
|
getForeignKey(schema: string, table: string, foreignTable: string): Promise<{
|
|
68
68
|
column: string;
|
|
69
69
|
foreignColumn: string;
|
|
70
|
+
isArray: boolean;
|
|
70
71
|
} | null>;
|
|
71
72
|
rpc(procedureName: string, params?: Record<string, any>): RpcBuilder;
|
|
72
73
|
testConnection(): Promise<boolean>;
|
package/dist/postgres-client.js
CHANGED
|
@@ -329,11 +329,26 @@ class SupaLitePG {
|
|
|
329
329
|
`;
|
|
330
330
|
const activeClient = this.isTransaction && this.client ? this.client : await this.pool.connect();
|
|
331
331
|
try {
|
|
332
|
+
// 1) One-to-many: `foreignTable` has a foreign key referencing `table`
|
|
333
|
+
// e.g. authors <- books.author_id, so embedding books(*) on authors returns an array
|
|
332
334
|
const result = await activeClient.query(query, [schema, foreignTable, table]);
|
|
333
335
|
if (result.rows.length > 0) {
|
|
334
336
|
const relationship = {
|
|
335
337
|
column: result.rows[0].foreign_column_name,
|
|
336
338
|
foreignColumn: result.rows[0].column_name,
|
|
339
|
+
isArray: true,
|
|
340
|
+
};
|
|
341
|
+
this.foreignKeyCache.set(cacheKey, relationship);
|
|
342
|
+
return relationship;
|
|
343
|
+
}
|
|
344
|
+
// 2) Many-to-one: `table` has a foreign key referencing `foreignTable`
|
|
345
|
+
// e.g. books.author_id -> authors.id, so embedding authors(*) on books returns an object
|
|
346
|
+
const reverseResult = await activeClient.query(query, [schema, table, foreignTable]);
|
|
347
|
+
if (reverseResult.rows.length > 0) {
|
|
348
|
+
const relationship = {
|
|
349
|
+
column: reverseResult.rows[0].column_name,
|
|
350
|
+
foreignColumn: reverseResult.rows[0].foreign_column_name,
|
|
351
|
+
isArray: false,
|
|
337
352
|
};
|
|
338
353
|
this.foreignKeyCache.set(cacheKey, relationship);
|
|
339
354
|
return relationship;
|
package/dist/query-builder.d.ts
CHANGED
|
@@ -57,8 +57,10 @@ export declare class QueryBuilder<T extends DatabaseSchema, S extends SchemaName
|
|
|
57
57
|
returns<NewS extends SchemaName<T>, NewK extends TableName<T, NewS>>(): QueryBuilder<T, NewS, NewK>;
|
|
58
58
|
range(from: number, to: number): this;
|
|
59
59
|
upsert(values: InsertRow<T, S, K>, options?: {
|
|
60
|
-
onConflict: string;
|
|
60
|
+
onConflict: string | string[];
|
|
61
61
|
}): this;
|
|
62
|
+
private formatConflictTarget;
|
|
63
|
+
private quoteConflictTargetColumn;
|
|
62
64
|
private shouldReturnData;
|
|
63
65
|
private buildWhereClause;
|
|
64
66
|
private buildQuery;
|
package/dist/query-builder.js
CHANGED
|
@@ -234,6 +234,39 @@ class QueryBuilder {
|
|
|
234
234
|
this.conflictTarget = options?.onConflict;
|
|
235
235
|
return this;
|
|
236
236
|
}
|
|
237
|
+
formatConflictTarget(target) {
|
|
238
|
+
if (Array.isArray(target)) {
|
|
239
|
+
return target
|
|
240
|
+
.map((column) => this.quoteConflictTargetColumn(column))
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
.join(', ');
|
|
243
|
+
}
|
|
244
|
+
const trimmedTarget = target.trim();
|
|
245
|
+
if (!trimmedTarget) {
|
|
246
|
+
return trimmedTarget;
|
|
247
|
+
}
|
|
248
|
+
if (trimmedTarget.includes('"') || trimmedTarget.includes('(') || trimmedTarget.includes(')')) {
|
|
249
|
+
return trimmedTarget;
|
|
250
|
+
}
|
|
251
|
+
if (trimmedTarget.includes(',')) {
|
|
252
|
+
return trimmedTarget
|
|
253
|
+
.split(',')
|
|
254
|
+
.map((column) => this.quoteConflictTargetColumn(column))
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.join(', ');
|
|
257
|
+
}
|
|
258
|
+
return this.quoteConflictTargetColumn(trimmedTarget);
|
|
259
|
+
}
|
|
260
|
+
quoteConflictTargetColumn(column) {
|
|
261
|
+
const trimmedColumn = column.trim();
|
|
262
|
+
if (!trimmedColumn) {
|
|
263
|
+
return trimmedColumn;
|
|
264
|
+
}
|
|
265
|
+
if (trimmedColumn.startsWith('"') && trimmedColumn.endsWith('"')) {
|
|
266
|
+
return trimmedColumn;
|
|
267
|
+
}
|
|
268
|
+
return `"${trimmedColumn}"`;
|
|
269
|
+
}
|
|
237
270
|
shouldReturnData() {
|
|
238
271
|
return this.selectColumns !== null;
|
|
239
272
|
}
|
|
@@ -280,12 +313,23 @@ class QueryBuilder {
|
|
|
280
313
|
return null;
|
|
281
314
|
}
|
|
282
315
|
const foreignSchemaTable = `"${String(this.schema)}"."${join.foreignTable}"`;
|
|
316
|
+
if (fk.isArray) {
|
|
317
|
+
return `(
|
|
318
|
+
SELECT COALESCE(json_agg(j), '[]'::json)
|
|
319
|
+
FROM (
|
|
320
|
+
SELECT ${join.columns}
|
|
321
|
+
FROM ${foreignSchemaTable}
|
|
322
|
+
WHERE "${fk.foreignColumn}" = ${schemaTable}."${fk.column}"
|
|
323
|
+
) as j
|
|
324
|
+
) as "${join.foreignTable}"`;
|
|
325
|
+
}
|
|
283
326
|
return `(
|
|
284
|
-
SELECT
|
|
327
|
+
SELECT row_to_json(j)
|
|
285
328
|
FROM (
|
|
286
329
|
SELECT ${join.columns}
|
|
287
330
|
FROM ${foreignSchemaTable}
|
|
288
331
|
WHERE "${fk.foreignColumn}" = ${schemaTable}."${fk.column}"
|
|
332
|
+
LIMIT 1
|
|
289
333
|
) as j
|
|
290
334
|
) as "${join.foreignTable}"`;
|
|
291
335
|
}));
|
|
@@ -356,14 +400,13 @@ class QueryBuilder {
|
|
|
356
400
|
query = `INSERT INTO ${schemaTable} ("${insertColumns.join('","')}") VALUES (${insertPlaceholders})`;
|
|
357
401
|
}
|
|
358
402
|
if (this.queryType === 'UPSERT' && this.conflictTarget) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
.join(', ');
|
|
403
|
+
const conflictTargetSQL = this.formatConflictTarget(this.conflictTarget);
|
|
404
|
+
if (conflictTargetSQL) {
|
|
405
|
+
query += ` ON CONFLICT (${conflictTargetSQL}) DO UPDATE SET `;
|
|
406
|
+
query += insertColumns
|
|
407
|
+
.map((col) => `"${col}" = EXCLUDED."${col}"`)
|
|
408
|
+
.join(', ');
|
|
409
|
+
}
|
|
367
410
|
}
|
|
368
411
|
query += returning;
|
|
369
412
|
break;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# PostgREST-style Embed: Many-to-One Support
|
|
2
|
+
|
|
3
|
+
- **Date**: 2025-12-17
|
|
4
|
+
- **Author**: Codex
|
|
5
|
+
- **Status**: Completed
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
Fixed PostgREST-style embed syntax in `select()` (e.g. `related_table(*)`) so it works for both relationship directions:
|
|
10
|
+
|
|
11
|
+
- **1:N** (foreign table references the base table) returns an **array** (defaults to `[]`).
|
|
12
|
+
- **N:1** (base table references the foreign table) returns a **single object** (or `null`).
|
|
13
|
+
|
|
14
|
+
## Changes
|
|
15
|
+
|
|
16
|
+
1. **Bidirectional FK resolution**
|
|
17
|
+
- `SupaLitePG.getForeignKey()` now checks both directions between `table` and `foreignTable` and returns whether the embed should be an array or object.
|
|
18
|
+
|
|
19
|
+
2. **Correct JSON shape in SQL generation**
|
|
20
|
+
- `QueryBuilder` uses `json_agg` (with `COALESCE(..., '[]'::json)`) for 1:N embeds.
|
|
21
|
+
- `QueryBuilder` uses `row_to_json` (with `LIMIT 1`) for N:1 embeds.
|
|
22
|
+
|
|
23
|
+
3. **Unit tests**
|
|
24
|
+
- Added tests to cover N:1 embed behavior and nested column selection.
|
|
25
|
+
|
|
26
|
+
## Impact
|
|
27
|
+
|
|
28
|
+
- Queries like `from('menu_item_opts').select('*, menu_item_opts_schema(*)')` now embed `menu_item_opts_schema` without warnings, matching PostgREST expectations.
|
|
29
|
+
- Existing 1:N embed behavior remains compatible, with an improved empty-result shape (`[]` instead of `null`).
|
|
30
|
+
|