Browse Source

Merge branch 'feat/export-nest' into develop

feat/export-nest
mertmit 1 year ago
parent
commit
b4f1e77a78
  1. 111
      packages/nc-gui/components/dlg/AirtableImport.vue
  2. 2
      packages/nc-gui/components/smartsheet/Grid.vue
  3. 68
      packages/nc-gui/pages/index/index/index.vue
  4. 100
      packages/nc-gui/plugins/jobs.ts
  5. 12992
      packages/nc-plugin/package-lock.json
  6. 1
      packages/nc-plugin/package.json
  7. 4
      packages/nc-plugin/src/lib/IStorageAdapterV2.ts
  8. 3
      packages/nocodb-legacy/.gitignore
  9. 105
      packages/nocodb-legacy/package-lock.json
  10. 2
      packages/nocodb-legacy/package.json
  11. 19
      packages/nocodb-legacy/src/lib/controllers/exportImport/export.ctl.ts
  12. 39
      packages/nocodb-legacy/src/lib/controllers/exportImport/import.ctl.ts
  13. 7
      packages/nocodb-legacy/src/lib/controllers/exportImport/index.ts
  14. 87
      packages/nocodb-legacy/src/lib/db/sql-client/lib/sqlite/SqliteClient.ts
  15. 41
      packages/nocodb-legacy/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  16. 3
      packages/nocodb-legacy/src/lib/meta/api/index.ts
  17. 20
      packages/nocodb-legacy/src/lib/plugins/backblaze/Backblaze.ts
  18. 18
      packages/nocodb-legacy/src/lib/plugins/gcs/Gcs.ts
  19. 20
      packages/nocodb-legacy/src/lib/plugins/linode/LinodeObjectStorage.ts
  20. 22
      packages/nocodb-legacy/src/lib/plugins/mino/Minio.ts
  21. 20
      packages/nocodb-legacy/src/lib/plugins/ovhCloud/OvhCloud.ts
  22. 18
      packages/nocodb-legacy/src/lib/plugins/s3/S3.ts
  23. 16
      packages/nocodb-legacy/src/lib/plugins/scaleway/ScalewayObjectStorage.ts
  24. 20
      packages/nocodb-legacy/src/lib/plugins/spaces/Spaces.ts
  25. 18
      packages/nocodb-legacy/src/lib/plugins/upcloud/UpoCloud.ts
  26. 18
      packages/nocodb-legacy/src/lib/plugins/vultr/Vultr.ts
  27. 5
      packages/nocodb-legacy/src/lib/services/dbData/bulkData.ts
  28. 493
      packages/nocodb-legacy/src/lib/services/exportImport/export.svc.ts
  29. 844
      packages/nocodb-legacy/src/lib/services/exportImport/import.svc.ts
  30. 2
      packages/nocodb-legacy/src/lib/services/index.ts
  31. 2
      packages/nocodb-legacy/src/lib/services/metaDiff.svc.ts
  32. 27
      packages/nocodb-legacy/src/lib/v1-legacy/plugins/adapters/storage/Local.ts
  33. 84
      packages/nocodb-sdk/src/lib/Api.ts
  34. 3
      packages/nocodb/.gitignore
  35. 100
      packages/nocodb/litestream/Dockerfile
  36. 328
      packages/nocodb/package-lock.json
  37. 9
      packages/nocodb/package.json
  38. 4
      packages/nocodb/src/Noco.ts
  39. 12
      packages/nocodb/src/app.module.ts
  40. 6
      packages/nocodb/src/controllers/imports/helpers/NocoSyncDestAdapter.ts
  41. 7
      packages/nocodb/src/controllers/imports/helpers/NocoSyncSourceAdapter.ts
  42. 2480
      packages/nocodb/src/controllers/imports/helpers/job.ts
  43. 5
      packages/nocodb/src/controllers/imports/helpers/readAndProcessData.ts
  44. 21
      packages/nocodb/src/controllers/imports/import.controller.spec.ts
  45. 148
      packages/nocodb/src/controllers/imports/import.controller.ts
  46. 81
      packages/nocodb/src/db/BaseModelSqlv2.ts
  47. 87
      packages/nocodb/src/db/sql-client/lib/sqlite/SqliteClient.ts
  48. 82
      packages/nocodb/src/helpers/exportImportHelpers.ts
  49. 6
      packages/nocodb/src/helpers/populateMeta.ts
  50. 7
      packages/nocodb/src/interface/Jobs.ts
  51. 1
      packages/nocodb/src/models/Project.ts
  52. 6
      packages/nocodb/src/modules/global/global.module.ts
  53. 77
      packages/nocodb/src/modules/jobs/at-import/at-import.controller.ts
  54. 2516
      packages/nocodb/src/modules/jobs/at-import/at-import.processor.ts
  55. 222
      packages/nocodb/src/modules/jobs/at-import/helpers/EntityMap.ts
  56. 242
      packages/nocodb/src/modules/jobs/at-import/helpers/fetchAT.ts
  57. 362
      packages/nocodb/src/modules/jobs/at-import/helpers/readAndProcessData.ts
  58. 31
      packages/nocodb/src/modules/jobs/at-import/helpers/syncMap.ts
  59. 126
      packages/nocodb/src/modules/jobs/export-import/duplicate.controller.ts
  60. 562
      packages/nocodb/src/modules/jobs/export-import/duplicate.processor.ts
  61. 645
      packages/nocodb/src/modules/jobs/export-import/export.service.ts
  62. 1356
      packages/nocodb/src/modules/jobs/export-import/import.service.ts
  63. 118
      packages/nocodb/src/modules/jobs/fallback-queue.service.ts
  64. 62
      packages/nocodb/src/modules/jobs/jobs-event.service.ts
  65. 126
      packages/nocodb/src/modules/jobs/jobs.gateway.ts
  66. 39
      packages/nocodb/src/modules/jobs/jobs.module.ts
  67. 52
      packages/nocodb/src/modules/jobs/jobs.service.ts
  68. 19
      packages/nocodb/src/modules/metas/metas.module.ts
  69. 16
      packages/nocodb/src/plugins/backblaze/Backblaze.ts
  70. 16
      packages/nocodb/src/plugins/gcs/Gcs.ts
  71. 16
      packages/nocodb/src/plugins/linode/LinodeObjectStorage.ts
  72. 16
      packages/nocodb/src/plugins/mino/Minio.ts
  73. 16
      packages/nocodb/src/plugins/ovhCloud/OvhCloud.ts
  74. 16
      packages/nocodb/src/plugins/s3/S3.ts
  75. 16
      packages/nocodb/src/plugins/scaleway/ScalewayObjectStorage.ts
  76. 16
      packages/nocodb/src/plugins/spaces/Spaces.ts
  77. 33
      packages/nocodb/src/plugins/storage/Local.ts
  78. 16
      packages/nocodb/src/plugins/upcloud/UpoCloud.ts
  79. 16
      packages/nocodb/src/plugins/vultr/Vultr.ts
  80. 7
      packages/nocodb/src/run/docker.ts
  81. 115
      packages/nocodb/src/schema/swagger.json
  82. 15
      packages/nocodb/src/services/bulk-data-alias.service.ts
  83. 20
      packages/nocodb/src/services/datas.service.ts
  84. 6
      packages/nocodb/src/services/meta-diffs.service.ts
  85. 2
      packages/nocodb/src/services/projects.service.ts
  86. 19
      packages/nocodb/src/services/socket.gateway.spec.ts
  87. 67
      packages/nocodb/src/services/socket.gateway.ts
  88. 12
      packages/nocodb/src/services/socket.service.spec.ts
  89. 43
      packages/nocodb/src/services/socket.service.ts
  90. 55
      tests/playwright/package-lock.json
  91. 2
      tests/playwright/package.json
  92. 29
      tests/playwright/pages/ProjectsPage/index.ts
  93. 10
      tests/playwright/quickTests/commonTest.ts
  94. 116
      tests/playwright/tests/db/projectOperations.spec.ts
  95. 68
      tests/playwright/tests/utils/objectCompareUtil.ts
  96. 167
      tests/playwright/tests/utils/projectInfoOperator.ts

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

@ -1,6 +1,4 @@
<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,
@ -10,7 +8,6 @@ import {
iconMap,
message,
nextTick,
onBeforeUnmount,
onMounted,
ref,
storeToRefs,
@ -31,7 +28,7 @@ const { appInfo } = $(useGlobal())
const baseURL = appInfo.ncSiteUrl
const { $state } = useNuxtApp()
const { $state, $jobs } = useNuxtApp()
const projectStore = useProject()
@ -49,8 +46,6 @@ const logRef = ref<typeof AntCard>()
const enableAbort = ref(false)
let socket: Socket | null
const syncSource = ref({
id: '',
type: 'Airtable',
@ -72,6 +67,35 @@ const syncSource = ref({
},
})
const pushProgress = async (message: string, status: 'completed' | 'failed' | '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: 'active' | 'completed' | 'failed' | 'refresh', error?: any) => {
if (status === 'completed') {
showGoToDashboardButton.value = true
await loadTables()
pushProgress('Done!', status)
// TODO: add tab of the first table
} else if (status === 'failed') {
pushProgress(error, status)
}
}
const onLog = (data: { message: string }) => {
pushProgress(data.message, 'progress')
}
const validators = computed(() => ({
'details.apiKey': [fieldRequiredValidator()],
'details.syncSourceUrlOrId': [fieldRequiredValidator()],
@ -130,7 +154,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 +185,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 +204,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 +241,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 +370,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 === 'failed'" class="flex items-center">
<component :is="iconMap.closeCircle" class="text-red-500" />
<span class="text-red-500 ml-2">{{ msg }}</span>
@ -424,7 +387,7 @@ onBeforeUnmount(() => {
v-if="
!progress ||
!progress.length ||
(progress[progress.length - 1].status !== 'COMPLETED' && progress[progress.length - 1].status !== 'FAILED')
(progress[progress.length - 1].status !== 'completed' && progress[progress.length - 1].status !== 'failed')
"
class="flex items-center"
>

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

@ -756,7 +756,7 @@ const closeAddColumnDropdown = () => {
const confirmDeleteRow = (row: number) => {
Modal.confirm({
title: `Do you want to delete this row?`,
wrapClassName: 'nc-modal-row-delete',
wrapClassName: 'nc-modal-attachment-delete',
okText: 'Yes',
okType: 'danger',
cancelText: 'No',

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

@ -27,7 +27,7 @@ definePageMeta({
title: 'title.myProject',
})
const { $api, $e } = useNuxtApp()
const { $api, $e, $jobs } = useNuxtApp()
const { api, isLoading } = useApi()
@ -39,9 +39,17 @@ const filterQuery = ref('')
const projects = ref<ProjectType[]>()
const activePage = ref(1)
const pageChange = (p: number) => {
activePage.value = p
}
const loadProjects = async () => {
const lastPage = activePage.value
const response = await api.project.list({})
projects.value = response.list
activePage.value = lastPage
}
const filteredProjects = computed(
@ -74,6 +82,36 @@ const deleteProject = (project: ProjectType) => {
})
}
const duplicateProject = (project: ProjectType) => {
Modal.confirm({
title: `Do you want to duplicate '${project.title}' project?`,
wrapClassName: 'nc-modal-project-duplicate',
okText: 'Yes',
okType: 'primary',
cancelText: 'No',
async onOk() {
try {
const jobData = await api.project.duplicate(project.id as string)
await loadProjects()
$jobs.subscribe({ name: jobData.name, id: jobData.id }, null, async (status: string) => {
if (status === 'completed') {
await loadProjects()
} else if (status === 'failed') {
message.error('Failed to duplicate project')
await loadProjects()
}
})
$e('a:project:duplicate')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
const handleProjectColor = async (projectId: string, color: string) => {
const tcolor = tinycolor(color)
@ -122,7 +160,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 +234,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 +287,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 +303,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 +317,21 @@ 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>

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 { 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: 'active' | 'completed' | 'failed' | 'refresh', error?: 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.error)
if (data.status === 'completed' || data.status === '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",

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

@ -1,4 +1,5 @@
import IStorageAdapter from './IStorageAdapter';
import { Readable } from 'stream';
export default interface IStorageAdapterV2 extends IStorageAdapter {
fileCreateByUrl(
@ -6,6 +7,9 @@ export default interface IStorageAdapterV2 extends IStorageAdapter {
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);

84
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,80 @@ 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,
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',
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, params: RequestParams = {}) =>
this.request<
{
name?: string;
id?: string;
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/db/meta/duplicate/${projectId}`,
method: 'POST',
format: 'json',
...params,
}),
/**
* @description Get the info of a given project
*

3
packages/nocodb/.gitignore vendored

@ -35,4 +35,7 @@ lerna-debug.log*
!.vscode/extensions.json
/noco.db
# export
export/**
/docker/main.js

100
packages/nocodb/litestream/Dockerfile

@ -1,100 +0,0 @@
FROM golang:alpine3.14 as lt
WORKDIR /usr/src/
RUN apk add --no-cache git make musl-dev gcc
# build litestream
RUN git clone https://github.com/benbjohnson/litestream.git litestream
RUN cd litestream ; go install ./cmd/litestream
RUN cp $GOPATH/bin/litestream /usr/src/lt
FROM node:12 as builder
WORKDIR /usr/src/app
# Copy application dependency manifests to the container image.
# A wildcard is used to ensure both package.json AND package-lock.json are copied.
# Copying this separately prevents re-running npm ci on every code change.
COPY ./package*.json ./
COPY ./docker/main.js ./docker/main.js
#COPY ./docker/start.sh /usr/src/appEntry/start.sh
COPY ./docker/start-litestream.sh /usr/src/appEntry/start.sh
# install production dependencies,
# reduce node_module size with modclean & removing sqlite deps,
# package built code into app.tar.gz & add execute permission to start.sh
RUN npm ci --production --quiet
RUN npx modclean --patterns="default:*" --ignore="nc-lib-gui/**,dayjs/**,express-status-monitor/**" --run
RUN rm -rf ./node_modules/sqlite3/deps
RUN tar -czf ../appEntry/app.tar.gz ./*
RUN chmod +x /usr/src/appEntry/start.sh
FROM alpine:3.14
#ENV AWS_ACCESS_KEY_ID=
#ENV AWS_SECRET_ACCESS_KEY=
#ENV AWS_BUCKET=
#WORKDIR /usr/src/
#
## Install go lang
#RUN apk add --no-cache git make musl-dev go
#
## Configure Go
#ENV GOROOT /usr/lib/go
#ENV GOPATH /go
#ENV PATH /go/bin:$PATH
#
#RUN mkdir -p ${GOPATH}/src ${GOPATH}/bin
#
## build litestream
#
#RUN git clone https://github.com/benbjohnson/litestream.git litestream
#RUN cd litestream ; go install ./cmd/litestream
# Bug fix for segfault ( Convert PT_GNU_STACK program header into PT_PAX_FLAGS )
#RUN apk --update --no-cache add paxctl \
# && paxctl -cm $(which node)
WORKDIR /usr/src/app
ENV NC_DOCKER 0.6
ENV PORT 8080
ENV NC_TOOL_DIR=/usr/app/data/
# Copy application dependency manifests to the container image.
# A wildcard is used to ensure both package.json AND package-lock.json are copied.
# Copying this separately prevents re-running npm install on every code change.
#COPY ./build/ ./build/
#COPY ./docker/main.js ./docker/main.js
#COPY ./package.json ./
RUN apk --update --no-cache add \
nodejs \
tar
# Copy litestream binary build
COPY --from=lt /usr/src/lt /usr/src/appEntry/litestream
# Copy packaged production code & main entry file
COPY --from=builder /usr/src/appEntry/ /usr/src/appEntry/
# Run the web service on container startup.
#CMD [ "node", "docker/index.js" ]
ENTRYPOINT ["sh", "/usr/src/appEntry/start.sh"]

328
packages/nocodb/package-lock.json generated

@ -11,13 +11,17 @@
"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",
"@nestjs/jwt": "^10.0.3",
"@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",
@ -30,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",
@ -76,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": "file:../nc-plugin",
"ncp": "^2.0.0",
"nocodb-sdk": "0.107.0-beta.0",
"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",
@ -114,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",
@ -147,6 +154,40 @@
"webpack-cli": "^5.0.1"
}
},
"../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.107.0-beta.0",
"extraneous": true,
@ -1420,14 +1461,6 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"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",
@ -2300,6 +2333,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",
@ -2475,6 +2534,19 @@
}
}
},
"node_modules/@nestjs/event-emitter": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-1.4.1.tgz",
"integrity": "sha512-PmLpzMYgEKJNxOUrRjb6kNSm2PC6J+BeLTuF/bkYViGM/mVGvYOgU5jq8DQnXmiSmDmyWN+tO2cHSnR7odJJRA==",
"dependencies": {
"eventemitter2": "6.4.9"
},
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0",
"reflect-metadata": "^0.1.12"
}
},
"node_modules/@nestjs/jwt": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.0.3.tgz",
@ -2582,6 +2654,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",
@ -2655,6 +2745,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",
@ -3176,6 +3288,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",
@ -8054,6 +8176,16 @@
"node": ">=6"
}
},
"node_modules/eventemitter2": {
"version": "6.4.9",
"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",
@ -12989,14 +13121,6 @@
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"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",
@ -13025,16 +13149,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",
@ -13649,6 +13765,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",
@ -13693,6 +13817,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",
@ -19271,11 +19421,6 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"@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",
@ -19954,6 +20099,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",
@ -20058,6 +20220,14 @@
"uid": "2.0.2"
}
},
"@nestjs/event-emitter": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-1.4.1.tgz",
"integrity": "sha512-PmLpzMYgEKJNxOUrRjb6kNSm2PC6J+BeLTuF/bkYViGM/mVGvYOgU5jq8DQnXmiSmDmyWN+tO2cHSnR7odJJRA==",
"requires": {
"eventemitter2": "6.4.9"
}
},
"@nestjs/jwt": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.0.3.tgz",
@ -20128,6 +20298,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",
@ -20164,6 +20343,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",
@ -20615,6 +20804,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",
@ -24415,6 +24613,16 @@
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"eventemitter2": {
"version": "6.4.9",
"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",
@ -28191,11 +28399,6 @@
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"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",
@ -28218,12 +28421,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": {
@ -28672,6 +28894,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",
@ -28698,6 +28925,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",

9
packages/nocodb/package.json

@ -44,11 +44,15 @@
"@graphql-tools/merge": "^6.0.12",
"@nestjs/common": "^9.4.0",
"@nestjs/core": "^9.4.0",
"@nestjs/bull": "^0.6.3",
"@nestjs/event-emitter": "^1.4.1",
"@nestjs/jwt": "^10.0.3",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.4.0",
"@nestjs/serve-static": "^3.0.1",
"@nestjs/platform-socket.io": "^9.4.0",
"@nestjs/websockets": "^9.4.0",
"@sentry/node": "^6.3.5",
"@types/chai": "^4.2.12",
"airtable": "^0.11.3",
@ -61,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",
@ -107,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": "file:../nc-plugin",
"ncp": "^2.0.0",
"nocodb-sdk": "0.107.0-beta.0",
"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",
@ -145,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';
@ -96,6 +97,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 { Module, RequestMethod } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { BullModule } from '@nestjs/bull';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Connection } from './connection/connection';
import { GlobalExceptionFilter } from './filters/global-exception/global-exception.filter';
import NcPluginMgrv2 from './helpers/NcPluginMgrv2';
@ -20,6 +22,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,
@ -32,6 +35,15 @@ import type {
...(process.env['PLAYWRIGHT_TEST'] === 'true' ? [TestModule] : []),
MetasModule,
DatasModule,
JobsModule,
EventEmitterModule.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

5
packages/nocodb/src/controllers/imports/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';

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);
}
}

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

@ -2097,23 +2097,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
@ -2121,18 +2130,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) {
@ -2141,12 +2166,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 = [];
@ -2154,13 +2184,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 });
@ -2175,11 +2205,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;
}
@ -2010,14 +2009,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
@ -2029,19 +2028,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}`;
@ -2096,12 +2095,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;

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();

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

@ -0,0 +1,7 @@
export const JOBS_QUEUE = 'jobs';
export enum JobTypes {
DuplicateBase = 'duplicate-base',
DuplicateModel = 'duplicate-model',
AtImport = 'at-import',
}

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 '../../services/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 {}

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

@ -0,0 +1,77 @@
import { InjectQueue } from '@nestjs/bull';
import { Controller, HttpCode, Post, Request, UseGuards } from '@nestjs/common';
import { Queue } from 'bull';
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 { QueueService } from '../fallback-queue.service';
import { JobsService } from '../jobs.service';
import { JOBS_QUEUE, JobTypes } from '../../../interface/Jobs';
@Controller()
@UseGuards(ExtractProjectIdMiddleware, GlobalGuard)
export class AtImportController {
activeQueue;
constructor(
@InjectQueue(JOBS_QUEUE) private readonly jobsQueue: Queue,
private readonly fallbackQueueService: QueueService,
private readonly jobsService: JobsService,
) {
this.activeQueue = process.env.NC_REDIS_URL
? this.jobsQueue
: this.fallbackQueueService;
}
@Post('/api/v1/db/meta/import/airtable')
@HttpCode(200)
async importAirtable(@Request() req) {
const job = await this.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.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

222
packages/nocodb/src/modules/jobs/at-import/helpers/EntityMap.ts

@ -0,0 +1,222 @@
import { Readable } from 'stream';
import sqlite3 from 'sqlite3';
class EntityMap {
initialized: boolean;
cols: string[];
db: any;
constructor(...args) {
this.initialized = false;
this.cols = args.map((arg) => processKey(arg));
this.db = new Promise((resolve, reject) => {
const db = new sqlite3.Database(':memory:');
const colStatement =
this.cols.length > 0
? this.cols.join(' TEXT, ') + ' TEXT'
: 'mappingPlaceholder TEXT';
db.run(`CREATE TABLE mapping (${colStatement})`, (err) => {
if (err) {
console.log(err);
reject(err);
}
resolve(db);
});
});
}
async init() {
if (!this.initialized) {
this.db = await this.db;
this.initialized = true;
}
}
destroy() {
if (this.initialized && this.db) {
this.db.close();
}
}
async addRow(row) {
if (!this.initialized) {
throw 'Please initialize first!';
}
const cols = Object.keys(row).map((key) => processKey(key));
const colStatement = cols.map((key) => `'${key}'`).join(', ');
const questionMarks = cols.map(() => '?').join(', ');
const promises = [];
for (const col of cols.filter((col) => !this.cols.includes(col))) {
promises.push(
new Promise((resolve, reject) => {
this.db.run(`ALTER TABLE mapping ADD '${col}' TEXT;`, (err) => {
if (err) {
console.log(err);
reject(err);
}
this.cols.push(col);
resolve(true);
});
}),
);
}
await Promise.all(promises);
const values = Object.values(row).map((val) => {
if (typeof val === 'object') {
return `JSON::${JSON.stringify(val)}`;
}
return val;
});
return new Promise((resolve, reject) => {
this.db.run(
`INSERT INTO mapping (${colStatement}) VALUES (${questionMarks})`,
values,
(err) => {
if (err) {
console.log(err);
reject(err);
}
resolve(true);
},
);
});
}
getRow(col, val, res = []): Promise<Record<string, any>> {
if (!this.initialized) {
throw 'Please initialize first!';
}
return new Promise((resolve, reject) => {
col = processKey(col);
res = res.map((r) => processKey(r));
this.db.get(
`SELECT ${
res.length ? res.join(', ') : '*'
} FROM mapping WHERE ${col} = ?`,
[val],
(err, rs) => {
if (err) {
console.log(err);
reject(err);
}
if (rs) {
rs = processResponseRow(rs);
}
resolve(rs);
},
);
});
}
getCount(): Promise<number> {
if (!this.initialized) {
throw 'Please initialize first!';
}
return new Promise((resolve, reject) => {
this.db.get(`SELECT COUNT(*) as count FROM mapping`, (err, rs) => {
if (err) {
console.log(err);
reject(err);
}
resolve(rs.count);
});
});
}
getStream(res = []): DBStream {
if (!this.initialized) {
throw 'Please initialize first!';
}
res = res.map((r) => processKey(r));
return new DBStream(
this.db,
`SELECT ${res.length ? res.join(', ') : '*'} FROM mapping`,
);
}
getLimit(limit, offset, res = []): Promise<Record<string, any>[]> {
if (!this.initialized) {
throw 'Please initialize first!';
}
return new Promise((resolve, reject) => {
res = res.map((r) => processKey(r));
this.db.all(
`SELECT ${
res.length ? res.join(', ') : '*'
} FROM mapping LIMIT ${limit} OFFSET ${offset}`,
(err, rs) => {
if (err) {
console.log(err);
reject(err);
}
for (let row of rs) {
row = processResponseRow(row);
}
resolve(rs);
},
);
});
}
}
class DBStream extends Readable {
db: any;
stmt: any;
sql: any;
constructor(db, sql) {
super({ objectMode: true });
this.db = db;
this.sql = sql;
this.stmt = this.db.prepare(this.sql);
this.on('end', () => this.stmt.finalize());
}
_read() {
const stream = this;
this.stmt.get(function (err, result) {
if (err) {
stream.emit('error', err);
} else {
if (result) {
result = processResponseRow(result);
}
stream.push(result || null);
}
});
}
}
function processResponseRow(res: any) {
for (const key of Object.keys(res)) {
if (res[key] && res[key].startsWith('JSON::')) {
try {
res[key] = JSON.parse(res[key].replace('JSON::', ''));
} catch (e) {
console.log(e);
}
}
if (revertKey(key) !== key) {
res[revertKey(key)] = res[key];
delete res[key];
}
}
return res;
}
function processKey(key) {
return key.replace(/'/g, "''").replace(/[A-Z]/g, (match) => `_${match}`);
}
function revertKey(key) {
return key.replace(/''/g, "'").replace(/_[A-Z]/g, (match) => match[1]);
}
export default EntityMap;

242
packages/nocodb/src/modules/jobs/at-import/helpers/fetchAT.ts

@ -0,0 +1,242 @@
import axios from 'axios';
const info: any = {
initialized: false,
};
async function initialize(shareId) {
info.cookie = '';
const url = `https://airtable.com/${shareId}`;
try {
const hreq = await axios
.get(url, {
headers: {
accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'accept-language': 'en-US,en;q=0.9',
'sec-ch-ua':
'" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Linux"',
'sec-fetch-dest': 'document',
'sec-fetch-mode': 'navigate',
'sec-fetch-site': 'none',
'sec-fetch-user': '?1',
'upgrade-insecure-requests': '1',
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36',
},
// @ts-ignore
referrerPolicy: 'strict-origin-when-cross-origin',
body: null,
method: 'GET',
})
.then((response) => {
for (const ck of response.headers['set-cookie']) {
info.cookie += ck.split(';')[0] + '; ';
}
return response.data;
})
.catch(() => {
throw {
message:
'Invalid Shared Base ID :: Ensure www.airtable.com/<SharedBaseID> is accessible. Refer https://bit.ly/3x0OdXI for details',
};
});
info.headers = JSON.parse(
hreq.match(/(?<=var headers =)(.*)(?=;)/g)[0].trim(),
);
info.link = unicodeToChar(hreq.match(/(?<=fetch\(")(.*)(?=")/g)[0].trim());
info.baseInfo = decodeURIComponent(info.link)
.match(/{(.*)}/g)[0]
.split('&')
.reduce((result, el) => {
try {
return Object.assign(
result,
JSON.parse(el.includes('=') ? el.split('=')[1] : el),
);
} catch (e) {
if (el.includes('=')) {
return Object.assign(result, {
[el.split('=')[0]]: el.split('=')[1],
});
}
}
}, {});
info.baseId = info.baseInfo.applicationId;
info.initialized = true;
} catch (e) {
console.log(e);
info.initialized = false;
if (e.message) {
throw e;
} else {
throw {
message:
'Error processing Shared Base :: Ensure www.airtable.com/<SharedBaseID> is accessible. Refer https://bit.ly/3x0OdXI for details',
};
}
}
}
async function read() {
if (info.initialized) {
const resreq = await axios('https://airtable.com' + info.link, {
headers: {
accept: '*/*',
'accept-language': 'en-US,en;q=0.9',
'sec-ch-ua':
'" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Linux"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36',
'x-time-zone': 'Europe/Berlin',
cookie: info.cookie,
...info.headers,
},
// @ts-ignore
referrerPolicy: 'no-referrer',
body: null,
method: 'GET',
})
.then((response) => {
return response.data;
})
.catch(() => {
throw {
message:
'Error Reading :: Ensure www.airtable.com/<SharedBaseID> is accessible. Refer https://bit.ly/3x0OdXI for details',
};
});
return {
schema: resreq.data,
baseId: info.baseId,
baseInfo: info.baseInfo,
};
} else {
throw {
message: 'Error Initializing :: please try again !!',
};
}
}
async function readView(viewId) {
if (info.initialized) {
const resreq = await axios(
`https://airtable.com/v0.3/view/${viewId}/readData?` +
`stringifiedObjectParams=${encodeURIComponent('{}')}&requestId=${
info.baseInfo.requestId
}&accessPolicy=${encodeURIComponent(
JSON.stringify({
allowedActions: info.baseInfo.allowedActions,
shareId: info.baseInfo.shareId,
applicationId: info.baseInfo.applicationId,
generationNumber: info.baseInfo.generationNumber,
expires: info.baseInfo.expires,
signature: info.baseInfo.signature,
}),
)}`,
{
headers: {
accept: '*/*',
'accept-language': 'en-US,en;q=0.9',
'sec-ch-ua':
'" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Linux"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36',
'x-time-zone': 'Europe/Berlin',
cookie: info.cookie,
...info.headers,
},
// @ts-ignore
referrerPolicy: 'no-referrer',
body: null,
method: 'GET',
},
)
.then((response) => {
return response.data;
})
.catch(() => {
throw {
message:
'Error Reading View :: Ensure www.airtable.com/<SharedBaseID> is accessible. Refer https://bit.ly/3x0OdXI for details',
};
});
return { view: resreq.data };
} else {
throw {
message: 'Error Initializing :: please try again !!',
};
}
}
async function readTemplate(templateId) {
if (!info.initialized) {
await initialize('shrO8aYf3ybwSdDKn');
}
const resreq = await axios(
`https://www.airtable.com/v0.3/exploreApplications/${templateId}`,
{
headers: {
accept: '*/*',
'accept-language': 'en-US,en;q=0.9',
'sec-ch-ua':
'" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Linux"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36',
'x-time-zone': 'Europe/Berlin',
cookie: info.cookie,
...info.headers,
},
// @ts-ignore
referrer: 'https://www.airtable.com/',
referrerPolicy: 'same-origin',
body: null,
method: 'GET',
mode: 'cors',
credentials: 'include',
},
)
.then((response) => {
return response.data;
})
.catch(() => {
throw {
message:
'Error Fetching :: Ensure www.airtable.com/templates/featured/<TemplateID> is accessible.',
};
});
return { template: resreq };
}
function unicodeToChar(text) {
return text.replace(/\\u[\dA-F]{4}/gi, function (match) {
return String.fromCharCode(parseInt(match.replace(/\\u/g, ''), 16));
});
}
export default {
initialize,
read,
readView,
readTemplate,
};

362
packages/nocodb/src/modules/jobs/at-import/helpers/readAndProcessData.ts

@ -0,0 +1,362 @@
/* 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';
// @ts-ignore
import type { AirtableBase } from 'airtable/lib/airtable_base';
import type { TableType } from 'nocodb-sdk';
const BULK_DATA_BATCH_SIZE = 500;
const ASSOC_BULK_DATA_BATCH_SIZE = 1000;
const BULK_PARALLEL_PROCESS = 5;
interface AirtableImportContext {
bulkDataService: BulkDataAliasService;
tableService: TablesService;
}
async function readAllData({
table,
fields,
base,
logBasic = (_str) => {},
services,
}: {
table: { title?: string };
fields?;
base: AirtableBase;
logBasic?: (string) => void;
logDetailed?: (string) => void;
services: AirtableImportContext;
}): Promise<EntityMap> {
return new Promise((resolve, reject) => {
let data = null;
const selectParams: any = {
pageSize: 100,
};
if (fields) selectParams.fields = fields;
base(table.title)
.select(selectParams)
.eachPage(
async function page(records, fetchNextPage) {
if (!data) {
data = new EntityMap();
await data.init();
}
for await (const record of records) {
await data.addRow({ id: record.id, ...record.fields });
}
const tmpLength = await data.getCount();
logBasic(
`:: Reading '${table.title}' data :: ${Math.max(
1,
tmpLength - records.length,
)} - ${tmpLength}`,
);
// To fetch the next page of records, call `fetchNextPage`.
// If there are more records, `page` will get called again.
// If there are no more records, `done` will get called.
fetchNextPage();
},
async function done(err) {
if (err) {
console.error(err);
return reject(err);
}
resolve(data);
},
);
});
}
export async function importData({
projectName,
table,
base,
nocoBaseDataProcessing_v2,
sDB,
logDetailed = (_str) => {},
logBasic = (_str) => {},
services,
}: {
projectName: string;
table: { title?: string; id?: string };
fields?;
base: AirtableBase;
logBasic: (string) => void;
logDetailed: (string) => void;
nocoBaseDataProcessing_v2;
sDB;
services: AirtableImportContext;
}): Promise<EntityMap> {
try {
// @ts-ignore
const records = await readAllData({
table,
base,
logDetailed,
logBasic,
});
await new Promise(async (resolve) => {
const readable = records.getStream();
const allRecordsCount = await records.getCount();
const promises = [];
let tempData = [];
let importedCount = 0;
let activeProcess = 0;
readable.on('data', async (record) => {
promises.push(
new Promise(async (resolve) => {
activeProcess++;
if (activeProcess >= BULK_PARALLEL_PROCESS) readable.pause();
const { id: rid, ...fields } = record;
const r = await nocoBaseDataProcessing_v2(sDB, table, {
id: rid,
fields,
});
tempData.push(r);
if (tempData.length >= BULK_DATA_BATCH_SIZE) {
let insertArray = tempData.splice(0, tempData.length);
await services.bulkDataService.bulkDataInsert({
projectName,
tableName: table.title,
body: insertArray,
cookie: {},
});
logBasic(
`:: Importing '${
table.title
}' data :: ${importedCount} - ${Math.min(
importedCount + BULK_DATA_BATCH_SIZE,
allRecordsCount,
)}`,
);
importedCount += insertArray.length;
insertArray = [];
}
activeProcess--;
if (activeProcess < BULK_PARALLEL_PROCESS) readable.resume();
resolve(true);
}),
);
});
readable.on('end', async () => {
await Promise.all(promises);
if (tempData.length > 0) {
await services.bulkDataService.bulkDataInsert({
projectName,
tableName: table.title,
body: tempData,
cookie: {},
});
logBasic(
`:: Importing '${
table.title
}' data :: ${importedCount} - ${Math.min(
importedCount + BULK_DATA_BATCH_SIZE,
allRecordsCount,
)}`,
);
importedCount += tempData.length;
tempData = [];
}
resolve(true);
});
});
return records;
} catch (e) {
console.log(e);
return null;
}
}
export async function importLTARData({
table,
fields,
base,
projectName,
insertedAssocRef = {},
logDetailed = (_str) => {},
logBasic = (_str) => {},
records,
atNcAliasRef,
ncLinkMappingTable,
syncDB,
services,
}: {
projectName: string;
table: { title?: string; id?: string };
fields;
base: AirtableBase;
logDetailed: (string) => void;
logBasic: (string) => void;
insertedAssocRef: { [assocTableId: string]: boolean };
records?: EntityMap;
atNcAliasRef: {
[ncTableId: string]: {
[ncTitle: string]: string;
};
};
ncLinkMappingTable: Record<string, Record<string, any>>[];
syncDB;
services: AirtableImportContext;
}) {
const assocTableMetas: Array<{
modelMeta: { id?: string; title?: string };
colMeta: { title?: string };
curCol: { title?: string };
refCol: { title?: string };
}> = [];
const allData =
records ||
(await readAllData({
table,
fields,
base,
logDetailed,
logBasic,
services,
}));
const modelMeta: any =
await services.tableService.getTableWithAccessibleViews({
tableId: table.id,
user: syncDB.user,
});
for (const colMeta of modelMeta.columns) {
// skip columns which are not LTAR and Many to many
if (
colMeta.uidt !== UITypes.LinkToAnotherRecord ||
colMeta.colOptions.type !== RelationTypes.MANY_TO_MANY
) {
continue;
}
// skip if already inserted
if (colMeta.colOptions.fk_mm_model_id in insertedAssocRef) continue;
// self links: skip if the column under consideration is the add-on column NocoDB creates
if (ncLinkMappingTable.every((a) => a.nc.title !== colMeta.title)) continue;
// mark as inserted
insertedAssocRef[colMeta.colOptions.fk_mm_model_id] = true;
const assocModelMeta: TableType =
(await services.tableService.getTableWithAccessibleViews({
tableId: colMeta.colOptions.fk_mm_model_id,
user: syncDB.user,
})) as any;
// extract associative table and columns meta
assocTableMetas.push({
modelMeta: assocModelMeta,
colMeta,
curCol: assocModelMeta.columns.find(
(c) => c.id === colMeta.colOptions.fk_mm_child_column_id,
),
refCol: assocModelMeta.columns.find(
(c) => c.id === colMeta.colOptions.fk_mm_parent_column_id,
),
});
}
let nestedLinkCnt = 0;
// Iterate over all related M2M associative table
for await (const assocMeta of assocTableMetas) {
let assocTableData = [];
let importedCount = 0;
// extract insert data from records
await new Promise((resolve) => {
const promises = [];
const readable = allData.getStream();
let activeProcess = 0;
readable.on('data', async (record) => {
promises.push(
new Promise(async (resolve) => {
activeProcess++;
if (activeProcess >= BULK_PARALLEL_PROCESS) readable.pause();
const { id: _atId, ...rec } = record;
// todo: use actual alias instead of sanitized
assocTableData.push(
...(
rec?.[atNcAliasRef[table.id][assocMeta.colMeta.title]] || []
).map((id) => ({
[assocMeta.curCol.title]: record.id,
[assocMeta.refCol.title]: id,
})),
);
if (assocTableData.length >= ASSOC_BULK_DATA_BATCH_SIZE) {
let insertArray = assocTableData.splice(0, assocTableData.length);
logBasic(
`:: Importing '${
table.title
}' LTAR data :: ${importedCount} - ${Math.min(
importedCount + ASSOC_BULK_DATA_BATCH_SIZE,
insertArray.length,
)}`,
);
await services.bulkDataService.bulkDataInsert({
projectName,
tableName: assocMeta.modelMeta.title,
body: insertArray,
cookie: {},
});
importedCount += insertArray.length;
insertArray = [];
}
activeProcess--;
if (activeProcess < BULK_PARALLEL_PROCESS) readable.resume();
resolve(true);
}),
);
});
readable.on('end', async () => {
await Promise.all(promises);
if (assocTableData.length >= 0) {
logBasic(
`:: Importing '${
table.title
}' LTAR data :: ${importedCount} - ${Math.min(
importedCount + ASSOC_BULK_DATA_BATCH_SIZE,
assocTableData.length,
)}`,
);
await services.bulkDataService.bulkDataInsert({
projectName,
tableName: assocMeta.modelMeta.title,
body: assocTableData,
cookie: {},
});
importedCount += assocTableData.length;
assocTableData = [];
}
resolve(true);
});
});
nestedLinkCnt += importedCount;
}
return nestedLinkCnt;
}

31
packages/nocodb/src/modules/jobs/at-import/helpers/syncMap.ts

@ -0,0 +1,31 @@
export const mapTbl = {};
// static mapping records between aTblId && ncId
export const addToMappingTbl = function addToMappingTbl(
aTblId,
ncId,
ncName,
parent?,
) {
mapTbl[aTblId] = {
ncId: ncId,
ncParent: parent,
// name added to assist in quick debug
ncName: ncName,
};
};
// get NcID from airtable ID
export const getNcIdFromAtId = function getNcIdFromAtId(aId) {
return mapTbl[aId]?.ncId;
};
// get nc Parent from airtable ID
export const getNcParentFromAtId = function getNcParentFromAtId(aId) {
return mapTbl[aId]?.ncParent;
};
// get nc-title from airtable ID
export const getNcNameFromAtId = function getNcNameFromAtId(aId) {
return mapTbl[aId]?.ncName;
};

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

@ -0,0 +1,126 @@
import { InjectQueue } from '@nestjs/bull';
import {
Body,
Controller,
HttpCode,
Param,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { Queue } from 'bull';
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 { QueueService } from '../fallback-queue.service';
import { JOBS_QUEUE, JobTypes } from '../../../interface/Jobs';
@Controller()
@UseGuards(ExtractProjectIdMiddleware, GlobalGuard)
export class DuplicateController {
activeQueue;
constructor(
@InjectQueue(JOBS_QUEUE) private readonly jobsQueue: Queue,
private readonly fallbackQueueService: QueueService,
private readonly projectsService: ProjectsService,
) {
this.activeQueue = process.env.NC_REDIS_URL
? this.jobsQueue
: this.fallbackQueueService;
}
@Post('/api/v1/db/meta/duplicate/:projectId/:baseId?')
@HttpCode(200)
@Acl('duplicateBase')
async duplicateBase(
@Request() req,
@Param('projectId') projectId: string,
@Param('baseId') baseId?: string,
) {
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.activeQueue.add(JobTypes.DuplicateBase, {
projectId: project.id,
baseId: base.id,
dupProjectId: dupProject.id,
req: {
user: req.user,
clientIp: req.clientIp,
},
});
return { id: job.id, name: job.name };
}
@Post('/api/v1/db/meta/duplicate/:projectId/model/:modelId')
@HttpCode(200)
@Acl('duplicateModel')
async duplicateModel(
@Request() req,
@Param('projectId') projectId: string,
@Param('modelId') modelId?: string,
) {
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.activeQueue.add(JobTypes.DuplicateModel, {
projectId: project.id,
baseId: base.id,
modelId: model.id,
req: {
user: req.user,
clientIp: req.clientIp,
},
title: uniqueTitle,
});
return { id: job.id, name: job.name };
}
}

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

@ -0,0 +1,562 @@
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 } = job.data;
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),
});
elapsedTime(hrTime, 'serializeModels');
if (!exportedModels) {
throw new Error(`Export failed for base '${base.id}'`);
}
await dupProject.getBases();
const dupBase = dupProject.bases[0];
elapsedTime(hrTime, 'projectCreate');
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}'`);
}
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 } = job.data;
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],
})
)[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}'`);
}
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');
// console.log('exportedModel', exportedModel);
}
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) {
console.log('chunk', chunk);
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}`);
}
}
}
}

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

@ -0,0 +1,645 @@
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, 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[] }) {
const { modelIds } = param;
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();
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({
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,
})),
});
}
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.fileCreateByStream(
`${destPath}/schema.json`,
readableStream,
);
const handledMmList: string[] = [];
const combinedLinkStream = new Readable({
read() {},
});
const uploadLinkPromise = storageAdapter.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.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,
};
}
}

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

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import PQueue from 'p-queue';
import Emittery from 'emittery';
import { 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 queueIndex = 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('active', (data: any) => {
const job = this.queueMemory.find(
(job) => job.id === data.id && job.name === data.name,
);
job.status = 'active';
this.jobsEventService.onActive.apply(this.jobsEventService, [job as any]);
});
this.emitter.on('completed', (data: any) => {
const job = this.queueMemory.find(
(job) => job.id === data.id && job.name === data.name,
);
job.status = 'completed';
this.jobsEventService.onCompleted.apply(this.jobsEventService, [
data as any,
]);
});
this.emitter.on('failed', (data: { job: Job; error: Error }) => {
const job = this.queueMemory.find(
(job) => job.id === data.job.id && job.name === data.job.name,
);
job.status = 'failed';
this.jobsEventService.onFailed.apply(this.jobsEventService, [
data.job as any,
data.error,
]);
});
}
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('active', job);
try {
await this.jobMap[job.name].fn.apply(this.jobMap[job.name].this, [job]);
this.emitter.emit('completed', job);
} catch (error) {
this.emitter.emit('failed', { job, error });
}
}
get emitter() {
return QueueService.emitter;
}
get queue() {
return QueueService.queue;
}
get queueMemory() {
return QueueService.queueMemory;
}
get queueIndex() {
return QueueService.queueIndex;
}
set queueIndex(index: number) {
QueueService.queueIndex = index;
}
async add(name: string, data: any) {
const id = `${this.queueIndex++}`;
const job = { id: `${id}`, name, status: 'waiting', data };
this.queueMemory.push(job);
this.queue.add(() => this.jobWrapper(job));
return { id, name };
}
async getJobs(types: string[] | string) {
types = Array.isArray(types) ? types : [types];
return this.queueMemory.filter((q) => types.includes(q.status));
}
async getJob(id: string) {
return this.queueMemory.find((q) => q.id === id);
}
}

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

@ -0,0 +1,62 @@
import {
OnQueueActive,
OnQueueCompleted,
OnQueueFailed,
Processor,
} from '@nestjs/bull';
import { Job } from 'bull';
import boxen from 'boxen';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { JOBS_QUEUE } from '../../interface/Jobs';
@Processor(JOBS_QUEUE)
export class JobsEventService {
constructor(private eventEmitter: EventEmitter2) {}
@OnQueueActive()
onActive(job: Job) {
this.eventEmitter.emit('job.status', {
name: job.name,
id: job.id.toString(),
status: '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('job.status', {
name: job.name,
id: job.id.toString(),
status: 'failed',
error: error?.message,
});
}
@OnQueueCompleted()
onCompleted(job: Job) {
this.eventEmitter.emit('job.status', {
name: job.name,
id: job.id.toString(),
status: 'completed',
});
}
sendLog(job: Job, data: { message: string }) {
this.eventEmitter.emit('job.log', {
name: job.name,
id: job.id.toString(),
data,
});
}
}

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

@ -0,0 +1,126 @@
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 { JobsService } from './jobs.service';
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('job.status')
async sendJobStatus(data: {
name: string;
id: string;
status:
| 'completed'
| 'waiting'
| 'active'
| 'delayed'
| 'failed'
| 'paused'
| 'refresh';
error?: any;
}): Promise<void> {
this.server.to(`${data.name}-${data.id}`).emit('status', {
id: data.id,
name: data.name,
status: data.status,
error: data.error,
});
}
@OnEvent('job.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 {}

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

@ -0,0 +1,52 @@
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { JOBS_QUEUE } from '../../interface/Jobs';
import { QueueService } from './fallback-queue.service';
@Injectable()
export class JobsService {
activeQueue;
constructor(
@InjectQueue(JOBS_QUEUE) private readonly jobsQueue: Queue,
private readonly fallbackQueueService: QueueService,
) {
this.activeQueue = 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(['active', 'waiting', 'delayed', 'paused'])
).filter((j) => j.name === jobType);
}
async getJobWithData(data: any) {
const jobs = await this.activeQueue.getJobs([
// 'completed',
'waiting',
'active',
'delayed',
// 'failed',
'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;
}
}

19
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 { DatasModule } from '../datas/datas.module';
GridColumnsController,
GridsController,
HooksController,
ImportController,
KanbansController,
MapsController,
MetaDiffsController,
@ -156,5 +154,22 @@ import { DatasModule } from '../datas/datas.module';
SharedBasesService,
BulkDataAliasService,
],
exports: [
TablesService,
ColumnsService,
FiltersService,
SortsService,
ViewsService,
ViewColumnsService,
GridsService,
GridColumnsService,
FormsService,
FormColumnsService,
GalleriesService,
KanbansService,
ProjectsService,
AttachmentsService,
ProjectUsersService,
],
})
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);
}

7
packages/nocodb/src/run/docker.ts

@ -17,6 +17,13 @@ server.set('view engine', 'ejs');
process.env[`DEBUG`] = 'xc*';
// (async () => {
// await nocobuild(server);
// const httpServer = server.listen(process.env.PORT || 8080, async () => {
// console.log('Server started');
// });
// })().catch((e) => console.log(e));
(async () => {
const httpServer = server.listen(process.env.PORT || 8080, async () => {
server.use(await Noco.init({}, httpServer, server));

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

@ -2100,6 +2100,111 @@
]
}
},
"/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"
}
},
"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"
}
},
"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": [
{
@ -18255,6 +18360,11 @@
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"status": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Project Status",
"example": "locked"
}
},
"required": ["title"],
@ -18298,6 +18408,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 (

19
packages/nocodb/src/services/socket.gateway.spec.ts

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

67
packages/nocodb/src/services/socket.gateway.ts

@ -0,0 +1,67 @@
import crypto from 'crypto';
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 { JwtStrategy } from '../strategies/jwt.strategy';
import type { OnModuleInit } from '@nestjs/common';
import type { Socket } from 'socket.io';
function getHash(str) {
return crypto.createHash('md5').update(str).digest('hex');
}
@WebSocketGateway({
cors: {
origin: '*',
allowedHeaders: ['xc-auth'],
credentials: true,
},
})
@Injectable()
export class SocketGateway implements OnModuleInit {
// private server: HttpServer;
private clients: { [id: string]: Socket } = {};
constructor(
private jwtStrategy: JwtStrategy,
@Inject(HttpAdapterHost) private httpAdapterHost: HttpAdapterHost,
) {}
@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();
})
.on('connection', (socket) => {
this.clients[socket.id] = socket;
const id = getHash(
(process.env.NC_SERVER_UUID || T.id) +
(socket?.handshake as any)?.user?.id,
);
socket.on('page', (args) => {
T.page({ ...args, id });
});
socket.on('event', (args) => {
T.event({ ...args, id });
});
});
}
public get io() {
return this.server;
}
}

12
packages/nocodb/src/services/socket.service.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

@ -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;
}
}

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",

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: 'Yes' }).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 });
}
}
};

116
tests/playwright/tests/db/projectOperations.spec.ts

@ -1,9 +1,13 @@
import { test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../../pages/Dashboard';
import { airtableApiBase, airtableApiKey } from '../../constants';
import { quickVerify } from '../../quickTests/commonTest';
import setup from '../../setup';
import { ToolbarPage } from '../../pages/Dashboard/common/Toolbar';
import { ProjectsPage } from '../../pages/ProjectsPage';
import { Api } from 'nocodb-sdk';
import { ProjectInfo, ProjectInfoOperator } from '../utils/projectInfoOperator';
import { deepCompare } from '../utils/objectCompareUtil';
test.describe('Project operations', () => {
let dashboard: DashboardPage;
@ -11,8 +15,23 @@ test.describe('Project operations', () => {
let context: any;
let api: Api<any>;
let projectPage: ProjectsPage;
test.setTimeout(70000);
async function deleteIfExists(name: string) {
try {
const projectList = await api.project.list();
const project = projectList.list.find((p: any) => p.title === name);
if (project) {
await api.project.delete(project.id);
console.log('deleted project: ', project.id);
}
} catch (e) {
console.log('Error: ', e);
}
}
test.beforeEach(async ({ page }) => {
page.setDefaultTimeout(70000);
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
projectPage = new ProjectsPage(page);
@ -28,16 +47,7 @@ test.describe('Project operations', () => {
test('rename, delete', async () => {
// if project already exists, delete it
try {
const projectList = await api.project.list();
const project = projectList.list.find((p: any) => p.title === 'project-firstName');
if (project) {
await api.project.delete(project.id);
console.log('deleted project: ', project.id);
}
} catch (e) {
console.log('Error: ', e);
}
await deleteIfExists('project-firstName');
await dashboard.clickHome();
await projectPage.createProject({ name: 'project-firstName', withoutPrefix: true });
@ -52,4 +62,88 @@ test.describe('Project operations', () => {
await dashboard.clickHome();
await projectPage.deleteProject({ title: 'project-rename', withoutPrefix: true });
});
test('project_duplicate', async () => {
// if project already exists, delete it to avoid test failures due to residual data
const testProjectName = 'project-to-imexp';
const dupeProjectName: string = testProjectName + ' copy';
await deleteIfExists(testProjectName);
await deleteIfExists(dupeProjectName);
// // data creation for orginial test project
await createTestProjectWithData();
// create duplicate
await dashboard.clickHome();
await projectPage.duplicateProject({ name: testProjectName, withoutPrefix: true });
await projectPage.openProject({ title: dupeProjectName, withoutPrefix: true });
await quickVerify({ dashboard, airtableImport: true, context });
// compare
const projectList = await api.project.list();
const testProjectId = await projectList.list.find((p: any) => p.title === testProjectName);
const dupeProjectId = await projectList.list.find((p: any) => p.title === dupeProjectName);
const projectInfoOp: ProjectInfoOperator = new ProjectInfoOperator(context.token);
const orginal: Promise<ProjectInfo> = projectInfoOp.extractProjectData(testProjectId.id);
const duplicate: Promise<ProjectInfo> = projectInfoOp.extractProjectData(dupeProjectId.id);
await Promise.all([orginal, duplicate]).then(arr => {
// TODO: support providing full json path instead of just last field name
const ignoredFields: Set<string> = new Set([
'id',
'prefix',
'project_id',
'fk_view_id',
'ptn',
'base_id',
'table_name',
'fk_model_id',
'fk_column_id',
'fk_cover_image_col_id',
// potential bugs
'created_at',
'updated_at',
]);
const ignoredKeys: Set<string> = new Set([
'.project.is_meta.title',
// below are potential bugs
'.project.is_meta.title.status',
'.project.tables.0.table.shares.views.0.view._ptn.ptype.tn',
'.project.tables.0.table.shares.views.0.view._ptn.ptype.tn._tn',
'.project.tables.0.table.shares.views.0.view._ptn.ptype.tn._tn.table_meta.title',
'.project.tables.0.1.table.shares.views.0.view._ptn.ptype.tn',
'.project.tables.0.1.table.shares.views.0.view._ptn.ptype.tn._tn',
'.project.tables.0.1.table.shares.views.0.view._ptn.ptype.tn._tn.table_meta.title',
'.project.tables.0.1.2.table.shares.views.0.view._ptn.ptype.tn',
'.project.tables.0.1.2.table.shares.views.0.view._ptn.ptype.tn._tn',
'.project.tables.0.1.2.table.shares.views.0.view._ptn.ptype.tn._tn.table_meta.title',
'.project.tables.bases.0.alias.config',
'.project.tables.bases.users.0.1.email.invite_token.main_roles.roles',
'.project.tables.bases.users.0.1.2.email.invite_token.main_roles.roles',
]);
const orginalProjectInfo: ProjectInfo = arr[0];
const duplicateProjectInfo: ProjectInfo = arr[1];
expect(deepCompare(orginalProjectInfo, duplicateProjectInfo, ignoredFields, ignoredKeys)).toBeTruthy();
});
// cleanup test-data
await cleanupTestData();
async function createTestProjectWithData() {
await dashboard.clickHome();
await projectPage.createProject({ name: testProjectName, withoutPrefix: true });
await dashboard.treeView.quickImport({ title: 'Airtable' });
await dashboard.importAirtable.import({
key: airtableApiKey,
baseId: airtableApiBase,
});
await dashboard.rootPage.waitForTimeout(1000);
await quickVerify({ dashboard, airtableImport: true, context });
}
async function cleanupTestData() {
await dashboard.clickHome();
await projectPage.deleteProject({ title: dupeProjectName, withoutPrefix: true });
await projectPage.deleteProject({ title: testProjectName, withoutPrefix: true });
}
});
});

68
tests/playwright/tests/utils/objectCompareUtil.ts

@ -0,0 +1,68 @@
/**
* Compare obj1 and obj2 conditionally based on ignoredFields set
* Ignore the field names which are passed in the ignoredFields.
* optionally keyId will be use to prefix the keys mismatched
*
*
* use utility boolean param breakAtFirstMismatch to print diff for
* all the fields instead of breaking at first mismatch
*
* @param obj1
* @param obj2
* @param ignoredFields : filed names ex: title
* @param ignoredKeys : json path for the filed ex: ".project.is_meta.title"
* @param keyId : starts with ""
* @param breakAtFirstMismatch : default true. returns false on first field mismatch
* @returns
*/
export function deepCompare(
obj1: any,
obj2: any,
ignoredFields?: Set<string>,
ignoredKeys?: Set<string>,
keyId = '',
breakAtFirstMismatch = true
): boolean {
if (ignoredKeys !== undefined && ignoredKeys.has(keyId)) {
return true;
}
// If the objects are the same instance, they are equal
if (obj1 === obj2) {
return true;
}
// If one of the objects is null or not an object, they are not equal
if (!obj1 || !obj2 || typeof obj1 !== 'object' || typeof obj2 !== 'object') {
console.log(`Mismatch key: ${keyId} value1: "${obj1}" value2: "${obj2}"`);
return !breakAtFirstMismatch;
// return false;
}
// If the objects have different numbers of properties, they are not equal
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
console.log(`Mismatch length key: ${keyId} value1: "${obj1}" value2: "${obj2}"`);
return !breakAtFirstMismatch;
// return false;
}
// Recursively compare each property of the objects
for (const key of keys1) {
if (
(ignoredFields !== undefined && ignoredFields.has(key)) ||
key.endsWith(' List') /* temp hack to avoid fields like */
) {
// console.log(`${keyId} ignored in comparison`)
} else {
keyId = keyId + '.' + key;
if (!deepCompare(obj1[key], obj2[key], ignoredFields, ignoredKeys, keyId, breakAtFirstMismatch)) {
return !breakAtFirstMismatch;
// return false;
}
}
}
// If all properties match, the objects are equal
return true;
}

167
tests/playwright/tests/utils/projectInfoOperator.ts

@ -0,0 +1,167 @@
import {
Api,
BaseListType,
BaseType,
FilterListType,
FilterType,
HookListType,
HookType,
PaginatedType,
ProjectType,
SharedViewListType,
SharedViewType,
SignInReqType,
SortListType,
SortType,
TableListType,
TableType,
UserType,
ViewListType,
ViewType,
} from 'nocodb-sdk';
export class ViewInfo {
view: ViewType;
filters: FilterType[];
sorts: SortType[];
firstPageData?: {
/** List of data objects */
list: any[];
/** Paginated Info */
pageInfo: PaginatedType;
};
}
export class TableInfo {
table: TableType;
views: ViewInfo[];
shares: SharedViewType[];
webhooks: HookType[];
firstPageData?: {
/** List of data objects */
list: any[];
/** Paginated Info */
pageInfo: PaginatedType;
};
}
export class ProjectInfo {
project: ProjectType;
bases: BaseType[];
users: UserType[];
tables: TableInfo[];
}
export class ProjectInfoOperator {
api: Api<any>;
constructor(token: string) {
this.api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': token,
},
});
}
/**
* extracts the projectInfo using sdk via apis
*
* @param projectId
* @returns
*/
async extractProjectData(projectId: string): Promise<ProjectInfo> {
// TODO: capture apiTokens, projectSettings, ACLVisibilityRules, UI ACL (discuss before adding)
const project: ProjectType = await this.api.project.read(projectId);
// bases
const bases: BaseListType = await this.api.base.list(projectId);
// users
const usersWrapper: any = await this.api.auth.projectUserList(projectId);
// SET project, users and bases
const projectInfo: ProjectInfo = { project: project, tables: [], bases: [], users: [] };
projectInfo.bases = bases.list;
if (usersWrapper.users) {
projectInfo.users = usersWrapper.users.list as UserType[];
}
const tables: TableListType = await this.api.dbTable.list(projectId);
for (const table of tables.list) {
const tableInfo: TableInfo = { table: table, shares: [], views: [], webhooks: [] };
const views: ViewListType = await this.api.dbView.list(table.id);
for (const v of views.list) {
const filters: FilterListType = await this.api.dbTableFilter.read(v.id);
const sorts: SortListType = await this.api.dbTableSort.list(v.id);
// create ViewData and push to array
const viewInfo: ViewInfo = { view: v, filters: [], sorts: [] };
viewInfo.firstPageData = await this.api.dbViewRow.list('noco', projectId, table.id, v.id);
viewInfo.filters = filters.list;
viewInfo.sorts = sorts.list;
tableInfo.views.push(viewInfo);
}
const shares: SharedViewListType = await this.api.dbViewShare.list(table.id);
const webhooks: HookListType = await this.api.dbTableWebhook.list(table.id);
tableInfo.shares = shares.list;
tableInfo.webhooks = webhooks.list;
projectInfo.tables.push(tableInfo);
tableInfo.firstPageData = await this.api.dbTableRow.list('noco', projectId, table.id);
}
return projectInfo;
}
/**
* helper function to print projectInfo
* do not use this function to assert anything.
* this is only helper function to debug and should
* be allowed to modify without any test failures.
*
* @param projectData
*/
async printProjectData(projectData: ProjectInfo) {
console.log('project.title : ' + projectData.project.title);
// bases
console.log('Bases:');
for (const base of projectData.bases) {
console.log(base.id);
}
// users
console.log('Users:');
if (projectData.users) {
for (const user of projectData.users) {
console.log(user.email);
}
}
console.log('Tables: ');
if (projectData.tables) {
for (const tableData of projectData.tables) {
console.log('Table: ' + tableData.table.title);
console.log('Views: ');
console.log('Filters: ');
for (const viewData of tableData.views) {
const v: ViewType = viewData.view;
console.log(`${v.title} ${v.id}`);
if (viewData.filters.length > 0) {
console.log('======= Filters =======');
console.log(viewData.filters);
}
if (viewData.sorts.length > 0) {
console.log('======= Sorts =======');
console.log(viewData.sorts);
}
}
if (tableData.shares.length > 0) {
console.log('======= Shares =======');
console.log(tableData.shares.forEach(s => console.log(s.uuid)));
}
if (tableData.webhooks.length > 0) {
console.log('======= Webhooks =======');
console.log(tableData.webhooks.forEach(w => console.log(w.id)));
}
}
}
}
}
Loading…
Cancel
Save