sql-resource-boundary.spec.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. import { readdir, readFile } from 'node:fs/promises'
  2. import { fileURLToPath } from 'node:url'
  3. import ts from 'typescript'
  4. import { describe, expect, it } from 'vitest'
  5. const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url))
  6. const SQL_LITERAL = /^\s*(?:ALTER|ATTACH|BEGIN|COMMIT|CREATE|DELETE|DETACH|DROP|INSERT|PRAGMA|REINDEX|RELEASE|ROLLBACK|SAVEPOINT|SELECT|UPDATE|VACUUM|WITH)\s/iu // eslint-disable-line @stylistic/max-len
  7. async function filesUnder(path: string): Promise<string[]> {
  8. const entries = await readdir(path, { withFileTypes: true })
  9. return (await Promise.all(entries.map(async entry => entry.isDirectory()
  10. ? filesUnder(`${path}/${entry.name}`)
  11. : [`${path}/${entry.name}`]))).flat()
  12. }
  13. function sqlLiteralText(node: ts.Node): string | undefined {
  14. if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
  15. if (node.kind === ts.SyntaxKind.TemplateHead) {
  16. return (node as ts.Node & { readonly text: string }).text
  17. }
  18. return undefined
  19. }
  20. function isOwnedSqlSource(node: ts.Expression | undefined, source: ts.SourceFile): boolean {
  21. if (node === undefined) return false
  22. if (ts.isCallExpression(node)
  23. && ts.isIdentifier(node.expression)
  24. && (node.expression.text === 'sql' || node.expression.text === 'testSql')) return true
  25. if (!ts.isIdentifier(node) || node.text !== 'source') return false
  26. const call = node.parent
  27. if (!ts.isCallExpression(call)
  28. || call.arguments.length !== 1
  29. || call.arguments[0] !== node
  30. || !ts.isPropertyAccessExpression(call.expression)
  31. || call.expression.expression.kind !== ts.SyntaxKind.SuperKeyword
  32. || call.expression.name.text !== 'prepare') return false
  33. let method: ts.Node | undefined = node.parent
  34. while (method !== undefined && !ts.isMethodDeclaration(method)) method = method.parent
  35. if (method === undefined
  36. || method.name.getText(source) !== 'prepare'
  37. || method.parameters.length !== 1
  38. || method.parameters[0]?.name.getText(source) !== 'source') return false
  39. let classNode: ts.Node | undefined = method.parent
  40. while (classNode !== undefined && !ts.isClassExpression(classNode)) classNode = classNode.parent
  41. if (classNode === undefined || classNode.name?.text !== 'JournalFailureDatabase') return false
  42. const guard = method.body?.statements[0]
  43. if (guard === undefined
  44. || !ts.isIfStatement(guard)
  45. || !ts.isBinaryExpression(guard.expression)
  46. || guard.expression.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken
  47. || guard.expression.left.getText(source) !== 'source'
  48. || guard.expression.right.getText(source) !== "sql('journal-mode-wal')") return false
  49. return ts.isReturnStatement(guard.thenStatement)
  50. && guard.thenStatement.expression === call
  51. }
  52. describe('SQLite SQL resource boundary', () => {
  53. it('keeps statements and query assembly out of TypeScript files', async () => {
  54. const files = (await Promise.all([
  55. filesUnder(`${PACKAGE_ROOT}/src`),
  56. filesUnder(`${PACKAGE_ROOT}/tests`),
  57. ])).flat().filter(path => path.endsWith('.ts'))
  58. const violations: string[] = []
  59. for (const path of files) {
  60. const source = ts.createSourceFile(path, await readFile(path, 'utf8'), ts.ScriptTarget.Latest, true)
  61. const usesNodeSqlite = source.statements.some(statement => ts.isImportDeclaration(statement)
  62. && ts.isStringLiteral(statement.moduleSpecifier)
  63. && statement.moduleSpecifier.text === 'node:sqlite')
  64. const visit = (node: ts.Node): void => {
  65. const literal = sqlLiteralText(node)
  66. if (literal !== undefined && SQL_LITERAL.test(literal)) {
  67. violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: SQL literal`)
  68. }
  69. // Awaited prepare() is SessionPersistence; DatabaseSync.prepare() is synchronous.
  70. if (usesNodeSqlite
  71. && ts.isCallExpression(node)
  72. && ts.isPropertyAccessExpression(node.expression)
  73. && (node.expression.name.text === 'exec'
  74. || (node.expression.name.text === 'prepare' && !ts.isAwaitExpression(node.parent)))) {
  75. const argument = node.arguments[0]
  76. if (!isOwnedSqlSource(argument, source)) {
  77. violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: unowned query source`)
  78. }
  79. }
  80. ts.forEachChild(node, visit)
  81. }
  82. visit(source)
  83. }
  84. expect(violations).toEqual([])
  85. })
  86. it('keeps resource text static instead of interpolated', async () => {
  87. const files = (await Promise.all([
  88. filesUnder(`${PACKAGE_ROOT}/resources/sql`),
  89. filesUnder(`${PACKAGE_ROOT}/tests/resources/sql`),
  90. ])).flat()
  91. for (const path of files) {
  92. expect(path.endsWith('.sql')).toBe(true)
  93. expect(await readFile(path, 'utf8')).not.toContain('${')
  94. }
  95. })
  96. })