sql-identifier-quoting.test.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import { parse } from 'libpg-query'
  2. import { describe, expect, it } from 'vitest'
  3. import { extractIdentifiers, isQuotedInSql, needsQuoting } from './sql-identifier-quoting'
  4. describe('needsQuoting', () => {
  5. describe('reserved keywords', () => {
  6. it('returns true for reserved keywords (uppercase)', () => {
  7. expect(needsQuoting('SELECT')).toBe(true)
  8. expect(needsQuoting('FROM')).toBe(true)
  9. expect(needsQuoting('TABLE')).toBe(true)
  10. expect(needsQuoting('ORDER')).toBe(true)
  11. expect(needsQuoting('GROUP')).toBe(true)
  12. expect(needsQuoting('CREATE')).toBe(true)
  13. expect(needsQuoting('INSERT')).toBe(true)
  14. expect(needsQuoting('UPDATE')).toBe(true)
  15. expect(needsQuoting('DELETE')).toBe(true)
  16. })
  17. it('returns true for reserved keywords (lowercase)', () => {
  18. expect(needsQuoting('select')).toBe(true)
  19. expect(needsQuoting('from')).toBe(true)
  20. expect(needsQuoting('table')).toBe(true)
  21. expect(needsQuoting('order')).toBe(true)
  22. })
  23. it('returns true for reserved keywords (mixed case)', () => {
  24. expect(needsQuoting('Select')).toBe(true)
  25. expect(needsQuoting('From')).toBe(true)
  26. expect(needsQuoting('Table')).toBe(true)
  27. })
  28. })
  29. describe('mixed case identifiers', () => {
  30. it('returns true for identifiers with uppercase letters', () => {
  31. expect(needsQuoting('MyTable')).toBe(true)
  32. expect(needsQuoting('UserID')).toBe(true)
  33. expect(needsQuoting('camelCase')).toBe(true)
  34. expect(needsQuoting('PascalCase')).toBe(true)
  35. })
  36. })
  37. describe('special characters', () => {
  38. it('returns true for identifiers with dashes', () => {
  39. expect(needsQuoting('my-table')).toBe(true)
  40. expect(needsQuoting('user-name')).toBe(true)
  41. })
  42. it('returns true for identifiers with dots', () => {
  43. expect(needsQuoting('table.name')).toBe(true)
  44. expect(needsQuoting('schema.table')).toBe(true)
  45. })
  46. it('returns true for identifiers with spaces', () => {
  47. expect(needsQuoting('column with spaces')).toBe(true)
  48. expect(needsQuoting('my table')).toBe(true)
  49. })
  50. it('returns true for identifiers starting with numbers', () => {
  51. expect(needsQuoting('123table')).toBe(true)
  52. expect(needsQuoting('1col')).toBe(true)
  53. })
  54. })
  55. describe('valid unquoted identifiers', () => {
  56. it('returns false for lowercase identifiers', () => {
  57. expect(needsQuoting('users')).toBe(false)
  58. expect(needsQuoting('user_id')).toBe(false)
  59. expect(needsQuoting('orders')).toBe(false)
  60. })
  61. it('returns false for identifiers starting with underscore', () => {
  62. expect(needsQuoting('_private')).toBe(false)
  63. expect(needsQuoting('_internal')).toBe(false)
  64. })
  65. it('returns false for identifiers with numbers', () => {
  66. expect(needsQuoting('col1')).toBe(false)
  67. expect(needsQuoting('user123')).toBe(false)
  68. })
  69. it('returns false for identifiers with dollar sign in middle', () => {
  70. expect(needsQuoting('col$name')).toBe(false)
  71. })
  72. it('returns true for identifiers starting with dollar sign', () => {
  73. expect(needsQuoting('$variable')).toBe(true)
  74. })
  75. })
  76. describe('edge cases', () => {
  77. it('handles empty string', () => {
  78. expect(needsQuoting('')).toBe(true)
  79. })
  80. it('handles single character identifiers', () => {
  81. expect(needsQuoting('a')).toBe(false)
  82. expect(needsQuoting('A')).toBe(true)
  83. expect(needsQuoting('_')).toBe(false)
  84. })
  85. })
  86. })
  87. describe('isQuotedInSql', () => {
  88. describe('simple quoted identifiers', () => {
  89. it('returns true when identifier is quoted', () => {
  90. expect(isQuotedInSql('SELECT * FROM "MyTable"', 'MyTable')).toBe(true)
  91. expect(isQuotedInSql('SELECT "MyColumn" FROM users', 'MyColumn')).toBe(true)
  92. expect(isQuotedInSql('INSERT INTO "users" VALUES (1)', 'users')).toBe(true)
  93. })
  94. it('returns false when identifier is not quoted', () => {
  95. expect(isQuotedInSql('SELECT * FROM users', 'users')).toBe(false)
  96. expect(isQuotedInSql('SELECT id FROM orders', 'id')).toBe(false)
  97. expect(isQuotedInSql('INSERT INTO users VALUES (1)', 'users')).toBe(false)
  98. })
  99. })
  100. describe('case-insensitive matching', () => {
  101. it('matches quoted identifier regardless of case', () => {
  102. expect(isQuotedInSql('SELECT * FROM "mytable"', 'MyTable')).toBe(true)
  103. expect(isQuotedInSql('SELECT * FROM "MYTABLE"', 'mytable')).toBe(true)
  104. expect(isQuotedInSql('SELECT * FROM "MyTable"', 'MYTABLE')).toBe(true)
  105. })
  106. })
  107. describe('escaped quotes', () => {
  108. it('handles identifiers with escaped quotes inside', () => {
  109. expect(isQuotedInSql('SELECT * FROM "My""Table"', 'My"Table')).toBe(true)
  110. expect(isQuotedInSql('SELECT "col""name" FROM users', 'col"name')).toBe(true)
  111. })
  112. it('handles multiple escaped quotes', () => {
  113. expect(isQuotedInSql('SELECT "a""b""c" FROM users', 'a"b"c')).toBe(true)
  114. })
  115. })
  116. describe('special regex characters', () => {
  117. it('handles identifiers with dots', () => {
  118. expect(isQuotedInSql('SELECT * FROM "table.name"', 'table.name')).toBe(true)
  119. expect(isQuotedInSql('SELECT * FROM "schema.table"', 'schema.table')).toBe(true)
  120. })
  121. it('handles identifiers with parentheses', () => {
  122. expect(isQuotedInSql('SELECT * FROM "col(value)"', 'col(value)')).toBe(true)
  123. })
  124. it('handles identifiers with brackets', () => {
  125. expect(isQuotedInSql('SELECT * FROM "table[0]"', 'table[0]')).toBe(true)
  126. })
  127. it('handles identifiers with special regex chars', () => {
  128. expect(isQuotedInSql('SELECT * FROM "col*name"', 'col*name')).toBe(true)
  129. expect(isQuotedInSql('SELECT * FROM "col+name"', 'col+name')).toBe(true)
  130. expect(isQuotedInSql('SELECT * FROM "col?name"', 'col?name')).toBe(true)
  131. expect(isQuotedInSql('SELECT * FROM "col^name"', 'col^name')).toBe(true)
  132. expect(isQuotedInSql('SELECT * FROM "col$name"', 'col$name')).toBe(true)
  133. })
  134. })
  135. describe('multiple occurrences', () => {
  136. it('returns true if identifier appears quoted anywhere', () => {
  137. expect(isQuotedInSql('SELECT * FROM "MyTable" WHERE id = 1', 'MyTable')).toBe(true)
  138. expect(isQuotedInSql('SELECT users.id FROM "users"', 'users')).toBe(true)
  139. })
  140. it('returns true even if also appears unquoted', () => {
  141. expect(isQuotedInSql('SELECT * FROM "MyTable" JOIN MyTable ON 1=1', 'MyTable')).toBe(true)
  142. })
  143. })
  144. describe('partial matches', () => {
  145. it('does not match partial identifiers', () => {
  146. expect(isQuotedInSql('SELECT * FROM "MyTableX"', 'MyTable')).toBe(false)
  147. expect(isQuotedInSql('SELECT * FROM "XMyTable"', 'MyTable')).toBe(false)
  148. })
  149. it('matches exact identifier only', () => {
  150. expect(isQuotedInSql('SELECT * FROM "MyTable"', 'MyTable')).toBe(true)
  151. expect(isQuotedInSql('SELECT * FROM "MyTable"', 'Table')).toBe(false)
  152. })
  153. })
  154. describe('edge cases', () => {
  155. it('handles empty identifier', () => {
  156. expect(isQuotedInSql('SELECT * FROM ""', '')).toBe(true)
  157. expect(isQuotedInSql('SELECT * FROM users', '')).toBe(false)
  158. })
  159. it('handles SQL with comments', () => {
  160. expect(isQuotedInSql('SELECT * FROM "MyTable" -- comment', 'MyTable')).toBe(true)
  161. expect(isQuotedInSql('/* comment */ SELECT * FROM "MyTable"', 'MyTable')).toBe(true)
  162. })
  163. it('handles SQL with string literals', () => {
  164. expect(isQuotedInSql('SELECT * FROM "MyTable" WHERE name = \'test\'', 'MyTable')).toBe(true)
  165. expect(
  166. isQuotedInSql('SELECT "MyTable" FROM users WHERE name = "not a table"', 'MyTable')
  167. ).toBe(true)
  168. })
  169. })
  170. })
  171. describe('extractIdentifiers', () => {
  172. it('extracts table and column names from a simple SELECT', async () => {
  173. const identifiers = extractIdentifiers(await parse('SELECT id, name FROM users'))
  174. expect(identifiers).toEqual(expect.arrayContaining(['users', 'id', 'name']))
  175. })
  176. it('extracts schema-qualified table names', async () => {
  177. const identifiers = extractIdentifiers(await parse('SELECT id FROM public.users'))
  178. expect(identifiers).toEqual(expect.arrayContaining(['public', 'users', 'id']))
  179. })
  180. it('preserves case for quoted identifiers', async () => {
  181. const identifiers = extractIdentifiers(await parse('SELECT "MyColumn" FROM "MyTable"'))
  182. expect(identifiers).toEqual(expect.arrayContaining(['MyColumn', 'MyTable']))
  183. })
  184. it('lowercases unquoted identifiers with mixed case', async () => {
  185. const identifiers = extractIdentifiers(await parse('SELECT MyColumn FROM MyTable'))
  186. expect(identifiers).toEqual(expect.arrayContaining(['mycolumn', 'mytable']))
  187. })
  188. it('extracts identifiers from JOINs', async () => {
  189. const identifiers = extractIdentifiers(
  190. await parse('SELECT u.id, o.total FROM users u JOIN orders o ON u.id = o.user_id')
  191. )
  192. expect(identifiers).toEqual(
  193. expect.arrayContaining(['users', 'orders', 'id', 'total', 'user_id'])
  194. )
  195. })
  196. it('extracts identifiers from subqueries', async () => {
  197. const identifiers = extractIdentifiers(
  198. await parse('SELECT * FROM (SELECT id FROM inner_table) AS sub')
  199. )
  200. expect(identifiers).toEqual(expect.arrayContaining(['inner_table', 'id']))
  201. })
  202. it('handles INSERT statements', async () => {
  203. const identifiers = extractIdentifiers(
  204. await parse('INSERT INTO users (name, email) VALUES ($1, $2)')
  205. )
  206. expect(identifiers).toEqual(expect.arrayContaining(['users', 'name', 'email']))
  207. })
  208. it('handles UPDATE statements', async () => {
  209. const identifiers = extractIdentifiers(await parse('UPDATE users SET name = $1 WHERE id = $2'))
  210. expect(identifiers).toEqual(expect.arrayContaining(['users', 'name', 'id']))
  211. })
  212. it('handles CREATE TABLE statements', async () => {
  213. const identifiers = extractIdentifiers(
  214. await parse('CREATE TABLE "MyTable" ("MyColumn" TEXT, other_col INT)')
  215. )
  216. expect(identifiers).toEqual(expect.arrayContaining(['MyTable', 'MyColumn', 'other_col']))
  217. })
  218. it('returns empty array when no identifiers exist', async () => {
  219. const identifiers = extractIdentifiers(await parse('SELECT 1'))
  220. expect(identifiers).toEqual([])
  221. })
  222. })