alter table e2e test

This commit is contained in:
Diego Imbert
2026-01-08 16:27:36 +01:00
parent 82487332c7
commit 9b52e25dd8
3 changed files with 180 additions and 52 deletions
+131 -21
View File
@@ -1,10 +1,7 @@
// Assume the db manager was already opened
import { expect, Locator, Page } from '@playwright/test'
import {
getDbFeatures,
type DbFeatures
} from '../src/lib/components/apps/components/display/dbtable/dbFeatures'
import { getDbFeatures } from '../src/lib/components/apps/components/display/dbtable/dbFeatures'
import { ConfirmationModal, Toast } from './utils'
import { DbInput, DbType } from '../src/lib/components/dbTypes'
@@ -58,7 +55,7 @@ export async function runDbManagerAlterTableTest(page: Page, dbType: _DbType) {
let friendTableName = `friend_${timestamp}`
let tableEditor = await dbManager.openCreateTableDrawer()
await tableEditor.setTableName(friendTableName)
await new Column(page, tableEditor.columnsSection(), 'id').delete() // default id column
await tableEditor.getColumn('id').delete() // default id column
let friendIdCol = await tableEditor.addColumn('id', 'INT')
if (dbFeatures.primaryKeys) {
friendIdCol.setPrimaryKey(true)
@@ -74,16 +71,17 @@ export async function runDbManagerAlterTableTest(page: Page, dbType: _DbType) {
let messageTableName = `message_${timestamp}`
tableEditor = await dbManager.openCreateTableDrawer()
await tableEditor.setTableName(messageTableName)
await new Column(page, tableEditor.columnsSection(), 'id').delete() // default id column
await tableEditor.getColumn('id').delete() // default id column
let messageIdCol = await tableEditor.addColumn('id', 'INT')
if (dbFeatures.primaryKeys) messageIdCol.setPrimaryKey(true)
await tableEditor.addColumn('friend_id', 'INT')
await tableEditor.addColumn('content', 'TEXT')
let contentColumn = await tableEditor.addColumn('content', 'TEXT')
await contentColumn.setSettings({ nullable: true })
await tableEditor.addColumn('created_at', dbType === 'ms_sql_server' ? 'DATETIME2' : 'TIMESTAMP')
if (dbFeatures.foreignKeys) {
await tableEditor.addForeignKey(friendTableName, 'friend_id', 'id', {
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
onDelete: 'Cascade',
onUpdate: 'Cascade'
})
} else {
expect(tableEditor.foreignKeySection()).toBeHidden()
@@ -92,16 +90,51 @@ export async function runDbManagerAlterTableTest(page: Page, dbType: _DbType) {
await Toast.expectSuccess(page, `${messageTableName} created`)
// Alter message table
let actionsMenu = await dbManager.openActionsMenu(messageTableName)
await actionsMenu.alterTable()
tableEditor = new TableEditorDrawer(page)
await tableEditor.setTableName(`posts_${timestamp}`)
await new Column(page, tableEditor.columnsSection(), 'id').delete()
await new Column(page, tableEditor.columnsSection(), 'friend_id').setName('person_id')
await new Column(page, tableEditor.columnsSection(), 'created_at').setType('INT')
await new Column(page, tableEditor.columnsSection(), 'created_at').setName('created_timestamp')
await (await dbManager.openActionsMenu(messageTableName)).alterTable()
await tableEditor.expectNoChangesDetected()
let postsTableName = `posts_${timestamp}`
let friendCol = tableEditor.getColumn('friend_id')
let createdAtCol = tableEditor.getColumn('created_at')
let idCol = tableEditor.getColumn('id')
// Predicate checks
if (dbFeatures.primaryKeys) {
await idCol.checkPrimaryKeyIs(true)
await createdAtCol.checkPrimaryKeyIs(false)
await contentColumn.checkPrimaryKeyIs(false)
await friendCol.checkPrimaryKeyIs(false)
await friendCol.checkSettingsIs({ nullable: false, defaultValue: '' })
}
// Apply alterations
await tableEditor.setTableName(postsTableName)
await idCol.delete()
await friendCol.setName('person_id')
await friendCol.setType('BIGINT')
await friendCol.setSettings({ defaultValue: '123', nullable: false }) // Test no type-error
await friendCol.setPrimaryKey(true)
await createdAtCol.setPrimaryKey(true)
await contentColumn.setPrimaryKey(true)
if (dbFeatures.foreignKeys) await tableEditor.deleteForeignKey()
await tableEditor.alterTable()
await Toast.expectSuccess(page, `posts updated successfully`)
await Toast.expectSuccess(page, `${messageTableName} updated`) // uses old table name
// Verify alterations
await dbManager.selectTable(postsTableName) // Ensure the view refreshed
await (await dbManager.openActionsMenu(postsTableName)).alterTable()
await tableEditor.expectNoChangesDetected()
tableEditor = new TableEditorDrawer(page)
await idCol.checkNotExists()
await friendCol.checkNameIs('person_id')
await friendCol.checkTypeIs('BIGINT')
await friendCol.checkSettingsIs({ defaultValue: /123/ })
await createdAtCol.checkTypeIs('TIMESTAMP')
await createdAtCol.checkNameIs('created_at')
if (dbFeatures.primaryKeys) {
await friendCol.checkPrimaryKeyIs(true)
await createdAtCol.checkPrimaryKeyIs(true)
await contentColumn.checkPrimaryKeyIs(true)
}
}
export class DbManagerPage {
@@ -170,7 +203,7 @@ class TableEditorDrawer {
const newColNameInput = newColRow.locator('td').nth(0).locator('input')
await newColNameInput.fill(columnName)
let column = new Column(this.page, columnsSection, columnName)
let column = this.getColumn(columnName)
await column.setType(columnType)
return column
}
@@ -185,6 +218,13 @@ class TableEditorDrawer {
await ConfirmationModal.confirm(this.page, '#db-table-editor-confirmation-modal', 'Alter')
}
async deleteForeignKey() {
// TODO: do not assume a single foreign key
const fkSection = this.foreignKeySection()
const deleteBtn = fkSection.locator('.fk-delete-btn')
await deleteBtn.click()
}
async addForeignKey(
referencedTable: string,
fromCol: string,
@@ -223,6 +263,15 @@ class TableEditorDrawer {
await fkSettings.click()
}
}
async expectNoChangesDetected() {
const btn = this.tableEditor().locator(`button:has-text("No changes detected")`)
return await expect(btn).toBeVisible({ timeout: 10000 })
}
getColumn(columnName: string): Column {
return new Column(this.page, this.columnsSection(), columnName)
}
}
class Column {
@@ -236,13 +285,17 @@ class Column {
this.columnName = columnName
}
async row(): Promise<Locator> {
async rowOrUndefined(): Promise<Locator | undefined> {
let rows = await this.columnsSection.locator('tr:has(input)').all()
for (const row of rows) {
const val = await row.locator('input').first().inputValue()
if (val === this.columnName) return row
}
throw new Error(`Column with name ${this.columnName} not found`)
}
row = async () => {
const row = await this.rowOrUndefined()
if (!row) throw new Error(`Column with name ${this.columnName} not found`)
return row
}
primaryKeyCheckbox = async () => (await this.row()).locator('input.primary-key-checkbox')
@@ -253,12 +306,22 @@ class Column {
this.columnName = columnName
}
async checkNameIs(columnName: string) {
const newColNameInput = (await this.row()).locator('td').nth(0).locator('input')
await expect(newColNameInput).toHaveValue(columnName)
}
async setType(columnType: string) {
const newColTypeSelect = (await this.row()).locator('td').nth(1).locator('input')
await newColTypeSelect.click()
await this.page.locator(`.select-dropdown-open li:has(:text-is("${columnType}"))`).click()
}
async checkTypeIs(columnType: string) {
const newColTypeSelect = (await this.row()).locator('td').nth(1).locator('input')
await expect(newColTypeSelect).toHaveValue(columnType)
}
async delete() {
const deleteBtn = (await this.row()).locator('button.delete-column-btn')
await deleteBtn.click()
@@ -271,6 +334,53 @@ class Column {
await primaryKeyCheckbox.click()
}
}
async checkPrimaryKeyIs(isPrimaryKey: boolean) {
let primaryKeyCheckbox = await this.primaryKeyCheckbox()
const isChecked = await primaryKeyCheckbox.isChecked()
expect(isChecked).toBe(isPrimaryKey)
}
async setSettings(options: { nullable?: boolean; defaultValue?: string }) {
const settingsBtn = (await this.row()).locator('.settings-menu-btn')
await settingsBtn.click()
if (options.defaultValue !== undefined) {
const defaultValueInput = this.page.locator('input.default-value')
await defaultValueInput.fill(options.defaultValue)
}
if (options.nullable !== undefined) {
const nullableCheckbox = this.page.locator('input.nullable-checkbox')
const isChecked = await nullableCheckbox.isChecked()
if (isChecked !== options.nullable) {
await nullableCheckbox.click()
}
}
// Close the popover
await settingsBtn.click()
}
async checkSettingsIs(options: { nullable?: boolean; defaultValue?: string | RegExp }) {
const settingsBtn = (await this.row()).locator('.settings-menu-btn')
await settingsBtn.click()
if (options.defaultValue !== undefined) {
const defaultValueInput = this.page.locator('input.default-value')
await expect(defaultValueInput).toHaveValue(options.defaultValue)
}
if (options.nullable !== undefined) {
const nullableCheckbox = this.page.locator('input.nullable-checkbox')
const isChecked = await nullableCheckbox.isChecked()
expect(isChecked).toBe(options.nullable)
}
// Close the popover
await settingsBtn.click()
}
async checkNotExists() {
expect(await this.rowOrUndefined()).toBeUndefined()
}
}
class InsertRowDrawer {
@@ -39,7 +39,7 @@
</script>
<script lang="ts">
import { ArrowRight, ClipboardCopy, Info, Plus, Settings, X } from 'lucide-svelte'
import { ArrowRight, ClipboardCopy, Plus, Settings, X } from 'lucide-svelte'
import { Button } from './common'
import { Cell } from './table'
@@ -48,7 +48,6 @@
import { datatypeHasLength, dbSupportsSchemas } from './apps/components/display/dbtable/utils'
import { DB_TYPES } from '$lib/consts'
import Popover from './meltComponents/Popover.svelte'
import Tooltip from './meltComponents/Tooltip.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard } from '$lib/utils'
@@ -226,9 +225,13 @@
bind:checked={column.primaryKey}
/>
{/if}
<Popover class="ml-8" contentClasses="py-3 px-5 flex flex-col gap-6">
<Popover
class="ml-8"
contentClasses="py-3 px-5 flex flex-col gap-6"
enableFlyTransition
>
{#snippet trigger()}
<Settings size={18} />
<Settings size={18} class="settings-menu-btn" />
{/snippet}
{#snippet content()}
{#if datatypeHasLength(column.datatype)}
@@ -238,27 +241,26 @@
</label>
{/if}
{#if features?.defaultValues}
<label class="text-xs">
<span class="flex gap-1 mb-1">
Default value
<Tooltip>
<Info size={14} />
{#snippet text()}
Surround your expressions with curly brackets:
<code>
{'{NOW()}'}
</code>.
<br />
By default, it will be parsed as a literal
{/snippet}
</Tooltip>
</span>
<input type="text" placeholder="NULL" bind:value={column.defaultValue} />
</label>
<Label
class="flex gap-1 mb-1"
label="Default Value"
tooltip="Parsed as literal by default. Use curly brackets for expressions (e.g. {'{NOW()}'} )."
>
<input
class="default-value"
type="text"
placeholder="NULL"
bind:value={column.defaultValue}
/>
</Label>
{/if}
{#if !column.primaryKey}
<label class="flex gap-2 items-center text-xs">
<input type="checkbox" class="!w-4 !h-4" bind:checked={column.nullable} />
<input
type="checkbox"
class="nullable-checkbox !w-4 !h-4"
bind:checked={column.nullable}
/>
Nullable
</label>
{/if}
@@ -360,29 +362,32 @@
</div>
<div class="ml-auto flex">
{#if columnIndex === 0}
<Popover contentClasses="py-3 px-5 w-52 flex flex-col gap-6">
<Popover
contentClasses="py-3 px-5 w-52 flex flex-col gap-4"
enableFlyTransition
>
{#snippet trigger()}
<Settings class="fk-settings-btn" size={18} />
{/snippet}
{#snippet content()}
<Label label="ON DELETE">
<Label label="On delete">
<select
class="fk-on-delete-select"
bind:value={foreignKey.onDelete}
>
<option value="NO ACTION" selected>NO ACTION</option>
<option value="CASCADE" selected>CASCADE</option>
<option value="SET NULL" selected>SET NULL</option>
<option value="NO ACTION" selected>No action</option>
<option value="CASCADE" selected>Cascade</option>
<option value="SET NULL" selected>Set null</option>
</select>
</Label>
<Label label="ON UPDATE">
<Label label="On update">
<select
class="fk-on-update-select"
bind:value={foreignKey.onUpdate}
>
<option value="NO ACTION" selected>NO ACTION</option>
<option value="CASCADE" selected>CASCADE</option>
<option value="SET NULL" selected>SET NULL</option>
<option value="NO ACTION" selected>No action</option>
<option value="CASCADE" selected>Cascade</option>
<option value="SET NULL" selected>Set null</option>
</select>
</Label>
{/snippet}
@@ -75,6 +75,7 @@ export function columnDefToTableEditorValuesColumn(
} else {
datatype = colDef.datatype?.replace(/\s+/g, ' ').toUpperCase() || 'UNKNOWN'
}
datatype = normalizeDatatypeAlias(datatype)
const defaultValue = colDef.defaultvalue
? colDef.defaultvalue.startsWith("'") &&
@@ -95,3 +96,15 @@ export function columnDefToTableEditorValuesColumn(
default_constraint_name: colDef.default_constraint_name
}
}
function normalizeDatatypeAlias(datatype: string): string {
// Normalize some common datatype variations
const dt = datatype.toUpperCase().trim()
if (dt === 'INTEGER') return 'INT'
if (dt === 'DOUBLE PRECISION') return 'DOUBLE'
if (dt === 'TIMESTAMP WITHOUT TIME ZONE') return 'TIMESTAMP'
if (dt === 'TIMESTAMP WITH TIME ZONE') return 'TIMESTAMPTZ'
if (dt === 'CHARACTER VARYING') return 'VARCHAR'
if (dt === 'CHARACTER') return 'CHAR'
return datatype
}