Skip to main content

risingwave_frontend/handler/
create_mv.rs

1// Copyright 2022 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashSet;
16
17use either::Either;
18use itertools::Itertools;
19use pgwire::pg_response::{PgResponse, StatementType};
20use risingwave_common::catalog::{FunctionId, ObjectId, SecretId};
21use risingwave_common::license::Feature;
22use risingwave_pb::ddl_service::streaming_job_resource_type;
23use risingwave_pb::stream_plan::PbStreamFragmentGraph;
24use risingwave_sqlparser::ast::{EmitMode, Ident, ObjectName, Query};
25
26use super::RwPgResponse;
27use crate::binder::{Binder, BoundQuery, BoundSetExpr};
28use crate::catalog::check_column_name_not_reserved;
29use crate::error::ErrorCode::{InvalidInputSyntax, ProtocolError};
30use crate::error::{ErrorCode, Result, RwError};
31use crate::handler::HandlerArgs;
32use crate::handler::util::{
33    LongRunningNotificationAction, execute_with_long_running_notification,
34    reject_internal_table_dependencies,
35};
36use crate::optimizer::backfill_order_strategy::plan_backfill_order;
37use crate::optimizer::plan_node::generic::GenericPlanRef;
38use crate::optimizer::plan_node::{
39    BackfillType, Explain, StreamPlanRef as PlanRef, ensure_sync_log_store_fragment_root,
40};
41use crate::optimizer::{OptimizerContext, OptimizerContextRef, RelationCollectorVisitor};
42use crate::planner::Planner;
43use crate::scheduler::streaming_manager::CreatingStreamingJobInfo;
44use crate::session::SessionImpl;
45use crate::stream_fragmenter::{GraphJobType, build_graph_with_strategy};
46use crate::utils::{MV_REFRESH_INTERVAL_SEC_KEY, ordinal};
47use crate::{TableCatalog, WithOptions};
48
49pub const RESOURCE_GROUP_KEY: &str = "resource_group";
50pub const CLOUD_SERVERLESS_BACKFILL_ENABLED: &str = "cloud.serverless_backfill_enabled";
51
52pub(crate) struct StreamingJobResourceOptions {
53    pub resource_group: Option<String>,
54    pub serverless_backfill_enabled: Option<bool>,
55}
56
57pub(crate) fn extract_streaming_job_resource_options(
58    with_options: &mut WithOptions,
59) -> StreamingJobResourceOptions {
60    StreamingJobResourceOptions {
61        resource_group: with_options.remove(RESOURCE_GROUP_KEY),
62        serverless_backfill_enabled: with_options
63            .remove(CLOUD_SERVERLESS_BACKFILL_ENABLED)
64            .map(|value| value.parse::<bool>().unwrap_or(false)),
65    }
66}
67
68pub(super) fn parse_column_names(columns: &[Ident]) -> Option<Vec<String>> {
69    if columns.is_empty() {
70        None
71    } else {
72        Some(columns.iter().map(|v| v.real_value()).collect())
73    }
74}
75
76/// If columns is empty, it means that the user did not specify the column names.
77/// In this case, we extract the column names from the query.
78/// If columns is not empty, it means that user specify the column names and the user
79/// should guarantee that the column names number are consistent with the query.
80pub(super) fn get_column_names(
81    bound: &BoundQuery,
82    columns: Vec<Ident>,
83) -> Result<Option<Vec<String>>> {
84    let col_names = parse_column_names(&columns);
85    if let BoundSetExpr::Select(select) = &bound.body {
86        // `InputRef`'s alias will be implicitly assigned in `bind_project`.
87        // If user provides columns name (col_names.is_some()), we don't need alias.
88        // For other expressions (col_names.is_none()), we require the user to explicitly assign an
89        // alias.
90        if col_names.is_none() {
91            for (i, alias) in select.aliases.iter().enumerate() {
92                if alias.is_none() {
93                    return Err(ErrorCode::BindError(format!(
94                    "An alias must be specified for the {} expression (counting from 1) in result relation", ordinal(i+1)
95                ))
96                .into());
97                }
98            }
99        }
100    }
101
102    Ok(col_names)
103}
104
105/// Bind and generate create MV plan, return plan and mv table info.
106pub fn explain_create_mv_plan(
107    session: &SessionImpl,
108    context: OptimizerContextRef,
109    query: Query,
110    name: ObjectName,
111    columns: Vec<Ident>,
112    emit_mode: Option<EmitMode>,
113) -> Result<(PlanRef, TableCatalog)> {
114    let mut binder = Binder::new_for_stream(session);
115    let bound = binder.bind_query(&query)?;
116    gen_create_mv_plan_bound(session, context, bound, name, columns, emit_mode, None)
117}
118
119/// Generate create MV plan from a bound query
120pub fn gen_create_mv_plan_bound(
121    session: &SessionImpl,
122    context: OptimizerContextRef,
123    query: BoundQuery,
124    name: ObjectName,
125    columns: Vec<Ident>,
126    emit_mode: Option<EmitMode>,
127    refresh_interval_sec: Option<u64>,
128) -> Result<(PlanRef, TableCatalog)> {
129    if session.config().create_compaction_group_for_mv() {
130        context.warn_to_user("The session variable CREATE_COMPACTION_GROUP_FOR_MV has been deprecated. It will not take effect.");
131    }
132
133    let db_name = &session.database();
134    let (schema_name, table_name) = Binder::resolve_schema_qualified_name(db_name, &name)?;
135
136    let (database_id, schema_id) = session.get_database_and_schema_id_for_create(schema_name)?;
137
138    let definition = context.normalized_sql().to_owned();
139
140    let col_names = get_column_names(&query, columns)?;
141
142    let emit_on_window_close = emit_mode == Some(EmitMode::OnWindowClose);
143    if emit_on_window_close {
144        context.warn_to_user("EMIT ON WINDOW CLOSE is currently an experimental feature. Please use it with caution.");
145    }
146
147    let mut plan_root = Planner::new_for_stream(context).plan_query(query)?;
148    if let Some(col_names) = col_names {
149        for name in &col_names {
150            check_column_name_not_reserved(name)?;
151        }
152        plan_root.set_out_names(col_names)?;
153    }
154
155    let backfill_type = if refresh_interval_sec.is_some() {
156        plan_root.require_snapshot_backfill_for_batch_refresh()?;
157        BackfillType::SnapshotBackfill
158    } else {
159        plan_root.derive_backfill_type(true)
160    };
161
162    let materialize = plan_root.gen_materialize_plan(
163        database_id,
164        schema_id,
165        table_name,
166        definition,
167        emit_on_window_close,
168        backfill_type,
169    )?;
170
171    let mut table = materialize.table().clone();
172    table.owner = session.user_id();
173
174    let plan: PlanRef = ensure_sync_log_store_fragment_root(materialize.into());
175
176    let ctx = plan.ctx();
177    let explain_trace = ctx.is_explain_trace();
178    if explain_trace {
179        ctx.trace("Create Materialized View:");
180        ctx.trace(plan.explain_to_string());
181    }
182
183    Ok((plan, table))
184}
185
186pub async fn handle_create_mv(
187    handler_args: HandlerArgs,
188    if_not_exists: bool,
189    name: ObjectName,
190    query: Query,
191    columns: Vec<Ident>,
192    emit_mode: Option<EmitMode>,
193) -> Result<RwPgResponse> {
194    let (dependent_relations, dependent_udfs, dependent_secrets, bound_query) = {
195        let mut binder = Binder::new_for_stream(handler_args.session.as_ref());
196        let bound_query = binder.bind_query(&query)?;
197        (
198            binder.included_relations().clone(),
199            binder.included_udfs().clone(),
200            binder.included_secrets().clone(),
201            bound_query,
202        )
203    };
204    handle_create_mv_bound(
205        handler_args,
206        if_not_exists,
207        name,
208        bound_query,
209        dependent_relations,
210        dependent_udfs,
211        dependent_secrets,
212        columns,
213        emit_mode,
214    )
215    .await
216}
217
218pub(crate) fn resolve_streaming_job_resource_type(
219    session: &SessionImpl,
220    with_options: &mut WithOptions,
221) -> Result<streaming_job_resource_type::ResourceType> {
222    let StreamingJobResourceOptions {
223        resource_group,
224        serverless_backfill_enabled,
225    } = extract_streaming_job_resource_options(with_options);
226
227    if resource_group.is_some() {
228        Feature::ResourceGroup.check_available()?;
229    }
230
231    let is_serverless_backfill = match serverless_backfill_enabled {
232        Some(value) => value,
233        None => {
234            if resource_group.is_some() {
235                false
236            } else {
237                session.config().enable_serverless_backfill()
238            }
239        }
240    };
241
242    if resource_group.is_some() && is_serverless_backfill {
243        return Err(RwError::from(InvalidInputSyntax(
244            "Please do not specify serverless backfilling and resource group together".to_owned(),
245        )));
246    }
247
248    let resource_type = if is_serverless_backfill {
249        assert_eq!(resource_group, None);
250        streaming_job_resource_type::ResourceType::ServerlessBackfill(true)
251    } else if let Some(group) = resource_group {
252        streaming_job_resource_type::ResourceType::SpecificResourceGroup(group)
253    } else {
254        streaming_job_resource_type::ResourceType::Regular(true)
255    };
256
257    Ok(resource_type)
258}
259
260fn get_with_options(handler_args: HandlerArgs) -> WithOptions {
261    let context = OptimizerContext::from_handler_args(handler_args);
262    context.with_options().clone()
263}
264
265pub async fn handle_create_mv_bound(
266    handler_args: HandlerArgs,
267    if_not_exists: bool,
268    name: ObjectName,
269    query: BoundQuery,
270    dependent_relations: HashSet<ObjectId>,
271    dependent_udfs: HashSet<FunctionId>, // TODO(rc): merge with `dependent_relations`
272    dependent_secrets: HashSet<SecretId>,
273    columns: Vec<Ident>,
274    emit_mode: Option<EmitMode>,
275) -> Result<RwPgResponse> {
276    let session = handler_args.session.clone();
277
278    // Check cluster limits
279    session.check_cluster_limits().await?;
280
281    if let Either::Right(resp) = session.check_relation_name_duplicated(
282        name.clone(),
283        StatementType::CREATE_MATERIALIZED_VIEW,
284        if_not_exists,
285    )? {
286        return Ok(resp);
287    }
288
289    reject_internal_table_dependencies(
290        session.as_ref(),
291        &dependent_relations,
292        "CREATE MATERIALIZED VIEW",
293    )?;
294
295    let (table, graph, dependencies, resource_type, refresh_interval_sec) = {
296        gen_create_mv_graph(
297            handler_args,
298            name,
299            query,
300            dependent_relations,
301            dependent_udfs,
302            dependent_secrets,
303            columns,
304            emit_mode,
305        )?
306    };
307
308    // Ensure writes to `StreamJobTracker` are atomic.
309    let _job_guard =
310        session
311            .env()
312            .creating_streaming_job_tracker()
313            .guard(CreatingStreamingJobInfo::new(
314                session.session_id(),
315                table.database_id,
316                table.schema_id,
317                table.name.clone(),
318            ));
319
320    let catalog_writer = session.catalog_writer()?;
321    execute_with_long_running_notification(
322        catalog_writer.create_materialized_view(
323            table.to_prost(),
324            graph,
325            dependencies,
326            resource_type,
327            if_not_exists,
328            refresh_interval_sec,
329        ),
330        &session,
331        "CREATE MATERIALIZED VIEW",
332        LongRunningNotificationAction::MonitorBackfillJob,
333    )
334    .await?;
335
336    Ok(PgResponse::empty_result(
337        StatementType::CREATE_MATERIALIZED_VIEW,
338    ))
339}
340
341#[expect(clippy::type_complexity)]
342pub(crate) fn gen_create_mv_graph(
343    handler_args: HandlerArgs,
344    name: ObjectName,
345    query: BoundQuery,
346    dependent_relations: HashSet<ObjectId>,
347    dependent_udfs: HashSet<FunctionId>,
348    dependent_secrets: HashSet<SecretId>,
349    columns: Vec<Ident>,
350    emit_mode: Option<EmitMode>,
351) -> Result<(
352    TableCatalog,
353    PbStreamFragmentGraph,
354    HashSet<ObjectId>,
355    streaming_job_resource_type::ResourceType,
356    Option<u64>,
357)> {
358    let mut with_options = get_with_options(handler_args.clone());
359    let refresh_interval_sec = with_options.refresh_interval_sec()?;
360    with_options.remove(MV_REFRESH_INTERVAL_SEC_KEY);
361    let resource_type =
362        resolve_streaming_job_resource_type(handler_args.session.as_ref(), &mut with_options)?;
363
364    if !with_options.is_empty() {
365        // get other useful fields by `remove`, the logic here is to reject unknown options.
366        return Err(RwError::from(ProtocolError(format!(
367            "unexpected options in WITH clause: {:?}",
368            with_options.keys()
369        ))));
370    }
371
372    let context = OptimizerContext::from_handler_args(handler_args);
373    let has_order_by = !query.order.is_empty();
374    if has_order_by {
375        context.warn_to_user(r#"The ORDER BY clause in the CREATE MATERIALIZED VIEW statement does not guarantee that the rows selected out of this materialized view is returned in this order.
376It only indicates the physical clustering of the data, which may improve the performance of queries issued against this materialized view.
377"#.to_owned());
378    }
379
380    let context: OptimizerContextRef = context.into();
381    let session = context.session_ctx().as_ref();
382
383    let (plan, table) = gen_create_mv_plan_bound(
384        session,
385        context.clone(),
386        query,
387        name,
388        columns,
389        emit_mode,
390        refresh_interval_sec,
391    )?;
392
393    let backfill_order = plan_backfill_order(
394        session,
395        context.with_options().backfill_order_strategy(),
396        plan.clone(),
397    )?;
398
399    // TODO(rc): To be consistent with UDF dependency check, we should collect relation dependencies
400    // during binding instead of visiting the optimized plan.
401    let dependencies = RelationCollectorVisitor::collect_with(dependent_relations, plan.clone())
402        .into_iter()
403        .chain(dependent_udfs.iter().copied().map_into())
404        .chain(
405            dependent_secrets
406                .iter()
407                .copied()
408                .map(|id| id.as_object_id()),
409        )
410        .collect();
411
412    let graph = build_graph_with_strategy(
413        plan,
414        Some(GraphJobType::MaterializedView),
415        Some(backfill_order),
416    )?;
417
418    Ok((
419        table,
420        graph,
421        dependencies,
422        resource_type,
423        refresh_interval_sec,
424    ))
425}
426
427#[cfg(test)]
428pub mod tests {
429    use std::collections::HashMap;
430
431    use pgwire::pg_response::StatementType::CREATE_MATERIALIZED_VIEW;
432    use risingwave_common::catalog::{
433        DEFAULT_DATABASE_NAME, DEFAULT_SCHEMA_NAME, ROW_ID_COLUMN_NAME, RW_TIMESTAMP_COLUMN_NAME,
434    };
435    use risingwave_common::types::{DataType, StructType};
436
437    use crate::catalog::root_catalog::SchemaPath;
438    use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
439
440    #[tokio::test]
441    async fn test_create_mv_handler() {
442        let proto_file = create_proto_file(PROTO_FILE_DATA);
443        let sql = format!(
444            r#"CREATE SOURCE t1
445    WITH (connector = 'kinesis')
446    FORMAT PLAIN ENCODE PROTOBUF (message = '.test.TestRecord', schema.location = 'file://{}')"#,
447            proto_file.path().to_str().unwrap()
448        );
449        let frontend = LocalFrontend::new(Default::default()).await;
450        frontend.run_sql(sql).await.unwrap();
451
452        let sql = "create materialized view mv1 as select t1.country from t1";
453        frontend.run_sql(sql).await.unwrap();
454
455        let session = frontend.session_ref();
456        let catalog_reader = session.env().catalog_reader().read_guard();
457        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
458
459        // Check source exists.
460        let (source, _) = catalog_reader
461            .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t1")
462            .unwrap();
463        assert_eq!(source.name, "t1");
464
465        // Check table exists.
466        let (table, _) = catalog_reader
467            .get_created_table_by_name(DEFAULT_DATABASE_NAME, schema_path, "mv1")
468            .unwrap();
469        assert_eq!(table.name(), "mv1");
470
471        let columns = table
472            .columns
473            .iter()
474            .map(|col| (col.name(), col.data_type().clone()))
475            .collect::<HashMap<&str, DataType>>();
476
477        let city_type = StructType::new(vec![
478            ("address", DataType::Varchar),
479            ("zipcode", DataType::Varchar),
480        ])
481        // .with_ids([5, 6].map(ColumnId::new))
482        .into();
483        let expected_columns = maplit::hashmap! {
484            ROW_ID_COLUMN_NAME => DataType::Serial,
485            "country" => StructType::new(
486                 vec![("address", DataType::Varchar),("city", city_type),("zipcode", DataType::Varchar)],
487            )
488            // .with_ids([3, 4, 7].map(ColumnId::new))
489            .into(),
490            RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
491        };
492        assert_eq!(columns, expected_columns, "{columns:#?}");
493    }
494
495    /// When creating MV, a unique column name must be specified for each column
496    #[tokio::test]
497    async fn test_no_alias() {
498        let frontend = LocalFrontend::new(Default::default()).await;
499
500        let sql = "create table t(x varchar)";
501        frontend.run_sql(sql).await.unwrap();
502
503        // Aggregation without alias is ok.
504        let sql = "create materialized view mv0 as select count(x) from t";
505        frontend.run_sql(sql).await.unwrap();
506
507        // Same aggregations without alias is forbidden, because it make the same column name.
508        let sql = "create materialized view mv1 as select count(x), count(*) from t";
509        let err = frontend.run_sql(sql).await.unwrap_err();
510        assert_eq!(
511            err.to_string(),
512            "Invalid input syntax: column \"count\" specified more than once"
513        );
514
515        // Literal without alias is forbidden.
516        let sql = "create materialized view mv1 as select 1";
517        let err = frontend.run_sql(sql).await.unwrap_err();
518        assert_eq!(
519            err.to_string(),
520            "Bind error: An alias must be specified for the 1st expression (counting from 1) in result relation"
521        );
522
523        // some expression without alias is forbidden.
524        let sql = "create materialized view mv1 as select x is null from t";
525        let err = frontend.run_sql(sql).await.unwrap_err();
526        assert_eq!(
527            err.to_string(),
528            "Bind error: An alias must be specified for the 1st expression (counting from 1) in result relation"
529        );
530    }
531
532    /// Creating MV with order by returns a special notice
533    #[tokio::test]
534    async fn test_create_mv_with_order_by() {
535        let frontend = LocalFrontend::new(Default::default()).await;
536
537        let sql = "create table t(x varchar)";
538        frontend.run_sql(sql).await.unwrap();
539
540        // Without order by
541        let sql = "create materialized view mv1 as select * from t";
542        let response = frontend.run_sql(sql).await.unwrap();
543        assert_eq!(response.stmt_type(), CREATE_MATERIALIZED_VIEW);
544        assert!(response.notices().is_empty());
545
546        // With order by
547        let sql = "create materialized view mv2 as select * from t order by x";
548        let response = frontend.run_sql(sql).await.unwrap();
549        assert_eq!(response.stmt_type(), CREATE_MATERIALIZED_VIEW);
550    }
551}