Browse Source

Merge branch 'develop' into feat/kanban-view

pull/3818/head
Wing-Kam Wong 2 years ago
parent
commit
e346128d6c
  1. 24
      .do/deploy.template.yaml
  2. 39
      .github/workflows/ci-cd.yml
  3. 2
      .run/Clear metadb.run.xml
  4. 12
      .run/Run GUI v2.run.xml
  5. 2
      .run/Run NocoDB Sqlite.run.xml
  6. 4
      .run/build.run.xml
  7. 2
      .run/dev.run.xml
  8. 7
      .run/start_nocodb.run.xml
  9. 2
      .run/watch_run_mysql.run.xml
  10. 1
      packages/nc-gui/components.d.ts
  11. 34
      packages/nc-gui/components/smartsheet-column/AdvancedOptions.vue
  12. 11
      packages/nc-gui/components/smartsheet-toolbar/ColumnFilter.vue
  13. 4
      packages/nc-gui/components/smartsheet-toolbar/ShareView.vue
  14. 4
      packages/nc-gui/components/smartsheet-toolbar/SharedViewList.vue
  15. 4
      packages/nc-gui/components/smartsheet/ApiSnippet.vue
  16. 24
      packages/nc-gui/components/smartsheet/Grid.vue
  17. 2
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  18. 10
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  19. 4
      packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue
  20. 4
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  21. 4
      packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue
  22. 4
      packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue
  23. 5
      packages/nc-gui/components/webhook/Drawer.vue
  24. 8
      packages/nc-gui/components/webhook/Editor.vue
  25. 1
      packages/nc-gui/composables/index.ts
  26. 26
      packages/nc-gui/composables/useColumnCreateStore.ts
  27. 26
      packages/nc-gui/composables/useCopy.ts
  28. 2
      packages/nc-gui/composables/useViewFilters.ts
  29. 3
      packages/nc-gui/context/index.ts
  30. 282
      packages/nc-gui/lang/ar.json
  31. 4
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  32. 801
      packages/nocodb-sdk/package-lock.json
  33. 2
      packages/nocodb-sdk/src/lib/Api.ts
  34. 5
      packages/nocodb-sdk/src/lib/globals.ts
  35. 2
      packages/nocodb/.gitignore
  36. 2
      packages/nocodb/package-lock.json
  37. 5
      packages/nocodb/package.json
  38. 70
      packages/nocodb/src/__tests__/tsconfig.json
  39. 142
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  40. 257
      packages/nocodb/src/lib/meta/api/columnApis.ts
  41. 10
      packages/nocodb/src/lib/meta/api/dataApis/bulkDataAliasApis.ts
  42. 4
      packages/nocodb/src/lib/meta/api/dataApis/dataAliasApis.ts
  43. 11
      packages/nocodb/src/lib/meta/api/dataApis/dataAliasNestedApis.ts
  44. 2
      packages/nocodb/src/lib/meta/api/dataApis/dataApis.ts
  45. 2
      packages/nocodb/src/lib/meta/api/sync/helpers/job.ts
  46. 12
      packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts
  47. 37
      packages/nocodb/src/lib/migrations/v2/nc_011.ts
  48. 34
      packages/nocodb/src/lib/migrations/v2/nc_019_add_meta_in_meta_tables.ts
  49. 0
      packages/nocodb/src/lib/migrations/v2/nc_020_add_kanban_meta_col.ts
  50. 10
      packages/nocodb/src/lib/models/FormView.ts
  51. 45
      packages/nocodb/src/lib/models/FormViewColumn.ts
  52. 12
      packages/nocodb/src/lib/models/Model.ts
  53. 2
      packages/nocodb/src/lib/models/Project.ts
  54. 9
      packages/nocodb/src/lib/utils/common/NcConnectionMgrv2.ts
  55. 35
      packages/nocodb/src/lib/utils/globals.ts
  56. 19
      packages/nocodb/src/lib/utils/serialize.ts
  57. 658
      packages/nocodb/tests/mysql-sakila-db/03-test-sakila-schema.sql
  58. 46449
      packages/nocodb/tests/mysql-sakila-db/04-test-sakila-data.sql
  59. 243
      packages/nocodb/tests/unit/TestDbMngr.ts
  60. 203
      packages/nocodb/tests/unit/factory/column.ts
  61. 64
      packages/nocodb/tests/unit/factory/project.ts
  62. 181
      packages/nocodb/tests/unit/factory/row.ts
  63. 42
      packages/nocodb/tests/unit/factory/table.ts
  64. 18
      packages/nocodb/tests/unit/factory/user.ts
  65. 35
      packages/nocodb/tests/unit/factory/view.ts
  66. 20
      packages/nocodb/tests/unit/index.test.ts
  67. 56
      packages/nocodb/tests/unit/init/cleanupMeta.ts
  68. 81
      packages/nocodb/tests/unit/init/cleanupSakila.ts
  69. 12
      packages/nocodb/tests/unit/init/db.ts
  70. 42
      packages/nocodb/tests/unit/init/index.ts
  71. 10
      packages/nocodb/tests/unit/model/index.test.ts
  72. 500
      packages/nocodb/tests/unit/model/tests/baseModelSql.test.ts
  73. 18
      packages/nocodb/tests/unit/rest/index.test.ts
  74. 169
      packages/nocodb/tests/unit/rest/tests/auth.test.ts
  75. 268
      packages/nocodb/tests/unit/rest/tests/project.test.ts
  76. 253
      packages/nocodb/tests/unit/rest/tests/table.test.ts
  77. 2031
      packages/nocodb/tests/unit/rest/tests/tableRow.test.ts
  78. 1232
      packages/nocodb/tests/unit/rest/tests/viewRow.test.ts
  79. 72
      packages/nocodb/tests/unit/tsconfig.json
  80. 5
      scripts/cypress/support/commands.js
  81. 10
      scripts/sdk/swagger.json

24
.do/deploy.template.yaml

@ -0,0 +1,24 @@
spec:
name: nocodb
services:
- name: nocodb
image:
registry_type: DOCKER_HUB
registry: nocodb
repository: nocodb
tag: latest
run_command: "./server/scripts/digitalocean-postbuild.sh"
instance_size_slug: "basic-s"
health_check:
initial_delay_seconds: 10
http_path: /api/health
envs:
- key: NODE_ENV
value: "production"
- key: DATABASE_URL
scope: RUN_TIME
value: ${postgres.DATABASE_URL}
databases:
- name: postgres
engine: PG
production: false

39
.github/workflows/ci-cd.yml

@ -635,3 +635,42 @@ jobs:
name: cy-quick-pg-snapshots name: cy-quick-pg-snapshots
path: scripts/cypress/screenshots path: scripts/cypress/screenshots
retention-days: 2 retention-days: 2
unit-tests:
runs-on: ubuntu-20.04
steps:
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: 16.15.0
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
# npm cache files are stored in `~/.npm` on Linux/macOS
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: install dependencies nocodb-sdk
working-directory: ./packages/nocodb-sdk
run: npm install
- name: build nocodb-sdk
working-directory: ./packages/nocodb-sdk
run: npm run build:main
- name: Install dependencies
working-directory: ./packages/nocodb
run: npm install
- name: setup mysql
working-directory: ./
run: docker-compose -f ./scripts/docker-compose-cypress.yml up -d
- name: run unit tests
working-directory: ./packages/nocodb
run: npm run test:unit

2
.run/Clear metadb.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Drop metadb" type="NodeJSConfigurationType" path-to-js-file="$PROJECT_DIR$/packages/nocodb/src/run/deleteMetaDb.js" working-dir="$PROJECT_DIR$/packages/nocodb/src/run"> <configuration default="false" name="Drop metadb" type="NodeJSConfigurationType" path-to-js-file="deleteMetaDb.js" working-dir="$PROJECT_DIR$/packages/nocodb/src/run">
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>

12
.run/Run GUI v2.run.xml

@ -1,12 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run GUI" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/packages/nc-gui/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

2
.run/Run NocoDB Sqlite.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run NocoDB Sqlite" type="js.build_tools.npm" activateToolWindowBeforeRun="false"> <configuration default="false" name="Run::Backend" type="js.build_tools.npm" activateToolWindowBeforeRun="false">
<package-json value="$PROJECT_DIR$/packages/nocodb/package.json" /> <package-json value="$PROJECT_DIR$/packages/nocodb/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>

4
.run/build.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build Nc Common" type="js.build_tools.npm"> <configuration default="false" name="Build::SDK" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/packages/nocodb-sdk/package.json" /> <package-json value="$PROJECT_DIR$/packages/nocodb-sdk/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>
@ -11,4 +11,4 @@
</envs> </envs>
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>

2
.run/dev.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run GUI v2" type="js.build_tools.npm"> <configuration default="false" name="Run::Frontend" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/packages/nc-gui/package.json" /> <package-json value="$PROJECT_DIR$/packages/nc-gui/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>

7
.run/start_nocodb.run.xml

@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start::IDE" type="CompoundRunConfigurationType">
<toRun name="Run::Backend" type="js.build_tools.npm" />
<toRun name="Run::Frontend" type="js.build_tools.npm" />
<method v="2" />
</configuration>
</component>

2
.run/watch_run_mysql.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run NocoDB Mysql" type="js.build_tools.npm" activateToolWindowBeforeRun="false"> <configuration default="false" name="Run::Backend::Mysql" type="js.build_tools.npm" activateToolWindowBeforeRun="false">
<package-json value="$PROJECT_DIR$/packages/nocodb/package.json" /> <package-json value="$PROJECT_DIR$/packages/nocodb/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>

1
packages/nc-gui/components.d.ts vendored

@ -234,6 +234,7 @@ declare module '@vue/runtime-core' {
MdiUpload: typeof import('~icons/mdi/upload')['default'] MdiUpload: typeof import('~icons/mdi/upload')['default']
MdiUploadOutline: typeof import('~icons/mdi/upload-outline')['default'] MdiUploadOutline: typeof import('~icons/mdi/upload-outline')['default']
MdiViewListOutline: typeof import('~icons/mdi/view-list-outline')['default'] MdiViewListOutline: typeof import('~icons/mdi/view-list-outline')['default']
MdiWarning: typeof import('~icons/mdi/warning')['default']
MdiWhatsapp: typeof import('~icons/mdi/whatsapp')['default'] MdiWhatsapp: typeof import('~icons/mdi/whatsapp')['default']
MdiXml: typeof import('~icons/mdi/xml')['default'] MdiXml: typeof import('~icons/mdi/xml')['default']
MiCircleWarning: typeof import('~icons/mi/circle-warning')['default'] MiCircleWarning: typeof import('~icons/mi/circle-warning')['default']

34
packages/nc-gui/components/smartsheet-column/AdvancedOptions.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import { computed } from '#imports' import { computed } from '#imports'
interface Props { interface Props {
@ -10,12 +10,27 @@ const props = defineProps<Props>()
const emit = defineEmits(['update:value']) const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit) const vModel = useVModel(props, 'value', emit)
const { sqlUi } = useProject() const { sqlUi, isPg } = useProject()
const { onAlter, onDataTypeChange, validateInfos } = useColumnCreateStoreOrThrow() const { onAlter, onDataTypeChange, validateInfos } = useColumnCreateStoreOrThrow()
// todo: 2nd argument of `getDataTypeListForUiType` is missing! // todo: 2nd argument of `getDataTypeListForUiType` is missing!
const dataTypes = computed(() => sqlUi?.value?.getDataTypeListForUiType(vModel.value as { uidt: UITypes }, '' as any)) const dataTypes = computed(() => sqlUi.value.getDataTypeListForUiType(vModel.value as { uidt: UITypes }, '' as any))
const sampleValue = computed(() => {
switch (vModel.value.uidt) {
case UITypes.SingleSelect:
return 'eg : a'
case UITypes.MultiSelect:
return 'eg : a,b,c'
default:
return sqlUi.value.getDefaultValueForDatatype(vModel.value.dt)
}
})
const hideLength = computed(() => {
return [UITypes.SingleSelect, UITypes.MultiSelect].includes(vModel.value.uidt)
})
// to avoid type error with checkbox // to avoid type error with checkbox
vModel.value.rqd = !!vModel.value.rqd vModel.value.rqd = !!vModel.value.rqd
@ -23,6 +38,13 @@ vModel.value.pk = !!vModel.value.pk
vModel.value.un = !!vModel.value.un vModel.value.un = !!vModel.value.un
vModel.value.ai = !!vModel.value.ai vModel.value.ai = !!vModel.value.ai
vModel.value.au = !!vModel.value.au vModel.value.au = !!vModel.value.au
onBeforeMount(() => {
// Postgres returns default value wrapped with single quotes & casted with type so we have to get value between single quotes to keep it unified for all databases
if (isPg.value && vModel.value.cdf) {
vModel.value.cdf = vModel.value.cdf.substring(vModel.value.cdf.indexOf(`'`) + 1, vModel.value.cdf.lastIndexOf(`'`))
}
})
</script> </script>
<template> <template>
@ -66,7 +88,7 @@ vModel.value.au = !!vModel.value.au
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item :label="$t('labels.lengthValue')"> <a-form-item v-if="!hideLength" :label="$t('labels.lengthValue')">
<a-input <a-input
v-model:value="vModel.dtxp" v-model:value="vModel.dtxp"
:disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt) || !sqlUi.columnEditable(vModel)" :disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt) || !sqlUi.columnEditable(vModel)"
@ -74,11 +96,11 @@ vModel.value.au = !!vModel.value.au
/> />
</a-form-item> </a-form-item>
<a-form-item v-if="sqlUi.showScale(vModel)" label="Scale"> <a-form-item v-if="sqlUi.showScale(vModel)" label="Scale">
<a-input v-model="vModel.dtxs" :disabled="!sqlUi.columnEditable(vModel)" @input="onAlter" /> <a-input v-model:value="vModel.dtxs" :disabled="!sqlUi.columnEditable(vModel)" @input="onAlter" />
</a-form-item> </a-form-item>
<a-form-item :label="$t('placeholder.defaultValue')"> <a-form-item :label="$t('placeholder.defaultValue')">
<a-textarea v-model:value="vModel.cdf" auto-size @input="onAlter(2, true)" /> <a-textarea v-model:value="vModel.cdf" auto-size @input="onAlter(2, true)" />
<span class="text-gray-400 text-xs">{{ sqlUi.getDefaultValueForDatatype(vModel.dt) }}</span> <span class="text-gray-400 text-xs">{{ sampleValue }}</span>
</a-form-item> </a-form-item>
</div> </div>
</template> </template>

11
packages/nc-gui/components/smartsheet-toolbar/ColumnFilter.vue

@ -21,10 +21,11 @@ interface Props {
parentId?: string parentId?: string
autoSave: boolean autoSave: boolean
hookId?: string hookId?: string
showLoading?: boolean
modelValue?: Filter[] modelValue?: Filter[]
} }
const { nested = false, parentId, autoSave = true, hookId = null, modelValue } = defineProps<Props>() const { nested = false, parentId, autoSave = true, hookId = null, modelValue, showLoading = true } = defineProps<Props>()
const emit = defineEmits(['update:filtersLength']) const emit = defineEmits(['update:filtersLength'])
@ -46,9 +47,7 @@ const { filters, deleteFilter, saveOrUpdate, loadFilters, addFilter, addFilterGr
activeView, activeView,
parentId, parentId,
computed(() => autoSave), computed(() => autoSave),
() => { () => reloadDataHook.trigger(showLoading),
reloadDataHook.trigger()
},
modelValue || nestedFilters.value, modelValue || nestedFilters.value,
!modelValue, !modelValue,
) )
@ -134,8 +133,8 @@ defineExpose({
<template> <template>
<div <div
class="p-6 menu-filter-dropdown bg-gray-50 !border" class="p-4 menu-filter-dropdown bg-gray-50 !border mt-4"
:class="{ 'shadow-xl min-w-[430px] max-w-[630px] max-h-[max(80vh,500px)] overflow-auto': !nested, 'border-1 w-full': nested }" :class="{ 'shadow min-w-[430px] max-w-[630px] max-h-[max(80vh,500px)] overflow-auto': !nested, 'border-1 w-full': nested }"
> >
<div v-if="filters && filters.length" class="nc-filter-grid mb-2" @click.stop> <div v-if="filters && filters.length" class="nc-filter-grid mb-2" @click.stop>
<template v-for="(filter, i) in filters" :key="filter.id || i"> <template v-for="(filter, i) in filters" :key="filter.id || i">

4
packages/nc-gui/components/smartsheet-toolbar/ShareView.vue

@ -4,7 +4,7 @@ import { message } from 'ant-design-vue'
import { import {
computed, computed,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
useClipboard, useCopy,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
useProject, useProject,
@ -17,7 +17,7 @@ const { t } = useI18n()
const { view, $api } = useSmartsheetStoreOrThrow() const { view, $api } = useSmartsheetStoreOrThrow()
const { copy } = useClipboard() const { copy } = useCopy()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()

4
packages/nc-gui/components/smartsheet-toolbar/SharedViewList.vue

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import { Empty, message } from 'ant-design-vue' import { Empty, message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, onMounted, useClipboard, useI18n, useSmartsheetStoreOrThrow } from '#imports' import { extractSdkResponseErrorMsg, onMounted, useCopy, useI18n, useSmartsheetStoreOrThrow } from '#imports'
import MdiVisibilityOnIcon from '~icons/mdi/visibility' import MdiVisibilityOnIcon from '~icons/mdi/visibility'
import MdiVisibilityOffIcon from '~icons/mdi/visibility-off' import MdiVisibilityOffIcon from '~icons/mdi/visibility-off'
import MdiCopyIcon from '~icons/mdi/content-copy' import MdiCopyIcon from '~icons/mdi/content-copy'
@ -20,7 +20,7 @@ const { t } = useI18n()
const { $api, meta } = useSmartsheetStoreOrThrow() const { $api, meta } = useSmartsheetStoreOrThrow()
const { copy } = useClipboard() const { copy } = useCopy()
const { dashboardUrl } = useDashboard() const { dashboardUrl } = useDashboard()

4
packages/nc-gui/components/smartsheet/ApiSnippet.vue

@ -5,7 +5,7 @@ import {
ActiveViewInj, ActiveViewInj,
MetaInj, MetaInj,
inject, inject,
useClipboard, useCopy,
useGlobal, useGlobal,
useI18n, useI18n,
useProject, useProject,
@ -36,7 +36,7 @@ const { xWhere } = useSmartsheetStoreOrThrow()
const { queryParams } = $(useViewData($$(meta), $$(view), xWhere)) const { queryParams } = $(useViewData($$(meta), $$(view), xWhere))
const { copy } = useClipboard() const { copy } = useCopy()
let vModel = $(useVModel(props, 'modelValue', emits)) let vModel = $(useVModel(props, 'modelValue', emits))

24
packages/nc-gui/components/smartsheet/Grid.vue

@ -23,6 +23,7 @@ import {
provide, provide,
reactive, reactive,
ref, ref,
useCopy,
useEventListener, useEventListener,
useGridViewColumnWidth, useGridViewColumnWidth,
useI18n, useI18n,
@ -96,6 +97,8 @@ const {
const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view) const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view)
const { copy } = useCopy()
onMounted(loadGridViewColumns) onMounted(loadGridViewColumns)
provide(IsFormInj, ref(false)) provide(IsFormInj, ref(false))
@ -113,8 +116,15 @@ provide(ReadonlyInj, !hasEditPermission)
const disableUrlOverlay = ref(false) const disableUrlOverlay = ref(false)
provide(CellUrlDisableOverlayInj, disableUrlOverlay) provide(CellUrlDisableOverlayInj, disableUrlOverlay)
reloadViewDataHook?.on(async () => { const showLoading = ref(true)
reloadViewDataHook?.on(async (shouldShowLoading) => {
// set value if spinner should be hidden
showLoading.value = !!shouldShowLoading
await loadData() await loadData()
// reset to default (showing spinner on load)
showLoading.value = true
}) })
const skipRowRemovalOnCancel = ref(false) const skipRowRemovalOnCancel = ref(false)
@ -179,8 +189,6 @@ const clearCell = async (ctx: { row: number; col: number }) => {
await updateOrSaveRow(rowObj, columnObj.title) await updateOrSaveRow(rowObj, columnObj.title)
} }
const { copy } = useClipboard()
const makeEditable = (row: Row, col: ColumnType) => { const makeEditable = (row: Row, col: ColumnType) => {
if (!hasEditPermission || editEnabled || isView) { if (!hasEditPermission || editEnabled || isView) {
return return
@ -373,10 +381,12 @@ onBeforeUnmount(async () => {
<template> <template>
<div class="flex flex-col h-full min-h-0 w-full"> <div class="flex flex-col h-full min-h-0 w-full">
<div v-if="isLoading" class="flex items-center justify-center h-full w-full"> <general-overlay :model-value="isLoading" inline transition>
<a-spin size="large" /> <div class="flex items-center justify-center h-full w-full">
</div> <a-spin size="large" />
<div v-else class="nc-grid-wrapper min-h-0 flex-1 scrollbar-thin-dull"> </div>
</general-overlay>
<div class="nc-grid-wrapper min-h-0 flex-1 scrollbar-thin-dull">
<a-dropdown <a-dropdown
v-model:visible="contextMenu" v-model:visible="contextMenu"
:trigger="isSqlView ? [] : ['contextmenu']" :trigger="isSqlView ? [] : ['contextmenu']"

2
packages/nc-gui/components/smartsheet/expanded-form/Header.vue

@ -55,7 +55,7 @@ const iconColor = '#1890ff'
<template #title> <template #title>
<div class="text-center w-full">{{ $t('general.reload') }}</div> <div class="text-center w-full">{{ $t('general.reload') }}</div>
</template> </template>
<mdi-reload class="cursor-pointer select-none text-gray-500" @click="loadRow" /> <mdi-reload v-if="!isNew" class="cursor-pointer select-none text-gray-500" @click="loadRow" />
</a-tooltip> </a-tooltip>
<a-tooltip v-if="!isSqlView" placement="bottom"> <a-tooltip v-if="!isSqlView" placement="bottom">
<!-- Toggle comments draw --> <!-- Toggle comments draw -->

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TableType, ViewType } from 'nocodb-sdk' import type { TableType, ViewType } from 'nocodb-sdk'
import { isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import Cell from '../Cell.vue' import Cell from '../Cell.vue'
import VirtualCell from '../VirtualCell.vue' import VirtualCell from '../VirtualCell.vue'
@ -122,7 +122,13 @@ export default {
<div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]"> <div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]">
<div class="flex-1 overflow-auto scrollbar-thin-dull"> <div class="flex-1 overflow-auto scrollbar-thin-dull">
<div class="w-[500px] mx-auto"> <div class="w-[500px] mx-auto">
<div v-for="col of fields" :key="col.title" class="mt-2 py-2" :class="`nc-expand-col-${col.title}`"> <div
v-for="col of fields"
v-show="!isVirtualCol(col) || !isNew || col.uidt === UITypes.LinkToAnotherRecord"
:key="col.title"
class="mt-2 py-2"
:class="`nc-expand-col-${col.title}`"
>
<SmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" /> <SmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" />
<SmartsheetHeaderCell v-else :column="col" /> <SmartsheetHeaderCell v-else :column="col" />

4
packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ApiTokenType } from 'nocodb-sdk' import type { ApiTokenType } from 'nocodb-sdk'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useClipboard, useI18n } from '#imports' import { extractSdkResponseErrorMsg, useCopy, useI18n } from '#imports'
import KebabIcon from '~icons/ic/baseline-more-vert' import KebabIcon from '~icons/ic/baseline-more-vert'
import MdiPlusIcon from '~icons/mdi/plus' import MdiPlusIcon from '~icons/mdi/plus'
import CloseIcon from '~icons/material-symbols/close-rounded' import CloseIcon from '~icons/material-symbols/close-rounded'
@ -21,7 +21,7 @@ const { $api, $e } = useNuxtApp()
const { project } = $(useProject()) const { project } = $(useProject())
const { copy } = useClipboard() const { copy } = useCopy()
let tokensInfo = $ref<ApiToken[] | undefined>([]) let tokensInfo = $ref<ApiToken[] | undefined>([])

4
packages/nc-gui/components/tabs/auth/UserManagement.vue

@ -8,7 +8,7 @@ import {
onBeforeMount, onBeforeMount,
ref, ref,
useApi, useApi,
useClipboard, useCopy,
useDashboard, useDashboard,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
@ -26,7 +26,7 @@ const { api } = useApi()
const { project } = useProject() const { project } = useProject()
const { copy } = useClipboard() const { copy } = useCopy()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()

4
packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, onMounted, useClipboard, useI18n, useNuxtApp, useProject } from '#imports' import { extractSdkResponseErrorMsg, onMounted, useCopy, useI18n, useNuxtApp, useProject } from '#imports'
interface ShareBase { interface ShareBase {
uuid?: string uuid?: string
@ -25,7 +25,7 @@ const showEditBaseDropdown = $ref(false)
const { project } = useProject() const { project } = useProject()
const { copy } = useClipboard() const { copy } = useCopy()
const url = $computed(() => (base && base.uuid ? `${dashboardUrl}#/base/${base.uuid}` : null)) const url = $computed(() => (base && base.uuid ? `${dashboardUrl}#/base/${base.uuid}` : null))

4
packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue

@ -9,7 +9,7 @@ import {
projectRoleTagColors, projectRoleTagColors,
projectRoles, projectRoles,
ref, ref,
useClipboard, useCopy,
useDashboard, useDashboard,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
@ -37,7 +37,7 @@ const { t } = useI18n()
const { project } = useProject() const { project } = useProject()
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { copy } = useClipboard() const { copy } = useCopy()
const { dashboardUrl } = $(useDashboard()) const { dashboardUrl } = $(useDashboard())
const usersData = $ref<Users>({ emails: undefined, role: ProjectRole.Viewer, invitationToken: undefined }) const usersData = $ref<Users>({ emails: undefined, role: ProjectRole.Viewer, invitationToken: undefined })

5
packages/nc-gui/components/webhook/Drawer.vue

@ -32,12 +32,13 @@ async function editHook(hook: Record<string, any>) {
class="nc-drawer-webhook" class="nc-drawer-webhook"
@keydown.esc="vModel = false" @keydown.esc="vModel = false"
> >
<a-layout class=""> <a-layout>
<a-layout-content class="px-10 py-5 scrollbar-thin-primary"> <a-layout-content class="px-10 py-5 scrollbar-thin-primary">
<WebhookEditor v-if="editOrAdd" ref="webhookEditorRef" @back-to-list="editOrAdd = false" /> <WebhookEditor v-if="editOrAdd" ref="webhookEditorRef" @back-to-list="editOrAdd = false" />
<WebhookList v-else @edit="editHook" @add="editOrAdd = true" /> <WebhookList v-else @edit="editHook" @add="editOrAdd = true" />
</a-layout-content> </a-layout-content>
<a-layout-footer class="!bg-white flex">
<a-layout-footer class="!bg-white border-t flex">
<a-button v-e="['e:hiring']" class="mx-auto mb-4" href="https://angel.co/company/nocodb" target="_blank" size="large"> <a-button v-e="['e:hiring']" class="mx-auto mb-4" href="https://angel.co/company/nocodb" target="_blank" size="large">
🚀 {{ $t('labels.weAreHiring') }}! 🚀 🚀 {{ $t('labels.weAreHiring') }}! 🚀
</a-button> </a-button>

8
packages/nc-gui/components/webhook/Editor.vue

@ -606,7 +606,13 @@ onMounted(async () => {
<a-col :span="24"> <a-col :span="24">
<a-card> <a-card>
<a-checkbox v-model:checked="hook.condition" class="nc-check-box-hook-condition">On Condition</a-checkbox> <a-checkbox v-model:checked="hook.condition" class="nc-check-box-hook-condition">On Condition</a-checkbox>
<SmartsheetToolbarColumnFilter v-if="hook.condition" ref="filterRef" :auto-save="false" :hook-id="hook.id" /> <SmartsheetToolbarColumnFilter
v-if="hook.condition"
ref="filterRef"
:auto-save="false"
:show-loading="false"
:hook-id="hook.id"
/>
</a-card> </a-card>
</a-col> </a-col>
</a-row> </a-row>

1
packages/nc-gui/composables/index.ts

@ -26,3 +26,4 @@ export * from './useLTARStore'
export * from './useExpandedFormStore' export * from './useExpandedFormStore'
export * from './useSharedFormViewStore' export * from './useSharedFormViewStore'
export * from './useCellUrlConfig' export * from './useCellUrlConfig'
export * from './useCopy'

26
packages/nc-gui/composables/useColumnCreateStore.ts

@ -4,7 +4,6 @@ import type { ColumnType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useColumn } from './useColumn'
import { computed, createInjectionState, extractSdkResponseErrorMsg, useNuxtApp } from '#imports' import { computed, createInjectionState, extractSdkResponseErrorMsg, useNuxtApp } from '#imports'
const useForm = Form.useForm const useForm = Form.useForm
@ -109,20 +108,22 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
} }
} }
if (column.value?.uidt === UITypes.Currency) { // keep length and scale for same datatype
if (column.value && formState.value.uidt === column.value?.uidt) {
formState.value.dtxp = column.value.dtxp formState.value.dtxp = column.value.dtxp
formState.value.dtxs = column.value.dtxs formState.value.dtxs = column.value.dtxs
} else { } else {
formState.value.dtxp = 19 // default length and scale for currency
formState.value.dtxs = 2 if (formState.value?.uidt === UITypes.Currency) {
formState.value.dtxp = 19
formState.value.dtxs = 2
}
} }
formState.value.altered = formState.value.altered || 2 formState.value.altered = formState.value.altered || 2
} }
const onDataTypeChange = () => { const onDataTypeChange = () => {
const { isCurrency } = useColumn(ref(formState.value as ColumnType))
formState.value.rqd = false formState.value.rqd = false
if (formState.value.uidt !== UITypes.ID) { if (formState.value.uidt !== UITypes.ID) {
formState.value.primaryKey = false formState.value.primaryKey = false
@ -135,16 +136,19 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
formState.value.dtx = 'specificType' formState.value.dtx = 'specificType'
// use enum response as dtxp for select columns
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect] const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect]
if (column.value && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.value.uidt as UITypes)) { if (column.value && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.value.uidt as UITypes)) {
formState.value.dtxp = column.value.dtxp formState.value.dtxp = column.value.dtxp
} }
if (isCurrency.value) { // keep length and scale for same datatype
if (column.value?.uidt === UITypes.Currency) { if (column.value && formState.value.uidt === column.value?.uidt) {
formState.value.dtxp = column.value.dtxp formState.value.dtxp = column.value.dtxp
formState.value.dtxs = column.value.dtxs formState.value.dtxs = column.value.dtxs
} else { } else {
// default length and scale for currency
if (formState.value?.uidt === UITypes.Currency) {
formState.value.dtxp = 19 formState.value.dtxp = 19
formState.value.dtxs = 2 formState.value.dtxs = 2
} }

26
packages/nc-gui/composables/useCopy.ts

@ -0,0 +1,26 @@
import { useClipboard } from '#imports'
export const useCopy = () => {
/** fallback for copy if clipboard api is not supported */
const copyFallback = (text: string) => {
const textAreaEl = document.createElement('textarea')
textAreaEl.innerHTML = text
document.body.appendChild(textAreaEl)
textAreaEl.select()
const result = document.execCommand('copy')
document.body.removeChild(textAreaEl)
return result
}
const { copy: _copy, isSupported } = useClipboard()
const copy = async (text: string) => {
if (isSupported) {
await _copy(text)
} else {
copyFallback(text)
}
}
return { copy }
}

2
packages/nc-gui/composables/useViewFilters.ts

@ -150,7 +150,7 @@ export function useViewFilters(
} }
const saveOrUpdate = async (filter: Filter, i: number, force = false) => { const saveOrUpdate = async (filter: Filter, i: number, force = false) => {
if (!view?.value) return if (!view.value) return
try { try {
if (nestedMode.value) { if (nestedMode.value) {

3
packages/nc-gui/context/index.ts

@ -22,8 +22,9 @@ export const IsLockedInj: InjectionKey<Ref<boolean>> = Symbol('is-locked-injecti
export const CellValueInj: InjectionKey<Ref<any>> = Symbol('cell-value-injection') export const CellValueInj: InjectionKey<Ref<any>> = Symbol('cell-value-injection')
export const ActiveViewInj: InjectionKey<Ref<ViewType>> = Symbol('active-view-injection') export const ActiveViewInj: InjectionKey<Ref<ViewType>> = Symbol('active-view-injection')
export const ReadonlyInj: InjectionKey<boolean> = Symbol('readonly-injection') export const ReadonlyInj: InjectionKey<boolean> = Symbol('readonly-injection')
export const ReloadViewDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-view-data-injection')
export const ReloadKanbanMetaHookInj: InjectionKey<EventHook<void>> = Symbol('reload-kanban-meta-injection') export const ReloadKanbanMetaHookInj: InjectionKey<EventHook<void>> = Symbol('reload-kanban-meta-injection')
/** when bool is passed, it indicates if a loading spinner should be visible while reloading */
export const ReloadViewDataHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-data-injection')
export const ReloadRowDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-row-data-injection') export const ReloadRowDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-row-data-injection')
export const OpenNewRecordFormHookInj: InjectionKey<EventHook<void>> = Symbol('open-new-record-form-injection') export const OpenNewRecordFormHookInj: InjectionKey<EventHook<void>> = Symbol('open-new-record-form-injection')
export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection') export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection')

282
packages/nc-gui/lang/ar.json

@ -55,19 +55,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": "الوصول إلى هنا",
"betaNote": "This feature is currently in beta.", "betaNote": "هذه الميزة حاليا في بيتا.",
"moreInfo": "More information can be found here", "moreInfo": "المزيد من المعلومات تجدها هنا",
"logs": "Logs" "logs": "السجلات"
}, },
"objects": { "objects": {
"project": "مشروع", "project": "مشروع",
@ -120,7 +120,7 @@
"Time": "وقت", "Time": "وقت",
"PhoneNumber": "هاتف", "PhoneNumber": "هاتف",
"Email": "بريد إلكتروني", "Email": "بريد إلكتروني",
"URL": "URL", "URL": "الرابط",
"Number": "عدد", "Number": "عدد",
"Decimal": "عشري", "Decimal": "عشري",
"Currency": "عملة", "Currency": "عملة",
@ -153,8 +153,8 @@
"isNot like": "ليس مثل", "isNot like": "ليس مثل",
"isEmpty": "فارغ", "isEmpty": "فارغ",
"isNotEmpty": "ليس فارغ", "isNotEmpty": "ليس فارغ",
"isNull": "is null", "isNull": "فارغ",
"isNotNull": "is not null" "isNotNull": "ليس فارغاً"
}, },
"title": { "title": {
"newProj": "مشروع جديد", "newProj": "مشروع جديد",
@ -186,14 +186,14 @@
"teamAndSettings": "الفريق والإعدادات", "teamAndSettings": "الفريق والإعدادات",
"apiDocs": "مستندات API", "apiDocs": "مستندات API",
"importFromAirtable": "استيراد من Airtable", "importFromAirtable": "استيراد من Airtable",
"generateToken": "Generate Token", "generateToken": "إنشاء الرمز المميز",
"APIsAndSupport": "APIs & Support", "APIsAndSupport": "APIs & الدعم",
"helpCenter": "Help center", "helpCenter": "مركز المساعدة",
"swaggerDocumentation": "Swagger Documentation", "swaggerDocumentation": "Swagger Documentation",
"quickImportFrom": "Quick Import From", "quickImportFrom": "استيراد سريع من",
"quickImport": "Quick Import", "quickImport": "استيراد سريع",
"advancedSettings": "Advanced Settings", "advancedSettings": "الإعدادات المتقدمة",
"codeSnippet": "Code Snippet" "codeSnippet": "كتلة برمجية"
}, },
"labels": { "labels": {
"notifyVia": "إعلام عبر", "notifyVia": "إعلام عبر",
@ -261,37 +261,37 @@
"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": "طلب مصدر البيانات الذي تحتاجه؟",
"apiKey": "API Key", "apiKey": "مفتاح API",
"sharedBase": "Shared Base", "sharedBase": "قاعدة مشتركة",
"importData": "Import Data", "importData": "استيراد البيانات",
"importSecondaryViews": "Import Secondary Views", "importSecondaryViews": "استيراد المشاهدات الثانوية",
"importRollupColumns": "Import Rollup Columns", "importRollupColumns": "استيراد الأعمدة المتدحرجة",
"importLookupColumns": "Import Lookup Columns", "importLookupColumns": "استيراد أعمدة البحث",
"importAttachmentColumns": "Import Attachment Columns", "importAttachmentColumns": "استيراد أعمدة المرفق",
"importFormulaColumns": "Import Formula Columns", "importFormulaColumns": "استيراد أعمدة الصيغة",
"noData": "No Data", "noData": "لا توجد بيانات",
"goToDashboard": "Go to Dashboard", "goToDashboard": "الذهاب إلى لوحة التحكم",
"importing": "Importing", "importing": "الاستيراد",
"flattenNested": "Flatten Nested", "flattenNested": "متداخلة متقطعة",
"downloadAllowed": "Download allowed", "downloadAllowed": "التحميل المسموح به",
"weAreHiring": "We are Hiring!", "weAreHiring": "نحن نوظف!",
"primaryKey": "Primary key", "primaryKey": "المفتاح الأساسي",
"hasMany": "has many", "hasMany": "لديه العديد",
"belongsTo": "belongs to", "belongsTo": "ينتمي إلى",
"manyToMany": "have many to many relation", "manyToMany": "متعدد لمتعدد",
"extraConnectionParameters": "Extra connection parameters", "extraConnectionParameters": "معلمات اتصال إضافية",
"commentsOnly": "Comments only", "commentsOnly": "التعليقات فقط",
"documentation": "Documentation", "documentation": "الوثائق",
"subscribeNewsletter": "Subscribe to our weekly newsletter", "subscribeNewsletter": "اشترك في نشرتنا الإخبارية الأسبوعية",
"signUpWithGoogle": "Sign up with Google", "signUpWithGoogle": "التسجيل بواسطة Google",
"agreeToTos": "By signing up, you agree to the Terms of Service", "agreeToTos": "بالتسجيل، أنت توافق على شروط الخدمة",
"welcomeToNc": "Welcome to NocoDB!" "welcomeToNc": "مرحبا بكم في NocoDB!"
}, },
"activity": { "activity": {
"createProject": "إنشاء مشروع", "createProject": "إنشاء مشروع",
@ -399,14 +399,14 @@
"editConnJson": "تحرير اتصال جسون", "editConnJson": "تحرير اتصال جسون",
"sponsorUs": "ادعمنا", "sponsorUs": "ادعمنا",
"sendEmail": "ارسل بريد الكتروني", "sendEmail": "ارسل بريد الكتروني",
"addUserToProject": "Add user to project", "addUserToProject": "إضافة مستخدم إلى المشروع",
"getApiSnippet": "Get API Snippet", "getApiSnippet": "احصل على كتلة API",
"clearCell": "Clear cell", "clearCell": "مسح الخلية",
"addFilterGroup": "Add Filter Group", "addFilterGroup": "إضافة مجموعة عوامل التصفية",
"linkRecord": "Link record", "linkRecord": "اربط سجل",
"addNewRecord": "Add new record", "addNewRecord": "إضافة سجل جديد",
"useConnectionUrl": "Use Connection URL", "useConnectionUrl": "استخدام رابط الاتصال",
"toggleCommentsDraw": "Toggle comments draw" "toggleCommentsDraw": "تبديل سحب التعليقات"
}, },
"tooltip": { "tooltip": {
"saveChanges": "حفظ التغييرات", "saveChanges": "حفظ التغييرات",
@ -453,8 +453,8 @@
"noItemsFound": "لم يتم العثور على عناصر", "noItemsFound": "لم يتم العثور على عناصر",
"defaultValue": "القيمة الافتراضية", "defaultValue": "القيمة الافتراضية",
"filterByEmail": "تصفية عن طريق البريد الإلكتروني", "filterByEmail": "تصفية عن طريق البريد الإلكتروني",
"filterQuery": "Filter query", "filterQuery": "إستعلام الفلتر",
"selectField": "Select field" "selectField": "حدد حقل"
}, },
"msg": { "msg": {
"info": { "info": {
@ -548,29 +548,29 @@
"addDefaultColumns": "إضافة الأعمدة الافتراضية", "addDefaultColumns": "إضافة الأعمدة الافتراضية",
"tableNameInDb": "اسم الجدول كما تم حفظه في قاعدة البيانات", "tableNameInDb": "اسم الجدول كما تم حفظه في قاعدة البيانات",
"airtable": { "airtable": {
"credentials": "Where to find this?" "credentials": "أين تجد هذا؟"
}, },
"import": { "import": {
"clickOrDrag": "Click or drag file to this area to upload" "clickOrDrag": "انقر أو اسحب الملف إلى هذه المنطقة لتحميل"
}, },
"metaDataRecreated": "Table metadata recreated successfully", "metaDataRecreated": "تم إعادة إنشاء بيانات تعريف الجدول بنجاح",
"invalidCredentials": "Invalid credentials", "invalidCredentials": "بيانات الاعتماد غير صالحة",
"downloadingMoreFiles": "Downloading more files", "downloadingMoreFiles": "تحميل المزيد من الملفات",
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "تم النسخ إلى الحافظة",
"requriedFieldsCantBeMoved": "Required field can't be moved", "requriedFieldsCantBeMoved": "لا يمكن نقل الحقل المطلوب",
"updateNotAllowedWithoutPK": "Update not allowed for table which doesn't have primary key", "updateNotAllowedWithoutPK": "التحديث غير مسموح به للجدول الذي لا يحتوي على مفتاح أساسي",
"autoIncFieldNotEditable": "Auto increment field is not editable", "autoIncFieldNotEditable": "الحقل الإضافي التلقائي غير قابل للتحرير",
"editingPKnotSupported": "Editing primary key not supported", "editingPKnotSupported": "تحرير المفتاح الأساسي غير مدعوم",
"deletedCache": "Deleted cache successfully", "deletedCache": "تم حذف ذاكرة التخزين المؤقت بنجاح",
"cacheEmpty": "Cache is empty", "cacheEmpty": "ذاكرة التخزين المؤقت فارغة",
"exportedCache": "Exported Cache Successfully", "exportedCache": "تم تصدير ذاكرة التخزين المؤقت بنجاح",
"valueAlreadyInList": "This value is already in the list", "valueAlreadyInList": "هذه القيمة موجودة مسبقاً في القائمة",
"noColumnsToUpdate": "No columns to update", "noColumnsToUpdate": "لا توجد أعمدة للتحديث",
"tableDeleted": "Deleted table successfully", "tableDeleted": "تم حذف الجدول بنجاح",
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base", "generatePublicShareableReadonlyBase": "إنشاء قاعدة للقراءة فقط قابلة للمشاركة العامة",
"deleteViewConfirmation": "Are you sure you want to delete this view?", "deleteViewConfirmation": "هل أنت متأكد من أنك تريد حذف هذا العرض؟",
"deleteTableConfirmation": "Do you want to delete the table", "deleteTableConfirmation": "هل تريد حذف الجدول",
"showM2mTables": "Show M2M Tables" "showM2mTables": "إظهار جداول M2M"
}, },
"error": { "error": {
"searchProject": "البحث عن {بحث} لم يتم العثور على نتائج", "searchProject": "البحث عن {بحث} لم يتم العثور على نتائج",
@ -586,37 +586,37 @@
"passwdRequired": "كلمة المرور مطلوبة", "passwdRequired": "كلمة المرور مطلوبة",
"passwdLength": "يجب أن تكون كلمة المرور الخاصة بك 8 أحرف على الأقل", "passwdLength": "يجب أن تكون كلمة المرور الخاصة بك 8 أحرف على الأقل",
"passwdMismatch": "كلمات المرور غير متطابقة", "passwdMismatch": "كلمات المرور غير متطابقة",
"completeRuleSet": "At least 8 characters with one Uppercase, one number and one special character", "completeRuleSet": "8 أحرف على الأقل مع حرف كبير واحد ورقم واحد وحرف خاص واحد",
"atLeast8Char": "At least 8 characters", "atLeast8Char": "8 أحرف على الأقل",
"atLeastOneUppercase": "One Uppercase letter", "atLeastOneUppercase": "حرف كبير واحد",
"atLeastOneNumber": "One Number", "atLeastOneNumber": "رقم واحد",
"atLeastOneSpecialChar": "One special character", "atLeastOneSpecialChar": "حرف خاص واحد",
"allowedSpecialCharList": "Allowed special character list" "allowedSpecialCharList": "قائمة الأحرف الخاصة المسموح بها"
}, },
"invalidURL": "Invalid URL", "invalidURL": "رابط غير صالح",
"internalError": "Some internal error occurred", "internalError": "حدث خطأ داخلي",
"templateGeneratorNotFound": "Template Generator cannot be found!", "templateGeneratorNotFound": "لا يمكن العثور على مولد القالب!",
"fileUploadFailed": "Failed to upload file", "fileUploadFailed": "فشل في رفع الملف",
"primaryColumnUpdateFailed": "Failed to update primary column", "primaryColumnUpdateFailed": "فشل تحديث العمود الأساسي",
"formDescriptionTooLong": "Data too long for Form Description", "formDescriptionTooLong": "بيانات طويلة جداً لوصف النموذج",
"columnsRequired": "Following columns are required", "columnsRequired": "الأعمدة التالية مطلوبة",
"selectAtleastOneColumn": "At least one column has to be selected", "selectAtleastOneColumn": "يجب اختيار عمود واحد على الأقل",
"columnDescriptionNotFound": "Cannot find the destination column for", "columnDescriptionNotFound": "لا يمكن العثور على عمود الوجهة لـ",
"duplicateMappingFound": "Duplicate mapping found, please remove one of the mapping", "duplicateMappingFound": "تم العثور على تعيين متكرر، الرجاء إزالة أحد الخرائط",
"nullValueViolatesNotNull": "Null value violates not-null constraint", "nullValueViolatesNotNull": "قيمة الفراغ تنتهك قيودا غير فارغة",
"sourceHasInvalidNumbers": "Source data contains some invalid numbers", "sourceHasInvalidNumbers": "بيانات المصدر تحتوي على بعض الأرقام غير صالحة",
"sourceHasInvalidBoolean": "Source data contains some invalid boolean values", "sourceHasInvalidBoolean": "بيانات المصدر تحتوي على بعض القيم المنطقية غير صالحة",
"invalidForm": "Invalid Form", "invalidForm": "نموذج غير صالح",
"formValidationFailed": "Form validation failed", "formValidationFailed": "فشل التحقق من النموذج",
"youHaveBeenSignedOut": "You have been signed out", "youHaveBeenSignedOut": "لقد قمت بتسجيل الخروج",
"failedToLoadList": "Failed to load list", "failedToLoadList": "فشل تحميل القائمة",
"failedToLoadChildrenList": "Failed to load children list", "failedToLoadChildrenList": "فشل تحميل القائمة الفرعية",
"deleteFailed": "Delete failed", "deleteFailed": "فشل الحذف",
"unlinkFailed": "Unlink failed", "unlinkFailed": "فشل إلغاء الارتباط",
"rowUpdateFailed": "Row update failed", "rowUpdateFailed": "فشل تحديث الصف",
"deleteRowFailed": "Failed to delete row", "deleteRowFailed": "فشل في حذف الصف",
"setFormDataFailed": "Failed to set form data", "setFormDataFailed": "فشل في تعيين بيانات النموذج",
"formViewUpdateFailed": "Failed to update form view" "formViewUpdateFailed": "فشل تحديث عرض النموذج"
}, },
"toast": { "toast": {
"exportMetadata": "تصدير البيانات الوصفية للمشروع بنجاح", "exportMetadata": "تصدير البيانات الوصفية للمشروع بنجاح",
@ -636,33 +636,33 @@
"futureRelease": "قريبا!" "futureRelease": "قريبا!"
}, },
"success": { "success": {
"updatedUIACL": "Updated UI ACL for tables successfully", "updatedUIACL": "تم تحديث ACL واجهة المستخدم للجداول بنجاح",
"pluginUninstalled": "Plugin uninstalled successfully", "pluginUninstalled": "تم إلغاء تثبيت الإضافة بنجاح",
"pluginSettingsSaved": "Plugin settings saved successfully", "pluginSettingsSaved": "تم حفظ إعدادات الإضافة بنجاح",
"pluginTested": "Successfully tested plugin settings", "pluginTested": "تم اختبار إعدادات الإضافات بنجاح",
"tableRenamed": "Table renamed successfully", "tableRenamed": "تمت إعادة تسمية الجدول بنجاح",
"viewDeleted": "View deleted successfully", "viewDeleted": "تم حذف العرض بنجاح",
"primaryColumnUpdated": "Successfully updated as primary column", "primaryColumnUpdated": "تم التحديث بنجاح كعمود أساسي",
"tableDataExported": "Successfully exported all table data", "tableDataExported": "تم تصدير جميع بيانات الجدول بنجاح",
"updated": "Successfully updated", "updated": "تم التحديث بنجاح",
"sharedViewDeleted": "Deleted shared view successfully", "sharedViewDeleted": "تم حذف العرض المشترك بنجاح",
"viewRenamed": "View renamed successfully", "viewRenamed": "تمت إعادة التسمية بنجاح",
"tokenGenerated": "Token generated successfully", "tokenGenerated": "تم إنشاء الرمز بنجاح",
"tokenDeleted": "Token deleted successfully", "tokenDeleted": "تم حذف الرمز بنجاح",
"userAddedToProject": "Successfully added user to project", "userAddedToProject": "تمت إضافة المستخدم إلى المشروع بنجاح",
"userDeletedFromProject": "Successfully deleted user from project", "userDeletedFromProject": "تم حذف المستخدم بنجاح من المشروع",
"inviteEmailSent": "Invite Email sent successfully", "inviteEmailSent": "دعوة البريد الإلكتروني مرسلة بنجاح",
"inviteURLCopied": "Invite URL copied to clipboard", "inviteURLCopied": "تم نسخ عنوان URL الدعوة إلى الحافظة",
"shareableURLCopied": "Copied shareable base URL to clipboard!", "shareableURLCopied": "تم نسخ عنوان URL الأساسي القابل للمشاركة إلى الحافظة!",
"embeddableHTMLCodeCopied": "Copied embeddable HTML code!", "embeddableHTMLCodeCopied": "تم نسخ رمز HTML القابل للدمج!",
"userDetailsUpdated": "Successfully updated the user details", "userDetailsUpdated": "تم تحديث تفاصيل المستخدم بنجاح",
"tableDataImported": "Successfully imported table data", "tableDataImported": "تم استيراد بيانات الجدول بنجاح",
"webhookUpdated": "Webhook details updated successfully", "webhookUpdated": "تم تحديث تفاصيل Webhook بنجاح",
"webhookDeleted": "Hook deleted successfully", "webhookDeleted": "تم حذف هوك بنجاح",
"webhookTested": "Webhook tested successfully", "webhookTested": "تم اختبار Webhook بنجاح",
"columnUpdated": "Column updated", "columnUpdated": "تم تحديث العمود",
"columnCreated": "Column created", "columnCreated": "تم إنشاء العمود",
"passwordChanged": "Password changed successfully. Please login again." "passwordChanged": "تم تغيير كلمة المرور بنجاح. الرجاء تسجيل الدخول مرة أخرى."
} }
} }
} }

4
packages/nc-gui/pages/[projectType]/[projectId]/index.vue

@ -12,7 +12,7 @@ import {
projectThemeColors, projectThemeColors,
provide, provide,
ref, ref,
useClipboard, useCopy,
useGlobal, useGlobal,
useI18n, useI18n,
useProject, useProject,
@ -42,7 +42,7 @@ const { clearTabs, addTab } = useTabs()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { copy } = useClipboard() const { copy } = useCopy()
const isLocked = ref(false) const isLocked = ref(false)

801
packages/nocodb-sdk/package-lock.json generated

File diff suppressed because it is too large Load Diff

2
packages/nocodb-sdk/src/lib/Api.ts

@ -403,6 +403,7 @@ export interface FormType {
columns?: FormColumnType[]; columns?: FormColumnType[];
fk_model_id?: string; fk_model_id?: string;
lock_type?: 'collaborative' | 'locked' | 'personal'; lock_type?: 'collaborative' | 'locked' | 'personal';
meta?: any;
} }
export interface FormColumnType { export interface FormColumnType {
@ -418,6 +419,7 @@ export interface FormColumnType {
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
description?: string; description?: string;
meta?: any;
} }
export interface PaginatedType { export interface PaginatedType {

5
packages/nocodb-sdk/src/lib/globals.ts

@ -39,6 +39,11 @@ export enum AuditOperationTypes {
export enum AuditOperationSubTypes { export enum AuditOperationSubTypes {
UPDATE = 'UPDATE', UPDATE = 'UPDATE',
INSERT = 'INSERT', INSERT = 'INSERT',
BULK_INSERT = 'BULK_INSERT',
BULK_UPDATE = 'BULK_UPDATE',
BULK_DELETE = 'BULK_DELETE',
LINK_RECORD = 'LINK_RECORD',
UNLINK_RECORD = 'UNLINK_RECORD',
DELETE = 'DELETE', DELETE = 'DELETE',
CREATED = 'CREATED', CREATED = 'CREATED',
DELETED = 'DELETED', DELETED = 'DELETED',

2
packages/nocodb/.gitignore vendored

@ -16,3 +16,5 @@ xc.db*
noco.db* noco.db*
/nc/ /nc/
/docker/main.js /docker/main.js
test_meta.db
test_sakila.db

2
packages/nocodb/package-lock.json generated

@ -173,7 +173,7 @@
} }
}, },
"../nocodb-sdk": { "../nocodb-sdk": {
"version": "0.96.3", "version": "0.96.4",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

5
packages/nocodb/package.json

@ -21,11 +21,10 @@
"local:test:graphql": "cross-env DATABASE_URL=mysql://root:password@localhost:3306/sakila TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit", "local:test:graphql": "cross-env DATABASE_URL=mysql://root:password@localhost:3306/sakila TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit",
"test:graphql": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit", "test:graphql": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit",
"test:grpc": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/grpc.test.ts --recursive --timeout 10000 --exit", "test:grpc": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/grpc.test.ts --recursive --timeout 10000 --exit",
"local:test:rest": "cross-env DATABASE_URL=mysql://root:password@localhost:3306/sakila TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/rest.test.ts --recursive --timeout 10000 --exit", "local:test:unit": "cross-env TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/index.test.ts --recursive --timeout 300000 --exit --delay",
"test:rest": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/rest.test.ts --recursive --timeout 10000 --exit", "test:unit": "cross-env TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/index.test.ts --recursive --timeout 300000 --exit --delay",
"test1": "run-s build test:*", "test1": "run-s build test:*",
"test:lint": "tslint --project . && prettier \"src/**/*.ts\" --list-different", "test:lint": "tslint --project . && prettier \"src/**/*.ts\" --list-different",
"test:unit": "nyc --silent ava",
"watch": "run-s clean build:main && run-p \"build:main -- -w\" \"test:unit -- --watch\"", "watch": "run-s clean build:main && run-p \"build:main -- -w\" \"test:unit -- --watch\"",
"cov": "run-s build test:unit cov:html && open-cli coverage/index.html", "cov": "run-s build test:unit cov:html && open-cli coverage/index.html",
"cov:html": "nyc report --reporter=html", "cov:html": "nyc report --reporter=html",

70
packages/nocodb/src/__tests__/tsconfig.json

@ -0,0 +1,70 @@
{
"compilerOptions": {
"skipLibCheck": true,
"composite": true,
"target": "es2017",
"outDir": "build/main",
"rootDir": "src",
"moduleResolution": "node",
"module": "commonjs",
"declaration": true,
"inlineSourceMap": true,
"esModuleInterop": true
/* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"allowJs": false,
// "strict": true /* Enable all strict type-checking options. */,
/* Strict Type-Checking Options */
// "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
// "strictNullChecks": true /* Enable strict null checks. */,
// "strictFunctionTypes": true /* Enable strict checking of function types. */,
// "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
// "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
// "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
"resolveJsonModule": true,
/* Additional Checks */
"noUnusedLocals": false
/* Report errors on unused locals. */,
"noUnusedParameters": false
/* Report errors on unused parameters. */,
"noImplicitReturns": false
/* Report error when not all code paths in function return a value. */,
"noFallthroughCasesInSwitch": false
/* Report errors for fallthrough cases in switch statement. */,
/* Debugging Options */
"traceResolution": false
/* Report module resolution log messages. */,
"listEmittedFiles": false
/* Print names of generated files part of the compilation. */,
"listFiles": false
/* Print names of files part of the compilation. */,
"pretty": true
/* Stylize errors and messages using color and context. */,
/* Experimental Options */
// "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
// "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
"lib": [
"es2017"
],
"types": [
"mocha", "node"
],
"typeRoots": [
"node_modules/@types",
"src/types"
]
},
"include": [
"src/**/*.ts",
// "src/lib/xgene/migrations/*.js",
"src/**/*.json"
],
"exclude": [
"node_modules/**",
"node_modules",
"../../../xc-lib-private/**",
"../../../xc-lib-private"
],
"compileOnSave": false
}

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

@ -116,6 +116,7 @@ class BaseModelSqlv2 {
return !!(await qb.where(_wherePk(pks, id)).first()); return !!(await qb.where(_wherePk(pks, id)).first());
} }
// todo: add support for sortArrJson
public async findOne( public async findOne(
args: { args: {
where?: string; where?: string;
@ -143,7 +144,6 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
@ -205,7 +205,6 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
@ -230,7 +229,6 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
@ -322,6 +320,7 @@ class BaseModelSqlv2 {
return (this.isPg ? res.rows[0] : res[0][0] ?? res[0]).count; return (this.isPg ? res.rows[0] : res[0][0] ?? res[0]).count;
} }
// todo: add support for sortArrJson and filterArrJson
async groupBy( async groupBy(
args: { args: {
where?: string; where?: string;
@ -1667,8 +1666,10 @@ class BaseModelSqlv2 {
datas: any[], datas: any[],
{ {
chunkSize: _chunkSize = 100, chunkSize: _chunkSize = 100,
cookie,
}: { }: {
chunkSize?: number; chunkSize?: number;
cookie?: any;
} = {} } = {}
) { ) {
try { try {
@ -1699,7 +1700,7 @@ class BaseModelSqlv2 {
.batchInsert(this.model.table_name, insertDatas, chunkSize) .batchInsert(this.model.table_name, insertDatas, chunkSize)
.returning(this.model.primaryKey?.column_name); .returning(this.model.primaryKey?.column_name);
// await this.afterInsertb(insertDatas, null); await this.afterBulkInsert(insertDatas, this.dbDriver, cookie);
return response; return response;
} catch (e) { } catch (e) {
@ -1708,7 +1709,7 @@ class BaseModelSqlv2 {
} }
} }
async bulkUpdate(datas: any[]) { async bulkUpdate(datas: any[], { cookie }: { cookie?: any } = {}) {
let transaction; let transaction;
try { try {
const updateDatas = await Promise.all( const updateDatas = await Promise.all(
@ -1733,7 +1734,7 @@ class BaseModelSqlv2 {
res.push(response); res.push(response);
} }
// await this.afterUpdateb(res, transaction); await this.afterBulkUpdate(updateDatas.length, this.dbDriver, cookie);
transaction.commit(); transaction.commit();
return res; return res;
@ -1747,13 +1748,14 @@ class BaseModelSqlv2 {
async bulkUpdateAll( async bulkUpdateAll(
args: { where?: string; filterArr?: Filter[] } = {}, args: { where?: string; filterArr?: Filter[] } = {},
data data,
{ cookie }: { cookie?: any } = {}
) { ) {
let queryResponse;
try { try {
const updateData = await this.model.mapAliasToColumn(data); const updateData = await this.model.mapAliasToColumn(data);
await this.validate(updateData); await this.validate(updateData);
const pkValues = await this._extractPksValues(updateData); const pkValues = await this._extractPksValues(updateData);
let res = null;
if (pkValues) { if (pkValues) {
// pk is specified - by pass // pk is specified - by pass
} else { } else {
@ -1775,21 +1777,25 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
); );
qb.update(updateData); qb.update(updateData);
res = ((await qb) as any).count; queryResponse = (await qb) as any;
} }
return res;
const count = queryResponse ?? 0;
await this.afterBulkUpdate(count, this.dbDriver, cookie);
return count;
} catch (e) { } catch (e) {
throw e; throw e;
} }
} }
async bulkDelete(ids: any[]) { async bulkDelete(ids: any[], { cookie }: { cookie?: any } = {}) {
let transaction; let transaction;
try { try {
transaction = await this.dbDriver.transaction(); transaction = await this.dbDriver.transaction();
@ -1808,6 +1814,8 @@ class BaseModelSqlv2 {
transaction.commit(); transaction.commit();
await this.afterBulkDelete(ids.length, this.dbDriver, cookie);
return res; return res;
} catch (e) { } catch (e) {
if (transaction) transaction.rollback(); if (transaction) transaction.rollback();
@ -1817,7 +1825,10 @@ class BaseModelSqlv2 {
} }
} }
async bulkDeleteAll(args: { where?: string; filterArr?: Filter[] } = {}) { async bulkDeleteAll(
args: { where?: string; filterArr?: Filter[] } = {},
{ cookie }: { cookie?: any } = {}
) {
try { try {
await this.model.getColumns(); await this.model.getColumns();
const { where } = this._getListArgs(args); const { where } = this._getListArgs(args);
@ -1837,13 +1848,16 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
); );
qb.del(); qb.del();
return ((await qb) as any).count; const count = (await qb) as any;
await this.afterBulkDelete(count, this.dbDriver, cookie);
return count;
} catch (e) { } catch (e) {
throw e; throw e;
} }
@ -1861,7 +1875,7 @@ class BaseModelSqlv2 {
await this.handleHooks('After.insert', data, req); await this.handleHooks('After.insert', data, req);
// if (req?.headers?.['xc-gui']) { // if (req?.headers?.['xc-gui']) {
const id = this._extractPksValues(data); const id = this._extractPksValues(data);
Audit.insert({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
row_id: id, row_id: id,
op_type: AuditOperationTypes.DATA, op_type: AuditOperationTypes.DATA,
@ -1876,6 +1890,48 @@ class BaseModelSqlv2 {
// } // }
} }
public async afterBulkUpdate(count: number, _trx: any, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_UPDATE,
description: DOMPurify.sanitize(
`${count} records bulk updated in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
}
public async afterBulkDelete(count: number, _trx: any, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_DELETE,
description: DOMPurify.sanitize(
`${count} records bulk deleted in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
}
public async afterBulkInsert(data: any[], _trx: any, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_INSERT,
description: DOMPurify.sanitize(
`${data.length} records bulk inserted into ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
}
public async beforeUpdate(data: any, _trx: any, req): Promise<void> { public async beforeUpdate(data: any, _trx: any, req): Promise<void> {
const ignoreWebhook = req.query?.ignoreWebhook; const ignoreWebhook = req.query?.ignoreWebhook;
if (ignoreWebhook) { if (ignoreWebhook) {
@ -1889,6 +1945,18 @@ class BaseModelSqlv2 {
} }
public async afterUpdate(data: any, _trx: any, req): Promise<void> { public async afterUpdate(data: any, _trx: any, req): Promise<void> {
const id = this._extractPksValues(data);
Audit.insert({
fk_model_id: this.model.id,
row_id: id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.UPDATE,
description: DOMPurify.sanitize(`${id} updated in ${this.model.title}`),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
const ignoreWebhook = req.query?.ignoreWebhook; const ignoreWebhook = req.query?.ignoreWebhook;
if (ignoreWebhook) { if (ignoreWebhook) {
if (ignoreWebhook != 'true' && ignoreWebhook != 'false') { if (ignoreWebhook != 'true' && ignoreWebhook != 'false') {
@ -1907,7 +1975,7 @@ class BaseModelSqlv2 {
public async afterDelete(data: any, _trx: any, req): Promise<void> { public async afterDelete(data: any, _trx: any, req): Promise<void> {
// if (req?.headers?.['xc-gui']) { // if (req?.headers?.['xc-gui']) {
const id = req?.params?.id; const id = req?.params?.id;
Audit.insert({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
row_id: id, row_id: id,
op_type: AuditOperationTypes.DATA, op_type: AuditOperationTypes.DATA,
@ -2070,10 +2138,12 @@ class BaseModelSqlv2 {
colId, colId,
rowId, rowId,
childId, childId,
cookie,
}: { }: {
colId: string; colId: string;
rowId: string; rowId: string;
childId: string; childId: string;
cookie?: any;
}) { }) {
const columns = await this.model.getColumns(); const columns = await this.model.getColumns();
const column = columns.find((c) => c.id === colId); const column = columns.find((c) => c.id === colId);
@ -2140,16 +2210,35 @@ class BaseModelSqlv2 {
} }
break; break;
} }
await this.afterAddChild(rowId, childId, cookie);
}
public async afterAddChild(rowId, childId, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.LINK_RECORD,
row_id: rowId,
description: DOMPurify.sanitize(
`Record [id:${childId}] record linked with record [id:${rowId}] record in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
} }
async removeChild({ async removeChild({
colId, colId,
rowId, rowId,
childId, childId,
cookie,
}: { }: {
colId: string; colId: string;
rowId: string; rowId: string;
childId: string; childId: string;
cookie?: any;
}) { }) {
const columns = await this.model.getColumns(); const columns = await this.model.getColumns();
const column = columns.find((c) => c.id === colId); const column = columns.find((c) => c.id === colId);
@ -2214,6 +2303,23 @@ class BaseModelSqlv2 {
} }
break; break;
} }
await this.afterRemoveChild(rowId, childId, cookie);
}
public async afterRemoveChild(rowId, childId, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.UNLINK_RECORD,
row_id: rowId,
description: DOMPurify.sanitize(
`Record [id:${childId}] record unlinked with record [id:${rowId}] record in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
} }
private async extractRawQueryAndExec(qb: QueryBuilder) { private async extractRawQueryAndExec(qb: QueryBuilder) {
@ -2225,7 +2331,7 @@ class BaseModelSqlv2 {
} }
return this.isPg return this.isPg
? (await this.dbDriver.raw(query))?.rows ? (await this.dbDriver.raw(query))?.rows
: query.slice(0, 6) === 'select' : query.slice(0, 6) === 'select' && !this.isMssql
? await this.dbDriver.from( ? await this.dbDriver.from(
this.dbDriver.raw(query).wrap('(', ') __nc_alias') this.dbDriver.raw(query).wrap('(', ') __nc_alias')
) )

257
packages/nocodb/src/lib/meta/api/columnApis.ts

@ -513,7 +513,10 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
if ([UITypes.SingleSelect, UITypes.MultiSelect].includes(colBody.uidt)) { if ([UITypes.SingleSelect, UITypes.MultiSelect].includes(colBody.uidt)) {
const dbDriver = NcConnectionMgrv2.get(base); const dbDriver = NcConnectionMgrv2.get(base);
const driverType = dbDriver.clientType(); const driverType = dbDriver.clientType();
const optionTitles = colBody.colOptions.options.map(el => el.title); const optionTitles = colBody.colOptions.options.map(el => el.title.replace(/'/g, "''"));
// this is not used for select columns and cause issue for MySQL
colBody.dtxs = '';
// Handle default values // Handle default values
if (colBody.cdf) { if (colBody.cdf) {
if (colBody.uidt === UITypes.SingleSelect) { if (colBody.uidt === UITypes.SingleSelect) {
@ -551,13 +554,6 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
NcError.badRequest('Empty options are not allowed!'); NcError.badRequest('Empty options are not allowed!');
} }
// Handle empty enum/set for mysql (we restrict empty user options beforehand)
if (driverType === 'mysql' || driverType === 'mysql2') {
if (!colBody.colOptions.options.length && (!colBody.dtxp || colBody.dtxp === '')) {
colBody.colOptions.options.push({ title: '' });
}
}
// Trim end of enum/set // Trim end of enum/set
if (colBody.dt === 'enum' || colBody.dt === 'set') { if (colBody.dt === 'enum' || colBody.dt === 'set') {
for (const opt of colBody.colOptions.options) { for (const opt of colBody.colOptions.options) {
@ -579,6 +575,13 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
}).join(',')}` }).join(',')}`
: ''; : '';
} }
// Handle empty enum/set for mysql (we restrict empty user options beforehand)
if (driverType === 'mysql' || driverType === 'mysql2') {
if (!colBody.colOptions.options.length && (!colBody.dtxp || colBody.dtxp === '')) {
colBody.dtxp = '\'\'';
}
}
} }
@ -732,7 +735,7 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
dbDriver: NcConnectionMgrv2.get(base) dbDriver: NcConnectionMgrv2.get(base)
}); });
if (column.colOptions?.options) { if (colBody.colOptions?.options) {
const supportedDrivers = ['mysql', 'mysql2', 'pg', 'mssql', 'sqlite3']; const supportedDrivers = ['mysql', 'mysql2', 'pg', 'mssql', 'sqlite3'];
const dbDriver = NcConnectionMgrv2.get(base); const dbDriver = NcConnectionMgrv2.get(base);
const driverType = dbDriver.clientType(); const driverType = dbDriver.clientType();
@ -751,12 +754,14 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
} }
// Handle migrations // Handle migrations
for (const op of column.colOptions.options.filter(el => el.order === null)) { if (column.colOptions?.options) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '') for (const op of column.colOptions.options.filter(el => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '')
}
} }
// Handle default values // Handle default values
const optionTitles = colBody.colOptions.options.map(el => el.title); const optionTitles = colBody.colOptions.options.map(el => el.title.replace(/'/g, "''"));
if (colBody.cdf) { if (colBody.cdf) {
if (colBody.uidt === UITypes.SingleSelect) { if (colBody.uidt === UITypes.SingleSelect) {
if (!optionTitles.includes(colBody.cdf)) { if (!optionTitles.includes(colBody.cdf)) {
@ -794,13 +799,6 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
NcError.badRequest('Empty options are not allowed!'); NcError.badRequest('Empty options are not allowed!');
} }
// Handle empty enum/set for mysql (we restrict empty user options beforehand)
if (driverType === 'mysql' || driverType === 'mysql2') {
if (!colBody.colOptions.options.length && (!colBody.dtxp || colBody.dtxp === '')) {
colBody.dtxp = '\'\'';
}
}
// Trim end of enum/set // Trim end of enum/set
if (colBody.dt === 'enum' || colBody.dt === 'set') { if (colBody.dt === 'enum' || colBody.dt === 'set') {
for (const opt of colBody.colOptions.options) { for (const opt of colBody.colOptions.options) {
@ -823,26 +821,35 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
: ''; : '';
} }
// Handle option delete // Handle empty enum/set for mysql (we restrict empty user options beforehand)
for (const option of column.colOptions.options.filter(oldOp => colBody.colOptions.options.find(newOp => newOp.id === oldOp.id) ? false : true)) { if (driverType === 'mysql' || driverType === 'mysql2') {
if (!supportedDrivers.includes(driverType) && column.uidt === UITypes.MultiSelect) { if (!colBody.colOptions.options.length && (!colBody.dtxp || colBody.dtxp === '')) {
NcError.badRequest('Your database not yet supported for this operation. Please remove option from records manually before dropping.'); colBody.dtxp = '\'\'';
} }
if (column.uidt === UITypes.SingleSelect) { }
if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = NULL WHERE ?? LIKE ?`, [table.table_name, column.column_name, column.column_name, option.title]); // Handle option delete
} else { if (column.colOptions?.options) {
await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${option.title})` }, { [column.column_name]: null }); for (const option of column.colOptions.options.filter(oldOp => colBody.colOptions.options.find(newOp => newOp.id === oldOp.id) ? false : true)) {
if (!supportedDrivers.includes(driverType) && column.uidt === UITypes.MultiSelect) {
NcError.badRequest('Your database not yet supported for this operation. Please remove option from records manually before dropping.');
} }
} else if (column.uidt === UITypes.MultiSelect) { if (column.uidt === UITypes.SingleSelect) {
if (driverType === 'mysql' || driverType === 'mysql2') { if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', ??, ','), CONCAT(',', ?, ','), ',')) WHERE FIND_IN_SET(?, ??)`, [table.table_name, column.column_name, column.column_name, option.title, option.title, column.column_name]); await dbDriver.raw(`UPDATE ?? SET ?? = NULL WHERE ?? LIKE ?`, [table.table_name, column.column_name, column.column_name, option.title]);
} else if (driverType === 'pg') { } else {
await dbDriver.raw(`UPDATE ?? SET ?? = array_to_string(array_remove(string_to_array(??, ','), ?), ',')`, [table.table_name, column.column_name, column.column_name, option.title]); await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${option.title})` }, { [column.column_name]: null }, { cookie: req});
} else if (driverType === 'mssql') { }
await dbDriver.raw(`UPDATE ?? SET ?? = substring(replace(concat(',', ??, ','), concat(',', ?, ','), ','), 2, len(replace(concat(',', ??, ','), concat(',', ?, ','), ',')) - 2)`, [table.table_name, column.column_name, column.column_name, option.title, column.column_name, option.title]); } else if (column.uidt === UITypes.MultiSelect) {
} else if (driverType === 'sqlite3') { if (driverType === 'mysql' || driverType === 'mysql2') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(REPLACE(',' || ?? || ',', ',' || ? || ',', ','), ',')`, [table.table_name, column.column_name, column.column_name, option.title]); await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', ??, ','), CONCAT(',', ?, ','), ',')) WHERE FIND_IN_SET(?, ??)`, [table.table_name, column.column_name, column.column_name, option.title, option.title, column.column_name]);
} else if (driverType === 'pg') {
await dbDriver.raw(`UPDATE ?? SET ?? = array_to_string(array_remove(string_to_array(??, ','), ?), ',')`, [table.table_name, column.column_name, column.column_name, option.title]);
} else if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = substring(replace(concat(',', ??, ','), concat(',', ?, ','), ','), 2, len(replace(concat(',', ??, ','), concat(',', ?, ','), ',')) - 2)`, [table.table_name, column.column_name, column.column_name, option.title, column.column_name, option.title]);
} else if (driverType === 'sqlite3') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(REPLACE(',' || ?? || ',', ',' || ? || ',', ','), ',')`, [table.table_name, column.column_name, column.column_name, option.title]);
}
} }
} }
} }
@ -850,97 +857,99 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
let interchange = []; let interchange = [];
// Handle option update // Handle option update
const old_titles = column.colOptions.options.map(el => el.title); if (column.colOptions?.options) {
for (const option of column.colOptions.options.filter(oldOp => colBody.colOptions.options.find(newOp => newOp.id === oldOp.id && newOp.title !== oldOp.title))) { const old_titles = column.colOptions.options.map(el => el.title);
if (!supportedDrivers.includes(driverType) && column.uidt === UITypes.MultiSelect) { for (const option of column.colOptions.options.filter(oldOp => colBody.colOptions.options.find(newOp => newOp.id === oldOp.id && newOp.title !== oldOp.title))) {
NcError.badRequest('Your database not yet supported for this operation. Please remove option from records manually before updating.'); if (!supportedDrivers.includes(driverType) && column.uidt === UITypes.MultiSelect) {
} NcError.badRequest('Your database not yet supported for this operation. Please remove option from records manually before updating.');
let newOp = { ...colBody.colOptions.options.find(el => option.id === el.id) };
if (old_titles.includes(newOp.title)) {
let def_option = { ...newOp };
let title_counter = 1;
while (old_titles.includes(newOp.title)) {
newOp.title = `${def_option.title}_${title_counter++}`;
}
interchange.push( {
def_option,
temp_title: newOp.title
} );
}
// Append new option before editing
if ((driverType === 'mysql' || driverType === 'mysql2') && (column.dt === 'enum' || column.dt === 'set')) {
column.colOptions.options.push({ title: newOp.title });
let temp_dtxp = '';
if (column.uidt === UITypes.SingleSelect) {
temp_dtxp = (column.colOptions?.options.length)
? `${column.colOptions.options.map(o => `'${o.title.replace(/'/gi, '\'\'')}'`).join(',')}`
: '';
} else if (column.uidt === UITypes.MultiSelect){
temp_dtxp = (column.colOptions?.options.length)
? `${column.colOptions.options.map((o) => {
if(o.title.includes(',')) {
NcError.badRequest('Illegal char(\',\') for MultiSelect');
throw new Error('');
}
return `'${o.title.replace(/'/gi, '\'\'')}'`;
}).join(',')}`
: '';
} }
const tableUpdateBody = { let newOp = { ...colBody.colOptions.options.find(el => option.id === el.id) };
...table, if (old_titles.includes(newOp.title)) {
tn: table.table_name, let def_option = { ...newOp };
originalColumns: table.columns.map((c) => ({ let title_counter = 1;
...c, while (old_titles.includes(newOp.title)) {
cn: c.column_name, newOp.title = `${def_option.title}_${title_counter++}`;
cno: c.column_name, }
})), interchange.push( {
columns: await Promise.all( def_option,
table.columns.map(async (c) => { temp_title: newOp.title
if (c.id === req.params.columnId) { } );
const res = { }
...c,
...column, // Append new option before editing
cn: column.column_name, if ((driverType === 'mysql' || driverType === 'mysql2') && (column.dt === 'enum' || column.dt === 'set')) {
cno: c.column_name, column.colOptions.options.push({ title: newOp.title });
dtxp: temp_dtxp,
altered: Altered.UPDATE_COLUMN, let temp_dtxp = '';
}; if (column.uidt === UITypes.SingleSelect) {
return Promise.resolve(res); temp_dtxp = (column.colOptions.options.length)
} else { ? `${column.colOptions.options.map(o => `'${o.title.replace(/'/gi, '\'\'')}'`).join(',')}`
(c as any).cn = c.column_name; : '';
} } else if (column.uidt === UITypes.MultiSelect){
return Promise.resolve(c); temp_dtxp = (column.colOptions.options.length)
}) ? `${column.colOptions.options.map((o) => {
), if(o.title.includes(',')) {
}; NcError.badRequest('Illegal char(\',\') for MultiSelect');
throw new Error('');
}
return `'${o.title.replace(/'/gi, '\'\'')}'`;
}).join(',')}`
: '';
}
const sqlMgr = await ProjectMgrv2.getSqlMgr({ id: base.project_id }); const tableUpdateBody = {
await sqlMgr.sqlOpPlus(base, 'tableUpdate', tableUpdateBody); ...table,
tn: table.table_name,
await Column.update(req.params.columnId, { originalColumns: table.columns.map((c) => ({
...column, ...c,
}); cn: c.column_name,
} cno: c.column_name,
})),
columns: await Promise.all(
table.columns.map(async (c) => {
if (c.id === req.params.columnId) {
const res = {
...c,
...column,
cn: column.column_name,
cno: c.column_name,
dtxp: temp_dtxp,
altered: Altered.UPDATE_COLUMN,
};
return Promise.resolve(res);
} else {
(c as any).cn = c.column_name;
}
return Promise.resolve(c);
})
),
};
if (column.uidt === UITypes.SingleSelect) { const sqlMgr = await ProjectMgrv2.getSqlMgr({ id: base.project_id });
if (driverType === 'mssql') { await sqlMgr.sqlOpPlus(base, 'tableUpdate', tableUpdateBody);
await dbDriver.raw(`UPDATE ?? SET ?? = ? WHERE ?? LIKE ?`, [table.table_name, column.column_name, newOp.title, column.column_name, option.title]);
} else { await Column.update(req.params.columnId, {
await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${option.title})` }, { [column.column_name]: newOp.title }); ...column,
});
} }
} else if (column.uidt === UITypes.MultiSelect) {
if (driverType === 'mysql' || driverType === 'mysql2') { if (column.uidt === UITypes.SingleSelect) {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', ??, ','), CONCAT(',', ?, ','), CONCAT(',', ?, ','))) WHERE FIND_IN_SET(?, ??)`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title, option.title, column.column_name]); if (driverType === 'mssql') {
} else if (driverType === 'pg') { await dbDriver.raw(`UPDATE ?? SET ?? = ? WHERE ?? LIKE ?`, [table.table_name, column.column_name, newOp.title, column.column_name, option.title]);
await dbDriver.raw(`UPDATE ?? SET ?? = array_to_string(array_replace(string_to_array(??, ','), ?, ?), ',')`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title]); } else {
} else if (driverType === 'mssql') { await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${option.title})` }, { [column.column_name]: newOp.title }, { cookie: req});
await dbDriver.raw(`UPDATE ?? SET ?? = substring(replace(concat(',', ??, ','), concat(',', ?, ','), concat(',', ?, ',')), 2, len(replace(concat(',', ??, ','), concat(',', ?, ','), concat(',', ?, ','))) - 2)`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title, column.column_name, option.title, newOp.title]); }
} else if (driverType === 'sqlite3') { } else if (column.uidt === UITypes.MultiSelect) {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(REPLACE(',' || ?? || ',', ',' || ? || ',', ',' || ? || ','), ',')`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title]); if (driverType === 'mysql' || driverType === 'mysql2') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', ??, ','), CONCAT(',', ?, ','), CONCAT(',', ?, ','))) WHERE FIND_IN_SET(?, ??)`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title, option.title, column.column_name]);
} else if (driverType === 'pg') {
await dbDriver.raw(`UPDATE ?? SET ?? = array_to_string(array_replace(string_to_array(??, ','), ?, ?), ',')`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title]);
} else if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = substring(replace(concat(',', ??, ','), concat(',', ?, ','), concat(',', ?, ',')), 2, len(replace(concat(',', ??, ','), concat(',', ?, ','), concat(',', ?, ','))) - 2)`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title, column.column_name, option.title, newOp.title]);
} else if (driverType === 'sqlite3') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(REPLACE(',' || ?? || ',', ',' || ? || ',', ',' || ? || ','), ',')`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title]);
}
} }
} }
} }
@ -951,7 +960,7 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
if (driverType === 'mssql') { if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = ? WHERE ?? LIKE ?`, [table.table_name, column.column_name, newOp.title, column.column_name, ch.temp_title]); await dbDriver.raw(`UPDATE ?? SET ?? = ? WHERE ?? LIKE ?`, [table.table_name, column.column_name, newOp.title, column.column_name, ch.temp_title]);
} else { } else {
await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${ch.temp_title})` }, { [column.column_name]: newOp.title }); await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${ch.temp_title})` }, { [column.column_name]: newOp.title }, { cookie: req});
} }
} else if (column.uidt === UITypes.MultiSelect) { } else if (column.uidt === UITypes.MultiSelect) {
if (driverType === 'mysql' || driverType === 'mysql2') { if (driverType === 'mysql' || driverType === 'mysql2') {

10
packages/nocodb/src/lib/meta/api/dataApis/bulkDataAliasApis.ts

@ -17,7 +17,7 @@ async function bulkDataInsert(req: Request, res: Response) {
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
res.json(await baseModel.bulkInsert(req.body)); res.json(await baseModel.bulkInsert(req.body, { cookie: req }));
} }
async function bulkDataUpdate(req: Request, res: Response) { async function bulkDataUpdate(req: Request, res: Response) {
@ -30,9 +30,10 @@ async function bulkDataUpdate(req: Request, res: Response) {
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
res.json(await baseModel.bulkUpdate(req.body)); res.json(await baseModel.bulkUpdate(req.body, { cookie: req }));
} }
// todo: Integrate with filterArrJson bulkDataUpdateAll
async function bulkDataUpdateAll(req: Request, res: Response) { async function bulkDataUpdateAll(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
const base = await Base.get(model.base_id); const base = await Base.get(model.base_id);
@ -43,7 +44,7 @@ async function bulkDataUpdateAll(req: Request, res: Response) {
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
res.json(await baseModel.bulkUpdateAll(req.query, req.body)); res.json(await baseModel.bulkUpdateAll(req.query, req.body, { cookie: req }));
} }
async function bulkDataDelete(req: Request, res: Response) { async function bulkDataDelete(req: Request, res: Response) {
@ -55,9 +56,10 @@ async function bulkDataDelete(req: Request, res: Response) {
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
res.json(await baseModel.bulkDelete(req.body)); res.json(await baseModel.bulkDelete(req.body, { cookie: req }));
} }
// todo: Integrate with filterArrJson bulkDataDeleteAll
async function bulkDataDeleteAll(req: Request, res: Response) { async function bulkDataDeleteAll(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
const base = await Base.get(model.base_id); const base = await Base.get(model.base_id);

4
packages/nocodb/src/lib/meta/api/dataApis/dataAliasApis.ts

@ -10,6 +10,7 @@ import { getViewAndModelFromRequestByAliasOrId } from './helpers';
import apiMetrics from '../../helpers/apiMetrics'; import apiMetrics from '../../helpers/apiMetrics';
import getAst from '../../../db/sql-data-mapper/lib/sql/helpers/getAst'; import getAst from '../../../db/sql-data-mapper/lib/sql/helpers/getAst';
// todo: Handle the error case where view doesnt belong to model
async function dataList(req: Request, res: Response) { async function dataList(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
res.json(await getDataList(model, view, req)); res.json(await getDataList(model, view, req));
@ -46,6 +47,7 @@ async function dataCount(req: Request, res: Response) {
res.json({ count }); res.json({ count });
} }
// todo: Handle the error case where view doesnt belong to model
async function dataInsert(req: Request, res: Response) { async function dataInsert(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
@ -81,6 +83,8 @@ async function dataDelete(req: Request, res: Response) {
viewId: view?.id, viewId: view?.id,
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
// todo: Should have error http status code
const message = await baseModel.hasLTARData(req.params.rowId, model); const message = await baseModel.hasLTARData(req.params.rowId, model);
if (message.length) { if (message.length) {
res.json({ message }); res.json({ message });

11
packages/nocodb/src/lib/meta/api/dataApis/dataAliasNestedApis.ts

@ -4,10 +4,14 @@ import Base from '../../../models/Base';
import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2'; import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2';
import { PagedResponseImpl } from '../../helpers/PagedResponse'; import { PagedResponseImpl } from '../../helpers/PagedResponse';
import ncMetaAclMw from '../../helpers/ncMetaAclMw'; import ncMetaAclMw from '../../helpers/ncMetaAclMw';
import { getColumnByIdOrName, getViewAndModelFromRequestByAliasOrId } from './helpers' import {
getColumnByIdOrName,
getViewAndModelFromRequestByAliasOrId,
} from './helpers';
import { NcError } from '../../helpers/catchError'; import { NcError } from '../../helpers/catchError';
import apiMetrics from '../../helpers/apiMetrics'; import apiMetrics from '../../helpers/apiMetrics';
// todo: handle case where the given column is not ltar
export async function mmList(req: Request, res: Response, next) { export async function mmList(req: Request, res: Response, next) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
@ -157,6 +161,7 @@ export async function btExcludedList(req: Request, res: Response, next) {
); );
} }
// todo: handle case where the given column is not ltar
export async function hmList(req: Request, res: Response, next) { export async function hmList(req: Request, res: Response, next) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
if (!model) return next(new Error('Table not found')); if (!model) return next(new Error('Table not found'));
@ -212,12 +217,14 @@ async function relationDataRemove(req, res) {
colId: column.id, colId: column.id,
childId: req.params.refRowId, childId: req.params.refRowId,
rowId: req.params.rowId, rowId: req.params.rowId,
cookie: req,
}); });
res.json({ msg: 'success' }); res.json({ msg: 'success' });
} }
//@ts-ignore //@ts-ignore
// todo: Give proper error message when reference row is already related and handle duplicate ref row id in hm
async function relationDataAdd(req, res) { async function relationDataAdd(req, res) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
if (!model) NcError.notFound('Table not found'); if (!model) NcError.notFound('Table not found');
@ -235,12 +242,12 @@ async function relationDataAdd(req, res) {
colId: column.id, colId: column.id,
childId: req.params.refRowId, childId: req.params.refRowId,
rowId: req.params.rowId, rowId: req.params.rowId,
cookie: req,
}); });
res.json({ msg: 'success' }); res.json({ msg: 'success' });
} }
const router = Router({ mergeParams: true }); const router = Router({ mergeParams: true });
router.get( router.get(

2
packages/nocodb/src/lib/meta/api/dataApis/dataApis.ts

@ -494,6 +494,7 @@ async function relationDataDelete(req, res) {
colId: req.params.colId, colId: req.params.colId,
childId: req.params.childId, childId: req.params.childId,
rowId: req.params.rowId, rowId: req.params.rowId,
cookie: req,
}); });
res.json({ msg: 'success' }); res.json({ msg: 'success' });
@ -521,6 +522,7 @@ async function relationDataAdd(req, res) {
colId: req.params.colId, colId: req.params.colId,
childId: req.params.childId, childId: req.params.childId,
rowId: req.params.rowId, rowId: req.params.rowId,
cookie: req,
}); });
res.json({ msg: 'success' }); res.json({ msg: 'success' });

2
packages/nocodb/src/lib/meta/api/sync/helpers/job.ts

@ -198,7 +198,7 @@ export default async (
multiCollaborator: UITypes.Collaborator, multiCollaborator: UITypes.Collaborator,
date: UITypes.Date, date: UITypes.Date,
phone: UITypes.PhoneNumber, phone: UITypes.PhoneNumber,
number: UITypes.Number, number: UITypes.Decimal,
rating: UITypes.Rating, rating: UITypes.Rating,
formula: UITypes.Formula, formula: UITypes.Formula,
rollup: UITypes.Rollup, rollup: UITypes.Rollup,

12
packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts

@ -6,7 +6,8 @@ import * as nc_015_add_meta_col_in_column_table from './v2/nc_015_add_meta_col_i
import * as nc_016_alter_hooklog_payload_types from './v2/nc_016_alter_hooklog_payload_types'; import * as nc_016_alter_hooklog_payload_types from './v2/nc_016_alter_hooklog_payload_types';
import * as nc_017_add_user_token_version_column from './v2/nc_017_add_user_token_version_column'; import * as nc_017_add_user_token_version_column from './v2/nc_017_add_user_token_version_column';
import * as nc_018_add_meta_in_view from './v2/nc_018_add_meta_in_view'; import * as nc_018_add_meta_in_view from './v2/nc_018_add_meta_in_view';
import * as nc_019_add_kanban_meta_col from './v2/nc_019_add_kanban_meta_col'; import * as nc_019_add_meta_in_meta_tables from './v2/nc_019_add_meta_in_meta_tables';
import * as nc_020_add_kanban_meta_col from './v2/nc_019_add_kanban_meta_col';
// Create a custom migration source class // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -24,7 +25,8 @@ export default class XcMigrationSourcev2 {
'nc_016_alter_hooklog_payload_types', 'nc_016_alter_hooklog_payload_types',
'nc_017_add_user_token_version_column', 'nc_017_add_user_token_version_column',
'nc_018_add_meta_in_view', 'nc_018_add_meta_in_view',
'nc_019_add_kanban_meta_col', 'nc_019_add_meta_in_meta_tables',
'nc_020_add_kanban_meta_col',
]); ]);
} }
@ -50,8 +52,10 @@ export default class XcMigrationSourcev2 {
return nc_017_add_user_token_version_column; return nc_017_add_user_token_version_column;
case 'nc_018_add_meta_in_view': case 'nc_018_add_meta_in_view':
return nc_018_add_meta_in_view; return nc_018_add_meta_in_view;
case 'nc_019_add_kanban_meta_col': case 'nc_019_add_meta_in_meta_tables':
return nc_019_add_kanban_meta_col; return nc_019_add_meta_in_meta_tables;
case 'nc_020_add_kanban_meta_col':
return nc_020_add_kanban_meta_col;
} }
} }
} }

37
packages/nocodb/src/lib/migrations/v2/nc_011.ts

@ -1,4 +1,4 @@
import { MetaTable } from '../../utils/globals'; import { MetaTable, orderedMetaTables } from '../../utils/globals';
// import googleAuth from '../plugins/googleAuth'; // import googleAuth from '../plugins/googleAuth';
// import ses from '../plugins/ses'; // import ses from '../plugins/ses';
// import cache from '../plugins/cache'; // import cache from '../plugins/cache';
@ -806,38 +806,9 @@ const up = async (knex) => {
}; };
const down = async (knex) => { const down = async (knex) => {
await knex.schema.dropTable(MetaTable.MODEL_ROLE_VISIBILITY); for (const tableName of orderedMetaTables) {
await knex.schema.dropTable(MetaTable.PLUGIN); await knex.schema.dropTable(tableName);
await knex.schema.dropTable(MetaTable.AUDIT); }
await knex.schema.dropTable(MetaTable.TEAM_USERS);
await knex.schema.dropTable(MetaTable.TEAMS);
await knex.schema.dropTable(MetaTable.ORGS);
await knex.schema.dropTable(MetaTable.PROJECT_USERS);
await knex.schema.dropTable(MetaTable.USERS);
await knex.schema.dropTable(MetaTable.KANBAN_VIEW_COLUMNS);
await knex.schema.dropTable(MetaTable.KANBAN_VIEW);
await knex.schema.dropTable(MetaTable.GRID_VIEW_COLUMNS);
await knex.schema.dropTable(MetaTable.GRID_VIEW);
await knex.schema.dropTable(MetaTable.GALLERY_VIEW_COLUMNS);
await knex.schema.dropTable(MetaTable.GALLERY_VIEW);
await knex.schema.dropTable(MetaTable.FORM_VIEW_COLUMNS);
await knex.schema.dropTable(MetaTable.FORM_VIEW);
await knex.schema.dropTable(MetaTable.SHARED_VIEWS);
await knex.schema.dropTable(MetaTable.SORT);
await knex.schema.dropTable(MetaTable.FILTER_EXP);
await knex.schema.dropTable(MetaTable.HOOK_LOGS);
await knex.schema.dropTable(MetaTable.HOOKS);
await knex.schema.dropTable(MetaTable.VIEWS);
await knex.schema.dropTable(MetaTable.COL_FORMULA);
await knex.schema.dropTable(MetaTable.COL_ROLLUP);
await knex.schema.dropTable(MetaTable.COL_LOOKUP);
await knex.schema.dropTable(MetaTable.COL_SELECT_OPTIONS);
await knex.schema.dropTable(MetaTable.COL_RELATIONS);
await knex.schema.dropTable(MetaTable.COLUMN_VALIDATIONS);
await knex.schema.dropTable(MetaTable.COLUMNS);
await knex.schema.dropTable(MetaTable.MODELS);
await knex.schema.dropTable(MetaTable.BASES);
await knex.schema.dropTable(MetaTable.PROJECT);
}; };
export { up, down }; export { up, down };

34
packages/nocodb/src/lib/migrations/v2/nc_019_add_meta_in_meta_tables.ts

@ -0,0 +1,34 @@
import Knex from 'knex';
import { MetaTable } from '../../utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.FORM_VIEW, (table) => {
table.text('meta');
});
await knex.schema.alterTable(MetaTable.FORM_VIEW_COLUMNS, (table) => {
table.text('meta');
});
await knex.schema.alterTable(MetaTable.GRID_VIEW, (table) => {
table.text('meta');
});
await knex.schema.alterTable(MetaTable.GALLERY_VIEW, (table) => {
table.text('meta');
});
};
const down = async (knex) => {
await knex.schema.alterTable(MetaTable.FORM_VIEW, (table) => {
table.dropColumns('meta');
});
await knex.schema.alterTable(MetaTable.FORM_VIEW_COLUMNS, (table) => {
table.dropColumns('meta');
});
await knex.schema.alterTable(MetaTable.GRID_VIEW, (table) => {
table.dropColumns('meta');
});
await knex.schema.alterTable(MetaTable.GALLERY_VIEW, (table) => {
table.dropColumns('meta');
});
};
export { up, down };

0
packages/nocodb/src/lib/migrations/v2/nc_019_add_kanban_meta_col.ts → packages/nocodb/src/lib/migrations/v2/nc_020_add_kanban_meta_col.ts

10
packages/nocodb/src/lib/models/FormView.ts

@ -1,6 +1,7 @@
import Noco from '../Noco'; import Noco from '../Noco';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals'; import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import { FormType } from 'nocodb-sdk'; import { FormType } from 'nocodb-sdk';
import { deserializeJSON, serializeJSON } from '../utils/serialize';
import FormViewColumn from './FormViewColumn'; import FormViewColumn from './FormViewColumn';
import View from './View'; import View from './View';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
@ -26,6 +27,7 @@ export default class FormView implements FormType {
project_id?: string; project_id?: string;
base_id?: string; base_id?: string;
meta?: string | Record<string, any>;
constructor(data: FormView) { constructor(data: FormView) {
Object.assign(this, data); Object.assign(this, data);
@ -42,7 +44,10 @@ export default class FormView implements FormType {
view = await ncMeta.metaGet2(null, null, MetaTable.FORM_VIEW, { view = await ncMeta.metaGet2(null, null, MetaTable.FORM_VIEW, {
fk_view_id: viewId, fk_view_id: viewId,
}); });
await NocoCache.set(`${CacheScope.FORM_VIEW}:${viewId}`, view); if (view) {
view.meta = deserializeJSON(view.meta);
await NocoCache.set(`${CacheScope.FORM_VIEW}:${viewId}`, view);
}
} }
return view && new FormView(view); return view && new FormView(view);
} }
@ -62,6 +67,7 @@ export default class FormView implements FormType {
logo_url: view.logo_url, logo_url: view.logo_url,
submit_another_form: view.submit_another_form, submit_another_form: view.submit_another_form,
show_blank_form: view.show_blank_form, show_blank_form: view.show_blank_form,
meta: serializeJSON(view.meta),
}; };
if (!(view.project_id && view.base_id)) { if (!(view.project_id && view.base_id)) {
const viewRef = await View.get(view.fk_view_id); const viewRef = await View.get(view.fk_view_id);
@ -92,8 +98,10 @@ export default class FormView implements FormType {
o.logo_url = body.logo_url; o.logo_url = body.logo_url;
o.submit_another_form = body.submit_another_form; o.submit_another_form = body.submit_another_form;
o.show_blank_form = body.show_blank_form; o.show_blank_form = body.show_blank_form;
o.meta = body.meta;
// set cache // set cache
await NocoCache.set(key, o); await NocoCache.set(key, o);
o.meta = serializeJSON(body.meta);
} }
// update meta // update meta
return await ncMeta.metaUpdate( return await ncMeta.metaUpdate(

45
packages/nocodb/src/lib/models/FormViewColumn.ts

@ -1,6 +1,7 @@
import Noco from '../Noco'; import Noco from '../Noco';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals'; import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import { FormColumnType } from 'nocodb-sdk'; import { FormColumnType } from 'nocodb-sdk';
import { deserializeJSON, serializeJSON } from '../utils/serialize';
import View from './View'; import View from './View';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
import { extractProps } from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
@ -18,6 +19,7 @@ export default class FormViewColumn implements FormColumnType {
fk_column_id?: string; fk_column_id?: string;
project_id?: string; project_id?: string;
base_id?: string; base_id?: string;
meta?: string | Record<string, any>;
constructor(data: FormViewColumn) { constructor(data: FormViewColumn) {
Object.assign(this, data); Object.assign(this, data);
@ -25,28 +27,35 @@ export default class FormViewColumn implements FormColumnType {
uuid?: any; uuid?: any;
public static async get(formViewId: string, ncMeta = Noco.ncMeta) { public static async get(formViewColumnId: string, ncMeta = Noco.ncMeta) {
let view = let viewColumn =
formViewId && formViewColumnId &&
(await NocoCache.get( (await NocoCache.get(
`${CacheScope.FORM_VIEW_COLUMN}:${formViewId}`, `${CacheScope.FORM_VIEW_COLUMN}:${formViewColumnId}`,
CacheGetType.TYPE_OBJECT CacheGetType.TYPE_OBJECT
)); ));
if (!view) { if (!viewColumn) {
view = await ncMeta.metaGet2( viewColumn = await ncMeta.metaGet2(
null, null,
null, null,
MetaTable.FORM_VIEW_COLUMNS, MetaTable.FORM_VIEW_COLUMNS,
formViewId formViewColumnId
); );
viewColumn.meta =
viewColumn.meta && typeof viewColumn.meta === 'string'
? JSON.parse(viewColumn.meta)
: viewColumn.meta;
} }
await NocoCache.set(`${CacheScope.FORM_VIEW_COLUMN}:${formViewId}`, view); await NocoCache.set(
`${CacheScope.FORM_VIEW_COLUMN}:${formViewColumnId}`,
viewColumn
);
return view && new FormViewColumn(view); return viewColumn && new FormViewColumn(viewColumn);
} }
static async insert(column: Partial<FormViewColumn>, ncMeta = Noco.ncMeta) { static async insert(column: Partial<FormViewColumn>, ncMeta = Noco.ncMeta) {
const insertObj = { const insertObj: Partial<FormViewColumn> = {
fk_view_id: column.fk_view_id, fk_view_id: column.fk_view_id,
fk_column_id: column.fk_column_id, fk_column_id: column.fk_column_id,
order: await ncMeta.metaGetNextOrder(MetaTable.FORM_VIEW_COLUMNS, { order: await ncMeta.metaGetNextOrder(MetaTable.FORM_VIEW_COLUMNS, {
@ -61,6 +70,10 @@ export default class FormViewColumn implements FormColumnType {
required: column.required, required: column.required,
}; };
if (column.meta) {
insertObj.meta = serializeJSON(column.meta);
}
if (!(column.project_id && column.base_id)) { if (!(column.project_id && column.base_id)) {
const viewRef = await View.get(column.fk_view_id, ncMeta); const viewRef = await View.get(column.fk_view_id, ncMeta);
insertObj.project_id = viewRef.project_id; insertObj.project_id = viewRef.project_id;
@ -113,6 +126,11 @@ export default class FormViewColumn implements FormColumnType {
}, },
} }
); );
for (const viewColumn of viewColumns) {
viewColumn.meta = deserializeJSON(viewColumn.meta);
}
await NocoCache.setList( await NocoCache.setList(
CacheScope.FORM_VIEW_COLUMN, CacheScope.FORM_VIEW_COLUMN,
[viewId], [viewId],
@ -139,7 +157,9 @@ export default class FormViewColumn implements FormColumnType {
'required', 'required',
'show', 'show',
'order', 'order',
'meta',
]); ]);
// get existing cache // get existing cache
const key = `${CacheScope.FORM_VIEW_COLUMN}:${columnId}`; const key = `${CacheScope.FORM_VIEW_COLUMN}:${columnId}`;
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT); const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
@ -148,6 +168,11 @@ export default class FormViewColumn implements FormColumnType {
// set cache // set cache
await NocoCache.set(key, o); await NocoCache.set(key, o);
} }
if (insertObj.meta) {
insertObj.meta = serializeJSON(insertObj.meta);
}
// update meta // update meta
await ncMeta.metaUpdate( await ncMeta.metaUpdate(
null, null,

12
packages/nocodb/src/lib/models/Model.ts

@ -628,11 +628,13 @@ export default class Model implements TableType {
], ],
} }
); );
await NocoCache.set( if (model) {
`${CacheScope.MODEL}:${project_id}:${aliasOrId}`, await NocoCache.set(
model.id `${CacheScope.MODEL}:${project_id}:${aliasOrId}`,
); model.id
await NocoCache.set(`${CacheScope.MODEL}:${model.id}`, model); );
await NocoCache.set(`${CacheScope.MODEL}:${model.id}`, model);
}
return model && new Model(model); return model && new Model(model);
} }
return modelId && this.get(modelId); return modelId && this.get(modelId);

2
packages/nocodb/src/lib/models/Project.ts

@ -173,6 +173,7 @@ export default class Project implements ProjectType {
return null; return null;
} }
// Todo: Remove the project entry from the connection pool in NcConnectionMgrv2
// @ts-ignore // @ts-ignore
static async softDelete( static async softDelete(
projectId: string, projectId: string,
@ -273,6 +274,7 @@ export default class Project implements ProjectType {
); );
} }
// Todo: Remove the project entry from the connection pool in NcConnectionMgrv2
static async delete(projectId, ncMeta = Noco.ncMeta): Promise<any> { static async delete(projectId, ncMeta = Noco.ncMeta): Promise<any> {
const bases = await Base.list({ projectId }); const bases = await Base.list({ projectId });
for (const base of bases) { for (const base of bases) {

9
packages/nocodb/src/lib/utils/common/NcConnectionMgrv2.ts

@ -22,6 +22,15 @@ export default class NcConnectionMgrv2 {
// this.metaKnex = ncMeta; // this.metaKnex = ncMeta;
// } // }
public static async destroyAll() {
for (const projectId in this.connectionRefs) {
for (const baseId in this.connectionRefs[projectId]) {
await this.connectionRefs[projectId][baseId].destroy();
}
}
}
// Todo: Should await on connection destroy
public static delete(base: Base) { public static delete(base: Base) {
// todo: ignore meta projects // todo: ignore meta projects
if (this.connectionRefs?.[base.project_id]?.[base.id]) { if (this.connectionRefs?.[base.project_id]?.[base.id]) {

35
packages/nocodb/src/lib/utils/globals.ts

@ -39,6 +39,41 @@ export enum MetaTable {
SYNC_LOGS = 'nc_sync_logs_v2', SYNC_LOGS = 'nc_sync_logs_v2',
} }
export const orderedMetaTables = [
MetaTable.MODEL_ROLE_VISIBILITY,
MetaTable.PLUGIN,
MetaTable.AUDIT,
MetaTable.TEAM_USERS,
MetaTable.TEAMS,
MetaTable.ORGS,
MetaTable.PROJECT_USERS,
MetaTable.USERS,
MetaTable.KANBAN_VIEW_COLUMNS,
MetaTable.KANBAN_VIEW,
MetaTable.GRID_VIEW_COLUMNS,
MetaTable.GRID_VIEW,
MetaTable.GALLERY_VIEW_COLUMNS,
MetaTable.GALLERY_VIEW,
MetaTable.FORM_VIEW_COLUMNS,
MetaTable.FORM_VIEW,
MetaTable.SHARED_VIEWS,
MetaTable.SORT,
MetaTable.FILTER_EXP,
MetaTable.HOOK_LOGS,
MetaTable.HOOKS,
MetaTable.VIEWS,
MetaTable.COL_FORMULA,
MetaTable.COL_ROLLUP,
MetaTable.COL_LOOKUP,
MetaTable.COL_SELECT_OPTIONS,
MetaTable.COL_RELATIONS,
MetaTable.COLUMN_VALIDATIONS,
MetaTable.COLUMNS,
MetaTable.MODELS,
MetaTable.BASES,
MetaTable.PROJECT,
];
export enum CacheScope { export enum CacheScope {
PROJECT = 'project', PROJECT = 'project',
BASE = 'base', BASE = 'base',

19
packages/nocodb/src/lib/utils/serialize.ts

@ -0,0 +1,19 @@
export const serializeJSON = (data: string | Record<string, any>) => {
// if already in string format ignore stringify
if (typeof data === 'string') {
return data;
}
return JSON.stringify(data);
};
export const deserializeJSON = (data: string | Record<string, any>) => {
// if already in object format ignore parse
if (typeof data === 'object') {
return data ?? {};
}
try {
return JSON.parse(data) ?? {};
} catch (e) {
return {};
}
};

658
packages/nocodb/tests/mysql-sakila-db/03-test-sakila-schema.sql

@ -0,0 +1,658 @@
-- Sakila Sample Database Schema
-- Version 1.2
-- Copyright (c) 2006, 2019, Oracle and/or its affiliates.
-- Redistribution and use in source and binary forms, with or without
-- modification, are permitted provided that the following conditions are
-- met:
-- * Redistributions of source code must retain the above copyright notice,
-- this list of conditions and the following disclaimer.
-- * Redistributions in binary form must reproduce the above copyright
-- notice, this list of conditions and the following disclaimer in the
-- documentation and/or other materials provided with the distribution.
-- * Neither the name of Oracle nor the names of its contributors may be used
-- to endorse or promote products derived from this software without
-- specific prior written permission.
-- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
-- IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
-- THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-- PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
-- CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
-- EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
-- PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
-- PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
-- LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
-- NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-- SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
SET NAMES utf8mb4;
SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;
SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;
SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL';
DROP SCHEMA IF EXISTS test_sakila;
CREATE SCHEMA test_sakila;
USE test_sakila;
--
-- Table structure for table `actor`
--
CREATE TABLE actor (
actor_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
first_name VARCHAR(45) NOT NULL,
last_name VARCHAR(45) NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (actor_id),
KEY idx_actor_last_name (last_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `address`
--
CREATE TABLE address (
address_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
address VARCHAR(50) NOT NULL,
address2 VARCHAR(50) DEFAULT NULL,
district VARCHAR(20) NOT NULL,
city_id SMALLINT UNSIGNED NOT NULL,
postal_code VARCHAR(10) DEFAULT NULL,
phone VARCHAR(20) NOT NULL,
-- Add GEOMETRY column for MySQL 5.7.5 and higher
-- Also include SRID attribute for MySQL 8.0.3 and higher
/*!50705 location GEOMETRY */ /*!80003 SRID 0 */ /*!50705 NOT NULL,*/
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (address_id),
KEY idx_fk_city_id (city_id),
/*!50705 SPATIAL KEY `idx_location` (location),*/
CONSTRAINT `fk_address_city` FOREIGN KEY (city_id) REFERENCES city (city_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `category`
--
CREATE TABLE category (
category_id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(25) NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `city`
--
CREATE TABLE city (
city_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
city VARCHAR(50) NOT NULL,
country_id SMALLINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (city_id),
KEY idx_fk_country_id (country_id),
CONSTRAINT `fk_city_country` FOREIGN KEY (country_id) REFERENCES country (country_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `country`
--
CREATE TABLE country (
country_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
country VARCHAR(50) NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (country_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `customer`
--
CREATE TABLE customer (
customer_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
store_id TINYINT UNSIGNED NOT NULL,
first_name VARCHAR(45) NOT NULL,
last_name VARCHAR(45) NOT NULL,
email VARCHAR(50) DEFAULT NULL,
address_id SMALLINT UNSIGNED NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
create_date DATETIME NOT NULL,
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (customer_id),
KEY idx_fk_store_id (store_id),
KEY idx_fk_address_id (address_id),
KEY idx_last_name (last_name),
CONSTRAINT fk_customer_address FOREIGN KEY (address_id) REFERENCES address (address_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_customer_store FOREIGN KEY (store_id) REFERENCES store (store_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `film`
--
CREATE TABLE film (
film_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(128) NOT NULL,
description TEXT DEFAULT NULL,
release_year YEAR DEFAULT NULL,
language_id TINYINT UNSIGNED NOT NULL,
original_language_id TINYINT UNSIGNED DEFAULT NULL,
rental_duration TINYINT UNSIGNED NOT NULL DEFAULT 3,
rental_rate DECIMAL(4,2) NOT NULL DEFAULT 4.99,
length SMALLINT UNSIGNED DEFAULT NULL,
replacement_cost DECIMAL(5,2) NOT NULL DEFAULT 19.99,
rating ENUM('G','PG','PG-13','R','NC-17') DEFAULT 'G',
special_features SET('Trailers','Commentaries','Deleted Scenes','Behind the Scenes') DEFAULT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (film_id),
KEY idx_title (title),
KEY idx_fk_language_id (language_id),
KEY idx_fk_original_language_id (original_language_id),
CONSTRAINT fk_film_language FOREIGN KEY (language_id) REFERENCES language (language_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_film_language_original FOREIGN KEY (original_language_id) REFERENCES language (language_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `film_actor`
--
CREATE TABLE film_actor (
actor_id SMALLINT UNSIGNED NOT NULL,
film_id SMALLINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (actor_id,film_id),
KEY idx_fk_film_id (`film_id`),
CONSTRAINT fk_film_actor_actor FOREIGN KEY (actor_id) REFERENCES actor (actor_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_film_actor_film FOREIGN KEY (film_id) REFERENCES film (film_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `film_category`
--
CREATE TABLE film_category (
film_id SMALLINT UNSIGNED NOT NULL,
category_id TINYINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (film_id, category_id),
CONSTRAINT fk_film_category_film FOREIGN KEY (film_id) REFERENCES film (film_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_film_category_category FOREIGN KEY (category_id) REFERENCES category (category_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `film_text`
--
-- InnoDB added FULLTEXT support in 5.6.10. If you use an
-- earlier version, then consider upgrading (recommended) or
-- changing InnoDB to MyISAM as the film_text engine
--
-- Use InnoDB for film_text as of 5.6.10, MyISAM prior to 5.6.10.
SET @old_default_storage_engine = @@default_storage_engine;
SET @@default_storage_engine = 'MyISAM';
/*!50610 SET @@default_storage_engine = 'InnoDB'*/;
CREATE TABLE film_text (
film_id SMALLINT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
PRIMARY KEY (film_id),
FULLTEXT KEY idx_title_description (title,description)
) DEFAULT CHARSET=utf8mb4;
SET @@default_storage_engine = @old_default_storage_engine;
--
-- Triggers for loading film_text from film
--
CREATE TRIGGER `ins_film` AFTER INSERT ON `film` FOR EACH ROW BEGIN
INSERT INTO film_text (film_id, title, description)
VALUES (new.film_id, new.title, new.description);
END;
CREATE TRIGGER `upd_film` AFTER UPDATE ON `film` FOR EACH ROW BEGIN
IF (old.title != new.title) OR (old.description != new.description) OR (old.film_id != new.film_id)
THEN
UPDATE film_text
SET title=new.title,
description=new.description,
film_id=new.film_id
WHERE film_id=old.film_id;
END IF;
END;
CREATE TRIGGER `del_film` AFTER DELETE ON `film` FOR EACH ROW BEGIN
DELETE FROM film_text WHERE film_id = old.film_id;
END;
--
-- Table structure for table `inventory`
--
CREATE TABLE inventory (
inventory_id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT,
film_id SMALLINT UNSIGNED NOT NULL,
store_id TINYINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (inventory_id),
KEY idx_fk_film_id (film_id),
KEY idx_store_id_film_id (store_id,film_id),
CONSTRAINT fk_inventory_store FOREIGN KEY (store_id) REFERENCES store (store_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_inventory_film FOREIGN KEY (film_id) REFERENCES film (film_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `language`
--
CREATE TABLE language (
language_id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
name CHAR(20) NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (language_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `payment`
--
CREATE TABLE payment (
payment_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_id SMALLINT UNSIGNED NOT NULL,
staff_id TINYINT UNSIGNED NOT NULL,
rental_id INT DEFAULT NULL,
amount DECIMAL(5,2) NOT NULL,
payment_date DATETIME NOT NULL,
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (payment_id),
KEY idx_fk_staff_id (staff_id),
KEY idx_fk_customer_id (customer_id),
CONSTRAINT fk_payment_rental FOREIGN KEY (rental_id) REFERENCES rental (rental_id) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT fk_payment_customer FOREIGN KEY (customer_id) REFERENCES customer (customer_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_payment_staff FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `rental`
--
CREATE TABLE rental (
rental_id INT NOT NULL AUTO_INCREMENT,
rental_date DATETIME NOT NULL,
inventory_id MEDIUMINT UNSIGNED NOT NULL,
customer_id SMALLINT UNSIGNED NOT NULL,
return_date DATETIME DEFAULT NULL,
staff_id TINYINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (rental_id),
UNIQUE KEY (rental_date,inventory_id,customer_id),
KEY idx_fk_inventory_id (inventory_id),
KEY idx_fk_customer_id (customer_id),
KEY idx_fk_staff_id (staff_id),
CONSTRAINT fk_rental_staff FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_rental_inventory FOREIGN KEY (inventory_id) REFERENCES inventory (inventory_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_rental_customer FOREIGN KEY (customer_id) REFERENCES customer (customer_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `staff`
--
CREATE TABLE staff (
staff_id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
first_name VARCHAR(45) NOT NULL,
last_name VARCHAR(45) NOT NULL,
address_id SMALLINT UNSIGNED NOT NULL,
picture BLOB DEFAULT NULL,
email VARCHAR(50) DEFAULT NULL,
store_id TINYINT UNSIGNED NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
username VARCHAR(16) NOT NULL,
password VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (staff_id),
KEY idx_fk_store_id (store_id),
KEY idx_fk_address_id (address_id),
CONSTRAINT fk_staff_store FOREIGN KEY (store_id) REFERENCES store (store_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_staff_address FOREIGN KEY (address_id) REFERENCES address (address_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `store`
--
CREATE TABLE store (
store_id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
manager_staff_id TINYINT UNSIGNED NOT NULL,
address_id SMALLINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (store_id),
UNIQUE KEY idx_unique_manager (manager_staff_id),
KEY idx_fk_address_id (address_id),
CONSTRAINT fk_store_staff FOREIGN KEY (manager_staff_id) REFERENCES staff (staff_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_store_address FOREIGN KEY (address_id) REFERENCES address (address_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- View structure for view `customer_list`
--
CREATE VIEW customer_list
AS
SELECT cu.customer_id AS ID, CONCAT(cu.first_name, _utf8mb4' ', cu.last_name) AS name, a.address AS address, a.postal_code AS `zip code`,
a.phone AS phone, city.city AS city, country.country AS country, IF(cu.active, _utf8mb4'active',_utf8mb4'') AS notes, cu.store_id AS SID
FROM customer AS cu JOIN address AS a ON cu.address_id = a.address_id JOIN city ON a.city_id = city.city_id
JOIN country ON city.country_id = country.country_id;
--
-- View structure for view `film_list`
--
CREATE VIEW film_list
AS
SELECT film.film_id AS FID, film.title AS title, film.description AS description, category.name AS category, film.rental_rate AS price,
film.length AS length, film.rating AS rating, GROUP_CONCAT(CONCAT(actor.first_name, _utf8mb4' ', actor.last_name) SEPARATOR ', ') AS actors
FROM category LEFT JOIN film_category ON category.category_id = film_category.category_id LEFT JOIN film ON film_category.film_id = film.film_id
JOIN film_actor ON film.film_id = film_actor.film_id
JOIN actor ON film_actor.actor_id = actor.actor_id
GROUP BY film.film_id, category.name;
--
-- View structure for view `nicer_but_slower_film_list`
--
CREATE VIEW nicer_but_slower_film_list
AS
SELECT film.film_id AS FID, film.title AS title, film.description AS description, category.name AS category, film.rental_rate AS price,
film.length AS length, film.rating AS rating, GROUP_CONCAT(CONCAT(CONCAT(UCASE(SUBSTR(actor.first_name,1,1)),
LCASE(SUBSTR(actor.first_name,2,LENGTH(actor.first_name))),_utf8mb4' ',CONCAT(UCASE(SUBSTR(actor.last_name,1,1)),
LCASE(SUBSTR(actor.last_name,2,LENGTH(actor.last_name)))))) SEPARATOR ', ') AS actors
FROM category LEFT JOIN film_category ON category.category_id = film_category.category_id LEFT JOIN film ON film_category.film_id = film.film_id
JOIN film_actor ON film.film_id = film_actor.film_id
JOIN actor ON film_actor.actor_id = actor.actor_id
GROUP BY film.film_id, category.name;
--
-- View structure for view `staff_list`
--
CREATE VIEW staff_list
AS
SELECT s.staff_id AS ID, CONCAT(s.first_name, _utf8mb4' ', s.last_name) AS name, a.address AS address, a.postal_code AS `zip code`, a.phone AS phone,
city.city AS city, country.country AS country, s.store_id AS SID
FROM staff AS s JOIN address AS a ON s.address_id = a.address_id JOIN city ON a.city_id = city.city_id
JOIN country ON city.country_id = country.country_id;
--
-- View structure for view `sales_by_store`
--
CREATE VIEW sales_by_store
AS
SELECT
CONCAT(c.city, _utf8mb4',', cy.country) AS store
, CONCAT(m.first_name, _utf8mb4' ', m.last_name) AS manager
, SUM(p.amount) AS total_sales
FROM payment AS p
INNER JOIN rental AS r ON p.rental_id = r.rental_id
INNER JOIN inventory AS i ON r.inventory_id = i.inventory_id
INNER JOIN store AS s ON i.store_id = s.store_id
INNER JOIN address AS a ON s.address_id = a.address_id
INNER JOIN city AS c ON a.city_id = c.city_id
INNER JOIN country AS cy ON c.country_id = cy.country_id
INNER JOIN staff AS m ON s.manager_staff_id = m.staff_id
GROUP BY s.store_id
ORDER BY cy.country, c.city;
--
-- View structure for view `sales_by_film_category`
--
-- Note that total sales will add up to >100% because
-- some titles belong to more than 1 category
--
CREATE VIEW sales_by_film_category
AS
SELECT
c.name AS category
, SUM(p.amount) AS total_sales
FROM payment AS p
INNER JOIN rental AS r ON p.rental_id = r.rental_id
INNER JOIN inventory AS i ON r.inventory_id = i.inventory_id
INNER JOIN film AS f ON i.film_id = f.film_id
INNER JOIN film_category AS fc ON f.film_id = fc.film_id
INNER JOIN category AS c ON fc.category_id = c.category_id
GROUP BY c.name
ORDER BY total_sales DESC;
--
-- View structure for view `actor_info`
--
CREATE DEFINER=CURRENT_USER SQL SECURITY INVOKER VIEW actor_info
AS
SELECT
a.actor_id,
a.first_name,
a.last_name,
GROUP_CONCAT(DISTINCT CONCAT(c.name, ': ',
(SELECT GROUP_CONCAT(f.title ORDER BY f.title SEPARATOR ', ')
FROM test_sakila.film f
INNER JOIN test_sakila.film_category fc
ON f.film_id = fc.film_id
INNER JOIN test_sakila.film_actor fa
ON f.film_id = fa.film_id
WHERE fc.category_id = c.category_id
AND fa.actor_id = a.actor_id
)
)
ORDER BY c.name SEPARATOR '; ')
AS film_info
FROM test_sakila.actor a
LEFT JOIN test_sakila.film_actor fa
ON a.actor_id = fa.actor_id
LEFT JOIN test_sakila.film_category fc
ON fa.film_id = fc.film_id
LEFT JOIN test_sakila.category c
ON fc.category_id = c.category_id
GROUP BY a.actor_id, a.first_name, a.last_name;
--
-- Procedure structure for procedure `rewards_report`
--
CREATE PROCEDURE rewards_report (
IN min_monthly_purchases TINYINT UNSIGNED
, IN min_dollar_amount_purchased DECIMAL(10,2)
, OUT count_rewardees INT
)
LANGUAGE SQL
NOT DETERMINISTIC
READS SQL DATA
SQL SECURITY DEFINER
COMMENT 'Provides a customizable report on best customers'
proc: BEGIN
DECLARE last_month_start DATE;
DECLARE last_month_end DATE;
/* Some sanity checks... */
IF min_monthly_purchases = 0 THEN
SELECT 'Minimum monthly purchases parameter must be > 0';
LEAVE proc;
END IF;
IF min_dollar_amount_purchased = 0.00 THEN
SELECT 'Minimum monthly dollar amount purchased parameter must be > $0.00';
LEAVE proc;
END IF;
/* Determine start and end time periods */
SET last_month_start = DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH);
SET last_month_start = STR_TO_DATE(CONCAT(YEAR(last_month_start),'-',MONTH(last_month_start),'-01'),'%Y-%m-%d');
SET last_month_end = LAST_DAY(last_month_start);
/*
Create a temporary storage area for
Customer IDs.
*/
CREATE TEMPORARY TABLE tmpCustomer (customer_id SMALLINT UNSIGNED NOT NULL PRIMARY KEY);
/*
Find all customers meeting the
monthly purchase requirements
*/
INSERT INTO tmpCustomer (customer_id)
SELECT p.customer_id
FROM payment AS p
WHERE DATE(p.payment_date) BETWEEN last_month_start AND last_month_end
GROUP BY customer_id
HAVING SUM(p.amount) > min_dollar_amount_purchased
AND COUNT(customer_id) > min_monthly_purchases;
/* Populate OUT parameter with count of found customers */
SELECT COUNT(*) FROM tmpCustomer INTO count_rewardees;
/*
Output ALL customer information of matching rewardees.
Customize output as needed.
*/
SELECT c.*
FROM tmpCustomer AS t
INNER JOIN customer AS c ON t.customer_id = c.customer_id;
/* Clean up */
DROP TABLE tmpCustomer;
END;
CREATE FUNCTION IF NOT EXISTS get_customer_balance(p_customer_id INT, p_effective_date DATETIME) RETURNS DECIMAL(5,2)
DETERMINISTIC
READS SQL DATA
BEGIN
#OK, WE NEED TO CALCULATE THE CURRENT BALANCE GIVEN A CUSTOMER_ID AND A DATE
#THAT WE WANT THE BALANCE TO BE EFFECTIVE FOR. THE BALANCE IS:
# 1) RENTAL FEES FOR ALL PREVIOUS RENTALS
# 2) ONE DOLLAR FOR EVERY DAY THE PREVIOUS RENTALS ARE OVERDUE
# 3) IF A FILM IS MORE THAN RENTAL_DURATION * 2 OVERDUE, CHARGE THE REPLACEMENT_COST
# 4) SUBTRACT ALL PAYMENTS MADE BEFORE THE DATE SPECIFIED
DECLARE v_rentfees DECIMAL(5,2); #FEES PAID TO RENT THE VIDEOS INITIALLY
DECLARE v_overfees INTEGER; #LATE FEES FOR PRIOR RENTALS
DECLARE v_payments DECIMAL(5,2); #SUM OF PAYMENTS MADE PREVIOUSLY
SELECT IFNULL(SUM(film.rental_rate),0) INTO v_rentfees
FROM film, inventory, rental
WHERE film.film_id = inventory.film_id
AND inventory.inventory_id = rental.inventory_id
AND rental.rental_date <= p_effective_date
AND rental.customer_id = p_customer_id;
SELECT IFNULL(SUM(IF((TO_DAYS(rental.return_date) - TO_DAYS(rental.rental_date)) > film.rental_duration,
((TO_DAYS(rental.return_date) - TO_DAYS(rental.rental_date)) - film.rental_duration),0)),0) INTO v_overfees
FROM rental, inventory, film
WHERE film.film_id = inventory.film_id
AND inventory.inventory_id = rental.inventory_id
AND rental.rental_date <= p_effective_date
AND rental.customer_id = p_customer_id;
SELECT IFNULL(SUM(payment.amount),0) INTO v_payments
FROM payment
WHERE payment.payment_date <= p_effective_date
AND payment.customer_id = p_customer_id;
RETURN v_rentfees + v_overfees - v_payments;
END;
CREATE PROCEDURE film_in_stock(IN p_film_id INT, IN p_store_id INT, OUT p_film_count INT)
READS SQL DATA
BEGIN
SELECT inventory_id
FROM inventory
WHERE film_id = p_film_id
AND store_id = p_store_id
AND inventory_in_stock(inventory_id);
SELECT COUNT(*)
FROM inventory
WHERE film_id = p_film_id
AND store_id = p_store_id
AND inventory_in_stock(inventory_id)
INTO p_film_count;
END;
CREATE PROCEDURE film_not_in_stock(IN p_film_id INT, IN p_store_id INT, OUT p_film_count INT)
READS SQL DATA
BEGIN
SELECT inventory_id
FROM inventory
WHERE film_id = p_film_id
AND store_id = p_store_id
AND NOT inventory_in_stock(inventory_id);
SELECT COUNT(*)
FROM inventory
WHERE film_id = p_film_id
AND store_id = p_store_id
AND NOT inventory_in_stock(inventory_id)
INTO p_film_count;
END;
CREATE FUNCTION IF NOT EXISTS inventory_held_by_customer(p_inventory_id INT) RETURNS INT
READS SQL DATA
BEGIN
DECLARE v_customer_id INT;
DECLARE EXIT HANDLER FOR NOT FOUND RETURN NULL;
SELECT customer_id INTO v_customer_id
FROM rental
WHERE return_date IS NULL
AND inventory_id = p_inventory_id;
RETURN v_customer_id;
END;
CREATE FUNCTION IF NOT EXISTS inventory_in_stock(p_inventory_id INT) RETURNS BOOLEAN
READS SQL DATA
BEGIN
DECLARE v_rentals INT;
DECLARE v_out INT;
#AN ITEM IS IN-STOCK IF THERE ARE EITHER NO ROWS IN THE rental TABLE
#FOR THE ITEM OR ALL ROWS HAVE return_date POPULATED
SELECT COUNT(*) INTO v_rentals
FROM rental
WHERE inventory_id = p_inventory_id;
IF v_rentals = 0 THEN
RETURN TRUE;
END IF;
SELECT COUNT(rental_id) INTO v_out
FROM inventory LEFT JOIN rental USING(inventory_id)
WHERE inventory.inventory_id = p_inventory_id
AND rental.return_date IS NULL;
IF v_out > 0 THEN
RETURN FALSE;
ELSE
RETURN TRUE;
END IF;
END;
SET SQL_MODE=@OLD_SQL_MODE;
SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;
SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;

46449
packages/nocodb/tests/mysql-sakila-db/04-test-sakila-data.sql

File diff suppressed because one or more lines are too long

243
packages/nocodb/tests/unit/TestDbMngr.ts

@ -0,0 +1,243 @@
import { DbConfig } from "../../src/interface/config";
import { NcConfigFactory } from "../../src/lib";
import SqlMgrv2 from "../../src/lib/db/sql-mgr/v2/SqlMgrv2";
import fs from 'fs';
import knex from "knex";
import process from "process";
export default class TestDbMngr {
public static readonly dbName = 'test_meta';
public static readonly sakilaDbName = 'test_sakila';
public static metaKnex: knex;
public static sakilaKnex: knex;
public static defaultConnection = {
user: process.env['DB_USER'] || 'root',
password: process.env['DB_PASSWORD'] || 'password',
host: process.env['DB_HOST'] || 'localhost',
port: Number(process.env['DB_PORT']) || 3306,
client: 'mysql2',
}
public static dbConfig: DbConfig;
static async testConnection(config: DbConfig) {
try {
return await SqlMgrv2.testConnection(config);
} catch (e) {
console.log(e);
return { code: -1, message: 'Connection invalid' };
}
}
static async init() {
if(await TestDbMngr.isMysqlConfigured()){
await TestDbMngr.connectMysql();
} else {
await TestDbMngr.switchToSqlite();
}
}
static async isMysqlConfigured() {
const { user, password, host, port, client } = TestDbMngr.defaultConnection;
const config = NcConfigFactory.urlToDbConfig(`${client}://${user}:${password}@${host}:${port}`);
config.connection = {
user,
password,
host,
port,
}
const result = await TestDbMngr.testConnection(config);
return result.code !== -1;
}
static async connectMysql() {
const { user, password, host, port, client } = TestDbMngr.defaultConnection;
if(!process.env[`DATABASE_URL`]){
process.env[`DATABASE_URL`] = `${client}://${user}:${password}@${host}:${port}/${TestDbMngr.dbName}`;
}
TestDbMngr.dbConfig = NcConfigFactory.urlToDbConfig(
NcConfigFactory.extractXcUrlFromJdbc(process.env[`DATABASE_URL`])
);
this.dbConfig.meta = {
tn: 'nc_evolutions',
dbAlias: 'db',
api: {
type: 'rest',
prefix: '',
graphqlDepthLimit: 10,
},
inflection: {
tn: 'camelize',
cn: 'camelize',
},
}
await TestDbMngr.setupMeta();
await TestDbMngr.setupSakila();
}
static async setupMeta() {
if(TestDbMngr.metaKnex){
await TestDbMngr.metaKnex.destroy();
}
if(TestDbMngr.isSqlite()){
await TestDbMngr.resetMetaSqlite();
TestDbMngr.metaKnex = knex(TestDbMngr.getMetaDbConfig());
return
}
TestDbMngr.metaKnex = knex(TestDbMngr.getDbConfigWithNoDb());
await TestDbMngr.resetDatabase(TestDbMngr.metaKnex, TestDbMngr.dbName);
await TestDbMngr.metaKnex.destroy();
TestDbMngr.metaKnex = knex(TestDbMngr.getMetaDbConfig());
await TestDbMngr.useDatabase(TestDbMngr.metaKnex, TestDbMngr.dbName);
}
static async setupSakila () {
if(TestDbMngr.sakilaKnex) {
await TestDbMngr.sakilaKnex.destroy();
}
if(TestDbMngr.isSqlite()){
await TestDbMngr.seedSakila();
TestDbMngr.sakilaKnex = knex(TestDbMngr.getSakilaDbConfig());
return
}
TestDbMngr.sakilaKnex = knex(TestDbMngr.getDbConfigWithNoDb());
await TestDbMngr.resetDatabase(TestDbMngr.sakilaKnex, TestDbMngr.sakilaDbName);
await TestDbMngr.sakilaKnex.destroy();
TestDbMngr.sakilaKnex = knex(TestDbMngr.getSakilaDbConfig());
await TestDbMngr.useDatabase(TestDbMngr.sakilaKnex, TestDbMngr.sakilaDbName);
}
static async switchToSqlite() {
// process.env[`DATABASE_URL`] = `sqlite3:///?database=${__dirname}/${TestDbMngr.dbName}.sqlite`;
TestDbMngr.dbConfig = {
client: 'sqlite3',
connection: {
filename: `${__dirname}/${TestDbMngr.dbName}.db`,
database: TestDbMngr.dbName,
},
useNullAsDefault: true,
meta: {
tn: 'nc_evolutions',
dbAlias: 'db',
api: {
type: 'rest',
prefix: '',
graphqlDepthLimit: 10,
},
inflection: {
tn: 'camelize',
cn: 'camelize',
},
},
}
process.env[`NC_DB`] = `sqlite3:///?database=${__dirname}/${TestDbMngr.dbName}.db`;
await TestDbMngr.setupMeta();
await TestDbMngr.setupSakila();
}
private static async resetDatabase(knexClient, dbName) {
if(TestDbMngr.isSqlite()){
// return knexClient.raw(`DELETE FROM sqlite_sequence`);
} else {
try {
await knexClient.raw(`DROP DATABASE ${dbName}`);
} catch(e) {}
await knexClient.raw(`CREATE DATABASE ${dbName}`);
console.log(`Database ${dbName} created`);
await knexClient.raw(`USE ${dbName}`);
}
}
static isSqlite() {
return TestDbMngr.dbConfig.client === 'sqlite3';
}
private static async useDatabase(knexClient, dbName) {
if(!TestDbMngr.isSqlite()){
await knexClient.raw(`USE ${dbName}`);
}
}
static getDbConfigWithNoDb() {
const dbConfig =JSON.parse(JSON.stringify(TestDbMngr.dbConfig));
delete dbConfig.connection.database;
return dbConfig;
}
static getMetaDbConfig() {
return TestDbMngr.dbConfig;
}
private static resetMetaSqlite() {
if(fs.existsSync(`${__dirname}/test_meta.db`)){
fs.unlinkSync(`${__dirname}/test_meta.db`);
}
}
static getSakilaDbConfig() {
const sakilaDbConfig = JSON.parse(JSON.stringify(TestDbMngr.dbConfig));
sakilaDbConfig.connection.database = TestDbMngr.sakilaDbName;
sakilaDbConfig.connection.multipleStatements = true
if(TestDbMngr.isSqlite()){
sakilaDbConfig.connection.filename = `${__dirname}/test_sakila.db`;
}
return sakilaDbConfig;
}
static async seedSakila() {
const testsDir = __dirname.replace('tests/unit', 'tests');
if(TestDbMngr.isSqlite()){
if(fs.existsSync(`${__dirname}/test_sakila.db`)){
fs.unlinkSync(`${__dirname}/test_sakila.db`);
}
fs.copyFileSync(`${testsDir}/sqlite-sakila-db/sakila.db`, `${__dirname}/test_sakila.db`);
} else {
const schemaFile = fs.readFileSync(`${testsDir}/mysql-sakila-db/03-test-sakila-schema.sql`).toString();
const dataFile = fs.readFileSync(`${testsDir}/mysql-sakila-db/04-test-sakila-data.sql`).toString();
await TestDbMngr.sakilaKnex.raw(schemaFile);
await TestDbMngr.sakilaKnex.raw(dataFile);
}
}
static async disableForeignKeyChecks(knexClient) {
if(TestDbMngr.isSqlite()){
await knexClient.raw("PRAGMA foreign_keys = OFF");
}
else {
await knexClient.raw(`SET FOREIGN_KEY_CHECKS = 0`);
}
}
static async enableForeignKeyChecks(knexClient) {
if(TestDbMngr.isSqlite()){
await knexClient.raw(`PRAGMA foreign_keys = ON;`);
}
else {
await knexClient.raw(`SET FOREIGN_KEY_CHECKS = 1`);
}
}
static async showAllTables(knexClient) {
if(TestDbMngr.isSqlite()){
const tables = await knexClient.raw(`SELECT name FROM sqlite_master WHERE type='table'`);
return tables.filter(t => t.name !== 'sqlite_sequence' && t.name !== '_evolutions').map(t => t.name);
}
else {
const response = await knexClient.raw(`SHOW TABLES`);
return response[0].map(
(table) => Object.values(table)[0]
);
}
}
}

203
packages/nocodb/tests/unit/factory/column.ts

@ -0,0 +1,203 @@
import { UITypes } from 'nocodb-sdk';
import request from 'supertest';
import Column from '../../../src/lib/models/Column';
import FormViewColumn from '../../../src/lib/models/FormViewColumn';
import GalleryViewColumn from '../../../src/lib/models/GalleryViewColumn';
import GridViewColumn from '../../../src/lib/models/GridViewColumn';
import Model from '../../../src/lib/models/Model';
import Project from '../../../src/lib/models/Project';
import View from '../../../src/lib/models/View';
import { isSqlite } from '../init/db';
const defaultColumns = function(context) {
return [
{
column_name: 'id',
title: 'Id',
uidt: 'ID',
},
{
column_name: 'title',
title: 'Title',
uidt: 'SingleLineText',
},
{
cdf: 'CURRENT_TIMESTAMP',
column_name: 'created_at',
title: 'CreatedAt',
dtxp: '',
dtxs: '',
uidt: 'DateTime',
},
{
cdf: isSqlite(context) ? 'CURRENT_TIMESTAMP': 'CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP',
column_name: 'updated_at',
title: 'UpdatedAt',
dtxp: '',
dtxs: '',
uidt: 'DateTime',
},
]
};
const createColumn = async (context, table, columnAttr) => {
await request(context.app)
.post(`/api/v1/db/meta/tables/${table.id}/columns`)
.set('xc-auth', context.token)
.send({
...columnAttr,
});
const column: Column = (await table.getColumns()).find(
(column) => column.title === columnAttr.title
);
return column;
};
const createRollupColumn = async (
context,
{
project,
title,
rollupFunction,
table,
relatedTableName,
relatedTableColumnTitle,
}: {
project: Project;
title: string;
rollupFunction: string;
table: Model;
relatedTableName: string;
relatedTableColumnTitle: string;
}
) => {
const childBases = await project.getBases();
const childTable = await Model.getByIdOrName({
project_id: project.id,
base_id: childBases[0].id!,
table_name: relatedTableName,
});
const childTableColumns = await childTable.getColumns();
const childTableColumn = await childTableColumns.find(
(column) => column.title === relatedTableColumnTitle
);
const ltarColumn = (await table.getColumns()).find(
(column) =>
column.uidt === UITypes.LinkToAnotherRecord &&
column.colOptions?.fk_related_model_id === childTable.id
);
const rollupColumn = await createColumn(context, table, {
title: title,
uidt: UITypes.Rollup,
fk_relation_column_id: ltarColumn?.id,
fk_rollup_column_id: childTableColumn?.id,
rollup_function: rollupFunction,
table_name: table.table_name,
column_name: title,
});
return rollupColumn;
};
const createLookupColumn = async (
context,
{
project,
title,
table,
relatedTableName,
relatedTableColumnTitle,
}: {
project: Project;
title: string;
table: Model;
relatedTableName: string;
relatedTableColumnTitle: string;
}
) => {
const childBases = await project.getBases();
const childTable = await Model.getByIdOrName({
project_id: project.id,
base_id: childBases[0].id!,
table_name: relatedTableName,
});
const childTableColumns = await childTable.getColumns();
const childTableColumn = await childTableColumns.find(
(column) => column.title === relatedTableColumnTitle
);
if (!childTableColumn) {
throw new Error(
`Could not find column ${relatedTableColumnTitle} in ${relatedTableName}`
);
}
const ltarColumn = (await table.getColumns()).find(
(column) =>
column.uidt === UITypes.LinkToAnotherRecord &&
column.colOptions?.fk_related_model_id === childTable.id
);
const lookupColumn = await createColumn(context, table, {
title: title,
uidt: UITypes.Lookup,
fk_relation_column_id: ltarColumn?.id,
fk_lookup_column_id: childTableColumn?.id,
table_name: table.table_name,
column_name: title,
});
return lookupColumn;
};
const createLtarColumn = async (
context,
{
title,
parentTable,
childTable,
type,
}: {
title: string;
parentTable: Model;
childTable: Model;
type: string;
}
) => {
const ltarColumn = await createColumn(context, parentTable, {
title: title,
column_name: title,
uidt: UITypes.LinkToAnotherRecord,
parentId: parentTable.id,
childId: childTable.id,
type: type,
});
return ltarColumn;
};
const updateViewColumn = async (context, {view, column, attr}: {column: Column, view: View, attr: any}) => {
const res = await request(context.app)
.patch(`/api/v1/db/meta/views/${view.id}/columns/${column.id}`)
.set('xc-auth', context.token)
.send({
...attr,
});
const updatedColumn: FormViewColumn | GridViewColumn | GalleryViewColumn = (await view.getColumns()).find(
(column) => column.id === column.id
)!;
return updatedColumn;
}
export {
defaultColumns,
createColumn,
createRollupColumn,
createLookupColumn,
createLtarColumn,
updateViewColumn
};

64
packages/nocodb/tests/unit/factory/project.ts

@ -0,0 +1,64 @@
import request from 'supertest';
import Project from '../../../src/lib/models/Project';
import TestDbMngr from '../TestDbMngr';
const externalProjectConfig = {
title: 'sakila',
bases: [
{
type: 'mysql2',
config: {
client: 'mysql2',
connection: {
host: 'localhost',
port: '3306',
user: 'root',
password: 'password',
database: TestDbMngr.sakilaDbName,
},
},
inflection_column: 'camelize',
inflection_table: 'camelize',
},
],
external: true,
};
const defaultProjectValue = {
title: 'Title',
};
const defaultSharedBaseValue = {
roles: 'viewer',
password: 'test',
};
const createSharedBase = async (app, token, project, sharedBaseArgs = {}) => {
await request(app)
.post(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', token)
.send({
...defaultSharedBaseValue,
...sharedBaseArgs,
});
};
const createSakilaProject = async (context) => {
const response = await request(context.app)
.post('/api/v1/db/meta/projects/')
.set('xc-auth', context.token)
.send(externalProjectConfig);
return (await Project.getByTitleOrId(response.body.id)) as Project;
};
const createProject = async (context, projectArgs = defaultProjectValue) => {
const response = await request(context.app)
.post('/api/v1/db/meta/projects/')
.set('xc-auth', context.token)
.send(projectArgs);
return (await Project.getByTitleOrId(response.body.id)) as Project;
};
export { createProject, createSharedBase, createSakilaProject };

181
packages/nocodb/tests/unit/factory/row.ts

@ -0,0 +1,181 @@
import { ColumnType, UITypes } from 'nocodb-sdk';
import request from 'supertest';
import Column from '../../../src/lib/models/Column';
import Filter from '../../../src/lib/models/Filter';
import Model from '../../../src/lib/models/Model';
import Project from '../../../src/lib/models/Project';
import Sort from '../../../src/lib/models/Sort';
import NcConnectionMgrv2 from '../../../src/lib/utils/common/NcConnectionMgrv2';
const rowValue = (column: ColumnType, index: number) => {
switch (column.uidt) {
case UITypes.Number:
return index;
case UITypes.SingleLineText:
return `test-${index}`;
case UITypes.Date:
return '2020-01-01';
case UITypes.DateTime:
return '2020-01-01 00:00:00';
case UITypes.Email:
return `test-${index}@example.com`;
default:
return `test-${index}`;
}
};
const getRow = async (context, {project, table, id}) => {
const response = await request(context.app)
.get(`/api/v1/db/data/noco/${project.id}/${table.id}/${id}`)
.set('xc-auth', context.token);
return response.body;
};
const listRow = async ({
project,
table,
options,
}: {
project: Project;
table: Model;
options?: {
limit?: any;
offset?: any;
filterArr?: Filter[];
sortArr?: Sort[];
};
}) => {
const bases = await project.getBases();
const baseModel = await Model.getBaseModelSQL({
id: table.id,
dbDriver: NcConnectionMgrv2.get(bases[0]!),
});
const ignorePagination = !options;
return await baseModel.list(options, ignorePagination);
};
const getOneRow = async (
context,
{ project, table }: { project: Project; table: Model }
) => {
const response = await request(context.app)
.get(`/api/v1/db/data/noco/${project.id}/${table.id}/find-one`)
.set('xc-auth', context.token);
return response.body;
};
const generateDefaultRowAttributes = ({
columns,
index = 0,
}: {
columns: ColumnType[];
index?: number;
}) =>
columns.reduce((acc, column) => {
if (
column.uidt === UITypes.LinkToAnotherRecord ||
column.uidt === UITypes.ForeignKey ||
column.uidt === UITypes.ID
) {
return acc;
}
acc[column.title!] = rowValue(column, index);
return acc;
}, {});
const createRow = async (
context,
{
project,
table,
index = 0,
}: {
project: Project;
table: Model;
index?: number;
}
) => {
const columns = await table.getColumns();
const rowData = generateDefaultRowAttributes({ columns, index });
const response = await request(context.app)
.post(`/api/v1/db/data/noco/${project.id}/${table.id}`)
.set('xc-auth', context.token)
.send(rowData);
return response.body;
};
const createBulkRows = async (
context,
{
project,
table,
values
}: {
project: Project;
table: Model;
values: any[];
}) => {
await request(context.app)
.post(`/api/v1/db/data/bulk/noco/${project.id}/${table.id}`)
.set('xc-auth', context.token)
.send(values)
.expect(200);
}
// Links 2 table rows together. Will create rows if ids are not provided
const createChildRow = async (
context,
{
project,
table,
childTable,
column,
rowId,
childRowId,
type,
}: {
project: Project;
table: Model;
childTable: Model;
column: Column;
rowId?: string;
childRowId?: string;
type: string;
}
) => {
if (!rowId) {
const row = await createRow(context, { project, table });
rowId = row['Id'];
}
if (!childRowId) {
const row = await createRow(context, { table: childTable, project });
childRowId = row['Id'];
}
await request(context.app)
.post(
`/api/v1/db/data/noco/${project.id}/${table.id}/${rowId}/${type}/${column.title}/${childRowId}`
)
.set('xc-auth', context.token);
const row = await getRow(context, { project, table, id: rowId });
return row;
};
export {
createRow,
getRow,
createChildRow,
getOneRow,
listRow,
generateDefaultRowAttributes,
createBulkRows
};

42
packages/nocodb/tests/unit/factory/table.ts

@ -0,0 +1,42 @@
import request from 'supertest';
import Model from '../../../src/lib/models/Model';
import Project from '../../../src/lib/models/Project';
import { defaultColumns } from './column';
const defaultTableValue = (context) => ({
table_name: 'Table1',
title: 'Table1_Title',
columns: defaultColumns(context),
});
const createTable = async (context, project, args = {}) => {
const defaultValue = defaultTableValue(context);
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({ ...defaultValue, ...args });
const table: Model = await Model.get(response.body.id);
return table;
};
const getTable = async ({project, name}: {project: Project, name: string}) => {
const bases = await project.getBases();
return await Model.getByIdOrName({
project_id: project.id,
base_id: bases[0].id!,
table_name: name,
});
}
const getAllTables = async ({project}: {project: Project}) => {
const bases = await project.getBases();
const tables = await Model.list({
project_id: project.id,
base_id: bases[0].id!,
});
return tables;
}
export { createTable, getTable, getAllTables };

18
packages/nocodb/tests/unit/factory/user.ts

@ -0,0 +1,18 @@
import request from 'supertest';
import User from '../../../src/lib/models/User';
const defaultUserArgs = {
email: 'test@example.com',
password: 'A1234abh2@dsad',
};
const createUser = async (context, userArgs = {}) => {
const args = { ...defaultUserArgs, ...userArgs };
const response = await request(context.app)
.post('/api/v1/auth/user/signup')
.send(args);
const user = User.getByEmail(args.email);
return { token: response.body.token, user };
};
export { createUser, defaultUserArgs };

35
packages/nocodb/tests/unit/factory/view.ts

@ -0,0 +1,35 @@
import { ViewTypes } from 'nocodb-sdk';
import request from 'supertest';
import Model from '../../../src/lib/models/Model';
import View from '../../../src/lib/models/View';
const createView = async (context, {title, table, type}: {title: string, table: Model, type: ViewTypes}) => {
const viewTypeStr = (type) => {
switch (type) {
case ViewTypes.GALLERY:
return 'galleries';
case ViewTypes.FORM:
return 'forms';
case ViewTypes.GRID:
return 'grids';
case ViewTypes.KANBAN:
return 'kanbans';
default:
throw new Error('Invalid view type');
}
};
await request(context.app)
.post(`/api/v1/db/meta/tables/${table.id}/${viewTypeStr(type)}`)
.set('xc-auth', context.token)
.send({
title,
type,
});
const view = await View.getByTitleOrId({fk_model_id: table.id, titleOrId:title}) as View;
return view
}
export {createView}

20
packages/nocodb/tests/unit/index.test.ts

@ -0,0 +1,20 @@
import 'mocha';
import restTests from './rest/index.test';
import modelTests from './model/index.test';
import TestDbMngr from './TestDbMngr'
process.env.NODE_ENV = 'test';
process.env.TEST = 'test';
process.env.NC_DISABLE_CACHE = 'true';
process.env.NC_DISABLE_TELE = 'true';
(async function() {
await TestDbMngr.init();
modelTests();
restTests();
run();
})();

56
packages/nocodb/tests/unit/init/cleanupMeta.ts

@ -0,0 +1,56 @@
import Model from "../../../src/lib/models/Model";
import Project from "../../../src/lib/models/Project";
import NcConnectionMgrv2 from "../../../src/lib/utils/common/NcConnectionMgrv2";
import { orderedMetaTables } from "../../../src/lib/utils/globals";
import TestDbMngr from "../TestDbMngr";
const dropTablesAllNonExternalProjects = async () => {
const projects = await Project.list({});
const userCreatedTableNames: string[] = [];
await Promise.all(
projects
.filter((project) => project.is_meta)
.map(async (project) => {
await project.getBases();
const base = project.bases && project.bases[0];
if (!base) return;
const models = await Model.list({
project_id: project.id,
base_id: base.id!,
});
models.forEach((model) => {
userCreatedTableNames.push(model.table_name);
});
})
);
await TestDbMngr.disableForeignKeyChecks(TestDbMngr.metaKnex);
for (const tableName of userCreatedTableNames) {
await TestDbMngr.metaKnex.raw(`DROP TABLE ${tableName}`);
}
await TestDbMngr.enableForeignKeyChecks(TestDbMngr.metaKnex);
};
const cleanupMetaTables = async () => {
await TestDbMngr.disableForeignKeyChecks(TestDbMngr.metaKnex);
for (const tableName of orderedMetaTables) {
try {
await TestDbMngr.metaKnex.raw(`DELETE FROM ${tableName}`);
} catch (e) {}
}
await TestDbMngr.enableForeignKeyChecks(TestDbMngr.metaKnex);
};
export default async function () {
try {
await NcConnectionMgrv2.destroyAll();
await dropTablesAllNonExternalProjects();
await cleanupMetaTables();
} catch (e) {
console.error('cleanupMeta', e);
}
}

81
packages/nocodb/tests/unit/init/cleanupSakila.ts

@ -0,0 +1,81 @@
import Audit from '../../../src/lib/models/Audit';
import Project from '../../../src/lib/models/Project';
import TestDbMngr from '../TestDbMngr';
const dropTablesOfSakila = async () => {
await TestDbMngr.disableForeignKeyChecks(TestDbMngr.sakilaKnex);
for(const tableName of sakilaTableNames){
try {
await TestDbMngr.sakilaKnex.raw(`DROP TABLE ${tableName}`);
} catch(e){}
}
await TestDbMngr.enableForeignKeyChecks(TestDbMngr.sakilaKnex);
}
const resetAndSeedSakila = async () => {
try {
await dropTablesOfSakila();
await TestDbMngr.seedSakila();
} catch (e) {
console.error('resetSakila', e);
throw e
}
}
const cleanUpSakila = async () => {
try {
const sakilaProject = await Project.getByTitle('sakila');
const audits = sakilaProject && await Audit.projectAuditList(sakilaProject.id, {});
if(audits?.length > 0) {
return await resetAndSeedSakila();
}
const tablesInSakila = await TestDbMngr.showAllTables(TestDbMngr.sakilaKnex);
await Promise.all(
tablesInSakila
.filter((tableName) => !sakilaTableNames.includes(tableName))
.map(async (tableName) => {
try {
await TestDbMngr.sakilaKnex.raw(`DROP TABLE ${tableName}`);
} catch (e) {
console.error(e);
}
})
);
} catch (e) {
console.error('cleanUpSakila', e);
}
};
const sakilaTableNames = [
'actor',
'address',
'category',
'city',
'country',
'customer',
'film',
'film_actor',
'film_category',
'film_text',
'inventory',
'language',
'payment',
'rental',
'staff',
'store',
'actor_info',
'customer_list',
'film_list',
'nicer_but_slower_film_list',
'sales_by_film_category',
'sales_by_store',
'staff_list',
];
export { cleanUpSakila, resetAndSeedSakila };

12
packages/nocodb/tests/unit/init/db.ts

@ -0,0 +1,12 @@
import { DbConfig } from "../../../src/interface/config";
const isSqlite = (context) =>{
return (context.dbConfig as DbConfig).client === 'sqlite' || (context.dbConfig as DbConfig).client === 'sqlite3';
}
const isMysql = (context) =>
(context.dbConfig as DbConfig).client === 'mysql' ||
(context.dbConfig as DbConfig).client === 'mysql2';
export { isSqlite, isMysql };

42
packages/nocodb/tests/unit/init/index.ts

@ -0,0 +1,42 @@
import express from 'express';
import { Noco } from '../../../src/lib';
import cleanupMeta from './cleanupMeta';
import {cleanUpSakila, resetAndSeedSakila} from './cleanupSakila';
import { createUser } from '../factory/user';
let server;
const serverInit = async () => {
const serverInstance = express();
serverInstance.enable('trust proxy');
serverInstance.use(await Noco.init());
serverInstance.use(function(req, res, next){
// 50 sec timeout
req.setTimeout(500000, function(){
console.log('Request has timed out.');
res.send(408);
});
next();
});
return serverInstance;
};
const isFirstTimeRun = () => !server
export default async function () {
const {default: TestDbMngr} = await import('../TestDbMngr');
if (isFirstTimeRun()) {
await resetAndSeedSakila();
server = await serverInit();
}
await cleanUpSakila();
await cleanupMeta();
const { token } = await createUser({ app: server }, { roles: 'editor' });
return { app: server, token, dbConfig: TestDbMngr.dbConfig };
}

10
packages/nocodb/tests/unit/model/index.test.ts

@ -0,0 +1,10 @@
import 'mocha';
import baseModelSqlTest from './tests/baseModelSql.test';
function modelTests() {
baseModelSqlTest();
}
export default function () {
describe('Model', modelTests);
}

500
packages/nocodb/tests/unit/model/tests/baseModelSql.test.ts

@ -0,0 +1,500 @@
import 'mocha';
import init from '../../init';
import { BaseModelSqlv2 } from '../../../../src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2';
import { createProject } from '../../factory/project';
import { createTable } from '../../factory/table';
import NcConnectionMgrv2 from '../../../../src/lib/utils/common/NcConnectionMgrv2';
import Base from '../../../../src/lib/models/Base';
import Model from '../../../../src/lib/models/Model';
import Project from '../../../../src/lib/models/Project';
import View from '../../../../src/lib/models/View';
import { createRow, generateDefaultRowAttributes } from '../../factory/row';
import Audit from '../../../../src/lib/models/Audit';
import { expect } from 'chai';
import Filter from '../../../../src/lib/models/Filter';
import { createLtarColumn } from '../../factory/column';
import LinkToAnotherRecordColumn from '../../../../src/lib/models/LinkToAnotherRecordColumn';
function baseModelSqlTests() {
let context;
let project: Project;
let table: Model;
let view: View;
let baseModelSql: BaseModelSqlv2;
beforeEach(async function () {
context = await init();
project = await createProject(context);
table = await createTable(context, project);
view = await table.getViews()[0];
const base = await Base.get(table.base_id);
baseModelSql = new BaseModelSqlv2({
dbDriver: NcConnectionMgrv2.get(base),
model: table,
view
})
});
it('Insert record', async () => {
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
const columns = await table.getColumns();
const inputData = generateDefaultRowAttributes({columns})
const response = await baseModelSql.insert(generateDefaultRowAttributes({columns}), undefined, request);
const insertedRow = (await baseModelSql.list())[0];
expect(insertedRow).to.include(inputData);
expect(insertedRow).to.include(response);
const rowInsertedAudit = (await Audit.projectAuditList(project.id, {})).find((audit) => audit.op_sub_type === 'INSERT');
expect(rowInsertedAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
project_id: project.id,
fk_model_id: table.id,
row_id: '1',
op_type: 'DATA',
op_sub_type: 'INSERT',
description: '1 inserted into Table1_Title',
});
});
it('Bulk insert record', async () => {
const columns = await table.getColumns();
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
const bulkData = Array(10).fill(0).map((_, index) => generateDefaultRowAttributes({columns, index}))
await baseModelSql.bulkInsert(bulkData, {cookie:request});
const insertedRows = await baseModelSql.list();
bulkData.forEach((inputData, index) => {
expect(insertedRows[index]).to.include(inputData);
});
const rowBulkInsertedAudit = (await Audit.projectAuditList(project.id, {})).find((audit) => audit.op_sub_type === 'BULK_INSERT');;
expect(rowBulkInsertedAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
project_id: project.id,
fk_model_id: table.id,
row_id: null,
op_type: 'DATA',
op_sub_type: 'BULK_INSERT',
status: null,
description: '10 records bulk inserted into Table1_Title',
details: null,
});
});
it('Update record', async () => {
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
const columns = await table.getColumns();
await baseModelSql.insert(generateDefaultRowAttributes({columns}));
const rowId = 1;
await baseModelSql.updateByPk(rowId, {Title: 'test'},undefined, request);
const updatedRow = await baseModelSql.readByPk(1);
expect(updatedRow).to.include({Id: rowId, Title: 'test'});
const rowUpdatedAudit = (await Audit.projectAuditList(project.id, {})).find((audit) => audit.op_sub_type === 'UPDATE');
expect(rowUpdatedAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
project_id: project.id,
fk_model_id: table.id,
row_id: '1',
op_type: 'DATA',
op_sub_type: 'UPDATE',
description: '1 updated in Table1_Title',
});
});
it('Bulk update record', async () => {
const columns = await table.getColumns();
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
const bulkData = Array(10).fill(0).map((_, index) => generateDefaultRowAttributes({columns, index}))
await baseModelSql.bulkInsert(bulkData, {cookie:request});
const insertedRows: any[] = await baseModelSql.list();
await baseModelSql.bulkUpdate(insertedRows.map((row)=> ({...row, Title: `new-${row['Title']}`})), { cookie: request });
const updatedRows = await baseModelSql.list();
updatedRows.forEach((row, index) => {
expect(row['Title']).to.equal(`new-test-${index}`);
})
const rowBulkUpdateAudit = (await Audit.projectAuditList(project.id, {})).find((audit) => audit.op_sub_type === 'BULK_UPDATE');
expect(rowBulkUpdateAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
fk_model_id: table.id,
project_id: project.id,
row_id: null,
op_type: 'DATA',
op_sub_type: 'BULK_UPDATE',
status: null,
description: '10 records bulk updated in Table1_Title',
details: null,
});
});
it('Bulk update all record', async () => {
const columns = await table.getColumns();
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
const bulkData = Array(10).fill(0).map((_, index) => generateDefaultRowAttributes({columns, index}))
await baseModelSql.bulkInsert(bulkData, {cookie:request});
const idColumn = columns.find((column) => column.title === 'Id')!;
await baseModelSql.bulkUpdateAll({filterArr: [
new Filter({
logical_op: 'and',
fk_column_id: idColumn.id,
comparison_op: 'lt',
value: 5,
})
]}, ({Title: 'new-1'}), { cookie: request });
const updatedRows = await baseModelSql.list();
updatedRows.forEach((row) => {
if(row.id < 5) expect(row['Title']).to.equal('new-1');
})
const rowBulkUpdateAudit = (await Audit.projectAuditList(project.id, {})).find((audit) => audit.op_sub_type === 'BULK_UPDATE');
expect(rowBulkUpdateAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
fk_model_id: table.id,
project_id: project.id,
row_id: null,
op_type: 'DATA',
op_sub_type: 'BULK_UPDATE',
status: null,
description: '4 records bulk updated in Table1_Title',
details: null,
});
});
it('Delete record', async () => {
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'},
params: {id: 1}
}
const columns = await table.getColumns();
const bulkData = Array(10).fill(0).map((_, index) => generateDefaultRowAttributes({columns, index}))
await baseModelSql.bulkInsert(bulkData, {cookie:request});
const rowIdToDeleted = 1;
await baseModelSql.delByPk(rowIdToDeleted,undefined ,request);
const deletedRow = await baseModelSql.readByPk(rowIdToDeleted);
expect(deletedRow).to.be.undefined;
console.log('Delete record', await Audit.projectAuditList(project.id, {}));
const rowDeletedAudit = (await Audit.projectAuditList(project.id, {})).find((audit) => audit.op_sub_type === 'DELETE');
expect(rowDeletedAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
project_id: project.id,
fk_model_id: table.id,
row_id: '1',
op_type: 'DATA',
op_sub_type: 'DELETE',
description: '1 deleted from Table1_Title',
});
});
it('Bulk delete records', async () => {
const columns = await table.getColumns();
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
const bulkData = Array(10).fill(0).map((_, index) => generateDefaultRowAttributes({columns, index}))
await baseModelSql.bulkInsert(bulkData, {cookie:request});
const insertedRows: any[] = await baseModelSql.list();
await baseModelSql.bulkDelete(
insertedRows
.filter((row) => row['Id'] < 5)
.map((row)=> ({'id': row['Id']})),
{ cookie: request }
);
const remainingRows = await baseModelSql.list();
expect(remainingRows).to.length(6);
const rowBulkDeleteAudit = (await Audit.projectAuditList(project.id, {})).find((audit) => audit.op_sub_type === 'BULK_DELETE');
expect(rowBulkDeleteAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
fk_model_id: table.id,
project_id: project.id,
row_id: null,
op_type: 'DATA',
op_sub_type: 'BULK_DELETE',
status: null,
description: '4 records bulk deleted in Table1_Title',
details: null,
});
});
it('Bulk delete all record', async () => {
const columns = await table.getColumns();
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
const bulkData = Array(10).fill(0).map((_, index) => generateDefaultRowAttributes({columns, index}))
await baseModelSql.bulkInsert(bulkData, {cookie:request});
const idColumn = columns.find((column) => column.title === 'Id')!;
await baseModelSql.bulkDeleteAll({filterArr: [
new Filter({
logical_op: 'and',
fk_column_id: idColumn.id,
comparison_op: 'lt',
value: 5,
})
]}, { cookie: request });
const remainingRows = await baseModelSql.list();
expect(remainingRows).to.length(6);
const rowBulkDeleteAudit = (await Audit.projectAuditList(project.id, {})).find((audit) => audit.op_sub_type === 'BULK_DELETE');
expect(rowBulkDeleteAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
fk_model_id: table.id,
project_id: project.id,
row_id: null,
op_type: 'DATA',
op_sub_type: 'BULK_DELETE',
status: null,
description: '4 records bulk deleted in Table1_Title',
details: null,
});
});
it('Nested insert', async () => {
const childTable = await createTable(context, project, {
title: 'Child Table',
table_name: 'child_table',
})
const ltarColumn = await createLtarColumn(context, {
title: 'Ltar Column',
parentTable: table,
childTable,
type: "hm"
})
const childRow = await createRow(context, {
project,
table: childTable,
})
const ltarColOptions = await ltarColumn.getColOptions<LinkToAnotherRecordColumn>();
const childCol = await ltarColOptions.getChildColumn();
const columns = await table.getColumns();
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
await baseModelSql.nestedInsert(
{...generateDefaultRowAttributes({columns}), [ltarColumn.title]: [{'Id': childRow['Id']}]},
undefined,
request
);
const childBaseModel = new BaseModelSqlv2({
dbDriver: NcConnectionMgrv2.get(await Base.get(table.base_id)),
model: childTable,
view
})
const insertedChildRow = await childBaseModel.readByPk(childRow['Id']);
expect(insertedChildRow[childCol.column_name]).to.equal(childRow['Id']);
const rowInsertedAudit = (await Audit.projectAuditList(project.id, {}))
.filter((audit) => audit.fk_model_id === table.id)
.find((audit) => audit.op_sub_type === 'INSERT');
expect(rowInsertedAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
project_id: project.id,
fk_model_id: table.id,
row_id: '1',
op_type: 'DATA',
op_sub_type: 'INSERT',
description: '1 inserted into Table1_Title',
});
})
it('Link child', async () => {
const childTable = await createTable(context, project, {
title: 'Child Table',
table_name: 'child_table',
})
const ltarColumn = await createLtarColumn(context, {
title: 'Ltar Column',
parentTable: table,
childTable,
type: "hm"
})
const insertedChildRow = await createRow(context, {
project,
table: childTable,
})
const ltarColOptions = await ltarColumn.getColOptions<LinkToAnotherRecordColumn>();
const childCol = await ltarColOptions.getChildColumn();
const columns = await table.getColumns();
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
await baseModelSql.insert(generateDefaultRowAttributes({columns}), undefined, request);
const insertedRow = await baseModelSql.readByPk(1);
await baseModelSql.addChild({
colId: ltarColumn.id,
rowId: insertedRow['Id'],
childId: insertedChildRow['Id'],
cookie: request
});
const childBaseModel = new BaseModelSqlv2({
dbDriver: NcConnectionMgrv2.get(await Base.get(table.base_id)),
model: childTable,
view
})
const updatedChildRow = await childBaseModel.readByPk(insertedChildRow['Id']);
expect(updatedChildRow[childCol.column_name]).to.equal(insertedRow['Id']);
const rowInsertedAudit = (await Audit.projectAuditList(project.id, {}))
.filter((audit) => audit.fk_model_id === table.id)
.find((audit) => audit.op_sub_type === 'LINK_RECORD');
expect(rowInsertedAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
project_id: project.id,
fk_model_id: table.id,
row_id: '1',
op_type: 'DATA',
op_sub_type: 'LINK_RECORD',
description: 'Record [id:1] record linked with record [id:1] record in Table1_Title',
});
})
it('Unlink child', async () => {
const childTable = await createTable(context, project, {
title: 'Child Table',
table_name: 'child_table',
})
const ltarColumn = await createLtarColumn(context, {
title: 'Ltar Column',
parentTable: table,
childTable,
type: "hm"
})
const insertedChildRow = await createRow(context, {
project,
table: childTable,
})
const ltarColOptions = await ltarColumn.getColOptions<LinkToAnotherRecordColumn>();
const childCol = await ltarColOptions.getChildColumn();
const columns = await table.getColumns();
const request = {
clientIp: '::ffff:192.0.0.1',
user: {email: 'test@example.com'}
}
await baseModelSql.insert(generateDefaultRowAttributes({columns}), undefined, request);
const insertedRow = await baseModelSql.readByPk(1);
await baseModelSql.addChild({
colId: ltarColumn.id,
rowId: insertedRow['Id'],
childId: insertedChildRow['Id'],
cookie: request
});
await baseModelSql.removeChild({
colId: ltarColumn.id,
rowId: insertedRow['Id'],
childId: insertedChildRow['Id'],
cookie: request
});
const childBaseModel = new BaseModelSqlv2({
dbDriver: NcConnectionMgrv2.get(await Base.get(table.base_id)),
model: childTable,
view
})
const updatedChildRow = await childBaseModel.readByPk(insertedChildRow['Id']);
expect(updatedChildRow[childCol.column_name]).to.be.null;
const rowInsertedAudit = (await Audit.projectAuditList(project.id, {}))
.filter((audit) => audit.fk_model_id === table.id)
.find((audit) => audit.op_sub_type === 'UNLINK_RECORD');
expect(rowInsertedAudit).to.include({
user: 'test@example.com',
ip: '::ffff:192.0.0.1',
base_id: null,
project_id: project.id,
fk_model_id: table.id,
row_id: '1',
op_type: 'DATA',
op_sub_type: 'UNLINK_RECORD',
description: 'Record [id:1] record unlinked with record [id:1] record in Table1_Title',
});
})
}
export default function () {
describe('BaseModelSql', baseModelSqlTests);
}

18
packages/nocodb/tests/unit/rest/index.test.ts

@ -0,0 +1,18 @@
import 'mocha';
import authTests from './tests/auth.test';
import projectTests from './tests/project.test';
import tableTests from './tests/table.test';
import tableRowTests from './tests/tableRow.test';
import viewRowTests from './tests/viewRow.test';
function restTests() {
authTests();
projectTests();
tableTests();
tableRowTests();
viewRowTests();
}
export default function () {
describe('Rest', restTests);
}

169
packages/nocodb/tests/unit/rest/tests/auth.test.ts

@ -0,0 +1,169 @@
import { expect } from 'chai';
import 'mocha';
import request from 'supertest';
import init from '../../init';
import { defaultUserArgs } from '../../factory/user';
function authTests() {
let context;
beforeEach(async function () {
context = await init();
});
it('Signup with valid email', async () => {
const response = await request(context.app)
.post('/api/v1/auth/user/signup')
.send({ email: 'new@example.com', password: defaultUserArgs.password })
.expect(200)
const token = response.body.token;
expect(token).to.be.a('string');
});
it('Signup with invalid email', async () => {
await request(context.app)
.post('/api/v1/auth/user/signup')
.send({ email: 'test', password: defaultUserArgs.password })
.expect(400);
});
it('Signup with invalid passsword', async () => {
await request(context.app)
.post('/api/v1/auth/user/signup')
.send({ email: defaultUserArgs.email, password: 'weakpass' })
.expect(400);
});
it('Signin with valid credentials', async () => {
const response = await request(context.app)
.post('/api/v1/auth/user/signin')
.send({
email: defaultUserArgs.email,
password: defaultUserArgs.password,
})
.expect(200);
const token = response.body.token;
expect(token).to.be.a('string');
});
it('Signup without email and password', async () => {
await request(context.app)
.post('/api/v1/auth/user/signin')
// pass empty data in await request
.send({})
.expect(400);
});
it('Signin with invalid credentials', async () => {
await request(context.app)
.post('/api/v1/auth/user/signin')
.send({ email: 'abc@abc.com', password: defaultUserArgs.password })
.expect(400);
});
it('Signin with invalid password', async () => {
await request(context.app)
.post('/api/v1/auth/user/signin')
.send({ email: defaultUserArgs.email, password: 'wrongPassword' })
.expect(400);
});
it('me without token', async () => {
const response = await request(context.app)
.get('/api/v1/auth/user/me')
.unset('xc-auth')
.expect(200);
if (!response.body?.roles?.guest) {
return new Error('User should be guest');
}
});
it('me with token', async () => {
const response = await request(context.app)
.get('/api/v1/auth/user/me')
.set('xc-auth', context.token)
.expect(200);
const email = response.body.email;
expect(email).to.equal(defaultUserArgs.email);
});
it('Forgot password with a non-existing email id', async () => {
await request(context.app)
.post('/api/v1/auth/password/forgot')
.send({ email: 'nonexisting@email.com' })
.expect(400);
});
// todo: fix mailer issues
// it('Forgot password with an existing email id', function () {});
it('Change password', async () => {
await request(context.app)
.post('/api/v1/auth/password/change')
.set('xc-auth', context.token)
.send({
currentPassword: defaultUserArgs.password,
newPassword: 'NEW' + defaultUserArgs.password,
})
.expect(200);
});
it('Change password - after logout', async () => {
await request(context.app)
.post('/api/v1/auth/password/change')
.unset('xc-auth')
.send({
currentPassword: defaultUserArgs.password,
newPassword: 'NEW' + defaultUserArgs.password,
})
.expect(401);
});
// todo:
it('Reset Password with an invalid token', async () => {
await request(context.app)
.post('/api/v1/auth/password/reset/someRandomValue')
.send({ email: defaultUserArgs.email })
.expect(400);
});
it('Email validate with an invalid token', async () => {
await request(context.app)
.post('/api/v1/auth/email/validate/someRandomValue')
.send({ email: defaultUserArgs.email })
.expect(400);
});
// todo:
// it('Email validate with a valid token', async () => {
// // await request(context.app)
// // .post('/auth/email/validate/someRandomValue')
// // .send({email: EMAIL_ID})
// // .expect(500, done);
// });
// todo:
// it('Forgot password validate with a valid token', async () => {
// // await request(context.app)
// // .post('/auth/token/validate/someRandomValue')
// // .send({email: EMAIL_ID})
// // .expect(500, done);
// });
// todo:
// it('Reset Password with an valid token', async () => {
// // await request(context.app)
// // .post('/auth/password/reset/someRandomValue')
// // .send({password: 'anewpassword'})
// // .expect(500, done);
// });
// todo: refresh token api
}
export default function () {
describe('Auth', authTests);
}

268
packages/nocodb/tests/unit/rest/tests/project.test.ts

@ -0,0 +1,268 @@
import 'mocha';
import request from 'supertest';
import init from '../../init/index';
import { createProject, createSharedBase } from '../../factory/project';
import { beforeEach } from 'mocha';
import { Exception } from 'handlebars';
import Project from '../../../../src/lib/models/Project';
function projectTest() {
let context;
let project;
beforeEach(async function () {
context = await init();
project = await createProject(context);
});
it('Get project info', async () => {
await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/info`)
.set('xc-auth', context.token)
.send({})
.expect(200);
});
// todo: Test by creating models under project and check if the UCL is working
it('UI ACL', async () => {
await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/visibility-rules`)
.set('xc-auth', context.token)
.send({})
.expect(200);
});
// todo: Test creating visibility set
it('List projects', async () => {
const response = await request(context.app)
.get('/api/v1/db/meta/projects/')
.set('xc-auth', context.token)
.send({})
.expect(200);
if (response.body.list.length !== 1) new Error('Should list only 1 project');
if (!response.body.pageInfo) new Error('Should have pagination info');
});
it('Create project', async () => {
const response = await request(context.app)
.post('/api/v1/db/meta/projects/')
.set('xc-auth', context.token)
.send({
title: 'Title1',
})
.expect(200);
const newProject = await Project.getByTitleOrId(response.body.id);
if (!newProject) return new Error('Project not created');
});
it('Create projects with existing title', async () => {
await request(context.app)
.post(`/api/v1/db/meta/projects/`)
.set('xc-auth', context.token)
.send({
title: project.title,
})
.expect(400);
});
// todo: fix passport user role popluation bug
// it('Delete project', async async () => {
// const toBeDeletedProject = await createProject(app, token, {
// title: 'deletedTitle',
// });
// await request(app)
// .delete('/api/v1/db/meta/projects/${toBeDeletedProject.id}')
// .set('xc-auth', token)
// .send({
// title: 'Title1',
// })
// .expect(200, async (err) => {
// // console.log(res);
//
// const deletedProject = await Project.getByTitleOrId(
// toBeDeletedProject.id
// );
// if (deletedProject) return new Error('Project not delete');
// new Error();
// });
// });
it('Read project', async () => {
const response = await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}`)
.set('xc-auth', context.token)
.send()
.expect(200);
if (response.body.id !== project.id) return new Error('Got the wrong project');
});
it('Update projects', async () => {
await request(context.app)
.patch(`/api/v1/db/meta/projects/${project.id}`)
.set('xc-auth', context.token)
.send({
title: 'NewTitle',
})
.expect(200);
const newProject = await Project.getByTitleOrId(project.id);
if (newProject.title !== 'NewTitle') {
return new Error('Project not updated');
}
});
it('Update projects with existing title', async function () {
const newProject = await createProject(context, {
title: 'NewTitle1',
});
await request(context.app)
.patch(`/api/v1/db/meta/projects/${project.id}`)
.set('xc-auth', context.token)
.send({
title: newProject.title,
})
.expect(400);
});
it('Create project shared base', async () => {
await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', context.token)
.send({
roles: 'viewer',
password: 'test',
})
.expect(200);
const updatedProject = await Project.getByTitleOrId(project.id);
if (
!updatedProject.uuid ||
updatedProject.roles !== 'viewer' ||
updatedProject.password !== 'test'
) {
return new Error('Shared base not configured properly');
}
});
it('Created project shared base should have only editor or viewer role', async () => {
await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', context.token)
.send({
roles: 'commenter',
password: 'test',
})
.expect(200);
const updatedProject = await Project.getByTitleOrId(project.id);
if (updatedProject.roles === 'commenter') {
return new Error('Shared base not configured properly');
}
});
it('Updated project shared base should have only editor or viewer role', async () => {
await createSharedBase(context.app, context.token, project);
await request(context.app)
.patch(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', context.token)
.send({
roles: 'commenter',
password: 'test',
})
.expect(200);
const updatedProject = await Project.getByTitleOrId(project.id);
if (updatedProject.roles === 'commenter') {
throw new Exception('Shared base not updated properly');
}
});
it('Updated project shared base', async () => {
await createSharedBase(context.app, context.token, project);
await request(context.app)
.patch(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', context.token)
.send({
roles: 'editor',
password: 'test',
})
.expect(200);
const updatedProject = await Project.getByTitleOrId(project.id);
if (updatedProject.roles !== 'editor') {
throw new Exception('Shared base not updated properly');
}
});
it('Get project shared base', async () => {
await createSharedBase(context.app, context.token, project);
await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', context.token)
.send()
.expect(200);
const updatedProject = await Project.getByTitleOrId(project.id);
if (!updatedProject.uuid) {
throw new Exception('Shared base not created');
}
});
it('Delete project shared base', async () => {
await createSharedBase(context.app, context.token, project);
await request(context.app)
.delete(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', context.token)
.send()
.expect(200);
const updatedProject = await Project.getByTitleOrId(project.id);
if (updatedProject.uuid) {
throw new Exception('Shared base not deleted');
}
});
// todo: Do compare api test
it('Meta diff sync', async () => {
await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/meta-diff`)
.set('xc-auth', context.token)
.send()
.expect(200);
});
it('Meta diff sync', async () => {
await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/meta-diff`)
.set('xc-auth', context.token)
.send()
.expect(200);
});
// todo: improve test. Check whether the all the actions are present in the response and correct as well
it('Meta diff sync', async () => {
await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/audits`)
.set('xc-auth', context.token)
.send()
.expect(200);
});
}
export default function () {
describe('Project', projectTest);
}

253
packages/nocodb/tests/unit/rest/tests/table.test.ts

@ -0,0 +1,253 @@
// import { expect } from 'chai';
import 'mocha';
import request from 'supertest';
import init from '../../init';
import { createTable, getAllTables } from '../../factory/table';
import { createProject } from '../../factory/project';
import { defaultColumns } from '../../factory/column';
import Model from '../../../../src/lib/models/Model';
function tableTest() {
let context;
let project;
let table;
beforeEach(async function () {
context = await init();
project = await createProject(context);
table = await createTable(context, project);
});
it('Get table list', async function () {
const response = await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({})
.expect(200);
if (response.body.list.length !== 1) return new Error('Wrong number of tables');
});
it('Create table', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
table_name: 'table2',
title: 'new_title_2',
columns: defaultColumns(context),
})
.expect(200);
const tables = await getAllTables({ project });
if (tables.length !== 2) {
return new Error('Tables is not be created');
}
if (response.body.columns.length !== (defaultColumns(context))) {
return new Error('Columns not saved properly');
}
if (
!(
response.body.table_name.startsWith(project.prefix) &&
response.body.table_name.endsWith('table2')
)
) {
return new Error('table name not configured properly');
}
});
it('Create table with no table name', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
table_name: undefined,
title: 'new_title',
columns: defaultColumns(context),
})
.expect(400);
if (
!response.text.includes(
'Missing table name `table_name` property in request body'
)
) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
console.log(tables);
return new Error(
`Tables should not be created, tables.length:${tables.length}`
);
}
});
it('Create table with same table name', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
table_name: table.table_name,
title: 'New_title',
columns: defaultColumns(context),
})
.expect(400);
if (!response.text.includes('Duplicate table name')) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Create table with same title', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
table_name: 'New_table_name',
title: table.title,
columns: defaultColumns(context),
})
.expect(400);
if (!response.text.includes('Duplicate table alias')) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Create table with title length more than the limit', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
table_name: 'a'.repeat(256),
title: 'new_title',
columns: defaultColumns(context),
})
.expect(400);
if (!response.text.includes('Table name exceeds ')) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Create table with title having leading white space', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
table_name: 'table_name_with_whitespace ',
title: 'new_title',
columns: defaultColumns(context),
})
.expect(400);
if (
!response.text.includes(
'Leading or trailing whitespace not allowed in table names'
)
) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Update table', async function () {
const response = await request(context.app)
.patch(`/api/v1/db/meta/tables/${table.id}`)
.set('xc-auth', context.token)
.send({
project_id: project.id,
table_name: 'new_title',
})
.expect(200);
const updatedTable = await Model.get(table.id);
if (!updatedTable.table_name.endsWith('new_title')) {
return new Error('Table was not updated');
}
});
it('Delete table', async function () {
const response = await request(context.app)
.delete(`/api/v1/db/meta/tables/${table.id}`)
.set('xc-auth', context.token)
.send({})
.expect(200);
const tables = await getAllTables({ project });
if (tables.length !== 0) {
return new Error('Table is not deleted');
}
});
// todo: Check the condtion where the table being deleted is being refered by multiple tables
// todo: Check the if views are also deleted
it('Get table', async function () {
const response = await request(context.app)
.get(`/api/v1/db/meta/tables/${table.id}`)
.set('xc-auth', context.token)
.send({})
.expect(200);
if (response.body.id !== table.id) new Error('Wrong table');
});
// todo: flaky test, order condition is sometimes not met
it('Reorder table', async function () {
const newOrder = table.order === 0 ? 1 : 0;
const response = await request(context.app)
.post(`/api/v1/db/meta/tables/${table.id}/reorder`)
.set('xc-auth', context.token)
.send({
order: newOrder,
})
.expect(200);
// .expect(200, async (err) => {
// if (err) return new Error(err);
// const updatedTable = await Model.get(table.id);
// console.log(Number(updatedTable.order), newOrder);
// if (Number(updatedTable.order) !== newOrder) {
// return new Error('Reordering failed');
// }
// new Error();
// });
});
}
export default async function () {
describe('Table', tableTest);
}

2031
packages/nocodb/tests/unit/rest/tests/tableRow.test.ts

File diff suppressed because it is too large Load Diff

1232
packages/nocodb/tests/unit/rest/tests/viewRow.test.ts

File diff suppressed because it is too large Load Diff

72
packages/nocodb/tests/unit/tsconfig.json

@ -0,0 +1,72 @@
{
"compilerOptions": {
"skipLibCheck": true,
"composite": true,
"target": "es2017",
"outDir": "build/main",
"rootDir": "src",
"moduleResolution": "node",
"module": "commonjs",
"declaration": true,
"inlineSourceMap": true,
"esModuleInterop": true
/* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"allowJs": false,
// "strict": true /* Enable all strict type-checking options. */,
/* Strict Type-Checking Options */
// "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
// "strictNullChecks": true /* Enable strict null checks. */,
// "strictFunctionTypes": true /* Enable strict checking of function types. */,
// "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
// "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
// "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
"resolveJsonModule": true,
/* Additional Checks */
"noUnusedLocals": false
/* Report errors on unused locals. */,
"noUnusedParameters": false
/* Report errors on unused parameters. */,
"noImplicitReturns": false
/* Report error when not all code paths in function return a value. */,
"noFallthroughCasesInSwitch": false
/* Report errors for fallthrough cases in switch statement. */,
/* Debugging Options */
"traceResolution": false
/* Report module resolution log messages. */,
"listEmittedFiles": false
/* Print names of generated files part of the compilation. */,
"listFiles": false
/* Print names of files part of the compilation. */,
"pretty": true
/* Stylize errors and messages using color and context. */,
/* Experimental Options */
// "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
// "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
"lib": [
"es2017"
],
"types": [
"mocha", "node"
],
"typeRoots": [
"node_modules/@types",
"src/types"
]
},
"parserOptions": {
"sourceType": "module",
"tsconfigRootDir": "./",
"project": "./tsconfig.json",
},
"include": [
"./tests/**/**/**.ts",
"./tests/**/**.ts"
// "**/*.ts",
// "**/*.json"
],
"exclude": [
],
"compileOnSave": false
}

5
scripts/cypress/support/commands.js

@ -374,9 +374,8 @@ Cypress.Commands.add('createColumn', (table, columnName) => {
}); });
Cypress.Commands.add('toastWait', (msg) => { Cypress.Commands.add('toastWait', (msg) => {
// cy.get('.ant-message-notice-content:visible', { timout: 30000 }).should('exist') cy.get('.ant-message-notice-content:visible', { timeout: 60000 }).contains(msg).should('exist');
cy.get('.ant-message-notice-content:visible', { timout: 30000 }).contains(msg).should('exist'); cy.get('.ant-message-notice-content:visible', { timeout: 12000 }).should('not.exist');
cy.get('.ant-message-notice-content:visible', { timout: 12000 }).should('not.exist');
}); });
// vn: view name // vn: view name

10
scripts/sdk/swagger.json

@ -6295,7 +6295,9 @@
] ]
} }
}, },
"required": ["title"] "required": [
"title"
]
}, },
"TableInfo": { "TableInfo": {
"title": "Table", "title": "Table",
@ -7394,7 +7396,8 @@
"locked", "locked",
"personal" "personal"
] ]
} },
"meta": {}
} }
}, },
"FormColumn": { "FormColumn": {
@ -7457,7 +7460,8 @@
"description": { "description": {
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
} },
"meta": {}
} }
}, },
"Paginated": { "Paginated": {

Loading…
Cancel
Save