diff --git a/packages/nc-gui/components/smartsheet/Form.vue b/packages/nc-gui/components/smartsheet/Form.vue index 670b7c5c9e..fd8de40185 100644 --- a/packages/nc-gui/components/smartsheet/Form.vue +++ b/packages/nc-gui/components/smartsheet/Form.vue @@ -67,7 +67,7 @@ reloadEventHook.on(async () => { const { showAll, hideAll, saveOrUpdate } = useViewColumnsOrThrow() -const { syncLTARRefs, row } = useProvideSmartsheetRowStore( +const { state, row } = useProvideSmartsheetRowStore( meta, ref({ row: formState, @@ -124,11 +124,7 @@ async function submitForm() { if (e.errorFields.length) return } - const insertedRowData = await insertRow({ row: formState, oldRow: {}, rowMeta: { new: true } }) - - if (insertedRowData) { - await syncLTARRefs(insertedRowData) - } + await insertRow({ row: { ...formState, ...state.value }, oldRow: {}, rowMeta: { new: true } }) submitted.value = true } diff --git a/packages/nc-gui/components/smartsheet/Row.vue b/packages/nc-gui/components/smartsheet/Row.vue index 39f313c052..57e31f4bd4 100644 --- a/packages/nc-gui/components/smartsheet/Row.vue +++ b/packages/nc-gui/components/smartsheet/Row.vue @@ -24,16 +24,6 @@ const { meta } = useSmartsheetStoreOrThrow() const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef } = useProvideSmartsheetRowStore(meta as Ref, currentRow) -// on changing isNew(new record insert) status sync LTAR cell values -watch(isNew, async (nextVal, prevVal) => { - if (prevVal && !nextVal) { - await syncLTARRefs(currentRow.value.row) - // update row values without invoking api - currentRow.value.row = { ...currentRow.value.row, ...state.value } - currentRow.value.oldRow = { ...currentRow.value.row, ...state.value } - } -}) - const reloadViewDataTrigger = inject(ReloadViewDataHookInj)! // override reload trigger and use it to reload row diff --git a/packages/nc-gui/components/smartsheet/expanded-form/index.vue b/packages/nc-gui/components/smartsheet/expanded-form/index.vue index 2ea4aaa0ae..c346d771ad 100644 --- a/packages/nc-gui/components/smartsheet/expanded-form/index.vue +++ b/packages/nc-gui/components/smartsheet/expanded-form/index.vue @@ -187,7 +187,6 @@ const onDuplicateRow = () => { const save = async () => { if (isNew.value) { const data = await _save(rowState.value) - await syncLTARRefs(data) reloadTrigger?.trigger() } else { let kanbanClbk @@ -347,8 +346,7 @@ useActiveKeyupListener( e.stopPropagation() if (isNew.value) { - const data = await _save(rowState.value) - await syncLTARRefs(data) + await _save(rowState.value) reloadHook?.trigger(null) } else { await save() @@ -382,8 +380,7 @@ useActiveKeyupListener( okText: t('general.save'), cancelText: t('labels.discard'), onOk: async () => { - const data = await _save(rowState.value) - await syncLTARRefs(data) + await _save(rowState.value) reloadHook?.trigger(null) addNewRow() }, diff --git a/packages/nc-gui/components/virtual-cell/Links.vue b/packages/nc-gui/components/virtual-cell/Links.vue index 51179a4cd1..78668353aa 100644 --- a/packages/nc-gui/components/virtual-cell/Links.vue +++ b/packages/nc-gui/components/virtual-cell/Links.vue @@ -47,7 +47,7 @@ const relatedTableDisplayColumn = computed( loadRelatedTableMeta() const textVal = computed(() => { - if (isForm?.value) { + if (isForm?.value || isNew.value) { return state.value?.[colTitle.value]?.length ? `${+state.value?.[colTitle.value]?.length} ${t('msg.recordsLinked')}` : t('msg.noRecordsLinked') diff --git a/packages/nc-gui/composables/useData.ts b/packages/nc-gui/composables/useData.ts index fa9c7475ab..24bbe57373 100644 --- a/packages/nc-gui/composables/useData.ts +++ b/packages/nc-gui/composables/useData.ts @@ -91,7 +91,7 @@ export function useData(args: { base?.value.id as string, metaValue?.id as string, viewMetaValue?.id as string, - insertObj, + { ...insertObj, ...(ltarState ||{}) }, ) if (!undo) { diff --git a/packages/nc-gui/composables/useExpandedFormStore.ts b/packages/nc-gui/composables/useExpandedFormStore.ts index ae26808fe9..6910cf3d14 100644 --- a/packages/nc-gui/composables/useExpandedFormStore.ts +++ b/packages/nc-gui/composables/useExpandedFormStore.ts @@ -180,7 +180,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m if (missingRequiredColumns.size) return - data = await $api.dbTableRow.create('noco', base.value.id as string, meta.value.id, insertObj) + data = await $api.dbTableRow.create('noco', base.value.id as string, meta.value.id, { ...insertObj, ...(ltarState ||{}) }) Object.assign(row.value, { row: data, diff --git a/packages/nocodb/src/db/BaseModelSqlv2.ts b/packages/nocodb/src/db/BaseModelSqlv2.ts index f5ce571808..afc13c598a 100644 --- a/packages/nocodb/src/db/BaseModelSqlv2.ts +++ b/packages/nocodb/src/db/BaseModelSqlv2.ts @@ -19,8 +19,8 @@ import Validator from 'validator'; import { customAlphabet } from 'nanoid'; import DOMPurify from 'isomorphic-dompurify'; import { v4 as uuidv4 } from 'uuid'; +import { Knex } from 'knex'; import type LookupColumn from '~/models/LookupColumn'; -import type { Knex } from 'knex'; import type { XKnex } from '~/db/CustomKnex'; import type { XcFilter, @@ -65,6 +65,7 @@ import { } from '~/utils/globals'; import { extractProps } from '~/helpers/extractProps'; import { defaultLimitConfig } from '~/helpers/extractLimitAndOffset'; +import Transaction = Knex.Transaction; dayjs.extend(utc); @@ -1736,28 +1737,35 @@ class BaseModelSqlv2 { (await column.getColOptions()) as LinkToAnotherRecordColumn; if (colOptions?.type === 'hm') { - 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] : [])); - } else { - return [ - await this.hmList( + const listLoader = new DataLoader( + async (ids: string[]) => { + if (ids.length > 1) { + const data = await this.multipleHmList( { colId: column.id, - id: ids[0], + ids, }, (listLoader as any).args, - ), - ]; - } - }); + ); + return ids.map((id: string) => + data[id] ? data[id] : [], + ); + } else { + return [ + await this.hmList( + { + colId: column.id, + id: ids[0], + }, + (listLoader as any).args, + ), + ]; + } + }, + { + cache: false, + }, + ); const self: BaseModelSqlv2 = this; proto[ @@ -1771,29 +1779,34 @@ class BaseModelSqlv2 { ); }; } else if (colOptions.type === 'mm') { - const listLoader = new DataLoader(async (ids: string[]) => { - if (ids?.length > 1) { - const data = await this.multipleMmList( - { - parentIds: ids, - colId: column.id, - }, - (listLoader as any).args, - ); - - return data; - } else { - return [ - await this.mmList( + const listLoader = new DataLoader( + async (ids: string[]) => { + if (ids?.length > 1) { + const data = await this.multipleMmList( { - parentId: ids[0], + parentIds: ids, colId: column.id, }, (listLoader as any).args, - ), - ]; - } - }); + ); + + return data; + } else { + return [ + await this.mmList( + { + parentId: ids[0], + colId: column.id, + }, + (listLoader as any).args, + ), + ]; + } + }, + { + cache: false, + }, + ); const self: BaseModelSqlv2 = this; proto[ @@ -1821,55 +1834,62 @@ class BaseModelSqlv2 { // 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', - }), - ], - }, - true, - ); + 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', + }), + ], + }, + true, + ); - const groupedList = groupBy(data, pCol.title); - return _ids.map(async (id: string) => groupedList?.[id]?.[0]); - }); + 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) { @@ -2530,71 +2550,15 @@ class BaseModelSqlv2 { ); let rowId = null; - const postInsertOps = []; const nestedCols = (await this.model.getColumns()).filter((c) => isLinksOrLTAR(c), ); - - for (const col of nestedCols) { - if (col.title in data) { - const colOptions = - await col.getColOptions(); - - // parse data if it's JSON string - const nestedData = - typeof data[col.title] === 'string' - ? JSON.parse(data[col.title]) - : data[col.title]; - - switch (colOptions.type) { - case RelationTypes.BELONGS_TO: - { - const childCol = await colOptions.getChildColumn(); - const parentCol = await colOptions.getParentColumn(); - insertObj[childCol.column_name] = nestedData?.[parentCol.title]; - } - break; - case RelationTypes.HAS_MANY: - { - const childCol = await colOptions.getChildColumn(); - const childModel = await childCol.getModel(); - await childModel.getColumns(); - - postInsertOps.push(async () => { - await this.dbDriver(this.getTnPath(childModel.table_name)) - .update({ - [childCol.column_name]: rowId, - }) - .whereIn( - childModel.primaryKey.column_name, - nestedData?.map((r) => r[childModel.primaryKey.title]), - ); - }); - } - break; - case RelationTypes.MANY_TO_MANY: { - postInsertOps.push(async () => { - const parentModel = await colOptions - .getParentColumn() - .then((c) => c.getModel()); - await parentModel.getColumns(); - const parentMMCol = await colOptions.getMMParentColumn(); - const childMMCol = await colOptions.getMMChildColumn(); - const mmModel = await colOptions.getMMModel(); - - const rows = nestedData.map((r) => ({ - [parentMMCol.column_name]: r[parentModel.primaryKey.title], - [childMMCol.column_name]: rowId, - })); - await this.dbDriver(this.getTnPath(mmModel.table_name)).insert( - rows, - ); - }); - } - } - } - } + const postInsertOps = await this.prepareNestedLinkQb({ + nestedCols, + data, + insertObj, + }); await this.validate(insertObj); @@ -2615,51 +2579,50 @@ class BaseModelSqlv2 { !response || (typeof response?.[0] !== 'object' && response?.[0] !== null) ) { - let id; if (response?.length) { - id = response[0]; + rowId = response[0]; } else { - id = (await query)[0]; + rowId = (await query)[0]; } if (ai) { if (this.isSqlite) { // sqlite doesnt return id after insert - id = ( + rowId = ( await this.dbDriver(this.tnPath) .select(ai.column_name) .max(ai.column_name, { as: 'id' }) )[0].id; } else if (this.isSnowflake) { - id = ( + rowId = ( (await this.dbDriver(this.tnPath).max(ai.column_name, { as: 'id', })) as any ).rows[0].id; } - response = await this.readByPk( - id, - false, - {}, - { ignoreView: true, getHiddenColumn: true }, - ); + // response = await this.readByPk( + // id, + // false, + // {}, + // { ignoreView: true, getHiddenColumn: true }, + // ); } else { response = data; } } else if (ai) { - response = await this.readByPk( - Array.isArray(response) - ? response?.[0]?.[ai.title] - : response?.[ai.title], - ); + rowId = Array.isArray(response) + ? response?.[0]?.[ai.title] + : response?.[ai.title]; } - response = Array.isArray(response) ? response[0] : response; - if (response) - rowId = - response[this.model.primaryKey.title] || - response[this.model.primaryKey.column_name]; - await Promise.all(postInsertOps.map((f) => f())); + await Promise.all(postInsertOps.map((f) => f(rowId))); + + response = await this.readByPk( + rowId, + false, + {}, + { ignoreView: true, getHiddenColumn: true }, + ); await this.afterInsert(response, this.dbDriver, cookie); @@ -2670,6 +2633,94 @@ class BaseModelSqlv2 { } } + private async prepareNestedLinkQb({ + nestedCols, + data, + insertObj, + }: { + nestedCols: Column[]; + data: Record; + insertObj: Record; + }) { + const postInsertOps: ((rowId: any, trx?: any) => Promise)[] = []; + for (const col of nestedCols) { + if (col.title in data) { + const colOptions = await col.getColOptions(); + + // parse data if it's JSON string + let nestedData; + try { + nestedData = + typeof data[col.title] === 'string' + ? JSON.parse(data[col.title]) + : data[col.title]; + } catch { + continue; + } + switch (colOptions.type) { + case RelationTypes.BELONGS_TO: + { + if (typeof nestedData !== 'object') continue; + const childCol = await colOptions.getChildColumn(); + const parentCol = await colOptions.getParentColumn(); + insertObj[childCol.column_name] = nestedData?.[parentCol.title]; + } + break; + case RelationTypes.HAS_MANY: + { + if (!Array.isArray(nestedData)) continue; + const childCol = await colOptions.getChildColumn(); + const childModel = await childCol.getModel(); + await childModel.getColumns(); + + postInsertOps.push( + async ( + rowId, + // todo: use transaction type + trx: any = this.dbDriver, + ) => { + await trx(this.getTnPath(childModel.table_name)) + .update({ + [childCol.column_name]: rowId, + }) + .whereIn( + childModel.primaryKey.column_name, + nestedData?.map((r) => r[childModel.primaryKey.title]), + ); + }, + ); + } + break; + case RelationTypes.MANY_TO_MANY: { + if (!Array.isArray(nestedData)) continue; + postInsertOps.push( + async ( + rowId, + // todo: use transaction type + trx: any = this.dbDriver, + ) => { + const parentModel = await colOptions + .getParentColumn() + .then((c) => c.getModel()); + await parentModel.getColumns(); + const parentMMCol = await colOptions.getMMParentColumn(); + const childMMCol = await colOptions.getMMChildColumn(); + const mmModel = await colOptions.getMMModel(); + + const rows = nestedData.map((r) => ({ + [parentMMCol.column_name]: r[parentModel.primaryKey.title], + [childMMCol.column_name]: rowId, + })); + await trx(this.getTnPath(mmModel.table_name)).insert(rows); + }, + ); + } + } + } + } + return postInsertOps; + } + async bulkInsert( datas: any[], { @@ -2694,8 +2745,13 @@ class BaseModelSqlv2 { try { // TODO: ag column handling for raw bulk insert const insertDatas = raw ? datas : []; + let postInsertOps: ((rowId: any, trx?: any) => Promise)[] = []; if (!raw) { + const nestedCols = (await this.model.getColumns()).filter((c) => + isLinksOrLTAR(c), + ); + await this.model.getColumns(); for (const d of datas) { @@ -2822,6 +2878,15 @@ class BaseModelSqlv2 { await this.prepareAttachmentData(insertObj); + // prepare nested link data for insert only if it is single record insertion + if (isSingleRecordInsertion) { + postInsertOps = await this.prepareNestedLinkQb({ + nestedCols, + data: d, + insertObj, + }); + } + insertDatas.push(insertObj); } } @@ -2866,7 +2931,10 @@ class BaseModelSqlv2 { this.isPg || this.isMssql ? await trx .batchInsert(this.tnPath, insertDatas, chunkSize) - .returning(this.model.primaryKey?.column_name) + .returning({ + [this.model.primaryKey?.title]: + this.model.primaryKey?.column_name, + }) : await trx.batchInsert(this.tnPath, insertDatas, chunkSize); } @@ -2878,6 +2946,12 @@ class BaseModelSqlv2 { } } + // insert nested link data for single record insertion + if (isSingleRecordInsertion) { + const rowId = response[0][this.model.primaryKey?.title]; + await Promise.all(postInsertOps.map((f) => f(rowId, trx))); + } + await trx.commit(); if (!raw && !skip_hooks) { @@ -3605,6 +3679,12 @@ class BaseModelSqlv2 { const childTn = this.getTnPath(childTable); const parentTn = this.getTnPath(parentTable); + const prevData = await this.readByPk( + rowId, + false, + {}, + { ignoreView: true, getHiddenColumn: true }, + ); switch (colOptions.type) { case RelationTypes.MANY_TO_MANY: @@ -3682,7 +3762,7 @@ class BaseModelSqlv2 { {}, { ignoreView: true, getHiddenColumn: true }, ); - await this.afterInsert(response, this.dbDriver, cookie); + await this.afterUpdate(prevData, response, this.dbDriver, cookie); await this.afterAddChild(rowId, childId, cookie); } @@ -4347,7 +4427,7 @@ class BaseModelSqlv2 { } async addLinks({ - cookie: _cookie, + cookie, childIds, colId, rowId, @@ -4363,9 +4443,12 @@ class BaseModelSqlv2 { if (!column || !isLinksOrLTAR(column)) NcError.notFound(`Link column ${colId} not found`); - const row = await this.dbDriver(this.tnPath) - .where(await this._wherePk(rowId)) - .first(); + const row = await this.readByPk( + rowId, + false, + {}, + { ignoreView: true, getHiddenColumn: true }, + ); // validate rowId if (!row) { @@ -4569,13 +4652,20 @@ class BaseModelSqlv2 { break; } - // const response = await this.readByPk(rowId); - // await this.afterInsert(response, this.dbDriver, cookie); - // await this.afterAddChild(rowId, childId, cookie); + const updatedRow = await this.readByPk( + rowId, + false, + {}, + { ignoreView: true, getHiddenColumn: true }, + ); + await this.afterUpdate(row, updatedRow, this.dbDriver, cookie); + for (const childId of childIds) { + await this.afterAddChild(rowId, childId, cookie); + } } async removeLinks({ - cookie: _cookie, + cookie, childIds, colId, rowId, @@ -4591,9 +4681,12 @@ class BaseModelSqlv2 { if (!column || !isLinksOrLTAR(column)) NcError.notFound(`Link column ${colId} not found`); - const row = await this.dbDriver(this.tnPath) - .where(await this._wherePk(rowId)) - .first(); + const row = await this.readByPk( + rowId, + false, + {}, + { ignoreView: true, getHiddenColumn: true }, + ); // validate rowId if (!row) { @@ -4772,9 +4865,16 @@ class BaseModelSqlv2 { break; } - // const newData = await this.readByPk(rowId); - // await this.afterUpdate(prevData, newData, this.dbDriver, cookie); - // await this.afterRemoveChild(rowId, childIds, cookie); + const updatedRow = await this.readByPk( + rowId, + false, + {}, + { ignoreView: true, getHiddenColumn: true }, + ); + await this.afterUpdate(row, updatedRow, this.dbDriver, cookie); + for (const childId of childIds) { + await this.afterRemoveChild(rowId, childId, cookie); + } } async btRead( diff --git a/packages/nocodb/src/services/datas.service.ts b/packages/nocodb/src/services/datas.service.ts index a20a8bb034..79154b57cb 100644 --- a/packages/nocodb/src/services/datas.service.ts +++ b/packages/nocodb/src/services/datas.service.ts @@ -78,7 +78,7 @@ export class DatasService { dbDriver: await NcConnectionMgrv2.get(source), }); - return await baseModel.insert(param.body, null, param.cookie); + return await baseModel.nestedInsert(param.body, null, param.cookie); } async dataUpdate(