From e04aca7bf4ea173c30191a590408ffd9bfeabc9e Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Fri, 10 Jan 2025 16:42:54 -0500 Subject: [PATCH 1/8] SQL Macros Plugin --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index a3a7c3a..8c77340 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { corsPreflight } from './cors' import { StarbasePlugin } from './plugin' import { WebSocketPlugin } from '../plugins/websocket' import { StudioPlugin } from '../plugins/studio' +import { SqlMacros } from '../plugins/sql-macros' export { StarbaseDBDurableObject } from './do' @@ -175,6 +176,9 @@ export default { password: env.STUDIO_PASS, apiKey: env.ADMIN_AUTHORIZATION_TOKEN, }), + new SqlMacros({ + preventSelectStar: true, + }), ] satisfies StarbasePlugin[] const starbase = new StarbaseDB({ From 3265904e51b39fef8f8ec54f8977b7ef793b8099 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Fri, 10 Jan 2025 16:47:07 -0500 Subject: [PATCH 2/8] Include the macros file --- plugins/sql-macros/index.ts | 191 ++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 plugins/sql-macros/index.ts diff --git a/plugins/sql-macros/index.ts b/plugins/sql-macros/index.ts new file mode 100644 index 0000000..9e3a1d7 --- /dev/null +++ b/plugins/sql-macros/index.ts @@ -0,0 +1,191 @@ +import { + StarbaseApp, + StarbaseContext, + StarbaseDBConfiguration, +} from '../../src/handler' +import { StarbasePlugin } from '../../src/plugin' +import { DataSource, QueryResult } from '../../src/types' + +const parser = new (require('node-sql-parser').Parser)() + +export class SqlMacros extends StarbasePlugin { + config?: StarbaseDBConfiguration + + // Prevents SQL statements with `SELECT *` from being executed + preventSelectStar?: boolean + + constructor(opts?: { preventSelectStar: boolean }) { + super('starbasedb:sql-macros') + this.preventSelectStar = opts?.preventSelectStar + } + + override async register(app: StarbaseApp) { + app.use(async (c, next) => { + this.config = c?.get('config') + await next() + }) + } + + override async beforeQuery(opts: { + sql: string + params?: unknown[] + dataSource?: DataSource + config?: StarbaseDBConfiguration + }): Promise<{ sql: string; params?: unknown[] }> { + let { dataSource, sql, params } = opts + + // A data source is required for this plugin to operate successfully + if (!dataSource) { + return Promise.resolve({ + sql, + params, + }) + } + + sql = await this.replaceExcludeColumns(dataSource, sql, params) + + // Prevention of `SELECT *` statements is only enforced on non-admin users + // Admins should be able to continue running these statements in database + // tools such as Outerbase Studio. + if (this.preventSelectStar && this.config?.role !== 'admin') { + sql = this.checkSelectStar(sql, params) + } + + return Promise.resolve({ + sql, + params, + }) + } + + private checkSelectStar(sql: string, params?: unknown[]): string { + try { + const ast = parser.astify(sql)[0] + + // Only check SELECT statements + if (ast.type === 'select') { + const hasSelectStar = ast.columns.some( + (col: any) => + col.expr.type === 'star' || + (col.expr.type === 'column_ref' && + col.expr.column === '*') + ) + + if (hasSelectStar) { + throw new Error( + 'SELECT * is not allowed. Please specify explicit columns.' + ) + } + } + + return sql + } catch (error) { + // If the error is our SELECT * error, rethrow it + if ( + error instanceof Error && + error.message.includes('SELECT * is not allowed') + ) { + throw error + } + // For parsing errors or other issues, return original SQL + return sql + } + } + + private async replaceExcludeColumns( + dataSource: DataSource, + sql: string, + params?: unknown[] + ): Promise { + // Only currently works for internal data source (Durable Object SQLite) + if (dataSource.source !== 'internal') { + return sql + } + + // Special handling for pragma queries + if (sql.toLowerCase().includes('pragma_table_info')) { + return sql + } + + try { + // We allow users to write it `$_exclude` but convert it to `__exclude` so it can be + // parsed with the AST library without throwing an error. + sql = sql.replaceAll('$_exclude', '__exclude') + + const normalizedQuery = parser.astify(sql)[0] + + // Only process SELECT statements + if (normalizedQuery.type !== 'select') { + return sql + } + + // Find any columns using `__exclude` + const columns = normalizedQuery.columns + const excludeFnIdx = columns.findIndex( + (col: any) => + col.expr && + col.expr.type === 'function' && + col.expr.name === '__exclude' + ) + + if (excludeFnIdx === -1) { + return sql + } + + // Get the table name from the FROM clause + const tableName = normalizedQuery.from[0].table + let excludedColumns: string[] = [] + + try { + const excludeExpr = normalizedQuery.columns[excludeFnIdx].expr + + // Handle both array and single argument cases + const args = excludeExpr.args.value + + // Extract column name(s) from arguments + excludedColumns = Array.isArray(args) + ? args.map((arg: any) => arg.column) + : [args.column] + } catch (error: any) { + console.error('Error processing exclude arguments:', error) + console.error(error.stack) + return sql + } + + // Query database for all columns in this table + // This only works for the internal SQLite data source + const schemaQuery = ` + SELECT name as column_name + FROM pragma_table_info('${tableName}') + ` + + const allColumns = (await dataSource?.rpc.executeQuery({ + sql: schemaQuery, + })) as QueryResult[] + + const includedColumns = allColumns + .map((row: any) => row.column_name) + .filter((col: string) => { + const shouldInclude = !excludedColumns.includes( + col.toLowerCase() + ) + return shouldInclude + }) + + // Replace the __exclude function with explicit columns + normalizedQuery.columns.splice( + excludeFnIdx, + 1, + ...includedColumns.map((col: string) => ({ + expr: { type: 'column_ref', table: null, column: col }, + as: null, + })) + ) + + // Convert back to SQL + return parser.sqlify(normalizedQuery) + } catch (error) { + console.error('SQL parsing error:', error) + return sql + } + } +} From b0feb086477c5560b1371b20592b21dab2a91eed Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Sun, 12 Jan 2025 09:56:26 -0500 Subject: [PATCH 3/8] Include README for usage examples --- plugins/sql-macros/README.md | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 plugins/sql-macros/README.md diff --git a/plugins/sql-macros/README.md b/plugins/sql-macros/README.md new file mode 100644 index 0000000..4c82a6f --- /dev/null +++ b/plugins/sql-macros/README.md @@ -0,0 +1,59 @@ +# SQL Macros Plugin + +The SQL Macros Plugin for Starbase provides SQL query validation and enhancement features to improve code quality and prevent common SQL anti-patterns. + +## Installation + +```bash +npm install @starbase/sql-macros-plugin +``` + +## Usage + +Add the SqlMacros plugin to your Starbase configuration: + +```typescript +import { SqlMacros } from './plugins/sql-macros-plugin' +const plugins = [ + // ... other plugins + new SqlMacros({ + preventSelectStar: true, + }), +] satisfies StarbasePlugin[] +``` + +## Configuration Options + +| Option | Type | Default | Description | +| ------------------- | ------- | ------- | ---------------------------------------------------------------------------------------------- | +| `preventSelectStar` | boolean | `false` | When enabled, prevents the use of `SELECT *` in queries to encourage explicit column selection | + +## Features + +### Prevent SELECT \* + +When `preventSelectStar` is enabled, the plugin will raise an error if it encounters a `SELECT *` in your SQL queries. This encourages better practices by requiring explicit column selection. + +Example of invalid query: + +```sql +SELECT * FROM users; // Will raise an error +``` + +Example of valid query: + +```sql +SELECT id, username, email FROM users; // Correct usage +``` + +### Exclude Columns with `$_exclude` + +The `$_exclude` macro allows you to select all columns except the specified ones. This is useful when you want to avoid listing all columns explicitly except a few. + +Example usage: + +```sql +SELECT $_exclude(password, secret_key) FROM users; +``` + +In this example, all columns from the `users` table will be selected except for `password` and `secret_key`. From 126f0e33aaad8d563fb9c5782f30ca326c2d2960 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Sun, 12 Jan 2025 09:57:27 -0500 Subject: [PATCH 4/8] Allow SELECT * by default --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 8c77340..1fdb67d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -177,7 +177,7 @@ export default { apiKey: env.ADMIN_AUTHORIZATION_TOKEN, }), new SqlMacros({ - preventSelectStar: true, + preventSelectStar: false, }), ] satisfies StarbasePlugin[] From b4e17aa3c92c6c165a6deb22e2072fa369c39e4e Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Mon, 13 Jan 2025 12:17:23 -0500 Subject: [PATCH 5/8] Update README instructions --- plugins/sql-macros/README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/plugins/sql-macros/README.md b/plugins/sql-macros/README.md index 4c82a6f..fbf0eb1 100644 --- a/plugins/sql-macros/README.md +++ b/plugins/sql-macros/README.md @@ -2,18 +2,12 @@ The SQL Macros Plugin for Starbase provides SQL query validation and enhancement features to improve code quality and prevent common SQL anti-patterns. -## Installation - -```bash -npm install @starbase/sql-macros-plugin -``` - ## Usage Add the SqlMacros plugin to your Starbase configuration: ```typescript -import { SqlMacros } from './plugins/sql-macros-plugin' +import { SqlMacros } from './plugins/sql-macros' const plugins = [ // ... other plugins new SqlMacros({ From 42e07b12617b64a75ff226dea4a43d354b5ff2e1 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Mon, 13 Jan 2025 12:26:08 -0500 Subject: [PATCH 6/8] Include meta file --- plugins/sql-macros/meta.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 plugins/sql-macros/meta.json diff --git a/plugins/sql-macros/meta.json b/plugins/sql-macros/meta.json new file mode 100644 index 0000000..08f914c --- /dev/null +++ b/plugins/sql-macros/meta.json @@ -0,0 +1,12 @@ +{ + "resources": { + "tables": {}, + "secrets": {}, + "variables": {} + }, + "dependencies": { + "tables": {}, + "secrets": {}, + "variables": {} + } +} From 3a6b44f5d2ab1437d642039c425cac2852ad810e Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Wed, 15 Jan 2025 09:31:27 -0500 Subject: [PATCH 7/8] Merge --- dist/plugins.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dist/plugins.ts b/dist/plugins.ts index 15f4c22..b07590f 100644 --- a/dist/plugins.ts +++ b/dist/plugins.ts @@ -1,3 +1,4 @@ export { StudioPlugin } from '../plugins/studio' export { WebSocketPlugin } from '../plugins/websocket' export { SqlMacrosPlugin } from '../plugins/sql-macros' +export { StripeSubscriptionPlugin } from '../plugins/stripe' From d1870a3d96815286af9bdfa195097c1d0760e52f Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Wed, 15 Jan 2025 09:36:57 -0500 Subject: [PATCH 8/8] Resolve queries without trailing semicolon --- plugins/sql-macros/index.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/sql-macros/index.ts b/plugins/sql-macros/index.ts index be8cb0d..9dfbb85 100644 --- a/plugins/sql-macros/index.ts +++ b/plugins/sql-macros/index.ts @@ -107,11 +107,16 @@ export class SqlMacrosPlugin extends StarbasePlugin { } try { + // Add semicolon if missing + const normalizedSql = sql.trim().endsWith(';') ? sql : `${sql};` + // We allow users to write it `$_exclude` but convert it to `__exclude` so it can be // parsed with the AST library without throwing an error. - sql = sql.replaceAll('$_exclude', '__exclude') - - const normalizedQuery = parser.astify(sql)[0] + const preparedSql = normalizedSql.replaceAll( + '$_exclude', + '__exclude' + ) + const normalizedQuery = parser.astify(preparedSql)[0] // Only process SELECT statements if (normalizedQuery.type !== 'select') { @@ -181,8 +186,8 @@ export class SqlMacrosPlugin extends StarbasePlugin { })) ) - // Convert back to SQL - return parser.sqlify(normalizedQuery) + // Convert back to SQL and remove trailing semicolon to maintain original format + return parser.sqlify(normalizedQuery).replace(/;$/, '') } catch (error) { console.error('SQL parsing error:', error) return sql