sqlmesh.integrations.dlt
1import typing as t 2import click 3from datetime import datetime, timedelta, timezone 4from pydantic import ValidationError 5from sqlglot import exp, parse_one 6from sqlmesh.core.config.connection import parse_connection_config 7from sqlmesh.core.context import Context 8from sqlmesh.utils.date import yesterday_ds 9 10 11def generate_dlt_models_and_settings( 12 pipeline_name: str, 13 dialect: str, 14 tables: t.Optional[t.List[str]] = None, 15 dlt_path: t.Optional[str] = None, 16) -> t.Tuple[t.Set[t.Tuple[str, str]], t.Optional[str], str]: 17 """ 18 This function attaches to a DLT pipeline and retrieves the connection configs and 19 SQLMesh models based on the tables present in the pipeline's default schema. 20 21 Args: 22 pipeline_name: The name of the DLT pipeline to attach to. 23 dialect: The SQL dialect to use for generating SQLMesh models. 24 tables: A list of table names to include. 25 dlt_path: The path to the DLT pipelines working directory, where DLT stores 26 pipeline state (by default ~/.dlt/pipelines). 27 28 Returns: 29 A tuple containing a set of the SQLMesh model definitions, the connection config and the start date. 30 """ 31 32 import dlt 33 from dlt.common.schema.utils import has_table_seen_data, is_complete_column 34 from dlt.pipeline.exceptions import CannotRestorePipelineException 35 36 try: 37 pipeline = dlt.attach(pipeline_name=pipeline_name, pipelines_dir=dlt_path or "") 38 except CannotRestorePipelineException as e: 39 from pathlib import Path 40 from dlt.common.pipeline import get_dlt_pipelines_dir 41 42 searched_dir = dlt_path or get_dlt_pipelines_dir() 43 msg = f"Could not attach to pipeline {pipeline_name}.\nSearched in: {searched_dir}\n{e}" 44 if dlt_path and (Path(get_dlt_pipelines_dir()) / pipeline_name).exists(): 45 msg += ( 46 f"\nHint: A pipeline named '{pipeline_name}' exists in the default pipelines " 47 f"working directory '{get_dlt_pipelines_dir()}'. Note that --dlt-path must " 48 "point to the directory where DLT stores pipeline working state (by default " 49 "~/.dlt/pipelines), not the directory containing your pipeline scripts. " 50 "Try omitting --dlt-path." 51 ) 52 raise click.ClickException(msg) 53 54 schema = pipeline.default_schema 55 dataset = pipeline.dataset_name 56 57 # Get the start date from the load_ids 58 storage_ids = list(pipeline._get_load_storage().list_loaded_packages()) 59 start_date = get_start_date(storage_ids) 60 61 # Get the connection credentials 62 db_type = pipeline.destination.to_name(pipeline.destination) 63 if db_type == "filesystem": 64 connection_config = None 65 else: 66 client = pipeline.destination_client() 67 config = client.config 68 credentials = config.credentials 69 configs = { 70 key: value 71 for key in dir(credentials) 72 if not key.startswith("_") 73 and not callable(value := getattr(credentials, key)) 74 and value is not None 75 } 76 connection_config = format_config(configs, db_type) 77 78 dlt_tables = { 79 name: table 80 for name, table in schema.tables.items() 81 if ( 82 (has_table_seen_data(table) and not name.startswith(schema._dlt_tables_prefix)) 83 or name == schema.loads_table_name 84 ) 85 and (name in tables if tables else True) 86 } 87 88 sqlmesh_models = set() 89 for table_name, table in dlt_tables.items(): 90 dlt_columns = {} 91 primary_key = [] 92 93 # is_complete_column returns true if column contains a name and a data type 94 for col in filter(is_complete_column, table["columns"].values()): 95 dlt_columns[col["name"]] = exp.DataType.build(str(col["data_type"]), dialect=dialect) 96 if col.get("primary_key"): 97 primary_key.append(str(col["name"])) 98 99 load_id = next( 100 (col for col in ["_dlt_load_id", "load_id"] if col in dlt_columns), 101 None, 102 ) 103 load_key = "c." + load_id if load_id else "" 104 parent_table = None 105 106 # Handling for nested tables: https://dlthub.com/docs/general-usage/destination-tables#nested-tables 107 if not load_id: 108 if ( 109 "_dlt_parent_id" in dlt_columns 110 and (parent_table := table["parent"]) 111 and parent_table in dlt_tables 112 ): 113 load_key = "p._dlt_load_id" 114 parent_table = dataset + "." + parent_table 115 else: 116 break 117 118 column_types = [ 119 exp.cast(exp.column(column, table="c"), data_type, dialect=dialect) 120 .as_(column) 121 .sql(dialect=dialect) 122 for column, data_type in dlt_columns.items() 123 if isinstance(column, str) 124 ] 125 select_columns = ( 126 ",\n".join(f" {column_name}" for column_name in column_types) if column_types else "" 127 ) 128 129 grain = f"\n grain ({', '.join(primary_key)})," if primary_key else "" 130 incremental_model_name = f"{dataset}_sqlmesh.incremental_{table_name}" 131 incremental_model_sql = generate_incremental_model( 132 incremental_model_name, 133 select_columns, 134 grain, 135 dataset + "." + table_name, 136 dialect, 137 load_key, 138 parent_table, 139 ) 140 sqlmesh_models.add((incremental_model_name, incremental_model_sql)) 141 142 return sqlmesh_models, connection_config, start_date 143 144 145def generate_dlt_models( 146 context: Context, 147 pipeline_name: str, 148 tables: t.List[str], 149 force: bool, 150 dlt_path: t.Optional[str] = None, 151) -> t.List[str]: 152 from sqlmesh.cli.project_init import _create_object_files 153 154 sqlmesh_models, _, _ = generate_dlt_models_and_settings( 155 pipeline_name=pipeline_name, 156 dialect=context.config.dialect or "", 157 tables=tables if tables else None, 158 dlt_path=dlt_path, 159 ) 160 161 if not tables and not force: 162 existing_models = [m.name for m in context.models.values()] 163 sqlmesh_models = {model for model in sqlmesh_models if model[0] not in existing_models} 164 165 if sqlmesh_models: 166 _create_object_files( 167 context.path / "models", 168 {model[0].split(".")[-1]: model[1] for model in sqlmesh_models}, 169 "sql", 170 ) 171 return [model[0] for model in sqlmesh_models] 172 return [] 173 174 175def generate_incremental_model( 176 model_name: str, 177 select_columns: str, 178 grain: str, 179 from_table: str, 180 dialect: str, 181 load_id: str, 182 parent_table: t.Optional[str] = None, 183) -> str: 184 """Generate the SQL definition for an incremental model.""" 185 186 time_column = parse_one(f"to_timestamp(CAST({load_id} AS DOUBLE))").sql(dialect=dialect) 187 188 from_clause = f"{from_table} as c" 189 if parent_table: 190 from_clause += f"""\nJOIN 191 {parent_table} as p 192ON 193 c._dlt_parent_id = p._dlt_id""" 194 195 return f"""MODEL ( 196 name {model_name}, 197 kind INCREMENTAL_BY_TIME_RANGE ( 198 time_column _dlt_load_time, 199 ),{grain} 200); 201 202SELECT 203{select_columns}, 204 {time_column} as _dlt_load_time 205FROM 206 {from_clause} 207WHERE 208 {time_column} BETWEEN @start_ts AND @end_ts 209""" 210 211 212def format_config(configs: t.Dict[str, str], db_type: str) -> str: 213 """Generate a string for the gateway connection config.""" 214 config = { 215 "type": db_type, 216 } 217 218 for key, value in configs.items(): 219 if key == "password": 220 config[key] = f'"{value}"' 221 elif key == "username": 222 config["user"] = value 223 else: 224 config[key] = value 225 226 # Validate the connection config fields 227 invalid_fields = [] 228 try: 229 parse_connection_config(config) 230 except ValidationError as e: 231 for error in e.errors(): 232 invalid_fields.append(error.get("loc", [])[0]) 233 234 return "\n".join( 235 [f" {key}: {value}" for key, value in config.items() if key not in invalid_fields] 236 ) 237 238 239def get_start_date(load_ids: t.List[str]) -> str: 240 """Convert the earliest load_id to UTC timestamp, subtract a day and format as 'YYYY-MM-DD'.""" 241 242 timestamps = [datetime.fromtimestamp(float(id), tz=timezone.utc) for id in load_ids] 243 if timestamps: 244 start_timestamp = min(timestamps) - timedelta(days=1) 245 return start_timestamp.strftime("%Y-%m-%d") 246 return yesterday_ds()
def
generate_dlt_models_and_settings( pipeline_name: str, dialect: str, tables: Optional[List[str]] = None, dlt_path: Optional[str] = None) -> Tuple[Set[Tuple[str, str]], Optional[str], str]:
12def generate_dlt_models_and_settings( 13 pipeline_name: str, 14 dialect: str, 15 tables: t.Optional[t.List[str]] = None, 16 dlt_path: t.Optional[str] = None, 17) -> t.Tuple[t.Set[t.Tuple[str, str]], t.Optional[str], str]: 18 """ 19 This function attaches to a DLT pipeline and retrieves the connection configs and 20 SQLMesh models based on the tables present in the pipeline's default schema. 21 22 Args: 23 pipeline_name: The name of the DLT pipeline to attach to. 24 dialect: The SQL dialect to use for generating SQLMesh models. 25 tables: A list of table names to include. 26 dlt_path: The path to the DLT pipelines working directory, where DLT stores 27 pipeline state (by default ~/.dlt/pipelines). 28 29 Returns: 30 A tuple containing a set of the SQLMesh model definitions, the connection config and the start date. 31 """ 32 33 import dlt 34 from dlt.common.schema.utils import has_table_seen_data, is_complete_column 35 from dlt.pipeline.exceptions import CannotRestorePipelineException 36 37 try: 38 pipeline = dlt.attach(pipeline_name=pipeline_name, pipelines_dir=dlt_path or "") 39 except CannotRestorePipelineException as e: 40 from pathlib import Path 41 from dlt.common.pipeline import get_dlt_pipelines_dir 42 43 searched_dir = dlt_path or get_dlt_pipelines_dir() 44 msg = f"Could not attach to pipeline {pipeline_name}.\nSearched in: {searched_dir}\n{e}" 45 if dlt_path and (Path(get_dlt_pipelines_dir()) / pipeline_name).exists(): 46 msg += ( 47 f"\nHint: A pipeline named '{pipeline_name}' exists in the default pipelines " 48 f"working directory '{get_dlt_pipelines_dir()}'. Note that --dlt-path must " 49 "point to the directory where DLT stores pipeline working state (by default " 50 "~/.dlt/pipelines), not the directory containing your pipeline scripts. " 51 "Try omitting --dlt-path." 52 ) 53 raise click.ClickException(msg) 54 55 schema = pipeline.default_schema 56 dataset = pipeline.dataset_name 57 58 # Get the start date from the load_ids 59 storage_ids = list(pipeline._get_load_storage().list_loaded_packages()) 60 start_date = get_start_date(storage_ids) 61 62 # Get the connection credentials 63 db_type = pipeline.destination.to_name(pipeline.destination) 64 if db_type == "filesystem": 65 connection_config = None 66 else: 67 client = pipeline.destination_client() 68 config = client.config 69 credentials = config.credentials 70 configs = { 71 key: value 72 for key in dir(credentials) 73 if not key.startswith("_") 74 and not callable(value := getattr(credentials, key)) 75 and value is not None 76 } 77 connection_config = format_config(configs, db_type) 78 79 dlt_tables = { 80 name: table 81 for name, table in schema.tables.items() 82 if ( 83 (has_table_seen_data(table) and not name.startswith(schema._dlt_tables_prefix)) 84 or name == schema.loads_table_name 85 ) 86 and (name in tables if tables else True) 87 } 88 89 sqlmesh_models = set() 90 for table_name, table in dlt_tables.items(): 91 dlt_columns = {} 92 primary_key = [] 93 94 # is_complete_column returns true if column contains a name and a data type 95 for col in filter(is_complete_column, table["columns"].values()): 96 dlt_columns[col["name"]] = exp.DataType.build(str(col["data_type"]), dialect=dialect) 97 if col.get("primary_key"): 98 primary_key.append(str(col["name"])) 99 100 load_id = next( 101 (col for col in ["_dlt_load_id", "load_id"] if col in dlt_columns), 102 None, 103 ) 104 load_key = "c." + load_id if load_id else "" 105 parent_table = None 106 107 # Handling for nested tables: https://dlthub.com/docs/general-usage/destination-tables#nested-tables 108 if not load_id: 109 if ( 110 "_dlt_parent_id" in dlt_columns 111 and (parent_table := table["parent"]) 112 and parent_table in dlt_tables 113 ): 114 load_key = "p._dlt_load_id" 115 parent_table = dataset + "." + parent_table 116 else: 117 break 118 119 column_types = [ 120 exp.cast(exp.column(column, table="c"), data_type, dialect=dialect) 121 .as_(column) 122 .sql(dialect=dialect) 123 for column, data_type in dlt_columns.items() 124 if isinstance(column, str) 125 ] 126 select_columns = ( 127 ",\n".join(f" {column_name}" for column_name in column_types) if column_types else "" 128 ) 129 130 grain = f"\n grain ({', '.join(primary_key)})," if primary_key else "" 131 incremental_model_name = f"{dataset}_sqlmesh.incremental_{table_name}" 132 incremental_model_sql = generate_incremental_model( 133 incremental_model_name, 134 select_columns, 135 grain, 136 dataset + "." + table_name, 137 dialect, 138 load_key, 139 parent_table, 140 ) 141 sqlmesh_models.add((incremental_model_name, incremental_model_sql)) 142 143 return sqlmesh_models, connection_config, start_date
This function attaches to a DLT pipeline and retrieves the connection configs and SQLMesh models based on the tables present in the pipeline's default schema.
Arguments:
- pipeline_name: The name of the DLT pipeline to attach to.
- dialect: The SQL dialect to use for generating SQLMesh models.
- tables: A list of table names to include.
- dlt_path: The path to the DLT pipelines working directory, where DLT stores pipeline state (by default ~/.dlt/pipelines).
Returns:
A tuple containing a set of the SQLMesh model definitions, the connection config and the start date.
def
generate_dlt_models( context: sqlmesh.core.context.Context, pipeline_name: str, tables: List[str], force: bool, dlt_path: Optional[str] = None) -> List[str]:
146def generate_dlt_models( 147 context: Context, 148 pipeline_name: str, 149 tables: t.List[str], 150 force: bool, 151 dlt_path: t.Optional[str] = None, 152) -> t.List[str]: 153 from sqlmesh.cli.project_init import _create_object_files 154 155 sqlmesh_models, _, _ = generate_dlt_models_and_settings( 156 pipeline_name=pipeline_name, 157 dialect=context.config.dialect or "", 158 tables=tables if tables else None, 159 dlt_path=dlt_path, 160 ) 161 162 if not tables and not force: 163 existing_models = [m.name for m in context.models.values()] 164 sqlmesh_models = {model for model in sqlmesh_models if model[0] not in existing_models} 165 166 if sqlmesh_models: 167 _create_object_files( 168 context.path / "models", 169 {model[0].split(".")[-1]: model[1] for model in sqlmesh_models}, 170 "sql", 171 ) 172 return [model[0] for model in sqlmesh_models] 173 return []
def
generate_incremental_model( model_name: str, select_columns: str, grain: str, from_table: str, dialect: str, load_id: str, parent_table: Optional[str] = None) -> str:
176def generate_incremental_model( 177 model_name: str, 178 select_columns: str, 179 grain: str, 180 from_table: str, 181 dialect: str, 182 load_id: str, 183 parent_table: t.Optional[str] = None, 184) -> str: 185 """Generate the SQL definition for an incremental model.""" 186 187 time_column = parse_one(f"to_timestamp(CAST({load_id} AS DOUBLE))").sql(dialect=dialect) 188 189 from_clause = f"{from_table} as c" 190 if parent_table: 191 from_clause += f"""\nJOIN 192 {parent_table} as p 193ON 194 c._dlt_parent_id = p._dlt_id""" 195 196 return f"""MODEL ( 197 name {model_name}, 198 kind INCREMENTAL_BY_TIME_RANGE ( 199 time_column _dlt_load_time, 200 ),{grain} 201); 202 203SELECT 204{select_columns}, 205 {time_column} as _dlt_load_time 206FROM 207 {from_clause} 208WHERE 209 {time_column} BETWEEN @start_ts AND @end_ts 210"""
Generate the SQL definition for an incremental model.
def
format_config(configs: Dict[str, str], db_type: str) -> str:
213def format_config(configs: t.Dict[str, str], db_type: str) -> str: 214 """Generate a string for the gateway connection config.""" 215 config = { 216 "type": db_type, 217 } 218 219 for key, value in configs.items(): 220 if key == "password": 221 config[key] = f'"{value}"' 222 elif key == "username": 223 config["user"] = value 224 else: 225 config[key] = value 226 227 # Validate the connection config fields 228 invalid_fields = [] 229 try: 230 parse_connection_config(config) 231 except ValidationError as e: 232 for error in e.errors(): 233 invalid_fields.append(error.get("loc", [])[0]) 234 235 return "\n".join( 236 [f" {key}: {value}" for key, value in config.items() if key not in invalid_fields] 237 )
Generate a string for the gateway connection config.
def
get_start_date(load_ids: List[str]) -> str:
240def get_start_date(load_ids: t.List[str]) -> str: 241 """Convert the earliest load_id to UTC timestamp, subtract a day and format as 'YYYY-MM-DD'.""" 242 243 timestamps = [datetime.fromtimestamp(float(id), tz=timezone.utc) for id in load_ids] 244 if timestamps: 245 start_timestamp = min(timestamps) - timedelta(days=1) 246 return start_timestamp.strftime("%Y-%m-%d") 247 return yesterday_ds()
Convert the earliest load_id to UTC timestamp, subtract a day and format as 'YYYY-MM-DD'.