Edit on GitHub

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        if dlt.__version__ >= "1.10.0":
 67            client = pipeline.destination_client()
 68        else:
 69            client = pipeline._sql_job_client(schema)  # type: ignore
 70        config = client.config
 71        credentials = config.credentials
 72        configs = {
 73            key: value
 74            for key in dir(credentials)
 75            if not key.startswith("_")
 76            and not callable(value := getattr(credentials, key))
 77            and value is not None
 78        }
 79        connection_config = format_config(configs, db_type)
 80
 81    dlt_tables = {
 82        name: table
 83        for name, table in schema.tables.items()
 84        if (
 85            (has_table_seen_data(table) and not name.startswith(schema._dlt_tables_prefix))
 86            or name == schema.loads_table_name
 87        )
 88        and (name in tables if tables else True)
 89    }
 90
 91    sqlmesh_models = set()
 92    for table_name, table in dlt_tables.items():
 93        dlt_columns = {}
 94        primary_key = []
 95
 96        # is_complete_column returns true if column contains a name and a data type
 97        for col in filter(is_complete_column, table["columns"].values()):
 98            dlt_columns[col["name"]] = exp.DataType.build(str(col["data_type"]), dialect=dialect)
 99            if col.get("primary_key"):
100                primary_key.append(str(col["name"]))
101
102        load_id = next(
103            (col for col in ["_dlt_load_id", "load_id"] if col in dlt_columns),
104            None,
105        )
106        load_key = "c." + load_id if load_id else ""
107        parent_table = None
108
109        # Handling for nested tables: https://dlthub.com/docs/general-usage/destination-tables#nested-tables
110        if not load_id:
111            if (
112                "_dlt_parent_id" in dlt_columns
113                and (parent_table := table["parent"])
114                and parent_table in dlt_tables
115            ):
116                load_key = "p._dlt_load_id"
117                parent_table = dataset + "." + parent_table
118            else:
119                break
120
121        column_types = [
122            exp.cast(exp.column(column, table="c"), data_type, dialect=dialect)
123            .as_(column)
124            .sql(dialect=dialect)
125            for column, data_type in dlt_columns.items()
126            if isinstance(column, str)
127        ]
128        select_columns = (
129            ",\n".join(f"  {column_name}" for column_name in column_types) if column_types else ""
130        )
131
132        grain = f"\n  grain ({', '.join(primary_key)})," if primary_key else ""
133        incremental_model_name = f"{dataset}_sqlmesh.incremental_{table_name}"
134        incremental_model_sql = generate_incremental_model(
135            incremental_model_name,
136            select_columns,
137            grain,
138            dataset + "." + table_name,
139            dialect,
140            load_key,
141            parent_table,
142        )
143        sqlmesh_models.add((incremental_model_name, incremental_model_sql))
144
145    return sqlmesh_models, connection_config, start_date
146
147
148def generate_dlt_models(
149    context: Context,
150    pipeline_name: str,
151    tables: t.List[str],
152    force: bool,
153    dlt_path: t.Optional[str] = None,
154) -> t.List[str]:
155    from sqlmesh.cli.project_init import _create_object_files
156
157    sqlmesh_models, _, _ = generate_dlt_models_and_settings(
158        pipeline_name=pipeline_name,
159        dialect=context.config.dialect or "",
160        tables=tables if tables else None,
161        dlt_path=dlt_path,
162    )
163
164    if not tables and not force:
165        existing_models = [m.name for m in context.models.values()]
166        sqlmesh_models = {model for model in sqlmesh_models if model[0] not in existing_models}
167
168    if sqlmesh_models:
169        _create_object_files(
170            context.path / "models",
171            {model[0].split(".")[-1]: model[1] for model in sqlmesh_models},
172            "sql",
173        )
174        return [model[0] for model in sqlmesh_models]
175    return []
176
177
178def generate_incremental_model(
179    model_name: str,
180    select_columns: str,
181    grain: str,
182    from_table: str,
183    dialect: str,
184    load_id: str,
185    parent_table: t.Optional[str] = None,
186) -> str:
187    """Generate the SQL definition for an incremental model."""
188
189    time_column = parse_one(f"to_timestamp(CAST({load_id} AS DOUBLE))").sql(dialect=dialect)
190
191    from_clause = f"{from_table} as c"
192    if parent_table:
193        from_clause += f"""\nJOIN
194  {parent_table} as p
195ON
196  c._dlt_parent_id = p._dlt_id"""
197
198    return f"""MODEL (
199  name {model_name},
200  kind INCREMENTAL_BY_TIME_RANGE (
201    time_column _dlt_load_time,
202  ),{grain}
203);
204
205SELECT
206{select_columns},
207  {time_column} as _dlt_load_time
208FROM
209  {from_clause}
210WHERE
211  {time_column} BETWEEN @start_ts AND @end_ts
212"""
213
214
215def format_config(configs: t.Dict[str, str], db_type: str) -> str:
216    """Generate a string for the gateway connection config."""
217    config = {
218        "type": db_type,
219    }
220
221    for key, value in configs.items():
222        if key == "password":
223            config[key] = f'"{value}"'
224        elif key == "username":
225            config["user"] = value
226        else:
227            config[key] = value
228
229    # Validate the connection config fields
230    invalid_fields = []
231    try:
232        parse_connection_config(config)
233    except ValidationError as e:
234        for error in e.errors():
235            invalid_fields.append(error.get("loc", [])[0])
236
237    return "\n".join(
238        [f"      {key}: {value}" for key, value in config.items() if key not in invalid_fields]
239    )
240
241
242def get_start_date(load_ids: t.List[str]) -> str:
243    """Convert the earliest load_id to UTC timestamp, subtract a day and format as 'YYYY-MM-DD'."""
244
245    timestamps = [datetime.fromtimestamp(float(id), tz=timezone.utc) for id in load_ids]
246    if timestamps:
247        start_timestamp = min(timestamps) - timedelta(days=1)
248        return start_timestamp.strftime("%Y-%m-%d")
249    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        if dlt.__version__ >= "1.10.0":
 68            client = pipeline.destination_client()
 69        else:
 70            client = pipeline._sql_job_client(schema)  # type: ignore
 71        config = client.config
 72        credentials = config.credentials
 73        configs = {
 74            key: value
 75            for key in dir(credentials)
 76            if not key.startswith("_")
 77            and not callable(value := getattr(credentials, key))
 78            and value is not None
 79        }
 80        connection_config = format_config(configs, db_type)
 81
 82    dlt_tables = {
 83        name: table
 84        for name, table in schema.tables.items()
 85        if (
 86            (has_table_seen_data(table) and not name.startswith(schema._dlt_tables_prefix))
 87            or name == schema.loads_table_name
 88        )
 89        and (name in tables if tables else True)
 90    }
 91
 92    sqlmesh_models = set()
 93    for table_name, table in dlt_tables.items():
 94        dlt_columns = {}
 95        primary_key = []
 96
 97        # is_complete_column returns true if column contains a name and a data type
 98        for col in filter(is_complete_column, table["columns"].values()):
 99            dlt_columns[col["name"]] = exp.DataType.build(str(col["data_type"]), dialect=dialect)
100            if col.get("primary_key"):
101                primary_key.append(str(col["name"]))
102
103        load_id = next(
104            (col for col in ["_dlt_load_id", "load_id"] if col in dlt_columns),
105            None,
106        )
107        load_key = "c." + load_id if load_id else ""
108        parent_table = None
109
110        # Handling for nested tables: https://dlthub.com/docs/general-usage/destination-tables#nested-tables
111        if not load_id:
112            if (
113                "_dlt_parent_id" in dlt_columns
114                and (parent_table := table["parent"])
115                and parent_table in dlt_tables
116            ):
117                load_key = "p._dlt_load_id"
118                parent_table = dataset + "." + parent_table
119            else:
120                break
121
122        column_types = [
123            exp.cast(exp.column(column, table="c"), data_type, dialect=dialect)
124            .as_(column)
125            .sql(dialect=dialect)
126            for column, data_type in dlt_columns.items()
127            if isinstance(column, str)
128        ]
129        select_columns = (
130            ",\n".join(f"  {column_name}" for column_name in column_types) if column_types else ""
131        )
132
133        grain = f"\n  grain ({', '.join(primary_key)})," if primary_key else ""
134        incremental_model_name = f"{dataset}_sqlmesh.incremental_{table_name}"
135        incremental_model_sql = generate_incremental_model(
136            incremental_model_name,
137            select_columns,
138            grain,
139            dataset + "." + table_name,
140            dialect,
141            load_key,
142            parent_table,
143        )
144        sqlmesh_models.add((incremental_model_name, incremental_model_sql))
145
146    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]:
149def generate_dlt_models(
150    context: Context,
151    pipeline_name: str,
152    tables: t.List[str],
153    force: bool,
154    dlt_path: t.Optional[str] = None,
155) -> t.List[str]:
156    from sqlmesh.cli.project_init import _create_object_files
157
158    sqlmesh_models, _, _ = generate_dlt_models_and_settings(
159        pipeline_name=pipeline_name,
160        dialect=context.config.dialect or "",
161        tables=tables if tables else None,
162        dlt_path=dlt_path,
163    )
164
165    if not tables and not force:
166        existing_models = [m.name for m in context.models.values()]
167        sqlmesh_models = {model for model in sqlmesh_models if model[0] not in existing_models}
168
169    if sqlmesh_models:
170        _create_object_files(
171            context.path / "models",
172            {model[0].split(".")[-1]: model[1] for model in sqlmesh_models},
173            "sql",
174        )
175        return [model[0] for model in sqlmesh_models]
176    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:
179def generate_incremental_model(
180    model_name: str,
181    select_columns: str,
182    grain: str,
183    from_table: str,
184    dialect: str,
185    load_id: str,
186    parent_table: t.Optional[str] = None,
187) -> str:
188    """Generate the SQL definition for an incremental model."""
189
190    time_column = parse_one(f"to_timestamp(CAST({load_id} AS DOUBLE))").sql(dialect=dialect)
191
192    from_clause = f"{from_table} as c"
193    if parent_table:
194        from_clause += f"""\nJOIN
195  {parent_table} as p
196ON
197  c._dlt_parent_id = p._dlt_id"""
198
199    return f"""MODEL (
200  name {model_name},
201  kind INCREMENTAL_BY_TIME_RANGE (
202    time_column _dlt_load_time,
203  ),{grain}
204);
205
206SELECT
207{select_columns},
208  {time_column} as _dlt_load_time
209FROM
210  {from_clause}
211WHERE
212  {time_column} BETWEEN @start_ts AND @end_ts
213"""

Generate the SQL definition for an incremental model.

def format_config(configs: Dict[str, str], db_type: str) -> str:
216def format_config(configs: t.Dict[str, str], db_type: str) -> str:
217    """Generate a string for the gateway connection config."""
218    config = {
219        "type": db_type,
220    }
221
222    for key, value in configs.items():
223        if key == "password":
224            config[key] = f'"{value}"'
225        elif key == "username":
226            config["user"] = value
227        else:
228            config[key] = value
229
230    # Validate the connection config fields
231    invalid_fields = []
232    try:
233        parse_connection_config(config)
234    except ValidationError as e:
235        for error in e.errors():
236            invalid_fields.append(error.get("loc", [])[0])
237
238    return "\n".join(
239        [f"      {key}: {value}" for key, value in config.items() if key not in invalid_fields]
240    )

Generate a string for the gateway connection config.

def get_start_date(load_ids: List[str]) -> str:
243def get_start_date(load_ids: t.List[str]) -> str:
244    """Convert the earliest load_id to UTC timestamp, subtract a day and format as 'YYYY-MM-DD'."""
245
246    timestamps = [datetime.fromtimestamp(float(id), tz=timezone.utc) for id in load_ids]
247    if timestamps:
248        start_timestamp = min(timestamps) - timedelta(days=1)
249        return start_timestamp.strftime("%Y-%m-%d")
250    return yesterday_ds()

Convert the earliest load_id to UTC timestamp, subtract a day and format as 'YYYY-MM-DD'.