Browse Source

Nc feat/one to one (#7915)

* feat: one-to-one relation - wip

* feat: one-to-one relation

* feat: one-to-one relation - link, unlink, list, excluded list, single query

* fix: pass proper fk value

* feat: add non-single-query support

* feat: filter, sort and delete

* fix: ui - keep only one as linked record in ui - similar to bt

* fix: initial column name correction

* fix: field modal related fixes

* fix: nested insert related bugs

* fix: nested insert corrections

* fix: formula support

* fix: delete cell data

* fix: invalid offset issue

* fix: form submit issue

* fix: return first element - oo relation

* fix: Lookup column rendering

* fix: add link api correction

* fix: sort and group by menu correction

* chore: lint

* refactor: spacing between radio buttons

* fix: undo/redo support with delete key

* fix: formula related issues

* fix: duplicate related issues

* fix: ui label and icon color

* chore: lint

* chore: reset page if offset is beyond offset(temporary solution)

* refactor: suggested review changes

* refactor: suggested review changes

* chore: lint

* fix: missing await

Signed-off-by: Pranav C <pranavxc@gmail.com>

* refactor: add comments

Signed-off-by: Pranav C <pranavxc@gmail.com>

---------

Signed-off-by: Pranav C <pranavxc@gmail.com>
pull/7921/head
Pranav C 7 months ago committed by GitHub
parent
commit
0e13bff899
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      packages/nc-gui/assets/nc-icons/onetoone.svg
  2. 2
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  3. 13
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  4. 4
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  5. 9
      packages/nc-gui/components/smartsheet/grid/Table.vue
  6. 6
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  7. 2
      packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts
  8. 7
      packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue
  9. 7
      packages/nc-gui/components/smartsheet/toolbar/CreateSort.vue
  10. 5
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  11. 18
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  12. 12
      packages/nc-gui/components/virtual-cell/Lookup.vue
  13. 150
      packages/nc-gui/components/virtual-cell/OneToOne.vue
  14. 27
      packages/nc-gui/composables/useLTARStore.ts
  15. 4
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  16. 7
      packages/nc-gui/composables/useMultiSelect/index.ts
  17. 11
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  18. 1
      packages/nc-gui/lang/en.json
  19. 2
      packages/nc-gui/utils/iconUtils.ts
  20. 3
      packages/nc-gui/utils/virtualCell.ts
  21. 1
      packages/nocodb-sdk/src/lib/globals.ts
  22. 20
      packages/nocodb/src/controllers/data-alias-nested.controller.ts
  23. 428
      packages/nocodb/src/db/BaseModelSqlv2.ts
  24. 31
      packages/nocodb/src/db/conditionV2.ts
  25. 95
      packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts
  26. 21
      packages/nocodb/src/db/genRollupSelectv2.ts
  27. 35
      packages/nocodb/src/db/generateLookupSelectQuery.ts
  28. 1
      packages/nocodb/src/db/sql-client/lib/mysql/MysqlClient.ts
  29. 2
      packages/nocodb/src/db/sql-client/lib/pg/PgClient.ts
  30. 5
      packages/nocodb/src/db/sql-client/lib/sqlite/SqliteClient.ts
  31. 108
      packages/nocodb/src/helpers/columnHelpers.ts
  32. 12
      packages/nocodb/src/helpers/getAst.ts
  33. 2
      packages/nocodb/src/models/LinkToAnotherRecordColumn.ts
  34. 16
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.processor.ts
  35. 5
      packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts
  36. 29
      packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts
  37. 6
      packages/nocodb/src/schema/swagger-v2.json
  38. 15
      packages/nocodb/src/schema/swagger.json
  39. 5
      packages/nocodb/src/services/api-docs/swaggerV2/templates/paths.ts
  40. 268
      packages/nocodb/src/services/columns.service.ts
  41. 41
      packages/nocodb/src/services/data-alias-nested.service.ts
  42. 2
      packages/nocodb/src/utils/acl.ts

6
packages/nc-gui/assets/nc-icons/onetoone.svg

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 10C11.8954 10 11 9.10457 11 8C11 6.89543 11.8954 6 13 6C14.1046 6 15 6.89543 15 8C15 9.10457 14.1046 10 13 10Z" stroke="#F3ECFA" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 10C1.89543 10 1 9.10457 1 8C1 6.89543 1.89543 6 3 6C4.10457 6 5 6.89543 5 8C5 9.10457 4.10457 10 3 10Z" stroke="#F3ECFA" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8L11 8" stroke="#F3ECFA" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 10C11.8954 10 11 9.10457 11 8C11 6.89543 11.8954 6 13 6C14.1046 6 15 6.89543 15 8C15 9.10457 14.1046 10 13 10Z" stroke="purple" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 10C1.89543 10 1 9.10457 1 8C1 6.89543 1.89543 6 3 6C4.10457 6 5 6.89543 5 8C5 9.10457 4.10457 10 3 10Z" stroke="purple" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8L11 8" stroke="purple" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 628 B

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

@ -20,6 +20,7 @@ import {
isLink,
isLookup,
isMm,
isOo,
isPrimary,
isQrCode,
isRollup,
@ -111,6 +112,7 @@ onUnmounted(() => {
<LazyVirtualCellHasMany v-else-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" />
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
<LazyVirtualCellOneToOne v-else-if="isOo(column)" />
<LazyVirtualCellRollup v-else-if="isRollup(column)" />
<LazyVirtualCellFormula v-else-if="isFormula(column)" />
<LazyVirtualCellQrCode v-else-if="isQrCode(column)" />

13
packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ModelTypes, MssqlUi, SqliteUi, UITypes } from 'nocodb-sdk'
import { ModelTypes, MssqlUi, RelationTypes, SqliteUi, UITypes } from 'nocodb-sdk'
import { MetaInj, inject, ref, storeToRefs, useBase, useVModel } from '#imports'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
@ -52,16 +52,19 @@ const refTables = computed(() => {
const filterOption = (value: string, option: { key: string }) => option.key.toLowerCase().includes(value.toLowerCase())
const isLinks = computed(() => vModel.value.uidt === UITypes.Links)
const isLinks = computed(() => vModel.value.uidt === UITypes.Links && vModel.value.type !== RelationTypes.ONE_TO_ONE)
const oneToOneEnabled = ref(false)
</script>
<template>
<div class="w-full flex flex-col mb-2 mt-4">
<div class="border-2 p-6">
<a-form-item v-bind="validateInfos.type" class="nc-ltar-relation-type">
<a-radio-group v-model:value="vModel.type" name="type" v-bind="validateInfos.type">
<a-radio value="hm">{{ $t('title.hasMany') }}</a-radio>
<a-radio-group v-model:value="vModel.type" name="type" v-bind="validateInfos.type" class="!flex flex-col gap-2">
<a-radio @dblclick="oneToOneEnabled = !oneToOneEnabled" value="hm">{{ $t('title.hasMany') }}</a-radio>
<a-radio value="mm">{{ $t('title.manyToMany') }}</a-radio>
<a-radio v-if="oneToOneEnabled" value="oo">{{ $t('title.oneToOne') }}</a-radio>
</a-radio-group>
</a-form-item>
@ -102,7 +105,7 @@ const isLinks = computed(() => vModel.value.uidt === UITypes.Links)
</div>
<div v-if="advancedOptions" class="flex flex-col p-6 gap-4 border-2 mt-2">
<LazySmartsheetColumnLinkOptions v-model:value="vModel" class="-my-2" />
<LazySmartsheetColumnLinkOptions v-if="isLinks" v-model:value="vModel" class="-my-2" />
<template v-if="!isXcdbBase">
<div class="flex flex-row space-x-2">
<a-form-item class="flex w-1/2" :label="$t('labels.onUpdate')">

4
packages/nc-gui/components/smartsheet/column/RollupOptions.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, TableType, UITypes } from 'nocodb-sdk'
import { type ColumnType, type LinkToAnotherRecordType, RelationTypes, type TableType, type UITypes } from 'nocodb-sdk'
import { getAvailableRollupForUiType, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from '#imports'
import {
@ -58,7 +58,7 @@ const refTables = computed(() => {
.filter(
(c) =>
isLinksOrLTAR(c) &&
(c.colOptions as LinkToAnotherRecordType).type !== 'bt' &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((c.colOptions as LinkToAnotherRecordType).type) &&
!c.system &&
c.source_id === meta.value?.source_id,
)

9
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -35,6 +35,7 @@ import {
isEeUI,
isMac,
isMm,
isOo,
message,
onClickOutside,
onMounted,
@ -294,7 +295,7 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
columnObj.id === col.id
) {
if (rowRefs.value) {
if (isBt(columnObj)) {
if (isBt(columnObj) || isOo(columnObj)) {
rowObj.row[columnObj.title] = row.row[columnObj.title]
await rowRefs.value[ctx.row]!.addLTARRef(rowObj.row[columnObj.title], columnObj)
@ -335,7 +336,7 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const columnObj = fields.value[ctx.col]
if (rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && columnObj.id === col.id) {
if (rowRefs.value) {
if (isBt(columnObj)) {
if (isBt(columnObj) || isOo(columnObj)) {
await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
} else if (isMm(columnObj)) {
await rowRefs.value[ctx.row]!.cleaMMCell(columnObj)
@ -357,7 +358,7 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
},
scope: defineViewScope({ view: view.value }),
})
if (isBt(columnObj) && rowRefs.value) await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
if ((isBt(columnObj) || isOo(columnObj)) && rowRefs.value) await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
return
}
@ -928,7 +929,7 @@ async function clearSelectedRangeOfCells() {
// TODO handle LinkToAnotherRecord
if (isVirtualCol(col)) {
if ((isBt(col) || isMm(col)) && !isInfoShown) {
if ((isBt(col) || isOo(col) || isMm(col)) && !isInfoShown) {
message.info(t('msg.info.groupClearIsNotSupportedOnLinksColumn'))
isInfoShown = true
}

6
packages/nc-gui/components/smartsheet/header/VirtualCell.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ColumnReqType, ColumnType, FormulaType, LinkToAnotherRecordType, LookupType, RollupType } from 'nocodb-sdk'
import { UITypes, UITypesName, substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
import { RelationTypes, UITypes, UITypesName, substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
import {
ColumnInj,
IsFormInj,
@ -122,6 +122,10 @@ const columnTypeName = computed(() => {
if (column.value.uidt === UITypes.LongText && parseProp(column?.value?.meta)?.richMode) {
return UITypesName.RichText
}
if (column.value.uidt === UITypes.LinkToAnotherRecord && column.value.colOptions?.type === RelationTypes.ONE_TO_ONE) {
return UITypesName[UITypes.Links]
}
return column.value.uidt ? UITypesName[column.value.uidt] : ''
})

2
packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts

@ -30,6 +30,8 @@ const renderIcon = (column: ColumnType, relationColumn?: ColumnType) => {
return { icon: iconMap.hm_solid }
case RelationTypes.BELONGS_TO:
return { icon: iconMap.bt_solid }
case RelationTypes.ONE_TO_ONE:
return { icon: iconMap.oneToOneSolid, color: 'text-blue-500' }
}
break
case UITypes.SpecificDBType:

7
packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue

@ -50,7 +50,12 @@ const options = computed<ColumnType[]>(
return false
} else {
/** ignore hasmany and manytomany relations if it's using within group menu */
return !(isLinksOrLTAR(c) && (c.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO)
return !(
isLinksOrLTAR(c) &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(
(c.colOptions as LinkToAnotherRecordType).type as RelationTypes,
)
)
}
})
.filter((c: ColumnType) => !groupBy.value.find((g) => g.column?.id === c.id))

7
packages/nc-gui/components/smartsheet/toolbar/CreateSort.vue

@ -46,7 +46,12 @@ const options = computed<ColumnType[]>(
return false
} else {
/** ignore hasmany and manytomany relations if it's using within sort menu */
return !(isLinksOrLTAR(c) && (c.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO)
return !(
isLinksOrLTAR(c) &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(
(c.colOptions as LinkToAnotherRecordType).type as RelationTypes,
)
)
/** ignore virtual fields which are system fields ( mm relation ) and qr code fields */
}
})

5
packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue

@ -67,7 +67,10 @@ const availableColumns = computed(() => {
return false
} else {
/** ignore hasmany and manytomany relations if it's using within sort menu */
return !(isLinksOrLTAR(c) && (c.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO)
return !(
isLinksOrLTAR(c) &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((c.colOptions as LinkToAnotherRecordType).type)
)
/** ignore virtual fields which are system fields ( mm relation ) and qr code fields */
}
})

18
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -42,12 +42,8 @@ const listItemsDlg = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, unlink } = useProvideLTARStore(
column as Ref<Required<ColumnType>>,
row,
isNew,
reloadRowTrigger.trigger,
)
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
useProvideLTARStore(column as Ref<Required<ColumnType>>, row, isNew, reloadRowTrigger.trigger)
await loadRelatedTableMeta()
@ -96,10 +92,14 @@ watch([listItemsDlg], () => {
<template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && relatedTableDisplayValueProp">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"
:value="!Array.isArray(value) && typeof value === 'object' ? value[relatedTableDisplayValueProp] : value"
:value="
!Array.isArray(value) && typeof value === 'object'
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
: value
"
:column="belongsToColumn"
:show-unlink-button="true"
@unlink="unlinkRef(value)"
@ -116,7 +116,7 @@ watch([listItemsDlg], () => {
>
<GeneralIcon
:icon="addIcon"
class="text-sm nc-action-icon group-focus:visible invisible text-gray-500/50 hover:text-gray-500 select-none group-hover:(text-gray-500) nc-plus"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/>
</div>

12
packages/nc-gui/components/virtual-cell/Lookup.vue

@ -77,9 +77,12 @@ watch([lookupColumn, rowHeight], () => {
const arrValue = computed(() => {
if (!cellValue.value) return []
// if lookup column is Attachment and relation type is Belongs to wrap the value in an array
// if lookup column is Attachment and relation type is Belongs/OneToOne to wrap the value in an array
// since the attachment component expects an array or JSON string array
if (lookupColumn.value?.uidt === UITypes.Attachment && relationColumn.value?.colOptions?.type === RelationTypes.BELONGS_TO)
if (
lookupColumn.value?.uidt === UITypes.Attachment &&
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(relationColumn.value?.colOptions?.type)
)
return [cellValue.value]
// TODO: We are filtering null as cell value can be null. Find the root cause and fix it
@ -114,11 +117,12 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)" class="flex h-full">
<!-- If non-belongs-to LTAR column then pass the array value, else iterate and render -->
<!-- If non-belongs-to and non-one-to-one LTAR column then pass the array value, else iterate and render -->
<template
v-if="
lookupColumn.uidt !== UITypes.LinkToAnotherRecord ||
(lookupColumn.uidt === UITypes.LinkToAnotherRecord && lookupColumn.colOptions.type === RelationTypes.BELONGS_TO)
(lookupColumn.uidt === UITypes.LinkToAnotherRecord &&
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(lookupColumn.colOptions.type))
"
>
<LazySmartsheetVirtualCell

150
packages/nc-gui/components/virtual-cell/OneToOne.vue

@ -0,0 +1,150 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import {
ActiveCellInj,
CellValueInj,
ColumnInj,
IsFormInj,
IsUnderLookupInj,
ReadonlyInj,
ReloadRowDataHookInj,
RowInj,
computed,
createEventHook,
inject,
ref,
useProvideLTARStore,
useRoles,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
} from '#imports'
const column = inject(ColumnInj)!
const reloadRowTrigger = inject(ReloadRowDataHookInj, createEventHook())
const cellValue = inject(CellValueInj, ref<any>(null))
const row = inject(RowInj)!
const active = inject(ActiveCellInj)!
const readOnly = inject(ReadonlyInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const { isUIAllowed } = useRoles()
const listItemsDlg = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
useProvideLTARStore(column as Ref<Required<ColumnType>>, row, isNew, reloadRowTrigger.trigger)
await loadRelatedTableMeta()
const addIcon = computed(() => (cellValue?.value ? 'expand' : 'plus'))
const value = computed(() => {
if (cellValue?.value) {
return cellValue?.value
} else if (isNew.value) {
return state?.value?.[column?.value.title as string]
}
return null
})
const unlinkRef = async (rec: Record<string, any>) => {
if (isNew.value) {
await removeLTARRef(rec, column?.value as ColumnType)
} else {
await unlink(rec)
}
}
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
listItemsDlg.value = true
e.stopPropagation()
break
}
})
const belongsToColumn = computed(
() =>
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
const plusBtnRef = ref<HTMLElement | null>(null)
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
})
</script>
<template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"
:value="
!Array.isArray(value) && typeof value === 'object'
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
: value
"
:column="belongsToColumn"
:show-unlink-button="true"
@unlink="unlinkRef(value)"
/>
</template>
</div>
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
ref="plusBtnRef"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
:icon="addIcon"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
@attach-record="listItemsDlg = true"
/>
</div>
</template>
<style scoped lang="scss">
.nc-action-icon {
@apply cursor-pointer;
}
.chips-wrapper:hover,
.chips-wrapper.active {
.nc-action-icon {
@apply inline-block;
}
}
.chips-wrapper:hover {
.nc-action-icon {
@apply visible;
}
}
</style>

27
packages/nc-gui/composables/useLTARStore.ts

@ -2,11 +2,10 @@ import type {
type ColumnType,
type LinkToAnotherRecordType,
type PaginatedType,
RelationTypes,
type RequestParams,
type TableType,
} from 'nocodb-sdk'
import { UITypes, dateFormats, parseStringDateTime, timeFormats } from 'nocodb-sdk'
import { RelationTypes, UITypes, dateFormats, parseStringDateTime, timeFormats } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import type { Row } from '#imports'
import {
@ -120,7 +119,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const getRelatedTableRowId = (row: Record<string, any>) => {
return relatedTableMeta.value?.columns
?.filter((c) => c.pk)
.map((c) => row?.[c.title as string])
.map((c) => row?.[c.title as string] ?? row?.[c.id as string])
.join('___')
}
@ -134,6 +133,11 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
return (relatedTableMeta.value?.columns?.find((c) => c.pv) || relatedTableMeta?.value?.columns?.[0])?.title || ''
})
// todo: temp fix, handle in backend
const relatedTableDisplayValuePropId = computed(() => {
return (relatedTableMeta.value?.columns?.find((c) => c.pv) || relatedTableMeta?.value?.columns?.[0])?.id || ''
})
const relatedTablePrimaryKeyProps = computed(() => {
return relatedTableMeta.value?.columns?.filter((c) => c.pk)?.map((c) => c.title) ?? []
})
@ -186,8 +190,8 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const loadChildrenExcludedList = async (activeState?: any) => {
if (activeState) newRowState.state = activeState
try {
let offset =
childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1) - childrenExcludedOffsetCount.value
// todo: confirm the use case of `childrenExcludedOffsetCount.value`
let offset = childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1)
if (offset < 0) {
offset = 0
@ -278,6 +282,12 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
})
}
} catch (e: any) {
// temporary fix to handle when offset is beyond limit
if(await extractSdkResponseErrorMsg(e) === 'Offset is beyond the total number of records'){
childrenExcludedListPagination.page = 0;
return loadChildrenExcludedList(activeState)
}
message.error(`${t('msg.error.failedToLoadList')}: ${await extractSdkResponseErrorMsg(e)}`)
} finally {
isChildrenExcludedLoading.value = false
@ -287,7 +297,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const loadChildrenList = async () => {
try {
isChildrenLoading.value = true
if (colOptions.value.type === 'bt') return
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(colOptions.value.type)) return
if (!rowId.value || !column.value) return
let offset = childrenListPagination.size * (childrenListPagination.page - 1) + childrenListOffsetCount.value
@ -438,7 +448,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
}
isChildrenExcludedListLinked.value[index] = false
isChildrenListLinked.value[index] = false
if (colOptions.value.type !== 'bt') {
if (colOptions.value.type !== RelationTypes.BELONGS_TO && colOptions.value.type !== RelationTypes.ONE_TO_ONE) {
childrenListCount.value = childrenListCount.value - 1
}
} catch (e: any) {
@ -505,7 +515,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
isChildrenExcludedListLinked.value[index] = true
isChildrenListLinked.value[index] = true
if (colOptions.value.type !== 'bt') {
if (colOptions.value.type !== RelationTypes.BELONGS_TO && colOptions.value.type !== RelationTypes.ONE_TO_ONE) {
childrenListCount.value = childrenListCount.value + 1
} else {
isChildrenExcludedListLinked.value = Array(childrenExcludedList.value?.list.length).fill(false)
@ -569,6 +579,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
deleteRelatedRow,
getRelatedTableRowId,
headerDisplayValue,
relatedTableDisplayValuePropId,
}
},
'ltar-store',

4
packages/nc-gui/composables/useMultiSelect/convertCellData.ts

@ -2,7 +2,7 @@ import dayjs from 'dayjs'
import type { AttachmentType, ColumnType, LinkToAnotherRecordType, SelectOptionsType } from 'nocodb-sdk'
import { UITypes, getDateFormat, getDateTimeFormat, populateUniqueFileName } from 'nocodb-sdk'
import type { AppInfo } from '~/composables/useGlobal'
import { isBt, isMm, parseProp } from '#imports'
import { isBt, isMm, isOo, parseProp } from '#imports'
export default function convertCellData(
args: { to: UITypes; value: string; column: ColumnType; appInfo: AppInfo; files?: FileList | File[]; oldValue?: unknown },
@ -250,7 +250,7 @@ export default function convertCellData(
return undefined
}
if (isBt(column)) {
if (isBt(column) || isOo(column)) {
const parsedVal = typeof value === 'string' ? JSON.parse(value) : value
if (

7
packages/nc-gui/composables/useMultiSelect/index.ts

@ -33,6 +33,7 @@ import {
isExpandedCellInputExist,
isMac,
isMm,
isOo,
isTypableInputColumn,
message,
parseProp,
@ -158,7 +159,7 @@ export function useMultiSelect(
}
}
if (isBt(columnObj)) {
if (isBt(columnObj) || isOo(columnObj)) {
// fk_related_model_id is used to prevent paste operation in different fk_related_model_id cell
textToCopy = {
fk_related_model_id: (columnObj.colOptions as LinkToAnotherRecordType).fk_related_model_id,
@ -859,7 +860,7 @@ export function useMultiSelect(
const pasteCol = colsToPaste[j]
if (!isPasteable(pasteRow, pasteCol)) {
if ((isBt(pasteCol) || isMm(pasteCol)) && !isInfoShown) {
if ((isBt(pasteCol) || isOo(pasteCol) || isMm(pasteCol)) && !isInfoShown) {
message.info(t('msg.info.groupPasteIsNotSupportedOnLinksColumn'))
isInfoShown = true
}
@ -1163,7 +1164,7 @@ export function useMultiSelect(
for (const col of cols) {
if (!col.title || !isPasteable(row, col)) {
if ((isBt(col) || isMm(col)) && !isInfoShown) {
if ((isBt(col) || isOo(pasteCol) || isMm(col)) && !isInfoShown) {
message.info(t('msg.info.groupPasteIsNotSupportedOnLinksColumn'))
isInfoShown = true
}

11
packages/nc-gui/composables/useSmartsheetRowStore.ts

@ -11,6 +11,7 @@ import {
isBt,
isHm,
isMm,
isOo,
message,
ref,
storeToRefs,
@ -56,7 +57,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
} else {
state.value[column.title!]!.push(value)
}
} else if (isBt(column)) {
} else if (isBt(column) || isOo(column)) {
state.value[column.title!] = value
}
}
@ -65,7 +66,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
const removeLTARRef = async (value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
state.value[column.title!]?.splice(state.value[column.title!]?.indexOf(value), 1)
} else if (isBt(column)) {
} else if (isBt(column) || isOo(column)) {
state.value[column.title!] = null
}
}
@ -114,7 +115,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
{ metaValue },
)
}
} else if (isBt(column) && state.value?.[column.title!]) {
} else if ((isBt(column) || isOo(column)) && state.value?.[column.title!]) {
await linkRecord(
id,
extractPkFromRow(state.value?.[column.title!] as Record<string, any>, relatedTableMeta.columns as ColumnType[]),
@ -139,14 +140,14 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
if (isNew.value) {
state.value[column.title!] = null
} else if (currentRow.value) {
if ((<LinkToAnotherRecordType>column.colOptions)?.type === RelationTypes.BELONGS_TO) {
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((<LinkToAnotherRecordType>column.colOptions)?.type)) {
if (!currentRow.value.row[column.title!]) return
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
extractPkFromRow(currentRow.value.row, meta.value?.columns as ColumnType[]),
'bt' as any,
(<LinkToAnotherRecordType>column.colOptions)?.type as any,
column.id as string,
extractPkFromRow(currentRow.value.row[column.title!], relatedTableMeta?.columns as ColumnType[]),
)

1
packages/nc-gui/lang/en.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

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

@ -166,6 +166,7 @@ import NcCellSystemText from '~icons/nc-icons/system-text'
import NcCellAttachment from '~icons/nc-icons/cell-attachment'
import NcCircleCheck from '~icons/nc-icons/circle-check'
import OnetoOneIcon from '~icons/nc-icons/onetoone'
// keep it for reference
// todo: remove it after all icons are migrated
@ -358,6 +359,7 @@ export const iconMap = {
mm_solid: ManytoManySolidIcon,
hm_solid: HasManySolidIcon,
bt_solid: BelongsToSolidIcon,
oneToOneSolid: OnetoOneIcon,
workspaceDefault: MsGroup,
project: Project,
search: NcSearch,

3
packages/nc-gui/utils/virtualCell.ts

@ -15,6 +15,9 @@ export const isMm = (column: ColumnType) =>
export const isBt = (column: ColumnType) =>
isLTAR(column.uidt!, column.colOptions) && column.colOptions?.type === RelationTypes.BELONGS_TO
export const isOo = (column: ColumnType) =>
isLTAR(column.uidt!, column.colOptions) && column.colOptions?.type === RelationTypes.ONE_TO_ONE
export const isLookup = (column: ColumnType) => column.uidt === UITypes.Lookup
export const isRollup = (column: ColumnType) => column.uidt === UITypes.Rollup
export const isFormula = (column: ColumnType) => column.uidt === UITypes.Formula

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

@ -20,6 +20,7 @@ export enum RelationTypes {
HAS_MANY = 'hm',
BELONGS_TO = 'bt',
MANY_TO_MANY = 'mm',
ONE_TO_ONE = 'oo',
}
export enum ExportTypes {

20
packages/nocodb/src/controllers/data-alias-nested.controller.ts

@ -98,6 +98,26 @@ export class DataAliasNestedController {
});
}
@Get([
'/api/v1/db/data/:orgs/:baseName/:tableName/:rowId/oo/:columnName/exclude',
])
@Acl('ooExcludedList')
async ooExcludedList(
@Req() req: Request,
@Param('columnName') columnName: string,
@Param('rowId') rowId: string,
@Param('baseName') baseName: string,
@Param('tableName') tableName: string,
) {
return await this.dataAliasNestedService.ooExcludedList({
query: req.query,
columnName: columnName,
rowId: rowId,
baseName: baseName,
tableName: tableName,
});
}
// todo: handle case where the given column is not ltar
@Get(['/api/v1/db/data/:orgs/:baseName/:tableName/:rowId/hm/:columnName'])

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

@ -1805,6 +1805,56 @@ class BaseModelSqlv2 {
?.count;
}
// todo: naming & optimizing
public async countExcludedOneToOneChildren(
{ colId, cid = null },
args,
): Promise<any> {
const { where } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
const rcn = (await relColOptions.getParentColumn()).column_name;
const parentTable = await (
await relColOptions.getParentColumn()
).getModel();
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getChildColumn()).getModel();
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const rtn = parentTn;
const tn = childTn;
await childTable.getColumns();
// one-to-one relation is combination of both hm and bt to identify table which have
// foreign key column(similar to bt) we are adding a boolean flag `bt` under meta
const isBt = relColumn.meta?.bt;
const qb = this.dbDriver(isBt ? rtn : tn)
.where((qb) => {
qb.whereNotIn(
isBt ? rcn : cn,
this.dbDriver(isBt ? tn : rtn)
.select(isBt ? cn : rcn)
.where(_wherePk((isBt ? childTable : parentTable).primaryKeys, cid))
.whereNotNull(isBt ? cn : rcn),
).orWhereNull(isBt ? rcn : cn);
})
.count(`*`, { as: 'count' });
const aliasColObjMap = await parentTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
return (await this.execAndParse(qb, null, { raw: true, first: true }))
?.count;
}
// todo: naming & optimizing
public async getBtChildrenExcludedList(
{ colId, cid = null },
@ -1877,6 +1927,85 @@ class BaseModelSqlv2 {
});
}
// todo: naming & optimizing
public async getExcludedOneToOneChildrenList(
{ colId, cid = null },
args,
): Promise<any> {
const { where, ...rest } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
const rcn = (await relColOptions.getParentColumn()).column_name;
const parentTable = await (
await relColOptions.getParentColumn()
).getModel();
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getChildColumn()).getModel();
const parentModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: parentTable,
});
const childModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: childTable,
});
const rtn = this.getTnPath(parentTable);
const tn = this.getTnPath(childTable);
await childTable.getColumns();
// one-to-one relation is combination of both hm and bt to identify table which have
// foreign key column(similar to bt) we are adding a boolean flag `bt` under meta
const isBt = relColumn.meta?.bt;
const qb = this.dbDriver(isBt ? rtn : tn).where((qb) => {
qb.whereNotIn(
isBt ? rcn : cn,
this.dbDriver(isBt ? tn : rtn)
.select(isBt ? cn : rcn)
.where(_wherePk((isBt ? childTable : parentTable).primaryKeys, cid))
.whereNotNull(isBt ? cn : rcn),
).orWhereNull(isBt ? rcn : cn);
});
if (+rest?.shuffle) {
await this.shuffle({ qb });
}
await (isBt ? parentModel : childModel).selectObject({ qb });
const aliasColObjMap = await parentTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
// sort by primary key if not autogenerated string
// if autogenerated string sort by created_at column if present
if (parentTable.primaryKey && parentTable.primaryKey.ai) {
qb.orderBy(parentTable.primaryKey.column_name);
} else if (
parentTable.columns.find((c) => c.column_name === 'created_at')
) {
qb.orderBy('created_at');
}
applyPaginate(qb, rest);
const proto = await (isBt ? parentModel : childModel).getProto();
const data = await this.execAndParse(
qb,
await (isBt ? parentTable : childTable).getColumns(),
);
return data.map((c) => {
c.__proto__ = proto;
return c;
});
}
protected async getSelectQueryBuilderForFormula(
column: Column<any>,
tableAlias?: string,
@ -2106,6 +2235,141 @@ class BaseModelSqlv2 {
return await readLoader.load(this?.[cCol?.title]);
};
// todo : handle mm
} else if (colOptions.type === 'oo') {
const isBt = column.meta?.bt;
if (isBt) {
// @ts-ignore
const colOptions =
(await column.getColOptions()) as LinkToAnotherRecordColumn;
const pCol = await Column.get({
colId: colOptions.fk_parent_column_id,
});
const cCol = await Column.get({
colId: colOptions.fk_child_column_id,
});
// use dataloader to get batches of parent data together rather than getting them individually
// it takes individual keys and callback is invoked with an array of values and we can get the
// result for all those together and return the value in the same order as in the array
// this way all parents data extracted together
const readLoader = new DataLoader(
async (_ids: string[]) => {
// handle binary(16) foreign keys
const ids = _ids.map((id) => {
if (pCol.ct !== 'binary(16)') return id;
// Cast the id to string.
const idAsString = id + '';
// Check if the id is a UUID and the column is binary(16)
const isUUIDBinary16 =
idAsString.length === 36 || idAsString.length === 32;
// If the id is a UUID and the column is binary(16), convert the id to a Buffer. Otherwise, return null to indicate that the id is not a UUID.
const idAsUUID = isUUIDBinary16
? idAsString.length === 32
? idAsString.replace(
/(.{8})(.{4})(.{4})(.{4})(.{12})/,
'$1-$2-$3-$4-$5',
)
: idAsString
: null;
return idAsUUID
? Buffer.from(idAsUUID.replace(/-/g, ''), 'hex')
: id;
});
const data = await (
await Model.getBaseModelSQL({
id: pCol.fk_model_id,
dbDriver: this.dbDriver,
})
).list(
{
fieldsSet: (readLoader as any).args?.fieldsSet,
filterArr: [
new Filter({
id: null,
fk_column_id: pCol.id,
fk_model_id: pCol.fk_model_id,
value: ids as any[],
comparison_op: 'in',
}),
],
},
{
ignoreViewFilterAndSort: true,
ignorePagination: true,
},
);
const groupedList = groupBy(data, pCol.title);
return _ids.map(
async (id: string) => groupedList?.[id]?.[0],
);
},
{
cache: false,
},
);
// defining BelongsTo read resolver method
proto[column.title] = async function (args?: any) {
if (
this?.[cCol?.title] === null ||
this?.[cCol?.title] === undefined
)
return null;
(readLoader as any).args = args;
return await readLoader.load(this?.[cCol?.title]);
};
} else {
const listLoader = new DataLoader(
async (ids: string[]) => {
if (ids.length > 1) {
const data = await this.multipleHmList(
{
colId: column.id,
ids,
},
(listLoader as any).args,
);
return ids.map((id: string) =>
data[id] ? data[id]?.[0] : null,
);
} else {
return [
(
await this.hmList(
{
colId: column.id,
id: ids[0],
},
(listLoader as any).args,
)
)?.[0] ?? null,
];
}
},
{
cache: false,
},
);
const self: BaseModelSqlv2 = this;
proto[
column.uidt === UITypes.Links
? `_nc_lk_${column.title}`
: column.title
] = async function (args): Promise<any> {
(listLoader as any).args = args;
return listLoader.load(
getCompositePk(self.model.primaryKeys, this),
);
};
}
}
}
break;
@ -2806,7 +3070,7 @@ class BaseModelSqlv2 {
const nestedCols = (await this.model.getColumns()).filter((c) =>
isLinksOrLTAR(c),
);
const postInsertOps = await this.prepareNestedLinkQb({
const { postInsertOps, preInsertOps } = await this.prepareNestedLinkQb({
nestedCols,
data,
insertObj,
@ -2818,6 +3082,8 @@ class BaseModelSqlv2 {
await this.prepareNocoData(insertObj, true, cookie);
await Promise.all(preInsertOps.map((f) => f(this.dbDriver)));
let response;
const query = this.dbDriver(this.tnPath).insert(insertObj);
@ -2958,6 +3224,7 @@ class BaseModelSqlv2 {
insertObj: Record<string, any>;
}) {
const postInsertOps: ((rowId: any, trx?: any) => Promise<void>)[] = [];
const preInsertOps: ((trx?: any) => Promise<void>)[] = [];
for (const col of nestedCols) {
if (col.title in data) {
const colOptions = await col.getColOptions<LinkToAnotherRecordColumn>();
@ -2981,6 +3248,45 @@ class BaseModelSqlv2 {
insertObj[childCol.column_name] = nestedData?.[parentCol.title];
}
break;
case RelationTypes.ONE_TO_ONE:
{
const isBt = col.meta?.bt;
const childCol = await colOptions.getChildColumn();
const childModel = await childCol.getModel();
await childModel.getColumns();
if (isBt) {
// todo: unlink the ref record
preInsertOps.push(async (trx: any = this.dbDriver) => {
await trx(this.getTnPath(childModel.table_name))
.update({
[childCol.column_name]: null,
})
.where(
childCol.column_name,
nestedData[childModel.primaryKey.title],
);
});
if (typeof nestedData !== 'object') continue;
const childCol = await colOptions.getChildColumn();
const parentCol = await colOptions.getParentColumn();
insertObj[childCol.column_name] = nestedData?.[parentCol.title];
} else {
postInsertOps.push(async (rowId, trx: any = this.dbDriver) => {
await trx(this.getTnPath(childModel.table_name))
.update({
[childCol.column_name]: rowId,
})
.where(
childModel.primaryKey.column_name,
nestedData[childModel.primaryKey.title],
);
});
}
}
break;
case RelationTypes.HAS_MANY:
{
if (!Array.isArray(nestedData)) continue;
@ -3033,7 +3339,7 @@ class BaseModelSqlv2 {
}
}
}
return postInsertOps;
return { postInsertOps, preInsertOps };
}
async bulkInsert(
@ -3061,6 +3367,7 @@ class BaseModelSqlv2 {
// TODO: ag column handling for raw bulk insert
const insertDatas = raw ? datas : [];
let postInsertOps: ((rowId: any, trx?: any) => Promise<void>)[] = [];
let preInsertOps: ((trx?: any) => Promise<void>)[] = [];
let aiPkCol: Column;
let agPkCol: Column;
@ -3209,11 +3516,14 @@ class BaseModelSqlv2 {
// prepare nested link data for insert only if it is single record insertion
if (isSingleRecordInsertion) {
postInsertOps = await this.prepareNestedLinkQb({
const operations = await this.prepareNestedLinkQb({
nestedCols,
data: d,
insertObj,
});
postInsertOps = operations.postInsertOps;
preInsertOps = operations.preInsertOps;
}
insertDatas.push(insertObj);
@ -3244,6 +3554,8 @@ class BaseModelSqlv2 {
}
}
await Promise.all(preInsertOps.map((f) => f(trx)));
let responses;
// insert one by one as fallback to get ids for sqlite and mysql
@ -4245,6 +4557,54 @@ class BaseModelSqlv2 {
});
}
break;
case RelationTypes.ONE_TO_ONE:
{
const isBt = column.meta?.bt;
// todo: unlink if it's already mapped
// unlink already mapped record if any
await this.execAndParse(
this.dbDriver(childTn)
.where({
[childColumn.column_name]: this.dbDriver.from(
this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(
_wherePk(parentTable.primaryKeys, isBt ? childId : rowId),
)
.first()
.as('___cn_alias'),
),
})
.update({ [childColumn.column_name]: null }),
null,
{ raw: true },
);
await this.execAndParse(
this.dbDriver(childTn)
.update({
[childColumn.column_name]: this.dbDriver.from(
this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(
_wherePk(parentTable.primaryKeys, isBt ? childId : rowId),
)
.first()
.as('___cn_alias'),
),
})
.where(_wherePk(childTable.primaryKeys, isBt ? rowId : childId)),
null,
{ raw: true },
);
await this.updateLastModified({
model: parentTable,
rowIds: [childId],
cookie,
});
}
break;
}
const response = await this.readByPk(
@ -4395,6 +4755,24 @@ class BaseModelSqlv2 {
});
}
break;
case RelationTypes.ONE_TO_ONE:
{
const isBt = column.meta?.bt;
await this.execAndParse(
this.dbDriver(childTn)
.where(_wherePk(childTable.primaryKeys, isBt ? rowId : childId))
.update({ [childColumn.column_name]: null }),
null,
{ raw: true },
);
await this.updateLastModified({
model: parentTable,
rowIds: [childId],
cookie,
});
}
break;
}
const newData = await this.readByPk(
@ -4767,7 +5145,11 @@ class BaseModelSqlv2 {
}
idToAliasMap[col.id] = col.title;
if (col.colOptions?.type === 'bt') {
if (
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(
col.colOptions?.type,
)
) {
btMap[col.id] = true;
const btData = Object.values(data).find(
(d) => d[col.id] && Object.keys(d[col.id]),
@ -5200,7 +5582,7 @@ class BaseModelSqlv2 {
async addLinks({
cookie,
childIds,
childIds: _childIds,
colId,
rowId,
}: {
@ -5227,7 +5609,7 @@ class BaseModelSqlv2 {
NcError.notFound(`Record with id '${rowId}' not found`);
}
if (!childIds.length) return;
if (!_childIds.length) return;
const colOptions = await column.getColOptions<LinkToAnotherRecordColumn>();
@ -5241,7 +5623,39 @@ class BaseModelSqlv2 {
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
switch (colOptions.type) {
let relationType = colOptions.type;
let childIds = _childIds;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = column.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
childIds = childIds.slice(0, 1);
// unlink
await this.execAndParse(
this.dbDriver(childTn)
.where({
[childColumn.column_name]: this.dbDriver.from(
this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(
_wherePk(
parentTable.primaryKeys,
column.meta?.bt ? childIds[0] : rowId,
),
)
.first()
.as('___cn_alias'),
),
})
.update({ [childColumn.column_name]: null }),
null,
{ raw: true },
);
}
switch (relationType) {
case RelationTypes.MANY_TO_MANY:
{
const vChildCol = await colOptions.getMMChildColumn();

31
packages/nocodb/src/db/conditionV2.ts

@ -16,7 +16,6 @@ import type LookupColumn from '~/models/LookupColumn';
import type RollupColumn from '~/models/RollupColumn';
import type FormulaColumn from '~/models/FormulaColumn';
import { getColumnName } from '~/db/BaseModelSqlv2';
import { type BarcodeColumn, BaseUser, type QrCodeColumn } from '~/models';
import { NcError } from '~/helpers/catchError';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import genRollupSelectv2 from '~/db/genRollupSelectv2';
@ -25,6 +24,7 @@ import Filter from '~/models/Filter';
import generateLookupSelectQuery from '~/db/generateLookupSelectQuery';
import { getAliasGenerator } from '~/utils';
import { getRefColumnIfAlias } from '~/helpers';
import { type BarcodeColumn, BaseUser, type QrCodeColumn } from '~/models';
// tod: tobe fixed
// extend(customParseFormat);
@ -180,7 +180,16 @@ const parseConditionV2 = async (
await childModel.getColumns();
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
if (colOptions.type === RelationTypes.HAS_MANY) {
let relationType = colOptions.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = column.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
if (relationType === RelationTypes.HAS_MANY) {
if (
['blank', 'notblank', 'checked', 'notchecked'].includes(
filter.comparison_op,
@ -245,7 +254,7 @@ const parseConditionV2 = async (
qbP.whereNotIn(parentColumn.column_name, selectQb);
else qbP.whereIn(parentColumn.column_name, selectQb);
};
} else if (colOptions.type === RelationTypes.BELONGS_TO) {
} else if (relationType === RelationTypes.BELONGS_TO) {
if (
['blank', 'notblank', 'checked', 'notchecked'].includes(
filter.comparison_op,
@ -315,7 +324,7 @@ const parseConditionV2 = async (
);
} else qbP.whereIn(childColumn.column_name, selectQb);
};
} else if (colOptions.type === RelationTypes.MANY_TO_MANY) {
} else if (relationType === RelationTypes.MANY_TO_MANY) {
const mmModel = await colOptions.getMMModel();
const mmParentColumn = await colOptions.getMMParentColumn();
const mmChildColumn = await colOptions.getMMChildColumn();
@ -1247,7 +1256,15 @@ async function generateLookupCondition(
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
if (relationColumnOptions.type === RelationTypes.HAS_MANY) {
let relationType = relationColumnOptions.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationColumn.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
if (relationType === RelationTypes.HAS_MANY) {
qb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
@ -1278,7 +1295,7 @@ async function generateLookupCondition(
qbP.whereNotIn(parentColumn.column_name, qb);
else qbP.whereIn(parentColumn.column_name, qb);
};
} else if (relationColumnOptions.type === RelationTypes.BELONGS_TO) {
} else if (relationType === RelationTypes.BELONGS_TO) {
qb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel.table_name),
@ -1312,7 +1329,7 @@ async function generateLookupCondition(
);
else qbP.whereIn(childColumn.column_name, qb);
};
} else if (relationColumnOptions.type === RelationTypes.MANY_TO_MANY) {
} else if (relationType === RelationTypes.MANY_TO_MANY) {
const mmModel = await relationColumnOptions.getMMModel();
const mmParentColumn = await relationColumnOptions.getMMParentColumn();
const mmChildColumn = await relationColumnOptions.getMMChildColumn();

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

@ -2,6 +2,7 @@ import jsep from 'jsep';
import {
FormulaDataTypes,
jsepCurlyHook,
RelationTypes,
UITypes,
validateDateWithUnknownFormat,
validateFormulaAndExtractTreeWithType,
@ -141,14 +142,14 @@ async function _formulaQueryBuilder(
aliasToColumn[col.id] = async (): Promise<any> => {
let aliasCount = 0;
let selectQb;
let isMany = false;
let isArray = false;
const alias = `__nc_formula${aliasCount++}`;
const lookup = await col.getColOptions<LookupColumn>();
{
const relationCol = await lookup.getRelationColumn();
const relation =
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
// if (relation.type !== 'bt') continue;
// if (relation.type !== RelationTypes.BELONGS_TO) continue;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -156,8 +157,17 @@ async function _formulaQueryBuilder(
await childModel.getColumns();
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
switch (relation.type) {
case 'bt':
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
switch (relationType) {
case RelationTypes.BELONGS_TO:
selectQb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel.table_name),
@ -173,8 +183,8 @@ async function _formulaQueryBuilder(
]),
);
break;
case 'hm':
isMany = true;
case RelationTypes.HAS_MANY:
isArray = relation.type !== RelationTypes.ONE_TO_ONE;
selectQb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
@ -190,9 +200,9 @@ async function _formulaQueryBuilder(
]),
);
break;
case 'mm':
case RelationTypes.MANY_TO_MANY:
{
isMany = true;
isArray = true;
const mmModel = await relation.getMMModel();
const mmParentColumn = await relation.getMMParentColumn();
const mmChildColumn = await relation.getMMChildColumn();
@ -236,7 +246,7 @@ async function _formulaQueryBuilder(
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
// if any of the relation in nested lookup is
// not belongs to then ignore the sort option
// if (relation.type !== 'bt') continue;
// if (relation.type !== RelationTypes.BELONGS_TO) continue;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -245,8 +255,16 @@ async function _formulaQueryBuilder(
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
switch (relation.type) {
case 'bt':
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
switch (relationType) {
case RelationTypes.BELONGS_TO:
{
selectQb.join(
knex.raw(`?? as ??`, [
@ -258,9 +276,9 @@ async function _formulaQueryBuilder(
);
}
break;
case 'hm':
case RelationTypes.HAS_MANY:
{
isMany = true;
isArray = relation.type !== RelationTypes.ONE_TO_ONE;
selectQb.join(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
@ -271,8 +289,8 @@ async function _formulaQueryBuilder(
);
}
break;
case 'mm': {
isMany = true;
case RelationTypes.MANY_TO_MANY: {
isArray = true;
const mmModel = await relation.getMMModel();
const mmParentColumn = await relation.getMMParentColumn();
const mmChildColumn = await relation.getMMChildColumn();
@ -324,7 +342,7 @@ async function _formulaQueryBuilder(
).builder;
// selectQb.select(builder);
if (isMany) {
if (isArray) {
const qb = selectQb;
selectQb = (fn) =>
knex
@ -346,7 +364,7 @@ async function _formulaQueryBuilder(
const nestedAlias = `__nc_formula${aliasCount++}`;
const relation =
await lookupColumn.getColOptions<LinkToAnotherRecordColumn>();
// if (relation.type !== 'bt') continue;
// if (relation.type !== RelationTypes.BELONGS_TO) continue;
const colOptions =
(await lookupColumn.getColOptions()) as LinkToAnotherRecordColumn;
@ -357,8 +375,17 @@ async function _formulaQueryBuilder(
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
let cn;
switch (relation.type) {
case 'bt':
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
switch (relationType) {
case RelationTypes.BELONGS_TO:
{
selectQb.join(
knex.raw(`?? as ??`, [
@ -374,9 +401,9 @@ async function _formulaQueryBuilder(
]);
}
break;
case 'hm':
case RelationTypes.HAS_MANY:
{
isMany = true;
isArray = relation.type !== RelationTypes.ONE_TO_ONE;
selectQb.join(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
@ -391,9 +418,9 @@ async function _formulaQueryBuilder(
]);
}
break;
case 'mm':
case RelationTypes.MANY_TO_MANY:
{
isMany = true;
isArray = true;
const mmModel = await relation.getMMModel();
const mmParentColumn =
await relation.getMMParentColumn();
@ -434,7 +461,7 @@ async function _formulaQueryBuilder(
`${prevAlias}.${childColumn.column_name}`,
);
if (isMany) {
if (isArray) {
const qb = selectQb;
selectQb = (fn) =>
knex
@ -464,7 +491,7 @@ async function _formulaQueryBuilder(
aliasToColumn,
formulaOption.getParsedTree(),
);
if (isMany) {
if (isArray) {
const qb = selectQb;
selectQb = (fn) =>
knex
@ -483,7 +510,7 @@ async function _formulaQueryBuilder(
break;
default:
{
if (isMany) {
if (isArray) {
const qb = selectQb;
selectQb = (fn) =>
knex
@ -529,7 +556,7 @@ async function _formulaQueryBuilder(
aliasToColumn[col.id] = async (): Promise<any> => {
const alias = `__nc_formula_ll`;
const relation = await col.getColOptions<LinkToAnotherRecordColumn>();
// if (relation.type !== 'bt') continue;
// if (relation.type !== RelationTypes.BELONGS_TO) continue;
const colOptions =
(await col.getColOptions()) as LinkToAnotherRecordColumn;
@ -540,8 +567,16 @@ async function _formulaQueryBuilder(
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = col.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
let selectQb;
if (relation.type === 'bt') {
if (relationType === RelationTypes.BELONGS_TO) {
selectQb = knex(baseModelSqlv2.getTnPath(parentModel.table_name))
.select(parentModel?.displayValue?.column_name)
.where(
@ -555,7 +590,7 @@ async function _formulaQueryBuilder(
}.${childColumn.column_name}`,
]),
);
} else if (relation.type == 'hm') {
} else if (relationType == RelationTypes.HAS_MANY) {
const qb = knex(baseModelSqlv2.getTnPath(childModel.table_name))
// .select(knex.raw(`GROUP_CONCAT(??)`, [childModel?.pv?.title]))
.where(
@ -582,7 +617,7 @@ async function _formulaQueryBuilder(
.wrap('(', ')');
// getAggregateFn();
} else if (relation.type == 'mm') {
} else if (relationType == RelationTypes.MANY_TO_MANY) {
// todo:
// const qb = knex(childModel.title)
// // .select(knex.raw(`GROUP_CONCAT(??)`, [childModel?.pv?.title]))

21
packages/nocodb/src/db/genRollupSelectv2.ts

@ -51,6 +51,27 @@ export default async function ({
knex.ref(`${refTableAlias}.${childCol.column_name}`),
),
};
case RelationTypes.ONE_TO_ONE:
return {
builder: knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name),
refTableAlias,
]),
)
[columnOptions.rollup_function as string]?.(
knex.ref(`${refTableAlias}.${rollupColumn.column_name}`),
)
.where(
knex.ref(
`${alias || baseModelSqlv2.getTnPath(parentModel.table_name)}.${
parentCol.column_name
}`,
),
'=',
knex.ref(`${refTableAlias}.${childCol.column_name}`),
),
};
case RelationTypes.MANY_TO_MANY: {
const mmModel = await relationColumnOption.getMMModel();
const mmChildCol = await relationColumnOption.getMMChildColumn();

35
packages/nocodb/src/db/generateLookupSelectQuery.ts

@ -70,8 +70,15 @@ export default async function generateLookupSelectQuery({
const relation =
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
// if not belongs to then throw error as we don't support
if (relation.type === RelationTypes.BELONGS_TO) {
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
if (relationType === RelationTypes.BELONGS_TO) {
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
const childModel = await childColumn.getModel();
@ -92,10 +99,7 @@ export default async function generateLookupSelectQuery({
}`,
]),
);
}
// if not belongs to then throw error as we don't support
else if (relation.type === RelationTypes.HAS_MANY) {
} else if (relationType === RelationTypes.HAS_MANY) {
isBtLookup = false;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -117,10 +121,7 @@ export default async function generateLookupSelectQuery({
}`,
]),
);
}
// if not belongs to then throw error as we don't support
else if (relation.type === RelationTypes.MANY_TO_MANY) {
} else if (relationType === RelationTypes.MANY_TO_MANY) {
isBtLookup = false;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -191,9 +192,17 @@ export default async function generateLookupSelectQuery({
const relation =
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
// if any of the relation in nested lookupColOpt is
// not belongs to then throw error as we don't support
if (relation.type === RelationTypes.BELONGS_TO) {
if (relationType === RelationTypes.BELONGS_TO) {
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
const childModel = await childColumn.getModel();
@ -209,7 +218,7 @@ export default async function generateLookupSelectQuery({
`${nestedAlias}.${parentColumn.column_name}`,
`${prevAlias}.${childColumn.column_name}`,
);
} else if (relation.type === RelationTypes.HAS_MANY) {
} else if (relationType === RelationTypes.HAS_MANY) {
isBtLookup = false;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -226,7 +235,7 @@ export default async function generateLookupSelectQuery({
`${nestedAlias}.${childColumn.column_name}`,
`${prevAlias}.${parentColumn.column_name}`,
);
} else if (relation.type === RelationTypes.MANY_TO_MANY) {
} else if (relationType === RelationTypes.MANY_TO_MANY) {
isBtLookup = false;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();

1
packages/nocodb/src/db/sql-client/lib/mysql/MysqlClient.ts

@ -2496,6 +2496,7 @@ class MysqlClient extends KnexClient {
query += n.un ? ' UNSIGNED' : '';
query += n.rqd ? ' NOT NULL' : ' NULL';
query += n.ai ? ' auto_increment' : '';
query += n.unique ? ` UNIQUE` : '';
const defaultValue = this.sanitiseDefaultValue(n.cdf);
query += defaultValue
? `

2
packages/nocodb/src/db/sql-client/lib/pg/PgClient.ts

@ -2863,6 +2863,7 @@ class PGClient extends KnexClient {
);
query += n.rqd ? ' NOT NULL' : ' NULL';
query += defaultValue ? ` DEFAULT ${defaultValue}` : '';
query += n.unique ? ` UNIQUE` : '';
}
} else if (change === 1) {
query += this.genQuery(
@ -2872,6 +2873,7 @@ class PGClient extends KnexClient {
);
query += n.rqd ? ' NOT NULL' : ' NULL';
query += defaultValue ? ` DEFAULT ${defaultValue}` : '';
query += n.unique ? ` UNIQUE` : '';
query = this.genQuery(`ALTER TABLE ?? ${query};`, [t], shouldSanitize);
} else {
if (n.cn !== o.cn) {

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

@ -2132,6 +2132,7 @@ class SqliteClient extends KnexClient {
? ' '
: ` DEFAULT ''`;
addNewColumnQuery += n.rqd ? ` NOT NULL` : ' ';
query += n.unique ? ` UNIQUE` : '';
addNewColumnQuery = this.genQuery(
`ALTER TABLE ?? ${addNewColumnQuery};`,
[t],
@ -2161,6 +2162,8 @@ class SqliteClient extends KnexClient {
query += n.dtxp && n.dt !== 'text' ? `(${this.genRaw(n.dtxp)})` : '';
query += n.cdf ? ` DEFAULT ${this.genValue(n.cdf)}` : ' ';
query += n.rqd ? ` NOT NULL` : ' ';
// todo: unique constraint should be added using index
// query += n.unique ? ` UNIQUE` : '';
} else if (change === 1) {
shouldSanitize = true;
query += this.genQuery(
@ -2175,6 +2178,8 @@ class SqliteClient extends KnexClient {
? ' '
: ` DEFAULT ''`;
query += n.rqd ? ` NOT NULL` : ' ';
// todo: unique constraint should be added using index
// query += n.unique ? ` UNIQUE` : '';
query = this.genQuery(`ALTER TABLE ?? ${query};`, [t], shouldSanitize);
} else {
// if(n.cn!==o.cno) {

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

@ -1,12 +1,15 @@
import { customAlphabet } from 'nanoid';
import { getAvailableRollupForUiType, UITypes } from 'nocodb-sdk';
import {
getAvailableRollupForUiType,
RelationTypes,
UITypes,
} from 'nocodb-sdk';
import { pluralize, singularize } from 'inflection';
import type {
BoolType,
ColumnReqType,
LinkToAnotherRecordType,
LookupColumnReqType,
RelationTypes,
RollupColumnReqType,
TableType,
} from 'nocodb-sdk';
@ -92,6 +95,90 @@ export async function createHmAndBtColumn(
}
}
/**
* Creates a column with a one-to-one (1:1) relationship.
* @param {Model} child - The child model.
* @param {Model} parent - The parent model.
* @param {Column} childColumn - The child column.
* @param {RelationTypes} [type] - The type of relationship.
* @param {string} [alias] - The alias for the column.
* @param {string} [fkColName] - The foreign key column name.
* @param {BoolType} [virtual=false] - Whether the column is virtual.
* @param {boolean} [isSystemCol=false] - Whether the column is a system column.
* @param {any} [columnMeta=null] - Metadata for the column.
* @param {any} [colExtra] - Additional column parameters.
*/
export async function createOOColumn(
child: Model,
parent: Model,
childColumn: Column,
type?: RelationTypes,
alias?: string,
fkColName?: string,
virtual: BoolType = false,
isSystemCol = false,
columnMeta = null,
colExtra?: any,
) {
// save bt column
{
const title = getUniqueColumnAliasName(
await child.getColumns(),
`${parent.title}`,
);
await Column.insert<LinkToAnotherRecordColumn>({
title,
fk_model_id: child.id,
// ref_db_alias
uidt: UITypes.LinkToAnotherRecord,
type: RelationTypes.ONE_TO_ONE,
fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id,
fk_related_model_id: parent.id,
virtual,
// if self referencing treat it as system field to hide from ui
system: isSystemCol || parent.id === child.id,
fk_col_name: fkColName,
fk_index_name: fkColName,
// ...(colExtra || {}),
meta: {
...(colExtra?.meta || {}),
// one-to-one relation is combination of both hm and bt to identify table which have
// foreign key column(similar to bt) we are adding a boolean flag `bt` under meta
bt: true,
},
});
}
// save hm column
{
const title = getUniqueColumnAliasName(
await parent.getColumns(),
alias || child.title,
);
const meta = {
plural: columnMeta?.plural || pluralize(child.title),
singular: columnMeta?.singular || singularize(child.title),
};
await Column.insert({
title,
fk_model_id: parent.id,
uidt: UITypes.LinkToAnotherRecord,
type: 'oo',
fk_child_column_id: childColumn.id,
fk_parent_column_id: parent.primaryKey.id,
fk_related_model_id: child.id,
virtual,
system: isSystemCol,
fk_col_name: fkColName,
fk_index_name: fkColName,
meta,
...(colExtra || {}),
});
}
}
export async function validateRollupPayload(payload: ColumnReqType | Column) {
validateParams(
[
@ -176,12 +263,10 @@ export async function validateLookupPayload(
);
}
}
const relation = await (
await Column.get({
colId: (payload as LookupColumnReqType).fk_relation_column_id,
})
).getColOptions<LinkToAnotherRecordType>();
const column = await Column.get({
colId: (payload as LookupColumnReqType).fk_relation_column_id,
});
const relation = await column.getColOptions<LinkToAnotherRecordType>();
if (!relation) {
throw new Error('Relation column not found');
@ -200,6 +285,13 @@ export async function validateLookupPayload(
colId: relation.fk_parent_column_id,
});
break;
case 'oo':
relatedColumn = await Column.get({
colId: column.meta?.bt
? relation.fk_parent_column_id
: relation.fk_child_column_id,
});
break;
}
const relatedTable = await relatedColumn.getModel();

12
packages/nocodb/src/helpers/getAst.ts

@ -287,7 +287,17 @@ const extractRelationDependencies = async (
dependencyFields.fieldsSet.add(
await relationColumnOpts.getChildColumn().then((col) => col.title),
);
break;
case RelationTypes.ONE_TO_ONE:
if (relationColumn.meta?.bt) {
dependencyFields.fieldsSet.add(
await relationColumnOpts.getChildColumn().then((col) => col.title),
);
} else {
dependencyFields.fieldsSet.add(
await relationColumnOpts.getParentColumn().then((col) => col.title),
);
}
break;
}
};

2
packages/nocodb/src/models/LinkToAnotherRecordColumn.ts

@ -20,7 +20,7 @@ export default class LinkToAnotherRecordColumn {
ur?: string;
fk_index_name?: string;
type: 'hm' | 'bt' | 'mm';
type: 'hm' | 'bt' | 'mm' | 'oo';
virtual: BoolType = false;
mmModel?: Model;

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

@ -3,7 +3,7 @@ import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import papaparse from 'papaparse';
import debug from 'debug';
import { isLinksOrLTAR, isVirtualCol } from 'nocodb-sdk';
import { isLinksOrLTAR, isVirtualCol, RelationTypes } from 'nocodb-sdk';
import { Base, Column, Model, Source } from '~/models';
import { BasesService } from '~/services/bases.service';
import {
@ -202,7 +202,9 @@ export class DuplicateProcessor {
.filter(
(c) =>
isLinksOrLTAR(c) &&
c.colOptions.type === 'bt' &&
(c.colOptions.type === RelationTypes.BELONGS_TO ||
(c.colOptions.type === RelationTypes.ONE_TO_ONE &&
c.meta?.bt)) &&
c.colOptions.fk_related_model_id === modelId,
)
.map((c) => c.id);
@ -342,7 +344,9 @@ export class DuplicateProcessor {
.filter(
(c) =>
isLinksOrLTAR(c) &&
c.colOptions.type === 'bt' &&
(c.colOptions.type === RelationTypes.BELONGS_TO ||
(c.colOptions.type === RelationTypes.ONE_TO_ONE &&
c.meta?.bt)) &&
c.colOptions.fk_related_model_id === sourceModel.id,
)
.map((c) => c.id);
@ -522,7 +526,11 @@ export class DuplicateProcessor {
colId: id,
});
if (col) {
if (col.colOptions?.type === 'bt') {
if (
col.colOptions?.type === RelationTypes.BELONGS_TO ||
(col.colOptions?.type === RelationTypes.ONE_TO_ONE &&
col.meta?.bt)
) {
const childCol = await Column.get({
source_id: destBase.id,
colId: col.colOptions.fk_child_column_id,

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

@ -1,5 +1,5 @@
import { Readable } from 'stream';
import { isLinksOrLTAR, UITypes, ViewTypes } from 'nocodb-sdk';
import { isLinksOrLTAR, RelationTypes, UITypes, ViewTypes } from 'nocodb-sdk';
import { unparse } from 'papaparse';
import debug from 'debug';
import { Injectable } from '@nestjs/common';
@ -387,7 +387,8 @@ export class ExportService {
for (const column of model.columns.filter(
(col) =>
col.uidt === UITypes.LinkToAnotherRecord &&
col.colOptions?.type === 'bt',
(col.colOptions?.type === RelationTypes.BELONGS_TO ||
(col.colOptions?.type === RelationTypes.ONE_TO_ONE && col.meta?.bt)),
)) {
await column.getColOptions();
const fkCol = model.columns.find(

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

@ -1,4 +1,10 @@
import { isLinksOrLTAR, isVirtualCol, UITypes, ViewTypes } from 'nocodb-sdk';
import {
isLinksOrLTAR,
isVirtualCol,
RelationTypes,
UITypes,
ViewTypes,
} from 'nocodb-sdk';
import { Injectable, Logger } from '@nestjs/common';
import papaparse from 'papaparse';
import debug from 'debug';
@ -317,7 +323,10 @@ export class ImportService {
}
}
}
} else if (colOptions.type === 'hm') {
} else if (
colOptions.type === RelationTypes.HAS_MANY ||
(colOptions.type === RelationTypes.ONE_TO_ONE && !col.meta?.bt)
) {
// delete col.column_name as it is not required and will cause ajv error (null for LTAR)
delete col.column_name;
@ -517,7 +526,10 @@ export class ImportService {
}
}
}
} else if (colOptions.type === 'hm') {
} else if (
colOptions.type === RelationTypes.HAS_MANY ||
(colOptions.type === RelationTypes.ONE_TO_ONE && !col.meta?.bt)
) {
if (
!linkMap.has(
`${colOptions.fk_parent_column_id}::${colOptions.fk_child_column_id}`,
@ -639,7 +651,10 @@ export class ImportService {
}
}
}
} else if (colOptions.type === 'bt') {
} else if (
colOptions.type === RelationTypes.BELONGS_TO ||
(colOptions.type === RelationTypes.ONE_TO_ONE && col.meta?.bt)
) {
if (
!linkMap.has(
`${colOptions.fk_parent_column_id}::${colOptions.fk_child_column_id}`,
@ -1466,7 +1481,11 @@ export class ImportService {
colId: id,
});
if (col) {
if (col.colOptions?.type === 'bt') {
if (
col.colOptions?.type === RelationTypes.BELONGS_TO ||
(col.colOptions?.type === RelationTypes.ONE_TO_ONE &&
col.meta?.bt)
) {
const childCol = await Column.get({
source_id: destBase.id,
colId: col.colOptions.fk_child_column_id,

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

@ -7901,7 +7901,8 @@
"enum": [
"mm",
"hm",
"bt"
"bt",
"oo"
]
},
"name": "relationType",
@ -15137,7 +15138,8 @@
"enum": [
"bt",
"hm",
"mm"
"mm",
"oo"
],
"type": "string",
"description": "The type of the relationship"

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

@ -11983,7 +11983,8 @@
"enum": [
"mm",
"hm",
"bt"
"bt",
"oo"
]
},
"name": "relationType",
@ -12095,7 +12096,8 @@
"enum": [
"mm",
"hm",
"bt"
"bt",
"oo"
]
},
"name": "relationType",
@ -12320,7 +12322,8 @@
"enum": [
"mm",
"hm",
"bt"
"bt",
"oo"
]
},
"name": "relationType",
@ -12993,7 +12996,8 @@
"enum": [
"mm",
"hm",
"bt"
"bt",
"oo"
]
},
"name": "relationType",
@ -21298,7 +21302,8 @@
"enum": [
"bt",
"hm",
"mm"
"mm",
"oo"
],
"type": "string",
"description": "The type of the relationship"

5
packages/nocodb/src/services/api-docs/swaggerV2/templates/paths.ts

@ -322,6 +322,11 @@ export const getModelPaths = async (ctx: {
},
],
},
'Example 2': {
value: {
Id: 4,
},
},
},
},
},

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

@ -31,6 +31,7 @@ import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import ProjectMgrv2 from '~/db/sql-mgr/v2/ProjectMgrv2';
import {
createHmAndBtColumn,
createOOColumn,
generateFkName,
randomID,
sanitizeColumnName,
@ -1643,7 +1644,7 @@ export class ColumnsService {
colExtra,
});
this.appHooksService.emit(AppEvents.RELATION_DELETE, {
this.appHooksService.emit(AppEvents.RELATION_CREATE, {
column: {
...colBody,
fk_model_id: param.tableId,
@ -2146,6 +2147,20 @@ export class ColumnsService {
});
}
break;
case 'oo':
{
await this.deleteOoRelation({
relationColOpt,
source,
childColumn,
childTable,
parentColumn,
parentTable,
sqlMgr,
ncMeta,
});
}
break;
case 'mm':
{
const mmTable = await relationColOpt.getMMModel(ncMeta);
@ -2395,8 +2410,154 @@ export class ColumnsService {
}
if (!relationColOpt?.virtual && !virtual) {
// todo: handle relation delete exception
// Ensure relation deletion is not attempted for virtual relations
try {
// Attempt to delete the foreign key constraint from the database
await sqlMgr.sqlOpPlus(source, 'relationDelete', {
childColumn: childColumn.column_name,
childTable: childTable.table_name,
parentTable: parentTable.table_name,
parentColumn: parentColumn.column_name,
foreignKeyName,
});
} catch (e) {
console.log(e.message);
}
}
}
if (!relationColOpt) return;
const columnsInRelatedTable: Column[] = await relationColOpt
.getRelatedTable(ncMeta)
.then((m) => m.getColumns(ncMeta));
const relType = relationColOpt.type === 'bt' ? 'hm' : 'bt';
for (const c of columnsInRelatedTable) {
if (c.uidt !== UITypes.LinkToAnotherRecord) continue;
const colOpt = await c.getColOptions<LinkToAnotherRecordColumn>(ncMeta);
if (
colOpt.fk_parent_column_id === parentColumn.id &&
colOpt.fk_child_column_id === childColumn.id &&
colOpt.type === relType
) {
await Column.delete(c.id, ncMeta);
break;
}
}
// delete virtual columns
await Column.delete(relationColOpt.fk_column_id, ncMeta);
if (!ignoreFkDelete) {
const cTable = await Model.getWithInfo(
{
id: childTable.id,
},
ncMeta,
);
// if virtual column delete all index before deleting the column
if (relationColOpt?.virtual) {
const indexes =
(
await sqlMgr.sqlOp(source, 'indexList', {
tn: cTable.table_name,
})
)?.data?.list ?? [];
for (const index of indexes) {
if (index.cn !== childColumn.column_name) continue;
await sqlMgr.sqlOpPlus(source, 'indexDelete', {
...index,
tn: cTable.table_name,
columns: [childColumn.column_name],
indexName: index.key_name,
});
}
}
const tableUpdateBody = {
...cTable,
tn: cTable.table_name,
originalColumns: cTable.columns.map((c) => ({
...c,
cn: c.column_name,
cno: c.column_name,
})),
columns: cTable.columns.map((c) => {
if (c.id === childColumn.id) {
return {
...c,
cn: c.column_name,
cno: c.column_name,
altered: Altered.DELETE_COLUMN,
};
} else {
(c as any).cn = c.column_name;
}
return c;
}),
};
await sqlMgr.sqlOpPlus(source, 'tableUpdate', tableUpdateBody);
}
// delete foreign key column
await Column.delete(childColumn.id, ncMeta);
};
deleteOoRelation = async (
{
relationColOpt,
source,
childColumn,
childTable,
parentColumn,
parentTable,
sqlMgr,
ncMeta = Noco.ncMeta,
virtual,
}: {
relationColOpt: LinkToAnotherRecordColumn;
source: Source;
childColumn: Column;
childTable: Model;
parentColumn: Column;
parentTable: Model;
sqlMgr: SqlMgrv2;
ncMeta?: MetaService;
virtual?: boolean;
},
ignoreFkDelete = false,
) => {
if (childTable) {
let foreignKeyName;
// if relationColOpt is not provided, extract it from child table
// and get the foreign key name for dropping the foreign key
if (!relationColOpt) {
foreignKeyName = (
(
await childTable.getColumns(ncMeta).then(async (cols) => {
for (const col of cols) {
if (col.uidt === UITypes.LinkToAnotherRecord) {
const colOptions =
await col.getColOptions<LinkToAnotherRecordColumn>(ncMeta);
if (colOptions.fk_related_model_id === parentTable.id) {
return { colOptions };
}
}
}
})
)?.colOptions as LinkToAnotherRecordType
).fk_index_name;
} else {
foreignKeyName = relationColOpt.fk_index_name;
}
if (!relationColOpt?.virtual && !virtual) {
// Ensure relation deletion is not attempted for virtual relations
try {
// Attempt to delete the foreign key constraint from the database
await sqlMgr.sqlOpPlus(source, 'relationDelete', {
childColumn: childColumn.column_name,
childTable: childTable.table_name,
@ -2623,6 +2784,102 @@ export class ColumnsService {
isLinks,
param.colExtra,
);
} else if ((param.column as LinkToAnotherColumnReqType).type === 'oo') {
// populate fk column name
const fkColName = getUniqueColumnName(
await child.getColumns(),
`${parent.table_name}_id`,
);
let foreignKeyName;
{
// Create foreign key column for one-to-one relationship
const newColumn = {
cn: fkColName, // Column name in the database
title: fkColName, // Human-readable title for the column
column_name: fkColName, // Column name in the database ( used in sql client )
rqd: false,
pk: false,
ai: false,
cdf: null,
dt: parent.primaryKey.dt,
dtxp: parent.primaryKey.dtxp,
dtxs: parent.primaryKey.dtxs,
un: parent.primaryKey.un,
altered: Altered.NEW_COLUMN,
unique: 1, // Ensure the foreign key column is unique for one-to-one relationships
};
const tableUpdateBody = {
...child,
tn: child.table_name,
originalColumns: child.columns.map((c) => ({
...c,
cn: c.column_name,
})),
columns: [
...child.columns.map((c) => ({
...c,
cn: c.column_name,
})),
newColumn,
],
};
await sqlMgr.sqlOpPlus(param.source, 'tableUpdate', tableUpdateBody);
const { id } = await Column.insert({
...newColumn,
uidt: UITypes.ForeignKey,
fk_model_id: child.id,
});
childColumn = await Column.get({ colId: id });
// ignore relation creation if virtual
if (!(param.column as LinkToAnotherColumnReqType).virtual) {
foreignKeyName = generateFkName(parent, child);
// create relation
await sqlMgr.sqlOpPlus(param.source, 'relationCreate', {
childColumn: fkColName,
childTable: child.table_name,
parentTable: parent.table_name,
onDelete: 'NO ACTION',
onUpdate: 'NO ACTION',
type: 'real',
parentColumn: parent.primaryKey.column_name,
foreignKeyName,
});
}
// todo: create index for virtual relations as well
// create index for foreign key in pg
if (
param.source.type === 'pg' ||
(param.column as LinkToAnotherColumnReqType).virtual
) {
await this.createColumnIndex({
column: new Column({
...newColumn,
fk_model_id: child.id,
}),
source: param.source,
sqlMgr,
});
}
}
await createOOColumn(
child,
parent,
childColumn,
(param.column as LinkToAnotherColumnReqType).type as RelationTypes,
(param.column as LinkToAnotherColumnReqType).title,
foreignKeyName,
(param.column as LinkToAnotherColumnReqType).virtual,
null,
param.column['meta'],
param.colExtra,
);
} else if ((param.column as LinkToAnotherColumnReqType).type === 'mm') {
const aTn = `${param.base?.prefix ?? ''}_nc_m2m_${randomID()}`;
const aTnAlias = aTn;
@ -2843,10 +3100,11 @@ export class ColumnsService {
non_unique: nonUnique,
indexName,
};
sqlMgr.sqlOpPlus(source, 'indexCreate', indexArgs);
await sqlMgr.sqlOpPlus(source, 'indexCreate', indexArgs);
}
async updateRollupOrLookup(colBody: any, column: Column<any>) {
// Validate rollup or lookup payload before proceeding with the update
if (
UITypes.Lookup === column.uidt &&
validateRequiredField(colBody, [
@ -2854,6 +3112,7 @@ export class ColumnsService {
'fk_relation_column_id',
])
) {
// Perform additional validation for lookup payload
await validateLookupPayload(colBody, column.id);
await Column.update(column.id, colBody);
} else if (
@ -2864,6 +3123,7 @@ export class ColumnsService {
'rollup_function',
])
) {
// Perform additional validation for rollup payload
await validateRollupPayload(colBody);
await Column.update(column.id, colBody);
}
@ -2971,7 +3231,7 @@ export class ColumnsService {
}
const failedOps = [];
// Perform operations in a loop, capturing any errors for individual operations
for (const op of ops) {
const column = op.column;

41
packages/nocodb/src/services/data-alias-nested.service.ts

@ -186,6 +186,47 @@ export class DataAliasNestedService {
...param.query,
});
}
async ooExcludedList(
param: PathParams & {
query: any;
columnName: string;
rowId: string;
},
) {
const { model, view } = await getViewAndModelByAliasOrId(param);
if (!model) NcError.notFound('Table not found');
const source = await Source.get(model.source_id);
const baseModel = await Model.getBaseModelSQL({
id: model.id,
viewId: view?.id,
dbDriver: await NcConnectionMgrv2.get(source),
});
const column = await getColumnByIdOrName(param.columnName, model);
const data = await baseModel.getExcludedOneToOneChildrenList(
{
colId: column.id,
cid: param.rowId,
},
param.query,
);
const count = await baseModel.countExcludedOneToOneChildren(
{
colId: column.id,
cid: param.rowId,
},
param.query,
);
return new PagedResponseImpl(data, {
count,
...param.query,
});
}
// todo: handle case where the given column is not ltar
async hmList(

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

@ -111,6 +111,7 @@ const permissionScopes = {
'mmExcludedList',
'hmExcludedList',
'btExcludedList',
'ooExcludedList',
'gridColumnUpdate',
'bulkDataInsert',
'bulkDataUpdate',
@ -223,6 +224,7 @@ const rolePermissions:
mmExcludedList: true,
hmExcludedList: true,
btExcludedList: true,
ooExcludedList: true,
gridColumnUpdate: true,
bulkDataInsert: true,
bulkDataUpdate: true,

Loading…
Cancel
Save