@better-auth/kysely-adapter 1.5.0-beta.9

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.
@@ -0,0 +1,639 @@
1
+ import type { BetterAuthOptions } from "@better-auth/core";
2
+ import type {
3
+ AdapterFactoryCustomizeAdapterCreator,
4
+ AdapterFactoryOptions,
5
+ DBAdapter,
6
+ DBAdapterDebugLogOption,
7
+ JoinConfig,
8
+ Where,
9
+ } from "@better-auth/core/db/adapter";
10
+ import { createAdapterFactory } from "@better-auth/core/db/adapter";
11
+ import type {
12
+ InsertQueryBuilder,
13
+ Kysely,
14
+ RawBuilder,
15
+ UpdateQueryBuilder,
16
+ } from "kysely";
17
+ import { sql } from "kysely";
18
+ import type { KyselyDatabaseType } from "./types";
19
+
20
+ interface KyselyAdapterConfig {
21
+ /**
22
+ * Database type.
23
+ */
24
+ type?: KyselyDatabaseType | undefined;
25
+ /**
26
+ * Enable debug logs for the adapter
27
+ *
28
+ * @default false
29
+ */
30
+ debugLogs?: DBAdapterDebugLogOption | undefined;
31
+ /**
32
+ * Use plural for table names.
33
+ *
34
+ * @default false
35
+ */
36
+ usePlural?: boolean | undefined;
37
+ /**
38
+ * Whether to execute multiple operations in a transaction.
39
+ *
40
+ * If the database doesn't support transactions,
41
+ * set this to `false` and operations will be executed sequentially.
42
+ * @default false
43
+ */
44
+ transaction?: boolean | undefined;
45
+ }
46
+
47
+ export const kyselyAdapter = (
48
+ db: Kysely<any>,
49
+ config?: KyselyAdapterConfig | undefined,
50
+ ) => {
51
+ let lazyOptions: BetterAuthOptions | null = null;
52
+ const createCustomAdapter = (
53
+ db: Kysely<any>,
54
+ ): AdapterFactoryCustomizeAdapterCreator => {
55
+ return ({
56
+ getFieldName,
57
+ schema,
58
+ getDefaultFieldName,
59
+ getDefaultModelName,
60
+ getFieldAttributes,
61
+ getModelName,
62
+ }) => {
63
+ const selectAllJoins = (join: JoinConfig | undefined) => {
64
+ // Use selectAll which will handle column naming appropriately
65
+ const allSelects: RawBuilder<unknown>[] = [];
66
+ const allSelectsStr: {
67
+ joinModel: string;
68
+ joinModelRef: string;
69
+ fieldName: string;
70
+ }[] = [];
71
+ if (join) {
72
+ for (const [joinModel, _] of Object.entries(join)) {
73
+ const fields = schema[getDefaultModelName(joinModel)]?.fields;
74
+ const [_joinModelSchema, joinModelName] = joinModel.includes(".")
75
+ ? joinModel.split(".")
76
+ : [undefined, joinModel];
77
+
78
+ if (!fields) continue;
79
+ fields.id = { type: "string" }; // make sure there is at least an id field
80
+ for (const [field, fieldAttr] of Object.entries(fields)) {
81
+ allSelects.push(
82
+ sql`${sql.ref(`join_${joinModelName}`)}.${sql.ref(fieldAttr.fieldName || field)} as ${sql.ref(`_joined_${joinModelName}_${fieldAttr.fieldName || field}`)}`,
83
+ );
84
+ allSelectsStr.push({
85
+ joinModel: joinModel,
86
+ joinModelRef: joinModelName,
87
+ fieldName: fieldAttr.fieldName || field,
88
+ });
89
+ }
90
+ }
91
+ }
92
+ return { allSelectsStr, allSelects };
93
+ };
94
+
95
+ const withReturning = async (
96
+ values: Record<string, any>,
97
+ builder:
98
+ | InsertQueryBuilder<any, any, any>
99
+ | UpdateQueryBuilder<any, string, string, any>,
100
+ model: string,
101
+ where: Where[],
102
+ ) => {
103
+ let res: any;
104
+ if (config?.type === "mysql") {
105
+ // This isn't good, but kysely doesn't support returning in mysql and it doesn't return the inserted id.
106
+ // Change this if there is a better way.
107
+ await builder.execute();
108
+ const field = values.id
109
+ ? "id"
110
+ : where.length > 0 && where[0]?.field
111
+ ? where[0].field
112
+ : "id";
113
+
114
+ if (!values.id && where.length === 0) {
115
+ res = await db
116
+ .selectFrom(model)
117
+ .selectAll()
118
+ .orderBy(getFieldName({ model, field }), "desc")
119
+ .limit(1)
120
+ .executeTakeFirst();
121
+ return res;
122
+ }
123
+
124
+ const value = values[field] || where[0]?.value;
125
+ res = await db
126
+ .selectFrom(model)
127
+ .selectAll()
128
+ .orderBy(getFieldName({ model, field }), "desc")
129
+ .where(getFieldName({ model, field }), "=", value)
130
+ .limit(1)
131
+ .executeTakeFirst();
132
+ return res;
133
+ }
134
+ if (config?.type === "mssql") {
135
+ res = await builder.outputAll("inserted").executeTakeFirst();
136
+ return res;
137
+ }
138
+ res = await builder.returningAll().executeTakeFirst();
139
+ return res;
140
+ };
141
+ function convertWhereClause(model: string, w?: Where[] | undefined) {
142
+ if (!w)
143
+ return {
144
+ and: null,
145
+ or: null,
146
+ };
147
+
148
+ const conditions = {
149
+ and: [] as any[],
150
+ or: [] as any[],
151
+ };
152
+
153
+ w.forEach((condition) => {
154
+ const {
155
+ field: _field,
156
+ value: _value,
157
+ operator = "=",
158
+ connector = "AND",
159
+ } = condition;
160
+ const value: any = _value;
161
+ const field: string | any = getFieldName({
162
+ model,
163
+ field: _field,
164
+ });
165
+
166
+ const expr = (eb: any) => {
167
+ const f = `${model}.${field}`;
168
+ if (operator.toLowerCase() === "in") {
169
+ return eb(f, "in", Array.isArray(value) ? value : [value]);
170
+ }
171
+
172
+ if (operator.toLowerCase() === "not_in") {
173
+ return eb(f, "not in", Array.isArray(value) ? value : [value]);
174
+ }
175
+
176
+ if (operator === "contains") {
177
+ return eb(f, "like", `%${value}%`);
178
+ }
179
+
180
+ if (operator === "starts_with") {
181
+ return eb(f, "like", `${value}%`);
182
+ }
183
+
184
+ if (operator === "ends_with") {
185
+ return eb(f, "like", `%${value}`);
186
+ }
187
+
188
+ if (operator === "eq") {
189
+ return eb(f, "=", value);
190
+ }
191
+
192
+ if (operator === "ne") {
193
+ return eb(f, "<>", value);
194
+ }
195
+
196
+ if (operator === "gt") {
197
+ return eb(f, ">", value);
198
+ }
199
+
200
+ if (operator === "gte") {
201
+ return eb(f, ">=", value);
202
+ }
203
+
204
+ if (operator === "lt") {
205
+ return eb(f, "<", value);
206
+ }
207
+
208
+ if (operator === "lte") {
209
+ return eb(f, "<=", value);
210
+ }
211
+
212
+ return eb(f, operator, value);
213
+ };
214
+
215
+ if (connector === "OR") {
216
+ conditions.or.push(expr);
217
+ } else {
218
+ conditions.and.push(expr);
219
+ }
220
+ });
221
+
222
+ return {
223
+ and: conditions.and.length ? conditions.and : null,
224
+ or: conditions.or.length ? conditions.or : null,
225
+ };
226
+ }
227
+
228
+ function processJoinedResults(
229
+ rows: any[],
230
+ joinConfig: JoinConfig | undefined,
231
+ allSelectsStr: {
232
+ joinModel: string;
233
+ joinModelRef: string;
234
+ fieldName: string;
235
+ }[],
236
+ ) {
237
+ if (!joinConfig || !rows.length) {
238
+ return rows;
239
+ }
240
+
241
+ // Group rows by main model ID
242
+ const groupedByMainId = new Map<string, any>();
243
+
244
+ for (const currentRow of rows) {
245
+ // Separate main model columns from joined columns
246
+ const mainModelFields: Record<string, any> = {};
247
+ const joinedModelFields: Record<string, Record<string, any>> = {};
248
+
249
+ // Initialize joined model fields map
250
+ for (const [joinModel] of Object.entries(joinConfig)) {
251
+ joinedModelFields[getModelName(joinModel)] = {};
252
+ }
253
+
254
+ // Distribute all columns - collect complete objects per model
255
+ for (const [key, value] of Object.entries(currentRow)) {
256
+ const keyStr = String(key);
257
+ let assigned = false;
258
+
259
+ // Check if this is a joined column
260
+ for (const {
261
+ joinModel,
262
+ fieldName,
263
+ joinModelRef,
264
+ } of allSelectsStr) {
265
+ if (keyStr === `_joined_${joinModelRef}_${fieldName}`) {
266
+ joinedModelFields[getModelName(joinModel)]![
267
+ getFieldName({
268
+ model: joinModel,
269
+ field: fieldName,
270
+ })
271
+ ] = value;
272
+ assigned = true;
273
+ break;
274
+ }
275
+ }
276
+
277
+ if (!assigned) {
278
+ mainModelFields[key] = value;
279
+ }
280
+ }
281
+
282
+ const mainId = mainModelFields.id;
283
+ if (!mainId) continue;
284
+
285
+ // Initialize or get existing entry for this main model
286
+ if (!groupedByMainId.has(mainId)) {
287
+ const entry: Record<string, any> = { ...mainModelFields };
288
+
289
+ // Initialize joined models based on uniqueness
290
+ for (const [joinModel, joinAttr] of Object.entries(joinConfig)) {
291
+ entry[getModelName(joinModel)] =
292
+ joinAttr.relation === "one-to-one" ? null : [];
293
+ }
294
+
295
+ groupedByMainId.set(mainId, entry);
296
+ }
297
+
298
+ const entry = groupedByMainId.get(mainId)!;
299
+
300
+ // Add joined records to the entry
301
+ for (const [joinModel, joinAttr] of Object.entries(joinConfig)) {
302
+ const isUnique = joinAttr.relation === "one-to-one";
303
+ const limit = joinAttr.limit ?? 100;
304
+
305
+ const joinedObj = joinedModelFields[getModelName(joinModel)];
306
+
307
+ const hasData =
308
+ joinedObj &&
309
+ Object.keys(joinedObj).length > 0 &&
310
+ Object.values(joinedObj).some(
311
+ (value) => value !== null && value !== undefined,
312
+ );
313
+
314
+ if (isUnique) {
315
+ entry[getModelName(joinModel)] = hasData ? joinedObj : null;
316
+ } else {
317
+ // For arrays, append if not already there (deduplicate by id) and respect limit
318
+ const joinModelName = getModelName(joinModel);
319
+ if (Array.isArray(entry[joinModelName]) && hasData) {
320
+ // Check if we've reached the limit before processing
321
+ if (entry[joinModelName].length >= limit) {
322
+ continue;
323
+ }
324
+
325
+ // Get the id field name using getFieldName to ensure correct transformation
326
+ const idFieldName = getFieldName({
327
+ model: joinModel,
328
+ field: "id",
329
+ });
330
+ const joinedId = joinedObj[idFieldName];
331
+
332
+ // Only deduplicate if we have an id field
333
+ if (joinedId) {
334
+ const exists = entry[joinModelName].some(
335
+ (item: any) => item[idFieldName] === joinedId,
336
+ );
337
+ if (!exists && entry[joinModelName].length < limit) {
338
+ entry[joinModelName].push(joinedObj);
339
+ }
340
+ } else {
341
+ // If no id field, still add the object if it has data and limit not reached
342
+ if (entry[joinModelName].length < limit) {
343
+ entry[joinModelName].push(joinedObj);
344
+ }
345
+ }
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ const result = Array.from(groupedByMainId.values());
352
+
353
+ // Apply final limit to non-unique join arrays as a safety measure
354
+ for (const entry of result) {
355
+ for (const [joinModel, joinAttr] of Object.entries(joinConfig)) {
356
+ if (joinAttr.relation !== "one-to-one") {
357
+ const joinModelName = getModelName(joinModel);
358
+ if (Array.isArray(entry[joinModelName])) {
359
+ const limit = joinAttr.limit ?? 100;
360
+ if (entry[joinModelName].length > limit) {
361
+ entry[joinModelName] = entry[joinModelName].slice(0, limit);
362
+ }
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ return result;
369
+ }
370
+
371
+ return {
372
+ async create({ data, model }) {
373
+ const builder = db.insertInto(model).values(data);
374
+ const returned = await withReturning(data, builder, model, []);
375
+ return returned;
376
+ },
377
+ async findOne({ model, where, select, join }) {
378
+ const { and, or } = convertWhereClause(model, where);
379
+ let query: any = db
380
+ .selectFrom((eb) => {
381
+ let b = eb.selectFrom(model);
382
+ if (and) {
383
+ b = b.where((eb: any) =>
384
+ eb.and(and.map((expr: any) => expr(eb))),
385
+ );
386
+ }
387
+ if (or) {
388
+ b = b.where((eb: any) =>
389
+ eb.or(or.map((expr: any) => expr(eb))),
390
+ );
391
+ }
392
+ return b.selectAll().as("primary");
393
+ })
394
+ .selectAll("primary");
395
+
396
+ if (join) {
397
+ for (const [joinModel, joinAttr] of Object.entries(join)) {
398
+ const [_joinModelSchema, joinModelName] = joinModel.includes(".")
399
+ ? joinModel.split(".")
400
+ : [undefined, joinModel];
401
+
402
+ query = query.leftJoin(
403
+ `${joinModel} as join_${joinModelName}`,
404
+ (join: any) =>
405
+ join.onRef(
406
+ `join_${joinModelName}.${joinAttr.on.to}`,
407
+ "=",
408
+ `primary.${joinAttr.on.from}`,
409
+ ),
410
+ );
411
+ }
412
+ }
413
+
414
+ const { allSelectsStr, allSelects } = selectAllJoins(join);
415
+ query = query.select(allSelects);
416
+
417
+ const res = await query.execute();
418
+ if (!res || !Array.isArray(res) || res.length === 0) return null;
419
+
420
+ // Get the first row from the result array
421
+ const row = res[0];
422
+
423
+ if (join) {
424
+ const processedRows = processJoinedResults(
425
+ res,
426
+ join,
427
+ allSelectsStr,
428
+ );
429
+
430
+ return processedRows[0] as any;
431
+ }
432
+
433
+ return row as any;
434
+ },
435
+ async findMany({ model, where, limit, offset, sortBy, join }) {
436
+ const { and, or } = convertWhereClause(model, where);
437
+ let query: any = db
438
+ .selectFrom((eb) => {
439
+ let b = eb.selectFrom(model);
440
+
441
+ if (config?.type === "mssql") {
442
+ if (offset !== undefined) {
443
+ if (!sortBy) {
444
+ b = b.orderBy(getFieldName({ model, field: "id" }));
445
+ }
446
+ b = b.offset(offset).fetch(limit || 100);
447
+ } else if (limit !== undefined) {
448
+ b = b.top(limit);
449
+ }
450
+ } else {
451
+ if (limit !== undefined) {
452
+ b = b.limit(limit);
453
+ }
454
+ if (offset !== undefined) {
455
+ b = b.offset(offset);
456
+ }
457
+ }
458
+
459
+ if (sortBy?.field) {
460
+ b = b.orderBy(
461
+ `${getFieldName({ model, field: sortBy.field })}`,
462
+ sortBy.direction,
463
+ );
464
+ }
465
+
466
+ if (and) {
467
+ b = b.where((eb: any) =>
468
+ eb.and(and.map((expr: any) => expr(eb))),
469
+ );
470
+ }
471
+
472
+ if (or) {
473
+ b = b.where((eb: any) =>
474
+ eb.or(or.map((expr: any) => expr(eb))),
475
+ );
476
+ }
477
+
478
+ return b.selectAll().as("primary");
479
+ })
480
+ .selectAll("primary");
481
+
482
+ if (join) {
483
+ for (const [joinModel, joinAttr] of Object.entries(join)) {
484
+ // it's possible users provide a schema name in the model name (`<schema>.<model>`)
485
+ const [_joinModelSchema, joinModelName] = joinModel.includes(".")
486
+ ? joinModel.split(".")
487
+ : [undefined, joinModel];
488
+
489
+ query = query.leftJoin(
490
+ `${joinModel} as join_${joinModelName}`,
491
+ (join: any) =>
492
+ join.onRef(
493
+ `join_${joinModelName}.${joinAttr.on.to}`,
494
+ "=",
495
+ `primary.${joinAttr.on.from}`,
496
+ ),
497
+ );
498
+ }
499
+ }
500
+
501
+ const { allSelectsStr, allSelects } = selectAllJoins(join);
502
+
503
+ query = query.select(allSelects);
504
+
505
+ if (sortBy?.field) {
506
+ query = query.orderBy(
507
+ `${getFieldName({ model, field: sortBy.field })}`,
508
+ sortBy.direction,
509
+ );
510
+ }
511
+
512
+ const res = await query.execute();
513
+
514
+ if (!res) return [];
515
+ if (join) return processJoinedResults(res, join, allSelectsStr);
516
+ return res;
517
+ },
518
+ async update({ model, where, update: values }) {
519
+ const { and, or } = convertWhereClause(model, where);
520
+
521
+ let query = db.updateTable(model).set(values as any);
522
+ if (and) {
523
+ query = query.where((eb) => eb.and(and.map((expr) => expr(eb))));
524
+ }
525
+ if (or) {
526
+ query = query.where((eb) => eb.or(or.map((expr) => expr(eb))));
527
+ }
528
+ return await withReturning(values as any, query, model, where);
529
+ },
530
+ async updateMany({ model, where, update: values }) {
531
+ const { and, or } = convertWhereClause(model, where);
532
+ let query = db.updateTable(model).set(values as any);
533
+ if (and) {
534
+ query = query.where((eb) => eb.and(and.map((expr) => expr(eb))));
535
+ }
536
+ if (or) {
537
+ query = query.where((eb) => eb.or(or.map((expr) => expr(eb))));
538
+ }
539
+ const res = (await query.executeTakeFirst()).numUpdatedRows;
540
+ return res > Number.MAX_SAFE_INTEGER
541
+ ? Number.MAX_SAFE_INTEGER
542
+ : Number(res);
543
+ },
544
+ async count({ model, where }) {
545
+ const { and, or } = convertWhereClause(model, where);
546
+ let query = db
547
+ .selectFrom(model)
548
+ // a temporal solution for counting other than "*" - see more - https://www.sqlite.org/quirks.html#double_quoted_string_literals_are_accepted
549
+ .select(db.fn.count("id").as("count"));
550
+ if (and) {
551
+ query = query.where((eb) => eb.and(and.map((expr) => expr(eb))));
552
+ }
553
+ if (or) {
554
+ query = query.where((eb) => eb.or(or.map((expr) => expr(eb))));
555
+ }
556
+ const res = await query.execute();
557
+ if (typeof res[0]!.count === "number") {
558
+ return res[0]!.count;
559
+ }
560
+ if (typeof res[0]!.count === "bigint") {
561
+ return Number(res[0]!.count);
562
+ }
563
+ return parseInt(res[0]!.count);
564
+ },
565
+ async delete({ model, where }) {
566
+ const { and, or } = convertWhereClause(model, where);
567
+ let query = db.deleteFrom(model);
568
+ if (and) {
569
+ query = query.where((eb) => eb.and(and.map((expr) => expr(eb))));
570
+ }
571
+
572
+ if (or) {
573
+ query = query.where((eb) => eb.or(or.map((expr) => expr(eb))));
574
+ }
575
+ await query.execute();
576
+ },
577
+ async deleteMany({ model, where }) {
578
+ const { and, or } = convertWhereClause(model, where);
579
+ let query = db.deleteFrom(model);
580
+ if (and) {
581
+ query = query.where((eb) => eb.and(and.map((expr) => expr(eb))));
582
+ }
583
+ if (or) {
584
+ query = query.where((eb) => eb.or(or.map((expr) => expr(eb))));
585
+ }
586
+ const res = (await query.executeTakeFirst()).numDeletedRows;
587
+ return res > Number.MAX_SAFE_INTEGER
588
+ ? Number.MAX_SAFE_INTEGER
589
+ : Number(res);
590
+ },
591
+ options: config,
592
+ };
593
+ };
594
+ };
595
+ let adapterOptions: AdapterFactoryOptions | null = null;
596
+ adapterOptions = {
597
+ config: {
598
+ adapterId: "kysely",
599
+ adapterName: "Kysely Adapter",
600
+ usePlural: config?.usePlural,
601
+ debugLogs: config?.debugLogs,
602
+ supportsBooleans:
603
+ config?.type === "sqlite" ||
604
+ config?.type === "mssql" ||
605
+ config?.type === "mysql" ||
606
+ !config?.type
607
+ ? false
608
+ : true,
609
+ supportsDates:
610
+ config?.type === "sqlite" || config?.type === "mssql" || !config?.type
611
+ ? false
612
+ : true,
613
+ supportsJSON:
614
+ config?.type === "postgres"
615
+ ? true // even if there is JSON support, only pg supports passing direct json, all others must stringify
616
+ : false,
617
+ supportsArrays: false, // Even if field supports JSON, we must pass stringified arrays to the database.
618
+ supportsUUIDs: config?.type === "postgres" ? true : false,
619
+ transaction: config?.transaction
620
+ ? (cb) =>
621
+ db.transaction().execute((trx) => {
622
+ const adapter = createAdapterFactory({
623
+ config: adapterOptions!.config,
624
+ adapter: createCustomAdapter(trx),
625
+ })(lazyOptions!);
626
+ return cb(adapter);
627
+ })
628
+ : false,
629
+ },
630
+ adapter: createCustomAdapter(db),
631
+ };
632
+
633
+ const adapter = createAdapterFactory(adapterOptions);
634
+
635
+ return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
636
+ lazyOptions = options;
637
+ return adapter(options);
638
+ };
639
+ };