Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { BlockAuth, createAction, Property } from '@openops/blocks-framework';
import {
batchUpdateRows,
getFields,
getTableIdByTableName,
OpenOpsField,
openopsTablesDropdownProperty,
resolveTokenProvider,
TokenOrResolver,
} from '@openops/common';
import { cacheWrapper } from '@openops/server-shared';

export const updateRecordsBatchAction = createAction({
auth: BlockAuth.None(),
name: 'update_records_batch',
description: 'Update multiple existing records in an OpenOps table.',
displayName: 'Update Records Batch',
isWriteAction: true,
props: {
tableName: openopsTablesDropdownProperty(),
items: Property.Json({
displayName: 'Items',
required: true,
description:
'An array of objects with rowId (Baserow internal row ID as integer) and fields keyed by table field names. Example: [{ rowId: 123, fields: { Owner: "user@example.com" } }]',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we get the Baserow internal row ID?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its automatically included when you get a baserow object, so when we do get records its already there

}),
},
async run(context) {
const tableName = context.propsValue.tableName as unknown as string;
const items = context.propsValue.items as unknown;

if (!Array.isArray(items)) {
throw new Error(

Check warning on line 33 in packages/blocks/openops-tables/src/actions/update-records-batch-action.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`new Error()` is too unspecific for a type check. Use `new TypeError()` instead.

See more on https://sonarcloud.io/project/issues?id=openops-cloud_openops&issues=AZ41SvFCmJyaFL6eHGB3&open=AZ41SvFCmJyaFL6eHGB3&pullRequest=2298
'Items must be an array of objects with rowId and fields.',
);
}

if (
items.some(
(item) =>
item === null ||
typeof item !== 'object' ||
Array.isArray(item) ||
typeof item.rowId !== 'number' ||
!Number.isInteger(item.rowId) ||
item.fields === null ||
typeof item.fields !== 'object' ||
Array.isArray(item.fields),
)
) {
throw new Error(
'Each item must include an integer rowId and an object fields value.',
);
}
Comment thread
bigfluffycookie marked this conversation as resolved.

const tableCacheKey = `${context.run.id}-table-${tableName}`;
const tableId = await cacheWrapper.getOrAdd(
tableCacheKey,
getTableIdByTableName,
[tableName, context.server],
);

const tokenOrResolver = await resolveTokenProvider(context.server);
const fieldsCacheKey = `${context.run.id}-${tableId}-fields`;
await cacheWrapper.getOrAdd<OpenOpsField[], [number, TokenOrResolver]>(
fieldsCacheKey,
getFields,
[tableId, tokenOrResolver],
);

return await batchUpdateRows({
tableId,
tokenOrResolver,
items: items as {
rowId: number;
fields: { [key: string]: any };
}[],
});
},
});
2 changes: 2 additions & 0 deletions packages/blocks/openops-tables/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { deleteRecordAction } from './actions/delete-record-action';
import { getRecordsAction } from './actions/get-records-action';
import { getTableUrlAction } from './actions/get-table-url-action';
import { updateRecordAction } from './actions/update-record-action';
import { updateRecordsBatchAction } from './actions/update-records-batch-action';

export const openopsTables = createBlock({
displayName: 'OpenOps Tables',
Expand All @@ -17,6 +18,7 @@ export const openopsTables = createBlock({
getRecordsAction,
createRecordsBatchAction,
updateRecordAction,
updateRecordsBatchAction,
deleteRecordAction,
getTableUrlAction,
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
const cacheWrapperMock = {
getSerializedObject: jest.fn(),
setSerializedObject: jest.fn(),
getOrAdd: jest.fn(),
};

jest.mock('@openops/server-shared', () => ({
...jest.requireActual('@openops/server-shared'),
cacheWrapper: cacheWrapperMock,
}));

const openopsCommonMock = {
...jest.requireActual('@openops/common'),
batchUpdateRows: jest.fn(),
getFields: jest.fn(),
getTableIdByTableName: jest.fn(),
openopsTablesDropdownProperty: jest.fn().mockReturnValue({
required: true,
defaultValue: false,
type: 'DROPDOWN',
}),
resolveTokenProvider: jest.fn(async (serverContext) => {
return {
getToken: () => serverContext.tablesDatabaseToken,
};
}),
};

jest.mock('@openops/common', () => openopsCommonMock);

import { getFields, getTableIdByTableName } from '@openops/common';
import { nanoid } from 'nanoid';
import { updateRecordsBatchAction } from '../../src/actions/update-records-batch-action';

describe('updateRecordsBatchAction', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('should create action with correct properties', () => {
expect(Object.keys(updateRecordsBatchAction.props).length).toBe(2);
expect(updateRecordsBatchAction.props).toMatchObject({
tableName: {
required: true,
type: 'DROPDOWN',
},
items: {
required: true,
type: 'JSON',
},
});
});

test('should resolve table metadata and batch update rows', async () => {
cacheWrapperMock.getOrAdd
.mockResolvedValueOnce(1)
.mockResolvedValueOnce([{ name: 'ID', primary: true }]);
openopsCommonMock.batchUpdateRows.mockResolvedValue([{ id: 101 }]);

const context = createContext({
items: [
{
rowId: 1,
fields: { Owner: 'leyla@openops.com' },
},
],
});

const result = await updateRecordsBatchAction.run(context);

expect(cacheWrapperMock.getOrAdd).toHaveBeenNthCalledWith(
1,
`${context.run.id}-table-${context.propsValue.tableName}`,
getTableIdByTableName,
[context.propsValue.tableName, context.server],
);
expect(cacheWrapperMock.getOrAdd).toHaveBeenNthCalledWith(
2,
`${context.run.id}-1-fields`,
getFields,
[
1,
expect.objectContaining({
getToken: expect.any(Function),
}),
],
);
expect(openopsCommonMock.resolveTokenProvider).toHaveBeenCalledWith(
context.server,
);
expect(openopsCommonMock.batchUpdateRows).toHaveBeenCalledWith({
tableId: 1,
tokenOrResolver: expect.objectContaining({
getToken: expect.any(Function),
}),
items: [
{
rowId: 1,
fields: { Owner: 'leyla@openops.com' },
},
],
});
expect(result).toEqual([{ id: 101 }]);
});

test('should reject non-array items', async () => {
const context = createContext({
items: { rowId: 1, fields: { Owner: 'a@b.com' } },
});

await expect(updateRecordsBatchAction.run(context)).rejects.toThrow(
'Items must be an array of objects with rowId and fields.',
);

expect(cacheWrapperMock.getOrAdd).not.toHaveBeenCalled();
expect(openopsCommonMock.batchUpdateRows).not.toHaveBeenCalled();
});

test('should reject invalid batch update items', async () => {
const context = createContext({
items: [
{
rowId: '1',
fields: { Owner: 'a@b.com' },
},
],
});

await expect(updateRecordsBatchAction.run(context)).rejects.toThrow(
'Each item must include an integer rowId and an object fields value.',
);

expect(cacheWrapperMock.getOrAdd).not.toHaveBeenCalled();
expect(openopsCommonMock.batchUpdateRows).not.toHaveBeenCalled();
});
});

function createContext(params?: { tableName?: string; items?: unknown }) {
return {
...jest.requireActual('@openops/blocks-framework'),
propsValue: {
tableName: params?.tableName ?? 'Opportunity',
items: params?.items ?? [],
},
server: {
tablesDatabaseId: 1,
tablesDatabaseToken: 'token',
},
run: {
id: nanoid(),
},
};
}
6 changes: 5 additions & 1 deletion packages/blocks/openops-tables/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { openopsTables } from '../src/index';

describe('block declaration tests', () => {
test('should return block with correct number of actions', () => {
expect(Object.keys(openopsTables.actions()).length).toBe(5);
expect(Object.keys(openopsTables.actions()).length).toBe(6);
expect(openopsTables.actions()).toMatchObject({
update_record: {
name: 'update_record',
requireAuth: true,
},
update_records_batch: {
name: 'update_records_batch',
requireAuth: true,
},
get_records: {
name: 'get_records',
requireAuth: true,
Expand Down
65 changes: 62 additions & 3 deletions packages/openops/src/lib/openops-tables/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export interface BatchCreateRowsParams extends RowParams {
items: { [key: string]: any }[];
}

export interface BatchUpdateRowsParams extends RowParams {
items: {
rowId: number;
fields: { [key: string]: any };
}[];
}

export interface UpsertRowParams extends RowParams {
fields: { [key: string]: any };
}
Expand Down Expand Up @@ -69,7 +76,7 @@ class TablesAccessSemaphore {
}

const semaphore = TablesAccessSemaphore.getInstance();
const MAX_BATCH_CREATE_ROWS = 200;
const MAX_BATCH_ROWS = 200;

async function executeWithConcurrencyLimit<T>(
fn: () => Promise<T>,
Expand Down Expand Up @@ -226,11 +233,11 @@ export async function batchCreateRows(
for (
let index = 0;
index < batchCreateRowsParams.items.length;
index += MAX_BATCH_CREATE_ROWS
index += MAX_BATCH_ROWS
) {
const items = batchCreateRowsParams.items.slice(
index,
index + MAX_BATCH_CREATE_ROWS,
index + MAX_BATCH_ROWS,
);

const response = await makeOpenOpsTablesPost<unknown>(
Expand All @@ -257,6 +264,58 @@ export async function batchCreateRows(
);
}

export async function batchUpdateRows(
batchUpdateRowsParams: BatchUpdateRowsParams,
) {
if (batchUpdateRowsParams.items.length === 0) {
return [];
}

const url = `api/database/rows/table/${batchUpdateRowsParams.tableId}/batch/?user_field_names=true`;

return executeWithConcurrencyLimit(
async () => {
const authenticationHeader = createAxiosHeaders(
batchUpdateRowsParams.tokenOrResolver,
);
const results = [];

for (
let index = 0;
index < batchUpdateRowsParams.items.length;
index += MAX_BATCH_ROWS
) {
const items = batchUpdateRowsParams.items
.slice(index, index + MAX_BATCH_ROWS)
.map(({ rowId, fields }) => ({
id: rowId,
...fields,
}));

const response = await makeOpenOpsTablesPatch<unknown>(
url,
{ items },
authenticationHeader,
);
Comment thread
bigfluffycookie marked this conversation as resolved.
if (Array.isArray(response)) {
results.push(...response);
} else if (response != null) {
results.push(response);
}
}

return results;
},
(error) => {
logger.error('Error while batch updating rows:', {
error,
url,
itemsCount: batchUpdateRowsParams.items.length,
});
},
);
}

export async function deleteRow(deleteRowParams: DeleteRowParams) {
const url = `api/database/rows/table/${deleteRowParams.tableId}/${deleteRowParams.rowId}/`;

Expand Down
Loading
Loading