feat(analytics): add materialized view support to analytics models and table management

- add MaterializedView type
- wire materializedViews into AnalyticsBaseModel (constructor param, property, getter/setter)
- implement materialized view processing in AnalyticsTableManagement (validation, existence check, creation)
This commit is contained in:
Nawaz Dhandala
2025-10-22 18:04:51 +01:00
parent 988d5d327c
commit 1300c4e667
3 changed files with 122 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import AnalyticsTableEngine from "../../../Types/AnalyticsDatabase/AnalyticsTabl
import AnalyticsTableColumn from "../../../Types/AnalyticsDatabase/TableColumn";
import TableColumnType from "../../../Types/AnalyticsDatabase/TableColumnType";
import Projection from "../../../Types/AnalyticsDatabase/Projection";
import MaterializedView from "../../../Types/AnalyticsDatabase/MaterializedView";
import {
ColumnAccessControl,
TableAccessControl,
@@ -42,6 +43,7 @@ export default class AnalyticsBaseModel extends CommonModel {
enableRealtimeEventsOn?: EnableRealtimeEventsOn | undefined;
partitionKey: string;
projections?: Array<Projection> | undefined;
materializedViews?: Array<MaterializedView> | undefined;
}) {
super({
tableColumns: data.tableColumns,
@@ -143,6 +145,7 @@ export default class AnalyticsBaseModel extends CommonModel {
this.enableRealtimeEventsOn = data.enableRealtimeEventsOn;
this.partitionKey = data.partitionKey;
this.projections = data.projections || [];
this.materializedViews = data.materializedViews || [];
}
private _enableWorkflowOn: EnableWorkflowOn | undefined;
@@ -261,6 +264,14 @@ export default class AnalyticsBaseModel extends CommonModel {
this._projections = v;
}
private _materializedViews: Array<MaterializedView> = [];
public get materializedViews(): Array<MaterializedView> {
return this._materializedViews;
}
public set materializedViews(v: Array<MaterializedView>) {
this._materializedViews = v;
}
public getTenantColumn(): AnalyticsTableColumn | null {
const column: AnalyticsTableColumn | undefined = this.tableColumns.find(
(column: AnalyticsTableColumn) => {

View File

@@ -0,0 +1,4 @@
export default interface MaterializedView {
name: string;
query: string;
}

View File

@@ -6,6 +6,7 @@ import AnalyticsDatabaseService, {
import AnalyticsBaseModel from "Common/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
import logger from "Common/Server/Utils/Logger";
import Projection from "Common/Types/AnalyticsDatabase/Projection";
import MaterializedView from "Common/Types/AnalyticsDatabase/MaterializedView";
import { JSONObject } from "Common/Types/JSON";
export default class AnalyticsTableManagement {
@@ -67,6 +68,57 @@ export default class AnalyticsTableManagement {
projection.name,
);
}
const materializedViews: Array<MaterializedView> =
service.model.materializedViews;
if (materializedViews.length > 0) {
logger.debug(
`Processing ${materializedViews.length} materialized views for ${service.model.tableName}`,
);
}
for (const materializedView of materializedViews) {
if (!materializedView.query || materializedView.query.trim().length === 0) {
logger.debug(
`Skipping materialized view with empty query on ${service.model.tableName}`,
);
continue;
}
if (!materializedView.name || materializedView.name.trim().length === 0) {
logger.debug(
`Skipping materialized view with empty name on ${service.model.tableName}`,
);
continue;
}
logger.debug(
`Ensuring materialized view ${materializedView.name} exists on ${service.model.tableName}`,
);
const viewExists: boolean =
await AnalyticsTableManagement.doesMaterializedViewExist(
service,
materializedView.name,
);
if (viewExists) {
logger.debug(
`Materialized view ${materializedView.name} already exists on ${service.model.tableName}`,
);
continue;
}
logger.debug(
`Creating materialized view ${materializedView.name} on ${service.model.tableName}`,
);
await AnalyticsTableManagement.createMaterializedView(
service,
materializedView,
);
}
}
}
@@ -155,4 +207,59 @@ export default class AnalyticsTableManagement {
private static escapeIdentifier(value: string): string {
return `\`${value.replace(/`/g, "``")}\``;
}
private static async doesMaterializedViewExist(
service: AnalyticsDatabaseService<AnalyticsBaseModel>,
viewName: string,
): Promise<boolean> {
const databaseName: string | undefined =
service.database.getDatasourceOptions().database;
if (!databaseName) {
return false;
}
const escapedDatabaseName: string = this.escapeForQuery(databaseName);
const escapedViewName: string = this.escapeForQuery(viewName);
const statement: string = `SELECT name FROM system.tables WHERE database = '${escapedDatabaseName}' AND name = '${escapedViewName}' AND engine = 'MaterializedView' LIMIT 1`;
let result: Results;
try {
result = await service.executeQuery(statement);
} catch (error) {
logger.error({
message: `Failed to verify materialized view ${viewName} on ${service.model.tableName}`,
error: (error as Error).message,
});
throw error;
}
const response: DbJSONResponse = await result.json<{
data?: Array<JSONObject>;
}>();
return Boolean(response.data && response.data.length > 0);
}
private static async createMaterializedView(
service: AnalyticsDatabaseService<AnalyticsBaseModel>,
materializedView: MaterializedView,
): Promise<void> {
try {
await service.execute(materializedView.query);
} catch (error) {
const clickhouseError: { code?: string } = error as { code?: string };
logger.error({
message: `Failed to create materialized view ${materializedView.name} on ${service.model.tableName}`,
error: (error as Error).message,
code: clickhouseError?.code,
stack: (error as Error).stack,
});
throw error;
}
}
}