Skip to content

Commit

Permalink
Add executeSQL function (#43)
Browse files Browse the repository at this point in the history
* Add executeSQL function

* Throw errors if the SQL query fails
  • Loading branch information
thomaslombart authored Oct 11, 2024
1 parent f7d8e0a commit 864ee9f
Show file tree
Hide file tree
Showing 11 changed files with 244 additions and 82 deletions.
1 change: 1 addition & 0 deletions docs/utils-reference/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- [runAppleScript](utils-reference/functions/runAppleScript.md)
- [showFailureToast](utils-reference/functions/showFailureToast.md)
- [createDeeplink](utils-reference/functions/createDeeplink.md)
- [executeSQL](utils-reference/functions/executeSQL.md)
- [Icons](utils-reference/icons/README.md)
- [getAvatarIcon](utils-reference/icons/getAvatarIcon.md)
- [getFavicon](utils-reference/icons/getFavicon.md)
Expand Down
46 changes: 46 additions & 0 deletions docs/utils-reference/functions/executeSQL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# `executeSQL`

A function that executes a SQL query on a local SQLite database and returns the query result in JSON format.

## Signature

```ts
function executeSQL<T = unknown>(databasePath: string, query: string): Promise<T[]>
```

### Arguments

- `databasePath` is the path to the local SQL database.
- `query` is the SQL query to run on the database.

### Return

Returns a `Promise` that resolves to an array of objects representing the query results.

## Example

```typescript
import { closeMainWindow, Clipboard } from "@raycast/api";
import { executeSQL } from "@raycast/utils";
type Message = { body: string; code: string };
const DB_PATH = "/path/to/chat.db";
export default async function Command() {
const query = `
SELECT body, code
FROM message
ORDER BY date DESC
LIMIT 1;
`;
const messages = await executeSQL<Message>(DB_PATH, query);
if (messages.length > 0) {
const latestCode = messages[0].code;
await Clipboard.paste(latestCode);
await closeMainWindow();
}
}
```
4 changes: 4 additions & 0 deletions docs/utils-reference/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ npm install --save @raycast/utils

## Changelog

### v1.18.0

- Add a new [`executeSQL](./functions/executeSQL.md) function.

### v1.17.0

- Add a new [`createDeeplink`](./functions/createDeeplink.md) function.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@raycast/utils",
"version": "1.17.0",
"version": "1.18.0",
"description": "Set of utilities to streamline building Raycast extensions",
"author": "Raycast Technologies Ltd.",
"homepage": "https://developers.raycast.com/utils-reference",
Expand Down
34 changes: 34 additions & 0 deletions src/executeSQL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { baseExecuteSQL } from "./sql-utils";

/**
* Executes a SQL query on a local SQLite database and returns the query result in JSON format.
*
* @param databasePath - The path to the SQLite database file.
* @param query - The SQL query to execute.
* @returns A Promise that resolves to an array of objects representing the query results.
*
* @example
* ```typescript
* import { closeMainWindow, Clipboard } from "@raycast/api";
* import { executeSQL } from "@raycast/utils";
*
* type Message = { body: string; code: string };
*
* const DB_PATH = "/path/to/chat.db";
*
* export default async function Command() {
* const query = `SELECT body, code FROM ...`
*
* const messages = await executeSQL<Message>(DB_PATH, query);
*
* if (messages.length > 0) {
* const latestCode = messages[0].code;
* await Clipboard.paste(latestCode);
* await closeMainWindow();
* }
* }
* ```
*/
export function executeSQL<T = unknown>(databasePath: string, query: string) {
return baseExecuteSQL<T>(databasePath, query);
}
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ export * from "./icon";

export * from "./oauth";

export * from "./createDeeplink";
export * from "./executeSQL";
export * from "./run-applescript";
export * from "./showFailureToast";

export * from "./createDeeplink";

export type { AsyncState, MutatePromise } from "./types";
export type { Response } from "cross-fetch";
91 changes: 91 additions & 0 deletions src/sql-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { existsSync } from "node:fs";
import { copyFile, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import childProcess from "node:child_process";
import path from "node:path";
import { getSpawnedPromise, getSpawnedResult } from "./exec-utils";
import { hash } from "./helpers";

export class PermissionError extends Error {
constructor(message: string) {
super(message);
this.name = "PermissionError";
}
}

export function isPermissionError(error: unknown): error is PermissionError {
return error instanceof Error && error.name === "PermissionError";
}

export async function baseExecuteSQL<T = unknown>(
databasePath: string,
query: string,
options?: {
signal?: AbortSignal;
},
): Promise<T[]> {
if (!existsSync(databasePath)) {
throw new Error("The database does not exist");
}

const abortSignal = options?.signal;
let workaroundCopiedDb: string | undefined;

let spawned = childProcess.spawn("sqlite3", ["--json", "--readonly", databasePath, query], { signal: abortSignal });
let spawnedPromise = getSpawnedPromise(spawned);
let [{ error, exitCode, signal }, stdoutResult, stderrResult] = await getSpawnedResult<string>(
spawned,
{ encoding: "utf-8" },
spawnedPromise,
);
checkAborted(abortSignal);

if (stderrResult.match("(5)") || stderrResult.match("(14)")) {
// That means that the DB is busy because of another app is locking it
// This happens when Chrome or Arc is opened: they lock the History db.
// As an ugly workaround, we duplicate the file and read that instead
// (with vfs unix - none to just not care about locks)
if (!workaroundCopiedDb) {
const tempFolder = path.join(os.tmpdir(), "useSQL", hash(databasePath));
await mkdir(tempFolder, { recursive: true });
checkAborted(abortSignal);

workaroundCopiedDb = path.join(tempFolder, "db.db");
await copyFile(databasePath, workaroundCopiedDb);

await writeFile(workaroundCopiedDb + "-shm", "");
await writeFile(workaroundCopiedDb + "-wal", "");

checkAborted(abortSignal);
}

spawned = childProcess.spawn("sqlite3", ["--json", "--readonly", "--vfs", "unix-none", workaroundCopiedDb, query], {
signal: abortSignal,
});
spawnedPromise = getSpawnedPromise(spawned);
[{ error, exitCode, signal }, stdoutResult, stderrResult] = await getSpawnedResult<string>(
spawned,
{ encoding: "utf-8" },
spawnedPromise,
);
checkAborted(abortSignal);
}

if (error || exitCode !== 0 || signal !== null) {
if (stderrResult.includes("authorization denied")) {
throw new PermissionError("You do not have permission to access the database.");
} else {
throw new Error(stderrResult || "Unknown error");
}
}

return JSON.parse(stdoutResult.trim() || "[]") as T[];
}

function checkAborted(signal?: AbortSignal) {
if (signal?.aborted) {
const error = new Error("aborted");
error.name = "AbortError";
throw error;
}
}
81 changes: 3 additions & 78 deletions src/useSQL.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { List, ActionPanel, Action, environment, MenuBarExtra, Icon, open, LaunchType } from "@raycast/api";
import { List, MenuBarExtra, Icon, open, LaunchType, environment, ActionPanel, Action } from "@raycast/api";
import { existsSync } from "node:fs";
import { copyFile, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import childProcess from "node:child_process";
import path from "node:path";
import { useRef, useState, useCallback, useMemo } from "react";
import { usePromise, PromiseOptions } from "./usePromise";
import { useLatest } from "./useLatest";
import { getSpawnedPromise, getSpawnedResult } from "./exec-utils";
import { showFailureToast } from "./showFailureToast";
import { hash } from "./helpers";
import { baseExecuteSQL, PermissionError, isPermissionError } from "./sql-utils";

/**
* Executes a query on a local SQL database and returns the {@link AsyncState} corresponding to the query of the command. The last value will be kept between command runs.
Expand Down Expand Up @@ -88,62 +84,10 @@ export function useSQL<T = unknown>(
if (!existsSync(databasePath)) {
throw new Error("The database does not exist");
}
let workaroundCopiedDb: string | undefined = undefined;

return async (databasePath: string, query: string) => {
const abortSignal = abortable.current?.signal;
const spawned = childProcess.spawn("sqlite3", ["--json", "--readonly", databasePath, query], {
signal: abortSignal,
});
const spawnedPromise = getSpawnedPromise(spawned);
let [{ error, exitCode, signal }, stdoutResult, stderrResult] = await getSpawnedResult<string>(
spawned,
{ encoding: "utf-8" },
spawnedPromise,
);

checkAborted(abortSignal);

if (stderrResult.match("(5)") || stderrResult.match("(14)")) {
// That means that the DB is busy because of another app is locking it
// This happens when Chrome or Arc is opened: they lock the History db.
// As an ugly workaround, we duplicate the file and read that instead
// (with vfs unix - none to just not care about locks)
if (!workaroundCopiedDb) {
const tempFolder = path.join(os.tmpdir(), "useSQL", hash(databasePath));
await mkdir(tempFolder, { recursive: true });
checkAborted(abortSignal);

workaroundCopiedDb = path.join(tempFolder, "db.db");
await copyFile(databasePath, workaroundCopiedDb);

// needed for certain db
await writeFile(workaroundCopiedDb + "-shm", "");
await writeFile(workaroundCopiedDb + "-wal", "");

checkAborted(abortSignal);
}
const spawned = childProcess.spawn(
"sqlite3",
["--json", "--readonly", "--vfs", "unix-none", workaroundCopiedDb, query],
{
signal: abortSignal,
},
);
const spawnedPromise = getSpawnedPromise(spawned);
[{ error, exitCode, signal }, stdoutResult, stderrResult] = await getSpawnedResult<string>(
spawned,
{ encoding: "utf-8" },
spawnedPromise,
);
checkAborted(abortSignal);
}

if (error || exitCode !== 0 || signal !== null) {
throw new Error(stderrResult);
}

return JSON.parse(stdoutResult.trim() || "[]") as T[];
return baseExecuteSQL<T>(databasePath, query, { signal: abortSignal });
};
}, [databasePath]);

Expand All @@ -153,17 +97,6 @@ export function useSQL<T = unknown>(
};
}

class PermissionError extends Error {
constructor(message: string) {
super(message);
this.name = "PermissionError";
}
}

function isPermissionError(error: unknown) {
return error instanceof Error && error.name === "PermissionError";
}

const macosVenturaAndLater = parseInt(os.release().split(".")[0]) >= 22;
const preferencesString = macosVenturaAndLater ? "Settings" : "Preferences";

Expand Down Expand Up @@ -219,11 +152,3 @@ function PermissionErrorScreen(props: { priming?: string }) {
</List>
);
}

function checkAborted(signal?: AbortSignal) {
if (signal?.aborted) {
const error = new Error("aborted");
error.name = "AbortError";
throw error;
}
}
9 changes: 8 additions & 1 deletion tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,19 @@
"mode": "view"
},
{
"name": "sql",
"name": "use-sql",
"title": "useSQL",
"subtitle": "Utils Smoke Tests",
"description": "Utils Smoke Tests",
"mode": "view"
},
{
"name": "execute-sql",
"title": "executeSQL",
"subtitle": "Utils Smoke Tests",
"description": "Utils Smoke Tests",
"mode": "view"
},
{
"name": "applescript",
"title": "runAppleScript",
Expand Down
Loading

0 comments on commit 864ee9f

Please sign in to comment.