Skip to content

Commit 496e9c9

Browse files
authored
Merge pull request #61 from outerbase/bwilmoth/macros-plugin
SQL Macros Plugin
2 parents 8e36487 + d1870a3 commit 496e9c9

File tree

5 files changed

+268
-1
lines changed

5 files changed

+268
-1
lines changed

dist/plugins.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { StudioPlugin } from '../plugins/studio'
22
export { WebSocketPlugin } from '../plugins/websocket'
3+
export { SqlMacrosPlugin } from '../plugins/sql-macros'
4+
export { StripeSubscriptionPlugin } from '../plugins/stripe'

plugins/sql-macros/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# SQL Macros Plugin
2+
3+
The SQL Macros Plugin for Starbase provides SQL query validation and enhancement features to improve code quality and prevent common SQL anti-patterns.
4+
5+
## Usage
6+
7+
Add the SqlMacros plugin to your Starbase configuration:
8+
9+
```typescript
10+
import { SqlMacros } from './plugins/sql-macros'
11+
const plugins = [
12+
// ... other plugins
13+
new SqlMacros({
14+
preventSelectStar: true,
15+
}),
16+
] satisfies StarbasePlugin[]
17+
```
18+
19+
## Configuration Options
20+
21+
| Option | Type | Default | Description |
22+
| ------------------- | ------- | ------- | ---------------------------------------------------------------------------------------------- |
23+
| `preventSelectStar` | boolean | `false` | When enabled, prevents the use of `SELECT *` in queries to encourage explicit column selection |
24+
25+
## Features
26+
27+
### Prevent SELECT \*
28+
29+
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.
30+
31+
Example of invalid query:
32+
33+
```sql
34+
SELECT * FROM users; // Will raise an error
35+
```
36+
37+
Example of valid query:
38+
39+
```sql
40+
SELECT id, username, email FROM users; // Correct usage
41+
```
42+
43+
### Exclude Columns with `$_exclude`
44+
45+
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.
46+
47+
Example usage:
48+
49+
```sql
50+
SELECT $_exclude(password, secret_key) FROM users;
51+
```
52+
53+
In this example, all columns from the `users` table will be selected except for `password` and `secret_key`.

plugins/sql-macros/index.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import {
2+
StarbaseApp,
3+
StarbaseContext,
4+
StarbaseDBConfiguration,
5+
} from '../../src/handler'
6+
import { StarbasePlugin } from '../../src/plugin'
7+
import { DataSource, QueryResult } from '../../src/types'
8+
9+
const parser = new (require('node-sql-parser').Parser)()
10+
11+
export class SqlMacrosPlugin extends StarbasePlugin {
12+
config?: StarbaseDBConfiguration
13+
14+
// Prevents SQL statements with `SELECT *` from being executed
15+
preventSelectStar?: boolean
16+
17+
constructor(opts?: { preventSelectStar: boolean }) {
18+
super('starbasedb:sql-macros')
19+
this.preventSelectStar = opts?.preventSelectStar
20+
}
21+
22+
override async register(app: StarbaseApp) {
23+
app.use(async (c, next) => {
24+
this.config = c?.get('config')
25+
await next()
26+
})
27+
}
28+
29+
override async beforeQuery(opts: {
30+
sql: string
31+
params?: unknown[]
32+
dataSource?: DataSource
33+
config?: StarbaseDBConfiguration
34+
}): Promise<{ sql: string; params?: unknown[] }> {
35+
let { dataSource, sql, params } = opts
36+
37+
// A data source is required for this plugin to operate successfully
38+
if (!dataSource) {
39+
return Promise.resolve({
40+
sql,
41+
params,
42+
})
43+
}
44+
45+
sql = await this.replaceExcludeColumns(dataSource, sql, params)
46+
47+
// Prevention of `SELECT *` statements is only enforced on non-admin users
48+
// Admins should be able to continue running these statements in database
49+
// tools such as Outerbase Studio.
50+
if (this.preventSelectStar && this.config?.role !== 'admin') {
51+
sql = this.checkSelectStar(sql, params)
52+
}
53+
54+
return Promise.resolve({
55+
sql,
56+
params,
57+
})
58+
}
59+
60+
private checkSelectStar(sql: string, params?: unknown[]): string {
61+
try {
62+
const ast = parser.astify(sql)[0]
63+
64+
// Only check SELECT statements
65+
if (ast.type === 'select') {
66+
const hasSelectStar = ast.columns.some(
67+
(col: any) =>
68+
col.expr.type === 'star' ||
69+
(col.expr.type === 'column_ref' &&
70+
col.expr.column === '*')
71+
)
72+
73+
if (hasSelectStar) {
74+
throw new Error(
75+
'SELECT * is not allowed. Please specify explicit columns.'
76+
)
77+
}
78+
}
79+
80+
return sql
81+
} catch (error) {
82+
// If the error is our SELECT * error, rethrow it
83+
if (
84+
error instanceof Error &&
85+
error.message.includes('SELECT * is not allowed')
86+
) {
87+
throw error
88+
}
89+
// For parsing errors or other issues, return original SQL
90+
return sql
91+
}
92+
}
93+
94+
private async replaceExcludeColumns(
95+
dataSource: DataSource,
96+
sql: string,
97+
params?: unknown[]
98+
): Promise<string> {
99+
// Only currently works for internal data source (Durable Object SQLite)
100+
if (dataSource.source !== 'internal') {
101+
return sql
102+
}
103+
104+
// Special handling for pragma queries
105+
if (sql.toLowerCase().includes('pragma_table_info')) {
106+
return sql
107+
}
108+
109+
try {
110+
// Add semicolon if missing
111+
const normalizedSql = sql.trim().endsWith(';') ? sql : `${sql};`
112+
113+
// We allow users to write it `$_exclude` but convert it to `__exclude` so it can be
114+
// parsed with the AST library without throwing an error.
115+
const preparedSql = normalizedSql.replaceAll(
116+
'$_exclude',
117+
'__exclude'
118+
)
119+
const normalizedQuery = parser.astify(preparedSql)[0]
120+
121+
// Only process SELECT statements
122+
if (normalizedQuery.type !== 'select') {
123+
return sql
124+
}
125+
126+
// Find any columns using `__exclude`
127+
const columns = normalizedQuery.columns
128+
const excludeFnIdx = columns.findIndex(
129+
(col: any) =>
130+
col.expr &&
131+
col.expr.type === 'function' &&
132+
col.expr.name === '__exclude'
133+
)
134+
135+
if (excludeFnIdx === -1) {
136+
return sql
137+
}
138+
139+
// Get the table name from the FROM clause
140+
const tableName = normalizedQuery.from[0].table
141+
let excludedColumns: string[] = []
142+
143+
try {
144+
const excludeExpr = normalizedQuery.columns[excludeFnIdx].expr
145+
146+
// Handle both array and single argument cases
147+
const args = excludeExpr.args.value
148+
149+
// Extract column name(s) from arguments
150+
excludedColumns = Array.isArray(args)
151+
? args.map((arg: any) => arg.column)
152+
: [args.column]
153+
} catch (error: any) {
154+
console.error('Error processing exclude arguments:', error)
155+
console.error(error.stack)
156+
return sql
157+
}
158+
159+
// Query database for all columns in this table
160+
// This only works for the internal SQLite data source
161+
const schemaQuery = `
162+
SELECT name as column_name
163+
FROM pragma_table_info('${tableName}')
164+
`
165+
166+
const allColumns = (await dataSource?.rpc.executeQuery({
167+
sql: schemaQuery,
168+
})) as QueryResult[]
169+
170+
const includedColumns = allColumns
171+
.map((row: any) => row.column_name)
172+
.filter((col: string) => {
173+
const shouldInclude = !excludedColumns.includes(
174+
col.toLowerCase()
175+
)
176+
return shouldInclude
177+
})
178+
179+
// Replace the __exclude function with explicit columns
180+
normalizedQuery.columns.splice(
181+
excludeFnIdx,
182+
1,
183+
...includedColumns.map((col: string) => ({
184+
expr: { type: 'column_ref', table: null, column: col },
185+
as: null,
186+
}))
187+
)
188+
189+
// Convert back to SQL and remove trailing semicolon to maintain original format
190+
return parser.sqlify(normalizedQuery).replace(/;$/, '')
191+
} catch (error) {
192+
console.error('SQL parsing error:', error)
193+
return sql
194+
}
195+
}
196+
}

plugins/sql-macros/meta.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"version": "1.0.0",
3+
"resources": {
4+
"tables": {},
5+
"secrets": {},
6+
"variables": {}
7+
},
8+
"dependencies": {
9+
"tables": {},
10+
"secrets": {},
11+
"variables": {}
12+
}
13+
}

src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { corsPreflight } from './cors'
66
import { StarbasePlugin } from './plugin'
77
import { WebSocketPlugin } from '../plugins/websocket'
88
import { StudioPlugin } from '../plugins/studio'
9-
import { StripeSubscriptionPlugin } from '../plugins/stripe'
9+
import { SqlMacrosPlugin } from '../plugins/sql-macros'
1010

1111
export { StarbaseDBDurableObject } from './do'
1212

@@ -176,6 +176,9 @@ export default {
176176
password: env.STUDIO_PASS,
177177
apiKey: env.ADMIN_AUTHORIZATION_TOKEN,
178178
}),
179+
new SqlMacrosPlugin({
180+
preventSelectStar: false,
181+
}),
179182
] satisfies StarbasePlugin[]
180183

181184
const starbase = new StarbaseDB({

0 commit comments

Comments
 (0)