Browse Source

feat: extensions (#8303)

* feat: extensions

Signed-off-by: mertmit <mertmit99@gmail.com>

* chore: sync

Signed-off-by: mertmit <mertmit99@gmail.com>

---------

Signed-off-by: mertmit <mertmit99@gmail.com>
pull/8326/head
Mert E 2 months ago committed by GitHub
parent
commit
d46b60d92e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 42
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  2. 2
      packages/nc-gui/components/dlg/QuickImport.vue
  3. 110
      packages/nc-gui/components/extensions/Details.vue
  4. 231
      packages/nc-gui/components/extensions/Extension.vue
  5. 72
      packages/nc-gui/components/extensions/Market.vue
  6. 59
      packages/nc-gui/components/extensions/Pane.vue
  7. 18
      packages/nc-gui/components/extensions/Wrapper.vue
  8. 4
      packages/nc-gui/components/general/Modal.vue
  9. 9
      packages/nc-gui/components/smartsheet/Pagination.vue
  10. 13
      packages/nc-gui/components/smartsheet/Topbar.vue
  11. 2
      packages/nc-gui/components/smartsheet/calendar/SideMenu.vue
  12. 2
      packages/nc-gui/components/smartsheet/grid/index.vue
  13. 92
      packages/nc-gui/components/tabs/Smartsheet.vue
  14. 2
      packages/nc-gui/components/template/Editor.vue
  15. 224
      packages/nc-gui/composables/useExtensionHelper.ts
  16. 394
      packages/nc-gui/composables/useExtensions.ts
  17. 1
      packages/nc-gui/lib/enums.ts
  18. 29
      packages/nc-gui/store/workspace.ts
  19. 27
      packages/nc-gui/utils/baseCreateUtils.ts
  20. 2
      packages/nc-gui/utils/iconUtils.ts
  21. 4
      packages/nocodb-sdk/src/lib/enums.ts
  22. 820
      packages/nocodb-sdk/src/lib/sqlUi/DatabricksUi.ts
  23. 5
      packages/nocodb-sdk/src/lib/sqlUi/SqlUiFactory.ts
  24. 21
      packages/nocodb/src/controllers/extensions.controller.spec.ts
  25. 80
      packages/nocodb/src/controllers/extensions.controller.ts
  26. 18
      packages/nocodb/src/db/BaseModelSqlv2.ts
  27. 26
      packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts
  28. 44
      packages/nocodb/src/db/functionMappings/databricks.ts
  29. 4
      packages/nocodb/src/db/mapFunctionName.ts
  30. 509
      packages/nocodb/src/db/sql-mgr/code/models/xc/ModelXcMetaDatabricks.ts
  31. 3
      packages/nocodb/src/db/sql-mgr/code/models/xc/ModelXcMetaFactory.ts
  32. 8
      packages/nocodb/src/db/sql-migrator/lib/KnexMigratorv2.ts
  33. 1
      packages/nocodb/src/helpers/PagedResponse.ts
  34. 14
      packages/nocodb/src/helpers/columnHelpers.ts
  35. 8
      packages/nocodb/src/helpers/populateMeta.ts
  36. 3
      packages/nocodb/src/helpers/syncMigration.ts
  37. 3
      packages/nocodb/src/meta/meta.service.ts
  38. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  39. 30
      packages/nocodb/src/meta/migrations/v2/nc_045_extensions.ts
  40. 4
      packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts
  41. 3
      packages/nocodb/src/models/Base.ts
  42. 161
      packages/nocodb/src/models/Extension.ts
  43. 10
      packages/nocodb/src/models/Source.ts
  44. 1
      packages/nocodb/src/models/index.ts
  45. 7
      packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts
  46. 3
      packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts
  47. 29
      packages/nocodb/src/modules/jobs/jobs/source-create/source-create.processor.ts
  48. 4
      packages/nocodb/src/modules/metas/metas.module.ts
  49. 9
      packages/nocodb/src/schema/swagger-v2.json
  50. 253
      packages/nocodb/src/schema/swagger.json
  51. 3
      packages/nocodb/src/services/app-hooks/app-hooks.service.ts
  52. 10
      packages/nocodb/src/services/columns.service.ts
  53. 19
      packages/nocodb/src/services/extensions.service.spec.ts
  54. 74
      packages/nocodb/src/services/extensions.service.ts
  55. 39
      packages/nocodb/src/services/sources.service.ts
  56. 13
      packages/nocodb/src/services/tables.service.ts
  57. 10
      packages/nocodb/src/utils/acl.ts
  58. 3
      packages/nocodb/src/utils/globals.ts
  59. 2
      packages/nocodb/src/utils/nc-config/constants.ts

42
packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue

@ -8,8 +8,8 @@ import {
ClientType,
ProjectIdInj,
SSLUsage,
clientTypes as _clientTypes,
baseTitleValidator,
clientTypes,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
@ -57,6 +57,12 @@ const { t } = useI18n()
const editingSource = ref(false)
const clientTypes = computed(() => {
return _clientTypes.filter((type) => {
return ![ClientType.SNOWFLAKE, ClientType.DATABRICKS].includes(type.value)
})
})
const formState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
@ -437,6 +443,40 @@ onMounted(async () => {
</a-form-item>
</template>
<template v-else-if="formState.dataSource.client === ClientType.DATABRICKS">
<a-form-item label="Token" v-bind="validateInfos['dataSource.connection.token']">
<a-input
v-model:value="(formState.dataSource.connection as DatabricksConnection).token"
class="nc-extdb-host-token"
/>
</a-form-item>
<a-form-item label="Host" v-bind="validateInfos['dataSource.connection.host']">
<a-input
v-model:value="(formState.dataSource.connection as DatabricksConnection).host"
class="nc-extdb-host-address"
/>
</a-form-item>
<a-form-item label="Path" v-bind="validateInfos['dataSource.connection.path']">
<a-input v-model:value="(formState.dataSource.connection as DatabricksConnection).path" class="nc-extdb-host-path" />
</a-form-item>
<a-form-item label="Database" v-bind="validateInfos['dataSource.connection.database']">
<a-input
v-model:value="(formState.dataSource.connection as DatabricksConnection).database"
class="nc-extdb-host-database"
/>
</a-form-item>
<a-form-item label="Schema" v-bind="validateInfos['dataSource.connection.schema']">
<a-input
v-model:value="(formState.dataSource.connection as DatabricksConnection).schema"
class="nc-extdb-host-schema"
/>
</a-form-item>
</template>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">

2
packages/nc-gui/components/dlg/QuickImport.vue

@ -146,7 +146,7 @@ const importMeta = computed(() => {
const dialogShow = useVModel(rest, 'modelValue', emit)
// watch dialogShow to init or terminate worker
if (isWorkerSupport) {
if (isWorkerSupport && process.env.NODE_ENV === 'production') {
watch(
dialogShow,
async (val) => {

110
packages/nc-gui/components/extensions/Details.vue

@ -0,0 +1,110 @@
<script lang="ts" setup>
import { useVModel } from '#imports'
interface Prop {
modelValue: boolean
extensionId: string
from: 'market' | 'extension'
}
const props = defineProps<Prop>()
const emit = defineEmits(['update:modelValue'])
const vModel = useVModel(props, 'modelValue', emit)
const { availableExtensions, addExtension, getExtensionIcon, isMarketVisible } = useExtensions()
const onBack = () => {
vModel.value = false
isMarketVisible.value = true
}
const onAddExtension = (ext: any) => {
addExtension(ext)
vModel.value = false
}
const activeExtension = computed(() => {
return availableExtensions.value.find((ext) => ext.id === props.extensionId)
})
</script>
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '864px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="from === 'extension'"
:footer="null"
:width="1280"
size="medium"
wrap-class-name="nc-modal-extension-market"
>
<div v-if="activeExtension" class="flex flex-col w-full h-full">
<div v-if="from === 'market'" class="h-[40px] flex items-start">
<div class="flex items-center gap-2 pr-2 pb-2 cursor-pointer hover:text-primary" @click="onBack">
<GeneralIcon icon="ncArrowLeft" />
<span>Back</span>
</div>
</div>
<div v-else class="h-[40px]"></div>
<div class="extension-details">
<div class="extension-details-left">
<div class="flex">
<img :src="getExtensionIcon(activeExtension.iconUrl)" alt="icon" class="h-[90px]" />
<div class="flex flex-col p-4">
<div class="font-weight-700 text-2xl">{{ activeExtension.title }}</div>
</div>
</div>
<div class="p-4">
<div class="whitespace-pre-line">{{ activeExtension.description }}</div>
</div>
</div>
<div class="extension-details-right">
<NcButton class="w-full" @click="onAddExtension(activeExtension)">
<div class="flex items-center justify-center">Add Extension</div>
</NcButton>
<div class="flex flex-col gap-1">
<div class="text-md font-weight-600">Version</div>
<div>{{ activeExtension.version }}</div>
</div>
<div class="flex flex-col gap-1">
<div v-if="activeExtension.publisherName" class="text-md font-weight-600">Publisher</div>
<div>{{ activeExtension.publisherName }}</div>
</div>
<div v-if="activeExtension.publisherEmail" class="flex flex-col gap-1">
<div class="text-md font-weight-600">Publisher Email</div>
<div>
<a :href="`mailto:${activeExtension.publisherEmail}`" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherEmail }}
</a>
</div>
</div>
<div v-if="activeExtension.publisherUrl" class="flex flex-col gap-1">
<div class="text-md font-weight-600">Publisher Website</div>
<div>
<a :href="activeExtension.publisherUrl" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherUrl }}
</a>
</div>
</div>
</div>
</div>
</div>
</NcModal>
</template>
<style lang="scss" scoped>
.extension-details {
@apply flex w-full h-full;
.extension-details-left {
@apply flex flex-col w-3/4 p-2;
}
.extension-details-right {
@apply w-1/4 p-2 flex flex-col gap-4;
}
}
</style>

231
packages/nc-gui/components/extensions/Extension.vue

@ -0,0 +1,231 @@
<script setup lang="ts">
interface Prop {
extensionId: string
error?: any
}
const { extensionId, error } = defineProps<Prop>()
const { extensionList, extensionsLoaded, availableExtensions, getExtensionIcon, duplicateExtension, showExtensionDetails } =
useExtensions()
const activeError = ref(error)
const extensionModalRef = ref<HTMLElement>()
const extension = computed(() => {
const ext = extensionList.value.find((ext) => ext.id === extensionId)
if (!ext) {
throw new Error('Extension not found')
}
return ext
})
const titleInput = ref<HTMLInputElement | null>(null)
const titleEditMode = ref<boolean>(false)
const tempTitle = ref<string>(extension.value.title)
const enableEditMode = () => {
titleEditMode.value = true
tempTitle.value = extension.value.title
nextTick(() => {
titleInput.value?.focus()
titleInput.value?.select()
titleInput.value?.scrollIntoView()
})
}
const updateExtensionTitle = async () => {
await extension.value.setTitle(tempTitle.value)
titleEditMode.value = false
}
const { fullscreen, collapsed } = useProvideExtensionHelper(extension)
const component = ref<any>(null)
const extensionManifest = ref<any>(null)
onMounted(() => {
until(extensionsLoaded)
.toMatch((v) => v)
.then(() => {
extensionManifest.value = availableExtensions.value.find((ext) => ext.id === extension.value.extensionId)
if (!extensionManifest) {
return
}
import(`../../extensions/${extensionManifest.value.entry}/index.vue`).then((mod) => {
component.value = markRaw(mod.default)
})
})
.catch((err) => {
if (!extensionManifest.value) {
activeError.value = 'There was an error loading the extension'
return
}
activeError.value = err
})
})
// close fullscreen on escape key press
useEventListener('keydown', (e) => {
if (e.key === 'Escape') {
fullscreen.value = false
}
})
// close fullscreen on clicking extensionModalRef directly
const closeFullscreen = (e: MouseEvent) => {
if (e.target === extensionModalRef.value) {
fullscreen.value = false
}
}
</script>
<template>
<div class="w-full p-2">
<div class="extension-wrapper">
<div class="extension-header">
<div class="extension-header-left">
<GeneralIcon icon="drag" />
<img v-if="extensionManifest" :src="getExtensionIcon(extensionManifest.iconUrl)" alt="icon" class="h-6" />
<input
v-if="titleEditMode"
ref="titleInput"
v-model="tempTitle"
class="flex-grow leading-1 outline-0 ring-none capitalize !text-inherit !bg-transparent w-4/5"
@click.stop
@keyup.enter="updateExtensionTitle"
@keyup.esc="updateExtensionTitle"
@blur="updateExtensionTitle"
/>
<div v-else class="extension-title" @dblclick="enableEditMode">{{ extension.title }}</div>
</div>
<div class="extension-header-right">
<GeneralIcon v-if="!activeError" icon="expand" @click="fullscreen = true" />
<NcDropdown :trigger="['click']">
<GeneralIcon icon="threeDotVertical" />
<template #overlay>
<NcMenu>
<template v-if="!activeError">
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="enableEditMode">
<GeneralIcon icon="edit" />
Rename
</NcMenuItem>
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="duplicateExtension(extension.id)">
<GeneralIcon icon="duplicate" />
Duplicate
</NcMenuItem>
<NcMenuItem
data-rec="true"
class="!hover:text-primary"
@click="showExtensionDetails(extension.extensionId, 'extension')"
>
<GeneralIcon icon="info" />
Details
</NcMenuItem>
<NcDivider />
</template>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="extension.clear()">
<GeneralIcon icon="reload" />
Clear Data
</NcMenuItem>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="extension.delete()">
<GeneralIcon icon="delete" />
Delete
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<GeneralIcon v-if="collapsed" icon="arrowUp" @click="collapsed = !collapsed" />
<GeneralIcon v-else icon="arrowDown" @click="collapsed = !collapsed" />
</div>
</div>
<template v-if="activeError">
<div v-show="!collapsed" class="extension-content">
<a-result status="error" title="Extension Error">
<template #subTitle>{{ activeError }}</template>
<template #extra>
<NcButton @click="extension.clear()">
<div class="flex items-center gap-2">
<GeneralIcon icon="reload" />
Clear Data
</div>
</NcButton>
<NcButton type="danger" @click="extension.delete()">
<div class="flex items-center gap-2">
<GeneralIcon icon="delete" />
Delete
</div>
</NcButton>
</template>
</a-result>
</div>
</template>
<template v-else>
<Teleport to="body" :disabled="!fullscreen">
<div ref="extensionModalRef" :class="{ 'extension-modal': fullscreen }" @click="closeFullscreen">
<div :class="{ 'extension-modal-content': fullscreen }">
<div
v-if="fullscreen"
class="flex items-center justify-between p-2 bg-gray-100 rounded-t-lg cursor-default h-[40px]"
>
<div class="flex items-center gap-2 text-gray-500 font-weight-600">
<img v-if="extensionManifest" :src="getExtensionIcon(extensionManifest.iconUrl)" alt="icon" class="w-6 h-6" />
<div class="text-sm">{{ extension.title }}</div>
</div>
<GeneralIcon class="cursor-pointer" icon="close" @click="fullscreen = false" />
</div>
<div
v-show="fullscreen || !collapsed"
class="extension-content"
:class="{ 'border-1': !fullscreen, 'h-[calc(100%-40px)]': fullscreen }"
>
<component :is="component" :key="extension.uiKey" />
</div>
</div>
</div>
</Teleport>
</template>
</div>
</div>
</template>
<style scoped lang="scss">
.extension-wrapper {
@apply bg-white rounded-lg p-2 w-full border-1;
}
.extension-header {
@apply flex justify-between mb-2;
.extension-header-left {
@apply flex items-center gap-2;
}
.extension-header-right {
@apply flex items-center gap-4;
}
.extension-title {
@apply font-weight-600;
}
}
.extension-content {
@apply rounded-lg;
}
.extension-modal {
@apply absolute top-0 left-0 z-50 w-full h-full bg-black bg-opacity-50;
.extension-modal-content {
@apply bg-white rounded-lg w-[90%] h-[90vh] mt-[5vh] mx-auto;
}
}
</style>

72
packages/nc-gui/components/extensions/Market.vue

@ -0,0 +1,72 @@
<script lang="ts" setup>
import { useVModel } from '#imports'
interface Prop {
modelValue?: boolean
}
const props = defineProps<Prop>()
const emit = defineEmits(['update:modelValue'])
const vModel = useVModel(props, 'modelValue', emit)
const { availableExtensions, addExtension, getExtensionIcon, showExtensionDetails } = useExtensions()
const onExtensionClick = (extensionId: string) => {
showExtensionDetails(extensionId)
vModel.value = false
}
const onAddExtension = (ext: any) => {
addExtension(ext)
vModel.value = false
}
</script>
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '864px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="true"
:footer="null"
:width="1280"
size="medium"
wrap-class-name="nc-modal-extension-market"
>
<div class="flex flex-col h-full">
<div class="flex items-center px-4 py-2">
<div class="flex items-center gap-2">
<GeneralIcon icon="puzzle" />
<div class="font-weight-700">Extensions Marketplace</div>
</div>
</div>
<div class="flex flex-col flex-1 px-4 py-2">
<div class="flex flex-wrap gap-4 p-2">
<template v-for="ext of availableExtensions" :key="ext.id">
<div class="flex border-1 rounded-lg p-2 w-[360px] cursor-pointer" @click="onExtensionClick(ext.id)">
<div class="h-[60px] overflow-hidden m-auto">
<img :src="getExtensionIcon(ext.iconUrl)" alt="icon" class="w-full h-full object-cover" />
</div>
<div class="flex flex-grow flex-col ml-3">
<div class="flex justify-between">
<div class="font-weight-600">{{ ext.title }}</div>
<NcButton size="xsmall" @click.stop="onAddExtension(ext)">
<div class="flex items-center gap-1 mx-1">
<GeneralIcon icon="plus" />
Add
</div>
</NcButton>
</div>
<div class="w-[250px] h-[50px] text-xs line-clamp-3">{{ ext.description }}</div>
</div>
</div>
</template>
</div>
</div>
</div>
</NcModal>
</template>
<style lang="scss" scoped></style>

59
packages/nc-gui/components/extensions/Pane.vue

@ -0,0 +1,59 @@
<script setup lang="ts">
import { Pane } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
const { extensionList, isPanelExpanded, isDetailsVisible, detailsExtensionId, detailsFrom, isMarketVisible, extensionPanelSize } =
useExtensions()
const toggleMarket = () => {
isMarketVisible.value = !isMarketVisible.value
}
</script>
<template>
<Pane v-if="isPanelExpanded" :size="extensionPanelSize" class="flex flex-col bg-orange-50">
<div class="flex items-center pl-3 pt-3 font-weight-800 text-orange-500">Extensions</div>
<template v-if="extensionList.length === 0">
<div class="flex items-center flex-col gap-2 w-full nc-scrollbar-md">
<div class="w-[100px] h-[100px] bg-gray-200 rounded-lg mt-[100px]"></div>
<div class="font-weight-700">No extensions added</div>
<div>Add Extensions from the community extensions marketplace</div>
<NcButton @click="toggleMarket">
<div class="flex items-center gap-2 font-weight-600">
<GeneralIcon icon="plus" />
Add Extension
</div>
</NcButton>
</div>
</template>
<template v-else>
<div class="flex w-full items-center justify-between py-2 px-2 bg-orange-50">
<div class="flex flex-grow items-center mr-2">
<a-input type="text" class="!h-8 !px-3 !py-1 !rounded-lg" placeholder="Search Extension">
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
</div>
<NcButton type="ghost" size="small" class="!text-primary !bg-white" @click="toggleMarket">
<div class="flex items-center gap-1 px-1 text-xs">
<GeneralIcon icon="plus" />
Add Extension
</div>
</NcButton>
</div>
<div class="flex items-center flex-col w-full nc-scrollbar-md">
<ExtensionsWrapper v-for="ext in extensionList" :key="ext.id" :extension-id="ext.id" />
</div>
</template>
<ExtensionsMarket v-if="isMarketVisible" v-model="isMarketVisible" />
<ExtensionsDetails
v-if="isDetailsVisible && detailsExtensionId"
v-model="isDetailsVisible"
:extension-id="detailsExtensionId"
:from="detailsFrom"
/>
</Pane>
</template>
<style lang="scss"></style>

18
packages/nc-gui/components/extensions/Wrapper.vue

@ -0,0 +1,18 @@
<script setup lang="ts">
interface Prop {
extensionId: string
}
const { extensionId } = defineProps<Prop>()
</script>
<template>
<NuxtErrorBoundary>
<ExtensionsExtension :extension-id="extensionId" />
<template #error="{ error }">
<ExtensionsExtension :extension-id="extensionId" :error="error" />
</template>
</NuxtErrorBoundary>
</template>
<style scoped lang="scss"></style>

4
packages/nc-gui/components/general/Modal.vue

@ -20,8 +20,8 @@ const props = withDefaults(
const emits = defineEmits(['update:visible'])
const { width: propWidth, destroyOnClose } = props
const { maskClosable, closable, keyboard } = toRefs(props)
const { width: propWidth } = props
const { maskClosable, closable, keyboard, destroyOnClose } = toRefs(props)
const width = computed(() => {
if (propWidth) {

9
packages/nc-gui/components/smartsheet/Pagination.vue

@ -135,7 +135,14 @@ const tempPageVal = ref(page.value)
/>
<div v-else class="mx-auto flex items-center mt-n1" style="max-width: 250px">
<span class="text-xs" style="white-space: nowrap"> Change page:</span>
<a-input v-model:value="tempPageVal" size="small" class="ml-1 !text-xs" type="number" @keydown.enter="changePage(tempPageVal)" @blur="tempPageVal = page">
<a-input
v-model:value="tempPageVal"
size="small"
class="ml-1 !text-xs"
type="number"
@keydown.enter="changePage(tempPageVal)"
@blur="tempPageVal = page"
>
<template #suffix>
<component :is="iconMap.returnKey" class="mt-1" @click="changePage(page)" />
</template>

13
packages/nc-gui/components/smartsheet/Topbar.vue

@ -14,6 +14,8 @@ const { isMobileMode } = storeToRefs(useConfigStore())
const { appInfo } = useGlobal()
const { toggleExtensionPanel, isPanelExpanded, extensionsEgg, onEggClick } = useExtensions()
const isSharedBase = computed(() => route.value.params.typeOrId === 'base')
</script>
@ -36,6 +38,17 @@ const isSharedBase = computed(() => route.value.params.typeOrId === 'base')
<GeneralApiLoader v-if="!isMobileMode" />
<div
v-if="extensionsEgg"
class="flex items-center px-2 py-1 gap-2 border-1 rounded-lg h-8 xs:(h-10 ml-0) ml-1 border-gray-200 cursor-pointer font-weight-600"
:class="{ 'bg-orange-50': isPanelExpanded, 'text-orange-500': isPanelExpanded }"
@click="toggleExtensionPanel"
>
<GeneralIcon icon="puzzle" :class="{ 'border-l-1 border-white': isPanelExpanded }" />
Extensions
</div>
<div v-else class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<LazyGeneralShareProject
v-if="(isForm || isGrid || isKanban || isGallery || isMap || isCalendar) && !isPublic && !isMobileMode"
is-view-toolbar

2
packages/nc-gui/components/smartsheet/calendar/SideMenu.vue

@ -8,7 +8,7 @@ const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits(['expand-record', 'newRecord'])
const emit = defineEmits(['expandRecord', 'newRecord'])
const INFINITY_SCROLL_THRESHOLD = 100

2
packages/nc-gui/components/smartsheet/grid/index.vue

@ -173,7 +173,7 @@ const coreWrapperRef = ref<HTMLElement>()
const viewWidth = ref(0)
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.GROUP_BY_RELOAD) {
if (event === SmartsheetStoreEvents.GROUP_BY_RELOAD || event === SmartsheetStoreEvents.DATA_RELOAD) {
reloadViewDataHook?.trigger()
}
})

92
packages/nc-gui/components/tabs/Smartsheet.vue

@ -1,4 +1,6 @@
<script setup lang="ts">
import { Pane, Splitpanes } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isLinksOrLTAR } from 'nocodb-sdk'
@ -56,6 +58,8 @@ const { isGallery, isGrid, isForm, isKanban, isLocked, isMap, isCalendar } = use
useSqlEditor()
const { isPanelExpanded } = useExtensions()
const reloadViewDataEventHook = createEventHook()
const reloadViewMetaEventHook = createEventHook<void | boolean>()
@ -163,44 +167,94 @@ const onDrop = async (event: DragEvent) => {
watch([activeViewTitleOrId, activeTableId], () => {
handleSidebarOpenOnMobileForNonViews()
})
const { extensionPanelSize } = useExtensions()
const onResize = (sizes: { min: number; max: number; size: number }[]) => {
if (sizes.length === 2) {
if (!sizes[1].size) return
extensionPanelSize.value = sizes[1].size
}
}
</script>
<template>
<div class="nc-container flex flex-col h-full" @drop="onDrop" @dragover.prevent>
<LazySmartsheetTopbar />
<div style="height: calc(100% - var(--topbar-height))">
<div v-if="openedViewsTab === 'view'" class="flex flex-col h-full flex-1 min-w-0">
<LazySmartsheetToolbar v-if="!isForm" />
<div class="flex flex-row w-full" :style="{ height: isForm ? '100%' : 'calc(100% - var(--topbar-height))' }">
<Transition name="layout" mode="out-in">
<div v-if="openedViewsTab === 'view'" class="flex flex-1 min-h-0 w-3/4">
<div class="h-full flex-1 min-w-0 min-h-0 bg-white">
<LazySmartsheetGrid v-if="isGrid || !meta || !activeView" ref="grid" />
<Splitpanes v-if="openedViewsTab === 'view'" class="nc-extensions-content-resizable-wrapper" @resized="onResize">
<Pane class="flex flex-col h-full flex-1 min-w-0" size="60">
<LazySmartsheetToolbar v-if="!isForm" />
<div class="flex flex-row w-full" :style="{ height: isForm ? '100%' : 'calc(100% - var(--topbar-height))' }">
<Transition name="layout" mode="out-in">
<div v-if="openedViewsTab === 'view'" class="flex flex-1 min-h-0 w-3/4">
<div class="h-full flex-1 min-w-0 min-h-0 bg-white">
<LazySmartsheetGrid v-if="isGrid || !meta || !activeView" ref="grid" />
<template v-if="activeView && meta">
<LazySmartsheetGallery v-if="isGallery" />
<template v-if="activeView && meta">
<LazySmartsheetGallery v-if="isGallery" />
<LazySmartsheetForm v-else-if="isForm && !$route.query.reload" />
<LazySmartsheetForm v-else-if="isForm && !$route.query.reload" />
<LazySmartsheetKanban v-else-if="isKanban" />
<LazySmartsheetKanban v-else-if="isKanban" />
<LazySmartsheetCalendar v-else-if="isCalendar" />
<LazySmartsheetCalendar v-else-if="isCalendar" />
<LazySmartsheetMap v-else-if="isMap" />
</template>
<LazySmartsheetMap v-else-if="isMap" />
</template>
</div>
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</div>
</Pane>
<ExtensionsPane />
</Splitpanes>
<SmartsheetDetails v-else />
</div>
<LazySmartsheetExpandedFormDetached />
</div>
</template>
<style scoped>
<style lang="scss">
:deep(.nc-right-sidebar.ant-layout-sider-collapsed) {
@apply !w-0 !max-w-0 !min-w-0 overflow-x-hidden;
}
.nc-extensions-content-resizable-wrapper > {
.splitpanes__splitter {
@apply !w-0 relative overflow-visible;
}
.splitpanes__splitter:before {
@apply bg-gray-200 w-0.25 absolute left-0 top-0 h-full z-40;
content: '';
}
.splitpanes__splitter:hover:before {
@apply bg-scrollbar;
width: 3px !important;
left: 0px;
}
.splitpanes--dragging .splitpanes__splitter:before {
@apply bg-scrollbar;
width: 3px !important;
left: 0px;
}
.splitpanes--dragging .splitpanes__splitter {
@apply w-1 mr-0;
}
}
.splitpanes__pane {
transition: width 0.15s ease-in-out !important;
}
.splitpanes--dragging {
cursor: col-resize;
> .splitpanes__pane {
transition: none !important;
}
}
</style>

2
packages/nc-gui/components/template/Editor.vue

@ -306,7 +306,7 @@ function remapColNames(batchData: any[], columns: ColumnType[]) {
function missingRequiredColumnsValidation(tn: string) {
const missingRequiredColumns = columns.value.filter(
(c: Record<string, any>) =>
(c.pk ? !c.ai && !c.cdf : !c.cdf && c.rqd) &&
(c.pk ? !c.ai && !c.cdf && !c.meta?.ag : !c.cdf && c.rqd) &&
!srcDestMapping.value[tn].some((r: Record<string, any>) => r.destCn === c.title),
)

224
packages/nc-gui/composables/useExtensionHelper.ts

@ -0,0 +1,224 @@
import type { ColumnType, ViewType } from 'nocodb-sdk'
import type { ExtensionType } from '#imports'
import { useInjectionState } from '#imports'
const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState((extension: Ref<ExtensionType>) => {
const { $api } = useNuxtApp()
const basesStore = useBases()
const { activeProjectId: baseId } = storeToRefs(basesStore)
const tableStore = useTablesStore()
const { activeTables: tables } = storeToRefs(tableStore)
const viewStore = useViewsStore()
const { viewsByTable } = storeToRefs(viewStore)
const { getMeta } = useMetas()
const { eventBus } = useSmartsheetStoreOrThrow()
const fullscreen = ref(false)
const collapsed = computed({
get: () => extension.value?.meta?.collapsed ?? false,
set: (value) => {
extension.value?.setMeta('collapsed', value)
},
})
const getViewsForTable = async (tableId: string) => {
if (viewsByTable.value.has(tableId)) {
return viewsByTable.value.get(tableId) as ViewType[]
}
await viewStore.loadViews({ tableId, ignoreLoading: true })
return viewsByTable.value.get(tableId) as ViewType[]
}
const getData = async (params: {
tableId: string
viewId?: string
eachPage: (records: Record<string, any>[], nextPage: () => void) => Promise<void> | void
done: () => Promise<void> | void
}) => {
const { tableId, viewId, eachPage, done } = params
let page = 0
const nextPage = async () => {
const { list: records, pageInfo } = await $api.dbViewRow.list(
'noco',
baseId.value!,
tableId,
viewId as string,
{
offset: (page - 1) * 25,
limit: 25,
} as any,
)
if (pageInfo?.isLastPage) {
await eachPage(records, () => {})
await done()
} else {
page++
await eachPage(records, nextPage)
}
}
await nextPage()
}
const getTableMeta = async (tableId: string) => {
return getMeta(tableId)
}
const insertData = async (params: { tableId: string; data: Record<string, any> }) => {
const { tableId, data } = params
const chunks = []
let inserted = 0
// chunk data into 100 records
for (let i = 0; i < data.length; i += 100) {
chunks.push(data.slice(i, i + 100))
}
for (const chunk of chunks) {
inserted += chunk.length
await $api.dbDataTableRow.create(tableId, chunk)
}
return {
inserted,
}
}
const updateData = async (params: { tableId: string; data: Record<string, any> }) => {
const { tableId, data } = params
const chunks = []
let updated = 0
// chunk data into 100 records
for (let i = 0; i < data.length; i += 100) {
chunks.push(data.slice(i, i + 100))
}
for (const chunk of chunks) {
updated += chunk.length
await $api.dbDataTableRow.update(tableId, chunk)
}
return {
updated,
}
}
const upsertData = async (params: { tableId: string; data: Record<string, any>; upsertField: ColumnType }) => {
const { tableId, data, upsertField } = params
const chunkSize = 100
const tableMeta = await getMeta(tableId)
if (!tableMeta?.columns) throw new Error('Table not found')
const chunks = []
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(data.slice(i, i + chunkSize))
}
const insert = []
const update = []
let insertCounter = 0
let updateCounter = 0
for (const chunk of chunks) {
// select chunk of data to determine if it's an insert or update
const { list } = await $api.dbDataTableRow.list(tableId, {
where: `(${upsertField.title},in,${chunk.map((record: Record<string, any>) => record[upsertField.title!]).join(',')})`,
limit: chunkSize,
})
insert.push(
...chunk.filter(
(record: Record<string, any>) =>
!list.some((r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`),
),
)
update.push(
...chunk
.filter((record: Record<string, any>) =>
list.some((r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`),
)
.map((record: Record<string, any>) => {
const existingRecord = list.find(
(r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`,
)
return {
...rowPkData(existingRecord!, tableMeta.columns!),
...record,
}
}),
)
}
if (insert.length) {
insertCounter += insert.length
for (let i = 0; i < insert.length; i += chunkSize) {
await $api.dbDataTableRow.create(tableId, insert.splice(0, chunkSize))
}
}
if (update.length) {
updateCounter += update.length
for (let i = 0; i < update.length; i += chunkSize) {
await $api.dbDataTableRow.update(tableId, update.splice(0, chunkSize))
}
}
return { inserted: insertCounter, updated: updateCounter }
}
const reloadData = () => {
eventBus.emit(SmartsheetStoreEvents.DATA_RELOAD)
}
const reloadMeta = () => {
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
}
return {
fullscreen,
collapsed,
extension,
tables,
getViewsForTable,
getData,
getTableMeta,
$api,
insertData,
updateData,
upsertData,
reloadData,
reloadMeta,
}
}, 'extension-helper')
export { useProvideExtensionHelper }
export function useExtensionHelperOrThrow() {
const extensionStore = useExtensionHelper()
if (extensionStore == null) throw new Error('Please call `useProvideExtensionHelper` on the appropriate parent component')
return extensionStore
}

394
packages/nc-gui/composables/useExtensions.ts

@ -0,0 +1,394 @@
const extensionsState = createGlobalState(() => {
const baseExtensions = ref<Record<string, any>>({})
return { baseExtensions }
})
interface ExtensionManifest {
id: string
title: string
description: string
entry: string
version: string
iconUrl: string
publisherName: string
publisherEmail: string
publisherUrl: string
}
abstract class ExtensionType {
abstract id: string
abstract uiKey: number
abstract baseId: string
abstract fkUserId: string
abstract extensionId: string
abstract title: string
abstract kvStore: any
abstract meta: any
abstract setTitle(title: string): Promise<any>
abstract setMeta(key: string, value: any): Promise<any>
abstract clear(): Promise<any>
abstract delete(): Promise<any>
abstract serialize(): any
abstract deserialize(data: any): void
}
export { ExtensionType }
export const useExtensions = createSharedComposable(() => {
const { baseExtensions } = extensionsState()
const { $api } = useNuxtApp()
const { base } = storeToRefs(useBase())
const extensionsLoaded = ref(false)
const availableExtensions = ref<ExtensionManifest[]>([])
const extensionPanelSize = ref(40)
const activeBaseExtensions = computed(() => {
if (!base.value || !base.value.id) {
return null
}
return baseExtensions.value[base.value.id]
})
const isPanelExpanded = computed(() => {
return activeBaseExtensions.value ? activeBaseExtensions.value.expanded : false
})
const extensionList = computed<ExtensionType[]>(() => {
return activeBaseExtensions.value ? activeBaseExtensions.value.extensions : []
})
const toggleExtensionPanel = () => {
if (activeBaseExtensions.value) {
activeBaseExtensions.value.expanded = !activeBaseExtensions.value.expanded
}
}
const addExtension = async (extension: any) => {
if (!base.value || !base.value.id || !baseExtensions.value[base.value.id]) {
return
}
const extensionReq = {
base_id: base.value.id,
title: extension.title,
extension_id: extension.id,
meta: {
collapsed: false,
},
}
const newExtension = await $api.extensions.create(base.value.id, extensionReq)
if (newExtension) {
baseExtensions.value[base.value.id].extensions.push(new Extension(newExtension))
}
return newExtension
}
const updateExtension = async (extensionId: string, extension: any) => {
if (!base.value || !base.value.id || !baseExtensions.value[base.value.id]) {
return
}
const updatedExtension = await $api.extensions.update(extensionId, extension)
if (updatedExtension) {
const extension = baseExtensions.value[base.value.id].extensions.find((ext: any) => ext.id === extensionId)
if (extension) {
extension.deserialize(updatedExtension)
}
}
return updatedExtension
}
const updateExtensionMeta = async (extensionId: string, key: string, value: any) => {
const extension = extensionList.value.find((ext: any) => ext.id === extensionId)
if (!extension) {
return
}
return updateExtension(extensionId, {
meta: {
...extension.meta,
[key]: value,
},
})
}
const deleteExtension = async (extensionId: string) => {
if (!base.value || !base.value.id || !baseExtensions.value[base.value.id]) {
return
}
await $api.extensions.delete(extensionId)
baseExtensions.value[base.value.id].extensions = baseExtensions.value[base.value.id].extensions.filter(
(ext: any) => ext.id !== extensionId,
)
}
const duplicateExtension = async (extensionId: string) => {
if (!base.value || !base.value.id || !baseExtensions.value[base.value.id]) {
return
}
const extension = extensionList.value.find((ext: any) => ext.id === extensionId)
if (!extension) {
return
}
const { id: _id, ...extensionData } = extension.serialize()
const newExtension = await $api.extensions.create(base.value.id, {
...extensionData,
title: `${extension.title} (Copy)`,
})
if (newExtension) {
baseExtensions.value[base.value.id].extensions.push(new Extension(newExtension))
}
return newExtension
}
const clearKvStore = async (extensionId: string) => {
const extension = extensionList.value.find((ext: any) => ext.id === extensionId)
if (!extension) {
return
}
return updateExtension(extensionId, {
kv_store: {},
})
}
const loadExtensionsForBase = async (baseId: string) => {
if (!baseId) {
return
}
const { list } = await $api.extensions.list(baseId)
const extensions = list?.map((ext: any) => new Extension(ext))
if (baseExtensions.value[baseId]) {
baseExtensions.value[baseId].extensions = extensions || baseExtensions.value[baseId].extensions
} else {
baseExtensions.value[baseId] = {
extensions: extensions || [],
expanded: false,
}
}
}
const getExtensionIcon = (pathOrUrl: string) => {
if (pathOrUrl.startsWith('http')) {
return pathOrUrl
} else {
return new URL(`../extensions/${pathOrUrl}`, import.meta.url).href
}
}
class KvStore {
private _id: string
private data: Record<string, any>
constructor(id: string, data: any) {
this._id = id
this.data = data || {}
}
get(key: string) {
return this.data[key] || null
}
set(key: string, value: any) {
this.data[key] = value
return updateExtension(this._id, { kv_store: this.data })
}
delete(key: string) {
delete this.data[key]
return updateExtension(this._id, { kv_store: this.data })
}
serialize() {
return this.data
}
}
class Extension implements ExtensionType {
private _id: string
private _baseId: string
private _fkUserId: string
private _extensionId: string
private _title: string
private _kvStore: KvStore
private _meta: any
public uiKey = 0
constructor(data: any) {
this._id = data.id
this._baseId = data.base_id
this._fkUserId = data.fk_user_id
this._extensionId = data.extension_id
this._title = data.title
this._kvStore = new KvStore(this._id, data.kv_store)
this._meta = data.meta
}
get id() {
return this._id
}
get baseId() {
return this._baseId
}
get fkUserId() {
return this._fkUserId
}
get extensionId() {
return this._extensionId
}
get title() {
return this._title
}
get kvStore() {
return this._kvStore
}
get meta() {
return this._meta
}
serialize() {
return {
id: this._id,
base_id: this._baseId,
fk_user_id: this._fkUserId,
extension_id: this._extensionId,
title: this._title,
kv_store: this._kvStore.serialize(),
meta: this._meta,
}
}
deserialize(data: any) {
this._id = data.id
this._baseId = data.base_id
this._fkUserId = data.fk_user_id
this._extensionId = data.extension_id
this._title = data.title
this._kvStore = new KvStore(this._id, data.kv_store)
this._meta = data.meta
}
setTitle(title: string): Promise<any> {
return updateExtension(this.id, { title })
}
setMeta(key: string, value: any): Promise<any> {
return updateExtensionMeta(this.id, key, value)
}
clear(): Promise<any> {
return clearKvStore(this.id).then(() => {
this.uiKey++
})
}
delete(): Promise<any> {
return deleteExtension(this.id)
}
}
onMounted(() => {
const modules = import.meta.glob('../extensions/*/*.json')
for (const path in modules) {
modules[path]().then((mod: any) => {
const manifest = mod.default as ExtensionManifest
availableExtensions.value.push(manifest)
if (Object.keys(modules).length === availableExtensions.value.length) {
extensionsLoaded.value = true
}
})
}
until(base)
.toMatch((v) => !!v)
.then(() => {
if (!base.value || !base.value.id) {
return
}
if (!baseExtensions.value[base.value.id]) {
loadExtensionsForBase(base.value.id)
}
})
})
// Extension details modal
const isDetailsVisible = ref(false)
const detailsExtensionId = ref<string>()
const detailsFrom = ref<'market' | 'extension'>('market')
const showExtensionDetails = (extensionId: string, from?: 'market' | 'extension') => {
detailsExtensionId.value = extensionId
isDetailsVisible.value = true
detailsFrom.value = from || 'market'
}
// Extension market modal
const isMarketVisible = ref(false)
// Egg
const extensionsEgg = ref(false)
const extensionsEggCounter = ref(0)
const onEggClick = () => {
extensionsEggCounter.value++
if (extensionsEggCounter.value >= 2) {
extensionsEgg.value = true
}
}
return {
extensionsLoaded,
availableExtensions,
extensionList,
isPanelExpanded,
toggleExtensionPanel,
addExtension,
duplicateExtension,
updateExtension,
updateExtensionMeta,
clearKvStore,
deleteExtension,
getExtensionIcon,
isDetailsVisible,
detailsExtensionId,
detailsFrom,
showExtensionDetails,
isMarketVisible,
onEggClick,
extensionsEgg,
extensionPanelSize,
}
})

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

@ -5,6 +5,7 @@ export enum ClientType {
SQLITE = 'sqlite3',
VITESS = 'vitess',
SNOWFLAKE = 'snowflake',
DATABRICKS = 'databricks',
}
export enum Language {

29
packages/nc-gui/store/workspace.ts

@ -2,7 +2,17 @@ import type { BaseType } from 'nocodb-sdk'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { message } from 'ant-design-vue'
import { isString } from '@vue/shared'
import { computed, navigateTo, ref, useBases, useCommandPalette, useNuxtApp, useRouter, useTheme } from '#imports'
import {
computed,
navigateTo,
navigateToBlankTargetOpenOption,
ref,
useBases,
useCommandPalette,
useNuxtApp,
useRouter,
useTheme,
} from '#imports'
import type { ThemeConfig } from '#imports'
export const useWorkspace = defineStore('workspaceStore', () => {
@ -201,15 +211,14 @@ export const useWorkspace = defineStore('workspaceStore', () => {
})
}
const navigateToWorkspaceSettings = async (_, cmdOrCtrl) => {
await navigateTo(
`${cmdOrCtrl ? '#' : ''}/account/users`,
cmdOrCtrl
? {
open: navigateToBlankTargetOpenOption,
}
: undefined,
)
const navigateToWorkspaceSettings = async (_?: string, cmdOrCtrl?: boolean) => {
if (cmdOrCtrl) {
await navigateTo('#/account/users', {
open: navigateToBlankTargetOpenOption,
})
} else {
await navigateTo('/account/users')
}
}
function setLoadingState(isLoading = false) {

27
packages/nc-gui/utils/baseCreateUtils.ts

@ -9,7 +9,7 @@ interface ProjectCreateForm {
title: string
dataSource: {
client: ClientType
connection: DefaultConnection | SQLiteConnection | SnowflakeConnection
connection: DefaultConnection | SQLiteConnection | SnowflakeConnection | DatabricksConnection
searchPath?: string[]
}
inflection: {
@ -47,6 +47,14 @@ interface SnowflakeConnection {
schema: string
}
interface DatabricksConnection {
token: string
host: string
path: string
database: string
schema: string
}
const defaultHost = 'localhost'
const testDataBaseNames = {
@ -84,12 +92,16 @@ export const clientTypes = [
text: 'Snowflake',
value: ClientType.SNOWFLAKE,
},
{
text: 'Databricks',
value: ClientType.DATABRICKS,
},
]
const homeDir = ''
type ConnectionClientType =
| Exclude<ClientType, ClientType.SQLITE | ClientType.SNOWFLAKE>
| Exclude<ClientType, ClientType.SQLITE | ClientType.SNOWFLAKE | ClientType.DATABRICKS>
| 'tidb'
| 'yugabyte'
| 'citusdb'
@ -99,7 +111,7 @@ type ConnectionClientType =
const sampleConnectionData: { [key in ConnectionClientType]: DefaultConnection } & { [ClientType.SQLITE]: SQLiteConnection } & {
[ClientType.SNOWFLAKE]: SnowflakeConnection
} = {
} & { [ClientType.DATABRICKS]: DatabricksConnection } = {
[ClientType.PG]: {
host: defaultHost,
port: '5432',
@ -144,6 +156,13 @@ const sampleConnectionData: { [key in ConnectionClientType]: DefaultConnection }
database: 'DATABASE',
schema: 'PUBLIC',
},
[ClientType.DATABRICKS]: {
token: 'dapiPLACEHOLDER',
host: 'PLACEHOLDER.cloud.databricks.com',
path: '/sql/1.0/warehouses/PLACEHOLDER',
database: 'database',
schema: 'default',
},
tidb: {
host: defaultHost,
port: '4000',
@ -215,4 +234,4 @@ enum CertTypes {
key = 'key',
}
export { SSLUsage, CertTypes, ProjectCreateForm, DefaultConnection, SQLiteConnection, SnowflakeConnection }
export { SSLUsage, CertTypes, ProjectCreateForm, DefaultConnection, SQLiteConnection, SnowflakeConnection, DatabricksConnection }

2
packages/nc-gui/utils/iconUtils.ts

@ -60,6 +60,7 @@ import Up from '~icons/material-symbols/keyboard-arrow-up-rounded'
import Down from '~icons/material-symbols/keyboard-arrow-down-rounded'
import PhTriangleFill from '~icons/ph/triangle-fill'
import LcSend from '~icons/lucide/send'
import MdiPuzzle from '~icons/mdi/puzzle'
import HasManyIcon from '~icons/nc-icons/hasmany'
import ManytoManyIcon from '~icons/nc-icons/manytomany'
@ -583,6 +584,7 @@ export const iconMap = {
file: NcFile,
ncSettings: NcSettings,
ncHelp: NcHelp,
puzzle: MdiPuzzle,
}
export const getMdiIcon = (type: string): any => {

4
packages/nocodb-sdk/src/lib/enums.ts

@ -137,6 +137,10 @@ export enum AppEvents {
ATTACHMENT_UPLOAD = 'attachment.upload',
APIS_CREATED = 'apis.created',
EXTENSION_CREATE = 'extension.create',
EXTENSION_UPDATE = 'extension.update',
EXTENSION_DELETE = 'extension.delete',
}
export enum ClickhouseTables {

820
packages/nocodb-sdk/src/lib/sqlUi/DatabricksUi.ts

@ -0,0 +1,820 @@
import UITypes from '../UITypes';
import { IDType } from './index';
const dbTypes = [
'BIGINT',
'BINARY',
'BOOLEAN',
'DATE',
'DECIMAL',
'DOUBLE',
'FLOAT',
'INT',
'INTERVAL',
'VOID',
'SMALLINT',
'STRING',
'TIMESTAMP',
'TIMESTAMP_NTZ',
'TINYINT',
];
export class DatabricksUi {
static getNewTableColumns() {
return [
{
column_name: 'id',
title: 'Id',
dt: 'int',
dtx: 'int',
ct: 'int',
nrqd: false,
rqd: true,
ck: false,
pk: true,
un: false,
ai: true,
cdf: null,
clen: null,
np: null,
ns: 0,
dtxp: '',
dtxs: '',
altered: 1,
uidt: 'ID',
uip: '',
uicn: '',
},
{
column_name: 'title',
title: 'Title',
dt: 'string',
dtx: 'specificType',
ct: 'string',
nrqd: true,
rqd: false,
ck: false,
pk: false,
un: false,
ai: false,
cdf: null,
clen: 45,
np: null,
ns: null,
dtxp: '',
dtxs: '',
altered: 1,
uidt: 'SingleLineText',
uip: '',
uicn: '',
},
{
column_name: 'created_at',
title: 'CreatedAt',
dt: 'TIMESTAMP',
dtx: 'specificType',
ct: 'TIMESTAMP',
nrqd: true,
rqd: false,
ck: false,
pk: false,
un: false,
ai: false,
clen: 45,
np: null,
ns: null,
dtxp: '',
dtxs: '',
altered: 1,
uidt: UITypes.CreatedTime,
uip: '',
uicn: '',
system: true,
},
{
column_name: 'updated_at',
title: 'UpdatedAt',
dt: 'TIMESTAMP',
dtx: 'specificType',
ct: 'TIMESTAMP',
nrqd: true,
rqd: false,
ck: false,
pk: false,
un: false,
ai: false,
clen: 45,
np: null,
ns: null,
dtxp: '',
dtxs: '',
altered: 1,
uidt: UITypes.LastModifiedTime,
uip: '',
uicn: '',
system: true,
},
{
column_name: 'created_by',
title: 'nc_created_by',
dt: 'string',
dtx: 'specificType',
ct: 'string',
nrqd: true,
rqd: false,
ck: false,
pk: false,
un: false,
ai: false,
clen: 45,
np: null,
ns: null,
dtxp: '',
dtxs: '',
altered: 1,
uidt: UITypes.CreatedBy,
uip: '',
uicn: '',
system: true,
},
{
column_name: 'updated_by',
title: 'nc_updated_by',
dt: 'string',
dtx: 'specificType',
ct: 'string',
nrqd: true,
rqd: false,
ck: false,
pk: false,
un: false,
ai: false,
clen: 45,
np: null,
ns: null,
dtxp: '',
dtxs: '',
altered: 1,
uidt: UITypes.LastModifiedBy,
uip: '',
uicn: '',
system: true,
},
];
}
static getNewColumn(suffix) {
return {
column_name: 'title' + suffix,
dt: 'string',
dtx: 'specificType',
ct: 'string',
nrqd: true,
rqd: false,
ck: false,
pk: false,
un: false,
ai: false,
cdf: null,
clen: 45,
np: null,
ns: null,
dtxp: '',
dtxs: '',
altered: 1,
uidt: 'SingleLineText',
uip: '',
uicn: '',
};
}
static getDefaultLengthForDatatype(_type) {
return '';
}
static getDefaultLengthIsDisabled(type): any {
switch (type) {
case 'decimal':
return true;
case 'text':
return false;
}
}
static getDefaultValueForDatatype(type): any {
switch (type) {
case 'integer':
return 'eg : ' + 10;
case 'text':
return 'eg : hey';
case 'numeric':
return 'eg : ' + 10;
case 'real':
return 'eg : ' + 10.0;
case 'blob':
return 'eg : ' + 100;
}
}
static getDefaultScaleForDatatype(type): any {
switch (type) {
case 'integer':
case 'text':
case 'numeric':
case 'real':
case 'blob':
return ' ';
}
}
static colPropAIDisabled(col, columns) {
// console.log(col);
if (col.dt === 'integer') {
for (let i = 0; i < columns.length; ++i) {
if (columns[i].cn !== col.cn && columns[i].ai) {
return true;
}
}
return false;
} else {
return true;
}
}
static colPropUNDisabled(_col) {
return true;
}
static onCheckboxChangeAI(col) {
console.log(col);
if (
col.dt === 'int' ||
col.dt === 'bigint' ||
col.dt === 'smallint' ||
col.dt === 'tinyint'
) {
col.altered = col.altered || 2;
}
}
static showScale(_columnObj) {
return false;
}
static removeUnsigned(columns) {
for (let i = 0; i < columns.length; ++i) {
if (
columns[i].altered === 1 &&
!(
columns[i].dt === 'int' ||
columns[i].dt === 'bigint' ||
columns[i].dt === 'tinyint' ||
columns[i].dt === 'smallint' ||
columns[i].dt === 'mediumint'
)
) {
columns[i].un = false;
console.log('>> resetting unsigned value', columns[i].cn);
}
console.log(columns[i].cn);
}
}
/*static extractFunctionName(query) {
const reg =
/^\s*CREATE\s+(?:OR\s+REPLACE\s*)?\s*FUNCTION\s+(?:[\w\d_]+\.)?([\w_\d]+)/i;
const match = query.match(reg);
return match && match[1];
}
static extractProcedureName(query) {
const reg =
/^\s*CREATE\s+(?:OR\s+REPLACE\s*)?\s*PROCEDURE\s+(?:[\w\d_]+\.)?([\w_\d]+)/i;
const match = query.match(reg);
return match && match[1];
}*/
static columnEditable(_colObj) {
return true; // colObj.altered === 1;
}
/*static handleRawOutput(result, headers) {
console.log(result);
if (Array.isArray(result) && result[0]) {
const keys = Object.keys(result[0]);
// set headers before settings result
for (let i = 0; i < keys.length; i++) {
const text = keys[i];
headers.push({ text, value: text, sortable: false });
}
}
return result;
}
static splitQueries(query) {
/!***
* we are splitting based on semicolon
* there are mechanism to escape semicolon within single/double quotes(string)
*!/
return query.match(/\b("[^"]*;[^"]*"|'[^']*;[^']*'|[^;])*;/g);
}
/!**
* if sql statement is SELECT - it limits to a number
* @param args
* @returns {string|*}
*!/
sanitiseQuery(args) {
let q = args.query.trim().split(';');
if (q[0].startsWith('Select')) {
q = q[0] + ` LIMIT 0,${args.limit ? args.limit : 100};`;
} else if (q[0].startsWith('select')) {
q = q[0] + ` LIMIT 0,${args.limit ? args.limit : 100};`;
} else if (q[0].startsWith('SELECT')) {
q = q[0] + ` LIMIT 0,${args.limit ? args.limit : 100};`;
} else {
return args.query;
}
return q;
}
static getColumnsFromJson(json, tn) {
const columns = [];
try {
if (typeof json === 'object' && !Array.isArray(json)) {
const keys = Object.keys(json);
for (let i = 0; i < keys.length; ++i) {
const column = {
dp: null,
tn,
column_name: keys[i],
cno: keys[i],
np: null,
ns: null,
clen: null,
cop: 1,
pk: false,
nrqd: false,
rqd: false,
un: false,
ct: 'int(11) unsigned',
ai: false,
unique: false,
cdf: null,
cc: '',
csn: null,
dtx: 'specificType',
dtxp: null,
dtxs: 0,
altered: 1,
};
switch (typeof json[keys[i]]) {
case 'number':
if (Number.isInteger(json[keys[i]])) {
if (SqliteUi.isValidTimestamp(keys[i], json[keys[i]])) {
Object.assign(column, {
dt: 'timestamp',
});
} else {
Object.assign(column, {
dt: 'integer',
});
}
} else {
Object.assign(column, {
dt: 'real',
});
}
break;
case 'string':
// if (SqliteUi.isValidDate(json[keys[i]])) {
// Object.assign(column, {
// "dt": "datetime"
// });
// } else
if (json[keys[i]].length <= 255) {
Object.assign(column, {
dt: 'varchar',
});
} else {
Object.assign(column, {
dt: 'text',
});
}
break;
case 'boolean':
Object.assign(column, {
dt: 'integer',
});
break;
case 'object':
Object.assign(column, {
dt: 'text',
np: null,
dtxp: null,
});
break;
default:
break;
}
columns.push(column);
}
}
} catch (e) {
console.log('Error in getColumnsFromJson', e);
}
return columns;
}
static isValidTimestamp(key, value) {
if (typeof value !== 'number') {
return false;
}
return new Date(value).getTime() > 0 && /(?:_|(?=A))[aA]t$/.test(key);
}
static isValidDate(value) {
return new Date(value).getTime() > 0;
}*/
static onCheckboxChangeAU(col) {
console.log(col);
// if (1) {
col.altered = col.altered || 2;
// }
// if (!col.ai) {
// col.dtx = 'specificType'
// } else {
// col.dtx = ''
// }
}
static colPropAuDisabled(col) {
if (col.altered !== 1) {
return true;
}
switch (col.dt) {
case 'date':
case 'datetime':
case 'timestamp':
case 'time':
return false;
default:
return true;
}
}
static getAbstractType(col): any {
switch (col.dt?.replace(/\(\d+\)$/).toLowerCase()) {
case 'bigint':
case 'tinyint':
case 'int':
case 'smallint':
return 'integer';
case 'decimal':
case 'double':
case 'float':
return 'float';
case 'boolean':
return 'boolean';
case 'timestamp':
case 'timestamp_ntz':
return 'datetime';
case 'date':
return 'date';
case 'string':
return 'string';
}
return 'string';
}
static getUIType(col): any {
switch (this.getAbstractType(col)) {
case 'integer':
return 'Number';
case 'boolean':
return 'Checkbox';
case 'float':
return 'Decimal';
case 'date':
return 'Date';
case 'datetime':
return 'CreatedTime';
case 'time':
return 'Time';
case 'year':
return 'Year';
case 'string':
return 'SingleLineText';
case 'text':
return 'LongText';
case 'blob':
return 'Attachment';
case 'enum':
return 'SingleSelect';
case 'set':
return 'MultiSelect';
case 'json':
return 'LongText';
}
}
static getDataTypeForUiType(col: { uidt: UITypes; }, idType?: IDType) {
const colProp: any = {};
switch (col.uidt) {
case 'ID':
{
const isAutoIncId = idType === 'AI';
const isAutoGenId = idType === 'AG';
colProp.dt = isAutoGenId ? 'varchar' : 'integer';
colProp.pk = true;
colProp.un = isAutoIncId;
colProp.ai = isAutoIncId;
colProp.rqd = true;
colProp.meta = isAutoGenId ? { ag: 'nc' } : undefined;
}
break;
case 'ForeignKey':
colProp.dt = 'string';
break;
case 'SingleLineText':
colProp.dt = 'string';
break;
case 'LongText':
colProp.dt = 'string';
break;
case 'Attachment':
colProp.dt = 'string';
break;
case 'GeoData':
colProp.dt = 'string';
break;
case 'Checkbox':
colProp.dt = 'boolean';
break;
case 'MultiSelect':
colProp.dt = 'string';
break;
case 'SingleSelect':
colProp.dt = 'string';
break;
case 'Collaborator':
colProp.dt = 'string';
break;
case 'Date':
colProp.dt = 'date';
break;
case 'Year':
colProp.dt = 'number';
break;
case 'Time':
colProp.dt = 'string';
break;
case 'PhoneNumber':
colProp.dt = 'string';
colProp.validate = {
func: ['isMobilePhone'],
args: [''],
msg: ['Validation failed : isMobilePhone'],
};
break;
case 'Email':
colProp.dt = 'string';
colProp.validate = {
func: ['isEmail'],
args: [''],
msg: ['Validation failed : isEmail'],
};
break;
case 'URL':
colProp.dt = 'string';
colProp.validate = {
func: ['isURL'],
args: [''],
msg: ['Validation failed : isURL'],
};
break;
case 'Number':
colProp.dt = 'int';
break;
case 'Decimal':
colProp.dt = 'decimal';
break;
case 'Currency':
colProp.dt = 'double';
colProp.validate = {
func: ['isCurrency'],
args: [''],
msg: ['Validation failed : isCurrency'],
};
break;
case 'Percent':
colProp.dt = 'double';
break;
case 'Duration':
colProp.dt = 'decimal';
break;
case 'Rating':
colProp.dt = 'int';
colProp.cdf = '0';
break;
case 'Formula':
colProp.dt = 'string';
break;
case 'Rollup':
colProp.dt = 'string';
break;
case 'Count':
colProp.dt = 'int';
break;
case 'Lookup':
colProp.dt = 'string';
break;
case 'DateTime':
colProp.dt = 'datetime';
break;
case 'CreatedTime':
colProp.dt = 'datetime';
break;
case 'LastModifiedTime':
colProp.dt = 'datetime';
break;
case 'AutoNumber':
colProp.dt = 'int';
break;
case 'Barcode':
colProp.dt = 'string';
break;
case 'Button':
colProp.dt = 'string';
break;
case 'JSON':
colProp.dt = 'string';
break;
default:
colProp.dt = 'string';
break;
}
return colProp;
}
static getDataTypeListForUiType(col: { uidt: UITypes; }, idType?: IDType) {
switch (col.uidt) {
case 'ID':
if (idType === 'AG') {
return ['character', 'text', 'varchar'];
} else if (idType === 'AI') {
return [
'int',
'integer',
'tinyint',
'smallint',
'mediumint',
'bigint',
'int2',
'int8',
];
} else {
return dbTypes;
}
case 'ForeignKey':
return dbTypes;
case 'SingleLineText':
case 'LongText':
case 'Attachment':
case 'Collaborator':
case 'GeoData':
return ['string'];
case 'Checkbox':
return [
'boolean',
];
case 'MultiSelect':
return ['string'];
case 'SingleSelect':
return ['string'];
case 'Year':
return [
'int',
];
case 'Time':
return [
'string',
];
case 'PhoneNumber':
case 'Email':
return ['string'];
case 'URL':
return ['string'];
case 'Number':
return [
'int',
];
case 'Decimal':
return ['decimal', 'float', 'double'];
case 'Currency':
return [
'decimal',
];
case 'Percent':
return [
'decimal',
];
case 'Duration':
return [
'decimal',
];
case 'Rating':
return [
'int',
];
case 'Formula':
return ['string'];
case 'Rollup':
return ['string'];
case 'Count':
return [
'int',
];
case 'Lookup':
return ['string'];
case 'Date':
return ['date'];
case 'DateTime':
case 'CreatedTime':
case 'LastModifiedTime':
return ['datetime'];
case 'AutoNumber':
return [
'int',
];
case 'Barcode':
return ['string'];
case 'Geometry':
return ['string'];
case 'JSON':
return ['string'];
case 'Button':
default:
return dbTypes;
}
}
static getUnsupportedFnList() {
return [
'LOG',
'EXP',
'POWER',
'SQRT',
'XOR',
'REGEX_MATCH',
'REGEX_EXTRACT',
'REGEX_REPLACE',
'VALUE',
'COUNTA',
'COUNT',
'ROUNDDOWN',
'ROUNDUP',
'DATESTR',
'DAY',
'MONTH',
'HOUR',
];
}
}

5
packages/nocodb-sdk/src/lib/sqlUi/SqlUiFactory.ts

@ -7,6 +7,7 @@ import { OracleUi } from './OracleUi';
import { PgUi } from './PgUi';
import { SqliteUi } from './SqliteUi';
import { SnowflakeUi } from './SnowflakeUi';
import { DatabricksUi } from './DatabricksUi';
// import {YugabyteUi} from "./YugabyteUi";
// import {TidbUi} from "./TidbUi";
@ -48,6 +49,10 @@ export class SqlUiFactory {
return SnowflakeUi;
}
if (connectionConfig.client === 'databricks') {
return DatabricksUi;
}
throw new Error('Database not supported');
}
}

21
packages/nocodb/src/controllers/extensions.controller.spec.ts

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

80
packages/nocodb/src/controllers/extensions.controller.ts

@ -0,0 +1,80 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import type { ExtensionReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { ExtensionsService } from '~/services/extensions.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { NcRequest } from '~/interface/config';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
@Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class ExtensionsController {
constructor(private readonly extensionsService: ExtensionsService) {}
@Get(['/api/v2/extensions/:baseId'])
@Acl('extensionList')
async extensionList(@Param('baseId') baseId: string, @Req() _req: NcRequest) {
return new PagedResponseImpl(
await this.extensionsService.extensionList({ baseId }),
);
}
@Post(['/api/v2/extensions/:baseId'])
@Acl('extensionCreate')
async extensionCreate(
@Param('baseId') baseId: string,
@Body() body: Partial<ExtensionReqType>,
@Req() req: NcRequest,
) {
return await this.extensionsService.extensionCreate({
extension: {
...body,
base_id: baseId,
},
req,
});
}
@Get(['/api/v2/extensions/:extensionId'])
@Acl('extensionRead')
async extensionRead(@Param('extensionId') extensionId: string) {
return await this.extensionsService.extensionRead({ extensionId });
}
@Patch(['/api/v2/extensions/:extensionId'])
@Acl('extensionUpdate')
async extensionUpdate(
@Param('extensionId') extensionId: string,
@Body() body: Partial<ExtensionReqType>,
@Req() req: NcRequest,
) {
return await this.extensionsService.extensionUpdate({
extensionId,
extension: body,
req,
});
}
@Delete(['/api/v2/extensions/:extensionId'])
@Acl('extensionDelete')
async extensionDelete(
@Param('extensionId') extensionId: string,
@Req() req: NcRequest,
) {
return await this.extensionsService.extensionDelete({
extensionId,
req,
});
}
}

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

@ -2770,16 +2770,16 @@ class BaseModelSqlv2 {
{ raw: true, first: true },
)
)?.__nc_ai_id;
} else if (this.isSnowflake) {
} else if (this.isSnowflake || this.isDatabricks) {
id = (
await this.execAndParse(
this.dbDriver(this.tnPath).max(ai.column_name, {
as: 'id',
as: '__nc_ai_id',
}),
null,
{ raw: true, first: true },
)
).id;
).__nc_ai_id;
}
response = await this.readByPk(
this.extractCompositePK({ rowId: id, insertObj, ag }),
@ -3054,6 +3054,10 @@ class BaseModelSqlv2 {
return this.clientType === 'snowflake';
}
get isDatabricks() {
return this.clientType === 'databricks';
}
get clientType() {
return this.dbDriver.clientType();
}
@ -3161,16 +3165,16 @@ class BaseModelSqlv2 {
},
)
)?.__nc_ai_id;
} else if (this.isSnowflake) {
} else if (this.isSnowflake || this.isDatabricks) {
rowId = (
await this.execAndParse(
this.dbDriver(this.tnPath).max(ai.column_name, {
as: 'id',
as: '__nc_ai_id',
}),
null,
{ raw: true, first: true },
)
)?.id;
)?.__nc_ai_id;
}
// response = await this.readByPk(
// id,
@ -4477,7 +4481,7 @@ class BaseModelSqlv2 {
const vTn = this.getTnPath(vTable);
if (this.isSnowflake) {
if (this.isSnowflake || this.isDatabricks) {
const parentPK = this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(_wherePk(parentTable.primaryKeys, childId))

26
packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts

@ -833,6 +833,19 @@ async function _formulaQueryBuilder(
} else {
return fn(pt.arguments[0], a, prevBinaryOp);
}
} else if (knex.clientType() === 'databricks') {
const res = await mapFunctionName({
pt,
knex,
alias,
a,
aliasToCol: aliasToColumn,
fn,
colAlias,
prevBinaryOp,
model,
});
if (res) return res;
}
break;
case 'URL':
@ -921,6 +934,19 @@ async function _formulaQueryBuilder(
if (typeof builder === 'function') {
return { builder: knex.raw(`??${colAlias}`, builder(pt.fnName)) };
}
if (
knex.clientType() === 'databricks' &&
builder.toQuery().endsWith(')')
) {
// limit 1 for subquery
return {
builder: knex.raw(
`${builder.toQuery().replace(/\)$/, '')} LIMIT 1)${colAlias}`,
),
};
}
return { builder: knex.raw(`??${colAlias}`, [builder || pt.name]) };
} else if (pt.type === 'BinaryExpression') {
// treat `&` as shortcut for concat

44
packages/nocodb/src/db/functionMappings/databricks.ts

@ -0,0 +1,44 @@
import commonFns from './commonFns';
import type { MapFnArgs } from '../mapFunctionName';
const databricks = {
...commonFns,
AND: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'AND')).builder.toQuery(),
),
)
).join(' AND ')}`,
)
.wrap('(', ')')
.toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}`,
),
};
},
OR: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'OR')).builder.toQuery(),
),
)
).join(' OR ')}`,
)
.wrap('(', ')')
.toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}`,
),
};
},
};
export default databricks;

4
packages/nocodb/src/db/mapFunctionName.ts

@ -5,6 +5,7 @@ import mssql from '~/db/functionMappings/mssql';
import mysql from '~/db/functionMappings/mysql';
import pg from '~/db/functionMappings/pg';
import sqlite from '~/db/functionMappings/sqlite';
import databricks from '~/db/functionMappings/databricks';
export interface MapFnArgs {
pt: any;
@ -42,6 +43,9 @@ const mapFunctionName = async (args: MapFnArgs): Promise<any> => {
case 'sqlite3':
val = sqlite[name] || name;
break;
case 'databricks':
val = databricks[name] || name;
break;
}
if (typeof val === 'function') {

509
packages/nocodb/src/db/sql-mgr/code/models/xc/ModelXcMetaDatabricks.ts

@ -0,0 +1,509 @@
import BaseModelXcMeta from './BaseModelXcMeta';
class ModelXcMetaDatabricks extends BaseModelXcMeta {
/**
* @param dir
* @param filename
* @param ctx
* @param ctx.tn
* @param ctx.columns
* @param ctx.relations
*/
constructor({ dir, filename, ctx }) {
super({ dir, filename, ctx });
}
/**
* Prepare variables used in code template
*/
prepare() {
const data: any = {};
/* run of simple variable */
data.tn = this.ctx.tn;
data.dbType = this.ctx.dbType;
/* for complex code provide a func and args - do derivation within the func cbk */
data.columns = {
func: this._renderXcColumns.bind(this),
args: {
tn: this.ctx.tn,
columns: this.ctx.columns,
relations: this.ctx.relations,
},
};
/* for complex code provide a func and args - do derivation within the func cbk */
data.hasMany = {
func: this.renderXcHasMany.bind(this),
args: {
tn: this.ctx.tn,
columns: this.ctx.columns,
hasMany: this.ctx.hasMany,
},
};
/* for complex code provide a func and args - do derivation within the func cbk */
data.belongsTo = {
func: this.renderXcBelongsTo.bind(this),
args: {
tn: this.ctx.tn,
columns: this.ctx.columns,
belongsTo: this.ctx.belongsTo,
},
};
return data;
}
/**
*
* @param args
* @param args.columns
* @param args.relations
* @returns {string}
* @private
*/
_renderXcColumns(args) {
let str = '[\r\n';
for (let i = 0; i < args.columns.length; ++i) {
str += `{\r\n`;
str += `cn: '${args.columns[i].cn}',\r\n`;
str += `type: '${this._getAbstractType(args.columns[i])}',\r\n`;
str += `dt: '${args.columns[i].dt}',\r\n`;
if (args.columns[i].rqd) str += `rqd: ${args.columns[i].rqd},\r\n`;
if (args.columns[i].cdf) {
str += `default: "${args.columns[i].cdf}",\r\n`;
str += `columnDefault: "${args.columns[i].cdf}",\r\n`;
}
if (args.columns[i].un) str += `un: ${args.columns[i].un},\r\n`;
if (args.columns[i].pk) str += `pk: ${args.columns[i].pk},\r\n`;
if (args.columns[i].ai) str += `ai: ${args.columns[i].ai},\r\n`;
if (args.columns[i].dtxp) str += `dtxp: "${args.columns[i].dtxp}",\r\n`;
if (args.columns[i].dtxs) str += `dtxs: ${args.columns[i].dtxs},\r\n`;
str += `validate: {
func: [],
args: [],
msg: []
},`;
str += `},\r\n`;
}
str += ']\r\n';
return str;
}
_getAbstractType(column) {
return this.getAbstractType(column);
}
getUIDataType(col): any {
const dt = col.dt.toLowerCase();
switch (dt) {
case 'bigint':
case 'tinyint':
case 'int':
case 'smallint':
return 'Number';
case 'decimal':
case 'double':
case 'float':
return 'Decimal';
case 'boolean':
return 'Checkbox';
case 'timestamp':
case 'timestamp_ntz':
return 'DateTime';
case 'date':
return 'Date';
case 'string':
return 'LongText';
case 'interval':
case 'void':
case 'binary':
default:
return 'SpecificDBType';
}
}
getAbstractType(col): any {
const dt = col.dt.toLowerCase();
switch (dt) {
case 'bigint':
case 'tinyint':
case 'decimal':
case 'double':
case 'float':
case 'int':
case 'smallint':
return 'integer';
case 'binary':
return dt;
case 'boolean':
return 'boolean';
case 'interval':
case 'void':
return dt;
case 'timestamp':
case 'timestamp_ntz':
return 'datetime';
case 'date':
return 'date';
case 'string':
return 'string';
}
}
_sequelizeGetType(column) {
let str = '';
switch (column.dt) {
case 'int':
str += `DataTypes.INTEGER(${column.dtxp})`;
if (column.un) str += `.UNSIGNED`;
break;
case 'tinyint':
str += `DataTypes.INTEGER(${column.dtxp})`;
if (column.un) str += `.UNSIGNED`;
break;
case 'smallint':
str += `DataTypes.INTEGER(${column.dtxp})`;
if (column.un) str += `.UNSIGNED`;
break;
case 'mediumint':
str += `DataTypes.INTEGER(${column.dtxp})`;
if (column.un) str += `.UNSIGNED`;
break;
case 'bigint':
str += `DataTypes.BIGINT`;
if (column.un) str += `.UNSIGNED`;
break;
case 'float':
str += `DataTypes.FLOAT`;
break;
case 'decimal':
str += `DataTypes.DECIMAL`;
break;
case 'double':
str += `"DOUBLE(${column.dtxp},${column.ns})"`;
break;
case 'real':
str += `DataTypes.FLOAT`;
break;
case 'bit':
str += `DataTypes.BOOLEAN`;
break;
case 'boolean':
str += `DataTypes.STRING(45)`;
break;
case 'serial':
str += `DataTypes.BIGINT`;
break;
case 'date':
str += `DataTypes.DATEONLY`;
break;
case 'datetime':
str += `DataTypes.DATE`;
break;
case 'timestamp':
str += `DataTypes.DATE`;
break;
case 'time':
str += `DataTypes.TIME`;
break;
case 'year':
str += `"YEAR"`;
break;
case 'char':
str += `DataTypes.CHAR(${column.dtxp})`;
break;
case 'varchar':
str += `DataTypes.STRING(${column.dtxp})`;
break;
case 'nchar':
str += `DataTypes.CHAR(${column.dtxp})`;
break;
case 'text':
str += `DataTypes.TEXT`;
break;
case 'tinytext':
str += `DataTypes.TEXT`;
break;
case 'mediumtext':
str += `DataTypes.TEXT`;
break;
case 'longtext':
str += `DataTypes.TEXT`;
break;
case 'binary':
str += `"BINARY(${column.dtxp})"`;
break;
case 'varbinary':
str += `"VARBINARY(${column.dtxp})"`;
break;
case 'blob':
str += `"BLOB"`;
break;
case 'tinyblob':
str += `"TINYBLOB"`;
break;
case 'mediumblob':
str += `"MEDIUMBLOB"`;
break;
case 'longblob':
str += `"LONGBLOB"`;
break;
case 'enum':
str += `DataTypes.ENUM(${column.dtxp})`;
break;
case 'set':
str += `"SET(${column.dtxp})"`;
break;
case 'geometry':
str += `DataTypes.GEOMETRY`;
break;
case 'point':
str += `"POINT"`;
break;
case 'linestring':
str += `"LINESTRING"`;
break;
case 'polygon':
str += `"POLYGON"`;
break;
case 'multipoint':
str += `"MULTIPOINT"`;
break;
case 'multilinestring':
str += `"MULTILINESTRING"`;
break;
case 'multipolygon':
str += `"MULTIPOLYGON"`;
break;
case 'json':
str += `DataTypes.JSON`;
break;
default:
str += `"${column.dt}"`;
break;
}
return str;
}
_sequelizeGetDefault(column) {
let str = '';
switch (column.dt) {
case 'int':
str += `'${column.cdf}'`;
break;
case 'tinyint':
str += `'${column.cdf}'`;
break;
case 'smallint':
str += `'${column.cdf}'`;
break;
case 'mediumint':
str += `'${column.cdf}'`;
break;
case 'bigint':
str += `'${column.cdf}'`;
break;
case 'float':
str += `'${column.cdf}'`;
break;
case 'decimal':
str += `'${column.cdf}'`;
break;
case 'double':
str += `'${column.cdf}'`;
break;
case 'real':
str += `'${column.cdf}'`;
break;
case 'bit':
str += column.cdf ? column.cdf.split('b')[1] : column.cdf;
break;
case 'boolean':
str += column.cdf;
break;
case 'serial':
str += column.cdf;
break;
case 'date':
str += `sequelize.literal('${column.cdf_sequelize}')`;
break;
case 'datetime':
str += `sequelize.literal('${column.cdf_sequelize}')`;
break;
case 'timestamp':
str += `sequelize.literal('${column.cdf_sequelize}')`;
break;
case 'time':
str += `'${column.cdf}'`;
break;
case 'year':
str += `'${column.cdf}'`;
break;
case 'char':
str += `'${column.cdf}'`;
break;
case 'varchar':
str += `'${column.cdf}'`;
break;
case 'nchar':
str += `'${column.cdf}'`;
break;
case 'text':
str += column.cdf;
break;
case 'tinytext':
str += column.cdf;
break;
case 'mediumtext':
str += column.cdf;
break;
case 'longtext':
str += column.cdf;
break;
case 'binary':
str += column.cdf;
break;
case 'varbinary':
str += column.cdf;
break;
case 'blob':
str += column.cdf;
break;
case 'tinyblob':
str += column.cdf;
break;
case 'mediumblob':
str += column.cdf;
break;
case 'longblob':
str += column.cdf;
break;
case 'enum':
str += `'${column.cdf}'`;
break;
case 'set':
str += `'${column.cdf}'`;
break;
case 'geometry':
str += `'${column.cdf}'`;
break;
case 'point':
str += `'${column.cdf}'`;
break;
case 'linestring':
str += `'${column.cdf}'`;
break;
case 'polygon':
str += `'${column.cdf}'`;
break;
case 'multipoint':
str += `'${column.cdf}'`;
break;
case 'multilinestring':
str += `'${column.cdf}'`;
break;
case 'multipolygon':
str += `'${column.cdf}'`;
break;
case 'json':
str += column.cdf;
break;
}
return str;
}
/* getXcColumnsObject(args) {
const columnsArr = [];
for (const column of args.columns) {
const columnObj = {
validate: {
func: [],
args: [],
msg: []
},
cn: column.cn,
_cn: column._cn || column.cn,
type: this._getAbstractType(column),
dt: column.dt,
uidt: column.uidt || this._getUIDataType(column),
uip: column.uip,
uicn: column.uicn,
...column
};
if (column.rqd) {
columnObj.rqd = column.rqd;
}
if (column.cdf) {
columnObj.default = column.cdf;
columnObj.columnDefault = column.cdf;
}
if (column.un) {
columnObj.un = column.un;
}
if (column.pk) {
columnObj.pk = column.pk;
}
if (column.ai) {
columnObj.ai = column.ai;
}
if (column.dtxp) {
columnObj.dtxp = column.dtxp;
}
if (column.dtxs) {
columnObj.dtxs = column.dtxs;
}
columnsArr.push(columnObj);
}
this.mapDefaultDisplayValue(columnsArr);
return columnsArr;
}*/
/* getObject() {
return {
tn: this.ctx.tn,
_tn: this.ctx._tn,
columns: this.getXcColumnsObject(this.ctx),
pks: [],
hasMany: this.ctx.hasMany,
belongsTo: this.ctx.belongsTo,
dbType: this.ctx.dbType,
type: this.ctx.type,
}
}*/
}
export default ModelXcMetaDatabricks;

3
packages/nocodb/src/db/sql-mgr/code/models/xc/ModelXcMetaFactory.ts

@ -4,6 +4,7 @@ import ModelXcMetaOracle from './ModelXcMetaOracle';
import ModelXcMetaPg from './ModelXcMetaPg';
import ModelXcMetaSqlite from './ModelXcMetaSqlite';
import ModelXcMetaSnowflake from './ModelXcMetaSnowflake';
import ModelXcMetaDatabricks from './ModelXcMetaDatabricks';
import type BaseModelXcMeta from './BaseModelXcMeta';
class ModelXcMetaFactory {
@ -23,6 +24,8 @@ class ModelXcMetaFactory {
return new ModelXcMetaOracle(args);
} else if (connectionConfig.client === 'snowflake') {
return new ModelXcMetaSnowflake(args);
} else if (connectionConfig.client === 'databricks') {
return new ModelXcMetaDatabricks(args);
}
throw new Error('Database not supported');

8
packages/nocodb/src/db/sql-migrator/lib/KnexMigratorv2.ts

@ -397,6 +397,14 @@ export default class KnexMigratorv2 {
database: connectionConfig.connection.database,
schema: source.getConfig()?.schema,
});
} else if (source.type === 'databricks') {
this.emit(
`${connectionConfig.client}: Creating DB if not exists ${connectionConfig.connection.database}`,
);
await sqlClient.createDatabaseIfNotExists({
database: connectionConfig.connection.database,
schema: connectionConfig.connection.schema,
});
} else if (connectionConfig.client !== 'sqlite3') {
this.emit(
`${connectionConfig.client}: Creating DB if not exists ${connectionConfig.connection.database}`,

1
packages/nocodb/src/helpers/PagedResponse.ts

@ -44,7 +44,6 @@ export class PagedResponseImpl<T> {
}
if (additionalProps) Object.assign(this, additionalProps);
}
list: Array<T>;

14
packages/nocodb/src/helpers/columnHelpers.ts

@ -21,6 +21,7 @@ import { GridViewColumn } from '~/models';
import validateParams from '~/helpers/validateParams';
import { getUniqueColumnAliasName } from '~/helpers/getUniqueName';
import Column from '~/models/Column';
import { DriverClient } from '~/utils/nc-config';
export const randomID = customAlphabet(
'1234567890abcdefghijklmnopqrstuvwxyz_',
@ -370,12 +371,19 @@ export async function populateRollupForLTAR({
await GridViewColumn.update(viewCol.id, { show: false });
}
export const sanitizeColumnName = (name: string) => {
export const sanitizeColumnName = (name: string, sourceType?: DriverClient) => {
if (process.env.NC_SANITIZE_COLUMN_NAME === 'false') return name;
const columnName = name.replace(/\W/g, '_');
let columnName = name.replace(/\W/g, '_');
// if column name only contains _ then return as 'field'
if (/^_+$/.test(columnName)) return 'field';
if (/^_+$/.test(columnName)) columnName = 'field';
if (sourceType) {
if (sourceType === DriverClient.DATABRICKS) {
// databricks column name should be lowercase
columnName = columnName.toLowerCase();
}
}
return columnName;
};

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

@ -334,6 +334,14 @@ export async function populateMeta(
let colOrder = 1;
for (const column of columns) {
if (source.type === 'databricks') {
if (column.pk && !column.cdf) {
column.meta = {
ag: 'nc',
};
}
}
await Column.insert({
uidt: column.uidt || getColumnUiType(source, column),
fk_model_id: models2[table.tn].id,

3
packages/nocodb/src/helpers/syncMigration.ts

@ -35,7 +35,6 @@ export async function syncBaseMigration(
await migrator.migrationsUp({ source });
} catch (e) {
console.log(e);
// throw e;
throw e;
}
}

3
packages/nocodb/src/meta/meta.service.ts

@ -249,6 +249,9 @@ export class MetaService {
case MetaTable.API_TOKENS:
prefix = 'tkn';
break;
case MetaTable.EXTENSIONS:
prefix = 'ext';
break;
default:
prefix = 'nc';
break;

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -31,6 +31,7 @@ import * as nc_041_calendar_view from '~/meta/migrations/v2/nc_041_calendar_view
import * as nc_042_user_block from '~/meta/migrations/v2/nc_042_user_block';
import * as nc_043_user_refresh_token from '~/meta/migrations/v2/nc_043_user_refresh_token';
import * as nc_044_view_column_index from '~/meta/migrations/v2/nc_044_view_column_index';
import * as nc_045_extensions from '~/meta/migrations/v2/nc_045_extensions';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -73,6 +74,7 @@ export default class XcMigrationSourcev2 {
'nc_042_user_block',
'nc_043_user_refresh_token',
'nc_044_view_column_index',
'nc_045_extensions',
]);
}
@ -148,6 +150,8 @@ export default class XcMigrationSourcev2 {
return nc_043_user_refresh_token;
case 'nc_044_view_column_index':
return nc_044_view_column_index;
case 'nc_045_extensions':
return nc_045_extensions;
}
}
}

30
packages/nocodb/src/meta/migrations/v2/nc_045_extensions.ts

@ -0,0 +1,30 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.createTable(MetaTable.EXTENSIONS, (table) => {
table.string('id', 20).primary();
table.string('base_id', 20).index();
table.string('fk_user_id', 20);
table.string('extension_id');
table.string('title');
table.text('kv_store');
table.text('meta');
table.float('order');
table.timestamps(true, true);
});
};
const down = async (knex: Knex) => {
await knex.schema.dropTable(MetaTable.EXTENSIONS);
};
export { up, down };

4
packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts

@ -14,6 +14,7 @@ import {
Audit,
Base,
Column,
Extension,
Filter,
FormViewColumn,
GalleryViewColumn,
@ -126,6 +127,9 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
} else if (params.syncId) {
const syncSource = await SyncSource.get(req.params.syncId);
req.ncBaseId = syncSource.base_id;
} else if (params.extensionId) {
const extension = await Extension.get(req.params.extensionId);
req.ncBaseId = extension.base_id;
}
// extract fk_model_id from query params only if it's audit post endpoint
else if (

3
packages/nocodb/src/models/Base.ts

@ -1,7 +1,6 @@
import type { BaseType, BoolType, MetaType } from 'nocodb-sdk';
import type { DB_TYPES } from '~/utils/globals';
import Source from '~/models/Source';
import { BaseUser } from '~/models';
import { BaseUser, Source } from '~/models';
import Noco from '~/Noco';
import {
CacheDelDirection,

161
packages/nocodb/src/models/Extension.ts

@ -0,0 +1,161 @@
import { prepareForDb, prepareForResponse } from '~/utils/modelUtils';
import Noco from '~/Noco';
import { extractProps } from '~/helpers/extractProps';
import {
CacheDelDirection,
CacheGetType,
CacheScope,
MetaTable,
} from '~/utils/globals';
import NocoCache from '~/cache/NocoCache';
export default class Extension {
id?: string;
base_id?: string;
fk_user_id?: string;
extension_id?: string;
title?: string;
kv_store?: any;
meta?: any;
order?: number;
constructor(extension: Partial<Extension>) {
Object.assign(this, extension);
}
public static async get(extensionId: string, ncMeta = Noco.ncMeta) {
let extension = await NocoCache.get(
`${CacheScope.EXTENSION}:${extensionId}`,
CacheGetType.TYPE_OBJECT,
);
if (!extension) {
extension = await ncMeta.metaGet2(
null,
null,
MetaTable.EXTENSIONS,
extensionId,
);
if (extension) {
extension = prepareForResponse(extension, ['kv_store', 'meta']);
NocoCache.set(`${CacheScope.EXTENSION}:${extensionId}`, extension);
}
}
return extension && new Extension(extension);
}
static async list(baseId: string, ncMeta = Noco.ncMeta) {
const cachedList = await NocoCache.getList(CacheScope.EXTENSION, [baseId]);
let { list: extensionList } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !extensionList.length) {
extensionList = await ncMeta.metaList(null, null, MetaTable.EXTENSIONS, {
condition: {
base_id: baseId,
},
orderBy: {
created_at: 'asc',
},
});
if (extensionList) {
extensionList = extensionList.map((extension) =>
prepareForResponse(extension, ['kv_store', 'meta']),
);
NocoCache.setList(CacheScope.EXTENSION, [baseId], extensionList);
}
}
return extensionList
?.sort((a, b) => (a?.order ?? Infinity) - (b?.order ?? Infinity))
.map((extension) => new Extension(extension));
}
public static async insert(
extension: Partial<Extension>,
ncMeta = Noco.ncMeta,
) {
const insertObj = extractProps(extension, [
'id',
'base_id',
'fk_user_id',
'extension_id',
'title',
'kv_store',
'meta',
'order',
]);
if (insertObj.order === null || insertObj.order === undefined) {
insertObj.order = await ncMeta.metaGetNextOrder(MetaTable.EXTENSIONS, {
base_id: insertObj.base_id,
});
}
const { id } = await ncMeta.metaInsert2(
null,
null,
MetaTable.EXTENSIONS,
prepareForDb(insertObj, ['kv_store', 'meta']),
);
return this.get(id, ncMeta).then(async (res) => {
await NocoCache.appendToList(
CacheScope.EXTENSION,
[extension.base_id],
`${CacheScope.EXTENSION}:${id}`,
);
return res;
});
}
public static async update(
extensionId: string,
extension: Partial<Extension>,
ncMeta = Noco.ncMeta,
) {
const updateObj = extractProps(extension, [
'base_id',
'fk_user_id',
'extension_id',
'title',
'kv_store',
'meta',
'order',
]);
// set meta
await ncMeta.metaUpdate(
null,
null,
MetaTable.EXTENSIONS,
prepareForDb(updateObj, ['kv_store', 'meta']),
extensionId,
);
await NocoCache.update(
`${CacheScope.EXTENSION}:${extensionId}`,
prepareForResponse(updateObj, ['kv_store', 'meta']),
);
return this.get(extensionId, ncMeta);
}
static async delete(extensionId: any, ncMeta = Noco.ncMeta) {
const res = await ncMeta.metaDelete(
null,
null,
MetaTable.EXTENSIONS,
extensionId,
);
await NocoCache.deepDel(
`${CacheScope.EXTENSION}:${extensionId}`,
CacheDelDirection.CHILD_TO_PARENT,
);
return res;
}
}

10
packages/nocodb/src/models/Source.ts

@ -1,11 +1,9 @@
import { UITypes } from 'nocodb-sdk';
import CryptoJS from 'crypto-js';
import { v4 as uuidv4 } from 'uuid';
import type { DriverClient } from '~/utils/nc-config';
import type { BoolType, SourceType } from 'nocodb-sdk';
import type { DB_TYPES } from '~/utils/globals';
import Model from '~/models/Model';
import Base from '~/models/Base';
import SyncSource from '~/models/SyncSource';
import { Base, Model, SyncSource } from '~/models';
import NocoCache from '~/cache/NocoCache';
import {
CacheDelDirection,
@ -29,7 +27,7 @@ export default class Source implements SourceType {
id?: string;
base_id?: string;
alias?: string;
type?: (typeof DB_TYPES)[number];
type?: DriverClient;
is_meta?: BoolType;
config?: string;
inflection_column?: string;
@ -39,7 +37,7 @@ export default class Source implements SourceType {
enabled?: BoolType;
meta?: any;
constructor(source: Partial<Source>) {
constructor(source: Partial<SourceType>) {
Object.assign(this, source);
}

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

@ -41,3 +41,4 @@ export { default as LinksColumn } from './LinksColumn';
export { default as Notification } from './Notification';
export { default as PresignedUrl } from './PresignedUrl';
export { default as UserRefreshToken } from './UserRefreshToken';
export { default as Extension } from './Extension';

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

@ -320,7 +320,7 @@ export class AtImportProcessor {
const uniqueFieldNameGen = getUniqueNameGenerator('field', table_name);
// truncate to 50 chars if character if exceeds above 50
const col_name = sanitizeColumnName(name)?.slice(0, 50);
const col_name = sanitizeColumnName(name, getRootDbType())?.slice(0, 50);
// for knex, replace . with _
const col_alias = name.trim().replace(/\./g, '_');
@ -498,7 +498,10 @@ export class AtImportProcessor {
// Enable to use aTbl identifiers as is: table.id = tblSchema[i].id;
table.title = tblSchema[i].name;
let sanitizedName = sanitizeColumnName(tblSchema[i].name);
let sanitizedName = sanitizeColumnName(
tblSchema[i].name,
getRootDbType(),
);
// truncate to 50 chars if character if exceeds above 50
// upto 64 should be fine but we are keeping it to 50 since

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

@ -187,7 +187,8 @@ export class ImportService {
const colRef = modelData.columns.find(
(a) =>
a.column_name &&
sanitizeColumnName(a.column_name) === col.column_name,
sanitizeColumnName(a.column_name, source.type) ===
col.column_name,
);
idMap.set(colRef.id, col.id);

29
packages/nocodb/src/modules/jobs/jobs/source-create/source-create.processor.ts

@ -25,19 +25,28 @@ export class SourceCreateProcessor {
this.debugLog(log);
};
const createdBase = await this.sourcesService.baseCreate({
baseId,
source,
logger: logBasic,
req,
});
if (createdBase.isMeta()) {
delete createdBase.config;
const { source: createdSource, error } =
await this.sourcesService.baseCreate({
baseId,
source,
logger: logBasic,
req,
});
if (error) {
await this.sourcesService.baseDelete({
sourceId: createdSource.id,
req: {},
});
throw error;
}
if (createdSource.isMeta()) {
delete createdSource.config;
}
this.debugLog(`job completed for ${job.id}`);
return createdBase;
return createdSource;
}
}

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

@ -76,6 +76,8 @@ import { NotificationsController } from '~/controllers/notifications.controller'
import { NotificationsGateway } from '~/gateways/notifications/notifications.gateway';
import { CommandPaletteService } from '~/services/command-palette.service';
import { CommandPaletteController } from '~/controllers/command-palette.controller';
import { ExtensionsService } from '~/services/extensions.service';
import { ExtensionsController } from '~/controllers/extensions.controller';
export const metaModuleMetadata = {
imports: [
@ -126,6 +128,7 @@ export const metaModuleMetadata = {
SharedBasesController,
NotificationsController,
CommandPaletteController,
ExtensionsController,
]
: []),
],
@ -169,6 +172,7 @@ export const metaModuleMetadata = {
NotificationsService,
NotificationsGateway,
CommandPaletteService,
ExtensionsService,
],
exports: [
TablesService,

9
packages/nocodb/src/schema/swagger-v2.json

@ -9737,7 +9737,8 @@
"oracledb",
"pg",
"snowflake",
"sqlite3"
"sqlite3",
"databricks"
],
"example": "mysql2",
"type": "string"
@ -11989,7 +11990,8 @@
"oracledb",
"pg",
"snowflake",
"sqlite3"
"sqlite3",
"databricks"
],
"example": "mysql2",
"type": "string"
@ -12153,7 +12155,8 @@
"oracledb",
"pg",
"snowflake",
"sqlite3"
"sqlite3",
"databricks"
],
"type": "string"
}

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

@ -14832,7 +14832,8 @@
"oracledb",
"pg",
"snowflake",
"sqlite3"
"sqlite3",
"databricks"
],
"example": "mysql2",
"type": "string"
@ -14977,7 +14978,8 @@
"oracledb",
"pg",
"snowflake",
"sqlite3"
"sqlite3",
"databricks"
],
"example": "mysql2",
"type": "string"
@ -17295,6 +17297,181 @@
"description": "Get dynamic command palette suggestions based on scope"
}
},
"/api/v2/extensions/{baseId}": {
"parameters": [
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "p124hhlkbeasewh",
"type": "string"
},
"name": "baseId",
"in": "path",
"required": true,
"description": "Unique Base ID"
}
],
"get": {
"summary": "Get Extensions",
"operationId": "extensions-list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
}
}
}
},
"tags": [
"Extensions"
],
"description": "Get all extensions for a given base",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
},
"post": {
"summary": "Create Extension",
"operationId": "extensions-create",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
}
},
"tags": [
"Extensions"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"description": "Create a new extension for a given base",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
}
},
"/api/v2/extensions/{extensionId}": {
"parameters": [
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "ext124dflkcvasewh",
"type": "string"
},
"name": "extensionId",
"in": "path",
"required": true,
"description": "Unique Extension ID"
}
],
"get": {
"summary": "Get Extension",
"operationId": "extensions-read",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Extensions"
],
"description": "Get extension details",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
},
"patch": {
"summary": "Update Extension",
"operationId": "extensions-update",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
}
},
"tags": [
"Extensions"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"description": "Update extension details",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
},
"delete": {
"summary": "Delete Extension",
"operationId": "extensions-delete",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
}
},
"tags": [
"Extensions"
],
"description": "Delete extension",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
}
},
"/jobs/listen": {
"post": {
"summary": "Jobs Listen",
@ -17900,7 +18077,8 @@
"oracledb",
"pg",
"snowflake",
"sqlite3"
"sqlite3",
"databricks"
],
"example": "mysql2",
"type": "string"
@ -18064,7 +18242,8 @@
"oracledb",
"pg",
"snowflake",
"sqlite3"
"sqlite3",
"databricks"
],
"type": "string"
}
@ -25424,6 +25603,72 @@
"x-stoplight": {
"id": "psbv6c6y9qvbu"
}
},
"Extension": {
"type": "object",
"properties": {
"id": {
"$ref": "#/components/schemas/Id",
"description": "Unique ID"
},
"base_id": {
"$ref": "#/components/schemas/Id",
"description": "Unique Base ID"
},
"fk_user_id": {
"$ref": "#/components/schemas/Id",
"description": "Unique User ID"
},
"extension_id": {
"type": "string",
"description": "Extension ID"
},
"title": {
"type": "string",
"description": "Extension Title"
},
"kv_store": {
"$ref": "#/components/schemas/Meta",
"description": "Key Value Store for the extension"
},
"meta": {
"$ref": "#/components/schemas/Meta",
"description": "Meta data for the extension"
},
"order": {
"type": "number",
"description": "Order of the extension"
}
}
},
"ExtensionReq": {
"type": "object",
"properties": {
"base_id": {
"$ref": "#/components/schemas/Id",
"description": "Unique Base ID"
},
"title": {
"type": "string",
"description": "Extension Title"
},
"extension_id": {
"type": "string",
"description": "Extension ID"
},
"kv_store": {
"$ref": "#/components/schemas/Meta",
"description": "Key Value Store for the extension"
},
"meta": {
"$ref": "#/components/schemas/Meta",
"description": "Meta data for the extension"
},
"order": {
"type": "number",
"description": "Order of the extension"
}
}
}
},
"responses": {

3
packages/nocodb/src/services/app-hooks/app-hooks.service.ts

@ -248,6 +248,9 @@ export class AppHooksService {
event: AppEvents.USER_EMAIL_VERIFICATION,
data: UserEmailVerificationEvent,
): void;
emit(event: AppEvents.EXTENSION_CREATE, data: any): void;
emit(event: AppEvents.EXTENSION_UPDATE, data: any): void;
emit(event: AppEvents.EXTENSION_DELETE, data: any): void;
emit(event, data): void {
this.eventEmitter.emit(event, data);
this.eventEmitter.emit(ALL_EVENTS, { event, data: data });

10
packages/nocodb/src/services/columns.service.ts

@ -165,7 +165,10 @@ export class ColumnsService {
const mxColumnLength = Column.getMaxColumnNameLength(sqlClientType);
if (!isVirtualCol(param.column)) {
param.column.column_name = sanitizeColumnName(param.column.column_name);
param.column.column_name = sanitizeColumnName(
param.column.column_name,
source.type,
);
}
// trim leading and trailing spaces from column title as knex trim them by default
@ -1458,7 +1461,10 @@ export class ColumnsService {
const mxColumnLength = Column.getMaxColumnNameLength(sqlClientType);
if (!isVirtualCol(param.column)) {
param.column.column_name = sanitizeColumnName(param.column.column_name);
param.column.column_name = sanitizeColumnName(
param.column.column_name,
source.type,
);
}
// trim leading and trailing spaces from column title as knex trim them by default

19
packages/nocodb/src/services/extensions.service.spec.ts

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

74
packages/nocodb/src/services/extensions.service.ts

@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { AppEvents, type ExtensionReqType } from 'nocodb-sdk';
import type { NcRequest } from '~/interface/config';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { validatePayload } from '~/helpers';
import { Extension } from '~/models';
@Injectable()
export class ExtensionsService {
constructor(private readonly appHooksService: AppHooksService) {}
async extensionList(param: { baseId: string }) {
return await Extension.list(param.baseId);
}
async extensionRead(param: { extensionId: string }) {
return await Extension.get(param.extensionId);
}
async extensionCreate(param: {
extension: ExtensionReqType;
req: NcRequest;
}) {
validatePayload(
'swagger.json#/components/schemas/ExtensionReq',
param.extension,
);
const res = await Extension.insert({
...param.extension,
fk_user_id: param.req.user.id,
});
this.appHooksService.emit(AppEvents.EXTENSION_CREATE, {
extensionId: res.id,
extension: param.extension,
req: param.req,
});
return res;
}
async extensionUpdate(param: {
extensionId: string;
extension: ExtensionReqType;
req: NcRequest;
}) {
validatePayload(
'swagger.json#/components/schemas/ExtensionReq',
param.extension,
);
const res = await Extension.update(param.extensionId, param.extension);
this.appHooksService.emit(AppEvents.EXTENSION_UPDATE, {
extensionId: param.extensionId,
extension: param.extension,
req: param.req,
});
return res;
}
async extensionDelete(param: { extensionId: string; req: NcRequest }) {
const res = await Extension.delete(param.extensionId);
this.appHooksService.emit(AppEvents.EXTENSION_DELETE, {
extensionId: param.extensionId,
req: param.req,
});
return res;
}
}

39
packages/nocodb/src/services/sources.service.ts

@ -83,13 +83,18 @@ export class SourcesService {
source: BaseReqType;
logger?: (message: string) => void;
req: NcRequest;
}) {
}): Promise<{
source: Source;
error?: any;
}> {
validatePayload('swagger.json#/components/schemas/BaseReq', param.source);
// type | base | baseId
const baseBody = param.source;
const base = await Base.getWithInfo(param.baseId);
let error;
param.logger?.('Creating the source');
const source = await Source.createBase({
@ -98,26 +103,30 @@ export class SourcesService {
baseId: base.id,
});
await syncBaseMigration(base, source);
try {
await syncBaseMigration(base, source);
param.logger?.('Populating meta');
param.logger?.('Populating meta');
const info = await populateMeta(source, base, param.logger);
const info = await populateMeta(source, base, param.logger);
await populateRollupColumnAndHideLTAR(source, base);
await populateRollupColumnAndHideLTAR(source, base);
this.appHooksService.emit(AppEvents.APIS_CREATED, {
info,
req: param.req,
});
this.appHooksService.emit(AppEvents.APIS_CREATED, {
info,
req: param.req,
});
delete source.config;
delete source.config;
this.appHooksService.emit(AppEvents.BASE_CREATE, {
source,
req: param.req,
});
this.appHooksService.emit(AppEvents.BASE_CREATE, {
source,
req: param.req,
});
} catch (e) {
error = e;
}
return source;
return { source, error };
}
}

13
packages/nocodb/src/services/tables.service.ts

@ -76,6 +76,12 @@ export class TablesService {
);
}
if (source.type === 'databricks') {
param.table.table_name = param.table.table_name
.replace(/\s/g, '_')
.toLowerCase();
}
if (source.isMeta(true) && base.prefix && !source.isMeta(true, 1)) {
if (!param.table.table_name.startsWith(base.prefix)) {
param.table.table_name = `${base.prefix}${param.table.table_name}`;
@ -512,6 +518,12 @@ export class TablesService {
);
}
if (source.type === 'databricks') {
tableCreatePayLoad.table_name = tableCreatePayLoad.table_name
.replace(/\s/g, '_')
.toLowerCase();
}
if (source.is_meta && base.prefix) {
if (!tableCreatePayLoad.table_name.startsWith(base.prefix)) {
tableCreatePayLoad.table_name = `${base.prefix}_${tableCreatePayLoad.table_name}`;
@ -594,6 +606,7 @@ export class TablesService {
// - 5 is a buffer for suffix
column.column_name = sanitizeColumnName(
column.column_name.slice(0, mxColumnLength - 5),
source.type,
);
if (uniqueColumnNameCount[column.column_name]) {

10
packages/nocodb/src/utils/acl.ts

@ -131,6 +131,13 @@ const permissionScopes = {
'baseApiTokenList',
'baseApiTokenCreate',
'baseApiTokenDelete',
// Extensions
'extensionList',
'extensionRead',
'extensionCreate',
'extensionUpdate',
'extensionDelete',
],
};
@ -192,6 +199,9 @@ const rolePermissions:
nestedDataList: true,
baseUserList: true,
extensionList: true,
extensionRead: true,
},
},
[ProjectRoles.COMMENTER]: {

3
packages/nocodb/src/utils/globals.ts

@ -47,6 +47,7 @@ export enum MetaTable {
STORE = 'nc_store',
NOTIFICATION = 'notification',
USER_REFRESH_TOKENS = 'nc_user_refresh_tokens',
EXTENSIONS = 'nc_extensions',
}
export enum MetaTableOldV2 {
@ -173,6 +174,7 @@ export enum CacheScope {
MODEL_ALIAS = 'modelAlias',
VIEW_ALIAS = 'viewAlias',
SSO_CLIENT = 'ssoClient',
EXTENSION = 'uiExtension',
}
export enum CacheGetType {
@ -256,4 +258,5 @@ export const DB_TYPES = <const>[
'snowflake',
'oracledb',
'pg',
'databricks',
];

2
packages/nocodb/src/utils/nc-config/constants.ts

@ -77,8 +77,10 @@ export const knownQueryParams = [
export enum DriverClient {
MYSQL = 'mysql2',
MYSQL_LEGACY = 'mysql',
MSSQL = 'mssql',
PG = 'pg',
SQLITE = 'sqlite3',
SNOWFLAKE = 'snowflake',
DATABRICKS = 'databricks',
}

Loading…
Cancel
Save