Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add executeSQL function #43

Merged
merged 2 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading