SidePanelEditor.utils.createTable.test.ts523 lines · main
1import { FOREIGN_KEY_CASCADE_ACTION, safeSql } from '@supabase/pg-meta'
2import { beforeEach, describe, expect, it, vi } from 'vitest'
3
4import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types'
5import type { ColumnField } from './SidePanelEditor.types'
6import { createTable } from './SidePanelEditor.utils'
7
8// Define mock functions at module level
9const mockExecuteSql = vi.fn()
10const mockGetTable = vi.fn()
11const mockSendEvent = vi.fn()
12const mockPrefetchEditorTablePage = vi.fn()
13const mockToastLoading = vi.fn()
14const mockToastSuccess = vi.fn()
15const mockToastError = vi.fn()
16const mockFetchQuery = vi.fn()
17
18// Setup mocks before imports
19vi.mock('@/data/query-client', () => ({
20 getQueryClient: () => ({
21 fetchQuery: mockFetchQuery,
22 }),
23}))
24
25vi.mock('@/data/sql/execute-sql-query', () => ({
26 executeSql: (...args: unknown[]) => mockExecuteSql(...args),
27}))
28
29vi.mock('@/data/tables/table-retrieve-query', () => ({
30 getTable: (...args: unknown[]) => mockGetTable(...args),
31 getTableQuery: (...args: unknown[]) => mockGetTable(...args),
32}))
33
34vi.mock('@/data/telemetry/send-event-mutation', () => ({
35 sendEvent: (...args: unknown[]) => mockSendEvent(...args),
36}))
37
38vi.mock('@/data/prefetchers/project.$ref.editor.$id', () => ({
39 prefetchEditorTablePage: (...args: unknown[]) => mockPrefetchEditorTablePage(...args),
40}))
41
42vi.mock('sonner', () => ({
43 toast: {
44 loading: (...args: unknown[]) => mockToastLoading(...args),
45 success: (...args: unknown[]) => mockToastSuccess(...args),
46 error: (...args: unknown[]) => mockToastError(...args),
47 },
48}))
49
50// Mock SparkBar component used in toast
51vi.mock('@/components/ui/SparkBar', () => ({
52 default: () => null,
53}))
54
55// Helper to create a column field with defaults
56const createColumnField = (overrides: Partial<ColumnField> = {}): ColumnField => ({
57 id: 'col-1',
58 name: 'column',
59 table: 'test_table',
60 schema: 'public',
61 format: 'text',
62 check: null,
63 comment: null,
64 defaultValue: null,
65 isNullable: true,
66 isUnique: false,
67 isArray: false,
68 isIdentity: false,
69 isPrimaryKey: false,
70 isNewColumn: true,
71 isEncrypted: false,
72 ...overrides,
73})
74
75describe('createTable', () => {
76 const projectRef = 'test-project-ref'
77 const connectionString = 'postgresql://localhost:5432/test'
78 const toastId = 'test-toast-id'
79
80 const basePayload = {
81 name: 'test_table',
82 schema: 'public',
83 comment: 'A test table',
84 }
85
86 const mockTableResult = {
87 id: 123,
88 name: 'test_table',
89 schema: 'public',
90 comment: 'A test table',
91 columns: [],
92 primary_keys: [],
93 relationships: [],
94 }
95
96 beforeEach(() => {
97 vi.clearAllMocks()
98
99 // Default mock implementations
100 mockExecuteSql.mockResolvedValue({ result: [] })
101 mockGetTable.mockResolvedValue(mockTableResult)
102 mockSendEvent.mockResolvedValue({})
103 mockPrefetchEditorTablePage.mockResolvedValue(undefined)
104 mockFetchQuery.mockImplementation(({ queryFn }) => {
105 if (queryFn) {
106 return queryFn({ signal: new AbortController().signal })
107 }
108 return Promise.resolve(mockTableResult)
109 })
110 })
111
112 it('should create a basic table with no columns', async () => {
113 const result = await createTable({
114 projectRef,
115 connectionString,
116 toastId,
117 payload: basePayload,
118 columns: [],
119 foreignKeyRelations: [],
120 isRLSEnabled: false,
121 })
122
123 expect(mockExecuteSql).toHaveBeenCalledTimes(1)
124 expect(mockExecuteSql).toHaveBeenCalledWith(
125 expect.objectContaining({
126 projectRef,
127 connectionString,
128 queryKey: ['table', 'create-with-columns'],
129 })
130 )
131
132 // Should show loading toast
133 expect(mockToastLoading).toHaveBeenCalledWith(`Creating table ${basePayload.name}...`, {
134 id: toastId,
135 })
136
137 // Should track table creation event
138 expect(mockSendEvent).toHaveBeenCalledWith({
139 event: {
140 action: 'table_created',
141 properties: {
142 has_generated_policies: false,
143 method: 'table_editor',
144 schema_name: 'public',
145 table_name: 'test_table',
146 },
147 groups: {
148 project: projectRef,
149 },
150 },
151 })
152
153 // Should prefetch the editor table page
154 expect(mockPrefetchEditorTablePage).toHaveBeenCalledWith(
155 expect.objectContaining({
156 projectRef,
157 connectionString,
158 id: mockTableResult.id,
159 })
160 )
161
162 expect(result).toStrictEqual({
163 failedPolicies: [],
164 table: mockTableResult,
165 })
166 })
167
168 it('should create a table with RLS enabled', async () => {
169 await createTable({
170 projectRef,
171 connectionString,
172 toastId,
173 payload: basePayload,
174 columns: [],
175 foreignKeyRelations: [],
176 isRLSEnabled: true,
177 })
178
179 const sqlCall = mockExecuteSql.mock.calls[0][0]
180 expect(sqlCall.sql).toContain('ENABLE ROW LEVEL SECURITY')
181
182 expect(mockSendEvent).toHaveBeenCalledWith({
183 event: {
184 action: 'table_rls_enabled',
185 properties: {
186 method: 'table_editor',
187 schema_name: 'public',
188 table_name: 'test_table',
189 },
190 groups: {
191 project: projectRef,
192 },
193 },
194 })
195 })
196
197 it('should create a table with columns', async () => {
198 const columns: ColumnField[] = [
199 createColumnField({
200 id: 'col-1',
201 name: 'id',
202 format: 'int8',
203 isNullable: false,
204 isIdentity: true,
205 isPrimaryKey: true,
206 }),
207 createColumnField({
208 id: 'col-2',
209 name: 'name',
210 format: 'text',
211 comment: 'User name',
212 }),
213 ]
214
215 await createTable({
216 projectRef,
217 connectionString,
218 toastId,
219 payload: basePayload,
220 columns,
221 foreignKeyRelations: [],
222 isRLSEnabled: false,
223 })
224
225 const sqlCall = mockExecuteSql.mock.calls[0][0]
226 expect(sqlCall.sql).toContain('ALTER TABLE')
227 expect(sqlCall.sql).toContain('ADD COLUMN')
228 expect(sqlCall.sql).toContain('ADD PRIMARY KEY')
229 expect(sqlCall.sql).toContain('id')
230 })
231
232 it('should create a table with composite primary key', async () => {
233 const columns: ColumnField[] = [
234 createColumnField({
235 id: 'col-1',
236 name: 'user_id',
237 format: 'int8',
238 isNullable: false,
239 isPrimaryKey: true,
240 }),
241 createColumnField({
242 id: 'col-2',
243 name: 'order_id',
244 format: 'int8',
245 isNullable: false,
246 isPrimaryKey: true,
247 }),
248 ]
249
250 await createTable({
251 projectRef,
252 connectionString,
253 toastId,
254 payload: basePayload,
255 columns,
256 foreignKeyRelations: [],
257 isRLSEnabled: false,
258 })
259
260 const sqlCall = mockExecuteSql.mock.calls[0][0]
261 expect(sqlCall.sql).toContain('ADD PRIMARY KEY')
262 expect(sqlCall.sql).toContain('user_id')
263 expect(sqlCall.sql).toContain('order_id')
264 })
265
266 it('should create a table with foreign key relations', async () => {
267 const columns: ColumnField[] = [
268 createColumnField({
269 id: 'col-1',
270 name: 'id',
271 format: 'int8',
272 isNullable: false,
273 isIdentity: true,
274 isPrimaryKey: true,
275 }),
276 createColumnField({
277 id: 'col-2',
278 name: 'user_id',
279 format: 'int8',
280 isNullable: false,
281 }),
282 ]
283
284 const foreignKeyRelations: ForeignKey[] = [
285 {
286 schema: 'public',
287 table: 'users',
288 columns: [{ source: 'user_id', target: 'id' }],
289 deletionAction: FOREIGN_KEY_CASCADE_ACTION.CASCADE,
290 updateAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION,
291 },
292 ]
293
294 await createTable({
295 projectRef,
296 connectionString,
297 toastId,
298 payload: basePayload,
299 columns,
300 foreignKeyRelations,
301 isRLSEnabled: false,
302 })
303
304 const sqlCall = mockExecuteSql.mock.calls[0][0]
305 expect(sqlCall.sql).toContain('ADD FOREIGN KEY')
306 expect(sqlCall.sql).toContain('REFERENCES')
307 expect(sqlCall.sql).toContain('users')
308 expect(sqlCall.sql).toContain('ON DELETE CASCADE')
309 })
310
311 it('should include organization slug in telemetry when provided', async () => {
312 const organizationSlug = 'test-org'
313
314 await createTable({
315 projectRef,
316 connectionString,
317 toastId,
318 payload: basePayload,
319 columns: [],
320 foreignKeyRelations: [],
321 isRLSEnabled: true,
322 organizationSlug,
323 })
324
325 expect(mockSendEvent).toHaveBeenCalledWith({
326 event: expect.objectContaining({
327 action: 'table_created',
328 groups: {
329 project: projectRef,
330 organization: organizationSlug,
331 },
332 }),
333 })
334
335 expect(mockSendEvent).toHaveBeenCalledWith({
336 event: expect.objectContaining({
337 action: 'table_rls_enabled',
338 groups: {
339 project: projectRef,
340 organization: organizationSlug,
341 },
342 }),
343 })
344 })
345
346 it('should handle telemetry errors gracefully', async () => {
347 const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
348 mockSendEvent.mockRejectedValue(new Error('Telemetry failed'))
349
350 const result = await createTable({
351 projectRef,
352 connectionString,
353 toastId,
354 payload: basePayload,
355 columns: [],
356 foreignKeyRelations: [],
357 isRLSEnabled: false,
358 })
359
360 expect(result).toStrictEqual({
361 failedPolicies: [],
362 table: mockTableResult,
363 })
364 expect(consoleErrorSpy).toHaveBeenCalledWith(
365 'Failed to track table creation event:',
366 expect.any(Error)
367 )
368
369 consoleErrorSpy.mockRestore()
370 })
371
372 it('should create a table with nullable connectionString', async () => {
373 await createTable({
374 projectRef,
375 connectionString: null,
376 toastId,
377 payload: basePayload,
378 columns: [],
379 foreignKeyRelations: [],
380 isRLSEnabled: false,
381 })
382
383 expect(mockExecuteSql).toHaveBeenCalledWith(
384 expect.objectContaining({
385 projectRef,
386 connectionString: null,
387 })
388 )
389 })
390
391 it('should create table with column having default value', async () => {
392 const columns: ColumnField[] = [
393 createColumnField({
394 name: 'status',
395 defaultValue: "'pending'",
396 isNullable: false,
397 }),
398 ]
399
400 await createTable({
401 projectRef,
402 connectionString,
403 toastId,
404 payload: basePayload,
405 columns,
406 foreignKeyRelations: [],
407 isRLSEnabled: false,
408 })
409
410 expect(mockExecuteSql).toHaveBeenCalledTimes(1)
411 })
412
413 it('should create table with unique column', async () => {
414 const columns: ColumnField[] = [
415 createColumnField({
416 name: 'email',
417 isNullable: false,
418 isUnique: true,
419 }),
420 ]
421
422 await createTable({
423 projectRef,
424 connectionString,
425 toastId,
426 payload: basePayload,
427 columns,
428 foreignKeyRelations: [],
429 isRLSEnabled: false,
430 })
431
432 expect(mockExecuteSql).toHaveBeenCalledTimes(1)
433 })
434
435 it('should create table with array column', async () => {
436 const columns: ColumnField[] = [
437 createColumnField({
438 name: 'tags',
439 isArray: true,
440 }),
441 ]
442
443 await createTable({
444 projectRef,
445 connectionString,
446 toastId,
447 payload: basePayload,
448 columns,
449 foreignKeyRelations: [],
450 isRLSEnabled: false,
451 })
452
453 expect(mockExecuteSql).toHaveBeenCalledTimes(1)
454 })
455
456 it('should create table with check constraint', async () => {
457 const columns: ColumnField[] = [
458 createColumnField({
459 name: 'age',
460 format: 'int4',
461 check: safeSql`age >= 0`,
462 isNullable: false,
463 }),
464 ]
465
466 await createTable({
467 projectRef,
468 connectionString,
469 toastId,
470 payload: basePayload,
471 columns,
472 foreignKeyRelations: [],
473 isRLSEnabled: false,
474 })
475
476 expect(mockExecuteSql).toHaveBeenCalledTimes(1)
477 })
478
479 it('should propagate SQL execution errors', async () => {
480 mockExecuteSql.mockRejectedValue(new Error('SQL execution failed'))
481
482 await expect(
483 createTable({
484 projectRef,
485 connectionString,
486 toastId,
487 payload: basePayload,
488 columns: [],
489 foreignKeyRelations: [],
490 isRLSEnabled: false,
491 })
492 ).rejects.toThrow('SQL execution failed')
493 })
494
495 it('should create table in non-public schema', async () => {
496 const customSchemaPayload = {
497 name: 'custom_table',
498 schema: 'private',
499 comment: 'A private table',
500 }
501
502 await createTable({
503 projectRef,
504 connectionString,
505 toastId,
506 payload: customSchemaPayload,
507 columns: [],
508 foreignKeyRelations: [],
509 isRLSEnabled: false,
510 })
511
512 const sqlCall = mockExecuteSql.mock.calls[0][0]
513 expect(sqlCall.sql).toMatch(/private\.custom_table|"private"\."custom_table"/)
514
515 expect(mockSendEvent).toHaveBeenCalledWith({
516 event: expect.objectContaining({
517 properties: expect.objectContaining({
518 schema_name: 'private',
519 }),
520 }),
521 })
522 })
523})