Skip to content

Commit

Permalink
feat: field plan (#317)
Browse files Browse the repository at this point in the history
* refactor: move graph to standalone module

* feat: create field plain api

* refactor: optimize graph component

* feat: field graph ui

* feat: optimize graph style

* feat: plan field update api

* feat: ui for plan field update

* fix: crash when number formula without formatting

* feat: create field plan api

* feat: show dependancies graph

* chore: update shadcn/ui to latest

* feat: polish field graph ux

* fix: test fail

* fix: pg test unstable
  • Loading branch information
tea-artist authored Jan 5, 2024
1 parent 13ad8a4 commit 01b28f6
Show file tree
Hide file tree
Showing 70 changed files with 2,546 additions and 688 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"signin",
"signout",
"sonarjs",
"sonner",
"teable",
"testid",
"topo",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { nameConsole } from './utils/name-console';

export interface ITopoOrdersContext {
fieldMap: IFieldMap;
allFieldIds: string[];
startFieldIds: string[];
directedGraph: IGraphItem[];
fieldId2DbTableName: { [fieldId: string]: string };
Expand All @@ -36,7 +37,7 @@ export class FieldCalculationService {
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
) {}

private async getSelfOriginRecords(dbTableName: string) {
async getSelfOriginRecords(dbTableName: string) {
const nativeSql = this.knex.queryBuilder().select('__id').from(dbTableName).toSQL().toNative();

const results = await this.prismaService
Expand Down Expand Up @@ -92,8 +93,11 @@ export class FieldCalculationService {
await this.batchService.updateRecords(opsMap, fieldMap, tableId2DbTableName);
}

async getTopoOrdersContext(fieldIds: string[]): Promise<ITopoOrdersContext> {
const directedGraph = await this.referenceService.getFieldGraphItems(fieldIds);
async getTopoOrdersContext(
fieldIds: string[],
customGraph?: IGraphItem[]
): Promise<ITopoOrdersContext> {
const directedGraph = customGraph || (await this.referenceService.getFieldGraphItems(fieldIds));

// get all related field by undirected graph
const allFieldIds = uniq(this.referenceService.flatGraph(directedGraph).concat(fieldIds));
Expand All @@ -113,6 +117,7 @@ export class FieldCalculationService {

return {
startFieldIds: fieldIds,
allFieldIds,
fieldMap,
directedGraph,
topoOrdersByFieldId,
Expand All @@ -123,7 +128,7 @@ export class FieldCalculationService {
};
}

private async getRecordItems(params: {
async getRecordItems(params: {
tableId: string;
startFieldIds: string[];
itemsToCalculate: string[];
Expand Down
31 changes: 26 additions & 5 deletions apps/nestjs-backend/src/features/calculation/reference.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ import type { ICellChange } from './utils/changes';
import { formatChangesToOps, mergeDuplicateChange } from './utils/changes';
import { isLinkCellValue } from './utils/detect-link';
import type { IAdjacencyMap } from './utils/dfs';
import { buildCompressedAdjacencyMap, filterDirectedGraph, getTopologicalOrder } from './utils/dfs';
import {
buildCompressedAdjacencyMap,
filterDirectedGraph,
topoOrderWithDepends,
} from './utils/dfs';

// topo item is for field level reference, all id stands for fieldId;
export interface ITopoItem {
Expand Down Expand Up @@ -143,7 +147,11 @@ export class ReferenceService {
return fieldIds.reduce<{
[fieldId: string]: ITopoItem[];
}>((pre, fieldId) => {
pre[fieldId] = getTopologicalOrder(fieldId, directedGraph);
try {
pre[fieldId] = topoOrderWithDepends(fieldId, directedGraph);
} catch (e) {
throw new BadRequestException((e as { message: string }).message);
}
return pre;
}, {});
}
Expand Down Expand Up @@ -709,18 +717,16 @@ export class ReferenceService {
return newOrder;
}

async getRecordMapBatch(params: {
getRecordIdsByTableName(params: {
fieldMap: IFieldMap;
fieldId2DbTableName: Record<string, string>;
dbTableName2fields: Record<string, IFieldInstance[]>;
initialRecordIdMap?: { [dbTableName: string]: Set<string> };
modifiedRecords: IRecordData[];
relatedRecordItems: IRelatedRecordItem[];
}) {
const {
fieldMap,
fieldId2DbTableName,
dbTableName2fields,
initialRecordIdMap,
modifiedRecords,
relatedRecordItems,
Expand Down Expand Up @@ -756,6 +762,21 @@ export class ReferenceService {
insertId(options.lookupFieldId, item.fromId);
insertId(item.fieldId, item.toId);
});

return recordIdsByTableName;
}

async getRecordMapBatch(params: {
fieldMap: IFieldMap;
fieldId2DbTableName: Record<string, string>;
dbTableName2fields: Record<string, IFieldInstance[]>;
initialRecordIdMap?: { [dbTableName: string]: Set<string> };
modifiedRecords: IRecordData[];
relatedRecordItems: IRelatedRecordItem[];
}) {
const { fieldId2DbTableName, dbTableName2fields, modifiedRecords } = params;

const recordIdsByTableName = this.getRecordIdsByTableName(params);
const recordMap = await this.getRecordMap(recordIdsByTableName, dbTableName2fields);
this.coverRecordData(fieldId2DbTableName, modifiedRecords, recordMap);

Expand Down
195 changes: 194 additions & 1 deletion apps/nestjs-backend/src/features/calculation/utils/dfs.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { buildAdjacencyMap, buildCompressedAdjacencyMap } from './dfs';
import type { IGraphItem } from './dfs';
import {
buildAdjacencyMap,
buildCompressedAdjacencyMap,
hasCycle,
pruneGraph,
topoOrderWithDepends,
topoOrderWithStart,
topologicalSort,
} from './dfs';

describe('Graph Processing Functions', () => {
describe('buildAdjacencyMap', () => {
Expand Down Expand Up @@ -82,4 +91,188 @@ describe('Graph Processing Functions', () => {
expect(buildCompressedAdjacencyMap(graph, linkIdSet)).toEqual(expected);
});
});

describe('topologicalSort', () => {
it('should perform a basic topological sort', () => {
const graph: IGraphItem[] = [
{ fromFieldId: 'a', toFieldId: 'b' },
{ fromFieldId: 'b', toFieldId: 'c' },
];
expect(topologicalSort(graph)).toEqual(['a', 'b', 'c']);
});

it('should perform a branched topological sort', () => {
const graph: IGraphItem[] = [
{ fromFieldId: 'a', toFieldId: 'b' },
{ fromFieldId: 'a', toFieldId: 'c' },
{ fromFieldId: 'b', toFieldId: 'c' },
{ fromFieldId: 'b', toFieldId: 'd' },
];
expect(topologicalSort(graph)).toEqual(['a', 'b', 'd', 'c']);
});

it('should handle an empty graph', () => {
const graph: IGraphItem[] = [];
expect(topologicalSort(graph)).toEqual([]);
});

it('should handle a graph with a single circular node', () => {
const graph: IGraphItem[] = [{ fromFieldId: 'a', toFieldId: 'a' }];
expect(() => topologicalSort(graph)).toThrowError();
});

it('should handle graphs with circular dependencies', () => {
const graph: IGraphItem[] = [
{ fromFieldId: 'a', toFieldId: 'b' },
{ fromFieldId: 'b', toFieldId: 'a' },
];
expect(() => topologicalSort(graph)).toThrowError();
});
});

describe('topoOrderWithDepends', () => {
it('should return an empty array for an empty graph', () => {
const result = topoOrderWithDepends('anyNodeId', []);
expect(result).toEqual([
{
id: 'anyNodeId',
dependencies: [],
},
]);
});

it('should handle circular single node graph correctly', () => {
const graph: IGraphItem[] = [{ fromFieldId: '1', toFieldId: '1' }];
expect(() => topoOrderWithDepends('1', graph)).toThrowError();
});

it('should handle circular node graph correctly', () => {
const graph: IGraphItem[] = [
{ fromFieldId: '1', toFieldId: '2' },
{ fromFieldId: '2', toFieldId: '1' },
];
expect(() => topoOrderWithDepends('1', graph)).toThrowError();
});

it('should return correct order for a normal DAG', () => {
const graph: IGraphItem[] = [
{ fromFieldId: '1', toFieldId: '2' },
{ fromFieldId: '2', toFieldId: '3' },
];
const result = topoOrderWithDepends('1', graph);
expect(result).toEqual([
{ id: '1', dependencies: [] },
{ id: '2', dependencies: ['1'] },
{ id: '3', dependencies: ['2'] },
]);
});

it('should return correct order for a complex DAG', () => {
const graph: IGraphItem[] = [
{ fromFieldId: '1', toFieldId: '2' },
{ fromFieldId: '2', toFieldId: '3' },
{ fromFieldId: '1', toFieldId: '3' },
{ fromFieldId: '3', toFieldId: '4' },
];
const result = topoOrderWithDepends('1', graph);
expect(result).toEqual([
{ id: '1', dependencies: [] },
{ id: '2', dependencies: ['1'] },
{ id: '3', dependencies: ['2', '1'] },
{ id: '4', dependencies: ['3'] },
]);
});
});

describe('hasCycle', () => {
it('should return false for an empty graph', () => {
expect(hasCycle([])).toBe(false);
});

it('should return true for a single node graph link to self', () => {
const graph = [{ fromFieldId: '1', toFieldId: '1' }];
expect(hasCycle(graph)).toBe(true);
});

it('should return false for a normal DAG without cycles', () => {
const graph = [
{ fromFieldId: '1', toFieldId: '2' },
{ fromFieldId: '2', toFieldId: '3' },
];
expect(hasCycle(graph)).toBe(false);
});

it('should return true for a graph with a cycle', () => {
const graph = [
{ fromFieldId: '1', toFieldId: '2' },
{ fromFieldId: '2', toFieldId: '3' },
{ fromFieldId: '3', toFieldId: '1' }, // creates a cycle
];
expect(hasCycle(graph)).toBe(true);
});
});

describe('topoOrderWithStart', () => {
it('should return correct order for a normal DAG', () => {
const graph: IGraphItem[] = [
{ fromFieldId: '1', toFieldId: '2' },
{ fromFieldId: '2', toFieldId: '3' },
];
const result = topoOrderWithStart('1', graph);
expect(result).toEqual(['1', '2', '3']);
});

it('should return correct order for a complex DAG', () => {
const graph: IGraphItem[] = [
{ fromFieldId: '1', toFieldId: '2' },
{ fromFieldId: '2', toFieldId: '3' },
{ fromFieldId: '1', toFieldId: '3' },
{ fromFieldId: '3', toFieldId: '4' },
];
const result = topoOrderWithStart('1', graph);
expect(result).toEqual(['1', '2', '3', '4']);
});
});

describe('pruneGraph', () => {
test('returns an empty array for an empty graph', () => {
expect(pruneGraph('A', [])).toEqual([]);
});

test('returns correct graph for a single-node graph', () => {
const graph: IGraphItem[] = [{ fromFieldId: 'A', toFieldId: 'B' }];
expect(pruneGraph('A', graph)).toEqual(graph);
});

test('returns correct graph for a tow-node graph', () => {
const graph: IGraphItem[] = [
{ fromFieldId: 'A', toFieldId: 'C' },
{ fromFieldId: 'B', toFieldId: 'C' },
];
expect(pruneGraph('C', graph)).toEqual(graph);
});

test('returns correct graph for a multi-node graph', () => {
const graph: IGraphItem[] = [
{ fromFieldId: 'A', toFieldId: 'B' },
{ fromFieldId: 'B', toFieldId: 'C' },
{ fromFieldId: 'C', toFieldId: 'D' },
{ fromFieldId: 'E', toFieldId: 'F' },
];
const expectedResult: IGraphItem[] = [
{ fromFieldId: 'A', toFieldId: 'B' },
{ fromFieldId: 'B', toFieldId: 'C' },
{ fromFieldId: 'C', toFieldId: 'D' },
];
expect(pruneGraph('A', graph)).toEqual(expectedResult);
});

test('returns an empty array for a graph with unrelated node', () => {
const graph: IGraphItem[] = [
{ fromFieldId: 'B', toFieldId: 'C' },
{ fromFieldId: 'C', toFieldId: 'D' },
];
expect(pruneGraph('A', graph)).toEqual([]);
});
});
});
Loading

0 comments on commit 01b28f6

Please sign in to comment.