create-monolayer-app 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +1 -1
- package/package.json +1 -1
- package/template/_gitignore +5 -0
- package/template/lib/db/client.ts +30 -0
- package/template/lib/db/db.ts +4 -0
- package/template/oxlint.config.ts +11 -0
- package/template/package.json +23 -3
- package/template/tsconfig.json +3 -2
- package/template/vendor/kysely-dsql/dsql/adapter.ts +15 -0
- package/template/vendor/kysely-dsql/dsql/alter-column-builder.ts +105 -0
- package/template/vendor/kysely-dsql/dsql/alter-table-builders.ts +195 -0
- package/template/vendor/kysely-dsql/dsql/compatibility-rules.ts +235 -0
- package/template/vendor/kysely-dsql/dsql/create-column-builder.ts +187 -0
- package/template/vendor/kysely-dsql/dsql/create-table-builder.ts +115 -0
- package/template/vendor/kysely-dsql/dsql/dialect.ts +41 -0
- package/template/vendor/kysely-dsql/dsql/dsql-kysely.ts +93 -0
- package/template/vendor/kysely-dsql/dsql/dsql-token.ts +29 -0
- package/template/vendor/kysely-dsql/dsql/index-view-builders.ts +206 -0
- package/template/vendor/kysely-dsql/dsql/pool.ts +40 -0
- package/template/vendor/kysely-dsql/dsql/query-compiler-error.ts +18 -0
- package/template/vendor/kysely-dsql/dsql/query-compiler.test.ts +765 -0
- package/template/vendor/kysely-dsql/dsql/query-compiler.ts +259 -0
- package/template/vendor/kysely-dsql/dsql/query-validation.test.ts +518 -0
- package/template/vendor/kysely-dsql/dsql/query-validation.ts +128 -0
- package/template/vendor/kysely-dsql/dsql/raw-statement.test.ts +167 -0
- package/template/vendor/kysely-dsql/dsql/raw-statement.ts +51 -0
- package/template/vendor/kysely-dsql/dsql/schema-module.ts +167 -0
- package/template/vendor/kysely-dsql/dsql/types.ts +4 -0
- package/template/vendor/kysely-dsql/generator/create-migration.ts +86 -0
- package/template/vendor/kysely-dsql/generator/generate-types-from-pglite.ts +40 -0
- package/template/vendor/kysely-dsql/generator/generated-types-writer.ts +73 -0
- package/template/vendor/kysely-dsql/generator/migration-file.ts +73 -0
- package/template/vendor/kysely-dsql/generator/type-generator.ts +330 -0
- package/template/vendor/kysely-dsql/plugins/auto-updated-at.plugin.ts +139 -0
- package/template/vendor/kysely-dsql/plugins/index.ts +10 -0
- package/template/vendor/kysely-dsql/plugins/soft-delete.plugin.ts +217 -0
- package/template/vitest.config.ts +23 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { CompiledQuery, QueryResult } from "kysely";
|
|
5
|
+
import { CompiledQuery as KyselyCompiledQuery } from "kysely";
|
|
6
|
+
|
|
7
|
+
interface TableColumnInfo {
|
|
8
|
+
readonly columnDefault: string | null;
|
|
9
|
+
readonly columnName: string;
|
|
10
|
+
readonly dataType: string;
|
|
11
|
+
readonly isGenerated: string;
|
|
12
|
+
readonly isIdentity: string;
|
|
13
|
+
readonly isNullable: string;
|
|
14
|
+
readonly tableName: string;
|
|
15
|
+
readonly udtName: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ColumnInfo {
|
|
19
|
+
readonly columnDefault: string | null;
|
|
20
|
+
readonly columnName: string | null;
|
|
21
|
+
readonly dataType: string | null;
|
|
22
|
+
readonly isGenerated: string | null;
|
|
23
|
+
readonly isIdentity: string | null;
|
|
24
|
+
readonly isNullable: string | null;
|
|
25
|
+
readonly tableName: string;
|
|
26
|
+
readonly udtName: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TypeGenerationConnection {
|
|
30
|
+
readonly executeQuery: (
|
|
31
|
+
query: CompiledQuery<ColumnInfo>
|
|
32
|
+
) => Promise<QueryResult<ColumnInfo>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The first line in each generated type file. */
|
|
36
|
+
export const GENERATED_FILE_HEADER =
|
|
37
|
+
"// This file is generated by @mnlyr-apps/db. Do not edit this file.";
|
|
38
|
+
|
|
39
|
+
/** The generated database file and table files. */
|
|
40
|
+
export interface GeneratedDatabaseTypes {
|
|
41
|
+
readonly databaseSource: string;
|
|
42
|
+
readonly tableSources: ReadonlyMap<string, string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const POSTGRES_TYPE_TO_TYPESCRIPT = new Map(
|
|
46
|
+
Object.entries({
|
|
47
|
+
bigint: "string",
|
|
48
|
+
bigserial: "string",
|
|
49
|
+
bit: "string",
|
|
50
|
+
boolean: "boolean",
|
|
51
|
+
bytea: "Uint8Array",
|
|
52
|
+
character: "string",
|
|
53
|
+
"character varying": "string",
|
|
54
|
+
cidr: "string",
|
|
55
|
+
date: "Date",
|
|
56
|
+
decimal: "string",
|
|
57
|
+
"double precision": "number",
|
|
58
|
+
inet: "string",
|
|
59
|
+
integer: "number",
|
|
60
|
+
interval: "unknown",
|
|
61
|
+
json: "unknown",
|
|
62
|
+
jsonb: "unknown",
|
|
63
|
+
macaddr: "string",
|
|
64
|
+
money: "string",
|
|
65
|
+
numeric: "string",
|
|
66
|
+
real: "number",
|
|
67
|
+
serial: "number",
|
|
68
|
+
smallint: "number",
|
|
69
|
+
smallserial: "number",
|
|
70
|
+
text: "string",
|
|
71
|
+
time: "string",
|
|
72
|
+
"time with time zone": "string",
|
|
73
|
+
"time without time zone": "string",
|
|
74
|
+
timestamp: "Date",
|
|
75
|
+
"timestamp with time zone": "Date",
|
|
76
|
+
"timestamp without time zone": "Date",
|
|
77
|
+
timestamptz: "Date",
|
|
78
|
+
tsquery: "string",
|
|
79
|
+
tsvector: "string",
|
|
80
|
+
uuid: "string",
|
|
81
|
+
"USER-DEFINED": "unknown",
|
|
82
|
+
varchar: "string",
|
|
83
|
+
xml: "string",
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const POSTGRES_UDT_TO_TYPESCRIPT = new Map(
|
|
88
|
+
Object.entries({
|
|
89
|
+
bool: "boolean",
|
|
90
|
+
bpchar: "string",
|
|
91
|
+
bytea: "Uint8Array",
|
|
92
|
+
date: "Date",
|
|
93
|
+
float4: "number",
|
|
94
|
+
float8: "number",
|
|
95
|
+
int2: "number",
|
|
96
|
+
int4: "number",
|
|
97
|
+
int8: "string",
|
|
98
|
+
json: "unknown",
|
|
99
|
+
jsonb: "unknown",
|
|
100
|
+
numeric: "string",
|
|
101
|
+
text: "string",
|
|
102
|
+
time: "string",
|
|
103
|
+
timestamp: "Date",
|
|
104
|
+
timestamptz: "Date",
|
|
105
|
+
uuid: "string",
|
|
106
|
+
varchar: "string",
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const INTROSPECTION_SQL = `
|
|
111
|
+
SELECT
|
|
112
|
+
t.table_name AS "tableName",
|
|
113
|
+
c.column_name AS "columnName",
|
|
114
|
+
c.data_type AS "dataType",
|
|
115
|
+
c.udt_name AS "udtName",
|
|
116
|
+
c.is_nullable AS "isNullable",
|
|
117
|
+
c.column_default AS "columnDefault",
|
|
118
|
+
c.is_identity AS "isIdentity",
|
|
119
|
+
c.is_generated AS "isGenerated"
|
|
120
|
+
FROM information_schema.tables t
|
|
121
|
+
LEFT JOIN information_schema.columns c
|
|
122
|
+
ON t.table_schema = c.table_schema
|
|
123
|
+
AND t.table_name = c.table_name
|
|
124
|
+
WHERE t.table_schema = $1
|
|
125
|
+
ORDER BY t.table_name, c.ordinal_position
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const INTERNAL_TABLE_PREFIXES = ["_", "kysely_"] as const;
|
|
129
|
+
|
|
130
|
+
const capitalize = (value: string): string =>
|
|
131
|
+
`${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
|
132
|
+
|
|
133
|
+
const camelCase = (value: string): string =>
|
|
134
|
+
value.replaceAll(/_[^_]/gu, (match) => match.slice(1).toUpperCase());
|
|
135
|
+
|
|
136
|
+
const propertyName = (databaseName: string): string => {
|
|
137
|
+
const name = camelCase(databaseName);
|
|
138
|
+
return /^[$A-Z_a-z][$\w]*$/u.test(name) ? name : JSON.stringify(name);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const typeName = (databaseName: string): string => {
|
|
142
|
+
const name = databaseName
|
|
143
|
+
.split(/[^A-Z_a-z0-9]+/u)
|
|
144
|
+
.flatMap((part) => part.split("_"))
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.map(capitalize)
|
|
147
|
+
.join("");
|
|
148
|
+
|
|
149
|
+
return /^\d/u.test(name) ? `Table${name}` : name;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const isTableColumn = (column: ColumnInfo): column is TableColumnInfo =>
|
|
153
|
+
column.columnName !== null;
|
|
154
|
+
|
|
155
|
+
const isGeneratedColumn = (column: TableColumnInfo): boolean =>
|
|
156
|
+
column.columnDefault !== null ||
|
|
157
|
+
column.isIdentity === "YES" ||
|
|
158
|
+
column.isGenerated !== "NEVER";
|
|
159
|
+
|
|
160
|
+
const isInternalTable = (tableName: string): boolean =>
|
|
161
|
+
INTERNAL_TABLE_PREFIXES.some((prefix) => tableName.startsWith(prefix));
|
|
162
|
+
|
|
163
|
+
const postgresTypeToTypescript = (column: TableColumnInfo): string => {
|
|
164
|
+
if (column.dataType === "ARRAY") {
|
|
165
|
+
const itemType =
|
|
166
|
+
POSTGRES_UDT_TO_TYPESCRIPT.get(column.udtName.slice(1)) ?? "unknown";
|
|
167
|
+
return `Array<${itemType}>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
POSTGRES_TYPE_TO_TYPESCRIPT.get(column.dataType) ??
|
|
172
|
+
POSTGRES_UDT_TO_TYPESCRIPT.get(column.udtName) ??
|
|
173
|
+
"unknown"
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const renderColumn = (column: TableColumnInfo): string => {
|
|
178
|
+
const nullableType = column.isNullable === "YES" ? ` | null` : "";
|
|
179
|
+
const dataType = `${postgresTypeToTypescript(column)}${nullableType}`;
|
|
180
|
+
const kyselyType = isGeneratedColumn(column)
|
|
181
|
+
? `Generated<${dataType}>`
|
|
182
|
+
: dataType;
|
|
183
|
+
|
|
184
|
+
return ` ${propertyName(column.columnName)}: ${kyselyType};`;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const renderKyselyImports = (columns: readonly TableColumnInfo[]): string => {
|
|
188
|
+
const names = ["Insertable", "Selectable", "Updateable"];
|
|
189
|
+
|
|
190
|
+
if (columns.some(isGeneratedColumn)) {
|
|
191
|
+
names.unshift("Generated");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return `import type { ${names.toSorted().join(", ")} } from "kysely";`;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const tableFileName = (tableName: string): string => {
|
|
198
|
+
const fileName = tableName.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-");
|
|
199
|
+
return `${fileName.replaceAll(/^-|-$/gu, "")}.ts`;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const renderTableInterface = (
|
|
203
|
+
name: string,
|
|
204
|
+
columns: readonly TableColumnInfo[]
|
|
205
|
+
): string => {
|
|
206
|
+
if (columns.length === 0) {
|
|
207
|
+
return [
|
|
208
|
+
"// oxlint-disable-next-line typescript/no-empty-interface typescript/no-empty-object-type",
|
|
209
|
+
`export interface ${name}Table {}`,
|
|
210
|
+
].join("\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const properties = columns.map(renderColumn).join("\n");
|
|
214
|
+
return [`export interface ${name}Table {`, properties, "}"].join("\n");
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const renderTableTypes = ([tableName, columns]: [
|
|
218
|
+
string,
|
|
219
|
+
TableColumnInfo[],
|
|
220
|
+
]): string => {
|
|
221
|
+
const name = typeName(tableName);
|
|
222
|
+
|
|
223
|
+
return [
|
|
224
|
+
GENERATED_FILE_HEADER,
|
|
225
|
+
renderKyselyImports(columns),
|
|
226
|
+
renderTableInterface(name, columns),
|
|
227
|
+
[
|
|
228
|
+
`export type ${name} = Selectable<${name}Table>;`,
|
|
229
|
+
`export type New${name} = Insertable<${name}Table>;`,
|
|
230
|
+
`export type ${name}Update = Updateable<${name}Table>;`,
|
|
231
|
+
].join("\n"),
|
|
232
|
+
]
|
|
233
|
+
.join("\n\n")
|
|
234
|
+
.concat("\n");
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const renderDatabaseImports = (
|
|
238
|
+
tables: ReadonlyMap<string, readonly TableColumnInfo[]>
|
|
239
|
+
): string[] =>
|
|
240
|
+
[...tables].map(([tableName]) => {
|
|
241
|
+
const name = typeName(tableName);
|
|
242
|
+
const importFile = tableFileName(tableName).replace(/\.ts$/u, ".js");
|
|
243
|
+
return `import type { ${name}Table } from "./tables/${importFile}";`;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const renderDatabaseProperties = (
|
|
247
|
+
tables: ReadonlyMap<string, readonly TableColumnInfo[]>
|
|
248
|
+
): string[] =>
|
|
249
|
+
[...tables].map(
|
|
250
|
+
([tableName]) =>
|
|
251
|
+
` ${propertyName(tableName)}: ${typeName(tableName)}Table;`
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const renderDatabaseInterface = (properties: string[]): string => {
|
|
255
|
+
const databaseInterface =
|
|
256
|
+
properties.length === 0
|
|
257
|
+
? [
|
|
258
|
+
"// oxlint-disable-next-line typescript/no-empty-interface typescript/no-empty-object-type",
|
|
259
|
+
"export interface Database {}",
|
|
260
|
+
].join("\n")
|
|
261
|
+
: ["export interface Database {", ...properties, "}"].join("\n");
|
|
262
|
+
return databaseInterface;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const renderDatabaseType = (
|
|
266
|
+
tables: ReadonlyMap<string, readonly TableColumnInfo[]>
|
|
267
|
+
): string => {
|
|
268
|
+
const imports = renderDatabaseImports(tables);
|
|
269
|
+
const properties = renderDatabaseProperties(tables);
|
|
270
|
+
const databaseInterface = renderDatabaseInterface(properties);
|
|
271
|
+
return [GENERATED_FILE_HEADER, imports.join("\n"), databaseInterface]
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.join("\n\n")
|
|
274
|
+
.concat("\n");
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const groupColumnsByTable = (
|
|
278
|
+
columns: readonly ColumnInfo[]
|
|
279
|
+
): Map<string, TableColumnInfo[]> => {
|
|
280
|
+
const tables = new Map<string, TableColumnInfo[]>();
|
|
281
|
+
|
|
282
|
+
for (const column of columns) {
|
|
283
|
+
if (isInternalTable(column.tableName)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const tableColumns = tables.get(column.tableName) ?? [];
|
|
288
|
+
if (isTableColumn(column)) {
|
|
289
|
+
tableColumns.push(column);
|
|
290
|
+
}
|
|
291
|
+
tables.set(column.tableName, tableColumns);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return tables;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const renderDatabaseTypes = (
|
|
298
|
+
columns: readonly ColumnInfo[]
|
|
299
|
+
): GeneratedDatabaseTypes => {
|
|
300
|
+
const tables = groupColumnsByTable(columns);
|
|
301
|
+
const tableSources = new Map<string, string>();
|
|
302
|
+
|
|
303
|
+
for (const table of tables) {
|
|
304
|
+
const fileName = tableFileName(table[0]);
|
|
305
|
+
if (tableSources.has(fileName)) {
|
|
306
|
+
throw new Error(`Two database tables map to ${fileName}.`);
|
|
307
|
+
}
|
|
308
|
+
tableSources.set(fileName, renderTableTypes(table));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
databaseSource: renderDatabaseType(tables),
|
|
313
|
+
tableSources,
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Create Kysely database types from the PostgreSQL public schema.
|
|
319
|
+
*
|
|
320
|
+
* @param connection - The Kysely connection for schema queries.
|
|
321
|
+
* @returns The generated database file and table files.
|
|
322
|
+
*/
|
|
323
|
+
export async function generateTypes(
|
|
324
|
+
connection: TypeGenerationConnection
|
|
325
|
+
): Promise<GeneratedDatabaseTypes> {
|
|
326
|
+
const query = KyselyCompiledQuery.raw(INTROSPECTION_SQL, ["public"]);
|
|
327
|
+
const result = await connection.executeQuery(query);
|
|
328
|
+
|
|
329
|
+
return renderDatabaseTypes(result.rows);
|
|
330
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// oxlint-disable max-classes-per-file
|
|
2
|
+
// source: https://github.com/LouisJULIEN/kysely-plugins/blob/main/src/auto-updated-at/auto-updated-at.plugin.ts
|
|
3
|
+
import type {
|
|
4
|
+
KyselyPlugin,
|
|
5
|
+
OperationNode,
|
|
6
|
+
PluginTransformQueryArgs,
|
|
7
|
+
PluginTransformResultArgs,
|
|
8
|
+
QueryResult,
|
|
9
|
+
RootOperationNode,
|
|
10
|
+
UnknownRow,
|
|
11
|
+
UpdateQueryNode,
|
|
12
|
+
} from "kysely";
|
|
13
|
+
import {
|
|
14
|
+
AliasNode,
|
|
15
|
+
ColumnNode,
|
|
16
|
+
ColumnUpdateNode,
|
|
17
|
+
OperationNodeTransformer,
|
|
18
|
+
RawNode,
|
|
19
|
+
TableNode,
|
|
20
|
+
} from "kysely";
|
|
21
|
+
|
|
22
|
+
export class AutoUpdatedAtError extends Error {
|
|
23
|
+
readonly node?: OperationNode | undefined;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
message: string,
|
|
27
|
+
node?: OperationNode | undefined
|
|
28
|
+
// oxlint-disable-next-line unicorn/custom-error-definition
|
|
29
|
+
) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.node = node;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class AutoUpdatedAtQueryTransformer extends OperationNodeTransformer {
|
|
36
|
+
private updatedAtColumnName: string;
|
|
37
|
+
private ignoredTables: string[];
|
|
38
|
+
|
|
39
|
+
constructor({
|
|
40
|
+
updatedAtColumnName,
|
|
41
|
+
ignoredTables,
|
|
42
|
+
}: {
|
|
43
|
+
updatedAtColumnName?: string;
|
|
44
|
+
ignoredTables?: string[];
|
|
45
|
+
} = {}) {
|
|
46
|
+
super();
|
|
47
|
+
this.updatedAtColumnName = updatedAtColumnName || "updated_at";
|
|
48
|
+
this.ignoredTables = ignoredTables || [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected override transformUpdateQuery(
|
|
52
|
+
originalNode: UpdateQueryNode
|
|
53
|
+
): UpdateQueryNode {
|
|
54
|
+
const node = super.transformUpdateQuery(originalNode);
|
|
55
|
+
return this.setUpdatedAt(node);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// oxlint-disable-next-line class-methods-use-this
|
|
59
|
+
private resolveUpdateTableName(
|
|
60
|
+
table: OperationNode | undefined,
|
|
61
|
+
node: OperationNode
|
|
62
|
+
): string {
|
|
63
|
+
if (table && AliasNode.is(table) && TableNode.is(table.node)) {
|
|
64
|
+
return table.node.table.identifier.name;
|
|
65
|
+
}
|
|
66
|
+
if (table && TableNode.is(table)) {
|
|
67
|
+
return table.table.identifier.name;
|
|
68
|
+
}
|
|
69
|
+
throw new AutoUpdatedAtError("no table to update", node);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private hasExplicitUpdatedAt(updates: readonly OperationNode[]): boolean {
|
|
73
|
+
return updates.some((u) => {
|
|
74
|
+
if (ColumnUpdateNode.is(u) && ColumnNode.is(u.column)) {
|
|
75
|
+
const colName = u.column.column.name;
|
|
76
|
+
return (
|
|
77
|
+
colName === this.updatedAtColumnName ||
|
|
78
|
+
colName === "updatedAt" ||
|
|
79
|
+
colName === "updated_at"
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private appendUpdatedAt<T extends UpdateQueryNode>(
|
|
87
|
+
node: T,
|
|
88
|
+
updates: readonly OperationNode[]
|
|
89
|
+
): T {
|
|
90
|
+
const updateUpdatedAtColumn = ColumnNode.create(this.updatedAtColumnName);
|
|
91
|
+
const updatedAtUpdate = ColumnUpdateNode.create(
|
|
92
|
+
updateUpdatedAtColumn,
|
|
93
|
+
RawNode.createWithSql("NOW()")
|
|
94
|
+
);
|
|
95
|
+
return { ...node, updates: [...updates, updatedAtUpdate] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private setUpdatedAt<T extends UpdateQueryNode>(node: T): T {
|
|
99
|
+
const tableName = this.resolveUpdateTableName(node.table, node);
|
|
100
|
+
if (!this.tableShouldHaveUpdatedAt(tableName)) {
|
|
101
|
+
return node;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const updates = node.updates || [];
|
|
105
|
+
if (this.hasExplicitUpdatedAt(updates)) {
|
|
106
|
+
return node;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return this.appendUpdatedAt(node, updates);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private tableShouldHaveUpdatedAt(tableName: string): boolean {
|
|
113
|
+
return (
|
|
114
|
+
!tableName.startsWith("kysely_") &&
|
|
115
|
+
!this.ignoredTables.includes(tableName)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class AutoUpdatedAtPlugin implements KyselyPlugin {
|
|
121
|
+
private transformer: AutoUpdatedAtQueryTransformer;
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
...args: ConstructorParameters<typeof AutoUpdatedAtQueryTransformer>
|
|
125
|
+
) {
|
|
126
|
+
this.transformer = new AutoUpdatedAtQueryTransformer(...args);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
transformQuery({ node }: PluginTransformQueryArgs): RootOperationNode {
|
|
130
|
+
return this.transformer.transformNode(node);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// oxlint-disable-next-line class-methods-use-this
|
|
134
|
+
transformResult({
|
|
135
|
+
result,
|
|
136
|
+
}: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
|
|
137
|
+
return Promise.resolve(result);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { CamelCasePlugin } from "kysely";
|
|
2
|
+
|
|
3
|
+
import { AutoUpdatedAtPlugin } from "./auto-updated-at.plugin";
|
|
4
|
+
import { SoftDelete } from "./soft-delete.plugin";
|
|
5
|
+
|
|
6
|
+
export default [
|
|
7
|
+
new CamelCasePlugin(),
|
|
8
|
+
new SoftDelete({ ignoredTables: [] }),
|
|
9
|
+
new AutoUpdatedAtPlugin({ ignoredTables: [] }),
|
|
10
|
+
];
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// oxlint-disable max-classes-per-file
|
|
2
|
+
// source: https://github.com/LouisJULIEN/kysely-plugins/blob/main/src/soft-delete/soft-delete.plugin.ts
|
|
3
|
+
import type {
|
|
4
|
+
KyselyPlugin,
|
|
5
|
+
OperationNode,
|
|
6
|
+
PluginTransformQueryArgs,
|
|
7
|
+
PluginTransformResultArgs,
|
|
8
|
+
QueryResult,
|
|
9
|
+
RootOperationNode,
|
|
10
|
+
SelectQueryNode,
|
|
11
|
+
UnknownRow,
|
|
12
|
+
UpdateQueryNode,
|
|
13
|
+
} from "kysely";
|
|
14
|
+
import {
|
|
15
|
+
AliasNode,
|
|
16
|
+
BinaryOperationNode,
|
|
17
|
+
ColumnNode,
|
|
18
|
+
IdentifierNode,
|
|
19
|
+
JoinNode,
|
|
20
|
+
OperationNodeTransformer,
|
|
21
|
+
OperatorNode,
|
|
22
|
+
RawNode,
|
|
23
|
+
ReferenceNode,
|
|
24
|
+
TableNode,
|
|
25
|
+
WhereNode,
|
|
26
|
+
} from "kysely";
|
|
27
|
+
|
|
28
|
+
// credits: https://github.com/kysely-org/kysely/issues/803
|
|
29
|
+
|
|
30
|
+
export class SoftDeleteError extends Error {
|
|
31
|
+
readonly node?: OperationNode | undefined;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
message: string,
|
|
35
|
+
node?: OperationNode | undefined
|
|
36
|
+
// oxlint-disable-next-line unicorn/custom-error-definition
|
|
37
|
+
) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.node = node;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
class SoftDeleteQueryTransformer extends OperationNodeTransformer {
|
|
43
|
+
private deletedAtColumnName: string;
|
|
44
|
+
private ignoredTables: string[];
|
|
45
|
+
|
|
46
|
+
constructor({
|
|
47
|
+
deletedAtColumnName,
|
|
48
|
+
ignoredTables,
|
|
49
|
+
}: {
|
|
50
|
+
deletedAtColumnName?: string;
|
|
51
|
+
ignoredTables?: string[];
|
|
52
|
+
} = {}) {
|
|
53
|
+
super();
|
|
54
|
+
this.deletedAtColumnName = deletedAtColumnName || "deleted_at";
|
|
55
|
+
this.ignoredTables = ignoredTables || [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
protected override transformSelectQuery(
|
|
59
|
+
originalNode: SelectQueryNode
|
|
60
|
+
): SelectQueryNode {
|
|
61
|
+
const node = super.transformSelectQuery(originalNode);
|
|
62
|
+
return this.addDeletedAtCondition(node);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
protected override transformUpdateQuery(
|
|
66
|
+
originalNode: UpdateQueryNode
|
|
67
|
+
): UpdateQueryNode {
|
|
68
|
+
const node = super.transformUpdateQuery(originalNode);
|
|
69
|
+
return this.addDeletedAtCondition(node);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private appendFromFilters(
|
|
73
|
+
where: WhereNode | undefined,
|
|
74
|
+
node: SelectQueryNode | UpdateQueryNode
|
|
75
|
+
): WhereNode | undefined {
|
|
76
|
+
let currentWhere = where;
|
|
77
|
+
if (node.from) {
|
|
78
|
+
for (const from of node.from.froms) {
|
|
79
|
+
const deletedAtReference = this.getDeletedAtReference(from);
|
|
80
|
+
if (deletedAtReference) {
|
|
81
|
+
currentWhere = this.addIsNotDeletedFilter(
|
|
82
|
+
currentWhere,
|
|
83
|
+
deletedAtReference
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return currentWhere;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private appendJoinFilters(
|
|
92
|
+
where: WhereNode | undefined,
|
|
93
|
+
node: SelectQueryNode | UpdateQueryNode
|
|
94
|
+
): WhereNode | undefined {
|
|
95
|
+
let currentWhere = where;
|
|
96
|
+
if (node.joins) {
|
|
97
|
+
for (const join of node.joins) {
|
|
98
|
+
const deletedAtReference = this.getDeletedAtReference(join);
|
|
99
|
+
if (deletedAtReference) {
|
|
100
|
+
currentWhere = this.addIsNotDeletedFilter(
|
|
101
|
+
currentWhere,
|
|
102
|
+
deletedAtReference
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return currentWhere;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private addDeletedAtCondition<T extends SelectQueryNode | UpdateQueryNode>(
|
|
111
|
+
node: T
|
|
112
|
+
): T {
|
|
113
|
+
let where = this.appendFromFilters(node.where, node);
|
|
114
|
+
where = this.appendJoinFilters(where, node);
|
|
115
|
+
return {
|
|
116
|
+
...node,
|
|
117
|
+
where,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private getJoinReference(node: JoinNode): ReferenceNode | null {
|
|
122
|
+
if (node.table === null) {
|
|
123
|
+
throw new SoftDeleteError("Join without a table");
|
|
124
|
+
}
|
|
125
|
+
return this.getDeletedAtReference(node.table);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private getAliasReference(
|
|
129
|
+
node: AliasNode,
|
|
130
|
+
deletedAtColumn: ColumnNode
|
|
131
|
+
): ReferenceNode | null {
|
|
132
|
+
if (!IdentifierNode.is(node.alias)) {
|
|
133
|
+
throw new SoftDeleteError("Alias without identifier", node.alias);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const tableAlias = node.alias.name;
|
|
137
|
+
if (!this.tableShouldHaveDeletedAt(tableAlias)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return ReferenceNode.create(deletedAtColumn, TableNode.create(tableAlias));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private getTableReference(
|
|
144
|
+
node: TableNode,
|
|
145
|
+
deletedAtColumn: ColumnNode
|
|
146
|
+
): ReferenceNode | null {
|
|
147
|
+
const tableName = node.table.identifier.name;
|
|
148
|
+
if (!this.tableShouldHaveDeletedAt(tableName)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return ReferenceNode.create(deletedAtColumn, node);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private getDeletedAtReference(node: OperationNode): ReferenceNode | null {
|
|
155
|
+
if (JoinNode.is(node)) {
|
|
156
|
+
return this.getJoinReference(node);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const deletedAtColumn = ColumnNode.create(this.deletedAtColumnName);
|
|
160
|
+
if (TableNode.is(node)) {
|
|
161
|
+
return this.getTableReference(node, deletedAtColumn);
|
|
162
|
+
}
|
|
163
|
+
if (AliasNode.is(node)) {
|
|
164
|
+
return this.getAliasReference(node, deletedAtColumn);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw new SoftDeleteError(
|
|
168
|
+
`Getting a reference to ${this.deletedAtColumnName} column for ${node.kind} is not supported yet.`,
|
|
169
|
+
node
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// oxlint-disable-next-line class-methods-use-this
|
|
174
|
+
private addIsNotDeletedFilter(
|
|
175
|
+
node: WhereNode | undefined,
|
|
176
|
+
deletedAtReference: ReferenceNode
|
|
177
|
+
): WhereNode {
|
|
178
|
+
const filterOperation = BinaryOperationNode.create(
|
|
179
|
+
deletedAtReference,
|
|
180
|
+
OperatorNode.create("is"),
|
|
181
|
+
RawNode.createWithSql("null")
|
|
182
|
+
);
|
|
183
|
+
if (node === null || node === undefined) {
|
|
184
|
+
return WhereNode.create(filterOperation);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return WhereNode.cloneWithOperation(node, "And", filterOperation);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private tableShouldHaveDeletedAt(tableName: string): boolean {
|
|
191
|
+
return (
|
|
192
|
+
!tableName.startsWith("kysely_") &&
|
|
193
|
+
!this.ignoredTables.includes(tableName)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class SoftDelete implements KyselyPlugin {
|
|
199
|
+
private transformer: SoftDeleteQueryTransformer;
|
|
200
|
+
|
|
201
|
+
constructor(
|
|
202
|
+
...args: ConstructorParameters<typeof SoftDeleteQueryTransformer>
|
|
203
|
+
) {
|
|
204
|
+
this.transformer = new SoftDeleteQueryTransformer(...args);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
transformQuery({ node }: PluginTransformQueryArgs): RootOperationNode {
|
|
208
|
+
return this.transformer.transformNode(node);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// oxlint-disable-next-line class-methods-use-this
|
|
212
|
+
transformResult({
|
|
213
|
+
result,
|
|
214
|
+
}: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
|
|
215
|
+
return Promise.resolve(result);
|
|
216
|
+
}
|
|
217
|
+
}
|