1use 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
76pub(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 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
105pub 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
119pub 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>, 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 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 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 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 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 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 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 .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 .into(),
490 RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
491 };
492 assert_eq!(columns, expected_columns, "{columns:#?}");
493 }
494
495 #[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 let sql = "create materialized view mv0 as select count(x) from t";
505 frontend.run_sql(sql).await.unwrap();
506
507 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 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 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 #[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 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 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}