Skip to main content

elefant_tools/models/
table.rs

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                        // We don't need to escape the column names here as they are already escaped in the index definition.
144                        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            // We don't need timescale to create the indices as we do it later on again based on what was exported.
275            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}