Skip to content

Commit

Permalink
Merge pull request #61 from outerbase/bwilmoth/macros-plugin
Browse files Browse the repository at this point in the history
SQL Macros Plugin
  • Loading branch information
Brayden authored Jan 15, 2025
2 parents 8e36487 + d1870a3 commit 496e9c9
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 1 deletion.
2 changes: 2 additions & 0 deletions dist/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { StudioPlugin } from '../plugins/studio'
export { WebSocketPlugin } from '../plugins/websocket'
export { SqlMacrosPlugin } from '../plugins/sql-macros'
export { StripeSubscriptionPlugin } from '../plugins/stripe'
53 changes: 53 additions & 0 deletions plugins/sql-macros/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 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.

## Usage

Add the SqlMacros plugin to your Starbase configuration:

```typescript
import { SqlMacros } from './plugins/sql-macros'
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`.
196 changes: 196 additions & 0 deletions plugins/sql-macros/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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 SqlMacrosPlugin 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<string> {
// 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 {
// 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.
const preparedSql = normalizedSql.replaceAll(
'$_exclude',
'__exclude'
)
const normalizedQuery = parser.astify(preparedSql)[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 and remove trailing semicolon to maintain original format
return parser.sqlify(normalizedQuery).replace(/;$/, '')
} catch (error) {
console.error('SQL parsing error:', error)
return sql
}
}
}
13 changes: 13 additions & 0 deletions plugins/sql-macros/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": "1.0.0",
"resources": {
"tables": {},
"secrets": {},
"variables": {}
},
"dependencies": {
"tables": {},
"secrets": {},
"variables": {}
}
}
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { corsPreflight } from './cors'
import { StarbasePlugin } from './plugin'
import { WebSocketPlugin } from '../plugins/websocket'
import { StudioPlugin } from '../plugins/studio'
import { StripeSubscriptionPlugin } from '../plugins/stripe'
import { SqlMacrosPlugin } from '../plugins/sql-macros'

export { StarbaseDBDurableObject } from './do'

Expand Down Expand Up @@ -176,6 +176,9 @@ export default {
password: env.STUDIO_PASS,
apiKey: env.ADMIN_AUTHORIZATION_TOKEN,
}),
new SqlMacrosPlugin({
preventSelectStar: false,
}),
] satisfies StarbasePlugin[]

const starbase = new StarbaseDB({
Expand Down

0 comments on commit 496e9c9

Please sign in to comment.