From 32d17da52c6d13966dfdada5c79500a9b596a168 Mon Sep 17 00:00:00 2001 From: Dapper Mink Date: Fri, 2 Aug 2024 19:55:48 +0900 Subject: [PATCH] feat: support refresh materialized view (#990) * feat: add postgres range types (#1086) * feat: support refresh naterialized view * fix tests by adding .materialized() to remove the matview * fix failing test * fix: References typo (#1092) * chore: refresh-view-node.ts => refresh-materialized-view-node.ts * chore: export node in index.ts --------- Co-authored-by: Isak <16906089+kansson@users.noreply.github.com> Co-authored-by: Jonathan Wu --- src/index.ts | 2 + .../operation-node-transformer.ts | 11 ++ src/operation-node/operation-node-visitor.ts | 3 + src/operation-node/operation-node.ts | 1 + .../refresh-materialized-view-node.ts | 41 +++++++ .../with-schema/with-schema-transformer.ts | 1 + src/query-compiler/default-query-compiler.ts | 17 +++ src/query-compiler/query-compiler.ts | 2 + .../refresh-materialized-view-builder.ts | 103 ++++++++++++++++++ src/schema/schema.ts | 22 ++++ test/node/src/schema.test.ts | 81 ++++++++++++++ 11 files changed, 284 insertions(+) create mode 100644 src/operation-node/refresh-materialized-view-node.ts create mode 100644 src/schema/refresh-materialized-view-builder.ts diff --git a/src/index.ts b/src/index.ts index 9818f1131..b467d871c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,7 @@ export * from './schema/column-definition-builder.js' export * from './schema/foreign-key-constraint-builder.js' export * from './schema/alter-table-builder.js' export * from './schema/create-view-builder.js' +export * from './schema/refresh-materialized-view-builder.js' export * from './schema/drop-view-builder.js' export * from './schema/alter-column-builder.js' @@ -133,6 +134,7 @@ export * from './operation-node/create-schema-node.js' export * from './operation-node/create-table-node.js' export * from './operation-node/create-type-node.js' export * from './operation-node/create-view-node.js' +export * from './operation-node/refresh-materialized-view-node.js' export * from './operation-node/data-type-node.js' export * from './operation-node/default-insert-value-node.js' export * from './operation-node/default-value-node.js' diff --git a/src/operation-node/operation-node-transformer.ts b/src/operation-node/operation-node-transformer.ts index 418b504e0..a9bca4d5e 100644 --- a/src/operation-node/operation-node-transformer.ts +++ b/src/operation-node/operation-node-transformer.ts @@ -94,6 +94,7 @@ import { CastNode } from './cast-node.js' import { FetchNode } from './fetch-node.js' import { TopNode } from './top-node.js' import { OutputNode } from './output-node.js' +import { RefreshMaterializedViewNode } from './refresh-materialized-view-node.js' /** * Transforms an operation node tree into another one. @@ -189,6 +190,7 @@ export class OperationNodeTransformer { DropConstraintNode: this.transformDropConstraint.bind(this), ForeignKeyConstraintNode: this.transformForeignKeyConstraint.bind(this), CreateViewNode: this.transformCreateView.bind(this), + RefreshMaterializedViewNode: this.transformRefreshMaterializedView.bind(this), DropViewNode: this.transformDropView.bind(this), GeneratedNode: this.transformGenerated.bind(this), DefaultValueNode: this.transformDefaultValue.bind(this), @@ -796,6 +798,15 @@ export class OperationNodeTransformer { }) } + protected transformRefreshMaterializedView(node: RefreshMaterializedViewNode): RefreshMaterializedViewNode { + return requireAllProps({ + kind: 'RefreshMaterializedViewNode', + name: this.transformNode(node.name), + concurrently: node.concurrently, + withNoData: node.withNoData, + }) + } + protected transformDropView(node: DropViewNode): DropViewNode { return requireAllProps({ kind: 'DropViewNode', diff --git a/src/operation-node/operation-node-visitor.ts b/src/operation-node/operation-node-visitor.ts index f88f12539..bbe02da53 100644 --- a/src/operation-node/operation-node-visitor.ts +++ b/src/operation-node/operation-node-visitor.ts @@ -96,6 +96,7 @@ import { CastNode } from './cast-node.js' import { FetchNode } from './fetch-node.js' import { TopNode } from './top-node.js' import { OutputNode } from './output-node.js' +import { RefreshMaterializedViewNode } from './refresh-materialized-view-node.js' export abstract class OperationNodeVisitor { protected readonly nodeStack: OperationNode[] = [] @@ -166,6 +167,7 @@ export abstract class OperationNodeVisitor { DropConstraintNode: this.visitDropConstraint.bind(this), ForeignKeyConstraintNode: this.visitForeignKeyConstraint.bind(this), CreateViewNode: this.visitCreateView.bind(this), + RefreshMaterializedViewNode: this.visitRefreshMaterializedView.bind(this), DropViewNode: this.visitDropView.bind(this), GeneratedNode: this.visitGenerated.bind(this), DefaultValueNode: this.visitDefaultValue.bind(this), @@ -277,6 +279,7 @@ export abstract class OperationNodeVisitor { protected abstract visitPrimitiveValueList(node: PrimitiveValueListNode): void protected abstract visitOperator(node: OperatorNode): void protected abstract visitCreateView(node: CreateViewNode): void + protected abstract visitRefreshMaterializedView(node: RefreshMaterializedViewNode): void protected abstract visitDropView(node: DropViewNode): void protected abstract visitGenerated(node: GeneratedNode): void protected abstract visitDefaultValue(node: DefaultValueNode): void diff --git a/src/operation-node/operation-node.ts b/src/operation-node/operation-node.ts index 6476a24eb..7786b7ec1 100644 --- a/src/operation-node/operation-node.ts +++ b/src/operation-node/operation-node.ts @@ -58,6 +58,7 @@ export type OperationNodeKind = | 'AddConstraintNode' | 'DropConstraintNode' | 'CreateViewNode' + | 'RefreshMaterializedViewNode' | 'DropViewNode' | 'GeneratedNode' | 'DefaultValueNode' diff --git a/src/operation-node/refresh-materialized-view-node.ts b/src/operation-node/refresh-materialized-view-node.ts new file mode 100644 index 000000000..e421a087b --- /dev/null +++ b/src/operation-node/refresh-materialized-view-node.ts @@ -0,0 +1,41 @@ +import { freeze } from '../util/object-utils.js' +import { OperationNode } from './operation-node.js' +import { SchemableIdentifierNode } from './schemable-identifier-node.js' + +export type RefreshMaterializedViewNodeParams = Omit< + Partial, + 'kind' | 'name' +> + +export interface RefreshMaterializedViewNode extends OperationNode { + readonly kind: 'RefreshMaterializedViewNode' + readonly name: SchemableIdentifierNode + readonly concurrently?: boolean + readonly withNoData?: boolean +} + +/** + * @internal + */ +export const RefreshMaterializedViewNode = freeze({ + is(node: OperationNode): node is RefreshMaterializedViewNode { + return node.kind === 'RefreshMaterializedViewNode' + }, + + create(name: string): RefreshMaterializedViewNode { + return freeze({ + kind: 'RefreshMaterializedViewNode', + name: SchemableIdentifierNode.create(name), + }) + }, + + cloneWith( + createView: RefreshMaterializedViewNode, + params: RefreshMaterializedViewNodeParams, + ): RefreshMaterializedViewNode { + return freeze({ + ...createView, + ...params, + }) + }, +}) diff --git a/src/plugin/with-schema/with-schema-transformer.ts b/src/plugin/with-schema/with-schema-transformer.ts index f036d67d4..3fd15a0e1 100644 --- a/src/plugin/with-schema/with-schema-transformer.ts +++ b/src/plugin/with-schema/with-schema-transformer.ts @@ -22,6 +22,7 @@ const ROOT_OPERATION_NODES: Record = freeze({ CreateTableNode: true, CreateTypeNode: true, CreateViewNode: true, + RefreshMaterializedViewNode: true, DeleteQueryNode: true, DropIndexNode: true, DropSchemaNode: true, diff --git a/src/query-compiler/default-query-compiler.ts b/src/query-compiler/default-query-compiler.ts index 22095172b..f2c4e2379 100644 --- a/src/query-compiler/default-query-compiler.ts +++ b/src/query-compiler/default-query-compiler.ts @@ -111,6 +111,7 @@ import { CastNode } from '../operation-node/cast-node.js' import { FetchNode } from '../operation-node/fetch-node.js' import { TopNode } from '../operation-node/top-node.js' import { OutputNode } from '../operation-node/output-node.js' +import { RefreshMaterializedViewNode } from '../operation-node/refresh-materialized-view-node.js' export class DefaultQueryCompiler extends OperationNodeVisitor @@ -1253,6 +1254,22 @@ export class DefaultQueryCompiler this.visitNode(node.as) } } + + protected override visitRefreshMaterializedView(node: RefreshMaterializedViewNode): void { + this.append('refresh materialized view ') + + if (node.concurrently) { + this.append('concurrently ') + } + + this.visitNode(node.name) + + if (node.withNoData) { + this.append(' with no data') + } else { + this.append(' with data') + } + } protected override visitDropView(node: DropViewNode): void { this.append('drop ') diff --git a/src/query-compiler/query-compiler.ts b/src/query-compiler/query-compiler.ts index ec3ed9a63..8a14b7a81 100644 --- a/src/query-compiler/query-compiler.ts +++ b/src/query-compiler/query-compiler.ts @@ -12,6 +12,7 @@ import { DropViewNode } from '../operation-node/drop-view-node.js' import { MergeQueryNode } from '../operation-node/merge-query-node.js' import { QueryNode } from '../operation-node/query-node.js' import { RawNode } from '../operation-node/raw-node.js' +import { RefreshMaterializedViewNode } from '../operation-node/refresh-materialized-view-node.js' import { CompiledQuery } from './compiled-query.js' export type RootOperationNode = @@ -20,6 +21,7 @@ export type RootOperationNode = | CreateIndexNode | CreateSchemaNode | CreateViewNode + | RefreshMaterializedViewNode | DropTableNode | DropIndexNode | DropSchemaNode diff --git a/src/schema/refresh-materialized-view-builder.ts b/src/schema/refresh-materialized-view-builder.ts new file mode 100644 index 000000000..6691dd120 --- /dev/null +++ b/src/schema/refresh-materialized-view-builder.ts @@ -0,0 +1,103 @@ +import { OperationNodeSource } from '../operation-node/operation-node-source.js' +import { CompiledQuery } from '../query-compiler/compiled-query.js' +import { Compilable } from '../util/compilable.js' +import { preventAwait } from '../util/prevent-await.js' +import { QueryExecutor } from '../query-executor/query-executor.js' +import { QueryId } from '../util/query-id.js' +import { freeze } from '../util/object-utils.js' +import { RefreshMaterializedViewNode } from '../operation-node/refresh-materialized-view-node.js' + +export class RefreshMaterializedViewBuilder implements OperationNodeSource, Compilable { + readonly #props: RefreshMaterializedViewBuilderProps + + constructor(props: RefreshMaterializedViewBuilderProps) { + this.#props = freeze(props) + } + + /** + * Adds the "concurrently" modifier. + * + * Use this to refresh the view without locking out concurrent selects on the materialized view. + * + * WARNING! + * This cannot be used with the "with no data" modifier. + */ + concurrently(): RefreshMaterializedViewBuilder { + return new RefreshMaterializedViewBuilder({ + ...this.#props, + node: RefreshMaterializedViewNode.cloneWith(this.#props.node, { + concurrently: true, + withNoData: false, + }), + }) + } + + /** + * Adds the "with data" modifier. + * + * If specified (or defaults) the backing query is executed to provide the new data, and the materialized view is left in a scannable state + */ + withData(): RefreshMaterializedViewBuilder { + return new RefreshMaterializedViewBuilder({ + ...this.#props, + node: RefreshMaterializedViewNode.cloneWith(this.#props.node, { + withNoData: false, + }), + }) + } + + /** + * Adds the "with no data" modifier. + * + * If specified, no new data is generated and the materialized view is left in an unscannable state. + * + * WARNING! + * This cannot be used with the "concurrently" modifier. + */ + withNoData(): RefreshMaterializedViewBuilder { + return new RefreshMaterializedViewBuilder({ + ...this.#props, + node: RefreshMaterializedViewNode.cloneWith(this.#props.node, { + withNoData: true, + concurrently: false, + }), + }) + } + + /** + * Simply calls the provided function passing `this` as the only argument. `$call` returns + * what the provided function returns. + */ + $call(func: (qb: this) => T): T { + return func(this) + } + + toOperationNode(): RefreshMaterializedViewNode { + return this.#props.executor.transformQuery( + this.#props.node, + this.#props.queryId, + ) + } + + compile(): CompiledQuery { + return this.#props.executor.compileQuery( + this.toOperationNode(), + this.#props.queryId, + ) + } + + async execute(): Promise { + await this.#props.executor.executeQuery(this.compile(), this.#props.queryId) + } +} + +preventAwait( + RefreshMaterializedViewBuilder, + "don't await RefreshMaterializedViewBuilder instances directly. To execute the query you need to call `execute`", +) + +export interface RefreshMaterializedViewBuilderProps { + readonly queryId: QueryId + readonly executor: QueryExecutor + readonly node: RefreshMaterializedViewNode +} diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 555483fb8..1f8fe414d 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -26,6 +26,8 @@ import { DropTypeBuilder } from './drop-type-builder.js' import { CreateTypeNode } from '../operation-node/create-type-node.js' import { DropTypeNode } from '../operation-node/drop-type-node.js' import { parseSchemableIdentifier } from '../parser/identifier-parser.js' +import { RefreshMaterializedViewBuilder } from './refresh-materialized-view-builder.js' +import { RefreshMaterializedViewNode } from '../operation-node/refresh-materialized-view-node.js' /** * Provides methods for building database schema. @@ -234,6 +236,26 @@ export class SchemaModule { }) } + /** + * Refresh a materialized view. + * + * ### Examples + * + * ```ts + * await db.schema + * .refreshMaterializedView('my_view') + * .concurrently() + * .execute() + * ``` + */ + refreshMaterializedView(viewName: string): RefreshMaterializedViewBuilder { + return new RefreshMaterializedViewBuilder({ + queryId: createQueryId(), + executor: this.#executor, + node: RefreshMaterializedViewNode.create(viewName), + }) + } + /** * Drop a view. * diff --git a/test/node/src/schema.test.ts b/test/node/src/schema.test.ts index 624b77267..3816e4084 100644 --- a/test/node/src/schema.test.ts +++ b/test/node/src/schema.test.ts @@ -1949,6 +1949,87 @@ for (const dialect of DIALECTS) { } }) + describe('refresh materialized view', () => { + beforeEach(async () => { + await ctx.db.schema + .createView('materialized_dogs') + .materialized() + .as(ctx.db.selectFrom('pet').selectAll().where('species', '=', 'dog')) + .execute() + }) + + afterEach(async () => { + await ctx.db.schema + .dropView('materialized_dogs') + .materialized() + .ifExists() + .execute() + }) + + if (dialect === 'postgres') { + it('should refresh a materialized view', async () => { + const builder = ctx.db.schema + .refreshMaterializedView('materialized_dogs') + + testSql(builder, dialect, { + postgres: { + sql: `refresh materialized view "materialized_dogs" with data`, + parameters: [], + }, + mssql: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + await builder.execute() + }) + + it('should refresh a materialized view concurrently', async () => { + // concurrent refreshes require a unique index + await ctx.db.schema + .createIndex('materialized_dogs_index') + .unique() + .on('materialized_dogs') + .columns(['id']) + .execute() + + const builder = ctx.db.schema + .refreshMaterializedView('materialized_dogs') + .concurrently() + + testSql(builder, dialect, { + postgres: { + sql: `refresh materialized view concurrently "materialized_dogs" with data`, + parameters: [], + }, + mssql: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + await builder.execute() + }) + + it('should refresh a materialized view with no data', async () => { + const builder = ctx.db.schema + .refreshMaterializedView('materialized_dogs') + .withNoData() + + testSql(builder, dialect, { + postgres: { + sql: `refresh materialized view "materialized_dogs" with no data`, + parameters: [], + }, + mssql: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + await builder.execute() + }) + } + }) + describe('drop view', () => { beforeEach(async () => { await ctx.db.schema