Skip to content

Commit

Permalink
feat: add dependency configuraiton for views #8240 (#8261)
Browse files Browse the repository at this point in the history
* feat: Add dependency configuraiton for views #8240

Add dependsOn option to @view decorator, where dependencies can be listed. Also use these dependencies to order draop/create view correctly when generating migrations

* fix: Make dropping views dependencies more readable

Rename some variables in viewDependencyChain  in RdbmsSchemaBuilder and add more thorough comments, so its more readable.

Co-authored-by: Svetlozar <ext-svetlozar@getitdone.co>
  • Loading branch information
zaro and Svetlozar committed Oct 26, 2021
1 parent 179ae75 commit 2c861af
Show file tree
Hide file tree
Showing 12 changed files with 256 additions and 19 deletions.
15 changes: 8 additions & 7 deletions docs/view-entities.md
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
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
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
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
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
85 changes: 75 additions & 10 deletions src/schema-builder/RdbmsSchemaBuilder.ts
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,85 @@ 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
// Collect view to be dropped
droppedViews.push(view);
}

// Helper function that for a given view, will recursively return list of the view and all views that depend on it
const viewDependencyChain = (view: View): View[] => {
// Get the view metadata
const viewMetadata = viewToMetadata.get(view);
let viewWithDependencies = [view];
// If no metadata is known for the view, simply return the view itself
if(!viewMetadata){
return viewWithDependencies;
}
// Iterate over all known views
for(const [currentView, currentMetadata] of viewToMetadata.entries()){
// Ignore self reference
if(currentView === view) {
continue;
}
// If the currently iterated view depends on the passed in view
if(currentMetadata.dependsOn && (
currentMetadata.dependsOn.has(viewMetadata.target) ||
currentMetadata.dependsOn.has(viewMetadata.name)
)){
// Recursively add currently iterate view and its dependents
viewWithDependencies = viewWithDependencies.concat(viewDependencyChain(currentView));
}
}
// Return all collected views
return viewWithDependencies;
};

// Collect final list of views to be dropped in a Set so there are no duplicates
const droppedViewsWithDependencies: Set<View> = new Set(
// Collect all dropped views, and their dependencies
droppedViews.map(view => viewDependencyChain(view))
// Flattened to single Array ( can be replaced with flatMap, once supported)
.reduce((all, segment) => {
return all.concat(segment);
}, [])
// Sort the views to be dropped in creation order
.sort((a, b)=> {
return ViewUtils.viewMetadataCmp(viewToMetadata.get(a), viewToMetadata.get(b));
})
// reverse order to get drop order
.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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;

}

0 comments on commit 2c861af

Please sign in to comment.