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) => {
nextTick(() => {
;(wrapperEl?.querySelector('input,select,textarea') as HTMLInputElement)?.focus()
const cellWrapperEl = ref<HTMLElement>()
onMounted(() => {
setTimeout(() => {
;(cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus()
})
}
})
</script>
<script lang="ts">
@ -163,7 +165,10 @@ export default {
<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" />
<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'
const renderIcon = (column: ColumnType, abstractType: any) => {
if (isPrimary(column)) {
if (isPrimaryKey(column)) {
return KeyIcon
} else if (isJSON(column)) {
return JSONIcon

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

@ -1,9 +1,22 @@
<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 { search, meta } = useSmartsheetStoreOrThrow()
const { meta } = useSmartsheetStoreOrThrow()
const activeView = inject(ActiveViewInj, ref())
const { search, loadFieldQuery } = useFieldQuery(activeView)
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() {
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 type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk'
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(
(
@ -17,12 +17,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const cellRefs = ref<HTMLTableDataCellElement[]>([])
// state
// todo: move to grid view store
const search = reactive({
field: '',
query: '',
})
const { search } = useFieldQuery(view)
// getters
const isLocked = computed(() => view.value?.lock_type === 'locked')
@ -35,21 +30,20 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const xWhere = computed(() => {
let where
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)
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') {
where = `(${col.title},like,%${search.query.trim()}%)`
where = `(${col.title},like,%${search.value.query.trim()}%)`
} else {
where = `(${col.title},eq,${search.query.trim()})`
where = `(${col.title},eq,${search.value.query.trim()})`
}
return where
})
const isSqlView = computed(() => (meta.value as TableType)?.type === 'view')
const sorts = ref<SortType[]>(unref(initialSorts) ?? [])
const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
@ -58,7 +52,6 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
meta,
isLocked,
$api,
search,
xWhere,
isPkAvail,
isForm,

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

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

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

@ -86,11 +86,11 @@
"records": "記錄",
"webhook": "Webhook",
"webhooks": "Webhook",
"view": "檢視",
"views": "檢視",
"view": "檢視",
"views": "所有檢視",
"viewType": {
"grid": "網格",
"gallery": "圖庫",
"gallery": "相簿",
"form": "表單",
"kanban": "看板",
"calendar": "日曆"
@ -206,7 +206,7 @@
"notifyVia": "透過...通知",
"projName": "項目名",
"tableName": "表名稱",
"viewName": "查看名稱",
"viewName": "檢視名稱",
"viewLink": "查看鏈接",
"columnName": "列名稱",
"columnType": "列類型",
@ -384,18 +384,18 @@
"clearMetadata": "清除中繼資料",
"exportToFile": "匯出為檔案",
"changePwd": "更改密碼",
"createView": "建立檢視",
"shareView": "分享檢視",
"createView": "建立檢視",
"shareView": "分享檢視",
"listSharedView": "共享視圖列表",
"ListView": "檢視表清單",
"copyView": "複製檢視",
"renameView": "重新命名檢視",
"deleteView": "刪除檢視",
"createGrid": "創建網格視",
"createGallery": "創建畫廊視圖",
"createCalendar": "創建日曆視",
"createKanban": "創建尋呼視圖",
"createForm": "創建表單視",
"copyView": "複製檢視",
"renameView": "重新命名檢視",
"deleteView": "刪除檢視",
"createGrid": "創建網格視",
"createGallery": "創建相簿檢視",
"createCalendar": "創建日曆視",
"createKanban": "創建看板檢視",
"createForm": "創建表單視",
"showSystemFields": "顯示系統字段",
"copyUrl": "複製網址",
"openTab": "開啟新分頁",
@ -490,7 +490,7 @@
"info": {
"roles": {
"orgCreator": "建立者可以建立專案與存取任何受邀請的專案",
"orgViewer": "建立者不能建立專案但可以存取任何受邀請的專案"
"orgViewer": "檢視者不能建立專案但可以存取任何受邀請的專案"
},
"footerInfo": "每頁行駛",
"upload": "選擇檔案以上傳",
@ -518,8 +518,8 @@
"formDesc": "添加表單描述",
"beforeEnablePwd": "使用密碼限制存取權限",
"afterEnablePwd": "存取受密碼限制",
"privateLink": "此檢視通過私人連結共享",
"privateLinkAdditionalInfo": "具有私有連結的人只能看到此檢視中可見的儲存格",
"privateLink": "此檢視通過私人連結共享",
"privateLinkAdditionalInfo": "具有私有連結的人只能看到此檢視中可見的儲存格",
"afterFormSubmitted": "表格提交後",
"apiOptions": "存取專案方式",
"submitAnotherForm": "顯示“提交另一個表格”按鈕",
@ -528,7 +528,7 @@
"showSysFields": "顯示系統字段",
"filterAutoApply": "自動申請",
"showMessage": "顯示此消息",
"viewNotShared": "當前視不共享!",
"viewNotShared": "當前視不共享!",
"showAllViews": "顯示此表的所有共享視圖",
"collabView": "具有編輯權限或更高的合作者可以更改視圖配置。",
"lockedView": "沒有人可以編輯視圖配置,直到它被解鎖。",
@ -570,11 +570,11 @@
"dontHaveAccount": "沒有帳號?"
},
"addView": {
"grid": "加入網格檢視",
"gallery": "加入圖庫檢視表",
"form": "加入表單檢視",
"kanban": "加入看板檢視",
"calendar": "加入日曆檢視"
"grid": "加入網格檢視",
"gallery": "加入相簿檢視",
"form": "加入表單檢視",
"kanban": "加入看板檢視",
"calendar": "加入日曆檢視"
},
"tablesMetadataInSync": "表元數據同步",
"addMultipleUsers": "您可以添加多個逗號(,)分隔的電子郵件",
@ -679,7 +679,7 @@
"authToken": "驗證權杖已複製到剪貼簿",
"projInfo": "已將專案資訊複製到剪貼簿",
"inviteUrlCopy": "已將邀請連結複製到剪貼簿",
"createView": "成功建立檢視",
"createView": "成功建立檢視",
"formEmailSMTP": "請啟用 App Store 中的 SMTP 外掛程式以啟用電子郵件通知",
"collabView": "成功轉換為協作視圖",
"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"
description: "Overview to testing"
title: "Writing Unit Tests"
description: "Overview to Unit Testing"
position: 3250
category: "Engineering"
menuTitle: "Writing Tests"
menuTitle: "Unit Testing"
---
## 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);
try {
await this.sqlClient.raw('SELECT 1+1 as data');
await this.sqlClient.raw('SELECT 1+1 AS data');
} catch (e) {
log.ppe(e);
result.code = -1;
@ -246,7 +246,7 @@ class MssqlClient extends KnexClient {
try {
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 = {};
@ -284,7 +284,7 @@ class MssqlClient extends KnexClient {
const tempSqlClient = knex(connectionParamsWithoutDb);
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) {
@ -356,14 +356,12 @@ class MssqlClient extends KnexClient {
try {
/** ************** START : create _evolution table if not exists *************** */
const exists = await this.sqlClient.schema
.withSchema(this.schema)
.hasTable(args.tn);
const exists = await this.sqlClient.schema.withSchema(this.schema).hasTable(args.tn);
if (!exists) {
await this.sqlClient.schema
.withSchema(this.schema)
.createTable(args.tn, function (table) {
await this.sqlClient.schema.withSchema(this.schema).createTable(
args.tn,
function (table) {
table.increments();
table.string('title').notNullable();
table.string('titleDown').nullable();
@ -373,7 +371,8 @@ class MssqlClient extends KnexClient {
table.integer('status').nullable();
table.dateTime('created');
table.timestamps();
});
}
);
log.debug('Table created:', `${this.getTnPath(args.tn)}`);
} else {
log.debug(`${this.getTnPath(args.tn)} tables exists`);
@ -395,9 +394,7 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args);
try {
result.data.value = await this.sqlClient.schema
.withSchema(this.schema)
.hasTable(args.tn);
result.data.value = await this.sqlClient.schema.withSchema(this.schema).hasTable(args.tn);
} catch (e) {
log.ppe(e, _func);
throw e;
@ -415,7 +412,7 @@ class MssqlClient extends KnexClient {
try {
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;
} catch (e) {
@ -441,7 +438,7 @@ class MssqlClient extends KnexClient {
try {
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) {
log.ppe(e, _func);
@ -466,8 +463,8 @@ class MssqlClient extends KnexClient {
try {
result.data.list = await this.sqlClient.raw(
`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 `,
`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 `,
[this.schema || 'dbo']
);
} catch (e) {
@ -487,7 +484,7 @@ class MssqlClient extends KnexClient {
try {
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) {
log.ppe(e, _func);
@ -535,53 +532,53 @@ class MssqlClient extends KnexClient {
try {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(`select
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,
c.column_name as cn,
c.ordinal_position as cop,
pk.constraint_type as ck,
case WHEN COLUMNPROPERTY(object_id(CONCAT('${this.schema}.', c.TABLE_NAME)), c.COLUMN_NAME, 'IsIdentity') = 1
const response = await this.sqlClient.raw(`SELECT
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,
c.column_name AS cn,
c.ordinal_position AS cop,
pk.constraint_type AS ck,
CASE WHEN COLUMNPROPERTY(object_id(CONCAT('${this.schema}.', c.TABLE_NAME)), c.COLUMN_NAME, 'IsIdentity') = 1
THEN
1
ELSE
0
END as ai,
c.is_nullable as nrqd,
c.data_type as dt,
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.collation_name as clnn,
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,
v.table_name as is_view,
END AS ai,
c.is_nullable AS nrqd,
c.data_type AS dt,
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.collation_name AS clnn,
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,
v.table_name AS is_view,
df.default_constraint_name
from information_schema.columns c
FROM INFORMATION_SCHEMA.COLUMNS c
left join
( select kc.constraint_name, kc.table_name,kc.column_name, kc.ordinal_position,tc.constraint_type
from information_schema.key_column_usage kc
inner join information_schema.table_constraints as tc
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}'
( SELECT kc.constraint_name, kc.table_name,kc.column_name, kc.ordinal_position,tc.constraint_type
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kc
INNER JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc
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}'
) pk
on
pk.table_name = c.table_name and pk.column_name=c.column_name
ON
pk.table_name = c.table_name AND pk.column_name=c.column_name
left join
( select
ccu.table_name as child_table
,ccu.column_name as child_column
,kcu.table_name as parent_table
,kcu.column_name as parent_column
( SELECT
ccu.table_name AS child_table
,ccu.column_name AS child_column
,kcu.table_name AS parent_table
,kcu.column_name AS parent_column
,ccu.constraint_name
from information_schema.constraint_column_usage ccu
inner join information_schema.referential_constraints rc
on ccu.constraint_name = rc.constraint_name
inner join information_schema.key_column_usage kcu
on kcu.constraint_name = rc.unique_constraint_name ) fk
on
fk.child_table = c.table_name and fk.child_column=c.column_name
left join information_schema.views v
on v.table_name=c.table_name
FROM INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE ccu
INNER JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
ON ccu.constraint_name = rc.constraint_name
INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
ON kcu.constraint_name = rc.unique_constraint_name ) fk
ON
fk.child_table = c.table_name AND fk.child_column=c.column_name
left join INFORMATION_SCHEMA.VIEWS v
ON v.table_name=c.table_name
left join (
SELECT
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
WHERE
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,
tab.name as [table1]
from sys.triggers trg
left join ( SELECT trg.name AS trigger_name,
tab.name AS [table1]
FROM sys.triggers trg
left join sys.objects tab
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)
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 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`);
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`);
for (let i = 0; i < response.length; i++) {
const el = response[i];
@ -660,39 +657,39 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args);
try {
const response = await this.sqlClient.raw(
`select t.[name] as table_view,
case when t.[type] = 'U' then 'Table'
when t.[type] = 'V' then 'View'
end as [object_type],
`SELECT t.[name] AS table_view,
CASE WHEN t.[type] = 'U' THEN 'Table'
WHEN t.[type] = 'V' THEN 'View'
END AS [object_type],
i.index_id,
case when i.is_primary_key = 1 then 'Primary key'
when i.is_unique = 1 then 'Unique'
else 'Not Unique' end as [type],
i.[name] as index_name,
substring(column_names, 1, len(column_names)-1) as [columns],
case when i.[type] = 1 then 'Clustered index'
when i.[type] = 2 then 'Nonclustered unique index'
when i.[type] = 3 then 'XML index'
when i.[type] = 4 then 'Spatial index'
when i.[type] = 5 then 'Clustered columnstore index'
when i.[type] = 6 then 'Nonclustered columnstore index'
when i.[type] = 7 then 'Nonclustered hash index'
end as index_type
from sys.objects t
inner join sys.indexes i
on t.object_id = i.object_id
cross apply (select col.[name] + ',' + CAST(ic.key_ordinal as varchar) + ','
from sys.index_columns ic
inner join sys.columns col
on ic.object_id = col.object_id
and ic.column_id = col.column_id
where ic.object_id = t.object_id
and ic.index_id = i.index_id
order by col.column_id
CASE WHEN i.is_primary_key = 1 THEN 'Primary key'
WHEN i.is_unique = 1 THEN 'Unique'
else 'Not Unique' END AS [type],
i.[name] AS index_name,
substring(column_names, 1, len(column_names)-1) AS [columns],
CASE WHEN i.[type] = 1 THEN 'Clustered index'
WHEN i.[type] = 2 THEN 'Nonclustered unique index'
WHEN i.[type] = 3 THEN 'XML index'
WHEN i.[type] = 4 THEN 'Spatial index'
WHEN i.[type] = 5 THEN 'Clustered columnstore index'
WHEN i.[type] = 6 THEN 'Nonclustered columnstore index'
WHEN i.[type] = 7 THEN 'Nonclustered hash index'
END AS index_type
FROM sys.objects t
INNER JOIN sys.indexes i
ON t.object_id = i.object_id
cross apply (SELECT col.[name] + ',' + CAST(ic.key_ordinal AS varchar) + ','
FROM sys.index_columns ic
INNER JOIN sys.columns col
ON ic.object_id = col.object_id
AND ic.column_id = col.column_id
WHERE ic.object_id = t.object_id
AND ic.index_id = i.index_id
ORDER BY col.column_id
for xml path ('') ) D (column_names)
where t.is_ms_shipped <> 1
and index_id > 0 and t.name = '${this.getTnPath(args.tn)}'
order by schema_name(t.schema_id) + '.' + t.[name], i.index_id`
WHERE t.is_ms_shipped <> 1
AND index_id > 0 AND t.name = '${this.getTnPath(args.tn)}'
ORDER BY schema_name(t.schema_id) + '.' + t.[name], i.index_id`
);
const rows = [];
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;
const response = await this.sqlClient.raw(
`select t.[name] as table_view,
case when t.[type] = 'U' then 'Table'
when t.[type] = 'V' then 'View'
end as [object_type],
`SELECT t.[name] AS table_view,
CASE WHEN t.[type] = 'U' THEN 'Table'
WHEN t.[type] = 'V' THEN 'View'
END AS [object_type],
i.index_id,
case when i.is_primary_key = 1 then 'Primary key'
when i.is_unique = 1 then 'Unique'
else 'Not Unique' end as [type],
i.[name] as index_name,
substring(column_names, 1, len(column_names)-1) as [columns],
case when i.[type] = 1 then 'Clustered index'
when i.[type] = 2 then 'Nonclustered unique index'
when i.[type] = 3 then 'XML index'
when i.[type] = 4 then 'Spatial index'
when i.[type] = 5 then 'Clustered columnstore index'
when i.[type] = 6 then 'Nonclustered columnstore index'
when i.[type] = 7 then 'Nonclustered hash index'
end as index_type
from sys.objects t
inner join sys.indexes i
on t.object_id = i.object_id
cross apply (select col.[name] + ', ' + CAST(ic.key_ordinal as varchar) + ', '
from sys.index_columns ic
inner join sys.columns col
on ic.object_id = col.object_id
and ic.column_id = col.column_id
where ic.object_id = t.object_id
and ic.index_id = i.index_id
order by col.column_id
CASE WHEN i.is_primary_key = 1 THEN 'Primary key'
WHEN i.is_unique = 1 THEN 'Unique'
else 'Not Unique' END AS [type],
i.[name] AS index_name,
substring(column_names, 1, len(column_names)-1) AS [columns],
CASE WHEN i.[type] = 1 THEN 'Clustered index'
WHEN i.[type] = 2 THEN 'Nonclustered unique index'
WHEN i.[type] = 3 THEN 'XML index'
WHEN i.[type] = 4 THEN 'Spatial index'
WHEN i.[type] = 5 THEN 'Clustered columnstore index'
WHEN i.[type] = 6 THEN 'Nonclustered columnstore index'
WHEN i.[type] = 7 THEN 'Nonclustered hash index'
END AS index_type
FROM sys.objects t
INNER JOIN sys.indexes i
ON t.object_id = i.object_id
cross apply (SELECT col.[name] + ', ' + CAST(ic.key_ordinal AS varchar) + ', '
FROM sys.index_columns ic
INNER JOIN sys.columns col
ON ic.object_id = col.object_id
AND ic.column_id = col.column_id
WHERE ic.object_id = t.object_id
AND ic.index_id = i.index_id
ORDER BY col.column_id
for xml path ('') ) D (column_names)
where t.is_ms_shipped <> 1
and index_id > 0 and t.name = '${this.getTnPath(args.tn)}'
order by schema_name(t.schema_id) + '.' + t.[name], i.index_id`
WHERE t.is_ms_shipped <> 1
AND index_id > 0 AND t.name = '${this.getTnPath(args.tn)}'
ORDER BY schema_name(t.schema_id) + '.' + t.[name], i.index_id`
);
const rows = [];
for (let i = 0, rowCount = 0; i < response.length; ++i, ++rowCount) {
@ -841,25 +838,25 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args);
try {
const response = await this.sqlClient
.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],
pk_col.name as rcn, fk.name as cstn,
fk.update_referential_action_desc as ur, fk.delete_referential_action_desc as dr
from sys.foreign_keys fk
inner join sys.tables fk_tab
on fk_tab.object_id = fk.parent_object_id
inner join sys.tables pk_tab
on pk_tab.object_id = fk.referenced_object_id
inner join sys.foreign_key_columns fk_cols
on fk_cols.constraint_object_id = fk.object_id
inner join sys.columns fk_col
on fk_col.column_id = fk_cols.parent_column_id
and fk_col.object_id = fk_tab.object_id
inner join sys.columns pk_col
on pk_col.column_id = fk_cols.referenced_column_id
and pk_col.object_id = pk_tab.object_id
where fk_tab.name = '${this.getTnPath(args.tn)}'
order by fk_tab.name, pk_tab.name, fk_cols.constraint_column_id`);
.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],
pk_col.name AS rcn, fk.name AS cstn,
fk.update_referential_action_desc AS ur, fk.delete_referential_action_desc AS dr
FROM sys.foreign_keys fk
INNER JOIN sys.tables fk_tab
ON fk_tab.object_id = fk.parent_object_id
INNER JOIN sys.tables pk_tab
ON pk_tab.object_id = fk.referenced_object_id
INNER JOIN sys.foreign_key_columns fk_cols
ON fk_cols.constraint_object_id = fk.object_id
INNER JOIN sys.columns fk_col
ON fk_col.column_id = fk_cols.parent_column_id
AND fk_col.object_id = fk_tab.object_id
INNER JOIN sys.columns pk_col
ON pk_col.column_id = fk_cols.referenced_column_id
AND pk_col.object_id = pk_tab.object_id
WHERE fk_tab.name = '${this.getTnPath(args.tn)}'
ORDER BY fk_tab.name, pk_tab.name, fk_cols.constraint_column_id`);
const ruleMapping = {
NO_ACTION: 'NO ACTION',
@ -907,24 +904,24 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args);
try {
const response = await this
.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],
pk_col.name as rcn, fk.name as cstn,
fk.update_referential_action_desc as ur, fk.delete_referential_action_desc as dr
from sys.foreign_keys fk
inner join sys.tables fk_tab
on fk_tab.object_id = fk.parent_object_id
inner join sys.tables pk_tab
on pk_tab.object_id = fk.referenced_object_id
inner join sys.foreign_key_columns fk_cols
on fk_cols.constraint_object_id = fk.object_id
inner join sys.columns fk_col
on fk_col.column_id = fk_cols.parent_column_id
and fk_col.object_id = fk_tab.object_id
inner join sys.columns pk_col
on pk_col.column_id = fk_cols.referenced_column_id
and pk_col.object_id = pk_tab.object_id
order by fk_tab.name, pk_tab.name, fk_cols.constraint_column_id`);
.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],
pk_col.name AS rcn, fk.name AS cstn,
fk.update_referential_action_desc AS ur, fk.delete_referential_action_desc AS dr
FROM sys.foreign_keys fk
INNER JOIN sys.tables fk_tab
ON fk_tab.object_id = fk.parent_object_id
INNER JOIN sys.tables pk_tab
ON pk_tab.object_id = fk.referenced_object_id
INNER JOIN sys.foreign_key_columns fk_cols
ON fk_cols.constraint_object_id = fk.object_id
INNER JOIN sys.columns fk_col
ON fk_col.column_id = fk_cols.parent_column_id
AND fk_col.object_id = fk_tab.object_id
INNER JOIN sys.columns pk_col
ON pk_col.column_id = fk_cols.referenced_column_id
AND pk_col.object_id = pk_tab.object_id
ORDER BY fk_tab.name, pk_tab.name, fk_cols.constraint_column_id`);
const ruleMapping = {
NO_ACTION: 'NO ACTION',
@ -972,31 +969,31 @@ class MssqlClient extends KnexClient {
const result = new Result();
log.api(`${_func}:args:`, args);
try {
const query = `select trg.name as trigger_name,
tab.name as [table],
case when is_instead_of_trigger = 1 then 'Instead of'
else 'After' end as [activation],
(case when objectproperty(trg.object_id, 'ExecIsUpdateTrigger') = 1
then 'Update' else '' end
+ case when objectproperty(trg.object_id, 'ExecIsDeleteTrigger') = 1
then 'Delete' else '' end
+ case when objectproperty(trg.object_id, 'ExecIsInsertTrigger') = 1
then 'Insert' else '' end
) as [event],
case when trg.parent_class = 1 then 'Table trigger'
when trg.parent_class = 0 then 'Database trigger'
end [class],
case when trg.[type] = 'TA' then 'Assembly (CLR) trigger'
when trg.[type] = 'TR' then 'SQL trigger'
else '' end as [type],
case when is_disabled = 1 then 'Disabled'
else 'Active' end as [status],
object_definition(trg.object_id) as [definition]
from sys.triggers trg
const query = `SELECT trg.name AS trigger_name,
tab.name AS [table],
CASE WHEN is_instead_of_trigger = 1 THEN 'Instead of'
else 'After' END AS [activation],
(CASE WHEN objectproperty(trg.object_id, 'ExecIsUpdateTrigger') = 1
THEN 'Update' else '' end
+ CASE WHEN objectproperty(trg.object_id, 'ExecIsDeleteTrigger') = 1
THEN 'Delete' else '' end
+ CASE WHEN objectproperty(trg.object_id, 'ExecIsInsertTrigger') = 1
THEN 'Insert' else '' end
) AS [event],
CASE WHEN trg.parent_class = 1 THEN 'Table trigger'
WHEN trg.parent_class = 0 THEN 'Database trigger'
END [class],
CASE WHEN trg.[type] = 'TA' THEN 'Assembly (CLR) trigger'
WHEN trg.[type] = 'TR' THEN 'SQL trigger'
else '' END AS [type],
CASE WHEN is_disabled = 1 THEN 'Disabled'
else 'Active' END AS [status],
object_definition(trg.object_id) AS [definition]
FROM sys.triggers trg
left join sys.objects tab
on trg.parent_id = tab.object_id
where tab.name = '${this.getTnPath(args.tn)}'
order by trg.name;`;
ON trg.parent_id = tab.object_id
WHERE tab.name = '${this.getTnPath(args.tn)}'
ORDER BY trg.name;`;
const response = await this.sqlClient.raw(query);
@ -1041,7 +1038,7 @@ class MssqlClient extends KnexClient {
log.api(`${_func}:args:`, args);
try {
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
JOIN sys.objects AS o ON m.object_id = o.object_id
AND type IN ('FN', 'IF', 'TF')`
@ -1086,8 +1083,8 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
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.*
from ${args.databaseName}.information_schema.routines as pc where routine_type = 'PROCEDURE'`
`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'`
);
result.data.list = response;
@ -1119,8 +1116,8 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
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
inner join sys.sql_modules as m on m.object_id = v.object_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`
);
result.data.list = response;
@ -1153,10 +1150,10 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
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
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++) {
@ -1194,8 +1191,8 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
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.*
from ${args.databaseName}.information_schema.routines as pc where routine_type = 'PROCEDURE' and SPECIFIC_NAME='${args.procedure_name}'`
`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}'`
);
result.data.list = response;
@ -1224,8 +1221,8 @@ class MssqlClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
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
inner join sys.sql_modules as m on m.object_id = v.object_id where v.name = '${args.view_name}'`
`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}'`
);
result.data.list = response;
@ -1259,9 +1256,9 @@ class MssqlClient extends KnexClient {
FROM sysobjects AS [so]
INNER JOIN sys.sql_modules AS df ON object_id = so.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
)}' and [so].[name] = '${args.trigger_name}'`
)}' AND [so].[name] = '${args.trigger_name}'`
);
for (let i = 0; i < response.length; i++) {
@ -1291,7 +1288,7 @@ class MssqlClient extends KnexClient {
try {
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) {
@ -1346,8 +1343,7 @@ class MssqlClient extends KnexClient {
log.api(`${func}:args:`, args);
// `DROP TRIGGER ${args.trigger_name}`
try {
const query = `${this.querySeparator()}DROP TRIGGER IF EXISTS ${
args.trigger_name
const query = `${this.querySeparator()}DROP TRIGGER IF EXISTS ${args.trigger_name
}`;
await this.sqlClient.raw(query);
@ -1478,8 +1474,7 @@ class MssqlClient extends KnexClient {
{
sql:
this.querySeparator() +
`DROP FUNCTION IF EXISTS ${
args.function_name
`DROP FUNCTION IF EXISTS ${args.function_name
};${this.querySeparator()}\n${args.create_function}`,
},
],
@ -1487,8 +1482,7 @@ class MssqlClient extends KnexClient {
{
sql:
this.querySeparator() +
`DROP FUNCTION IF EXISTS ${
args.function_name
`DROP FUNCTION IF EXISTS ${args.function_name
};${this.querySeparator()} ${args.oldCreateFunction}`,
},
],
@ -1560,8 +1554,7 @@ class MssqlClient extends KnexClient {
{
sql:
this.querySeparator() +
`DROP PROCEDURE IF EXISTS ${
args.procedure_name
`DROP PROCEDURE IF EXISTS ${args.procedure_name
};${this.querySeparator()}\n${args.create_procedure}`,
},
],
@ -1569,8 +1562,7 @@ class MssqlClient extends KnexClient {
{
sql:
this.querySeparator() +
`DROP PROCEDURE IF EXISTS ${
args.procedure_name
`DROP PROCEDURE IF EXISTS ${args.procedure_name
};${this.querySeparator()}${args.oldCreateProcedure}`,
},
],
@ -1599,7 +1591,7 @@ class MssqlClient extends KnexClient {
log.api(`${func}:args:`, args);
try {
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)]
);
await this.sqlClient.raw(query);
@ -1635,8 +1627,7 @@ class MssqlClient extends KnexClient {
try {
// await this.sqlClient.raw(`DROP TRIGGER ${args.trigger_name}`);
await this.sqlClient.raw(
`ALTER TRIGGER ${args.trigger_name} ON ${this.getTnPath(args.tn)} \n${
args.timing
`ALTER TRIGGER ${args.trigger_name} ON ${this.getTnPath(args.tn)} \n${args.timing
} ${args.event}\n AS\n${args.statement}`
);
@ -1721,8 +1712,7 @@ class MssqlClient extends KnexClient {
{
sql:
this.querySeparator() +
`DROP VIEW ${args.view_name} ; ${this.querySeparator()}${
args.oldViewDefination
`DROP VIEW ${args.view_name} ; ${this.querySeparator()}${args.oldViewDefination
};`,
},
],
@ -1817,10 +1807,7 @@ class MssqlClient extends KnexClient {
const downStatement =
this.querySeparator() +
this.sqlClient.schema
.withSchema(this.schema)
.dropTable(args.table)
.toString();
this.sqlClient.schema.withSchema(this.schema).dropTable(args.table).toString();
this.emit(`Success : ${upQuery}`);
@ -1861,16 +1848,8 @@ class MssqlClient extends KnexClient {
AS
BEGIN
SET NOCOUNT ON;
UPDATE ?? Set ?? = GetDate() where ?? in (SELECT ?? FROM Inserted)
END;`,
[
triggerName,
this.getTnPath(args.table_name),
this.getTnPath(args.table_name),
column.column_name,
pk.column_name,
pk.column_name,
]
UPDATE ?? Set ?? = GetDate() WHERE ?? in (SELECT ?? FROM Inserted)
END;`, [triggerName, this.getTnPath(args.table_name), this.getTnPath(args.table_name), column.column_name, pk.column_name, pk.column_name]
);
upQuery += triggerCreateQuery;
@ -1905,7 +1884,7 @@ class MssqlClient extends KnexClient {
AS
BEGIN
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;`;
upQuery += triggerCreateQuery;
@ -2072,10 +2051,7 @@ class MssqlClient extends KnexClient {
/** ************** create up & down statements *************** */
const upStatement =
this.querySeparator() +
this.sqlClient.schema
.withSchema(this.schema)
.dropTable(args.tn)
.toString();
this.sqlClient.schema.withSchema(this.schema).dropTable(args.tn).toString();
let downQuery = this.querySeparator() + this.createTable(args.tn, args);
this.emit(`Success : ${upStatement}`);
@ -2089,9 +2065,8 @@ class MssqlClient extends KnexClient {
for (const relation of relationsList) {
downQuery +=
this.querySeparator() +
(await this.sqlClient
.withSchema(this.schema)
.schema.table(relation.tn, (table) => {
(await this.sqlClient.withSchema(this.schema).schema
.table(relation.tn, (table) => {
table = table
.foreign(relation.cn, null)
.references(relation.rcn)
@ -2134,8 +2109,7 @@ class MssqlClient extends KnexClient {
)) {
downQuery +=
this.querySeparator() +
this.sqlClient.schema
.withSchema(this.schema)
this.sqlClient.schema.withSchema(this.schema)
.table(tn, function (table) {
if (non_unique) {
table.index(columns, key_name);
@ -2180,9 +2154,7 @@ class MssqlClient extends KnexClient {
try {
const self = this;
await this.sqlClient.schema.table(
this.getTnPath(args.childTable),
function (table) {
await this.sqlClient.schema.table(this.getTnPath(args.childTable), function (table) {
table = table
.foreign(args.childColumn, foreignKeyName)
.references(args.parentColumn)
@ -2194,8 +2166,7 @@ class MssqlClient extends KnexClient {
if (args.onDelete) {
table = table.onDelete(args.onDelete);
}
}
);
});
const upStatement =
this.querySeparator() +
@ -2256,12 +2227,9 @@ class MssqlClient extends KnexClient {
try {
const self = this;
await this.sqlClient.schema.table(
this.getTnPath(args.childTable),
function (table) {
await this.sqlClient.schema.table(this.getTnPath(args.childTable), function (table) {
table.dropForeign(args.childColumn, foreignKeyName);
}
);
});
const upStatement =
this.querySeparator() +
@ -2404,7 +2372,7 @@ class MssqlClient extends KnexClient {
const result = new Result();
log.api(`${_func}:args:`, args);
try {
result.data = `DELETE FROM ${this.getTnPath(args.tn)} where ;`;
result.data = `DELETE FROM ${this.getTnPath(args.tn)} WHERE ;`;
} catch (e) {
log.ppe(e, _func);
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));
const data = (await this.extractRawQueryAndExec(qb))?.[0];
let data = (await this.extractRawQueryAndExec(qb))?.[0];
if (data) {
data = this.convertAttachmentType(data);
const proto = await this.getProto();
data.__proto__ = proto;
}
@ -251,7 +252,8 @@ class BaseModelSqlv2 {
if (!ignoreViewFilterAndSort) applyPaginate(qb, rest);
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) => {
d.__proto__ = proto;
@ -423,7 +425,8 @@ class BaseModelSqlv2 {
.as('list')
);
const children = await this.extractRawQueryAndExec(childQb);
let children = await this.extractRawQueryAndExec(childQb);
children = this.convertAttachmentType(children);
const proto = await (
await Model.getBaseModelSQL({
id: childTable.id,
@ -550,7 +553,8 @@ class BaseModelSqlv2 {
await childModel.selectObject({ qb });
const children = await this.extractRawQueryAndExec(qb);
let children = await this.extractRawQueryAndExec(qb);
children = this.convertAttachmentType(children);
const proto = await (
await Model.getBaseModelSQL({
@ -668,6 +672,7 @@ class BaseModelSqlv2 {
);
let children = await this.extractRawQueryAndExec(finalQb);
children = this.convertAttachmentType(children);
if (this.isMySQL) {
children = children[0];
}
@ -735,7 +740,8 @@ class BaseModelSqlv2 {
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
const children = await this.extractRawQueryAndExec(qb);
let children = await this.extractRawQueryAndExec(qb);
children = this.convertAttachmentType(children);
const proto = await (
await Model.getBaseModelSQL({ id: rtnId, dbDriver: this.dbDriver })
).getProto();
@ -1076,7 +1082,8 @@ class BaseModelSqlv2 {
applyPaginate(qb, rest);
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) => {
c.__proto__ = proto;
@ -1194,7 +1201,8 @@ class BaseModelSqlv2 {
applyPaginate(qb, rest);
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) => {
c.__proto__ = proto;
@ -2726,6 +2734,30 @@ class BaseModelSqlv2 {
)
: 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(

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({
// Playwright action that triggers the request i.e locatorSomething.click()
uiAction,
httpMethodsToMatch = [],
requestUrlPathToMatch,
// A function that takes the response body and returns true if the response is the one we are looking for
responseJsonMatcher,
}: {
uiAction: Promise<any>;

Loading…
Cancel
Save