Skip to content

Commit f33ac5e

Browse files
gribnoysupaddaleaxAnemy
authored
feat(data-modeling): implement automatic relationship inference algorithm COMPASS-9776 (#7275)
* feat(data-modeling): implement automatic relationship inference algorithm * chore(data-service, data-modeling): add proper support for indexes full option; gate automatic inference with a feature flag * chore(data-modeling): add method description; add unit tests * chore(data-modeling): fix type in test * chore(data-modeling): filter out nullish values from the sample; fix logid * chore(data-modeling): more comments * chore(data-modeling): adjust wording Co-authored-by: Anna Henningsen <[email protected]> Co-authored-by: Rhys <[email protected]> * chore(data-modeling): do not allow multiple analysis to run at the same time * fix(data-service): make sure that _getOptionsWithFallbackReadPreference works with no options provided * chore(data-modeling): convert traverse to a generator function * chore(data-modeling): better comment * chore(data-service): improve _getOptionsWithFallbackReadPreference types Co-authored-by: Anna Henningsen <[email protected]> * chore(data-modeling): do not filter out id fields with matching types during relationship discovery --------- Co-authored-by: Anna Henningsen <[email protected]> Co-authored-by: Rhys <[email protected]>
1 parent 055602b commit f33ac5e

File tree

8 files changed

+598
-77
lines changed

8 files changed

+598
-77
lines changed

packages/compass-data-modeling/src/components/new-diagram-form.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ type NewDiagramFormProps = {
198198
collections: string[];
199199
selectedCollections: string[];
200200
error: Error | null;
201+
analysisInProgress: boolean;
201202

202203
onCancel: () => void;
203204
onNameChange: (name: string) => void;
@@ -224,6 +225,7 @@ const NewDiagramForm: React.FunctionComponent<NewDiagramFormProps> = ({
224225
collections,
225226
selectedCollections,
226227
error,
228+
analysisInProgress,
227229
onCancel,
228230
onNameChange,
229231
onNameConfirm,
@@ -297,7 +299,9 @@ const NewDiagramForm: React.FunctionComponent<NewDiagramFormProps> = ({
297299
onConfirmAction: onCollectionsSelectionConfirm,
298300
confirmActionLabel: 'Generate',
299301
isConfirmDisabled:
300-
!selectedCollections || selectedCollections.length === 0,
302+
!selectedCollections ||
303+
selectedCollections.length === 0 ||
304+
analysisInProgress,
301305
onCancelAction: onDatabaseSelectCancel,
302306
cancelLabel: 'Back',
303307
footerText: (
@@ -312,19 +316,20 @@ const NewDiagramForm: React.FunctionComponent<NewDiagramFormProps> = ({
312316
}
313317
}, [
314318
currentStep,
319+
onNameConfirm,
315320
diagramName,
316321
onCancel,
317-
onCollectionsSelectionConfirm,
318322
onConnectionConfirmSelection,
319-
onConnectionSelectCancel,
320-
onDatabaseConfirmSelection,
321-
onDatabaseSelectCancel,
322-
onNameConfirm,
323-
onNameConfirmCancel,
324-
selectedCollections,
325323
selectedConnectionId,
324+
onNameConfirmCancel,
325+
onDatabaseConfirmSelection,
326326
selectedDatabase,
327-
collections,
327+
onConnectionSelectCancel,
328+
collections.length,
329+
onCollectionsSelectionConfirm,
330+
selectedCollections,
331+
analysisInProgress,
332+
onDatabaseSelectCancel,
328333
]);
329334

330335
const formContent = useMemo(() => {
@@ -509,6 +514,8 @@ export default connect(
509514
collections: databaseCollections ?? [],
510515
selectedCollections: selectedCollections ?? [],
511516
error,
517+
analysisInProgress:
518+
state.analysisProgress.analysisProcessStatus === 'in-progress',
512519
};
513520
},
514521
{

packages/compass-data-modeling/src/store/analysis-process.ts

Lines changed: 101 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { isAction } from './util';
33
import type { DataModelingThunkAction } from './reducer';
44
import { analyzeDocuments, type MongoDBJSONSchema } from 'mongodb-schema';
55
import { getCurrentDiagramFromState } from './diagram';
6-
import type { Document } from 'bson';
7-
import type { AggregationCursor } from 'mongodb';
6+
import { UUID } from 'bson';
87
import type { Relationship } from '../services/data-model-storage';
98
import { applyLayout } from '@mongodb-js/diagramming';
109
import { collectionToBaseNodeForLayout } from '../utils/nodes-and-edges';
10+
import { inferForeignToLocalRelationshipsForCollection } from './relationships';
11+
import { mongoLogId } from '@mongodb-js/compass-logging/provider';
1112

1213
export type AnalysisProcessState = {
1314
currentAnalysisOptions:
@@ -18,9 +19,10 @@ export type AnalysisProcessState = {
1819
collections: string[];
1920
} & AnalysisOptions)
2021
| null;
22+
analysisProcessStatus: 'idle' | 'in-progress';
2123
samplesFetched: number;
2224
schemasAnalyzed: number;
23-
relationsInferred: boolean;
25+
relationsInferred: number;
2426
};
2527

2628
export enum AnalysisProcessActionTypes {
@@ -58,6 +60,8 @@ export type NamespaceSchemaAnalyzedAction = {
5860

5961
export type NamespacesRelationsInferredAction = {
6062
type: AnalysisProcessActionTypes.NAMESPACES_RELATIONS_INFERRED;
63+
namespace: string;
64+
count: number;
6165
};
6266

6367
export type AnalysisFinishedAction = {
@@ -92,9 +96,10 @@ export type AnalysisProgressActions =
9296

9397
const INITIAL_STATE = {
9498
currentAnalysisOptions: null,
99+
analysisProcessStatus: 'idle' as const,
95100
samplesFetched: 0,
96101
schemasAnalyzed: 0,
97-
relationsInferred: false,
102+
relationsInferred: 0,
98103
};
99104

100105
export const analysisProcessReducer: Reducer<AnalysisProcessState> = (
@@ -106,6 +111,7 @@ export const analysisProcessReducer: Reducer<AnalysisProcessState> = (
106111
) {
107112
return {
108113
...INITIAL_STATE,
114+
analysisProcessStatus: 'in-progress',
109115
currentAnalysisOptions: {
110116
name: action.name,
111117
connectionId: action.connectionId,
@@ -127,6 +133,16 @@ export const analysisProcessReducer: Reducer<AnalysisProcessState> = (
127133
schemasAnalyzed: state.schemasAnalyzed + 1,
128134
};
129135
}
136+
if (
137+
isAction(action, AnalysisProcessActionTypes.ANALYSIS_CANCELED) ||
138+
isAction(action, AnalysisProcessActionTypes.ANALYSIS_FAILED) ||
139+
isAction(action, AnalysisProcessActionTypes.ANALYSIS_FINISHED)
140+
) {
141+
return {
142+
...state,
143+
analysisProcessStatus: 'idle',
144+
};
145+
}
130146
return state;
131147
};
132148

@@ -146,11 +162,26 @@ export function startAnalysis(
146162
| AnalysisCanceledAction
147163
| AnalysisFailedAction
148164
> {
149-
return async (dispatch, getState, services) => {
165+
return async (
166+
dispatch,
167+
getState,
168+
{
169+
connections,
170+
cancelAnalysisControllerRef,
171+
logger,
172+
track,
173+
dataModelStorage,
174+
preferences,
175+
}
176+
) => {
177+
// Analysis is in progress, don't start a new one unless user canceled it
178+
if (cancelAnalysisControllerRef.current) {
179+
return;
180+
}
150181
const namespaces = collections.map((collName) => {
151182
return `${database}.${collName}`;
152183
});
153-
const cancelController = (services.cancelAnalysisControllerRef.current =
184+
const cancelController = (cancelAnalysisControllerRef.current =
154185
new AbortController());
155186
dispatch({
156187
type: AnalysisProcessActionTypes.ANALYZING_COLLECTIONS_START,
@@ -161,18 +192,17 @@ export function startAnalysis(
161192
options,
162193
});
163194
try {
164-
const dataService =
165-
services.connections.getDataServiceForConnection(connectionId);
195+
let relations: Relationship[] = [];
196+
const dataService = connections.getDataServiceForConnection(connectionId);
197+
166198
const collections = await Promise.all(
167199
namespaces.map(async (ns) => {
168-
const sample: AggregationCursor<Document> = dataService.sampleCursor(
200+
const sample = await dataService.sample(
169201
ns,
170202
{ size: 100 },
203+
{ promoteValues: false },
171204
{
172-
signal: cancelController.signal,
173-
promoteValues: false,
174-
},
175-
{
205+
abortSignal: cancelController.signal,
176206
fallbackReadPreference: 'secondaryPreferred',
177207
}
178208
);
@@ -194,26 +224,71 @@ export function startAnalysis(
194224
type: AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED,
195225
namespace: ns,
196226
});
197-
return { ns, schema };
227+
return { ns, schema, sample };
198228
})
199229
);
200230

201-
if (options.automaticallyInferRelations) {
202-
// TODO
231+
if (
232+
preferences.getPreferences().enableAutomaticRelationshipInference &&
233+
options.automaticallyInferRelations
234+
) {
235+
relations = (
236+
await Promise.all(
237+
collections.map(
238+
async ({
239+
ns,
240+
schema,
241+
sample,
242+
}): Promise<Relationship['relationship'][]> => {
243+
const relationships =
244+
await inferForeignToLocalRelationshipsForCollection(
245+
ns,
246+
schema,
247+
sample,
248+
collections,
249+
dataService,
250+
cancelController.signal,
251+
(err) => {
252+
logger.log.warn(
253+
mongoLogId(1_001_000_371),
254+
'DataModeling',
255+
'Failed to identify relationship for collection',
256+
{ ns, error: err.message }
257+
);
258+
}
259+
);
260+
dispatch({
261+
type: AnalysisProcessActionTypes.NAMESPACES_RELATIONS_INFERRED,
262+
namespace: ns,
263+
count: relationships.length,
264+
});
265+
return relationships;
266+
}
267+
)
268+
)
269+
).flatMap((relationships) => {
270+
return relationships.map((relationship) => {
271+
return {
272+
id: new UUID().toHexString(),
273+
relationship,
274+
isInferred: true,
275+
};
276+
});
277+
});
203278
}
204279

205280
if (cancelController.signal.aborted) {
206281
throw cancelController.signal.reason;
207282
}
208283

209284
const positioned = await applyLayout(
210-
collections.map((coll) =>
211-
collectionToBaseNodeForLayout({
285+
collections.map((coll) => {
286+
return collectionToBaseNodeForLayout({
212287
ns: coll.ns,
213288
jsonSchema: coll.schema,
214289
displayPosition: [0, 0],
215-
})
216-
),
290+
});
291+
}),
217292
[],
218293
'LEFT_RIGHT'
219294
);
@@ -229,22 +304,20 @@ export function startAnalysis(
229304
const position = node ? node.position : { x: 0, y: 0 };
230305
return { ...coll, position };
231306
}),
232-
relations: [],
307+
relations,
233308
});
234309

235-
services.track('Data Modeling Diagram Created', {
310+
track('Data Modeling Diagram Created', {
236311
num_collections: collections.length,
237312
});
238313

239-
void services.dataModelStorage.save(
240-
getCurrentDiagramFromState(getState())
241-
);
314+
void dataModelStorage.save(getCurrentDiagramFromState(getState()));
242315
} catch (err) {
243316
if (cancelController.signal.aborted) {
244317
dispatch({ type: AnalysisProcessActionTypes.ANALYSIS_CANCELED });
245318
} else {
246-
services.logger.log.error(
247-
services.logger.mongoLogId(1_001_000_350),
319+
logger.log.error(
320+
mongoLogId(1_001_000_350),
248321
'DataModeling',
249322
'Failed to analyze schema',
250323
{ err }
@@ -255,7 +328,7 @@ export function startAnalysis(
255328
});
256329
}
257330
} finally {
258-
services.cancelAnalysisControllerRef.current = null;
331+
cancelAnalysisControllerRef.current = null;
259332
}
260333
};
261334
}

0 commit comments

Comments
 (0)