1use crate::helpers::StringExt;
2use crate::models::column::PostgresColumn;
3use crate::models::constraint::PostgresConstraint;
4use crate::models::hypertable_retention::HypertableRetention;
5use crate::models::index::PostgresIndex;
6use crate::models::schema::PostgresSchema;
7use crate::object_id::ObjectId;
8use crate::postgres_client_wrapper::FromPgChar;
9use crate::quoting::AttemptedKeywordUsage::{ColumnName, TypeOrFunctionName};
10use crate::quoting::{
11 quote_value_string, AttemptedKeywordUsage, IdentifierQuoter, Quotable, QuotableIter,
12};
13use crate::storage::DataFormat;
14use crate::{default, ColumnIdentity, ElefantToolsError, HypertableCompression, PostgresIndexType};
15use elefant_client::Interval;
16use itertools::Itertools;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Eq, PartialEq, Default, Clone, Serialize, Deserialize)]
20pub struct PostgresTable {
21 pub name: String,
22 pub columns: Vec<PostgresColumn>,
23 pub constraints: Vec<PostgresConstraint>,
24 pub indices: Vec<PostgresIndex>,
25 pub comment: Option<String>,
26 pub storage_parameters: Vec<String>,
27 pub table_type: TableTypeDetails,
28 pub object_id: ObjectId,
29 pub depends_on: Vec<ObjectId>,
30}
31
32impl PostgresTable {
33 pub fn new(name: &str) -> Self {
34 PostgresTable {
35 name: name.to_string(),
36 ..default()
37 }
38 }
39
40 pub fn get_create_statement(
41 &self,
42 schema: &PostgresSchema,
43 identifier_quoter: &IdentifierQuoter,
44 ) -> String {
45 let escaped_relation_name = format!(
46 "{}.{}",
47 schema.name.quote(identifier_quoter, ColumnName),
48 self.name.quote(identifier_quoter, ColumnName)
49 );
50 let mut sql = "create table ".to_string();
51 sql.push_str(&escaped_relation_name);
52
53 if let TableTypeDetails::PartitionedChildTable {
54 partition_expression,
55 parent_table,
56 } = &self.table_type
57 {
58 sql.push_str(" partition of ");
59 sql.push_str(&parent_table.quote(identifier_quoter, ColumnName));
60 sql.push(' ');
61 sql.push_str(partition_expression);
62 } else {
63 sql.push_str(" (");
64
65 let mut text_row_count = 0;
66
67 for (column_index, column) in self.columns.iter().enumerate() {
68 let column_position = (column_index + 1) as i32;
69
70 if text_row_count > 0 {
71 sql.push(',');
72 }
73 sql.push_str("\n ");
74 sql.push_str(&column.name.quote(identifier_quoter, ColumnName));
75 sql.push(' ');
76 sql.push_str(
77 &column
78 .data_type
79 .quote(identifier_quoter, TypeOrFunctionName),
80 );
81
82 if let Some(length) = column.data_type_length {
83 sql.push_str(&format!("({length})"));
84 }
85
86 for _ in 0..column.array_dimensions {
87 sql.push_str("[]");
88 }
89
90 let has_named_not_null = !column.is_nullable
91 && self.constraints.iter().any(|c| {
92 matches!(c, PostgresConstraint::NotNull(nn) if nn.column_name == column.name)
93 });
94
95 if !column.is_nullable && !has_named_not_null {
96 sql.push_str(" not null");
97 }
98
99 if let Some(generated) = &column.generated {
100 sql.push_str(" generated always as (");
101 sql.push_str(&generated.expression);
102 match generated.generation_type {
103 crate::GeneratedColumnType::Stored => sql.push_str(") stored"),
104 crate::GeneratedColumnType::Virtual => sql.push_str(") virtual"),
105 }
106 }
107
108 if let Some(identity) = &column.identity {
109 sql.push_str(" generated ");
110 match identity {
111 ColumnIdentity::GeneratedAlways => sql.push_str("always"),
112 ColumnIdentity::GeneratedByDefault => sql.push_str("by default"),
113 }
114 sql.push_str(" as identity");
115
116 if let Some(seq) = &schema.sequences.iter().find(|s| {
117 s.author_table.as_ref().is_some_and(|t| *t == self.name)
118 && s.author_table_column_position == Some(column_position)
119 }) {
120 sql.push_str(" ( sequence name ");
121 sql.push_str(&seq.name.quote(identifier_quoter, TypeOrFunctionName));
122 sql.push_str(" )");
123 }
124 }
125
126 text_row_count += 1;
127 }
128
129 for index in &self.indices {
130 if index.index_constraint_type == PostgresIndexType::PrimaryKey {
131 if text_row_count > 0 {
132 sql.push(',');
133 }
134
135 sql.push_str("\n constraint ");
136 sql.push_str(&index.name.quote(identifier_quoter, ColumnName));
137
138 if let Some(ref constraint_def) = index.constraint_definition {
139 sql.push(' ');
140 sql.push_str(constraint_def);
141 } else {
142 sql.push_str(" primary key (");
143 sql.push_join(", ", index.key_columns.iter().map(|c| &c.name));
145 sql.push(')');
146 }
147 text_row_count += 1;
148 }
149 }
150
151 for constraint in &self.constraints {
152 if let PostgresConstraint::Check(check) = constraint {
153 if text_row_count > 0 {
154 sql.push(',');
155 }
156 sql.push_str("\n constraint ");
157 sql.push_str(&check.name.quote(identifier_quoter, ColumnName));
158 sql.push_str(" check ");
159 sql.push_str(&check.check_clause);
160 if !check.is_enforced {
161 sql.push_str(" not enforced");
162 }
163 text_row_count += 1;
164 }
165 }
166
167 if let TableTypeDetails::PartitionedParentTable {
168 partition_strategy,
169 partition_columns,
170 ..
171 } = &self.table_type
172 {
173 sql.push_str("\n) partition by ");
174 sql.push_str(match partition_strategy {
175 TablePartitionStrategy::Hash => "hash",
176 TablePartitionStrategy::List => "list",
177 TablePartitionStrategy::Range => "range",
178 });
179 sql.push_str(" (");
180
181 match partition_columns {
182 PartitionedTableColumns::Columns(columns) => {
183 sql.push_join(
184 ", ",
185 columns
186 .iter()
187 .map(|c| c.quote(identifier_quoter, ColumnName)),
188 );
189 }
190 PartitionedTableColumns::Expression(expr) => {
191 sql.push_str(expr);
192 }
193 }
194
195 sql.push(')');
196 } else if let TableTypeDetails::InheritedTable { parent_tables } = &self.table_type {
197 sql.push_str("\n) inherits (");
198 sql.push_join(
199 ", ",
200 parent_tables.iter().map(|c| {
201 c.quote(identifier_quoter, AttemptedKeywordUsage::TypeOrFunctionName)
202 }),
203 );
204 sql.push(')');
205 } else {
206 sql.push_str("\n)");
207 }
208 }
209
210 if !self.storage_parameters.is_empty() {
211 sql.push_str("\nwith (");
212 sql.push_join(", ", self.storage_parameters.iter());
213 sql.push(')');
214 }
215
216 sql.push(';');
217
218 if let Some(c) = &self.comment {
219 sql.push_str(&format!(
220 "\ncomment on table {} is {};",
221 escaped_relation_name,
222 quote_value_string(c)
223 ));
224 }
225
226 for col in &self.columns {
227 if let Some(c) = &col.comment {
228 sql.push_str(&format!(
229 "\ncomment on column {}.{} is {};",
230 escaped_relation_name,
231 col.name.quote(identifier_quoter, ColumnName),
232 quote_value_string(c)
233 ));
234 }
235 }
236
237 for constraint in &self.constraints {
238 if let PostgresConstraint::Check(constraint) = constraint {
239 if let Some(c) = &constraint.comment {
240 sql.push_str(&format!(
241 "\ncomment on constraint {} on {} is {};",
242 constraint.name.quote(identifier_quoter, ColumnName),
243 escaped_relation_name,
244 quote_value_string(c)
245 ));
246 }
247 }
248 }
249
250 if let TableTypeDetails::TimescaleHypertable {
251 dimensions,
252 compression: _,
253 retention: _,
254 } = &self.table_type
255 {
256 for index in &self.indices {
257 if index.index_constraint_type == PostgresIndexType::PrimaryKey {
258 continue;
259 }
260
261 let create_index_sql =
262 index.get_create_index_command(schema, self, identifier_quoter);
263 sql.push_str(&create_index_sql);
264 }
265
266 for constraint in &self.constraints {
267 if let PostgresConstraint::Unique(uk) = constraint {
268 let create_constraint_sql =
269 uk.get_create_statement(self, schema, identifier_quoter);
270 sql.push_str(&create_constraint_sql);
271 }
272 }
273
274 for (idx, dim) in dimensions.iter().enumerate() {
276 match dim {
277 HypertableDimension::Time {
278 column_name,
279 time_interval,
280 } => {
281 if idx == 0 {
282 sql.push_str(&format!("\nselect public.create_hypertable('{}', by_range('{}', INTERVAL '{}'), create_default_indexes => false);", escaped_relation_name, column_name.quote(identifier_quoter, ColumnName), time_interval.to_postgres()));
283 } else {
284 sql.push_str(&format!("\nselect public.add_dimension('{}', by_range('{}', INTERVAL '{}'));", escaped_relation_name, column_name.quote(identifier_quoter, ColumnName), time_interval.to_postgres()));
285 }
286 }
287 HypertableDimension::SpaceInterval {
288 column_name,
289 integer_interval,
290 } => {
291 if idx == 0 {
292 sql.push_str(&format!("\nselect public.create_hypertable('{}', by_range('{}', {}), create_default_indexes => false);", escaped_relation_name, column_name.quote(identifier_quoter, ColumnName), integer_interval));
293 } else {
294 sql.push_str(&format!(
295 "\nselect public.add_dimension('{}', by_range('{}', {}));",
296 escaped_relation_name,
297 column_name.quote(identifier_quoter, ColumnName),
298 integer_interval
299 ));
300 }
301 }
302 HypertableDimension::SpacePartitions {
303 column_name,
304 num_partitions,
305 } => {
306 if idx == 0 {
307 sql.push_str(&format!("\nselect public.create_hypertable('{}', by_hash('{}', {}), create_default_indexes => false);", escaped_relation_name, column_name.quote(identifier_quoter, ColumnName), num_partitions));
308 } else {
309 sql.push_str(&format!(
310 "\nselect public.add_dimension('{}', by_hash('{}', {}));",
311 escaped_relation_name,
312 column_name.quote(identifier_quoter, ColumnName),
313 num_partitions
314 ));
315 }
316 }
317 }
318 }
319 }
320
321 sql
322 }
323
324 pub fn get_copy_in_command(
325 &self,
326 schema: &PostgresSchema,
327 data_format: &DataFormat,
328 identifier_quoter: &IdentifierQuoter,
329 ) -> String {
330 let mut s = "copy ".to_string();
331
332 s.push_str(&schema.name.quote(identifier_quoter, ColumnName));
333 s.push('.');
334 s.push_str(&self.name.quote(identifier_quoter, ColumnName));
335
336 s.push_str(" (");
337
338 let cols = self.get_copy_columns_expression(identifier_quoter);
339
340 s.push_str(&cols);
341
342 s.push_str(") from stdin with (format ");
343 match data_format {
344 DataFormat::Text => {
345 s.push_str("text");
346 }
347 DataFormat::PostgresBinary { .. } => {
348 s.push_str("binary");
349 }
350 }
351 s.push_str(", header false);");
352
353 s
354 }
355
356 pub fn get_copy_out_command(
357 &self,
358 schema: &PostgresSchema,
359 data_format: &DataFormat,
360 identifier_quoter: &IdentifierQuoter,
361 ) -> String {
362 let mut s = "copy ".to_string();
363
364 if let TableTypeDetails::TimescaleHypertable { .. } = self.table_type {
365 s.push_str("(select ");
366 let cols = self.get_copy_columns_expression(identifier_quoter);
367
368 s.push_str(&cols);
369 s.push_str(" from ");
370
371 s.push_str(&schema.name.quote(identifier_quoter, ColumnName));
372 s.push('.');
373 s.push_str(&self.name.quote(identifier_quoter, ColumnName));
374 s.push_str(") ");
375 } else {
376 s.push_str(&schema.name.quote(identifier_quoter, ColumnName));
377 s.push('.');
378 s.push_str(&self.name.quote(identifier_quoter, ColumnName));
379
380 s.push_str(" (");
381
382 let cols = self.get_copy_columns_expression(identifier_quoter);
383
384 s.push_str(&cols);
385 s.push_str(") ");
386 }
387
388 s.push_str(" to stdout with (format ");
389 match data_format {
390 DataFormat::Text => {
391 s.push_str("text");
392 }
393 DataFormat::PostgresBinary { .. } => {
394 s.push_str("binary");
395 }
396 }
397 s.push_str(", header false, encoding 'utf-8');");
398
399 s
400 }
401
402 fn get_copy_columns_expression(&self, identifier_quoter: &IdentifierQuoter) -> String {
403 self.get_writable_columns()
404 .map(|c| c.name.as_str())
405 .quote(identifier_quoter, ColumnName)
406 .join(", ")
407 }
408
409 pub fn get_writable_columns(&self) -> impl Iterator<Item = &PostgresColumn> {
410 self.columns
411 .iter()
412 .filter(|c| c.generated.is_none())
413 .sorted_by_key(|c| c.ordinal_position)
414 }
415
416 pub fn get_timescale_post_settings(
417 &self,
418 schema: &PostgresSchema,
419 identifier_quoter: &IdentifierQuoter,
420 ) -> Option<String> {
421 if let TableTypeDetails::TimescaleHypertable {
422 compression,
423 retention,
424 ..
425 } = &self.table_type
426 {
427 let escaped_relation_name = format!(
428 "{}.{}",
429 schema.name.quote(identifier_quoter, ColumnName),
430 self.name.quote(identifier_quoter, ColumnName)
431 );
432 let mut sql = String::new();
433 if let Some(compression) = compression {
434 sql.push_str("alter table ");
435 compression.add_compression_settings(
436 &mut sql,
437 &escaped_relation_name,
438 identifier_quoter,
439 );
440 }
441
442 if let Some(retention) = retention {
443 if !sql.is_empty() {
444 sql.push('\n');
445 }
446
447 retention.add_retention(&mut sql, &escaped_relation_name);
448 }
449
450 if !sql.is_empty() {
451 return Some(sql);
452 }
453 }
454
455 None
456 }
457
458 pub fn is_timescale_table(&self) -> bool {
459 matches!(
460 self.table_type,
461 TableTypeDetails::TimescaleHypertable { .. }
462 )
463 }
464}
465
466#[derive(Debug, Eq, PartialEq, Clone, Default, Serialize, Deserialize)]
467#[serde(tag = "type")]
468pub enum TableTypeDetails {
469 #[default]
470 Table,
471 PartitionedParentTable {
472 partition_strategy: TablePartitionStrategy,
473 default_partition_name: Option<String>,
474 partition_columns: PartitionedTableColumns,
475 },
476 PartitionedChildTable {
477 parent_table: String,
478 partition_expression: String,
479 },
480 InheritedTable {
481 parent_tables: Vec<String>,
482 },
483 TimescaleHypertable {
484 dimensions: Vec<HypertableDimension>,
485 compression: Option<HypertableCompression>,
486 retention: Option<HypertableRetention>,
487 },
488}
489
490#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
491#[serde(tag = "type")]
492pub enum PartitionedTableColumns {
493 Columns(Vec<String>),
494 Expression(String),
495}
496
497#[derive(Debug, Eq, PartialEq, Copy, Clone, Serialize, Deserialize)]
498pub enum TablePartitionStrategy {
499 Hash,
500 List,
501 Range,
502}
503
504impl FromPgChar for TablePartitionStrategy {
505 fn from_pg_char(c: char) -> Result<Self, ElefantToolsError> {
506 match c {
507 'h' => Ok(TablePartitionStrategy::Hash),
508 'l' => Ok(TablePartitionStrategy::List),
509 'r' => Ok(TablePartitionStrategy::Range),
510 _ => Err(ElefantToolsError::InvalidTablePartitioningStrategy(
511 c.to_string(),
512 )),
513 }
514 }
515}
516
517#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
518#[serde(tag = "type")]
519pub enum HypertableDimension {
520 Time {
521 column_name: String,
522 time_interval: Interval,
523 },
524 SpaceInterval {
525 column_name: String,
526 integer_interval: i64,
527 },
528 SpacePartitions {
529 column_name: String,
530 num_partitions: i16,
531 },
532}