diff --git a/docs/view-entities.md b/docs/view-entities.md index be94d590ce..dd062242b5 100644 --- a/docs/view-entities.md +++ b/docs/view-entities.md @@ -15,11 +15,12 @@ You can create a view entity by defining a new class and mark it with `@ViewEnti * `database` - database name in selected DB server. * `schema` - schema name. * `expression` - view definition. **Required parameter**. +* `dependsOn` - List of other views on which the current views depends. If your view uses another view in it's definition, you can add it here so that migrations are generated in the correct order. `expression` can be string with properly escaped columns and tables, depend on database used (postgres in example): ```typescript -@ViewEntity({ +@ViewEntity({ expression: ` SELECT "post"."id" AS "id", "post"."name" AS "name", "category"."name" AS "categoryName" FROM "post" "post" @@ -31,7 +32,7 @@ You can create a view entity by defining a new class and mark it with `@ViewEnti or an instance of QueryBuilder ```typescript -@ViewEntity({ +@ViewEntity({ expression: (connection: Connection) => connection.createQueryBuilder() .select("post.id", "id") .addSelect("post.name", "name") @@ -44,7 +45,7 @@ or an instance of QueryBuilder **Note:** parameter binding is not supported due to drivers limitations. Use the literal parameters instead. ```typescript -@ViewEntity({ +@ViewEntity({ expression: (connection: Connection) => connection.createQueryBuilder() .select("post.id", "id") .addSelect("post.name", "name") @@ -92,14 +93,14 @@ const connection: Connection = await createConnection({ ## View Entity columns To map data from view into the correct entity columns you must mark entity columns with `@ViewColumn()` -decorator and specify these columns as select statement aliases. +decorator and specify these columns as select statement aliases. example with string expression definition: ```typescript import {ViewEntity, ViewColumn} from "typeorm"; -@ViewEntity({ +@ViewEntity({ expression: ` SELECT "post"."id" AS "id", "post"."name" AS "name", "category"."name" AS "categoryName" FROM "post" "post" @@ -125,7 +126,7 @@ example using QueryBuilder: ```typescript import {ViewEntity, ViewColumn} from "typeorm"; -@ViewEntity({ +@ViewEntity({ expression: (connection: Connection) => connection.createQueryBuilder() .select("post.id", "id") .addSelect("post.name", "name") @@ -192,7 +193,7 @@ export class Post { ```typescript import {ViewEntity, ViewColumn, Connection} from "typeorm"; -@ViewEntity({ +@ViewEntity({ expression: (connection: Connection) => connection.createQueryBuilder() .select("post.id", "id") .addSelect("post.name", "name") diff --git a/src/decorator/entity-view/ViewEntity.ts b/src/decorator/entity-view/ViewEntity.ts index 0eeec88fb5..c9fd4fa8f8 100644 --- a/src/decorator/entity-view/ViewEntity.ts +++ b/src/decorator/entity-view/ViewEntity.ts @@ -27,6 +27,7 @@ export function ViewEntity(nameOrOptions?: string|ViewEntityOptions, maybeOption target: target, name: name, expression: options.expression, + dependsOn: options.dependsOn ? new Set(options.dependsOn) : undefined, type: "view", database: options.database ? options.database : undefined, schema: options.schema ? options.schema : undefined, diff --git a/src/decorator/options/ViewEntityOptions.ts b/src/decorator/options/ViewEntityOptions.ts index ad4aeaf6be..d06408ac9f 100644 --- a/src/decorator/options/ViewEntityOptions.ts +++ b/src/decorator/options/ViewEntityOptions.ts @@ -38,4 +38,10 @@ export interface ViewEntityOptions { * It's supported by Postgres and Oracle. */ materialized?: boolean; + + /** + * View dependencies. In case the view depends on another view it can be listed here + * to ensure correct order of view migrations. + */ + dependsOn?: (Function|string)[]; } diff --git a/src/metadata-args/TableMetadataArgs.ts b/src/metadata-args/TableMetadataArgs.ts index ac768d0c00..dfa9c1ef3b 100644 --- a/src/metadata-args/TableMetadataArgs.ts +++ b/src/metadata-args/TableMetadataArgs.ts @@ -56,6 +56,11 @@ export interface TableMetadataArgs { */ expression?: string|((connection: Connection) => SelectQueryBuilder); + /** + * View dependencies. + */ + dependsOn?: Set; + /** * Indicates if view is materialized */ @@ -63,7 +68,7 @@ export interface TableMetadataArgs { /** * If set to 'true' this option disables Sqlite's default behaviour of secretly creating - * an integer primary key column named 'rowid' on table creation. + * an integer primary key column named 'rowid' on table creation. */ - withoutRowid?: boolean; + withoutRowid?: boolean; } diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index 6e023493b7..3de5c5b68a 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -98,6 +98,12 @@ export class EntityMetadata { */ expression?: string|((connection: Connection) => SelectQueryBuilder); + /** + * View's dependencies. + * Used in views + */ + dependsOn?: Set; + /** * Enables Sqlite "WITHOUT ROWID" modifier for the "CREATE TABLE" statement */ @@ -510,6 +516,7 @@ export class EntityMetadata { this.tableType = this.tableMetadataArgs.type; this.expression = this.tableMetadataArgs.expression; this.withoutRowid = this.tableMetadataArgs.withoutRowid; + this.dependsOn = this.tableMetadataArgs.dependsOn; } // ------------------------------------------------------------------------- diff --git a/src/schema-builder/RdbmsSchemaBuilder.ts b/src/schema-builder/RdbmsSchemaBuilder.ts index 2cbc44bd33..0545bd38dd 100644 --- a/src/schema-builder/RdbmsSchemaBuilder.ts +++ b/src/schema-builder/RdbmsSchemaBuilder.ts @@ -17,6 +17,7 @@ import {TableUnique} from "./table/TableUnique"; import {TableCheck} from "./table/TableCheck"; import {TableExclusion} from "./table/TableExclusion"; import {View} from "./view/View"; +import { ViewUtils } from "./util/ViewUtils"; import {AuroraDataApiDriver} from "../driver/aurora-data-api/AuroraDataApiDriver"; /** @@ -160,7 +161,10 @@ export class RdbmsSchemaBuilder implements SchemaBuilder { * Returns only entities that should be synced in the database. */ protected get viewEntityToSyncMetadatas(): EntityMetadata[] { - return this.connection.entityMetadatas.filter(metadata => metadata.tableType === "view" && metadata.synchronize); + return this.connection.entityMetadatas + .filter(metadata => metadata.tableType === "view" && metadata.synchronize) + // sort views in creation order by dependencies + .sort(ViewUtils.viewMetadataCmp); } /** @@ -426,24 +430,73 @@ export class RdbmsSchemaBuilder implements SchemaBuilder { } protected async dropOldViews(): Promise { - const droppedViews: Set = new Set(); + const droppedViews: Array = []; + const viewEntityToSyncMetadatas = this.viewEntityToSyncMetadatas; + // BuIld lookup cache for finding views metadata + const viewToMetadata = new Map(); for (const view of this.queryRunner.loadedViews) { - const existViewMetadata = this.viewEntityToSyncMetadatas.find(metadata => { - const viewExpression = typeof view.expression === "string" ? view.expression.trim() : view.expression(this.connection).getQuery(); - const metadataExpression = typeof metadata.expression === "string" ? metadata.expression.trim() : metadata.expression!(this.connection).getQuery(); - return this.getTablePath(view) === this.getTablePath(metadata) && viewExpression === metadataExpression; + const viewMetadata = viewEntityToSyncMetadatas.find(metadata => { + return this.getTablePath(view) === this.getTablePath(metadata); }); + if(viewMetadata){ + viewToMetadata.set(view, viewMetadata); + } + } + // Gather all changed view, that need a drop + for (const view of this.queryRunner.loadedViews) { + const viewMetadata = viewToMetadata.get(view); + if(!viewMetadata){ + continue; + } + const viewExpression = typeof view.expression === "string" ? view.expression.trim() : view.expression(this.connection).getQuery(); + const metadataExpression = typeof viewMetadata.expression === "string" ? viewMetadata.expression.trim() : viewMetadata.expression!(this.connection).getQuery(); - if (existViewMetadata) + if (viewExpression === metadataExpression) continue; this.connection.logger.logSchemaBuild(`dropping an old view: ${view.name}`); // drop an old view + //await this.queryRunner.dropView(view); + droppedViews.push(view); + } + const viewDependencyChain = (view: View): View[] => { + const viewMetadata = viewToMetadata.get(view); + let views = [view]; + if(!viewMetadata){ + return views; + } + for(const [view, metadata] of viewToMetadata.entries()){ + if(metadata.dependsOn && ( + metadata.dependsOn.has(viewMetadata.target) || + metadata.dependsOn.has(viewMetadata.name) + )){ + views = views.concat(viewDependencyChain(view)); + } + } + return views; + }; + // Add dependencies to be dropped also + const droppedViewsWithDependencies: Set = new Set( + droppedViews.map(view => viewDependencyChain(view)) + .reduce((all, segment) => { + for(const el of segment){ + if(!all.includes(el)){ + all.push(el); + } + } + return all; + }, []) + .sort((a, b)=> { + return ViewUtils.viewMetadataCmp(viewToMetadata.get(a), viewToMetadata.get(b)); + }) + .reverse() + ); + // Finally emit all drop views + for(const view of droppedViewsWithDependencies){ await this.queryRunner.dropView(view); - droppedViews.add(view); } - this.queryRunner.loadedViews = this.queryRunner.loadedViews.filter(view => !droppedViews.has(view)); + this.queryRunner.loadedViews = this.queryRunner.loadedViews.filter(view => !droppedViewsWithDependencies.has(view)); } /** diff --git a/src/schema-builder/util/ViewUtils.ts b/src/schema-builder/util/ViewUtils.ts new file mode 100644 index 0000000000..722e8bb948 --- /dev/null +++ b/src/schema-builder/util/ViewUtils.ts @@ -0,0 +1,27 @@ +import { EntityMetadata } from "../../metadata/EntityMetadata"; + +export class ViewUtils { + + /** + * Comparator for .sort() that will order views bases on dependencies in creation order + */ + static viewMetadataCmp(metadataA: EntityMetadata | undefined, metadataB: EntityMetadata| undefined): number { + if(!metadataA || !metadataB){ + return 0; + } + if(metadataA.dependsOn && ( + metadataA.dependsOn.has(metadataB.target) || + metadataA.dependsOn.has(metadataB.name) + )) { + return 1; + } + if(metadataB.dependsOn && ( + metadataB.dependsOn.has(metadataA.target) || + metadataB.dependsOn.has(metadataA.name) + )){ + return -1; + } + return 0; + } + +} diff --git a/test/functional/view-entity/view-dependencies/entity/Test.ts b/test/functional/view-entity/view-dependencies/entity/Test.ts new file mode 100644 index 0000000000..b44171ff68 --- /dev/null +++ b/test/functional/view-entity/view-dependencies/entity/Test.ts @@ -0,0 +1,30 @@ +import {Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn} from "../../../../../src"; +import { ViewC } from "./ViewC"; +import { ViewB } from "./ViewB"; +import { ViewA } from "./ViewA"; + + +@Entity() +export class TestEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column("varchar") + type: string; + + // Bogus relations to mix up import order + @OneToOne(() => ViewC) + @JoinColumn() + somehowMatched: ViewC; + + // Bogus relations to mix up import order + @OneToOne(() => ViewB) + @JoinColumn() + somehowMatched2: ViewB; + + // Bogus relations to mix up import order + @OneToOne(() => ViewA) + @JoinColumn() + somehowMatched3: ViewA; + +} diff --git a/test/functional/view-entity/view-dependencies/entity/ViewA.ts b/test/functional/view-entity/view-dependencies/entity/ViewA.ts new file mode 100644 index 0000000000..db628a7ede --- /dev/null +++ b/test/functional/view-entity/view-dependencies/entity/ViewA.ts @@ -0,0 +1,16 @@ +import {ViewColumn, ViewEntity} from "../../../../../src"; + +@ViewEntity({ + name: "view_a", + expression: ` + select * from test_entity -- V1 simulate view change with comment + ` +}) +export class ViewA { + @ViewColumn() + id: number; + + @ViewColumn() + type: string; + +} diff --git a/test/functional/view-entity/view-dependencies/entity/ViewB.ts b/test/functional/view-entity/view-dependencies/entity/ViewB.ts new file mode 100644 index 0000000000..e73488ee10 --- /dev/null +++ b/test/functional/view-entity/view-dependencies/entity/ViewB.ts @@ -0,0 +1,15 @@ +import { ViewColumn, ViewEntity} from "../../../../../src"; + +@ViewEntity({ + name: "view_b", + expression: `select * from view_a -- V1 simulate view change with comment`, + dependsOn: ["ViewA"], +}) +export class ViewB { + @ViewColumn() + id: number; + + @ViewColumn() + type: string; + +} diff --git a/test/functional/view-entity/view-dependencies/entity/ViewC.ts b/test/functional/view-entity/view-dependencies/entity/ViewC.ts new file mode 100644 index 0000000000..40e1800387 --- /dev/null +++ b/test/functional/view-entity/view-dependencies/entity/ViewC.ts @@ -0,0 +1,15 @@ +import {ViewColumn, ViewEntity} from "../../../../../src"; + +@ViewEntity({ + name: "view_c", + expression: `select * from view_b -- V1 simulate view change with comment`, + dependsOn: ["ViewB"], +}) +export class ViewC { + @ViewColumn() + id: number; + + @ViewColumn() + type: string; + +} diff --git a/test/functional/view-entity/view-dependencies/view-entity-dependencies.ts b/test/functional/view-entity/view-dependencies/view-entity-dependencies.ts new file mode 100644 index 0000000000..a01b5cdb56 --- /dev/null +++ b/test/functional/view-entity/view-dependencies/view-entity-dependencies.ts @@ -0,0 +1,49 @@ +import { expect } from "chai"; +import "reflect-metadata"; +import {Connection} from "../../../../src"; +import {closeTestingConnections, createTestingConnections} from "../../../utils/test-utils"; +import {ViewC} from "./entity/ViewC"; +import {ViewA} from "./entity/ViewA"; +import {ViewB} from "./entity/ViewB"; +import {TestEntity} from "./entity/Test"; + +describe("views dependencies", () => { + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + enabledDrivers: ["postgres"], + schemaCreate: true, + dropSchema: true, + // entities: [ViewC, ViewB, ViewA, TestEntity], + entities: [TestEntity, ViewA, ViewB, ViewC], + })); + after(() => closeTestingConnections(connections)); + + it("should generate drop and create queries in correct order", () => Promise.all(connections.map(async connection => { + const expectedDrops: RegExp[] = []; + const expectedCreates: RegExp[] = []; + // Views in order in which they should be created + for(const view of [ViewA, ViewB, ViewC]){ + const metadata = connection.getMetadata(view); + // Modify ViewA, this should trigger updates on all views that depend on it + if(view === ViewA){ + metadata.expression = (metadata.expression as string)?.replace("V1", "V2"); + } + expectedDrops.push(new RegExp(`^DROP\\s+VIEW.*"${metadata.tableName}"`)); + expectedCreates.push(new RegExp(`^CREATE\\s+VIEW.*"${metadata.tableName}"`)); + } + // Drop order should be reverse of create order + expectedDrops.reverse(); + const sqlInMemory = await connection.driver.createSchemaBuilder().log(); + // console.log(sqlInMemory.upQueries.map(q => q.query)); + const dropPositions = expectedDrops.map(expected => sqlInMemory.upQueries.findIndex(q => q.query.match(expected))); + // console.log("dropPositions", dropPositions); + expect(dropPositions).to.have.length(3); + const dropPositionsSorted = dropPositions.slice().sort((a,b) => a-b); + expect(dropPositions).eql(dropPositionsSorted); + const createPositions = expectedCreates.map(expected => sqlInMemory.upQueries.findIndex(q => q.query.match(expected))); + // console.log("createPositions", createPositions); + expect(createPositions).to.have.length(3); + const createPositionsSorted = createPositions.slice().sort((a,b) => a-b); + expect(createPositions).eql(createPositionsSorted); + }))); +});