feat: add SurrealDB adapter (#355)

This commit is contained in:
Oskar Gmerek
2024-11-02 22:52:45 +03:00
committed by GitHub
parent 64531ef5ae
commit e0977076f1
10 changed files with 3895 additions and 7706 deletions
+18
View File
@@ -1,6 +1,23 @@
version: '3.8'
services:
surrealdb:
container_name: surrealdb
entrypoint:
- /surreal
- start
- --allow-all
- --user
- root
- --pass
- root
- rocksdb:/surrealdb/surreal.db
volumes:
- ./_surrealdb:/surrealdb
image: surrealdb/surrealdb:latest
ports:
- 8000:8000
mongodb:
image: mongo:latest
container_name: mongodb
@@ -35,6 +52,7 @@ services:
- mysql_data:/var/lib/mysql
volumes:
_surrealdb:
mongodb_data:
postgres_data:
mysql_data:
+27
View File
@@ -142,6 +142,33 @@ export const auth = betterAuth({
})
```
### SurrealDB Adapter
The SurrealDB adapter expects a SurrealDB client instance with established connection. See <Link href="https://surrealdb.com/docs/sdk/javascript/start#initialize-the-sdk" target="_blank">SurrealDB docs</Link>.
SurrealDB database must be configured to allow CRUD operation of the connected user.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { Surreal } from "surrealdb";
import { surrealdbAdapter } from "better-auth/adapters/surrealdb";
const client = new Surreal();
const instance = await client.connect(
"http://localhost:8000", {
namespace: 'namespace',
database: 'database',
auth: {
username: 'root', // do not use root in production
password: 'surrealdb',
},
}
)
export const auth = betterAuth({
database: surrealdbAdapter(instance)
})
```
### MongoDB Adapter
The MongoDB adapter expects a mongodb client instance and a database name. The adapter will create a new database with the provided name if it doesn't exist.
+12
View File
@@ -169,6 +169,18 @@ If your database isn’t supported by Kysely, you can use an adapter to connect.
});
```
</Tab>
<Tab value="surrealDB">
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { surrealdbAdapter } from "better-auth/adapters/surrealdb";
import { surrealClient } from "@/db"; // your SurrealDB client
export const auth = betterAuth({
database: surrealdbAdapter(client)
});
```
</Tab>
</Tabs>
<Callout>
For optimal performance, use the built-in Kysely adapter if possible.
+9
View File
@@ -137,6 +137,11 @@
"types": "./dist/adapters/mongodb.d.ts",
"import": "./dist/adapters/mongodb.js",
"require": "./dist/adapters/mongodb.cjs"
},
"./adapters/surrealdb": {
"types": "./dist/adapters/surrealdb.d.ts",
"import": "./dist/adapters/surrealdb.js",
"require": "./dist/adapters/surrealdb.cjs"
}
},
"typesVersions": {
@@ -212,6 +217,9 @@
],
"adapters/mongodb": [
"./dist/adapters/mongodb.d.ts"
],
"adapters/surrealdb": [
"./dist/adapters/surrealdb.d.ts"
]
}
},
@@ -236,6 +244,7 @@
"react": "^18.3.1",
"react-native": "~0.74.6",
"solid-js": "^1.8.18",
"surrealdb": "^1.0.6",
"tsup": "^8.2.4",
"typescript": "5.6.1-rc",
"vitest": "^1.6.0",
@@ -1,5 +1,6 @@
export * from "./prisma-adapter";
export * from "./drizzle-adapter";
export * from "./mongodb-adapter";
export * from "./surrealdb-adapter";
export * from "./kysely-adapter";
export * from "./test";
@@ -0,0 +1,44 @@
import { beforeAll, describe } from "vitest";
import { Surreal } from "surrealdb";
import { surrealdbAdapter } from ".";
import { runAdapterTest } from "../test";
describe("adapter test", async () => {
const surrealClient = async (
url: string,
surrealDB: string,
surrealNS: string,
auth?: { username: string; password: string },
token?: string,
) => {
const client = new Surreal();
await client.connect(url, {
namespace: surrealNS,
database: surrealDB,
auth: auth || token,
});
return client;
};
const db = await surrealClient(
"http://127.0.0.1:8000",
"better_auth",
"better_auth",
{ username: "root", password: "root" },
);
async function cleanup() {
await db.query(
"DEFINE NAMESPACE IF NOT EXISTS better_auth; DEFINE DATABASE IF NOT EXISTS better_auth; DELETE user;",
);
}
beforeAll(async () => {
await cleanup();
});
const adapter = surrealdbAdapter(db);
await runAdapterTest({
adapter,
});
});
@@ -0,0 +1,233 @@
import { PreparedQuery, type Surreal } from "surrealdb";
import type { Adapter, Where } from "../../types";
/**
* Compose a SurrealDB where clause from a list of where objects.
*
* @param {Where[]} where - List of where objects.
* @param {string} model - Model name.
* @returns {string} Composed where clause as a string.
*/
function composeWhereClause(where: Where[], model: string): string {
if (!where.length) return "";
return where
.map(({ field, value, operator = "eq", connector = "AND" }, index) => {
const val =
typeof value === "string" ? `'${value.replace(/'/g, "\\'")}'` : value;
const mod = `'${model.replace(/'/g, "\\'")}'`;
const condition = {
eq: () =>
field === "id"
? `${field} = type::thing(${mod}, ${val})`
: `${field} = ${val}`,
in: () =>
field === "id"
? `${field} IN [${
Array.isArray(val)
? val.map((v) => `type::thing('${model}', '${v}')`).join(", ")
: `type::thing('${model}', '${val}')`
}]`
: `${field} IN [${
Array.isArray(value)
? value.map((v) => `'${v}'`).join(", ")
: val
}]`,
gt: () => `${field} > ${val}`,
gte: () => `${field} >= ${val}`,
lt: () => `${field} < ${val}`,
lte: () => `${field} <= ${val}`,
ne: () => `${field} != ${val}`,
contains: () => `${field} CONTAINS ${val}`,
starts_with: () => `string::starts_with(${field}, ${val})`,
ends_with: () => `string::ends_with(${field}, ${val})`,
}[operator.toLowerCase() as typeof operator]();
return index > 0 ? `${connector} ${condition}` : condition;
})
.join(" ");
}
/**
* Scans for the presence of an "id" field in the array of where clauses.
*
* @param {Where[]} where - Array of where objects.
* @returns {string | number | boolean | string[] | number[] | undefined}
* The value of the "id" field if present, otherwise undefined.
*/
function checkForIdInWhereClause(
where: Where[],
): string | number | boolean | string[] | number[] | undefined {
if (where.some(({ field }) => field === "id")) {
return where.find(({ field }) => field === "id")?.value;
}
}
export const surrealdbAdapter = (
surrealdb: Surreal,
opts?: {
/**
* Custom generateId function.
*
* If not provided, nanoid will be used.
* If set to false, the database's auto generated id will be used.
*
* @default nanoid
*/
generateId?: ((size?: number) => string) | false;
},
) => {
const db = surrealdb;
return {
id: "surrealdb",
async create(data) {
const { model, data: val } = data;
if (opts?.generateId !== undefined) {
val.id = opts.generateId ? opts.generateId() : undefined;
}
const query = new PreparedQuery(
`CREATE type::table($model) CONTENT $val `,
{
model: model,
val: val,
},
);
const response = await db.query<[any[]]>(query);
const result = response[0][0];
return result;
},
async findOne(data) {
const { model, where, select = [] } = data;
const wheres = composeWhereClause(where, model);
const query =
select.length > 0
? new PreparedQuery(
`SELECT type::fields($selects) FROM IF $thing {type::thing($model, $thing)} ELSE {type::table($model)} WHERE $wheres;`,
{
id: select.includes("id")
? 'meta::id("id") as id, '
: undefined,
thing: checkForIdInWhereClause(where) || undefined,
selects: select,
model: model,
wheres: wheres,
},
)
: new PreparedQuery(
`SELECT * FROM IF $thing {type::thing($model, $thing)} ELSE {type::table($model)} WHERE $wheres;`,
{
id: select.includes("id")
? 'meta::id("id") as id, '
: undefined,
thing: checkForIdInWhereClause(where) || undefined,
model: model,
wheres: wheres,
},
);
const response = await db.query<[any[]]>(query);
const result = response[0][0];
if (!result) {
return null;
}
return result;
},
async findMany(data) {
const { model, where, limit, offset, sortBy } = data;
const clauses = [];
if (where) {
const wheres = composeWhereClause(where, model);
clauses.push(`WHERE ${wheres}`);
}
if (sortBy !== undefined) {
clauses.push(`ORDER BY ${sortBy.field} ${sortBy.direction}`);
}
if (limit !== undefined) {
clauses.push(`LIMIT type::number('${limit}')`);
}
if (offset !== undefined) {
clauses.push(`START type::number('${offset}')`);
}
const query = new PreparedQuery(
`SELECT * FROM type::table($model) ${
clauses.length > 0 ? clauses.join(" ") : ""
}`,
{
model: model,
},
);
const response = await db.query<[any[]]>(query);
const result = response[0];
return result;
},
async update(data) {
const { model, where, update } = data;
const wheres = composeWhereClause(where, model);
if (!wheres)
throw new Error("Empty conditions - possible unintended operation");
if (update.id) {
update.id = undefined;
}
const query = new PreparedQuery(
`UPDATE type::table($model) MERGE { $update } WHERE $wheres`,
{
model: model,
update: update,
wheres: wheres,
},
);
const response = await db.query<[any[]]>(query);
const result = response[0][0];
return result;
},
async delete(data) {
const { model, where } = data;
const wheres = composeWhereClause(where, model);
if (!wheres)
throw new Error("Empty conditions - possible unintended operation");
const query = new PreparedQuery(
`DELETE type::table($model) WHERE ${wheres}`,
{
model: model,
wheres: wheres,
},
);
await db.query(query);
},
async deleteMany(data) {
const { model, where } = data;
const wheres = composeWhereClause(where, model);
if (!wheres)
throw new Error("Empty conditions - possible unintended operation");
const query = new PreparedQuery(
`DELETE type::table($model) WHERE ${wheres}`,
{
model: model,
wheres: wheres,
},
);
await db.query(query);
},
} satisfies Adapter;
};
+1 -1
View File
@@ -131,7 +131,7 @@ export async function runAdapterTest(opts: AdapterTestOptions) {
expect(res.length).toBe(1);
});
test("should fin many with operators", async () => {
test("should find many with operators", async () => {
const res = await adapter.findMany({
model: "user",
where: [
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig((env) => {
"adapters/prisma": "./src/adapters/prisma-adapter/index.ts",
"adapters/drizzle": "./src/adapters/drizzle-adapter/index.ts",
"adapters/mongodb": "./src/adapters/mongodb-adapter/index.ts",
"adapters/surrealdb": "./src/adapters/surrealdb-adapter/index.ts",
"adapters/kysely": "./src/adapters/kysely-adapter/index.ts",
db: "./src/db/index.ts",
oauth2: "./src/oauth2/index.ts",
+3549 -7705
View File
File diff suppressed because it is too large Load Diff