sonamu 0.10.4 → 0.10.6
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/bin/cli.js +206 -187
- package/dist/cone/cone-generator.js +3 -9
- package/dist/database/knex.d.ts.map +1 -1
- package/dist/database/knex.js +2 -2
- package/dist/database/puri.d.ts +17 -0
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +102 -3
- package/dist/migration/code-generation.js +6 -6
- package/dist/migration/migrator.d.ts.map +1 -1
- package/dist/migration/migrator.js +51 -6
- package/dist/ui-web/assets/{index-CDd6xT-F.js → index-D0MHYbxl.js} +2 -2
- package/dist/ui-web/assets/index-Dx_JX4aQ.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/package.json +2 -2
- package/src/bin/cli.ts +283 -282
- package/src/cone/cone-generator.ts +2 -18
- package/src/database/__tests__/puri.test.ts +183 -0
- package/src/database/knex.ts +4 -1
- package/src/database/puri.ts +229 -2
- package/src/database/puri.types.test-d.ts +56 -0
- package/src/migration/code-generation.ts +5 -5
- package/src/migration/migrator.ts +81 -7
- package/src/skills/AGENTS.md +76 -50
- package/src/skills/sonamu/SKILL.md +46 -227
- package/src/skills/{sonamu/ai-agents.md → sonamu-ai-agents/SKILL.md} +3 -3
- package/src/skills/{sonamu/api.md → sonamu-api/SKILL.md} +3 -2
- package/src/skills/{sonamu/auth.md → sonamu-auth/SKILL.md} +9 -4
- package/src/skills/{sonamu/auth-plugins.md → sonamu-auth/references/plugins.md} +2 -7
- package/src/skills/sonamu-auth/references/user-id-migration-followups.md +192 -0
- package/src/skills/{sonamu/auth-migration.md → sonamu-auth/references/user-id-migration.md} +1 -195
- package/src/skills/sonamu-config/SKILL.md +203 -0
- package/src/skills/{sonamu → sonamu-config/references}/database.md +3 -8
- package/src/skills/sonamu-config/references/environments.md +178 -0
- package/src/skills/sonamu-config/references/server-options.md +400 -0
- package/src/skills/sonamu-entity/SKILL.md +180 -0
- package/src/skills/{sonamu/entity-validation-checklist.md → sonamu-entity/references/creation-workflow.md} +117 -8
- package/src/skills/sonamu-entity/references/design-guides.md +243 -0
- package/src/skills/sonamu-entity/references/field-types.md +170 -0
- package/src/skills/sonamu-entity/references/relations-detail.md +245 -0
- package/src/skills/{sonamu/entity-relations.md → sonamu-entity/references/relations.md} +1 -258
- package/src/skills/{sonamu → sonamu-entity/references}/subset.md +1 -11
- package/src/skills/sonamu-fixture/SKILL.md +180 -0
- package/src/skills/{sonamu/fixture-cli.md → sonamu-fixture/references/cli-usage.md} +5 -176
- package/src/skills/{sonamu → sonamu-fixture/references}/cone.md +4 -14
- package/src/skills/sonamu-frontend/SKILL.md +142 -0
- package/src/skills/sonamu-frontend/references/components.md +323 -0
- package/src/skills/sonamu-frontend/references/examples.md +64 -0
- package/src/skills/sonamu-frontend/references/hooks.md +273 -0
- package/src/skills/sonamu-frontend/references/runtime.md +165 -0
- package/src/skills/{sonamu → sonamu-frontend/references}/scaffolding.md +3 -8
- package/src/skills/{sonamu/i18n.md → sonamu-i18n/SKILL.md} +2 -2
- package/src/skills/{sonamu/migration.md → sonamu-migration/SKILL.md} +1 -1
- package/src/skills/{sonamu/naite.md → sonamu-naite/SKILL.md} +5 -5
- package/src/skills/sonamu-query/SKILL.md +48 -0
- package/src/skills/sonamu-query/references/model-patterns.md +390 -0
- package/src/skills/{sonamu → sonamu-query/references}/model.md +1 -401
- package/src/skills/{sonamu → sonamu-query/references}/puri.md +31 -243
- package/src/skills/sonamu-query/references/search.md +238 -0
- package/src/skills/{sonamu → sonamu-query/references}/upsert.md +1 -6
- package/src/skills/{sonamu/tasks.md → sonamu-tasks/SKILL.md} +1 -1
- package/src/skills/sonamu-testing/SKILL.md +251 -0
- package/src/skills/{sonamu/testing-devrunner.md → sonamu-testing/references/devrunner.md} +4 -9
- package/src/skills/sonamu-testing/references/helpers.md +185 -0
- package/src/skills/sonamu-testing/references/patterns.md +263 -0
- package/src/skills/sonamu-testing/references/pitfalls.md +588 -0
- package/src/skills/sonamu-testing/references/quick-start.md +285 -0
- package/src/skills/sonamu-testing/references/type-safety.md +172 -0
- package/src/skills/sonamu-testing/references/writing-plan.md +375 -0
- package/src/skills/{sonamu/vector.md → sonamu-vector/SKILL.md} +1 -1
- package/dist/ui-web/assets/index-Dx4ap5i4.css +0 -1
- package/src/skills/commands/sonamu-skills.md +0 -20
- package/src/skills/project/README.md +0 -19
- package/src/skills/project/architecture.md +0 -373
- package/src/skills/sonamu/cdd.md +0 -129
- package/src/skills/sonamu/config.md +0 -772
- package/src/skills/sonamu/create-sonamu.md +0 -208
- package/src/skills/sonamu/entity-basic.md +0 -678
- package/src/skills/sonamu/framework-change.md +0 -96
- package/src/skills/sonamu/frontend.md +0 -915
- package/src/skills/sonamu/project-init.md +0 -477
- package/src/skills/sonamu/skill-contribution.md +0 -247
- package/src/skills/sonamu/testing.md +0 -2163
|
@@ -85,10 +85,9 @@ function getApiKey(): string {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* 도메인별 {domain}.contract.md
|
|
88
|
+
* 도메인별 {domain}.contract.md를 읽어 컨텍스트로 반환합니다.
|
|
89
89
|
*
|
|
90
|
-
* - contract/{domain}/{domain}.contract.md: 도메인 규칙과 결정 근거
|
|
91
|
-
* - .claude/skills/project/architecture.md: 엔티티 설계 구조 (보조 참조)
|
|
90
|
+
* - contract/{domain}/{domain}.contract.md: 도메인 규칙과 결정 근거
|
|
92
91
|
*
|
|
93
92
|
* cone 생성 시 LLM에게 전달하여 도메인 맥락에 맞는 메타데이터를 생성하도록 합니다.
|
|
94
93
|
*/
|
|
@@ -122,21 +121,6 @@ function readProjectSkills(): string {
|
|
|
122
121
|
}
|
|
123
122
|
}
|
|
124
123
|
|
|
125
|
-
// .claude/skills/project/architecture.md 보조 참조
|
|
126
|
-
const architecturePath = path.join(
|
|
127
|
-
projectRoot,
|
|
128
|
-
".claude",
|
|
129
|
-
"skills",
|
|
130
|
-
"project",
|
|
131
|
-
"architecture.md",
|
|
132
|
-
);
|
|
133
|
-
if (fs.existsSync(architecturePath)) {
|
|
134
|
-
const content = fs.readFileSync(architecturePath, "utf-8").trim();
|
|
135
|
-
if (content) {
|
|
136
|
-
contents.push(`--- architecture.md ---\n${content}`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
124
|
return contents.join("\n\n");
|
|
141
125
|
} catch {
|
|
142
126
|
// Sonamu 미초기화 또는 파일 접근 오류 시 빈 문자열 반환
|
|
@@ -7,6 +7,7 @@ type TestSchema = {
|
|
|
7
7
|
users: {
|
|
8
8
|
id: number;
|
|
9
9
|
name: string;
|
|
10
|
+
department_id: number | null;
|
|
10
11
|
payload: {
|
|
11
12
|
actor: {
|
|
12
13
|
id: string;
|
|
@@ -18,6 +19,14 @@ type TestSchema = {
|
|
|
18
19
|
state: string;
|
|
19
20
|
readonly __json__: readonly ["payload", "tags", "state"];
|
|
20
21
|
};
|
|
22
|
+
departments: {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
};
|
|
26
|
+
companies: {
|
|
27
|
+
id: number;
|
|
28
|
+
name: string;
|
|
29
|
+
};
|
|
21
30
|
};
|
|
22
31
|
|
|
23
32
|
const db = knex({ client: "pg" });
|
|
@@ -99,3 +108,177 @@ describe("Puri JSONB containment", () => {
|
|
|
99
108
|
expect(query.rawQuery().toSQL().sql).toBe('select * from "users"');
|
|
100
109
|
});
|
|
101
110
|
});
|
|
111
|
+
|
|
112
|
+
describe("Puri ensureJoin", () => {
|
|
113
|
+
it("기존의 동일한 JOIN을 재사용한다", () => {
|
|
114
|
+
const query = usersQuery()
|
|
115
|
+
.join({ department: "departments" }, "users.department_id", "department.id")
|
|
116
|
+
.ensureJoin({ department: "departments" }, "users.department_id", "department.id");
|
|
117
|
+
|
|
118
|
+
expect(query.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("등록되지 않은 JOIN을 추가한다", () => {
|
|
122
|
+
const query = usersQuery().ensureJoin(
|
|
123
|
+
{ department: "departments" },
|
|
124
|
+
"users.department_id",
|
|
125
|
+
"department.id",
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(query.toQuery()).toContain(
|
|
129
|
+
'join "departments" as "department" on "users"."department_id" = "department"."id"',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("같은 alias의 JOIN 조건이 다르면 실행 전에 오류를 던진다", () => {
|
|
134
|
+
const query = usersQuery().join(
|
|
135
|
+
{ department: "departments" },
|
|
136
|
+
"users.department_id",
|
|
137
|
+
"department.id",
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(() =>
|
|
141
|
+
query.ensureJoin({ department: "departments" }, "users.id", "department.id"),
|
|
142
|
+
).toThrowError(
|
|
143
|
+
[
|
|
144
|
+
'Join alias "department" is already registered with a different definition.',
|
|
145
|
+
"Existing: JOIN departments AS department ON users.department_id = department.id",
|
|
146
|
+
"Requested: JOIN departments AS department ON users.id = department.id",
|
|
147
|
+
].join("\n"),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it.each([
|
|
152
|
+
{
|
|
153
|
+
name: "테이블",
|
|
154
|
+
create: () =>
|
|
155
|
+
usersQuery()
|
|
156
|
+
.join({ department: "departments" }, "users.department_id", "department.id")
|
|
157
|
+
.ensureJoin({ department: "companies" }, "users.department_id", "department.id"),
|
|
158
|
+
requested: "Requested: JOIN companies AS department ON users.department_id = department.id",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "JOIN 타입",
|
|
162
|
+
create: () =>
|
|
163
|
+
usersQuery()
|
|
164
|
+
.join({ department: "departments" }, "users.department_id", "department.id")
|
|
165
|
+
.ensureLeftJoin({ department: "departments" }, "users.department_id", "department.id"),
|
|
166
|
+
requested:
|
|
167
|
+
"Requested: LEFT JOIN departments AS department ON users.department_id = department.id",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "왼쪽 조건",
|
|
171
|
+
create: () =>
|
|
172
|
+
usersQuery()
|
|
173
|
+
.join({ department: "departments" }, "users.department_id", "department.id")
|
|
174
|
+
.ensureJoin({ department: "departments" }, "users.id", "department.id"),
|
|
175
|
+
requested: "Requested: JOIN departments AS department ON users.id = department.id",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "오른쪽 조건",
|
|
179
|
+
create: () =>
|
|
180
|
+
usersQuery()
|
|
181
|
+
.join({ department: "departments" }, "users.department_id", "department.id")
|
|
182
|
+
.ensureJoin({ department: "departments" }, "users.department_id", "department.name"),
|
|
183
|
+
requested:
|
|
184
|
+
"Requested: JOIN departments AS department ON users.department_id = department.name",
|
|
185
|
+
},
|
|
186
|
+
])("같은 alias의 $name 정의가 다르면 실행 전에 오류를 던진다", ({ create, requested }) => {
|
|
187
|
+
expect(create).toThrowError(
|
|
188
|
+
'Join alias "department" is already registered with a different definition.',
|
|
189
|
+
);
|
|
190
|
+
expect(create).toThrowError(requested);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("일반 JOIN은 같은 alias를 중복 등록할 수 없다", () => {
|
|
194
|
+
const query = usersQuery().join(
|
|
195
|
+
{ department: "departments" },
|
|
196
|
+
"users.department_id",
|
|
197
|
+
"department.id",
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(() =>
|
|
201
|
+
query.join({ department: "departments" }, "users.department_id", "department.id"),
|
|
202
|
+
).toThrowError(/Join alias "department" is already registered/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("같은 테이블을 서로 다른 alias로 JOIN할 수 있다", () => {
|
|
206
|
+
const query = usersQuery()
|
|
207
|
+
.ensureJoin(
|
|
208
|
+
{ primary_department: "departments" },
|
|
209
|
+
"users.department_id",
|
|
210
|
+
"primary_department.id",
|
|
211
|
+
)
|
|
212
|
+
.ensureJoin({ fallback_department: "departments" }, "users.id", "fallback_department.id");
|
|
213
|
+
|
|
214
|
+
expect(query.toQuery()).toContain('join "departments" as "primary_department"');
|
|
215
|
+
expect(query.toQuery()).toContain('join "departments" as "fallback_department"');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("기존의 동일한 LEFT JOIN을 재사용한다", () => {
|
|
219
|
+
const query = usersQuery()
|
|
220
|
+
.leftJoin({ department: "departments" }, "users.department_id", "department.id")
|
|
221
|
+
.ensureLeftJoin({ department: "departments" }, "users.department_id", "department.id");
|
|
222
|
+
|
|
223
|
+
expect(query.toQuery().match(/left join "departments" as "department"/g)).toHaveLength(1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("callback JOIN은 동일성을 추론하지 않는다", () => {
|
|
227
|
+
const query = usersQuery().join({ department: "departments" }, (join) => {
|
|
228
|
+
join.on("users.department_id", "department.id");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(() =>
|
|
232
|
+
query.ensureJoin({ department: "departments" }, "users.department_id", "department.id"),
|
|
233
|
+
).toThrowError(/Existing: JOIN departments AS department with opaque condition/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("clone은 JOIN registry를 독립적으로 복제한다", () => {
|
|
237
|
+
const original = usersQuery().ensureJoin(
|
|
238
|
+
{ department: "departments" },
|
|
239
|
+
"users.department_id",
|
|
240
|
+
"department.id",
|
|
241
|
+
);
|
|
242
|
+
const cloned = original
|
|
243
|
+
.clone()
|
|
244
|
+
.ensureJoin({ department: "departments" }, "users.department_id", "department.id")
|
|
245
|
+
.ensureJoin({ fallback: "departments" }, "users.id", "fallback.id");
|
|
246
|
+
|
|
247
|
+
expect(original.toQuery()).not.toContain('as "fallback"');
|
|
248
|
+
expect(cloned.toQuery()).toContain('as "fallback"');
|
|
249
|
+
expect(cloned.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("JOIN을 clear한 뒤 같은 alias를 다시 등록할 수 있다", () => {
|
|
253
|
+
const query = usersQuery()
|
|
254
|
+
.ensureJoin({ department: "departments" }, "users.department_id", "department.id")
|
|
255
|
+
.clear("join")
|
|
256
|
+
.ensureJoin({ department: "departments" }, "users.department_id", "department.id");
|
|
257
|
+
|
|
258
|
+
expect(query.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("clearJoin으로 삭제한 alias를 다시 등록할 수 있다", () => {
|
|
262
|
+
const query = usersQuery()
|
|
263
|
+
.ensureJoin({ department: "departments" }, "users.department_id", "department.id")
|
|
264
|
+
.clearJoin("department")
|
|
265
|
+
.ensureJoin({ department: "departments" }, "users.department_id", "department.id");
|
|
266
|
+
|
|
267
|
+
expect(query.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("clearJoin은 subquery JOIN의 registry도 함께 제거한다", () => {
|
|
271
|
+
const departmentIds = new Puri<
|
|
272
|
+
TestSchema,
|
|
273
|
+
{ departments: TestSchema["departments"] },
|
|
274
|
+
TestSchema["departments"]
|
|
275
|
+
>(db, "departments").select({ id: "departments.id" });
|
|
276
|
+
const query = usersQuery()
|
|
277
|
+
.join({ department: departmentIds }, "users.department_id", "department.id")
|
|
278
|
+
.clearJoin("department")
|
|
279
|
+
.ensureJoin({ department: "departments" }, "users.department_id", "department.id");
|
|
280
|
+
|
|
281
|
+
expect(query.toQuery()).not.toContain('select "id" from "departments"');
|
|
282
|
+
expect(query.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
|
|
283
|
+
});
|
|
284
|
+
});
|
package/src/database/knex.ts
CHANGED
|
@@ -12,14 +12,17 @@ export function createKnexInstance(config: Knex.Config): Knex {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
config.pool = {
|
|
15
|
+
// 아래는 기본값이며, 호출측이 config.pool로 덮어쓸 수 있다.
|
|
16
|
+
// (예: 마이그레이션 상태 조회는 fail-fast를 위해 짧은 타임아웃 + propagateCreateError를 지정)
|
|
15
17
|
maxConnectionLifetimeMillis: 1800000,
|
|
16
18
|
maxConnectionLifetimeJitterMillis: 300000,
|
|
17
|
-
...config.pool,
|
|
18
19
|
propagateCreateError: false,
|
|
19
20
|
idleTimeoutMillis: 10000,
|
|
20
21
|
reapIntervalMillis: 1000,
|
|
21
22
|
acquireTimeoutMillis: 30000,
|
|
22
23
|
createTimeoutMillis: 30000,
|
|
24
|
+
...config.pool,
|
|
25
|
+
// validate/afterCreate는 항상 프레임워크 기본값을 사용한다.
|
|
23
26
|
validate: (connection: unknown) => {
|
|
24
27
|
if (typeof connection !== "object" || connection === null) return false;
|
|
25
28
|
const conn = connection as Record<string, unknown>;
|
package/src/database/puri.ts
CHANGED
|
@@ -61,6 +61,24 @@ type PuriOrderByRuntimeItem = {
|
|
|
61
61
|
};
|
|
62
62
|
type PuriOrderByRuntimeEntry = string | PuriOrderByExpression | PuriOrderByRuntimeItem;
|
|
63
63
|
|
|
64
|
+
type FromRegistration = {
|
|
65
|
+
kind: "from";
|
|
66
|
+
alias: string;
|
|
67
|
+
table: string | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type JoinRegistration = {
|
|
71
|
+
kind: "join";
|
|
72
|
+
alias: string;
|
|
73
|
+
table: string | null;
|
|
74
|
+
joinType: "join" | "leftJoin";
|
|
75
|
+
left: string | null;
|
|
76
|
+
right: string | null;
|
|
77
|
+
reusable: boolean;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type CorrelationRegistration = FromRegistration | JoinRegistration;
|
|
81
|
+
|
|
64
82
|
function normalizeFuzzyOperator(operator?: string): FuzzyOperator {
|
|
65
83
|
const normalized = operator?.trim() ?? "<%";
|
|
66
84
|
const fuzzyOperator = FUZZY_OPERATORS.find((candidate) => candidate === normalized);
|
|
@@ -123,6 +141,7 @@ function isSqlExpression(value: PuriOrderByRuntimeEntry): value is PuriOrderByEx
|
|
|
123
141
|
export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
124
142
|
private knexQuery: Knex.QueryBuilder;
|
|
125
143
|
private tableSpec: TableSpec | null = null;
|
|
144
|
+
private correlationRegistry = new Map<string, CorrelationRegistration>();
|
|
126
145
|
|
|
127
146
|
// 생성자 시그니처들
|
|
128
147
|
constructor(knex: Knex, tableName: string);
|
|
@@ -135,6 +154,11 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
135
154
|
// Case: new Puri(knex, "users")
|
|
136
155
|
this.knexQuery = this.knex(tableNameOrSource).from(tableNameOrSource);
|
|
137
156
|
this.tableSpec = this.safeGetTableSpec(tableNameOrSource);
|
|
157
|
+
this.correlationRegistry.set(tableNameOrSource, {
|
|
158
|
+
kind: "from",
|
|
159
|
+
alias: tableNameOrSource,
|
|
160
|
+
table: tableNameOrSource,
|
|
161
|
+
});
|
|
138
162
|
} else if (typeof tableNameOrSource === "object") {
|
|
139
163
|
const entries = Object.entries(tableNameOrSource);
|
|
140
164
|
if (entries.length !== 1) {
|
|
@@ -145,9 +169,11 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
145
169
|
if (typeof source === "string") {
|
|
146
170
|
this.knexQuery = this.knex(source).from({ [alias]: source });
|
|
147
171
|
this.tableSpec = this.safeGetTableSpec(source);
|
|
172
|
+
this.correlationRegistry.set(alias, { kind: "from", alias, table: source });
|
|
148
173
|
} else if (source instanceof Puri) {
|
|
149
174
|
const subqueryBuilder = source.rawQuery();
|
|
150
175
|
this.knexQuery = this.knex.from(subqueryBuilder.as(alias));
|
|
176
|
+
this.correlationRegistry.set(alias, { kind: "from", alias, table: null });
|
|
151
177
|
} else {
|
|
152
178
|
throw new Error("Invalid table specification");
|
|
153
179
|
}
|
|
@@ -559,19 +585,27 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
559
585
|
// CLEAR
|
|
560
586
|
clear(statement: ClearStatements): this {
|
|
561
587
|
this.knexQuery.clear(statement);
|
|
588
|
+
if (statement === "join") {
|
|
589
|
+
this.clearJoinRegistrations();
|
|
590
|
+
}
|
|
562
591
|
return this;
|
|
563
592
|
}
|
|
564
593
|
|
|
565
594
|
// knex에 없어서 직접 구현함
|
|
566
595
|
clearJoin(alias: string): this {
|
|
596
|
+
let removed = false;
|
|
567
597
|
(this.knexQuery as any)._statements = (this.knexQuery as any)._statements.filter((s: any) => {
|
|
568
598
|
if ("joinType" in s) {
|
|
569
|
-
const
|
|
570
|
-
|
|
599
|
+
const shouldRemove = this.getKnexJoinAlias(s.table) === alias;
|
|
600
|
+
removed ||= shouldRemove;
|
|
601
|
+
return !shouldRemove;
|
|
571
602
|
} else {
|
|
572
603
|
return true;
|
|
573
604
|
}
|
|
574
605
|
});
|
|
606
|
+
if (removed && this.correlationRegistry.get(alias)?.kind === "join") {
|
|
607
|
+
this.correlationRegistry.delete(alias);
|
|
608
|
+
}
|
|
575
609
|
return this;
|
|
576
610
|
}
|
|
577
611
|
|
|
@@ -625,6 +659,22 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
625
659
|
return this.__commonJoin("join", tableNameOrSpec, ...args);
|
|
626
660
|
}
|
|
627
661
|
|
|
662
|
+
// ENSURE JOIN: 테이블 + Alias
|
|
663
|
+
ensureJoin<TJoinTable extends keyof TSchema, TJoinAlias extends string>(
|
|
664
|
+
tableSpec: { [K in TJoinAlias]: TJoinTable },
|
|
665
|
+
left: AvailableColumns<TTables>,
|
|
666
|
+
right: `${TJoinAlias}.${ColumnKeys<TSchema[TJoinTable]>}`,
|
|
667
|
+
): Puri<TSchema, TTables & Record<TJoinAlias, TSchema[TJoinTable]>, TResult>;
|
|
668
|
+
// ENSURE JOIN: 테이블명
|
|
669
|
+
ensureJoin<TJoinTable extends keyof TSchema>(
|
|
670
|
+
tableName: TJoinTable,
|
|
671
|
+
left: AvailableColumns<TTables>,
|
|
672
|
+
right: `${TJoinTable & string}.${ColumnKeys<TSchema[TJoinTable]>}`,
|
|
673
|
+
): Puri<TSchema, TTables & Record<TJoinTable, TSchema[TJoinTable]>, TResult>;
|
|
674
|
+
ensureJoin(tableNameOrSpec: any, left: string, right: string): any {
|
|
675
|
+
return this.__ensureJoin("join", tableNameOrSpec, left, right);
|
|
676
|
+
}
|
|
677
|
+
|
|
628
678
|
// LEFT JOIN: 서브쿼리 + Alias
|
|
629
679
|
leftJoin<TJoinAlias extends string, TSubResult>(
|
|
630
680
|
tableSpec: { [K in TJoinAlias]: Puri<TSchema, any, TSubResult> },
|
|
@@ -676,7 +726,38 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
676
726
|
return this.__commonJoin("leftJoin", tableNameOrSpec, ...args);
|
|
677
727
|
}
|
|
678
728
|
|
|
729
|
+
// ENSURE LEFT JOIN: 테이블 + Alias
|
|
730
|
+
ensureLeftJoin<
|
|
731
|
+
TJoinTable extends keyof TSchema,
|
|
732
|
+
TJoinAlias extends string,
|
|
733
|
+
TLeft extends AvailableColumns<TTables>,
|
|
734
|
+
>(
|
|
735
|
+
tableSpec: { [K in TJoinAlias]: TJoinTable },
|
|
736
|
+
left: TLeft,
|
|
737
|
+
right: `${TJoinAlias}.${ColumnKeys<TSchema[TJoinTable]>}`,
|
|
738
|
+
): Puri<
|
|
739
|
+
TSchema,
|
|
740
|
+
TTables & Record<TJoinAlias, TSchema[TJoinTable] & LeftJoinMarkerFor<TTables, TLeft>>,
|
|
741
|
+
TResult
|
|
742
|
+
>;
|
|
743
|
+
// ENSURE LEFT JOIN: 테이블명
|
|
744
|
+
ensureLeftJoin<TJoinTable extends keyof TSchema, TLeft extends AvailableColumns<TTables>>(
|
|
745
|
+
tableName: TJoinTable,
|
|
746
|
+
left: TLeft,
|
|
747
|
+
right: `${TJoinTable & string}.${ColumnKeys<TSchema[TJoinTable]>}`,
|
|
748
|
+
): Puri<
|
|
749
|
+
TSchema,
|
|
750
|
+
TTables & Record<TJoinTable, TSchema[TJoinTable] & LeftJoinMarkerFor<TTables, TLeft>>,
|
|
751
|
+
TResult
|
|
752
|
+
>;
|
|
753
|
+
ensureLeftJoin(tableNameOrSpec: any, left: string, right: string): any {
|
|
754
|
+
return this.__ensureJoin("leftJoin", tableNameOrSpec, left, right);
|
|
755
|
+
}
|
|
756
|
+
|
|
679
757
|
__commonJoin(joinType: "join" | "leftJoin", tableNameOrSpec: any, ...args: any[]): this {
|
|
758
|
+
const registration = this.createJoinRegistration(joinType, tableNameOrSpec, args);
|
|
759
|
+
this.assertCorrelationAvailable(registration);
|
|
760
|
+
|
|
680
761
|
if (typeof tableNameOrSpec === "string") {
|
|
681
762
|
// Case 1: join("posts", ...)
|
|
682
763
|
const tableName = tableNameOrSpec;
|
|
@@ -734,9 +815,154 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
734
815
|
throw new Error("Invalid arguments");
|
|
735
816
|
}
|
|
736
817
|
|
|
818
|
+
this.correlationRegistry.set(registration.alias, registration);
|
|
737
819
|
return this;
|
|
738
820
|
}
|
|
739
821
|
|
|
822
|
+
private __ensureJoin(
|
|
823
|
+
joinType: "join" | "leftJoin",
|
|
824
|
+
tableNameOrSpec: string | Record<string, string>,
|
|
825
|
+
left: string,
|
|
826
|
+
right: string,
|
|
827
|
+
): this {
|
|
828
|
+
const requested = this.createJoinRegistration(joinType, tableNameOrSpec, [left, right]);
|
|
829
|
+
if (!requested.reusable) {
|
|
830
|
+
throw new Error(
|
|
831
|
+
`${joinType === "join" ? "ensureJoin" : "ensureLeftJoin"} only supports physical tables with simple column equality conditions.`,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
const existing = this.correlationRegistry.get(requested.alias);
|
|
835
|
+
|
|
836
|
+
if (!existing) {
|
|
837
|
+
return this.__commonJoin(joinType, tableNameOrSpec, left, right);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (this.isSameJoinRegistration(existing, requested)) {
|
|
841
|
+
return this;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
throw this.createJoinConflictError(existing, requested);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private createJoinRegistration(
|
|
848
|
+
joinType: "join" | "leftJoin",
|
|
849
|
+
tableNameOrSpec: any,
|
|
850
|
+
args: any[],
|
|
851
|
+
): JoinRegistration {
|
|
852
|
+
let alias: string;
|
|
853
|
+
let table: string | null;
|
|
854
|
+
|
|
855
|
+
if (typeof tableNameOrSpec === "string") {
|
|
856
|
+
alias = tableNameOrSpec;
|
|
857
|
+
table = tableNameOrSpec;
|
|
858
|
+
} else if (typeof tableNameOrSpec === "object") {
|
|
859
|
+
const entries = Object.entries(tableNameOrSpec);
|
|
860
|
+
if (entries.length !== 1) {
|
|
861
|
+
throw new Error("Table spec must have exactly one entry");
|
|
862
|
+
}
|
|
863
|
+
assert(entries[0]);
|
|
864
|
+
const [[joinAlias, spec]] = entries;
|
|
865
|
+
alias = joinAlias;
|
|
866
|
+
table = typeof spec === "string" ? spec : null;
|
|
867
|
+
} else {
|
|
868
|
+
throw new Error("Invalid arguments");
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const [left, right] = args;
|
|
872
|
+
const reusable =
|
|
873
|
+
table !== null && args.length === 2 && typeof left === "string" && typeof right === "string";
|
|
874
|
+
|
|
875
|
+
return {
|
|
876
|
+
kind: "join",
|
|
877
|
+
alias,
|
|
878
|
+
table,
|
|
879
|
+
joinType,
|
|
880
|
+
left: reusable ? left : null,
|
|
881
|
+
right: reusable ? right : null,
|
|
882
|
+
reusable,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private assertCorrelationAvailable(requested: JoinRegistration): void {
|
|
887
|
+
const existing = this.correlationRegistry.get(requested.alias);
|
|
888
|
+
if (existing) {
|
|
889
|
+
if (this.isSameJoinRegistration(existing, requested)) {
|
|
890
|
+
throw new Error(
|
|
891
|
+
`Join alias "${requested.alias}" is already registered. Use ensureJoin() or ensureLeftJoin() to reuse an identical join.`,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
throw this.createJoinConflictError(existing, requested);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
private isSameJoinRegistration(
|
|
899
|
+
existing: CorrelationRegistration,
|
|
900
|
+
requested: JoinRegistration,
|
|
901
|
+
): existing is JoinRegistration {
|
|
902
|
+
return (
|
|
903
|
+
existing.kind === "join" &&
|
|
904
|
+
existing.reusable &&
|
|
905
|
+
requested.reusable &&
|
|
906
|
+
existing.table === requested.table &&
|
|
907
|
+
existing.joinType === requested.joinType &&
|
|
908
|
+
existing.left === requested.left &&
|
|
909
|
+
existing.right === requested.right
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private createJoinConflictError(
|
|
914
|
+
existing: CorrelationRegistration,
|
|
915
|
+
requested: JoinRegistration,
|
|
916
|
+
): Error {
|
|
917
|
+
return new Error(
|
|
918
|
+
[
|
|
919
|
+
`Join alias "${requested.alias}" is already registered with a different definition.`,
|
|
920
|
+
`Existing: ${this.formatCorrelationRegistration(existing)}`,
|
|
921
|
+
`Requested: ${this.formatCorrelationRegistration(requested)}`,
|
|
922
|
+
].join("\n"),
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private formatCorrelationRegistration(registration: CorrelationRegistration): string {
|
|
927
|
+
if (registration.kind === "from") {
|
|
928
|
+
return `FROM ${registration.table ?? "subquery"} AS ${registration.alias}`;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const joinKeyword = registration.joinType === "join" ? "JOIN" : "LEFT JOIN";
|
|
932
|
+
const source = registration.table ?? "opaque source";
|
|
933
|
+
const condition = registration.reusable
|
|
934
|
+
? ` ON ${registration.left} = ${registration.right}`
|
|
935
|
+
: " with opaque condition";
|
|
936
|
+
return `${joinKeyword} ${source} AS ${registration.alias}${condition}`;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private clearJoinRegistrations(): void {
|
|
940
|
+
for (const [alias, registration] of this.correlationRegistry) {
|
|
941
|
+
if (registration.kind === "join") {
|
|
942
|
+
this.correlationRegistry.delete(alias);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private getKnexJoinAlias(table: unknown): string | null {
|
|
948
|
+
if (typeof table === "string") {
|
|
949
|
+
return table;
|
|
950
|
+
}
|
|
951
|
+
if (typeof table === "object" && table !== null) {
|
|
952
|
+
if (
|
|
953
|
+
"_single" in table &&
|
|
954
|
+
typeof table._single === "object" &&
|
|
955
|
+
table._single !== null &&
|
|
956
|
+
"as" in table._single &&
|
|
957
|
+
typeof table._single.as === "string"
|
|
958
|
+
) {
|
|
959
|
+
return table._single.as;
|
|
960
|
+
}
|
|
961
|
+
return Object.keys(table)[0] ?? null;
|
|
962
|
+
}
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
|
|
740
966
|
// WHERE: 객체 - 사용: .where({ "u.id": 1, "u.status": "active" })
|
|
741
967
|
where(conditions: WhereCondition<TTables>): this;
|
|
742
968
|
// WHERE: 컬럼 - 사용: .where("u.id", 1), .where("u.id", null)
|
|
@@ -1278,6 +1504,7 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
|
|
|
1278
1504
|
// 'dual'은 더미 테이블이며, 바로 아래 줄에서 knexQuery가 덮어씌워집니다.
|
|
1279
1505
|
const newPuri = new Puri<TSchema, TTables, TResult>(this.knex, "dual");
|
|
1280
1506
|
newPuri.knexQuery = this.knexQuery.clone();
|
|
1507
|
+
newPuri.correlationRegistry = new Map(this.correlationRegistry);
|
|
1281
1508
|
return newPuri;
|
|
1282
1509
|
}
|
|
1283
1510
|
|
|
@@ -661,3 +661,59 @@ describe("Puri orderBy methods", () => {
|
|
|
661
661
|
query.orderBy(["name", "missing_column"]);
|
|
662
662
|
});
|
|
663
663
|
});
|
|
664
|
+
|
|
665
|
+
describe("Puri ensureJoin methods", () => {
|
|
666
|
+
it("ensureJoin 이후 alias 컬럼을 타입 안전하게 사용할 수 있다", () => {
|
|
667
|
+
type Query = Puri<MockSchema, { users: MockSchema["users"] }, MockSchema["users"]>;
|
|
668
|
+
const query = {} as Query;
|
|
669
|
+
|
|
670
|
+
const joined = query.ensureJoin(
|
|
671
|
+
{ department: "departments" },
|
|
672
|
+
"users.department_id",
|
|
673
|
+
"department.id",
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
expectTypeOf(joined.where("department.name", "개발팀")).toEqualTypeOf(joined);
|
|
677
|
+
|
|
678
|
+
// @ts-expect-error JOIN한 테이블에 없는 컬럼은 사용할 수 없다.
|
|
679
|
+
joined.where("department.email", "dev@example.com");
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("ensureLeftJoin은 nullable FK의 객체 타입을 유지한다", () => {
|
|
683
|
+
type Query = Puri<MockSchema, { users: MockSchema["users"] }, MockSchema["users"]>;
|
|
684
|
+
const query = {} as Query;
|
|
685
|
+
|
|
686
|
+
const result = query
|
|
687
|
+
.ensureLeftJoin({ department: "departments" }, "users.department_id", "department.id")
|
|
688
|
+
.select({ department: { id: "department.id" } })
|
|
689
|
+
.first();
|
|
690
|
+
|
|
691
|
+
expectTypeOf(result).resolves.toEqualTypeOf<{
|
|
692
|
+
department: { id: number } | null;
|
|
693
|
+
}>();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("ensureLeftJoin은 non-null FK의 객체를 non-null로 추론한다", () => {
|
|
697
|
+
type Query = Puri<MockSchema, { employees: MockSchema["employees"] }, MockSchema["employees"]>;
|
|
698
|
+
const query = {} as Query;
|
|
699
|
+
|
|
700
|
+
const result = query
|
|
701
|
+
.ensureLeftJoin({ user: "users" }, "employees.user_id", "user.id")
|
|
702
|
+
.select({ user: { id: "user.id" } })
|
|
703
|
+
.first();
|
|
704
|
+
|
|
705
|
+
expectTypeOf(result).resolves.toEqualTypeOf<{
|
|
706
|
+
user: { id: number };
|
|
707
|
+
}>();
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("ensureJoin은 callback 형식을 허용하지 않는다", () => {
|
|
711
|
+
type Query = Puri<MockSchema, { users: MockSchema["users"] }, MockSchema["users"]>;
|
|
712
|
+
const query = {} as Query;
|
|
713
|
+
|
|
714
|
+
// @ts-expect-error callback JOIN은 동일성을 판정할 수 없으므로 지원하지 않는다.
|
|
715
|
+
query.ensureJoin({ department: "departments" }, (join) => {
|
|
716
|
+
join.on("users.department_id", "department.id");
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
});
|
|
@@ -757,7 +757,7 @@ async function generateCreateCode_ColumnAndIndexes(
|
|
|
757
757
|
|
|
758
758
|
// 컬럼, 인덱스 처리
|
|
759
759
|
const lines: string[] = [
|
|
760
|
-
'import { Knex } from "knex";',
|
|
760
|
+
'import type { Knex } from "knex";',
|
|
761
761
|
"",
|
|
762
762
|
"export async function up(knex: Knex): Promise<void> {",
|
|
763
763
|
...helperDefinitions,
|
|
@@ -1083,7 +1083,7 @@ async function generateCreateCode_Foreign(
|
|
|
1083
1083
|
}
|
|
1084
1084
|
|
|
1085
1085
|
const lines: string[] = [
|
|
1086
|
-
'import { Knex } from "knex";',
|
|
1086
|
+
'import type { Knex } from "knex";',
|
|
1087
1087
|
"",
|
|
1088
1088
|
"export async function up(knex: Knex): Promise<void> {",
|
|
1089
1089
|
`return knex.schema.alterTable("${table}", (table) => {`,
|
|
@@ -1304,7 +1304,7 @@ async function generateAlterCode_ColumnAndIndexes(
|
|
|
1304
1304
|
];
|
|
1305
1305
|
|
|
1306
1306
|
const lines: string[] = [
|
|
1307
|
-
'import { Knex } from "knex";',
|
|
1307
|
+
'import type { Knex } from "knex";',
|
|
1308
1308
|
"",
|
|
1309
1309
|
"export async function up(knex: Knex): Promise<void> {",
|
|
1310
1310
|
...(upBuilderLines.length > 0
|
|
@@ -1776,7 +1776,7 @@ async function generateAlterCode_Foreigns(
|
|
|
1776
1776
|
}
|
|
1777
1777
|
|
|
1778
1778
|
const lines: string[] = [
|
|
1779
|
-
'import { Knex } from "knex";',
|
|
1779
|
+
'import type { Knex } from "knex";',
|
|
1780
1780
|
"",
|
|
1781
1781
|
"export async function up(knex: Knex): Promise<void> {",
|
|
1782
1782
|
`return knex.schema.alterTable("${table}", (table) => {`,
|
|
@@ -2104,7 +2104,7 @@ async function generatePkTypeChangeMigration(
|
|
|
2104
2104
|
}
|
|
2105
2105
|
|
|
2106
2106
|
const lines: string[] = [
|
|
2107
|
-
'import { Knex } from "knex";',
|
|
2107
|
+
'import type { Knex } from "knex";',
|
|
2108
2108
|
"",
|
|
2109
2109
|
"export async function up(knex: Knex): Promise<void> {",
|
|
2110
2110
|
...upLines,
|