Browse Source

Merge pull request #5608 from nocodb/feat/export-nest

feat: duplicate project and table
pull/5621/head
navi 2 years ago committed by GitHub
parent
commit
ee8fe005a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 60
      packages/nc-gui/components/dashboard/TreeView.vue
  2. 113
      packages/nc-gui/components/dlg/AirtableImport.vue
  3. 84
      packages/nc-gui/components/dlg/ProjectDuplicate.vue
  4. 84
      packages/nc-gui/components/dlg/TableDuplicate.vue
  5. 10
      packages/nc-gui/lib/enums.ts
  6. 16
      packages/nc-gui/nuxt-shim.d.ts
  7. 75
      packages/nc-gui/pages/index/index/index.vue
  8. 100
      packages/nc-gui/plugins/jobs.ts
  9. 12992
      packages/nc-plugin/package-lock.json
  10. 1
      packages/nc-plugin/package.json
  11. 16
      packages/nc-plugin/src/lib/IStorageAdapterV2.ts
  12. 3
      packages/nocodb-legacy/.gitignore
  13. 105
      packages/nocodb-legacy/package-lock.json
  14. 2
      packages/nocodb-legacy/package.json
  15. 19
      packages/nocodb-legacy/src/lib/controllers/exportImport/export.ctl.ts
  16. 39
      packages/nocodb-legacy/src/lib/controllers/exportImport/import.ctl.ts
  17. 7
      packages/nocodb-legacy/src/lib/controllers/exportImport/index.ts
  18. 87
      packages/nocodb-legacy/src/lib/db/sql-client/lib/sqlite/SqliteClient.ts
  19. 41
      packages/nocodb-legacy/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  20. 3
      packages/nocodb-legacy/src/lib/meta/api/index.ts
  21. 20
      packages/nocodb-legacy/src/lib/plugins/backblaze/Backblaze.ts
  22. 18
      packages/nocodb-legacy/src/lib/plugins/gcs/Gcs.ts
  23. 20
      packages/nocodb-legacy/src/lib/plugins/linode/LinodeObjectStorage.ts
  24. 22
      packages/nocodb-legacy/src/lib/plugins/mino/Minio.ts
  25. 20
      packages/nocodb-legacy/src/lib/plugins/ovhCloud/OvhCloud.ts
  26. 18
      packages/nocodb-legacy/src/lib/plugins/s3/S3.ts
  27. 16
      packages/nocodb-legacy/src/lib/plugins/scaleway/ScalewayObjectStorage.ts
  28. 20
      packages/nocodb-legacy/src/lib/plugins/spaces/Spaces.ts
  29. 18
      packages/nocodb-legacy/src/lib/plugins/upcloud/UpoCloud.ts
  30. 18
      packages/nocodb-legacy/src/lib/plugins/vultr/Vultr.ts
  31. 5
      packages/nocodb-legacy/src/lib/services/dbData/bulkData.ts
  32. 493
      packages/nocodb-legacy/src/lib/services/exportImport/export.svc.ts
  33. 844
      packages/nocodb-legacy/src/lib/services/exportImport/import.svc.ts
  34. 2
      packages/nocodb-legacy/src/lib/services/index.ts
  35. 2
      packages/nocodb-legacy/src/lib/services/metaDiff.svc.ts
  36. 27
      packages/nocodb-legacy/src/lib/v1-legacy/plugins/adapters/storage/Local.ts
  37. 146
      packages/nocodb-sdk/src/lib/Api.ts
  38. 6
      packages/nocodb/.gitignore
  39. 242
      packages/nocodb/package-lock.json
  40. 8
      packages/nocodb/package.json
  41. 4
      packages/nocodb/src/Noco.ts
  42. 12
      packages/nocodb/src/app.module.ts
  43. 6
      packages/nocodb/src/controllers/imports/helpers/NocoSyncDestAdapter.ts
  44. 7
      packages/nocodb/src/controllers/imports/helpers/NocoSyncSourceAdapter.ts
  45. 2480
      packages/nocodb/src/controllers/imports/helpers/job.ts
  46. 21
      packages/nocodb/src/controllers/imports/import.controller.spec.ts
  47. 148
      packages/nocodb/src/controllers/imports/import.controller.ts
  48. 30
      packages/nocodb/src/controllers/test/TestResetService/index.ts
  49. 81
      packages/nocodb/src/db/BaseModelSqlv2.ts
  50. 87
      packages/nocodb/src/db/sql-client/lib/sqlite/SqliteClient.ts
  51. 12
      packages/nocodb/src/gateways/socket.gateway.spec.ts
  52. 43
      packages/nocodb/src/gateways/socket.gateway.ts
  53. 82
      packages/nocodb/src/helpers/exportImportHelpers.ts
  54. 6
      packages/nocodb/src/helpers/populateMeta.ts
  55. 22
      packages/nocodb/src/interface/Jobs.ts
  56. 35
      packages/nocodb/src/jobs/EmitteryJobsMgr.ts
  57. 67
      packages/nocodb/src/jobs/JobsMgr.ts
  58. 20
      packages/nocodb/src/jobs/NocoJobs.ts
  59. 56
      packages/nocodb/src/jobs/RedisJobsMgr.ts
  60. 1
      packages/nocodb/src/models/Project.ts
  61. 6
      packages/nocodb/src/modules/global/global.module.ts
  62. 65
      packages/nocodb/src/modules/jobs/at-import/at-import.controller.ts
  63. 2516
      packages/nocodb/src/modules/jobs/at-import/at-import.processor.ts
  64. 0
      packages/nocodb/src/modules/jobs/at-import/helpers/EntityMap.ts
  65. 0
      packages/nocodb/src/modules/jobs/at-import/helpers/fetchAT.ts
  66. 5
      packages/nocodb/src/modules/jobs/at-import/helpers/readAndProcessData.ts
  67. 0
      packages/nocodb/src/modules/jobs/at-import/helpers/syncMap.ts
  68. 135
      packages/nocodb/src/modules/jobs/export-import/duplicate.controller.ts
  69. 575
      packages/nocodb/src/modules/jobs/export-import/duplicate.processor.ts
  70. 704
      packages/nocodb/src/modules/jobs/export-import/export.service.ts
  71. 1402
      packages/nocodb/src/modules/jobs/export-import/import.service.ts
  72. 136
      packages/nocodb/src/modules/jobs/fallback-queue.service.ts
  73. 69
      packages/nocodb/src/modules/jobs/jobs-event.service.ts
  74. 121
      packages/nocodb/src/modules/jobs/jobs.gateway.ts
  75. 39
      packages/nocodb/src/modules/jobs/jobs.module.ts
  76. 59
      packages/nocodb/src/modules/jobs/jobs.service.ts
  77. 20
      packages/nocodb/src/modules/metas/metas.module.ts
  78. 16
      packages/nocodb/src/plugins/backblaze/Backblaze.ts
  79. 16
      packages/nocodb/src/plugins/gcs/Gcs.ts
  80. 16
      packages/nocodb/src/plugins/linode/LinodeObjectStorage.ts
  81. 16
      packages/nocodb/src/plugins/mino/Minio.ts
  82. 16
      packages/nocodb/src/plugins/ovhCloud/OvhCloud.ts
  83. 16
      packages/nocodb/src/plugins/s3/S3.ts
  84. 16
      packages/nocodb/src/plugins/scaleway/ScalewayObjectStorage.ts
  85. 16
      packages/nocodb/src/plugins/spaces/Spaces.ts
  86. 33
      packages/nocodb/src/plugins/storage/Local.ts
  87. 16
      packages/nocodb/src/plugins/upcloud/UpoCloud.ts
  88. 16
      packages/nocodb/src/plugins/vultr/Vultr.ts
  89. 264
      packages/nocodb/src/schema/swagger.json
  90. 15
      packages/nocodb/src/services/bulk-data-alias.service.ts
  91. 20
      packages/nocodb/src/services/datas.service.ts
  92. 6
      packages/nocodb/src/services/meta-diffs.service.ts
  93. 2
      packages/nocodb/src/services/projects.service.ts
  94. 8
      tests/playwright/fixtures/expectedData.txt
  95. 38
      tests/playwright/fixtures/expectedDataSqlite.txt
  96. 55
      tests/playwright/package-lock.json
  97. 2
      tests/playwright/package.json
  98. 15
      tests/playwright/pages/Dashboard/WebhookForm/index.ts
  99. 29
      tests/playwright/pages/ProjectsPage/index.ts
  100. 10
      tests/playwright/quickTests/commonTest.ts
  101. Some files were not shown because too many files have changed in this diff Show More

60
packages/nc-gui/components/dashboard/TreeView.vue

@ -9,6 +9,7 @@ import type { VNodeRef } from '#imports'
import {
ClientType,
Empty,
JobStatus,
TabType,
computed,
extractSdkResponseErrorMsg,
@ -37,7 +38,7 @@ const { isMobileMode } = useGlobal()
const { addTab, updateTab } = useTabs()
const { $api, $e } = useNuxtApp()
const { $api, $e, $jobs } = useNuxtApp()
const projectStore = useProject()
@ -389,6 +390,38 @@ const setIcon = async (icon: string, table: TableType) => {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const duplicateTable = async (table: TableType) => {
if (!table || !table.id || !table.project_id) return
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgTableDuplicate'), {
'modelValue': isOpen,
'table': table,
'onOk': async (jobData: { name: string; id: string }) => {
$jobs.subscribe({ name: jobData.name, id: jobData.id }, undefined, async (status: string, data?: any) => {
if (status === JobStatus.COMPLETED) {
await loadTables()
const newTable = tables.value.find((el) => el.id === data?.result?.id)
if (newTable) addTableTab(newTable)
} else if (status === JobStatus.FAILED) {
message.error('Failed to duplicate table')
await loadTables()
}
})
$e('a:table:duplicate')
},
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
</script>
<template>
@ -734,6 +767,16 @@ const setIcon = async (icon: string, table: TableType) => {
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('table-duplicate') && !table.mm"
v-e="['c:table:duplicate']"
@click="duplicateTable(table)"
>
<div class="nc-project-menu-item">
{{ $t('general.duplicate') }}
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('table-delete')"
:data-testid="`sidebar-table-delete-${table.title}`"
@ -1050,6 +1093,12 @@ const setIcon = async (icon: string, table: TableType) => {
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('table-duplicate') && !table.mm" @click="duplicateTable(table)">
<div class="nc-project-menu-item">
{{ $t('general.duplicate') }}
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('table-delete')"
:data-testid="`sidebar-table-delete-${table.title}`"
@ -1085,6 +1134,15 @@ const setIcon = async (icon: string, table: TableType) => {
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('table-duplicate') && !contextMenuTarget.value.mm"
@click="duplicateTable(contextMenuTarget.value)"
>
<div class="nc-project-menu-item">
{{ $t('general.duplicate') }}
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('table-delete')" @click="deleteTable(contextMenuTarget.value)">
<div class="nc-project-menu-item">
{{ $t('general.delete') }}

113
packages/nc-gui/components/dlg/AirtableImport.vue

@ -1,16 +1,14 @@
<script setup lang="ts">
import type { Socket } from 'socket.io-client'
import io from 'socket.io-client'
import type { Card as AntCard } from 'ant-design-vue'
import {
Form,
JobStatus,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
iconMap,
message,
nextTick,
onBeforeUnmount,
onMounted,
ref,
storeToRefs,
@ -31,7 +29,7 @@ const { appInfo } = $(useGlobal())
const baseURL = appInfo.ncSiteUrl
const { $state } = useNuxtApp()
const { $state, $jobs } = useNuxtApp()
const projectStore = useProject()
@ -49,8 +47,6 @@ const logRef = ref<typeof AntCard>()
const enableAbort = ref(false)
let socket: Socket | null
const syncSource = ref({
id: '',
type: 'Airtable',
@ -72,6 +68,35 @@ const syncSource = ref({
},
})
const pushProgress = async (message: string, status: JobStatus | 'progress') => {
progress.value.push({ msg: message, status })
await nextTick(() => {
const container: HTMLDivElement = logRef.value?.$el?.firstElementChild
if (!container) return
container.scrollTop = container.scrollHeight
})
}
const onSubscribe = () => {
step.value = 2
}
const onStatus = async (status: JobStatus, data?: any) => {
if (status === JobStatus.COMPLETED) {
showGoToDashboardButton.value = true
await loadTables()
pushProgress('Done!', status)
// TODO: add tab of the first table
} else if (status === JobStatus.FAILED) {
pushProgress(data.error.message, status)
}
}
const onLog = (data: { message: string }) => {
pushProgress(data.message, 'progress')
}
const validators = computed(() => ({
'details.apiKey': [fieldRequiredValidator()],
'details.syncSourceUrlOrId': [fieldRequiredValidator()],
@ -130,7 +155,7 @@ async function loadSyncSrc() {
srcs[0].details = srcs[0].details || {}
syncSource.value = migrateSync(srcs[0])
syncSource.value.details.syncSourceUrlOrId = srcs[0].details.shareId
socket?.emit('subscribe', syncSource.value.id)
$jobs.subscribe({ syncId: syncSource.value.id }, onSubscribe, onStatus, onLog)
} else {
syncSource.value = {
id: '',
@ -161,11 +186,8 @@ async function sync() {
baseURL,
method: 'POST',
headers: { 'xc-auth': $state.token.value as string },
params: {
id: socket?.id,
},
})
socket?.emit('subscribe', syncSource.value.id)
$jobs.subscribe({ syncId: syncSource.value.id }, onSubscribe, onStatus, onLog)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
@ -183,9 +205,6 @@ async function abort() {
baseURL,
method: 'POST',
headers: { 'xc-auth': $state.token.value as string },
params: {
id: socket?.id,
},
})
step.value = 1
} catch (e: any) {
@ -223,67 +242,12 @@ watch(
)
onMounted(async () => {
socket = io(new URL(baseURL, window.location.href.split(/[?#]/)[0]).href, {
extraHeaders: { 'xc-auth': $state.token.value as string },
})
socket.on('progress', async (d: Record<string, any>) => {
progress.value.push(d)
await nextTick(() => {
const container: HTMLDivElement = logRef.value?.$el?.firstElementChild
if (!container) return
container.scrollTop = container.scrollHeight
})
if (d.status === 'COMPLETED') {
showGoToDashboardButton.value = true
await loadTables()
// TODO: add tab of the first table
}
})
socket.on('disconnect', () => {
console.log('socket disconnected')
const rcInterval = setInterval(() => {
if (socket?.connected) {
clearInterval(rcInterval)
socket?.emit('subscribe', syncSource.value.id)
} else {
socket?.connect()
}
}, 2000)
})
socket.on('job', () => {
step.value = 2
})
// connect event does not provide data
socket.on('connect', () => {
console.log('socket connected')
if (syncSource.value.id) {
socket?.emit('subscribe', syncSource.value.id)
}
})
socket?.io.on('reconnect', () => {
console.log('socket reconnected')
if (syncSource.value.id) {
socket?.emit('subscribe', syncSource.value.id)
}
})
if (syncSource.value.id) {
$jobs.subscribe({ syncId: syncSource.value.id }, onSubscribe, onStatus, onLog)
}
await loadSyncSrc()
})
onBeforeUnmount(() => {
if (socket) {
socket.off('disconnect')
socket.disconnect()
socket.removeAllListeners()
}
})
</script>
<template>
@ -407,7 +371,7 @@ onBeforeUnmount(() => {
<a-card ref="logRef" :body-style="{ backgroundColor: '#000000', height: '400px', overflow: 'auto' }">
<div v-for="({ msg, status }, i) in progress" :key="i">
<div v-if="status === 'FAILED'" class="flex items-center">
<div v-if="status === JobStatus.FAILED" class="flex items-center">
<component :is="iconMap.closeCircle" class="text-red-500" />
<span class="text-red-500 ml-2">{{ msg }}</span>
@ -424,7 +388,8 @@ onBeforeUnmount(() => {
v-if="
!progress ||
!progress.length ||
(progress[progress.length - 1].status !== 'COMPLETED' && progress[progress.length - 1].status !== 'FAILED')
(progress[progress.length - 1].status !== JobStatus.COMPLETED &&
progress[progress.length - 1].status !== JobStatus.FAILED)
"
class="flex items-center"
>

84
packages/nc-gui/components/dlg/ProjectDuplicate.vue

@ -0,0 +1,84 @@
<script setup lang="ts">
import type { ProjectType } from 'nocodb-sdk'
import { useVModel } from '#imports'
const props = defineProps<{
modelValue: boolean
project: ProjectType
onOk: (jobData: { name: string; id: string }) => Promise<void>
}>()
const emit = defineEmits(['update:modelValue'])
const { api } = useApi()
const dialogShow = useVModel(props, 'modelValue', emit)
const options = ref({
includeData: true,
includeViews: true,
includeHooks: true,
})
const optionsToExclude = computed(() => {
const { includeData, includeViews, includeHooks } = options.value
return {
excludeData: !includeData,
excludeViews: !includeViews,
excludeHooks: !includeHooks,
}
})
const isLoading = ref(false)
const _duplicate = async () => {
isLoading.value = true
try {
const jobData = await api.project.duplicate(props.project.id as string, optionsToExclude.value)
props.onOk(jobData as any)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
isLoading.value = false
dialogShow.value = false
}
const isEaster = ref(false)
</script>
<template>
<a-modal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
width="max(30vw, 600px)"
centered
wrap-class-name="nc-modal-project-duplicate"
@keydown.esc="dialogShow = false"
>
<template #footer>
<a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" size="large" type="primary" :loading="isLoading" @click="_duplicate"
>{{ $t('general.confirm') }}
</a-button>
</template>
<div class="pl-10 pr-10 pt-5">
<div class="prose-xl font-bold self-center my-4" @dblclick="isEaster = !isEaster">{{ $t('general.duplicate') }}</div>
<div class="mb-2">Are you sure you want to duplicate the `{{ project.title }}` project?</div>
<div class="prose-md self-center text-gray-500 mt-4">{{ $t('title.advancedSettings') }}</div>
<a-divider class="!m-0 !p-0 !my-2" />
<div class="text-xs p-2">
<a-checkbox v-model:checked="options.includeData">Include data</a-checkbox>
<a-checkbox v-model:checked="options.includeViews">Include views</a-checkbox>
<a-checkbox v-show="isEaster" v-model:checked="options.includeHooks">Include webhooks</a-checkbox>
</div>
</div>
</a-modal>
</template>
<style scoped lang="scss"></style>

84
packages/nc-gui/components/dlg/TableDuplicate.vue

@ -0,0 +1,84 @@
<script setup lang="ts">
import type { TableType } from 'nocodb-sdk'
import { useVModel } from '#imports'
const props = defineProps<{
modelValue: boolean
table: TableType
onOk: (jobData: { name: string; id: string }) => Promise<void>
}>()
const emit = defineEmits(['update:modelValue'])
const { api } = useApi()
const dialogShow = useVModel(props, 'modelValue', emit)
const options = ref({
includeData: true,
includeViews: true,
includeHooks: true,
})
const optionsToExclude = computed(() => {
const { includeData, includeViews, includeHooks } = options.value
return {
excludeData: !includeData,
excludeViews: !includeViews,
excludeHooks: !includeHooks,
}
})
const isLoading = ref(false)
const _duplicate = async () => {
isLoading.value = true
try {
const jobData = await api.dbTable.duplicate(props.table.project_id!, props.table.id!, optionsToExclude.value)
props.onOk(jobData as any)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
isLoading.value = false
dialogShow.value = false
}
const isEaster = ref(false)
</script>
<template>
<a-modal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
width="max(30vw, 600px)"
centered
wrap-class-name="nc-modal-table-duplicate"
@keydown.esc="dialogShow = false"
>
<template #footer>
<a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" size="large" type="primary" :loading="isLoading" @click="_duplicate"
>{{ $t('general.confirm') }}
</a-button>
</template>
<div class="pl-10 pr-10 pt-5">
<div class="prose-xl font-bold self-center my-4" @dblclick="isEaster = !isEaster">{{ $t('general.duplicate') }}</div>
<div class="mb-2">Are you sure you want to duplicate the `{{ table.title }}` table?</div>
<div class="prose-md self-center text-gray-500 mt-4">{{ $t('title.advancedSettings') }}</div>
<a-divider class="!m-0 !p-0 !my-2" />
<div class="text-xs p-2">
<a-checkbox v-model:checked="options.includeData">Include data</a-checkbox>
<a-checkbox v-model:checked="options.includeViews">Include views</a-checkbox>
<a-checkbox v-show="isEaster" v-model:checked="options.includeHooks">Include hooks</a-checkbox>
</div>
</div>
</a-modal>
</template>
<style scoped lang="scss"></style>

10
packages/nc-gui/lib/enums.ts

@ -105,3 +105,13 @@ export enum AutomationLogLevel {
ERROR = 'ERROR',
ALL = 'ALL',
}
export enum JobStatus {
COMPLETED = 'completed',
WAITING = 'waiting',
ACTIVE = 'active',
DELAYED = 'delayed',
FAILED = 'failed',
PAUSED = 'paused',
REFRESH = 'refresh',
}

16
packages/nc-gui/nuxt-shim.d.ts vendored

@ -1,6 +1,6 @@
import type { Api as BaseAPI } from 'nocodb-sdk'
import type { UseGlobalReturn } from './composables/useGlobal/types'
import type { NocoI18n } from './lib'
import type { JobStatus, NocoI18n } from './lib'
import type { TabType } from './composables'
declare module '#app/nuxt' {
@ -13,6 +13,20 @@ declare module '#app/nuxt' {
/** {@link import('./plugins/tele') Telemetry} Emit telemetry event */
$e: (event: string, data?: any) => void
$state: UseGlobalReturn
$jobs: {
subscribe(
job:
| {
id: string
name: string
}
| any,
subscribedCb?: () => void,
statusCb?: ((status: JobStatus, error?: any) => void) | undefined,
logCb?: ((data: { message: string }) => void) | undefined,
): void
getStatus(name: string, id: string): Promise<string>
}
}
}

75
packages/nc-gui/pages/index/index/index.vue

@ -4,6 +4,7 @@ import tinycolor from 'tinycolor2'
import { breakpointsTailwind } from '@vueuse/core'
import {
Empty,
JobStatus,
Modal,
computed,
definePageMeta,
@ -27,7 +28,7 @@ definePageMeta({
title: 'title.myProject',
})
const { $api, $e } = useNuxtApp()
const { $api, $e, $jobs } = useNuxtApp()
const { api, isLoading } = useApi()
@ -39,9 +40,17 @@ const filterQuery = ref('')
const projects = ref<ProjectType[]>()
const activePage = ref(1)
const pageChange = (p: number) => {
activePage.value = p
}
const loadProjects = async () => {
const lastActivePage = activePage.value
const response = await api.project.list({})
projects.value = response.list
activePage.value = lastActivePage
}
const filteredProjects = computed(
@ -74,6 +83,36 @@ const deleteProject = (project: ProjectType) => {
})
}
const duplicateProject = (project: ProjectType) => {
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgProjectDuplicate'), {
'modelValue': isOpen,
'project': project,
'onOk': async (jobData: { name: string; id: string }) => {
await loadProjects()
$jobs.subscribe({ name: jobData.name, id: jobData.id }, undefined, async (status: string) => {
if (status === JobStatus.COMPLETED) {
await loadProjects()
} else if (status === JobStatus.FAILED) {
message.error('Failed to duplicate project')
await loadProjects()
}
})
$e('a:project:duplicate')
},
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
const handleProjectColor = async (projectId: string, color: string) => {
const tcolor = tinycolor(color)
@ -122,7 +161,7 @@ const getProjectPrimary = (project: ProjectType) => {
const customRow = (record: ProjectType) => ({
onClick: async () => {
await navigateTo(`/nc/${record.id}`)
if (record.status !== 'job') await navigateTo(`/nc/${record.id}`)
$e('a:project:open')
},
@ -196,7 +235,7 @@ const copyProjectMeta = async () => {
v-else
:custom-row="customRow"
:data-source="filteredProjects"
:pagination="{ position: ['bottomCenter'] }"
:pagination="{ 'position': ['bottomCenter'], 'current': activePage, 'onUpdate:current': pageChange }"
:table-layout="md ? 'auto' : 'fixed'"
>
<template #emptyText>
@ -249,8 +288,13 @@ const copyProjectMeta = async () => {
</a-menu>
</div>
<div
class="capitalize color-transition group-hover:text-primary !w-[400px] h-full overflow-hidden overflow-ellipsis whitespace-nowrap pl-2"
class="flex capitalize color-transition group-hover:text-primary !w-[400px] h-full overflow-hidden overflow-ellipsis whitespace-nowrap pl-2"
>
<component
:is="iconMap.reload"
v-if="record.status === 'job'"
:class="{ 'animate-infinite animate-spin text-gray-500': record.status === 'job' }"
/>
{{ text }}
</div>
</div>
@ -260,7 +304,7 @@ const copyProjectMeta = async () => {
<a-table-column key="id" :title="$t('labels.actions')" data-index="id">
<template #default="{ text, record }">
<div class="flex items-center gap-2">
<div v-if="record.status !== 'job'" class="flex items-center gap-2">
<component
:is="iconMap.edit"
v-e="['c:project:edit:rename']"
@ -274,6 +318,25 @@ const copyProjectMeta = async () => {
:data-testid="`delete-project-${record.title}`"
@click.stop="deleteProject(record)"
/>
<a-dropdown :trigger="['click']" overlay-class-name="nc-dropdown-import-menu" @click.stop>
<GeneralIcon
icon="threeDotVertical"
class="nc-import-menu outline-0"
:data-testid="`p-three-dot-${record.title}`"
/>
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
<a-menu-item key="duplicate" v-e="['c:project:duplicate']" @click.stop="duplicateProject(record)">
<div class="color-transition nc-project-menu-item group" :data-testid="`dupe-project-${record.title}`">
<GeneralIcon icon="copy" class="group-hover:text-accent" />
Duplicate
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</a-table-column>
@ -282,7 +345,7 @@ const copyProjectMeta = async () => {
</div>
</template>
<style scoped>
<style lang="scss" scoped>
.nc-action-btn {
@apply text-gray-500 group-hover:text-accent active:(ring ring-accent ring-opacity-100) cursor-pointer p-2 w-[30px] h-[30px] hover:bg-gray-300/50 rounded-full;
}

100
packages/nc-gui/plugins/jobs.ts

@ -0,0 +1,100 @@
import type { Socket } from 'socket.io-client'
import io from 'socket.io-client'
import { JobStatus, defineNuxtPlugin, useGlobal, watch } from '#imports'
export default defineNuxtPlugin(async (nuxtApp) => {
const { appInfo } = $(useGlobal())
let socket: Socket | null = null
let messageIndex = 0
const init = async (token: string) => {
try {
if (socket) socket.disconnect()
const url = new URL(appInfo.ncSiteUrl, window.location.href.split(/[?#]/)[0])
socket = io(`${url.href}jobs`, {
extraHeaders: { 'xc-auth': token },
})
socket.on('connect_error', (e) => {
console.error(e)
socket?.disconnect()
})
} catch {}
}
if (nuxtApp.$state.signedIn.value) {
await init(nuxtApp.$state.token.value)
}
const send = (name: string, data: any) => {
if (socket) {
const _id = messageIndex++
socket.emit(name, { _id, data })
return _id
}
}
const jobs = {
subscribe(
job: { id: string; name: string } | any,
subscribedCb?: () => void,
statusCb?: (status: JobStatus, data?: any) => void,
logCb?: (data: { message: string }) => void,
) {
const logFn = (data: { id: string; name: string; data: { message: string } }) => {
if (data.id === job.id) {
if (logCb) logCb(data.data)
}
}
const statusFn = (data: any) => {
if (data.id === job.id) {
if (statusCb) statusCb(data.status, data.data)
if (data.status === JobStatus.COMPLETED || data.status === JobStatus.FAILED) {
socket?.off('status', statusFn)
socket?.off('log', logFn)
}
}
}
const _id = send('subscribe', job)
const subscribeFn = (data: { _id: number; name: string; id: string }) => {
if (data._id === _id) {
if (data.id !== job.id || data.name !== job.name) {
job.id = data.id
job.name = data.name
}
if (subscribedCb) subscribedCb()
socket?.on('log', logFn)
socket?.on('status', statusFn)
socket?.off('subscribed', subscribeFn)
}
}
socket?.on('subscribed', subscribeFn)
},
getStatus(name: string, id: string): Promise<string> {
return new Promise((resolve) => {
if (socket) {
const _id = send('status', { name, id })
const tempFn = (data: any) => {
if (data._id === _id) {
resolve(data.status)
socket?.off('status', tempFn)
}
}
socket.on('status', tempFn)
}
})
},
}
watch((nuxtApp.$state as ReturnType<typeof useGlobal>).token, (newToken, oldToken) => {
if (newToken && newToken !== oldToken) init(newToken)
else if (!newToken) socket?.disconnect()
})
nuxtApp.provide('jobs', jobs)
})

12992
packages/nc-plugin/package-lock.json generated

File diff suppressed because it is too large Load Diff

1
packages/nc-plugin/package.json

@ -58,6 +58,7 @@
},
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@types/node": "^18.11.9",
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"ava": "^5.2.0",

16
packages/nc-plugin/src/lib/IStorageAdapterV2.ts

@ -1,11 +1,27 @@
import { Readable } from 'stream';
import IStorageAdapter from './IStorageAdapter';
/*
#ref: https://github.com/nocodb/nocodb/pull/5608
fileCreateByStream: write file from a readable stream to the storage
fileReadByStream: read file from the storage to a readable stream
getDirectoryList: get files available in a directory
These methods are added to support export/import without buffering all the data in memory.
The methods are only available for Local storage adapter for now, and will be implemented for other adapters later.
The methods are not used in current codebase, but will be used in future.
*/
export default interface IStorageAdapterV2 extends IStorageAdapter {
fileCreateByUrl(
destPath: string,
url: string,
fileMeta?: FileMeta
): Promise<any>;
fileCreateByStream(destPath: string, readStream: Readable): Promise<void>;
fileReadByStream(key: string): Promise<Readable>;
getDirectoryList(path: string): Promise<string[]>;
}
interface FileMeta {

3
packages/nocodb-legacy/.gitignore vendored

@ -19,4 +19,5 @@ noco.db*
test_meta.db
test_sakila.db
test_sakila_*.db
.env
.env
export/**

105
packages/nocodb-legacy/package-lock.json generated

@ -68,7 +68,7 @@
"nanoid": "^3.1.20",
"nc-help": "0.2.87",
"nc-lib-gui": "0.106.1",
"nc-plugin": "0.1.2",
"nc-plugin": "file:../nc-plugin",
"ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk",
"nodemailer": "^6.4.10",
@ -155,8 +155,42 @@
"vuedraggable": "^2.24.3"
}
},
"../nc-plugin": {
"version": "0.1.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"nc-common": "0.0.6"
},
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@types/node": "^18.11.9",
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"ava": "^5.2.0",
"codecov": "^3.5.0",
"cspell": "^4.1.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.0.2",
"eslint-plugin-import": "^2.22.0",
"gh-pages": "^3.1.0",
"npm-run-all": "^4.1.5",
"nyc": "^15.1.0",
"open-cli": "^7.1.0",
"prettier": "^2.1.1",
"standard-version": "^9.0.0",
"ts-node": "^9.0.0",
"typedoc": "^0.19.0",
"typescript": "^4.0.2"
},
"engines": {
"node": ">=10"
}
},
"../nocodb-sdk": {
"version": "0.106.1",
"version": "0.107.0-beta.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",
@ -614,14 +648,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@bitauth/libauth": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-1.19.1.tgz",
"integrity": "sha512-R524tD5VwOt3QRHr7N518nqTVR/HKgfWL4LypekcGuNQN8R4PWScvuRcRzrY39A28kLztMv+TJdiKuMNbkU1ug==",
"engines": {
"node": ">=8.9"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@ -11302,14 +11328,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/nc-common": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/nc-common/-/nc-common-0.0.6.tgz",
"integrity": "sha512-3AryS9uwa5NfISLxMciUonrH7YfXp+nlahB9T7girXIsLQrmwX4MdnuKs32akduCOGpKmjTJSWmATULbuMkbfw==",
"engines": {
"node": ">=10"
}
},
"node_modules/nc-help": {
"version": "0.2.87",
"resolved": "https://registry.npmjs.org/nc-help/-/nc-help-0.2.87.tgz",
@ -11338,16 +11356,8 @@
}
},
"node_modules/nc-plugin": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/nc-plugin/-/nc-plugin-0.1.2.tgz",
"integrity": "sha512-9NZJPIgD6r3GnCNJelfZFga+RQPDuHZCoFus2qaZsNdbJEP7LSTOeZ1Pw8l89fvVbp0VFLO3Bcg8ggJppKt8JQ==",
"dependencies": {
"@bitauth/libauth": "^1.17.1",
"nc-common": "0.0.6"
},
"engines": {
"node": ">=10"
}
"resolved": "../nc-plugin",
"link": true
},
"node_modules/ncp": {
"version": "2.0.0",
@ -19575,11 +19585,6 @@
"regenerator-runtime": "^0.13.11"
}
},
"@bitauth/libauth": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-1.19.1.tgz",
"integrity": "sha512-R524tD5VwOt3QRHr7N518nqTVR/HKgfWL4LypekcGuNQN8R4PWScvuRcRzrY39A28kLztMv+TJdiKuMNbkU1ug=="
},
"@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@ -28023,11 +28028,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"nc-common": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/nc-common/-/nc-common-0.0.6.tgz",
"integrity": "sha512-3AryS9uwa5NfISLxMciUonrH7YfXp+nlahB9T7girXIsLQrmwX4MdnuKs32akduCOGpKmjTJSWmATULbuMkbfw=="
},
"nc-help": {
"version": "0.2.87",
"resolved": "https://registry.npmjs.org/nc-help/-/nc-help-0.2.87.tgz",
@ -28050,12 +28050,31 @@
}
},
"nc-plugin": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/nc-plugin/-/nc-plugin-0.1.2.tgz",
"integrity": "sha512-9NZJPIgD6r3GnCNJelfZFga+RQPDuHZCoFus2qaZsNdbJEP7LSTOeZ1Pw8l89fvVbp0VFLO3Bcg8ggJppKt8JQ==",
"version": "file:../nc-plugin",
"requires": {
"@bitauth/libauth": "^1.17.1",
"nc-common": "0.0.6"
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@types/node": "^18.11.9",
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"ava": "^5.2.0",
"codecov": "^3.5.0",
"cspell": "^4.1.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.0.2",
"eslint-plugin-import": "^2.22.0",
"gh-pages": "^3.1.0",
"nc-common": "0.0.6",
"npm-run-all": "^4.1.5",
"nyc": "^15.1.0",
"open-cli": "^7.1.0",
"prettier": "^2.1.1",
"standard-version": "^9.0.0",
"ts-node": "^9.0.0",
"typedoc": "^0.19.0",
"typescript": "^4.0.2"
}
},
"ncp": {

2
packages/nocodb-legacy/package.json

@ -110,7 +110,7 @@
"nanoid": "^3.1.20",
"nc-help": "0.2.87",
"nc-lib-gui": "0.106.1",
"nc-plugin": "0.1.2",
"nc-plugin": "file:../nc-plugin",
"ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk",
"nodemailer": "^6.4.10",

19
packages/nocodb-legacy/src/lib/controllers/exportImport/export.ctl.ts

@ -0,0 +1,19 @@
import { Router } from 'express';
import ncMetaAclMw from '../../meta/helpers/ncMetaAclMw';
import { exportService } from '../../services';
import type { Request, Response } from 'express';
export async function exportBase(req: Request, res: Response) {
res.json(
await exportService.exportBase({ baseId: req.params.baseId, path: req.body.path })
);
}
const router = Router({ mergeParams: true });
router.post(
'/api/v1/db/meta/export/:projectId/:baseId',
ncMetaAclMw(exportBase, 'exportBase')
);
export default router;

39
packages/nocodb-legacy/src/lib/controllers/exportImport/import.ctl.ts

@ -0,0 +1,39 @@
import { Router } from 'express';
import ncMetaAclMw from '../../meta/helpers/ncMetaAclMw';
import { importService } from '../../services';
import type { Request, Response } from 'express';
export async function importModels(req: Request, res: Response) {
const { body, ...rest } = req;
res.json(
await importService.importModels({
user: (req as any).user,
projectId: req.params.projectId,
baseId: req.params.baseId,
data: Array.isArray(body) ? body : body.models,
req: rest,
})
);
}
export async function importBase(req: Request, res: Response) {
const { body, ...rest } = req;
res.json(
await importService.importBase({
user: (req as any).user,
projectId: req.params.projectId,
baseId: req.params.baseId,
src: body.src,
req: rest,
})
);
}
const router = Router({ mergeParams: true });
router.post(
'/api/v1/db/meta/import/:projectId/:baseId',
ncMetaAclMw(importBase, 'importBase')
);
export default router;

7
packages/nocodb-legacy/src/lib/controllers/exportImport/index.ts

@ -0,0 +1,7 @@
import exportController from './export.ctl';
import importController from './import.ctl';
export default {
exportController,
importController,
};

87
packages/nocodb-legacy/src/lib/db/sql-client/lib/sqlite/SqliteClient.ts

@ -200,7 +200,7 @@ class SqliteClient extends KnexClient {
table.integer('status').nullable();
table.dateTime('created');
table.timestamps();
}
},
);
log.debug('Table created:', `${args.tn}`, data);
} else {
@ -295,7 +295,7 @@ class SqliteClient extends KnexClient {
try {
const response = await this.sqlClient.raw(
`SELECT name as tn FROM sqlite_master where type = 'table'`
`SELECT name as tn FROM sqlite_master where type = 'table'`,
);
result.data.list = [];
@ -359,7 +359,7 @@ class SqliteClient extends KnexClient {
try {
const response = await this.sqlClient.raw(
`PRAGMA table_info("${args.tn}")`
`PRAGMA table_info("${args.tn}")`,
);
const triggerList = (await this.triggerList(args)).data.list;
@ -409,7 +409,7 @@ class SqliteClient extends KnexClient {
response[i].not_nullable = response[i].notnull === 1;
response[i].rqd = response[i].notnull === 1;
response[i].cdf = response[i].dflt_value;
response[i].pk = response[i].pk === 1;
response[i].pk = response[i].pk > 0;
response[i].cop = response[i].cid;
// https://stackoverflow.com/a/7906029
@ -420,7 +420,8 @@ class SqliteClient extends KnexClient {
response[i].dtxs = '';
response[i].au = !!triggerList.find(
({ trigger }) => trigger === `xc_trigger_${args.tn}_${response[i].cn}`
({ trigger }) =>
trigger === `xc_trigger_${args.tn}_${response[i].cn}`,
);
}
@ -466,7 +467,7 @@ class SqliteClient extends KnexClient {
// PRAGMA index_xinfo('idx_fk_original_language_id');
const response = await this.sqlClient.raw(
`PRAGMA index_list("${args.tn}")`
`PRAGMA index_list("${args.tn}")`,
);
const rows = [];
@ -478,7 +479,7 @@ class SqliteClient extends KnexClient {
response[i].unique = response[i].unique === 1 ? 1 : 0;
const colsInIndex = await this.sqlClient.raw(
`PRAGMA index_info('${response[i].key_name}')`
`PRAGMA index_info('${response[i].key_name}')`,
);
if (colsInIndex.length === 1) {
@ -531,7 +532,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`PRAGMA foreign_key_list('${args.tn}')`
`PRAGMA foreign_key_list('${args.tn}')`,
);
for (let i = 0; i < response.length; ++i) {
@ -582,7 +583,7 @@ class SqliteClient extends KnexClient {
for (let i = 0; i < tables.length; ++i) {
const response = await this.sqlClient.raw(
`PRAGMA foreign_key_list('${tables[i].tn}')`
`PRAGMA foreign_key_list('${tables[i].tn}')`,
);
for (let j = 0; j < response.length; ++j) {
@ -633,7 +634,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`select *, name as trigger_name from sqlite_master where type = 'trigger' and tbl_name='${args.tn}';`
`select *, name as trigger_name from sqlite_master where type = 'trigger' and tbl_name='${args.tn}';`,
);
for (let i = 0; i < response.length; ++i) {
@ -676,7 +677,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`show function status where db='${args.databaseName}'`
`show function status where db='${args.databaseName}'`,
);
if (response.length === 2) {
@ -730,7 +731,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`show procedure status where db='${args.databaseName}'`
`show procedure status where db='${args.databaseName}'`,
);
if (response.length === 2) {
@ -775,7 +776,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`SELECT * FROM sqlite_master WHERE type = 'view'`
`SELECT * FROM sqlite_master WHERE type = 'view'`,
);
for (let i = 0; i < response.length; ++i) {
@ -813,7 +814,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`SHOW CREATE FUNCTION ${args.function_name};`
`SHOW CREATE FUNCTION ${args.function_name};`,
);
if (response.length === 2) {
@ -865,7 +866,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`show create procedure ${args.procedure_name};`
`show create procedure ${args.procedure_name};`,
);
if (response.length === 2) {
@ -911,7 +912,7 @@ class SqliteClient extends KnexClient {
try {
const response = await this.sqlClient.raw(
`SELECT * FROM sqlite_master WHERE type = 'view' AND name = '${args.view_name}'`
`SELECT * FROM sqlite_master WHERE type = 'view' AND name = '${args.view_name}'`,
);
for (let i = 0; i < response.length; ++i) {
@ -938,7 +939,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`SHOW FULL TABLES IN ${args.databaseName} WHERE TABLE_TYPE LIKE 'VIEW';`
`SHOW FULL TABLES IN ${args.databaseName} WHERE TABLE_TYPE LIKE 'VIEW';`,
);
if (response.length === 2) {
@ -970,7 +971,7 @@ class SqliteClient extends KnexClient {
log.api(`${_func}:args:`, args);
const rows = await this.sqlClient.raw(
`create database ${args.database_name}`
`create database ${args.database_name}`,
);
return rows;
}
@ -981,7 +982,7 @@ class SqliteClient extends KnexClient {
log.api(`${_func}:args:`, args);
const rows = await this.sqlClient.raw(
`drop database ${args.database_name}`
`drop database ${args.database_name}`,
);
return rows;
}
@ -1011,7 +1012,7 @@ class SqliteClient extends KnexClient {
log.api(`${_func}:args:`, args);
const rows = await this.sqlClient.raw(
`DROP FUNCTION IF EXISTS ${args.function_name}`
`DROP FUNCTION IF EXISTS ${args.function_name}`,
);
return rows;
}
@ -1022,7 +1023,7 @@ class SqliteClient extends KnexClient {
log.api(`${_func}:args:`, args);
const rows = await this.sqlClient.raw(
`DROP PROCEDURE IF EXISTS ${args.procedure_name}`
`DROP PROCEDURE IF EXISTS ${args.procedure_name}`,
);
return rows;
}
@ -1042,7 +1043,7 @@ class SqliteClient extends KnexClient {
this._version = result.data.object;
log.debug(
`Version was empty for ${args.func}: population version for database as`,
this._version
this._version,
);
}
@ -1073,7 +1074,7 @@ class SqliteClient extends KnexClient {
log.api(`${func}:args:`, args);
try {
const rows = await this.sqlClient.raw(
`CREATE TRIGGER \`${args.function_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
`CREATE TRIGGER \`${args.function_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
);
result.data.list = rows;
} catch (e) {
@ -1101,7 +1102,7 @@ class SqliteClient extends KnexClient {
try {
await this.sqlClient.raw(`DROP TRIGGER ${args.function_name}`);
const rows = await this.sqlClient.raw(
`CREATE TRIGGER \`${args.function_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
`CREATE TRIGGER \`${args.function_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
);
result.data.list = rows;
} catch (e) {
@ -1128,7 +1129,7 @@ class SqliteClient extends KnexClient {
log.api(`${func}:args:`, args);
try {
const rows = await this.sqlClient.raw(
`CREATE TRIGGER \`${args.procedure_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
`CREATE TRIGGER \`${args.procedure_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
);
result.data.list = rows;
} catch (e) {
@ -1156,7 +1157,7 @@ class SqliteClient extends KnexClient {
try {
await this.sqlClient.raw(`DROP TRIGGER ${args.procedure_name}`);
const rows = await this.sqlClient.raw(
`CREATE TRIGGER \`${args.procedure_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
`CREATE TRIGGER \`${args.procedure_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
);
result.data.list = rows;
} catch (e) {
@ -1216,7 +1217,7 @@ class SqliteClient extends KnexClient {
try {
await this.sqlClient.raw(`DROP TRIGGER ${args.trigger_name}`);
await this.sqlClient.raw(
`CREATE TRIGGER \`${args.trigger_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
`CREATE TRIGGER \`${args.trigger_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
);
const upQuery = `DROP TRIGGER ${args.trigger_name};\nCREATE TRIGGER \`${args.trigger_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`;
@ -1507,13 +1508,13 @@ class SqliteClient extends KnexClient {
args.table,
args.columns[i],
oldColumn,
upQuery
upQuery,
);
downQuery += this.alterTableAddColumn(
args.table,
oldColumn,
args.columns[i],
downQuery
downQuery,
);
} else if (args.columns[i].altered & 2 || args.columns[i].altered & 8) {
// col edit
@ -1521,7 +1522,7 @@ class SqliteClient extends KnexClient {
args.table,
args.columns[i],
oldColumn,
upQuery
upQuery,
);
downQuery += ';';
// downQuery += this.alterTableChangeColumn(
@ -1537,7 +1538,7 @@ class SqliteClient extends KnexClient {
args.table,
args.columns[i],
oldColumn,
upQuery
upQuery,
);
downQuery += ';';
// downQuery += alterTableRemoveColumn(
@ -1553,7 +1554,7 @@ class SqliteClient extends KnexClient {
const pkQuery = this.alterTablePK(
args.columns,
args.originalColumns,
upQuery
upQuery,
);
await this.sqlClient.raw('PRAGMA foreign_keys = OFF;');
@ -1572,7 +1573,7 @@ class SqliteClient extends KnexClient {
if (pkQuery) {
await trx.schema.alterTable(args.table, (table) => {
for (const pk of pkQuery.oldPks.filter(
(el) => !pkQuery.newPks.includes(el)
(el) => !pkQuery.newPks.includes(el),
)) {
table.dropPrimary(pk);
}
@ -1858,7 +1859,7 @@ class SqliteClient extends KnexClient {
/* Filter relations for current table */
if (args.tn) {
relations = relations.filter(
(r) => r.tn === args.tn || r.rtn === args.tn
(r) => r.tn === args.tn || r.rtn === args.tn,
);
}
@ -1867,7 +1868,7 @@ class SqliteClient extends KnexClient {
let columns: any = await this.columnList({ tn: tables[i].tn });
columns = columns.data.list;
console.log(
`Sequelize model created: ${tables[i].tn}(${columns.length})\n`
`Sequelize model created: ${tables[i].tn}(${columns.length})\n`,
);
// let SqliteSequelizeRender = require('./SqliteSequelizeRender');
@ -1967,7 +1968,7 @@ class SqliteClient extends KnexClient {
query += this.genQuery(
`ALTER TABLE ?? DROP COLUMN ??`,
[t, n.cn],
shouldSanitize
shouldSanitize,
);
return query;
}
@ -2009,14 +2010,14 @@ class SqliteClient extends KnexClient {
const backupOldColumnQuery = this.genQuery(
`ALTER TABLE ?? RENAME COLUMN ?? TO ??;`,
[t, o.cn, `${o.cno}_nc_${suffix}`],
shouldSanitize
shouldSanitize,
);
let addNewColumnQuery = '';
addNewColumnQuery += this.genQuery(
` ADD ?? ${n.dt}`,
[n.cn],
shouldSanitize
shouldSanitize,
);
addNewColumnQuery += n.dtxp && n.dt !== 'text' ? `(${n.dtxp})` : '';
addNewColumnQuery += n.cdf
@ -2028,19 +2029,19 @@ class SqliteClient extends KnexClient {
addNewColumnQuery = this.genQuery(
`ALTER TABLE ?? ${addNewColumnQuery};`,
[t],
shouldSanitize
shouldSanitize,
);
const updateNewColumnQuery = this.genQuery(
`UPDATE ?? SET ?? = ??;`,
[t, n.cn, `${o.cno}_nc_${suffix}`],
shouldSanitize
shouldSanitize,
);
const dropOldColumnQuery = this.genQuery(
`ALTER TABLE ?? DROP COLUMN ??;`,
[t, `${o.cno}_nc_${suffix}`],
shouldSanitize
shouldSanitize,
);
query = `${backupOldColumnQuery}${addNewColumnQuery}${updateNewColumnQuery}${dropOldColumnQuery}`;
@ -2095,12 +2096,12 @@ class SqliteClient extends KnexClient {
try {
const tables = await this.sqlClient.raw(
`SELECT name FROM sqlite_master WHERE type='table';`
`SELECT name FROM sqlite_master WHERE type='table';`,
);
let count = 0;
for (const tb of tables) {
const tmp = await this.sqlClient.raw(
`SELECT COUNT(*) as ct FROM '${tb.name}';`
`SELECT COUNT(*) as ct FROM '${tb.name}';`,
);
if (tmp && tmp.length) {
count += tmp[0].ct;

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

@ -2066,13 +2066,18 @@ class BaseModelSqlv2 {
{
chunkSize: _chunkSize = 100,
cookie,
foreign_key_checks = true,
raw = false,
}: {
chunkSize?: number;
cookie?: any;
foreign_key_checks?: boolean;
raw?: boolean;
} = {}
) {
try {
const insertDatas = await Promise.all(
// TODO: ag column handling for raw bulk insert
const insertDatas = raw ? datas : await Promise.all(
datas.map(async (d) => {
await populatePk(this.model, d);
return this.model.mapAliasToColumn(d);
@ -2081,8 +2086,10 @@ class BaseModelSqlv2 {
// await this.beforeInsertb(insertDatas, null);
for (const data of datas) {
await this.validate(data);
if (!raw) {
for (const data of datas) {
await this.validate(data);
}
}
// fallbacks to `10` if database client is sqlite
@ -2090,18 +2097,38 @@ class BaseModelSqlv2 {
// refer : https://www.sqlite.org/limits.html
const chunkSize = this.isSqlite ? 10 : _chunkSize;
const trx = await this.dbDriver.transaction();
if (!foreign_key_checks) {
if (this.isPg) {
await trx.raw('set session_replication_role to replica;');
} else if (this.isMySQL) {
await trx.raw('SET foreign_key_checks = 0;');
}
}
const response =
this.isPg || this.isMssql
? await this.dbDriver
? await trx
.batchInsert(this.tnPath, insertDatas, chunkSize)
.returning(this.model.primaryKey?.column_name)
: await this.dbDriver.batchInsert(
: await trx.batchInsert(
this.tnPath,
insertDatas,
chunkSize
);
await this.afterBulkInsert(insertDatas, this.dbDriver, cookie);
if (!foreign_key_checks) {
if (this.isPg) {
await trx.raw('set session_replication_role to origin;');
} else if (this.isMySQL) {
await trx.raw('SET foreign_key_checks = 1;');
}
}
await trx.commit();
if (!raw) await this.afterBulkInsert(insertDatas, this.dbDriver, cookie);
return response;
} catch (e) {
@ -2719,7 +2746,7 @@ class BaseModelSqlv2 {
await this.afterInsert(response, this.dbDriver, cookie);
await this.afterAddChild(rowId, childId, cookie);
}
public async afterAddChild(rowId, childId, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,

3
packages/nocodb-legacy/src/lib/meta/api/index.ts

@ -50,6 +50,7 @@ import {
import swaggerController from '../../controllers/apiDocs';
import { importController, syncSourceController } from '../../controllers/sync';
import mapViewController from '../../controllers/views/mapView.ctl';
import exportImportController from '../../controllers/exportImport'
import type { Socket } from 'socket.io';
import type { Router } from 'express';
@ -103,6 +104,8 @@ export default function (router: Router, server) {
router.use(syncSourceController);
router.use(kanbanViewController);
router.use(mapViewController);
router.use(exportImportController.exportController);
router.use(exportImportController.importController);
userController(router);

20
packages/nocodb-legacy/src/lib/plugins/backblaze/Backblaze.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class Backblaze implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -73,11 +74,26 @@ export default class Backblaze implements IStorageAdapterV2 {
resolve(data.Location);
}
});
}
},
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
patchRegion(region: string): string {
// in v0.0.1, we constructed the endpoint with `region = s3.us-west-001`
// in v0.0.2, `region` would be `us-west-001`
@ -116,7 +132,7 @@ export default class Backblaze implements IStorageAdapterV2 {
s3Options.secretAccessKey = this.input.access_secret;
s3Options.endpoint = new AWS.Endpoint(
`s3.${s3Options.region}.backblazeb2.com`
`s3.${s3Options.region}.backblazeb2.com`,
);
this.s3Client = new AWS.S3(s3Options);

18
packages/nocodb-legacy/src/lib/plugins/gcs/Gcs.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
import type { StorageOptions } from '@google-cloud/storage';
export default class Gcs implements IStorageAdapterV2 {
@ -122,8 +123,23 @@ export default class Gcs implements IStorageAdapterV2 {
.save(body)
.then((res) => resolve(res))
.catch(reject);
}
},
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
}

20
packages/nocodb-legacy/src/lib/plugins/linode/LinodeObjectStorage.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class LinodeObjectStorage implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -73,11 +74,26 @@ export default class LinodeObjectStorage implements IStorageAdapterV2 {
resolve(data.Location);
}
});
}
},
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}
@ -106,7 +122,7 @@ export default class LinodeObjectStorage implements IStorageAdapterV2 {
s3Options.secretAccessKey = this.input.access_secret;
s3Options.endpoint = new AWS.Endpoint(
`${this.input.region}.linodeobjects.com`
`${this.input.region}.linodeobjects.com`,
);
this.s3Client = new AWS.S3(s3Options);

22
packages/nocodb-legacy/src/lib/plugins/mino/Minio.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class Minio implements IStorageAdapterV2 {
private minioClient: MinioClient;
@ -39,7 +40,7 @@ export default class Minio implements IStorageAdapterV2 {
resolve(
`http${this.input.useSSL ? 's' : ''}://${this.input.endPoint}:${
this.input.port
}/${this.input.bucket}/${key}`
}/${this.input.bucket}/${key}`,
);
})
.catch(reject);
@ -123,12 +124,27 @@ export default class Minio implements IStorageAdapterV2 {
resolve(
`http${this.input.useSSL ? 's' : ''}://${this.input.endPoint}:${
this.input.port
}/${this.input.bucket}/${key}`
}/${this.input.bucket}/${key}`,
);
})
.catch(reject);
}
},
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
}

20
packages/nocodb-legacy/src/lib/plugins/ovhCloud/OvhCloud.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class OvhCloud implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -73,11 +74,26 @@ export default class OvhCloud implements IStorageAdapterV2 {
resolve(data.Location);
}
});
}
},
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}
@ -106,7 +122,7 @@ export default class OvhCloud implements IStorageAdapterV2 {
s3Options.secretAccessKey = this.input.access_secret;
s3Options.endpoint = new AWS.Endpoint(
`s3.${this.input.region}.cloud.ovh.net`
`s3.${this.input.region}.cloud.ovh.net`,
);
this.s3Client = new AWS.S3(s3Options);

18
packages/nocodb-legacy/src/lib/plugins/s3/S3.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class S3 implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -72,11 +73,26 @@ export default class S3 implements IStorageAdapterV2 {
resolve(data.Location);
}
});
}
},
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}

16
packages/nocodb-legacy/src/lib/plugins/scaleway/ScalewayObjectStorage.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import { Readable } from 'stream';
export default class ScalewayObjectStorage implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -127,4 +128,19 @@ export default class ScalewayObjectStorage implements IStorageAdapterV2 {
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
}

20
packages/nocodb-legacy/src/lib/plugins/spaces/Spaces.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class Spaces implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -73,11 +74,26 @@ export default class Spaces implements IStorageAdapterV2 {
resolve(data.Location);
}
});
}
},
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}
@ -114,7 +130,7 @@ export default class Spaces implements IStorageAdapterV2 {
s3Options.secretAccessKey = this.input.access_secret;
s3Options.endpoint = new AWS.Endpoint(
`${this.input.region || 'nyc3'}.digitaloceanspaces.com`
`${this.input.region || 'nyc3'}.digitaloceanspaces.com`,
);
this.s3Client = new AWS.S3(s3Options);

18
packages/nocodb-legacy/src/lib/plugins/upcloud/UpoCloud.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class UpoCloud implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -73,11 +74,26 @@ export default class UpoCloud implements IStorageAdapterV2 {
resolve(data.Location);
}
});
}
},
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}

18
packages/nocodb-legacy/src/lib/plugins/vultr/Vultr.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class Vultr implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -73,11 +74,26 @@ export default class Vultr implements IStorageAdapterV2 {
resolve(data.Location);
}
});
}
},
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}

5
packages/nocodb-legacy/src/lib/services/dbData/bulkData.ts

@ -38,12 +38,15 @@ export async function bulkDataInsert(
param: PathParams & {
body: any;
cookie: any;
chunkSize?: number;
foreign_key_checks?: boolean;
raw?: boolean;
}
) {
return await executeBulkOperation({
...param,
operation: 'bulkInsert',
options: [param.body, { cookie: param.cookie }],
options: [param.body, { cookie: param.cookie, foreign_key_checks: param.foreign_key_checks, chunkSize: param.chunkSize, raw: param.raw }],
});
}

493
packages/nocodb-legacy/src/lib/services/exportImport/export.svc.ts

@ -0,0 +1,493 @@
import { NcError } from './../../meta/helpers/catchError';
import { UITypes, ViewTypes } from 'nocodb-sdk';
import { Project, Base, Model, View, LinkToAnotherRecordColumn } from '../../models';
import { dataService } from '..';
import { getViewAndModelByAliasOrId } from '../dbData/helpers';
import { Readable } from 'stream';
import NcPluginMgrv2 from '../../meta/helpers/NcPluginMgrv2';
import { unparse } from 'papaparse';
import { IStorageAdapterV2 } from 'nc-plugin';
/*
{
"entity": "project",
"bases": [
### current scope
{
"entity": "base",
"models": [
{
"entity": "model",
"model": {},
"views": []
}
]
}
### end current scope
]
}
*/
async function generateBaseIdMap(base: Base, idMap: Map<string, string>) {
idMap.set(base.project_id, base.project_id);
idMap.set(base.id, `${base.project_id}::${base.id}`);
const models = await base.getModels();
for (const md of models) {
idMap.set(md.id, `${base.project_id}::${base.id}::${md.id}`);
await md.getColumns();
for (const column of md.columns) {
idMap.set(column.id, `${idMap.get(md.id)}::${column.id}`);
}
}
return models;
}
async function serializeModels(param: { modelId: string[] }) {
const serializedModels = [];
// db id to structured id
const idMap = new Map<string, string>();
const projects: Project[] = []
const bases: Base[] = []
const modelsMap = new Map<string, Model[]>();
for (const modelId of param.modelId) {
const model = await Model.get(modelId);
if (!model) return NcError.badRequest(`Model not found for id '${modelId}'`);
const fndProject = projects.find(p => p.id === model.project_id)
const project = fndProject || await Project.get(model.project_id);
const fndBase = bases.find(b => b.id === model.base_id)
const base = fndBase || await Base.get(model.base_id);
if (!fndProject) projects.push(project);
if (!fndBase) bases.push(base);
if (!modelsMap.has(base.id)) {
modelsMap.set(base.id, await generateBaseIdMap(base, idMap));
}
await model.getColumns();
await model.getViews();
for (const column of model.columns) {
await column.getColOptions();
if (column.colOptions) {
for (const [k, v] of Object.entries(column.colOptions)) {
switch (k) {
case 'fk_mm_child_column_id':
case 'fk_mm_parent_column_id':
case 'fk_mm_model_id':
case 'fk_parent_column_id':
case 'fk_child_column_id':
case 'fk_related_model_id':
case 'fk_relation_column_id':
case 'fk_lookup_column_id':
case 'fk_rollup_column_id':
column.colOptions[k] = idMap.get(v as string);
break;
case 'options':
for (const o of column.colOptions['options']) {
delete o.id;
delete o.fk_column_id;
}
break;
case 'formula':
column.colOptions[k] = column.colOptions[k].replace(/(?<=\{\{).*?(?=\}\})/gm, (match) => idMap.get(match));
break;
case 'id':
case 'created_at':
case 'updated_at':
case 'fk_column_id':
delete column.colOptions[k];
break;
}
}
}
}
for (const view of model.views) {
idMap.set(view.id, `${idMap.get(model.id)}::${view.id}`);
await view.getColumns();
await view.getFilters();
await view.getSorts();
if (view.filter) {
const export_filters = []
for (const fl of view.filter.children) {
const tempFl = {
id: `${idMap.get(view.id)}::${fl.id}`,
fk_column_id: idMap.get(fl.fk_column_id),
fk_parent_id: fl.fk_parent_id,
is_group: fl.is_group,
logical_op: fl.logical_op,
comparison_op: fl.comparison_op,
comparison_sub_op: fl.comparison_sub_op,
value: fl.value,
}
if (tempFl.is_group) {
delete tempFl.comparison_op;
delete tempFl.comparison_sub_op;
delete tempFl.value;
}
export_filters.push(tempFl)
}
view.filter.children = export_filters;
}
if (view.sorts) {
const export_sorts = []
for (const sr of view.sorts) {
const tempSr = {
fk_column_id: idMap.get(sr.fk_column_id),
direction: sr.direction,
}
export_sorts.push(tempSr)
}
view.sorts = export_sorts;
}
if (view.view) {
for (const [k, v] of Object.entries(view.view)) {
switch (k) {
case 'fk_column_id':
case 'fk_cover_image_col_id':
case 'fk_grp_col_id':
view.view[k] = idMap.get(v as string);
break;
case 'meta':
if (view.type === ViewTypes.KANBAN) {
const meta = JSON.parse(view.view.meta as string) as Record<string, any>;
for (const [k, v] of Object.entries(meta)) {
const colId = idMap.get(k as string);
for (const op of v) {
op.fk_column_id = idMap.get(op.fk_column_id);
delete op.id;
}
meta[colId] = v;
delete meta[k];
}
view.view.meta = meta;
}
break;
case 'created_at':
case 'updated_at':
case 'fk_view_id':
case 'project_id':
case 'base_id':
case 'uuid':
delete view.view[k];
break;
}
}
}
}
serializedModels.push({
entity: 'model',
model: {
id: idMap.get(model.id),
prefix: project.prefix,
title: model.title,
table_name: clearPrefix(model.table_name, project.prefix),
meta: model.meta,
columns: model.columns.map((column) => ({
id: idMap.get(column.id),
ai: column.ai,
column_name: column.column_name,
cc: column.cc,
cdf: column.cdf,
meta: column.meta,
pk: column.pk,
order: column.order,
rqd: column.rqd,
system: column.system,
uidt: column.uidt,
title: column.title,
un: column.un,
unique: column.unique,
colOptions: column.colOptions,
})),
},
views: model.views.map((view) => ({
id: idMap.get(view.id),
is_default: view.is_default,
type: view.type,
meta: view.meta,
order: view.order,
title: view.title,
show: view.show,
show_system_fields: view.show_system_fields,
filter: view.filter,
sorts: view.sorts,
lock_type: view.lock_type,
columns: view.columns.map((column) => {
const {
id,
fk_view_id,
fk_column_id,
project_id,
base_id,
created_at,
updated_at,
uuid,
...rest
} = column as any;
return {
fk_column_id: idMap.get(fk_column_id),
...rest,
};
}),
view: view.view,
})),
});
}
return serializedModels;
}
async function exportModelData(param: {
storageAdapter: IStorageAdapterV2;
path: string;
projectId: string;
modelId: string;
viewId?: string;
}) {
const { model, view } = await getViewAndModelByAliasOrId({
projectName: param.projectId,
tableName: param.modelId,
viewName: param.viewId,
});
await model.getColumns();
const hasLink = model.columns.some((c) => c.uidt === UITypes.LinkToAnotherRecord && c.colOptions?.type === 'mm');
const pkMap = new Map<string, string>();
for (const column of model.columns.filter((c) => c.uidt === UITypes.LinkToAnotherRecord && c.colOptions?.type !== 'hm')) {
const relatedTable = await (
(await column.getColOptions()) as LinkToAnotherRecordColumn
).getRelatedTable();
await relatedTable.getColumns();
pkMap.set(column.id, relatedTable.primaryKey.title);
}
const readableStream = new Readable({
read() {},
});
const readableLinkStream = new Readable({
read() {},
});
readableStream.setEncoding('utf8');
readableLinkStream.setEncoding('utf8');
const storageAdapter = param.storageAdapter;
const uploadPromise = storageAdapter.fileCreateByStream(
`${param.path}/${model.id}.csv`,
readableStream
);
const uploadLinkPromise = hasLink
? storageAdapter.fileCreateByStream(
`${param.path}/${model.id}_links.csv`,
readableLinkStream
)
: Promise.resolve();
const limit = 100;
let offset = 0;
const primaryKey = model.columns.find((c) => c.pk);
const formatData = (data: any) => {
const linkData = [];
for (const row of data) {
const pkValue = primaryKey ? row[primaryKey.title] : undefined;
const linkRow = {};
for (const [k, v] of Object.entries(row)) {
const col = model.columns.find((c) => c.title === k);
if (col) {
if (col.pk) linkRow['pk'] = pkValue;
const colId = `${col.project_id}::${col.base_id}::${col.fk_model_id}::${col.id}`;
switch(col.uidt) {
case UITypes.LinkToAnotherRecord:
if (col.system || col.colOptions.type === 'hm') break;
const pkList = [];
const links = Array.isArray(v) ? v : [v];
for (const link of links) {
if (link) {
for (const [k, val] of Object.entries(link)) {
if (k === pkMap.get(col.id)) {
pkList.push(val);
}
}
}
}
if (col.colOptions.type === 'mm') {
linkRow[colId] = pkList.join(',');
} else {
row[colId] = pkList[0];
}
break;
case UITypes.Attachment:
try {
row[colId] = JSON.stringify(v);
} catch (e) {
row[colId] = v;
}
break;
case UITypes.ForeignKey:
case UITypes.Formula:
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Rating:
case UITypes.Barcode:
// skip these types
break;
default:
row[colId] = v;
break;
}
delete row[k];
}
}
linkData.push(linkRow);
}
return { data, linkData };
}
try {
await recursiveRead(formatData, readableStream, readableLinkStream, model, view, offset, limit, true);
await uploadPromise;
await uploadLinkPromise;
} catch (e) {
await storageAdapter.fileDelete(`${param.path}/${model.id}.csv`);
await storageAdapter.fileDelete(`${param.path}/${model.id}_links.csv`);
console.error(e);
throw e;
}
return true;
}
async function recursiveRead(
formatter: Function,
stream: Readable,
linkStream: Readable,
model: Model,
view: View,
offset: number,
limit: number,
header = false
): Promise<void> {
return new Promise((resolve, reject) => {
dataService
.getDataList({ model, view, query: { limit, offset } })
.then((result) => {
try {
if (!header) {
stream.push('\r\n');
linkStream.push('\r\n');
}
const { data, linkData } = formatter(result.list);
stream.push(unparse(data, { header }));
linkStream.push(unparse(linkData, { header }));
if (result.pageInfo.isLastPage) {
stream.push(null);
linkStream.push(null);
resolve();
} else {
recursiveRead(formatter, stream, linkStream, model, view, offset + limit, limit).then(resolve);
}
} catch (e) {
reject(e);
}
});
});
}
function clearPrefix(text: string, prefix?: string) {
if (!prefix || prefix.length === 0) return text;
return text.replace(new RegExp(`^${prefix}_?`), '');
}
export async function exportBaseSchema(param: { baseId: string }) {
const base = await Base.get(param.baseId);
if (!base) return NcError.badRequest(`Base not found for id '${param.baseId}'`);
const project = await Project.get(base.project_id);
const models = (await base.getModels()).filter((m) => !m.mm && m.type === 'table');
const exportedModels = await serializeModels({ modelId: models.map(m => m.id) });
const exportData = { id: `${project.id}::${base.id}`, entity: 'base', models: exportedModels };
return exportData;
}
export async function exportBase(param: { path: string; baseId: string }) {
const base = await Base.get(param.baseId);
if (!base) return NcError.badRequest(`Base not found for id '${param.baseId}'`);
const project = await Project.get(base.project_id);
const models = (await base.getModels()).filter((m) => !m.mm && m.type === 'table');
const exportedModels = await serializeModels({ modelId: models.map(m => m.id) });
const exportData = { id: `${project.id}::${base.id}`, entity: 'base', models: exportedModels };
const storageAdapter = await NcPluginMgrv2.storageAdapter();
const destPath = `export/${project.id}/${base.id}/${param.path}/schema.json`;
try {
const readableStream = new Readable({
read() {},
});
readableStream.setEncoding('utf8');
readableStream.push(JSON.stringify(exportData));
readableStream.push(null);
await storageAdapter.fileCreateByStream(
destPath,
readableStream
);
for (const model of models) {
await exportModelData({
storageAdapter,
path: `export/${project.id}/${base.id}/${param.path}/data`,
projectId: project.id,
modelId: model.id,
});
}
} catch (e) {
console.error(e);
return NcError.internalServerError('Error while exporting base');
}
return true;
}

844
packages/nocodb-legacy/src/lib/services/exportImport/import.svc.ts

@ -0,0 +1,844 @@
import type { ViewCreateReqType } from 'nocodb-sdk';
import { UITypes, ViewTypes } from 'nocodb-sdk';
import { tableService, gridViewService, filterService, viewColumnService, gridViewColumnService, sortService, formViewService, galleryViewService, kanbanViewService, formViewColumnService, columnService, bulkDataService } from '..';
import { NcError } from '../../meta/helpers/catchError';
import { Project, Base, User, View, Model, Column, LinkToAnotherRecordColumn } from '../../models';
import NcPluginMgrv2 from '../../meta/helpers/NcPluginMgrv2';
import papaparse from 'papaparse';
export async function importModels(param: {
user: User;
projectId: string;
baseId: string;
data: { models: { model: any; views: any[] }[] } | { model: any; views: any[] }[];
req: any;
}) {
// structured id to db id
const idMap = new Map<string, string>();
const project = await Project.get(param.projectId);
if (!project) return NcError.badRequest(`Project not found for id '${param.projectId}'`);
const base = await Base.get(param.baseId);
if (!base) return NcError.badRequest(`Base not found for id '${param.baseId}'`);
const tableReferences = new Map<string, Model>();
const linkMap = new Map<string, string>();
param.data = Array.isArray(param.data) ? param.data : param.data.models;
// create tables with static columns
for (const data of param.data) {
const modelData = data.model;
const reducedColumnSet = modelData.columns.filter(
(a) =>
a.uidt !== UITypes.LinkToAnotherRecord &&
a.uidt !== UITypes.Lookup &&
a.uidt !== UITypes.Rollup &&
a.uidt !== UITypes.Formula &&
a.uidt !== UITypes.ForeignKey
);
// create table with static columns
const table = await tableService.tableCreate({
projectId: project.id,
baseId: base.id,
user: param.user,
table: withoutId({
...modelData,
columns: reducedColumnSet.map((a) => withoutId(a)),
}),
});
idMap.set(modelData.id, table.id);
// map column id's with new created column id's
for (const col of table.columns) {
const colRef = modelData.columns.find(
(a) => a.column_name === col.column_name
);
idMap.set(colRef.id, col.id);
}
tableReferences.set(modelData.id, table);
}
const referencedColumnSet = []
// create columns with reference to other columns
for (const data of param.data) {
const modelData = data.model;
const table = tableReferences.get(modelData.id);
const linkedColumnSet = modelData.columns.filter(
(a) => a.uidt === UITypes.LinkToAnotherRecord
);
// create columns with reference to other columns
for (const col of linkedColumnSet) {
if (col.colOptions) {
const colOptions = col.colOptions;
if (col.uidt === UITypes.LinkToAnotherRecord && idMap.has(colOptions.fk_related_model_id)) {
if (colOptions.type === 'mm') {
if (!linkMap.has(colOptions.fk_mm_model_id)) {
// delete col.column_name as it is not required and will cause ajv error (null for LTAR)
delete col.column_name;
const freshModelData = await columnService.columnAdd({
tableId: table.id,
column: withoutId({
...col,
...{
parentId: idMap.get(getParentIdentifier(colOptions.fk_child_column_id)),
childId: idMap.get(getParentIdentifier(colOptions.fk_parent_column_id)),
type: colOptions.type,
virtual: colOptions.virtual,
ur: colOptions.ur,
dr: colOptions.dr,
},
}),
req: param.req,
});
for (const nColumn of freshModelData.columns) {
if (nColumn.title === col.title) {
idMap.set(col.id, nColumn.id);
linkMap.set(colOptions.fk_mm_model_id, nColumn.colOptions.fk_mm_model_id);
break;
}
}
const childModel = getParentIdentifier(colOptions.fk_parent_column_id) === modelData.id ? freshModelData : await Model.get(idMap.get(getParentIdentifier(colOptions.fk_parent_column_id)));
if (getParentIdentifier(colOptions.fk_parent_column_id) !== modelData.id) await childModel.getColumns();
const childColumn = param.data.find(a => a.model.id === getParentIdentifier(colOptions.fk_parent_column_id)).model.columns.find(a => a.colOptions?.fk_mm_model_id === colOptions.fk_mm_model_id && a.id !== col.id);
for (const nColumn of childModel.columns) {
if (nColumn?.colOptions?.fk_mm_model_id === linkMap.get(colOptions.fk_mm_model_id) && nColumn.id !== idMap.get(col.id)) {
idMap.set(childColumn.id, nColumn.id);
if (nColumn.title !== childColumn.title) {
await columnService.columnUpdate({
columnId: nColumn.id,
column: {
...nColumn,
column_name: childColumn.title,
title: childColumn.title,
},
});
}
break;
}
}
}
} else if (colOptions.type === 'hm') {
// delete col.column_name as it is not required and will cause ajv error (null for LTAR)
delete col.column_name;
const freshModelData = await columnService.columnAdd({
tableId: table.id,
column: withoutId({
...col,
...{
parentId: idMap.get(getParentIdentifier(colOptions.fk_parent_column_id)),
childId: idMap.get(getParentIdentifier(colOptions.fk_child_column_id)),
type: colOptions.type,
virtual: colOptions.virtual,
ur: colOptions.ur,
dr: colOptions.dr,
},
}),
req: param.req,
});
for (const nColumn of freshModelData.columns) {
if (nColumn.title === col.title) {
idMap.set(col.id, nColumn.id);
idMap.set(colOptions.fk_parent_column_id, nColumn.colOptions.fk_parent_column_id);
idMap.set(colOptions.fk_child_column_id, nColumn.colOptions.fk_child_column_id);
break;
}
}
const childModel = colOptions.fk_related_model_id === modelData.id ? freshModelData : await Model.get(idMap.get(colOptions.fk_related_model_id));
if (colOptions.fk_related_model_id !== modelData.id) await childModel.getColumns();
const childColumn = param.data
.find((a) => a.model.id === colOptions.fk_related_model_id)
.model.columns.find(
(a) =>
a.colOptions?.fk_parent_column_id ===
colOptions.fk_parent_column_id &&
a.colOptions?.fk_child_column_id ===
colOptions.fk_child_column_id &&
a.id !== col.id
);
for (const nColumn of childModel.columns) {
if (nColumn.id !== idMap.get(col.id) && nColumn.colOptions?.fk_parent_column_id === idMap.get(colOptions.fk_parent_column_id) && nColumn.colOptions?.fk_child_column_id === idMap.get(colOptions.fk_child_column_id)) {
idMap.set(childColumn.id, nColumn.id);
if (nColumn.title !== childColumn.title) {
await columnService.columnUpdate({
columnId: nColumn.id,
column: {
...nColumn,
column_name: childColumn.title,
title: childColumn.title,
},
});
}
break;
}
}
}
}
}
}
referencedColumnSet.push(...modelData.columns.filter(
(a) =>
a.uidt === UITypes.Lookup ||
a.uidt === UITypes.Rollup ||
a.uidt === UITypes.Formula
));
}
const sortedReferencedColumnSet = [];
// sort referenced columns to avoid referencing before creation
for (const col of referencedColumnSet) {
const relatedColIds = [];
if (col.colOptions?.fk_lookup_column_id) {
relatedColIds.push(col.colOptions.fk_lookup_column_id);
}
if (col.colOptions?.fk_rollup_column_id) {
relatedColIds.push(col.colOptions.fk_rollup_column_id);
}
if (col.colOptions?.formula) {
relatedColIds.push(...col.colOptions.formula.match(/(?<=\{\{).*?(?=\}\})/gm));
}
// find the last related column in the sorted array
let fnd = undefined;
for (let i = sortedReferencedColumnSet.length - 1; i >= 0; i--) {
if (relatedColIds.includes(sortedReferencedColumnSet[i].id)) {
fnd = sortedReferencedColumnSet[i];
break;
}
}
if (!fnd) {
sortedReferencedColumnSet.unshift(col);
} else {
sortedReferencedColumnSet.splice(sortedReferencedColumnSet.indexOf(fnd) + 1, 0, col);
}
}
// create referenced columns
for (const col of sortedReferencedColumnSet) {
const { colOptions, ...flatCol } = col;
if (col.uidt === UITypes.Lookup) {
if (!idMap.get(colOptions.fk_relation_column_id)) continue;
const freshModelData = await columnService.columnAdd({
tableId: idMap.get(getParentIdentifier(col.id)),
column: withoutId({
...flatCol,
...{
fk_lookup_column_id: idMap.get(colOptions.fk_lookup_column_id),
fk_relation_column_id: idMap.get(colOptions.fk_relation_column_id),
},
}),
req: param.req,
});
for (const nColumn of freshModelData.columns) {
if (nColumn.title === col.title) {
idMap.set(col.id, nColumn.id);
break;
}
}
} else if (col.uidt === UITypes.Rollup) {
if (!idMap.get(colOptions.fk_relation_column_id)) continue;
const freshModelData = await columnService.columnAdd({
tableId: idMap.get(getParentIdentifier(col.id)),
column: withoutId({
...flatCol,
...{
fk_rollup_column_id: idMap.get(colOptions.fk_rollup_column_id),
fk_relation_column_id: idMap.get(colOptions.fk_relation_column_id),
rollup_function: colOptions.rollup_function,
},
}),
req: param.req,
});
for (const nColumn of freshModelData.columns) {
if (nColumn.title === col.title) {
idMap.set(col.id, nColumn.id);
break;
}
}
} else if (col.uidt === UITypes.Formula) {
const freshModelData = await columnService.columnAdd({
tableId: idMap.get(getParentIdentifier(col.id)),
column: withoutId({
...flatCol,
...{
formula_raw: colOptions.formula_raw,
},
}),
req: param.req,
});
for (const nColumn of freshModelData.columns) {
if (nColumn.title === col.title) {
idMap.set(col.id, nColumn.id);
break;
}
}
}
}
// create views
for (const data of param.data) {
const modelData = data.model;
const viewsData = data.views;
const table = tableReferences.get(modelData.id);
// get default view
await table.getViews();
for (const view of viewsData) {
const viewData = withoutId({
...view,
});
const vw = await createView(idMap, table, viewData, table.views);
if (!vw) continue;
idMap.set(view.id, vw.id);
// create filters
const filters = view.filter.children;
for (const fl of filters) {
const fg = await filterService.filterCreate({
viewId: vw.id,
filter: withoutId({
...fl,
fk_column_id: idMap.get(fl.fk_column_id),
fk_parent_id: idMap.get(fl.fk_parent_id),
}),
});
idMap.set(fl.id, fg.id);
}
// create sorts
for (const sr of view.sorts) {
await sortService.sortCreate({
viewId: vw.id,
sort: withoutId({
...sr,
fk_column_id: idMap.get(sr.fk_column_id),
}),
})
}
// update view columns
const vwColumns = await viewColumnService.columnList({ viewId: vw.id })
for (const cl of vwColumns) {
const fcl = view.columns.find(a => a.fk_column_id === reverseGet(idMap, cl.fk_column_id))
if (!fcl) continue;
await viewColumnService.columnUpdate({
viewId: vw.id,
columnId: cl.id,
column: {
show: fcl.show,
order: fcl.order,
},
})
}
switch (vw.type) {
case ViewTypes.GRID:
for (const cl of vwColumns) {
const fcl = view.columns.find(a => a.fk_column_id === reverseGet(idMap, cl.fk_column_id))
if (!fcl) continue;
const { fk_column_id, ...rest } = fcl;
await gridViewColumnService.gridColumnUpdate({
gridViewColumnId: cl.id,
grid: {
...withoutNull(rest),
},
})
}
break;
case ViewTypes.FORM:
for (const cl of vwColumns) {
const fcl = view.columns.find(a => a.fk_column_id === reverseGet(idMap, cl.fk_column_id))
if (!fcl) continue;
const { fk_column_id, ...rest } = fcl;
await formViewColumnService.columnUpdate({
formViewColumnId: cl.id,
formViewColumn: {
...withoutNull(rest),
},
})
}
break;
case ViewTypes.GALLERY:
case ViewTypes.KANBAN:
break;
}
}
}
return idMap;
}
async function createView(idMap: Map<string, string>, md: Model, vw: Partial<View>, views: View[]): Promise<View> {
if (vw.is_default) {
const view = views.find((a) => a.is_default);
if (view) {
const gridData = withoutNull(vw.view);
if (gridData) {
await gridViewService.gridViewUpdate({
viewId: view.id,
grid: gridData,
});
}
}
return view;
}
switch (vw.type) {
case ViewTypes.GRID:
const gview = await gridViewService.gridViewCreate({
tableId: md.id,
grid: vw as ViewCreateReqType,
});
const gridData = withoutNull(vw.view);
if (gridData) {
await gridViewService.gridViewUpdate({
viewId: gview.id,
grid: gridData,
});
}
return gview;
case ViewTypes.FORM:
const fview = await formViewService.formViewCreate({
tableId: md.id,
body: vw as ViewCreateReqType,
});
const formData = withoutNull(vw.view);
if (formData) {
await formViewService.formViewUpdate({
formViewId: fview.id,
form: formData,
});
}
return fview;
case ViewTypes.GALLERY:
const glview = await galleryViewService.galleryViewCreate({
tableId: md.id,
gallery: vw as ViewCreateReqType,
});
const galleryData = withoutNull(vw.view);
if (galleryData) {
for (const [k, v] of Object.entries(galleryData)) {
switch (k) {
case 'fk_cover_image_col_id':
galleryData[k] = idMap.get(v as string);
break;
}
}
await galleryViewService.galleryViewUpdate({
galleryViewId: glview.id,
gallery: galleryData,
});
}
return glview;
case ViewTypes.KANBAN:
const kview = await kanbanViewService.kanbanViewCreate({
tableId: md.id,
kanban: vw as ViewCreateReqType,
});
const kanbanData = withoutNull(vw.view);
if (kanbanData) {
for (const [k, v] of Object.entries(kanbanData)) {
switch (k) {
case 'fk_grp_col_id':
case 'fk_cover_image_col_id':
kanbanData[k] = idMap.get(v as string);
break;
case 'meta':
const meta = {};
for (const [mk, mv] of Object.entries(v as any)) {
const tempVal = [];
for (const vl of mv as any) {
if (vl.fk_column_id) {
tempVal.push({
...vl,
fk_column_id: idMap.get(vl.fk_column_id),
});
} else {
delete vl.fk_column_id;
tempVal.push({
...vl,
id: "uncategorized",
});
}
}
meta[idMap.get(mk)] = tempVal;
}
kanbanData[k] = meta;
break;
}
}
await kanbanViewService.kanbanViewUpdate({
kanbanViewId: kview.id,
kanban: kanbanData,
});
}
return kview;
}
return null
}
function withoutNull(obj: any) {
const newObj = {};
let found = false;
for (const [key, value] of Object.entries(obj)) {
if (value !== null) {
newObj[key] = value;
found = true;
}
}
if (!found) return null;
return newObj;
}
function reverseGet(map: Map<string, string>, vl: string) {
for (const [key, value] of map.entries()) {
if (vl === value) {
return key;
}
}
return undefined
}
function withoutId(obj: any) {
const { id, ...rest } = obj;
return rest;
}
function getParentIdentifier(id: string) {
const arr = id.split('::');
arr.pop();
return arr.join('::');
}
function getEntityIdentifier(id: string) {
const arr = id.split('::');
return arr.pop();
}
function findWithIdentifier(map: Map<string, any>, id: string) {
for (const key of map.keys()) {
if (getEntityIdentifier(key) === id) {
return map.get(key);
}
}
return undefined;
}
export async function importBase(param: {
user: User;
projectId: string;
baseId: string;
src: { type: 'local' | 'url' | 'file'; path?: string; url?: string; file?: any };
req: any;
}) {
const { user, projectId, baseId, src, req } = param;
const debug = req.params.debug === 'true';
const debugLog = (...args: any[]) => {
if (!debug) return;
console.log(...args);
}
let start = process.hrtime();
let elapsedTime = function(label?: string){
const elapsedS = (process.hrtime(start)[0]).toFixed(3);
const elapsedMs = process.hrtime(start)[1] / 1000000;
if (label) debugLog(`${label}: ${elapsedS}s ${elapsedMs}ms`);
start = process.hrtime();
}
switch (src.type) {
case 'local':
const path = src.path.replace(/\/$/, '');
const storageAdapter = await NcPluginMgrv2.storageAdapter();
try {
const schema = JSON.parse(await storageAdapter.fileRead(`${path}/schema.json`));
elapsedTime('read schema');
// store fk_mm_model_id (mm) to link once
const handledLinks = [];
const idMap = await importModels({
user,
projectId,
baseId,
data: schema,
req,
});
elapsedTime('import models');
if (idMap) {
const files = await storageAdapter.getDirectoryList(`${path}/data`);
const dataFiles = files.filter((file) => !file.match(/_links\.csv$/));
const linkFiles = files.filter((file) => file.match(/_links\.csv$/));
for (const file of dataFiles) {
const readStream = await storageAdapter.fileReadByStream(
`${path}/data/${file}`
);
const headers: string[] = [];
let chunk = [];
const modelId = findWithIdentifier(
idMap,
file.replace(/\.csv$/, '')
);
const model = await Model.get(modelId);
debugLog(`Importing ${model.title}...`);
await new Promise(async (resolve) => {
papaparse.parse(readStream, {
newline: '\r\n',
step: async function (results, parser) {
if (!headers.length) {
parser.pause();
for (const header of results.data) {
const id = idMap.get(header);
if (id) {
const col = await Column.get({
base_id: baseId,
colId: id,
});
if (col.colOptions?.type === 'bt') {
const childCol = await Column.get({
base_id: baseId,
colId: col.colOptions.fk_child_column_id,
});
headers.push(childCol.column_name);
} else {
headers.push(col.column_name);
}
} else {
debugLog(header);
}
}
parser.resume();
} else {
if (results.errors.length === 0) {
const row = {};
for (let i = 0; i < headers.length; i++) {
if (results.data[i] !== '') {
row[headers[i]] = results.data[i];
}
}
chunk.push(row);
if (chunk.length > 100) {
parser.pause();
elapsedTime('before import chunk');
try {
await bulkDataService.bulkDataInsert({
projectName: projectId,
tableName: modelId,
body: chunk,
cookie: null,
chunkSize: chunk.length + 1,
foreign_key_checks: false,
raw: true,
});
} catch (e) {
debugLog(`${model.title} import throwed an error!`);
console.log(e);
}
chunk = [];
elapsedTime('after import chunk');
parser.resume();
}
}
}
},
complete: async function () {
if (chunk.length > 0) {
elapsedTime('before import chunk');
try {
await bulkDataService.bulkDataInsert({
projectName: projectId,
tableName: modelId,
body: chunk,
cookie: null,
chunkSize: chunk.length + 1,
foreign_key_checks: false,
raw: true,
});
} catch (e) {
debugLog(chunk);
console.log(e);
}
chunk = [];
elapsedTime('after import chunk');
}
resolve(null);
},
});
});
}
// reset timer
elapsedTime();
for (const file of linkFiles) {
const readStream = await storageAdapter.fileReadByStream(
`${path}/data/${file}`
);
const headers: string[] = [];
const mmParentChild: any = {};
let chunk: Record<string, any[]> = {}; // colId: { rowId, childId }[]
const modelId = findWithIdentifier(
idMap,
file.replace(/_links\.csv$/, '')
);
const model = await Model.get(modelId);
let pkIndex = -1;
debugLog(`Linking ${model.title}...`);
await new Promise(async (resolve) => {
papaparse.parse(readStream, {
newline: '\r\n',
step: async function (results, parser) {
if (!headers.length) {
parser.pause();
for (const header of results.data) {
if (header === 'pk') {
headers.push(null);
pkIndex = headers.length - 1;
continue;
}
const id = idMap.get(header);
if (id) {
const col = await Column.get({
base_id: baseId,
colId: id,
});
if (
col.uidt === UITypes.LinkToAnotherRecord &&
col.colOptions.fk_mm_model_id &&
handledLinks.includes(col.colOptions.fk_mm_model_id)
) {
headers.push(null);
} else {
if (
col.uidt === UITypes.LinkToAnotherRecord &&
col.colOptions.fk_mm_model_id &&
!handledLinks.includes(
col.colOptions.fk_mm_model_id
)
) {
const colOptions = await col.getColOptions<LinkToAnotherRecordColumn>();
const vChildCol = await colOptions.getMMChildColumn();
const vParentCol = await colOptions.getMMParentColumn();
mmParentChild[col.colOptions.fk_mm_model_id] = {
parent: vParentCol.column_name,
child: vChildCol.column_name,
}
handledLinks.push(col.colOptions.fk_mm_model_id);
}
headers.push(col.colOptions.fk_mm_model_id);
chunk[col.colOptions.fk_mm_model_id] = []
}
}
}
parser.resume();
} else {
if (results.errors.length === 0) {
for (let i = 0; i < headers.length; i++) {
if (!headers[i]) continue;
const mm = mmParentChild[headers[i]];
for (const rel of results.data[i].split(',')) {
if (rel.trim() === '') continue;
chunk[headers[i]].push({ [mm.parent]: rel, [mm.child]: results.data[pkIndex] });
}
}
}
}
},
complete: async function () {
for (const [k, v] of Object.entries(chunk)) {
try {
elapsedTime('prepare link chunk');
await bulkDataService.bulkDataInsert({
projectName: projectId,
tableName: k,
body: v,
cookie: null,
chunkSize: 1000,
foreign_key_checks: false,
raw: true,
});
elapsedTime('insert link chunk');
} catch (e) {
console.log(e);
}
}
resolve(null);
},
});
});
}
}
} catch (e) {
throw new Error(e);
}
break;
case 'url':
break;
case 'file':
break;
}
}

2
packages/nocodb-legacy/src/lib/services/index.ts

@ -36,3 +36,5 @@ export * as syncService from './sync';
export * from './public';
export * as orgTokenService from './orgToken.svc';
export * as orgTokenServiceEE from './ee/orgToken.svc';
export * as exportService from './exportImport/export.svc';
export * as importService from './exportImport/import.svc';

2
packages/nocodb-legacy/src/lib/services/metaDiff.svc.ts

@ -1010,7 +1010,7 @@ export async function extractAndGenerateManyToManyRelations(
}
// todo: impl better method to identify m2m relation
if (belongsToCols?.length === 2 && normalColumns.length < 5) {
if (belongsToCols?.length === 2 && normalColumns.length < 5 && assocModel.primaryKeys.length === 2) {
const modelA = await belongsToCols[0].colOptions.getRelatedTable();
const modelB = await belongsToCols[1].colOptions.getRelatedTable();

27
packages/nocodb-legacy/src/lib/v1-legacy/plugins/adapters/storage/Local.ts

@ -4,6 +4,7 @@ import { promisify } from 'util';
import mkdirp from 'mkdirp';
import axios from 'axios';
import NcConfigFactory from '../../../../utils/NcConfigFactory';
import type { Readable } from 'stream';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
export default class Local implements IStorageAdapterV2 {
@ -65,6 +66,32 @@ export default class Local implements IStorageAdapterV2 {
});
}
public async fileCreateByStream(key: string, stream: Readable): Promise<void> {
return new Promise(async (resolve, reject) => {
const destPath = path.join(NcConfigFactory.getToolDir(), ...key.split('/'));
try {
await mkdirp(path.dirname(destPath));
const writableStream = fs.createWriteStream(destPath);
writableStream.on('finish', () => resolve());
writableStream.on('error', (err) => reject(err));
stream.pipe(writableStream);
} catch (e) {
throw e;
}
});
}
public async fileReadByStream(key: string): Promise<Readable> {
const srcPath = path.join(NcConfigFactory.getToolDir(), ...key.split('/'));
return fs.createReadStream(srcPath, { encoding: 'utf8' });
}
public async getDirectoryList(key: string): Promise<string[]> {
const destDir = path.join(NcConfigFactory.getToolDir(), ...key.split('/'));
return fs.promises.readdir(destDir);
}
// todo: implement
fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);

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

@ -1892,6 +1892,11 @@ export interface ProjectReqType {
* @example My Project
*/
title: string;
/**
* Project Status
* @example locked
*/
status?: StringOrNullType;
}
/**
@ -1910,6 +1915,11 @@ export interface ProjectUpdateReqType {
* @example My Project
*/
title?: string;
/**
* Project Status
* @example locked
*/
status?: StringOrNullType;
}
/**
@ -4001,6 +4011,97 @@ export class Api<
...params,
}),
/**
* @description Duplicate a project
*
* @tags Project
* @name BaseDuplicate
* @summary Duplicate Project Base
* @request POST:/api/v1/db/meta/duplicate/{projectId}/{baseId}
* @response `200` `{
name?: string,
id?: string,
}` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
baseDuplicate: (
projectId: IdType,
data: {
excludeData?: boolean;
excludeViews?: boolean;
excludeHooks?: boolean;
},
baseId?: IdType,
params: RequestParams = {}
) =>
this.request<
{
name?: string;
id?: string;
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/db/meta/duplicate/${projectId}/${baseId}`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Duplicate a project
*
* @tags Project
* @name Duplicate
* @summary Duplicate Project
* @request POST:/api/v1/db/meta/duplicate/{projectId}
* @response `200` `{
name?: string,
id?: string,
}` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
duplicate: (
projectId: IdType,
data: {
excludeData?: boolean;
excludeViews?: boolean;
excludeHooks?: boolean;
},
params: RequestParams = {}
) =>
this.request<
{
name?: string;
id?: string;
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/db/meta/duplicate/${projectId}`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Get the info of a given project
*
@ -4995,6 +5096,51 @@ export class Api<
...params,
}),
/**
* @description Duplicate a table
*
* @tags DB Table
* @name Duplicate
* @summary Duplicate Table
* @request POST:/api/v1/db/meta/duplicate/{projectId}/table/{tableId}
* @response `200` `{
name?: string,
id?: string,
}` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
duplicate: (
projectId: IdType,
tableId: IdType,
data: {
excludeData?: boolean;
excludeViews?: boolean;
},
params: RequestParams = {}
) =>
this.request<
{
name?: string;
id?: string;
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/db/meta/duplicate/${projectId}/table/${tableId}`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Update the order of the given Table
*

6
packages/nocodb/.gitignore vendored

@ -35,4 +35,10 @@ lerna-debug.log*
!.vscode/extensions.json
/noco.db
# export
export/**
# test dbs
test_sakila_?.db
/docker/main.js

242
packages/nocodb/package-lock.json generated

@ -11,6 +11,7 @@
"dependencies": {
"@google-cloud/storage": "^5.7.2",
"@graphql-tools/merge": "^6.0.12",
"@nestjs/bull": "^0.6.3",
"@nestjs/common": "^9.4.0",
"@nestjs/core": "^9.4.0",
"@nestjs/event-emitter": "^1.4.1",
@ -18,7 +19,9 @@
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.4.0",
"@nestjs/platform-socket.io": "^9.4.0",
"@nestjs/serve-static": "^3.0.1",
"@nestjs/websockets": "^9.4.0",
"@sentry/node": "^6.3.5",
"@types/chai": "^4.2.12",
"airtable": "^0.11.3",
@ -31,6 +34,7 @@
"bcryptjs": "^2.4.3",
"body-parser": "^1.19.0",
"boxen": "^5.0.0",
"bull": "^4.10.4",
"bullmq": "^1.81.1",
"clear": "^0.1.0",
"colors": "^1.4.0",
@ -77,12 +81,13 @@
"nanoid": "^3.1.20",
"nc-help": "^0.2.87",
"nc-lib-gui": "0.107.0-beta.0",
"nc-plugin": "0.1.2",
"nc-plugin": "^0.1.3",
"ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk",
"nodemailer": "^6.4.10",
"object-hash": "^3.0.0",
"os-locale": "^6.0.2",
"p-queue": "^6.6.2",
"papaparse": "^5.3.1",
"parse-database-url": "^0.3.0",
"passport": "^0.4.1",
@ -115,6 +120,7 @@
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@nestjsplus/dyn-schematics": "^1.0.12",
"@types/bull": "^4.10.0",
"@types/express": "^4.17.13",
"@types/jest": "^29.5.0",
"@types/mocha": "^10.0.1",
@ -148,6 +154,41 @@
"webpack-cli": "^5.0.1"
}
},
"../nc-plugin": {
"version": "0.1.3",
"extraneous": true,
"license": "AGPL-3.0-or-later",
"dependencies": {
"nc-common": "0.0.6"
},
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@types/node": "^18.11.9",
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"ava": "^5.2.0",
"codecov": "^3.5.0",
"cspell": "^4.1.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.0.2",
"eslint-plugin-import": "^2.22.0",
"gh-pages": "^3.1.0",
"npm-run-all": "^4.1.5",
"nyc": "^15.1.0",
"open-cli": "^7.1.0",
"prettier": "^2.1.1",
"standard-version": "^9.0.0",
"ts-node": "^9.0.0",
"typedoc": "^0.19.0",
"typescript": "^4.0.2"
},
"engines": {
"node": ">=10"
}
},
"../nocodb-sdk": {
"version": "0.107.0-beta.0",
"license": "AGPL-3.0-or-later",
@ -2300,6 +2341,32 @@
"win32"
]
},
"node_modules/@nestjs/bull": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-0.6.3.tgz",
"integrity": "sha512-CckH9O3t9qSiO4RCzdYvtFSaaMfIhTXMYagV/rtmVvI1SX5XNnxEaQXvtjxDBXF9DB1JE/5AejIl6ICym+MJIw==",
"dependencies": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
},
"peerDependencies": {
"@nestjs/common": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"bull": "^3.3 || ^4.0.0"
}
},
"node_modules/@nestjs/bull-shared": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-0.1.3.tgz",
"integrity": "sha512-K0a1ERpnl/ZnTmm0UtYSSClDlDkQwNNwJYM6PogzpeflD64oqwVIn8Pj8rdS+BOYUxqdDy55q3p67ytO5oaVDA==",
"dependencies": {
"tslib": "2.5.0"
},
"peerDependencies": {
"@nestjs/common": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0"
}
},
"node_modules/@nestjs/cli": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.3.0.tgz",
@ -2595,6 +2662,24 @@
"node": ">=10.0.0"
}
},
"node_modules/@nestjs/platform-socket.io": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-9.4.0.tgz",
"integrity": "sha512-pk5uWItnsrFKzvQrFcAmyfcb8cpGgoj4yR4+vbA5H/MLcv+8vGqruQO8riN8jAYGNPN9Y02ihBKbIvQqn92M5g==",
"dependencies": {
"socket.io": "4.6.1",
"tslib": "2.5.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@nestjs/common": "^9.0.0",
"@nestjs/websockets": "^9.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.1.0.tgz",
@ -2668,6 +2753,28 @@
}
}
},
"node_modules/@nestjs/websockets": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-9.4.0.tgz",
"integrity": "sha512-RATR9C0cKhXp3mTQAg75iItyUuRhVwU39Xe/kl0XLpvAhWzhnGrn6CxSTRRzBfp3F68DOKvs7/ODDY51f+rdXw==",
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
"tslib": "2.5.0"
},
"peerDependencies": {
"@nestjs/common": "^9.0.0",
"@nestjs/core": "^9.0.0",
"@nestjs/platform-socket.io": "^9.0.0",
"reflect-metadata": "^0.1.12",
"rxjs": "^7.1.0"
},
"peerDependenciesMeta": {
"@nestjs/platform-socket.io": {
"optional": true
}
}
},
"node_modules/@nestjsplus/dyn-schematics": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@nestjsplus/dyn-schematics/-/dyn-schematics-1.0.12.tgz",
@ -3189,6 +3296,16 @@
"@types/node": "*"
}
},
"node_modules/@types/bull": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-4.10.0.tgz",
"integrity": "sha512-RkYW8K2H3J76HT6twmHYbzJ0GtLDDotpLP9ah9gtiA7zfF6peBH1l5fEiK0oeIZ3/642M7Jcb9sPmor8Vf4w6g==",
"deprecated": "This is a stub types definition. bull provides its own type definitions, so you do not need this installed.",
"dev": true,
"dependencies": {
"bull": "*"
}
},
"node_modules/@types/chai": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.4.tgz",
@ -8072,6 +8189,11 @@
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg=="
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -13043,9 +13165,9 @@
}
},
"node_modules/nc-plugin": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/nc-plugin/-/nc-plugin-0.1.2.tgz",
"integrity": "sha512-9NZJPIgD6r3GnCNJelfZFga+RQPDuHZCoFus2qaZsNdbJEP7LSTOeZ1Pw8l89fvVbp0VFLO3Bcg8ggJppKt8JQ==",
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/nc-plugin/-/nc-plugin-0.1.3.tgz",
"integrity": "sha512-QosW02G6fk/32wc7jKqxCy0sm43cS2cvfkedP2fa8VVeLP6Q6tvhpm/k1boxbCRaLNbQx59F/PNBhogXFRO23g==",
"dependencies": {
"@bitauth/libauth": "^1.17.1",
"nc-common": "0.0.6"
@ -13662,6 +13784,14 @@
"node": ">=0.10.0"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"engines": {
"node": ">=4"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -13706,6 +13836,32 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@ -19967,6 +20123,23 @@
"integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==",
"optional": true
},
"@nestjs/bull": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-0.6.3.tgz",
"integrity": "sha512-CckH9O3t9qSiO4RCzdYvtFSaaMfIhTXMYagV/rtmVvI1SX5XNnxEaQXvtjxDBXF9DB1JE/5AejIl6ICym+MJIw==",
"requires": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
}
},
"@nestjs/bull-shared": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-0.1.3.tgz",
"integrity": "sha512-K0a1ERpnl/ZnTmm0UtYSSClDlDkQwNNwJYM6PogzpeflD64oqwVIn8Pj8rdS+BOYUxqdDy55q3p67ytO5oaVDA==",
"requires": {
"tslib": "2.5.0"
}
},
"@nestjs/cli": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.3.0.tgz",
@ -20149,6 +20322,15 @@
}
}
},
"@nestjs/platform-socket.io": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-9.4.0.tgz",
"integrity": "sha512-pk5uWItnsrFKzvQrFcAmyfcb8cpGgoj4yR4+vbA5H/MLcv+8vGqruQO8riN8jAYGNPN9Y02ihBKbIvQqn92M5g==",
"requires": {
"socket.io": "4.6.1",
"tslib": "2.5.0"
}
},
"@nestjs/schematics": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.1.0.tgz",
@ -20185,6 +20367,16 @@
"tslib": "2.5.0"
}
},
"@nestjs/websockets": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-9.4.0.tgz",
"integrity": "sha512-RATR9C0cKhXp3mTQAg75iItyUuRhVwU39Xe/kl0XLpvAhWzhnGrn6CxSTRRzBfp3F68DOKvs7/ODDY51f+rdXw==",
"requires": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
"tslib": "2.5.0"
}
},
"@nestjsplus/dyn-schematics": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@nestjsplus/dyn-schematics/-/dyn-schematics-1.0.12.tgz",
@ -20636,6 +20828,15 @@
"@types/node": "*"
}
},
"@types/bull": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-4.10.0.tgz",
"integrity": "sha512-RkYW8K2H3J76HT6twmHYbzJ0GtLDDotpLP9ah9gtiA7zfF6peBH1l5fEiK0oeIZ3/642M7Jcb9sPmor8Vf4w6g==",
"dev": true,
"requires": {
"bull": "*"
}
},
"@types/chai": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.4.tgz",
@ -24441,6 +24642,11 @@
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg=="
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -28244,9 +28450,9 @@
}
},
"nc-plugin": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/nc-plugin/-/nc-plugin-0.1.2.tgz",
"integrity": "sha512-9NZJPIgD6r3GnCNJelfZFga+RQPDuHZCoFus2qaZsNdbJEP7LSTOeZ1Pw8l89fvVbp0VFLO3Bcg8ggJppKt8JQ==",
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/nc-plugin/-/nc-plugin-0.1.3.tgz",
"integrity": "sha512-QosW02G6fk/32wc7jKqxCy0sm43cS2cvfkedP2fa8VVeLP6Q6tvhpm/k1boxbCRaLNbQx59F/PNBhogXFRO23g==",
"requires": {
"@bitauth/libauth": "^1.17.1",
"nc-common": "0.0.6"
@ -28708,6 +28914,11 @@
"minimist": "^1.1.0"
}
},
"p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="
},
"p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -28734,6 +28945,23 @@
"aggregate-error": "^3.0.0"
}
},
"p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"requires": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
}
},
"p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"requires": {
"p-finally": "^1.0.0"
}
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",

8
packages/nocodb/package.json

@ -42,6 +42,7 @@
"dependencies": {
"@google-cloud/storage": "^5.7.2",
"@graphql-tools/merge": "^6.0.12",
"@nestjs/bull": "^0.6.3",
"@nestjs/common": "^9.4.0",
"@nestjs/core": "^9.4.0",
"@nestjs/event-emitter": "^1.4.1",
@ -49,7 +50,9 @@
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.4.0",
"@nestjs/platform-socket.io": "^9.4.0",
"@nestjs/serve-static": "^3.0.1",
"@nestjs/websockets": "^9.4.0",
"@sentry/node": "^6.3.5",
"@types/chai": "^4.2.12",
"airtable": "^0.11.3",
@ -62,6 +65,7 @@
"bcryptjs": "^2.4.3",
"body-parser": "^1.19.0",
"boxen": "^5.0.0",
"bull": "^4.10.4",
"bullmq": "^1.81.1",
"clear": "^0.1.0",
"colors": "^1.4.0",
@ -108,12 +112,13 @@
"nanoid": "^3.1.20",
"nc-help": "^0.2.87",
"nc-lib-gui": "0.107.0-beta.0",
"nc-plugin": "0.1.2",
"nc-plugin": "^0.1.3",
"ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk",
"nodemailer": "^6.4.10",
"object-hash": "^3.0.0",
"os-locale": "^6.0.2",
"p-queue": "^6.6.2",
"papaparse": "^5.3.1",
"parse-database-url": "^0.3.0",
"passport": "^0.4.1",
@ -146,6 +151,7 @@
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@nestjsplus/dyn-schematics": "^1.0.12",
"@types/bull": "^4.10.0",
"@types/express": "^4.17.13",
"@types/jest": "^29.5.0",
"@types/mocha": "^10.0.1",

4
packages/nocodb/src/Noco.ts

@ -3,6 +3,7 @@ import { NestFactory } from '@nestjs/core';
import clear from 'clear';
import * as express from 'express';
import NcToolGui from 'nc-lib-gui';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { AppModule } from './app.module';
import { NC_LICENSE_KEY } from './constants';
@ -98,6 +99,9 @@ export default class Noco {
this._server = server;
const nestApp = await NestFactory.create(AppModule);
nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
nestApp.use(
express.json({ limit: process.env.NC_REQUEST_BODY_SIZE || '50mb' }),
);

12
packages/nocodb/src/app.module.ts

@ -1,5 +1,7 @@
import { Inject, Module, RequestMethod } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { BullModule } from '@nestjs/bull';
import { EventEmitterModule as NestJsEventEmitter } from '@nestjs/event-emitter';
import { Connection } from './connection/connection';
import { GlobalExceptionFilter } from './filters/global-exception/global-exception.filter';
import NcPluginMgrv2 from './helpers/NcPluginMgrv2';
@ -23,6 +25,7 @@ import NcConfigFactory from './utils/NcConfigFactory';
import NcUpgrader from './version-upgrader/NcUpgrader';
import { MetasModule } from './modules/metas/metas.module';
import NocoCache from './cache/NocoCache';
import { JobsModule } from './modules/jobs/jobs.module';
import type {
MiddlewareConsumer,
OnApplicationBootstrap,
@ -36,6 +39,15 @@ import type {
MetasModule,
DatasModule,
EventEmitterModule,
JobsModule,
NestJsEventEmitter.forRoot(),
...(process.env['NC_REDIS_URL']
? [
BullModule.forRoot({
redis: process.env.NC_REDIS_URL,
}),
]
: []),
],
controllers: [],
providers: [

6
packages/nocodb/src/controllers/imports/helpers/NocoSyncDestAdapter.ts

@ -1,6 +0,0 @@
export abstract class NocoSyncSourceAdapter {
public abstract init(): Promise<void>;
public abstract destProjectWrite(): Promise<any>;
public abstract destSchemaWrite(): Promise<any>;
public abstract destDataWrite(): Promise<any>;
}

7
packages/nocodb/src/controllers/imports/helpers/NocoSyncSourceAdapter.ts

@ -1,7 +0,0 @@
export abstract class NocoSyncSourceAdapter {
public abstract init(): Promise<void>;
public abstract srcSchemaGet(): Promise<any>;
public abstract srcDataLoad(): Promise<any>;
public abstract srcDataListen(): Promise<any>;
public abstract srcDataPoll(): Promise<any>;
}

2480
packages/nocodb/src/controllers/imports/helpers/job.ts

File diff suppressed because it is too large Load Diff

21
packages/nocodb/src/controllers/imports/import.controller.spec.ts

@ -1,21 +0,0 @@
import { Test } from '@nestjs/testing';
import { ImportService } from '../../services/import.service';
import { ImportController } from './import.controller';
import type { TestingModule } from '@nestjs/testing';
describe('ImportController', () => {
let controller: ImportController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ImportController],
providers: [ImportService],
}).compile();
controller = module.get<ImportController>(ImportController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

148
packages/nocodb/src/controllers/imports/import.controller.ts

@ -1,148 +0,0 @@
import { Controller, HttpCode, Post, Request, UseGuards } from '@nestjs/common';
import { forwardRef, Inject } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { GlobalGuard } from '../../guards/global/global.guard';
import { NcError } from '../../helpers/catchError';
import { ExtractProjectIdMiddleware } from '../../middlewares/extract-project-id/extract-project-id.middleware';
import { SyncSource } from '../../models';
import NocoJobs from '../../jobs/NocoJobs';
import { SocketService } from '../../services/socket.service';
import airtableSyncJob from './helpers/job';
import type { AirtableSyncConfig } from './helpers/job';
import type { Server } from 'socket.io';
const AIRTABLE_IMPORT_JOB = 'AIRTABLE_IMPORT_JOB';
const AIRTABLE_PROGRESS_JOB = 'AIRTABLE_PROGRESS_JOB';
enum SyncStatus {
PROGRESS = 'PROGRESS',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
}
const initJob = (sv: Server, jobs: { [p: string]: { last_message: any } }) => {
// add importer job handler and progress notification job handler
NocoJobs.jobsMgr.addJobWorker(AIRTABLE_IMPORT_JOB, airtableSyncJob);
NocoJobs.jobsMgr.addJobWorker(
AIRTABLE_PROGRESS_JOB,
({ payload, progress }) => {
sv.to(payload?.id).emit('progress', {
msg: progress?.msg,
level: progress?.level,
status: progress?.status,
});
if (payload?.id in jobs) {
jobs[payload?.id].last_message = {
msg: progress?.msg,
level: progress?.level,
status: progress?.status,
};
}
},
);
NocoJobs.jobsMgr.addProgressCbk(AIRTABLE_IMPORT_JOB, (payload, progress) => {
NocoJobs.jobsMgr.add(AIRTABLE_PROGRESS_JOB, {
payload,
progress: {
msg: progress?.msg,
level: progress?.level,
status: progress?.status,
},
});
});
NocoJobs.jobsMgr.addSuccessCbk(AIRTABLE_IMPORT_JOB, (payload) => {
NocoJobs.jobsMgr.add(AIRTABLE_PROGRESS_JOB, {
payload,
progress: {
msg: 'Complete!',
status: SyncStatus.COMPLETED,
},
});
delete jobs[payload?.id];
});
NocoJobs.jobsMgr.addFailureCbk(AIRTABLE_IMPORT_JOB, (payload, error: any) => {
NocoJobs.jobsMgr.add(AIRTABLE_PROGRESS_JOB, {
payload,
progress: {
msg: error?.message || 'Failed due to some internal error',
status: SyncStatus.FAILED,
},
});
delete jobs[payload?.id];
});
};
@Controller()
@UseGuards(ExtractProjectIdMiddleware, GlobalGuard)
export class ImportController {
constructor(
private readonly socketService: SocketService,
@Inject(forwardRef(() => ModuleRef)) private readonly moduleRef: ModuleRef,
) {}
@Post('/api/v1/db/meta/import/airtable')
@HttpCode(200)
importAirtable(@Request() req) {
NocoJobs.jobsMgr.add(AIRTABLE_IMPORT_JOB, {
id: req.query.id,
...req.body,
});
return {};
}
@Post('/api/v1/db/meta/syncs/:syncId/trigger')
@HttpCode(200)
async triggerSync(@Request() req) {
if (req.params.syncId in this.socketService.jobs) {
NcError.badRequest('Sync already in progress');
}
const syncSource = await SyncSource.get(req.params.syncId);
const user = await syncSource.getUser();
// Treat default baseUrl as siteUrl from req object
let baseURL = (req as any).ncSiteUrl;
// if environment value avail use it
// or if it's docker construct using `PORT`
if (process.env.NC_DOCKER) {
baseURL = `http://localhost:${process.env.PORT || 8080}`;
}
setTimeout(() => {
NocoJobs.jobsMgr.add<AirtableSyncConfig>(AIRTABLE_IMPORT_JOB, {
id: req.params.syncId,
...(syncSource?.details || {}),
projectId: syncSource.project_id,
baseId: syncSource.base_id,
authToken: '',
baseURL,
user: user,
moduleRef: this.moduleRef,
});
}, 1000);
this.socketService.jobs[req.params.syncId] = {
last_message: {
msg: 'Sync started',
},
};
return {};
}
@Post('/api/v1/db/meta/syncs/:syncId/abort')
@HttpCode(200)
async abortImport(@Request() req) {
if (req.params.syncId in this.socketService.jobs) {
delete this.socketService.jobs[req.params.syncId];
}
return {};
}
async onModuleInit() {
initJob(this.socketService.io, this.socketService.jobs);
}
}

30
packages/nocodb/src/controllers/test/TestResetService/index.ts

@ -4,11 +4,16 @@ import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2';
import Noco from '../../../Noco';
import User from '../../../models/User';
import NocoCache from '../../../cache/NocoCache';
import { CacheScope } from '../../../utils/globals';
import {
CacheDelDirection,
CacheScope,
MetaTable,
} from '../../../utils/globals';
import ProjectUser from '../../../models/ProjectUser';
import resetPgSakilaProject from './resetPgSakilaProject';
import resetMysqlSakilaProject from './resetMysqlSakilaProject';
import resetMetaSakilaSqliteProject from './resetMetaSakilaSqliteProject';
import type ApiToken from '../../../models/ApiToken';
const workerStatus = {};
@ -80,6 +85,7 @@ export class TestResetService {
try {
await removeAllProjectCreatedByTheTest(this.parallelId);
await removeAllPrefixedUsersExceptSuper(this.parallelId);
await removeAllTokensCreatedByTheTest(this.parallelId);
} catch (e) {
console.log(`Error in cleaning up project: ${this.parallelId}`, e);
}
@ -174,6 +180,28 @@ const removeAllPrefixedUsersExceptSuper = async (parallelId: string) => {
}
};
const removeAllTokensCreatedByTheTest = async (parallelId: string) => {
const tokens: ApiToken[] = await Noco.ncMeta.metaList(
null,
null,
MetaTable.API_TOKENS,
);
for (const token of tokens) {
if (token.description.startsWith(`nc_test_${parallelId}`)) {
await NocoCache.deepDel(
CacheScope.API_TOKEN,
`${CacheScope.API_TOKEN}:${token.token}`,
CacheDelDirection.CHILD_TO_PARENT,
);
await Noco.ncMeta.metaDelete(null, null, MetaTable.API_TOKENS, {
token: token.token,
});
}
}
};
// todo: Remove this once user deletion improvement PR is merged
const removeProjectUsersFromCache = async (project: Project) => {
const projectUsers: ProjectUser[] = await ProjectUser.getUsersList({

81
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -2091,23 +2091,32 @@ class BaseModelSqlv2 {
{
chunkSize: _chunkSize = 100,
cookie,
foreign_key_checks = true,
raw = false,
}: {
chunkSize?: number;
cookie?: any;
foreign_key_checks?: boolean;
raw?: boolean;
} = {},
) {
try {
const insertDatas = await Promise.all(
datas.map(async (d) => {
await populatePk(this.model, d);
return this.model.mapAliasToColumn(d);
}),
);
// TODO: ag column handling for raw bulk insert
const insertDatas = raw
? datas
: await Promise.all(
datas.map(async (d) => {
await populatePk(this.model, d);
return this.model.mapAliasToColumn(d);
}),
);
// await this.beforeInsertb(insertDatas, null);
for (const data of datas) {
await this.validate(data);
if (!raw) {
for (const data of datas) {
await this.validate(data);
}
}
// fallbacks to `10` if database client is sqlite
@ -2115,18 +2124,34 @@ class BaseModelSqlv2 {
// refer : https://www.sqlite.org/limits.html
const chunkSize = this.isSqlite ? 10 : _chunkSize;
const trx = await this.dbDriver.transaction();
if (!foreign_key_checks) {
if (this.isPg) {
await trx.raw('set session_replication_role to replica;');
} else if (this.isMySQL) {
await trx.raw('SET foreign_key_checks = 0;');
}
}
const response =
this.isPg || this.isMssql
? await this.dbDriver
? await trx
.batchInsert(this.tnPath, insertDatas, chunkSize)
.returning(this.model.primaryKey?.column_name)
: await this.dbDriver.batchInsert(
this.tnPath,
insertDatas,
chunkSize,
);
: await trx.batchInsert(this.tnPath, insertDatas, chunkSize);
await this.afterBulkInsert(insertDatas, this.dbDriver, cookie);
if (!foreign_key_checks) {
if (this.isPg) {
await trx.raw('set session_replication_role to origin;');
} else if (this.isMySQL) {
await trx.raw('SET foreign_key_checks = 1;');
}
}
await trx.commit();
if (!raw) await this.afterBulkInsert(insertDatas, this.dbDriver, cookie);
return response;
} catch (e) {
@ -2135,12 +2160,17 @@ class BaseModelSqlv2 {
}
}
async bulkUpdate(datas: any[], { cookie }: { cookie?: any } = {}) {
async bulkUpdate(
datas: any[],
{ cookie, raw = false }: { cookie?: any; raw?: boolean } = {},
) {
let transaction;
try {
const updateDatas = await Promise.all(
datas.map((d) => this.model.mapAliasToColumn(d)),
);
if (raw) await this.model.getColumns();
const updateDatas = raw
? datas
: await Promise.all(datas.map((d) => this.model.mapAliasToColumn(d)));
const prevData = [];
const newData = [];
@ -2148,13 +2178,13 @@ class BaseModelSqlv2 {
const toBeUpdated = [];
const res = [];
for (const d of updateDatas) {
await this.validate(d);
if (!raw) await this.validate(d);
const pkValues = await this._extractPksValues(d);
if (!pkValues) {
// pk not specified - bypass
continue;
}
prevData.push(await this.readByPk(pkValues));
if (!raw) prevData.push(await this.readByPk(pkValues));
const wherePk = await this._wherePk(pkValues);
res.push(wherePk);
toBeUpdated.push({ d, wherePk });
@ -2169,11 +2199,14 @@ class BaseModelSqlv2 {
await transaction.commit();
for (const pkValues of updatePkValues) {
newData.push(await this.readByPk(pkValues));
if (!raw) {
for (const pkValues of updatePkValues) {
newData.push(await this.readByPk(pkValues));
}
}
await this.afterBulkUpdate(prevData, newData, this.dbDriver, cookie);
if (!raw)
await this.afterBulkUpdate(prevData, newData, this.dbDriver, cookie);
return res;
} catch (e) {

87
packages/nocodb/src/db/sql-client/lib/sqlite/SqliteClient.ts

@ -200,7 +200,7 @@ class SqliteClient extends KnexClient {
table.integer('status').nullable();
table.dateTime('created');
table.timestamps();
},
}
);
log.debug('Table created:', `${args.tn}`, data);
} else {
@ -295,7 +295,7 @@ class SqliteClient extends KnexClient {
try {
const response = await this.sqlClient.raw(
`SELECT name as tn FROM sqlite_master where type = 'table'`,
`SELECT name as tn FROM sqlite_master where type = 'table'`
);
result.data.list = [];
@ -359,7 +359,7 @@ class SqliteClient extends KnexClient {
try {
const response = await this.sqlClient.raw(
`PRAGMA table_info("${args.tn}")`,
`PRAGMA table_info("${args.tn}")`
);
const triggerList = (await this.triggerList(args)).data.list;
@ -409,7 +409,7 @@ class SqliteClient extends KnexClient {
response[i].not_nullable = response[i].notnull === 1;
response[i].rqd = response[i].notnull === 1;
response[i].cdf = response[i].dflt_value;
response[i].pk = response[i].pk === 1;
response[i].pk = response[i].pk > 0;
response[i].cop = response[i].cid;
// https://stackoverflow.com/a/7906029
@ -420,8 +420,7 @@ class SqliteClient extends KnexClient {
response[i].dtxs = '';
response[i].au = !!triggerList.find(
({ trigger }) =>
trigger === `xc_trigger_${args.tn}_${response[i].cn}`,
({ trigger }) => trigger === `xc_trigger_${args.tn}_${response[i].cn}`
);
}
@ -467,7 +466,7 @@ class SqliteClient extends KnexClient {
// PRAGMA index_xinfo('idx_fk_original_language_id');
const response = await this.sqlClient.raw(
`PRAGMA index_list("${args.tn}")`,
`PRAGMA index_list("${args.tn}")`
);
const rows = [];
@ -479,7 +478,7 @@ class SqliteClient extends KnexClient {
response[i].unique = response[i].unique === 1 ? 1 : 0;
const colsInIndex = await this.sqlClient.raw(
`PRAGMA index_info('${response[i].key_name}')`,
`PRAGMA index_info('${response[i].key_name}')`
);
if (colsInIndex.length === 1) {
@ -532,7 +531,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`PRAGMA foreign_key_list('${args.tn}')`,
`PRAGMA foreign_key_list('${args.tn}')`
);
for (let i = 0; i < response.length; ++i) {
@ -583,7 +582,7 @@ class SqliteClient extends KnexClient {
for (let i = 0; i < tables.length; ++i) {
const response = await this.sqlClient.raw(
`PRAGMA foreign_key_list('${tables[i].tn}')`,
`PRAGMA foreign_key_list('${tables[i].tn}')`
);
for (let j = 0; j < response.length; ++j) {
@ -634,7 +633,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`select *, name as trigger_name from sqlite_master where type = 'trigger' and tbl_name='${args.tn}';`,
`select *, name as trigger_name from sqlite_master where type = 'trigger' and tbl_name='${args.tn}';`
);
for (let i = 0; i < response.length; ++i) {
@ -677,7 +676,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`show function status where db='${args.databaseName}'`,
`show function status where db='${args.databaseName}'`
);
if (response.length === 2) {
@ -731,7 +730,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`show procedure status where db='${args.databaseName}'`,
`show procedure status where db='${args.databaseName}'`
);
if (response.length === 2) {
@ -776,7 +775,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`SELECT * FROM sqlite_master WHERE type = 'view'`,
`SELECT * FROM sqlite_master WHERE type = 'view'`
);
for (let i = 0; i < response.length; ++i) {
@ -814,7 +813,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`SHOW CREATE FUNCTION ${args.function_name};`,
`SHOW CREATE FUNCTION ${args.function_name};`
);
if (response.length === 2) {
@ -866,7 +865,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`show create procedure ${args.procedure_name};`,
`show create procedure ${args.procedure_name};`
);
if (response.length === 2) {
@ -912,7 +911,7 @@ class SqliteClient extends KnexClient {
try {
const response = await this.sqlClient.raw(
`SELECT * FROM sqlite_master WHERE type = 'view' AND name = '${args.view_name}'`,
`SELECT * FROM sqlite_master WHERE type = 'view' AND name = '${args.view_name}'`
);
for (let i = 0; i < response.length; ++i) {
@ -939,7 +938,7 @@ class SqliteClient extends KnexClient {
args.databaseName = this.connectionConfig.connection.database;
const response = await this.sqlClient.raw(
`SHOW FULL TABLES IN ${args.databaseName} WHERE TABLE_TYPE LIKE 'VIEW';`,
`SHOW FULL TABLES IN ${args.databaseName} WHERE TABLE_TYPE LIKE 'VIEW';`
);
if (response.length === 2) {
@ -971,7 +970,7 @@ class SqliteClient extends KnexClient {
log.api(`${_func}:args:`, args);
const rows = await this.sqlClient.raw(
`create database ${args.database_name}`,
`create database ${args.database_name}`
);
return rows;
}
@ -982,7 +981,7 @@ class SqliteClient extends KnexClient {
log.api(`${_func}:args:`, args);
const rows = await this.sqlClient.raw(
`drop database ${args.database_name}`,
`drop database ${args.database_name}`
);
return rows;
}
@ -1012,7 +1011,7 @@ class SqliteClient extends KnexClient {
log.api(`${_func}:args:`, args);
const rows = await this.sqlClient.raw(
`DROP FUNCTION IF EXISTS ${args.function_name}`,
`DROP FUNCTION IF EXISTS ${args.function_name}`
);
return rows;
}
@ -1023,7 +1022,7 @@ class SqliteClient extends KnexClient {
log.api(`${_func}:args:`, args);
const rows = await this.sqlClient.raw(
`DROP PROCEDURE IF EXISTS ${args.procedure_name}`,
`DROP PROCEDURE IF EXISTS ${args.procedure_name}`
);
return rows;
}
@ -1043,7 +1042,7 @@ class SqliteClient extends KnexClient {
this._version = result.data.object;
log.debug(
`Version was empty for ${args.func}: population version for database as`,
this._version,
this._version
);
}
@ -1074,7 +1073,7 @@ class SqliteClient extends KnexClient {
log.api(`${func}:args:`, args);
try {
const rows = await this.sqlClient.raw(
`CREATE TRIGGER \`${args.function_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
`CREATE TRIGGER \`${args.function_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
);
result.data.list = rows;
} catch (e) {
@ -1102,7 +1101,7 @@ class SqliteClient extends KnexClient {
try {
await this.sqlClient.raw(`DROP TRIGGER ${args.function_name}`);
const rows = await this.sqlClient.raw(
`CREATE TRIGGER \`${args.function_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
`CREATE TRIGGER \`${args.function_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
);
result.data.list = rows;
} catch (e) {
@ -1129,7 +1128,7 @@ class SqliteClient extends KnexClient {
log.api(`${func}:args:`, args);
try {
const rows = await this.sqlClient.raw(
`CREATE TRIGGER \`${args.procedure_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
`CREATE TRIGGER \`${args.procedure_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
);
result.data.list = rows;
} catch (e) {
@ -1157,7 +1156,7 @@ class SqliteClient extends KnexClient {
try {
await this.sqlClient.raw(`DROP TRIGGER ${args.procedure_name}`);
const rows = await this.sqlClient.raw(
`CREATE TRIGGER \`${args.procedure_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
`CREATE TRIGGER \`${args.procedure_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
);
result.data.list = rows;
} catch (e) {
@ -1217,7 +1216,7 @@ class SqliteClient extends KnexClient {
try {
await this.sqlClient.raw(`DROP TRIGGER ${args.trigger_name}`);
await this.sqlClient.raw(
`CREATE TRIGGER \`${args.trigger_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`,
`CREATE TRIGGER \`${args.trigger_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`
);
const upQuery = `DROP TRIGGER ${args.trigger_name};\nCREATE TRIGGER \`${args.trigger_name}\` \n${args.timing} ${args.event}\nON "${args.tn}" FOR EACH ROW\n${args.statement}`;
@ -1508,13 +1507,13 @@ class SqliteClient extends KnexClient {
args.table,
args.columns[i],
oldColumn,
upQuery,
upQuery
);
downQuery += this.alterTableAddColumn(
args.table,
oldColumn,
args.columns[i],
downQuery,
downQuery
);
} else if (args.columns[i].altered & 2 || args.columns[i].altered & 8) {
// col edit
@ -1522,7 +1521,7 @@ class SqliteClient extends KnexClient {
args.table,
args.columns[i],
oldColumn,
upQuery,
upQuery
);
downQuery += ';';
// downQuery += this.alterTableChangeColumn(
@ -1538,7 +1537,7 @@ class SqliteClient extends KnexClient {
args.table,
args.columns[i],
oldColumn,
upQuery,
upQuery
);
downQuery += ';';
// downQuery += alterTableRemoveColumn(
@ -1554,7 +1553,7 @@ class SqliteClient extends KnexClient {
const pkQuery = this.alterTablePK(
args.columns,
args.originalColumns,
upQuery,
upQuery
);
await this.sqlClient.raw('PRAGMA foreign_keys = OFF;');
@ -1573,7 +1572,7 @@ class SqliteClient extends KnexClient {
if (pkQuery) {
await trx.schema.alterTable(args.table, (table) => {
for (const pk of pkQuery.oldPks.filter(
(el) => !pkQuery.newPks.includes(el),
(el) => !pkQuery.newPks.includes(el)
)) {
table.dropPrimary(pk);
}
@ -1859,7 +1858,7 @@ class SqliteClient extends KnexClient {
/* Filter relations for current table */
if (args.tn) {
relations = relations.filter(
(r) => r.tn === args.tn || r.rtn === args.tn,
(r) => r.tn === args.tn || r.rtn === args.tn
);
}
@ -1868,7 +1867,7 @@ class SqliteClient extends KnexClient {
let columns: any = await this.columnList({ tn: tables[i].tn });
columns = columns.data.list;
console.log(
`Sequelize model created: ${tables[i].tn}(${columns.length})\n`,
`Sequelize model created: ${tables[i].tn}(${columns.length})\n`
);
// let SqliteSequelizeRender = require('./SqliteSequelizeRender');
@ -1968,7 +1967,7 @@ class SqliteClient extends KnexClient {
query += this.genQuery(
`ALTER TABLE ?? DROP COLUMN ??`,
[t, n.cn],
shouldSanitize,
shouldSanitize
);
return query;
}
@ -2008,14 +2007,14 @@ class SqliteClient extends KnexClient {
const backupOldColumnQuery = this.genQuery(
`ALTER TABLE ?? RENAME COLUMN ?? TO ??;`,
[t, o.cn, `${o.cno}_nc_${suffix}`],
shouldSanitize,
shouldSanitize
);
let addNewColumnQuery = '';
addNewColumnQuery += this.genQuery(
` ADD ?? ${this.sanitiseDataType(n.dt)}`,
[n.cn],
shouldSanitize,
shouldSanitize
);
addNewColumnQuery += n.dtxp && n.dt !== 'text' ? `(${n.dtxp})` : '';
addNewColumnQuery += n.cdf
@ -2027,19 +2026,19 @@ class SqliteClient extends KnexClient {
addNewColumnQuery = this.genQuery(
`ALTER TABLE ?? ${addNewColumnQuery};`,
[t],
shouldSanitize,
shouldSanitize
);
const updateNewColumnQuery = this.genQuery(
`UPDATE ?? SET ?? = ??;`,
[t, n.cn, `${o.cno}_nc_${suffix}`],
shouldSanitize,
shouldSanitize
);
const dropOldColumnQuery = this.genQuery(
`ALTER TABLE ?? DROP COLUMN ??;`,
[t, `${o.cno}_nc_${suffix}`],
shouldSanitize,
shouldSanitize
);
query = `${backupOldColumnQuery}${addNewColumnQuery}${updateNewColumnQuery}${dropOldColumnQuery}`;
@ -2106,12 +2105,12 @@ class SqliteClient extends KnexClient {
try {
const tables = await this.sqlClient.raw(
`SELECT name FROM sqlite_master WHERE type='table';`,
`SELECT name FROM sqlite_master WHERE type='table';`
);
let count = 0;
for (const tb of tables) {
const tmp = await this.sqlClient.raw(
`SELECT COUNT(*) as ct FROM '${tb.name}';`,
`SELECT COUNT(*) as ct FROM '${tb.name}';`
);
if (tmp && tmp.length) {
count += tmp[0].ct;

12
packages/nocodb/src/services/socket.service.spec.ts → packages/nocodb/src/gateways/socket.gateway.spec.ts

@ -1,19 +1,19 @@
import { Test } from '@nestjs/testing';
import { SocketService } from './socket.service';
import { SocketGateway } from './socket.gateway';
import type { TestingModule } from '@nestjs/testing';
describe('ClientService', () => {
let service: SocketService;
describe('SocketGateway', () => {
let gateway: SocketGateway;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SocketService],
providers: [SocketGateway],
}).compile();
service = module.get<SocketService>(SocketService);
gateway = module.get<SocketGateway>(SocketGateway);
});
it('should be defined', () => {
expect(service).toBeDefined();
expect(gateway).toBeDefined();
});
});

43
packages/nocodb/src/services/socket.service.ts → packages/nocodb/src/gateways/socket.gateway.ts

@ -1,11 +1,11 @@
import crypto from 'crypto';
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Inject, Injectable } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { T } from 'nc-help';
import { Server } from 'socket.io';
import { AuthGuard } from '@nestjs/passport';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import Noco from '../Noco';
import { JwtStrategy } from '../strategies/jwt.strategy';
import type { OnModuleInit } from '@nestjs/common';
import type { Socket } from 'socket.io';
@ -14,30 +14,28 @@ function getHash(str) {
return crypto.createHash('md5').update(str).digest('hex');
}
@WebSocketGateway({
cors: {
origin: '*',
allowedHeaders: ['xc-auth'],
credentials: true,
},
})
@Injectable()
export class SocketService implements OnModuleInit {
export class SocketGateway implements OnModuleInit {
// private server: HttpServer;
private clients: { [id: string]: Socket } = {};
private _jobs: { [id: string]: { last_message: any } } = {};
private _io: Server;
constructor(
private jwtStrategy: JwtStrategy,
@Inject(HttpAdapterHost) private httpAdapterHost: HttpAdapterHost,
) {}
@WebSocketServer()
server: Server;
async onModuleInit() {
this._io = new Server(
Noco.httpServer ?? this.httpAdapterHost.httpAdapter.getHttpServer(),
{
cors: {
origin: '*',
allowedHeaders: ['xc-auth'],
credentials: true,
},
},
);
this.io
this.server
.use(async (socket, next) => {
try {
const context = new ExecutionContextHost([socket.handshake as any]);
@ -60,21 +58,10 @@ export class SocketService implements OnModuleInit {
socket.on('event', (args) => {
T.event({ ...args, id });
});
socket.on('subscribe', (room) => {
if (room in this.jobs) {
socket.join(room);
socket.emit('job');
socket.emit('progress', this.jobs[room].last_message);
}
});
});
}
public get io() {
return this._io;
}
public get jobs() {
return this._jobs;
return this.server;
}
}

82
packages/nocodb/src/helpers/exportImportHelpers.ts

@ -0,0 +1,82 @@
import type { Base } from '../models';
export async function generateBaseIdMap(
base: Base,
idMap: Map<string, string>,
) {
idMap.set(base.project_id, base.project_id);
idMap.set(base.id, `${base.project_id}::${base.id}`);
const models = await base.getModels();
for (const md of models) {
idMap.set(md.id, `${base.project_id}::${base.id}::${md.id}`);
await md.getColumns();
for (const column of md.columns) {
idMap.set(column.id, `${idMap.get(md.id)}::${column.id}`);
}
}
return models;
}
export function clearPrefix(text: string, prefix?: string) {
if (!prefix || prefix.length === 0) return text;
return text.replace(new RegExp(`^${prefix}_?`), '');
}
export function withoutNull(obj: any) {
const newObj = {};
let found = false;
for (const [key, value] of Object.entries(obj)) {
if (value !== null) {
newObj[key] = value;
found = true;
}
}
if (!found) return null;
return newObj;
}
export function reverseGet(map: Map<string, string>, vl: string) {
for (const [key, value] of map.entries()) {
if (vl === value) {
return key;
}
}
return undefined;
}
export function withoutId(obj: any) {
const { id, ...rest } = obj;
return rest;
}
export function getParentIdentifier(id: string) {
const arr = id.split('::');
arr.pop();
return arr.join('::');
}
export function getEntityIdentifier(id: string) {
const arr = id.split('::');
return arr.pop();
}
export function findWithIdentifier(map: Map<string, any>, id: string) {
for (const key of map.keys()) {
if (getEntityIdentifier(key) === id) {
return map.get(key);
}
}
return undefined;
}
export function generateUniqueName(name: string, names: string[]) {
let newName = name;
let i = 1;
while (names.includes(newName)) {
newName = `${name}_${i}`;
i++;
}
return newName;
}

6
packages/nocodb/src/helpers/populateMeta.ts

@ -83,7 +83,11 @@ export async function extractAndGenerateManyToManyRelations(
}
// todo: impl better method to identify m2m relation
if (belongsToCols?.length === 2 && normalColumns.length < 5) {
if (
belongsToCols?.length === 2 &&
normalColumns.length < 5 &&
assocModel.primaryKeys.length === 2
) {
const modelA = await belongsToCols[0].colOptions.getRelatedTable();
const modelB = await belongsToCols[1].colOptions.getRelatedTable();

22
packages/nocodb/src/interface/Jobs.ts

@ -0,0 +1,22 @@
export const JOBS_QUEUE = 'jobs';
export enum JobTypes {
DuplicateBase = 'duplicate-base',
DuplicateModel = 'duplicate-model',
AtImport = 'at-import',
}
export enum JobStatus {
COMPLETED = 'completed',
WAITING = 'waiting',
ACTIVE = 'active',
DELAYED = 'delayed',
FAILED = 'failed',
PAUSED = 'paused',
REFRESH = 'refresh',
}
export enum JobEvents {
STATUS = 'job.status',
LOG = 'job.log',
}

35
packages/nocodb/src/jobs/EmitteryJobsMgr.ts

@ -1,35 +0,0 @@
import Emittery from 'emittery';
import JobsMgr from './JobsMgr';
export default class EmitteryJobsMgr extends JobsMgr {
emitter: Emittery;
constructor() {
super();
this.emitter = new Emittery();
}
add(jobName: string, payload: any): Promise<any> {
return this.emitter.emit(jobName, payload);
}
addJobWorker(
jobName: string,
workerFn: (
payload: any,
progressCbk?: (payload: any, msg?: string) => void,
) => void,
) {
this.emitter.on(jobName, async (payload) => {
try {
await workerFn(payload, (msg) =>
this.invokeProgressCbks(jobName, payload, msg),
);
await this.invokeSuccessCbks(jobName, payload);
} catch (e) {
console.log(e);
await this.invokeFailureCbks(jobName, payload, e);
}
});
}
}

67
packages/nocodb/src/jobs/JobsMgr.ts

@ -1,67 +0,0 @@
export default abstract class JobsMgr {
protected successCbks: Array<{
[jobName: string]: (payload: any) => void;
}> = [];
protected failureCbks: Array<{
[jobName: string]: (payload: any, error: Error) => void;
}> = [];
protected progressCbks: Array<{
[jobName: string]: (payload: any, msg?: string) => void;
}> = [];
public abstract add<T>(jobName: string, payload: T): Promise<any>;
public abstract addJobWorker(
jobName: string,
workerFn: (
payload: any,
progressCbk?: (payload: any, msg?: string) => void,
) => void,
options?: {
onSuccess?: (payload: any) => void;
onFailure?: (payload: any, errorData: any) => void;
onProgress?: (payload: any, progressData: any) => void;
},
);
addSuccessCbk(jobName: string, cbk: (payload: any) => void) {
this.successCbks[jobName] = this.successCbks[jobName] || [];
this.successCbks[jobName].push(cbk);
}
addFailureCbk(jobName: string, cbk: (payload: any, errorData: any) => void) {
this.failureCbks[jobName] = this.failureCbks[jobName] || [];
this.failureCbks[jobName].push(cbk);
}
addProgressCbk(
jobName: string,
cbk: (payload: any, progressData: any) => void,
) {
this.progressCbks[jobName] = this.progressCbks[jobName] || [];
this.progressCbks[jobName].push(cbk);
}
protected async invokeSuccessCbks(jobName: string, payload: any) {
await Promise.all(
this.successCbks?.[jobName]?.map((cb) => cb(payload)) || [],
);
}
protected async invokeFailureCbks(
jobName: string,
payload: any,
error?: Error,
) {
await Promise.all(
this.failureCbks?.[jobName]?.map((cb) => cb(payload, error)) || [],
);
}
protected async invokeProgressCbks(
jobName: string,
payload: any,
data?: any,
) {
await Promise.all(
this.progressCbks?.[jobName]?.map((cb) => cb(payload, data)) || [],
);
}
}

20
packages/nocodb/src/jobs/NocoJobs.ts

@ -1,20 +0,0 @@
import EmitteryJobsMgr from './EmitteryJobsMgr';
import RedisJobsMgr from './RedisJobsMgr';
import type JobsMgr from './JobsMgr';
export default class NocoJobs {
private static client: JobsMgr;
private static init() {
if (process.env.NC_REDIS_URL) {
this.client = new RedisJobsMgr(process.env.NC_REDIS_URL);
} else {
this.client = new EmitteryJobsMgr();
}
}
public static get jobsMgr(): JobsMgr {
if (!this.client) this.init();
return this.client;
}
}

56
packages/nocodb/src/jobs/RedisJobsMgr.ts

@ -1,56 +0,0 @@
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
import JobsMgr from './JobsMgr';
export default class RedisJobsMgr extends JobsMgr {
queue: { [jobName: string]: Queue };
workers: { [jobName: string]: Worker };
connection: Redis;
constructor(config: any) {
super();
this.queue = {};
this.workers = {};
this.connection = new Redis(config, {
maxRetriesPerRequest: null,
});
}
async add(
jobName: string,
payload: any,
// options?: {
// onSuccess?: (payload: any) => void;
// onFailure?: (payload: any, msg: string) => void;
// onProgress?: (payload: any, msgOrData: any) => void;
// }
): Promise<any> {
this.queue[jobName] =
this.queue[jobName] ||
new Queue(jobName, { connection: this.connection });
this.queue[jobName].add(jobName, payload);
}
addJobWorker(
jobName: string,
workerFn: (
payload: any,
progressCbk?: (payload: any, msg?: string) => void,
) => void,
) {
this.workers[jobName] = new Worker(
jobName,
async (payload) => {
try {
await workerFn(payload.data, (...args) =>
this.invokeProgressCbks(jobName, ...args),
);
await this.invokeFailureCbks(jobName, payload.data);
} catch (e) {
await this.invokeFailureCbks(jobName, payload.data);
}
},
{ connection: this.connection },
);
}
}

1
packages/nocodb/src/models/Project.ts

@ -43,6 +43,7 @@ export default class Project implements ProjectType {
'prefix',
'description',
'is_meta',
'status',
]);
const { id: projectId } = await ncMeta.metaInsert2(

6
packages/nocodb/src/modules/global/global.module.ts

@ -1,10 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { ExtractJwt } from 'passport-jwt';
import { SocketGateway } from '../../gateways/socket.gateway';
import { Connection } from '../../connection/connection';
import { GlobalGuard } from '../../guards/global/global.guard';
import { MetaService } from '../../meta/meta.service';
import { SocketService } from '../../services/socket.service';
import { JwtStrategy } from '../../strategies/jwt.strategy';
import NcConfigFactory from '../../utils/NcConfigFactory';
import { UsersService } from '../../services/users/users.service';
@ -38,7 +38,7 @@ export const JwtStrategyProvider: Provider = {
UsersService,
JwtStrategyProvider,
GlobalGuard,
SocketService,
SocketGateway,
],
exports: [
Connection,
@ -46,7 +46,7 @@ export const JwtStrategyProvider: Provider = {
JwtStrategyProvider,
UsersService,
GlobalGuard,
SocketService,
SocketGateway,
],
})
export class GlobalModule {}

65
packages/nocodb/src/modules/jobs/at-import/at-import.controller.ts

@ -0,0 +1,65 @@
import { Controller, HttpCode, Post, Request, UseGuards } from '@nestjs/common';
import { GlobalGuard } from '../../../guards/global/global.guard';
import { ExtractProjectIdMiddleware } from '../../../middlewares/extract-project-id/extract-project-id.middleware';
import { SyncSource } from '../../../models';
import { NcError } from '../../../helpers/catchError';
import { JobsService } from '../jobs.service';
import { JobTypes } from '../../../interface/Jobs';
@Controller()
@UseGuards(ExtractProjectIdMiddleware, GlobalGuard)
export class AtImportController {
constructor(private readonly jobsService: JobsService) {}
@Post('/api/v1/db/meta/import/airtable')
@HttpCode(200)
async importAirtable(@Request() req) {
const job = await this.jobsService.activeQueue.add(JobTypes.AtImport, {
...req.body,
});
return { id: job.id, name: job.name };
}
@Post('/api/v1/db/meta/syncs/:syncId/trigger')
@HttpCode(200)
async triggerSync(@Request() req) {
const jobs = await this.jobsService.jobList(JobTypes.AtImport);
const fnd = jobs.find((j) => j.data.syncId === req.params.syncId);
if (fnd) {
NcError.badRequest('Sync already in progress');
}
const syncSource = await SyncSource.get(req.params.syncId);
const user = await syncSource.getUser();
// Treat default baseUrl as siteUrl from req object
let baseURL = (req as any).ncSiteUrl;
// if environment value avail use it
// or if it's docker construct using `PORT`
if (process.env.NC_DOCKER) {
baseURL = `http://localhost:${process.env.PORT || 8080}`;
}
const job = await this.jobsService.activeQueue.add(JobTypes.AtImport, {
syncId: req.params.syncId,
...(syncSource?.details || {}),
projectId: syncSource.project_id,
baseId: syncSource.base_id,
authToken: '',
baseURL,
user: user,
});
return { id: job.id, name: job.name };
}
@Post('/api/v1/db/meta/syncs/:syncId/abort')
@HttpCode(200)
async abortImport(@Request() req) {
return {};
}
}

2516
packages/nocodb/src/modules/jobs/at-import/at-import.processor.ts

File diff suppressed because it is too large Load Diff

0
packages/nocodb/src/controllers/imports/helpers/EntityMap.ts → packages/nocodb/src/modules/jobs/at-import/helpers/EntityMap.ts

0
packages/nocodb/src/controllers/imports/helpers/fetchAT.ts → packages/nocodb/src/modules/jobs/at-import/helpers/fetchAT.ts

5
packages/nocodb/src/controllers/imports/helpers/readAndProcessData.ts → packages/nocodb/src/modules/jobs/at-import/helpers/readAndProcessData.ts

@ -1,7 +1,8 @@
/* eslint-disable no-async-promise-executor */
import { RelationTypes, UITypes } from 'nocodb-sdk';
import EntityMap from './EntityMap';
import type { BulkDataAliasService } from '../../../services/bulk-data-alias.service';
import type { TablesService } from '../../../services/tables.service';
import type { BulkDataAliasService } from '../../../../services/bulk-data-alias.service';
import type { TablesService } from '../../../../services/tables.service';
// @ts-ignore
import type { AirtableBase } from 'airtable/lib/airtable_base';
import type { TableType } from 'nocodb-sdk';

0
packages/nocodb/src/controllers/imports/helpers/syncMap.ts → packages/nocodb/src/modules/jobs/at-import/helpers/syncMap.ts

135
packages/nocodb/src/modules/jobs/export-import/duplicate.controller.ts

@ -0,0 +1,135 @@
import {
Body,
Controller,
HttpCode,
Param,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { GlobalGuard } from '../../../guards/global/global.guard';
import {
Acl,
ExtractProjectIdMiddleware,
} from '../../../middlewares/extract-project-id/extract-project-id.middleware';
import { ProjectsService } from '../../../services/projects.service';
import { Base, Model, Project } from '../../../models';
import { generateUniqueName } from '../../../helpers/exportImportHelpers';
import { JobsService } from '../jobs.service';
import { JobTypes } from '../../../interface/Jobs';
@Controller()
@UseGuards(ExtractProjectIdMiddleware, GlobalGuard)
export class DuplicateController {
constructor(
private readonly jobsService: JobsService,
private readonly projectsService: ProjectsService,
) {}
@Post('/api/v1/db/meta/duplicate/:projectId/:baseId?')
@HttpCode(200)
@Acl('duplicateBase')
async duplicateBase(
@Request() req,
@Param('projectId') projectId: string,
@Param('baseId') baseId?: string,
@Body()
options?: {
excludeData?: boolean;
excludeViews?: boolean;
excludeHooks?: boolean;
},
) {
const project = await Project.get(projectId);
if (!project) {
throw new Error(`Project not found for id '${projectId}'`);
}
const base = baseId
? await Base.get(baseId)
: (await project.getBases())[0];
if (!base) {
throw new Error(`Base not found!`);
}
const projects = await Project.list({});
const uniqueTitle = generateUniqueName(
`${project.title} copy`,
projects.map((p) => p.title),
);
const dupProject = await this.projectsService.projectCreate({
project: { title: uniqueTitle, status: 'job' },
user: { id: req.user.id },
});
const job = await this.jobsService.activeQueue.add(JobTypes.DuplicateBase, {
projectId: project.id,
baseId: base.id,
dupProjectId: dupProject.id,
options,
req: {
user: req.user,
clientIp: req.clientIp,
},
});
return { id: job.id, name: job.name };
}
@Post('/api/v1/db/meta/duplicate/:projectId/table/:modelId')
@HttpCode(200)
@Acl('duplicateModel')
async duplicateModel(
@Request() req,
@Param('projectId') projectId: string,
@Param('modelId') modelId?: string,
@Body()
options?: {
excludeData?: boolean;
excludeViews?: boolean;
excludeHooks?: boolean;
},
) {
const project = await Project.get(projectId);
if (!project) {
throw new Error(`Project not found for id '${projectId}'`);
}
const model = await Model.get(modelId);
if (!model) {
throw new Error(`Model not found!`);
}
const base = await Base.get(model.base_id);
const models = await base.getModels();
const uniqueTitle = generateUniqueName(
`${model.title} copy`,
models.map((p) => p.title),
);
const job = await this.jobsService.activeQueue.add(
JobTypes.DuplicateModel,
{
projectId: project.id,
baseId: base.id,
modelId: model.id,
title: uniqueTitle,
options,
req: {
user: req.user,
clientIp: req.clientIp,
},
},
);
return { id: job.id, name: job.name };
}
}

575
packages/nocodb/src/modules/jobs/export-import/duplicate.processor.ts

@ -0,0 +1,575 @@
import { Readable } from 'stream';
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import papaparse from 'papaparse';
import { UITypes } from 'nocodb-sdk';
import { Base, Column, Model, Project } from '../../../models';
import { ProjectsService } from '../../../services/projects.service';
import { findWithIdentifier } from '../../../helpers/exportImportHelpers';
import { BulkDataAliasService } from '../../../services/bulk-data-alias.service';
import { JOBS_QUEUE, JobTypes } from '../../../interface/Jobs';
import { ExportService } from './export.service';
import { ImportService } from './import.service';
import type { LinkToAnotherRecordColumn } from '../../../models';
const DEBUG = false;
const debugLog = function (...args: any[]) {
if (DEBUG) {
console.log(...args);
}
};
const initTime = function () {
return {
hrTime: process.hrtime(),
};
};
const elapsedTime = function (
time: { hrTime: [number, number] },
label?: string,
) {
const elapsedS = process.hrtime(time.hrTime)[0].toFixed(3);
const elapsedMs = process.hrtime(time.hrTime)[1] / 1000000;
if (label) debugLog(`${label}: ${elapsedS}s ${elapsedMs}ms`);
time.hrTime = process.hrtime();
};
@Processor(JOBS_QUEUE)
export class DuplicateProcessor {
constructor(
private readonly exportService: ExportService,
private readonly importService: ImportService,
private readonly projectsService: ProjectsService,
private readonly bulkDataService: BulkDataAliasService,
) {}
@Process(JobTypes.DuplicateBase)
async duplicateBase(job: Job) {
const hrTime = initTime();
const { projectId, baseId, dupProjectId, req, options } = job.data;
const excludeData = options?.excludeData || false;
const excludeHooks = options?.excludeHooks || false;
const excludeViews = options?.excludeViews || false;
const project = await Project.get(projectId);
const dupProject = await Project.get(dupProjectId);
const base = await Base.get(baseId);
try {
if (!project || !dupProject || !base) {
throw new Error(`Project or base not found!`);
}
const user = (req as any).user;
const models = (await base.getModels()).filter(
// TODO revert this when issue with cache is fixed
(m) => m.base_id === base.id && !m.mm && m.type === 'table',
);
const exportedModels = await this.exportService.serializeModels({
modelIds: models.map((m) => m.id),
excludeViews,
excludeHooks,
});
elapsedTime(hrTime, 'serializeModels');
if (!exportedModels) {
throw new Error(`Export failed for base '${base.id}'`);
}
await dupProject.getBases();
const dupBase = dupProject.bases[0];
const idMap = await this.importService.importModels({
user,
projectId: dupProject.id,
baseId: dupBase.id,
data: exportedModels,
req: req,
});
elapsedTime(hrTime, 'importModels');
if (!idMap) {
throw new Error(`Import failed for base '${base.id}'`);
}
if (!excludeData) {
await this.importModelsData({
idMap,
sourceProject: project,
sourceModels: models,
destProject: dupProject,
destBase: dupBase,
hrTime,
});
}
await this.projectsService.projectUpdate({
projectId: dupProject.id,
project: {
status: null,
},
});
} catch (e) {
if (dupProject?.id) {
await this.projectsService.projectSoftDelete({
projectId: dupProject.id,
});
}
throw e;
}
}
@Process(JobTypes.DuplicateModel)
async duplicateModel(job: Job) {
const hrTime = initTime();
const { projectId, baseId, modelId, title, req, options } = job.data;
const excludeData = options?.excludeData || false;
const excludeHooks = options?.excludeHooks || false;
const excludeViews = options?.excludeViews || false;
const project = await Project.get(projectId);
const base = await Base.get(baseId);
const user = (req as any).user;
const models = (await base.getModels()).filter(
(m) => !m.mm && m.type === 'table',
);
const sourceModel = models.find((m) => m.id === modelId);
await sourceModel.getColumns();
const relatedModelIds = sourceModel.columns
.filter((col) => col.uidt === UITypes.LinkToAnotherRecord)
.map((col) => col.colOptions.fk_related_model_id)
.filter((id) => id);
const relatedModels = models.filter((m) => relatedModelIds.includes(m.id));
const exportedModel = (
await this.exportService.serializeModels({
modelIds: [modelId],
excludeViews,
excludeHooks,
})
)[0];
elapsedTime(hrTime, 'serializeModel');
if (!exportedModel) {
throw new Error(`Export failed for base '${base.id}'`);
}
exportedModel.model.title = title;
exportedModel.model.table_name = title.toLowerCase().replace(/ /g, '_');
const idMap = await this.importService.importModels({
projectId,
baseId,
data: [exportedModel],
user,
req,
externalModels: relatedModels,
});
elapsedTime(hrTime, 'reimportModelSchema');
if (!idMap) {
throw new Error(`Import failed for model '${modelId}'`);
}
if (!excludeData) {
const fields: Record<string, string[]> = {};
for (const md of relatedModels) {
const bts = md.columns
.filter(
(c) =>
c.uidt === UITypes.LinkToAnotherRecord &&
c.colOptions.type === 'bt' &&
c.colOptions.fk_related_model_id === modelId,
)
.map((c) => c.id);
if (bts.length > 0) {
fields[md.id] = [md.primaryKey.id];
fields[md.id].push(...bts);
}
}
await this.importModelsData({
idMap,
sourceProject: project,
sourceModels: [sourceModel],
destProject: project,
destBase: base,
hrTime,
modelFieldIds: fields,
externalModels: relatedModels,
});
elapsedTime(hrTime, 'reimportModelData');
}
return await Model.get(findWithIdentifier(idMap, sourceModel.id));
}
async importModelsData(param: {
idMap: Map<string, string>;
sourceProject: Project;
sourceModels: Model[];
destProject: Project;
destBase: Base;
hrTime: { hrTime: [number, number] };
modelFieldIds?: Record<string, string[]>;
externalModels?: Model[];
}) {
const {
idMap,
sourceProject,
sourceModels,
destProject,
destBase,
hrTime,
modelFieldIds,
externalModels,
} = param;
const handledLinks = [];
const lChunks: Record<string, any[]> = {}; // fk_mm_model_id: { rowId, childId }[]
const insertChunks = async () => {
for (const [k, v] of Object.entries(lChunks)) {
try {
if (v.length === 0) continue;
await this.bulkDataService.bulkDataInsert({
projectName: destProject.id,
tableName: k,
body: v,
cookie: null,
chunkSize: 1000,
foreign_key_checks: false,
raw: true,
});
lChunks[k] = [];
} catch (e) {
console.log(e);
}
}
};
for (const sourceModel of sourceModels) {
const dataStream = new Readable({
read() {},
});
const linkStream = new Readable({
read() {},
});
this.exportService.streamModelData({
dataStream,
linkStream,
projectId: sourceProject.id,
modelId: sourceModel.id,
handledMmList: handledLinks,
});
const headers: string[] = [];
let chunk = [];
const model = await Model.get(findWithIdentifier(idMap, sourceModel.id));
await new Promise((resolve) => {
papaparse.parse(dataStream, {
newline: '\r\n',
step: async (results, parser) => {
if (!headers.length) {
parser.pause();
for (const header of results.data) {
const id = idMap.get(header);
if (id) {
const col = await Column.get({
base_id: destBase.id,
colId: id,
});
if (col.colOptions?.type === 'bt') {
const childCol = await Column.get({
base_id: destBase.id,
colId: col.colOptions.fk_child_column_id,
});
headers.push(childCol.column_name);
} else {
headers.push(col.column_name);
}
} else {
debugLog('header not found', header);
}
}
parser.resume();
} else {
if (results.errors.length === 0) {
const row = {};
for (let i = 0; i < headers.length; i++) {
if (results.data[i] !== '') {
row[headers[i]] = results.data[i];
}
}
chunk.push(row);
if (chunk.length > 1000) {
parser.pause();
try {
await this.bulkDataService.bulkDataInsert({
projectName: destProject.id,
tableName: model.id,
body: chunk,
cookie: null,
chunkSize: chunk.length + 1,
foreign_key_checks: false,
raw: true,
});
} catch (e) {
console.log(e);
}
chunk = [];
parser.resume();
}
}
}
},
complete: async () => {
if (chunk.length > 0) {
try {
await this.bulkDataService.bulkDataInsert({
projectName: destProject.id,
tableName: model.id,
body: chunk,
cookie: null,
chunkSize: chunk.length + 1,
foreign_key_checks: false,
raw: true,
});
} catch (e) {
console.log(e);
}
chunk = [];
}
resolve(null);
},
});
});
let headersFound = false;
let childIndex = -1;
let parentIndex = -1;
let columnIndex = -1;
const mmColumns: Record<string, Column> = {};
const mmParentChild: any = {};
await new Promise((resolve) => {
papaparse.parse(linkStream, {
newline: '\r\n',
step: async (results, parser) => {
if (!headersFound) {
for (const [i, header] of Object.entries(results.data)) {
if (header === 'child') {
childIndex = parseInt(i);
} else if (header === 'parent') {
parentIndex = parseInt(i);
} else if (header === 'column') {
columnIndex = parseInt(i);
}
}
headersFound = true;
} else {
if (results.errors.length === 0) {
const child = results.data[childIndex];
const parent = results.data[parentIndex];
const columnId = results.data[columnIndex];
if (child && parent && columnId) {
if (mmColumns[columnId]) {
// push to chunk
const mmModelId =
mmColumns[columnId].colOptions.fk_mm_model_id;
const mm = mmParentChild[mmModelId];
lChunks[mmModelId].push({
[mm.parent]: parent,
[mm.child]: child,
});
} else {
// get column for the first time
parser.pause();
await insertChunks();
const col = await Column.get({
base_id: destBase.id,
colId: findWithIdentifier(idMap, columnId),
});
const colOptions =
await col.getColOptions<LinkToAnotherRecordColumn>();
const vChildCol = await colOptions.getMMChildColumn();
const vParentCol = await colOptions.getMMParentColumn();
mmParentChild[col.colOptions.fk_mm_model_id] = {
parent: vParentCol.column_name,
child: vChildCol.column_name,
};
mmColumns[columnId] = col;
handledLinks.push(col.colOptions.fk_mm_model_id);
const mmModelId = col.colOptions.fk_mm_model_id;
// create chunk
lChunks[mmModelId] = [];
// push to chunk
const mm = mmParentChild[mmModelId];
lChunks[mmModelId].push({
[mm.parent]: parent,
[mm.child]: child,
});
parser.resume();
}
}
}
}
},
complete: async () => {
await insertChunks();
resolve(null);
},
});
});
elapsedTime(hrTime, model.title);
}
// update external models (has bt to this model)
if (externalModels) {
for (const sourceModel of externalModels) {
const fields = modelFieldIds?.[sourceModel.id];
if (!fields) continue;
const dataStream = new Readable({
read() {},
});
const linkStream = new Readable({
read() {},
});
this.exportService.streamModelData({
dataStream,
linkStream,
projectId: sourceProject.id,
modelId: sourceModel.id,
handledMmList: handledLinks,
_fieldIds: fields,
});
const headers: string[] = [];
let chunk = [];
const model = await Model.get(sourceModel.id);
await new Promise((resolve) => {
papaparse.parse(dataStream, {
newline: '\r\n',
step: async (results, parser) => {
if (!headers.length) {
parser.pause();
for (const header of results.data) {
const id = idMap.get(header);
if (id) {
const col = await Column.get({
base_id: destBase.id,
colId: id,
});
if (col.colOptions?.type === 'bt') {
const childCol = await Column.get({
base_id: destBase.id,
colId: col.colOptions.fk_child_column_id,
});
headers.push(childCol.column_name);
} else {
headers.push(col.column_name);
}
} else {
debugLog('header not found', header);
}
}
parser.resume();
} else {
if (results.errors.length === 0) {
const row = {};
for (let i = 0; i < headers.length; i++) {
if (results.data[i] !== '') {
row[headers[i]] = results.data[i];
}
}
chunk.push(row);
if (chunk.length > 1000) {
parser.pause();
try {
await this.bulkDataService.bulkDataUpdate({
projectName: destProject.id,
tableName: model.id,
body: chunk,
cookie: null,
raw: true,
});
} catch (e) {
console.log(e);
}
chunk = [];
parser.resume();
}
}
}
},
complete: async () => {
if (chunk.length > 0) {
try {
await this.bulkDataService.bulkDataUpdate({
projectName: destProject.id,
tableName: model.id,
body: chunk,
cookie: null,
raw: true,
});
} catch (e) {
console.log(e);
}
chunk = [];
}
resolve(null);
},
});
});
elapsedTime(hrTime, `external bt ${model.title}`);
}
}
}
}

704
packages/nocodb/src/modules/jobs/export-import/export.service.ts

@ -0,0 +1,704 @@
import { Readable } from 'stream';
import { UITypes, ViewTypes } from 'nocodb-sdk';
import { unparse } from 'papaparse';
import { Injectable } from '@nestjs/common';
import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2';
import { getViewAndModelByAliasOrId } from '../../../modules/datas/helpers';
import {
clearPrefix,
generateBaseIdMap,
} from '../../../helpers/exportImportHelpers';
import NcPluginMgrv2 from '../../../helpers/NcPluginMgrv2';
import { NcError } from '../../../helpers/catchError';
import { Base, Hook, Model, Project } from '../../../models';
import { DatasService } from '../../../services/datas.service';
import type { BaseModelSqlv2 } from '../../../db/BaseModelSqlv2';
import type { LinkToAnotherRecordColumn, View } from '../../../models';
@Injectable()
export class ExportService {
constructor(private datasService: DatasService) {}
async serializeModels(param: {
modelIds: string[];
excludeViews?: boolean;
excludeHooks?: boolean;
}) {
const { modelIds } = param;
const excludeViews = param?.excludeViews || false;
const excludeHooks = param?.excludeHooks || false;
const serializedModels = [];
// db id to structured id
const idMap = new Map<string, string>();
const projects: Project[] = [];
const bases: Base[] = [];
const modelsMap = new Map<string, Model[]>();
for (const modelId of modelIds) {
const model = await Model.get(modelId);
if (!model)
return NcError.badRequest(`Model not found for id '${modelId}'`);
const fndProject = projects.find((p) => p.id === model.project_id);
const project = fndProject || (await Project.get(model.project_id));
const fndBase = bases.find((b) => b.id === model.base_id);
const base = fndBase || (await Base.get(model.base_id));
if (!fndProject) projects.push(project);
if (!fndBase) bases.push(base);
if (!modelsMap.has(base.id)) {
modelsMap.set(base.id, await generateBaseIdMap(base, idMap));
}
await model.getColumns();
await model.getViews();
// if views are excluded, filter all views except default
if (excludeViews) {
model.views = model.views.filter((v) => v.is_default);
}
for (const column of model.columns) {
await column.getColOptions();
if (column.colOptions) {
for (const [k, v] of Object.entries(column.colOptions)) {
switch (k) {
case 'fk_mm_child_column_id':
case 'fk_mm_parent_column_id':
case 'fk_mm_model_id':
case 'fk_parent_column_id':
case 'fk_child_column_id':
case 'fk_related_model_id':
case 'fk_relation_column_id':
case 'fk_lookup_column_id':
case 'fk_rollup_column_id':
column.colOptions[k] = idMap.get(v as string);
break;
case 'options':
for (const o of column.colOptions['options']) {
delete o.id;
delete o.fk_column_id;
}
break;
case 'formula':
column.colOptions[k] = column.colOptions[k].replace(
/(?<=\{\{).*?(?=\}\})/gm,
(match) => idMap.get(match),
);
break;
case 'id':
case 'created_at':
case 'updated_at':
case 'fk_column_id':
delete column.colOptions[k];
break;
}
}
}
}
for (const view of model.views) {
idMap.set(view.id, `${idMap.get(model.id)}::${view.id}`);
await view.getColumns();
await view.getFilters();
await view.getSorts();
if (view.filter) {
const export_filters = [];
for (const fl of view.filter.children) {
const tempFl = {
id: `${idMap.get(view.id)}::${fl.id}`,
fk_column_id: idMap.get(fl.fk_column_id),
fk_parent_id: fl.fk_parent_id,
is_group: fl.is_group,
logical_op: fl.logical_op,
comparison_op: fl.comparison_op,
comparison_sub_op: fl.comparison_sub_op,
value: fl.value,
};
if (tempFl.is_group) {
delete tempFl.comparison_op;
delete tempFl.comparison_sub_op;
delete tempFl.value;
}
export_filters.push(tempFl);
}
view.filter.children = export_filters;
}
if (view.sorts) {
const export_sorts = [];
for (const sr of view.sorts) {
const tempSr = {
fk_column_id: idMap.get(sr.fk_column_id),
direction: sr.direction,
};
export_sorts.push(tempSr);
}
view.sorts = export_sorts;
}
if (view.view) {
for (const [k, v] of Object.entries(view.view)) {
switch (k) {
case 'fk_column_id':
case 'fk_cover_image_col_id':
case 'fk_grp_col_id':
view.view[k] = idMap.get(v as string);
break;
case 'meta':
if (view.type === ViewTypes.KANBAN) {
const meta = JSON.parse(view.view.meta as string) as Record<
string,
any
>;
for (const [k, v] of Object.entries(meta)) {
const colId = idMap.get(k as string);
for (const op of v) {
op.fk_column_id = idMap.get(op.fk_column_id);
delete op.id;
}
meta[colId] = v;
delete meta[k];
}
view.view.meta = meta;
}
break;
case 'created_at':
case 'updated_at':
case 'fk_view_id':
case 'project_id':
case 'base_id':
case 'uuid':
delete view.view[k];
break;
}
}
}
}
const serializedHooks = [];
if (!excludeHooks) {
const hooks = await Hook.list({ fk_model_id: model.id });
for (const hook of hooks) {
idMap.set(hook.id, `${idMap.get(hook.fk_model_id)}::${hook.id}`);
const hookFilters = await hook.getFilters();
const export_filters = [];
if (hookFilters) {
for (const fl of hookFilters) {
const tempFl = {
id: `${idMap.get(hook.id)}::${fl.id}`,
fk_column_id: idMap.get(fl.fk_column_id),
fk_parent_id: fl.fk_parent_id,
is_group: fl.is_group,
logical_op: fl.logical_op,
comparison_op: fl.comparison_op,
comparison_sub_op: fl.comparison_sub_op,
value: fl.value,
};
if (tempFl.is_group) {
delete tempFl.comparison_op;
delete tempFl.comparison_sub_op;
delete tempFl.value;
}
export_filters.push(tempFl);
}
}
serializedHooks.push({
id: idMap.get(hook.id),
title: hook.title,
active: hook.active,
condition: hook.condition,
event: hook.event,
operation: hook.operation,
notification: hook.notification,
version: hook.version,
filters: export_filters,
});
}
}
serializedModels.push({
model: {
id: idMap.get(model.id),
prefix: project.prefix,
title: model.title,
table_name: clearPrefix(model.table_name, project.prefix),
meta: model.meta,
columns: model.columns.map((column) => ({
id: idMap.get(column.id),
ai: column.ai,
column_name: column.column_name,
cc: column.cc,
cdf: column.cdf,
meta: column.meta,
pk: column.pk,
pv: column.pv,
order: column.order,
rqd: column.rqd,
system: column.system,
uidt: column.uidt,
title: column.title,
un: column.un,
unique: column.unique,
colOptions: column.colOptions,
})),
},
views: model.views.map((view) => ({
id: idMap.get(view.id),
is_default: view.is_default,
type: view.type,
meta: view.meta,
order: view.order,
title: view.title,
show: view.show,
show_system_fields: view.show_system_fields,
filter: view.filter,
sorts: view.sorts,
lock_type: view.lock_type,
columns: view.columns.map((column) => {
const {
id,
fk_view_id,
fk_column_id,
project_id,
base_id,
created_at,
updated_at,
uuid,
...rest
} = column as any;
return {
fk_column_id: idMap.get(fk_column_id),
...rest,
};
}),
view: view.view,
})),
hooks: serializedHooks,
});
}
return serializedModels;
}
async streamModelData(param: {
dataStream: Readable;
linkStream: Readable;
projectId: string;
modelId: string;
viewId?: string;
handledMmList?: string[];
_fieldIds?: string[];
}) {
const { dataStream, linkStream, handledMmList } = param;
const { model, view } = await getViewAndModelByAliasOrId({
projectName: param.projectId,
tableName: param.modelId,
viewName: param.viewId,
});
const base = await Base.get(model.base_id);
await model.getColumns();
const btMap = new Map<string, string>();
for (const column of model.columns.filter(
(col) =>
col.uidt === UITypes.LinkToAnotherRecord &&
col.colOptions?.type === 'bt',
)) {
await column.getColOptions();
const fkCol = model.columns.find(
(c) => c.id === column.colOptions?.fk_child_column_id,
);
if (fkCol) {
// replace bt column with fk column if it is in _fieldIds
if (param._fieldIds && param._fieldIds.includes(column.id)) {
param._fieldIds.push(fkCol.id);
const btIndex = param._fieldIds.indexOf(column.id);
param._fieldIds.splice(btIndex, 1);
}
btMap.set(
fkCol.id,
`${column.project_id}::${column.base_id}::${column.fk_model_id}::${column.id}`,
);
}
}
const fields = param._fieldIds
? model.columns
.filter((c) => param._fieldIds?.includes(c.id))
.map((c) => c.title)
.join(',')
: model.columns
.filter((c) => c.uidt !== UITypes.LinkToAnotherRecord)
.map((c) => c.title)
.join(',');
const mmColumns = model.columns.filter(
(col) =>
col.uidt === UITypes.LinkToAnotherRecord &&
col.colOptions?.type === 'mm',
);
const hasLink = mmColumns.length > 0;
dataStream.setEncoding('utf8');
const formatData = (data: any) => {
for (const row of data) {
for (const [k, v] of Object.entries(row)) {
const col = model.columns.find((c) => c.title === k);
if (col) {
const colId = `${col.project_id}::${col.base_id}::${col.fk_model_id}::${col.id}`;
switch (col.uidt) {
case UITypes.ForeignKey:
{
if (btMap.has(col.id)) {
row[btMap.get(col.id)] = v;
delete row[k];
}
}
break;
case UITypes.Attachment:
try {
row[colId] = JSON.stringify(v);
} catch (e) {
row[colId] = v;
}
break;
case UITypes.Formula:
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Barcode:
case UITypes.QrCode:
// skip these types
break;
default:
row[colId] = v;
break;
}
delete row[k];
}
}
}
return { data };
};
const baseModel = await Model.getBaseModelSQL({
id: model.id,
viewId: view?.id,
dbDriver: await NcConnectionMgrv2.get(base),
});
const limit = 200;
const offset = 0;
try {
await this.recursiveRead(
formatData,
baseModel,
dataStream,
model,
view,
offset,
limit,
fields,
true,
);
} catch (e) {
console.error(e);
throw e;
}
if (hasLink) {
linkStream.setEncoding('utf8');
for (const mm of mmColumns) {
if (handledMmList.includes(mm.colOptions?.fk_mm_model_id)) continue;
const mmModel = await Model.get(mm.colOptions?.fk_mm_model_id);
await mmModel.getColumns();
const childColumn = mmModel.columns.find(
(col) => col.id === mm.colOptions?.fk_mm_child_column_id,
);
const parentColumn = mmModel.columns.find(
(col) => col.id === mm.colOptions?.fk_mm_parent_column_id,
);
const childColumnTitle = childColumn.title;
const parentColumnTitle = parentColumn.title;
const mmFields = mmModel.columns
.filter((c) => c.uidt === UITypes.ForeignKey)
.map((c) => c.title)
.join(',');
const mmFormatData = (data: any) => {
data.map((d) => {
d.column = mm.id;
d.child = d[childColumnTitle];
d.parent = d[parentColumnTitle];
delete d[childColumnTitle];
delete d[parentColumnTitle];
return d;
});
return { data };
};
const mmLimit = 200;
const mmOffset = 0;
const mmBase =
mmModel.base_id === base.id ? base : await Base.get(mmModel.base_id);
const mmBaseModel = await Model.getBaseModelSQL({
id: mmModel.id,
dbDriver: await NcConnectionMgrv2.get(mmBase),
});
try {
await this.recursiveLinkRead(
mmFormatData,
mmBaseModel,
linkStream,
mmModel,
undefined,
mmOffset,
mmLimit,
mmFields,
true,
);
} catch (e) {
console.error(e);
throw e;
}
handledMmList.push(mm.colOptions?.fk_mm_model_id);
}
linkStream.push(null);
} else {
linkStream.push(null);
}
}
async recursiveRead(
formatter: (data: any) => { data: any },
baseModel: BaseModelSqlv2,
stream: Readable,
model: Model,
view: View,
offset: number,
limit: number,
fields: string,
header = false,
): Promise<void> {
return new Promise((resolve, reject) => {
this.datasService
.getDataList({
model,
view,
query: { limit, offset, fields },
baseModel,
})
.then((result) => {
try {
if (!header) {
stream.push('\r\n');
}
const { data } = formatter(result.list);
stream.push(unparse(data, { header }));
if (result.pageInfo.isLastPage) {
stream.push(null);
resolve();
} else {
this.recursiveRead(
formatter,
baseModel,
stream,
model,
view,
offset + limit,
limit,
fields,
).then(resolve);
}
} catch (e) {
reject(e);
}
});
});
}
async recursiveLinkRead(
formatter: (data: any) => { data: any },
baseModel: BaseModelSqlv2,
linkStream: Readable,
model: Model,
view: View,
offset: number,
limit: number,
fields: string,
header = false,
): Promise<void> {
return new Promise((resolve, reject) => {
this.datasService
.getDataList({
model,
view,
query: { limit, offset, fields },
baseModel,
})
.then((result) => {
try {
if (!header) {
linkStream.push('\r\n');
}
const { data } = formatter(result.list);
if (data) linkStream.push(unparse(data, { header }));
if (result.pageInfo.isLastPage) {
resolve();
} else {
this.recursiveLinkRead(
formatter,
baseModel,
linkStream,
model,
view,
offset + limit,
limit,
fields,
).then(resolve);
}
} catch (e) {
reject(e);
}
});
});
}
async exportBase(param: { path: string; baseId: string }) {
const base = await Base.get(param.baseId);
if (!base)
throw NcError.badRequest(`Base not found for id '${param.baseId}'`);
const project = await Project.get(base.project_id);
const models = (await base.getModels()).filter(
// TODO revert this when issue with cache is fixed
(m) => m.base_id === base.id && !m.mm && m.type === 'table',
);
const exportedModels = await this.serializeModels({
modelIds: models.map((m) => m.id),
});
const exportData = {
id: `${project.id}::${base.id}`,
models: exportedModels,
};
const storageAdapter = await NcPluginMgrv2.storageAdapter();
const destPath = `export/${project.id}/${base.id}/${param.path}`;
try {
const readableStream = new Readable({
read() {},
});
readableStream.setEncoding('utf8');
readableStream.push(JSON.stringify(exportData));
readableStream.push(null);
await (storageAdapter as any).fileCreateByStream(
`${destPath}/schema.json`,
readableStream,
);
const handledMmList: string[] = [];
const combinedLinkStream = new Readable({
read() {},
});
const uploadLinkPromise = (storageAdapter as any).fileCreateByStream(
`${destPath}/data/links.csv`,
combinedLinkStream,
);
for (const model of models) {
const dataStream = new Readable({
read() {},
});
const linkStream = new Readable({
read() {},
});
const linkPromise = new Promise((resolve) => {
linkStream.on('data', (chunk) => {
combinedLinkStream.push(chunk);
});
linkStream.on('end', () => {
combinedLinkStream.push('\r\n');
resolve(null);
});
linkStream.on('error', (e) => {
console.error(e);
resolve(null);
});
});
const uploadPromise = (storageAdapter as any).fileCreateByStream(
`${destPath}/data/${model.id}.csv`,
dataStream,
);
this.streamModelData({
dataStream,
linkStream,
projectId: project.id,
modelId: model.id,
handledMmList,
});
await Promise.all([uploadPromise, linkPromise]);
}
combinedLinkStream.push(null);
await uploadLinkPromise;
} catch (e) {
throw NcError.badRequest(e);
}
return {
path: destPath,
};
}
}

1402
packages/nocodb/src/modules/jobs/export-import/import.service.ts

File diff suppressed because it is too large Load Diff

136
packages/nocodb/src/modules/jobs/fallback-queue.service.ts

@ -0,0 +1,136 @@
import { Injectable } from '@nestjs/common';
import PQueue from 'p-queue';
import Emittery from 'emittery';
import { JobStatus, JobTypes } from '../../interface/Jobs';
import { DuplicateProcessor } from './export-import/duplicate.processor';
import { JobsEventService } from './jobs-event.service';
import { AtImportProcessor } from './at-import/at-import.processor';
interface Job {
id: string;
name: string;
status: string;
data: any;
}
@Injectable()
export class QueueService {
static queue = new PQueue({ concurrency: 1 });
static queueIdCounter = 1;
static processed = 0;
static queueMemory: Job[] = [];
static emitter = new Emittery();
constructor(
private readonly jobsEventService: JobsEventService,
private readonly duplicateProcessor: DuplicateProcessor,
private readonly atImportProcessor: AtImportProcessor,
) {
this.emitter.on(JobStatus.ACTIVE, (data: { job: Job }) => {
const job = this.queueMemory.find(
(job) => job.id === data.job.id && job.name === data.job.name,
);
job.status = JobStatus.ACTIVE;
this.jobsEventService.onActive.apply(this.jobsEventService, [job as any]);
});
this.emitter.on(JobStatus.COMPLETED, (data: { job: Job; result: any }) => {
const job = this.queueMemory.find(
(job) => job.id === data.job.id && job.name === data.job.name,
);
job.status = JobStatus.COMPLETED;
this.jobsEventService.onCompleted.apply(this.jobsEventService, [
job,
data.result,
]);
// clear job from memory
this.removeJob(job);
});
this.emitter.on(JobStatus.FAILED, (data: { job: Job; error: Error }) => {
const job = this.queueMemory.find(
(job) => job.id === data.job.id && job.name === data.job.name,
);
job.status = JobStatus.FAILED;
this.jobsEventService.onFailed.apply(this.jobsEventService, [
job,
data.error,
]);
// clear job from memory
this.removeJob(job);
});
}
jobMap = {
[JobTypes.DuplicateBase]: {
this: this.duplicateProcessor,
fn: this.duplicateProcessor.duplicateBase,
},
[JobTypes.DuplicateModel]: {
this: this.duplicateProcessor,
fn: this.duplicateProcessor.duplicateModel,
},
[JobTypes.AtImport]: {
this: this.atImportProcessor,
fn: this.atImportProcessor.job,
},
};
async jobWrapper(job: Job) {
this.emitter.emit(JobStatus.ACTIVE, { job });
try {
const result = await this.jobMap[job.name].fn.apply(
this.jobMap[job.name].this,
[job],
);
this.emitter.emit(JobStatus.COMPLETED, { job, result });
} catch (error) {
this.emitter.emit(JobStatus.FAILED, { job, error });
}
}
get emitter() {
return QueueService.emitter;
}
get queue() {
return QueueService.queue;
}
get queueMemory() {
return QueueService.queueMemory;
}
get queueIndex() {
return QueueService.queueIdCounter;
}
set queueIndex(index: number) {
QueueService.queueIdCounter = index;
}
add(name: string, data: any) {
const id = `${this.queueIndex++}`;
const job = { id: `${id}`, name, status: JobStatus.WAITING, data };
this.queueMemory.push(job);
this.queue.add(() => this.jobWrapper(job));
return { id, name };
}
getJobs(types: string[] | string) {
types = Array.isArray(types) ? types : [types];
return this.queueMemory.filter((q) => types.includes(q.status));
}
getJob(id: string) {
return this.queueMemory.find((q) => q.id === id);
}
// remove job from memory
private removeJob(job: Job) {
const fIndex = this.queueMemory.findIndex(
(q) => q.id === job.id && q.name === job.name,
);
if (fIndex) {
this.queueMemory.splice(fIndex, 1);
}
}
}

69
packages/nocodb/src/modules/jobs/jobs-event.service.ts

@ -0,0 +1,69 @@
import {
OnQueueActive,
OnQueueCompleted,
OnQueueFailed,
Processor,
} from '@nestjs/bull';
import { Job } from 'bull';
import boxen from 'boxen';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { JobEvents, JOBS_QUEUE, JobStatus } from '../../interface/Jobs';
@Processor(JOBS_QUEUE)
export class JobsEventService {
constructor(private eventEmitter: EventEmitter2) {}
@OnQueueActive()
onActive(job: Job) {
this.eventEmitter.emit(JobEvents.STATUS, {
name: job.name,
id: job.id.toString(),
status: JobStatus.ACTIVE,
});
}
@OnQueueFailed()
onFailed(job: Job, error: Error) {
console.error(
boxen(
`---- !! JOB FAILED !! ----\nname: ${job.name}\nid:${job.id}\nerror:${error.name} (${error.message})\n\nstack: ${error.stack}`,
{
padding: 1,
borderStyle: 'double',
borderColor: 'yellow',
},
),
);
this.eventEmitter.emit(JobEvents.STATUS, {
name: job.name,
id: job.id.toString(),
status: JobStatus.FAILED,
data: {
error: {
message: error?.message,
},
},
});
}
@OnQueueCompleted()
onCompleted(job: Job, data: any) {
this.eventEmitter.emit(JobEvents.STATUS, {
name: job.name,
id: job.id.toString(),
status: JobStatus.COMPLETED,
data: {
result: data,
},
});
}
sendLog(job: Job, data: { message: string }) {
this.eventEmitter.emit(JobEvents.LOG, {
name: job.name,
id: job.id.toString(),
data,
});
}
}

121
packages/nocodb/src/modules/jobs/jobs.gateway.ts

@ -0,0 +1,121 @@
import {
ConnectedSocket,
MessageBody,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import { AuthGuard } from '@nestjs/passport';
import { OnEvent } from '@nestjs/event-emitter';
import { JobEvents } from '../../interface/Jobs';
import { JobsService } from './jobs.service';
import type { JobStatus } from '../../interface/Jobs';
import type { OnModuleInit } from '@nestjs/common';
@WebSocketGateway({
cors: {
origin: '*',
allowedHeaders: ['xc-auth'],
credentials: true,
},
namespace: 'jobs',
})
export class JobsGateway implements OnModuleInit {
constructor(private readonly jobsService: JobsService) {}
@WebSocketServer()
server: Server;
async onModuleInit() {
this.server.use(async (socket, next) => {
try {
const context = new ExecutionContextHost([socket.handshake as any]);
const guard = new (AuthGuard('jwt'))(context);
await guard.canActivate(context);
} catch {}
next();
});
}
@SubscribeMessage('subscribe')
async subscribe(
@MessageBody()
body: { _id: number; data: { id: string; name: string } | any },
@ConnectedSocket() client: Socket,
): Promise<void> {
const { _id, data } = body;
if (
Object.keys(data).every((k) => ['name', 'id'].includes(k)) &&
data?.name &&
data?.id
) {
const rooms = (await this.jobsService.jobList(data.name)).map(
(j) => `${j.name}-${j.id}`,
);
const room = rooms.find((r) => r === `${data.name}-${data.id}`);
if (room) {
client.join(`${data.name}-${data.id}`);
client.emit('subscribed', {
_id,
name: data.name,
id: data.id,
});
}
} else {
const job = await this.jobsService.getJobWithData(data);
if (job) {
client.join(`${job.name}-${job.id}`);
client.emit('subscribed', {
_id,
name: job.name,
id: job.id,
});
}
}
}
@SubscribeMessage('status')
async status(
@MessageBody() body: { _id: number; data: { id: string; name: string } },
@ConnectedSocket() client: Socket,
): Promise<void> {
const { _id, data } = body;
client.emit('status', {
_id,
id: data.id,
name: data.name,
status: await this.jobsService.jobStatus(data.id),
});
}
@OnEvent(JobEvents.STATUS)
async sendJobStatus(data: {
name: string;
id: string;
status: JobStatus;
data?: any;
}): Promise<void> {
this.server.to(`${data.name}-${data.id}`).emit('status', {
id: data.id,
name: data.name,
status: data.status,
data: data.data,
});
}
@OnEvent(JobEvents.LOG)
async sendJobLog(data: {
name: string;
id: string;
data: { message: string };
}): Promise<void> {
this.server.to(`${data.name}-${data.id}`).emit('log', {
id: data.id,
name: data.name,
data: data.data,
});
}
}

39
packages/nocodb/src/modules/jobs/jobs.module.ts

@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { GlobalModule } from '../global/global.module';
import { DatasModule } from '../datas/datas.module';
import { MetasModule } from '../metas/metas.module';
import { JOBS_QUEUE } from '../../interface/Jobs';
import { JobsService } from './jobs.service';
import { ExportService } from './export-import/export.service';
import { ImportService } from './export-import/import.service';
import { DuplicateController } from './export-import/duplicate.controller';
import { DuplicateProcessor } from './export-import/duplicate.processor';
import { JobsGateway } from './jobs.gateway';
import { QueueService } from './fallback-queue.service';
import { JobsEventService } from './jobs-event.service';
import { AtImportController } from './at-import/at-import.controller';
import { AtImportProcessor } from './at-import/at-import.processor';
@Module({
imports: [
GlobalModule,
DatasModule,
MetasModule,
BullModule.registerQueue({
name: JOBS_QUEUE,
}),
],
controllers: [DuplicateController, AtImportController],
providers: [
QueueService,
JobsGateway,
JobsService,
JobsEventService,
DuplicateProcessor,
ExportService,
ImportService,
AtImportProcessor,
],
})
export class JobsModule {}

59
packages/nocodb/src/modules/jobs/jobs.service.ts

@ -0,0 +1,59 @@
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { JOBS_QUEUE, JobStatus } from '../../interface/Jobs';
import { QueueService } from './fallback-queue.service';
@Injectable()
export class JobsService {
public activeQueue;
constructor(
@InjectQueue(JOBS_QUEUE) private readonly jobsQueue: Queue,
private readonly fallbackQueueService: QueueService,
) {
this.activeQueue = this.fallbackQueueService;
/* process.env.NC_REDIS_URL
? this.jobsQueue
: this.fallbackQueueService;
*/
}
async jobStatus(jobId: string) {
return await (await this.activeQueue.getJob(jobId)).getState();
}
async jobList(jobType: string) {
return (
await this.activeQueue.getJobs([
JobStatus.ACTIVE,
JobStatus.WAITING,
JobStatus.DELAYED,
JobStatus.PAUSED,
])
).filter((j) => j.name === jobType);
}
async getJobWithData(data: any) {
const jobs = await this.activeQueue.getJobs([
// 'completed',
JobStatus.WAITING,
JobStatus.ACTIVE,
JobStatus.DELAYED,
// 'failed',
JobStatus.PAUSED,
]);
const job = jobs.find((j) => {
for (const key in data) {
if (j.data[key]) {
if (j.data[key] !== data[key]) return false;
} else {
return false;
}
}
return true;
});
return job;
}
}

20
packages/nocodb/src/modules/metas/metas.module.ts

@ -16,7 +16,6 @@ import { GalleriesController } from '../../controllers/galleries.controller';
import { GridColumnsController } from '../../controllers/grid-columns.controller';
import { GridsController } from '../../controllers/grids.controller';
import { HooksController } from '../../controllers/hooks.controller';
import { ImportController } from '../../controllers/imports/import.controller';
import { KanbansController } from '../../controllers/kanbans.controller';
import { MapsController } from '../../controllers/maps.controller';
import { MetaDiffsController } from '../../controllers/meta-diffs.controller';
@ -98,7 +97,6 @@ import { ProjectUsersService } from '../../services/project-users/project-users.
GridColumnsController,
GridsController,
HooksController,
ImportController,
KanbansController,
MapsController,
MetaDiffsController,
@ -156,5 +154,23 @@ import { ProjectUsersService } from '../../services/project-users/project-users.
SharedBasesService,
BulkDataAliasService,
],
exports: [
TablesService,
ColumnsService,
FiltersService,
SortsService,
ViewsService,
ViewColumnsService,
GridsService,
GridColumnsService,
FormsService,
FormColumnsService,
GalleriesService,
KanbansService,
ProjectsService,
AttachmentsService,
ProjectUsersService,
HooksService,
],
})
export class MetasModule {}

16
packages/nocodb/src/plugins/backblaze/Backblaze.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class Backblaze implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -78,6 +79,21 @@ export default class Backblaze implements IStorageAdapterV2 {
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
patchRegion(region: string): string {
// in v0.0.1, we constructed the endpoint with `region = s3.us-west-001`
// in v0.0.2, `region` would be `us-west-001`

16
packages/nocodb/src/plugins/gcs/Gcs.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
import type { StorageOptions } from '@google-cloud/storage';
export default class Gcs implements IStorageAdapterV2 {
@ -126,4 +127,19 @@ export default class Gcs implements IStorageAdapterV2 {
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
}

16
packages/nocodb/src/plugins/linode/LinodeObjectStorage.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class LinodeObjectStorage implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -78,6 +79,21 @@ export default class LinodeObjectStorage implements IStorageAdapterV2 {
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}

16
packages/nocodb/src/plugins/mino/Minio.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class Minio implements IStorageAdapterV2 {
private minioClient: MinioClient;
@ -131,4 +132,19 @@ export default class Minio implements IStorageAdapterV2 {
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
}

16
packages/nocodb/src/plugins/ovhCloud/OvhCloud.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class OvhCloud implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -78,6 +79,21 @@ export default class OvhCloud implements IStorageAdapterV2 {
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}

16
packages/nocodb/src/plugins/s3/S3.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class S3 implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -77,6 +78,21 @@ export default class S3 implements IStorageAdapterV2 {
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}

16
packages/nocodb/src/plugins/scaleway/ScalewayObjectStorage.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class ScalewayObjectStorage implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -127,4 +128,19 @@ export default class ScalewayObjectStorage implements IStorageAdapterV2 {
);
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
}

16
packages/nocodb/src/plugins/spaces/Spaces.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class Spaces implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -78,6 +79,21 @@ export default class Spaces implements IStorageAdapterV2 {
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}

33
packages/nocodb/src/plugins/storage/Local.ts

@ -5,6 +5,7 @@ import mkdirp from 'mkdirp';
import axios from 'axios';
import NcConfigFactory from '../../utils/NcConfigFactory';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class Local implements IStorageAdapterV2 {
constructor() {}
@ -65,6 +66,38 @@ export default class Local implements IStorageAdapterV2 {
});
}
public async fileCreateByStream(
key: string,
stream: Readable,
): Promise<void> {
return new Promise((resolve, reject) => {
const destPath = path.join(
NcConfigFactory.getToolDir(),
...key.split('/'),
);
try {
mkdirp(path.dirname(destPath)).then(() => {
const writableStream = fs.createWriteStream(destPath);
writableStream.on('finish', () => resolve());
writableStream.on('error', (err) => reject(err));
stream.pipe(writableStream);
});
} catch (e) {
throw e;
}
});
}
public async fileReadByStream(key: string): Promise<Readable> {
const srcPath = path.join(NcConfigFactory.getToolDir(), ...key.split('/'));
return fs.createReadStream(srcPath, { encoding: 'utf8' });
}
public async getDirectoryList(key: string): Promise<string[]> {
const destDir = path.join(NcConfigFactory.getToolDir(), ...key.split('/'));
return fs.promises.readdir(destDir);
}
// todo: implement
fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);

16
packages/nocodb/src/plugins/upcloud/UpoCloud.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class UpoCloud implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -78,6 +79,21 @@ export default class UpoCloud implements IStorageAdapterV2 {
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}

16
packages/nocodb/src/plugins/vultr/Vultr.ts

@ -7,6 +7,7 @@ import {
waitForStreamClose,
} from '../../utils/pluginUtils';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
export default class Vultr implements IStorageAdapterV2 {
private s3Client: AWS.S3;
@ -78,6 +79,21 @@ export default class Vultr implements IStorageAdapterV2 {
});
}
// TODO - implement
fileCreateByStream(_key: string, _stream: Readable): Promise<void> {
return Promise.resolve(undefined);
}
// TODO - implement
fileReadByStream(_key: string): Promise<Readable> {
return Promise.resolve(undefined);
}
// TODO - implement
getDirectoryList(_path: string): Promise<string[]> {
return Promise.resolve(undefined);
}
public async fileDelete(_path: string): Promise<any> {
return Promise.resolve(undefined);
}

264
packages/nocodb/src/schema/swagger.json

@ -2100,6 +2100,175 @@
]
}
},
"/api/v1/db/meta/duplicate/{projectId}/{baseId}": {
"post": {
"summary": "Duplicate Project Base",
"operationId": "project-base-duplicate",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"id": {
"type": "string"
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"excludeData": {
"type": "boolean",
"required": false
},
"excludeViews": {
"type": "boolean",
"required": false
},
"excludeHooks": {
"type": "boolean",
"required": false
}
}
},
"examples": {
"Example 1": {
"value": {
"excludeData": true,
"excludeViews": true,
"excludeHooks": true
}
}
}
}
}
},
"tags": ["Project"],
"description": "Duplicate a project",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
},
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "p_124hhlkbeasewh",
"type": "string"
},
"name": "projectId",
"in": "path",
"required": true,
"description": "Unique Project ID"
},
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "ds_124hhlkbeasewh",
"type": "string"
},
"name": "baseId",
"in": "path",
"required": false,
"description": "Unique Base ID"
}
]
}
},
"/api/v1/db/meta/duplicate/{projectId}": {
"post": {
"summary": "Duplicate Project",
"operationId": "project-duplicate",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"id": {
"type": "string"
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"excludeData": {
"type": "boolean",
"required": false
},
"excludeViews": {
"type": "boolean",
"required": false
},
"excludeHooks": {
"type": "boolean",
"required": false
}
}
},
"examples": {
"Example 1": {
"value": {
"excludeData": true,
"excludeViews": true,
"excludeHooks": true
}
}
}
}
}
},
"tags": ["Project"],
"description": "Duplicate a project",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
},
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "p_124hhlkbeasewh",
"type": "string"
},
"name": "projectId",
"in": "path",
"required": true,
"description": "Unique Project ID"
}
]
}
},
"/api/v1/db/meta/projects/{projectId}": {
"parameters": [
{
@ -3776,6 +3945,91 @@
]
}
},
"/api/v1/db/meta/duplicate/{projectId}/table/{tableId}": {
"post": {
"summary": "Duplicate Table",
"operationId": "db-table-duplicate",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"id": {
"type": "string"
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"excludeData": {
"type": "boolean",
"required": false
},
"excludeViews": {
"type": "boolean",
"required": false
}
}
},
"examples": {
"Example 1": {
"value": {
"excludeData": true,
"excludeViews": true
}
}
}
}
}
},
"tags": ["DB Table"],
"description": "Duplicate a table",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
},
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "p_124hhlkbeasewh",
"type": "string"
},
"name": "projectId",
"in": "path",
"required": true,
"description": "Unique Project ID"
},
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "md_124hhlkbeasewh",
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true,
"description": "Unique Table ID"
}
]
}
},
"/api/v1/db/meta/projects/{projectId}/{baseId}/tables": {
"parameters": [
{
@ -18255,6 +18509,11 @@
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"status": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Project Status",
"example": "locked"
}
},
"required": ["title"],
@ -18298,6 +18557,11 @@
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"status": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Project Status",
"example": "locked"
}
}
},

15
packages/nocodb/src/services/bulk-data-alias.service.ts

@ -44,12 +44,22 @@ export class BulkDataAliasService {
param: PathParams & {
body: any;
cookie: any;
chunkSize?: number;
foreign_key_checks?: boolean;
raw?: boolean;
},
) {
return await this.executeBulkOperation({
...param,
operation: 'bulkInsert',
options: [param.body, { cookie: param.cookie }],
options: [
param.body,
{
cookie: param.cookie,
foreign_key_checks: param.foreign_key_checks,
raw: param.raw,
},
],
});
}
@ -58,12 +68,13 @@ export class BulkDataAliasService {
param: PathParams & {
body: any;
cookie: any;
raw?: boolean;
},
) {
return await this.executeBulkOperation({
...param,
operation: 'bulkUpdate',
options: [param.body, { cookie: param.cookie }],
options: [param.body, { cookie: param.cookie, raw: param.raw }],
});
}

20
packages/nocodb/src/services/datas.service.ts

@ -13,6 +13,7 @@ import {
getViewAndModelByAliasOrId,
serializeCellValue,
} from '../modules/datas/helpers';
import type { BaseModelSqlv2 } from '../db/BaseModelSqlv2';
import type { PathParams } from '../modules/datas/helpers';
import type { LinkToAnotherRecordColumn, LookupColumn } from '../models';
@ -110,16 +111,23 @@ export class DatasService {
return await baseModel.delByPk(param.rowId, null, param.cookie);
}
async getDataList(param: { model: Model; view: View; query: any }) {
async getDataList(param: {
model: Model;
view: View;
query: any;
baseModel?: BaseModelSqlv2;
}) {
const { model, view, query = {} } = param;
const base = await Base.get(model.base_id);
const baseModel = await Model.getBaseModelSQL({
id: model.id,
viewId: view?.id,
dbDriver: await NcConnectionMgrv2.get(base),
});
const baseModel =
param.baseModel ||
(await Model.getBaseModelSQL({
id: model.id,
viewId: view?.id,
dbDriver: await NcConnectionMgrv2.get(base),
}));
const { ast, dependencyFields } = await getAst({ model, query, view });

6
packages/nocodb/src/services/meta-diffs.service.ts

@ -1014,7 +1014,11 @@ export class MetaDiffsService {
}
// todo: impl better method to identify m2m relation
if (belongsToCols?.length === 2 && normalColumns.length < 5) {
if (
belongsToCols?.length === 2 &&
normalColumns.length < 5 &&
assocModel.primaryKeys.length === 2
) {
const modelA = await belongsToCols[0].colOptions.getRelatedTable();
const modelB = await belongsToCols[1].colOptions.getRelatedTable();

2
packages/nocodb/src/services/projects.service.ts

@ -58,7 +58,7 @@ export class ProjectsService {
const data: Partial<Project> = extractPropsAndSanitize(
param?.project as Project,
['title', 'meta', 'color'],
['title', 'meta', 'color', 'status'],
);
if (

8
tests/playwright/fixtures/expectedData.txt

@ -1,4 +1,4 @@
Address,District,PostalCode,Phone,Location,Customer List,Staff List,City,Staff List1
1661 Abha Drive,Tamil Nadu,14400,270456873752,"{""x"":78.8214191,""y"":10.3812871}",1,,Pudukkottai,
1993 Tabuk Lane,Tamil Nadu,64221,648482415405,"{""x"":80.1270701,""y"":12.9246028}",2,,Tambaram,
381 Kabul Way,Taipei,87272,55477302294,"{""x"":0,""y"":0}",2,,Hsichuh,
Address,District,PostalCode,Phone,Location,Customer List,Staff List,City
1661 Abha Drive,Tamil Nadu,14400,270456873752,"{""x"":78.8214191,""y"":10.3812871}",1,,Pudukkottai
1993 Tabuk Lane,Tamil Nadu,64221,648482415405,"{""x"":80.1270701,""y"":12.9246028}",2,,Tambaram
381 Kabul Way,Taipei,87272,55477302294,"{""x"":0,""y"":0}",2,,Hsichuh

38
tests/playwright/fixtures/expectedDataSqlite.txt

@ -1,19 +1,19 @@
Address,District,PostalCode,Phone,Customer List,Staff List,City,Staff List1
1013 Tabuk Boulevard," ",96203," ",2,,Kanchrapara,
1168 Najafabad Parkway," ",40301," ",1,,Kabul,
1294 Firozabad Drive," ",70618," ",2,,Pingxiang,
1342 Abha Boulevard," ",10714," ",2,,Bucuresti,
1368 Maracabo Boulevard," ",32716," ",2,,South Hill,
1427 Tabuk Place," ",31342," ",2,,Cape Coral,
1519 Santiago de los Caballeros Loop," ",22025," ",2,,Mwene-Ditu,
1661 Abha Drive," ",14400," ",1,,Pudukkottai,
17 Kabul Boulevard," ",38594," ",1,,Nagareyama,
1838 Tabriz Lane," ",1195," ",1,,Dhaka,
1888 Kabul Drive," ",20936," ",1,,Ife,
1892 Nabereznyje Telny Lane," ",28396," ",2,,Tafuna,
1993 Tabuk Lane," ",64221," ",2,,Tambaram,
217 Botshabelo Place," ",49521," ",2,,Davao,
381 Kabul Way," ",87272," ",2,,Hsichuh,
44 Najafabad Way," ",61391," ",2,,Donostia-San Sebastin,
48 Maracabo Place," ",1570," ",1,,Talavera,
669 Firozabad Loop," ",92265," ",1,,al-Ayn,
Address,District,PostalCode,Phone,Customer List,Staff List,City
1013 Tabuk Boulevard," ",96203," ",2,,Kanchrapara
1168 Najafabad Parkway," ",40301," ",1,,Kabul
1294 Firozabad Drive," ",70618," ",2,,Pingxiang
1342 Abha Boulevard," ",10714," ",2,,Bucuresti
1368 Maracabo Boulevard," ",32716," ",2,,South Hill
1427 Tabuk Place," ",31342," ",2,,Cape Coral
1519 Santiago de los Caballeros Loop," ",22025," ",2,,Mwene-Ditu
1661 Abha Drive," ",14400," ",1,,Pudukkottai
17 Kabul Boulevard," ",38594," ",1,,Nagareyama
1838 Tabriz Lane," ",1195," ",1,,Dhaka
1888 Kabul Drive," ",20936," ",1,,Ife
1892 Nabereznyje Telny Lane," ",28396," ",2,,Tafuna
1993 Tabuk Lane," ",64221," ",2,,Tambaram
217 Botshabelo Place," ",49521," ",2,,Davao
381 Kabul Way," ",87272," ",2,,Hsichuh
44 Najafabad Way," ",61391," ",2,,Donostia-San Sebastin
48 Maracabo Place," ",1570," ",1,,Talavera
669 Firozabad Loop," ",92265," ",1,,al-Ayn

55
tests/playwright/package-lock.json generated

@ -16,7 +16,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "1.27.1",
"@playwright/test": "1.32.2",
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"axios": "^0.24.0",
@ -304,19 +304,22 @@
}
},
"node_modules/@playwright/test": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.27.1.tgz",
"integrity": "sha512-mrL2q0an/7tVqniQQF6RBL2saskjljXzqNcCOVMUjRIgE6Y38nCNaP+Dc2FBW06bcpD3tqIws/HT9qiMHbNU0A==",
"version": "1.32.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.2.tgz",
"integrity": "sha512-nhaTSDpEdTTttdkDE8Z6K3icuG1DVRxrl98Qq0Lfc63SS9a2sjc9+x8ezysh7MzCKz6Y+nArml3/mmt+gqRmQQ==",
"dev": true,
"dependencies": {
"@types/node": "*",
"playwright-core": "1.27.1"
"playwright-core": "1.32.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@types/json-schema": {
@ -2107,6 +2110,20 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -3841,9 +3858,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.27.1.tgz",
"integrity": "sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==",
"version": "1.32.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.2.tgz",
"integrity": "sha512-zD7aonO+07kOTthsrCR3YCVnDcqSHIJpdFUtZEMOb6//1Rc7/6mZDRdw+nlzcQiQltOOsiqI3rrSyn/SlyjnJQ==",
"dev": true,
"bin": {
"playwright": "cli.js"
@ -5192,13 +5209,14 @@
}
},
"@playwright/test": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.27.1.tgz",
"integrity": "sha512-mrL2q0an/7tVqniQQF6RBL2saskjljXzqNcCOVMUjRIgE6Y38nCNaP+Dc2FBW06bcpD3tqIws/HT9qiMHbNU0A==",
"version": "1.32.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.2.tgz",
"integrity": "sha512-nhaTSDpEdTTttdkDE8Z6K3icuG1DVRxrl98Qq0Lfc63SS9a2sjc9+x8ezysh7MzCKz6Y+nArml3/mmt+gqRmQQ==",
"dev": true,
"requires": {
"@types/node": "*",
"playwright-core": "1.27.1"
"fsevents": "2.3.2",
"playwright-core": "1.32.2"
}
},
"@types/json-schema": {
@ -6493,6 +6511,13 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -7800,9 +7825,9 @@
"dev": true
},
"playwright-core": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.27.1.tgz",
"integrity": "sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==",
"version": "1.32.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.2.tgz",
"integrity": "sha512-zD7aonO+07kOTthsrCR3YCVnDcqSHIJpdFUtZEMOb6//1Rc7/6mZDRdw+nlzcQiQltOOsiqI3rrSyn/SlyjnJQ==",
"dev": true
},
"postgres-array": {

2
tests/playwright/package.json

@ -23,7 +23,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.27.1",
"@playwright/test": "1.32.2",
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"axios": "^0.24.0",

15
tests/playwright/pages/Dashboard/WebhookForm/index.ts

@ -83,7 +83,7 @@ export class WebhookFormPage extends BasePage {
}
if (save) {
await this.save();
await this.save(true);
await this.close();
}
}
@ -91,18 +91,27 @@ export class WebhookFormPage extends BasePage {
async deleteCondition(p: { save: boolean }) {
await this.get().locator(`.nc-filter-item-remove-btn`).click();
if (p.save) {
await this.save();
await this.save(true);
await this.close();
}
}
async save() {
async save(condition = false) {
const saveAction = () => this.saveButton.click();
await this.waitForResponse({
uiAction: saveAction,
requestUrlPathToMatch: '/hooks',
httpMethodsToMatch: ['POST', 'PATCH'],
});
if (condition) {
await this.waitForResponse({
uiAction: saveAction,
requestUrlPathToMatch: '/filters',
httpMethodsToMatch: ['POST', 'PATCH', 'DELETE'],
});
}
await this.verifyToast({ message: 'Webhook details updated successfully' });
}

29
tests/playwright/pages/ProjectsPage/index.ts

@ -37,6 +37,35 @@ export class ProjectsPage extends BasePage {
await this.rootPage.locator('.nc-container').waitFor({ state: 'visible' });
}
// duplicate project
async duplicateProject({
name = 'sample',
withoutPrefix,
}: {
name?: string;
type?: string;
withoutPrefix?: boolean;
}) {
if (!withoutPrefix) name = this.prefixTitle(name);
// click three-dot
await this.rootPage.getByTestId('p-three-dot-' + name).click();
// check duplicate visible
await expect(this.rootPage.getByTestId('dupe-project-' + name)).toBeVisible();
// click duplicate
await this.rootPage.getByTestId('dupe-project-' + name).click();
// click duplicate confirmation "Do you want to duplicate 'sampleREST0' project?"
// assert message on duplicate confirmation page
const dupeProjectSubmitAction = () => this.rootPage.getByRole('button', { name: 'Confirm' }).click();
await this.waitForResponse({
uiAction: dupeProjectSubmitAction,
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: 'api/v1/db/meta/duplicate/',
});
// wait for duplicate create completed and render kebab
await this.get().locator(`[data-testid="p-three-dot-${name} copy"]`).waitFor();
}
async checkProjectCreateButton({ exists = true }) {
await expect(this.rootPage.locator('.nc-new-project-menu:visible')).toHaveCount(exists ? 1 : 0);
}

10
tests/playwright/quickTests/commonTest.ts

@ -246,10 +246,16 @@ const quickVerify = async ({
}
if (airtableImport) {
// Delete project
// Delete default context project
await dashboard.clickHome();
const projectsPage = new ProjectsPage(dashboard.rootPage);
await projectsPage.deleteProject({ title: context.project.title, withoutPrefix: true });
const projExists: boolean = await projectsPage
.get()
.locator(`[data-testid="delete-project-${context.project.title}"]`)
.isVisible();
if (projExists) {
await projectsPage.deleteProject({ title: context.project.title, withoutPrefix: true });
}
}
};

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save