Skip to content

Commit 3265904

Browse files
committed
Include the macros file
1 parent e04aca7 commit 3265904

1 file changed

Lines changed: 191 additions & 0 deletions

File tree

plugins/sql-macros/index.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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 SqlMacros 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+
// We allow users to write it `$_exclude` but convert it to `__exclude` so it can be
111+
// parsed with the AST library without throwing an error.
112+
sql = sql.replaceAll('$_exclude', '__exclude')
113+
114+
const normalizedQuery = parser.astify(sql)[0]
115+
116+
// Only process SELECT statements
117+
if (normalizedQuery.type !== 'select') {
118+
return sql
119+
}
120+
121+
// Find any columns using `__exclude`
122+
const columns = normalizedQuery.columns
123+
const excludeFnIdx = columns.findIndex(
124+
(col: any) =>
125+
col.expr &&
126+
col.expr.type === 'function' &&
127+
col.expr.name === '__exclude'
128+
)
129+
130+
if (excludeFnIdx === -1) {
131+
return sql
132+
}
133+
134+
// Get the table name from the FROM clause
135+
const tableName = normalizedQuery.from[0].table
136+
let excludedColumns: string[] = []
137+
138+
try {
139+
const excludeExpr = normalizedQuery.columns[excludeFnIdx].expr
140+
141+
// Handle both array and single argument cases
142+
const args = excludeExpr.args.value
143+
144+
// Extract column name(s) from arguments
145+
excludedColumns = Array.isArray(args)
146+
? args.map((arg: any) => arg.column)
147+
: [args.column]
148+
} catch (error: any) {
149+
console.error('Error processing exclude arguments:', error)
150+
console.error(error.stack)
151+
return sql
152+
}
153+
154+
// Query database for all columns in this table
155+
// This only works for the internal SQLite data source
156+
const schemaQuery = `
157+
SELECT name as column_name
158+
FROM pragma_table_info('${tableName}')
159+
`
160+
161+
const allColumns = (await dataSource?.rpc.executeQuery({
162+
sql: schemaQuery,
163+
})) as QueryResult[]
164+
165+
const includedColumns = allColumns
166+
.map((row: any) => row.column_name)
167+
.filter((col: string) => {
168+
const shouldInclude = !excludedColumns.includes(
169+
col.toLowerCase()
170+
)
171+
return shouldInclude
172+
})
173+
174+
// Replace the __exclude function with explicit columns
175+
normalizedQuery.columns.splice(
176+
excludeFnIdx,
177+
1,
178+
...includedColumns.map((col: string) => ({
179+
expr: { type: 'column_ref', table: null, column: col },
180+
as: null,
181+
}))
182+
)
183+
184+
// Convert back to SQL
185+
return parser.sqlify(normalizedQuery)
186+
} catch (error) {
187+
console.error('SQL parsing error:', error)
188+
return sql
189+
}
190+
}
191+
}

0 commit comments

Comments
 (0)