diff --git a/.gitignore b/.gitignore index f67b990..cb9943b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ scratch/** !scratch/README.md .DS_Store **/.DS_Store -.claude/settings.local.json +.claude/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 6945bca..2d5e48b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,4 +25,22 @@ ], "cursorpyright.analysis.stubPath": ".vscode", "python.languageServer": "None", + "json.schemas": [ + { + "fileMatch": ["*_main.json"], + "url": "./src/schemas/main.json" + }, + { + "fileMatch": ["*_flow.json"], + "url": "./src/schemas/flow_group.json" + }, + { + "fileMatch": ["*_dqe.json"], + "url": "./src/schemas/expectations.json" + }, + { + "fileMatch": ["*_secrets.json"], + "url": "./src/schemas/secrets.json" + } + ] } diff --git a/docs/decisions/0008-unified-nodespec-dataflow-spec.md b/docs/decisions/0008-unified-nodespec-dataflow-spec.md new file mode 100644 index 0000000..835155f --- /dev/null +++ b/docs/decisions/0008-unified-nodespec-dataflow-spec.md @@ -0,0 +1,365 @@ +# ADR-0008: A unified, node-based dataflow spec (`nodespec`) + +**Date:** 2026-06-19 +**Status:** Accepted (amended) + +--- + +## Amendment — post-acceptance refinements + +The decision below stands. Since acceptance, the authoring syntax has been +refined; the examples in this ADR use the **original** field names. The current +names (authoritative reference: ``docs/source/dataflow_spec_ref_main_nodespec.rst``) +differ as follows: + +- **`input` → `input_flows`** — the target-node wiring list was renamed for clarity. +- **Data quality → nested `data_quality`** object (`{ "expectations_path": ... }`), + replacing the `data_quality_expectations_enabled` / `_path` pair; presence implies + enabled. +- **Quarantine → nested `quarantine`** object (`{ "mode", "target" }`), replacing + `quarantine_mode` / `quarantine_target_details`. +- **`data_flow_type` is optional** — nodespec is the framework default, so a spec + that omits it is treated as nodespec. +- **`scd_type` is a string** in `cdc_settings` (e.g. `"2"`). +- **Sink targets keep flat fields** (`sink_type` / `sink_config` / `sink_options`); + a nested `sink` object was considered but deferred. +- **A node's `config` is schema-discriminated** by `node_type` + + `source_type`/`target_type`, so editors offer only the fields valid for that node. + +--- + +## Context + +The framework historically shipped several distinct dataflow spec formats, and +each one has its own shape and its own rules: + +- **Standard**: a single source flattened onto a single target, with CDC, data + quality, quarantine, and table settings spread across the top level of the spec. +- **Flow**: a hand-authored flow graph with views, staging tables, and flow + groups, where the author must already understand the framework's internal + flow-spec representation. +- **Materialized view**: a separate `materializedViews` map with its own nested + `sourceView` and `tableDetails` structure. + +This creates real friction: + +1. **Three formats to learn instead of one.** A new user has to first work out + which spec type applies to their case, and then learn a different field layout + for each. Knowledge gained writing a standard spec does not transfer to a flow + or materialized view spec. + +2. **The structure leaks framework internals.** The flow format in particular + asks authors to think in terms of flow groups, view registration, and staging + tables. Those are concepts that exist to serve the engine, not the person + describing a pipeline. To write a correct spec you must understand how the + framework assembles a pipeline, not just what you want the pipeline to do. + +3. **Deep nesting is hard to write and hard to read.** Settings live at different + depths depending on the format and the feature. Answering a simple question, + such as "what feeds this table, and what happens to the data on the way in?", + can mean tracing keys across several levels of nested objects. The nesting + obscures the one thing a reader actually cares about, which is the shape of the + pipeline. + +4. **Topology is implicit.** In the older formats the connections between steps + are inferred (from SQL, from key names, from position) rather than stated. The + graph a pipeline actually forms is not something you can see by reading the + spec. + +5. **Streaming tables and materialized views must live in separate specs.** + Because each output type has its own format, a pipeline that mixes streaming + tables and materialized views has to be split across multiple specs. When those + tables feed each other, for example a streaming table chained into a + materialized view, the split is artificial and inconvenient: one logical + pipeline ends up fragmented across files for no reason other than the spec + format. + +The cost of this is paid most by the two groups this framework exists to help. +The first is newcomers writing their first pipeline, who should not have to learn +the engine's internals to get started. The second is large organizations that +want to standardize and to easily read, understand, and edit each other's +pipelines across many people. Making both of those easier is the point of the +framework, and the current formats work against it. + +## Decision + +Introduce a single, unified dataflow spec, called **`nodespec`**, and make it the +way pipelines are described going forward. + +A `nodespec` spec is a **graph of nodes**. There are exactly three node types, and +they compose by **chaining**: + +``` +source -> transformation -> target +(where data (how data is (where data + comes from) transformed) lands) +``` + +- A **source** node says where data comes from (a table, files, a stream). +- A **transformation** node says how data is reshaped (SQL or Python). +- A **target** node says where data lands, and carries its own table-level + settings (CDC, data quality, quarantine, clustering, and so on). + +Nodes are wired together in two complementary ways. A **target** node declares +what feeds it through an explicit `input` list. **Source** and **transformation** +nodes are connected by explicit reference inside their own definition: a +transformation names its upstream view directly in its SQL or Python (for example +`FROM STREAM(live.v_source_customer)`), and a source names the table, path, or +stream it reads. The `input` list is therefore a target-node construct, while the +intermediate source-to-transformation wiring lives where the logic that uses it +lives. That uniform model replaces all three legacy formats. Every pipeline, +whether a one-hop ingest, a CDC merge, a multi-step transform, or a materialized +view, is expressed the same way: declare nodes, then chain them together. + +Because output type is just a property of a target node, a single `nodespec` spec +can declare both streaming-table and materialized-view targets at once, including +chains where one feeds the other. A logical pipeline that mixes the two no longer +has to be split across separate specs by output type. + +### The `input` list + +`input` belongs to target nodes and lists what feeds the target. Each item is +either a plain string (the upstream node name, with the flow name auto-generated) +or an object that defines the flow name: + +```json +"input": [ + "v_source_customer", + { "view": "v_source_address", "flow": "f_append_address" } +] +``` + +The two forms can be mixed in the same list. By default the framework derives the +flow name from the graph, so simple specs stay simple; the object form defines it +when stability matters. This matters because renaming a flow forces a full refresh +in SDP, so defining the flow name lets authors keep it stable across edits and +migrations without triggering one. Source and transformation nodes do not carry an +`input` list; they reference their upstream within their own definition as +described above. + +### Why a node graph + +The node-and-edge model is the mental model most people already have for data +pipelines. It is how ETL is drawn on a whiteboard, how lineage is shown, and how +virtually every visual pipeline tool represents work. By matching that model +directly, the spec stops being something you have to learn the internals of and +becomes something you can read at a glance. Newcomers can be productive without +first absorbing the framework's flow-spec representation, and teams can read each +other's pipelines without reverse-engineering the structure first. + +### Before and after + +Consider a real pattern: two streaming sources are appended into one staging +table, and that staging table is then merged into a silver table with SCD2 +history. In the flow format the author hand-builds the flow groups, registers +each view, declares the staging table, and wires the flows. The pipeline's shape +is buried several levels deep: + +```json +{ + "dataFlowId": "multi_source_streaming_decomp_staging", + "dataFlowGroup": "multi_source_streaming_decomposed_staging", + "dataFlowType": "flow", + "targetFormat": "delta", + "targetDetails": { + "database": "{silver_schema}", + "table": "customer_ms_decomp_merge", + "schemaPath": "customer.json", + "tableProperties": { "delta.enableChangeDataFeed": "true" } + }, + "cdcSettings": { + "keys": ["CUSTOMER_ID"], + "scd_type": "2", + "sequence_by": "LOAD_TIMESTAMP", + "except_column_list": ["LOAD_TIMESTAMP"], + "ignore_null_updates": true + }, + "flowGroups": [ + { + "flowGroupId": "multi_source_streaming_decomp_staging_1", + "stagingTables": { "customer_ms_decomp_appnd": { "type": "ST" } }, + "flows": { + "f_customer": { + "flowType": "append_view", + "flowDetails": { + "targetTable": "customer_ms_decomp_appnd", + "sourceView": "v_customer" + }, + "views": { + "v_customer": { + "mode": "stream", + "sourceType": "delta", + "sourceDetails": { + "database": "{bronze_schema}", + "table": "base_customer_append", + "cdfEnabled": true + } + } + } + }, + "f_customer_address": { + "flowType": "append_view", + "flowDetails": { + "targetTable": "customer_ms_decomp_appnd", + "sourceView": "v_customer_address" + }, + "views": { + "v_customer_address": { + "mode": "stream", + "sourceType": "delta", + "sourceDetails": { + "database": "{bronze_schema}", + "table": "base_customer_address_append", + "cdfEnabled": true, + "whereClause": ["STATE is not NULL"] + } + } + } + }, + "f_merge": { + "flowType": "merge", + "flowDetails": { + "targetTable": "{silver_schema}.customer_ms_decomp_merge", + "sourceView": "customer_ms_decomp_appnd" + } + } + } + } + ] +} +``` + +The same pipeline as a `nodespec` graph is a flat list of nodes that reads +top-to-bottom in the order the data flows. Each concern sits on the node it +belongs to, and the connections are stated rather than inferred: + +```json +{ + "data_flow_id": "multi_source_streaming_decomp_staging", + "data_flow_group": "multi_source_streaming_decomposed_staging", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_customer", + "node_type": "source", + "source_type": "delta", + "config": { "database": "{bronze_schema}", "table": "base_customer_append", "cdf_enabled": true, "mode": "stream" } + }, + { + "name": "v_customer_address", + "node_type": "source", + "source_type": "delta", + "config": { "database": "{bronze_schema}", "table": "base_customer_address_append", "cdf_enabled": true, "mode": "stream", "where_clause": ["STATE is not NULL"] } + }, + { + "name": "staging_customer_append", + "node_type": "target", + "config": { + "table": "customer_ms_decomp_appnd", + "input": ["v_customer", "v_customer_address"] + } + }, + { + "name": "v_staging_cdf", + "node_type": "source", + "source_type": "delta", + "config": { "table": "customer_ms_decomp_appnd", "cdf_enabled": true, "mode": "stream" } + }, + { + "name": "target_customer_merge", + "node_type": "target", + "config": { + "database": "{silver_schema}", + "table": "customer_ms_decomp_merge", + "schema_path": "customer.json", + "table_properties": { "delta.enableChangeDataFeed": "true" }, + "cdc_settings": { + "keys": ["CUSTOMER_ID"], + "sequence_by": "LOAD_TIMESTAMP", + "scd_type": "2", + "except_column_list": ["LOAD_TIMESTAMP"], + "ignore_null_updates": true + }, + "input": ["v_staging_cdf"] + } + } + ] +} +``` + +There are no flow groups to assemble, no views to register by hand, and no +separate staging-table block. Fan-in is just two names in one `input` list, CDC +lives on the target it applies to, and adding another step later is another node +in the chain rather than another layer of nesting. + +### Convergence behaviour + +`nodespec` does not change the engine. The transformer lowers a node graph into +the framework's existing flow-spec representation, so all current capabilities +(CDC, snapshots, data quality, quarantine, sinks, table migration, materialized +views) are available. The legacy formats continue to transform into the same +representation, so the two can coexist during adoption. + +### Aligning toward the chaining model + +To steer everyone toward the chaining model, two behaviours change: + +- **Inline SQL/Python sources are discouraged (warning, not breaking).** Folding + transformation logic into a source definition, meaning a source whose type is + `sql` or `python`, or an `append_sql` flow, conflates "where data comes from" + with "how it is transformed". This still works, but now emits a warning, in + both the legacy and `nodespec` formats. The recommended replacement is a plain + source chained into a dedicated transformation node. + +- **Inline source views on materialized view targets are removed (breaking).** A + materialized view target may no longer carry an inline `source_view`. Authors + declare a source node and chain it into the materialized view target via + `input`, exactly like every other node. This keeps the graph uniform, with one + way to feed a target rather than a special case for materialized views. Because + this pattern was not in use, it is removed outright rather than deprecated. + +## Consequences + +- **One format to learn.** Knowledge transfers across every pipeline shape. + Onboarding becomes "learn the three node types and how to chain them" instead of + "learn three different specs and how to choose between them". + +- **Specs are readable.** A reader follows the chain top-to-bottom and sees the + pipeline's shape directly. Per-target settings live on the target they + describe, which makes specs easier for teams to review and edit. + +- **Topology is explicit.** Targets state what feeds them via `input`, and + transformations name their upstream views directly in their logic, which makes + specs inspectable and a natural fit for lineage and visual tooling. + +- **Streaming tables and materialized views in one spec.** A single spec can + declare both streaming-table and materialized-view targets, including chains + where one feeds the other, so a logical pipeline no longer has to be split + across specs by output type. + +- **No loss of capability.** The node graph lowers into the existing flow-spec + representation, so every current feature remains available. + +- **Smooth adoption.** Legacy formats keep working. A migration script converts + existing specs into `nodespec`. The inline-source-transformation warning gives + consumers time to align before any future removal. + +- **One breaking change.** Materialized view targets no longer accept an inline + `source_view`, so authors chain a source node instead. + +## Key design decisions + +The decisions below define how the `nodespec` model behaves. They are the design +choices made while building out the format and translating the full feature set +onto it. + +| Decision | Rationale | +|----------|-----------| +| **No primary or secondary target.** The spec target the backend needs (`targetDetails`) is auto-selected from the graph as the terminal target; authors never mark one. | "Primary target" was a backend implementation detail leaking into the spec. The graph already determines which target is terminal. | +| **All target settings live on the target node.** CDC, data quality, quarantine, table migration, and sink config are declared on the target they apply to, not at the spec level. | Each setting belongs with the thing it configures, which removes the spec-level scatter of the older formats. | +| **Materialized views are target nodes** (`table_type: "mv"`), and a single spec may contain both streaming-table and materialized-view targets, including chains between them. | Keeps the node model uniform and lets one logical pipeline stay in one spec instead of being split by output type. | +| **Inline SQL/Python sources and `append_sql` are discouraged.** They still work and are warned about; the recommended alternative is a transformation node, which defines the logic as a view rather than folding it into a source. | Keeps "where data comes from" separate from "how it is transformed". | +| **Materialized view inline `source_view` is removed (breaking).** Chain a source node into the MV target via `input`. | One uniform way to feed any target, with no special case for materialized views. | +| **Historical snapshot targets need no source node.** Targets with historical snapshot CDC config describe their source within that config; periodic snapshot targets still require a source. | The historical snapshot system reads files or tables directly, so a source view would have nothing to read. | +| **`input` is a target-node construct.** Each item is a string (auto flow name) or `{ "view", "flow" }` (flow name defined). | Source-to-transformation wiring lives in the transformation's own definition; `input` only describes what feeds a target. Defining the flow name avoids a full refresh in SDP on rename. | +| **Internal sources are auto-detected by table name.** A source that reads a table produced by a target in the same spec is wired internally with no extra view. | Enables staging-to-downstream chains without manual `live.` wiring. | diff --git a/docs/source/dataflow_spec_ref_main_nodespec.rst b/docs/source/dataflow_spec_ref_main_nodespec.rst new file mode 100644 index 0000000..f153349 --- /dev/null +++ b/docs/source/dataflow_spec_ref_main_nodespec.rst @@ -0,0 +1,558 @@ +Creating a Nodespec Data Flow Spec Reference +############################################ + +The **Nodespec** Data Flow Spec describes a pipeline as a graph of nodes that +chain together: + +.. code-block:: text + + source -> transformation -> target + +Instead of the target-table-driven layout of the standard and flow specs, a +Nodespec spec is a flat list of nodes that reads top-to-bottom in the order the +data flows. The framework converts a Nodespec spec into its internal flow-based +format at build time, so every existing capability (CDC, data quality, +quarantine, snapshots, table migration, sinks, materialized views) is available. + +Key concepts +============ + +There are three node types: + +- **source** — where data comes from (Delta table, cloud files, Kafka, a SQL + query, or a Python function). +- **transformation** — how data is reshaped (SQL or Python). +- **target** — where data lands. A target carries its own table-level settings + (CDC, data quality, quarantine, clustering, and so on). + +**How nodes connect.** A *target* node declares what feeds it through an explicit +``input_flows`` list. *Source* and *transformation* nodes are wired by explicit +reference inside their own definition: a SQL transformation names the view it +reads in its SQL (for example ``FROM STREAM(live.v_source_customer)``), and a +source names the table, path, or stream it reads. The ``input_flows`` list is +therefore a target-node construct. + +**Casing.** Nodespec specs use ``snake_case`` field names. + +**Field order.** Within a node's ``config``, fields are written in a consistent +order — identity (``table``/``database``), then table/structural details, then +feature blocks (``cdc_settings``, ``data_quality``, ``quarantine``, +``table_migration_details``), and finally ``input_flows``. The order is +conventional only; it does not affect behaviour. + +Example: simple data flow +========================= + +The simplest spec connects a source to a target: + +.. code-block:: json + + { + "data_flow_id": "nodespec_customer_simple", + "data_flow_group": "nodespec_base_samples", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_source_customer", + "node_type": "source", + "source_type": "delta", + "config": { + "database": "{bronze_schema}", + "table": "customer", + "cdf_enabled": true, + "mode": "stream" + } + }, + { + "name": "target_customer", + "node_type": "target", + "config": { + "table": "customer_silver", + "cluster_by_auto": true, + "input_flows": [ + { "view": "v_source_customer", "flow": "f_customer_ingest" } + ] + } + } + ] + } + +``data_flow_type`` is optional — nodespec is the framework's default spec type, so +a spec that omits it is treated as nodespec. Include ``"data_flow_type": +"nodespec"`` only if you want to be explicit. + +Example: multi-step transformation and CDC +========================================== + +Chaining a transformation into a CDC target. The SQL transformation names the +view it reads; the target lists the transformation in its ``input_flows``: + +.. code-block:: json + + { + "data_flow_id": "nodespec_customer_enriched", + "data_flow_group": "nodespec_base_samples", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_source_customer", + "node_type": "source", + "source_type": "delta", + "config": { "database": "{bronze_schema}", "table": "customer", "cdf_enabled": true, "mode": "stream" } + }, + { + "name": "v_enrich", + "node_type": "transformation", + "transformation_type": "sql", + "config": { + "sql_statement": "SELECT CUSTOMER_ID, EMAIL, CITY, LOAD_TIMESTAMP FROM STREAM(live.v_source_customer)" + } + }, + { + "name": "target_customer_enriched", + "node_type": "target", + "config": { + "table": "customer_enriched", + "schema_path": "customer_enriched_schema.json", + "cdc_settings": { + "keys": ["CUSTOMER_ID"], + "sequence_by": "LOAD_TIMESTAMP", + "scd_type": "2", + "ignore_null_updates": true + }, + "input_flows": ["v_enrich"] + } + } + ] + } + +Editor autocomplete and validation +================================== + +Nodespec specs have a JSON Schema (``src/schemas/spec_nodespec.json``), so editors +can offer key/value autocomplete and inline validation while you author them. The +schema is wired through ``main.json``, which routes any ``*_main.json`` file to +the correct spec schema based on its ``data_flow_type``. Add the ``json.schemas`` +mapping described in :doc:`feature_auto_complete` — the same ``*_main.json`` +mapping enables IntelliSense for nodespec (no nodespec-specific configuration is +required). + +.. _dataflow-spec-nodespec-metadata-configuration: + +Dataflow metadata configuration +=============================== + +.. list-table:: + :header-rows: 1 + :widths: auto + + * - **Field** + - **Type** + - **Description** + * - **data_flow_id** + - ``string`` + - A unique identifier for the data flow. + * - **data_flow_group** + - ``string`` + - The group to which the data flow belongs. + * - **data_flow_type** (*optional*) + - ``string`` + - ``"nodespec"``. Optional — nodespec is the default spec type, so a spec + that omits ``data_flow_type`` is treated as nodespec. When present it must + be ``"nodespec"``. + * - **data_flow_version** (*optional*) + - ``string`` + - Version of the dataflow spec for migration purposes. + * - **tags** (*optional*) + - ``object`` + - Custom tags for the dataflow. + * - **features** (*optional*) + - ``object`` + - Feature flags (e.g., ``operationalMetadataEnabled``). + +.. _dataflow-spec-nodespec-nodes: + +Node configuration +================== + +Each entry of the ``nodes`` array has: + +.. list-table:: + :header-rows: 1 + :widths: auto + + * - **Field** + - **Type** + - **Description** + * - **name** + - ``string`` + - Unique name for the node within the spec. Source and transformation node + names double as their view names (prefixed with ``v_`` if not already). + * - **node_type** + - ``string`` + - One of ``source``, ``transformation``, ``target``. + * - **source_type** + - ``string`` + - Required for source nodes (see below). + * - **transformation_type** + - ``string`` + - Required for transformation nodes (see below). + * - **target_type** (*optional*) + - ``string`` + - For target nodes: ``delta`` (default), ``delta_sink``, + ``foreach_batch_sink``, or ``custom_python_sink``. + * - **output_view_name** (*optional*) + - ``string`` + - Override the generated view name for a source/transformation node. + * - **enabled** (*optional*) + - ``boolean`` + - Whether the node is enabled. Default: ``true``. + * - **config** + - ``object`` + - Node-specific configuration (see below). + +.. note:: + There is no primary/secondary target flag. The framework auto-selects the + spec target (the backend's ``targetDetails``) as the terminal target — the + one not consumed by any other node. Any other targets become staging tables. + +.. _dataflow-spec-nodespec-source-nodes: + +Source node configuration +========================= + +.. list-table:: + :header-rows: 1 + :widths: auto + + * - **Field** + - **Type** + - **Description** + * - **source_type** + - ``string`` + - ``delta``, ``cloud_files``, ``batch_files``, ``delta_join``, ``kafka``, ``sql``, ``python``. + * - **mode** (*optional*) + - ``string`` + - ``stream`` or ``batch``. Default: ``stream``. + * - **database** / **table** + - ``string`` + - For ``delta`` sources. + * - **path** + - ``string`` + - For ``cloud_files`` / ``batch_files`` sources. + * - **cdf_enabled** (*optional*) + - ``boolean`` + - Enable Change Data Feed for Delta sources. + * - **reader_options** (*optional*) + - ``object`` + - Reader options (cloud files, Kafka). + * - **schema_path** (*optional*) + - ``string`` + - Path to a schema definition file. + * - **select_exp** / **where_clause** (*optional*) + - ``array`` + - Select expressions / WHERE clauses applied on read. + * - **python_transform** (*optional*) + - ``object`` + - Apply a Python ``apply_transform(df)`` to the source on read. Supported + for backward compatibility; prefer a dedicated transformation node. + +.. note:: + ``source_type: "sql"`` and ``source_type: "python"`` embed transformation + logic directly in a source definition. They remain supported but are + **discouraged** and emit a warning at build time. Model the logic as a + dedicated transformation node instead. + +.. _dataflow-spec-nodespec-transformation-nodes: + +Transformation node configuration +================================= + +.. list-table:: + :header-rows: 1 + :widths: auto + + * - **Field** + - **Type** + - **Description** + * - **transformation_type** + - ``string`` + - ``sql`` or ``python``. + * - **sql_path** / **sql_statement** + - ``string`` + - For ``sql`` transforms. The SQL names the view(s) it reads, e.g. + ``FROM STREAM(live.v_source_customer)``. + * - **function_path** / **python_module** + - ``string`` + - For ``python`` transforms. The referenced file/module must contain an + ``apply_transform(df)`` (or ``apply_transform(df, tokens)``) function. + * - **tokens** (*optional*) + - ``object`` + - Tokens passed to a Python transform. + +.. note:: + A ``python`` transformation node becomes its own view that reads its upstream + view and applies ``apply_transform``. The upstream is inferred from the graph, + so no explicit reference is required. + +.. _dataflow-spec-nodespec-target-nodes: + +Target node configuration +========================= + +.. list-table:: + :header-rows: 1 + :widths: auto + + * - **Field** + - **Type** + - **Description** + * - **table** + - ``string`` + - Target table name. + * - **database** (*optional*) + - ``string`` + - Target database/schema. + * - **table_type** (*optional*) + - ``string`` + - ``st`` (streaming table, default) or ``mv`` (materialized view). + * - **schema_path** (*optional*) + - ``string`` + - Schema definition file (``.json`` or ``.ddl``). + * - **table_properties** (*optional*) + - ``object`` + - Delta table properties. + * - **partition_columns** / **cluster_by_columns** / **cluster_by_auto** (*optional*) + - ``array`` / ``array`` / ``boolean`` + - Partitioning and liquid clustering. ``partition_columns`` and + ``cluster_by_columns`` are mutually exclusive. + * - **comment** / **spark_conf** / **row_filter** / **config_flags** (*optional*) + - ``string`` / ``object`` / ``string`` / ``array`` + - Additional table settings. + * - **once** (*optional*) + - ``boolean`` + - Execute the flow to this target only once (batch). + * - **cdc_settings** / **cdc_snapshot_settings** (*optional*) + - ``object`` + - CDC / snapshot-CDC settings for this target. + * - **data_quality** (*optional*) + - ``object`` + - Data quality expectations: ``{ "expectations_path": }``. Presence + enables expectations (there is no separate enabled flag). + * - **quarantine** (*optional*) + - ``object`` + - Quarantine for rows failing expectations: ``{ "mode": "flag" | "table", + "target": { ... } }``. Omit for no quarantine; ``target`` is the quarantine + table config, required when ``mode`` is ``table``. + * - **table_migration_details** (*optional*) + - ``object`` + - Table migration configuration. + * - **input_flows** + - ``array`` + - What feeds this target. Each item is either an upstream node name + (``string``, flow name auto-generated) or an object + ``{ "view": , "flow": }`` that sets the flow name. + Conventionally written last in the config. + +.. note:: + Which fields are valid depends on ``table_type``. Streaming-table settings + (``cdc_settings``, ``cdc_snapshot_settings``, ``table_migration_details``, + ``once``) are only allowed on streaming tables (``table_type`` ``st`` or + omitted); materialized-view settings (``sql_path``, ``sql_statement``, + ``refresh_policy``, ``private``) are only allowed on materialized views + (``table_type: "mv"``). The schema enforces this, so an editor flags, e.g., + ``sql_path`` on a streaming table or ``cdc_settings`` on a materialized view. + +Sink targets +----------- + +When ``target_type`` is a sink (``delta_sink``, ``kafka_sink``, +``foreach_batch_sink``, ``custom_python_sink``) the target config uses the sink +fields instead of the delta-table fields: ``name``, ``sink_type`` (sink sub-type, +e.g. ``basic_sql`` / ``python_function`` for ``foreach_batch_sink``), +``sink_config`` (sink-specific configuration), and ``sink_options`` (e.g. +``table_name``/``path`` for a delta sink, or Kafka connection options), plus +``input_flows``. + +Defining flow names +------------------- + +By default the framework derives a flow name from the graph, so simple specs stay +simple. Use the object form of ``input_flows`` to set a flow name explicitly: + +.. code-block:: json + + "input_flows": [ + "v_source_a", + { "view": "v_source_b", "flow": "f_append_b" } + ] + +Defining the flow name keeps it stable across edits. This matters because +renaming a flow forces a full refresh in SDP, so a defined name avoids triggering +one. The string and object forms can be mixed in the same list. + +Snapshot CDC targets +------------------- + +A target's ``cdc_snapshot_settings`` configures snapshot-based CDC. There are two +modes, set via ``snapshot_type``: + +- **historical** — the snapshot source is built **inside** the settings via + ``source_type`` (``file`` or ``table``) and a ``source`` object. No source node + is required; the framework reads the files/table directly. +- **periodic** — the target reads from an upstream **source node** chained via + ``input_flows``; ``source_type`` / ``source`` are not used. + +For historical snapshots the ``source`` object fields depend on ``source_type``: + +.. list-table:: + :header-rows: 1 + :widths: auto + + * - **Field** + - **source_type** + - **Description** + * - **path** + - ``file`` + - Snapshot file path/pattern; may contain a ``{version}`` token. + * - **format** + - ``file`` + - File format, e.g. ``csv``, ``parquet``, ``json``. + * - **reader_options** + - ``file`` + - Options passed to the file reader, e.g. ``{"header": "true"}``. + * - **datetime_format** + - ``file`` + - Format used to parse the ``{version}`` token for timestamp versioning. + * - **schema_path** + - ``file`` + - Schema file applied when reading the snapshot files. + * - **recursiveFileLookup** + - ``file`` + - Recurse into subdirectories when discovering snapshot files. + * - **table** + - ``table`` + - Source table, e.g. ``{schema}.snapshot_source``. + * - **version_column** + - ``table`` + - Column carrying the snapshot version. + * - **version_type** + - both + - How versions are interpreted, e.g. ``timestamp`` or ``integer``. + * - **select_exp** + - both + - Select expressions applied to the snapshot source. + +Example — historical file snapshot: + +.. code-block:: json + + { + "name": "target_customer_snapshot", + "node_type": "target", + "config": { + "table": "customer_snapshot", + "cdc_snapshot_settings": { + "keys": ["CUSTOMER_ID"], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "file", + "source": { + "format": "csv", + "path": "{sample_file_location}/snapshot_customer/customer_{version}.csv", + "reader_options": { "header": "true" }, + "version_type": "timestamp", + "datetime_format": "%Y_%m_%d" + } + } + } + } + +.. _dataflow-spec-nodespec-mv: + +Materialized view targets +========================= + +A materialized view is a target node with ``table_type: "mv"``. It can be defined +by inline SQL or by chaining a source node into it: + +.. code-block:: json + + { + "name": "target_customer_summary", + "node_type": "target", + "config": { + "table": "customer_summary", + "table_type": "mv", + "sql_statement": "SELECT STATE, count(*) AS n FROM live.customer_silver GROUP BY STATE" + } + } + +To feed a materialized view from a source node, chain it via ``input_flows`` (an +inline ``source_view`` on the target is **not** supported): + +.. code-block:: json + + { + "name": "v_mv_source", + "node_type": "source", + "source_type": "delta", + "config": { "database": "{staging_schema}", "table": "customer", "mode": "batch" } + }, + { + "name": "target_customer_mv", + "node_type": "target", + "config": { + "table": "customer_mv", + "table_type": "mv", + "input_flows": ["v_mv_source"] + } + } + +A single spec may contain both streaming-table and materialized-view targets, +including chains where one feeds the other, so a pipeline that mixes the two does +not have to be split across separate specs. + +.. _dataflow-spec-nodespec-conversion: + +How Nodespec specs are converted +================================ + +1. The terminal target (not consumed by any other node) becomes ``targetDetails``; + its CDC, data quality, and quarantine settings move to the spec level. +2. Other targets become ``stagingTables``, each with their own settings. +3. Source nodes become views; an internal source (one that reads a table produced + by a target in the same spec) references that table directly. +4. Transformation nodes become SQL or Python views. +5. Each ``input_flows`` entry becomes a flow into its target. The flow type is + ``merge`` when the target has CDC, otherwise ``append_view`` (or ``append_sql`` + for an inline SQL source). +6. Each ``table_type: "mv"`` target becomes its own materialized-view flow spec. + +Comparison with other spec types +================================ + +.. list-table:: + :header-rows: 1 + :widths: auto + + * - **Aspect** + - **Standard** + - **Flow** + - **Nodespec** + * - **Paradigm** + - Single source → target + - Target-table-driven with flow groups + - Node graph + * - **Multiple sources** + - No + - Yes (via views) + - Yes (via source nodes) + * - **Intermediate tables** + - No + - Yes (staging tables) + - Yes (non-terminal targets) + * - **Streaming tables + materialized views in one spec** + - No + - No + - Yes diff --git a/docs/source/dataflow_spec_reference.rst b/docs/source/dataflow_spec_reference.rst index 2b38678..a69b2f7 100644 --- a/docs/source/dataflow_spec_reference.rst +++ b/docs/source/dataflow_spec_reference.rst @@ -16,4 +16,5 @@ A Data Flow Spec is a JSON file that defines the structure of a single data flow dataflow_spec_ref_main_standard dataflow_spec_ref_main_flows - dataflow_spec_ref_main_materialized_views \ No newline at end of file + dataflow_spec_ref_main_materialized_views + dataflow_spec_ref_main_nodespec \ No newline at end of file diff --git a/docs/source/feature_auto_complete.rst b/docs/source/feature_auto_complete.rst index a116155..1b91db5 100644 --- a/docs/source/feature_auto_complete.rst +++ b/docs/source/feature_auto_complete.rst @@ -19,6 +19,13 @@ The framework uses JSON Schema-based IntelliSense when creating or editing Data - Validation (checking for errors based on the schema) - Quick Fixes (where applicable) +The ``*_main.json`` mapping below covers **all** Data Flow Spec types — standard, +flow, materialized view, and :doc:`nodespec `. +``main.json`` inspects the ``data_flow_type`` / ``dataFlowType`` field and routes +to the matching schema automatically, so a single mapping enables IntelliSense +for every spec type. Nodespec specs are authored in ``snake_case`` and the schema +validates them as written. + Autocompletion Example ~~~~~~~~~~~~~~~~~~~~~~ *Suggesting keys* diff --git a/samples/deploy.sh b/samples/deploy.sh index e51eec7..ac169e5 100755 --- a/samples/deploy.sh +++ b/samples/deploy.sh @@ -40,6 +40,9 @@ echo "" # Deploy Pattern Samples (end-to-end medallion patterns: bronze → silver → gold) ./deploy_pattern_samples.sh -u "$user" -h "$host" -p "$profile" -c "$compute" -l "$logical_env" --catalog "$catalog" --schema_namespace "$schema_namespace" +# Deploy Nodespec Samples (node-based dataflow spec) +./deploy_nodespec.sh -u "$user" -h "$host" -p "$profile" -c "$compute" -l "$logical_env" --catalog "$catalog" --schema_namespace "$schema_namespace" + # Note: Kafka samples are deployed as part of feature-samples but require external Kafka infrastructure. # Run ./deploy_feature_samples.sh separately and then trigger the kafka_samples_run_job manually. # Note: TPCH sample is deployed separately via ./deploy_tpch.sh diff --git a/samples/deploy_and_test.sh b/samples/deploy_and_test.sh index 6c76620..577e09f 100755 --- a/samples/deploy_and_test.sh +++ b/samples/deploy_and_test.sh @@ -144,6 +144,31 @@ for ((i=1; i<=num_runs; i++)); do echo "" done +cd "$SCRIPT_DIR" || exit 1 + +# Step 4: Execute nodespec samples run job (reads from staging/bronze created by pattern run 1) +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Executing Nodespec Samples Run Job" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +nodespec_schema="${schema_namespace}_silver${logical_env}" +setup_bundle_env "Nodespec Samples Test Run" "$nodespec_schema" + +cd "$SCRIPT_DIR/nodespec_sample" || { + log_error "Failed to change directory to nodespec_sample" + exit 1 +} + +log_info "Running nodespec_samples_run_job..." +if databricks bundle run nodespec_samples_run_job -t dev --profile "$profile" 2>&1; then + log_success "Nodespec samples run completed successfully" +else + log_error "Nodespec samples run failed" + exit 1 +fi + cd "$SCRIPT_DIR" || exit 1 unset MSYS_NO_PATHCONV diff --git a/samples/deploy_nodespec.sh b/samples/deploy_nodespec.sh new file mode 100755 index 0000000..04a3826 --- /dev/null +++ b/samples/deploy_nodespec.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Nodespec Sample Bundle Deployment + +# Sample-specific constants +BUNDLE_NAME="Nodespec Sample Bundle" +SCHEMA="${DEFAULT_SCHEMA_NAMESPACE}_silver" + +# Source common library and configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Parse command-line arguments +parse_common_args "$@" + +# Prompt for missing parameters +prompt_common_params + +# Validate all required parameters +if ! validate_required_params; then + exit 1 +fi + +# Set schema - use command line if provided, otherwise use constant with logical environment +if [[ -n "$schema_namespace" ]]; then + # Use schema from command line + schema="${schema_namespace}_silver$logical_env" +else + # Use schema constant with logical environment + schema="$SCHEMA$logical_env" +fi + +# Set up bundle environment +setup_bundle_env "$BUNDLE_NAME" "$schema" + +# Update substitutions file with catalog and schema namespace +if ! update_substitutions_file "nodespec_sample/src/pipeline_configs/dev_substitutions.json"; then + log_error "Failed to update substitutions file. Exiting." + exit 1 +fi + +# Change to nodespec_sample directory for deployment +cd nodespec_sample + +# Deploy the bundle +deploy_bundle "$BUNDLE_NAME" + +# Return to parent directory +cd .. + +# Restore original substitutions file +restore_substitutions_file "nodespec_sample/src/pipeline_configs/dev_substitutions.json" + diff --git a/samples/destroy.sh b/samples/destroy.sh index 8e69d45..7fe88ae 100755 --- a/samples/destroy.sh +++ b/samples/destroy.sh @@ -24,6 +24,16 @@ databricks bundle destroy -t dev --profile "$profile" --auto-approve echo "" cd .. +########## +# Destroy Nodespec Samples +echo "Destroying Nodespec Sample Bundle" +export BUNDLE_VAR_schema="${schema_namespace}_silver${logical_env}" +echo "BUNDLE_VAR_schema: $BUNDLE_VAR_schema" +cd nodespec_sample +databricks bundle destroy -t dev --profile "$profile" --auto-approve +echo "" +cd .. + ########## # Destroy Pattern Samples echo "Destroying Pattern Samples Bundle" diff --git a/samples/nodespec_sample/.gitignore b/samples/nodespec_sample/.gitignore new file mode 100644 index 0000000..d5416f6 --- /dev/null +++ b/samples/nodespec_sample/.gitignore @@ -0,0 +1,8 @@ +.databricks/ +build/ +dist/ +__pycache__/ +*.egg-info +.venv/ +scratch/ + diff --git a/samples/nodespec_sample/.vscode/settings.json b/samples/nodespec_sample/.vscode/settings.json new file mode 100644 index 0000000..3c11d5a --- /dev/null +++ b/samples/nodespec_sample/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "jupyter.interactiveWindow.cellMarker.codeRegex": "^# COMMAND ----------|^# Databricks notebook source|^(#\\s*%%|#\\s*\\|#\\s*In\\[\\d*?\\]|#\\s*In\\[ \\])", + "jupyter.interactiveWindow.cellMarker.default": "# COMMAND ----------" +} + diff --git a/samples/nodespec_sample/README.md b/samples/nodespec_sample/README.md new file mode 100644 index 0000000..a7be717 --- /dev/null +++ b/samples/nodespec_sample/README.md @@ -0,0 +1,208 @@ +# Nodespec Sample + +This sample demonstrates how to use node-based node-based dataflow specifications with the Lakeflow Framework. + +## Overview + +The Nodespec dataflow type (`dataFlowType: "nodespec"`) provides compatibility with [node-based](https://github.com/Mmodarre/Lakehouse_Nodespec) style specifications. This allows users familiar with Nodespec's graph-based approach to define pipelines using source, transformation, and target nodes. + +## Directory Structure + +``` +nodespec_sample/ +├── databricks.yml # DABs bundle configuration +├── .gitignore +├── pytest.ini +├── README.md +├── resources/ +│ ├── classic/ # Classic compute pipeline configs +│ │ ├── nodespec_base_samples_pipeline.yml +│ │ ├── nodespec_customer_simple_pipeline.yml +│ │ ├── nodespec_customer_transform_pipeline.yml +│ │ ├── nodespec_customer_multi_target_pipeline.yml +│ │ └── nodespec_customer_cloudfiles_pipeline.yml +│ └── serverless/ # Serverless pipeline configs +│ ├── nodespec_base_samples_pipeline.yml +│ ├── nodespec_customer_simple_pipeline.yml +│ ├── nodespec_customer_transform_pipeline.yml +│ ├── nodespec_customer_multi_target_pipeline.yml +│ └── nodespec_customer_cloudfiles_pipeline.yml +├── src/ +│ ├── dataflows/ +│ │ └── base_samples/ +│ │ ├── dataflowspec/ # Nodespec-style dataflow specs +│ │ └── schemas/ # Table schemas +│ └── pipeline_configs/ +│ ├── dev_substitutions.json +│ └── global.json +├── scratch/ # For local resource overrides +├── fixtures/ # Test fixtures +└── tests/ # Unit tests +``` + +## Key Concepts + +### Node Types + +1. **Source Nodes**: Define where data comes from (Delta tables, cloud files, Kafka, etc.) +2. **Transformation Nodes**: Define SQL or Python transformations +3. **Target Nodes**: Define where data is written + +### Target-Level Configuration + +All target-specific settings are configured directly on target nodes: + +| Setting | Description | Location | +|---------|-------------|----------| +| `cdcSettings` | CDC/SCD2 merge settings | Target node `config` | +| `cdcSnapshotSettings` | CDC snapshot settings | Target node `config` | +| `dataQualityExpectationsEnabled` | Enable DQ expectations | Target node `config` | +| `dataQualityExpectationsPath` | Path to DQ file | Target node `config` | +| `quarantineMode` | Quarantine mode (off/flag/table) | Target node `config` | +| `quarantineTargetDetails` | Quarantine table config | Target node `config` | + +**Example with CDC settings on target:** + +```json +{ + "id": "target_customer", + "type": "target", + "inputs": ["transform_final"], + "config": { + "table": "customer_final", + "cdcSettings": { + "keys": ["CUSTOMER_ID"], + "scd_type": "2", + "sequence_by": "LOAD_TIMESTAMP" + } + } +} +``` + +### Main Target Selection + +When multiple targets exist, the transformer needs to identify the "main" target: + +- Use `isPrimary: true` on a target to explicitly mark it as main (optional) +- If no `isPrimary` flag is specified, the first target node in the array becomes the main target +- Other targets become staging tables with their own independent settings + +**All target-level settings are now applied per-target** - including CDC, data quality, and quarantine. + +## Sample Files + +### `customer_simple_main.json` +Basic example: single source → single target. No `isPrimary` flag needed for single-target specs. + +### `customer_with_transform_main.json` +Multiple sources with append-merge pattern. Demonstrates: +- Multiple sources appending to a shared staging table +- CDC merge on staging table (configured at target level) +- Final transformation and CDC merge to enriched target + +### `customer_multi_target_main.json` +Demonstrates the staging pattern with: +- Source → Transform → Staging (append) → Transform → Final (CDC merge) +- CDC settings specified on the final target node directly + +### `customer_cloudfiles_main.json` +Example of ingesting from cloud files with transformations. + +## View Naming Convention + +When writing SQL transformations, reference input nodes using the pattern `v_`: + +```sql +SELECT * FROM v_source_customer WHERE status = 'active' +``` + +## Deployment + +### Prerequisites + +1. **Databricks CLI** installed and configured +2. **Framework deployed** to your workspace +3. **Source tables** created (for Delta source examples) + +### Deploy using DABs + +1. Copy the desired pipeline configuration from `resources/classic/` or `resources/serverless/` to `scratch/resources/`: + +```bash +# For classic compute +cp resources/classic/nodespec_base_samples_pipeline.yml scratch/resources/ + +# For serverless +cp resources/serverless/nodespec_base_samples_pipeline.yml scratch/resources/ +``` + +2. Deploy the bundle: + +```bash +databricks bundle deploy \ + --var catalog= \ + --var schema= \ + --var framework_source_path= \ + --var workspace_host= +``` + +3. Run the pipeline: + +```bash +databricks bundle run lakeflow_samples_nodespec_base_pipeline +``` + +### Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `catalog` | Unity Catalog name | `main` | +| `schema` | Target schema | `silver_dev` | +| `framework_source_path` | Workspace path to framework src | `/Workspace/Repos/user/lakeflow_framework/src` | +| `workspace_host` | Databricks workspace URL | `https://your-workspace.cloud.databricks.com/` | +| `layer` | Pipeline layer (optional) | `silver` | +| `logical_env` | Logical environment (optional) | `dev` | + +## Customization + +### Adding Substitutions + +Edit `src/pipeline_configs/dev_substitutions.json` to add your own substitution values: + +```json +{ + "substitutions": { + "bronze_schema": "bronze_dev", + "silver_schema": "silver_dev", + "catalog": "main" + } +} +``` + +### Creating New Nodespec Specs + +1. Create a new `*_main.json` file in `src/dataflows/base_samples/dataflowspec/` +2. Use `dataFlowType: "nodespec"` and define your nodes +3. Add corresponding schema files if needed +4. Create a pipeline resource file in `resources/classic/` and `resources/serverless/` + +The Nodespec specs are automatically converted to flow specs at runtime, so all framework features (CDC, data quality, secrets, etc.) work seamlessly. + +## Backend Behavior Notes + +### Per-Target Settings +All target-level settings are now applied independently per target: + +| Setting | Main Target | Staging Tables | +|---------|-------------|----------------| +| `cdcSettings` | ✅ Applied | ✅ Applied | +| `cdcSnapshotSettings` | ✅ Applied | ✅ Applied | +| `dataQualityExpectationsEnabled/Path` | ✅ Applied | ✅ Applied | +| `quarantineMode` | ✅ Applied | ✅ Applied | +| `quarantineTargetDetails` | ✅ Applied | ✅ Applied | + +This means you can have different CDC, data quality, and quarantine configurations for different targets in the same dataflow. + +### Table Migration +Table migration (`tableMigrationDetails`) is only supported on the main target table, as it's designed for migrating external tables to the primary output. + diff --git a/samples/nodespec_sample/databricks.yml b/samples/nodespec_sample/databricks.yml new file mode 100644 index 0000000..0d7406a --- /dev/null +++ b/samples/nodespec_sample/databricks.yml @@ -0,0 +1,38 @@ +# This is a Databricks asset bundle definition for nodespec_sample. +# See https://docs.databricks.com/dev-tools/bundles/index.html for documentation. +bundle: + name: nodespec_sample + +include: + - scratch/resources/*.yml + +variables: + catalog: + description: The target UC catalog + framework_source_path: + description: The full workspace path to the framwework src folder + schema: + description: The target UC schema + workspace_host: + description: workspace url used for API calls from Framework (usually same as deployment URL) e.g. https://e2-demo-field-eng.cloud.databricks.com/ + layer: + description: The target layer + default: silver + logical_env: + description: The logical environment + default: "" + pipeline_cluster_config: + description: Basic cluster config, add node types as necessary + default: + label: default + autoscale: + min_workers: 1 + max_workers: 5 + mode: ENHANCED + +targets: + # The 'dev' target, for development purposes. This target is the default. + dev: + mode: development + default: true + diff --git a/samples/nodespec_sample/pytest.ini b/samples/nodespec_sample/pytest.ini new file mode 100644 index 0000000..30c5e5d --- /dev/null +++ b/samples/nodespec_sample/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +pythonpath = src + diff --git a/samples/nodespec_sample/resources/classic/nodespec_base_samples_pipeline.yml b/samples/nodespec_sample/resources/classic/nodespec_base_samples_pipeline.yml new file mode 100644 index 0000000..c91fb69 --- /dev/null +++ b/samples/nodespec_sample/resources/classic/nodespec_base_samples_pipeline.yml @@ -0,0 +1,24 @@ +resources: + pipelines: + lakeflow_samples_nodespec_base_pipeline: + name: Lakeflow Framework - Nodespec - Base Pipeline (${var.logical_env}) + channel: CURRENT + clusters: + - ${var.pipeline_cluster_config} + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + #pipeline.dataFlowIdFilter: nodespec_customer_simple,nodespec_customer_transform + pipeline.dataFlowGroupFilter: nodespec_base_samples + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/classic/nodespec_customer_cloudfiles_pipeline.yml b/samples/nodespec_sample/resources/classic/nodespec_customer_cloudfiles_pipeline.yml new file mode 100644 index 0000000..881a2b7 --- /dev/null +++ b/samples/nodespec_sample/resources/classic/nodespec_customer_cloudfiles_pipeline.yml @@ -0,0 +1,23 @@ +resources: + pipelines: + lakeflow_samples_nodespec_customer_cloudfiles: + name: Lakeflow Framework - Nodespec - Customer CloudFiles (${var.logical_env}) + channel: CURRENT + clusters: + - ${var.pipeline_cluster_config} + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowIdFilter: nodespec_customer_cloudfiles + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/classic/nodespec_customer_multi_target_pipeline.yml b/samples/nodespec_sample/resources/classic/nodespec_customer_multi_target_pipeline.yml new file mode 100644 index 0000000..d241e56 --- /dev/null +++ b/samples/nodespec_sample/resources/classic/nodespec_customer_multi_target_pipeline.yml @@ -0,0 +1,23 @@ +resources: + pipelines: + lakeflow_samples_nodespec_customer_multi_target: + name: Lakeflow Framework - Nodespec - Customer Multi Target (${var.logical_env}) + channel: CURRENT + clusters: + - ${var.pipeline_cluster_config} + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowIdFilter: nodespec_customer_multi_target + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/classic/nodespec_customer_simple_pipeline.yml b/samples/nodespec_sample/resources/classic/nodespec_customer_simple_pipeline.yml new file mode 100644 index 0000000..430fd5e --- /dev/null +++ b/samples/nodespec_sample/resources/classic/nodespec_customer_simple_pipeline.yml @@ -0,0 +1,23 @@ +resources: + pipelines: + lakeflow_samples_nodespec_customer_simple: + name: Lakeflow Framework - Nodespec - Customer Simple (${var.logical_env}) + channel: CURRENT + clusters: + - ${var.pipeline_cluster_config} + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowIdFilter: nodespec_customer_simple + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/classic/nodespec_customer_transform_pipeline.yml b/samples/nodespec_sample/resources/classic/nodespec_customer_transform_pipeline.yml new file mode 100644 index 0000000..66adb78 --- /dev/null +++ b/samples/nodespec_sample/resources/classic/nodespec_customer_transform_pipeline.yml @@ -0,0 +1,23 @@ +resources: + pipelines: + lakeflow_samples_nodespec_customer_transform: + name: Lakeflow Framework - Nodespec - Customer Transform (${var.logical_env}) + channel: CURRENT + clusters: + - ${var.pipeline_cluster_config} + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowIdFilter: nodespec_customer_transform + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/serverless/jobs/nodespec_samples_run_job.yml b/samples/nodespec_sample/resources/serverless/jobs/nodespec_samples_run_job.yml new file mode 100644 index 0000000..fc8fb14 --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/jobs/nodespec_samples_run_job.yml @@ -0,0 +1,63 @@ +resources: + jobs: + nodespec_samples_run_job: + name: Lakeflow Framework - Nodespec Samples - Run (${var.logical_env}) + tasks: + # Base samples — individual pipelines + - task_key: nodespec_customer_simple + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_customer_simple.id} + full_refresh: true + + - task_key: nodespec_customer_cloudfiles + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_customer_cloudfiles.id} + full_refresh: true + + - task_key: nodespec_customer_transform + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_customer_transform.id} + full_refresh: true + + - task_key: nodespec_customer_multi_target + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_customer_multi_target.id} + full_refresh: true + + # Note: nodespec_base_pipeline (combined) is excluded because individual + # base sample specs share view names which causes SDP "Cannot redefine" errors + # when loaded into a single pipeline. The individual pipelines above cover all base samples. + + # Feature samples — all run in parallel + - task_key: nodespec_feature_general + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_feature_general_pipeline.id} + full_refresh: true + + - task_key: nodespec_feature_python + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_feature_python_pipeline.id} + full_refresh: true + + - task_key: nodespec_feature_data_quality + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_feature_data_quality_pipeline.id} + full_refresh: true + + - task_key: nodespec_feature_snapshots + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_feature_snapshots_pipeline.id} + full_refresh: true + + - task_key: nodespec_feature_table_migration + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_feature_table_migration_pipeline.id} + full_refresh: true + + - task_key: nodespec_feature_materialized_views + pipeline_task: + pipeline_id: ${resources.pipelines.lakeflow_samples_nodespec_feature_materialized_views_pipeline.id} + full_refresh: true + + queue: + enabled: true diff --git a/samples/nodespec_sample/resources/serverless/nodespec_base_samples_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_base_samples_pipeline.yml new file mode 100644 index 0000000..c85641b --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_base_samples_pipeline.yml @@ -0,0 +1,23 @@ +resources: + pipelines: + lakeflow_samples_nodespec_base_pipeline: + name: Lakeflow Framework - Nodespec - Base Pipeline (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + #pipeline.dataFlowIdFilter: nodespec_customer_simple,nodespec_customer_transform + pipeline.dataFlowGroupFilter: nodespec_base_samples + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/serverless/nodespec_customer_cloudfiles_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_customer_cloudfiles_pipeline.yml new file mode 100644 index 0000000..373834e --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_customer_cloudfiles_pipeline.yml @@ -0,0 +1,22 @@ +resources: + pipelines: + lakeflow_samples_nodespec_customer_cloudfiles: + name: Lakeflow Framework - Nodespec - Customer CloudFiles (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowIdFilter: nodespec_customer_cloudfiles + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/serverless/nodespec_customer_multi_target_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_customer_multi_target_pipeline.yml new file mode 100644 index 0000000..253b1b2 --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_customer_multi_target_pipeline.yml @@ -0,0 +1,22 @@ +resources: + pipelines: + lakeflow_samples_nodespec_customer_multi_target: + name: Lakeflow Framework - Nodespec - Customer Multi Target (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowIdFilter: nodespec_customer_multi_target + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/serverless/nodespec_customer_simple_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_customer_simple_pipeline.yml new file mode 100644 index 0000000..25f848d --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_customer_simple_pipeline.yml @@ -0,0 +1,22 @@ +resources: + pipelines: + lakeflow_samples_nodespec_customer_simple: + name: Lakeflow Framework - Nodespec - Customer Simple (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowIdFilter: nodespec_customer_simple + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/serverless/nodespec_customer_transform_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_customer_transform_pipeline.yml new file mode 100644 index 0000000..6b33ce8 --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_customer_transform_pipeline.yml @@ -0,0 +1,22 @@ +resources: + pipelines: + lakeflow_samples_nodespec_customer_transform: + name: Lakeflow Framework - Nodespec - Customer Transform (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowIdFilter: nodespec_customer_transform + root_path: ${workspace.file_path}/src/dataflows/base_samples + diff --git a/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_data_quality_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_data_quality_pipeline.yml new file mode 100644 index 0000000..f0f2fd1 --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_data_quality_pipeline.yml @@ -0,0 +1,20 @@ +resources: + pipelines: + lakeflow_samples_nodespec_feature_data_quality_pipeline: + name: Lakeflow Framework - Nodespec - Feature Samples Data Quality Pipeline (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowGroupFilter: nodespec_feature_samples_data_quality + root_path: ${workspace.file_path}/src/dataflows/feature_samples diff --git a/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_general_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_general_pipeline.yml new file mode 100644 index 0000000..2f96d88 --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_general_pipeline.yml @@ -0,0 +1,20 @@ +resources: + pipelines: + lakeflow_samples_nodespec_feature_general_pipeline: + name: Lakeflow Framework - Nodespec - Feature Samples General Pipeline (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowGroupFilter: nodespec_feature_samples_general + root_path: ${workspace.file_path}/src/dataflows/feature_samples diff --git a/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_materialized_views_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_materialized_views_pipeline.yml new file mode 100644 index 0000000..4b9a720 --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_materialized_views_pipeline.yml @@ -0,0 +1,20 @@ +resources: + pipelines: + lakeflow_samples_nodespec_feature_materialized_views_pipeline: + name: Lakeflow Framework - Nodespec - Feature Samples Materialized Views Pipeline (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowGroupFilter: nodespec_feature_samples_materialized_views + root_path: ${workspace.file_path}/src/dataflows/feature_samples diff --git a/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_python_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_python_pipeline.yml new file mode 100644 index 0000000..03e60fd --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_python_pipeline.yml @@ -0,0 +1,20 @@ +resources: + pipelines: + lakeflow_samples_nodespec_feature_python_pipeline: + name: Lakeflow Framework - Nodespec - Feature Samples Python Pipeline (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowGroupFilter: nodespec_feature_samples_python + root_path: ${workspace.file_path}/src/dataflows/feature_samples diff --git a/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_snapshots_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_snapshots_pipeline.yml new file mode 100644 index 0000000..733cf73 --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_snapshots_pipeline.yml @@ -0,0 +1,20 @@ +resources: + pipelines: + lakeflow_samples_nodespec_feature_snapshots_pipeline: + name: Lakeflow Framework - Nodespec - Feature Samples Snapshots Pipeline (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowGroupFilter: nodespec_feature_samples_snapshots + root_path: ${workspace.file_path}/src/dataflows/feature_samples diff --git a/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_table_migration_pipeline.yml b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_table_migration_pipeline.yml new file mode 100644 index 0000000..416e000 --- /dev/null +++ b/samples/nodespec_sample/resources/serverless/nodespec_feature_samples_table_migration_pipeline.yml @@ -0,0 +1,20 @@ +resources: + pipelines: + lakeflow_samples_nodespec_feature_table_migration_pipeline: + name: Lakeflow Framework - Nodespec - Feature Samples Table Migration Pipeline (${var.logical_env}) + channel: CURRENT + serverless: true + catalog: ${var.catalog} + schema: ${var.schema} + libraries: + - notebook: + path: ${var.framework_source_path}/dlt_pipeline + configuration: + bundle.sourcePath: ${workspace.file_path}/src + bundle.target: ${bundle.target} + framework.sourcePath: ${var.framework_source_path} + workspace.host: ${var.workspace_host} + pipeline.layer: ${var.layer} + logicalEnv: ${var.logical_env} + pipeline.dataFlowGroupFilter: nodespec_feature_samples_table_migration + root_path: ${workspace.file_path}/src/dataflows/feature_samples diff --git a/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_cloudfiles_main.json b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_cloudfiles_main.json new file mode 100644 index 0000000..19b134b --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_cloudfiles_main.json @@ -0,0 +1,48 @@ +{ + "data_flow_id": "nodespec_customer_cloudfiles", + "data_flow_group": "nodespec_base_samples", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_source_files", + "node_type": "source", + "source_type": "cloud_files", + "config": { + "mode": "stream", + "path": "{sample_file_location}/customer/", + "schema_path": "customer_source_schema.json", + "reader_options": { + "cloudFiles.format": "json", + "cloudFiles.inferColumnTypes": "true", + "cloudFiles.schemaEvolutionMode": "rescue" + } + } + }, + { + "name": "v_transform_normalize", + "node_type": "transformation", + "transformation_type": "sql", + "output_view_name": "v_normalized_customer", + "config": { + "sql_statement": "SELECT CAST(customer_id AS BIGINT) as CUSTOMER_ID, UPPER(customer_name) as CUSTOMER_NAME, LOWER(email) as EMAIL, TO_DATE(created_date) as CREATED_DATE, current_timestamp() as LOAD_TIMESTAMP FROM STREAM(live.v_source_files)" + } + }, + { + "name": "target_bronze", + "node_type": "target", + "config": { + "table": "customer_bronze_node", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cluster_by_auto": true, + "input_flows": [ + { + "view": "v_transform_normalize", + "flow": "f_transform_normalize" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_multi_target_main.json b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_multi_target_main.json new file mode 100644 index 0000000..90d55ff --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_multi_target_main.json @@ -0,0 +1,97 @@ +{ + "data_flow_id": "nodespec_customer_multi_target", + "data_flow_group": "nodespec_base_samples", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_source_customer", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "v_transform_append", + "node_type": "transformation", + "transformation_type": "sql", + "output_view_name": "v_customer_with_timestamp", + "config": { + "sql_statement": "SELECT * EXCEPT(LOAD_TIMESTAMP), current_timestamp() as LOAD_TIMESTAMP FROM STREAM(live.v_source_customer)" + } + }, + { + "name": "staging_append", + "node_type": "target", + "config": { + "table": "customer_append_staging_node", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cluster_by_auto": true, + "data_quality": { + "expectations_path": "customer_staging_dqe.json" + }, + "quarantine": { + "mode": "flag" + }, + "input_flows": [ + { + "flow": "f_append_staging", + "view": "v_transform_append" + } + ] + } + }, + { + "name": "v_transform_merge", + "node_type": "transformation", + "transformation_type": "sql", + "output_view_name": "v_customer_for_merge", + "config": { + "sql_statement": "SELECT * FROM STREAM(live.customer_append_staging_node)" + } + }, + { + "name": "target_final", + "node_type": "target", + "config": { + "table": "customer_final_node", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cluster_by_columns": [ + "CUSTOMER_ID" + ], + "comment": "Final customer table with SCD2 history and data quality", + "cdc_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "sequence_by": "LOAD_TIMESTAMP", + "ignore_null_updates": true + }, + "data_quality": { + "expectations_path": "customer_final_dqe.json" + }, + "quarantine": { + "mode": "table", + "target": { + "target_format": "delta", + "table": "customer_final_node_quarantine" + } + }, + "input_flows": [ + { + "flow": "f_merge_final", + "view": "v_transform_merge" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_simple_main.json b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_simple_main.json new file mode 100644 index 0000000..1796646 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_simple_main.json @@ -0,0 +1,35 @@ +{ + "data_flow_id": "nodespec_customer_simple", + "data_flow_group": "nodespec_base_samples", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_source_customer", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "target_customer", + "node_type": "target", + "config": { + "table": "customer_silver_node", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cluster_by_auto": true, + "input_flows": [ + { + "flow": "f_customer_silver_ingest", + "view": "v_source_customer" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_st_with_mv_main.json b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_st_with_mv_main.json new file mode 100644 index 0000000..005eb12 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_st_with_mv_main.json @@ -0,0 +1,44 @@ +{ + "data_flow_id": "nodespec_customer_st_with_mv", + "data_flow_group": "nodespec_base_samples", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_source_customer_st_mv", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "target_customer_streaming", + "node_type": "target", + "config": { + "table": "customer_st_with_mv_node", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cluster_by_auto": true, + "input_flows": [ + { + "view": "v_source_customer_st_mv", + "flow": "f_source_customer_st_mv" + } + ] + } + }, + { + "name": "target_customer_summary", + "node_type": "target", + "config": { + "table": "customer_summary_mv_node", + "table_type": "mv", + "sql_statement": "SELECT count(*) as customer_count, count(CASE WHEN DELETE_FLAG IS NULL OR DELETE_FLAG = false THEN 1 END) as active_customer_count, max(LOAD_TIMESTAMP) as latest_load_timestamp FROM live.customer_st_with_mv_node" + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_with_transform_main.json b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_with_transform_main.json new file mode 100644 index 0000000..e85e0ef --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/dataflowspec/customer_with_transform_main.json @@ -0,0 +1,143 @@ +{ + "data_flow_id": "nodespec_customer_transform", + "data_flow_group": "nodespec_base_samples", + "data_flow_type": "nodespec", + "tags": { + "layer": "silver", + "source_project": "nodespec" + }, + "nodes": [ + { + "name": "v_source_customer", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "v_source_customer_address", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer_address", + "cdf_enabled": true + } + }, + { + "name": "staging_append_customer", + "node_type": "target", + "config": { + "table": "customer_transform_append_node", + "cluster_by_auto": true, + "input_flows": [ + { + "flow": "f_append_customer", + "view": "v_source_customer" + } + ] + } + }, + { + "name": "staging_append_address", + "node_type": "target", + "config": { + "table": "customer_transform_append_node", + "cluster_by_auto": true, + "input_flows": [ + { + "flow": "f_append_address", + "view": "v_source_customer_address" + } + ] + } + }, + { + "name": "v_source_append_cdf", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "table": "customer_transform_append_node", + "cdf_enabled": true + } + }, + { + "name": "staging_merge", + "node_type": "target", + "config": { + "table": "customer_transform_merge_node", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cluster_by_auto": true, + "cdc_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "sequence_by": "LOAD_TIMESTAMP", + "ignore_null_updates": true, + "scd_type": "2" + }, + "input_flows": [ + { + "flow": "f_merge_append_cdf", + "view": "v_source_append_cdf" + } + ] + } + }, + { + "name": "v_source_merge_cdf", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "table": "customer_transform_merge_node", + "cdf_enabled": true + } + }, + { + "name": "v_transform_final", + "node_type": "transformation", + "transformation_type": "sql", + "output_view_name": "v_final_transform", + "config": { + "sql_statement": "SELECT CUSTOMER_ID, EMAIL, CITY, STATE, LOAD_TIMESTAMP FROM STREAM(live.v_source_merge_cdf)" + } + }, + { + "name": "target_customer_enriched", + "node_type": "target", + "config": { + "table": "customer_enriched_node", + "schema_path": "customer_enriched_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cluster_by_columns": [ + "CUSTOMER_ID" + ], + "cdc_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "sequence_by": "LOAD_TIMESTAMP", + "ignore_null_updates": true + }, + "input_flows": [ + { + "flow": "f_enrich_final", + "view": "v_transform_final" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/base_samples/expectations/customer_final_dqe.json b/samples/nodespec_sample/src/dataflows/base_samples/expectations/customer_final_dqe.json new file mode 100644 index 0000000..08136bc --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/expectations/customer_final_dqe.json @@ -0,0 +1,21 @@ +{ + "expect": [ + { + "name": "customer_id_positive", + "constraint": "CUSTOMER_ID > 0", + "tag": "Validity" + } + ], + "expect_or_drop": [ + { + "name": "customer_id_not_null", + "constraint": "CUSTOMER_ID IS NOT NULL", + "tag": "Validity" + }, + { + "name": "load_timestamp_valid", + "constraint": "LOAD_TIMESTAMP IS NOT NULL", + "tag": "Completeness" + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/base_samples/expectations/customer_staging_dqe.json b/samples/nodespec_sample/src/dataflows/base_samples/expectations/customer_staging_dqe.json new file mode 100644 index 0000000..3fca604 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/expectations/customer_staging_dqe.json @@ -0,0 +1,14 @@ +{ + "expect_or_drop": [ + { + "name": "customer_id_not_null", + "constraint": "CUSTOMER_ID IS NOT NULL", + "tag": "Validity" + }, + { + "name": "email_not_empty", + "constraint": "EMAIL IS NOT NULL AND LENGTH(EMAIL) > 0", + "tag": "Completeness" + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/base_samples/schemas/customer_enriched_schema.json b/samples/nodespec_sample/src/dataflows/base_samples/schemas/customer_enriched_schema.json new file mode 100644 index 0000000..bfd1dd4 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/schemas/customer_enriched_schema.json @@ -0,0 +1,35 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "integer", + "nullable": true, + "metadata": {} + }, + { + "name": "EMAIL", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LOAD_TIMESTAMP", + "type": "timestamp", + "nullable": true, + "metadata": {} + }, + { + "name": "CITY", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "STATE", + "type": "string", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/base_samples/schemas/customer_schema.json b/samples/nodespec_sample/src/dataflows/base_samples/schemas/customer_schema.json new file mode 100644 index 0000000..d543bcd --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/schemas/customer_schema.json @@ -0,0 +1,41 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "integer", + "nullable": true, + "metadata": {} + }, + { + "name": "FIRST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LAST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "EMAIL", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "DELETE_FLAG", + "type": "boolean", + "nullable": true, + "metadata": {} + }, + { + "name": "LOAD_TIMESTAMP", + "type": "timestamp", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/base_samples/schemas/customer_source_schema.json b/samples/nodespec_sample/src/dataflows/base_samples/schemas/customer_source_schema.json new file mode 100644 index 0000000..9267d4f --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/base_samples/schemas/customer_source_schema.json @@ -0,0 +1,29 @@ +{ + "type": "struct", + "fields": [ + { + "name": "customer_id", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "customer_name", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "email", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "created_date", + "type": "string", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/append_sql_flow_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/append_sql_flow_main.json new file mode 100644 index 0000000..172c48f --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/append_sql_flow_main.json @@ -0,0 +1,38 @@ +{ + "data_flow_id": "append_sql_flow", + "data_flow_group": "nodespec_feature_samples_general", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_sql_f_customer_address_append_sql", + "node_type": "source", + "source_type": "sql", + "config": { + "sql_statement": "SELECT * FROM STREAM({staging_schema}.customer_address)" + } + }, + { + "name": "target_append_sql_flow", + "node_type": "target", + "config": { + "table": "append_sql_flow", + "schema_path": "target/customer_address_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "data_quality": { + "expectations_path": "./customer_address_dqe.json" + }, + "quarantine": { + "mode": "flag" + }, + "input_flows": [ + { + "view": "v_sql_f_customer_address_append_sql", + "flow": "f_sql_f_customer_address_append_sql" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/append_view_flow_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/append_view_flow_main.json new file mode 100644 index 0000000..f1caff0 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/append_view_flow_main.json @@ -0,0 +1,34 @@ +{ + "data_flow_id": "append_view_flow", + "data_flow_group": "nodespec_feature_samples_general", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_append_view_flow", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "target_append_view_flow", + "node_type": "target", + "config": { + "table": "append_view_flow", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "input_flows": [ + { + "view": "v_append_view_flow", + "flow": "f_append_view_flow" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/append_view_once_flow_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/append_view_once_flow_main.json new file mode 100644 index 0000000..90fa667 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/append_view_once_flow_main.json @@ -0,0 +1,34 @@ +{ + "data_flow_id": "append_view_once_flow", + "data_flow_group": "nodespec_feature_samples_general", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_append_view_once_flow", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "batch", + "database": "{staging_schema}", + "table": "customer" + } + }, + { + "name": "target_append_view_once_flow", + "node_type": "target", + "config": { + "table": "append_view_once_flow", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "once": true, + "input_flows": [ + { + "view": "v_append_view_once_flow", + "flow": "f_append_view_once_flow" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/ddl_schema_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/ddl_schema_main.json new file mode 100644 index 0000000..467f351 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/ddl_schema_main.json @@ -0,0 +1,35 @@ +{ + "data_flow_id": "feature_constraints", + "data_flow_group": "nodespec_feature_samples_general", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_feature_constraints", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "target_feature_constraints", + "node_type": "target", + "config": { + "table": "feature_constraints", + "schema_path": "target/feature_ddl_schema.ddl", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "input_flows": [ + { + "view": "v_feature_constraints", + "flow": "f_feature_constraints" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_main.json new file mode 100644 index 0000000..5285204 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_main.json @@ -0,0 +1,41 @@ +{ + "data_flow_id": "feature_historical_files_snapshot_datetime", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_feature_historical_snapshot_files_datetime", + "node_type": "target", + "config": { + "table": "feature_historical_snapshot_files_datetime", + "schema_path": "target/customer_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "file", + "source": { + "format": "csv", + "path": "{sample_file_location}/snapshot_customer/customer_{version}.csv", + "reader_options": { + "header": "true" + }, + "version_type": "timestamp", + "datetime_format": "%Y_%m_%d" + }, + "track_history_except_column_list": [ + "LOAD_TIMESTAMP" + ] + } + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_multifile_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_multifile_main.json new file mode 100644 index 0000000..8b07e59 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_multifile_main.json @@ -0,0 +1,41 @@ +{ + "data_flow_id": "feature_historical_files_snapshot_datetime_multifile", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_feature_historical_snapshot_files_datetime_multifile", + "node_type": "target", + "config": { + "table": "feature_historical_snapshot_files_datetime_multifile", + "schema_path": "target/customer_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "file", + "source": { + "format": "csv", + "path": "{sample_file_location}/snapshot_customer_multifile/customer_{version}_split_{fragment}.csv", + "reader_options": { + "header": "true" + }, + "version_type": "timestamp", + "datetime_format": "%Y_%m_%d" + }, + "track_history_except_column_list": [ + "LOAD_TIMESTAMP" + ] + } + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_recursive_and_partitioned_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_recursive_and_partitioned_main.json new file mode 100644 index 0000000..7276564 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_recursive_and_partitioned_main.json @@ -0,0 +1,42 @@ +{ + "data_flow_id": "feature_historical_files_snapshot_datetime_recursive_and_partitioned", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_feature_historical_snapshot_files_datetime_recursive_and_partitioned", + "node_type": "target", + "config": { + "table": "feature_historical_snapshot_files_datetime_recursive_and_partitioned", + "schema_path": "target/customer_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "file", + "source": { + "format": "csv", + "path": "{sample_file_location}/snapshot_customer_partitioned/{version}/customer.csv", + "reader_options": { + "header": "true" + }, + "version_type": "timestamp", + "datetime_format": "YEAR=%Y/MONTH=%m/DAY=%d", + "recursive_file_lookup": true + }, + "track_history_except_column_list": [ + "LOAD_TIMESTAMP" + ] + } + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_recursive_and_partitioned_parquet_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_recursive_and_partitioned_parquet_main.json new file mode 100644 index 0000000..0575d49 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_recursive_and_partitioned_parquet_main.json @@ -0,0 +1,40 @@ +{ + "data_flow_id": "feature_historical_files_snapshot_datetime_recursive_and_partitioned_parquet", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_feature_historical_snapshot_files_datetime_recursive_and_partitioned_parquet", + "node_type": "target", + "config": { + "table": "feature_historical_snapshot_files_datetime_recursive_and_partitioned_parquet", + "schema_path": "target/customer_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "file", + "source": { + "format": "parquet", + "path": "{sample_file_location}/snapshot_customer_partitioned_parquet/{version}/customer.parquet", + "version_type": "timestamp", + "datetime_format": "YEAR=%Y/MONTH=%m/DAY=%d", + "deduplicate_mode": "keys_only", + "recursive_file_lookup": true + }, + "track_history_except_column_list": [ + "LOAD_TIMESTAMP" + ] + } + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_recursive_and_partitioned_regex_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_recursive_and_partitioned_regex_main.json new file mode 100644 index 0000000..ac7d15c --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_datetime_recursive_and_partitioned_regex_main.json @@ -0,0 +1,42 @@ +{ + "data_flow_id": "feature_historical_files_snapshot_datetime_recursive_and_regex", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_feature_historical_snapshot_files_datetime_recursive_and_regex", + "node_type": "target", + "config": { + "table": "feature_historical_snapshot_files_datetime_recursive_and_regex", + "schema_path": "target/customer_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "file", + "source": { + "format": "csv", + "path": "{sample_file_location}/snapshot_customer_regex/(?P.+)/(?P.+)/data/customer_(?P.+).csv", + "reader_options": { + "header": "true" + }, + "version_type": "timestamp", + "datetime_format": "%Y%m%d", + "recursive_file_lookup": true + }, + "track_history_except_column_list": [ + "LOAD_TIMESTAMP" + ] + } + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_flow_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_flow_main.json new file mode 100644 index 0000000..53cc1f7 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_flow_main.json @@ -0,0 +1,85 @@ +{ + "data_flow_id": "feature_historical_files_snapshot_flow", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_stg_feature_historical_files_snapshot_flow", + "node_type": "target", + "config": { + "table": "stg_feature_historical_files_snapshot_flow", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "1", + "snapshot_type": "historical", + "source_type": "file", + "source": { + "format": "csv", + "path": "{sample_file_location}/snapshot_customer/customer_{version}.csv", + "reader_options": { + "header": "true" + }, + "version_type": "timestamp", + "datetime_format": "%Y_%m_%d", + "schema_path": "source/feature_historic_snapshot_customer_schema.json", + "select_exp": [ + "CUSTOMER_ID", + "FIRST_NAME", + "LAST_NAME", + "EMAIL", + "TO_TIMESTAMP(LOAD_TIMESTAMP, 'yyyy-MM-dd HH:mm:ss') AS LOAD_TIMESTAMP" + ] + } + } + } + }, + { + "name": "v_feature_historical_files_snapshot_append", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "live", + "table": "stg_feature_historical_files_snapshot_flow", + "cdf_enabled": true, + "cdf_change_type_override": [ + "insert", + "update_postimage", + "delete" + ], + "starting_version_from_dlt_setup": true, + "select_exp": [ + "CUSTOMER_ID", + "FIRST_NAME", + "LAST_NAME", + "EMAIL", + "_change_type AS META_CDC_CHANGE_TYPE" + ] + } + }, + { + "name": "target_feature_historical_files_snapshot_flow", + "node_type": "target", + "config": { + "table": "feature_historical_files_snapshot_flow", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "input_flows": [ + { + "view": "v_feature_historical_files_snapshot_append", + "flow": "f_feature_historical_files_snapshot_append" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_int_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_int_main.json new file mode 100644 index 0000000..deccc3f --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_int_main.json @@ -0,0 +1,40 @@ +{ + "data_flow_id": "feature_historical_files_snapshot_int", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_feature_historical_snapshot_files_int", + "node_type": "target", + "config": { + "table": "feature_historical_snapshot_files_int", + "schema_path": "target/customer_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "file", + "source": { + "format": "csv", + "path": "{sample_file_location}/customer/customer_{version}.csv", + "reader_options": { + "header": "true" + }, + "version_type": "integer" + }, + "track_history_except_column_list": [ + "LOAD_TIMESTAMP" + ] + } + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_schema_and_select_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_schema_and_select_main.json new file mode 100644 index 0000000..728a8ec --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_files_schema_and_select_main.json @@ -0,0 +1,48 @@ +{ + "data_flow_id": "feature_historical_files_snapshot_schema_and_select", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_feature_historical_snapshot_files_schema_and_select", + "node_type": "target", + "config": { + "table": "feature_historical_snapshot_files_schema_and_select", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "file", + "source": { + "format": "csv", + "path": "{sample_file_location}/snapshot_customer/customer_{version}.csv", + "reader_options": { + "header": "true" + }, + "version_type": "timestamp", + "datetime_format": "%Y_%m_%d", + "schema_path": "source/feature_historic_snapshot_customer_schema.json", + "select_exp": [ + "CUSTOMER_ID", + "FIRST_NAME", + "LAST_NAME", + "EMAIL", + "LOAD_TIMESTAMP" + ] + }, + "track_history_except_column_list": [ + "LOAD_TIMESTAMP" + ] + } + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_table_datetime_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_table_datetime_main.json new file mode 100644 index 0000000..4344a2e --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_table_datetime_main.json @@ -0,0 +1,37 @@ +{ + "data_flow_id": "feature_historical_table_snapshot_datetime", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_feature_historical_snapshot_table_datetime", + "node_type": "target", + "config": { + "table": "feature_historical_snapshot_table_datetime", + "schema_path": "target/feature_historic_snapshot_customer_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "table", + "source": { + "table": "{staging_schema}.customer_historical_snapshot_source", + "version_column": "LOAD_TIMESTAMP", + "version_type": "timestamp" + }, + "track_history_except_column_list": [ + "LOAD_TIMESTAMP" + ] + } + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_table_select_expression_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_table_select_expression_main.json new file mode 100644 index 0000000..6f8e86e --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/historical_snapshot_table_select_expression_main.json @@ -0,0 +1,43 @@ +{ + "data_flow_id": "feature_historical_table_snapshot_select_expression", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "target_feature_historical_snapshot_table_select_expression", + "node_type": "target", + "config": { + "table": "feature_historical_snapshot_table_select_expression", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "snapshot_type": "historical", + "source_type": "table", + "source": { + "table": "{staging_schema}.customer_historical_snapshot_source", + "version_column": "LOAD_TIMESTAMP", + "version_type": "timestamp", + "select_exp": [ + "CUSTOMER_ID", + "FIRST_NAME", + "LAST_NAME", + "EMAIL", + "LOAD_TIMESTAMP" + ] + }, + "track_history_except_column_list": [ + "LOAD_TIMESTAMP" + ] + } + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/materialized_views_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/materialized_views_main.json new file mode 100644 index 0000000..a9e6099 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/materialized_views_main.json @@ -0,0 +1,95 @@ +{ + "data_flow_id": "feature_materialized_views", + "data_flow_group": "nodespec_feature_samples_materialized_views", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_feature_mv_source_view", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "batch", + "database": "{staging_schema}", + "table": "customer" + } + }, + { + "name": "target_feature_mv_source_view", + "node_type": "target", + "config": { + "table": "feature_mv_source_view", + "table_type": "mv", + "input_flows": [ + { + "flow": "f_mv_source_view_load", + "view": "v_feature_mv_source_view" + } + ] + } + }, + { + "name": "target_feature_mv_sql_path", + "node_type": "target", + "config": { + "table": "feature_mv_sql_path", + "table_type": "mv", + "sql_path": "./feature_mv_sql_path.sql" + } + }, + { + "name": "target_feature_mv_sql_statement", + "node_type": "target", + "config": { + "table": "feature_mv_sql_statement", + "table_type": "mv", + "sql_statement": "SELECT * FROM {staging_schema}.customer", + "private": true + } + }, + { + "name": "target_feature_mv_with_quarantine", + "node_type": "target", + "config": { + "table": "feature_mv_with_quarantine", + "table_type": "mv", + "sql_statement": "SELECT * FROM {staging_schema}.customer_address", + "data_quality": { + "expectations_path": "./customer_address_dqe.json" + }, + "quarantine": { + "mode": "table", + "target": { + "target_format": "delta", + "cluster_by_auto": true + } + } + } + }, + { + "name": "target_feature_mv_chain_mvs", + "node_type": "target", + "config": { + "table": "feature_mv_chain_mvs", + "table_type": "mv", + "sql_statement": "SELECT * FROM live.feature_mv_sql_statement", + "comment": "Test Config", + "spark_conf": { + "spark.sql.session.timeZone": "Australia/Sydney" + } + } + }, + { + "name": "target_feature_mv_with_refresh_policy", + "node_type": "target", + "config": { + "table": "feature_mv_with_refresh_policy", + "table_type": "mv", + "sql_statement": "SELECT * FROM {staging_schema}.customer", + "config_flags": [ + "disable_operational_metadata" + ], + "refresh_policy": "incremental_strict" + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/periodic_snapshot_scd1_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/periodic_snapshot_scd1_main.json new file mode 100644 index 0000000..7624ef9 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/periodic_snapshot_scd1_main.json @@ -0,0 +1,52 @@ +{ + "data_flow_id": "feature_periodic_snapshot_scd1", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_periodic_snapshot_customer_scd1", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "batch", + "database": "{staging_schema}", + "table": "customer_snapshot_source", + "cdf_enabled": false, + "select_exp": [ + "CUSTOMER_ID", + "FIRST_NAME", + "LAST_NAME", + "EMAIL", + "DELETE_FLAG" + ] + } + }, + { + "name": "target_feature_periodic_snapshot_scd1", + "node_type": "target", + "config": { + "table": "feature_periodic_snapshot_scd1", + "schema_path": "target/feature_periodic_snapshot_customer_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "snapshot_type": "periodic", + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "1" + }, + "input_flows": [ + { + "view": "v_periodic_snapshot_customer_scd1", + "flow": "f_periodic_snapshot_customer_scd1" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/periodic_snapshot_scd2_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/periodic_snapshot_scd2_main.json new file mode 100644 index 0000000..76774b8 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/periodic_snapshot_scd2_main.json @@ -0,0 +1,52 @@ +{ + "data_flow_id": "feature_periodic_snapshot_scd2", + "data_flow_group": "nodespec_feature_samples_snapshots", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_periodic_snapshot_customer_scd2", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "batch", + "database": "{staging_schema}", + "table": "customer_snapshot_source", + "cdf_enabled": false, + "select_exp": [ + "CUSTOMER_ID", + "FIRST_NAME", + "LAST_NAME", + "EMAIL", + "DELETE_FLAG" + ] + } + }, + { + "name": "target_feature_periodic_snapshot_scd2", + "node_type": "target", + "config": { + "table": "feature_periodic_snapshot_scd2", + "schema_path": "target/feature_periodic_snapshot_customer_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "config_flags": [ + "disable_operational_metadata" + ], + "cdc_snapshot_settings": { + "snapshot_type": "periodic", + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2" + }, + "input_flows": [ + { + "view": "v_periodic_snapshot_customer_scd2", + "flow": "f_periodic_snapshot_customer_scd2" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_source_extension_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_source_extension_main.json new file mode 100644 index 0000000..421c831 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_source_extension_main.json @@ -0,0 +1,36 @@ +{ + "data_flow_id": "feature_python_extension_source", + "data_flow_group": "nodespec_feature_samples_python", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_feature_python_extension_source", + "node_type": "source", + "source_type": "python", + "config": { + "mode": "stream", + "python_module": "sources.get_customer_cdf", + "tokens": { + "sourceTable": "{staging_schema}.customer" + } + } + }, + { + "name": "target_feature_python_extension_source", + "node_type": "target", + "config": { + "table": "feature_python_extension_source", + "schema_path": "target/feature_python_function_source_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "input_flows": [ + { + "view": "v_feature_python_extension_source", + "flow": "f_feature_python_extension_source" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_source_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_source_main.json new file mode 100644 index 0000000..ae3cccb --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_source_main.json @@ -0,0 +1,36 @@ +{ + "data_flow_id": "feature_python_function_source", + "data_flow_group": "nodespec_feature_samples_python", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_feature_python_function_source", + "node_type": "source", + "source_type": "python", + "config": { + "mode": "stream", + "function_path": "feature_python_function_source.py", + "tokens": { + "sourceTable": "{staging_schema}.customer" + } + } + }, + { + "name": "target_feature_python_function_source", + "node_type": "target", + "config": { + "table": "feature_python_function_source", + "schema_path": "target/feature_python_function_source_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "input_flows": [ + { + "view": "v_feature_python_function_source", + "flow": "f_feature_python_function_source" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_transform_extension_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_transform_extension_main.json new file mode 100644 index 0000000..5268d51 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_transform_extension_main.json @@ -0,0 +1,43 @@ +{ + "data_flow_id": "feature_python_extension_transform", + "data_flow_group": "nodespec_feature_samples_python", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_source_python_extension_transform", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "v_transform_python_extension", + "node_type": "transformation", + "transformation_type": "python", + "config": { + "python_module": "transforms.customer_aggregation" + } + }, + { + "name": "target_feature_python_extension_transform", + "node_type": "target", + "config": { + "table": "feature_python_extension_transform", + "schema_path": "target/feature_python_function_transform_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "input_flows": [ + { + "view": "v_transform_python_extension", + "flow": "f_transform_python_extension" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_transform_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_transform_main.json new file mode 100644 index 0000000..fade5cd --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/python_transform_main.json @@ -0,0 +1,43 @@ +{ + "data_flow_id": "feature_python_function_transform_transform", + "data_flow_group": "nodespec_feature_samples_python", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_source_python_function_transform", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "v_transform_python_function", + "node_type": "transformation", + "transformation_type": "python", + "config": { + "function_path": "feature_python_function_transform.py" + } + }, + { + "name": "target_feature_python_function_transform", + "node_type": "target", + "config": { + "table": "feature_python_function_transform", + "schema_path": "target/feature_python_function_transform_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "input_flows": [ + { + "view": "v_transform_python_function", + "flow": "f_transform_python_function" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/quarantine_flag_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/quarantine_flag_main.json new file mode 100644 index 0000000..02677d2 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/quarantine_flag_main.json @@ -0,0 +1,47 @@ +{ + "data_flow_id": "feature_quarantine_flag", + "data_flow_group": "nodespec_feature_samples_data_quality", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_customer_address_feature_quarantine_flag", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer_address", + "cdf_enabled": true, + "select_exp": [ + "CUSTOMER_ID", + "CITY", + "STATE", + "LOAD_TIMESTAMP" + ] + } + }, + { + "name": "target_feature_quarantine_flag", + "node_type": "target", + "config": { + "table": "feature_quarantine_flag", + "schema_path": "target/customer_address_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "data_quality": { + "expectations_path": "./customer_address_dqe.json" + }, + "quarantine": { + "mode": "flag" + }, + "input_flows": [ + { + "view": "v_customer_address_feature_quarantine_flag", + "flow": "f_customer_address_feature_quarantine_flag" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/quarantine_table_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/quarantine_table_main.json new file mode 100644 index 0000000..db6d0a9 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/quarantine_table_main.json @@ -0,0 +1,50 @@ +{ + "data_flow_id": "feature_quarantine_table", + "data_flow_group": "nodespec_feature_samples_data_quality", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_customer_address_feature_quarantine_table", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer_address", + "cdf_enabled": true, + "select_exp": [ + "CUSTOMER_ID", + "CITY", + "STATE", + "LOAD_TIMESTAMP" + ] + } + }, + { + "name": "target_feature_quarantine_table", + "node_type": "target", + "config": { + "table": "feature_quarantine_table", + "schema_path": "target/customer_address_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "data_quality": { + "expectations_path": "./customer_address_dqe.json" + }, + "quarantine": { + "mode": "table", + "target": { + "target_format": "delta" + } + }, + "input_flows": [ + { + "view": "v_customer_address_feature_quarantine_table", + "flow": "f_customer_address_feature_quarantine_table" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/quarantine_table_with_cdc_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/quarantine_table_with_cdc_main.json new file mode 100644 index 0000000..5c78dec --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/quarantine_table_with_cdc_main.json @@ -0,0 +1,57 @@ +{ + "data_flow_id": "feature_cdc_with_quarantine_table", + "data_flow_group": "nodespec_feature_samples", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_customer_address_feature_quarantine_table_cdc", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer_address", + "cdf_enabled": true, + "select_exp": [ + "CUSTOMER_ID", + "CITY", + "STATE", + "LOAD_TIMESTAMP" + ] + } + }, + { + "name": "target_feature_cdc_with_quarantine_table", + "node_type": "target", + "config": { + "table": "feature_cdc_with_quarantine_table", + "schema_path": "target/customer_address_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cdc_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "sequence_by": "LOAD_TIMESTAMP", + "scd_type": "2" + }, + "data_quality": { + "expectations_path": "./customer_address_dqe.json" + }, + "quarantine": { + "mode": "table", + "target": { + "target_format": "delta" + } + }, + "input_flows": [ + { + "view": "v_customer_address_feature_quarantine_table_cdc", + "flow": "f_customer_address_feature_quarantine_table_cdc" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_custom_python_generate_cdc_feed.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_custom_python_generate_cdc_feed.json new file mode 100644 index 0000000..ac8d828 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_custom_python_generate_cdc_feed.json @@ -0,0 +1,35 @@ +{ + "data_flow_id": "sink_delta_uc_table", + "data_flow_group": "nodespec_feature_samples_general_DISABLED", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_customer_delta_sink_uc_table", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "target_sink_delta_uc_table", + "node_type": "target", + "target_type": "delta_sink", + "config": { + "name": "sink_delta_uc_table", + "sink_options": { + "table_name": "{bronze_schema}.feature_sink_delta_uc_table" + }, + "input_flows": [ + { + "view": "v_customer_delta_sink_uc_table", + "flow": "f_customer_delta_sink_uc_table" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_delta_path_table_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_delta_path_table_main.json new file mode 100644 index 0000000..40e6c6f --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_delta_path_table_main.json @@ -0,0 +1,35 @@ +{ + "data_flow_id": "sink_delta_path_table", + "data_flow_group": "nodespec_feature_samples_general_DISABLED", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_customer_delta_sink_path_table", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "target_sink_delta_path_table", + "node_type": "target", + "target_type": "delta_sink", + "config": { + "name": "sink_delta_path_table", + "sink_options": { + "path": "{staging_volume}/feature_sink_delta_path_table" + }, + "input_flows": [ + { + "view": "v_customer_delta_sink_path_table", + "flow": "f_customer_delta_sink_path_table" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_delta_uc_table_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_delta_uc_table_main.json new file mode 100644 index 0000000..ac8d828 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_delta_uc_table_main.json @@ -0,0 +1,35 @@ +{ + "data_flow_id": "sink_delta_uc_table", + "data_flow_group": "nodespec_feature_samples_general_DISABLED", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_customer_delta_sink_uc_table", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "target_sink_delta_uc_table", + "node_type": "target", + "target_type": "delta_sink", + "config": { + "name": "sink_delta_uc_table", + "sink_options": { + "table_name": "{bronze_schema}.feature_sink_delta_uc_table" + }, + "input_flows": [ + { + "view": "v_customer_delta_sink_uc_table", + "flow": "f_customer_delta_sink_uc_table" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_foreach_batch_single_basic_sql_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_foreach_batch_single_basic_sql_main.json new file mode 100644 index 0000000..4170a75 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_foreach_batch_single_basic_sql_main.json @@ -0,0 +1,41 @@ +{ + "data_flow_id": "for_each_batch_basic_sql", + "data_flow_group": "nodespec_feature_samples_general", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_customer_purchase_basic_sql", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer_purchase", + "cdf_enabled": true + } + }, + { + "name": "target_for_each_batch_basic_sql", + "node_type": "target", + "target_type": "foreach_batch_sink", + "config": { + "name": "for_each_batch_basic_sql", + "sink_type": "basic_sql", + "sink_config": { + "database": "{staging_schema}", + "table": "feature_foreach_batch_single_basic_sql", + "sql_path": "./customer_purchase_transformed.sql", + "table_properties": { + "delta.enableChangeDataFeed": "true" + } + }, + "input_flows": [ + { + "view": "v_customer_purchase_basic_sql", + "flow": "f_customer_purchase_basic_sql" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_foreach_batch_single_python_function_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_foreach_batch_single_python_function_main.json new file mode 100644 index 0000000..5be3db7 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/sink_foreach_batch_single_python_function_main.json @@ -0,0 +1,41 @@ +{ + "data_flow_id": "for_each_batch_python_function", + "data_flow_group": "nodespec_feature_samples_general", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_customer_purchase_python_function", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer_purchase", + "cdf_enabled": true + } + }, + { + "name": "target_for_each_batch_python_function", + "node_type": "target", + "target_type": "foreach_batch_sink", + "config": { + "name": "for_each_batch_python_function", + "sink_type": "python_function", + "sink_config": { + "function_path": "./feature_foreach_batch_python.py", + "tokens": { + "staging_schema": "{staging_schema}", + "bronze_schema": "{bronze_schema}", + "staging_volume": "{staging_volume}" + } + }, + "input_flows": [ + { + "view": "v_customer_purchase_python_function", + "flow": "f_customer_purchase_python_function" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/table_migration_append_only_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/table_migration_append_only_main.json new file mode 100644 index 0000000..f666af4 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/table_migration_append_only_main.json @@ -0,0 +1,50 @@ +{ + "data_flow_id": "table_migration_append_only", + "data_flow_group": "nodespec_feature_samples_table_migration", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_feature_tm_customer_scd0", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true, + "select_exp": [ + "CUSTOMER_ID", + "FIRST_NAME", + "LAST_NAME", + "EMAIL", + "LOAD_TIMESTAMP" + ] + } + }, + { + "name": "target_feature_migrated_table_append_only", + "node_type": "target", + "config": { + "table": "feature_migrated_table_append_only", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "table_migration_details": { + "catalog_type": "uc", + "enabled": true, + "auto_starting_versions_enabled": true, + "source_details": { + "database": "{bronze_schema}", + "table": "table_to_migrate_scd0" + } + }, + "input_flows": [ + { + "view": "v_feature_tm_customer_scd0", + "flow": "f_feature_tm_customer_scd0" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/table_migration_scd2_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/table_migration_scd2_main.json new file mode 100644 index 0000000..024f922 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/table_migration_scd2_main.json @@ -0,0 +1,68 @@ +{ + "data_flow_id": "table_migration_scd2", + "data_flow_group": "nodespec_feature_samples_table_migration", + "data_flow_type": "nodespec", + "nodes": [ + { + "name": "v_feature_tm_customer_scd2", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true, + "select_exp": [ + "CUSTOMER_ID", + "FIRST_NAME", + "LAST_NAME", + "EMAIL", + "LOAD_TIMESTAMP" + ] + } + }, + { + "name": "target_feature_migrated_table_scd2", + "node_type": "target", + "config": { + "table": "feature_migrated_table_scd2", + "table_properties": { + "delta.enableChangeDataFeed": "false" + }, + "cdc_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "scd_type": "2", + "sequence_by": "LOAD_TIMESTAMP", + "except_column_list": [], + "ignore_null_updates": true, + "apply_as_deletes": "" + }, + "table_migration_details": { + "catalog_type": "uc", + "enabled": true, + "auto_starting_versions_enabled": true, + "source_details": { + "database": "{bronze_schema}", + "table": "table_to_migrate_scd2", + "select_exp": [ + "CUSTOMER_ID", + "FIRST_NAME", + "LAST_NAME", + "EMAIL", + "EFFECTIVE_FROM AS __START_AT", + "EFFECTIVE_TO AS __END_AT" + ] + } + }, + "input_flows": [ + { + "view": "v_feature_tm_customer_scd2", + "flow": "f_feature_tm_customer_scd2" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/version_mapping_flows_dataflowspec_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/version_mapping_flows_dataflowspec_main.json new file mode 100644 index 0000000..1fe1362 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/version_mapping_flows_dataflowspec_main.json @@ -0,0 +1,80 @@ +{ + "data_flow_id": "version_mapping_flows", + "data_flow_group": "nodespec_feature_samples_general", + "data_flow_type": "nodespec", + "data_flow_version": "0.1.0", + "nodes": [ + { + "name": "target_feature_version_mapping_stage", + "node_type": "target", + "config": { + "table": "feature_version_mapping_stage", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cdc_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "sequence_by": "LOAD_TIMESTAMP", + "ignore_null_updates": true, + "scd_type": "1" + }, + "input_flows": [ + { + "view": "v_flows_version_mapping", + "flow": "f_flows_version_mapping" + } + ] + } + }, + { + "name": "v_flows_version_mapping", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "v_version_mapping_final", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "live", + "table": "feature_version_mapping_stage", + "cdf_enabled": true + } + }, + { + "name": "target_feature_version_mapping_flows", + "node_type": "target", + "config": { + "table": "feature_version_mapping_flows", + "schema_path": "target/feature_version_mapping_flows_schema.json", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cdc_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "sequence_by": "LOAD_TIMESTAMP", + "where": "", + "ignore_null_updates": false, + "scd_type": "1" + }, + "input_flows": [ + { + "view": "v_version_mapping_final", + "flow": "f_version_mapping_final" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/version_mapping_standard_dataflowspec_main.json b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/version_mapping_standard_dataflowspec_main.json new file mode 100644 index 0000000..fbbeee0 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dataflowspec/version_mapping_standard_dataflowspec_main.json @@ -0,0 +1,44 @@ +{ + "data_flow_id": "version_mapping", + "data_flow_group": "nodespec_feature_samples_general", + "data_flow_type": "nodespec", + "data_flow_version": "0.1.0", + "nodes": [ + { + "name": "v_version_mapping_standard", + "node_type": "source", + "source_type": "delta", + "config": { + "mode": "stream", + "database": "{staging_schema}", + "table": "customer", + "cdf_enabled": true + } + }, + { + "name": "target_v_version_mapping_standard", + "node_type": "target", + "config": { + "table": "v_version_mapping_standard", + "table_properties": { + "delta.enableChangeDataFeed": "true" + }, + "cdc_settings": { + "keys": [ + "CUSTOMER_ID" + ], + "sequence_by": "LOAD_TIMESTAMP", + "where": "", + "ignore_null_updates": false, + "scd_type": "1" + }, + "input_flows": [ + { + "view": "v_version_mapping_standard", + "flow": "f_version_mapping_standard" + } + ] + } + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dml/customer_purchase_transformed.sql b/samples/nodespec_sample/src/dataflows/feature_samples/dml/customer_purchase_transformed.sql new file mode 100644 index 0000000..9a4e59b --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dml/customer_purchase_transformed.sql @@ -0,0 +1,9 @@ +SELECT * +FROM ( + SELECT customer_id, product, quantity + FROM micro_batch_view +) src +PIVOT ( + SUM(quantity) + FOR product IN ('apples', 'bananas', 'oranges', 'pears') +) \ No newline at end of file diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/dml/feature_mv_sql_path.sql b/samples/nodespec_sample/src/dataflows/feature_samples/dml/feature_mv_sql_path.sql new file mode 100644 index 0000000..98bc595 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/dml/feature_mv_sql_path.sql @@ -0,0 +1 @@ +SELECT * FROM {staging_schema}.customer \ No newline at end of file diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/expectations/customer_address_dqe.json b/samples/nodespec_sample/src/dataflows/feature_samples/expectations/customer_address_dqe.json new file mode 100644 index 0000000..f35d49b --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/expectations/customer_address_dqe.json @@ -0,0 +1,15 @@ +{ + "expect_or_drop": [ + { + "name": "PK not null", + "constraint": "CUSTOMER_ID IS NOT NULL", + "tag": "Validity" + }, + { + "name": "enabledTest", + "constraint": "CUSTOMER_ID = 1", + "tag": "Validity", + "enabled": false + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/python_functions/feature_foreach_batch_python.py b/samples/nodespec_sample/src/dataflows/feature_samples/python_functions/feature_foreach_batch_python.py new file mode 100644 index 0000000..9fd61b9 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/python_functions/feature_foreach_batch_python.py @@ -0,0 +1,46 @@ +from pyspark.sql import DataFrame, SparkSession +from pyspark.sql import functions as F +from typing import Dict + +def micro_batch_function(df: DataFrame, batch_id: int, tokens: Dict) -> DataFrame: + bronze_schema = tokens["bronze_schema"] + staging_schema = tokens["staging_schema"] + staging_volume = tokens["staging_volume"] + volume_root_file_path = f"/Volumes/{staging_schema}/{staging_volume}".replace(".", "/") + spark = df.sparkSession + + df_transformed = ( + df.groupBy("customer_id") + .pivot("product", ["apples", "bananas", "oranges", "pears"]) + .agg(F.sum("quantity")) + ) + + # Support multiple writes without multiple reads + df_transformed.persist() + + # New UC Delta Table example + table_name = f"{bronze_schema}.feature_foreach_batch_python" + + write_command = df_transformed.write.format("delta") \ + .mode("append") + # You can add partitionBy and clusterBy here + + try: + spark.sql(f"DESCRIBE TABLE {table_name}") + write_command.saveAsTable(table_name) + except Exception: + # Create table if it does not exist + write_command.saveAsTable(table_name) + spark.sql(f"ALTER TABLE {table_name} SET TBLPROPERTIES (delta.enableChangeDataFeed = 'true')") + + # External Delta Table + df_transformed.write \ + .format("delta") \ + .mode("append") \ + .save(f"{volume_root_file_path}/feature_foreach_batch_python/delta_target") + + # JSON file location + df_transformed.write \ + .format("json") \ + .mode("append") \ + .save(f"{volume_root_file_path}/feature_foreach_batch_python/json_target") diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/python_functions/feature_python_function_source.py b/samples/nodespec_sample/src/dataflows/feature_samples/python_functions/feature_python_function_source.py new file mode 100644 index 0000000..12bd91d --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/python_functions/feature_python_function_source.py @@ -0,0 +1,15 @@ +from pyspark.sql import DataFrame, SparkSession +from pyspark.sql import functions as F +from typing import Dict + +def get_df(spark: SparkSession, tokens: Dict) -> DataFrame: + """ + Get a DataFrame from the source details with applied transformations. + """ + source_table = tokens["sourceTable"] + reader_options = { + "readChangeFeed": "true" + } + + df = spark.readStream.options(**reader_options).table(source_table) + return df.withColumn("TEST_COLUMN", F.lit("testing...")) diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/python_functions/feature_python_function_transform.py b/samples/nodespec_sample/src/dataflows/feature_samples/python_functions/feature_python_function_transform.py new file mode 100644 index 0000000..a4b15fd --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/python_functions/feature_python_function_transform.py @@ -0,0 +1,12 @@ +from pyspark.sql import DataFrame +from pyspark.sql import functions as F + +def apply_transform(df: DataFrame) -> DataFrame: + """ + Apply a transformation to the DataFrame. + """ + return ( + df.withWatermark("load_timestamp", "10 minutes") + .groupBy("CUSTOMER_ID") + .agg(F.count("*").alias("COUNT")) + ) diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/schemas/source/feature_historic_snapshot_customer_schema.json b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/source/feature_historic_snapshot_customer_schema.json new file mode 100644 index 0000000..1ba5997 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/source/feature_historic_snapshot_customer_schema.json @@ -0,0 +1,35 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "FIRST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LAST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "EMAIL", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LOAD_TIMESTAMP", + "type": "string", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/customer_address_schema.json b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/customer_address_schema.json new file mode 100644 index 0000000..c8460d9 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/customer_address_schema.json @@ -0,0 +1,29 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "integer", + "nullable": true, + "metadata": {} + }, + { + "name": "CITY", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "STATE", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LOAD_TIMESTAMP", + "type": "timestamp", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/customer_schema.json b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/customer_schema.json new file mode 100644 index 0000000..30b679e --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/customer_schema.json @@ -0,0 +1,41 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "FIRST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LAST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "EMAIL", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "DELETE_FLAG", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LOAD_TIMESTAMP", + "type": "string", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_ddl_schema.ddl b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_ddl_schema.ddl new file mode 100644 index 0000000..d525792 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_ddl_schema.ddl @@ -0,0 +1,8 @@ +CUSTOMER_ID integer NOT NULL, +FIRST_NAME string, +LAST_NAME string, +EMAIL string, +DELETE_FLAG boolean, +LOAD_TIMESTAMP timestamp, +YEAR_TEST INT GENERATED ALWAYS AS (YEAR(LOAD_TIMESTAMP)) +-- CONSTRAINT pk_customer PRIMARY KEY(CUSTOMER_ID) \ No newline at end of file diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_historic_snapshot_customer_schema.json b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_historic_snapshot_customer_schema.json new file mode 100644 index 0000000..a54c2b2 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_historic_snapshot_customer_schema.json @@ -0,0 +1,35 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "integer", + "nullable": true, + "metadata": {} + }, + { + "name": "FIRST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LAST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "EMAIL", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LOAD_TIMESTAMP", + "type": "timestamp", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_periodic_snapshot_customer_schema.json b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_periodic_snapshot_customer_schema.json new file mode 100644 index 0000000..d543bcd --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_periodic_snapshot_customer_schema.json @@ -0,0 +1,41 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "integer", + "nullable": true, + "metadata": {} + }, + { + "name": "FIRST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LAST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "EMAIL", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "DELETE_FLAG", + "type": "boolean", + "nullable": true, + "metadata": {} + }, + { + "name": "LOAD_TIMESTAMP", + "type": "timestamp", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_python_function_source_schema.json b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_python_function_source_schema.json new file mode 100644 index 0000000..1e17a11 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_python_function_source_schema.json @@ -0,0 +1,47 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "integer", + "nullable": true, + "metadata": {} + }, + { + "name": "FIRST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LAST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "EMAIL", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "DELETE_FLAG", + "type": "boolean", + "nullable": true, + "metadata": {} + }, + { + "name": "TEST_COLUMN", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LOAD_TIMESTAMP", + "type": "timestamp", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_python_function_transform_schema.json b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_python_function_transform_schema.json new file mode 100644 index 0000000..81366e8 --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_python_function_transform_schema.json @@ -0,0 +1,17 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "integer", + "nullable": true, + "metadata": {} + }, + { + "name": "COUNT", + "type": "long", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_version_mapping_flows_schema.json b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_version_mapping_flows_schema.json new file mode 100644 index 0000000..d543bcd --- /dev/null +++ b/samples/nodespec_sample/src/dataflows/feature_samples/schemas/target/feature_version_mapping_flows_schema.json @@ -0,0 +1,41 @@ +{ + "type": "struct", + "fields": [ + { + "name": "CUSTOMER_ID", + "type": "integer", + "nullable": true, + "metadata": {} + }, + { + "name": "FIRST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "LAST_NAME", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "EMAIL", + "type": "string", + "nullable": true, + "metadata": {} + }, + { + "name": "DELETE_FLAG", + "type": "boolean", + "nullable": true, + "metadata": {} + }, + { + "name": "LOAD_TIMESTAMP", + "type": "timestamp", + "nullable": true, + "metadata": {} + } + ] +} diff --git a/samples/nodespec_sample/src/extensions/sources.py b/samples/nodespec_sample/src/extensions/sources.py new file mode 100644 index 0000000..72538eb --- /dev/null +++ b/samples/nodespec_sample/src/extensions/sources.py @@ -0,0 +1,31 @@ +""" +Python source extensions for the bronze sample pipeline. + +These functions are loaded via the pythonModule reference in dataflow specs +and are available because the extensions directory is added to sys.path +during pipeline initialization. +""" +from pyspark.sql import DataFrame, SparkSession +from pyspark.sql import functions as F +from typing import Dict + + +def get_customer_cdf(spark: SparkSession, tokens: Dict) -> DataFrame: + """ + Get customer data with Change Data Feed enabled. + + Args: + spark: SparkSession instance + tokens: Dictionary of tokens from the dataflow spec + + Returns: + DataFrame with customer data and a TEST_COLUMN added + """ + source_table = tokens["sourceTable"] + reader_options = { + "readChangeFeed": "true" + } + + df = spark.readStream.options(**reader_options).table(source_table) + return df.withColumn("TEST_COLUMN", F.lit("testing from extension...")) + diff --git a/samples/nodespec_sample/src/extensions/transforms.py b/samples/nodespec_sample/src/extensions/transforms.py new file mode 100644 index 0000000..0fff8df --- /dev/null +++ b/samples/nodespec_sample/src/extensions/transforms.py @@ -0,0 +1,57 @@ +""" +Python transform extensions for the bronze sample pipeline. + +These functions are loaded via the pythonTransform.module reference in dataflow specs +and are available because the extensions directory is added to sys.path +during pipeline initialization. + +Transform functions receive a DataFrame and optionally tokens, and return a DataFrame. +""" +from pyspark.sql import DataFrame +from pyspark.sql import functions as F +from typing import Dict + + +def customer_aggregation(df: DataFrame) -> DataFrame: + """ + Apply customer aggregation transformation. + + Groups by CUSTOMER_ID and counts records within a 10-minute watermark window. + + Args: + df: Input DataFrame with customer data + + Returns: + DataFrame with CUSTOMER_ID and COUNT columns + """ + return ( + df.withWatermark("load_timestamp", "10 minutes") + .groupBy("CUSTOMER_ID") + .agg(F.count("*").alias("COUNT")) + ) + + +def customer_aggregation_with_tokens(df: DataFrame, tokens: Dict) -> DataFrame: + """ + Apply customer aggregation transformation with configurable parameters. + + Args: + df: Input DataFrame with customer data + tokens: Configuration tokens with: + - watermark_column: Column to use for watermark (default: load_timestamp) + - watermark_delay: Watermark delay duration (default: 10 minutes) + - group_by_column: Column to group by (default: CUSTOMER_ID) + + Returns: + DataFrame with grouped counts + """ + watermark_column = tokens.get("watermarkColumn", "load_timestamp") + watermark_delay = tokens.get("watermarkDelay", "10 minutes") + group_by_column = tokens.get("groupByColumn", "CUSTOMER_ID") + + return ( + df.withWatermark(watermark_column, watermark_delay) + .groupBy(group_by_column) + .agg(F.count("*").alias("COUNT")) + ) + diff --git a/samples/nodespec_sample/src/pipeline_configs/dev_substitutions.json b/samples/nodespec_sample/src/pipeline_configs/dev_substitutions.json new file mode 100644 index 0000000..5ce2264 --- /dev/null +++ b/samples/nodespec_sample/src/pipeline_configs/dev_substitutions.json @@ -0,0 +1,13 @@ +{ + "tokens": { + "staging_schema": "main.lakeflow_samples_staging{logical_env}", + "bronze_schema": "main.lakeflow_samples_bronze{logical_env}", + "silver_schema": "main.lakeflow_samples_silver{logical_env}", + "staging_volume": "stg_volume", + "sample_file_location": "/Volumes/main/lakeflow_samples_staging{logical_env}/stg_volume", + "kafka_sink_servers": "your_kafka_sink_server_urls", + "kafka_sink_topic": "your_topic_with_{logical_env}", + "kafka_source_servers": "your_kafka_source_server_urls", + "kafka_source_topic": "your_kafka_source_topic" + } +} diff --git a/samples/nodespec_sample/src/pipeline_configs/global.json b/samples/nodespec_sample/src/pipeline_configs/global.json new file mode 100644 index 0000000..3554532 --- /dev/null +++ b/samples/nodespec_sample/src/pipeline_configs/global.json @@ -0,0 +1,6 @@ +{ + "logLevel": "INFO", + "specFileFormat": "json", + "table_migration_state_volume_path": "/Volumes/main/lakeflow_samples_staging{logical_env}/stg_volume/checkpoint_state" +} + diff --git a/samples/nodespec_sample/tests/__init__.py b/samples/nodespec_sample/tests/__init__.py new file mode 100644 index 0000000..ebcebe0 --- /dev/null +++ b/samples/nodespec_sample/tests/__init__.py @@ -0,0 +1 @@ +# Nodespec Sample Tests diff --git a/samples/nodespec_sample/tests/test_placeholder.py b/samples/nodespec_sample/tests/test_placeholder.py new file mode 100644 index 0000000..816a6ba --- /dev/null +++ b/samples/nodespec_sample/tests/test_placeholder.py @@ -0,0 +1,11 @@ +""" +Placeholder tests for the Nodespec sample. + +To run integration tests, deploy this bundle and verify the pipelines +execute successfully using the Databricks CLI or UI. +""" + + +def test_placeholder(): + """Placeholder test to verify pytest configuration.""" + assert True diff --git a/samples/pattern-samples/src/notebooks/initialize.ipynb b/samples/pattern-samples/src/notebooks/initialize.ipynb index e4c7d64..1f61618 100644 --- a/samples/pattern-samples/src/notebooks/initialize.ipynb +++ b/samples/pattern-samples/src/notebooks/initialize.ipynb @@ -35,6 +35,7 @@ "silver_schema = f'{catalog}.{schema_namespace}_silver{logical_env}'\n", "gold_schema = f'{catalog}.{schema_namespace}_gold{logical_env}'\n", "dpm_schema = f'{catalog}.{schema_namespace}_dpm{logical_env}'\n", + "yaml_schema = f'{catalog}.{schema_namespace}_yaml{logical_env}'\n", "staging_volume = \"stg_volume\"\n", "\n", "volume_root_file_path = f\"/Volumes/{staging_schema}/{staging_volume}\".replace(\".\", \"/\")\n", diff --git a/scripts/migrate_to_nodespec.py b/scripts/migrate_to_nodespec.py new file mode 100644 index 0000000..f2cc501 --- /dev/null +++ b/scripts/migrate_to_nodespec.py @@ -0,0 +1,653 @@ +#!/usr/bin/env python3 +""" +Migrate Lakeflow Framework dataflow specs to Nodespec format. + +Converts standard, flow, and materialized_view specs into the node-based +Nodespec specification format. + +Nodespec specs are snake_case throughout, so this script converts the +camelCase field names used by the standard/flow/materialized_view formats +(e.g. ``cdfEnabled`` -> ``cdf_enabled``, ``tableProperties`` -> +``table_properties``) while leaving opaque value maps (table properties, +reader options, spark conf, tokens) untouched. + +Target wiring and feature settings use the current nodespec shape: targets +declare their inputs via ``input_flows``; data quality is a nested +``data_quality`` object; quarantine is a nested ``quarantine`` object +(``mode`` + optional ``target``). Sink targets keep their flat +``sink_type``/``sink_config``/``sink_options`` fields. Output is emitted in a +canonical field order (identity, then table/structural details, then feature +bundles, then ``input_flows``). Passing an existing nodespec spec normalises any +legacy fields and reorders it (idempotent). + +Usage: + python migrate_to_nodespec.py [--output ] + python migrate_to_nodespec.py --output-dir [--recursive] + +Examples: + # Single file + python migrate_to_nodespec.py spec_main.json --output nodespec_spec_main.json + + # Directory (all *_main.json files) + python migrate_to_nodespec.py ./dataflowspec/ --output-dir ./nodespec_dataflowspec/ +""" +import argparse +import json +import os +import sys +from typing import Dict, List, Optional, Any, Tuple + + +# ─── camelCase -> snake_case key maps ──────────────────────────────────────── +# Only structural keys are renamed. Values of opaque maps (table properties, +# reader options, spark conf, tokens) are copied verbatim — never recursed into. + +_SOURCE_DETAIL_MAP = { + "cdfEnabled": "cdf_enabled", + "tablePath": "table_path", + "readerOptions": "reader_options", + "functionPath": "function_path", + "pythonModule": "python_module", + "sqlPath": "sql_path", + "sqlStatement": "sql_statement", + "selectExp": "select_exp", + "whereClause": "where_clause", + "schemaPath": "schema_path", + "startingVersionFromDLTSetup": "starting_version_from_dlt_setup", + "cdfChangeTypeOverride": "cdf_change_type_override", + "pythonTransform": "python_transform", +} + +_TARGET_DETAIL_MAP = { + "schemaPath": "schema_path", + "tableProperties": "table_properties", + "partitionColumns": "partition_columns", + "clusterByColumns": "cluster_by_columns", + "clusterByAuto": "cluster_by_auto", + "sparkConf": "spark_conf", + "rowFilter": "row_filter", + "configFlags": "config_flags", +} + +# Snapshot settings carry camelCase keys at both the top level and inside the +# nested `source` object. All are converted to snake_case for nodespec; the +# transformer maps them back to the backend's camelCase at build time. +_SNAPSHOT_MAP = { + "snapshotType": "snapshot_type", + "sourceType": "source_type", + "versionType": "version_type", + "versionColumn": "version_column", + "startingVersion": "starting_version", + "datetimeFormat": "datetime_format", + "readerOptions": "reader_options", + "schemaPath": "schema_path", + "selectExp": "select_exp", + "deduplicateMode": "deduplicate_mode", + "recursiveFileLookup": "recursive_file_lookup", +} + +_QUARANTINE_MAP = { + "targetFormat": "target_format", + "clusterByAuto": "cluster_by_auto", + "clusterByColumns": "cluster_by_columns", + "partitionColumns": "partition_columns", +} + +_TABLE_MIGRATION_MAP = { + "catalogType": "catalog_type", + "autoStartingVersionsEnabled": "auto_starting_versions_enabled", + "sourceDetails": "source_details", + "tableName": "table_name", +} + +# Inner keys of table_migration_details.source_details (a delta source shape). +_MIGRATION_SOURCE_MAP = { + "selectExp": "select_exp", + "whereClause": "where_clause", + "exceptColumns": "except_columns", +} + +_PYTHON_TRANSFORM_MAP = { + "functionPath": "function_path", + "pythonModule": "python_module", + "module": "module", +} + + +def _rename(d: Dict, mapping: Dict[str, str]) -> Dict: + """Return a new dict with top-level keys renamed per mapping; values as-is.""" + if not isinstance(d, dict): + return d + return {mapping.get(k, k): v for k, v in d.items()} + + +# ─── canonical field ordering ──────────────────────────────────────────────── +# A single, readable order applied to emitted specs, nodes, and configs so that +# generated (and hand-authored) specs read consistently: identity first, then +# table/structural details, then feature bundles, with input_flows (the wiring) +# last. Keys not listed keep their original relative order, after the known ones. + +_SPEC_ORDER = ["data_flow_id", "data_flow_group", "data_flow_type", + "data_flow_version", "tags", "features", "nodes"] + +_NODE_ORDER = ["name", "node_type", "source_type", "transformation_type", + "target_type", "output_view_name", "enabled", "config"] + +_CONFIG_ORDER = [ + "mode", "database", "table", "table_path", "path", "format", + "cdf_enabled", "cdf_change_type_override", "starting_version_from_dlt_setup", + "table_type", + "function_path", "python_module", "sql_path", "sql_statement", "tokens", + "select_exp", "where_clause", "schema_path", "reader_options", "python_transform", + "table_properties", "partition_columns", "cluster_by_columns", "cluster_by_auto", + "comment", "spark_conf", "row_filter", "config_flags", + "refresh_policy", "private", "once", + "cdc_settings", "cdc_snapshot_settings", "data_quality", "quarantine", + "table_migration_details", + "name", "sink_type", "sink_config", "sink_options", + "input_flows", +] + + +def _order(d: Dict, order: List[str]) -> Dict: + """Return d with keys in `order` first (when present), remaining keys after.""" + if not isinstance(d, dict): + return d + known = [k for k in order if k in d] + rest = [k for k in d if k not in order] + return {k: d[k] for k in known + rest} + + +# nodespec authors enum VALUES in snake_case; legacy formats use camelCase for +# a few of them, so snake them during migration. +_SOURCE_TYPE_TO_SNAKE = {"cloudFiles": "cloud_files", "batchFiles": "batch_files", "deltaJoin": "delta_join"} +_CONFIG_FLAG_TO_SNAKE = {"disableOperationalMetadata": "disable_operational_metadata"} + + +def _snake_config_flag_values(obj) -> None: + """Recursively snake `config_flags` array values in place.""" + if isinstance(obj, dict): + for k, v in obj.items(): + if k == "config_flags" and isinstance(v, list): + obj[k] = [_CONFIG_FLAG_TO_SNAKE.get(x, x) if isinstance(x, str) else x for x in v] + else: + _snake_config_flag_values(v) + elif isinstance(obj, list): + for i in obj: + _snake_config_flag_values(i) + + +def _order_node(node: Dict) -> Dict: + """Order a node's keys and its config's keys canonically, and snake enum values.""" + if node.get("source_type") in _SOURCE_TYPE_TO_SNAKE: + node["source_type"] = _SOURCE_TYPE_TO_SNAKE[node["source_type"]] + node = _order(node, _NODE_ORDER) + if isinstance(node.get("config"), dict): + _snake_config_flag_values(node["config"]) + node["config"] = _order(node["config"], _CONFIG_ORDER) + return node + + +def _convert_source_details(details: Dict) -> Dict: + """camelCase -> snake_case for a source/view sourceDetails block.""" + out = _rename(details, _SOURCE_DETAIL_MAP) + if isinstance(out.get("python_transform"), dict): + out["python_transform"] = _rename(out["python_transform"], _PYTHON_TRANSFORM_MAP) + return out + + +def _convert_snapshot(cs: Dict) -> Dict: + """camelCase -> snake_case for cdcSnapshotSettings, including nested source.""" + out = _rename(cs, _SNAPSHOT_MAP) + if isinstance(out.get("source"), dict): + out["source"] = _rename(out["source"], _SNAPSHOT_MAP) + return out + + +def _get(src: Dict, camel: str, snake: str): + """Read a value that may be present under either camelCase or snake_case.""" + if camel in src: + return src[camel] + return src.get(snake) + + +def _add_target_settings(config: Dict, src: Dict) -> None: + """Copy CDC / DQ / quarantine / table-migration settings onto a target config. + + `src` is the spec (standard), a staging-table config (flow), or an MV config. + Reads either casing; writes snake_case. + """ + # nodespec has a single CDC field (cdc_settings). Legacy cdcApplyChanges is + # the same thing, so migrate either source key into cdc_settings. + cdc = (_get(src, "cdcSettings", "cdc_settings") + or _get(src, "cdcApplyChanges", "cdc_apply_changes")) + if cdc: + config["cdc_settings"] = cdc + snapshot = _get(src, "cdcSnapshotSettings", "cdc_snapshot_settings") + if snapshot: + config["cdc_snapshot_settings"] = _convert_snapshot(snapshot) + + # Data quality collapses to a single nested object; presence implies enabled, + # so the standalone enabled flag is dropped. + dq_path = _get(src, "dataQualityExpectationsPath", "data_quality_expectations_path") + if dq_path: + config["data_quality"] = {"expectations_path": dq_path} + + # Quarantine collapses mode + target details into one nested object. + # Mode "off" (or absent) means no quarantine, so no object is emitted. + quarantine_mode = _get(src, "quarantineMode", "quarantine_mode") + if quarantine_mode and quarantine_mode != "off": + quarantine: Dict[str, Any] = {"mode": quarantine_mode} + quarantine_details = _get(src, "quarantineTargetDetails", "quarantine_target_details") + if quarantine_details: + quarantine["target"] = _rename(quarantine_details, _QUARANTINE_MAP) + config["quarantine"] = quarantine + + table_migration = _get(src, "tableMigrationDetails", "table_migration_details") + if table_migration: + tm = _rename(table_migration, _TABLE_MIGRATION_MAP) + if isinstance(tm.get("source_details"), dict): + tm["source_details"] = _rename(tm["source_details"], _MIGRATION_SOURCE_MAP) + config["table_migration_details"] = tm + + +def _result_envelope(spec: Dict, nodes: List[Dict]) -> Dict: + """Build the top-level nodespec spec (snake_case metadata).""" + result = { + "data_flow_id": spec.get("dataFlowId") or spec.get("data_flow_id"), + "data_flow_group": spec.get("dataFlowGroup") or spec.get("data_flow_group"), + "data_flow_type": "nodespec", + "nodes": nodes, + } + version = _get(spec, "dataFlowVersion", "data_flow_version") + if version: + result["data_flow_version"] = version + if spec.get("tags"): + result["tags"] = spec["tags"] + if spec.get("features"): + result["features"] = spec["features"] + result["nodes"] = [_order_node(n) for n in nodes] + return _order(result, _SPEC_ORDER) + + +def _migrate_nodespec_config(cfg: Dict) -> None: + """Migrate a single node config's legacy fields to the current shape, in place.""" + if "input" in cfg and "input_flows" not in cfg: + cfg["input_flows"] = cfg.pop("input") + path = cfg.pop("data_quality_expectations_path", None) + cfg.pop("data_quality_expectations_enabled", None) + if path and "data_quality" not in cfg: + cfg["data_quality"] = {"expectations_path": path} + mode = cfg.pop("quarantine_mode", None) + qtd = cfg.pop("quarantine_target_details", None) + if mode and mode != "off" and "quarantine" not in cfg: + quarantine: Dict[str, Any] = {"mode": mode} + if qtd: + quarantine["target"] = qtd + cfg["quarantine"] = quarantine + # table_details is dropped: its fields (private + comment/spark_conf/config_flags) + # move to the config top level. + td = cfg.pop("table_details", None) + if isinstance(td, dict): + for k, v in td.items(): + cfg.setdefault(k, v) + + +def _migrate_nodespec(spec: Dict) -> Dict: + """Normalise an existing nodespec spec: migrate any legacy config fields + (input -> input_flows; data_quality_expectations_* -> data_quality; + quarantine_mode/quarantine_target_details -> quarantine) and apply the + canonical field order. Idempotent for already-current specs.""" + for node in spec.get("nodes", []): + if isinstance(node.get("config"), dict): + _migrate_nodespec_config(node["config"]) + spec["nodes"] = [_order_node(n) for n in spec.get("nodes", [])] + return _order(spec, _SPEC_ORDER) + + +def migrate_spec(spec: Dict) -> Dict: + """Convert a dataflow spec to nodespec format based on its dataFlowType.""" + spec_type = (spec.get("dataFlowType") or spec.get("data_flow_type") or "standard").lower() + + if spec_type == "standard": + return _migrate_standard(spec) + elif spec_type == "flow": + return _migrate_flow(spec) + elif spec_type == "materialized_view": + return _migrate_materialized_view(spec) + elif spec_type == "nodespec": + return _migrate_nodespec(spec) # normalise legacy fields + canonical order + else: + print(f" Warning: Unknown dataFlowType '{spec_type}', treating as standard") + return _migrate_standard(spec) + + +# ─── Standard Spec Migration ───────────────────────────────────────────────── + +def _migrate_standard(spec: Dict) -> Dict: + """Convert a standard spec to nodespec.""" + # Historical snapshot targets read files/tables directly (via + # cdc_snapshot_settings) and have no source node — the standard spec carries + # no sourceDetails in that case, so don't synthesise one. + snapshot = _get(spec, "cdcSnapshotSettings", "cdc_snapshot_settings") or {} + is_historical_snapshot = _get(snapshot, "snapshotType", "snapshot_type") == "historical" + + nodes: List[Dict] = [] + source_id: Optional[str] = None + if not is_historical_snapshot: + source_id = spec.get("sourceViewName") or "v_source" + nodes.append(_build_source_node(source_id, spec.get("sourceType", "delta"), + spec.get("sourceDetails", {}), spec.get("mode", "stream"))) + + target_config, target_type = _build_spec_target(spec) + if source_id: + target_config["input_flows"] = [source_id] + target_name = target_config.get("table") or target_config.get("name") or "output" + target_node: Dict[str, Any] = {"name": f"target_{target_name}", "node_type": "target"} + if target_type != "delta": + target_node["target_type"] = target_type + target_node["config"] = target_config + nodes.append(target_node) + + return _result_envelope(spec, nodes) + + +def _build_source_node(name: str, source_type: str, source_details: Dict, mode: str) -> Dict: + """Build a nodespec source node (snake_case) from camelCase source details.""" + config: Dict[str, Any] = {} + if mode: + config["mode"] = mode + config.update(_convert_source_details(source_details)) + + node: Dict[str, Any] = {"name": name, "node_type": "source", "source_type": source_type} + if config: + node["config"] = config + return node + + +def _build_spec_target(spec: Dict) -> Tuple[Dict, str]: + """Build the spec-level target config (delta or sink). Returns (config, target_type).""" + target_details = spec.get("targetDetails", {}) + target_format = spec.get("targetFormat", "delta") + + if target_format != "delta": + # Sink target: map the known sink fields to snake_case. + config = {} + if "name" in target_details: + config["name"] = target_details["name"] + if "type" in target_details: + config["sink_type"] = target_details["type"] + if "config" in target_details: + config["sink_config"] = target_details["config"] + sink_options = _get(target_details, "sinkOptions", "sink_options") + if sink_options is not None: + config["sink_options"] = sink_options + else: + config = _rename(target_details, _TARGET_DETAIL_MAP) + + _add_target_settings(config, spec) + return config, target_format + + +# ─── Flow Spec Migration ───────────────────────────────────────────────────── + +def _migrate_flow(spec: Dict) -> Dict: + """Convert a flow spec to nodespec.""" + nodes: List[Dict] = [] + node_ids: set = set() + + flow_groups = spec.get("flowGroups", []) + + staging_table_names = set() + for fg in flow_groups: + staging_table_names.update(fg.get("stagingTables", {}).keys()) + + def find_target_node(target_name: str) -> Optional[Dict]: + for n in nodes: + if n["name"] == f"target_{target_name}": + return n + return None + + for fg in flow_groups: + staging_tables = fg.get("stagingTables", {}) + flows = fg.get("flows", {}) + + # Staging tables -> target nodes (inputs filled while processing flows). + for stg_name, stg_config in staging_tables.items(): + target_id = f"target_{stg_name}" + if target_id in node_ids: + continue + node_ids.add(target_id) + config = _build_target_config_from_staging(stg_name, stg_config) + config["input_flows"] = [] + nodes.append({"name": target_id, "node_type": "target", "config": config}) + + for flow_name, flow_config in flows.items(): + flow_type = flow_config.get("flowType") + flow_details = flow_config.get("flowDetails", {}) + target_table = flow_details.get("targetTable") + views = flow_config.get("views", {}) + + source_node_id = None + + # Views -> source nodes. + for view_name, view_config in views.items(): + if view_name not in node_ids: + node_ids.add(view_name) + nodes.append(_build_source_node( + view_name, + view_config.get("sourceType", "delta"), + view_config.get("sourceDetails", {}), + view_config.get("mode", "stream"), + )) + source_node_id = view_name + + # append_sql flows carry SQL directly in flowDetails (no view). + if flow_type == "append_sql": + sql_source_id = f"v_sql_{flow_name}" + if sql_source_id not in node_ids: + node_ids.add(sql_source_id) + sql_config: Dict[str, Any] = {} + if flow_details.get("sqlStatement"): + sql_config["sql_statement"] = flow_details["sqlStatement"] + elif flow_details.get("sqlPath"): + sql_config["sql_path"] = flow_details["sqlPath"] + node: Dict[str, Any] = {"name": sql_source_id, "node_type": "source", "source_type": "sql"} + if sql_config: + node["config"] = sql_config + nodes.append(node) + source_node_id = sql_source_id + + # Fall back to an explicit sourceView reference. When that reference + # is a staging table (a table produced by another target in this + # spec), nodespec models it as an explicit "internal" source node + # that reads the staging table; the transformer auto-detects it. + if not source_node_id and flow_details.get("sourceView"): + source_view = flow_details["sourceView"] + staging_key = source_view.split(".")[-1] + if staging_key in staging_table_names: + internal_id = source_view if source_view.startswith("v_") else f"v_{staging_key}" + if internal_id not in node_ids: + node_ids.add(internal_id) + nodes.append({ + "name": internal_id, + "node_type": "source", + "source_type": "delta", + "config": {"mode": "stream", "table": staging_key}, + }) + source_node_id = internal_id + else: + source_node_id = source_view + + if not (target_table and source_node_id): + continue + + # Strip any schema qualifier from the target table name when matching. + target_key = target_table.split(".")[-1] + target_node = find_target_node(target_key) + if target_node is not None: + target_node["config"].setdefault("input_flows", []).append(source_node_id) + else: + # Main (spec-level) target (delta or sink). + target_id = f"target_{target_key}" + if target_id not in node_ids: + node_ids.add(target_id) + main_config, target_type = _build_spec_target(spec) + if flow_details.get("once"): + main_config["once"] = True + main_config["input_flows"] = [source_node_id] + main_node: Dict[str, Any] = {"name": target_id, "node_type": "target"} + if target_type != "delta": + main_node["target_type"] = target_type + main_node["config"] = main_config + nodes.append(main_node) + + # Drop placeholder empty input lists. + for node in nodes: + config = node.get("config", {}) + if config.get("input_flows") == []: + del config["input_flows"] + + return _result_envelope(spec, nodes) + + +def _build_target_config_from_staging(table_name: str, stg_config: Dict) -> Dict: + """Build a target config from a flow staging-table definition.""" + config: Dict[str, Any] = {"table": table_name} + config.update(_rename( + {k: v for k, v in stg_config.items() if k != "type"}, + _TARGET_DETAIL_MAP, + )) + # Drop settings handled separately so they get correct snake_case + conversion. + for k in ("cdcSettings", "cdc_settings", "cdcApplyChanges", "cdc_apply_changes", + "cdcSnapshotSettings", "cdc_snapshot_settings", + "dataQualityExpectationsEnabled", "data_quality_expectations_enabled", + "dataQualityExpectationsPath", "data_quality_expectations_path", + "quarantineMode", "quarantine_mode", + "quarantineTargetDetails", "quarantine_target_details"): + config.pop(k, None) + _add_target_settings(config, stg_config) + return config + + +# ─── Materialized View Spec Migration ───────────────────────────────────────── + +def _migrate_materialized_view(spec: Dict) -> Dict: + """Convert a materialized_view spec to nodespec.""" + materialized_views = spec.get("materializedViews", {}) + nodes: List[Dict] = [] + + for mv_name, mv_config in materialized_views.items(): + target_config: Dict[str, Any] = {"table": mv_name, "table_type": "mv"} + + for camel, snake in (("sqlPath", "sql_path"), ("sqlStatement", "sql_statement"), + ("refreshPolicy", "refresh_policy")): + val = _get(mv_config, camel, snake) + if val is not None: + target_config[snake] = val + + # Legacy MV tableDetails settings are flattened onto the target config + # top-level (nodespec has no separate table_details object; MV-only + # `private` is a top-level field). + table_details = _get(mv_config, "tableDetails", "table_details") + if table_details: + target_config.update(_rename(table_details, _TARGET_DETAIL_MAP)) + + _add_target_settings(target_config, mv_config) + + # MV source views are no longer inlined on the target: emit a source node + # and chain it into the MV via `input`. + source_view = _get(mv_config, "sourceView", "source_view") + if isinstance(source_view, dict) and source_view: + source_id = (source_view.get("sourceViewName") + or source_view.get("source_view_name") + or f"v_source_{mv_name}") + nodes.append(_build_source_node( + source_id, + source_view.get("sourceType") or source_view.get("source_type", "delta"), + source_view.get("sourceDetails") or source_view.get("source_details", {}), + "batch", + )) + target_config["input_flows"] = [source_id] + + nodes.append({"name": f"target_{mv_name}", "node_type": "target", "config": target_config}) + + return _result_envelope(spec, nodes) + + +# ─── CLI ────────────────────────────────────────────────────────────────────── + +def process_file(input_path: str, output_path: Optional[str] = None) -> str: + """Process a single spec file.""" + with open(input_path, "r") as f: + spec = json.load(f) + + result = migrate_spec(spec) + + if output_path is None: + output_path = input_path + + with open(output_path, "w") as f: + json.dump(result, f, indent=4) + f.write("\n") + + return output_path + + +def main(): + parser = argparse.ArgumentParser( + description="Migrate Lakeflow Framework specs to Nodespec format" + ) + parser.add_argument("input", help="Input spec file or directory") + parser.add_argument("--output", "-o", help="Output file (for single file mode)") + parser.add_argument("--output-dir", "-d", help="Output directory (for directory mode)") + parser.add_argument("--recursive", "-r", action="store_true", + help="Process directories recursively") + parser.add_argument("--dry-run", action="store_true", + help="Show what would be done without writing files") + + args = parser.parse_args() + + if os.path.isfile(args.input): + output = args.output or args.input.replace(".json", "_nodespec.json") + if args.dry_run: + print(f"Would convert: {args.input} → {output}") + else: + result = process_file(args.input, output) + print(f"Converted: {args.input} → {result}") + + elif os.path.isdir(args.input): + output_dir = args.output_dir or args.input + os.makedirs(output_dir, exist_ok=True) + + count = 0 + for root, dirs, files in os.walk(args.input): + for fname in sorted(files): + if fname.endswith("_main.json"): + input_path = os.path.join(root, fname) + rel = os.path.relpath(input_path, args.input) + output_path = os.path.join(output_dir, rel) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + if args.dry_run: + print(f"Would convert: {input_path} → {output_path}") + else: + try: + process_file(input_path, output_path) + print(f" ✓ {fname}") + count += 1 + except Exception as e: + print(f" ✗ {fname}: {e}") + + if not args.recursive: + break + + if not args.dry_run: + print(f"\nConverted {count} files to {output_dir}") + else: + print(f"Error: {args.input} not found") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/dataflow/dataflow.py b/src/dataflow/dataflow.py index 7cef5c8..f057563 100644 --- a/src/dataflow/dataflow.py +++ b/src/dataflow/dataflow.py @@ -39,15 +39,18 @@ class DataFlow: cdc_settings (CDCSettings): The CDC settings. cdc_snapshot_settings (CDCSnapshotSettings): The CDC snapshot settings. - # expectations - expectations_enabled (bool): Whether expectations are enabled. - expectations (DataQualityExpectations): The data quality expectations. + # expectations (for main target) + expectations_enabled (bool): Whether expectations are enabled for main target. + expectations (DataQualityExpectations): The data quality expectations for main target. expectations_clause (Dict): The expectations clause for the SDP create table api. - # quarantine settings - quarantine_enabled (bool): Whether quarantine is enabled. - quarantine_manager (QuarantineManager): The quarantine manager. - quarantine_mode (str): The quarantine mode. + # quarantine settings (for main target) + quarantine_enabled (bool): Whether quarantine is enabled for main target. + quarantine_manager (QuarantineManager): The quarantine manager for main target. + quarantine_mode (str): The quarantine mode for main target. + + # per-target quarantine managers + staging_quarantine_managers (Dict): Quarantine managers per staging table. # table migration settings table_migration_manager (TableMigrationManager): The table migration manager. @@ -92,6 +95,9 @@ def __init__( self._init_expectations() self._init_quarantine() self._init_table_migration() + + # Initialize per-staging-table quarantine managers (populated during flow group creation) + self.staging_quarantine_managers: Dict[str, QuarantineManager] = {} def _init_target_details(self): """init target details from the dataflow specification.""" @@ -268,7 +274,14 @@ def _create_flow_group(self, flow_group: FlowGroup): if staging_tables: self.logger.info("Creating Staging Tables...") for staging_table in staging_tables.values(): - staging_table.create_table() + # Initialize DQ and quarantine for this staging table + staging_expectations = self._init_staging_table_expectations(staging_table) + self._init_staging_table_quarantine(staging_table, staging_expectations) + # Add SCD columns to staging table schema if it has a schema and CDC/CDC Snapshot SCD2 + self._init_staging_table_cdc_settings(staging_table) + + # Create the staging table with expectations if enabled + staging_table.create_table(staging_expectations) # Support direct historical snapshots into Staging Tables in Flows cdc_snapshot_settings = staging_table.get_cdc_snapshot_settings() @@ -294,6 +307,93 @@ def _create_flow_group(self, flow_group: FlowGroup): else: self.logger.info("Flow Disabled: %s", flow.flowName) + def _init_staging_table_expectations(self, staging_table: StagingTable) -> Dict: + """Initialize data quality expectations for a staging table.""" + if not staging_table.dataQualityExpectationsEnabled: + return None + + self.logger.info("Initializing DQ expectations for staging table: %s", staging_table.table) + + expectations = staging_table.get_data_quality_expectations() + if expectations is None: + self.logger.warning( + "DQ enabled for staging table %s but no expectations loaded", + staging_table.table + ) + return None + + self.logger.debug("Expectations for %s: %s", staging_table.table, expectations.__dict__) + + # For quarantine FLAG mode, return expect_all format + if (staging_table.has_quarantine_enabled() + and staging_table.quarantineMode == QuarantineMode.FLAG): + return expectations.get_expectations_as_expect_all() + + return expectations.get_expectations() + + def _init_staging_table_quarantine( + self, + staging_table: StagingTable, + expectations: Dict + ) -> None: + """Initialize quarantine manager for a staging table if enabled.""" + if not staging_table.has_quarantine_enabled(): + return + + # Get the DQ expectations object for quarantine rules + dq_expectations = staging_table.get_data_quality_expectations() + if dq_expectations is None or not dq_expectations.all_rules: + self.logger.warning( + "Quarantine enabled for staging table %s but no DQ rules available", + staging_table.table + ) + return + + self.logger.info( + "Initializing quarantine for staging table: %s, mode: %s", + staging_table.table, staging_table.quarantineMode + ) + + # Create quarantine manager for this staging table + quarantine_manager = QuarantineManager( + quarantine_mode=staging_table.quarantineMode, + data_quality_rules=dq_expectations.all_rules, + target_format=TargetType.DELTA, + target_details=staging_table, + quarantine_target_details=staging_table.quarantineTargetDetails or {} + ) + + # Store the quarantine manager for this staging table + self.staging_quarantine_managers[staging_table.table] = quarantine_manager + + # Add quarantine columns to staging table schema if FLAG mode + if staging_table.quarantineMode == QuarantineMode.FLAG: + quarantine_manager.add_quarantine_columns_delta(staging_table) + + def _init_staging_table_cdc_settings(self, staging_table: StagingTable): + """Add SCD2 columns to staging table schema if it has a defined schema and CDC/CDC Snapshot SCD type 2.""" + if not hasattr(staging_table, 'schema') or not staging_table.schema: + return + + def get_scd2_columns(sequence_by_data_type: T.DataType) -> List[T.StructField]: + return [ + T.StructField(SystemColumns.SCD2Columns.SCD2_START_AT.value, sequence_by_data_type), + T.StructField(SystemColumns.SCD2Columns.SCD2_END_AT.value, sequence_by_data_type) + ] + + cdc_settings = staging_table.get_cdc_settings() + cdc_snapshot_settings = staging_table.get_cdc_snapshot_settings() + + scd2_cfg = None + if cdc_settings and cdc_settings.scd_type == "2": + scd2_cfg = cdc_settings + elif cdc_snapshot_settings and cdc_snapshot_settings.scd_type == "2": + scd2_cfg = cdc_snapshot_settings + if scd2_cfg: + sequence_by_data_type = scd2_cfg.sequence_by_data_type + scd2_columns = get_scd2_columns(sequence_by_data_type) + staging_table.add_columns(scd2_columns) + def _create_flow(self, flow: BaseFlow, staging_tables: Dict[str, StagingTable]): """Create a flow and its associated views.""" self.logger.info("Creating Views...") @@ -301,49 +401,93 @@ def _create_flow(self, flow: BaseFlow, staging_tables: Dict[str, StagingTable]): # Prepare Flow Configuration is_target = self.is_target(flow.targetTable) flow_config = self._prepare_flow_config(flow, staging_tables) + + # Get quarantine settings for this flow's target + quarantine_enabled, quarantine_mode, quarantine_manager = self._get_target_quarantine_settings( + flow.targetTable, staging_tables + ) if isinstance(flow, BaseFlowWithViews): views = flow.get_views() or {} # Create views - self._create_views(views, flow.sourceView, is_target, flow_config.target_config_flags) + self._create_views( + views, flow.sourceView, is_target, flow_config.target_config_flags, + quarantine_enabled, quarantine_mode, quarantine_manager + ) # Create Flow flow.create_flow(self.dataflow_config, flow_config) # Handle Table Quarantine Mode - if (self.quarantine_enabled - and self.quarantine_mode == QuarantineMode.TABLE - and is_target + if (quarantine_enabled + and quarantine_mode == QuarantineMode.TABLE + and quarantine_manager ): - self.quarantine_manager.create_quarantine_flow(flow.sourceView) + quarantine_manager.create_quarantine_flow(flow.sourceView) else: # Get quarantine rules if needed. note in table mode we don't apply them to the source view, # they are applied to a quarantine view that passes to the the quarantine target table. quarantine_rules = None - if (self.quarantine_enabled - and self.quarantine_mode != QuarantineMode.TABLE - and is_target + if (quarantine_enabled + and quarantine_mode != QuarantineMode.TABLE + and quarantine_manager ): - quarantine_rules = self.quarantine_manager.quarantine_rules + quarantine_rules = quarantine_manager.quarantine_rules # Create Flow flow.create_flow(self.dataflow_config, flow_config, quarantine_rules) - def _create_views(self, views: Dict[str, View], flow_source_view: str, is_target: bool, target_config_flags: List[str]) -> None: + def _get_target_quarantine_settings( + self, + target_table: str, + staging_tables: Dict[str, StagingTable] + ) -> tuple: + """Get quarantine settings for a target (main target or staging table).""" + is_target = self.is_target(target_table) + + if is_target: + # Main target uses spec-level quarantine settings + return ( + self.quarantine_enabled, + self.quarantine_mode, + self.quarantine_manager if self.quarantine_enabled else None + ) + else: + # Staging table uses its own quarantine settings + staging_table = staging_tables.get(target_table) + if staging_table and staging_table.has_quarantine_enabled(): + quarantine_manager = self.staging_quarantine_managers.get(staging_table.table) + return ( + True, + staging_table.quarantineMode, + quarantine_manager + ) + return (False, None, None) + + def _create_views( + self, + views: Dict[str, View], + flow_source_view: str, + is_target: bool, + target_config_flags: List[str], + quarantine_enabled: bool = False, + quarantine_mode: str = None, + quarantine_manager: QuarantineManager = None + ) -> None: """Create views for the flow, handling quarantine as needed.""" for view in views.values(): # Get quarantine rules if needed. note in table mode we don't apply them to the source view, # they are applied to a quarantine view that passes to the the quarantine target table. quarantine_rules = None - if (self.quarantine_enabled - and self.quarantine_mode != QuarantineMode.TABLE - and is_target + if (quarantine_enabled + and quarantine_mode != QuarantineMode.TABLE and flow_source_view == view.viewName + and quarantine_manager ): - quarantine_rules = self.quarantine_manager.quarantine_rules + quarantine_rules = quarantine_manager.quarantine_rules # Create the view view.create_view( diff --git a/src/dataflow/targets/staging_table.py b/src/dataflow/targets/staging_table.py index dcb0551..03106ba 100644 --- a/src/dataflow/targets/staging_table.py +++ b/src/dataflow/targets/staging_table.py @@ -1,8 +1,9 @@ from dataclasses import dataclass, field -from typing import Dict +from typing import Dict, Optional from ..cdc import CDCSettings from ..cdc_snapshot import CDCSnapshotSettings +from ..expectations import DataQualityExpectations from .delta_streaming_table import TargetDeltaStreamingTable @@ -15,14 +16,32 @@ class StagingTable(TargetDeltaStreamingTable): Attributes: cdcSettings (Dict, optional): CDC settings. cdcSnapshotSettings (Dict, optional): CDC snapshot settings. + dataQualityExpectationsEnabled (bool, optional): Whether DQ expectations are enabled. + dataQualityExpectationsPath (str, optional): Path to DQ expectations file. + dataQualityExpectations (Dict, optional): Loaded DQ expectations. + quarantineMode (str, optional): Quarantine mode (off/flag/table). + quarantineTargetDetails (Dict, optional): Quarantine target configuration. Methods: get_cdc_settings() -> CDCSettings: Get CDC settings. get_cdc_snapshot_settings() -> CDCSnapshotSettings: Get CDC snapshot settings. + get_data_quality_expectations() -> DataQualityExpectations: Get DQ expectations. + has_quarantine_enabled() -> bool: Check if quarantine is enabled. """ cdcSettings: Dict = field(default_factory=dict) cdcSnapshotSettings: Dict = field(default_factory=dict) + dataQualityExpectationsEnabled: bool = False + dataQualityExpectationsPath: str = None + dataQualityExpectations: Dict = field(default_factory=dict) + quarantineMode: str = None + quarantineTargetDetails: Dict = field(default_factory=dict) + + def __post_init__(self): + """Post-init processing.""" + super().__post_init__() + if self.quarantineMode: + self.quarantineMode = self.quarantineMode.lower() def get_cdc_settings(self) -> CDCSettings: """Get CDC configuration.""" @@ -33,3 +52,16 @@ def get_cdc_snapshot_settings(self) -> CDCSnapshotSettings: """Get CDC snapshot settings.""" return CDCSnapshotSettings(**self.cdcSnapshotSettings) \ if self.cdcSnapshotSettings else None + + def get_data_quality_expectations(self) -> Optional[DataQualityExpectations]: + """Get data quality expectations for this staging table.""" + return DataQualityExpectations(**self.dataQualityExpectations) \ + if self.dataQualityExpectationsEnabled and self.dataQualityExpectations else None + + def has_quarantine_enabled(self) -> bool: + """Check if quarantine is enabled for this staging table.""" + return ( + self.dataQualityExpectationsEnabled + and self.quarantineMode is not None + and self.quarantineMode != "off" + ) diff --git a/src/dataflow_spec_builder/dataflow_spec_builder.py b/src/dataflow_spec_builder/dataflow_spec_builder.py index 8199310..c3e5430 100644 --- a/src/dataflow_spec_builder/dataflow_spec_builder.py +++ b/src/dataflow_spec_builder/dataflow_spec_builder.py @@ -46,6 +46,10 @@ class DataflowSpecBuilder: build(): Build dataflow specifications. """ + # The default spec type used when a spec omits data_flow_type. nodespec is + # the framework's unified spec format. + DEFAULT_DATA_FLOW_TYPE = "nodespec" + class Keys: """Constants for dictionary keys for the dataflow spec JSON files, and final dataflow spec format""" # Core dataflow specification keys @@ -203,8 +207,31 @@ def _read_dataflow_specs(self) -> Dict: main_specs = {} flow_specs = {} + # Map of snake_case → camelCase for core metadata keys. + # Specs may use either convention; we normalise to camelCase in-place + # so downstream processing (filtering, validation, transformation) works uniformly. + _METADATA_SNAKE_TO_CAMEL = { + "data_flow_id": self.Keys.DATA_FLOW_ID, + "data_flow_group": self.Keys.DATA_FLOW_GROUP, + "data_flow_type": self.Keys.DATA_FLOW_TYPE, + "data_flow_version": self.Keys.DATA_FLOW_VERSION, + } + + def _normalise_spec_metadata(spec: Dict) -> None: + """Normalise snake_case metadata keys to camelCase in-place.""" + for snake, camel in _METADATA_SNAKE_TO_CAMEL.items(): + if snake in spec and camel not in spec: + spec[camel] = spec.pop(snake) + def _extract_spec(spec: Dict) -> Dict: """Extract metadata from a dataflow specification.""" + _normalise_spec_metadata(spec) + # nodespec is the default/unified spec type, so data_flow_type may be + # omitted. Default it here (in-place) so type selection, metadata + # validation, schema validation, and transformation all treat a + # type-less spec as nodespec. + if not spec.get(self.Keys.DATA_FLOW_TYPE): + spec[self.Keys.DATA_FLOW_TYPE] = self.DEFAULT_DATA_FLOW_TYPE return { "fileType": "main", self.Keys.DATA_FLOW_ID: spec.get(self.Keys.DATA_FLOW_ID, None), @@ -421,7 +448,30 @@ def _validate_json(spec_item: tuple) -> tuple: spec_path, spec_payload = spec_item file_type = spec_payload.get("fileType", "main") json_data = spec_payload.get(self.Keys.DATA) - if file_type == "main": + spec_type = spec_payload.get(self.Keys.DATA_FLOW_TYPE, "") + + # Nodespec specs use their own schema directly — the main.json + # if/else chain doesn't route cleanly for non-standard types. + if spec_type == "nodespec": + nodespec_schema_path = os.path.join(self.framework_path, "schemas", "spec_nodespec.json") + if os.path.exists(nodespec_schema_path): + nodespec_validator = utility.JSONValidator(nodespec_schema_path) + # Nodespec field names are snake_case. The metadata keys were + # normalised to camelCase at read time for uniform downstream + # processing, so present them as snake_case for validation. + camel_to_snake = { + self.Keys.DATA_FLOW_ID: "data_flow_id", + self.Keys.DATA_FLOW_GROUP: "data_flow_group", + self.Keys.DATA_FLOW_TYPE: "data_flow_type", + self.Keys.DATA_FLOW_VERSION: "data_flow_version", + } + validation_data = { + camel_to_snake.get(k, k): v for k, v in json_data.items() + } + errors = nodespec_validator.validate(validation_data) + else: + errors = [] + elif file_type == "main": errors = self.main_validator.validate(json_data) else: errors = self.flow_validator.validate(json_data) @@ -698,6 +748,12 @@ def _get_base_path(self, file_path: str) -> str: def _get_expectations(self, dataflow_spec: Dict, base_path: str) -> Dict: """Set the expectation validator path in the dataflow specification.""" dqe_validator_path = os.path.join(self.framework_path,FrameworkPaths.EXPECTATIONS_SPEC_SCHEMA_PATH) + dqe_builder = DataQualityExpectationBuilder( + self.logger, + dqe_validator_path, + self.spec_file_format + ) + if dataflow_spec.get(self.Keys.DATA_QUALITY_EXPECTATIONS_ENABLED, False): dqe_path = dataflow_spec.get(self.Keys.DATA_QUALITY_EXPECTATIONS_PATH, None) if dqe_path is None or dqe_path.strip() == "": @@ -705,10 +761,21 @@ def _get_expectations(self, dataflow_spec: Dict, base_path: str) -> Dict: dqe_path = f"{base_path}/{PipelineBundlePaths.DQE_PATH}/{dqe_path}" dataflow_spec[self.Keys.DATA_QUALITY_EXPECTATIONS] = ( - DataQualityExpectationBuilder( - self.logger, - dqe_validator_path, - self.spec_file_format - ).get_expectations(dqe_path).__dict__) + dqe_builder.get_expectations(dqe_path).__dict__) + + # Also load DQ expectations for staging tables within flow groups + for flow_group in dataflow_spec.get(self.Keys.FLOW_GROUPS, []): + staging_tables = flow_group.get("stagingTables", {}) + for staging_config in staging_tables.values(): + if staging_config.get(self.Keys.DATA_QUALITY_EXPECTATIONS_ENABLED, False): + staging_dqe_path = staging_config.get(self.Keys.DATA_QUALITY_EXPECTATIONS_PATH, None) + if staging_dqe_path is None or staging_dqe_path.strip() == "": + self.logger.warning( + "DQ expectations enabled for staging table but no path specified" + ) + continue + full_staging_dqe_path = f"{base_path}/{PipelineBundlePaths.DQE_PATH}/{staging_dqe_path}" + staging_config[self.Keys.DATA_QUALITY_EXPECTATIONS] = ( + dqe_builder.get_expectations(full_staging_dqe_path).__dict__) return dataflow_spec diff --git a/src/dataflow_spec_builder/spec_mapper.py b/src/dataflow_spec_builder/spec_mapper.py index 724350e..21df221 100644 --- a/src/dataflow_spec_builder/spec_mapper.py +++ b/src/dataflow_spec_builder/spec_mapper.py @@ -2,7 +2,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Dict, Tuple, Optional, Any -from config_resolver import resolve_framework_config_dir +from config_resolver import resolve_framework_config_dir, resolve_framework_config_path from constants import FrameworkPaths import pipeline_config import utility @@ -45,6 +45,7 @@ def __init__(self, framework_path: str, max_workers: int = 1): max_workers: Maximum parallel workers for processing """ self.framework_path = framework_path + self._framework_config_path = resolve_framework_config_path(framework_path) self.max_workers = max_workers self._mapping_cache: Dict[str, Dict] = {} diff --git a/src/dataflow_spec_builder/transformer/__init__.py b/src/dataflow_spec_builder/transformer/__init__.py index d182f34..b1aa804 100644 --- a/src/dataflow_spec_builder/transformer/__init__.py +++ b/src/dataflow_spec_builder/transformer/__init__.py @@ -2,6 +2,7 @@ from .standard import StandardSpecTransformer from .flow import FlowSpecTransformer from .materialized_views import MaterializedViewSpecTransformer +from .nodespec import NodespecSpecTransformer from .factory import SpecTransformerFactory __all__ = [ @@ -9,5 +10,6 @@ 'StandardSpecTransformer', 'FlowSpecTransformer', 'MaterializedViewSpecTransformer', + 'NodespecSpecTransformer', 'SpecTransformerFactory' ] diff --git a/src/dataflow_spec_builder/transformer/base.py b/src/dataflow_spec_builder/transformer/base.py index c250b2e..f0f04cf 100644 --- a/src/dataflow_spec_builder/transformer/base.py +++ b/src/dataflow_spec_builder/transformer/base.py @@ -7,7 +7,14 @@ class BaseSpecTransformer(ABC): """Base class for dataflow spec transformers.""" - + + # When True, transform() inspects the produced flow spec and warns on + # source definitions that embed SQL/Python transformation logic. Spec + # types that can already detect this on their own input (e.g. nodespec, + # which distinguishes source nodes from transformation nodes) set this to + # False and emit the warning themselves. + WARN_INLINE_SOURCE_TRANSFORMATIONS_FROM_OUTPUT = True + def __init__(self): self.logger = pipeline_config.get_logger() @@ -19,8 +26,53 @@ def _process_spec(self, spec_data: Dict) -> Union[Dict, List[Dict]]: def transform(self, spec_data: Dict) -> Union[Dict, List[Dict]]: """Transform the spec data. Returns either a single Dict or List[Dict].""" spec_data = self._apply_features_and_limitations(spec_data) - return self._process_spec(spec_data) - + result = self._process_spec(spec_data) + if self.WARN_INLINE_SOURCE_TRANSFORMATIONS_FROM_OUTPUT: + for flow_spec in (result if isinstance(result, list) else [result]): + self._warn_inline_source_transformations(flow_spec) + return result + + def _warn_inline_source_transformations(self, flow_spec: Dict) -> None: + """Warn when a source definition embeds SQL/Python transformation logic. + + Defining a source whose type is SQL or Python (or appending the result + of an inline SQL statement) folds transformation logic into the source + definition. This is still supported for backward compatibility, but is + discouraged: it conflates "where the data comes from" with "how the data + is transformed". The recommended pattern is to declare a plain source + and model transformation logic as a separate transformation step. This + behaviour may not be supported in a future release. + """ + if not isinstance(flow_spec, dict): + return + + data_flow_id = flow_spec.get("dataFlowId") + for flow_group in flow_spec.get("flowGroups", []) or []: + flows = flow_group.get("flows", {}) or {} + for flow_name, flow in flows.items(): + if str(flow.get("flowType", "")).lower() == "append_sql": + self.logger.warning( + "Flow '%s' (dataFlowId '%s') uses an inline SQL source " + "(flowType 'append_sql'). This is still supported but " + "discouraged and may not be supported in a future " + "release. Model transformation logic as a separate " + "transformation step instead.", + flow_name, data_flow_id, + ) + for view_name, view in (flow.get("views", {}) or {}).items(): + source_type = str(view.get("sourceType", "")).lower() + if source_type in ("sql", "python"): + self.logger.warning( + "Source view '%s' (dataFlowId '%s') defines an " + "inline %s transformation (sourceType '%s'). This " + "is still supported but discouraged and may not be " + "supported in a future release. Declare a plain " + "source and model transformation logic as a " + "separate transformation step instead.", + view_name, data_flow_id, source_type, source_type, + ) + + def _apply_features_and_limitations(self, dataflow_spec: Dict) -> Dict: """Apply common features and limitations transformations.""" # Operational MetadataSnapshot diff --git a/src/dataflow_spec_builder/transformer/factory.py b/src/dataflow_spec_builder/transformer/factory.py index a1c8b8b..04cc30d 100644 --- a/src/dataflow_spec_builder/transformer/factory.py +++ b/src/dataflow_spec_builder/transformer/factory.py @@ -3,6 +3,7 @@ from .standard import StandardSpecTransformer from .flow import FlowSpecTransformer from .materialized_views import MaterializedViewSpecTransformer +from .nodespec import NodespecSpecTransformer class SpecTransformerFactory: @@ -11,7 +12,8 @@ class SpecTransformerFactory: _transformers = { "standard": StandardSpecTransformer, "flow": FlowSpecTransformer, - "materialized_view": MaterializedViewSpecTransformer + "materialized_view": MaterializedViewSpecTransformer, + "nodespec": NodespecSpecTransformer } @classmethod @@ -26,4 +28,4 @@ def create_transformer(cls, dataflow_type: str, dataflow_spec_mapping_path: str @classmethod def get_supported_types(cls) -> List[str]: """Return list of supported dataflow types.""" - return list(cls._transformers.keys()) \ No newline at end of file + return list(cls._transformers.keys()) diff --git a/src/dataflow_spec_builder/transformer/nodespec.py b/src/dataflow_spec_builder/transformer/nodespec.py new file mode 100644 index 0000000..cd6cda5 --- /dev/null +++ b/src/dataflow_spec_builder/transformer/nodespec.py @@ -0,0 +1,468 @@ +""" +Nodespec Spec Transformer. + +Converts a node-based dataflow spec (source -> transformation -> target nodes) +into the framework's flow-based spec. Sources/transformations become views; +targets become the spec target table or staging tables (each carrying its own +CDC / data quality / quarantine settings). The terminal target (not consumed by +another node) becomes the backend ``targetDetails``; the rest become staging +tables. ``table_type: "mv"`` targets each become their own flow spec, so a mixed +spec returns a list. + +The snake_case -> camelCase translation is centralised: one global key map +(``_KEYS``) plus ``_camel`` (flat) / ``_deep_camel`` (nested blobs). Each builder +copies every config key that isn't "structural" (``_HANDLED`` — input_flows, +cdc_*, data_quality, quarantine, sink_*, ...); everything else is passthrough +table/source detail. +""" +from collections import defaultdict +from typing import Any, Dict, List, Optional, Tuple, Set, Union + +from .base import BaseSpecTransformer +from dataflow.enums import FlowType, Mode, TableType, TargetType + + +# The single snake_case -> camelCase map. Identity keys (database, table, path, +# comment, name, tokens, private, ...) need no entry. Used both for flat detail +# conversion and recursive conversion of nested blobs (snapshot CDC, table +# migration, sink config). +_KEYS = { + "schema_path": "schemaPath", "table_properties": "tableProperties", + "partition_columns": "partitionColumns", "cluster_by_columns": "clusterByColumns", + "cluster_by_auto": "clusterByAuto", "spark_conf": "sparkConf", "row_filter": "rowFilter", + "config_flags": "configFlags", "cdf_enabled": "cdfEnabled", "table_path": "tablePath", + "reader_options": "readerOptions", "sql_path": "sqlPath", "sql_statement": "sqlStatement", + "function_path": "functionPath", "python_module": "pythonModule", "select_exp": "selectExp", + "where_clause": "whereClause", "refresh_policy": "refreshPolicy", + "starting_version_from_dlt_setup": "startingVersionFromDLTSetup", + "cdf_change_type_override": "cdfChangeTypeOverride", + # nested snapshot-CDC / table-migration keys + "snapshot_type": "snapshotType", "source_type": "sourceType", "version_type": "versionType", + "version_column": "versionColumn", "starting_version": "startingVersion", + "datetime_format": "datetimeFormat", "deduplicate_mode": "deduplicateMode", + "recursive_file_lookup": "recursiveFileLookup", + "catalog_type": "catalogType", "auto_starting_versions_enabled": "autoStartingVersionsEnabled", + "source_details": "sourceDetails", "table_name": "tableName", +} + +# Keys a target config handles specially, so they are NOT copied into details. +_HANDLED = { + "input_flows", "table", "table_type", "enabled", "once", "name", + "cdc_settings", "cdc_snapshot_settings", + "data_quality", "quarantine", "table_migration_details", + "sink_type", "sink_config", "sink_options", "source_view", +} + + +def _camel(cfg: Dict, drop: Set[str] = frozenset()) -> Dict: + """Flat snake->camel rename of top-level keys (values copied verbatim).""" + return {_KEYS.get(k, k): v for k, v in cfg.items() if k not in drop} + + +def _deep_camel(obj: Any) -> Any: + """Recursive snake->camel for nested blobs (snapshot CDC, migration, sink config).""" + if isinstance(obj, dict): + return {_KEYS.get(k, k): _deep_camel(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_deep_camel(i) for i in obj] + return obj + + +# nodespec authors enum VALUES in snake_case; the backend/legacy formats expect +# camelCase for a few of them. Map those back on the transformer's output. +_SOURCE_TYPE_TO_BACKEND = {"cloud_files": "cloudFiles", "batch_files": "batchFiles", "delta_join": "deltaJoin"} +_CONFIG_FLAG_TO_BACKEND = {"disable_operational_metadata": "disableOperationalMetadata"} + + +def _values_to_backend(obj: Any) -> Any: + """Recursively map nodespec snake_case enum values back to backend values + (sourceType: cloud_files->cloudFiles etc.; configFlags entries). Applied to + the final flow spec, so it also covers the file/table snapshot sourceType and + the sql/delta transform sourceType, which are simply left unchanged.""" + if isinstance(obj, dict): + for k, v in obj.items(): + if k == "sourceType" and isinstance(v, str): + obj[k] = _SOURCE_TYPE_TO_BACKEND.get(v, v) + elif k == "configFlags" and isinstance(v, list): + obj[k] = [_CONFIG_FLAG_TO_BACKEND.get(x, x) if isinstance(x, str) else x for x in v] + else: + _values_to_backend(v) + elif isinstance(obj, list): + for i in obj: + _values_to_backend(i) + return obj + + +def _first(cfg: Dict, *keys: str) -> Dict: + """{camel(key): value} for the first present snake key (e.g. sql_path|sql_statement).""" + for k in keys: + if k in cfg: + return {_KEYS.get(k, k): cfg[k]} + return {} + + +class NodespecSpecTransformer(BaseSpecTransformer): + """Transform a nodespec node-graph spec into a flow spec (or list of them).""" + + WARN_INLINE_SOURCE_TRANSFORMATIONS_FROM_OUTPUT = False + FLOW_GROUP_ID = "nodespec_main" + FLOW_PREFIX = "f_" + VIEW_PREFIX = "v_" + SOURCE, TRANSFORMATION, TARGET = "source", "transformation", "target" + + def _process_spec(self, spec_data: Dict) -> Union[Dict, List[Dict]]: + """Transform a nodespec spec into one flow spec, or a list (streaming + MV).""" + nodes = spec_data.get("nodes", []) + if not nodes: + raise ValueError("Nodespec spec must contain at least one node") + + sources = [n for n in nodes if n.get("node_type", "").lower() == self.SOURCE] + targets = [n for n in nodes if n.get("node_type", "").lower() == self.TARGET] + self._warn_inline_sources(sources) + self._validate(sources, targets, nodes) + + self.lookup = {n.get("name"): n for n in nodes} + st = [t for t in targets if not self._is_mv(t)] + mv = [t for t in targets if self._is_mv(t)] + + specs: List[Dict] = [] + if st: + self.internal = self._internal_sources(sources, st) + spec_target, others = self._select_spec_target(st, sources, nodes) + specs.append(self._streaming_spec(spec_data, spec_target, others)) + for target in mv: + specs.append(self._mv_spec(spec_data, target, sources)) + + if not specs: + raise ValueError("Nodespec spec must contain at least one target node") + for s in specs: + _values_to_backend(s) + return specs if len(specs) > 1 else specs[0] + + @staticmethod + def _is_mv(target: Dict) -> bool: + return target.get("config", {}).get("table_type") == "mv" + + # -- validation / warnings -- + + def _warn_inline_sources(self, sources: List[Dict]) -> None: + for node in sources: + st = node.get("source_type", "delta") + if st in ("sql", "python"): + self.logger.warning( + "Source node '%s' defines an inline %s transformation (source_type: '%s'). " + "This is still supported but discouraged and may not be supported in a future " + "release. Define a source node and chain a dedicated transformation node off it " + "instead.", node.get("name"), st, st) + + def _validate(self, sources: List[Dict], targets: List[Dict], nodes: List[Dict]) -> None: + names = {n.get("name") for n in nodes} + for t in targets: + for view in self._inputs(t): + if view[1] not in names: + raise ValueError(f"Node '{t.get('name')}' references non-existent input '{view[1]}'") + if self._is_mv(t) and "source_view" in t.get("config", {}): + raise ValueError( + f"Materialized view target '{t.get('name')}' defines an inline 'source_view'. " + "This is no longer supported. Declare a source node and chain it into the " + "materialized view target via its 'input_flows' array instead.") + if not targets: + raise ValueError("Nodespec spec must contain at least one target node") + + mv_inline_sql = all( + self._is_mv(t) and (t["config"].get("sql_path") or t["config"].get("sql_statement")) for t in targets) + snapshot = all(t.get("config", {}).get("cdc_snapshot_settings") for t in targets) + if not sources and not (mv_inline_sql or snapshot): + raise ValueError("Nodespec spec must contain at least one source node") + + # -- inputs -- + + def _inputs(self, node: Dict) -> List[Tuple[Optional[str], str]]: + """``input_flows`` items as (flow_name, view_name); strings auto-name the flow.""" + pairs = [] + for item in node.get("config", {}).get("input_flows", []) or []: + if isinstance(item, dict) and item.get("view"): + pairs.append((item.get("flow"), item["view"])) + elif not isinstance(item, dict): + pairs.append((None, item)) + return pairs + + # -- shared settings -- + + def _settings(self, dst: Dict, cfg: Dict, *, cdc=False, migration=False, dq_default=False) -> None: + """Copy CDC / data-quality / quarantine / table-migration settings onto `dst`.""" + if cdc: + if cfg.get("cdc_settings"): + dst["cdcSettings"] = cfg["cdc_settings"] + if cfg.get("cdc_snapshot_settings"): + dst["cdcSnapshotSettings"] = _deep_camel(cfg["cdc_snapshot_settings"]) + dq = cfg.get("data_quality") or {} + if dq_default: + dst["dataQualityExpectationsEnabled"] = bool(dq) + elif dq: + dst["dataQualityExpectationsEnabled"] = True + if dq.get("expectations_path"): + dst["dataQualityExpectationsPath"] = dq["expectations_path"] + quarantine = cfg.get("quarantine") or {} + if quarantine.get("mode"): + dst["quarantineMode"] = quarantine["mode"] + if quarantine.get("target"): + dst["quarantineTargetDetails"] = quarantine["target"] + if migration and cfg.get("table_migration_details"): + dst["tableMigrationDetails"] = _deep_camel(cfg["table_migration_details"]) + + def _base(self, spec_data: Dict) -> Dict: + base = {k: spec_data.get(c) for k, c in + (("dataFlowId", "dataFlowId"), ("dataFlowGroup", "dataFlowGroup"), ("dataFlowType", "dataFlowType"))} + base["features"] = spec_data.get("features", {}) + if spec_data.get("dataFlowVersion"): + base["dataFlowVersion"] = spec_data["dataFlowVersion"] + return base + + # -- streaming targets -- + + def _select_spec_target(self, targets, sources, nodes) -> Tuple[Dict, List[Dict]]: + """Terminal target (not consumed by another node) becomes the spec target.""" + if len(targets) == 1: + return targets[0], [] + consumed = {v for n in nodes for _, v in self._inputs(n)} + source_tables = {s.get("config", {}).get("table") for s in sources} - {None} + terminal, other = [], [] + for t in targets: + table = t.get("config", {}).get("table") + (other if t.get("name") in consumed or table in source_tables else terminal).append(t) + if not terminal: + raise ValueError("Could not determine spec target: all target nodes are consumed by other nodes") + if len(terminal) > 1: + self.logger.warning("Multiple terminal targets found %s. Using last target '%s' as spec target.", + [t.get("name") for t in terminal], terminal[-1].get("name")) + return terminal[-1], other + terminal[:-1] + + def _streaming_spec(self, spec_data: Dict, spec_target: Dict, others: List[Dict]) -> Dict: + cfg = spec_target.get("config", {}) + fmt = spec_target.get("target_type", "delta") + has_cdc = bool(cfg.get("cdc_settings") or cfg.get("cdc_snapshot_settings")) + + spec = self._base(spec_data) + spec["targetFormat"] = fmt + spec["targetDetails"] = self._target_details(cfg) if fmt == "delta" else self._sink_details(cfg) + spec["tags"] = spec_data.get("tags", {}) + self._settings(spec, cfg, cdc=True, migration=True, dq_default=True) + spec["flowGroups"] = [self._flow_group(spec_target, others, has_cdc)] + return spec + + def _target_details(self, cfg: Dict) -> Dict: + return {"table": cfg.get("table"), "type": TableType.STREAMING, **_camel(cfg, _HANDLED)} + + def _sink_details(self, cfg: Dict) -> Dict: + details: Dict[str, Any] = {} + if "name" in cfg: + details["name"] = cfg["name"] + if "sink_type" in cfg: + details["type"] = cfg["sink_type"] + if "sink_options" in cfg: + details["sinkOptions"] = cfg["sink_options"] + if "sink_config" in cfg: + details["config"] = _deep_camel(cfg["sink_config"]) + return details + + def _flow_group(self, spec_target: Dict, others: List[Dict], has_cdc: bool) -> Dict: + group = {"flowGroupId": self.FLOW_GROUP_ID, "flows": {}} + staging = {} + for t in others: + cfg = t.get("config", {}) + table = cfg.get("table") + if table and table not in staging: + entry = {"type": "ST", **_camel(cfg, _HANDLED)} + self._settings(entry, cfg, cdc=True) + staging[table] = entry + if staging: + group["stagingTables"] = staging + + registered: Set[str] = set() # views already attached (avoid SDP "Cannot redefine") + by_table: Dict[str, List[Dict]] = defaultdict(list) + for t in others + [spec_target]: + cfg = t.get("config", {}) + table = cfg.get("table") or cfg.get("name") + if table: + by_table[table].append(t) + + counter = 0 + for table, ts in by_table.items(): + for t in ts: + counter = self._add_flows(group, t, table, spec_target, has_cdc, registered, counter) + return group + + def _add_flows(self, group, target, table, spec_target, has_cdc, registered, counter) -> int: + cfg = target.get("config", {}) + name = target.get("name") + enabled = target.get("enabled", True) + inputs = self._inputs(target) + + if not inputs: # only valid for snapshot CDC targets (snapshot reads its source directly) + if cfg.get("cdc_snapshot_settings"): + group["flows"][f"{self.FLOW_PREFIX}{name}_{counter}"] = { + "flowType": FlowType.MERGE, "flowDetails": {"targetTable": table}, "enabled": enabled} + counter += 1 + else: + self.logger.warning(f"Target '{name}' has no inputs, skipping") + return counter + + merge = bool(cfg.get("cdc_settings") or (has_cdc and target is spec_target)) + for flow_name, view in inputs: + node = self.lookup.get(view, {}) + sql_source = node.get("node_type", "").lower() == self.SOURCE and node.get("source_type") == "sql" + ftype = FlowType.MERGE if merge else (FlowType.APPEND_SQL if sql_source else FlowType.APPEND_VIEW) + key = flow_name or f"{self.FLOW_PREFIX}{view}_{counter}" + counter += 1 + + if ftype == FlowType.APPEND_SQL: + flow = {"flowType": ftype, "flowDetails": {"targetTable": table}, "enabled": enabled} + flow["flowDetails"].update(_first(node.get("config", {}), "sql_statement", "sql_path")) + else: + source_view, views = self._views_for(view) + if not source_view: + self.logger.warning(f"Could not determine source view for target '{name}' input '{view}'") + continue + flow = {"flowType": ftype, "flowDetails": {"sourceView": source_view, "targetTable": table}, + "enabled": enabled} + fresh = {k: v for k, v in views.items() if k not in registered} + if fresh: + flow["views"] = fresh + registered.update(views) + + if cfg.get("once"): + flow["flowDetails"]["once"] = True + group["flows"][key] = flow + return counter + + # -- materialized views -- + + def _mv_spec(self, spec_data: Dict, target: Dict, sources: List[Dict]) -> Dict: + cfg = target.get("config", {}) + mv = cfg.get("table") + # All table settings (including MV-only `private`) live top-level on the config. + details = {"table": mv, "type": TableType.MATERIALIZED_VIEW, **_camel(cfg, _HANDLED)} + + spec = self._base(spec_data) + spec.pop("dataFlowVersion", None) # MV specs do not carry a version + spec["targetFormat"] = TargetType.DELTA + spec["targetDetails"] = details + spec["localPath"] = spec_data.get("localPath") + self._settings(spec, cfg, dq_default=True) + + group = {"flowGroupId": self.FLOW_GROUP_ID, "flows": {}} + inputs = self._inputs(target) + if inputs: + self.internal = self._internal_sources(sources, [target]) + flow_name, view = inputs[0] + source_view, views = self._views_for(view) + if source_view: + details["sourceView"] = source_view + flow = {"flowType": FlowType.MATERIALIZED_VIEW, "flowDetails": {"targetTable": mv}} + if views: + for v in views.values(): + v["mode"] = Mode.BATCH # MVs use batch reads (spark.sql) + flow["views"] = views + group["flows"][flow_name or f"{self.FLOW_PREFIX}{mv}"] = flow + spec["flowGroups"] = [group] + return spec + + # -- views / internal sources -- + + def _internal_sources(self, sources: List[Dict], targets: List[Dict]) -> Dict[str, str]: + """Delta sources reading a table produced by a target here (referenced directly, no view).""" + produced = {t["config"]["table"]: (t["config"].get("database") or None) + for t in targets if t.get("config", {}).get("table")} + internal = {} + for s in sources: + if s.get("source_type", "delta") != "delta": + continue + cfg = s.get("config", {}) + table = cfg.get("table", "") + if table in produced: + sdb, tdb = cfg.get("database") or None, produced[table] + if not sdb or not tdb or sdb == tdb: + internal[s.get("name")] = table + return internal + + def _view_name(self, name: str) -> str: + return name if name.startswith(self.VIEW_PREFIX) else f"{self.VIEW_PREFIX}{name}" + + def _views_for(self, view: str) -> Tuple[Optional[str], Dict]: + """Resolve an input to (source_view_name, views). A transformation also pulls + in every source node so SDP can resolve its references.""" + if view in self.internal: # internal source reads the produced table directly + return self.internal[view], {} + node = self.lookup.get(view) + if not node: + return self._view_name(view), {} + + name = node.get("output_view_name") or self._view_name(view) + ntype = node.get("node_type", "").lower() + views = {} + if ntype == self.SOURCE: + views[name] = self._source_view(node) + elif ntype == self.TRANSFORMATION: + views[name] = self._transform_view(node) + for n2, node2 in self.lookup.items(): # register all sources for SDP resolution + if node2.get("node_type", "").lower() != self.SOURCE: + continue + vn = node2.get("output_view_name") or self._view_name(n2) + if vn in views: + continue + if n2 in self.internal: # internal sources need database "live" + node2 = {**node2, "config": {**node2.get("config", {}), "database": "live"}} + views[vn] = self._source_view(node2) + return name, views + + def _source_view(self, node: Dict) -> Dict: + st = node.get("source_type", "delta") + cfg = node.get("config", {}) + details = _camel(cfg, drop={"mode", "python_transform"}) + if st == "delta": + details.setdefault("cdfEnabled", False) + if cfg.get("python_transform"): + details["pythonTransform"] = self._py_transform(cfg["python_transform"]) + return {"mode": cfg.get("mode", Mode.STREAM), "sourceType": st, "sourceDetails": details} + + def _transform_view(self, node: Dict) -> Dict: + cfg = node.get("config", {}) + if node.get("transformation_type", "sql") != "python": + return {"mode": Mode.BATCH, "sourceType": "sql", + "sourceDetails": _first(cfg, "sql_path", "sql_statement")} + # python transform: its own view reading the inferred upstream via apply_transform(df). + view, mode = self._python_upstream(node) + pt: Dict[str, Any] = {} + if cfg.get("function_path"): + pt["functionPath"] = cfg["function_path"] + elif cfg.get("python_module"): + pt["module"] = cfg["python_module"] + if cfg.get("tokens"): + pt["tokens"] = cfg["tokens"] + return {"mode": mode, "sourceType": "delta", + "sourceDetails": {"database": "live", "table": view, "pythonTransform": pt}} + + @staticmethod + def _py_transform(pt: Dict) -> Dict: + out: Dict[str, Any] = {} + if pt.get("function_path") or pt.get("functionPath"): + out["functionPath"] = pt.get("function_path") or pt.get("functionPath") + if pt.get("python_module") or pt.get("module"): + out["module"] = pt.get("python_module") or pt.get("module") + if pt.get("tokens"): + out["tokens"] = pt["tokens"] + return out + + def _python_upstream(self, node: Dict) -> Tuple[str, str]: + """Infer the upstream view a python transform reads (it has no textual ref).""" + sources = [n for n in self.lookup.values() if n.get("node_type", "").lower() == self.SOURCE] + if not sources: + self.logger.warning("Python transformation '%s' has no upstream source node to read from.", + node.get("name")) + return self._view_name(node.get("name")), Mode.STREAM + if len(sources) > 1: + self.logger.warning("Python transformation '%s' has multiple upstream sources %s; reading from '%s'.", + node.get("name"), [s.get("name") for s in sources], sources[0].get("name")) + up = sources[0] + return up.get("output_view_name") or self._view_name(up.get("name")), up.get("config", {}).get("mode", Mode.STREAM) diff --git a/src/schemas/flow_group.json b/src/schemas/flow_group.json index 738c09d..e9f5f09 100644 --- a/src/schemas/flow_group.json +++ b/src/schemas/flow_group.json @@ -50,6 +50,10 @@ "clusterByAuto": {"type": "boolean"}, "cdcSettings": {"$ref": "./definitions_main.json#/definitions/cdcSettings"}, "cdcSnapshotSettings": {"$ref": "./definitions_main.json#/definitions/cdcSnapshotSettings"}, + "dataQualityExpectationsEnabled": {"type": "boolean", "default": false}, + "dataQualityExpectationsPath": {"type": "string"}, + "quarantineMode": {"type": "string", "enum": ["off", "flag", "table"], "default": "off"}, + "quarantineTargetDetails": {"$ref": "./definitions_main.json#/definitions/quarantineTargetDetails"}, "configFlags": {"$ref": "./definitions_targets.json#/$defs/configFlags"} }, "additionalProperties": false diff --git a/src/schemas/main.json b/src/schemas/main.json index 1d34ce6..1f09697 100644 --- a/src/schemas/main.json +++ b/src/schemas/main.json @@ -1,40 +1,48 @@ { "title": "Main DataFlowSpec", - "type": "object", - "properties": { - "dataFlowId": {"type": "string"}, - "dataFlowGroup": {"type": "string"}, - "dataFlowType": {"type": "string", "enum": ["flow", "standard", "materialized_view"]}, - "dataFlowVersion": {"type": "string"}, - "tags": {"type": "object", "additionalProperties": true}, - "features": { - "type": "object", - "properties": { - "operationalMetadataEnabled": {"type": "boolean"} - } - } - }, + "$comment": "nodespec is the default spec type: a spec is validated as a legacy format only when it carries an explicit legacy dataFlowType (standard/flow/materialized_view). Anything else — including specs that omit the type entirely — is treated as nodespec.", "if": { - "properties": { - "dataFlowType": { "const": "standard" } - } + "properties": { "dataFlowType": { "enum": ["flow", "standard", "materialized_view"] } }, + "required": ["dataFlowType"] }, - "then": { "$ref": "./spec_standard.json#/$defs/standardSpec" }, - "else": { + "then": { + "type": "object", + "properties": { + "dataFlowId": {"type": "string"}, + "dataFlowGroup": {"type": "string"}, + "dataFlowType": {"type": "string", "enum": ["flow", "standard", "materialized_view"]}, + "dataFlowVersion": {"type": "string"}, + "tags": {"type": "object", "additionalProperties": true}, + "features": { + "type": "object", + "properties": { + "operationalMetadataEnabled": {"type": "boolean"} + } + } + }, "if": { "properties": { - "dataFlowType": { "const": "flow" } + "dataFlowType": { "const": "standard" } } }, - "then": { "$ref": "./spec_flows.json#/$defs/flowsSpec" }, - "else": { + "then": { "$ref": "./spec_standard.json#/$defs/standardSpec" }, + "else": { "if": { "properties": { - "dataFlowType": { "const": "materialized_view" } + "dataFlowType": { "const": "flow" } } }, - "then": { "$ref": "./spec_materialized_views.json#/$defs/materializedViewsSpec" } - } + "then": { "$ref": "./spec_flows.json#/$defs/flowsSpec" }, + "else": { + "if": { + "properties": { + "dataFlowType": { "const": "materialized_view" } + } + }, + "then": { "$ref": "./spec_materialized_views.json#/$defs/materializedViewsSpec" } + } + }, + "required": ["dataFlowId", "dataFlowGroup", "dataFlowType"] }, - "required": ["dataFlowId", "dataFlowGroup", "dataFlowType"] -} \ No newline at end of file + "else": { "$ref": "./spec_nodespec.json#/$defs/nodespec_spec" } +} diff --git a/src/schemas/spec_nodespec.json b/src/schemas/spec_nodespec.json new file mode 100644 index 0000000..d939fca --- /dev/null +++ b/src/schemas/spec_nodespec.json @@ -0,0 +1,572 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://dlt-framework/schemas/spec_nodespec.json", + "title": "Nodespec Dataflow Specification", + "description": "Node-based dataflow spec (snake_case). A node's `config` is discriminated by node_type + source_type/target_type/table_type so editors offer only the fields valid for that node. Feature bundles (cdc, snapshot, data_quality, quarantine, migration) are single nested objects. Targets declare their inputs via `input_flows`. Sink targets keep their current flat form for now (sink redesign deferred).", + "$comment": "A node's `config` is discriminated at the node level via allOf/if-then keyed on node_type + source_type/target_type/table_type; each `then` selects one closed config $def, so editors offer only the fields valid for that node. Nested blobs are typed objects with descriptions. Sink targets keep their current flat form (sink_type/sink_config/sink_options) pending the sink redesign. Legacy definitions_sources.json / definitions_targets.json are camelCase; nodespec is snake_case, so source/target detail shapes are re-declared here. Literal Spark option keys (cloudFiles.*, kafka.*) are preserved verbatim.", + "$defs": { + "nodespec_spec": { + "type": "object", + "description": "A nodespec pipeline: a graph of source -> transformation -> target nodes.", + "properties": { + "data_flow_id": {"type": "string", "description": "Unique id for this dataflow."}, + "data_flow_group": {"type": "string", "description": "Group this dataflow belongs to (maps to a pipeline)."}, + "data_flow_type": {"const": "nodespec", "description": "Optional. nodespec is the default spec type, so this may be omitted; when present it must be \"nodespec\"."}, + "data_flow_version": {"type": "string"}, + "tags": {"type": "object", "additionalProperties": true}, + "features": {"type": "object", "additionalProperties": true}, + "nodes": { + "type": "array", + "description": "Nodes in the pipeline graph.", + "items": {"$ref": "#/$defs/node"}, + "minItems": 1 + } + }, + "required": ["data_flow_id", "data_flow_group", "nodes"], + "additionalProperties": false + }, + + "node": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Unique node name within the pipeline. Also the emitted view name for source/transformation nodes unless output_view_name is set."}, + "node_type": { + "type": "string", + "enum": ["source", "transformation", "target"], + "description": "source (reads data) | transformation (reshapes a view) | target (writes an output)." + }, + "source_type": { + "type": "string", + "enum": ["batch_files", "cloud_files", "delta", "delta_join", "kafka", "python", "sql"], + "description": "Kind of source. Required when node_type=source; selects which config fields apply." + }, + "transformation_type": { + "type": "string", + "enum": ["sql", "python"], + "description": "Kind of transformation. Required when node_type=transformation." + }, + "target_type": { + "type": "string", + "enum": ["delta", "delta_sink", "foreach_batch_sink", "custom_python_sink", "kafka_sink"], + "default": "delta", + "description": "Output format for a target. Default: delta (a managed table). The *_sink values write to external sinks." + }, + "output_view_name": { + "type": "string", + "description": "Overrides the emitted view name (source/transformation nodes). Defaults to the node name." + }, + "enabled": {"type": "boolean", "default": true, "description": "Whether this node is included in the pipeline."}, + "config": {"type": "object", "description": "Node configuration. Its valid fields are determined by node_type and the type field above."} + }, + "required": ["name", "node_type"], + "additionalProperties": false, + "allOf": [ + { + "if": {"properties": {"node_type": {"const": "source"}, "source_type": {"const": "delta"}}, "required": ["node_type", "source_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/source_delta_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "source"}, "source_type": {"const": "cloud_files"}}, "required": ["node_type", "source_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/source_cloud_files_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "source"}, "source_type": {"const": "batch_files"}}, "required": ["node_type", "source_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/source_batch_files_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "source"}, "source_type": {"const": "kafka"}}, "required": ["node_type", "source_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/source_kafka_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "source"}, "source_type": {"const": "delta_join"}}, "required": ["node_type", "source_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/source_delta_join_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "source"}, "source_type": {"const": "python"}}, "required": ["node_type", "source_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/python_logic_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "source"}, "source_type": {"const": "sql"}}, "required": ["node_type", "source_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/sql_logic_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "source"}}, "required": ["node_type"]}, + "then": {"required": ["source_type"]} + }, + + { + "if": {"properties": {"node_type": {"const": "transformation"}, "transformation_type": {"const": "sql"}}, "required": ["node_type", "transformation_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/sql_logic_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "transformation"}, "transformation_type": {"const": "python"}}, "required": ["node_type", "transformation_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/python_logic_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "transformation"}}, "required": ["node_type"]}, + "then": {"required": ["transformation_type"]} + }, + + { + "if": {"properties": {"node_type": {"const": "target"}, "target_type": {"const": "delta"}}, "required": ["node_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/target_delta_config"}}} + }, + { + "if": {"properties": {"node_type": {"const": "target"}, "target_type": {"enum": ["delta_sink", "kafka_sink", "foreach_batch_sink", "custom_python_sink"]}}, "required": ["node_type", "target_type"]}, + "then": {"properties": {"config": {"$ref": "#/$defs/target_sink_config"}}} + } + ] + }, + + "source_common": { + "type": "object", + "properties": { + "mode": {"type": "string", "enum": ["stream", "batch"], "default": "stream", "description": "Read mode. Default: stream."}, + "select_exp": {"type": "array", "items": {"type": "string"}, "description": "SELECT expressions applied to the source."}, + "where_clause": {"type": "array", "items": {"type": "string"}, "description": "WHERE predicates applied to the source."}, + "schema_path": {"type": "string", "pattern": "\\.(json|ddl)$", "description": "Path to a schema file applied when reading."} + } + }, + + "python_transform": { + "type": "object", + "description": "Inline Python transform applied to a source. Provide function_path or python_module (module alias also accepted).", + "properties": { + "function_path": {"type": "string", "pattern": "\\.py$"}, + "python_module": {"type": "string"}, + "module": {"type": "string"}, + "tokens": {"type": "object", "additionalProperties": true} + }, + "additionalProperties": false + }, + + "source_delta_config": { + "type": "object", + "description": "Delta table source.", + "allOf": [ + {"$ref": "#/$defs/source_common"}, + { + "if": {"properties": {"starting_version_from_dlt_setup": {"const": true}}, "required": ["starting_version_from_dlt_setup"]}, + "then": {"properties": {"cdf_enabled": {"const": true}}} + } + ], + "properties": { + "mode": {"type": "string", "enum": ["stream", "batch"], "default": "stream"}, + "database": {"type": "string", "description": "Source schema/database."}, + "table": {"type": "string", "description": "Source table name."}, + "table_path": {"type": "string", "description": "Physical path to the Delta table (alternative to database/table)."}, + "cdf_enabled": {"type": "boolean", "description": "Read the table's Change Data Feed."}, + "cdf_change_type_override": {"type": "array", "items": {"type": "string", "enum": ["insert", "update_postimage", "delete"]}}, + "starting_version_from_dlt_setup": {"type": "boolean"}, + "select_exp": {"type": "array", "items": {"type": "string"}}, + "where_clause": {"type": "array", "items": {"type": "string"}}, + "schema_path": {"type": "string", "pattern": "\\.(json|ddl)$"}, + "reader_options": {"type": "object", "additionalProperties": true}, + "python_transform": {"$ref": "#/$defs/python_transform"} + }, + "required": ["table"], + "additionalProperties": false + }, + + "source_cloud_files_config": { + "type": "object", + "description": "Auto Loader (cloudFiles) source.", + "properties": { + "mode": {"type": "string", "enum": ["stream", "batch"], "default": "stream"}, + "path": {"type": "string", "description": "Directory or glob to ingest."}, + "reader_options": {"$ref": "#/$defs/reader_options_cloud_files"}, + "select_exp": {"type": "array", "items": {"type": "string"}}, + "where_clause": {"type": "array", "items": {"type": "string"}}, + "schema_path": {"type": "string", "pattern": "\\.json$"}, + "python_transform": {"$ref": "#/$defs/python_transform"} + }, + "required": ["path", "reader_options"], + "additionalProperties": false + }, + + "source_batch_files_config": { + "type": "object", + "description": "Batch file source (read once, not streamed).", + "properties": { + "mode": {"type": "string", "enum": ["stream", "batch"], "default": "batch"}, + "format": {"type": "string", "enum": ["csv", "json", "parquet", "text", "xml"]}, + "path": {"type": "string"}, + "reader_options": {"type": "object", "additionalProperties": true}, + "select_exp": {"type": "array", "items": {"type": "string"}}, + "where_clause": {"type": "array", "items": {"type": "string"}}, + "schema_path": {"type": "string", "pattern": "\\.json$"}, + "python_transform": {"$ref": "#/$defs/python_transform"} + }, + "required": ["path", "reader_options"], + "additionalProperties": false + }, + + "source_kafka_config": { + "type": "object", + "description": "Kafka source.", + "properties": { + "mode": {"type": "string", "enum": ["stream", "batch"], "default": "stream"}, + "reader_options": {"$ref": "#/$defs/reader_options_kafka"}, + "select_exp": {"type": "array", "items": {"type": "string"}}, + "where_clause": {"type": "array", "items": {"type": "string"}}, + "schema_path": {"type": "string", "pattern": "\\.json$"}, + "python_transform": {"$ref": "#/$defs/python_transform"} + }, + "required": ["reader_options"], + "additionalProperties": false + }, + + "source_delta_join_config": { + "type": "object", + "description": "Join of multiple Delta sources into one view.", + "properties": { + "mode": {"type": "string", "enum": ["stream", "batch"], "default": "stream"}, + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "database": {"type": "string"}, + "table": {"type": "string"}, + "alias": {"type": "string"}, + "join_mode": {"type": "string", "enum": ["stream", "static"], "default": "stream"}, + "cdf_enabled": {"type": "boolean"}, + "table_path": {"type": "string"}, + "select_exp": {"type": "array", "items": {"type": "string"}}, + "where_clause": {"type": "array", "items": {"type": "string"}}, + "schema_path": {"type": "string", "pattern": "\\.json$"}, + "reader_options": {"type": "object", "additionalProperties": true}, + "python_transform": {"$ref": "#/$defs/python_transform"} + }, + "required": ["database", "table", "alias", "cdf_enabled", "join_mode"], + "additionalProperties": false + } + }, + "joins": { + "type": "array", + "items": { + "type": "object", + "properties": { + "join_type": {"type": "string", "enum": ["left", "inner"], "default": "left"}, + "condition": {"type": "string"} + }, + "required": ["join_type", "condition"], + "additionalProperties": false + } + }, + "select_exp": {"type": "array", "items": {"type": "string"}}, + "where_clause": {"type": "array", "items": {"type": "string"}} + }, + "required": ["sources", "joins"], + "additionalProperties": false + }, + + "sql_logic_config": { + "type": "object", + "description": "SQL source or transformation. Provide exactly one of sql_path or sql_statement.", + "oneOf": [ + { + "properties": { + "mode": {"type": "string", "enum": ["stream", "batch"], "default": "stream"}, + "sql_path": {"type": "string", "description": "Path to a .sql file."} + }, + "required": ["sql_path"], + "additionalProperties": false + }, + { + "properties": { + "mode": {"type": "string", "enum": ["stream", "batch"], "default": "stream"}, + "sql_statement": {"type": "string", "description": "Inline SQL. May reference upstream views via STREAM(live.)."} + }, + "required": ["sql_statement"], + "additionalProperties": false + } + ] + }, + + "python_logic_config": { + "type": "object", + "description": "Python source or transformation. Provide exactly one of function_path or python_module.", + "properties": { + "mode": {"type": "string", "enum": ["stream", "batch"], "default": "stream"}, + "function_path": {"type": "string", "pattern": "\\.py$", "description": "Path to a .py file exposing the function."}, + "python_module": {"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_.]*\\.[a-zA-Z_][a-zA-Z0-9_]*$", "description": "Dotted module.function reference."}, + "tokens": {"type": "object", "additionalProperties": true, "description": "Tokens passed to the Python callable."} + }, + "oneOf": [ + {"required": ["function_path"]}, + {"required": ["python_module"]} + ], + "additionalProperties": false + }, + + "target_delta_config": { + "type": "object", + "description": "Delta target (target_type=delta). A streaming table by default; set table_type=mv for a materialized view (then supply sql_path/sql_statement or an input_flows source).", + "properties": { + "input_flows": {"$ref": "#/$defs/input_flows"}, + "table": {"type": "string", "description": "Target table name."}, + "database": {"type": "string", "description": "Target schema/database."}, + "table_type": {"type": "string", "enum": ["st", "mv"], "default": "st", "description": "st (streaming table) or mv (materialized view). Default: st."}, + "schema_path": {"type": "string", "pattern": "\\.(json|ddl)$"}, + "path": {"type": "string", "description": "External location for the table."}, + "table_properties": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Delta table properties."}, + "partition_columns": {"type": "array", "items": {"type": "string"}}, + "cluster_by_columns": {"type": "array", "items": {"type": "string"}}, + "cluster_by_auto": {"type": "boolean", "description": "Enable Delta automatic liquid clustering."}, + "comment": {"type": "string"}, + "spark_conf": {"type": "object"}, + "row_filter": {"type": "string"}, + "config_flags": {"type": "array", "items": {"type": "string", "enum": ["disable_operational_metadata"]}}, + "once": {"type": "boolean", "default": false, "description": "Run the flow to this target once (batch), then stop."}, + "cdc_settings": {"$ref": "./definitions_main.json#/definitions/cdcSettings"}, + "cdc_snapshot_settings": {"$ref": "#/$defs/snapshot_cdc_config"}, + "table_migration_details": {"$ref": "#/$defs/table_migration_details"}, + "sql_path": {"type": "string", "description": "MV only: path to a .sql file defining the view."}, + "sql_statement": {"type": "string", "description": "MV only: inline SQL defining the view."}, + "refresh_policy": {"type": "string", "enum": ["auto", "incremental", "incremental_strict", "full"], "description": "MV only: refresh policy."}, + "private": {"type": "boolean", "description": "MV only: create the materialized view as private (internal, not published to the catalog)."}, + "data_quality": {"$ref": "#/$defs/data_quality"}, + "quarantine": {"$ref": "#/$defs/quarantine"} + }, + "required": ["table"], + "additionalProperties": false, + "not": {"required": ["cluster_by_columns", "partition_columns"]}, + "allOf": [ + { + "if": {"properties": {"table_type": {"const": "mv"}}, "required": ["table_type"]}, + "then": { + "$comment": "Materialized view: streaming-table-only fields are not allowed.", + "properties": { + "cdc_settings": false, + "cdc_snapshot_settings": false, + "table_migration_details": false, + "once": false + } + }, + "else": { + "$comment": "Streaming table (table_type st/omitted): materialized-view-only fields are not allowed.", + "properties": { + "sql_path": false, + "sql_statement": false, + "refresh_policy": false, + "private": false + } + } + } + ] + }, + + "target_sink_config": { + "type": "object", + "description": "Sink target (target_type = delta_sink / kafka_sink / foreach_batch_sink / custom_python_sink). NOTE: the sink redesign is DEFERRED — sink fields remain in their current flat form (sink_type / sink_config / sink_options) rather than a nested `sink` object. Revisit when the sink redesign is taken up.", + "properties": { + "input_flows": {"$ref": "#/$defs/input_flows"}, + "name": {"type": "string", "description": "Sink name."}, + "sink_type": {"type": "string", "description": "Sink sub-type, e.g. basic_sql or python_function (foreach_batch_sink)."}, + "sink_config": {"type": "object", "additionalProperties": true, "description": "Sink-specific configuration (e.g. sql_path, function_path, tokens)."}, + "sink_options": {"type": "object", "additionalProperties": true, "description": "Sink options (e.g. table_name/path for delta_sink; topic/bootstrap servers for kafka_sink)."} + }, + "additionalProperties": false + }, + + "input_flows": { + "type": "array", + "description": "Upstream views feeding this target. Each item is a node name (flow auto-named) or {view, flow} to pin the SDP flow name.", + "items": { + "oneOf": [ + {"type": "string", "description": "Upstream node name; SDP flow name auto-generated."}, + { + "type": "object", + "properties": { + "view": {"type": "string", "description": "Upstream node name feeding this target."}, + "flow": {"type": "string", "description": "Explicit SDP flow name. Pin to keep it stable (renaming forces a full refresh)."} + }, + "required": ["view"], + "additionalProperties": false + } + ] + } + }, + + "data_quality": { + "type": "object", + "description": "Data quality expectations. Presence enables expectations (no separate enabled flag).", + "properties": { + "expectations_path": {"type": "string", "description": "Path to the expectations file."} + }, + "required": ["expectations_path"], + "additionalProperties": false + }, + + "quarantine": { + "type": "object", + "description": "Quarantine for rows failing expectations. Omit the whole object for no quarantine.", + "properties": { + "mode": {"type": "string", "enum": ["flag", "table"], "description": "flag: mark rows in place; table: route bad rows to a side table."}, + "target": {"$ref": "#/$defs/quarantine_target", "description": "Quarantine table config (required when mode=table)."} + }, + "required": ["mode"], + "additionalProperties": false, + "if": {"properties": {"mode": {"const": "table"}}, "required": ["mode"]}, + "then": {"required": ["target"]} + }, + + "quarantine_target": { + "type": "object", + "properties": { + "target_format": {"type": "string", "enum": ["delta"], "default": "delta"}, + "table": {"type": "string"}, + "database": {"type": "string"}, + "table_properties": {"type": "object", "additionalProperties": {"type": "string"}}, + "path": {"type": "string"}, + "partition_columns": {"type": "array", "items": {"type": "string"}}, + "cluster_by_columns": {"type": "array", "items": {"type": "string"}}, + "cluster_by_auto": {"type": "boolean"} + }, + "additionalProperties": false + }, + + "table_migration_details": { + "type": "object", + "description": "One-time migration of an existing table into this pipeline.", + "properties": { + "enabled": {"type": "boolean"}, + "auto_starting_versions_enabled": {"type": "boolean", "default": true}, + "catalog_type": {"type": "string", "enum": ["hms", "uc"]}, + "source_details": { + "type": "object", + "properties": { + "database": {"type": "string"}, + "table": {"type": "string"}, + "select_exp": {"type": "array", "items": {"type": "string"}}, + "where_clause": {"type": "array", "items": {"type": "string"}}, + "except_columns": {"type": "array", "items": {"type": "string"}} + }, + "required": ["database", "table"], + "additionalProperties": false + } + }, + "required": ["enabled", "catalog_type", "source_details"], + "additionalProperties": false + }, + + "snapshot_cdc_config": { + "type": "object", + "description": "Snapshot CDC config for a target. Historical snapshots read files/tables directly (no source node); periodic snapshots read from an upstream source.", + "properties": { + "keys": {"type": "array", "items": {"type": "string"}}, + "scd_type": {"type": ["string", "integer"]}, + "snapshot_type": {"type": "string", "enum": ["historical", "periodic"]}, + "source_type": {"type": "string", "enum": ["file", "table"], "description": "Historical source kind. Not used for periodic."}, + "source": { + "type": "object", + "description": "Historical snapshot source. Fields depend on source_type. Periodic snapshots omit this.", + "properties": { + "table": {"type": "string"}, + "path": {"type": "string"}, + "format": {"type": "string"}, + "reader_options": {"type": "object", "additionalProperties": true}, + "schema_path": {"type": "string"}, + "select_exp": {"type": "array", "items": {"type": "string"}}, + "version_type": {"type": "string"}, + "version_column": {"type": "string"}, + "starting_version": {"type": ["string", "integer"]}, + "datetime_format": {"type": "string"}, + "deduplicate_mode": {"type": "string"}, + "recursive_file_lookup": {"type": ["boolean", "string"]} + }, + "additionalProperties": true + }, + "track_history_column_list": {"type": "array", "items": {"type": "string"}}, + "track_history_except_column_list": {"type": "array", "items": {"type": "string"}} + }, + "required": ["keys", "scd_type", "snapshot_type"], + "additionalProperties": true, + "allOf": [ + { + "$comment": "Periodic snapshots read from an upstream source node; no inline source_type/source.", + "if": {"properties": {"snapshot_type": {"const": "periodic"}}, "required": ["snapshot_type"]}, + "then": {"properties": {"source_type": false, "source": false}}, + "else": {"required": ["source_type", "source"]} + }, + { + "$comment": "Historical file source needs a path and format.", + "if": {"properties": {"source_type": {"const": "file"}}, "required": ["source_type"]}, + "then": {"properties": {"source": {"required": ["path", "format"]}}} + }, + { + "$comment": "Historical table source needs a table.", + "if": {"properties": {"source_type": {"const": "table"}}, "required": ["source_type"]}, + "then": {"properties": {"source": {"required": ["table"]}}} + } + ] + }, + + "reader_options_cloud_files": { + "type": "object", + "description": "Auto Loader options. cloudFiles.format is required; format-specific options are offered once it is set.", + "properties": { + "cloudFiles.format": {"type": "string", "enum": ["avro", "binaryFile", "csv", "json", "orc", "parquet", "text", "xml"]}, + "cloudFiles.inferColumnTypes": {"type": "string", "enum": ["true", "false"], "default": "false"}, + "cloudFiles.schemaEvolutionMode": {"type": "string", "enum": ["addNewColumns", "rescue", "failOnNewColumns"]}, + "cloudFiles.schemaHints": {"type": "string"}, + "cloudFiles.schemaLocation": {"type": "string"}, + "cloudFiles.maxFilesPerTrigger": {"type": "string", "default": "1000"}, + "cloudFiles.maxBytesPerTrigger": {"type": "string"}, + "cloudFiles.includeExistingFiles": {"type": "string", "enum": ["true", "false"], "default": "true"}, + "cloudFiles.allowOverwrites": {"type": "string", "enum": ["true", "false"], "default": "false"}, + "cloudFiles.useStrictGlobber": {"type": "string", "enum": ["true", "false"], "default": "false"}, + "cloudFiles.validateOptions": {"type": "string", "enum": ["true", "false"], "default": "false"}, + "pathGlobFilter": {"type": "string"}, + "recursiveFileLookup": {"type": "string", "enum": ["true", "false"], "default": "false"} + }, + "required": ["cloudFiles.format"], + "additionalProperties": true, + "allOf": [ + { + "if": {"properties": {"cloudFiles.format": {"const": "csv"}}, "required": ["cloudFiles.format"]}, + "then": {"properties": { + "header": {"type": "string", "enum": ["true", "false"], "default": "true"}, + "inferSchema": {"type": "string", "enum": ["true", "false"], "default": "false"}, + "sep": {"type": "string", "default": ","}, + "quote": {"type": "string", "default": "\""}, + "mode": {"type": "string", "enum": ["PERMISSIVE", "DROPMALFORMED", "FAILFAST"], "default": "PERMISSIVE"}, + "multiLine": {"type": "string", "enum": ["true", "false"], "default": "false"}, + "rescuedDataColumn": {"type": "string", "default": "_rescued_data"} + }} + }, + { + "if": {"properties": {"cloudFiles.format": {"const": "json"}}, "required": ["cloudFiles.format"]}, + "then": {"properties": { + "mode": {"type": "string", "enum": ["PERMISSIVE", "DROPMALFORMED", "FAILFAST"], "default": "PERMISSIVE"}, + "multiLine": {"type": "string", "enum": ["true", "false"], "default": "false"}, + "rescuedDataColumn": {"type": "string", "default": "_rescued_data"} + }} + } + ] + }, + + "reader_options_kafka": { + "type": "object", + "description": "Kafka reader options.", + "properties": { + "subscribe": {"type": "string"}, + "kafka.bootstrap.servers": {"type": "string"}, + "kafka.group.id": {"type": "string"}, + "kafka.security.protocol": {"type": "string", "default": "SASL_SSL"}, + "kafka.sasl.mechanism": {"type": "string", "default": "PLAIN"}, + "kafka.ssl.truststore.location": {"type": "string"}, + "kafka.ssl.truststore.password": {"type": "string"}, + "kafka.ssl.keystore.location": {"type": "string"}, + "kafka.ssl.keystore.password": {"type": "string"}, + "startingOffsets": {"type": "string"}, + "endingOffsets": {"type": "string"}, + "minPartitions": {"type": "string"}, + "failOnDataLoss": {"type": "string"} + }, + "required": ["kafka.bootstrap.servers"], + "additionalProperties": true + } + } +}