Browse Source

Merge branch 'develop' into feat/keyboard-manoeuvre

pull/4482/head
Wing-Kam Wong 2 years ago
parent
commit
461e1b9fc0
  1. 15
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  2. 2
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  3. 27
      packages/nc-gui/components/smartsheet/toolbar/SearchData.vue
  4. 34
      packages/nc-gui/composables/useFieldQuery.ts
  5. 19
      packages/nc-gui/composables/useSmartsheetStore.ts
  6. 72
      packages/nc-gui/lang/uk.json
  7. 48
      packages/nc-gui/lang/zh-Hant.json
  8. 224
      packages/noco-docs/content/en/engineering/playwright.md
  9. 161
      packages/noco-docs/content/en/engineering/unit-testing.md
  10. 470
      packages/nocodb/src/lib/db/sql-client/lib/mssql/MssqlClient.ts
  11. 46
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  12. 100
      tests/playwright/README.md
  13. 2
      tests/playwright/pages/Base.ts

15
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -122,11 +122,13 @@ if (isKanban.value) {
} }
} }
const cellWrapperEl = (wrapperEl: HTMLElement) => { const cellWrapperEl = ref<HTMLElement>()
nextTick(() => {
;(wrapperEl?.querySelector('input,select,textarea') as HTMLInputElement)?.focus() onMounted(() => {
setTimeout(() => {
;(cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus()
})
}) })
}
</script> </script>
<script lang="ts"> <script lang="ts">
@ -163,7 +165,10 @@ export default {
<LazySmartsheetHeaderCell v-else :column="col" /> <LazySmartsheetHeaderCell v-else :column="col" />
<div :ref="i ? null : cellWrapperEl" class="!bg-white rounded px-1 min-h-[35px] flex items-center mt-2"> <div
:ref="i ? null : (el) => (cellWrapperEl = el)"
class="!bg-white rounded px-1 min-h-[35px] flex items-center mt-2"
>
<LazySmartsheetVirtualCell v-if="isVirtualCol(col)" v-model="row.row[col.title]" :row="row" :column="col" /> <LazySmartsheetVirtualCell v-if="isVirtualCol(col)" v-model="row.row[col.title]" :row="row" :column="col" />
<LazySmartsheetCell <LazySmartsheetCell

2
packages/nc-gui/components/smartsheet/header/CellIcon.ts

@ -56,7 +56,7 @@ import SpecificDBTypeIcon from '~icons/mdi/database-settings'
import DurationIcon from '~icons/mdi/timer-outline' import DurationIcon from '~icons/mdi/timer-outline'
const renderIcon = (column: ColumnType, abstractType: any) => { const renderIcon = (column: ColumnType, abstractType: any) => {
if (isPrimary(column)) { if (isPrimaryKey(column)) {
return KeyIcon return KeyIcon
} else if (isJSON(column)) { } else if (isJSON(column)) {
return JSONIcon return JSONIcon

27
packages/nc-gui/components/smartsheet/toolbar/SearchData.vue

@ -1,9 +1,22 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ReloadViewDataHookInj, computed, inject, onClickOutside, ref, useSmartsheetStoreOrThrow } from '#imports' import {
ActiveViewInj,
ReloadViewDataHookInj,
computed,
inject,
onClickOutside,
ref,
useFieldQuery,
useSmartsheetStoreOrThrow,
} from '#imports'
const reloadData = inject(ReloadViewDataHookInj)! const reloadData = inject(ReloadViewDataHookInj)!
const { search, meta } = useSmartsheetStoreOrThrow() const { meta } = useSmartsheetStoreOrThrow()
const activeView = inject(ActiveViewInj, ref())
const { search, loadFieldQuery } = useFieldQuery(activeView)
const isDropdownOpen = ref(false) const isDropdownOpen = ref(false)
@ -18,6 +31,16 @@ const columns = computed(() =>
})), })),
) )
watch(
() => activeView.value?.id,
(n, o) => {
if (n !== o) {
loadFieldQuery(activeView)
}
},
{ immediate: true },
)
function onPressEnter() { function onPressEnter() {
reloadData.trigger() reloadData.trigger()
} }

34
packages/nc-gui/composables/useFieldQuery.ts

@ -0,0 +1,34 @@
import type { Ref } from 'vue'
import type { ViewType } from 'nocodb-sdk'
import { useState } from '#imports'
export function useFieldQuery(view: Ref<ViewType | undefined>) {
// initial search object
const emptyFieldQueryObj = {
field: '',
query: '',
}
// mapping view id (key) to corresponding emptyFieldQueryObj (value)
const searchMap = useState<Record<string, { field: string; query: string }>>('field-query-search-map', () => ({}))
// the fieldQueryObj under the current view
const search = useState<{ field: string; query: string }>('field-query-search', () => emptyFieldQueryObj)
// map current view id to emptyFieldQueryObj
if (view?.value?.id) {
searchMap.value[view!.value!.id] = search.value
}
// retrieve the fieldQueryObj of the given view id
// if it is not found in `searchMap`, init with emptyFieldQueryObj
const loadFieldQuery = (view: Ref<ViewType | undefined>) => {
if (!view.value?.id) return
if (!(view!.value!.id in searchMap.value)) {
searchMap.value[view!.value!.id!] = emptyFieldQueryObj
}
search.value = searchMap.value[view!.value!.id!]
}
return { search, loadFieldQuery }
}

19
packages/nc-gui/composables/useSmartsheetStore.ts

@ -1,7 +1,7 @@
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk' import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { computed, reactive, ref, unref, useInjectionState, useNuxtApp, useProject } from '#imports' import { computed, ref, unref, useFieldQuery, useInjectionState, useNuxtApp, useProject } from '#imports'
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState( const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
( (
@ -17,12 +17,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const cellRefs = ref<HTMLTableDataCellElement[]>([]) const cellRefs = ref<HTMLTableDataCellElement[]>([])
// state const { search } = useFieldQuery(view)
// todo: move to grid view store
const search = reactive({
field: '',
query: '',
})
// getters // getters
const isLocked = computed(() => view.value?.lock_type === 'locked') const isLocked = computed(() => view.value?.lock_type === 'locked')
@ -35,21 +30,20 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const xWhere = computed(() => { const xWhere = computed(() => {
let where let where
const col = const col =
(meta.value as TableType)?.columns?.find(({ id }) => id === search.field) || (meta.value as TableType)?.columns?.find(({ id }) => id === search.value.field) ||
(meta.value as TableType)?.columns?.find((v) => v.pv) (meta.value as TableType)?.columns?.find((v) => v.pv)
if (!col) return if (!col) return
if (!search.query.trim()) return if (!search.value.query.trim()) return
if (['text', 'string'].includes(sqlUi.value.getAbstractType(col)) && col.dt !== 'bigint') { if (['text', 'string'].includes(sqlUi.value.getAbstractType(col)) && col.dt !== 'bigint') {
where = `(${col.title},like,%${search.query.trim()}%)` where = `(${col.title},like,%${search.value.query.trim()}%)`
} else { } else {
where = `(${col.title},eq,${search.query.trim()})` where = `(${col.title},eq,${search.value.query.trim()})`
} }
return where return where
}) })
const isSqlView = computed(() => (meta.value as TableType)?.type === 'view') const isSqlView = computed(() => (meta.value as TableType)?.type === 'view')
const sorts = ref<SortType[]>(unref(initialSorts) ?? []) const sorts = ref<SortType[]>(unref(initialSorts) ?? [])
const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? []) const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
@ -58,7 +52,6 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
meta, meta,
isLocked, isLocked,
$api, $api,
search,
xWhere, xWhere,
isPkAvail, isPkAvail,
isForm, isForm,

72
packages/nc-gui/lang/uk.json

@ -16,7 +16,7 @@
"cancel": "Скасувати", "cancel": "Скасувати",
"submit": "Подавати", "submit": "Подавати",
"create": "Створювати", "create": "Створювати",
"duplicate": "Duplicate", "duplicate": "Дублювати",
"insert": "Вставляти", "insert": "Вставляти",
"delete": "Видаляти", "delete": "Видаляти",
"update": "Оновлення", "update": "Оновлення",
@ -56,19 +56,19 @@
"notification": "Сповіщення", "notification": "Сповіщення",
"reference": "Довідник", "reference": "Довідник",
"function": "Функція", "function": "Функція",
"confirm": "Confirm", "confirm": "Підтвердити",
"generate": "Generate", "generate": "Генерувати",
"copy": "Copy", "copy": "Копіювати",
"misc": "Miscellaneous", "misc": "Інше",
"lock": "Lock", "lock": "Блокувати",
"unlock": "Unlock", "unlock": "Розблокувати",
"credentials": "Credentials", "credentials": "Дані доступу",
"help": "Help", "help": "Довідка",
"questions": "Questions", "questions": "Питання",
"reachOut": "Reach out here", "reachOut": "Reach out here",
"betaNote": "This feature is currently in beta.", "betaNote": "Ця функція знаходиться в стадії бета-версії.",
"moreInfo": "More information can be found here", "moreInfo": "Тут можна знайти більше інформації",
"logs": "Logs", "logs": "Логи",
"groupingField": "Grouping Field" "groupingField": "Grouping Field"
}, },
"objects": { "objects": {
@ -84,8 +84,8 @@
"pages": "Сторінка", "pages": "Сторінка",
"record": "Рекорд", "record": "Рекорд",
"records": "Записи", "records": "Записи",
"webhook": "Webhook", "webhook": "Вебхук",
"webhooks": "Webhooks", "webhooks": "Вебхуки",
"view": "Погляд", "view": "Погляд",
"views": "Вигляд", "views": "Вигляд",
"viewType": { "viewType": {
@ -190,15 +190,15 @@
"headLogin": "Вхід | Нокодб", "headLogin": "Вхід | Нокодб",
"resetPassword": "Скинути пароль", "resetPassword": "Скинути пароль",
"teamAndSettings": "Team & Settings", "teamAndSettings": "Team & Settings",
"apiDocs": "API Docs", "apiDocs": "Документи API",
"importFromAirtable": "Import From Airtable", "importFromAirtable": "Імпортувати з Airtable",
"generateToken": "Generate Token", "generateToken": "Генерувати токен",
"APIsAndSupport": "APIs & Support", "APIsAndSupport": "API та Підтримка",
"helpCenter": "Help center", "helpCenter": "Довідковий центр",
"swaggerDocumentation": "Swagger Documentation", "swaggerDocumentation": "Документація Swagger",
"quickImportFrom": "Quick Import From", "quickImportFrom": "Швидкий імпорт з",
"quickImport": "Quick Import", "quickImport": "Швидкий імпорт",
"advancedSettings": "Advanced Settings", "advancedSettings": "Додаткові налаштування",
"codeSnippet": "Code Snippet" "codeSnippet": "Code Snippet"
}, },
"labels": { "labels": {
@ -220,7 +220,7 @@
"port": "Номер порту", "port": "Номер порту",
"username": "Ім'я користувача", "username": "Ім'я користувача",
"password": "Пароль", "password": "Пароль",
"schemaName": "Schema name", "schemaName": "Назва схеми",
"database": "База даних", "database": "База даних",
"action": "Дія", "action": "Дія",
"actions": "Акції", "actions": "Акції",
@ -268,25 +268,25 @@
"childColumn": "Дитяча колонка", "childColumn": "Дитяча колонка",
"onUpdate": "На оновлення", "onUpdate": "На оновлення",
"onDelete": "На видалі", "onDelete": "На видалі",
"account": "Account", "account": "Обліковий запис",
"language": "Language", "language": "Мова",
"primaryColor": "Primary Color", "primaryColor": "Основний колір",
"accentColor": "Accent Color", "accentColor": "Додатковий колір",
"customTheme": "Custom Theme", "customTheme": "Користувацька тема",
"requestDataSource": "Request a data source you need?", "requestDataSource": "Request a data source you need?",
"apiKey": "API Key", "apiKey": "API ключ",
"sharedBase": "Shared Base", "sharedBase": "Shared Base",
"importData": "Import Data", "importData": "Імпорт даних",
"importSecondaryViews": "Import Secondary Views", "importSecondaryViews": "Import Secondary Views",
"importRollupColumns": "Import Rollup Columns", "importRollupColumns": "Import Rollup Columns",
"importLookupColumns": "Import Lookup Columns", "importLookupColumns": "Import Lookup Columns",
"importAttachmentColumns": "Import Attachment Columns", "importAttachmentColumns": "Import Attachment Columns",
"importFormulaColumns": "Import Formula Columns", "importFormulaColumns": "Import Formula Columns",
"noData": "No Data", "noData": "Немає даних",
"goToDashboard": "Go to Dashboard", "goToDashboard": "Go to Dashboard",
"importing": "Importing", "importing": "Імпортування",
"flattenNested": "Flatten Nested", "flattenNested": "Flatten Nested",
"downloadAllowed": "Download allowed", "downloadAllowed": "Завантаження дозволене",
"weAreHiring": "We are Hiring!", "weAreHiring": "We are Hiring!",
"primaryKey": "Primary key", "primaryKey": "Primary key",
"hasMany": "has many", "hasMany": "has many",
@ -499,7 +499,7 @@
"excelURL": "Введіть URL-адресу Excel", "excelURL": "Введіть URL-адресу Excel",
"csvURL": "Введіть URL-адресу CSV", "csvURL": "Введіть URL-адресу CSV",
"footMsg": "# рядків, щоб розібрати для виведення даних", "footMsg": "# рядків, щоб розібрати для виведення даних",
"excelImport": "Лист (и) доступні для імпорту", "excelImport": "лист(и) доступні для імпорту",
"exportMetadata": "Ви хочете експортувати метадані з мета-таблиць?", "exportMetadata": "Ви хочете експортувати метадані з мета-таблиць?",
"importMetadata": "Ви хочете імпортувати метадані з мета-таблиць?", "importMetadata": "Ви хочете імпортувати метадані з мета-таблиць?",
"clearMetadata": "Ви хочете очистити метадані з мета-таблиць?", "clearMetadata": "Ви хочете очистити метадані з мета-таблиць?",

48
packages/nc-gui/lang/zh-Hant.json

@ -86,11 +86,11 @@
"records": "記錄", "records": "記錄",
"webhook": "Webhook", "webhook": "Webhook",
"webhooks": "Webhook", "webhooks": "Webhook",
"view": "檢視", "view": "檢視",
"views": "檢視", "views": "所有檢視",
"viewType": { "viewType": {
"grid": "網格", "grid": "網格",
"gallery": "圖庫", "gallery": "相簿",
"form": "表單", "form": "表單",
"kanban": "看板", "kanban": "看板",
"calendar": "日曆" "calendar": "日曆"
@ -206,7 +206,7 @@
"notifyVia": "透過...通知", "notifyVia": "透過...通知",
"projName": "項目名", "projName": "項目名",
"tableName": "表名稱", "tableName": "表名稱",
"viewName": "查看名稱", "viewName": "檢視名稱",
"viewLink": "查看鏈接", "viewLink": "查看鏈接",
"columnName": "列名稱", "columnName": "列名稱",
"columnType": "列類型", "columnType": "列類型",
@ -384,18 +384,18 @@
"clearMetadata": "清除中繼資料", "clearMetadata": "清除中繼資料",
"exportToFile": "匯出為檔案", "exportToFile": "匯出為檔案",
"changePwd": "更改密碼", "changePwd": "更改密碼",
"createView": "建立檢視", "createView": "建立檢視",
"shareView": "分享檢視", "shareView": "分享檢視",
"listSharedView": "共享視圖列表", "listSharedView": "共享視圖列表",
"ListView": "檢視表清單", "ListView": "檢視表清單",
"copyView": "複製檢視", "copyView": "複製檢視",
"renameView": "重新命名檢視", "renameView": "重新命名檢視",
"deleteView": "刪除檢視", "deleteView": "刪除檢視",
"createGrid": "創建網格視", "createGrid": "創建網格視",
"createGallery": "創建畫廊視圖", "createGallery": "創建相簿檢視",
"createCalendar": "創建日曆視", "createCalendar": "創建日曆視",
"createKanban": "創建尋呼視圖", "createKanban": "創建看板檢視",
"createForm": "創建表單視", "createForm": "創建表單視",
"showSystemFields": "顯示系統字段", "showSystemFields": "顯示系統字段",
"copyUrl": "複製網址", "copyUrl": "複製網址",
"openTab": "開啟新分頁", "openTab": "開啟新分頁",
@ -490,7 +490,7 @@
"info": { "info": {
"roles": { "roles": {
"orgCreator": "建立者可以建立專案與存取任何受邀請的專案", "orgCreator": "建立者可以建立專案與存取任何受邀請的專案",
"orgViewer": "建立者不能建立專案但可以存取任何受邀請的專案" "orgViewer": "檢視者不能建立專案但可以存取任何受邀請的專案"
}, },
"footerInfo": "每頁行駛", "footerInfo": "每頁行駛",
"upload": "選擇檔案以上傳", "upload": "選擇檔案以上傳",
@ -518,8 +518,8 @@
"formDesc": "添加表單描述", "formDesc": "添加表單描述",
"beforeEnablePwd": "使用密碼限制存取權限", "beforeEnablePwd": "使用密碼限制存取權限",
"afterEnablePwd": "存取受密碼限制", "afterEnablePwd": "存取受密碼限制",
"privateLink": "此檢視通過私人連結共享", "privateLink": "此檢視通過私人連結共享",
"privateLinkAdditionalInfo": "具有私有連結的人只能看到此檢視中可見的儲存格", "privateLinkAdditionalInfo": "具有私有連結的人只能看到此檢視中可見的儲存格",
"afterFormSubmitted": "表格提交後", "afterFormSubmitted": "表格提交後",
"apiOptions": "存取專案方式", "apiOptions": "存取專案方式",
"submitAnotherForm": "顯示“提交另一個表格”按鈕", "submitAnotherForm": "顯示“提交另一個表格”按鈕",
@ -528,7 +528,7 @@
"showSysFields": "顯示系統字段", "showSysFields": "顯示系統字段",
"filterAutoApply": "自動申請", "filterAutoApply": "自動申請",
"showMessage": "顯示此消息", "showMessage": "顯示此消息",
"viewNotShared": "當前視不共享!", "viewNotShared": "當前視不共享!",
"showAllViews": "顯示此表的所有共享視圖", "showAllViews": "顯示此表的所有共享視圖",
"collabView": "具有編輯權限或更高的合作者可以更改視圖配置。", "collabView": "具有編輯權限或更高的合作者可以更改視圖配置。",
"lockedView": "沒有人可以編輯視圖配置,直到它被解鎖。", "lockedView": "沒有人可以編輯視圖配置,直到它被解鎖。",
@ -570,11 +570,11 @@
"dontHaveAccount": "沒有帳號?" "dontHaveAccount": "沒有帳號?"
}, },
"addView": { "addView": {
"grid": "加入網格檢視", "grid": "加入網格檢視",
"gallery": "加入圖庫檢視表", "gallery": "加入相簿檢視",
"form": "加入表單檢視", "form": "加入表單檢視",
"kanban": "加入看板檢視", "kanban": "加入看板檢視",
"calendar": "加入日曆檢視" "calendar": "加入日曆檢視"
}, },
"tablesMetadataInSync": "表元數據同步", "tablesMetadataInSync": "表元數據同步",
"addMultipleUsers": "您可以添加多個逗號(,)分隔的電子郵件", "addMultipleUsers": "您可以添加多個逗號(,)分隔的電子郵件",
@ -679,7 +679,7 @@
"authToken": "驗證權杖已複製到剪貼簿", "authToken": "驗證權杖已複製到剪貼簿",
"projInfo": "已將專案資訊複製到剪貼簿", "projInfo": "已將專案資訊複製到剪貼簿",
"inviteUrlCopy": "已將邀請連結複製到剪貼簿", "inviteUrlCopy": "已將邀請連結複製到剪貼簿",
"createView": "成功建立檢視", "createView": "成功建立檢視",
"formEmailSMTP": "請啟用 App Store 中的 SMTP 外掛程式以啟用電子郵件通知", "formEmailSMTP": "請啟用 App Store 中的 SMTP 外掛程式以啟用電子郵件通知",
"collabView": "成功轉換為協作視圖", "collabView": "成功轉換為協作視圖",
"lockedView": "成功轉換為鎖定視圖", "lockedView": "成功轉換為鎖定視圖",

224
packages/noco-docs/content/en/engineering/playwright.md

@ -0,0 +1,224 @@
---
title: "Playwright E2E Testing"
description: "Overview to playwright based e2e tests"
position: 3260
category: "Engineering"
menuTitle: "Playwright E2E Testing"
---
## How to run tests
All the tests reside in `tests/playwright` folder.
Make sure to install the dependencies(in the playwright folder):
```bash
npm install
npx playwright install chromium --with-deps
```
### Run Test Server
Start the backend test server (in `packages/nocodb` folder):
```bash
npm run watch:run:playwright
```
Start the frontend test server (in `packages/nc-gui` folder):
```bash
NUXT_PAGE_TRANSITION_DISABLE=true npm run dev
```
### Running all tests
For selecting db type, rename `.env.example` to `.env` and set `E2E_DEV_DB_TYPE` to `sqlite`(default), `mysql` or `pg`.
headless mode(without opening browser):
```bash
npm run test
```
with browser:
```bash
npm run test:debug
```
</br>
</br>
For setting up mysql(sakila):
```bash
docker-compose -f ./tests/playwright/scripts/docker-compose-mysql-playwright.yml up -d
```
For setting up postgres(sakila):
```bash
docker-compose -f ./tests/playwright/scripts/docker-compose-playwright-pg.yml
```
### Running individual tests
Add `.only` to the test you want to run:
```js
test.only('should login', async ({ page }) => {
// ...
})
```
```bash
npm run test
```
## Concepts
### Independent tests
- All tests are independent of each other.
- Each test starts with a fresh project with a fresh sakila database(option to not use sakila db is also there).
- Each test creates a new user(email as `user@nocodb.com`) and logs in with that user to the dashboard.
Caveats:
- Some stuffs are shared i.e, users, plugins etc. So be catious while writing tests touching that. A fix for this is in the works.
- In test, we prefix email and project with the test id, which will be deleted after the test is done.
### What to test
- UI verification. This includes verifying the state of the UI element, i.e if the element is visible, if the element has a particular text etc.
- Test should verify all user flow. A test has a default timeout of 60 seconds. If a test is taking more than 60 seconds, it is a sign that the test should be broken down into smaller tests.
- Test should also verify all the side effects the feature(i.e. On adding a new column type, should verify column deletion as well) will have, and also error cases.
- Test name should be descriptive. It should be easy to understand what the test is doing by just reading the test name.
### Playwright
- Playwright is a nodejs library for automating chromium, firefox and webkit.
- For each test, a new browser context is created. This means that each test runs in a new incognito window.
- For assertion always use `expect` from `@playwright/test` library. This library provides a lot of useful assertions, which also has retry logic built in.
## Page Objects
- Page objects are used to abstract over the components/page. This makes the tests more readable and maintainable.
- All page objects are in `tests/playwright/pages` folder.
- All the test related code should be in page objects.
- Methods should be as thin as possible and its better to have multiple methods than one big method, which improves reusability.
The methods of a page object can be classified into 2 categories:
- Actions: Performs an UI actions like click, type, select etc. Is also responsible for waiting for the element to be ready and the action to be performed. This included waiting for API calls to complete.
- Assertions: Asserts the state of the UI element, i.e if the element is visible, if the element has a particular text etc. Use `expect` from `@playwright/test` and if not use `expect.poll` to wait for the assertion to pass.
## Writing a test
Let's write a test for testing filter functionality.
For simplicity, we will have `DashboardPage` implemented, which will have all the methods related to dashboard page and also its child components like Grid, etc.
### Create a test suite
Create a new file `filter.spec.ts` in `tests/playwright/tests` folder and use `setup` method to create a new project and user.
```js
import { test, expect } from '@playwright/test';
import setup, { NcContext } from '../setup';
test.describe('Filter', () => {
let context: NcContext;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
})
test('should filter', async ({ page }) => {
// ...
});
});
```
### Create a page object
Since filter is UI wise scoped to a `Toolbar` , we will add filter page object to `ToolbarPage` page object.
```js
export class ToolbarPage extends BasePage {
readonly parent: GridPage | GalleryPage | FormPage | KanbanPage;
readonly filter: ToolbarFilterPage;
constructor(parent: GridPage | GalleryPage | FormPage | KanbanPage) {
super(parent.rootPage);
this.parent = parent;
this.filter = new ToolbarFilterPage(this);
}
}
```
We will create `ToolbarFilterPage` page object, which will have all the methods related to filter.
```js
export class ToolbarFilterPage extends BasePage {
readonly toolbar: ToolbarPage;
constructor(toolbar: ToolbarPage) {
super(toolbar.rootPage);
this.toolbar = toolbar;
}
}
```
Here `BasePage` is an abstract class, which used to enforce structure for all page objects. Thus all page object *should* inherit `BasePage`.
- Helper methods like `waitForResponse` and `getClipboardText` (this can be access on any page object, with `this.waitForResponse`)
- Provides structure for page objects, enforces all Page objects to have `rootPage` property, which is the page object created in the test setup.
- Enforces all pages to have a `get` method which will return the locator of the main container of that page, hence we can have focused dom selection, i.e.
```js
// This will only select the button inside the container of the concerned page
await this.get().querySelector('button').count();
```
### Writing an action method
This a method which will reset/clear all the filters. Since this is an action method, it will also wait for the `delete` filter API to return. Ignoring this API call will cause flakiness in the test, down the line.
```js
async resetFilter() {
await this.waitForResponse({
uiAction: this.get().locator('.nc-filter-item-remove-btn').click(),
httpMethodsToMatch: ['DELETE'],
requestUrlPathToMatch: '/api/v1/db/meta/filters/',
});
}
```
### Writing an assertion/verification method
Here we use `expect` from `@playwright/test` library, which has retry logic built in.
```js
import { expect } from '@playwright/test';
async verifyFilter({ title }: { title: string }) {
await expect(
this.get().locator(`[data-testid="nc-fields-menu-${title}"]`).locator('input[type="checkbox"]')
).toBeChecked();
}
```
## Tips to avoid flakiness
- If an UI action, causes an API call or the UI state change, then wait for that API call to complete or the UI state to change.
- What to wait out can be situation specific, but in general, is best to wait for the final state to be reached, i.e. in the case of creating filter, while it seems like waiting for the filter API to complete is enough, but after its return the table rows are reloaded and the UI state changes, so its better to wait for the table rows to be reloaded.
## Accessing playwright report in the CI
- Open `Summary` tab in the CI workflow in github actions.
- Scroll down to `Artifacts` section.
- Access reports which suffixed with the db type and shard number(corresponding to the CI workerflow name). i.e `playwright-report-mysql-2` is for `playwright-mysql-2` workflow.
- Download it and run `npm install -D @playwright/test && npx playwright show-report ./` inside the downloaded folder.

161
packages/noco-docs/content/en/engineering/testing.md → packages/noco-docs/content/en/engineering/unit-testing.md

@ -1,9 +1,9 @@
--- ---
title: "Writing Tests" title: "Writing Unit Tests"
description: "Overview to testing" description: "Overview to Unit Testing"
position: 3250 position: 3250
category: "Engineering" category: "Engineering"
menuTitle: "Writing Tests" menuTitle: "Unit Testing"
--- ---
## Unit Tests ## Unit Tests
@ -189,158 +189,3 @@ function tableTest() {
}); });
} }
``` ```
## Cypress Tests
### End-to-end (E2E) Tests
Cypress tests are divided into 4 suites
- SQLite tests
- Postgres tests
- MySQL tests
- Quick import tests
First 3 suites, each have 4 test category
- Table operations (create, delete, rename, add column, delete column, rename column)
- Views (Grid, Gallery, Form)
- Roles (user profiles, access control & preview)
- Miscellaneous (Import, i18n, etc)
### SQLite Tests (XCDB Project)
```shell
# install dependencies(cypress)
npm install
# start MySQL database using docker compose
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up
# Run backend api using following command
npm run start:xcdb-api:cache
# Run frontend web UI using following command
npm run start:web
# wait until both 3000 and 8080 ports are available
# or run following command to run it with GUI
npm run cypress:open
# run one of 4 test scripts
# - Table operations : xcdb-restTableOps.js
# - Views : xcdb-restViews.js
# - Roles & access control : xcdb-restRoles.js
# - Miscellaneous : xcdb-restMisc.js
```
### MySQL Tests (External DB Project)
```shell
# install dependencies(cypress)
npm install
# start MySQL database using docker compose
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up
# Run backend api using following command
npm run start:api:cache
# Run frontend web UI using following command
npm run start:web
# wait until both 3000 and 8080 ports are available
# or run following command to run it with GUI
npm run cypress:open
# run one of 4 test scripts
# - Table operations : restTableOps.js
# - Views : restViews.js
# - Roles & access control : restRoles.js
# - Miscellaneous : restMisc.js
```
### Postgres Tests (External DB Project)
```shell
# install dependencies(cypress)
npm install
# start Postgres database using docker compose
docker-compose -f ./scripts/cypress/docker-compose-pg.yml up -d
# Run backend api using following command
npm run start:api:cache
# Run frontend web UI using following command
npm run start:web
# wait until both 3000 and 8080 ports are available
# or run following command to run it with GUI
npm run cypress:open
# run one of 4 test scripts
# - Table operations : pg-restTableOps.js
# - Views : pg-restViews.js
# - Roles & access control : pg-restRoles.js
# - Miscellaneous : pg-restMisc.js
```
### Quick Import Tests (SQLite Project)
```shell
# install dependencies(cypress)
npm install
# start MySQL database using docker compose
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up
# copy existing xcdb (v0.91.7) database to ./packages/nocodb/
cp ./scripts/cypress/fixtures/quickTest/noco_0_91_7.db ./packages/nocodb/noco.db
# Run backend api using following command
npm run start:api:cache
# Run frontend web UI using following command
npm run start:web
# wait until both 3000 and 8080 ports are available
# or run following command to run it with GUI
npm run cypress:open
# run test script
# - quickTest.js
```
### Quick import tests (Postgres)
```shell
# install dependencies(cypress)
npm install
# start PG database using docker compose
docker-compose -f ./scripts/cypress/docker-compose-pg.yml up -d
# copy existing xcdb (v0.91.7) database to ./packages/nocodb/
cp ./scripts/cypress/fixtures/quickTest/noco_0_91_7.db ./packages/nocodb/noco.db
# Run backend api using following command
npm run start:api:cache
# Run frontend web UI using following command
npm run start:web
# wait until both 3000 and 8080 ports are available
# or run following command to run it with GUI
npm run cypress:open
# run test script
# - quickTest.js
```
## Accessing CI-CD CY Screenshots
1. On Jobs link, click on `Summary`
![Screenshot 2022-10-31 at 9 25 23 PM](https://user-images.githubusercontent.com/86527202/199052696-af0bf066-d82f-487a-b487-602f55594fd7.png)
2. Click on `Artifacts`
![Screenshot 2022-10-31 at 9 26 01 PM](https://user-images.githubusercontent.com/86527202/199052712-04508921-32b1-4926-8291-396c804f7c3b.png)
3. Download logs for desired suite
![Screenshot 2022-10-31 at 9 26 34 PM](https://user-images.githubusercontent.com/86527202/199052727-9aebbdd1-749e-4bda-ab00-3cdd0e3f48fe.png)

470
packages/nocodb/src/lib/db/sql-client/lib/mssql/MssqlClient.ts

@ -183,7 +183,7 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args); log.api(`${_func}:args:`, args);
try { try {
await this.sqlClient.raw('SELECT 1+1 as data'); await this.sqlClient.raw('SELECT 1+1 AS data');
} catch (e) { } catch (e) {
log.ppe(e); log.ppe(e);
result.code = -1; result.code = -1;
@ -246,7 +246,7 @@ class MssqlClient extends KnexClient {
try { try {
const rows = await this.sqlClient.raw( const rows = await this.sqlClient.raw(
`SELECT SERVERPROPERTY('productversion') as version, SERVERPROPERTY ('productlevel') as level, SERVERPROPERTY ('edition') as edition, @@version as versionD` `SELECT SERVERPROPERTY('productversion') AS version, SERVERPROPERTY ('productlevel') AS level, SERVERPROPERTY ('edition') AS edition, @@version AS versionD`
); );
result.data.object = {}; result.data.object = {};
@ -284,7 +284,7 @@ class MssqlClient extends KnexClient {
const tempSqlClient = knex(connectionParamsWithoutDb); const tempSqlClient = knex(connectionParamsWithoutDb);
const rows = await tempSqlClient.raw( const rows = await tempSqlClient.raw(
`select name from sys.databases where name = '${args.database}'` `SELECT name from sys.databases WHERE name = '${args.database}'`
); );
if (rows.length === 0) { if (rows.length === 0) {
@ -356,14 +356,12 @@ class MssqlClient extends KnexClient {
try { try {
/** ************** START : create _evolution table if not exists *************** */ /** ************** START : create _evolution table if not exists *************** */
const exists = await this.sqlClient.schema const exists = await this.sqlClient.schema.withSchema(this.schema).hasTable(args.tn);
.withSchema(this.schema)
.hasTable(args.tn);
if (!exists) { if (!exists) {
await this.sqlClient.schema await this.sqlClient.schema.withSchema(this.schema).createTable(
.withSchema(this.schema) args.tn,
.createTable(args.tn, function (table) { function (table) {
table.increments(); table.increments();
table.string('title').notNullable(); table.string('title').notNullable();
table.string('titleDown').nullable(); table.string('titleDown').nullable();
@ -373,7 +371,8 @@ class MssqlClient extends KnexClient {
table.integer('status').nullable(); table.integer('status').nullable();
table.dateTime('created'); table.dateTime('created');
table.timestamps(); table.timestamps();
}); }
);
log.debug('Table created:', `${this.getTnPath(args.tn)}`); log.debug('Table created:', `${this.getTnPath(args.tn)}`);
} else { } else {
log.debug(`${this.getTnPath(args.tn)} tables exists`); log.debug(`${this.getTnPath(args.tn)} tables exists`);
@ -395,9 +394,7 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args); log.api(`${_func}:args:`, args);
try { try {
result.data.value = await this.sqlClient.schema result.data.value = await this.sqlClient.schema.withSchema(this.schema).hasTable(args.tn);
.withSchema(this.schema)
.hasTable(args.tn);
} catch (e) { } catch (e) {
log.ppe(e, _func); log.ppe(e, _func);
throw e; throw e;
@ -415,7 +412,7 @@ class MssqlClient extends KnexClient {
try { try {
const rows = await this.sqlClient.raw( const rows = await this.sqlClient.raw(
`select name from sys.databases where name = '${args.databaseName}'` `SELECT name FROM sys.databases WHERE name = '${args.databaseName}'`
); );
result.data.value = rows.length > 0; result.data.value = rows.length > 0;
} catch (e) { } catch (e) {
@ -441,7 +438,7 @@ class MssqlClient extends KnexClient {
try { try {
result.data.list = await this.sqlClient.raw( result.data.list = await this.sqlClient.raw(
`SELECT name as database_name, database_id, create_date from sys.databases order by name` `SELECT name AS database_name, database_id, create_date FROM sys.databases ORDER BY name`
); );
} catch (e) { } catch (e) {
log.ppe(e, _func); log.ppe(e, _func);
@ -466,8 +463,8 @@ class MssqlClient extends KnexClient {
try { try {
result.data.list = await this.sqlClient.raw( result.data.list = await this.sqlClient.raw(
`select schema_name(t.schema_id) as schema_name, `SELECT schema_name(t.schema_id) AS schema_name,
t.name as tn, t.create_date, t.modify_date from sys.tables t WHERE schema_name(t.schema_id) = ? order by schema_name,tn `, t.name AS tn, t.create_date, t.modify_date FROM sys.tables t WHERE schema_name(t.schema_id) = ? ORDER BY schema_name,tn `,
[this.schema || 'dbo'] [this.schema || 'dbo']
); );
} catch (e) { } catch (e) {
@ -487,7 +484,7 @@ class MssqlClient extends KnexClient {
try { try {
result.data.list = await this.sqlClient.raw( result.data.list = await this.sqlClient.raw(
`SELECT name as schema_name FROM master.${this.schema}.sysdatabases where name not in ('master', 'tempdb', 'model', 'msdb');` `SELECT name AS schema_name FROM master.${this.schema}.sysdatabases WHERE name not in ('master', 'tempdb', 'model', 'msdb');`
); );
} catch (e) { } catch (e) {
log.ppe(e, _func); log.ppe(e, _func);
@ -535,53 +532,53 @@ class MssqlClient extends KnexClient {
try { try {
args.databaseName = this.connectionConfig.connection.database; args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(`select const response = await this.sqlClient.raw(`SELECT
c.table_name as tn, c.table_name AS tn,
case WHEN trg1.trigger_name IS NULL THEN CAST(0 as BIT) ELSE CAST(1 as BIT) END as au, CASE WHEN trg1.trigger_name IS NULL THEN CAST(0 AS BIT) ELSE CAST(1 AS BIT) END AS au,
c.column_name as cn, c.column_name AS cn,
c.ordinal_position as cop, c.ordinal_position AS cop,
pk.constraint_type as ck, pk.constraint_type AS ck,
case WHEN COLUMNPROPERTY(object_id(CONCAT('${this.schema}.', c.TABLE_NAME)), c.COLUMN_NAME, 'IsIdentity') = 1 CASE WHEN COLUMNPROPERTY(object_id(CONCAT('${this.schema}.', c.TABLE_NAME)), c.COLUMN_NAME, 'IsIdentity') = 1
THEN THEN
1 1
ELSE ELSE
0 0
END as ai, END AS ai,
c.is_nullable as nrqd, c.is_nullable AS nrqd,
c.data_type as dt, c.data_type AS dt,
c.column_default as cdf,c.character_maximum_length as clen, c.column_default AS cdf,c.character_maximum_length AS clen,
c.character_octet_length,c.numeric_precision as np,c.numeric_scale as ns,c.datetime_precision as dp,c.character_set_name as csn, c.character_octet_length,c.numeric_precision AS np,c.numeric_scale AS ns,c.datetime_precision AS dp,c.character_set_name AS csn,
c.collation_name as clnn, c.collation_name AS clnn,
pk.constraint_type as cst, pk.ordinal_position as op, pk.constraint_name as pk_constraint_name, pk.constraint_type AS cst, pk.ordinal_position AS op, pk.constraint_name AS pk_constraint_name,
fk.parent_table as rtn, fk.parent_column as rcn, fk.parent_table AS rtn, fk.parent_column AS rcn,
v.table_name as is_view, v.table_name AS is_view,
df.default_constraint_name df.default_constraint_name
from information_schema.columns c FROM INFORMATION_SCHEMA.COLUMNS c
left join left join
( select kc.constraint_name, kc.table_name,kc.column_name, kc.ordinal_position,tc.constraint_type ( SELECT kc.constraint_name, kc.table_name,kc.column_name, kc.ordinal_position,tc.constraint_type
from information_schema.key_column_usage kc FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kc
inner join information_schema.table_constraints as tc INNER JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc
on kc.constraint_name = tc.constraint_name and tc.constraint_type in ('primary key') ON kc.constraint_name = tc.constraint_name AND tc.constraint_type in ('primary key')
where kc.table_catalog='${args.databaseName}' and kc.table_schema='${this.schema}' WHERE kc.table_catalog='${args.databaseName}' AND kc.table_schema='${this.schema}'
) pk ) pk
on ON
pk.table_name = c.table_name and pk.column_name=c.column_name pk.table_name = c.table_name AND pk.column_name=c.column_name
left join left join
( select ( SELECT
ccu.table_name as child_table ccu.table_name AS child_table
,ccu.column_name as child_column ,ccu.column_name AS child_column
,kcu.table_name as parent_table ,kcu.table_name AS parent_table
,kcu.column_name as parent_column ,kcu.column_name AS parent_column
,ccu.constraint_name ,ccu.constraint_name
from information_schema.constraint_column_usage ccu FROM INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE ccu
inner join information_schema.referential_constraints rc INNER JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
on ccu.constraint_name = rc.constraint_name ON ccu.constraint_name = rc.constraint_name
inner join information_schema.key_column_usage kcu INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
on kcu.constraint_name = rc.unique_constraint_name ) fk ON kcu.constraint_name = rc.unique_constraint_name ) fk
on ON
fk.child_table = c.table_name and fk.child_column=c.column_name fk.child_table = c.table_name AND fk.child_column=c.column_name
left join information_schema.views v left join INFORMATION_SCHEMA.VIEWS v
on v.table_name=c.table_name ON v.table_name=c.table_name
left join ( left join (
SELECT SELECT
default_constraints.name default_constraint_name, all_columns.name name default_constraints.name default_constraint_name, all_columns.name name
@ -598,17 +595,17 @@ class MssqlClient extends KnexClient {
ON all_columns.default_object_id = default_constraints.object_id ON all_columns.default_object_id = default_constraints.object_id
WHERE WHERE
schemas.name = '${this.schema}' schemas.name = '${this.schema}'
AND tables.name = '${args.tn}') df on df.name = c.column_name AND tables.name = '${args.tn}') df ON df.name = c.column_name
left join ( select trg.name as trigger_name, left join ( SELECT trg.name AS trigger_name,
tab.name as [table1] tab.name AS [table1]
from sys.triggers trg FROM sys.triggers trg
left join sys.objects tab left join sys.objects tab
on trg.parent_id = tab.object_id ON trg.parent_id = tab.object_id
where tab.name = '${args.tn}') trg1 on trg1.trigger_name = CONCAT('xc_trigger_${args.tn}_' , c.column_name) WHERE tab.name = '${args.tn}') trg1 ON trg1.trigger_name = CONCAT('xc_trigger_${args.tn}_' , c.column_name)
where c.table_catalog='${args.databaseName}' and c.table_schema='${this.schema}' and c.table_name = '${args.tn}' WHERE c.table_catalog='${args.databaseName}' AND c.table_schema='${this.schema}' AND c.table_name = '${args.tn}'
order by c.table_name, c.ordinal_position`); ORDER BY c.table_name, c.ordinal_position`);
for (let i = 0; i < response.length; i++) { for (let i = 0; i < response.length; i++) {
const el = response[i]; const el = response[i];
@ -660,39 +657,39 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args); log.api(`${_func}:args:`, args);
try { try {
const response = await this.sqlClient.raw( const response = await this.sqlClient.raw(
`select t.[name] as table_view, `SELECT t.[name] AS table_view,
case when t.[type] = 'U' then 'Table' CASE WHEN t.[type] = 'U' THEN 'Table'
when t.[type] = 'V' then 'View' WHEN t.[type] = 'V' THEN 'View'
end as [object_type], END AS [object_type],
i.index_id, i.index_id,
case when i.is_primary_key = 1 then 'Primary key' CASE WHEN i.is_primary_key = 1 THEN 'Primary key'
when i.is_unique = 1 then 'Unique' WHEN i.is_unique = 1 THEN 'Unique'
else 'Not Unique' end as [type], else 'Not Unique' END AS [type],
i.[name] as index_name, i.[name] AS index_name,
substring(column_names, 1, len(column_names)-1) as [columns], substring(column_names, 1, len(column_names)-1) AS [columns],
case when i.[type] = 1 then 'Clustered index' CASE WHEN i.[type] = 1 THEN 'Clustered index'
when i.[type] = 2 then 'Nonclustered unique index' WHEN i.[type] = 2 THEN 'Nonclustered unique index'
when i.[type] = 3 then 'XML index' WHEN i.[type] = 3 THEN 'XML index'
when i.[type] = 4 then 'Spatial index' WHEN i.[type] = 4 THEN 'Spatial index'
when i.[type] = 5 then 'Clustered columnstore index' WHEN i.[type] = 5 THEN 'Clustered columnstore index'
when i.[type] = 6 then 'Nonclustered columnstore index' WHEN i.[type] = 6 THEN 'Nonclustered columnstore index'
when i.[type] = 7 then 'Nonclustered hash index' WHEN i.[type] = 7 THEN 'Nonclustered hash index'
end as index_type END AS index_type
from sys.objects t FROM sys.objects t
inner join sys.indexes i INNER JOIN sys.indexes i
on t.object_id = i.object_id ON t.object_id = i.object_id
cross apply (select col.[name] + ',' + CAST(ic.key_ordinal as varchar) + ',' cross apply (SELECT col.[name] + ',' + CAST(ic.key_ordinal AS varchar) + ','
from sys.index_columns ic FROM sys.index_columns ic
inner join sys.columns col INNER JOIN sys.columns col
on ic.object_id = col.object_id ON ic.object_id = col.object_id
and ic.column_id = col.column_id AND ic.column_id = col.column_id
where ic.object_id = t.object_id WHERE ic.object_id = t.object_id
and ic.index_id = i.index_id AND ic.index_id = i.index_id
order by col.column_id ORDER BY col.column_id
for xml path ('') ) D (column_names) for xml path ('') ) D (column_names)
where t.is_ms_shipped <> 1 WHERE t.is_ms_shipped <> 1
and index_id > 0 and t.name = '${this.getTnPath(args.tn)}' AND index_id > 0 AND t.name = '${this.getTnPath(args.tn)}'
order by schema_name(t.schema_id) + '.' + t.[name], i.index_id` ORDER BY schema_name(t.schema_id) + '.' + t.[name], i.index_id`
); );
const rows = []; const rows = [];
for (let i = 0, rowCount = 0; i < response.length; ++i, ++rowCount) { for (let i = 0, rowCount = 0; i < response.length; ++i, ++rowCount) {
@ -751,39 +748,39 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database; args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw( const response = await this.sqlClient.raw(
`select t.[name] as table_view, `SELECT t.[name] AS table_view,
case when t.[type] = 'U' then 'Table' CASE WHEN t.[type] = 'U' THEN 'Table'
when t.[type] = 'V' then 'View' WHEN t.[type] = 'V' THEN 'View'
end as [object_type], END AS [object_type],
i.index_id, i.index_id,
case when i.is_primary_key = 1 then 'Primary key' CASE WHEN i.is_primary_key = 1 THEN 'Primary key'
when i.is_unique = 1 then 'Unique' WHEN i.is_unique = 1 THEN 'Unique'
else 'Not Unique' end as [type], else 'Not Unique' END AS [type],
i.[name] as index_name, i.[name] AS index_name,
substring(column_names, 1, len(column_names)-1) as [columns], substring(column_names, 1, len(column_names)-1) AS [columns],
case when i.[type] = 1 then 'Clustered index' CASE WHEN i.[type] = 1 THEN 'Clustered index'
when i.[type] = 2 then 'Nonclustered unique index' WHEN i.[type] = 2 THEN 'Nonclustered unique index'
when i.[type] = 3 then 'XML index' WHEN i.[type] = 3 THEN 'XML index'
when i.[type] = 4 then 'Spatial index' WHEN i.[type] = 4 THEN 'Spatial index'
when i.[type] = 5 then 'Clustered columnstore index' WHEN i.[type] = 5 THEN 'Clustered columnstore index'
when i.[type] = 6 then 'Nonclustered columnstore index' WHEN i.[type] = 6 THEN 'Nonclustered columnstore index'
when i.[type] = 7 then 'Nonclustered hash index' WHEN i.[type] = 7 THEN 'Nonclustered hash index'
end as index_type END AS index_type
from sys.objects t FROM sys.objects t
inner join sys.indexes i INNER JOIN sys.indexes i
on t.object_id = i.object_id ON t.object_id = i.object_id
cross apply (select col.[name] + ', ' + CAST(ic.key_ordinal as varchar) + ', ' cross apply (SELECT col.[name] + ', ' + CAST(ic.key_ordinal AS varchar) + ', '
from sys.index_columns ic FROM sys.index_columns ic
inner join sys.columns col INNER JOIN sys.columns col
on ic.object_id = col.object_id ON ic.object_id = col.object_id
and ic.column_id = col.column_id AND ic.column_id = col.column_id
where ic.object_id = t.object_id WHERE ic.object_id = t.object_id
and ic.index_id = i.index_id AND ic.index_id = i.index_id
order by col.column_id ORDER BY col.column_id
for xml path ('') ) D (column_names) for xml path ('') ) D (column_names)
where t.is_ms_shipped <> 1 WHERE t.is_ms_shipped <> 1
and index_id > 0 and t.name = '${this.getTnPath(args.tn)}' AND index_id > 0 AND t.name = '${this.getTnPath(args.tn)}'
order by schema_name(t.schema_id) + '.' + t.[name], i.index_id` ORDER BY schema_name(t.schema_id) + '.' + t.[name], i.index_id`
); );
const rows = []; const rows = [];
for (let i = 0, rowCount = 0; i < response.length; ++i, ++rowCount) { for (let i = 0, rowCount = 0; i < response.length; ++i, ++rowCount) {
@ -841,25 +838,25 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args); log.api(`${_func}:args:`, args);
try { try {
const response = await this.sqlClient const response = await this.sqlClient
.raw(`select fk_tab.name as tn, '>-' as rel, pk_tab.name as rtn, .raw(`SELECT fk_tab.name AS tn, '>-' AS rel, pk_tab.name AS rtn,
fk_cols.constraint_column_id as no, fk_col.name as cn, ' = ' as [join], fk_cols.constraint_column_id AS no, fk_col.name AS cn, ' = ' AS [join],
pk_col.name as rcn, fk.name as cstn, pk_col.name AS rcn, fk.name AS cstn,
fk.update_referential_action_desc as ur, fk.delete_referential_action_desc as dr fk.update_referential_action_desc AS ur, fk.delete_referential_action_desc AS dr
from sys.foreign_keys fk FROM sys.foreign_keys fk
inner join sys.tables fk_tab INNER JOIN sys.tables fk_tab
on fk_tab.object_id = fk.parent_object_id ON fk_tab.object_id = fk.parent_object_id
inner join sys.tables pk_tab INNER JOIN sys.tables pk_tab
on pk_tab.object_id = fk.referenced_object_id ON pk_tab.object_id = fk.referenced_object_id
inner join sys.foreign_key_columns fk_cols INNER JOIN sys.foreign_key_columns fk_cols
on fk_cols.constraint_object_id = fk.object_id ON fk_cols.constraint_object_id = fk.object_id
inner join sys.columns fk_col INNER JOIN sys.columns fk_col
on fk_col.column_id = fk_cols.parent_column_id ON fk_col.column_id = fk_cols.parent_column_id
and fk_col.object_id = fk_tab.object_id AND fk_col.object_id = fk_tab.object_id
inner join sys.columns pk_col INNER JOIN sys.columns pk_col
on pk_col.column_id = fk_cols.referenced_column_id ON pk_col.column_id = fk_cols.referenced_column_id
and pk_col.object_id = pk_tab.object_id AND pk_col.object_id = pk_tab.object_id
where fk_tab.name = '${this.getTnPath(args.tn)}' WHERE fk_tab.name = '${this.getTnPath(args.tn)}'
order by fk_tab.name, pk_tab.name, fk_cols.constraint_column_id`); ORDER BY fk_tab.name, pk_tab.name, fk_cols.constraint_column_id`);
const ruleMapping = { const ruleMapping = {
NO_ACTION: 'NO ACTION', NO_ACTION: 'NO ACTION',
@ -907,24 +904,24 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args); log.api(`${_func}:args:`, args);
try { try {
const response = await this const response = await this
.raw(`select fk_tab.name as tn, '>-' as rel, pk_tab.name as rtn, .raw(`SELECT fk_tab.name AS tn, '>-' AS rel, pk_tab.name AS rtn,
fk_cols.constraint_column_id as no, fk_col.name as cn, ' = ' as [join], fk_cols.constraint_column_id AS no, fk_col.name AS cn, ' = ' AS [join],
pk_col.name as rcn, fk.name as cstn, pk_col.name AS rcn, fk.name AS cstn,
fk.update_referential_action_desc as ur, fk.delete_referential_action_desc as dr fk.update_referential_action_desc AS ur, fk.delete_referential_action_desc AS dr
from sys.foreign_keys fk FROM sys.foreign_keys fk
inner join sys.tables fk_tab INNER JOIN sys.tables fk_tab
on fk_tab.object_id = fk.parent_object_id ON fk_tab.object_id = fk.parent_object_id
inner join sys.tables pk_tab INNER JOIN sys.tables pk_tab
on pk_tab.object_id = fk.referenced_object_id ON pk_tab.object_id = fk.referenced_object_id
inner join sys.foreign_key_columns fk_cols INNER JOIN sys.foreign_key_columns fk_cols
on fk_cols.constraint_object_id = fk.object_id ON fk_cols.constraint_object_id = fk.object_id
inner join sys.columns fk_col INNER JOIN sys.columns fk_col
on fk_col.column_id = fk_cols.parent_column_id ON fk_col.column_id = fk_cols.parent_column_id
and fk_col.object_id = fk_tab.object_id AND fk_col.object_id = fk_tab.object_id
inner join sys.columns pk_col INNER JOIN sys.columns pk_col
on pk_col.column_id = fk_cols.referenced_column_id ON pk_col.column_id = fk_cols.referenced_column_id
and pk_col.object_id = pk_tab.object_id AND pk_col.object_id = pk_tab.object_id
order by fk_tab.name, pk_tab.name, fk_cols.constraint_column_id`); ORDER BY fk_tab.name, pk_tab.name, fk_cols.constraint_column_id`);
const ruleMapping = { const ruleMapping = {
NO_ACTION: 'NO ACTION', NO_ACTION: 'NO ACTION',
@ -972,31 +969,31 @@ class MssqlClient extends KnexClient {
const result = new Result(); const result = new Result();
log.api(`${_func}:args:`, args); log.api(`${_func}:args:`, args);
try { try {
const query = `select trg.name as trigger_name, const query = `SELECT trg.name AS trigger_name,
tab.name as [table], tab.name AS [table],
case when is_instead_of_trigger = 1 then 'Instead of' CASE WHEN is_instead_of_trigger = 1 THEN 'Instead of'
else 'After' end as [activation], else 'After' END AS [activation],
(case when objectproperty(trg.object_id, 'ExecIsUpdateTrigger') = 1 (CASE WHEN objectproperty(trg.object_id, 'ExecIsUpdateTrigger') = 1
then 'Update' else '' end THEN 'Update' else '' end
+ case when objectproperty(trg.object_id, 'ExecIsDeleteTrigger') = 1 + CASE WHEN objectproperty(trg.object_id, 'ExecIsDeleteTrigger') = 1
then 'Delete' else '' end THEN 'Delete' else '' end
+ case when objectproperty(trg.object_id, 'ExecIsInsertTrigger') = 1 + CASE WHEN objectproperty(trg.object_id, 'ExecIsInsertTrigger') = 1
then 'Insert' else '' end THEN 'Insert' else '' end
) as [event], ) AS [event],
case when trg.parent_class = 1 then 'Table trigger' CASE WHEN trg.parent_class = 1 THEN 'Table trigger'
when trg.parent_class = 0 then 'Database trigger' WHEN trg.parent_class = 0 THEN 'Database trigger'
end [class], END [class],
case when trg.[type] = 'TA' then 'Assembly (CLR) trigger' CASE WHEN trg.[type] = 'TA' THEN 'Assembly (CLR) trigger'
when trg.[type] = 'TR' then 'SQL trigger' WHEN trg.[type] = 'TR' THEN 'SQL trigger'
else '' end as [type], else '' END AS [type],
case when is_disabled = 1 then 'Disabled' CASE WHEN is_disabled = 1 THEN 'Disabled'
else 'Active' end as [status], else 'Active' END AS [status],
object_definition(trg.object_id) as [definition] object_definition(trg.object_id) AS [definition]
from sys.triggers trg FROM sys.triggers trg
left join sys.objects tab left join sys.objects tab
on trg.parent_id = tab.object_id ON trg.parent_id = tab.object_id
where tab.name = '${this.getTnPath(args.tn)}' WHERE tab.name = '${this.getTnPath(args.tn)}'
order by trg.name;`; ORDER BY trg.name;`;
const response = await this.sqlClient.raw(query); const response = await this.sqlClient.raw(query);
@ -1041,7 +1038,7 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args); log.api(`${_func}:args:`, args);
try { try {
const response = await this.sqlClient.raw( const response = await this.sqlClient.raw(
`SELECT o.name as function_name,definition, o.create_date as created, o.modify_date as modified,o.* `SELECT o.name AS function_name,definition, o.create_date AS created, o.modify_date AS modified,o.*
FROM sys.sql_modules AS m FROM sys.sql_modules AS m
JOIN sys.objects AS o ON m.object_id = o.object_id JOIN sys.objects AS o ON m.object_id = o.object_id
AND type IN ('FN', 'IF', 'TF')` AND type IN ('FN', 'IF', 'TF')`
@ -1086,8 +1083,8 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database; args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw( const response = await this.sqlClient.raw(
`select SPECIFIC_NAME as procedure_name, ROUTINE_TYPE as [type],LAST_ALTERED as modified, CREATED as created,ROUTINE_DEFINITION as definition ,pc.* `SELECT SPECIFIC_NAME AS procedure_name, ROUTINE_TYPE AS [type],LAST_ALTERED AS modified, CREATED AS created,ROUTINE_DEFINITION AS definition ,pc.*
from ${args.databaseName}.information_schema.routines as pc where routine_type = 'PROCEDURE'` FROM ${args.databaseName}.INFORMATION_SCHEMA.ROUTINES AS pc WHERE routine_type = 'PROCEDURE'`
); );
result.data.list = response; result.data.list = response;
@ -1119,8 +1116,8 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database; args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw( const response = await this.sqlClient.raw(
`SELECT v.name as view_name,v.*,m.* FROM sys.views v inner join sys.schemas s on s.schema_id = v.schema_id `SELECT v.name AS view_name,v.*,m.* FROM sys.views v INNER JOIN sys.schemas s ON s.schema_id = v.schema_id
inner join sys.sql_modules as m on m.object_id = v.object_id` INNER JOIN sys.sql_modules AS m ON m.object_id = v.object_id`
); );
result.data.list = response; result.data.list = response;
@ -1153,10 +1150,10 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database; args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw( const response = await this.sqlClient.raw(
`SELECT o.name as function_name,definition as create_function, o.create_date as created, o.modify_date as modified,o.* `SELECT o.name AS function_name,definition AS create_function, o.create_date AS created, o.modify_date AS modified,o.*
FROM sys.sql_modules AS m FROM sys.sql_modules AS m
JOIN sys.objects AS o ON m.object_id = o.object_id JOIN sys.objects AS o ON m.object_id = o.object_id
AND type IN ('FN', 'IF', 'TF') and o.name = '${args.function_name}'` AND type IN ('FN', 'IF', 'TF') AND o.name = '${args.function_name}'`
); );
for (let i = 0; i < response.length; i++) { for (let i = 0; i < response.length; i++) {
@ -1194,8 +1191,8 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database; args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw( const response = await this.sqlClient.raw(
`select SPECIFIC_NAME as procedure_name, ROUTINE_TYPE as [type],LAST_ALTERED as modified, CREATED as created,ROUTINE_DEFINITION as create_procedure ,pc.* `SELECT SPECIFIC_NAME AS procedure_name, ROUTINE_TYPE AS [type],LAST_ALTERED AS modified, CREATED AS created,ROUTINE_DEFINITION AS create_procedure ,pc.*
from ${args.databaseName}.information_schema.routines as pc where routine_type = 'PROCEDURE' and SPECIFIC_NAME='${args.procedure_name}'` FROM ${args.databaseName}.INFORMATION_SCHEMA.ROUTINES AS pc WHERE routine_type = 'PROCEDURE' AND SPECIFIC_NAME='${args.procedure_name}'`
); );
result.data.list = response; result.data.list = response;
@ -1224,8 +1221,8 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database; args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw( const response = await this.sqlClient.raw(
`SELECT v.name as view_name,v.*,m.*, m.definition as view_definition FROM sys.views v inner join sys.schemas s on s.schema_id = v.schema_id `SELECT v.name AS view_name,v.*,m.*, m.definition AS view_definition FROM sys.views v INNER JOIN sys.schemas s ON s.schema_id = v.schema_id
inner join sys.sql_modules as m on m.object_id = v.object_id where v.name = '${args.view_name}'` INNER JOIN sys.sql_modules AS m ON m.object_id = v.object_id WHERE v.name = '${args.view_name}'`
); );
result.data.list = response; result.data.list = response;
@ -1259,9 +1256,9 @@ class MssqlClient extends KnexClient {
FROM sysobjects AS [so] FROM sysobjects AS [so]
INNER JOIN sys.sql_modules AS df ON object_id = so.id INNER JOIN sys.sql_modules AS df ON object_id = so.id
INNER JOIN sysobjects AS so2 ON so.parent_obj = so2.Id INNER JOIN sysobjects AS so2 ON so.parent_obj = so2.Id
WHERE [so].[type] = 'TR' and so2.name = '${this.getTnPath( WHERE [so].[type] = 'TR' AND so2.name = '${this.getTnPath(
args.tn args.tn
)}' and [so].[name] = '${args.trigger_name}'` )}' AND [so].[name] = '${args.trigger_name}'`
); );
for (let i = 0; i < response.length; i++) { for (let i = 0; i < response.length; i++) {
@ -1291,7 +1288,7 @@ class MssqlClient extends KnexClient {
try { try {
const rows = await this.sqlClient.raw( const rows = await this.sqlClient.raw(
`select name from sys.databases where name = '${args.database_name}'` `SELECT name FROM sys.databases WHERE name = '${args.database_name}'`
); );
if (rows.length === 0) { if (rows.length === 0) {
@ -1346,8 +1343,7 @@ class MssqlClient extends KnexClient {
log.api(`${func}:args:`, args); log.api(`${func}:args:`, args);
// `DROP TRIGGER ${args.trigger_name}` // `DROP TRIGGER ${args.trigger_name}`
try { try {
const query = `${this.querySeparator()}DROP TRIGGER IF EXISTS ${ const query = `${this.querySeparator()}DROP TRIGGER IF EXISTS ${args.trigger_name
args.trigger_name
}`; }`;
await this.sqlClient.raw(query); await this.sqlClient.raw(query);
@ -1478,8 +1474,7 @@ class MssqlClient extends KnexClient {
{ {
sql: sql:
this.querySeparator() + this.querySeparator() +
`DROP FUNCTION IF EXISTS ${ `DROP FUNCTION IF EXISTS ${args.function_name
args.function_name
};${this.querySeparator()}\n${args.create_function}`, };${this.querySeparator()}\n${args.create_function}`,
}, },
], ],
@ -1487,8 +1482,7 @@ class MssqlClient extends KnexClient {
{ {
sql: sql:
this.querySeparator() + this.querySeparator() +
`DROP FUNCTION IF EXISTS ${ `DROP FUNCTION IF EXISTS ${args.function_name
args.function_name
};${this.querySeparator()} ${args.oldCreateFunction}`, };${this.querySeparator()} ${args.oldCreateFunction}`,
}, },
], ],
@ -1560,8 +1554,7 @@ class MssqlClient extends KnexClient {
{ {
sql: sql:
this.querySeparator() + this.querySeparator() +
`DROP PROCEDURE IF EXISTS ${ `DROP PROCEDURE IF EXISTS ${args.procedure_name
args.procedure_name
};${this.querySeparator()}\n${args.create_procedure}`, };${this.querySeparator()}\n${args.create_procedure}`,
}, },
], ],
@ -1569,8 +1562,7 @@ class MssqlClient extends KnexClient {
{ {
sql: sql:
this.querySeparator() + this.querySeparator() +
`DROP PROCEDURE IF EXISTS ${ `DROP PROCEDURE IF EXISTS ${args.procedure_name
args.procedure_name
};${this.querySeparator()}${args.oldCreateProcedure}`, };${this.querySeparator()}${args.oldCreateProcedure}`,
}, },
], ],
@ -1599,7 +1591,7 @@ class MssqlClient extends KnexClient {
log.api(`${func}:args:`, args); log.api(`${func}:args:`, args);
try { try {
const query = this.genQuery( const query = this.genQuery(
`CREATE TRIGGER ?? on ?? \n${args.timing} ${args.event}\n as\n${args.statement}`, `CREATE TRIGGER ?? ON ?? \n${args.timing} ${args.event}\n as\n${args.statement}`,
[args.trigger_name, this.getTnPath(args.tn)] [args.trigger_name, this.getTnPath(args.tn)]
); );
await this.sqlClient.raw(query); await this.sqlClient.raw(query);
@ -1635,8 +1627,7 @@ class MssqlClient extends KnexClient {
try { try {
// await this.sqlClient.raw(`DROP TRIGGER ${args.trigger_name}`); // await this.sqlClient.raw(`DROP TRIGGER ${args.trigger_name}`);
await this.sqlClient.raw( await this.sqlClient.raw(
`ALTER TRIGGER ${args.trigger_name} ON ${this.getTnPath(args.tn)} \n${ `ALTER TRIGGER ${args.trigger_name} ON ${this.getTnPath(args.tn)} \n${args.timing
args.timing
} ${args.event}\n AS\n${args.statement}` } ${args.event}\n AS\n${args.statement}`
); );
@ -1721,8 +1712,7 @@ class MssqlClient extends KnexClient {
{ {
sql: sql:
this.querySeparator() + this.querySeparator() +
`DROP VIEW ${args.view_name} ; ${this.querySeparator()}${ `DROP VIEW ${args.view_name} ; ${this.querySeparator()}${args.oldViewDefination
args.oldViewDefination
};`, };`,
}, },
], ],
@ -1817,10 +1807,7 @@ class MssqlClient extends KnexClient {
const downStatement = const downStatement =
this.querySeparator() + this.querySeparator() +
this.sqlClient.schema this.sqlClient.schema.withSchema(this.schema).dropTable(args.table).toString();
.withSchema(this.schema)
.dropTable(args.table)
.toString();
this.emit(`Success : ${upQuery}`); this.emit(`Success : ${upQuery}`);
@ -1861,16 +1848,8 @@ class MssqlClient extends KnexClient {
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
UPDATE ?? Set ?? = GetDate() where ?? in (SELECT ?? FROM Inserted) UPDATE ?? Set ?? = GetDate() WHERE ?? in (SELECT ?? FROM Inserted)
END;`, END;`, [triggerName, this.getTnPath(args.table_name), this.getTnPath(args.table_name), column.column_name, pk.column_name, pk.column_name]
[
triggerName,
this.getTnPath(args.table_name),
this.getTnPath(args.table_name),
column.column_name,
pk.column_name,
pk.column_name,
]
); );
upQuery += triggerCreateQuery; upQuery += triggerCreateQuery;
@ -1905,7 +1884,7 @@ class MssqlClient extends KnexClient {
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
UPDATE [${this.schema}].[${args.table_name}] Set [${column.column_name}] = GetDate() where [${pk.column_name}] in (SELECT [${pk.column_name}] FROM Inserted) UPDATE [${this.schema}].[${args.table_name}] Set [${column.column_name}] = GetDate() WHERE [${pk.column_name}] in (SELECT [${pk.column_name}] FROM Inserted)
END;`; END;`;
upQuery += triggerCreateQuery; upQuery += triggerCreateQuery;
@ -2072,10 +2051,7 @@ class MssqlClient extends KnexClient {
/** ************** create up & down statements *************** */ /** ************** create up & down statements *************** */
const upStatement = const upStatement =
this.querySeparator() + this.querySeparator() +
this.sqlClient.schema this.sqlClient.schema.withSchema(this.schema).dropTable(args.tn).toString();
.withSchema(this.schema)
.dropTable(args.tn)
.toString();
let downQuery = this.querySeparator() + this.createTable(args.tn, args); let downQuery = this.querySeparator() + this.createTable(args.tn, args);
this.emit(`Success : ${upStatement}`); this.emit(`Success : ${upStatement}`);
@ -2089,9 +2065,8 @@ class MssqlClient extends KnexClient {
for (const relation of relationsList) { for (const relation of relationsList) {
downQuery += downQuery +=
this.querySeparator() + this.querySeparator() +
(await this.sqlClient (await this.sqlClient.withSchema(this.schema).schema
.withSchema(this.schema) .table(relation.tn, (table) => {
.schema.table(relation.tn, (table) => {
table = table table = table
.foreign(relation.cn, null) .foreign(relation.cn, null)
.references(relation.rcn) .references(relation.rcn)
@ -2134,8 +2109,7 @@ class MssqlClient extends KnexClient {
)) { )) {
downQuery += downQuery +=
this.querySeparator() + this.querySeparator() +
this.sqlClient.schema this.sqlClient.schema.withSchema(this.schema)
.withSchema(this.schema)
.table(tn, function (table) { .table(tn, function (table) {
if (non_unique) { if (non_unique) {
table.index(columns, key_name); table.index(columns, key_name);
@ -2180,9 +2154,7 @@ class MssqlClient extends KnexClient {
try { try {
const self = this; const self = this;
await this.sqlClient.schema.table( await this.sqlClient.schema.table(this.getTnPath(args.childTable), function (table) {
this.getTnPath(args.childTable),
function (table) {
table = table table = table
.foreign(args.childColumn, foreignKeyName) .foreign(args.childColumn, foreignKeyName)
.references(args.parentColumn) .references(args.parentColumn)
@ -2194,8 +2166,7 @@ class MssqlClient extends KnexClient {
if (args.onDelete) { if (args.onDelete) {
table = table.onDelete(args.onDelete); table = table.onDelete(args.onDelete);
} }
} });
);
const upStatement = const upStatement =
this.querySeparator() + this.querySeparator() +
@ -2256,12 +2227,9 @@ class MssqlClient extends KnexClient {
try { try {
const self = this; const self = this;
await this.sqlClient.schema.table( await this.sqlClient.schema.table(this.getTnPath(args.childTable), function (table) {
this.getTnPath(args.childTable),
function (table) {
table.dropForeign(args.childColumn, foreignKeyName); table.dropForeign(args.childColumn, foreignKeyName);
} });
);
const upStatement = const upStatement =
this.querySeparator() + this.querySeparator() +
@ -2404,7 +2372,7 @@ class MssqlClient extends KnexClient {
const result = new Result(); const result = new Result();
log.api(`${_func}:args:`, args); log.api(`${_func}:args:`, args);
try { try {
result.data = `DELETE FROM ${this.getTnPath(args.tn)} where ;`; result.data = `DELETE FROM ${this.getTnPath(args.tn)} WHERE ;`;
} catch (e) { } catch (e) {
log.ppe(e, _func); log.ppe(e, _func);
throw e; throw e;

46
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts

@ -99,9 +99,10 @@ class BaseModelSqlv2 {
qb.where(_wherePk(this.model.primaryKeys, id)); qb.where(_wherePk(this.model.primaryKeys, id));
const data = (await this.extractRawQueryAndExec(qb))?.[0]; let data = (await this.extractRawQueryAndExec(qb))?.[0];
if (data) { if (data) {
data = this.convertAttachmentType(data);
const proto = await this.getProto(); const proto = await this.getProto();
data.__proto__ = proto; data.__proto__ = proto;
} }
@ -251,7 +252,8 @@ class BaseModelSqlv2 {
if (!ignoreViewFilterAndSort) applyPaginate(qb, rest); if (!ignoreViewFilterAndSort) applyPaginate(qb, rest);
const proto = await this.getProto(); const proto = await this.getProto();
const data = await this.extractRawQueryAndExec(qb); let data = await this.extractRawQueryAndExec(qb);
data = this.convertAttachmentType(data);
return data?.map((d) => { return data?.map((d) => {
d.__proto__ = proto; d.__proto__ = proto;
@ -423,7 +425,8 @@ class BaseModelSqlv2 {
.as('list') .as('list')
); );
const children = await this.extractRawQueryAndExec(childQb); let children = await this.extractRawQueryAndExec(childQb);
children = this.convertAttachmentType(children);
const proto = await ( const proto = await (
await Model.getBaseModelSQL({ await Model.getBaseModelSQL({
id: childTable.id, id: childTable.id,
@ -550,7 +553,8 @@ class BaseModelSqlv2 {
await childModel.selectObject({ qb }); await childModel.selectObject({ qb });
const children = await this.extractRawQueryAndExec(qb); let children = await this.extractRawQueryAndExec(qb);
children = this.convertAttachmentType(children);
const proto = await ( const proto = await (
await Model.getBaseModelSQL({ await Model.getBaseModelSQL({
@ -668,6 +672,7 @@ class BaseModelSqlv2 {
); );
let children = await this.extractRawQueryAndExec(finalQb); let children = await this.extractRawQueryAndExec(finalQb);
children = this.convertAttachmentType(children);
if (this.isMySQL) { if (this.isMySQL) {
children = children[0]; children = children[0];
} }
@ -735,7 +740,8 @@ class BaseModelSqlv2 {
qb.limit(+rest?.limit || 25); qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0); qb.offset(+rest?.offset || 0);
const children = await this.extractRawQueryAndExec(qb); let children = await this.extractRawQueryAndExec(qb);
children = this.convertAttachmentType(children);
const proto = await ( const proto = await (
await Model.getBaseModelSQL({ id: rtnId, dbDriver: this.dbDriver }) await Model.getBaseModelSQL({ id: rtnId, dbDriver: this.dbDriver })
).getProto(); ).getProto();
@ -1076,7 +1082,8 @@ class BaseModelSqlv2 {
applyPaginate(qb, rest); applyPaginate(qb, rest);
const proto = await childModel.getProto(); const proto = await childModel.getProto();
const data = await this.extractRawQueryAndExec(qb); let data = await this.extractRawQueryAndExec(qb);
data = this.convertAttachmentType(data);
return data.map((c) => { return data.map((c) => {
c.__proto__ = proto; c.__proto__ = proto;
@ -1194,7 +1201,8 @@ class BaseModelSqlv2 {
applyPaginate(qb, rest); applyPaginate(qb, rest);
const proto = await parentModel.getProto(); const proto = await parentModel.getProto();
const data = await this.extractRawQueryAndExec(qb); let data = await this.extractRawQueryAndExec(qb);
data = this.convertAttachmentType(data);
return data.map((c) => { return data.map((c) => {
c.__proto__ = proto; c.__proto__ = proto;
@ -2726,6 +2734,30 @@ class BaseModelSqlv2 {
) )
: await this.dbDriver.raw(query); : await this.dbDriver.raw(query);
} }
private convertAttachmentType(data) {
// attachment is stored in text and parse in UI
// convertAttachmentType is used to convert the response in string to array of object in API response
if (data) {
const attachmentColumns = this.model.columns.filter(
(c) => c.uidt === UITypes.Attachment
);
if (attachmentColumns.length) {
if (!Array.isArray(data)) {
data = [data];
}
data = data.map((d) => {
attachmentColumns.forEach((col) => {
if (d[col.title] && typeof d[col.title] === 'string') {
d[col.title] = JSON.parse(d[col.title]);
}
});
return d;
});
}
}
return data;
}
} }
function extractSortsObject( function extractSortsObject(

100
tests/playwright/README.md

@ -1,100 +0,0 @@
# Playwright E2E tests
## Setup
Make sure to install the dependencies(in the playwright folder, which is `./tests/playwright`):
```bash
npm install
npx playwright install chromium --with-deps
```
## Run Test Server
Start the backend test server (in `packages/nocodb` folder):
```bash
npm run watch:run:playwright
```
Start the frontend test server (in `packages/nc-gui` folder):
```bash
NUXT_PAGE_TRANSITION_DISABLE=true npm run dev
```
## Running Tests
### Running all tests
For selecting db type, rename `.env.example` to `.env` and set `E2E_DEV_DB_TYPE` to `sqlite`(default), `mysql` or `pg`.
headless mode(without opening browser):
```bash
npm run test
```
with browser:
```bash
npm run test:debug
```
</br>
</br>
For setting up mysql:
```bash
docker-compose -f ./tests/playwright/scripts/docker-compose-mysql-playwright.yml up -d
```
For setting up postgres:
```bash
docker-compose -f ./tests/playwright/scripts/docker-compose-playwright-pg.yml
```
### Running individual tests
Add `.only` to the test you want to run:
```js
test.only('should login', async ({ page }) => {
// ...
})
```
```bash
npm run test
```
## Developing tests
### WebStorm
In Webstorm, you can use the `test-debug` run action to run the tests.
Add `.only` to the test you want to run. This will open the test in a chromium session and you can also add break points.
### VSCode
In VSCode, use this [extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright).
It will have run button beside each test in the file.
## Page Objects
- Page object is a class which has methods to interact with a page/component. Methods should be thin and should not do a whole lot. They should also be reusable.
- All the action methods i.e click of a page object is also responsible for waiting till the action is completed. This can be done by waiting on an API call or some ui change.
- Do not add any logic to the tests. Instead, create a page object for the page you are testing.
All the selection, UI actions and assertions should be in the page object.
Page objects should be in `./tests/playwright/pages` folder.
## Verify if tests are not flaky
Add `.only` to the added tests and run `npm run test:repeat`. This will run the test multiple times and should show if the test is flaky.

2
tests/playwright/pages/Base.ts

@ -16,9 +16,11 @@ export default abstract class BasePage {
} }
async waitForResponse({ async waitForResponse({
// Playwright action that triggers the request i.e locatorSomething.click()
uiAction, uiAction,
httpMethodsToMatch = [], httpMethodsToMatch = [],
requestUrlPathToMatch, requestUrlPathToMatch,
// A function that takes the response body and returns true if the response is the one we are looking for
responseJsonMatcher, responseJsonMatcher,
}: { }: {
uiAction: Promise<any>; uiAction: Promise<any>;

Loading…
Cancel
Save