Skip to content

Commit

Permalink
feat: Add dependency configuraiton for views #8240
Browse files Browse the repository at this point in the history
Add dependsOn option to @view decorator, where dependencies can be listed. Also use these dependencies to order draop/create view correctly when generating migrations
  • Loading branch information
Svetlozar committed Oct 10, 2021
1 parent 8615733 commit d3e88cf
Show file tree
Hide file tree
Showing 12 changed files with 243 additions and 18 deletions.
15 changes: 8 additions & 7 deletions docs/view-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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"
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions src/decorator/entity-view/ViewEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/decorator/options/ViewEntityOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[];
}
9 changes: 7 additions & 2 deletions src/metadata-args/TableMetadataArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,19 @@ export interface TableMetadataArgs {
*/
expression?: string|((connection: Connection) => SelectQueryBuilder<any>);

/**
* View dependencies.
*/
dependsOn?: Set<Function|string>;

/**
* Indicates if view is materialized
*/
materialized?: boolean;

/**
* 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;
}
7 changes: 7 additions & 0 deletions src/metadata/EntityMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ export class EntityMetadata {
*/
expression?: string|((connection: Connection) => SelectQueryBuilder<any>);

/**
* View's dependencies.
* Used in views
*/
dependsOn?: Set<Function|string>;

/**
* Enables Sqlite "WITHOUT ROWID" modifier for the "CREATE TABLE" statement
*/
Expand Down Expand Up @@ -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;
}

// -------------------------------------------------------------------------
Expand Down
71 changes: 62 additions & 9 deletions src/schema-builder/RdbmsSchemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -426,24 +430,73 @@ export class RdbmsSchemaBuilder implements SchemaBuilder {
}

protected async dropOldViews(): Promise<void> {
const droppedViews: Set<View> = new Set();
const droppedViews: Array<View> = [];
const viewEntityToSyncMetadatas = this.viewEntityToSyncMetadatas;
// BuIld lookup cache for finding views metadata
const viewToMetadata = new Map<View, EntityMetadata>();
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<View> = 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));
}

/**
Expand Down
27 changes: 27 additions & 0 deletions src/schema-builder/util/ViewUtils.ts
Original file line number Diff line number Diff line change
@@ -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;
}

}
30 changes: 30 additions & 0 deletions test/functional/view-entity/view-dependencies/entity/Test.ts
Original file line number Diff line number Diff line change
@@ -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;

}
16 changes: 16 additions & 0 deletions test/functional/view-entity/view-dependencies/entity/ViewA.ts
Original file line number Diff line number Diff line change
@@ -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;

}
15 changes: 15 additions & 0 deletions test/functional/view-entity/view-dependencies/entity/ViewB.ts
Original file line number Diff line number Diff line change
@@ -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;

}
15 changes: 15 additions & 0 deletions test/functional/view-entity/view-dependencies/entity/ViewC.ts
Original file line number Diff line number Diff line change
@@ -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;

}
Original file line number Diff line number Diff line change
@@ -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);
})));
});

0 comments on commit d3e88cf

Please sign in to comment.