mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 16:01:42 +00:00
alter table e2e test
This commit is contained in:
+131
-21
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user