Skip to content
Open
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
179 changes: 179 additions & 0 deletions plugins/sql-macros/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, expect, it, vi } from 'vitest'
import { SqlMacrosPlugin } from './index'
import type { DataSource } from '../../src/types'

const createMockDataSource = (
opts: {
source?: string
columns?: string[]
} = {}
) => {
const columns = opts.columns ?? ['id', 'name', 'email', 'password']
const executeQuery = vi.fn().mockResolvedValue(
columns.map((column_name) => ({
column_name,
}))
)

return {
source: opts.source ?? 'internal',
rpc: {
executeQuery,
},
} as unknown as DataSource
}

describe('SqlMacrosPlugin', () => {
it('returns the original SQL and params when no data source is provided', async () => {
const plugin = new SqlMacrosPlugin({ preventSelectStar: true })
const params = [1]

await expect(
plugin.beforeQuery({
sql: 'SELECT * FROM users WHERE id = ?',
params,
})
).resolves.toEqual({
sql: 'SELECT * FROM users WHERE id = ?',
params,
})
})

it('blocks SELECT * for non-admin users when prevention is enabled', async () => {
const plugin = new SqlMacrosPlugin({ preventSelectStar: true })
plugin.config = { role: 'user' } as any

await expect(
plugin.beforeQuery({
sql: 'SELECT * FROM users',
dataSource: createMockDataSource(),
})
).rejects.toThrow(
'SELECT * is not allowed. Please specify explicit columns.'
)
})

it('allows SELECT * for admin users', async () => {
const plugin = new SqlMacrosPlugin({ preventSelectStar: true })
plugin.config = { role: 'admin' } as any

await expect(
plugin.beforeQuery({
sql: 'SELECT * FROM users',
dataSource: createMockDataSource(),
})
).resolves.toEqual({
sql: 'SELECT * FROM users',
params: undefined,
})
})

it('does not enforce SELECT * prevention when disabled', async () => {
const plugin = new SqlMacrosPlugin({ preventSelectStar: false })
plugin.config = { role: 'user' } as any

await expect(
plugin.beforeQuery({
sql: 'SELECT * FROM users',
dataSource: createMockDataSource(),
})
).resolves.toEqual({
sql: 'SELECT * FROM users',
params: undefined,
})
})

it('leaves $_exclude SQL unchanged for non-internal data sources', async () => {
const plugin = new SqlMacrosPlugin({ preventSelectStar: false })
const dataSource = createMockDataSource({ source: 'postgres' })

await expect(
plugin.beforeQuery({
sql: 'SELECT $_exclude(password) FROM users',
dataSource,
})
).resolves.toEqual({
sql: 'SELECT $_exclude(password) FROM users',
params: undefined,
})
expect(dataSource.rpc.executeQuery).not.toHaveBeenCalled()
})

it('leaves pragma table info queries unchanged', async () => {
const plugin = new SqlMacrosPlugin({ preventSelectStar: false })
const dataSource = createMockDataSource()

await expect(
plugin.beforeQuery({
sql: "SELECT name FROM pragma_table_info('users')",
dataSource,
})
).resolves.toEqual({
sql: "SELECT name FROM pragma_table_info('users')",
params: undefined,
})
expect(dataSource.rpc.executeQuery).not.toHaveBeenCalled()
})

it('expands $_exclude into explicit internal table columns', async () => {
const plugin = new SqlMacrosPlugin({ preventSelectStar: false })
const dataSource = createMockDataSource({
columns: ['id', 'name', 'email', 'password'],
})

const result = await plugin.beforeQuery({
sql: 'SELECT $_exclude(password) FROM users',
dataSource,
})

expect(dataSource.rpc.executeQuery).toHaveBeenCalledWith({
sql: expect.stringContaining("FROM pragma_table_info('users')"),
})
expect(result.sql).toContain('id')
expect(result.sql).toContain('name')
expect(result.sql).toContain('email')
expect(result.sql).not.toContain('password')
expect(result.params).toBeUndefined()
})

it('expands multiple excluded columns case-insensitively', async () => {
const plugin = new SqlMacrosPlugin({ preventSelectStar: false })
const dataSource = createMockDataSource({
columns: ['id', 'name', 'email', 'password'],
})

const result = await plugin.beforeQuery({
sql: 'SELECT $_exclude(password, EMAIL) FROM users',
dataSource,
})

expect(result.sql).toContain('id')
expect(result.sql).toContain('name')
expect(result.sql).not.toContain('password')
expect(result.sql).not.toContain('email')
})

it('logs parse errors and returns the original SQL', async () => {
const plugin = new SqlMacrosPlugin({ preventSelectStar: false })
const dataSource = createMockDataSource()
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})

await expect(
plugin.beforeQuery({
sql: 'SELECT $_exclude(password FROM users',
dataSource,
})
).resolves.toEqual({
sql: 'SELECT $_exclude(password FROM users',
params: undefined,
})
expect(consoleError).toHaveBeenCalledWith(
'SQL parsing error:',
expect.any(Error)
)

consoleError.mockRestore()
})
})
12 changes: 8 additions & 4 deletions plugins/sql-macros/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { DataSource, QueryResult } from '../../src/types'

const parser = new (require('node-sql-parser').Parser)()

function firstStatement(ast: any) {
return Array.isArray(ast) ? ast[0] : ast
}

export class SqlMacrosPlugin extends StarbasePlugin {
config?: StarbaseDBConfiguration

Expand Down Expand Up @@ -59,7 +63,7 @@ export class SqlMacrosPlugin extends StarbasePlugin {

private checkSelectStar(sql: string, params?: unknown[]): string {
try {
const ast = parser.astify(sql)[0]
const ast = firstStatement(parser.astify(sql))

// Only check SELECT statements
if (ast.type === 'select') {
Expand Down Expand Up @@ -116,7 +120,7 @@ export class SqlMacrosPlugin extends StarbasePlugin {
'$_exclude',
'__exclude'
)
const normalizedQuery = parser.astify(preparedSql)[0]
const normalizedQuery = firstStatement(parser.astify(preparedSql))

// Only process SELECT statements
if (normalizedQuery.type !== 'select') {
Expand Down Expand Up @@ -148,8 +152,8 @@ export class SqlMacrosPlugin extends StarbasePlugin {

// Extract column name(s) from arguments
excludedColumns = Array.isArray(args)
? args.map((arg: any) => arg.column)
: [args.column]
? args.map((arg: any) => arg.column.toLowerCase())
: [args.column.toLowerCase()]
} catch (error: any) {
console.error('Error processing exclude arguments:', error)
console.error(error.stack)
Expand Down