UNPKG

@finos/legend-studio

Version:
638 lines (602 loc) 20.3 kB
/** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { Entity } from '@finos/legend-model-storage'; import type { TreeData, TreeNodeData } from '@finos/legend-art'; import { type GeneratorFn, type Writable, assertErrorThrown, LogEvent, addUniqueEntry, assertNonEmptyString, assertTrue, guaranteeNonNullable, isNonNullable, filterByType, ActionState, } from '@finos/legend-shared'; import { observable, action, makeObservable, flow, flowResult } from 'mobx'; import { LEGEND_STUDIO_APP_EVENT } from '../../../LegendStudioAppEvent.js'; import type { EditorStore } from '../../../EditorStore.js'; import { type RelationalDatabaseConnection, type Schema, type Table, DatabaseBuilderInput, DatabasePattern, TargetDatabase, PackageableElementExplicitReference, Column, Database, isValidFullPath, resolvePackagePathAndElementName, isStubbed_PackageableElement, getSchema, getNullableSchema, getNullableTable, } from '@finos/legend-graph'; import { connection_setStore } from '../../../graphModifier/DSLMapping_GraphModifierHelper.js'; export abstract class DatabaseBuilderTreeNodeData implements TreeNodeData { isOpen?: boolean | undefined; id: string; label: string; parentId?: string | undefined; childrenIds?: string[] | undefined; isChecked = false; constructor(id: string, label: string, parentId: string | undefined) { this.id = id; this.label = label; this.parentId = parentId; } } export class SchemaDatabaseBuilderTreeNodeData extends DatabaseBuilderTreeNodeData { schema: Schema; constructor(id: string, parentId: string | undefined, schema: Schema) { super(id, schema.name, parentId); this.schema = schema; } } export class TableDatabaseBuilderTreeNodeData extends DatabaseBuilderTreeNodeData { table: Table; constructor(id: string, parentId: string | undefined, table: Table) { super(id, table.name, parentId); this.table = table; } } export class ColumnDatabaseBuilderTreeNodeData extends DatabaseBuilderTreeNodeData { column: Column; constructor(id: string, parentId: string | undefined, column: Column) { super(id, column.name, parentId); this.column = column; } } export interface DatabaseBuilderTreeData extends TreeData<DatabaseBuilderTreeNodeData> { database: Database; } const WILDCARD = '%'; export class DatabaseBuilderState { editorStore: EditorStore; connection: RelationalDatabaseConnection; showModal = false; databaseGrammarCode = ''; isBuildingDatabase = false; isSavingDatabase = false; targetDatabasePath: string; treeData?: DatabaseBuilderTreeData | undefined; constructor( editorStore: EditorStore, connection: RelationalDatabaseConnection, ) { makeObservable< DatabaseBuilderState, 'buildDatabaseFromInput' | 'buildDatabaseGrammar' >(this, { showModal: observable, targetDatabasePath: observable, isBuildingDatabase: observable, databaseGrammarCode: observable, isSavingDatabase: observable, setTargetDatabasePath: action, setShowModal: action, setDatabaseGrammarCode: action, setTreeData: action, treeData: observable, onNodeSelect: flow, buildDatabaseGrammar: flow, buildDatabaseFromInput: flow, buildDatabaseWithTreeData: flow, createOrUpdateDatabase: flow, fetchSchemaDefinitions: flow, fetchSchemaMetadata: flow, fetchTableMetadata: flow, }); this.connection = connection; this.editorStore = editorStore; this.targetDatabasePath = this.currentDatabase?.path ?? 'store::MyDatabase'; } setShowModal(val: boolean): void { this.showModal = val; } setTreeData(builderTreeData?: DatabaseBuilderTreeData): void { this.treeData = builderTreeData; } setDatabaseGrammarCode(val: string): void { this.databaseGrammarCode = val; } setTargetDatabasePath(val: string): void { this.targetDatabasePath = val; } *onNodeSelect( node: DatabaseBuilderTreeNodeData, treeData: DatabaseBuilderTreeData, ): GeneratorFn<void> { if ( node instanceof SchemaDatabaseBuilderTreeNodeData && !node.childrenIds ) { yield flowResult(this.fetchSchemaMetadata(node, treeData)); } else if ( node instanceof TableDatabaseBuilderTreeNodeData && !node.childrenIds ) { yield flowResult(this.fetchTableMetadata(node, treeData)); } node.isOpen = !node.isOpen; this.setTreeData({ ...treeData }); } getChildNodes( node: DatabaseBuilderTreeNodeData, treeData: DatabaseBuilderTreeData, ): DatabaseBuilderTreeNodeData[] | undefined { return node.childrenIds ?.map((n) => treeData.nodes.get(n)) .filter(isNonNullable); } toggleCheckedNode( node: DatabaseBuilderTreeNodeData, treeData: DatabaseBuilderTreeData, ): void { node.isChecked = !node.isChecked; if (node instanceof SchemaDatabaseBuilderTreeNodeData) { this.getChildNodes(node, treeData)?.forEach((n) => { n.isChecked = node.isChecked; }); } else if (node instanceof TableDatabaseBuilderTreeNodeData) { if (node.parentId) { const parent = treeData.nodes.get(node.parentId); if ( parent && this.getChildNodes(parent, treeData)?.every( (e) => e.isChecked === node.isChecked, ) ) { parent.isChecked = node.isChecked; } } } // TODO: handle ColumnDatabaseBuilderTreeNodeData this.setTreeData({ ...treeData }); } private buildNonEnrichedDbBuilderInput( schema?: string, ): DatabaseBuilderInput { const databaseBuilderInput = new DatabaseBuilderInput(this.connection); const [packagePath, databaseName] = this.getDatabasePackageAndName(); databaseBuilderInput.targetDatabase = new TargetDatabase( packagePath, databaseName, ); databaseBuilderInput.config.maxTables = undefined; databaseBuilderInput.config.enrichTables = Boolean(schema); databaseBuilderInput.config.patterns = [ new DatabasePattern(schema ?? WILDCARD, WILDCARD), ]; return databaseBuilderInput; } *fetchSchemaDefinitions(): GeneratorFn<void> { try { this.isBuildingDatabase = true; const databaseBuilderInput = this.buildNonEnrichedDbBuilderInput(); const database = (yield flowResult( this.buildDatabaseFromInput(databaseBuilderInput), )) as Database; const rootIds: string[] = []; const nodes = new Map<string, DatabaseBuilderTreeNodeData>(); database.schemas .slice() .sort((schemaA, schemaB) => schemaA.name.localeCompare(schemaB.name)) .forEach((dbSchema) => { const schemaId = dbSchema.name; rootIds.push(schemaId); const schemaNode = new SchemaDatabaseBuilderTreeNodeData( schemaId, undefined, dbSchema, ); schemaNode.isChecked = Boolean( this.currentDatabase?.schemas.find( (cSchema) => cSchema.name === dbSchema.name, ), ); nodes.set(schemaId, schemaNode); }); const treeData = { rootIds, nodes, database }; this.setTreeData(treeData); } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.log.error( LogEvent.create(LEGEND_STUDIO_APP_EVENT.DATABASE_BUILDER_FAILURE), error, ); this.editorStore.applicationStore.notifyError(error); } finally { this.isBuildingDatabase = false; } } *fetchSchemaMetadata( schemaNode: SchemaDatabaseBuilderTreeNodeData, treeData: DatabaseBuilderTreeData, ): GeneratorFn<void> { try { this.isBuildingDatabase = true; const schema = schemaNode.schema; const databaseBuilderInput = this.buildNonEnrichedDbBuilderInput( schema.name, ); const database = (yield flowResult( this.buildDatabaseFromInput(databaseBuilderInput), )) as Database; const tables = getSchema(database, schema.name).tables; const childrenIds = schemaNode.childrenIds ?? []; schema.tables = tables; tables .slice() .sort((tableA, tableB) => tableA.name.localeCompare(tableB.name)) .forEach((table) => { table.schema = schema; const tableId = `${schema.name}.${table.name}`; const tableNode = new TableDatabaseBuilderTreeNodeData( tableId, schemaNode.id, table, ); if (this.currentDatabase) { const matchingSchema = getNullableSchema( this.currentDatabase, schema.name, ); tableNode.isChecked = Boolean( matchingSchema ? getNullableTable(matchingSchema, table.name) : undefined, ); } else { tableNode.isChecked = false; } treeData.nodes.set(tableId, tableNode); addUniqueEntry(childrenIds, tableId); }); schemaNode.childrenIds = childrenIds; this.setTreeData({ ...treeData }); } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.log.error( LogEvent.create(LEGEND_STUDIO_APP_EVENT.DATABASE_BUILDER_FAILURE), error, ); this.editorStore.applicationStore.notifyError(error); } finally { this.isBuildingDatabase = false; } } *fetchTableMetadata( tableNode: TableDatabaseBuilderTreeNodeData, treeData: DatabaseBuilderTreeData, ): GeneratorFn<void> { try { const databaseBuilderInput = new DatabaseBuilderInput(this.connection); const [packagePath, databaseName] = resolvePackagePathAndElementName( this.targetDatabasePath, ); databaseBuilderInput.targetDatabase = new TargetDatabase( packagePath, databaseName, ); const config = databaseBuilderInput.config; config.maxTables = undefined; config.enrichTables = true; config.enrichColumns = true; config.enrichPrimaryKeys = true; const table = tableNode.table; config.patterns = [new DatabasePattern(table.schema.name, table.name)]; const database = (yield flowResult( this.buildDatabaseFromInput(databaseBuilderInput), )) as Database; const enrichedTable = database.schemas .find((s) => table.schema.name === s.name) ?.tables.find((t) => t.name === table.name); if (enrichedTable) { this.addColumnsNodeToTableNode(tableNode, enrichedTable, treeData); } } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.log.error( LogEvent.create(LEGEND_STUDIO_APP_EVENT.DATABASE_BUILDER_FAILURE), error, ); this.editorStore.applicationStore.notifyError(error); } finally { this.isBuildingDatabase = false; } } private addColumnsNodeToTableNode( tableNode: TableDatabaseBuilderTreeNodeData, enrichedTable: Table, treeData: DatabaseBuilderTreeData, ): void { const columns = enrichedTable.columns.filter(filterByType(Column)); tableNode.table.columns = columns; this.removeChildren(tableNode, treeData); const childrenIds: string[] = []; const tableId = tableNode.id; columns .slice() .sort((colA, colB) => colA.name.localeCompare(colB.name)) .forEach((c) => { const columnId = `${tableId}.${c.name}`; const columnNode = new ColumnDatabaseBuilderTreeNodeData( columnId, tableId, c, ); c.owner = tableNode.table; treeData.nodes.set(columnId, columnNode); addUniqueEntry(childrenIds, columnId); }); tableNode.childrenIds = childrenIds; } private removeChildren( node: DatabaseBuilderTreeNodeData, treeData: DatabaseBuilderTreeData, ): void { const currentChildren = node.childrenIds; if (currentChildren) { currentChildren.forEach((c) => treeData.nodes.delete(c)); node.childrenIds = undefined; } } private getDatabasePackageAndName(): [string, string] { if (this.currentDatabase) { return [ guaranteeNonNullable(this.currentDatabase.package).path, this.currentDatabase.name, ]; } assertNonEmptyString(this.targetDatabasePath, 'Must specify database path'); assertTrue( isValidFullPath(this.targetDatabasePath), 'Invalid database path', ); return resolvePackagePathAndElementName( this.targetDatabasePath, this.targetDatabasePath, ); } *buildDatabaseWithTreeData(): GeneratorFn<void> { try { if (this.treeData) { const dbTreeData = this.treeData; this.isBuildingDatabase = true; const databaseBuilderInput = new DatabaseBuilderInput(this.connection); const [packagePath, databaseName] = this.getDatabasePackageAndName(); databaseBuilderInput.targetDatabase = new TargetDatabase( packagePath, databaseName, ); const config = databaseBuilderInput.config; config.maxTables = undefined; config.enrichTables = true; config.enrichColumns = true; config.enrichPrimaryKeys = true; dbTreeData.rootIds .map((e) => dbTreeData.nodes.get(e)) .filter(isNonNullable) .forEach((schemaNode) => { if (schemaNode instanceof SchemaDatabaseBuilderTreeNodeData) { const tableNodes = this.getChildNodes(schemaNode, dbTreeData); const allChecked = tableNodes?.every((t) => t.isChecked === true); if ( allChecked || (schemaNode.isChecked && !schemaNode.childrenIds) ) { config.patterns.push( new DatabasePattern(schemaNode.schema.name, WILDCARD), ); } else { tableNodes?.forEach((t) => { if ( t instanceof TableDatabaseBuilderTreeNodeData && t.isChecked ) { config.patterns.push( new DatabasePattern(schemaNode.schema.name, t.table.name), ); } }); } } }); const entities = (yield this.editorStore.graphManagerState.graphManager.buildDatabase( databaseBuilderInput, )) as Entity[]; const dbGrammar = (yield this.editorStore.graphManagerState.graphManager.entitiesToPureCode( entities, )) as string; this.setDatabaseGrammarCode(dbGrammar); } } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.log.error( LogEvent.create(LEGEND_STUDIO_APP_EVENT.DATABASE_BUILDER_FAILURE), error, ); this.editorStore.applicationStore.notifyError(error); } finally { this.isBuildingDatabase = false; } } private getSchemasFromTreeNode(tree: DatabaseBuilderTreeData): Schema[] { return Array.from(tree.nodes.values()) .map((e) => { if (e instanceof SchemaDatabaseBuilderTreeNodeData) { return e.schema; } return undefined; }) .filter(isNonNullable); } private *buildDatabaseGrammar(grammar: string): GeneratorFn<Database> { const entities = (yield this.editorStore.graphManagerState.graphManager.pureCodeToEntities( grammar, )) as Entity[]; const dbGraph = this.editorStore.graphManagerState.createEmptyGraph(); (yield this.editorStore.graphManagerState.graphManager.buildGraph( dbGraph, entities, ActionState.create(), )) as Entity[]; assertTrue( dbGraph.ownDatabases.length === 1, 'Expected one database to be generated from grammar', ); return dbGraph.ownDatabases[0] as Database; } private *buildDatabaseFromInput( databaseBuilderInput: DatabaseBuilderInput, ): GeneratorFn<Database> { const entities = (yield this.editorStore.graphManagerState.graphManager.buildDatabase( databaseBuilderInput, )) as Entity[]; const dbGraph = this.editorStore.graphManagerState.createEmptyGraph(); (yield this.editorStore.graphManagerState.graphManager.buildGraph( dbGraph, entities, ActionState.create(), )) as Entity[]; assertTrue( dbGraph.ownDatabases.length === 1, 'Expected one database to be generated from input', ); return dbGraph.ownDatabases[0] as Database; } *createOrUpdateDatabase(): GeneratorFn<void> { try { this.isSavingDatabase = true; assertNonEmptyString( this.databaseGrammarCode, 'Database grammar is empty', ); const database = (yield flowResult( this.buildDatabaseGrammar(this.databaseGrammarCode), )) as Database; let currentDatabase: Database; const isUpdating = Boolean(this.currentDatabase); if (!this.currentDatabase) { const newDatabase = new Database(database.name); connection_setStore( this.connection, PackageableElementExplicitReference.create(newDatabase), ); const packagePath = guaranteeNonNullable( database.package?.name, 'Database package is missing', ); yield flowResult( this.editorStore.addElement(newDatabase, packagePath, false), ); currentDatabase = newDatabase; } else { currentDatabase = this.currentDatabase; } if (this.treeData) { const schemas = this.getSchemasFromTreeNode(this.treeData); this.updateDatabase(currentDatabase, database, schemas); this.editorStore.applicationStore.notifySuccess( `Database successfully '${isUpdating ? 'updated' : 'created'}. ${ !isUpdating ? 'Recompiling...' : '' }`, ); this.fetchSchemaDefinitions(); if (isUpdating) { yield flowResult( this.editorStore.graphState.globalCompileInFormMode({ message: `Can't compile graph after editing database. Redirecting you to text mode`, }), ); } } } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.log.error( LogEvent.create(LEGEND_STUDIO_APP_EVENT.DATABASE_BUILDER_FAILURE), error, ); this.editorStore.applicationStore.notifyError(error); } finally { this.isSavingDatabase = false; } } updateDatabase( current: Database, generatedDb: Database, allSchemas: Schema[], ): void { // remove shemas not defined current.schemas = current.schemas.filter((schema) => { if ( allSchemas.find((c) => c.name === schema.name) && !generatedDb.schemas.find((c) => c.name === schema.name) ) { return false; } return true; }); // update existing schemas generatedDb.schemas.forEach((schema) => { (schema as Writable<Schema>)._OWNER = current; const currentSchemaIndex = current.schemas.findIndex( (c) => c.name === schema.name, ); if (currentSchemaIndex !== -1) { current.schemas[currentSchemaIndex] = schema; } else { current.schemas.push(schema); } }); } get currentDatabase(): Database | undefined { const store = this.connection.store.value; if (store instanceof Database && !isStubbed_PackageableElement(store)) { return store; } return undefined; } }