diff --git a/.gitattributes b/.gitattributes
index a6344aac8c09253b3b630fb776ae94478aa0275b..bca3d0584d95a523a32c9a6bec0dc473f1979eea 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,35 +1,6 @@
-*.7z filter=lfs diff=lfs merge=lfs -text
-*.arrow filter=lfs diff=lfs merge=lfs -text
-*.bin filter=lfs diff=lfs merge=lfs -text
-*.bz2 filter=lfs diff=lfs merge=lfs -text
-*.ckpt filter=lfs diff=lfs merge=lfs -text
-*.ftz filter=lfs diff=lfs merge=lfs -text
-*.gz filter=lfs diff=lfs merge=lfs -text
-*.h5 filter=lfs diff=lfs merge=lfs -text
-*.joblib filter=lfs diff=lfs merge=lfs -text
-*.lfs.* filter=lfs diff=lfs merge=lfs -text
-*.mlmodel filter=lfs diff=lfs merge=lfs -text
-*.model filter=lfs diff=lfs merge=lfs -text
-*.msgpack filter=lfs diff=lfs merge=lfs -text
-*.npy filter=lfs diff=lfs merge=lfs -text
-*.npz filter=lfs diff=lfs merge=lfs -text
-*.onnx filter=lfs diff=lfs merge=lfs -text
-*.ot filter=lfs diff=lfs merge=lfs -text
-*.parquet filter=lfs diff=lfs merge=lfs -text
-*.pb filter=lfs diff=lfs merge=lfs -text
-*.pickle filter=lfs diff=lfs merge=lfs -text
-*.pkl filter=lfs diff=lfs merge=lfs -text
-*.pt filter=lfs diff=lfs merge=lfs -text
-*.pth filter=lfs diff=lfs merge=lfs -text
-*.rar filter=lfs diff=lfs merge=lfs -text
-*.safetensors filter=lfs diff=lfs merge=lfs -text
-saved_model/**/* filter=lfs diff=lfs merge=lfs -text
-*.tar.* filter=lfs diff=lfs merge=lfs -text
-*.tar filter=lfs diff=lfs merge=lfs -text
-*.tflite filter=lfs diff=lfs merge=lfs -text
-*.tgz filter=lfs diff=lfs merge=lfs -text
-*.wasm filter=lfs diff=lfs merge=lfs -text
-*.xz filter=lfs diff=lfs merge=lfs -text
-*.zip filter=lfs diff=lfs merge=lfs -text
-*.zst filter=lfs diff=lfs merge=lfs -text
-*tfevents* filter=lfs diff=lfs merge=lfs -text
+[README.md]
+README_HF_SPACE.md linguist-generated=false
+
+[examples/**]
+*.sql linguist-language=SQL
+model/risk_classifier.joblib filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..bed5a8873c7bd380e96b56e20613f3081046f7a7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+__pycache__/
+*.py[cod]
+*$py.class
+*.egg-info/
+dist/
+build/
+.eggs/
+*.egg
+.venv/
+venv/
+.env
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+htmlcov/
+.coverage
+migration-output/
+*.html
+!sqlshift/report/*.py
diff --git a/.hfignore b/.hfignore
new file mode 100644
index 0000000000000000000000000000000000000000..fe7c75d5d8894db72e6158a752f377fdc3b5ab57
--- /dev/null
+++ b/.hfignore
@@ -0,0 +1,17 @@
+# Extra excludes if this repo is used as a git-backed Hugging Face Space
+.venv/
+venv/
+.git/
+__pycache__/
+*.pyc
+*.egg-info/
+.pytest_cache/
+.ruff_cache/
+.mypy_cache/
+htmlcov/
+.coverage
+migration-output/
+.cursor/
+canvases/
+.env
+.DS_Store
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..333e13626722d5800d1f17ece194aa82f5275c6d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,190 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ Copyright 2026 Digvijay Waghela
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/MODEL_CARD.md b/MODEL_CARD.md
new file mode 100644
index 0000000000000000000000000000000000000000..825a08f26f36ab6d53fc1e84a21040c5bfd68450
--- /dev/null
+++ b/MODEL_CARD.md
@@ -0,0 +1,47 @@
+---
+language: en
+license: apache-2.0
+library_name: morphsql
+# Product name on the Hub UI / Space: MorphSQL
+pipeline_tag: text-classification
+tags:
+ - morphsql
+ - agent
+ - code
+ - sql
+ - text-generation
+ - text-classification
+ - rag
+ - sklearn
+ - migration
+ - dbt
+ - snowflake
+datasets:
+ - dgvj-work/vertica-snowflake-pairs
+---
+
+# MorphSQL
+
+Convert Vertica / Oracle / Redshift / BigQuery SQL to **pandas**, **PySpark**, Snowflake, BigQuery, or dbt.
+
+> Brand: **MorphSQL** · Package import: `morphsql` · Hub slug: `dgvj-work/morphsql`
+
+## Artifacts
+- `risk_classifier.joblib` — migration risk (`low` / `medium` / `high`)
+- `rewrite_vocabulary.json` — rewrite lexicon
+- `config.json` — dialect metadata
+
+## Quick start
+
+```python
+from morphsql.ai import pipeline
+
+print(pipeline("sql-migration")("SELECT COALESCE(a, 0) FROM t", source="snowflake", target="pandas"))
+print(pipeline("sql-migration")("SELECT COALESCE(a, 0) FROM t", source="snowflake", target="pyspark"))
+print(pipeline("sql-risk-classification")("EXECUTE IMMEDIATE 'x'"))
+```
+
+## Demo
+https://huggingface.co/spaces/dgvj-work/morphsql
+
+Author: Digvijay Waghela · Apache-2.0
diff --git a/PROJECT.md b/PROJECT.md
new file mode 100644
index 0000000000000000000000000000000000000000..0f30c5cda8270f63d3bc8af69c7d73aad8cf58c1
--- /dev/null
+++ b/PROJECT.md
@@ -0,0 +1,115 @@
+# MorphSQL — Project Context Document
+
+> **Purpose:** Give this file to any AI assistant or new developer to continue work without prior conversation context.
+
+## Naming
+
+| Layer | Name |
+|-------|------|
+| **Product (UI, docs, Hugging Face Space title)** | **MorphSQL** |
+| Python import | `morphsql` |
+| PyPI package | `morphsql` |
+| GitHub repo | `morphsql` |
+| HF Space / model slug | `dgvj-work/morphsql` |
+
+Always use **MorphSQL** / `morphsql` consistently (product, package, CLI, Hub).
+
+## What this project is
+
+**MorphSQL** is an open-source **SQL migration toolkit** (package import: `morphsql`).
+
+It is **not** only a single-query converter. It analyzes legacy SQL repositories and produces:
+
+1. **Discovery** — scan zip files/directories for SQL, procedures, views, dbt models
+2. **Lineage** — dependency graphs (table/object level)
+3. **Risk assessment** — complexity scoring, unsupported syntax, downstream impact
+4. **Workload rationalization** — migrate / review / rewrite / retire per object
+5. **Hybrid translation** — rule-based + sqlglot dialect conversion (pandas / PySpark / warehouse / dbt)
+6. **dbt architecture** — decompose procedures into staging/intermediate/mart
+7. **Validation** — reconciliation tests (row count, null rate, checksums)
+8. **Migration runbook** — phased cutover plan with object action table
+9. **Migration copilot** — LLM advisor grounded in scan context (HF Inference API)
+
+## Repository layout
+
+```
+morphsql/
+├── app.py # Hugging Face Gradio Space (MorphSQL UI)
+├── demo/
+│ ├── handlers.py # Gradio event handlers
+│ └── theme.py # Dark theme CSS/constants
+├── morphsql/ # Core Python package
+│ ├── scanner/ # Repository discovery
+│ ├── parser/ # SQL parsing (sqlglot)
+│ ├── lineage/ # Dependency graphs (networkx)
+│ ├── translator/ # Hybrid SQL conversion (+ pandas/pyspark codegen)
+│ ├── risk/ # Complexity & risk scoring
+│ ├── dbt_generator/ # dbt project decomposition
+│ ├── validation/ # Reconciliation tests
+│ ├── knowledge/ # Platform behavior differences KB
+│ ├── intelligence/ # Runbook, rationalization, lineage viz
+│ ├── assistant/ # LLM copilot (huggingface_hub)
+│ ├── report/ # HTML report generation
+│ ├── pipeline.py # Agent orchestration
+│ ├── models.py # Pydantic data models
+│ └── cli.py # `morphsql` CLI
+├── examples/vertica_legacy/ # Sample legacy Vertica repository
+├── tests/
+├── PROJECT.md # This file
+├── README.md
+├── README_HF_SPACE.md # HF Space card (title: MorphSQL)
+├── MODEL_CARD.md
+└── pyproject.toml
+```
+
+## Architecture
+
+```
+Repository (zip / directory)
+ ↓
+MigrationPipeline.analyze() → Discovery + Lineage + Risk
+ ↓
+MigrationPipeline.convert() → Hybrid SQL translation
+ ↓
+MigrationPipeline.validate() → Reconciliation tests
+ ↓
+generate_runbook() → Cutover plan
+generate_rationalization() → Migrate/retire recommendations
+decompose_to_dbt() → dbt scaffold
+MigrationCopilot.respond() → LLM Q&A grounded in report
+```
+
+## Key modules
+
+| Module | File | Purpose |
+|--------|------|---------|
+| Pipeline | `morphsql/pipeline.py` | Orchestrates full workflow |
+| Translator | `morphsql/translator/engine.py` | ZEROIFNULL, DATEADD, procedure wrappers, sqlglot |
+| Pandas / PySpark | `morphsql/translator/*_codegen.py` | Notebook / Spark DataFrame codegen |
+| Copilot | `morphsql/assistant/copilot.py` | HF Inference API + fallback KB |
+| Runbook | `morphsql/intelligence/runbook.py` | Migration runbook markdown |
+| Lineage viz | `morphsql/intelligence/lineage_viz.py` | Plotly network graph |
+| Rationalization | `morphsql/intelligence/rationalization.py` | Workload action plan |
+
+## CLI commands
+
+```bash
+morphsql analyze PATH -s vertica -t snowflake
+morphsql convert PATH -s snowflake -t pandas
+morphsql migrate PATH -s vertica -t snowflake -o migration-output
+morphsql version
+```
+
+## Hugging Face
+
+- Space title: **MorphSQL**
+- Space URL: https://huggingface.co/spaces/dgvj-work/morphsql
+- Deploy: `./scripts/deploy_hf.sh`
+- Preflight: `python scripts/check_space.py`
+- Copilot model env: `MORPHSQL_MODEL`
+
+## Gradio UI tabs
+
+1. **Convert** — primary: paste/upload SQL → pandas/PySpark/SQL/dbt + preview + download
+2. **Guide** — how-to for DS / AI users
+3. **More** — object assess, repo workbench, feature SQL, copilot, RAG, eval
diff --git a/README.md b/README.md
index 702805278577294ddcca3db09d78e27a3817690d..6e22e662352eb0fa3f69fdd6c5e8af09d99ce765 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,68 @@
---
-title: Morphsql
-emoji: ⚡
-colorFrom: purple
-colorTo: pink
+title: MorphSQL
+emoji: 🧬
+colorFrom: blue
+colorTo: green
sdk: gradio
-sdk_version: 6.20.0
-python_version: '3.13'
+sdk_version: "5.49.1"
+python_version: "3.11"
app_file: app.py
-pinned: false
+pinned: true
license: apache-2.0
-short_description: AI Sql Converter
+short_description: Convert SQL to pandas, PySpark, warehouses, or dbt
+tags:
+ - sql
+ - pandas
+ - pyspark
+ - code
+ - data-science
+ - machine-learning
+ - feature-extraction
+ - snowflake
+ - dbt
+ - data-engineering
+models:
+ - dgvj-work/morphsql
+datasets:
+ - dgvj-work/vertica-snowflake-pairs
+suggested_hardware: cpu-basic
---
-Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
+# MorphSQL
+
+Convert warehouse SQL → **pandas**, **PySpark**, Snowflake, BigQuery, or **dbt** — then **download** the result.
+
+Package / CLI / Hub: `morphsql` · Space: [dgvj-work/morphsql](https://huggingface.co/spaces/dgvj-work/morphsql)
+
+## How to use (30 seconds)
+
+1. Open the **Convert** tab
+2. Pick source dialect + **Convert to** target
+3. Load an example, paste SQL, or **upload** a `.sql` / `.zip`
+4. Click **Convert** or **Upload & Convert → Download**
+5. Download the `.py` / `.sql` / `.zip` to your machine
+
+| Target | Download |
+|--------|----------|
+| pandas / PySpark | `.py` ready for notebooks / Databricks |
+| Snowflake / BigQuery | `.sql` |
+| dbt | `.txt` project preview (or zip for batches) |
+
+## Python API
+
+```python
+from morphsql.ai import pipeline
+
+out = pipeline("sql-migration")(
+ "SELECT COALESCE(a, 0) FROM t",
+ source="snowflake",
+ target="pandas", # or "pyspark", "snowflake", "bigquery", "dbt-snowflake"
+)
+print(out["converted_sql"][:500])
+```
+
+## More tab
+
+Object risk assess · repo workbench · ML feature SQL · copilot · dialect notes · offline eval
+
+Author: Digvijay Waghela · Apache-2.0 · [GitHub](https://github.com/dgvj-work/morphsql)
diff --git a/README_HF_SPACE.md b/README_HF_SPACE.md
new file mode 100644
index 0000000000000000000000000000000000000000..6e22e662352eb0fa3f69fdd6c5e8af09d99ce765
--- /dev/null
+++ b/README_HF_SPACE.md
@@ -0,0 +1,68 @@
+---
+title: MorphSQL
+emoji: 🧬
+colorFrom: blue
+colorTo: green
+sdk: gradio
+sdk_version: "5.49.1"
+python_version: "3.11"
+app_file: app.py
+pinned: true
+license: apache-2.0
+short_description: Convert SQL to pandas, PySpark, warehouses, or dbt
+tags:
+ - sql
+ - pandas
+ - pyspark
+ - code
+ - data-science
+ - machine-learning
+ - feature-extraction
+ - snowflake
+ - dbt
+ - data-engineering
+models:
+ - dgvj-work/morphsql
+datasets:
+ - dgvj-work/vertica-snowflake-pairs
+suggested_hardware: cpu-basic
+---
+
+# MorphSQL
+
+Convert warehouse SQL → **pandas**, **PySpark**, Snowflake, BigQuery, or **dbt** — then **download** the result.
+
+Package / CLI / Hub: `morphsql` · Space: [dgvj-work/morphsql](https://huggingface.co/spaces/dgvj-work/morphsql)
+
+## How to use (30 seconds)
+
+1. Open the **Convert** tab
+2. Pick source dialect + **Convert to** target
+3. Load an example, paste SQL, or **upload** a `.sql` / `.zip`
+4. Click **Convert** or **Upload & Convert → Download**
+5. Download the `.py` / `.sql` / `.zip` to your machine
+
+| Target | Download |
+|--------|----------|
+| pandas / PySpark | `.py` ready for notebooks / Databricks |
+| Snowflake / BigQuery | `.sql` |
+| dbt | `.txt` project preview (or zip for batches) |
+
+## Python API
+
+```python
+from morphsql.ai import pipeline
+
+out = pipeline("sql-migration")(
+ "SELECT COALESCE(a, 0) FROM t",
+ source="snowflake",
+ target="pandas", # or "pyspark", "snowflake", "bigquery", "dbt-snowflake"
+)
+print(out["converted_sql"][:500])
+```
+
+## More tab
+
+Object risk assess · repo workbench · ML feature SQL · copilot · dialect notes · offline eval
+
+Author: Digvijay Waghela · Apache-2.0 · [GitHub](https://github.com/dgvj-work/morphsql)
diff --git a/app.py b/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..1041e57b9a925ccb44802170090afc004487858d
--- /dev/null
+++ b/app.py
@@ -0,0 +1,534 @@
+"""MorphSQL — Hugging Face Space (conversion-first, DS-friendly)."""
+
+from __future__ import annotations
+
+import inspect
+
+import gradio as gr
+
+from morphsql import __product_name__, __version__
+from morphsql.ai.risk_model import train_and_save
+from morphsql.eval.pairs import ensure_pairs_file
+from demo.handlers import (
+ HERO_EXAMPLE,
+ PLAYGROUND_EXAMPLE_LABELS,
+ SOURCE_DROPDOWN,
+ TARGET_DROPDOWN,
+ analyze_sql_object_ui,
+ convert_for_ui,
+ convert_upload_for_ui,
+ copilot_chat,
+ get_leaderboard_md,
+ load_sql_from_upload,
+ on_example_selected,
+ run_behavior_rag,
+ run_eval_suite,
+ run_feature_migration,
+ run_workbench_ui,
+ submit_eval_score,
+)
+from demo.theme import CUSTOM_CSS, build_theme
+
+SPACE_URL = "https://huggingface.co/spaces/dgvj-work/morphsql"
+GITHUB_URL = "https://github.com/dgvj-work/morphsql"
+
+# Soft-fail boot so the Space still loads if artifacts are regenerating
+try:
+ ensure_pairs_file()
+except Exception as exc: # pragma: no cover
+ print(f"[MorphSQL] pairs bootstrap skipped: {exc}")
+try:
+ train_and_save()
+except Exception as exc: # pragma: no cover
+ print(f"[MorphSQL] risk model bootstrap skipped: {exc}")
+
+try:
+ _BOOT = convert_for_ui(HERO_EXAMPLE, "snowflake", "pandas")
+except Exception as exc: # pragma: no cover
+ print(f"[MorphSQL] boot convert failed: {exc}")
+ _BOOT = (
+ "Paste SQL and click **Convert**.",
+ "",
+ "Ready",
+ f"[Space]({SPACE_URL})",
+ None,
+ None,
+ "# Convert SQL to get a notebook cell",
+ 'from morphsql.ai import pipeline\nprint(pipeline("sql-migration")("SELECT 1", source="snowflake", target="pandas"))\n',
+ )
+
+
+def _blocks_style_kwargs() -> dict:
+ """Gradio 5 (HF Spaces): theme/css on Blocks. Gradio 6+: those moved to launch()."""
+ params = inspect.signature(gr.Blocks.__init__).parameters
+ kwargs: dict = {}
+ if "theme" in params:
+ kwargs["theme"] = build_theme()
+ if "css" in params:
+ kwargs["css"] = CUSTOM_CSS
+ return kwargs
+
+
+def _launch_style_kwargs() -> dict:
+ """Pass theme/css to launch() when Blocks no longer accepts them (Gradio 6+)."""
+ if "theme" in inspect.signature(gr.Blocks.__init__).parameters:
+ return {}
+ return {"theme": build_theme(), "css": CUSTOM_CSS}
+
+
+def _chatbot(**kwargs):
+ """Chatbot with type='messages' when supported (Gradio 5.x)."""
+ params = inspect.signature(gr.Chatbot.__init__).parameters
+ if "type" in params and "type" not in kwargs:
+ kwargs["type"] = "messages"
+ return gr.Chatbot(**kwargs)
+
+
+def _build_demo() -> gr.Blocks:
+ with gr.Blocks(
+ title=f"{__product_name__} — SQL → pandas / PySpark",
+ **_blocks_style_kwargs(),
+ ) as demo:
+ eval_state = gr.State(value={})
+ eval_category = gr.State(value="all")
+
+ gr.HTML(
+ f"""
+
+ """
+ )
+
+ with gr.Tabs():
+ with gr.Tab("Convert"):
+ gr.Markdown(
+ "1. Choose input dialect + output · "
+ "2. Paste SQL, load an example, or **upload** a `.sql` / `.zip` · "
+ "3. **Convert** → preview + **download** to your machine"
+ )
+
+ example = gr.Dropdown(
+ choices=PLAYGROUND_EXAMPLE_LABELS,
+ value=PLAYGROUND_EXAMPLE_LABELS[0],
+ label="Load a data-science / AI example",
+ info="Fills SQL, converts, runs sample preview.",
+ )
+
+ with gr.Row(equal_height=True):
+ source = gr.Dropdown(
+ choices=SOURCE_DROPDOWN,
+ value="snowflake",
+ label="SQL is written for",
+ scale=2,
+ min_width=160,
+ )
+ target = gr.Dropdown(
+ choices=TARGET_DROPDOWN,
+ value="pandas",
+ label="Convert to",
+ scale=3,
+ min_width=220,
+ )
+ convert_btn = gr.Button(
+ "Convert",
+ variant="primary",
+ scale=1,
+ min_width=120,
+ elem_classes=["action-btn"],
+ )
+
+ with gr.Row(equal_height=False):
+ sql_upload = gr.File(
+ label="Upload SQL (.sql / .txt) or a .zip of SQL files",
+ file_types=[".sql", ".txt", ".ddl", ".zip"],
+ type="filepath",
+ scale=4,
+ min_width=280,
+ elem_classes=["sql-upload-box"],
+ )
+ with gr.Column(scale=1, min_width=180, elem_classes=["upload-action-col"]):
+ upload_convert_btn = gr.Button(
+ "Upload & Convert → Download",
+ variant="primary",
+ elem_classes=["action-btn", "upload-action-btn"],
+ )
+
+ status = gr.Textbox(
+ label="Status",
+ value=_BOOT[2],
+ interactive=False,
+ max_lines=1,
+ )
+
+ with gr.Row():
+ sql_in = gr.Textbox(
+ label="Input SQL",
+ value=HERO_EXAMPLE,
+ lines=12,
+ max_lines=24,
+ placeholder="Paste warehouse SQL, or upload a file above…",
+ )
+ sql_out = gr.Textbox(
+ label="Output code",
+ value=_BOOT[1],
+ lines=12,
+ max_lines=24,
+ )
+
+ with gr.Row():
+ download = gr.File(
+ label="Download converted file (.py / .sql / .zip)",
+ value=_BOOT[5],
+ elem_classes=["download-box"],
+ )
+ preview = gr.Dataframe(
+ label="Sample preview",
+ value=_BOOT[4],
+ wrap=True,
+ elem_classes=["preview-table"],
+ )
+
+ notes = gr.Markdown(value=_BOOT[0])
+ with gr.Accordion("Notebook starter cell", open=False):
+ notebook = gr.Code(
+ language="python",
+ value=_BOOT[6],
+ lines=10,
+ )
+ with gr.Accordion("Hugging Face pipeline API (AI / ML)", open=True):
+ gr.Markdown(
+ "Same style as `transformers.pipeline` — use in Colab, HF Jobs, or training scripts."
+ )
+ api_code = gr.Code(
+ language="python",
+ value=_BOOT[7],
+ lines=12,
+ )
+ share = gr.Markdown(value=_BOOT[3])
+
+ outs = [
+ sql_in,
+ source,
+ target,
+ notes,
+ sql_out,
+ status,
+ share,
+ preview,
+ download,
+ notebook,
+ api_code,
+ ]
+ example.change(on_example_selected, inputs=[example], outputs=outs)
+
+ convert_outs = [
+ notes,
+ sql_out,
+ status,
+ share,
+ preview,
+ download,
+ notebook,
+ api_code,
+ ]
+ convert_btn.click(
+ convert_for_ui,
+ inputs=[sql_in, source, target],
+ outputs=convert_outs,
+ )
+ sql_in.submit(
+ convert_for_ui,
+ inputs=[sql_in, source, target],
+ outputs=convert_outs,
+ )
+
+ # Upload: fill the editor immediately; button converts + refreshes download
+ sql_upload.change(
+ load_sql_from_upload,
+ inputs=[sql_upload, sql_in],
+ outputs=[sql_in],
+ )
+ upload_convert_outs = [
+ sql_in,
+ notes,
+ sql_out,
+ status,
+ share,
+ preview,
+ download,
+ notebook,
+ api_code,
+ ]
+ upload_convert_btn.click(
+ convert_upload_for_ui,
+ inputs=[sql_upload, sql_in, source, target],
+ outputs=upload_convert_outs,
+ )
+
+ with gr.Tab("Guide"):
+ gr.Markdown(
+ f"""
+## For AI / ML practitioners
+MorphSQL is a **deterministic** SQL→pandas / PySpark codegen tool (not a chat LLM). Use it when you have
+warehouse SQL for labels/features and want a Python frame for training or Spark jobs.
+
+**Typical path**
+1. Convert feature SQL → pandas or PySpark
+2. Point `tables[...]` at parquet / `datasets` / warehouse extracts / Spark tables
+3. Feed `result` into sklearn, XGBoost, Transformers, or Spark ML
+
+## Why data scientists use this
+Warehouse SQL often lives in BI tools. MorphSQL rewrites dialect quirks (NVL, ZEROIFNULL, dates)
+into pandas or PySpark you can run in Jupyter / Colab / Databricks.
+
+## Recommended workflow
+1. Paste SQL, load an example, or **upload** a `.sql` / `.zip`
+2. Choose **Convert to** (pandas / PySpark / Snowflake / BigQuery / dbt)
+3. Click **Convert** or **Upload & Convert → Download**
+4. Download the `.py` / `.sql` / `.zip` to your machine and replace synthetic tables with real data
+
+## Output choices
+| Convert to | Best for |
+|---|---|
+| **Python (pandas)** | Feature engineering, EDA, model training prep |
+| **Python (PySpark)** | Large-scale Spark DataFrame transforms |
+| **Snowflake / BigQuery SQL** | Keeping transforms in the warehouse |
+| **dbt project** | Productionizing SQL into models |
+
+## More tab (Lab)
+| Tool | Use when |
+|---|---|
+| **Object assess** | Need risk/complexity scoring for one SQL object |
+| **Repository workbench** | Scanning a SQL repo (sample or zip) with lineage + validation |
+| **ML feature SQL** | Migrating Vertica feature-engineering SQL → Snowflake/dbt |
+| **Copilot** | Migration Q&A (keyword / optional HF LLM) |
+| **Behavior notes / Eval** | Dialect quirks lookup and offline conversion scoring |
+
+```python
+from morphsql.ai import pipeline
+out = pipeline("sql-migration")(sql, source="snowflake", target="pandas")
+# or target="pyspark"
+# out["converted_sql"] → exec / save as features.py
+```
+
+[Space]({SPACE_URL}) · [GitHub]({GITHUB_URL})
+"""
+ )
+
+ with gr.Tab("More"):
+ gr.Markdown(
+ "Lab extras beyond day-to-day Convert: assess objects, scan a repo, "
+ "migrate feature SQL, ask the copilot, or run offline eval."
+ )
+ report_state = gr.State(value=None)
+
+ with gr.Accordion("Object assess & convert", open=False):
+ gr.Markdown(
+ "Score complexity/risk and convert a single SQL object "
+ "(pandas, PySpark, warehouse SQL, or dbt)."
+ )
+ with gr.Row():
+ assess_source = gr.Dropdown(
+ choices=SOURCE_DROPDOWN, value="vertica", label="Source"
+ )
+ assess_target = gr.Dropdown(
+ choices=TARGET_DROPDOWN, value="snowflake", label="Target"
+ )
+ assess_btn = gr.Button("Assess & Convert", variant="secondary")
+ assess_sql = gr.Textbox(
+ label="SQL object",
+ value="SELECT customer_id, ZEROIFNULL(order_amount) AS order_amount "
+ "FROM staging.orders WHERE order_date >= CURRENT_DATE - 30",
+ lines=8,
+ )
+ assess_badge = gr.Textbox(label="Score", interactive=False, max_lines=1)
+ with gr.Row():
+ assess_analysis = gr.Markdown()
+ assess_risk = gr.HTML()
+ assess_out = gr.Textbox(label="Converted output", lines=10)
+ assess_notes = gr.Markdown()
+ assess_btn.click(
+ analyze_sql_object_ui,
+ [assess_sql, assess_source, assess_target],
+ [assess_analysis, assess_risk, assess_badge, assess_out, assess_notes],
+ )
+
+ with gr.Accordion("Repository workbench", open=False):
+ gr.Markdown(
+ "Scan the sample Vertica repo (or upload a `.zip`), then convert / "
+ "validate / preview dbt + lineage."
+ )
+ with gr.Row():
+ wb_source = gr.Dropdown(
+ choices=SOURCE_DROPDOWN, value="vertica", label="Source"
+ )
+ wb_target = gr.Dropdown(
+ choices=TARGET_DROPDOWN, value="snowflake", label="Target"
+ )
+ wb_sample = gr.Checkbox(value=True, label="Use sample repository")
+ wb_upload = gr.File(label="Or upload SQL repo (.zip)", file_types=[".zip"])
+ wb_btn = gr.Button("Run migration intelligence", variant="secondary")
+ wb_summary = gr.Markdown()
+ wb_metrics = gr.Markdown()
+ with gr.Row():
+ wb_risk = gr.HTML()
+ wb_dist = gr.HTML()
+ wb_objects = gr.Markdown()
+ wb_lineage = gr.HTML()
+ with gr.Row():
+ wb_rationalization = gr.Markdown()
+ wb_runbook = gr.Markdown()
+ with gr.Row():
+ wb_dbt = gr.Markdown()
+ wb_validation = gr.Markdown()
+ wb_export = gr.Code(language="json", lines=8, label="Export JSON")
+ wb_btn.click(
+ run_workbench_ui,
+ [wb_upload, wb_sample, wb_source, wb_target],
+ [
+ wb_summary,
+ wb_objects,
+ wb_rationalization,
+ wb_runbook,
+ wb_dbt,
+ wb_validation,
+ wb_metrics,
+ wb_risk,
+ wb_dist,
+ wb_lineage,
+ wb_export,
+ report_state,
+ ],
+ )
+
+ with gr.Accordion("ML feature SQL migration", open=False):
+ gr.Markdown(
+ "Convert `examples/ml_features/churn_feature_sql.sql` "
+ "(Vertica feature engineering) → Snowflake SQL or a dbt feature mart."
+ )
+ with gr.Row():
+ feat_target = gr.Dropdown(
+ choices=[
+ ("Snowflake SQL", "snowflake"),
+ ("dbt project (Snowflake)", "dbt-snowflake"),
+ ],
+ value="snowflake",
+ label="Output",
+ )
+ feat_btn = gr.Button("Migrate feature SQL", variant="secondary")
+ feat_md = gr.Markdown()
+ feat_out = gr.Textbox(label="Output", lines=14)
+ feat_btn.click(run_feature_migration, [feat_target], [feat_md, feat_out])
+
+ with gr.Accordion("Migration copilot", open=False):
+ gr.Markdown(
+ "Ask migration questions. Uses keyword/HF fallback guidance "
+ "(set `HF_TOKEN` for LLM replies)."
+ )
+ copilot = _chatbot(label="Copilot", height=320)
+ copilot_msg = gr.Textbox(
+ label="Message",
+ placeholder="e.g. How should I handle ZEROIFNULL on Snowflake?",
+ lines=2,
+ )
+ with gr.Row():
+ copilot_source = gr.Dropdown(
+ choices=SOURCE_DROPDOWN, value="vertica", label="Source"
+ )
+ copilot_target = gr.Dropdown(
+ choices=TARGET_DROPDOWN, value="snowflake", label="Target"
+ )
+ copilot_btn = gr.Button("Ask", variant="secondary")
+ copilot_sql = gr.Textbox(
+ label="Optional SQL context",
+ value="SELECT ZEROIFNULL(amount) FROM staging.transactions",
+ lines=3,
+ )
+ copilot_btn.click(
+ copilot_chat,
+ [
+ copilot_msg,
+ copilot,
+ report_state,
+ copilot_sql,
+ copilot_source,
+ copilot_target,
+ ],
+ [copilot, copilot_msg],
+ )
+ copilot_msg.submit(
+ copilot_chat,
+ [
+ copilot_msg,
+ copilot,
+ report_state,
+ copilot_sql,
+ copilot_source,
+ copilot_target,
+ ],
+ [copilot, copilot_msg],
+ )
+
+ with gr.Accordion("Dialect behavior notes", open=False):
+ rag_q = gr.Textbox(
+ label="Question",
+ value="Oracle empty string vs Snowflake NULL",
+ lines=2,
+ )
+ with gr.Row():
+ rag_source = gr.Dropdown(
+ choices=SOURCE_DROPDOWN, value="oracle", label="From"
+ )
+ rag_target = gr.Dropdown(
+ choices=[
+ ("Snowflake SQL", "snowflake"),
+ ("BigQuery SQL", "bigquery"),
+ ],
+ value="snowflake",
+ label="To",
+ )
+ rag_run = gr.Button("Search", variant="secondary")
+ rag_out = gr.Markdown()
+ rag_run.click(
+ run_behavior_rag,
+ [rag_q, rag_source, rag_target],
+ [rag_out],
+ )
+
+ with gr.Accordion("Offline conversion eval", open=False):
+ with gr.Row():
+ eval_limit = gr.Slider(10, 200, value=40, step=10, label="Pairs")
+ eval_run = gr.Button("Run eval", variant="secondary")
+ eval_summary = gr.Markdown()
+ eval_detail = gr.Markdown()
+ with gr.Row():
+ lb_name = gr.Textbox(label="Handle", value="hf-user")
+ lb_submit = gr.Button("Submit local score")
+ leaderboard_md = gr.Markdown(value=get_leaderboard_md())
+ eval_run.click(
+ run_eval_suite,
+ [eval_limit, eval_category],
+ [eval_summary, eval_detail, eval_state],
+ )
+ lb_submit.click(
+ submit_eval_score, [lb_name, eval_state], [leaderboard_md]
+ )
+
+ gr.Markdown(
+ f""
+ )
+ return demo
+
+
+demo = _build_demo()
+
+
+if __name__ == "__main__":
+ demo.launch(**_launch_style_kwargs())
diff --git a/datasets/.gitignore b/datasets/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..3100784c8bdc1ea88f589f2893bf633a27ac5b67
--- /dev/null
+++ b/datasets/.gitignore
@@ -0,0 +1,2 @@
+# Keep generated leaderboard out of git; ship model weights
+leaderboard.json
diff --git a/datasets/leaderboard.json b/datasets/leaderboard.json
new file mode 100644
index 0000000000000000000000000000000000000000..99212ba4c590d851c172bac3605ffd78192445a8
--- /dev/null
+++ b/datasets/leaderboard.json
@@ -0,0 +1,12 @@
+[
+ {
+ "name": "smoke-test",
+ "exact_match": 0.9333,
+ "token_f1": 0.9992,
+ "fuzzy": 0.9967,
+ "pass_rate": 1.0,
+ "n_pairs": 30,
+ "notes": "MorphSQL hybrid translator",
+ "ts": "2026-07-13T14:41:11.441415+00:00"
+ }
+]
\ No newline at end of file
diff --git a/datasets/vertica_snowflake_pairs.jsonl b/datasets/vertica_snowflake_pairs.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..16d97c544f22f539de461f4f7aae8532a87377c5
--- /dev/null
+++ b/datasets/vertica_snowflake_pairs.jsonl
@@ -0,0 +1,256 @@
+{"id": "zeroifnull_basic", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(amount) AS amount FROM staging.orders", "target_sql": "SELECT COALESCE(amount, 0) AS amount FROM staging.orders", "notes": "ZEROIFNULL \u2192 COALESCE(expr, 0)"}
+{"id": "nvl_basic", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT NVL(discount, 0) AS discount FROM staging.orders", "target_sql": "SELECT COALESCE(discount, 0) AS discount FROM staging.orders", "notes": "NVL \u2192 COALESCE"}
+{"id": "date_sub_days", "category": "date", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT * FROM t WHERE order_date >= CURRENT_DATE - 30", "target_sql": "SELECT * FROM t WHERE order_date >= DATEADD(day, -30, CURRENT_DATE)", "notes": "Date arithmetic \u2192 DATEADD"}
+{"id": "local_temp", "category": "ddl", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "CREATE LOCAL TEMP TABLE tmp_x ON COMMIT PRESERVE ROWS AS SELECT 1 AS id", "target_sql": "CREATE OR REPLACE TEMPORARY TABLE tmp_x AS SELECT 1 AS id", "notes": "LOCAL TEMP \u2192 TEMPORARY TABLE"}
+{"id": "string_agg", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT STRING_AGG(name, ',') FROM users", "target_sql": "SELECT LISTAGG(name, ',') FROM users", "notes": "STRING_AGG \u2192 LISTAGG"}
+{"id": "approx_distinct", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT APPROXIMATE_COUNT_DISTINCT(user_id) FROM events", "target_sql": "SELECT APPROX_COUNT_DISTINCT(user_id) FROM events", "notes": "APPROXIMATE_COUNT_DISTINCT \u2192 APPROX_COUNT_DISTINCT"}
+{"id": "datediff_quoted", "category": "date", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT DATEDIFF('day', start_dt, end_dt) FROM t", "target_sql": "SELECT DATEDIFF(day, start_dt, end_dt) FROM t", "notes": "Quoted DATEDIFF unit \u2192 unquoted"}
+{"id": "sysdate", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT SYSDATE FROM dual", "target_sql": "SELECT CURRENT_TIMESTAMP()", "notes": "SYSDATE + drop DUAL"}
+{"id": "getdate_redshift", "category": "function", "source_dialect": "redshift", "target_dialect": "snowflake", "source_sql": "SELECT GETDATE(), NVL(x, 0) FROM t", "target_sql": "SELECT CURRENT_TIMESTAMP(), COALESCE(x, 0) FROM t", "notes": "GETDATE/NVL mappings"}
+{"id": "bigquery_ifnull", "category": "function", "source_dialect": "bigquery", "target_dialect": "snowflake", "source_sql": "SELECT IFNULL(a, 0), STRING_AGG(b, ',') FROM t GROUP BY a", "target_sql": "SELECT COALESCE(a, 0), LISTAGG(b, ',') FROM t GROUP BY a", "notes": "IFNULL/STRING_AGG \u2192 COALESCE/LISTAGG"}
+{"id": "feature_rfm", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT customer_id, ZEROIFNULL(SUM(order_amount)) AS monetary, COUNT(DISTINCT order_id) AS frequency, DATEDIFF('day', MAX(order_date), CURRENT_DATE) AS recency FROM staging.orders GROUP BY customer_id", "target_sql": "SELECT customer_id, COALESCE(SUM(order_amount), 0) AS monetary, COUNT(DISTINCT order_id) AS frequency, DATEDIFF(day, MAX(order_date), CURRENT_DATE) AS recency FROM staging.orders GROUP BY customer_id", "notes": "ML RFM feature SQL migration"}
+{"id": "feature_label", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT user_id, CASE WHEN ZEROIFNULL(churn_score) > 0.7 THEN 1 ELSE 0 END AS churn_label FROM ml.user_scores", "target_sql": "SELECT user_id, CASE WHEN COALESCE(churn_score, 0) > 0.7 THEN 1 ELSE 0 END AS churn_label FROM ml.user_scores", "notes": "Training label feature SQL"}
+{"id": "date_sub_7", "category": "date", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT * FROM events WHERE event_date >= CURRENT_DATE - 7", "target_sql": "SELECT * FROM events WHERE event_date >= DATEADD(day, -7, CURRENT_DATE)", "notes": "CURRENT_DATE - 7 \u2192 DATEADD"}
+{"id": "date_sub_14", "category": "date", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT * FROM events WHERE event_date >= CURRENT_DATE - 14", "target_sql": "SELECT * FROM events WHERE event_date >= DATEADD(day, -14, CURRENT_DATE)", "notes": "CURRENT_DATE - 14 \u2192 DATEADD"}
+{"id": "date_sub_30", "category": "date", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT * FROM events WHERE event_date >= CURRENT_DATE - 30", "target_sql": "SELECT * FROM events WHERE event_date >= DATEADD(day, -30, CURRENT_DATE)", "notes": "CURRENT_DATE - 30 \u2192 DATEADD"}
+{"id": "date_sub_60", "category": "date", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT * FROM events WHERE event_date >= CURRENT_DATE - 60", "target_sql": "SELECT * FROM events WHERE event_date >= DATEADD(day, -60, CURRENT_DATE)", "notes": "CURRENT_DATE - 60 \u2192 DATEADD"}
+{"id": "date_sub_90", "category": "date", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT * FROM events WHERE event_date >= CURRENT_DATE - 90", "target_sql": "SELECT * FROM events WHERE event_date >= DATEADD(day, -90, CURRENT_DATE)", "notes": "CURRENT_DATE - 90 \u2192 DATEADD"}
+{"id": "date_sub_180", "category": "date", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT * FROM events WHERE event_date >= CURRENT_DATE - 180", "target_sql": "SELECT * FROM events WHERE event_date >= DATEADD(day, -180, CURRENT_DATE)", "notes": "CURRENT_DATE - 180 \u2192 DATEADD"}
+{"id": "date_sub_365", "category": "date", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT * FROM events WHERE event_date >= CURRENT_DATE - 365", "target_sql": "SELECT * FROM events WHERE event_date >= DATEADD(day, -365, CURRENT_DATE)", "notes": "CURRENT_DATE - 365 \u2192 DATEADD"}
+{"id": "zeroifnull_amount", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(amount) AS amount FROM staging.facts", "target_sql": "SELECT COALESCE(amount, 0) AS amount FROM staging.facts", "notes": "ZEROIFNULL expansion"}
+{"id": "zeroifnull_revenue", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(revenue) AS revenue FROM staging.facts", "target_sql": "SELECT COALESCE(revenue, 0) AS revenue FROM staging.facts", "notes": "ZEROIFNULL expansion"}
+{"id": "zeroifnull_fee", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(fee) AS fee FROM staging.facts", "target_sql": "SELECT COALESCE(fee, 0) AS fee FROM staging.facts", "notes": "ZEROIFNULL expansion"}
+{"id": "zeroifnull_tax", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(tax) AS tax FROM staging.facts", "target_sql": "SELECT COALESCE(tax, 0) AS tax FROM staging.facts", "notes": "ZEROIFNULL expansion"}
+{"id": "zeroifnull_balance", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(balance) AS balance FROM staging.facts", "target_sql": "SELECT COALESCE(balance, 0) AS balance FROM staging.facts", "notes": "ZEROIFNULL expansion"}
+{"id": "zeroifnull_score", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score) AS score FROM staging.facts", "target_sql": "SELECT COALESCE(score, 0) AS score FROM staging.facts", "notes": "ZEROIFNULL expansion"}
+{"id": "zeroifnull_qty", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(qty) AS qty FROM staging.facts", "target_sql": "SELECT COALESCE(qty, 0) AS qty FROM staging.facts", "notes": "ZEROIFNULL expansion"}
+{"id": "agg_mix_1", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_1)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_1), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_2", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_2)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_2), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_3", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_3)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_3), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_4", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_4)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_4), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_5", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_5)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_5), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_6", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_6)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_6), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_7", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_7)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_7), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_8", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_8)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_8), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_9", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_9)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_9), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_10", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_10)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_10), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_11", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_11)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_11), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_12", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_12)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_12), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_13", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_13)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_13), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_14", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_14)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_14), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_15", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_15)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_15), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_16", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_16)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_16), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_17", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_17)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_17), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_18", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_18)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_18), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_19", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_19)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_19), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_20", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_20)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_20), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_21", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_21)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_21), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_22", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_22)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_22), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_23", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_23)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_23), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_24", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_24)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_24), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_25", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_25)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_25), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_26", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_26)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_26), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_27", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_27)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_27), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_28", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_28)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_28), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_29", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_29)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_29), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_30", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_30)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_30), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_31", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_31)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_31), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_32", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_32)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_32), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_33", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_33)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_33), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_34", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_34)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_34), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_35", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_35)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_35), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_36", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_36)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_36), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_37", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_37)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_37), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_38", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_38)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_38), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_39", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_39)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_39), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_40", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_40)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_40), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_41", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_41)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_41), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_42", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_42)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_42), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_43", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_43)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_43), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_44", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_44)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_44), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_45", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_45)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_45), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_46", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_46)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_46), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_47", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_47)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_47), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_48", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_48)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_48), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_49", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_49)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_49), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_50", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_50)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_50), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_51", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_51)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_51), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_52", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_52)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_52), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_53", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_53)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_53), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_54", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_54)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_54), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_55", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_55)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_55), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_56", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_56)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_56), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_57", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_57)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_57), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_58", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_58)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_58), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_59", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_59)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_59), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_60", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_60)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_60), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_61", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_61)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_61), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_62", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_62)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_62), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_63", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_63)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_63), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_64", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_64)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_64), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_65", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_65)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_65), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_66", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_66)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_66), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_67", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_67)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_67), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_68", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_68)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_68), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_69", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_69)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_69), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_70", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_70)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_70), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_71", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_71)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_71), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_72", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_72)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_72), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_73", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_73)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_73), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_74", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_74)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_74), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_75", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_75)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_75), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_76", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_1, ZEROIFNULL(SUM(metric_76)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_1, COALESCE(SUM(metric_76), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_77", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_2, ZEROIFNULL(SUM(metric_77)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_2, COALESCE(SUM(metric_77), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_78", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_3, ZEROIFNULL(SUM(metric_78)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "target_sql": "SELECT dim_3, COALESCE(SUM(metric_78), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_0 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_79", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_4, ZEROIFNULL(SUM(metric_79)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "target_sql": "SELECT dim_4, COALESCE(SUM(metric_79), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_1 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "agg_mix_80", "category": "aggregate", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT dim_0, ZEROIFNULL(SUM(metric_80)), APPROXIMATE_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "target_sql": "SELECT dim_0, COALESCE(SUM(metric_80), 0), APPROX_COUNT_DISTINCT(user_id) FROM fact_2 GROUP BY 1", "notes": "Synthetic aggregate pair"}
+{"id": "oracle_nvl_1", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_1, 0) FROM dual", "target_sql": "SELECT COALESCE(col_1, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_2", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_2, 0) FROM dual", "target_sql": "SELECT COALESCE(col_2, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_3", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_3, 0) FROM dual", "target_sql": "SELECT COALESCE(col_3, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_4", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_4, 0) FROM dual", "target_sql": "SELECT COALESCE(col_4, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_5", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_5, 0) FROM dual", "target_sql": "SELECT COALESCE(col_5, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_6", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_6, 0) FROM dual", "target_sql": "SELECT COALESCE(col_6, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_7", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_7, 0) FROM dual", "target_sql": "SELECT COALESCE(col_7, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_8", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_8, 0) FROM dual", "target_sql": "SELECT COALESCE(col_8, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_9", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_9, 0) FROM dual", "target_sql": "SELECT COALESCE(col_9, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_10", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_10, 0) FROM dual", "target_sql": "SELECT COALESCE(col_10, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_11", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_11, 0) FROM dual", "target_sql": "SELECT COALESCE(col_11, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_12", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_12, 0) FROM dual", "target_sql": "SELECT COALESCE(col_12, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_13", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_13, 0) FROM dual", "target_sql": "SELECT COALESCE(col_13, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_14", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_14, 0) FROM dual", "target_sql": "SELECT COALESCE(col_14, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_15", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_15, 0) FROM dual", "target_sql": "SELECT COALESCE(col_15, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_16", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_16, 0) FROM dual", "target_sql": "SELECT COALESCE(col_16, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_17", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_17, 0) FROM dual", "target_sql": "SELECT COALESCE(col_17, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_18", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_18, 0) FROM dual", "target_sql": "SELECT COALESCE(col_18, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_19", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_19, 0) FROM dual", "target_sql": "SELECT COALESCE(col_19, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_20", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_20, 0) FROM dual", "target_sql": "SELECT COALESCE(col_20, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_21", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_21, 0) FROM dual", "target_sql": "SELECT COALESCE(col_21, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_22", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_22, 0) FROM dual", "target_sql": "SELECT COALESCE(col_22, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_23", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_23, 0) FROM dual", "target_sql": "SELECT COALESCE(col_23, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_24", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_24, 0) FROM dual", "target_sql": "SELECT COALESCE(col_24, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_25", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_25, 0) FROM dual", "target_sql": "SELECT COALESCE(col_25, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_26", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_26, 0) FROM dual", "target_sql": "SELECT COALESCE(col_26, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_27", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_27, 0) FROM dual", "target_sql": "SELECT COALESCE(col_27, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_28", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_28, 0) FROM dual", "target_sql": "SELECT COALESCE(col_28, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_29", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_29, 0) FROM dual", "target_sql": "SELECT COALESCE(col_29, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_30", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_30, 0) FROM dual", "target_sql": "SELECT COALESCE(col_30, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_31", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_31, 0) FROM dual", "target_sql": "SELECT COALESCE(col_31, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_32", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_32, 0) FROM dual", "target_sql": "SELECT COALESCE(col_32, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_33", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_33, 0) FROM dual", "target_sql": "SELECT COALESCE(col_33, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_34", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_34, 0) FROM dual", "target_sql": "SELECT COALESCE(col_34, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_35", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_35, 0) FROM dual", "target_sql": "SELECT COALESCE(col_35, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_36", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_36, 0) FROM dual", "target_sql": "SELECT COALESCE(col_36, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_37", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_37, 0) FROM dual", "target_sql": "SELECT COALESCE(col_37, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_38", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_38, 0) FROM dual", "target_sql": "SELECT COALESCE(col_38, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_39", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_39, 0) FROM dual", "target_sql": "SELECT COALESCE(col_39, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_40", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_40, 0) FROM dual", "target_sql": "SELECT COALESCE(col_40, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_41", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_41, 0) FROM dual", "target_sql": "SELECT COALESCE(col_41, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_42", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_42, 0) FROM dual", "target_sql": "SELECT COALESCE(col_42, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_43", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_43, 0) FROM dual", "target_sql": "SELECT COALESCE(col_43, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_44", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_44, 0) FROM dual", "target_sql": "SELECT COALESCE(col_44, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_45", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_45, 0) FROM dual", "target_sql": "SELECT COALESCE(col_45, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_46", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_46, 0) FROM dual", "target_sql": "SELECT COALESCE(col_46, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_47", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_47, 0) FROM dual", "target_sql": "SELECT COALESCE(col_47, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_48", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_48, 0) FROM dual", "target_sql": "SELECT COALESCE(col_48, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_49", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_49, 0) FROM dual", "target_sql": "SELECT COALESCE(col_49, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_50", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_50, 0) FROM dual", "target_sql": "SELECT COALESCE(col_50, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_51", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_51, 0) FROM dual", "target_sql": "SELECT COALESCE(col_51, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_52", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_52, 0) FROM dual", "target_sql": "SELECT COALESCE(col_52, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_53", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_53, 0) FROM dual", "target_sql": "SELECT COALESCE(col_53, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_54", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_54, 0) FROM dual", "target_sql": "SELECT COALESCE(col_54, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_55", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_55, 0) FROM dual", "target_sql": "SELECT COALESCE(col_55, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_56", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_56, 0) FROM dual", "target_sql": "SELECT COALESCE(col_56, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_57", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_57, 0) FROM dual", "target_sql": "SELECT COALESCE(col_57, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_58", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_58, 0) FROM dual", "target_sql": "SELECT COALESCE(col_58, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_59", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_59, 0) FROM dual", "target_sql": "SELECT COALESCE(col_59, 0)", "notes": "Oracle NVL expansion"}
+{"id": "oracle_nvl_60", "category": "function", "source_dialect": "oracle", "target_dialect": "snowflake", "source_sql": "SELECT NVL(col_60, 0) FROM dual", "target_sql": "SELECT COALESCE(col_60, 0)", "notes": "Oracle NVL expansion"}
+{"id": "ml_feature_1", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_1)) AS f_1 FROM ml.training_window WHERE ds >= CURRENT_DATE - 8 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_1), 0) AS f_1 FROM ml.training_window WHERE ds >= DATEADD(day, -8, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_2", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_2)) AS f_2 FROM ml.training_window WHERE ds >= CURRENT_DATE - 9 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_2), 0) AS f_2 FROM ml.training_window WHERE ds >= DATEADD(day, -9, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_3", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_3)) AS f_3 FROM ml.training_window WHERE ds >= CURRENT_DATE - 10 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_3), 0) AS f_3 FROM ml.training_window WHERE ds >= DATEADD(day, -10, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_4", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_4)) AS f_4 FROM ml.training_window WHERE ds >= CURRENT_DATE - 11 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_4), 0) AS f_4 FROM ml.training_window WHERE ds >= DATEADD(day, -11, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_5", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_5)) AS f_5 FROM ml.training_window WHERE ds >= CURRENT_DATE - 12 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_5), 0) AS f_5 FROM ml.training_window WHERE ds >= DATEADD(day, -12, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_6", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_6)) AS f_6 FROM ml.training_window WHERE ds >= CURRENT_DATE - 13 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_6), 0) AS f_6 FROM ml.training_window WHERE ds >= DATEADD(day, -13, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_7", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_7)) AS f_7 FROM ml.training_window WHERE ds >= CURRENT_DATE - 14 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_7), 0) AS f_7 FROM ml.training_window WHERE ds >= DATEADD(day, -14, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_8", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_8)) AS f_8 FROM ml.training_window WHERE ds >= CURRENT_DATE - 15 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_8), 0) AS f_8 FROM ml.training_window WHERE ds >= DATEADD(day, -15, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_9", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_9)) AS f_9 FROM ml.training_window WHERE ds >= CURRENT_DATE - 16 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_9), 0) AS f_9 FROM ml.training_window WHERE ds >= DATEADD(day, -16, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_10", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_10)) AS f_10 FROM ml.training_window WHERE ds >= CURRENT_DATE - 17 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_10), 0) AS f_10 FROM ml.training_window WHERE ds >= DATEADD(day, -17, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_11", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_11)) AS f_11 FROM ml.training_window WHERE ds >= CURRENT_DATE - 18 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_11), 0) AS f_11 FROM ml.training_window WHERE ds >= DATEADD(day, -18, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_12", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_12)) AS f_12 FROM ml.training_window WHERE ds >= CURRENT_DATE - 19 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_12), 0) AS f_12 FROM ml.training_window WHERE ds >= DATEADD(day, -19, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_13", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_13)) AS f_13 FROM ml.training_window WHERE ds >= CURRENT_DATE - 20 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_13), 0) AS f_13 FROM ml.training_window WHERE ds >= DATEADD(day, -20, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_14", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_14)) AS f_14 FROM ml.training_window WHERE ds >= CURRENT_DATE - 21 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_14), 0) AS f_14 FROM ml.training_window WHERE ds >= DATEADD(day, -21, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_15", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_15)) AS f_15 FROM ml.training_window WHERE ds >= CURRENT_DATE - 22 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_15), 0) AS f_15 FROM ml.training_window WHERE ds >= DATEADD(day, -22, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_16", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_16)) AS f_16 FROM ml.training_window WHERE ds >= CURRENT_DATE - 23 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_16), 0) AS f_16 FROM ml.training_window WHERE ds >= DATEADD(day, -23, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_17", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_17)) AS f_17 FROM ml.training_window WHERE ds >= CURRENT_DATE - 24 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_17), 0) AS f_17 FROM ml.training_window WHERE ds >= DATEADD(day, -24, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_18", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_18)) AS f_18 FROM ml.training_window WHERE ds >= CURRENT_DATE - 25 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_18), 0) AS f_18 FROM ml.training_window WHERE ds >= DATEADD(day, -25, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_19", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_19)) AS f_19 FROM ml.training_window WHERE ds >= CURRENT_DATE - 26 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_19), 0) AS f_19 FROM ml.training_window WHERE ds >= DATEADD(day, -26, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_20", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_20)) AS f_20 FROM ml.training_window WHERE ds >= CURRENT_DATE - 27 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_20), 0) AS f_20 FROM ml.training_window WHERE ds >= DATEADD(day, -27, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_21", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_21)) AS f_21 FROM ml.training_window WHERE ds >= CURRENT_DATE - 28 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_21), 0) AS f_21 FROM ml.training_window WHERE ds >= DATEADD(day, -28, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_22", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_22)) AS f_22 FROM ml.training_window WHERE ds >= CURRENT_DATE - 29 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_22), 0) AS f_22 FROM ml.training_window WHERE ds >= DATEADD(day, -29, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_23", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_23)) AS f_23 FROM ml.training_window WHERE ds >= CURRENT_DATE - 30 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_23), 0) AS f_23 FROM ml.training_window WHERE ds >= DATEADD(day, -30, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_24", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_24)) AS f_24 FROM ml.training_window WHERE ds >= CURRENT_DATE - 31 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_24), 0) AS f_24 FROM ml.training_window WHERE ds >= DATEADD(day, -31, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_25", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_25)) AS f_25 FROM ml.training_window WHERE ds >= CURRENT_DATE - 32 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_25), 0) AS f_25 FROM ml.training_window WHERE ds >= DATEADD(day, -32, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_26", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_26)) AS f_26 FROM ml.training_window WHERE ds >= CURRENT_DATE - 33 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_26), 0) AS f_26 FROM ml.training_window WHERE ds >= DATEADD(day, -33, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_27", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_27)) AS f_27 FROM ml.training_window WHERE ds >= CURRENT_DATE - 34 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_27), 0) AS f_27 FROM ml.training_window WHERE ds >= DATEADD(day, -34, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_28", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_28)) AS f_28 FROM ml.training_window WHERE ds >= CURRENT_DATE - 35 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_28), 0) AS f_28 FROM ml.training_window WHERE ds >= DATEADD(day, -35, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_29", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_29)) AS f_29 FROM ml.training_window WHERE ds >= CURRENT_DATE - 36 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_29), 0) AS f_29 FROM ml.training_window WHERE ds >= DATEADD(day, -36, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_30", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_30)) AS f_30 FROM ml.training_window WHERE ds >= CURRENT_DATE - 37 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_30), 0) AS f_30 FROM ml.training_window WHERE ds >= DATEADD(day, -37, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_31", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_31)) AS f_31 FROM ml.training_window WHERE ds >= CURRENT_DATE - 38 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_31), 0) AS f_31 FROM ml.training_window WHERE ds >= DATEADD(day, -38, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_32", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_32)) AS f_32 FROM ml.training_window WHERE ds >= CURRENT_DATE - 39 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_32), 0) AS f_32 FROM ml.training_window WHERE ds >= DATEADD(day, -39, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_33", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_33)) AS f_33 FROM ml.training_window WHERE ds >= CURRENT_DATE - 40 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_33), 0) AS f_33 FROM ml.training_window WHERE ds >= DATEADD(day, -40, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_34", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_34)) AS f_34 FROM ml.training_window WHERE ds >= CURRENT_DATE - 41 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_34), 0) AS f_34 FROM ml.training_window WHERE ds >= DATEADD(day, -41, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_35", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_35)) AS f_35 FROM ml.training_window WHERE ds >= CURRENT_DATE - 42 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_35), 0) AS f_35 FROM ml.training_window WHERE ds >= DATEADD(day, -42, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_36", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_36)) AS f_36 FROM ml.training_window WHERE ds >= CURRENT_DATE - 43 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_36), 0) AS f_36 FROM ml.training_window WHERE ds >= DATEADD(day, -43, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_37", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_37)) AS f_37 FROM ml.training_window WHERE ds >= CURRENT_DATE - 44 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_37), 0) AS f_37 FROM ml.training_window WHERE ds >= DATEADD(day, -44, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_38", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_38)) AS f_38 FROM ml.training_window WHERE ds >= CURRENT_DATE - 45 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_38), 0) AS f_38 FROM ml.training_window WHERE ds >= DATEADD(day, -45, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_39", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_39)) AS f_39 FROM ml.training_window WHERE ds >= CURRENT_DATE - 46 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_39), 0) AS f_39 FROM ml.training_window WHERE ds >= DATEADD(day, -46, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_40", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_40)) AS f_40 FROM ml.training_window WHERE ds >= CURRENT_DATE - 47 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_40), 0) AS f_40 FROM ml.training_window WHERE ds >= DATEADD(day, -47, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_41", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_41)) AS f_41 FROM ml.training_window WHERE ds >= CURRENT_DATE - 48 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_41), 0) AS f_41 FROM ml.training_window WHERE ds >= DATEADD(day, -48, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_42", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_42)) AS f_42 FROM ml.training_window WHERE ds >= CURRENT_DATE - 49 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_42), 0) AS f_42 FROM ml.training_window WHERE ds >= DATEADD(day, -49, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_43", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_43)) AS f_43 FROM ml.training_window WHERE ds >= CURRENT_DATE - 50 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_43), 0) AS f_43 FROM ml.training_window WHERE ds >= DATEADD(day, -50, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_44", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_44)) AS f_44 FROM ml.training_window WHERE ds >= CURRENT_DATE - 51 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_44), 0) AS f_44 FROM ml.training_window WHERE ds >= DATEADD(day, -51, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_45", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_45)) AS f_45 FROM ml.training_window WHERE ds >= CURRENT_DATE - 52 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_45), 0) AS f_45 FROM ml.training_window WHERE ds >= DATEADD(day, -52, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_46", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_46)) AS f_46 FROM ml.training_window WHERE ds >= CURRENT_DATE - 53 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_46), 0) AS f_46 FROM ml.training_window WHERE ds >= DATEADD(day, -53, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_47", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_47)) AS f_47 FROM ml.training_window WHERE ds >= CURRENT_DATE - 54 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_47), 0) AS f_47 FROM ml.training_window WHERE ds >= DATEADD(day, -54, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_48", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_48)) AS f_48 FROM ml.training_window WHERE ds >= CURRENT_DATE - 55 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_48), 0) AS f_48 FROM ml.training_window WHERE ds >= DATEADD(day, -55, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_49", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_49)) AS f_49 FROM ml.training_window WHERE ds >= CURRENT_DATE - 56 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_49), 0) AS f_49 FROM ml.training_window WHERE ds >= DATEADD(day, -56, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ml_feature_50", "category": "ml_feature", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT entity_id, ZEROIFNULL(AVG(feature_50)) AS f_50 FROM ml.training_window WHERE ds >= CURRENT_DATE - 57 GROUP BY 1", "target_sql": "SELECT entity_id, COALESCE(AVG(feature_50), 0) AS f_50 FROM ml.training_window WHERE ds >= DATEADD(day, -57, CURRENT_DATE) GROUP BY 1", "notes": "ML feature window SQL"}
+{"id": "ai_codegen_1", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_1), NVL(flag_1, 0) FROM ml.inference_logs WHERE run_id = 1", "target_sql": "SELECT COALESCE(score_1, 0), COALESCE(flag_1, 0) FROM ml.inference_logs WHERE run_id = 1", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_2", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_2), NVL(flag_2, 0) FROM ml.inference_logs WHERE run_id = 2", "target_sql": "SELECT COALESCE(score_2, 0), COALESCE(flag_2, 0) FROM ml.inference_logs WHERE run_id = 2", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_3", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_3), NVL(flag_3, 0) FROM ml.inference_logs WHERE run_id = 3", "target_sql": "SELECT COALESCE(score_3, 0), COALESCE(flag_3, 0) FROM ml.inference_logs WHERE run_id = 3", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_4", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_4), NVL(flag_4, 0) FROM ml.inference_logs WHERE run_id = 4", "target_sql": "SELECT COALESCE(score_4, 0), COALESCE(flag_4, 0) FROM ml.inference_logs WHERE run_id = 4", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_5", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_5), NVL(flag_5, 0) FROM ml.inference_logs WHERE run_id = 5", "target_sql": "SELECT COALESCE(score_5, 0), COALESCE(flag_5, 0) FROM ml.inference_logs WHERE run_id = 5", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_6", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_6), NVL(flag_6, 0) FROM ml.inference_logs WHERE run_id = 6", "target_sql": "SELECT COALESCE(score_6, 0), COALESCE(flag_6, 0) FROM ml.inference_logs WHERE run_id = 6", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_7", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_7), NVL(flag_7, 0) FROM ml.inference_logs WHERE run_id = 7", "target_sql": "SELECT COALESCE(score_7, 0), COALESCE(flag_7, 0) FROM ml.inference_logs WHERE run_id = 7", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_8", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_8), NVL(flag_8, 0) FROM ml.inference_logs WHERE run_id = 8", "target_sql": "SELECT COALESCE(score_8, 0), COALESCE(flag_8, 0) FROM ml.inference_logs WHERE run_id = 8", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_9", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_9), NVL(flag_9, 0) FROM ml.inference_logs WHERE run_id = 9", "target_sql": "SELECT COALESCE(score_9, 0), COALESCE(flag_9, 0) FROM ml.inference_logs WHERE run_id = 9", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_10", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_10), NVL(flag_10, 0) FROM ml.inference_logs WHERE run_id = 10", "target_sql": "SELECT COALESCE(score_10, 0), COALESCE(flag_10, 0) FROM ml.inference_logs WHERE run_id = 10", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_11", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_11), NVL(flag_11, 0) FROM ml.inference_logs WHERE run_id = 11", "target_sql": "SELECT COALESCE(score_11, 0), COALESCE(flag_11, 0) FROM ml.inference_logs WHERE run_id = 11", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_12", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_12), NVL(flag_12, 0) FROM ml.inference_logs WHERE run_id = 12", "target_sql": "SELECT COALESCE(score_12, 0), COALESCE(flag_12, 0) FROM ml.inference_logs WHERE run_id = 12", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_13", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_13), NVL(flag_13, 0) FROM ml.inference_logs WHERE run_id = 13", "target_sql": "SELECT COALESCE(score_13, 0), COALESCE(flag_13, 0) FROM ml.inference_logs WHERE run_id = 13", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_14", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_14), NVL(flag_14, 0) FROM ml.inference_logs WHERE run_id = 14", "target_sql": "SELECT COALESCE(score_14, 0), COALESCE(flag_14, 0) FROM ml.inference_logs WHERE run_id = 14", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_15", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_15), NVL(flag_15, 0) FROM ml.inference_logs WHERE run_id = 15", "target_sql": "SELECT COALESCE(score_15, 0), COALESCE(flag_15, 0) FROM ml.inference_logs WHERE run_id = 15", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_16", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_16), NVL(flag_16, 0) FROM ml.inference_logs WHERE run_id = 16", "target_sql": "SELECT COALESCE(score_16, 0), COALESCE(flag_16, 0) FROM ml.inference_logs WHERE run_id = 16", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_17", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_17), NVL(flag_17, 0) FROM ml.inference_logs WHERE run_id = 17", "target_sql": "SELECT COALESCE(score_17, 0), COALESCE(flag_17, 0) FROM ml.inference_logs WHERE run_id = 17", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_18", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_18), NVL(flag_18, 0) FROM ml.inference_logs WHERE run_id = 18", "target_sql": "SELECT COALESCE(score_18, 0), COALESCE(flag_18, 0) FROM ml.inference_logs WHERE run_id = 18", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_19", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_19), NVL(flag_19, 0) FROM ml.inference_logs WHERE run_id = 19", "target_sql": "SELECT COALESCE(score_19, 0), COALESCE(flag_19, 0) FROM ml.inference_logs WHERE run_id = 19", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_20", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_20), NVL(flag_20, 0) FROM ml.inference_logs WHERE run_id = 20", "target_sql": "SELECT COALESCE(score_20, 0), COALESCE(flag_20, 0) FROM ml.inference_logs WHERE run_id = 20", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_21", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_21), NVL(flag_21, 0) FROM ml.inference_logs WHERE run_id = 21", "target_sql": "SELECT COALESCE(score_21, 0), COALESCE(flag_21, 0) FROM ml.inference_logs WHERE run_id = 21", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_22", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_22), NVL(flag_22, 0) FROM ml.inference_logs WHERE run_id = 22", "target_sql": "SELECT COALESCE(score_22, 0), COALESCE(flag_22, 0) FROM ml.inference_logs WHERE run_id = 22", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_23", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_23), NVL(flag_23, 0) FROM ml.inference_logs WHERE run_id = 23", "target_sql": "SELECT COALESCE(score_23, 0), COALESCE(flag_23, 0) FROM ml.inference_logs WHERE run_id = 23", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_24", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_24), NVL(flag_24, 0) FROM ml.inference_logs WHERE run_id = 24", "target_sql": "SELECT COALESCE(score_24, 0), COALESCE(flag_24, 0) FROM ml.inference_logs WHERE run_id = 24", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_25", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_25), NVL(flag_25, 0) FROM ml.inference_logs WHERE run_id = 25", "target_sql": "SELECT COALESCE(score_25, 0), COALESCE(flag_25, 0) FROM ml.inference_logs WHERE run_id = 25", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_26", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_26), NVL(flag_26, 0) FROM ml.inference_logs WHERE run_id = 26", "target_sql": "SELECT COALESCE(score_26, 0), COALESCE(flag_26, 0) FROM ml.inference_logs WHERE run_id = 26", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_27", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_27), NVL(flag_27, 0) FROM ml.inference_logs WHERE run_id = 27", "target_sql": "SELECT COALESCE(score_27, 0), COALESCE(flag_27, 0) FROM ml.inference_logs WHERE run_id = 27", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_28", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_28), NVL(flag_28, 0) FROM ml.inference_logs WHERE run_id = 28", "target_sql": "SELECT COALESCE(score_28, 0), COALESCE(flag_28, 0) FROM ml.inference_logs WHERE run_id = 28", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_29", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_29), NVL(flag_29, 0) FROM ml.inference_logs WHERE run_id = 29", "target_sql": "SELECT COALESCE(score_29, 0), COALESCE(flag_29, 0) FROM ml.inference_logs WHERE run_id = 29", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_30", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_30), NVL(flag_30, 0) FROM ml.inference_logs WHERE run_id = 30", "target_sql": "SELECT COALESCE(score_30, 0), COALESCE(flag_30, 0) FROM ml.inference_logs WHERE run_id = 30", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_31", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_31), NVL(flag_31, 0) FROM ml.inference_logs WHERE run_id = 31", "target_sql": "SELECT COALESCE(score_31, 0), COALESCE(flag_31, 0) FROM ml.inference_logs WHERE run_id = 31", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_32", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_32), NVL(flag_32, 0) FROM ml.inference_logs WHERE run_id = 32", "target_sql": "SELECT COALESCE(score_32, 0), COALESCE(flag_32, 0) FROM ml.inference_logs WHERE run_id = 32", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_33", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_33), NVL(flag_33, 0) FROM ml.inference_logs WHERE run_id = 33", "target_sql": "SELECT COALESCE(score_33, 0), COALESCE(flag_33, 0) FROM ml.inference_logs WHERE run_id = 33", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_34", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_34), NVL(flag_34, 0) FROM ml.inference_logs WHERE run_id = 34", "target_sql": "SELECT COALESCE(score_34, 0), COALESCE(flag_34, 0) FROM ml.inference_logs WHERE run_id = 34", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_35", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_35), NVL(flag_35, 0) FROM ml.inference_logs WHERE run_id = 35", "target_sql": "SELECT COALESCE(score_35, 0), COALESCE(flag_35, 0) FROM ml.inference_logs WHERE run_id = 35", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_36", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_36), NVL(flag_36, 0) FROM ml.inference_logs WHERE run_id = 36", "target_sql": "SELECT COALESCE(score_36, 0), COALESCE(flag_36, 0) FROM ml.inference_logs WHERE run_id = 36", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_37", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_37), NVL(flag_37, 0) FROM ml.inference_logs WHERE run_id = 37", "target_sql": "SELECT COALESCE(score_37, 0), COALESCE(flag_37, 0) FROM ml.inference_logs WHERE run_id = 37", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_38", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_38), NVL(flag_38, 0) FROM ml.inference_logs WHERE run_id = 38", "target_sql": "SELECT COALESCE(score_38, 0), COALESCE(flag_38, 0) FROM ml.inference_logs WHERE run_id = 38", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_39", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_39), NVL(flag_39, 0) FROM ml.inference_logs WHERE run_id = 39", "target_sql": "SELECT COALESCE(score_39, 0), COALESCE(flag_39, 0) FROM ml.inference_logs WHERE run_id = 39", "notes": "AI inference-log SQL pair"}
+{"id": "ai_codegen_40", "category": "function", "source_dialect": "vertica", "target_dialect": "snowflake", "source_sql": "SELECT ZEROIFNULL(score_40), NVL(flag_40, 0) FROM ml.inference_logs WHERE run_id = 40", "target_sql": "SELECT COALESCE(score_40, 0), COALESCE(flag_40, 0) FROM ml.inference_logs WHERE run_id = 40", "notes": "AI inference-log SQL pair"}
diff --git a/demo/__init__.py b/demo/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/demo/handlers.py b/demo/handlers.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd6a036c04624455ae41aa165eef57034e485016
--- /dev/null
+++ b/demo/handlers.py
@@ -0,0 +1,1376 @@
+"""Gradio event handlers for MorphSQL."""
+
+from __future__ import annotations
+
+import json
+import re
+import tempfile
+import zipfile
+from pathlib import Path
+
+import plotly.graph_objects as go
+
+from morphsql.assistant.copilot import MigrationCopilot
+from morphsql.dbt_generator.decomposer import decompose_to_dbt, format_dbt_project, is_dbt_target
+from morphsql.intelligence.lineage_viz import lineage_to_plotly
+from morphsql.intelligence.rationalization import generate_rationalization
+from morphsql.intelligence.runbook import generate_executive_summary, generate_runbook
+from morphsql.lineage.builder import build_lineage_graph
+from morphsql.models import Dialect, MigrationObject, MigrationReport, ObjectType
+from morphsql.pipeline import MigrationPipeline
+from morphsql.translator.engine import translate_sql
+from morphsql.translator.pandas_codegen import is_pandas_target
+from morphsql.translator.pyspark_codegen import is_pyspark_target
+from demo.theme import C_MUTED, C_PANEL, C_TEXT
+
+EXAMPLES_DIR = Path(__file__).parent.parent / "examples" / "vertica_legacy"
+_copilot = MigrationCopilot()
+
+
+def _sanitize_project(name: str) -> str:
+ return re.sub(r"[^a-zA-Z0-9_]", "_", name.lower()).strip("_") or "migration_project"
+
+
+def _apply_dark_layout(fig: go.Figure, height: int = 260) -> go.Figure:
+ fig.update_layout(
+ template="plotly_dark",
+ height=height,
+ margin=dict(t=40, b=40, l=30, r=20),
+ paper_bgcolor=C_PANEL,
+ plot_bgcolor=C_PANEL,
+ font={"color": C_TEXT, "family": "Inter, system-ui, sans-serif"},
+ xaxis={"gridcolor": "#334155", "zerolinecolor": "#334155", "color": C_TEXT},
+ yaxis={"gridcolor": "#334155", "zerolinecolor": "#334155", "color": C_TEXT},
+ )
+ return fig
+
+
+def _risk_gauge(score: int) -> go.Figure:
+ score = max(0, min(100, int(score or 0)))
+ color = "#22c55e" if score < 30 else "#eab308" if score < 60 else "#ef4444"
+ label = "Low" if score < 30 else "Medium" if score < 60 else "High"
+ fig = go.Figure(
+ go.Indicator(
+ mode="gauge+number",
+ value=score,
+ number={"suffix": " / 100", "font": {"color": C_TEXT, "size": 28}},
+ title={"text": f"Portfolio risk · {label}", "font": {"color": C_MUTED, "size": 13}},
+ gauge={
+ "axis": {"range": [0, 100], "tickcolor": C_MUTED, "tickfont": {"color": C_MUTED}},
+ "bar": {"color": color, "thickness": 0.8},
+ "bgcolor": "#0f172a",
+ "bordercolor": "#334155",
+ "steps": [
+ {"range": [0, 30], "color": "#14532d"},
+ {"range": [30, 60], "color": "#713f12"},
+ {"range": [60, 100], "color": "#7f1d1d"},
+ ],
+ },
+ )
+ )
+ return _apply_dark_layout(fig, height=280)
+
+
+def _distribution_chart(report: MigrationReport) -> go.Figure:
+ d = report.dashboard
+ fig = go.Figure(
+ go.Bar(
+ x=["Auto-migrate", "Review", "Redesign", "Retire"],
+ y=[
+ d.auto_migratable,
+ d.requires_review,
+ d.requires_redesign,
+ d.recommended_retirement,
+ ],
+ marker_color=["#22c55e", "#eab308", "#ef4444", "#64748b"],
+ text=[
+ d.auto_migratable,
+ d.requires_review,
+ d.requires_redesign,
+ d.recommended_retirement,
+ ],
+ textposition="outside",
+ textfont={"color": C_TEXT, "size": 14},
+ )
+ )
+ fig.update_layout(title={"text": "Object distribution", "font": {"color": C_MUTED, "size": 13}})
+ return _apply_dark_layout(fig, height=280)
+
+
+def _distribution_chart_empty() -> go.Figure:
+ fig = go.Figure(
+ go.Bar(
+ x=["Auto-migrate", "Review", "Redesign", "Retire"],
+ y=[0, 0, 0, 0],
+ marker_color=["#22c55e", "#eab308", "#ef4444", "#64748b"],
+ text=[0, 0, 0, 0],
+ textposition="outside",
+ textfont={"color": C_TEXT},
+ )
+ )
+ fig.update_layout(
+ title={"text": "Object distribution (run scan)", "font": {"color": C_MUTED, "size": 13}},
+ yaxis={"range": [0, 5]},
+ )
+ return _apply_dark_layout(fig, height=280)
+
+
+def figure_to_html(fig: go.Figure) -> str:
+ """Reliable Plotly HTML embed for Gradio (avoids blank Plot widgets)."""
+ return fig.to_html(
+ include_plotlyjs="cdn",
+ full_html=False,
+ config={"displayModeBar": False, "responsive": True},
+ )
+
+
+def empty_lineage_plot() -> go.Figure:
+ return lineage_to_plotly(build_lineage_graph([], Dialect.VERTICA))
+
+
+def _resolve_repo_path(upload_file, use_sample: bool) -> Path | None:
+ if use_sample:
+ return EXAMPLES_DIR if EXAMPLES_DIR.exists() else None
+ if not upload_file:
+ return None
+
+ # Gradio File may return str path, Path, or tempfile-like object
+ if isinstance(upload_file, (list, tuple)) and upload_file:
+ upload_file = upload_file[0]
+ path = Path(str(upload_file.name if hasattr(upload_file, "name") else upload_file))
+ if not path.exists():
+ return None
+ if path.suffix.lower() == ".zip":
+ tmp = Path(tempfile.mkdtemp(prefix="morphsql_"))
+ with zipfile.ZipFile(path) as zf:
+ zf.extractall(tmp)
+ return tmp
+ if path.is_dir():
+ return path
+ return None
+
+
+def _report_to_dict(report: MigrationReport | None) -> dict | None:
+ if report is None:
+ return None
+ return report.model_dump(mode="json")
+
+
+def _dict_to_report(data: dict | MigrationReport | None) -> MigrationReport | None:
+ if data is None:
+ return None
+ if isinstance(data, MigrationReport):
+ return data
+ if isinstance(data, dict):
+ try:
+ return MigrationReport.model_validate(data)
+ except Exception:
+ return None
+ return None
+
+
+def run_migration_workbench(
+ upload_file,
+ use_sample: bool,
+ source: str,
+ target: str,
+) -> tuple:
+ """Full migration intelligence pipeline for workbench tab."""
+ empty_msg = (
+ "Enable **Use sample repository** or upload a zip file, "
+ "then click **Run migration intelligence**."
+ )
+ empty_risk = _risk_gauge(0)
+ empty_dist = _distribution_chart_empty()
+ empty_lineage = empty_lineage_plot()
+ empty = (
+ empty_msg,
+ "No objects yet.",
+ "No rationalization yet.",
+ "No runbook yet.",
+ "No dbt preview yet.",
+ "No validation results yet.",
+ "Portfolio risk: —",
+ empty_risk,
+ empty_dist,
+ empty_lineage,
+ json.dumps({"status": "waiting_for_input"}, indent=2),
+ None,
+ )
+
+ try:
+ repo_path = _resolve_repo_path(upload_file, bool(use_sample))
+ except Exception as exc:
+ return (
+ f"Failed to open repository: {exc}",
+ *empty[1:],
+ )
+
+ if repo_path is None:
+ return empty
+
+ try:
+ source_d = Dialect(source)
+ wants_dbt = is_dbt_target(target)
+ if wants_dbt:
+ target_d = Dialect.SNOWFLAKE
+ elif is_pandas_target(target):
+ target_d = Dialect.PANDAS
+ elif is_pyspark_target(target):
+ target_d = Dialect.PYSPARK
+ else:
+ target_d = Dialect(target)
+
+ pipeline = MigrationPipeline(source=source_d, target=target_d)
+ report = pipeline.analyze(str(repo_path))
+ report = pipeline.convert(report)
+ report = pipeline.validate(report)
+ if wants_dbt:
+ report.target_dialect = Dialect.DBT_SNOWFLAKE
+
+ exec_summary = generate_executive_summary(report)
+ runbook = generate_runbook(report)
+ rationalization = generate_rationalization(report)
+
+ dbt_preview = "No suitable object for dbt decomposition."
+ candidates = sorted(report.objects, key=lambda o: o.complexity_score, reverse=True)
+ dbt_parts = ["### dbt project preview (Snowflake)\n"]
+ shown = 0
+ for obj in candidates:
+ if obj.object_type.value not in ("stored_procedure", "sql_script", "view"):
+ continue
+ files = decompose_to_dbt(obj, source_d, project_name=_sanitize_project(obj.name))
+ dbt_parts.append(f"#### `{obj.name}` → {len(files)} files\n")
+ # Show model SQL files first
+ model_files = [p for p in sorted(files) if p.startswith("models/") and p.endswith(".sql")]
+ for rel in model_files[:5]:
+ dbt_parts.append(f"**{rel}**\n```sql\n{files[rel][:1200]}\n```\n")
+ shown += 1
+ if shown >= 2:
+ break
+ if shown:
+ dbt_preview = "\n".join(dbt_parts)
+ elif not wants_dbt:
+ dbt_preview = (
+ "Target is not **dbt-snowflake**. "
+ "Select **dbt-snowflake** as the target to generate staging / intermediate / mart models, "
+ "or open Architecture (dbt) after a dbt-snowflake run."
+ )
+
+ val_lines = ["### Validation results\n"]
+ passed = sum(1 for r in report.validation_results if r.passed)
+ total = len(report.validation_results)
+ val_lines.append(f"**{passed}/{total}** checks passed\n")
+ for r in report.validation_results[:20]:
+ status = "PASS" if r.passed else "FAIL"
+ val_lines.append(f"- **[{status}]** {r.object_name} · {r.check_name}")
+ if r.root_cause:
+ val_lines.append(f" - {r.root_cause}")
+ if r.recommendation and not r.passed:
+ val_lines.append(f" - Fix: {r.recommendation}")
+ validation_md = "\n".join(val_lines) if total else "No validation checks generated."
+
+ obj_lines = [
+ "### Discovered objects\n",
+ "| Object | Type | Complexity | Risk | Confidence | Category |",
+ "|--------|------|------------|------|------------|----------|",
+ ]
+ for obj in report.objects:
+ obj_lines.append(
+ f"| {obj.name} | {obj.object_type.value} | {obj.complexity_score} "
+ f"| {obj.risk_level.value} | {obj.conversion_confidence:.0f}% "
+ f"| {obj.migration_category.value.replace('_', ' ')} |"
+ )
+ if not report.objects:
+ obj_lines.append("| — | — | — | — | — | No objects found |")
+
+ metrics_md = (
+ f"**Portfolio risk:** {int(report.dashboard.migration_risk_score)} / 100 · "
+ f"**Objects:** {report.dashboard.total_objects} · "
+ f"**Auto-migrate:** {report.dashboard.auto_migratable} · "
+ f"**Review:** {report.dashboard.requires_review} · "
+ f"**Redesign:** {report.dashboard.requires_redesign} · "
+ f"**Retire:** {report.dashboard.recommended_retirement}"
+ )
+
+ graph = build_lineage_graph(report.objects, source_d)
+ lineage_fig = lineage_to_plotly(graph, report.objects)
+ risk_fig = _risk_gauge(int(report.dashboard.migration_risk_score))
+ dist_fig = _distribution_chart(report)
+
+ export = json.dumps(
+ {
+ "repository": str(repo_path),
+ "source": source,
+ "target": target,
+ "dashboard": report.dashboard.model_dump(mode="json"),
+ "objects": [
+ {
+ "name": o.name,
+ "type": o.object_type.value,
+ "complexity": o.complexity_score,
+ "risk": o.risk_level.value,
+ "confidence": o.conversion_confidence,
+ "category": o.migration_category.value,
+ }
+ for o in report.objects
+ ],
+ },
+ indent=2,
+ )
+
+ return (
+ exec_summary + "\n\n" + metrics_md,
+ "\n".join(obj_lines),
+ rationalization,
+ runbook,
+ dbt_preview,
+ validation_md,
+ metrics_md,
+ risk_fig,
+ dist_fig,
+ lineage_fig,
+ export,
+ _report_to_dict(report),
+ )
+ except Exception as exc:
+ return (
+ f"**Workbench error:** {type(exc).__name__}: {exc}",
+ *empty[1:],
+ )
+
+
+def analyze_sql_object(sql: str, source: str, target: str) -> tuple[str, go.Figure, str, str, str]:
+ """Assess + convert a single SQL object.
+
+ Returns: analysis_md, risk_fig, badge, converted_sql_or_dbt, notes_md
+ """
+ if not (sql or "").strip():
+ return (
+ "Paste SQL above, then click **Assess & Convert**.",
+ _risk_gauge(0),
+ "—",
+ "",
+ "Waiting for SQL input.",
+ )
+
+ try:
+ from morphsql.parser.sql_parser import count_sql_complexity, detect_unsupported_features
+ from morphsql.risk.scorer import extract_business_rules, score_object
+ from morphsql.validation.reconciliation import generate_incremental_strategy
+
+ source_d = Dialect(source)
+ wants_dbt = is_dbt_target(target)
+ wants_pandas = is_pandas_target(target)
+ wants_pyspark = is_pyspark_target(target)
+ if wants_dbt:
+ target_d = Dialect.SNOWFLAKE
+ elif wants_pandas:
+ target_d = Dialect.PANDAS
+ elif wants_pyspark:
+ target_d = Dialect.PYSPARK
+ else:
+ target_d = Dialect(target)
+
+ # Infer object type for better dbt decomposition
+ obj_type = ObjectType.SQL_SCRIPT
+ if re.search(r"\bCREATE\s+(?:OR\s+REPLACE\s+)?PROCEDURE\b", sql, re.I):
+ obj_type = ObjectType.STORED_PROCEDURE
+ elif re.search(r"\bCREATE\s+(?:OR\s+REPLACE\s+)?VIEW\b", sql, re.I):
+ obj_type = ObjectType.VIEW
+
+ score_target = (
+ Dialect.SNOWFLAKE if (wants_pandas or wants_pyspark) else target_d
+ )
+ obj = MigrationObject(name="input_object", object_type=obj_type, source_sql=sql)
+ obj = score_object(obj, source_d, score_target)
+ complexity = count_sql_complexity(sql, source_d)
+ unsupported = detect_unsupported_features(sql, source_d, score_target)
+ rules = extract_business_rules(sql)
+ incremental = generate_incremental_strategy(sql)
+
+ converted, conf, auto, review = translate_sql(sql, source_d, target_d)
+ obj.target_sql = converted
+ obj.conversion_confidence = conf
+ obj.auto_converted = auto
+ obj.requires_review = review
+
+ output_sql = converted
+ dbt_note = ""
+ if wants_dbt:
+ files = decompose_to_dbt(obj, source_d, project_name="input_object")
+ output_sql = format_dbt_project(files)
+ dbt_note = (
+ f"**dbt project generated:** {len(files)} files "
+ f"({len(obj.dbt_models)} models) — staging / intermediate / marts\n\n"
+ )
+
+ lines = [
+ "### Object assessment",
+ "",
+ "| Field | Value |",
+ "|-------|-------|",
+ f"| Complexity | {obj.complexity_score} / 100 |",
+ f"| Risk | {obj.risk_level.value.title()} |",
+ f"| Category | {obj.migration_category.value.replace('_', ' ').title()} |",
+ f"| Conversion confidence | {conf:.0f}% |",
+ f"| Output | {'dbt models (Snowflake)' if wants_dbt else target} |",
+ f"| Lines | {complexity.get('lines', 0)} |",
+ f"| CTEs | {complexity.get('ctes', 0)} |",
+ f"| Joins | {complexity.get('joins', 0)} |",
+ f"| Temp tables | {complexity.get('temp_tables', 0)} |",
+ f"| Window functions | {complexity.get('window_functions', 0)} |",
+ "",
+ ]
+ if wants_dbt and obj.dbt_models:
+ lines += ["**dbt models**", ""] + [f"- `{m}`" for m in obj.dbt_models] + [""]
+ if obj.risk_factors:
+ lines += ["**Risk factors**", ""] + [f"- {rf.description}" for rf in obj.risk_factors] + [""]
+ if unsupported:
+ lines += ["**Unsupported syntax**", ""] + [f"- {u}" for u in unsupported] + [""]
+ if rules:
+ lines += ["**Business rules**", ""] + [f"- {r}" for r in rules[:5]] + [""]
+ if incremental:
+ lines += ["**Incremental pattern**", ""]
+ for k, v in incremental.items():
+ lines.append(f"- {k.replace('_', ' ').title()}: {v}")
+
+ notes = [
+ f"**Route:** {source} → {target}",
+ f"**Confidence:** {conf:.0f}%",
+ "",
+ ]
+ if dbt_note:
+ notes.append(dbt_note)
+ if auto:
+ notes.append("**SQL transformations**")
+ notes.extend(f"- {a}" for a in auto[:10])
+ notes.append("")
+ if review:
+ notes.append("**Manual review**")
+ notes.extend(f"- {r}" for r in review[:10])
+
+ badge = f"{obj.complexity_score} / 100 · {obj.risk_level.value.title()} · {conf:.0f}% conf"
+ return (
+ "\n".join(lines),
+ _risk_gauge(obj.complexity_score),
+ badge,
+ output_sql,
+ "\n".join(notes),
+ )
+ except Exception as exc:
+ return (
+ f"**Assessment error:** {type(exc).__name__}: {exc}",
+ _risk_gauge(0),
+ "Error",
+ "",
+ str(exc),
+ )
+
+
+def get_sample_workbench() -> tuple:
+ """Precompute sample workbench outputs so UI is never blank on load."""
+ return run_migration_workbench(None, True, "vertica", "snowflake")
+
+
+def copilot_chat(
+ message: str,
+ history: list | None,
+ report_data: dict | MigrationReport | None,
+ sql: str,
+ source: str,
+ target: str,
+) -> tuple[list, str]:
+ history = list(history or [])
+ if not (message or "").strip():
+ return history, ""
+
+ report = _dict_to_report(report_data)
+ normalized: list[dict] = []
+ for turn in history:
+ if isinstance(turn, dict) and "role" in turn and "content" in turn:
+ normalized.append({"role": turn["role"], "content": str(turn["content"])})
+ elif isinstance(turn, (list, tuple)) and len(turn) == 2:
+ if turn[0]:
+ normalized.append({"role": "user", "content": str(turn[0])})
+ if turn[1]:
+ normalized.append({"role": "assistant", "content": str(turn[1])})
+
+ reply = _copilot.respond(message.strip(), normalized, report, sql or "", source, target)
+ normalized.append({"role": "user", "content": message.strip()})
+ normalized.append({"role": "assistant", "content": reply})
+ return normalized, ""
+
+
+def report_to_context(report_data: dict | MigrationReport | None) -> str:
+ report = _dict_to_report(report_data)
+ if report is None:
+ return (
+ "No repository scan loaded yet. "
+ "Open Migration Workbench, run a scan, then return here — "
+ "the copilot will use that context."
+ )
+ ctx = _copilot.build_context(report)
+ # Avoid markdown code fences that can go white-on-white in Gradio themes
+ safe = ctx[:2500].replace("`", "'")
+ return f"### Active scan context\n\n{safe}"
+
+
+HERO_EXAMPLE = (
+ "SELECT customer_id, COALESCE(order_amount, 0) AS order_amount, "
+ "COALESCE(discount, 0) AS discount FROM staging.orders "
+ "WHERE order_date >= CURRENT_DATE - 30"
+)
+
+FEATURE_SQL_PATH = Path(__file__).parent.parent / "examples" / "ml_features" / "churn_feature_sql.sql"
+
+# Human-readable labels (defined before convert helpers)
+# Snowflake first — most common warehouse for DS/AI users
+SOURCE_LABELS = {
+ "snowflake": "Snowflake",
+ "bigquery": "BigQuery",
+ "redshift": "Redshift",
+ "oracle": "Oracle",
+ "vertica": "Vertica",
+}
+TARGET_LABELS = {
+ "pandas": "Python (pandas)",
+ "pyspark": "Python (PySpark)",
+ "snowflake": "Snowflake SQL",
+ "bigquery": "BigQuery SQL",
+ "dbt-snowflake": "dbt project (Snowflake)",
+}
+SOURCE_DROPDOWN = [(label, key) for key, label in SOURCE_LABELS.items()]
+TARGET_DROPDOWN = [
+ ("Python (pandas) — recommended for notebooks", "pandas"),
+ ("Python (PySpark) — Spark DataFrame API", "pyspark"),
+ ("Snowflake SQL", "snowflake"),
+ ("BigQuery SQL", "bigquery"),
+ ("dbt project (Snowflake)", "dbt-snowflake"),
+]
+
+
+def run_hero_agent(sql: str, source: str, target: str) -> tuple[str, str, str, str]:
+ """Convert SQL and return (notes_md, output, status, share_md)."""
+ from morphsql.risk.scorer import score_object
+
+ sql = sql or HERO_EXAMPLE
+ source_d = Dialect(source)
+ wants_dbt = is_dbt_target(target)
+ wants_pandas = is_pandas_target(target)
+ wants_pyspark = is_pyspark_target(target)
+ if wants_dbt:
+ target_d = Dialect.SNOWFLAKE
+ elif wants_pandas:
+ target_d = Dialect.PANDAS
+ elif wants_pyspark:
+ target_d = Dialect.PYSPARK
+ else:
+ target_d = Dialect(target)
+
+ converted, conf, auto, review = translate_sql(sql, source_d, target_d)
+ score_target = (
+ Dialect.SNOWFLAKE if (wants_pandas or wants_pyspark) else target_d
+ )
+ obj = MigrationObject(
+ name="hero_query",
+ object_type=ObjectType.SQL_SCRIPT,
+ source_sql=sql,
+ target_sql=converted,
+ )
+ obj = score_object(obj, source_d, score_target)
+
+ output = converted
+ if wants_dbt:
+ files = decompose_to_dbt(obj, source_d, project_name="morphsql_dbt")
+ output = format_dbt_project(files, max_files=12)
+
+ source_label = SOURCE_LABELS.get(source, source)
+ target_label = TARGET_LABELS.get(target, target)
+ if wants_pandas:
+ kind = "Python (pandas)"
+ next_steps = [
+ "1. Review the generated Python on the right.",
+ "2. Check the **sample preview** (synthetic tables).",
+ "3. Download the `.py` file or open the notebook starter cell.",
+ "4. Point `tables['…']` at your real DataFrames (`read_parquet` / `read_sql`).",
+ ]
+ elif wants_pyspark:
+ kind = "Python (PySpark)"
+ next_steps = [
+ "1. Review the generated PySpark code on the right.",
+ "2. Check the **sample preview** (same query logic on synthetic tables).",
+ "3. Download the `.py` file or open the Spark notebook starter.",
+ "4. Provide `tables['…']` or replace with `spark.table` / `spark.read`.",
+ ]
+ elif wants_dbt:
+ kind = "dbt project"
+ next_steps = [
+ "1. Copy the generated dbt files.",
+ "2. Check the **sample preview** for query-shape sanity.",
+ "3. Drop models into a dbt project and run `dbt run`.",
+ "4. Review models marked for manual checks.",
+ ]
+ else:
+ kind = f"{target_label} SQL"
+ next_steps = [
+ "1. Copy the converted SQL on the right.",
+ "2. Check the **sample preview** (same query logic on synthetic tables).",
+ "3. Run the SQL on the target warehouse.",
+ "4. Check any items under Needs review.",
+ ]
+
+ changes: list[str] = []
+ for a in auto[:8]:
+ if any(
+ k in a
+ for k in (
+ "→",
+ "Removed",
+ "Converted",
+ "JOIN",
+ "WHERE",
+ "GROUP",
+ "SELECT",
+ "pandas",
+ "Dialect",
+ "FROM dual",
+ )
+ ):
+ changes.append(f"- {a}")
+
+ notes = [
+ f"### {source_label} → {target_label}",
+ "",
+ f"**{conf:.0f}%** confidence · output: **{kind}**",
+ "",
+ "**Next steps**",
+ *next_steps,
+ "",
+ ]
+ if changes:
+ notes.append("**What changed**")
+ notes.extend(changes)
+ notes.append("")
+ if review:
+ notes.append("**Needs review**")
+ notes.extend(f"- {r}" for r in review[:5])
+ notes.append("")
+
+ status = f"{conf:.0f}% · {kind}"
+ space_url = "https://huggingface.co/spaces/dgvj-work/morphsql"
+ share = (
+ f"MorphSQL converted **{source_label} → {target_label}** "
+ f"({conf:.0f}% confidence).\n\n"
+ f"[Open Space]({space_url}) · "
+ f"[GitHub](https://github.com/dgvj-work/morphsql)"
+ )
+ return "\n".join(notes), output, status, share
+
+
+def _infer_columns_from_sql(sql: str) -> list[str]:
+ cols: list[str] = []
+ # AS aliases
+ cols.extend(re.findall(r"\bAS\s+([A-Za-z_][\w]*)", sql or "", flags=re.I))
+ # bare identifiers after SELECT / commas (best-effort)
+ cols.extend(re.findall(r"(?:SELECT|,)\s*(?:[\w.]+\.)?([A-Za-z_][\w]*)\b", sql or "", flags=re.I))
+ # common warehouse columns used in demos
+ cols.extend(
+ [
+ "customer_id",
+ "user_id",
+ "order_amount",
+ "discount",
+ "order_date",
+ "amount",
+ "name",
+ "id",
+ "a",
+ "b",
+ "x",
+ "dt",
+ "dept",
+ "event_value",
+ "event_ts",
+ "event_type",
+ "value",
+ "score",
+ ]
+ )
+ # de-dupe preserving order, drop SQL keywords
+ skip = {
+ "select",
+ "from",
+ "where",
+ "group",
+ "by",
+ "order",
+ "limit",
+ "as",
+ "and",
+ "or",
+ "on",
+ "join",
+ "left",
+ "right",
+ "inner",
+ "outer",
+ "case",
+ "when",
+ "then",
+ "else",
+ "end",
+ "null",
+ "not",
+ "is",
+ "in",
+ "distinct",
+ "count",
+ "sum",
+ "avg",
+ "min",
+ "max",
+ "coalesce",
+ "nvl",
+ "zeroifnull",
+ "ifnull",
+ "current_date",
+ "sysdate",
+ "getdate",
+ }
+ out: list[str] = []
+ for c in cols:
+ if c.lower() in skip:
+ continue
+ if c not in out:
+ out.append(c)
+ return out[:24]
+
+
+def build_sample_tables(code: str, sql: str = "") -> dict:
+ """Build tiny demo DataFrames so generated pandas can execute in the Space."""
+ import pandas as pd
+
+ keys = list(dict.fromkeys(re.findall(r"tables\[['\"]([^'\"]+)['\"]\]", code or "")))
+ if not keys and sql:
+ # Fallback: FROM / JOIN identifiers in the source SQL
+ keys = list(
+ dict.fromkeys(
+ re.findall(
+ r"(?:FROM|JOIN)\s+([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)?)",
+ sql,
+ flags=re.I,
+ )
+ )
+ )
+ keys = [k for k in keys if k.lower() not in {"dual", "select"}]
+ cols = _infer_columns_from_sql(sql)
+ # Include columns referenced in generated code (e.g. procedure params used as cols)
+ for c in re.findall(r"\[['\"]([^'\"]+)['\"]\]", code or ""):
+ if "." in c or c in keys:
+ continue # table keys like staging.orders
+ if c not in cols:
+ cols.append(c)
+ if not cols:
+ cols = ["id", "value"]
+
+ n = 5
+ today = pd.Timestamp.today().normalize()
+ frames: dict = {}
+ for key in keys:
+ data: dict = {}
+ for i, col in enumerate(cols):
+ cl = col.lower()
+ if any(t in cl for t in ("date", "ts", "time", "dt")):
+ data[col] = [today - pd.Timedelta(days=j) for j in range(n)]
+ elif any(t in cl for t in ("id", "count", "nunique", "flag", "label")):
+ data[col] = list(range(1, n + 1))
+ elif any(t in cl for t in ("amount", "value", "score", "avg", "sum", "discount")):
+ data[col] = [None if j == 0 else float(10 * (j + 1)) for j in range(n)]
+ elif any(t in cl for t in ("name", "type", "dept")):
+ data[col] = [f"item_{j}" for j in range(n)]
+ else:
+ data[col] = [j + 1 for j in range(n)]
+ frames[key] = pd.DataFrame(data)
+ return frames
+
+
+def _preview_code_for_target(output: str, target: str, sql: str, source: str) -> tuple[str, str]:
+ """
+ Return (python_code, via_label) used to produce a sample DataFrame preview.
+
+ Pandas targets execute the generated code. All other targets reuse the same
+ source SQL → pandas path so preview works without Spark / a warehouse.
+ """
+ from morphsql.translator.pandas_codegen import sql_to_pandas
+
+ code = output or ""
+ if is_pandas_target(target) and "import pandas" in code:
+ return code, "generated pandas"
+
+ if not (sql or "").strip():
+ return "", ""
+
+ try:
+ source_d = Dialect(source)
+ except ValueError:
+ source_d = Dialect.SNOWFLAKE
+
+ preview_code, _conf, _auto, _review = sql_to_pandas(sql, source_d)
+ label = TARGET_LABELS.get(target, target)
+ return preview_code, f"pandas runtime · same query logic (output above is {label})"
+
+
+def run_sample_preview(
+ output: str,
+ target: str,
+ sql: str = "",
+ source: str = "snowflake",
+) -> tuple:
+ """
+ Execute query logic against synthetic tables for a sample DataFrame preview.
+
+ Works for every Convert target: pandas runs the generated code; PySpark / SQL /
+ dbt reuse a pandas translation of the source SQL so the Space can show rows
+ without Spark or a warehouse connection.
+ Returns (dataframe_or_none, note_md).
+ """
+ import pandas as pd
+
+ preview_code, via = _preview_code_for_target(output, target, sql, source)
+ if not preview_code or "import pandas" not in preview_code:
+ return None, "_Could not build a sample preview for this input._"
+
+ tables = build_sample_tables(preview_code, sql)
+ if not tables and "tables[" in preview_code:
+ return None, "_Could not infer input tables for a live preview._"
+ if not tables:
+ tables = {}
+
+ ns: dict = {"pd": pd, "np": __import__("numpy"), "tables": tables}
+ try:
+ exec(preview_code, ns, ns) # noqa: S102 — intentional demo sandbox for generated code
+ except Exception as exc:
+ return None, f"_Preview could not run automatically:_ `{exc}`"
+
+ result = ns.get("result")
+ if not isinstance(result, pd.DataFrame):
+ # Procedures may leave the last SELECT under result_N
+ for key, val in reversed(list(ns.items())):
+ if key.startswith("result") and isinstance(val, pd.DataFrame):
+ result = val
+ break
+ if not isinstance(result, pd.DataFrame):
+ return None, "_Preview ran, but no `result` DataFrame was produced._"
+
+ note = (
+ f"**Sample preview** · {via} on {len(tables)} synthetic table(s) "
+ f"→ `{result.shape[0]}` rows × `{result.shape[1]}` cols. "
+ "Replace inputs with your real data when you run the converted output."
+ )
+ return result.head(20), note
+
+
+def write_output_download(
+ output: str,
+ target: str,
+ stem: str | None = None,
+) -> str:
+ """Write converted output to a downloadable temp file; return path."""
+ safe = re.sub(r"[^\w.\-]+", "_", (stem or "morphsql").strip())[:80] or "morphsql"
+ if is_pandas_target(target):
+ suffix, kind = ".py", "pandas"
+ elif is_pyspark_target(target):
+ suffix, kind = ".py", "pyspark"
+ elif is_dbt_target(target):
+ suffix, kind = ".txt", "dbt"
+ else:
+ suffix, kind = ".sql", str(target).replace("-", "_")
+ path = Path(tempfile.gettempdir()) / f"{safe}_{kind}{suffix}"
+ # Avoid collisions when converting multiple files in one session
+ if path.exists():
+ path = Path(tempfile.gettempdir()) / f"{safe}_{kind}_{abs(hash(output)) % 100_000}{suffix}"
+ path.write_text(output or "", encoding="utf-8")
+ return str(path)
+
+
+def _resolve_upload_path(upload_file) -> Path | None:
+ if not upload_file:
+ return None
+ if isinstance(upload_file, (list, tuple)) and upload_file:
+ upload_file = upload_file[0]
+ path = Path(str(getattr(upload_file, "name", upload_file)))
+ return path if path.exists() else None
+
+
+def _collect_sql_files(upload_file) -> list[Path]:
+ """Return SQL/text files from an upload (.sql/.txt or .zip of those)."""
+ path = _resolve_upload_path(upload_file)
+ if path is None:
+ return []
+ suffix = path.suffix.lower()
+ if suffix in {".sql", ".txt", ".ddl", ".prc", ".pkb", ".pks"}:
+ return [path]
+ if suffix == ".zip":
+ tmp = Path(tempfile.mkdtemp(prefix="morphsql_upload_"))
+ with zipfile.ZipFile(path) as zf:
+ zf.extractall(tmp)
+ files = sorted(
+ p
+ for p in tmp.rglob("*")
+ if p.is_file() and p.suffix.lower() in {".sql", ".txt", ".ddl", ".prc"}
+ )
+ return files
+ # Unknown extension — try reading as text SQL
+ return [path]
+
+
+def load_sql_from_upload(upload_file, current_sql: str = "") -> str:
+ """Load uploaded SQL into the input box (first file if a zip). Keeps current text if empty."""
+ files = _collect_sql_files(upload_file)
+ if not files:
+ return current_sql or ""
+ try:
+ return files[0].read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ return current_sql or ""
+
+
+def convert_upload_for_ui(upload_file, sql: str, source: str, target: str):
+ """
+ Convert pasted SQL or an uploaded .sql/.txt/.zip.
+
+ Single file → normal Convert payload with a named download.
+ Zip with multiple SQL files → convert each and return a zip download.
+ Returns: (sql_in, notes, output, status, share, preview, download, notebook, api)
+ """
+ files = _collect_sql_files(upload_file)
+
+ if not files:
+ text = (sql or "").strip()
+ if not text:
+ empty = convert_for_ui("", source, target)
+ return ("", *empty)
+ notes, output, status, share, preview, download, nb, api = convert_for_ui(
+ text, source, target
+ )
+ return text, notes, output, status, share, preview, download, nb, api
+
+ if len(files) == 1:
+ text = files[0].read_text(encoding="utf-8", errors="replace")
+ notes, output, status, share, preview, _old_dl, nb, api = convert_for_ui(
+ text, source, target
+ )
+ download = write_output_download(output, target, stem=files[0].stem)
+ note = (
+ f"\n**Upload** · converted `{files[0].name}` → download "
+ f"`{Path(download).name}`.\n"
+ )
+ return text, notes + note, output, status, share, preview, download, nb, api
+
+ # Multi-file zip → convert each and package
+ out_dir = Path(tempfile.mkdtemp(prefix="morphsql_batch_"))
+ converted_paths: list[Path] = []
+ previews = []
+ combined_notes = [
+ f"### Batch upload · {len(files)} SQL file(s) → **{TARGET_LABELS.get(target, target)}**",
+ "",
+ ]
+ first_output = ""
+ first_sql = ""
+ statuses: list[str] = []
+
+ for fp in files:
+ try:
+ text = fp.read_text(encoding="utf-8", errors="replace")
+ except OSError as exc:
+ combined_notes.append(f"- `{fp.name}`: read error ({exc})")
+ continue
+ if not first_sql:
+ first_sql = text
+ notes, output, status, _share, preview, _dl, _nb, _api = convert_for_ui(
+ text, source, target
+ )
+ if not first_output:
+ first_output = output
+ if preview is not None:
+ previews.append(preview)
+ if is_pandas_target(target):
+ dest = out_dir / f"{fp.stem}_pandas.py"
+ elif is_pyspark_target(target):
+ dest = out_dir / f"{fp.stem}_pyspark.py"
+ elif is_dbt_target(target):
+ dest = out_dir / f"{fp.stem}_dbt.txt"
+ else:
+ dest = out_dir / f"{fp.stem}_{target.replace('-', '_')}.sql"
+ dest.write_text(output or "", encoding="utf-8")
+ converted_paths.append(dest)
+ statuses.append(status)
+ combined_notes.append(f"- `{fp.name}` → `{dest.name}` · {status}")
+
+ zip_path = Path(tempfile.gettempdir()) / f"morphsql_batch_{target.replace('-', '_')}.zip"
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for p in converted_paths:
+ zf.write(p, arcname=p.name)
+
+ preview = previews[0] if previews else None
+ status = statuses[0] if statuses else "Batch convert"
+ share = (
+ f"Converted **{len(converted_paths)}** file(s) to `{target}`. "
+ f"Download the zip and drop files into your notebook / warehouse / dbt project.\n\n"
+ f"[Space](https://huggingface.co/spaces/dgvj-work/morphsql) · "
+ f"[GitHub](https://github.com/dgvj-work/morphsql)"
+ )
+ nb = notebook_cell(first_output, target)
+ api = hf_pipeline_snippet(first_sql or HERO_EXAMPLE, source, target)
+ notes = "\n".join(combined_notes) + (
+ f"\n\n**Download** the zip (`{zip_path.name}`) for all converted files.\n"
+ )
+ return (
+ first_sql,
+ notes,
+ first_output or "(see zip for all converted files)",
+ status,
+ share,
+ preview,
+ str(zip_path),
+ nb,
+ api,
+ )
+
+
+def notebook_cell(output: str, target: str) -> str:
+ """Short copy-paste cell for Jupyter / Colab / Databricks."""
+ if is_pyspark_target(target):
+ return (
+ "# MorphSQL → PySpark (paste into a Databricks / Spark notebook)\n"
+ "from pyspark.sql import SparkSession, functions as F, Window\n\n"
+ "# spark = SparkSession.builder.getOrCreate()\n"
+ "# 1) Provide input frames (or replace with spark.table / spark.read)\n"
+ "# tables = {\n"
+ "# 'staging.orders': spark.table('staging.orders'),\n"
+ "# }\n\n"
+ "# 2) Paste the generated MorphSQL code below (or %run the downloaded .py)\n"
+ "# 3) Use `result` as your Spark DataFrame\n"
+ )
+ if not is_pandas_target(target):
+ return (
+ "# Output is SQL/dbt — paste into your warehouse client or dbt project.\n"
+ "# Tip: switch Convert to → Python (pandas) or Python (PySpark).\n"
+ )
+ return (
+ "# MorphSQL → pandas (paste into a notebook cell)\n"
+ "import pandas as pd\n"
+ "import numpy as np\n\n"
+ "# 1) Load your real data (parquet / sql / HF datasets)\n"
+ "# tables = {\n"
+ "# 'staging.orders': pd.read_parquet('orders.parquet'),\n"
+ "# }\n\n"
+ "# 2) Paste the generated MorphSQL code below (or %run the downloaded .py)\n"
+ "# 3) Use `result` as features for sklearn / XGBoost / embeddings pipelines\n"
+ )
+
+
+def hf_pipeline_snippet(sql: str, source: str, target: str) -> str:
+ """Hugging Face-style API snippet for AI / ML users."""
+ sql_short = (sql or "").strip().replace("\\", "\\\\").replace('"""', "'''")
+ if len(sql_short) > 180:
+ sql_short = sql_short[:177] + "..."
+ return (
+ "from morphsql.ai import pipeline\n\n"
+ "# Same API style as transformers.pipeline\n"
+ 'pipe = pipeline("sql-migration")\n'
+ "out = pipe(\n"
+ f' """{sql_short}""",\n'
+ f' source="{source}",\n'
+ f' target="{target}",\n'
+ ")\n"
+ 'print(out["converted_sql"][:500]) # pandas code or SQL\n'
+ 'print(out.get("risk")) # optional risk head\n'
+ )
+
+
+def convert_for_ui(sql: str, source: str, target: str):
+ """Full Convert-tab payload for the Space UI."""
+ notes, output, status, share = run_hero_agent(sql, source, target)
+ preview, preview_note = run_sample_preview(
+ output, target, sql=sql or "", source=source or "snowflake"
+ )
+ download = write_output_download(output, target)
+ nb = notebook_cell(output, target)
+ api = hf_pipeline_snippet(sql or HERO_EXAMPLE, source, target)
+ if preview_note:
+ notes = notes + "\n" + preview_note + "\n"
+ return notes, output, status, share, preview, download, nb, api
+
+
+PLAYGROUND_EXAMPLE_LABELS = [
+ "DS: Snowflake orders → pandas (fillna / filters)",
+ "DS: Snowflake orders → PySpark (F.coalesce / filter)",
+ "AI: Feature aggregates → pandas (training features)",
+ "DS: Vertica ZEROIFNULL → pandas",
+ "DS: Oracle dual constants → pandas",
+ "DS: Redshift window slice → pandas",
+ "DS: BigQuery null handling → pandas",
+ "Warehouse: Vertica → Snowflake SQL",
+ "Warehouse: Vertica procedure → dbt",
+]
+
+PLAYGROUND_EXAMPLES = [
+ [
+ "SELECT customer_id, COALESCE(order_amount, 0) AS order_amount, COALESCE(discount, 0) AS discount FROM staging.orders WHERE order_date >= CURRENT_DATE - 30",
+ "snowflake",
+ "pandas",
+ ],
+ [
+ "SELECT customer_id, COALESCE(order_amount, 0) AS order_amount, COALESCE(discount, 0) AS discount FROM staging.orders WHERE order_date >= CURRENT_DATE - 30",
+ "snowflake",
+ "pyspark",
+ ],
+ [
+ "SELECT user_id, COUNT(*) AS event_count_90d, SUM(ZEROIFNULL(event_value)) AS value_sum_90d, AVG(ZEROIFNULL(event_value)) AS value_avg_90d FROM staging.product_events WHERE event_ts >= CURRENT_DATE - 90 GROUP BY user_id",
+ "vertica",
+ "pandas",
+ ],
+ [
+ "SELECT customer_id, ZEROIFNULL(order_amount) AS order_amount, NVL(discount, 0) AS discount FROM staging.orders WHERE order_date >= CURRENT_DATE - 30",
+ "vertica",
+ "pandas",
+ ],
+ [
+ "SELECT NVL(amount, 0) AS amount, SYSDATE AS ts FROM dual",
+ "oracle",
+ "pandas",
+ ],
+ [
+ "SELECT GETDATE() AS ts, name FROM users WHERE id > 1 LIMIT 5",
+ "redshift",
+ "pandas",
+ ],
+ [
+ "SELECT IFNULL(a, 0) AS a, b FROM t WHERE a IS NOT NULL",
+ "bigquery",
+ "pandas",
+ ],
+ [
+ "SELECT customer_id, ZEROIFNULL(order_amount) AS order_amount FROM staging.orders",
+ "vertica",
+ "snowflake",
+ ],
+ [
+ "CREATE OR REPLACE PROCEDURE p(load_date DATE) AS $$ BEGIN CREATE LOCAL TEMP TABLE tmp ON COMMIT PRESERVE ROWS AS SELECT customer_id, ZEROIFNULL(amount) AS amount FROM staging.orders WHERE order_date = load_date; INSERT INTO analytics.daily SELECT * FROM tmp; END; $$;",
+ "vertica",
+ "dbt-snowflake",
+ ],
+]
+
+
+def load_playground_example(index: int) -> tuple[str, str, str]:
+ """Return (sql, source, target) for a preset example index."""
+ idx = max(0, min(int(index), len(PLAYGROUND_EXAMPLES) - 1))
+ sql, source, target = PLAYGROUND_EXAMPLES[idx]
+ return sql, source, target
+
+
+def load_and_convert_example(index: int) -> tuple[str, str, str, str, str, str, str]:
+ """Load a preset and immediately convert (legacy 7-tuple)."""
+ sql, source, target = load_playground_example(index)
+ explain, output, badge, share = run_hero_agent(sql, source, target)
+ return sql, source, target, explain, output, badge, share
+
+
+def on_example_selected(label: str | None):
+ """Dropdown helper used by the Convert tab (extended UI payload)."""
+ if not label or label not in PLAYGROUND_EXAMPLE_LABELS:
+ label = PLAYGROUND_EXAMPLE_LABELS[0]
+ sql, source, target = load_playground_example(PLAYGROUND_EXAMPLE_LABELS.index(label))
+ notes, output, status, share, preview, download, nb, api = convert_for_ui(
+ sql, source, target
+ )
+ return sql, source, target, notes, output, status, share, preview, download, nb, api
+
+
+AGENT_PROMPTS = [
+ (
+ "Convert this SQL to pandas",
+ PLAYGROUND_EXAMPLES[0][0],
+ "snowflake",
+ "pandas",
+ ),
+ (
+ "Convert this SQL to PySpark",
+ PLAYGROUND_EXAMPLES[1][0],
+ "snowflake",
+ "pyspark",
+ ),
+ (
+ "Convert feature SQL to pandas",
+ PLAYGROUND_EXAMPLES[2][0],
+ "vertica",
+ "pandas",
+ ),
+ (
+ "Emit a dbt project from this procedure",
+ PLAYGROUND_EXAMPLES[8][0],
+ "vertica",
+ "dbt-snowflake",
+ ),
+]
+
+
+def load_agent_example(index: int) -> tuple[str, str, str, str]:
+ idx = max(0, min(int(index), len(AGENT_PROMPTS) - 1))
+ return AGENT_PROMPTS[idx]
+
+
+def run_eval_suite(limit: int, category: str) -> tuple[str, str, dict]:
+ """Run eval suite and return markdown summary, detail table, metrics dict."""
+ from morphsql.eval.metrics import run_eval
+ from morphsql.eval.pairs import ensure_pairs_file
+
+ ensure_pairs_file()
+ cats = None if category in ("all", "", None) else [category]
+ limit_i = int(limit) if limit else 50
+ results, summary = run_eval(limit=limit_i, categories=cats)
+
+ lines = [
+ "### Eval suite results",
+ "",
+ f"| Metric | Score |",
+ f"|--------|-------|",
+ f"| Pairs | {summary['n_pairs']} |",
+ f"| Exact match | {100 * summary['exact_match']:.1f}% |",
+ f"| Token F1 | {100 * summary['token_f1']:.1f}% |",
+ f"| Fuzzy (Dice) | {100 * summary['fuzzy']:.1f}% |",
+ f"| Pass rate | {100 * summary['pass_rate']:.1f}% |",
+ "",
+ "#### By category",
+ "",
+ "| Category | N | Exact | Token F1 | Pass |",
+ "|----------|---|-------|----------|------|",
+ ]
+ for cat, stats in summary.get("by_category", {}).items():
+ lines.append(
+ f"| {cat} | {stats['n']} | {100 * stats['exact_match']:.0f}% "
+ f"| {100 * stats['token_f1']:.0f}% | {100 * stats['pass_rate']:.0f}% |"
+ )
+
+ detail = [
+ "### Sample predictions",
+ "",
+ "| ID | Pass | F1 | Exact |",
+ "|----|------|----|-------|",
+ ]
+ for r in results[:25]:
+ detail.append(
+ f"| {r.pair_id} | {'yes' if r.passed else 'no'} "
+ f"| {100 * r.token_f1:.0f}% | {100 * r.exact_match:.0f}% |"
+ )
+ return "\n".join(lines), "\n".join(detail), summary
+
+
+def submit_eval_score(name: str, summary: dict | None) -> str:
+ from morphsql.eval.leaderboard import format_leaderboard_md, submit_score
+
+ if not summary or not summary.get("n_pairs"):
+ return format_leaderboard_md() + "\n\n_Run the eval suite before submitting._"
+ board = submit_score(
+ name=name or "anonymous",
+ exact_match=summary.get("exact_match", 0),
+ token_f1=summary.get("token_f1", 0),
+ fuzzy=summary.get("fuzzy", 0),
+ pass_rate=summary.get("pass_rate", 0),
+ n_pairs=summary.get("n_pairs", 0),
+ notes="MorphSQL hybrid translator",
+ )
+ return format_leaderboard_md(board)
+
+
+def run_behavior_rag(query: str, source: str, target: str) -> str:
+ from morphsql.intelligence.rag import get_rag
+
+ return get_rag().answer(query or "NULL empty string timezone", source, target)
+
+
+def run_feature_migration(target: str) -> tuple[str, str]:
+ """Convert ML feature SQL and optionally emit dbt feature mart."""
+ sql = FEATURE_SQL_PATH.read_text(encoding="utf-8") if FEATURE_SQL_PATH.exists() else HERO_EXAMPLE
+ wants_dbt = is_dbt_target(target) or target == "dbt-snowflake"
+ target_d = Dialect.SNOWFLAKE
+ converted, conf, auto, review = translate_sql(sql, Dialect.VERTICA, target_d)
+ obj = MigrationObject(
+ name="churn_features",
+ object_type=ObjectType.SQL_SCRIPT,
+ source_sql=sql,
+ target_sql=converted,
+ )
+ if wants_dbt:
+ files = decompose_to_dbt(obj, Dialect.VERTICA, project_name="ml_feature_mart")
+ out = format_dbt_project(files, max_files=14)
+ else:
+ out = converted
+
+ md = [
+ "### ML / DS feature SQL migration",
+ "",
+ "Legacy Vertica feature engineering SQL → Snowflake"
+ + (" dbt feature mart" if wants_dbt else ""),
+ "",
+ f"**Confidence:** {conf:.0f}%",
+ "",
+ "This path is for **data scientists / ML engineers** migrating training-feature SQL "
+ "into warehouse-native, versioned dbt models.",
+ "",
+ "**Transforms**",
+ ]
+ md.extend(f"- {a}" for a in auto[:10])
+ if review:
+ md.append("")
+ md.append("**Review**")
+ md.extend(f"- {r}" for r in review[:6])
+ return "\n".join(md), out
+
+
+def analyze_sql_object_ui(sql: str, source: str, target: str) -> tuple:
+ """Gradio-friendly object assess (Plotly → HTML)."""
+ analysis, risk_fig, badge, output, notes = analyze_sql_object(sql, source, target)
+ return analysis, figure_to_html(risk_fig), badge, output, notes
+
+
+def run_workbench_ui(upload_file, use_sample: bool, source: str, target: str) -> tuple:
+ """Gradio-friendly workbench (Plotly figs → HTML)."""
+ (
+ summary,
+ objects,
+ rationalization,
+ runbook,
+ dbt_preview,
+ validation,
+ metrics,
+ risk_fig,
+ dist_fig,
+ lineage_fig,
+ export,
+ report_data,
+ ) = run_migration_workbench(upload_file, use_sample, source, target)
+ return (
+ summary,
+ objects,
+ rationalization,
+ runbook,
+ dbt_preview,
+ validation,
+ metrics,
+ figure_to_html(risk_fig) if isinstance(risk_fig, go.Figure) else str(risk_fig),
+ figure_to_html(dist_fig) if isinstance(dist_fig, go.Figure) else str(dist_fig),
+ figure_to_html(lineage_fig) if isinstance(lineage_fig, go.Figure) else str(lineage_fig),
+ export,
+ report_data,
+ )
+
+
+def get_leaderboard_md() -> str:
+ from morphsql.eval.leaderboard import format_leaderboard_md
+
+ return format_leaderboard_md()
+
diff --git a/demo/theme.py b/demo/theme.py
new file mode 100644
index 0000000000000000000000000000000000000000..36fb4b9823706f9c852cb0c9b46200f82b7f508f
--- /dev/null
+++ b/demo/theme.py
@@ -0,0 +1,265 @@
+"""UI theme — dark shell with high-contrast tables/files."""
+
+C_BG = "#0b1220"
+C_PANEL = "#111827"
+C_BORDER = "#334155"
+C_TEXT = "#f1f5f9"
+C_MUTED = "#cbd5e1"
+C_ACCENT = "#3b82f6"
+C_CODE_BG = "#020617"
+C_CODE_FG = "#93c5fd"
+C_TABLE_BG = "#0f172a"
+C_TABLE_TEXT = "#f8fafc"
+C_TABLE_HEADER = "#1e293b"
+
+CUSTOM_CSS = f"""
+.gradio-container {{
+ max-width: 1360px !important;
+ font-family: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif !important;
+ background: {C_BG} !important;
+ color: {C_TEXT} !important;
+}}
+
+.header-block {{
+ border-bottom: 1px solid {C_BORDER};
+ padding-bottom: 1.1rem;
+ margin-bottom: 1rem;
+}}
+.header-block .eyebrow {{
+ font-size: 0.72rem;
+ letter-spacing: 0.14em;
+ font-weight: 600;
+ color: {C_ACCENT} !important;
+ margin-bottom: 0.4rem;
+}}
+.header-block h1 {{
+ font-size: 1.85rem;
+ font-weight: 700;
+ margin: 0;
+ color: {C_TEXT} !important;
+ letter-spacing: -0.02em;
+}}
+.header-block p {{
+ margin: 0.5rem 0 0;
+ color: {C_MUTED} !important;
+ font-size: 1.02rem;
+ max-width: 44rem;
+ line-height: 1.5;
+}}
+.footer-viral {{
+ color: {C_MUTED} !important;
+ font-size: 0.78rem;
+ margin-top: 1.5rem;
+}}
+.footer-viral a {{
+ color: {C_ACCENT} !important;
+}}
+
+/* Markdown / prose */
+.prose, .markdown, .md, [class*="prose"] {{
+ color: {C_TEXT} !important;
+}}
+.prose h1, .prose h2, .prose h3, .prose h4,
+.markdown h1, .markdown h2, .markdown h3, .markdown h4 {{
+ color: {C_TEXT} !important;
+}}
+.prose p, .prose li, .markdown p, .markdown li {{
+ color: {C_TEXT} !important;
+}}
+.prose strong, .markdown strong {{
+ color: #ffffff !important;
+}}
+.prose a, .markdown a {{
+ color: {C_ACCENT} !important;
+}}
+.prose code, .markdown code, code {{
+ background: {C_CODE_BG} !important;
+ color: {C_CODE_FG} !important;
+ border: 1px solid {C_BORDER} !important;
+ border-radius: 4px !important;
+ padding: 0.1rem 0.35rem !important;
+}}
+.prose pre, .markdown pre, pre {{
+ background: {C_CODE_BG} !important;
+ color: {C_TEXT} !important;
+ border: 1px solid {C_BORDER} !important;
+ border-radius: 8px !important;
+}}
+.prose pre code, .markdown pre code, pre code {{
+ background: transparent !important;
+ border: none !important;
+ color: {C_TEXT} !important;
+}}
+
+/* Guide markdown tables stay dark */
+.prose table, .markdown table {{
+ border-color: {C_BORDER} !important;
+}}
+.prose th, .markdown th {{
+ background: {C_TABLE_HEADER} !important;
+ color: {C_TABLE_TEXT} !important;
+}}
+.prose td, .markdown td {{
+ color: {C_TABLE_TEXT} !important;
+ border-color: {C_BORDER} !important;
+ background: {C_TABLE_BG} !important;
+}}
+
+/* Code / inputs */
+.cm-editor, .cm-content, .cm-line {{
+ background: {C_CODE_BG} !important;
+ color: {C_TEXT} !important;
+}}
+textarea, input {{
+ color: {C_TEXT} !important;
+}}
+
+/*
+ * CRITICAL: Gradio Dataframe + File often render white cells.
+ * Global light text then becomes invisible — force dark surfaces + light text.
+ */
+.preview-table table,
+.preview-table th,
+.preview-table td,
+.preview-table .table-wrap,
+.preview-table [data-testid="dataframe"] table,
+.preview-table [data-testid="dataframe"] th,
+.preview-table [data-testid="dataframe"] td {{
+ background: {C_TABLE_BG} !important;
+ color: {C_TABLE_TEXT} !important;
+ border-color: {C_BORDER} !important;
+}}
+.preview-table th {{
+ background: {C_TABLE_HEADER} !important;
+ font-weight: 600 !important;
+}}
+.preview-table tr:nth-child(even) td {{
+ background: #111827 !important;
+}}
+
+.download-box,
+.download-box *,
+.download-box a,
+.download-box span,
+.download-box label,
+.download-box .file-preview,
+.download-box [data-testid="file"] {{
+ color: {C_TABLE_TEXT} !important;
+}}
+.download-box .file,
+.download-box .file-preview,
+.download-box [data-testid="file"],
+.download-box .wrap,
+.download-box .or {{
+ background: {C_TABLE_BG} !important;
+ border: 1px solid {C_BORDER} !important;
+ border-radius: 8px !important;
+ color: {C_TABLE_TEXT} !important;
+}}
+.download-box a {{
+ color: {C_ACCENT} !important;
+}}
+
+/* Keep Convert / Upload actions compact — don't stretch to file-drop height */
+.action-btn button,
+.upload-action-btn button {{
+ height: 42px !important;
+ min-height: 42px !important;
+ max-height: 48px !important;
+ width: 100% !important;
+ white-space: normal !important;
+ line-height: 1.25 !important;
+ padding: 0.55rem 0.85rem !important;
+}}
+.upload-action-col {{
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: flex-start !important;
+ padding-top: 1.55rem !important; /* align with file label */
+}}
+.sql-upload-box {{
+ flex-grow: 1 !important;
+}}
+.sql-upload-box [data-testid="file"],
+.sql-upload-box .wrap,
+.sql-upload-box .upload-container {{
+ min-height: 7.5rem !important;
+}}
+
+/* Fallback for unscoped Gradio dataframe/file widgets */
+[data-testid="dataframe"] table,
+[data-testid="dataframe"] th,
+[data-testid="dataframe"] td {{
+ background: {C_TABLE_BG} !important;
+ color: {C_TABLE_TEXT} !important;
+ border-color: {C_BORDER} !important;
+}}
+[data-testid="file"] .file-preview,
+[data-testid="file"] .file {{
+ background: {C_TABLE_BG} !important;
+ color: {C_TABLE_TEXT} !important;
+ border: 1px solid {C_BORDER} !important;
+}}
+
+footer {{ display: none !important; }}
+"""
+
+
+def build_theme():
+ import gradio as gr
+
+ return gr.themes.Base(
+ primary_hue=gr.themes.colors.blue,
+ neutral_hue=gr.themes.colors.slate,
+ font=[gr.themes.GoogleFont("IBM Plex Sans"), "ui-sans-serif", "system-ui", "sans-serif"],
+ font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"],
+ ).set(
+ body_background_fill=C_BG,
+ body_background_fill_dark=C_BG,
+ background_fill_primary=C_PANEL,
+ background_fill_primary_dark=C_PANEL,
+ background_fill_secondary="#0f172a",
+ background_fill_secondary_dark="#0f172a",
+ border_color_primary=C_BORDER,
+ border_color_primary_dark=C_BORDER,
+ body_text_color=C_TEXT,
+ body_text_color_dark=C_TEXT,
+ body_text_color_subdued=C_MUTED,
+ body_text_color_subdued_dark=C_MUTED,
+ block_background_fill=C_PANEL,
+ block_background_fill_dark=C_PANEL,
+ block_label_text_color=C_MUTED,
+ block_label_text_color_dark=C_MUTED,
+ block_title_text_color=C_TEXT,
+ block_title_text_color_dark=C_TEXT,
+ input_background_fill=C_CODE_BG,
+ input_background_fill_dark=C_CODE_BG,
+ input_border_color=C_BORDER,
+ input_border_color_dark=C_BORDER,
+ input_placeholder_color=C_MUTED,
+ input_placeholder_color_dark=C_MUTED,
+ button_primary_background_fill=C_ACCENT,
+ button_primary_background_fill_dark=C_ACCENT,
+ button_primary_text_color="#ffffff",
+ button_primary_text_color_dark="#ffffff",
+ button_secondary_background_fill="#1e293b",
+ button_secondary_background_fill_dark="#1e293b",
+ button_secondary_text_color=C_TEXT,
+ button_secondary_text_color_dark=C_TEXT,
+ link_text_color=C_ACCENT,
+ link_text_color_dark=C_ACCENT,
+ code_background_fill=C_CODE_BG,
+ code_background_fill_dark=C_CODE_BG,
+ table_even_background_fill=C_TABLE_BG,
+ table_even_background_fill_dark=C_TABLE_BG,
+ table_odd_background_fill="#111827",
+ table_odd_background_fill_dark="#111827",
+ table_row_focus=C_TABLE_HEADER,
+ table_row_focus_dark=C_TABLE_HEADER,
+ table_text_color=C_TABLE_TEXT,
+ table_text_color_dark=C_TABLE_TEXT,
+ table_radius="8px",
+ )
+
+
+DARK_THEME = None
diff --git a/examples/ml_features/churn_feature_sql.sql b/examples/ml_features/churn_feature_sql.sql
new file mode 100644
index 0000000000000000000000000000000000000000..54bfbe7a466a15b61cceea07dd350337a9e5cf5a
--- /dev/null
+++ b/examples/ml_features/churn_feature_sql.sql
@@ -0,0 +1,40 @@
+-- ML / DS feature engineering SQL (Vertica legacy)
+-- Migrates to Snowflake / dbt feature mart for model training
+
+CREATE LOCAL TEMP TABLE tmp_user_events ON COMMIT PRESERVE ROWS AS
+SELECT
+ user_id,
+ event_ts,
+ event_type,
+ ZEROIFNULL(event_value) AS event_value
+FROM staging.product_events
+WHERE event_ts >= CURRENT_DATE - 90;
+
+WITH user_agg AS (
+ SELECT
+ user_id,
+ COUNT(*) AS event_count_90d,
+ COUNT(DISTINCT event_type) AS event_type_nunique,
+ SUM(event_value) AS value_sum_90d,
+ AVG(event_value) AS value_avg_90d,
+ DATEDIFF('day', MAX(event_ts), CURRENT_DATE) AS days_since_last_event
+ FROM tmp_user_events
+ GROUP BY user_id
+),
+labels AS (
+ SELECT
+ user_id,
+ CASE WHEN ZEROIFNULL(churned_flag) = 1 THEN 1 ELSE 0 END AS churn_label
+ FROM ml.churn_labels
+ WHERE label_date = CURRENT_DATE - 1
+)
+SELECT
+ a.user_id,
+ a.event_count_90d,
+ a.event_type_nunique,
+ a.value_sum_90d,
+ a.value_avg_90d,
+ a.days_since_last_event,
+ l.churn_label
+FROM user_agg a
+JOIN labels l ON a.user_id = l.user_id;
diff --git a/examples/vertica_legacy/procedures/SP_BUILD_CUSTOMER_DAILY.sql b/examples/vertica_legacy/procedures/SP_BUILD_CUSTOMER_DAILY.sql
new file mode 100644
index 0000000000000000000000000000000000000000..603f328e9ae03fd58597a5ba55d2f66728cbcef3
--- /dev/null
+++ b/examples/vertica_legacy/procedures/SP_BUILD_CUSTOMER_DAILY.sql
@@ -0,0 +1,61 @@
+-- Vertica legacy customer metrics stored procedure
+-- Migration candidate: SP_BUILD_CUSTOMER_DAILY
+
+CREATE OR REPLACE PROCEDURE SP_BUILD_CUSTOMER_DAILY(load_date DATE)
+AS $$
+BEGIN
+ -- Stage customer transactions
+ CREATE LOCAL TEMP TABLE tmp_customer_txns ON COMMIT PRESERVE ROWS AS
+ SELECT
+ customer_id,
+ order_id,
+ order_date,
+ order_amount,
+ ZEROIFNULL(discount_amount) AS discount_amount,
+ order_amount - ZEROIFNULL(discount_amount) AS net_amount
+ FROM staging.customer_transactions
+ WHERE order_date = load_date;
+
+ -- Build daily customer aggregates
+ CREATE LOCAL TEMP TABLE tmp_customer_daily ON COMMIT PRESERVE ROWS AS
+ SELECT
+ customer_id,
+ load_date AS activity_date,
+ COUNT(DISTINCT order_id) AS order_count,
+ SUM(net_amount) AS total_spend,
+ MAX(order_date) AS last_order_date,
+ AVG(net_amount) AS avg_order_value
+ FROM tmp_customer_txns
+ GROUP BY customer_id, load_date;
+
+ -- Classify customer segments
+ DELETE FROM analytics.customer_daily WHERE activity_date = load_date;
+
+ INSERT INTO analytics.customer_daily
+ SELECT
+ customer_id,
+ activity_date,
+ order_count,
+ total_spend,
+ last_order_date,
+ avg_order_value,
+ CASE
+ WHEN order_count >= 5
+ AND last_order_date >= load_date - 90
+ AND total_spend > 500
+ THEN 'HIGH_VALUE'
+ WHEN order_count >= 2
+ AND total_spend > 100
+ THEN 'MEDIUM_VALUE'
+ ELSE 'STANDARD'
+ END AS customer_segment,
+ CASE
+ WHEN order_count = 1 THEN 'NEW'
+ WHEN last_order_date >= load_date - 30 THEN 'ACTIVE'
+ ELSE 'DORMANT'
+ END AS activity_status
+ FROM tmp_customer_daily;
+
+ COMMIT;
+END;
+$$;
diff --git a/examples/vertica_legacy/queries/customer_lifetime_value.sql b/examples/vertica_legacy/queries/customer_lifetime_value.sql
new file mode 100644
index 0000000000000000000000000000000000000000..8db4f567842908265ca22d6ca0379e941398ae1a
--- /dev/null
+++ b/examples/vertica_legacy/queries/customer_lifetime_value.sql
@@ -0,0 +1,60 @@
+-- Legacy customer lifetime value calculation
+-- 4 CTEs, multiple joins, window functions
+
+WITH customer_orders AS (
+ SELECT
+ customer_id,
+ order_id,
+ order_date,
+ order_amount - ZEROIFNULL(discount_amount) AS net_amount
+ FROM staging.customer_transactions
+ WHERE order_date >= CURRENT_DATE - 365
+),
+customer_refunds AS (
+ SELECT
+ customer_id,
+ SUM(refund_amount) AS total_refunds
+ FROM staging.refunds
+ WHERE refund_date >= CURRENT_DATE - 365
+ GROUP BY customer_id
+),
+customer_summary AS (
+ SELECT
+ co.customer_id,
+ COUNT(DISTINCT co.order_id) AS order_count,
+ SUM(co.net_amount) AS gross_spend,
+ ZEROIFNULL(cr.total_refunds) AS total_refunds,
+ SUM(co.net_amount) - ZEROIFNULL(cr.total_refunds) AS net_spend,
+ MAX(co.order_date) AS last_order_date,
+ MIN(co.order_date) AS first_order_date
+ FROM customer_orders co
+ LEFT JOIN customer_refunds cr
+ ON co.customer_id = cr.customer_id
+ GROUP BY co.customer_id, cr.total_refunds
+),
+customer_lifetime AS (
+ SELECT
+ cs.*,
+ DATEDIFF('day', cs.first_order_date, cs.last_order_date) AS customer_lifespan_days,
+ CASE
+ WHEN cs.order_count >= 10 AND cs.net_spend > 1000 THEN 'PLATINUM'
+ WHEN cs.order_count >= 5 AND cs.net_spend > 500 THEN 'GOLD'
+ WHEN cs.order_count >= 2 THEN 'SILVER'
+ ELSE 'BRONZE'
+ END AS loyalty_tier
+ FROM customer_summary cs
+)
+SELECT
+ cl.customer_id,
+ c.customer_name,
+ cl.order_count,
+ cl.gross_spend,
+ cl.total_refunds,
+ cl.net_spend AS customer_lifetime_value,
+ cl.loyalty_tier,
+ cl.last_order_date,
+ cl.customer_lifespan_days
+FROM customer_lifetime cl
+JOIN staging.customers c
+ ON cl.customer_id = c.customer_id
+ORDER BY cl.net_spend DESC;
diff --git a/examples/vertica_legacy/tables/customer_tables.sql b/examples/vertica_legacy/tables/customer_tables.sql
new file mode 100644
index 0000000000000000000000000000000000000000..1a85e367ed8b6117618d03fd3f08570daeccb816
--- /dev/null
+++ b/examples/vertica_legacy/tables/customer_tables.sql
@@ -0,0 +1,37 @@
+-- Customer transactions staging table
+CREATE TABLE staging.customer_transactions (
+ customer_id INT NOT NULL,
+ order_id INT NOT NULL,
+ order_date DATE NOT NULL,
+ order_amount DECIMAL(18,2),
+ discount_amount DECIMAL(18,2),
+ payment_method VARCHAR(50),
+ created_at TIMESTAMP DEFAULT SYSDATE
+)
+SEGMENTED BY HASH(customer_id) ALL NODES
+ORDER BY customer_id, order_date;
+
+-- Customers dimension
+CREATE TABLE staging.customers (
+ customer_id INT NOT NULL PRIMARY KEY,
+ customer_name VARCHAR(200),
+ email VARCHAR(200),
+ registration_date DATE,
+ country_code VARCHAR(3),
+ is_active INT DEFAULT 1
+)
+SEGMENTED BY HASH(customer_id) ALL NODES;
+
+-- Customer daily fact table
+CREATE TABLE analytics.customer_daily (
+ customer_id INT NOT NULL,
+ activity_date DATE NOT NULL,
+ order_count INT,
+ total_spend DECIMAL(18,2),
+ last_order_date DATE,
+ avg_order_value DECIMAL(18,2),
+ customer_segment VARCHAR(20),
+ activity_status VARCHAR(20)
+)
+SEGMENTED BY HASH(customer_id) ALL NODES
+ORDER BY activity_date, customer_id;
diff --git a/examples/vertica_legacy/tables/refunds.sql b/examples/vertica_legacy/tables/refunds.sql
new file mode 100644
index 0000000000000000000000000000000000000000..fe96160003df5bd144967026b95b240e1869d3c2
--- /dev/null
+++ b/examples/vertica_legacy/tables/refunds.sql
@@ -0,0 +1,12 @@
+-- Refunds staging table (referenced by customer_lifetime_value query)
+CREATE TABLE staging.refunds (
+ refund_id INT NOT NULL,
+ customer_id INT NOT NULL,
+ order_id INT,
+ refund_amount DECIMAL(18,2) NOT NULL,
+ refund_date DATE NOT NULL,
+ reason_code VARCHAR(50),
+ created_at TIMESTAMP DEFAULT SYSDATE
+)
+SEGMENTED BY HASH(customer_id) ALL NODES
+ORDER BY refund_date, customer_id;
diff --git a/examples/vertica_legacy/views/V_CUSTOMER_METRICS.sql b/examples/vertica_legacy/views/V_CUSTOMER_METRICS.sql
new file mode 100644
index 0000000000000000000000000000000000000000..b7b8d4e7fb57298e1b8533f929fb49809d1c01c3
--- /dev/null
+++ b/examples/vertica_legacy/views/V_CUSTOMER_METRICS.sql
@@ -0,0 +1,25 @@
+-- Customer metrics view built on daily aggregates
+CREATE OR REPLACE VIEW analytics.v_customer_metrics AS
+SELECT
+ cd.customer_id,
+ cd.activity_date,
+ cd.order_count,
+ cd.total_spend,
+ cd.customer_segment,
+ cd.activity_status,
+ c.customer_name,
+ c.registration_date,
+ DATEDIFF('day', c.registration_date, cd.activity_date) AS customer_tenure_days,
+ SUM(cd.total_spend) OVER (
+ PARTITION BY cd.customer_id
+ ORDER BY cd.activity_date
+ ROWS UNBOUNDED PRECEDING
+ ) AS lifetime_value,
+ ROW_NUMBER() OVER (
+ PARTITION BY cd.customer_id
+ ORDER BY cd.activity_date DESC
+ ) AS recency_rank
+FROM analytics.customer_daily cd
+JOIN staging.customers c
+ ON cd.customer_id = c.customer_id
+WHERE cd.activity_date >= CURRENT_DATE - 365;
diff --git a/model/README.md b/model/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..7c864b254f6ed812fe91fa295040452a3dea8fc0
--- /dev/null
+++ b/model/README.md
@@ -0,0 +1,59 @@
+---
+language: en
+license: apache-2.0
+library_name: morphsql
+pipeline_tag: text-classification
+tags:
+ - code
+ - sql
+ - agent
+ - text-generation
+ - text-classification
+ - rag
+ - sklearn
+ - migration
+ - snowflake
+ - dbt
+datasets:
+ - dgvj-work/vertica-snowflake-pairs
+metrics:
+ - accuracy
+---
+
+# MorphSQL risk classifier
+
+Small **TF-IDF + LogisticRegression** model that scores SQL migration risk (`low` / `medium` / `high`).
+
+Used by **MorphSQL** ([Space](https://huggingface.co/spaces/dgvj-work/morphsql)).
+Python package import: `morphsql` (PyPI: `morphsql`).
+
+## Files
+| File | Purpose |
+|------|---------|
+| `risk_classifier.joblib` | sklearn pipeline |
+| `rewrite_vocabulary.json` | common SQL rewrite map |
+| `config.json` | dialects + metadata |
+
+## Usage
+
+```python
+from huggingface_hub import hf_hub_download
+import joblib
+
+path = hf_hub_download(repo_id="dgvj-work/morphsql", filename="risk_classifier.joblib")
+clf = joblib.load(path)
+print(clf.predict(["EXECUTE IMMEDIATE 'SELECT 1'"]))
+```
+
+```python
+from morphsql.ai import pipeline
+print(pipeline("sql-risk-classification")("CREATE PROCEDURE p AS BEGIN NULL; END;"))
+print(pipeline("sql-migration")("SELECT ZEROIFNULL(a) FROM t"))
+```
+
+## Links
+- Space: https://huggingface.co/spaces/dgvj-work/morphsql
+- Dataset: https://huggingface.co/datasets/dgvj-work/vertica-snowflake-pairs
+- GitHub: https://github.com/dgvj-work/morphsql
+
+Author: Digvijay Waghela · Apache-2.0
diff --git a/model/config.json b/model/config.json
new file mode 100644
index 0000000000000000000000000000000000000000..de1fb8b6644064ce1c26f45858f634222f9f41fb
--- /dev/null
+++ b/model/config.json
@@ -0,0 +1,34 @@
+{
+ "model_type": "morphsql-agent",
+ "product_name": "MorphSQL",
+ "task": "text-classification",
+ "labels": [
+ "low",
+ "medium",
+ "high"
+ ],
+ "version": "0.4.0",
+ "description": "TF-IDF + LogisticRegression risk classifier for MorphSQL",
+ "vertica_syntax_patterns": [
+ "\\bSEGMENTED\\s+BY\\s+HASH\\s*\\([^)]+\\)\\s*ALL\\s*NODES",
+ "\\bENCODED\\s+BY\\s+[^;\\n]+",
+ "\\bINCLUDE\\s+SCHEMA\\s+PRIVILEGES\\b",
+ "\\bON\\s+COMMIT\\s+PRESERVE\\s+ROWS\\b",
+ "\\bPROJECTION\\s+\\w+\\b",
+ "(CREATE\\s+(?:OR\\s+REPLACE\\s+)?(?:LOCAL\\s+TEMP|TEMP|TEMPORARY)?\\s*TABLE\\s+[\\w.]+\\s*\\([^;]+\\))\\s*ORDER\\s+BY[^;]+"
+ ],
+ "supported_sources": [
+ "vertica",
+ "oracle",
+ "redshift",
+ "bigquery",
+ "snowflake"
+ ],
+ "supported_targets": [
+ "pandas",
+ "pyspark",
+ "snowflake",
+ "dbt-snowflake",
+ "bigquery"
+ ]
+}
diff --git a/model/rewrite_vocabulary.json b/model/rewrite_vocabulary.json
new file mode 100644
index 0000000000000000000000000000000000000000..b36211cb003a2b325e7276ee6fe5164de7d0e711
--- /dev/null
+++ b/model/rewrite_vocabulary.json
@@ -0,0 +1,12 @@
+{
+ "ZEROIFNULL": "COALESCE(expr, 0)",
+ "NVL": "COALESCE",
+ "ISNULL": "COALESCE",
+ "STRING_AGG": "LISTAGG",
+ "APPROXIMATE_COUNT_DISTINCT": "APPROX_COUNT_DISTINCT",
+ "GETDATE": "CURRENT_TIMESTAMP()",
+ "SYSDATE": "CURRENT_TIMESTAMP()",
+ "IFNULL": "COALESCE",
+ "IFF": "IF",
+ "LISTAGG": "STRING_AGG"
+}
\ No newline at end of file
diff --git a/model/risk_classifier.joblib b/model/risk_classifier.joblib
new file mode 100644
index 0000000000000000000000000000000000000000..b85e84cdee9c200b049157b4563fbaf0155bb554
--- /dev/null
+++ b/model/risk_classifier.joblib
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:28fed7e2d246113aa81d9c51590e57713c7ec5efd2e9e54ad159118406bad47e
+size 70084
diff --git a/morphsql/__init__.py b/morphsql/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3037e923bf0eb3212a6c9b70715fb6d75df90e3
--- /dev/null
+++ b/morphsql/__init__.py
@@ -0,0 +1,22 @@
+"""MorphSQL — AI SQL Migration Agent."""
+
+__version__ = "0.4.0"
+__product_name__ = "MorphSQL"
+
+from morphsql.models import (
+ Dialect,
+ MigrationObject,
+ MigrationReport,
+ ObjectType,
+ RiskLevel,
+)
+
+__all__ = [
+ "__version__",
+ "__product_name__",
+ "Dialect",
+ "MigrationObject",
+ "MigrationReport",
+ "ObjectType",
+ "RiskLevel",
+]
diff --git a/morphsql/ai/__init__.py b/morphsql/ai/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e14b265bf7dadcb685abc30e4aa8558e774b7992
--- /dev/null
+++ b/morphsql/ai/__init__.py
@@ -0,0 +1,14 @@
+"""AI package — agent, risk model, HF pipeline."""
+
+from morphsql.ai.agent import SQLMigrationAgent
+from morphsql.ai.pipeline import migrate_sql, pipeline
+from morphsql.ai.risk_model import MigrationRiskModel, get_risk_model, train_and_save
+
+__all__ = [
+ "SQLMigrationAgent",
+ "pipeline",
+ "migrate_sql",
+ "MigrationRiskModel",
+ "get_risk_model",
+ "train_and_save",
+]
diff --git a/morphsql/ai/agent.py b/morphsql/ai/agent.py
new file mode 100644
index 0000000000000000000000000000000000000000..148f64ba51c774ed6de214479e6631595d4f334d
--- /dev/null
+++ b/morphsql/ai/agent.py
@@ -0,0 +1,251 @@
+"""Tool-calling SQL Migration Agent (AI-first interface)."""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import asdict, dataclass, field
+from typing import Any, Callable
+
+from morphsql.ai.risk_model import get_risk_model
+from morphsql.dbt_generator.decomposer import decompose_to_dbt, format_dbt_project, is_dbt_target
+from morphsql.intelligence.rag import get_rag
+from morphsql.models import Dialect, MigrationObject, ObjectType
+from morphsql.translator.engine import translate_sql
+from morphsql.translator.pandas_codegen import is_pandas_target
+from morphsql.translator.pyspark_codegen import is_pyspark_target
+
+
+@dataclass
+class AgentMessage:
+ role: str
+ content: str
+ tool: str | None = None
+ data: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class AgentResult:
+ answer: str
+ converted_sql: str = ""
+ risk: dict[str, Any] = field(default_factory=dict)
+ tools_used: list[str] = field(default_factory=list)
+ messages: list[AgentMessage] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "answer": self.answer,
+ "converted_sql": self.converted_sql,
+ "risk": self.risk,
+ "tools_used": self.tools_used,
+ "messages": [asdict(m) for m in self.messages],
+ }
+
+
+class SQLMigrationAgent:
+ """
+ AI agent that plans tool calls for SQL migration.
+
+ Tools: convert_sql, predict_risk, retrieve_behavior, emit_dbt, explain
+ """
+
+ def __init__(self):
+ self.risk_model = get_risk_model()
+ self.rag = get_rag()
+
+ def run(
+ self,
+ prompt: str,
+ sql: str = "",
+ source: str = "snowflake",
+ target: str = "pandas",
+ ) -> AgentResult:
+ prompt = (prompt or "").strip()
+ sql = (sql or "").strip()
+ messages: list[AgentMessage] = [
+ AgentMessage(role="user", content=prompt or "Migrate this SQL", data={"sql": sql})
+ ]
+ tools_used: list[str] = []
+ converted = ""
+ risk: dict[str, Any] = {}
+
+ intent = self._detect_intent(prompt, sql)
+
+ if sql and intent in {"convert", "migrate", "dbt", "full", "auto"}:
+ converted, conf, auto, review = self._tool_convert(sql, source, target)
+ tools_used.append("convert_sql")
+ messages.append(
+ AgentMessage(
+ role="tool",
+ tool="convert_sql",
+ content=f"Converted with {conf:.0f}% confidence",
+ data={"auto": auto, "review": review, "sql": converted},
+ )
+ )
+
+ if sql and intent in {"risk", "full", "auto", "convert", "migrate", "dbt"}:
+ risk = self._tool_risk(sql)
+ tools_used.append("predict_risk")
+ messages.append(
+ AgentMessage(
+ role="tool",
+ tool="predict_risk",
+ content=f"Risk={risk['label']} ({risk['score']:.2f})",
+ data=risk,
+ )
+ )
+
+ if intent in {"rag", "behavior", "full", "auto", "explain"} or (
+ not sql and prompt
+ ):
+ rag_q = prompt or sql
+ hits = self.rag.retrieve(rag_q, top_k=4, source=source, target=target)
+ tools_used.append("retrieve_behavior")
+ messages.append(
+ AgentMessage(
+ role="tool",
+ tool="retrieve_behavior",
+ content=f"Retrieved {len(hits)} behavior docs",
+ data={"hits": [asdict(h) if hasattr(h, "__dataclass_fields__") else h.__dict__ for h in hits]},
+ )
+ )
+
+ dbt_out = ""
+ if sql and (intent == "dbt" or is_dbt_target(target)):
+ dbt_out = self._tool_dbt(sql, converted or sql, source)
+ tools_used.append("emit_dbt")
+ converted = dbt_out or converted
+ messages.append(
+ AgentMessage(
+ role="tool",
+ tool="emit_dbt",
+ content="Emitted dbt project scaffold",
+ data={"preview": dbt_out[:500]},
+ )
+ )
+
+ answer = self._compose_answer(prompt, source, target, converted, risk, messages, intent)
+ messages.append(AgentMessage(role="assistant", content=answer))
+ return AgentResult(
+ answer=answer,
+ converted_sql=converted,
+ risk=risk,
+ tools_used=list(dict.fromkeys(tools_used)),
+ messages=messages,
+ )
+
+ def _detect_intent(self, prompt: str, sql: str) -> str:
+ p = (prompt or "").lower()
+ wants_convert = bool(
+ re.search(r"\bconvert\b|\bmigrate\b|\btranslate\b|\btransform\b", p) or sql
+ )
+ if re.search(r"\bpandas\b|\bpyspark\b|\bspark\b|\bdataframe\b|\bpython\b", p):
+ # still convert; target dropdown should match pandas/pyspark
+ return "full"
+ if re.search(r"\bdbt\b|feature mart", p):
+ return "dbt"
+ if wants_convert and re.search(r"\brisk\b|complex|score|explain|null|behavior", p):
+ return "full"
+ if re.search(r"\brisk\b|complex|score", p) and not wants_convert:
+ return "risk"
+ if re.search(r"\bnull\b|timezone|behavior|rag|difference", p) and not sql:
+ return "rag"
+ if re.search(r"\bexplain\b|why|how", p) and not wants_convert:
+ return "explain"
+ if wants_convert:
+ return "full"
+ return "auto" if sql else "rag"
+
+ def _tool_convert(self, sql: str, source: str, target: str):
+ source_d = Dialect(source)
+ if is_pandas_target(target):
+ target_d = Dialect.PANDAS
+ elif is_pyspark_target(target):
+ target_d = Dialect.PYSPARK
+ elif is_dbt_target(target):
+ target_d = Dialect.SNOWFLAKE
+ else:
+ target_d = Dialect(target)
+ return translate_sql(sql, source_d, target_d)
+
+ def _tool_risk(self, sql: str) -> dict[str, Any]:
+ return self.risk_model.predict(sql)
+
+ def _tool_dbt(self, source_sql: str, target_sql: str, source: str) -> str:
+ obj = MigrationObject(
+ name="agent_object",
+ object_type=ObjectType.STORED_PROCEDURE
+ if "PROCEDURE" in source_sql.upper()
+ else ObjectType.SQL_SCRIPT,
+ source_sql=source_sql,
+ target_sql=target_sql,
+ )
+ files = decompose_to_dbt(obj, Dialect(source), project_name="ai_agent_project")
+ return format_dbt_project(files, max_files=10)
+
+ def _compose_answer(
+ self,
+ prompt: str,
+ source: str,
+ target: str,
+ converted: str,
+ risk: dict,
+ messages: list[AgentMessage],
+ intent: str,
+ ) -> str:
+ lines = [
+ f"**MorphSQL Agent** · `{source}` → `{target}`",
+ "",
+ ]
+ if risk:
+ lines.append(
+ f"**Risk model:** `{risk.get('label')}` "
+ f"(score={risk.get('score', 0):.2f})"
+ )
+ scores = risk.get("scores") or {}
+ if scores:
+ parts = ", ".join(f"{k}={v:.2f}" for k, v in scores.items())
+ lines.append(f"- class probs: {parts}")
+ lines.append("")
+ for m in messages:
+ if m.tool == "retrieve_behavior" and m.data.get("hits"):
+ lines.append("**Behavior RAG**")
+ for h in m.data["hits"][:3]:
+ name = h.get("name", "?")
+ rec = h.get("recommendation", "")
+ lines.append(f"- **{name}**: {rec}")
+ lines.append("")
+ if m.tool == "convert_sql" and m.data.get("auto"):
+ lines.append("**Transforms**")
+ for a in m.data["auto"][:8]:
+ lines.append(f"- {a}")
+ lines.append("")
+ if converted:
+ lines.append("**Converted output ready** in the panel (SQL or dbt project).")
+ elif not converted and intent == "rag":
+ lines.append("Ask a conversion question with SQL pasted, or use Convert mode.")
+ if prompt:
+ lines.append("")
+ lines.append(f"_User intent:_ {prompt}")
+ return "\n".join(lines)
+
+
+def chat_agent(
+ message: str,
+ history: list | None,
+ sql: str,
+ source: str,
+ target: str,
+) -> tuple[list, str, str, str]:
+ """Gradio chat adapter: returns history, cleared input, converted sql, risk badge."""
+ history = list(history or [])
+ agent = SQLMigrationAgent()
+ result = agent.run(message, sql=sql, source=source, target=target)
+ history.append({"role": "user", "content": message or "(run tools on SQL)"})
+ history.append({"role": "assistant", "content": result.answer})
+ badge = ""
+ if result.risk:
+ badge = f"{result.risk.get('label', '?')} · {result.risk.get('score', 0):.2f}"
+ tools = ", ".join(result.tools_used) or "none"
+ badge = f"{badge} · tools: {tools}".strip(" ·")
+ return history, "", result.converted_sql, badge
diff --git a/morphsql/ai/pipeline.py b/morphsql/ai/pipeline.py
new file mode 100644
index 0000000000000000000000000000000000000000..0a876c6bdca5994ead0d4ffcc58863a8c083b1d5
--- /dev/null
+++ b/morphsql/ai/pipeline.py
@@ -0,0 +1,82 @@
+"""Hugging Face-style pipeline API for SQL migration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from morphsql.ai.agent import SQLMigrationAgent
+from morphsql.ai.risk_model import get_risk_model
+from morphsql.models import Dialect
+from morphsql.translator.engine import translate_sql
+from morphsql.translator.pandas_codegen import is_pandas_target
+from morphsql.translator.pyspark_codegen import is_pyspark_target
+
+
+def pipeline(task: str = "sql-migration", **kwargs):
+ """
+ Mimic transformers.pipeline entrypoint for Hub users.
+
+ Examples:
+ pipe = pipeline("sql-migration")
+ pipe("SELECT COALESCE(a, 0) FROM t", source="snowflake", target="pandas")
+
+ pipe = pipeline("sql-risk-classification")
+ pipe("CREATE PROCEDURE ...")
+ """
+ task = (task or "sql-migration").lower().replace("_", "-")
+ if task in {"sql-migration", "sql-translation", "text2sql-migration", "sql-to-pandas"}:
+ return SQLMigrationPipeline(**kwargs)
+ if task in {"sql-risk-classification", "text-classification", "risk"}:
+ return SQLRiskPipeline(**kwargs)
+ raise ValueError(
+ f"Unknown task '{task}'. Use 'sql-migration' or 'sql-risk-classification'."
+ )
+
+
+class SQLMigrationPipeline:
+ def __init__(self, source: str = "snowflake", target: str = "pandas"):
+ self.source = source
+ self.target = target
+ self.agent = SQLMigrationAgent()
+
+ def __call__(
+ self,
+ sql: str,
+ source: str | None = None,
+ target: str | None = None,
+ prompt: str = "Convert and explain",
+ **_: Any,
+ ) -> dict[str, Any]:
+ source = source or self.source
+ target = target or self.target
+ result = self.agent.run(prompt, sql=sql, source=source, target=target)
+ return result.to_dict()
+
+
+class SQLRiskPipeline:
+ def __init__(self):
+ self.model = get_risk_model()
+
+ def __call__(self, sql: str, **_: Any) -> dict[str, Any]:
+ return self.model.predict(sql)
+
+
+def migrate_sql(sql: str, source: str = "snowflake", target: str = "pandas") -> dict[str, Any]:
+ """One-liner helper used in model card examples."""
+ if is_pandas_target(target):
+ out_target = Dialect.PANDAS
+ elif is_pyspark_target(target):
+ out_target = Dialect.PYSPARK
+ elif target == "dbt-snowflake":
+ out_target = Dialect.SNOWFLAKE
+ else:
+ out_target = Dialect(target)
+ out_sql, conf, auto, review = translate_sql(sql, Dialect(source), out_target)
+ risk = get_risk_model().predict(sql)
+ return {
+ "converted_sql": out_sql,
+ "confidence": conf,
+ "transforms": auto,
+ "review": review,
+ "risk": risk,
+ }
diff --git a/morphsql/ai/risk_model.py b/morphsql/ai/risk_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8b20997e7e449ed2c3194236a33259793582698
--- /dev/null
+++ b/morphsql/ai/risk_model.py
@@ -0,0 +1,195 @@
+"""Train and load the SQL migration risk classifier (downloadable HF model artifact)."""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+import joblib
+import numpy as np
+from sklearn.feature_extraction.text import TfidfVectorizer
+from sklearn.linear_model import LogisticRegression
+from sklearn.pipeline import Pipeline as SkPipeline
+
+MODEL_DIR = Path(__file__).resolve().parent.parent.parent / "model"
+MODEL_PATH = MODEL_DIR / "risk_classifier.joblib"
+CONFIG_PATH = MODEL_DIR / "config.json"
+
+LABELS = ["low", "medium", "high"]
+
+
+def _synthetic_corpus() -> tuple[list[str], list[str]]:
+ """Build labeled SQL snippets for a lightweight risk classifier."""
+ texts: list[str] = []
+ labels: list[str] = []
+
+ low = [
+ "SELECT a, b FROM t",
+ "SELECT COUNT(*) FROM orders WHERE dt = CURRENT_DATE",
+ "SELECT COALESCE(x, 0) FROM staging.facts",
+ "CREATE VIEW v AS SELECT id FROM customers",
+ "SELECT * FROM users LIMIT 100",
+ ]
+ medium = [
+ "SELECT ZEROIFNULL(amount) FROM t WHERE d >= CURRENT_DATE - 30",
+ "WITH cte AS (SELECT * FROM a JOIN b ON a.id=b.id) SELECT * FROM cte",
+ "SELECT STRING_AGG(name, ',') FROM users GROUP BY dept",
+ "CREATE LOCAL TEMP TABLE tmp AS SELECT * FROM staging.x",
+ "SELECT NVL(a,0), SYSDATE FROM dual",
+ ]
+ high = [
+ "CREATE OR REPLACE PROCEDURE p(load_date DATE) AS $$ BEGIN "
+ "EXECUTE IMMEDIATE 'SELECT 1'; OPEN cur FOR SELECT * FROM t; "
+ "EXCEPTION WHEN OTHERS THEN NULL; END; $$;",
+ "CREATE PACKAGE pkg AS PROCEDURE run; END;",
+ "MERGE INTO tgt USING src ON tgt.id=src.id WHEN MATCHED THEN UPDATE "
+ "WHEN NOT MATCHED THEN INSERT VALUES (src.id)",
+ "SELECT * FROM t START WITH id=1 CONNECT BY PRIOR id = parent_id",
+ "CREATE OR REPLACE PROCEDURE sp AS $$ BEGIN "
+ "FOR r IN cur LOOP EXECUTE IMMEDIATE dyn; END LOOP; END; $$;",
+ ]
+
+ for i in range(40):
+ for s in low:
+ texts.append(f"{s} -- sample {i}")
+ labels.append("low")
+ for s in medium:
+ texts.append(f"{s} /* var {i} */")
+ labels.append("medium")
+ for s in high:
+ texts.append(f"{s}\n-- complexity {i}")
+ labels.append("high")
+
+ # Extra keyword-heavy highs
+ for kw in ("CURSOR", "EXECUTE IMMEDIATE", "CONNECT BY", "PACKAGE", "EXCEPTION WHEN"):
+ for i in range(20):
+ texts.append(f"SELECT 1 FROM t -- {kw} marker {i}")
+ labels.append("high" if kw != "SELECT" else "low")
+
+ return texts, labels
+
+
+def train_and_save(model_dir: Path | None = None, *, force: bool = False) -> Path:
+ global _risk_singleton
+ model_dir = Path(model_dir or MODEL_DIR)
+ model_dir.mkdir(parents=True, exist_ok=True)
+ out = model_dir / "risk_classifier.joblib"
+ if out.exists() and not force:
+ return out
+ texts, labels = _synthetic_corpus()
+ clf = SkPipeline(
+ steps=[
+ (
+ "tfidf",
+ TfidfVectorizer(
+ analyzer="char_wb",
+ ngram_range=(3, 5),
+ min_df=2,
+ max_features=12000,
+ ),
+ ),
+ (
+ "lr",
+ LogisticRegression(
+ max_iter=400,
+ class_weight="balanced",
+ ),
+ ),
+ ]
+ )
+ clf.fit(texts, labels)
+ joblib.dump(clf, model_dir / "risk_classifier.joblib")
+
+ # Export rewrite rule “weights” as a downloadable JSON artifact
+ from morphsql.translator.engine import VERTICA_SYNTAX_REPLACEMENTS
+
+ rules = {
+ "model_type": "morphsql-agent",
+ "product_name": "MorphSQL",
+ "task": "text-classification",
+ "labels": LABELS,
+ "version": "0.4.0",
+ "description": "TF-IDF + LogisticRegression risk classifier for MorphSQL",
+ "vertica_syntax_patterns": [p for p, _ in VERTICA_SYNTAX_REPLACEMENTS],
+ "supported_sources": ["vertica", "oracle", "redshift", "bigquery", "snowflake"],
+ "supported_targets": ["pandas", "pyspark", "snowflake", "dbt-snowflake", "bigquery"],
+ }
+ (model_dir / "config.json").write_text(json.dumps(rules, indent=2), encoding="utf-8")
+ (model_dir / "rewrite_vocabulary.json").write_text(
+ json.dumps(
+ {
+ "ZEROIFNULL": "COALESCE(expr, 0)",
+ "NVL": "COALESCE",
+ "ISNULL": "COALESCE",
+ "STRING_AGG": "LISTAGG",
+ "APPROXIMATE_COUNT_DISTINCT": "APPROX_COUNT_DISTINCT",
+ "GETDATE": "CURRENT_TIMESTAMP()",
+ "SYSDATE": "CURRENT_TIMESTAMP()",
+ "IFNULL": "COALESCE",
+ "IFF": "IF",
+ "LISTAGG": "STRING_AGG",
+ },
+ indent=2,
+ ),
+ encoding="utf-8",
+ )
+ _risk_singleton = None
+ return model_dir / "risk_classifier.joblib"
+
+
+def _load_compatible_model(path: Path):
+ """Load the classifier, retraining when sklearn pickle versions disagree."""
+ if not path.exists():
+ train_and_save(path.parent)
+ try:
+ model = joblib.load(path)
+ model.predict_proba(["SELECT 1"])
+ return model
+ except Exception:
+ train_and_save(path.parent, force=True)
+ return joblib.load(path)
+
+
+class MigrationRiskModel:
+ """Loadable AI risk head used by the agent and HF model repo."""
+
+ def __init__(self, model_path: Path | None = None):
+ path = Path(model_path or MODEL_PATH)
+ self.model = _load_compatible_model(path)
+ self.labels = LABELS
+
+ def predict(self, sql: str) -> dict:
+ sql = sql or ""
+ proba = self.model.predict_proba([sql])[0]
+ classes = list(self.model.classes_)
+ idx = int(np.argmax(proba))
+ label = classes[idx]
+ # Heuristic boost for known hard patterns
+ hard = bool(
+ re.search(
+ r"EXECUTE\s+IMMEDIATE|\bCURSOR\b|CONNECT\s+BY|\bPACKAGE\b|EXCEPTION\s+WHEN",
+ sql,
+ re.I,
+ )
+ )
+ if hard and label != "high":
+ label = "high"
+ proba = np.array([0.05, 0.15, 0.80])
+ classes = LABELS
+ idx = 2
+ return {
+ "label": str(label),
+ "score": float(proba[idx]),
+ "scores": {str(c): float(p) for c, p in zip(classes, proba)},
+ }
+
+
+_risk_singleton: MigrationRiskModel | None = None
+
+
+def get_risk_model() -> MigrationRiskModel:
+ global _risk_singleton
+ if _risk_singleton is None:
+ _risk_singleton = MigrationRiskModel()
+ return _risk_singleton
diff --git a/morphsql/assistant/__init__.py b/morphsql/assistant/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe456875f53deeb740d9f830a1c1f26605e98795
--- /dev/null
+++ b/morphsql/assistant/__init__.py
@@ -0,0 +1,5 @@
+"""Migration copilot assistant."""
+
+from morphsql.assistant.copilot import MigrationCopilot
+
+__all__ = ["MigrationCopilot"]
diff --git a/morphsql/assistant/copilot.py b/morphsql/assistant/copilot.py
new file mode 100644
index 0000000000000000000000000000000000000000..4c8d5ffcf4365d1c2d57616660564c485c4807f2
--- /dev/null
+++ b/morphsql/assistant/copilot.py
@@ -0,0 +1,370 @@
+"""LLM-powered migration copilot with Hugging Face Inference API."""
+
+from __future__ import annotations
+
+import os
+import re
+
+from morphsql.knowledge.behavior import BEHAVIOR_DIFFERENCES
+from morphsql.models import MigrationReport
+from morphsql.risk.scorer import recommend_workload_action
+
+DEFAULT_MODEL = os.getenv(
+ "MORPHSQL_MODEL",
+ os.getenv("MIGRATIONIQ_MODEL", "Qwen/Qwen2.5-3B-Instruct"),
+)
+
+SYSTEM_PROMPT = """You are MorphSQL Copilot, an expert data platform migration advisor.
+
+You help data engineers plan warehouse migrations (Vertica, Oracle, Redshift → Snowflake, dbt, BigQuery).
+
+Expertise:
+- Repository discovery, dependency lineage, impact analysis
+- Migration risk scoring and workload rationalization (migrate / rewrite / retire)
+- Hybrid SQL translation and stored-procedure → dbt decomposition
+- Semantic validation, reconciliation testing, platform behavior differences
+- Cutover planning, incremental strategies, cost estimation
+
+Rules:
+- Answer like a senior migration consultant: clear, structured, actionable.
+- Ground answers in the migration context when provided.
+- Never claim automatic correctness for procedural SQL, dynamic SQL, or cursors.
+- Prefer numbered steps and short tables over fluff.
+"""
+
+
+class MigrationCopilot:
+ """Grounded migration copilot using HF Inference API with structured fallback."""
+
+ def __init__(self, model: str | None = None):
+ self.model = model or DEFAULT_MODEL
+ self._client = None
+ self._client_checked = False
+
+ @property
+ def client(self):
+ if self._client_checked:
+ return self._client
+ self._client_checked = True
+ token = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
+ if not token:
+ self._client = False
+ return self._client
+ try:
+ from huggingface_hub import InferenceClient
+
+ self._client = InferenceClient(token=token)
+ except Exception:
+ self._client = False
+ return self._client
+
+ def build_context(
+ self,
+ report: MigrationReport | None = None,
+ sql_snippet: str = "",
+ source: str = "vertica",
+ target: str = "snowflake",
+ ) -> str:
+ parts = [f"Migration route: {source} → {target}"]
+
+ if sql_snippet.strip():
+ snippet = sql_snippet.strip()[:3000]
+ parts.append(f"\n### Active SQL (truncated)\n```sql\n{snippet}\n```")
+
+ if report is None:
+ parts.append("\nNo repository scan loaded.")
+ return "\n".join(parts)
+
+ d = report.dashboard
+ parts.append(
+ f"\n### Repository scan: {report.repository_path}\n"
+ f"- Objects discovered: {d.total_objects}\n"
+ f"- Auto-migratable: {d.auto_migratable}\n"
+ f"- Needs review: {d.requires_review}\n"
+ f"- Manual redesign: {d.requires_redesign}\n"
+ f"- Retire/consolidate: {d.recommended_retirement}\n"
+ f"- Avg risk score: {d.migration_risk_score:.0f}/100\n"
+ f"- Lineage coverage: {d.lineage_coverage_pct:.0f}%\n"
+ f"- Validation pass: {d.validation_passed_pct:.0f}%\n"
+ f"- Est. annual savings: ${d.estimated_annual_savings_usd[0]:,.0f}–"
+ f"${d.estimated_annual_savings_usd[1]:,.0f}"
+ )
+
+ if report.objects:
+ parts.append("\n### Top objects by complexity")
+ for obj in sorted(report.objects, key=lambda o: o.complexity_score, reverse=True)[:10]:
+ action = recommend_workload_action(obj)
+ parts.append(
+ f"- **{obj.name}** ({obj.object_type.value}): "
+ f"complexity {obj.complexity_score}/100, "
+ f"risk {obj.risk_level.value}, "
+ f"confidence {obj.conversion_confidence:.0f}%, "
+ f"action={action}"
+ )
+
+ if report.retirement_candidates:
+ parts.append("\n### Retirement candidates")
+ for r in report.retirement_candidates[:8]:
+ parts.append(f"- {r}")
+
+ if report.behavior_warnings:
+ parts.append("\n### Behavior warnings")
+ for w in report.behavior_warnings[:6]:
+ parts.append(f"- {w[:220]}")
+
+ return "\n".join(parts)
+
+ def respond(
+ self,
+ message: str,
+ history: list[dict[str, str]],
+ report: MigrationReport | None = None,
+ sql_snippet: str = "",
+ source: str = "vertica",
+ target: str = "snowflake",
+ ) -> str:
+ if not message.strip():
+ return (
+ "Ask about migration scope, what to migrate first, lineage risk, "
+ "dbt strategy, validation, or cutover planning."
+ )
+
+ context = self.build_context(report, sql_snippet, source, target)
+ full_system = f"{SYSTEM_PROMPT}\n\n## Current session context\n{context}"
+
+ llm_reply = self._call_llm(full_system, history, message.strip())
+ if llm_reply:
+ return llm_reply
+
+ return self._fallback(message.strip(), report, sql_snippet, source, target)
+
+ def _call_llm(
+ self,
+ system: str,
+ history: list[dict[str, str]],
+ message: str,
+ ) -> str | None:
+ if not self.client:
+ return None
+
+ messages: list[dict[str, str]] = [{"role": "system", "content": system}]
+ for turn in history[-8:]:
+ role = turn.get("role", "user")
+ content = turn.get("content", "")
+ if role in ("user", "assistant") and content:
+ messages.append({"role": role, "content": content})
+ messages.append({"role": "user", "content": message})
+
+ try:
+ response = self.client.chat_completion(
+ model=self.model,
+ messages=messages,
+ max_tokens=900,
+ temperature=0.2,
+ )
+ content = response.choices[0].message.content
+ return content.strip() if content else None
+ except Exception:
+ return None
+
+ def _fallback(
+ self,
+ message: str,
+ report: MigrationReport | None,
+ sql_snippet: str,
+ source: str,
+ target: str,
+ ) -> str:
+ """Grounded consultant-style answers when HF token is not available."""
+ msg = message.lower()
+ sections: list[str] = []
+
+ # Always acknowledge route
+ sections.append(f"Looking at **{source} → {target}**.")
+
+ if report:
+ d = report.dashboard
+ ranked = sorted(report.objects, key=lambda o: o.complexity_score, reverse=True)
+
+ if any(k in msg for k in ("first", "priority", "start", "order", "sequence")):
+ low = [o for o in ranked if o.complexity_score < 30][::-1] or ranked[::-1]
+ high = [o for o in ranked if o.complexity_score >= 50]
+ sections.append(
+ f"\n**Recommended order** based on your scan of {d.total_objects} objects:\n"
+ "1. Convert low-risk objects first (build confidence and pipeline)\n"
+ "2. Parallel-run with reconciliation tests\n"
+ "3. Redesign high-complexity procedures last\n"
+ )
+ if low:
+ sections.append("**Start with:**")
+ for o in low[:4]:
+ sections.append(
+ f"- `{o.name}` — complexity {o.complexity_score}/100, "
+ f"{o.risk_level.value} risk"
+ )
+ if high:
+ sections.append("\n**Defer / redesign:**")
+ for o in high[:4]:
+ sections.append(
+ f"- `{o.name}` — complexity {o.complexity_score}/100 "
+ f"({recommend_workload_action(o).replace('_', ' ')})"
+ )
+ return "\n".join(sections)
+
+ if any(k in msg for k in ("scope", "summary", "overview", "dashboard", "status")):
+ sections.append(
+ f"\n**Current portfolio**\n"
+ f"- Objects: **{d.total_objects}**\n"
+ f"- Auto-migratable: **{d.auto_migratable}**\n"
+ f"- Needs review: **{d.requires_review}**\n"
+ f"- Redesign: **{d.requires_redesign}**\n"
+ f"- Retire/consolidate: **{d.recommended_retirement}**\n"
+ f"- Avg risk: **{d.migration_risk_score:.0f}/100**\n"
+ f"- Est. savings: **${d.estimated_annual_savings_usd[0]:,.0f}–"
+ f"${d.estimated_annual_savings_usd[1]:,.0f}/yr**"
+ )
+ if ranked:
+ sections.append("\n**Highest complexity objects:**")
+ for o in ranked[:5]:
+ sections.append(
+ f"- `{o.name}` ({o.object_type.value}) — "
+ f"{o.complexity_score}/100, confidence {o.conversion_confidence:.0f}%"
+ )
+ return "\n".join(sections)
+
+ if any(k in msg for k in ("retire", "rational", "consolidat", "unused", "orphan")):
+ sections.append("\n**Workload rationalization**")
+ for o in ranked:
+ action = recommend_workload_action(o)
+ if action in ("retire", "manual_redesign", "rewrite"):
+ sections.append(
+ f"- `{o.name}` → **{action.replace('_', ' ')}** "
+ f"(complexity {o.complexity_score})"
+ )
+ if report.retirement_candidates:
+ sections.append("\n**Retirement candidates from lineage:**")
+ for r in report.retirement_candidates[:6]:
+ sections.append(f"- {r}")
+ if len(sections) == 2:
+ sections.append("No strong retirement candidates in this sample. Focus on rewrite candidates.")
+ return "\n".join(sections)
+
+ if "lineage" in msg or "depend" in msg or "impact" in msg:
+ sections.append(
+ f"\nLineage coverage is **{d.lineage_coverage_pct:.0f}%**. "
+ "Open **More → Repository workbench** for the interactive lineage graph.\n\n"
+ "**Cutover risk tip:** migrate objects with few downstream dependents first; "
+ "leave hubs (high fan-out) for later phases with extra validation."
+ )
+ return "\n".join(sections)
+
+ if any(k in msg for k in ("validat", "reconcil", "test", "checksum")):
+ sections.append(
+ f"\nValidation pass rate in last scan: **{d.validation_passed_pct:.0f}%**.\n\n"
+ "**Minimum reconciliation suite:**\n"
+ "1. Row counts (source vs target)\n"
+ "2. Null rates on string columns (empty-string vs NULL)\n"
+ "3. Aggregate metric tolerance for financial columns\n"
+ "4. Incremental replay for delete+reload patterns\n"
+ "5. Spot-check business-rule CASE classifications"
+ )
+ return "\n".join(sections)
+
+ # SQL / dialect questions
+ if re.search(r"zeroifnull|nvl|isnull", msg):
+ sections.append(
+ "\n**Function mapping**\n"
+ "- `ZEROIFNULL(x)` → `COALESCE(x, 0)`\n"
+ "- `NVL(x, y)` / `ISNULL(x, y)` → `COALESCE(x, y)`\n\n"
+ "After conversion, validate NULL rates — Vertica/Oracle empty-string "
+ "semantics can differ from Snowflake."
+ )
+ return "\n".join(sections)
+
+ if any(k in msg for k in ("datediff", "dateadd", "date arithmetic", "timezone")):
+ sections.append(
+ "\n**Date handling**\n"
+ "- Vertica `DATEDIFF('day', a, b)` → Snowflake `DATEDIFF(day, a, b)`\n"
+ "- `col - 90` → `DATEADD(day, -90, col)`\n"
+ "- Normalize timestamps to UTC before period aggregations"
+ )
+ return "\n".join(sections)
+
+ if any(k in msg for k in ("procedure", "stored proc", "pl/sql")):
+ sections.append(
+ "\n**Procedure migration**\n"
+ "1. Convert wrapper to Snowflake `LANGUAGE SQL` with `:PARAM` bindings\n"
+ "2. Replace `LOCAL TEMP` with `CREATE OR REPLACE TEMPORARY TABLE`\n"
+ "3. Prefer decomposing into dbt staging → intermediate → mart\n"
+ "4. Manually review cursors, dynamic SQL, and exception handlers"
+ )
+ return "\n".join(sections)
+
+ if "dbt" in msg:
+ sections.append(
+ "\n**dbt architecture approach**\n"
+ "1. Sources for landing/staging tables\n"
+ "2. Staging models = 1:1 cleansed sources\n"
+ "3. Intermediate models = business transforms (CTEs)\n"
+ "4. Marts = consumer-facing tables\n"
+ "5. Incremental strategy: delete+insert for day-reload patterns; merge for CDC\n"
+ "6. Add schema tests (`unique`, `not_null`) on keys"
+ )
+ return "\n".join(sections)
+
+ if any(k in msg for k in ("cutover", "plan", "roadmap", "phase")):
+ sections.append(
+ "\n**Suggested cutover plan**\n"
+ "1. Discovery + lineage (**More → Repository workbench**)\n"
+ "2. Migrate low-risk objects + generate dbt scaffold\n"
+ "3. Parallel run with reconciliation tests\n"
+ "4. Redesign high-risk procedures\n"
+ "5. Retire orphaned / unused assets\n"
+ "6. Production cutover with rollback window"
+ )
+ if report:
+ d = report.dashboard
+ sections.append(
+ f"\nWith your current scan: start with **{d.auto_migratable}** auto-migratable "
+ f"objects, then schedule **{d.requires_review + d.requires_redesign}** for engineering."
+ )
+ return "\n".join(sections)
+
+ if sql_snippet.strip() and any(k in msg for k in ("this", "sql", "convert", "what", "explain")):
+ sections.append(
+ "\nI see SQL loaded in context. Use **More → Object assess & convert** "
+ "for dialect output and risk score. Ask specifically about functions, "
+ "incremental strategy, or dbt decomposition for this object."
+ )
+ return "\n".join(sections)
+
+ # Default helpful response
+ diffs = [d for d in BEHAVIOR_DIFFERENCES if d.source_platform == source][:3]
+ sections.append(
+ "\nI can answer like a migration consultant on:\n"
+ "- What to migrate first / portfolio risk\n"
+ "- Lineage impact and retirement candidates\n"
+ "- ZEROIFNULL / dates / procedures / dbt strategy\n"
+ "- Validation and cutover planning\n"
+ )
+ if report:
+ sections.append(
+ f"A scan is loaded ({report.dashboard.total_objects} objects). "
+ "Try: *What should we migrate first?*"
+ )
+ else:
+ sections.append(
+ "No scan loaded yet — run **More → Repository workbench** first for grounded answers, "
+ "or ask a dialect/process question."
+ )
+ if diffs:
+ sections.append("\n**Platform behaviors to watch:**")
+ for d in diffs:
+ sections.append(f"- {d.description}")
+
+ if not (os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")):
+ sections.append(
+ "\n_Tip: set `HF_TOKEN` for full LLM responses via Hugging Face Inference._"
+ )
+
+ return "\n".join(sections)
diff --git a/morphsql/cli.py b/morphsql/cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa47f97b04476ff563ed52f174dcd02300c3c8d5
--- /dev/null
+++ b/morphsql/cli.py
@@ -0,0 +1,275 @@
+"""MorphSQL CLI — `morphsql` command-line interface."""
+
+from __future__ import annotations
+
+import json
+from enum import Enum
+from pathlib import Path
+from typing import Optional
+
+import typer
+from rich.console import Console
+from rich.panel import Panel
+from rich.table import Table
+
+from morphsql import __product_name__, __version__
+from morphsql.models import Dialect
+from morphsql.pipeline import MigrationPipeline
+from morphsql.translator.engine import translate_sql
+
+app = typer.Typer(
+ name="morphsql",
+ help=f"{__product_name__} — AI-powered SQL migration toolkit",
+ add_completion=False,
+)
+console = Console()
+
+
+class SourceDialect(str, Enum):
+ snowflake = "snowflake"
+ bigquery = "bigquery"
+ redshift = "redshift"
+ oracle = "oracle"
+ vertica = "vertica"
+
+
+class TargetDialect(str, Enum):
+ pandas = "pandas"
+ pyspark = "pyspark"
+ snowflake = "snowflake"
+ dbt_snowflake = "dbt-snowflake"
+ bigquery = "bigquery"
+
+
+def _to_dialect(d: SourceDialect | TargetDialect) -> Dialect:
+ return Dialect(d.value)
+
+
+@app.callback()
+def main():
+ """MorphSQL — migration intelligence toolkit."""
+ pass
+
+
+@app.command()
+def analyze(
+ path: str = typer.Argument(..., help="Path to SQL repository (directory or .zip)"),
+ source: SourceDialect = typer.Option(SourceDialect.snowflake, "--source", "-s"),
+ target: TargetDialect = typer.Option(TargetDialect.pandas, "--target", "-t"),
+ output: Optional[str] = typer.Option(None, "--output", "-o", help="Output directory"),
+):
+ """Analyze a legacy SQL repository for migration complexity and risk."""
+ console.print(Panel(f"[bold cyan]{__product_name__}[/] v{__version__} — Analyze", expand=False))
+
+ pipeline = MigrationPipeline(source=_to_dialect(source), target=_to_dialect(target))
+ report = pipeline.analyze(path)
+
+ _print_dashboard(report)
+
+ table = Table(title="Object Assessment")
+ table.add_column("Object", style="cyan")
+ table.add_column("Type")
+ table.add_column("Complexity", justify="right")
+ table.add_column("Risk")
+ table.add_column("Category")
+
+ for obj in report.objects:
+ risk_color = {"low": "green", "medium": "yellow", "high": "red", "critical": "bold red"}
+ table.add_row(
+ obj.name,
+ obj.object_type.value,
+ f"{obj.complexity_score}/100",
+ f"[{risk_color.get(obj.risk_level.value, 'white')}]{obj.risk_level.value}[/]",
+ obj.migration_category.value.replace("_", " "),
+ )
+
+ console.print(table)
+
+ if output:
+ out = Path(output)
+ out.mkdir(parents=True, exist_ok=True)
+ pipeline.generate_report(report, out / "migration_report.html")
+ with open(out / "analysis.json", "w") as f:
+ json.dump(report.model_dump(), f, indent=2, default=str)
+ console.print(f"\n[green]✓[/] Report written to {out}")
+
+
+@app.command()
+def convert(
+ path: str = typer.Argument(..., help="Path to SQL file or repository"),
+ source: SourceDialect = typer.Option(SourceDialect.snowflake, "--source", "-s"),
+ target: TargetDialect = typer.Option(TargetDialect.pandas, "--target", "-t"),
+ output: Optional[str] = typer.Option(None, "--output", "-o"),
+ generate_tests: bool = typer.Option(False, "--generate-tests"),
+ generate_lineage: bool = typer.Option(False, "--generate-lineage"),
+ generate_dbt: bool = typer.Option(False, "--generate-dbt"),
+):
+ """Convert legacy SQL to target platform dialect."""
+ from morphsql.dbt_generator.decomposer import (
+ decompose_to_dbt,
+ format_dbt_project,
+ is_dbt_target,
+ write_dbt_project,
+ )
+ from morphsql.models import MigrationObject, ObjectType
+
+ wants_dbt = is_dbt_target(target.value) or generate_dbt
+ sql_target = Dialect.SNOWFLAKE if is_dbt_target(target.value) else _to_dialect(target)
+ pipeline = MigrationPipeline(source=_to_dialect(source), target=sql_target)
+
+ path_obj = Path(path)
+ if path_obj.is_file():
+ sql = path_obj.read_text()
+ translated, confidence, auto, review = translate_sql(
+ sql, _to_dialect(source), sql_target
+ )
+ console.print(Panel(f"Conversion confidence: [bold]{confidence:.0f}%[/]", expand=False))
+ if auto:
+ console.print("[green]Auto-converted:[/] " + ", ".join(auto[:5]))
+ if review:
+ console.print("[yellow]Requires review:[/] " + ", ".join(review[:5]))
+
+ if wants_dbt:
+ obj_type = ObjectType.SQL_SCRIPT
+ if "PROCEDURE" in sql.upper():
+ obj_type = ObjectType.STORED_PROCEDURE
+ elif "CREATE" in sql.upper() and "VIEW" in sql.upper():
+ obj_type = ObjectType.VIEW
+ obj = MigrationObject(
+ name=path_obj.stem,
+ object_type=obj_type,
+ source_sql=sql,
+ target_sql=translated,
+ )
+ files = decompose_to_dbt(obj, _to_dialect(source), project_name=path_obj.stem.lower())
+ console.print(f"\n[bold]dbt project[/] ({len(files)} files):\n")
+ console.print(format_dbt_project(files, max_files=8))
+ if output:
+ out_dir = Path(output)
+ write_dbt_project(files, out_dir)
+ console.print(f"[green]✓[/] Wrote dbt project → {out_dir}")
+ else:
+ console.print("\n[bold]Converted SQL:[/]\n")
+ console.print(translated)
+ if output:
+ out_path = Path(output)
+ if out_path.exists() and out_path.is_dir():
+ suffix = ".py" if sql_target in (Dialect.PANDAS, Dialect.PYSPARK) else ".sql"
+ out_path = out_path / f"{path_obj.stem}_converted{suffix}"
+ elif out_path.suffix == "":
+ out_path.mkdir(parents=True, exist_ok=True)
+ suffix = ".py" if sql_target in (Dialect.PANDAS, Dialect.PYSPARK) else ".sql"
+ out_path = out_path / f"{path_obj.stem}_converted{suffix}"
+ else:
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(translated, encoding="utf-8")
+ console.print(f"[green]✓[/] Wrote → {out_path}")
+ else:
+ report = pipeline.analyze(path)
+ report = pipeline.convert(report)
+ if generate_tests:
+ report = pipeline.validate(report)
+ out = Path(output or "migration-output")
+ out.mkdir(parents=True, exist_ok=True)
+ pipeline.generate_report(report, out / "migration_report.html")
+ if wants_dbt:
+ pipeline.generate_dbt(report, out / "dbt")
+ console.print(f"[green]✓[/] dbt projects written → {out / 'dbt'}")
+ if generate_lineage:
+ from morphsql.lineage import build_lineage_graph, format_lineage_tree
+
+ graph = build_lineage_graph(report.objects, _to_dialect(source))
+ lines = [
+ f"# MorphSQL lineage · {path}",
+ f"# nodes={graph.number_of_nodes()} edges={graph.number_of_edges()}",
+ "",
+ ]
+ roots = [n for n in graph.nodes() if graph.in_degree(n) == 0] or list(graph.nodes())[:5]
+ for root in roots[:20]:
+ try:
+ lines.append(format_lineage_tree(graph, root))
+ lines.append("")
+ except Exception:
+ lines.append(f"- {root}")
+ lineage_path = out / "lineage.txt"
+ lineage_path.write_text("\n".join(lines), encoding="utf-8")
+ console.print(f"[green]✓[/] Lineage written → {lineage_path}")
+ console.print(f"[green]✓[/] Converted {len(report.objects)} objects → {out}")
+
+
+@app.command()
+def validate(
+ path: str = typer.Argument(..., help="Path to SQL repository"),
+ source: SourceDialect = typer.Option(SourceDialect.snowflake, "--source", "-s"),
+ target: TargetDialect = typer.Option(TargetDialect.pandas, "--target", "-t"),
+ tolerance: float = typer.Option(0.01, "--tolerance"),
+ output: Optional[str] = typer.Option(None, "--output", "-o"),
+):
+ """Generate validation and reconciliation tests."""
+ pipeline = MigrationPipeline(source=_to_dialect(source), target=_to_dialect(target))
+ report = pipeline.analyze(path)
+ report = pipeline.convert(report)
+ report = pipeline.validate(report)
+
+ table = Table(title="Validation Results")
+ table.add_column("Object")
+ table.add_column("Check")
+ table.add_column("Status")
+ table.add_column("Details")
+
+ for result in report.validation_results:
+ status = "[green]PASS[/]" if result.passed else "[red]FAIL[/]"
+ detail = result.root_cause or result.recommendation or ""
+ table.add_row(result.object_name, result.check_name, status, detail[:60])
+
+ console.print(table)
+
+ if output:
+ out = Path(output)
+ out.mkdir(parents=True, exist_ok=True)
+ with open(out / "validation_results.json", "w") as f:
+ json.dump([r.model_dump() for r in report.validation_results], f, indent=2)
+ console.print(f"[green]✓[/] Validation results → {out}")
+
+
+@app.command(name="migrate")
+def migrate_cmd(
+ path: str = typer.Argument(..., help="Path to SQL repository"),
+ source: SourceDialect = typer.Option(SourceDialect.snowflake, "--source", "-s"),
+ target: TargetDialect = typer.Option(TargetDialect.pandas, "--target", "-t"),
+ output: str = typer.Option("migration-output", "--output", "-o"),
+):
+ """Run the complete migration pipeline: analyze → convert → validate → report."""
+ console.print(Panel(
+ f"[bold cyan]{__product_name__}[/] — Full Migration Pipeline\n"
+ f"{source.value} → {target.value}",
+ expand=False,
+ ))
+
+ pipeline = MigrationPipeline(source=_to_dialect(source), target=_to_dialect(target))
+ report = pipeline.run_full_pipeline(path, output)
+ _print_dashboard(report)
+ console.print(f"\n[green]✓[/] Complete migration package → [bold]{output}[/]")
+
+
+@app.command()
+def version():
+ """Show version information."""
+ console.print(f"{__product_name__} v{__version__}")
+
+
+def _print_dashboard(report) -> None:
+ d = report.dashboard
+ console.print(Panel(
+ f"Objects: [bold]{d.total_objects}[/] | "
+ f"Auto-migrate: [green]{d.auto_migratable}[/] | "
+ f"Review: [yellow]{d.requires_review}[/] | "
+ f"Redesign: [red]{d.requires_redesign}[/] | "
+ f"Risk: [bold]{d.migration_risk_score:.0f}/100[/]",
+ title="Migration Dashboard",
+ expand=False,
+ ))
+
+
+if __name__ == "__main__":
+ app()
diff --git a/morphsql/dbt_generator/__init__.py b/morphsql/dbt_generator/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..84c19270afbc71dbd9cfe27cfe9c47530b1f9840
--- /dev/null
+++ b/morphsql/dbt_generator/__init__.py
@@ -0,0 +1,5 @@
+"""dbt project generation package."""
+
+from morphsql.dbt_generator.decomposer import decompose_to_dbt, write_dbt_project
+
+__all__ = ["decompose_to_dbt", "write_dbt_project"]
diff --git a/morphsql/dbt_generator/decomposer.py b/morphsql/dbt_generator/decomposer.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc38521df686c5bbd5f872d3d62317cd63a07004
--- /dev/null
+++ b/morphsql/dbt_generator/decomposer.py
@@ -0,0 +1,520 @@
+"""Stored procedure and SQL → dbt project decomposition."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from morphsql.models import Dialect, MigrationObject, ObjectType
+from morphsql.parser.sql_parser import count_sql_complexity, extract_tables
+
+
+def is_dbt_target(target: str | Dialect) -> bool:
+ value = target.value if isinstance(target, Dialect) else str(target)
+ return value.lower() in {"dbt-snowflake", "dbt_snowflake"}
+
+
+def decompose_to_dbt(
+ obj: MigrationObject,
+ source: Dialect,
+ project_name: str = "migration_project",
+) -> dict[str, str]:
+ """
+ Decompose a SQL object into a dbt project structure.
+
+ Uses converted Snowflake SQL (`obj.target_sql`) when available, otherwise source SQL.
+ Returns dict of {relative_path: file_content}.
+ """
+ files: dict[str, str] = {}
+ base_name = _sanitize_name(obj.name)
+ sql = (obj.target_sql or obj.source_sql or "").strip()
+ complexity = count_sql_complexity(obj.source_sql or sql, source)
+ source_name = source.value.replace("-", "_")
+
+ files["dbt_project.yml"] = yaml.dump(
+ {
+ "name": project_name,
+ "version": "1.0.0",
+ "profile": project_name,
+ "model-paths": ["models"],
+ "macro-paths": ["macros"],
+ "seed-paths": ["seeds"],
+ "test-paths": ["tests"],
+ "vars": {"load_date": "CURRENT_DATE"},
+ "models": {
+ project_name: {
+ "staging": {"+materialized": "view"},
+ "intermediate": {"+materialized": "view"},
+ "marts": {"+materialized": "table"},
+ }
+ },
+ },
+ default_flow_style=False,
+ )
+
+ files["profiles.yml.example"] = (
+ f"{project_name}:\n"
+ f" target: dev\n"
+ f" outputs:\n"
+ f" dev:\n"
+ f" type: snowflake\n"
+ f" account: \n"
+ f" user: \n"
+ f" password: \n"
+ f" role: \n"
+ f" database: \n"
+ f" warehouse: \n"
+ f" schema: analytics\n"
+ f" threads: 4\n"
+ )
+
+ # Prefer physical source tables from original SQL
+ source_tables = extract_tables(obj.source_sql or sql, source)
+ # Drop obvious temp / intermediate names
+ physical = {
+ t
+ for t in source_tables
+ if not t.upper().startswith("TMP_")
+ and "TEMP" not in t.upper()
+ and not t.upper().startswith("TEMP_")
+ }
+ if physical:
+ files["models/staging/_sources.yml"] = yaml.dump(
+ {
+ "version": 2,
+ "sources": [
+ {
+ "name": source_name,
+ "description": f"Legacy {source.value} tables landed in Snowflake",
+ "database": "{{ var('source_database', 'RAW') }}",
+ "schema": "{{ var('source_schema', 'PUBLIC') }}",
+ "tables": [
+ {
+ "name": t.split(".")[-1].lower(),
+ "identifier": t.split(".")[-1],
+ }
+ for t in sorted(physical)
+ ],
+ }
+ ],
+ },
+ default_flow_style=False,
+ )
+
+ if _looks_like_procedure(sql) or obj.object_type == ObjectType.STORED_PROCEDURE:
+ models = _decompose_procedure(sql, base_name, source_name, obj.name)
+ elif _has_ctes(sql) and complexity.get("ctes", 0) >= 2:
+ models = _decompose_ctes(sql, base_name, source_name, obj.name)
+ else:
+ models = _simple_decomposition(sql, base_name, source_name, obj.name)
+
+ for path, content in models.items():
+ files[f"models/{path}"] = content
+
+ files[f"models/marts/_schema_{base_name}.yml"] = _generate_schema_yml(models, base_name, obj.name)
+ files["analyses/migration_validation.sql"] = _generate_validation_analysis(
+ obj, base_name, source
+ )
+
+ if re.search(r"ZEROIFNULL|NVL|ISNULL|COALESCE\([^,]+,\s*0\)", obj.source_sql or sql, re.I):
+ files["macros/null_safe.sql"] = _null_safe_macro()
+
+ files["README.md"] = _project_readme(obj, source, base_name, models)
+
+ obj.dbt_models = list(models.keys())
+ return files
+
+
+def format_dbt_project(files: dict[str, str], max_files: int | None = None) -> str:
+ """Render a multi-file dbt project as a single readable text block for the UI."""
+ preferred = sorted(
+ files.keys(),
+ key=lambda p: (
+ 0 if p.startswith("models/") else 1 if p.endswith(".yml") else 2,
+ p,
+ ),
+ )
+ if max_files is not None:
+ preferred = preferred[:max_files]
+
+ parts = [
+ "-- MorphSQL dbt project (Snowflake)",
+ f"-- {len(files)} files generated · copy into a dbt project directory",
+ "",
+ ]
+ for rel in preferred:
+ content = files[rel].rstrip()
+ parts.append(f"-- ===== {rel} =====")
+ parts.append(content)
+ parts.append("")
+ if max_files is not None and len(files) > max_files:
+ remaining = sorted(set(files) - set(preferred))
+ parts.append(f"-- … and {len(remaining)} more files: {', '.join(remaining)}")
+ return "\n".join(parts).strip() + "\n"
+
+
+def write_dbt_project(files: dict[str, str], output_dir: str | Path) -> Path:
+ """Write dbt project files to disk."""
+ output_dir = Path(output_dir)
+ for rel_path, content in files.items():
+ file_path = output_dir / rel_path
+ file_path.parent.mkdir(parents=True, exist_ok=True)
+ file_path.write_text(content, encoding="utf-8")
+ return output_dir
+
+
+def _looks_like_procedure(sql: str) -> bool:
+ return bool(re.search(r"\bCREATE\s+(?:OR\s+REPLACE\s+)?PROCEDURE\b", sql, re.I))
+
+
+def _has_ctes(sql: str) -> bool:
+ return bool(re.search(r"\bWITH\b", sql, re.I))
+
+
+def _decompose_procedure(
+ sql: str,
+ base_name: str,
+ source_name: str,
+ original_name: str,
+) -> dict[str, str]:
+ """
+ Turn procedural ETL into layered dbt models.
+
+ CREATE TEMP TABLE x AS SELECT … → staging / intermediate models
+ INSERT INTO target SELECT … → mart model
+ """
+ models: dict[str, str] = {}
+ temp_model_names: dict[str, str] = {}
+
+ # CREATE [OR REPLACE] [LOCAL] TEMP[ORARY] TABLE name [ON COMMIT …] AS SELECT …;
+ temp_pat = re.compile(
+ r"CREATE\s+(?:OR\s+REPLACE\s+)?"
+ r"(?:LOCAL\s+)?TEMP(?:ORARY)?\s+TABLE\s+([\w.]+)"
+ r"(?:\s+ON\s+COMMIT\s+\w+\s+ROWS)?"
+ r"\s+AS\s+(SELECT\b.*?)(?:;|(?=\n\s*(?:CREATE|DELETE|INSERT|COMMIT|RETURN)))",
+ re.IGNORECASE | re.DOTALL,
+ )
+ temps = list(temp_pat.finditer(sql))
+ for i, match in enumerate(temps):
+ raw_name = match.group(1).split(".")[-1]
+ safe = _sanitize_name(raw_name)
+ layer = "staging" if i == 0 else "intermediate"
+ prefix = "stg" if layer == "staging" else "int"
+ model_key = f"{layer}/{prefix}_{base_name}_{safe}.sql"
+ model_ref = f"{prefix}_{base_name}_{safe}"
+ temp_model_names[raw_name.upper()] = model_ref
+ temp_model_names[safe.upper()] = model_ref
+
+ select_sql = match.group(2).strip().rstrip(";")
+ select_sql = _rewrite_sql_for_dbt(select_sql, source_name, temp_model_names)
+ select_sql = _replace_params_with_vars(select_sql)
+
+ models[model_key] = (
+ f"{{{{ config(materialized='view') }}}}\n\n"
+ f"-- From procedure {original_name} · temp table {raw_name}\n\n"
+ f"{select_sql}\n"
+ )
+
+ # INSERT INTO schema.table SELECT …
+ insert_pat = re.compile(
+ r"INSERT\s+INTO\s+([\w.]+)\s+(SELECT\b.*?)(?:;|(?=\n\s*(?:CREATE|DELETE|INSERT|COMMIT|RETURN))|\Z)",
+ re.IGNORECASE | re.DOTALL,
+ )
+ inserts = list(insert_pat.finditer(sql))
+ if inserts:
+ last = inserts[-1]
+ target_table = last.group(1).split(".")[-1]
+ mart_name = _sanitize_name(target_table) or base_name
+ select_sql = last.group(2).strip().rstrip(";")
+ select_sql = _rewrite_sql_for_dbt(select_sql, source_name, temp_model_names)
+ select_sql = _replace_params_with_vars(select_sql)
+ models[f"marts/{mart_name}.sql"] = (
+ f"{{{{ config(materialized='incremental', unique_key='customer_id', "
+ f"incremental_strategy='delete+insert') }}}}\n\n"
+ f"-- Mart loaded by legacy procedure {original_name}\n"
+ f"-- Target table: {last.group(1)}\n\n"
+ f"{select_sql}\n"
+ )
+ elif not models:
+ # Fallback: wrap whatever SELECT body we can find
+ models.update(_simple_decomposition(sql, base_name, source_name, original_name))
+ else:
+ # Staging/int models exist but no INSERT — emit a mart that selects from last int model
+ last_ref = list(temp_model_names.values())[-1]
+ models[f"marts/{base_name}.sql"] = (
+ f"{{{{ config(materialized='table') }}}}\n\n"
+ f"-- Final model for procedure {original_name}\n\n"
+ f"SELECT *\nFROM {{{{ ref('{last_ref}') }}}}\n"
+ )
+
+ return models
+
+
+def _decompose_ctes(
+ sql: str,
+ base_name: str,
+ source_name: str,
+ original_name: str,
+) -> dict[str, str]:
+ """Split WITH cte AS (...) [, ...] SELECT into layered dbt models."""
+ models: dict[str, str] = {}
+ cte_map: dict[str, str] = {}
+
+ body = re.sub(
+ r"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:VIEW|TABLE)\s+\S+\s+AS\s*",
+ "",
+ sql,
+ flags=re.I,
+ ).strip().rstrip(";")
+
+ if not re.match(r"WITH\b", body, re.I):
+ return _simple_decomposition(sql, base_name, source_name, original_name)
+
+ cte_iter = list(re.finditer(r"(\w+)\s+AS\s*\(", body, re.IGNORECASE))
+ if not cte_iter:
+ return _simple_decomposition(sql, base_name, source_name, original_name)
+
+ last_cte_end = 0
+ for idx, start in enumerate(cte_iter):
+ name = start.group(1)
+ prefix = body[: start.start()].rstrip().upper()
+ if idx == 0 and not prefix.endswith("WITH"):
+ continue
+ if idx > 0 and not (prefix.endswith(",") or prefix.endswith(")")):
+ # allow CTEs separated by commas
+ pass
+
+ open_paren = start.end() - 1
+ depth = 0
+ end = open_paren
+ for i in range(open_paren, len(body)):
+ if body[i] == "(":
+ depth += 1
+ elif body[i] == ")":
+ depth -= 1
+ if depth == 0:
+ end = i
+ break
+ cte_sql = body[open_paren + 1:end].strip()
+ last_cte_end = end + 1
+
+ safe = _sanitize_name(name)
+ layer = "staging" if idx == 0 else "intermediate"
+ model_prefix = "stg" if layer == "staging" else "int"
+ model_ref = f"{model_prefix}_{base_name}_{safe}"
+ cte_map[name.upper()] = model_ref
+
+ rewritten = _rewrite_sql_for_dbt(cte_sql, source_name, cte_map)
+ rewritten = _replace_params_with_vars(rewritten)
+ models[f"{layer}/{model_ref}.sql"] = (
+ f"{{{{ config(materialized='view') }}}}\n\n"
+ f"-- CTE `{name}` from {original_name}\n\n"
+ f"{rewritten}\n"
+ )
+
+ if not models:
+ return _simple_decomposition(sql, base_name, source_name, original_name)
+
+ rest = body[last_cte_end:].strip().lstrip(",").strip()
+ final_match = re.search(r"\bSELECT\b[\s\S]*$", rest, re.I)
+ if final_match:
+ final_sql = final_match.group(0).strip().rstrip(";")
+ else:
+ last_ref = list(cte_map.values())[-1]
+ final_sql = f"SELECT *\nFROM {{{{ ref('{last_ref}') }}}}"
+
+ final_sql = _rewrite_sql_for_dbt(final_sql, source_name, cte_map)
+ final_sql = _replace_cte_refs(final_sql, cte_map)
+ final_sql = _replace_params_with_vars(final_sql)
+ models[f"marts/{base_name}.sql"] = (
+ f"{{{{ config(materialized='table') }}}}\n\n"
+ f"-- Final SELECT from {original_name}\n\n"
+ f"{final_sql}\n"
+ )
+ return models
+
+
+def _simple_decomposition(
+ sql: str,
+ base_name: str,
+ source_name: str,
+ original_name: str,
+) -> dict[str, str]:
+ cleaned = _extract_select_body(sql)
+ cleaned = _rewrite_sql_for_dbt(cleaned, source_name, {})
+ cleaned = _replace_params_with_vars(cleaned)
+ if not re.match(r"^\s*(WITH|SELECT)\b", cleaned, re.I):
+ cleaned = (
+ f"-- Procedural logic from {original_name} could not be fully auto-extracted.\n"
+ f"-- Review and rewrite as declarative dbt SQL.\n"
+ f"-- Original converted SQL (truncated):\n"
+ f"/*\n{sql[:1500]}\n*/\n"
+ f"SELECT 1 AS migration_placeholder\n"
+ )
+ return {
+ f"marts/{base_name}.sql": (
+ f"{{{{ config(materialized='table') }}}}\n\n"
+ f"-- Converted from: {original_name}\n\n"
+ f"{cleaned}\n"
+ )
+ }
+
+
+def _extract_select_body(sql: str) -> str:
+ sql = sql.strip().rstrip(";")
+ sql = re.sub(
+ r"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:VIEW|TABLE)\s+\S+\s+AS\s*",
+ "",
+ sql,
+ flags=re.I,
+ )
+ match = re.search(r"\b(WITH|SELECT)\b[\s\S]*$", sql, re.I)
+ return match.group(0).strip().rstrip(";") if match else sql
+
+
+def _rewrite_sql_for_dbt(
+ sql: str,
+ source_name: str,
+ known_models: dict[str, str],
+) -> str:
+ """Replace physical tables with source()/ref() and map temp tables to refs."""
+ pattern = re.compile(r"\b(FROM|JOIN)\s+((?:[\w]+\.)?[\w]+)\b", re.IGNORECASE)
+
+ def from_join_repl(match: re.Match) -> str:
+ kw = match.group(1)
+ name = match.group(2)
+ if name.upper() in {"SELECT", "LATERAL", "UNNEST", "TABLE"}:
+ return match.group(0)
+ if "{{" in name:
+ return match.group(0)
+ key = name.upper()
+ short = name.split(".")[-1].upper()
+ if key in known_models or short in known_models:
+ ref = known_models.get(key) or known_models[short]
+ return f"{kw} {{{{ ref('{ref}') }}}}"
+ if short.startswith("TMP_") or short.startswith("TEMP_"):
+ return f"{kw} {{{{ ref('{_sanitize_name(short)}') }}}}"
+ table = name.split(".")[-1].lower()
+ return f"{kw} {{{{ source('{source_name}', '{table}') }}}}"
+
+ return pattern.sub(from_join_repl, sql)
+
+
+def _replace_cte_refs(sql: str, cte_map: dict[str, str]) -> str:
+ """Replace remaining CTE name references in FROM/JOIN with ref()."""
+
+ def repl(match: re.Match) -> str:
+ kw, name = match.group(1), match.group(2)
+ key = name.upper()
+ if key in cte_map:
+ return f"{kw} {{{{ ref('{cte_map[key]}') }}}}"
+ return match.group(0)
+
+ return re.sub(r"\b(FROM|JOIN)\s+([\w]+)\b", repl, sql, flags=re.I)
+
+
+def _replace_params_with_vars(sql: str) -> str:
+ """Map Snowflake :PARAM / load_date style params to dbt vars."""
+
+ def bind_repl(match: re.Match) -> str:
+ name = match.group(1)
+ if name.lower() in {"load_date", "as_of_date", "run_date"}:
+ return "{{ var('load_date') }}"
+ return "{{ var('" + name.lower() + "') }}"
+
+ sql = re.sub(r":([A-Za-z_][\w]*)", bind_repl, sql)
+
+ # Bare procedure params — skip text already inside Jinja
+ parts = re.split(r"(\{\{.*?\}\})", sql, flags=re.DOTALL)
+ out: list[str] = []
+ for part in parts:
+ if part.startswith("{{"):
+ out.append(part)
+ continue
+ for param in ("load_date", "as_of_date", "run_date"):
+ part = re.sub(
+ rf"(? str:
+ mart_names = [
+ Path(p).stem for p in models if p.startswith("marts/") and p.endswith(".sql")
+ ]
+ schema_models = []
+ for name in mart_names or [base_name]:
+ schema_models.append(
+ {
+ "name": name,
+ "description": f"Migrated dbt model from legacy object {original_name}",
+ "columns": [
+ {
+ "name": "customer_id",
+ "description": "Primary business key (adjust as needed)",
+ "tests": ["not_null"],
+ }
+ ],
+ }
+ )
+ return yaml.dump({"version": 2, "models": schema_models}, default_flow_style=False)
+
+
+def _generate_validation_analysis(obj: MigrationObject, base_name: str, source: Dialect) -> str:
+ source_name = source.value.replace("-", "_")
+ mart = base_name
+ if obj.dbt_models:
+ marts = [Path(m).stem for m in obj.dbt_models if m.startswith("marts/")]
+ if marts:
+ mart = marts[0]
+ return f"""-- Migration validation analysis for {obj.name}
+-- Run after `dbt build` to compare source vs target
+
+SELECT 'source' AS platform, COUNT(*) AS row_count
+FROM {{{{ source('{source_name}', '{base_name}') }}}}
+UNION ALL
+SELECT 'target' AS platform, COUNT(*) AS row_count
+FROM {{{{ ref('{mart}') }}}};
+"""
+
+
+def _null_safe_macro() -> str:
+ return """{% macro null_safe(column, default=0) %}
+ COALESCE({{ column }}, {{ default }})
+{% endmacro %}
+"""
+
+
+def _project_readme(
+ obj: MigrationObject,
+ source: Dialect,
+ base_name: str,
+ models: dict[str, str],
+) -> str:
+ model_list = "\n".join(f"- `{m}`" for m in sorted(models))
+ return f"""# dbt project — {base_name}
+
+Generated by MorphSQL from `{obj.name}` ({source.value} → dbt-snowflake).
+
+## Models
+{model_list}
+
+## Run
+```bash
+dbt deps
+dbt build --vars '{{"load_date": "2024-01-15"}}'
+```
+
+Review staging sources in `models/staging/_sources.yml` and adjust database/schema vars.
+"""
+
+
+def _sanitize_name(name: str) -> str:
+ return re.sub(r"[^a-zA-Z0-9_]", "_", name.lower()).strip("_")
diff --git a/morphsql/eval/__init__.py b/morphsql/eval/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7b2a92d64b0da7be247c8f9a4aba6666e3e91040
--- /dev/null
+++ b/morphsql/eval/__init__.py
@@ -0,0 +1,14 @@
+"""Eval package exports."""
+
+from morphsql.eval.leaderboard import format_leaderboard_md, load_leaderboard, submit_score
+from morphsql.eval.metrics import run_eval
+from morphsql.eval.pairs import ensure_pairs_file, load_pairs
+
+__all__ = [
+ "ensure_pairs_file",
+ "load_pairs",
+ "run_eval",
+ "submit_score",
+ "load_leaderboard",
+ "format_leaderboard_md",
+]
diff --git a/morphsql/eval/leaderboard.py b/morphsql/eval/leaderboard.py
new file mode 100644
index 0000000000000000000000000000000000000000..9452605b40f450c787f01e1848b077ad7eeb2f01
--- /dev/null
+++ b/morphsql/eval/leaderboard.py
@@ -0,0 +1,68 @@
+"""Simple local leaderboard for conversion quality scores."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+
+LEADERBOARD_PATH = Path(__file__).resolve().parent.parent.parent / "datasets" / "leaderboard.json"
+
+
+def load_leaderboard() -> list[dict]:
+ if not LEADERBOARD_PATH.exists():
+ return []
+ try:
+ return json.loads(LEADERBOARD_PATH.read_text(encoding="utf-8"))
+ except Exception:
+ return []
+
+
+def submit_score(
+ name: str,
+ exact_match: float,
+ token_f1: float,
+ fuzzy: float,
+ pass_rate: float,
+ n_pairs: int,
+ notes: str = "",
+) -> list[dict]:
+ board = load_leaderboard()
+ entry = {
+ "name": (name or "anonymous").strip()[:64],
+ "exact_match": round(float(exact_match), 4),
+ "token_f1": round(float(token_f1), 4),
+ "fuzzy": round(float(fuzzy), 4),
+ "pass_rate": round(float(pass_rate), 4),
+ "n_pairs": int(n_pairs),
+ "notes": (notes or "").strip()[:200],
+ "ts": datetime.now(timezone.utc).isoformat(),
+ }
+ board.append(entry)
+ board.sort(key=lambda e: (e.get("pass_rate", 0), e.get("token_f1", 0)), reverse=True)
+ board = board[:50]
+ LEADERBOARD_PATH.parent.mkdir(parents=True, exist_ok=True)
+ LEADERBOARD_PATH.write_text(json.dumps(board, indent=2), encoding="utf-8")
+ return board
+
+
+def format_leaderboard_md(board: list[dict] | None = None) -> str:
+ board = board if board is not None else load_leaderboard()
+ if not board:
+ return (
+ "### Leaderboard\n\n"
+ "No scores yet. Run **Eval Suite**, then submit a score."
+ )
+ lines = [
+ "### Leaderboard",
+ "",
+ "| Rank | Name | Pass rate | Token F1 | Exact | Pairs |",
+ "|------|------|-----------|----------|-------|-------|",
+ ]
+ for i, e in enumerate(board[:20], 1):
+ lines.append(
+ f"| {i} | {e.get('name', '?')} | {100 * e.get('pass_rate', 0):.1f}% "
+ f"| {100 * e.get('token_f1', 0):.1f}% | {100 * e.get('exact_match', 0):.1f}% "
+ f"| {e.get('n_pairs', 0)} |"
+ )
+ return "\n".join(lines)
diff --git a/morphsql/eval/metrics.py b/morphsql/eval/metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..5bc47859a17df13f4cf3a7f51066c8a9dca2bf0c
--- /dev/null
+++ b/morphsql/eval/metrics.py
@@ -0,0 +1,170 @@
+"""Translation quality metrics for SQL migration eval."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import asdict, dataclass
+
+from morphsql.eval.pairs import load_pairs
+from morphsql.models import Dialect
+from morphsql.translator.engine import translate_sql
+
+
+def _normalize_sql(sql: str) -> str:
+ sql = sql.lower().strip()
+ sql = re.sub(r"--.*?$", "", sql, flags=re.M)
+ sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.S)
+ sql = re.sub(r"\s+", " ", sql)
+ sql = sql.replace(";", "")
+ return sql.strip()
+
+
+def _tokens(sql: str) -> list[str]:
+ return re.findall(r"[a-z_][a-z0-9_]*|\d+|[=<>!()*,.]", _normalize_sql(sql))
+
+
+def exact_match(pred: str, gold: str) -> float:
+ return 1.0 if _normalize_sql(pred) == _normalize_sql(gold) else 0.0
+
+
+def token_f1(pred: str, gold: str) -> float:
+ p, g = _tokens(pred), _tokens(gold)
+ if not p and not g:
+ return 1.0
+ if not p or not g:
+ return 0.0
+ from collections import Counter
+
+ pc, gc = Counter(p), Counter(g)
+ overlap = sum((pc & gc).values())
+ precision = overlap / max(sum(pc.values()), 1)
+ recall = overlap / max(sum(gc.values()), 1)
+ if precision + recall == 0:
+ return 0.0
+ return 2 * precision * recall / (precision + recall)
+
+
+def fuzzy_ratio(pred: str, gold: str) -> float:
+ """Character-level Dice coefficient on normalized SQL."""
+ a, b = _normalize_sql(pred), _normalize_sql(gold)
+ if not a and not b:
+ return 1.0
+ if not a or not b:
+ return 0.0
+ # bigram Dice
+ def bigrams(s: str) -> set[str]:
+ if len(s) < 2:
+ return {s}
+ return {s[i : i + 2] for i in range(len(s) - 1)}
+
+ ba, bb = bigrams(a), bigrams(b)
+ return 2 * len(ba & bb) / max(len(ba) + len(bb), 1)
+
+
+@dataclass
+class EvalResult:
+ pair_id: str
+ category: str
+ source_dialect: str
+ target_dialect: str
+ exact_match: float
+ token_f1: float
+ fuzzy: float
+ passed: bool
+ predicted: str
+ gold: str
+
+
+def llm_judge_optional(pred: str, gold: str, source_sql: str) -> float | None:
+ """Optional LLM-as-judge via HF Inference when HF_TOKEN is set. Returns 0..1 or None."""
+ import os
+
+ token = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
+ if not token:
+ return None
+ try:
+ from huggingface_hub import InferenceClient
+
+ client = InferenceClient(token=token)
+ prompt = (
+ "Score SQL migration quality from 0 to 1. Reply with ONLY a number.\n"
+ f"Source:\n{source_sql[:800]}\n\nGold:\n{gold[:800]}\n\nPred:\n{pred[:800]}\n"
+ )
+ text = client.text_generation(
+ prompt,
+ model=os.getenv("MORPHSQL_MODEL", "Qwen/Qwen2.5-3B-Instruct"),
+ max_new_tokens=8,
+ )
+ import re
+
+ m = re.search(r"0?\.\d+|1(?:\.0+)?|0", str(text))
+ if not m:
+ return None
+ return max(0.0, min(1.0, float(m.group(0))))
+ except Exception:
+ return None
+
+
+def run_eval(
+ limit: int | None = None,
+ categories: list[str] | None = None,
+) -> tuple[list[EvalResult], dict]:
+ """Run translator against gold pairs and return rows + aggregate metrics."""
+ pairs = load_pairs(limit=limit)
+ if categories:
+ cats = {c.lower() for c in categories}
+ pairs = [p for p in pairs if p.get("category", "").lower() in cats]
+
+ results: list[EvalResult] = []
+ for pair in pairs:
+ try:
+ source = Dialect(pair["source_dialect"])
+ target = Dialect(pair["target_dialect"])
+ except Exception:
+ continue
+ predicted, _, _, _ = translate_sql(pair["source_sql"], source, target)
+ em = exact_match(predicted, pair["target_sql"])
+ f1 = token_f1(predicted, pair["target_sql"])
+ fuzzy = fuzzy_ratio(predicted, pair["target_sql"])
+ # Pass if exact or strong fuzzy/token agreement
+ passed = em >= 1.0 or (f1 >= 0.85 and fuzzy >= 0.75)
+ results.append(
+ EvalResult(
+ pair_id=pair["id"],
+ category=pair.get("category", "unknown"),
+ source_dialect=pair["source_dialect"],
+ target_dialect=pair["target_dialect"],
+ exact_match=em,
+ token_f1=f1,
+ fuzzy=fuzzy,
+ passed=passed,
+ predicted=predicted,
+ gold=pair["target_sql"],
+ )
+ )
+
+ n = max(len(results), 1)
+ summary = {
+ "n_pairs": len(results),
+ "exact_match": sum(r.exact_match for r in results) / n,
+ "token_f1": sum(r.token_f1 for r in results) / n,
+ "fuzzy": sum(r.fuzzy for r in results) / n,
+ "pass_rate": sum(1 for r in results if r.passed) / n,
+ "by_category": {},
+ }
+ cats: dict[str, list[EvalResult]] = {}
+ for r in results:
+ cats.setdefault(r.category, []).append(r)
+ for cat, rows in cats.items():
+ m = max(len(rows), 1)
+ summary["by_category"][cat] = {
+ "n": len(rows),
+ "exact_match": sum(x.exact_match for x in rows) / m,
+ "token_f1": sum(x.token_f1 for x in rows) / m,
+ "pass_rate": sum(1 for x in rows if x.passed) / m,
+ }
+ return results, summary
+
+
+def results_to_dicts(results: list[EvalResult], limit: int = 40) -> list[dict]:
+ return [asdict(r) for r in results[:limit]]
diff --git a/morphsql/eval/pairs.py b/morphsql/eval/pairs.py
new file mode 100644
index 0000000000000000000000000000000000000000..a1165ec4d95d7adc54f74f6fa87d2ac72c59fe16
--- /dev/null
+++ b/morphsql/eval/pairs.py
@@ -0,0 +1,274 @@
+"""Generate and load Vertica ↔ Snowflake SQL migration pairs for HF dataset + evals."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+DATA_DIR = Path(__file__).resolve().parent.parent.parent / "datasets"
+PAIRS_PATH = DATA_DIR / "vertica_snowflake_pairs.jsonl"
+
+
+TEMPLATES: list[dict] = [
+ {
+ "id": "zeroifnull_basic",
+ "category": "function",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": "SELECT ZEROIFNULL(amount) AS amount FROM staging.orders",
+ "target_sql": "SELECT COALESCE(amount, 0) AS amount FROM staging.orders",
+ "notes": "ZEROIFNULL → COALESCE(expr, 0)",
+ },
+ {
+ "id": "nvl_basic",
+ "category": "function",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": "SELECT NVL(discount, 0) AS discount FROM staging.orders",
+ "target_sql": "SELECT COALESCE(discount, 0) AS discount FROM staging.orders",
+ "notes": "NVL → COALESCE",
+ },
+ {
+ "id": "date_sub_days",
+ "category": "date",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": "SELECT * FROM t WHERE order_date >= CURRENT_DATE - 30",
+ "target_sql": "SELECT * FROM t WHERE order_date >= DATEADD(day, -30, CURRENT_DATE)",
+ "notes": "Date arithmetic → DATEADD",
+ },
+ {
+ "id": "local_temp",
+ "category": "ddl",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": "CREATE LOCAL TEMP TABLE tmp_x ON COMMIT PRESERVE ROWS AS SELECT 1 AS id",
+ "target_sql": "CREATE OR REPLACE TEMPORARY TABLE tmp_x AS SELECT 1 AS id",
+ "notes": "LOCAL TEMP → TEMPORARY TABLE",
+ },
+ {
+ "id": "string_agg",
+ "category": "aggregate",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": "SELECT STRING_AGG(name, ',') FROM users",
+ "target_sql": "SELECT LISTAGG(name, ',') FROM users",
+ "notes": "STRING_AGG → LISTAGG",
+ },
+ {
+ "id": "approx_distinct",
+ "category": "aggregate",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": "SELECT APPROXIMATE_COUNT_DISTINCT(user_id) FROM events",
+ "target_sql": "SELECT APPROX_COUNT_DISTINCT(user_id) FROM events",
+ "notes": "APPROXIMATE_COUNT_DISTINCT → APPROX_COUNT_DISTINCT",
+ },
+ {
+ "id": "datediff_quoted",
+ "category": "date",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": "SELECT DATEDIFF('day', start_dt, end_dt) FROM t",
+ "target_sql": "SELECT DATEDIFF(day, start_dt, end_dt) FROM t",
+ "notes": "Quoted DATEDIFF unit → unquoted",
+ },
+ {
+ "id": "sysdate",
+ "category": "function",
+ "source_dialect": "oracle",
+ "target_dialect": "snowflake",
+ "source_sql": "SELECT SYSDATE FROM dual",
+ "target_sql": "SELECT CURRENT_TIMESTAMP()",
+ "notes": "SYSDATE + drop DUAL",
+ },
+ {
+ "id": "getdate_redshift",
+ "category": "function",
+ "source_dialect": "redshift",
+ "target_dialect": "snowflake",
+ "source_sql": "SELECT GETDATE(), NVL(x, 0) FROM t",
+ "target_sql": "SELECT CURRENT_TIMESTAMP(), COALESCE(x, 0) FROM t",
+ "notes": "GETDATE/NVL mappings",
+ },
+ {
+ "id": "bigquery_ifnull",
+ "category": "function",
+ "source_dialect": "bigquery",
+ "target_dialect": "snowflake",
+ "source_sql": "SELECT IFNULL(a, 0), STRING_AGG(b, ',') FROM t GROUP BY a",
+ "target_sql": "SELECT COALESCE(a, 0), LISTAGG(b, ',') FROM t GROUP BY a",
+ "notes": "IFNULL/STRING_AGG → COALESCE/LISTAGG",
+ },
+ {
+ "id": "feature_rfm",
+ "category": "ml_feature",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": (
+ "SELECT customer_id, "
+ "ZEROIFNULL(SUM(order_amount)) AS monetary, "
+ "COUNT(DISTINCT order_id) AS frequency, "
+ "DATEDIFF('day', MAX(order_date), CURRENT_DATE) AS recency "
+ "FROM staging.orders GROUP BY customer_id"
+ ),
+ "target_sql": (
+ "SELECT customer_id, "
+ "COALESCE(SUM(order_amount), 0) AS monetary, "
+ "COUNT(DISTINCT order_id) AS frequency, "
+ "DATEDIFF(day, MAX(order_date), CURRENT_DATE) AS recency "
+ "FROM staging.orders GROUP BY customer_id"
+ ),
+ "notes": "ML RFM feature SQL migration",
+ },
+ {
+ "id": "feature_label",
+ "category": "ml_feature",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": (
+ "SELECT user_id, "
+ "CASE WHEN ZEROIFNULL(churn_score) > 0.7 THEN 1 ELSE 0 END AS churn_label "
+ "FROM ml.user_scores"
+ ),
+ "target_sql": (
+ "SELECT user_id, "
+ "CASE WHEN COALESCE(churn_score, 0) > 0.7 THEN 1 ELSE 0 END AS churn_label "
+ "FROM ml.user_scores"
+ ),
+ "notes": "Training label feature SQL",
+ },
+]
+
+
+def _expand_pairs() -> list[dict]:
+ """Expand templates into a larger eval/dataset corpus."""
+ pairs = [dict(p) for p in TEMPLATES]
+ # Parametric expansions for volume (HF dataset appeal)
+ for days in (7, 14, 30, 60, 90, 180, 365):
+ pairs.append(
+ {
+ "id": f"date_sub_{days}",
+ "category": "date",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": f"SELECT * FROM events WHERE event_date >= CURRENT_DATE - {days}",
+ "target_sql": (
+ f"SELECT * FROM events WHERE event_date >= DATEADD(day, -{days}, CURRENT_DATE)"
+ ),
+ "notes": f"CURRENT_DATE - {days} → DATEADD",
+ }
+ )
+ for col in ("amount", "revenue", "fee", "tax", "balance", "score", "qty"):
+ pairs.append(
+ {
+ "id": f"zeroifnull_{col}",
+ "category": "function",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": f"SELECT ZEROIFNULL({col}) AS {col} FROM staging.facts",
+ "target_sql": f"SELECT COALESCE({col}, 0) AS {col} FROM staging.facts",
+ "notes": "ZEROIFNULL expansion",
+ }
+ )
+ for i in range(1, 81):
+ pairs.append(
+ {
+ "id": f"agg_mix_{i}",
+ "category": "aggregate",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": (
+ f"SELECT dim_{i % 5}, ZEROIFNULL(SUM(metric_{i})), "
+ f"APPROXIMATE_COUNT_DISTINCT(user_id) "
+ f"FROM fact_{i % 3} GROUP BY 1"
+ ),
+ "target_sql": (
+ f"SELECT dim_{i % 5}, COALESCE(SUM(metric_{i}), 0), "
+ f"APPROX_COUNT_DISTINCT(user_id) "
+ f"FROM fact_{i % 3} GROUP BY 1"
+ ),
+ "notes": "Synthetic aggregate pair",
+ }
+ )
+ for i in range(1, 61):
+ pairs.append(
+ {
+ "id": f"oracle_nvl_{i}",
+ "category": "function",
+ "source_dialect": "oracle",
+ "target_dialect": "snowflake",
+ "source_sql": f"SELECT NVL(col_{i}, 0) FROM dual",
+ "target_sql": f"SELECT COALESCE(col_{i}, 0)",
+ "notes": "Oracle NVL expansion",
+ }
+ )
+ for i in range(1, 51):
+ pairs.append(
+ {
+ "id": f"ml_feature_{i}",
+ "category": "ml_feature",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": (
+ f"SELECT entity_id, ZEROIFNULL(AVG(feature_{i})) AS f_{i} "
+ f"FROM ml.training_window WHERE ds >= CURRENT_DATE - {7 + i} "
+ f"GROUP BY 1"
+ ),
+ "target_sql": (
+ f"SELECT entity_id, COALESCE(AVG(feature_{i}), 0) AS f_{i} "
+ f"FROM ml.training_window WHERE ds >= DATEADD(day, -{7 + i}, CURRENT_DATE) "
+ f"GROUP BY 1"
+ ),
+ "notes": "ML feature window SQL",
+ }
+ )
+ # AI / LLM-eval oriented paraphrases
+ for i in range(1, 41):
+ pairs.append(
+ {
+ "id": f"ai_codegen_{i}",
+ "category": "function",
+ "source_dialect": "vertica",
+ "target_dialect": "snowflake",
+ "source_sql": (
+ f"SELECT ZEROIFNULL(score_{i}), NVL(flag_{i}, 0) "
+ f"FROM ml.inference_logs WHERE run_id = {i}"
+ ),
+ "target_sql": (
+ f"SELECT COALESCE(score_{i}, 0), COALESCE(flag_{i}, 0) "
+ f"FROM ml.inference_logs WHERE run_id = {i}"
+ ),
+ "notes": "AI inference-log SQL pair",
+ }
+ )
+ return pairs
+
+
+def ensure_pairs_file() -> Path:
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ pairs = _expand_pairs()
+ with PAIRS_PATH.open("w", encoding="utf-8") as fh:
+ for row in pairs:
+ fh.write(json.dumps(row) + "\n")
+ return PAIRS_PATH
+
+
+def load_pairs(limit: int | None = None) -> list[dict]:
+ if not PAIRS_PATH.exists():
+ ensure_pairs_file()
+ rows: list[dict] = []
+ with PAIRS_PATH.open(encoding="utf-8") as fh:
+ for line in fh:
+ line = line.strip()
+ if not line:
+ continue
+ rows.append(json.loads(line))
+ if limit is not None and len(rows) >= limit:
+ break
+ return rows
+
+
+if __name__ == "__main__":
+ path = ensure_pairs_file()
+ print(f"Wrote {len(load_pairs())} pairs → {path}")
diff --git a/morphsql/intelligence/__init__.py b/morphsql/intelligence/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..10eb5285441108b4029f28f3fb26312bda8b150d
--- /dev/null
+++ b/morphsql/intelligence/__init__.py
@@ -0,0 +1,12 @@
+"""Migration intelligence outputs beyond SQL translation."""
+
+from morphsql.intelligence.lineage_viz import lineage_to_plotly
+from morphsql.intelligence.rationalization import generate_rationalization
+from morphsql.intelligence.runbook import generate_executive_summary, generate_runbook
+
+__all__ = [
+ "generate_executive_summary",
+ "generate_runbook",
+ "generate_rationalization",
+ "lineage_to_plotly",
+]
diff --git a/morphsql/intelligence/lineage_viz.py b/morphsql/intelligence/lineage_viz.py
new file mode 100644
index 0000000000000000000000000000000000000000..88dfa2eeceaa07b5f30d63306b46cb62fe74fbfb
--- /dev/null
+++ b/morphsql/intelligence/lineage_viz.py
@@ -0,0 +1,119 @@
+"""Interactive lineage graph visualization."""
+
+from __future__ import annotations
+
+import networkx as nx
+import plotly.graph_objects as go
+
+from morphsql.models import Dialect, MigrationObject
+
+C_PANEL = "#1a2332"
+C_TEXT = "#e2e8f0"
+C_MUTED = "#94a3b8"
+C_BORDER = "#2d3a4f"
+
+
+def lineage_to_plotly(
+ graph: nx.DiGraph,
+ objects: list[MigrationObject] | None = None,
+) -> go.Figure:
+ """Render dependency graph as interactive Plotly network."""
+ if graph.number_of_nodes() == 0:
+ return _empty_graph("No lineage data — run a repository scan first.")
+
+ # Use spring layout for positioning
+ try:
+ pos = nx.spring_layout(graph, k=1.8, iterations=50, seed=42)
+ except nx.NetworkXError:
+ return _empty_graph("Could not layout lineage graph.")
+
+ obj_names = {o.name for o in (objects or [])}
+
+ edge_x, edge_y = [], []
+ for u, v in graph.edges():
+ x0, y0 = pos[u]
+ x1, y1 = pos[v]
+ edge_x += [x0, x1, None]
+ edge_y += [y0, y1, None]
+
+ edge_trace = go.Scatter(
+ x=edge_x,
+ y=edge_y,
+ mode="lines",
+ line=dict(width=1.2, color="#475569"),
+ hoverinfo="none",
+ showlegend=False,
+ )
+
+ node_x, node_y, node_text, node_color, node_size = [], [], [], [], []
+ for node in graph.nodes():
+ x, y = pos[node]
+ node_x.append(x)
+ node_y.append(y)
+ node_text.append(node)
+ obj_type = graph.nodes[node].get("object_type", "table")
+ if node in obj_names:
+ node_color.append("#3b82f6")
+ node_size.append(14)
+ elif obj_type in ("stored_procedure", "sql_script", "view"):
+ node_color.append("#8b5cf6")
+ node_size.append(12)
+ else:
+ node_color.append("#64748b")
+ node_size.append(10)
+
+ node_trace = go.Scatter(
+ x=node_x,
+ y=node_y,
+ mode="markers+text",
+ text=node_text,
+ textposition="top center",
+ textfont=dict(size=9, color=C_TEXT),
+ marker=dict(
+ size=node_size,
+ color=node_color,
+ line=dict(width=1, color=C_BORDER),
+ ),
+ hovertemplate="%{text}",
+ showlegend=False,
+ )
+
+ fig = go.Figure(data=[edge_trace, node_trace])
+ fig.update_layout(
+ template="plotly_dark",
+ title=dict(text="Dependency lineage", font=dict(size=13, color=C_MUTED)),
+ showlegend=False,
+ hovermode="closest",
+ height=420,
+ paper_bgcolor=C_PANEL,
+ plot_bgcolor=C_PANEL,
+ xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
+ yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
+ margin=dict(t=40, b=20, l=20, r=20),
+ font=dict(family="Inter, system-ui, sans-serif", color=C_TEXT),
+ )
+ return fig
+
+
+def _empty_graph(message: str) -> go.Figure:
+ fig = go.Figure()
+ fig.update_layout(
+ template="plotly_dark",
+ height=420,
+ paper_bgcolor=C_PANEL,
+ plot_bgcolor=C_PANEL,
+ xaxis=dict(visible=False),
+ yaxis=dict(visible=False),
+ annotations=[
+ dict(
+ text=message,
+ xref="paper",
+ yref="paper",
+ x=0.5,
+ y=0.5,
+ showarrow=False,
+ font=dict(size=13, color=C_MUTED),
+ )
+ ],
+ )
+ return fig
diff --git a/morphsql/intelligence/rag.py b/morphsql/intelligence/rag.py
new file mode 100644
index 0000000000000000000000000000000000000000..531463863e08c70ec67c48b196353e5239b4ab46
--- /dev/null
+++ b/morphsql/intelligence/rag.py
@@ -0,0 +1,168 @@
+"""RAG over platform behavior knowledge base (embeddings optional)."""
+
+from __future__ import annotations
+
+import math
+import re
+from dataclasses import dataclass
+
+from morphsql.knowledge.behavior import BEHAVIOR_DIFFERENCES, BehaviorDifference
+
+
+@dataclass
+class RetrievedDoc:
+ name: str
+ score: float
+ source_platform: str
+ target_platform: str
+ description: str
+ recommendation: str
+ severity: str
+
+
+def _tokenize(text: str) -> set[str]:
+ return {t for t in re.findall(r"[a-z0-9_]+", text.lower()) if len(t) > 2}
+
+
+def _keyword_score(query: str, doc: BehaviorDifference) -> float:
+ q = _tokenize(query)
+ if not q:
+ return 0.0
+ blob = " ".join(
+ [
+ doc.name,
+ doc.description,
+ doc.impact,
+ doc.recommendation,
+ doc.source_platform,
+ doc.target_platform,
+ doc.detection_pattern,
+ ]
+ )
+ d = _tokenize(blob)
+ if not d:
+ return 0.0
+ overlap = len(q & d)
+ return overlap / math.sqrt(len(q) * len(d))
+
+
+class BehaviorRAG:
+ """Retrieve relevant behavior diffs for a natural-language or SQL query."""
+
+ def __init__(self):
+ self._embeddings = None
+ self._matrix = None
+ self._docs = list(BEHAVIOR_DIFFERENCES)
+ self._backend = "keyword"
+
+ def _try_load_embeddings(self) -> None:
+ if self._embeddings is not None:
+ return
+ try:
+ from sentence_transformers import SentenceTransformer
+ import numpy as np
+
+ model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
+ texts = [
+ f"{d.name}. {d.description}. {d.recommendation}. "
+ f"{d.source_platform} to {d.target_platform}."
+ for d in self._docs
+ ]
+ self._matrix = model.encode(texts, normalize_embeddings=True)
+ self._embeddings = model
+ self._backend = "sentence-transformers"
+ _ = np # silence unused if encode returns list
+ except Exception:
+ self._embeddings = False
+ self._backend = "keyword"
+
+ def retrieve(
+ self,
+ query: str,
+ top_k: int = 5,
+ source: str | None = None,
+ target: str | None = None,
+ ) -> list[RetrievedDoc]:
+ query = (query or "").strip()
+ if not query:
+ return []
+
+ self._try_load_embeddings()
+ docs = self._docs
+ if source:
+ docs = [d for d in docs if d.source_platform == source]
+ if target:
+ # accept snowflake for dbt-snowflake
+ tgt = "snowflake" if target.startswith("dbt") else target
+ docs = [d for d in docs if d.target_platform == tgt]
+ if not docs:
+ docs = self._docs
+
+ scored: list[tuple[float, BehaviorDifference]] = []
+ if self._backend == "sentence-transformers" and self._embeddings and self._matrix is not None:
+ import numpy as np
+
+ q = self._embeddings.encode([query], normalize_embeddings=True)[0]
+ # map filtered docs back to indices
+ idx_map = {id(d): i for i, d in enumerate(self._docs)}
+ for d in docs:
+ i = idx_map.get(id(d))
+ if i is None:
+ continue
+ score = float(np.dot(self._matrix[i], q))
+ scored.append((score, d))
+ else:
+ for d in docs:
+ scored.append((_keyword_score(query, d), d))
+
+ scored.sort(key=lambda x: x[0], reverse=True)
+ out: list[RetrievedDoc] = []
+ for score, d in scored[:top_k]:
+ if score <= 0 and self._backend == "keyword":
+ continue
+ out.append(
+ RetrievedDoc(
+ name=d.name,
+ score=round(score, 4),
+ source_platform=d.source_platform,
+ target_platform=d.target_platform,
+ description=d.description,
+ recommendation=d.recommendation,
+ severity=d.severity,
+ )
+ )
+ return out
+
+ def answer(self, query: str, source: str = "vertica", target: str = "snowflake") -> str:
+ hits = self.retrieve(query, top_k=5, source=source, target=target)
+ if not hits:
+ hits = self.retrieve(query, top_k=5)
+ lines = [
+ f"### Behavior RAG ({self._backend})",
+ "",
+ f"**Query:** {query}",
+ f"**Route filter:** {source} → {target}",
+ "",
+ ]
+ if not hits:
+ lines.append("No matching behavior differences found. Try keywords like NULL, timezone, ROWNUM, MERGE.")
+ return "\n".join(lines)
+ for i, h in enumerate(hits, 1):
+ lines.append(
+ f"**{i}. [{h.severity.upper()}] {h.name}** "
+ f"({h.source_platform}→{h.target_platform}) · score={h.score}"
+ )
+ lines.append(f"- {h.description}")
+ lines.append(f"- Recommendation: {h.recommendation}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+_rag_singleton: BehaviorRAG | None = None
+
+
+def get_rag() -> BehaviorRAG:
+ global _rag_singleton
+ if _rag_singleton is None:
+ _rag_singleton = BehaviorRAG()
+ return _rag_singleton
diff --git a/morphsql/intelligence/rationalization.py b/morphsql/intelligence/rationalization.py
new file mode 100644
index 0000000000000000000000000000000000000000..3666696294a4dd8297a8d3bd6e428eafaaedf260
--- /dev/null
+++ b/morphsql/intelligence/rationalization.py
@@ -0,0 +1,65 @@
+"""Workload rationalization recommendations."""
+
+from __future__ import annotations
+
+from morphsql.models import MigrationCategory, MigrationReport
+from morphsql.risk.scorer import recommend_workload_action
+
+
+def generate_rationalization(report: MigrationReport) -> str:
+ """Generate workload rationalization analysis."""
+ migrate: list[str] = []
+ review: list[str] = []
+ rewrite: list[str] = []
+ retire: list[str] = []
+
+ for obj in report.objects:
+ action = recommend_workload_action(obj)
+ entry = f"**{obj.name}** ({obj.object_type.value}, complexity {obj.complexity_score}/100)"
+ if action == "retire" or obj.migration_category == MigrationCategory.RETIRE:
+ retire.append(entry)
+ elif action in ("manual_redesign", "rewrite"):
+ rewrite.append(entry)
+ elif action == "review_and_migrate":
+ review.append(entry)
+ else:
+ migrate.append(entry)
+
+ lines = [
+ "### Workload rationalization",
+ "",
+ "Enterprise migrations often fail by moving everything. "
+ "This analysis categorizes each object by recommended action.",
+ "",
+ f"| Action | Count |",
+ f"|--------|-------|",
+ f"| Migrate as-is | {len(migrate)} |",
+ f"| Review then migrate | {len(review)} |",
+ f"| Rewrite / redesign | {len(rewrite)} |",
+ f"| Retire / consolidate | {len(retire) + len(report.retirement_candidates)} |",
+ "",
+ ]
+
+ if migrate:
+ lines += ["#### Migrate", ""] + [f"- {e}" for e in migrate[:10]] + [""]
+ if review:
+ lines += ["#### Review required", ""] + [f"- {e}" for e in review[:10]] + [""]
+ if rewrite:
+ lines += ["#### Rewrite", ""] + [f"- {e}" for e in rewrite[:10]] + [""]
+ if retire or report.retirement_candidates:
+ lines += ["#### Retire / consolidate", ""]
+ for e in retire[:8]:
+ lines.append(f"- {e}")
+ for r in report.retirement_candidates[:5]:
+ lines.append(f"- {r}")
+
+ scope_reduction = len(retire) + len(report.retirement_candidates)
+ if scope_reduction:
+ lines += [
+ "",
+ f"**Scope reduction opportunity:** {scope_reduction} objects can be "
+ f"retired or consolidated, reducing migration effort by approximately "
+ f"{scope_reduction / max(len(report.objects), 1) * 100:.0f}%.",
+ ]
+
+ return "\n".join(lines)
diff --git a/morphsql/intelligence/runbook.py b/morphsql/intelligence/runbook.py
new file mode 100644
index 0000000000000000000000000000000000000000..65159c2336e5ef64bc3b56859cdd3c109408d5eb
--- /dev/null
+++ b/morphsql/intelligence/runbook.py
@@ -0,0 +1,113 @@
+"""Migration runbook and executive summary generation."""
+
+from __future__ import annotations
+
+from morphsql.models import MigrationReport
+from morphsql.risk.scorer import recommend_workload_action
+
+
+def generate_runbook(report: MigrationReport) -> str:
+ """Generate a markdown migration runbook from a scan report."""
+ d = report.dashboard
+ src = report.source_dialect.value.upper()
+ tgt = report.target_dialect.value.upper()
+
+ lines = [
+ f"# Migration Runbook",
+ f"",
+ f"**Source:** {src} · **Target:** {tgt} · **Repository:** `{report.repository_path}`",
+ f"",
+ f"## Executive summary",
+ f"",
+ f"| Metric | Value |",
+ f"|--------|-------|",
+ f"| Objects discovered | {d.total_objects} |",
+ f"| Auto-migratable | {d.auto_migratable} |",
+ f"| Requires review | {d.requires_review} |",
+ f"| Manual redesign | {d.requires_redesign} |",
+ f"| Retire / consolidate | {d.recommended_retirement} |",
+ f"| Migration risk score | {d.migration_risk_score:.0f} / 100 |",
+ f"| Lineage coverage | {d.lineage_coverage_pct:.0f}% |",
+ f"| Validation pass rate | {d.validation_passed_pct:.0f}% |",
+ f"| Est. annual savings | ${d.estimated_annual_savings_usd[0]:,.0f} – ${d.estimated_annual_savings_usd[1]:,.0f} |",
+ f"",
+ f"## Recommended phases",
+ f"",
+ f"### Phase 1 — Discovery (complete)",
+ f"- Scanned {d.total_objects} objects",
+ f"- Built dependency lineage ({d.lineage_coverage_pct:.0f}% coverage)",
+ f"- Identified {d.recommended_retirement} retirement candidates",
+ f"",
+ f"### Phase 2 — Low-risk conversion",
+ f"- Migrate {d.auto_migratable} automatically migratable objects",
+ f"- Generate dbt staging/intermediate/mart structure",
+ f"- Run reconciliation tests (row count, null rate, checksums)",
+ f"",
+ f"### Phase 3 — Review queue",
+ f"- Engineering review for {d.requires_review} objects",
+ f"- Address behavior warnings ({len(report.behavior_warnings)} detected)",
+ f"",
+ f"### Phase 4 — Redesign",
+ f"- Manual redesign for {d.requires_redesign} high-complexity objects",
+ f"- Decompose stored procedures into modular pipelines",
+ f"",
+ f"### Phase 5 — Cutover",
+ f"- Parallel run source vs target",
+ f"- Validate business metrics within tolerance",
+ f"- Retire legacy platform after sign-off",
+ f"",
+ f"## Object action plan",
+ f"",
+ f"| Object | Type | Action | Complexity | Risk |",
+ f"|--------|------|--------|------------|------|",
+ ]
+
+ for obj in sorted(report.objects, key=lambda o: o.complexity_score, reverse=True):
+ action = recommend_workload_action(obj).replace("_", " ")
+ lines.append(
+ f"| {obj.name} | {obj.object_type.value} | {action} "
+ f"| {obj.complexity_score} | {obj.risk_level.value} |"
+ )
+
+ if report.behavior_warnings:
+ lines += ["", "## Platform behavior risks", ""]
+ for w in report.behavior_warnings:
+ lines.append(f"- {w}")
+
+ if report.retirement_candidates:
+ lines += ["", "## Retirement candidates", ""]
+ for r in report.retirement_candidates:
+ lines.append(f"- {r}")
+
+ lines += [
+ "",
+ "## Validation checklist",
+ "",
+ "- [ ] Row count reconciliation per object",
+ "- [ ] Null rate comparison on string columns",
+ "- [ ] Aggregate metric tolerance (financial columns)",
+ "- [ ] Incremental load replay test",
+ "- [ ] Procedure parameter binding verified",
+ "- [ ] dbt schema tests generated and passing",
+ "",
+ "---",
+ "*Generated by MorphSQL*",
+ ]
+
+ return "\n".join(lines)
+
+
+def generate_executive_summary(report: MigrationReport) -> str:
+ """Short executive summary for dashboard display."""
+ d = report.dashboard
+ savings = d.estimated_annual_savings_usd
+ return (
+ f"Scanned **{d.total_objects} objects** from `{report.repository_path}`. "
+ f"**{d.auto_migratable}** are ready for automatic migration; "
+ f"**{d.requires_review}** need engineering review; "
+ f"**{d.requires_redesign}** require redesign. "
+ f"Average risk score **{d.migration_risk_score:.0f}/100**. "
+ f"Estimated annual savings **${savings[0]:,.0f}–${savings[1]:,.0f}** "
+ f"after moving from {report.source_dialect.value.upper()} to "
+ f"{report.target_dialect.value.upper()}."
+ )
diff --git a/morphsql/knowledge/__init__.py b/morphsql/knowledge/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..86e329f5c6f57fc32f1e418cb6332e81c88c255f
--- /dev/null
+++ b/morphsql/knowledge/__init__.py
@@ -0,0 +1,15 @@
+"""Knowledge base package."""
+
+from morphsql.knowledge.behavior import (
+ BEHAVIOR_DIFFERENCES,
+ BehaviorDifference,
+ format_behavior_warning,
+ get_behavior_warnings,
+)
+
+__all__ = [
+ "BEHAVIOR_DIFFERENCES",
+ "BehaviorDifference",
+ "format_behavior_warning",
+ "get_behavior_warnings",
+]
diff --git a/morphsql/knowledge/behavior.py b/morphsql/knowledge/behavior.py
new file mode 100644
index 0000000000000000000000000000000000000000..85a7bc38d664552729ac48ef840164918aba4803
--- /dev/null
+++ b/morphsql/knowledge/behavior.py
@@ -0,0 +1,227 @@
+"""Platform behavior difference knowledge base."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class BehaviorDifference:
+ name: str
+ source_platform: str
+ target_platform: str
+ description: str
+ impact: str
+ detection_pattern: str
+ recommendation: str
+ severity: str = "medium"
+
+
+BEHAVIOR_DIFFERENCES: list[BehaviorDifference] = [
+ BehaviorDifference(
+ name="empty_string_vs_null",
+ source_platform="oracle",
+ target_platform="snowflake",
+ description="Oracle treats empty strings as NULL; Snowflake preserves empty strings.",
+ impact="Row count and NULL rate differences in string columns.",
+ detection_pattern=r"''|EMPTY",
+ recommendation="Use NULLIF(TRIM(column), '') to normalize empty strings.",
+ severity="high",
+ ),
+ BehaviorDifference(
+ name="empty_string_vs_null_vertica",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="Vertica and Snowflake may handle empty strings differently in comparisons.",
+ impact="Filter predicates may include/exclude different rows.",
+ detection_pattern=r"=\s*''|<> ''|IS NULL",
+ recommendation="Explicitly handle empty strings with NULLIF and COALESCE.",
+ severity="medium",
+ ),
+ BehaviorDifference(
+ name="integer_division",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="Integer division behavior may differ between platforms.",
+ impact="Numeric calculations may produce truncated vs decimal results.",
+ detection_pattern=r"/\s*\d+|/\s*\w+",
+ recommendation="Use explicit CAST to DECIMAL or FLOAT before division.",
+ severity="medium",
+ ),
+ BehaviorDifference(
+ name="case_sensitivity",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="Vertica is case-sensitive for quoted identifiers; Snowflake uppercases unquoted.",
+ impact="Column and table name resolution failures or wrong joins.",
+ detection_pattern=r'"[^"]+"',
+ recommendation="Standardize identifier casing and use consistent quoting.",
+ severity="high",
+ ),
+ BehaviorDifference(
+ name="timezone_behavior",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="TIMESTAMP WITH TIME ZONE handling differs across platforms.",
+ impact="Date boundary comparisons may shift by timezone offset.",
+ detection_pattern=r"TIMESTAMP|TIMEZONE|AT TIME ZONE",
+ recommendation="Normalize all timestamps to UTC using CONVERT_TIMEZONE.",
+ severity="high",
+ ),
+ BehaviorDifference(
+ name="date_trunc_semantics",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="DATE_TRUNC unit boundaries may differ at month/quarter/year edges.",
+ impact="Aggregated period metrics may not reconcile.",
+ detection_pattern=r"DATE_TRUNC|TRUNC\s*\(",
+ recommendation="Validate period boundaries with sample date edge cases.",
+ severity="medium",
+ ),
+ BehaviorDifference(
+ name="floating_point_precision",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="FLOAT/DOUBLE precision and rounding differ between engines.",
+ impact="Checksum and aggregate metric mismatches within tolerance.",
+ detection_pattern=r"\bFLOAT\b|\bDOUBLE\b|\bREAL\b",
+ recommendation="Use DECIMAL for financial metrics; set numeric tolerance in validation.",
+ severity="medium",
+ ),
+ BehaviorDifference(
+ name="default_ordering",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="Results without ORDER BY have no guaranteed row order.",
+ impact="Row-by-row comparison tests may show false differences.",
+ detection_pattern=r"SELECT(?!.*ORDER BY)",
+ recommendation="Always add deterministic ORDER BY in reconciliation queries.",
+ severity="low",
+ ),
+ BehaviorDifference(
+ name="merge_semantics",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="MERGE duplicate-match behavior may differ when multiple source rows match.",
+ impact="Upsert operations may update different rows.",
+ detection_pattern=r"\bMERGE\s+INTO\b",
+ recommendation="Deduplicate source before MERGE; add unique key constraints.",
+ severity="high",
+ ),
+ BehaviorDifference(
+ name="boolean_handling",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="Boolean type support and casting differ; Vertica often uses INT flags.",
+ impact="Filter conditions on boolean-like columns may behave differently.",
+ detection_pattern=r"\bBOOLEAN\b|=\s*[01]\s",
+ recommendation="Explicitly cast boolean flags: column::BOOLEAN or column = 1.",
+ severity="medium",
+ ),
+ BehaviorDifference(
+ name="zeroifnull_isnull",
+ source_platform="vertica",
+ target_platform="snowflake",
+ description="Vertica ZEROIFNULL/ISNULL functions map to COALESCE/NVL differently.",
+ impact="NULL handling in arithmetic expressions produces different results.",
+ detection_pattern=r"ZEROIFNULL|ISNULL|NVL",
+ recommendation="Replace with COALESCE(column, 0) and validate NULL cases.",
+ severity="medium",
+ ),
+ BehaviorDifference(
+ name="sequence_handling",
+ source_platform="oracle",
+ target_platform="snowflake",
+ description="Oracle sequences vs Snowflake sequences/auto-increment differ.",
+ impact="Generated key values will not match between source and target.",
+ detection_pattern=r"\bSEQUENCE\b|\.NEXTVAL|AUTO_INCREMENT",
+ recommendation="Use Snowflake sequences; do not reconcile auto-generated keys.",
+ severity="low",
+ ),
+ BehaviorDifference(
+ name="rownum_vs_qualify",
+ source_platform="oracle",
+ target_platform="snowflake",
+ description="Oracle ROWNUM filtering does not map 1:1 to Snowflake QUALIFY/ROW_NUMBER.",
+ impact="Top-N queries may return different rows.",
+ detection_pattern=r"\bROWNUM\b",
+ recommendation="Rewrite as QUALIFY ROW_NUMBER() OVER (...) <= N.",
+ severity="high",
+ ),
+ BehaviorDifference(
+ name="redshift_encoding",
+ source_platform="redshift",
+ target_platform="snowflake",
+ description="Redshift column ENCODE / DISTKEY / SORTKEY have no Snowflake equivalent.",
+ impact="Physical design must be redesigned (clustering keys, search optimization).",
+ detection_pattern=r"\b(DISTKEY|SORTKEY|DISTSTYLE|ENCODE)\b",
+ recommendation="Drop distribution clauses; model clustering separately in Snowflake.",
+ severity="medium",
+ ),
+ BehaviorDifference(
+ name="redshift_listagg",
+ source_platform="redshift",
+ target_platform="bigquery",
+ description="LISTAGG / WITHIN GROUP semantics differ from BigQuery STRING_AGG.",
+ impact="Concatenated string order or NULL handling may differ.",
+ detection_pattern=r"\bLISTAGG\b",
+ recommendation="Validate STRING_AGG ORDER BY and NULL treatment.",
+ severity="medium",
+ ),
+ BehaviorDifference(
+ name="bigquery_struct",
+ source_platform="bigquery",
+ target_platform="snowflake",
+ description="BigQuery STRUCT/ARRAY types map to Snowflake VARIANT/ARRAY with different access paths.",
+ impact="Nested field projections may fail or need FLATTEN.",
+ detection_pattern=r"\b(STRUCT|ARRAY)\s*<|\bUNNEST\b",
+ recommendation="Model nested data as VARIANT and rewrite UNNEST to FLATTEN.",
+ severity="high",
+ ),
+ BehaviorDifference(
+ name="snowflake_variant",
+ source_platform="snowflake",
+ target_platform="bigquery",
+ description="Snowflake VARIANT/FLATTEN patterns need BigQuery JSON/UNNEST rewrites.",
+ impact="Semi-structured queries will not transpile cleanly.",
+ detection_pattern=r"\b(VARIANT|FLATTEN|OBJECT_INSERT)\b",
+ recommendation="Rewrite to JSON functions and UNNEST in BigQuery.",
+ severity="high",
+ ),
+ BehaviorDifference(
+ name="vertica_to_bigquery_types",
+ source_platform="vertica",
+ target_platform="bigquery",
+ description="Vertica numeric/date types map to BigQuery INT64/FLOAT64/DATE with different casting rules.",
+ impact="Implicit casts that worked in Vertica may fail in BigQuery.",
+ detection_pattern=r"\b(CAST|::|NUMERIC|FLOAT)\b",
+ recommendation="Use explicit CAST to BigQuery types (INT64, FLOAT64, NUMERIC).",
+ severity="medium",
+ ),
+]
+
+
+def get_behavior_warnings(
+ sql: str,
+ source: str,
+ target: str,
+) -> list[BehaviorDifference]:
+ """Return applicable behavior warnings for a SQL object."""
+ import re
+
+ warnings: list[BehaviorDifference] = []
+ for diff in BEHAVIOR_DIFFERENCES:
+ if diff.source_platform != source:
+ continue
+ if diff.target_platform != target:
+ continue
+ if re.search(diff.detection_pattern, sql, re.IGNORECASE):
+ warnings.append(diff)
+ return warnings
+
+
+def format_behavior_warning(diff: BehaviorDifference) -> str:
+ return (
+ f"[{diff.severity.upper()}] {diff.name}: {diff.description} "
+ f"Recommendation: {diff.recommendation}"
+ )
diff --git a/morphsql/lineage/__init__.py b/morphsql/lineage/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6ff7c7dad1ad71b9950cac05a773e3edba1b589b
--- /dev/null
+++ b/morphsql/lineage/__init__.py
@@ -0,0 +1,21 @@
+"""Lineage analysis package."""
+
+from morphsql.lineage.builder import (
+ build_column_lineage_for_object,
+ build_lineage_graph,
+ build_table_lineage,
+ detect_circular_dependencies,
+ find_orphaned_tables,
+ format_lineage_tree,
+ get_lineage_chain,
+)
+
+__all__ = [
+ "build_column_lineage_for_object",
+ "build_lineage_graph",
+ "build_table_lineage",
+ "detect_circular_dependencies",
+ "find_orphaned_tables",
+ "format_lineage_tree",
+ "get_lineage_chain",
+]
diff --git a/morphsql/lineage/builder.py b/morphsql/lineage/builder.py
new file mode 100644
index 0000000000000000000000000000000000000000..943a74da5c4e185cd547a0fa8b164e6beabcb6e3
--- /dev/null
+++ b/morphsql/lineage/builder.py
@@ -0,0 +1,243 @@
+"""Table and column-level lineage construction."""
+
+from __future__ import annotations
+
+import re
+from collections import defaultdict
+
+import networkx as nx
+
+from morphsql.models import ColumnLineage, Dialect, MigrationObject, TableLineage
+from morphsql.parser.sql_parser import extract_columns, extract_tables, parse_sql_multi
+
+
+def build_lineage_graph(
+ objects: list[MigrationObject],
+ dialect: Dialect,
+) -> nx.DiGraph:
+ """Build a directed dependency graph from migration objects."""
+ graph = nx.DiGraph()
+
+ for obj in objects:
+ graph.add_node(
+ obj.name,
+ object_type=obj.object_type.value,
+ source_path=obj.source_path,
+ )
+
+ tables_read = extract_tables(obj.source_sql, dialect)
+ tables_written = _extract_write_targets(obj.source_sql)
+
+ for table in tables_read:
+ graph.add_node(table, object_type="table")
+ graph.add_edge(table, obj.name, relation="reads")
+
+ for table in tables_written:
+ graph.add_node(table, object_type="table")
+ graph.add_edge(obj.name, table, relation="writes")
+
+ return graph
+
+
+def build_table_lineage(
+ objects: list[MigrationObject],
+ dialect: Dialect,
+) -> list[TableLineage]:
+ """Build table-level lineage with upstream/downstream relationships."""
+ graph = build_lineage_graph(objects, dialect)
+ lineages: list[TableLineage] = []
+
+ table_nodes = [
+ n for n, d in graph.nodes(data=True)
+ if d.get("object_type") in ("table", "view", "temp_table")
+ ]
+
+ for table in sorted(set(table_nodes)):
+ upstream = sorted(
+ pred for pred in graph.predecessors(table)
+ if graph.nodes[pred].get("object_type") != "table"
+ )
+ downstream = sorted(
+ succ for succ in graph.successors(table)
+ if graph.nodes[succ].get("object_type") != "table"
+ )
+
+ columns = _build_column_lineage(table, objects, dialect)
+
+ lineages.append(
+ TableLineage(
+ table=table,
+ upstream=upstream,
+ downstream=downstream,
+ columns=columns,
+ )
+ )
+
+ return lineages
+
+
+def build_column_lineage_for_object(
+ obj: MigrationObject,
+ dialect: Dialect,
+) -> list[ColumnLineage]:
+ """Extract column-level lineage for a single object."""
+ columns_map = extract_columns(obj.source_sql, dialect)
+ lineages: list[ColumnLineage] = []
+
+ for table, cols in columns_map.items():
+ for col in set(cols):
+ lineages.append(
+ ColumnLineage(
+ column=col,
+ table=table,
+ source_columns=[f"{table}.{col}"],
+ transformations=_detect_transformations(obj.source_sql, col),
+ downstream=[],
+ )
+ )
+
+ return lineages
+
+
+def get_lineage_chain(
+ graph: nx.DiGraph,
+ node: str,
+ direction: str = "downstream",
+ max_depth: int = 10,
+) -> list[str]:
+ """Get lineage chain for a node."""
+ chain: list[str] = []
+ visited: set[str] = set()
+
+ def _walk(current: str, depth: int) -> None:
+ if depth > max_depth or current in visited:
+ return
+ visited.add(current)
+ neighbors = (
+ list(graph.successors(current))
+ if direction == "downstream"
+ else list(graph.predecessors(current))
+ )
+ for neighbor in neighbors:
+ chain.append(neighbor)
+ _walk(neighbor, depth + 1)
+
+ _walk(node, 0)
+ return chain
+
+
+def format_lineage_tree(
+ graph: nx.DiGraph,
+ root: str,
+) -> str:
+ """Format lineage as an ASCII tree."""
+ lines: list[str] = [root]
+ visited: set[str] = {root}
+
+ def _render(node: str, prefix: str, depth: int) -> None:
+ if depth > 8:
+ return
+ successors = list(graph.successors(node))
+ for i, succ in enumerate(successors):
+ if succ in visited:
+ continue
+ visited.add(succ)
+ connector = "└── " if i == len(successors) - 1 else "├── "
+ lines.append(f"{prefix}{connector}{succ}")
+ extension = " " if i == len(successors) - 1 else "│ "
+ _render(succ, prefix + extension, depth + 1)
+
+ _render(root, "", 0)
+ return "\n".join(lines)
+
+
+def detect_circular_dependencies(graph: nx.DiGraph) -> list[list[str]]:
+ """Detect circular dependencies in the lineage graph."""
+ try:
+ cycles = list(nx.simple_cycles(graph))
+ return cycles
+ except nx.NetworkXError:
+ return []
+
+
+def find_orphaned_tables(
+ graph: nx.DiGraph,
+ objects: list[MigrationObject],
+) -> list[str]:
+ """Find tables with no downstream consumers."""
+ all_tables = set()
+ consumed = set()
+
+ for obj in objects:
+ for table in extract_tables(obj.source_sql, Dialect.VERTICA):
+ all_tables.add(table)
+ for table in _extract_write_targets(obj.source_sql):
+ all_tables.add(table)
+ if graph.out_degree(table) == 0:
+ consumed.add(table)
+
+ orphans = [t for t in all_tables if graph.out_degree(t) == 0 and t not in consumed]
+ return sorted(orphans)
+
+
+def _extract_write_targets(sql: str) -> set[str]:
+ """Extract tables written to (INSERT/CREATE/MERGE targets)."""
+ targets: set[str] = set()
+ patterns = [
+ r"INSERT\s+INTO\s+([\w.]+)",
+ r"CREATE\s+(?:OR\s+REPLACE\s+)?(?:LOCAL\s+TEMP\s+|TEMP\s+|TEMPORARY\s+)?TABLE\s+([\w.]+)",
+ r"MERGE\s+INTO\s+([\w.]+)",
+ r"UPDATE\s+([\w.]+)\s+SET",
+ r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+([\w.]+)",
+ ]
+ for pattern in patterns:
+ for match in re.finditer(pattern, sql, re.IGNORECASE):
+ targets.add(match.group(1).upper())
+ return targets
+
+
+def _build_column_lineage(
+ table: str,
+ objects: list[MigrationObject],
+ dialect: Dialect,
+) -> list[ColumnLineage]:
+ columns: dict[str, ColumnLineage] = {}
+
+ for obj in objects:
+ if table.upper() not in extract_tables(obj.source_sql, dialect):
+ if table.upper() not in _extract_write_targets(obj.source_sql):
+ continue
+
+ for col_lineage in build_column_lineage_for_object(obj, dialect):
+ key = f"{col_lineage.table}.{col_lineage.column}"
+ if key not in columns:
+ columns[key] = col_lineage
+ else:
+ columns[key].downstream.extend(col_lineage.source_columns)
+
+ return list(columns.values())
+
+
+def _detect_transformations(sql: str, column: str) -> list[str]:
+ """Detect transformation patterns involving a column."""
+ transformations: list[str] = []
+ col_escaped = re.escape(column.upper())
+
+ patterns = {
+ "aggregation": rf"\b(SUM|COUNT|AVG|MIN|MAX)\s*\([^)]*{col_escaped}",
+ "case_logic": rf"\bCASE\b[\s\S]*?{col_escaped}",
+ "window_function": rf"{col_escaped}[^)]*\bOVER\s*\(",
+ "cast": rf"CAST\s*\(\s*{col_escaped}",
+ "coalesce": rf"\b(COALESCE|NVL|IFNULL|ZEROIFNULL)\s*\([^)]*{col_escaped}",
+ "date_trunc": rf"\b(DATE_TRUNC|TRUNC)\s*\([^)]*{col_escaped}",
+ }
+
+ sql_upper = sql.upper()
+ for name, pattern in patterns.items():
+ try:
+ if re.search(pattern, sql_upper, re.IGNORECASE):
+ transformations.append(name)
+ except re.error:
+ continue
+
+ return transformations
diff --git a/morphsql/models.py b/morphsql/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..9f86068d4a6e4e12cc7ae1973998cf0af4361f01
--- /dev/null
+++ b/morphsql/models.py
@@ -0,0 +1,128 @@
+"""Core data models for MorphSQL."""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class Dialect(str, Enum):
+ VERTICA = "vertica"
+ ORACLE = "oracle"
+ SNOWFLAKE = "snowflake"
+ BIGQUERY = "bigquery"
+ REDSHIFT = "redshift"
+ DBT_SNOWFLAKE = "dbt-snowflake"
+ PANDAS = "pandas"
+ PYSPARK = "pyspark"
+
+
+class ObjectType(str, Enum):
+ TABLE = "table"
+ VIEW = "view"
+ STORED_PROCEDURE = "stored_procedure"
+ FUNCTION = "function"
+ SQL_SCRIPT = "sql_script"
+ DBT_MODEL = "dbt_model"
+ AIRFLOW_DAG = "airflow_dag"
+ TEMP_TABLE = "temp_table"
+ UNKNOWN = "unknown"
+
+
+class RiskLevel(str, Enum):
+ LOW = "low"
+ MEDIUM = "medium"
+ HIGH = "high"
+ CRITICAL = "critical"
+
+
+class MigrationCategory(str, Enum):
+ AUTO_MIGRATE = "automatically_migratable"
+ AUTO_WITH_REVIEW = "automatically_migratable_with_review"
+ PARTIAL = "partially_migratable"
+ MANUAL_REDESIGN = "manual_redesign_required"
+ RETIRE = "retire_or_consolidate"
+
+
+class ColumnLineage(BaseModel):
+ column: str
+ table: str
+ source_columns: list[str] = Field(default_factory=list)
+ transformations: list[str] = Field(default_factory=list)
+ downstream: list[str] = Field(default_factory=list)
+
+
+class TableLineage(BaseModel):
+ table: str
+ upstream: list[str] = Field(default_factory=list)
+ downstream: list[str] = Field(default_factory=list)
+ columns: list[ColumnLineage] = Field(default_factory=list)
+
+
+class RiskFactor(BaseModel):
+ name: str
+ score: int
+ description: str
+ category: str = "complexity"
+
+
+class MigrationObject(BaseModel):
+ name: str
+ object_type: ObjectType
+ source_path: str = ""
+ source_sql: str = ""
+ target_sql: str = ""
+ complexity_score: int = 0
+ risk_level: RiskLevel = RiskLevel.LOW
+ risk_factors: list[RiskFactor] = Field(default_factory=list)
+ migration_category: MigrationCategory = MigrationCategory.AUTO_MIGRATE
+ conversion_confidence: float = 0.0
+ auto_converted: list[str] = Field(default_factory=list)
+ requires_review: list[str] = Field(default_factory=list)
+ unsupported_features: list[str] = Field(default_factory=list)
+ dependencies: list[str] = Field(default_factory=list)
+ downstream_count: int = 0
+ business_rules: list[str] = Field(default_factory=list)
+ dbt_models: list[str] = Field(default_factory=list)
+ tests_generated: list[str] = Field(default_factory=list)
+ metadata: dict[str, Any] = Field(default_factory=dict)
+
+
+class DashboardMetrics(BaseModel):
+ total_objects: int = 0
+ auto_migratable: int = 0
+ requires_review: int = 0
+ requires_redesign: int = 0
+ recommended_retirement: int = 0
+ conversion_completed_pct: float = 0.0
+ validation_passed_pct: float = 0.0
+ lineage_coverage_pct: float = 0.0
+ test_coverage_pct: float = 0.0
+ migration_risk_score: float = 0.0
+ estimated_annual_savings_usd: tuple[float, float] = (0.0, 0.0)
+
+
+class ValidationResult(BaseModel):
+ object_name: str
+ check_name: str
+ passed: bool
+ source_value: str | float | int | None = None
+ target_value: str | float | int | None = None
+ difference: str | float | int | None = None
+ root_cause: str = ""
+ recommendation: str = ""
+
+
+class MigrationReport(BaseModel):
+ source_dialect: Dialect
+ target_dialect: Dialect
+ repository_path: str
+ objects: list[MigrationObject] = Field(default_factory=list)
+ lineage: list[TableLineage] = Field(default_factory=list)
+ dashboard: DashboardMetrics = Field(default_factory=DashboardMetrics)
+ validation_results: list[ValidationResult] = Field(default_factory=list)
+ behavior_warnings: list[str] = Field(default_factory=list)
+ retirement_candidates: list[str] = Field(default_factory=list)
+ consolidation_opportunities: list[str] = Field(default_factory=list)
diff --git a/morphsql/parser/__init__.py b/morphsql/parser/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c41d3f3dc5106aa72501d84a76a97349c930f17e
--- /dev/null
+++ b/morphsql/parser/__init__.py
@@ -0,0 +1,19 @@
+"""SQL parser package."""
+
+from morphsql.parser.sql_parser import (
+ count_sql_complexity,
+ detect_unsupported_features,
+ extract_columns,
+ extract_tables,
+ parse_sql,
+ parse_sql_multi,
+)
+
+__all__ = [
+ "count_sql_complexity",
+ "detect_unsupported_features",
+ "extract_columns",
+ "extract_tables",
+ "parse_sql",
+ "parse_sql_multi",
+]
diff --git a/morphsql/parser/sql_parser.py b/morphsql/parser/sql_parser.py
new file mode 100644
index 0000000000000000000000000000000000000000..26bc2a1f739c4c540d0bee0f71eefa1eb972271e
--- /dev/null
+++ b/morphsql/parser/sql_parser.py
@@ -0,0 +1,210 @@
+"""SQL parsing utilities built on sqlglot."""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any
+
+import sqlglot
+from sqlglot import exp, parse_one
+from sqlglot.errors import ParseError
+
+from morphsql.models import Dialect
+
+# Suppress noisy sqlglot fallback warnings for Vertica-specific DDL
+logging.getLogger("sqlglot").setLevel(logging.ERROR)
+
+DIALECT_MAP = {
+ # Vertica is not a sqlglot dialect; PostgreSQL is the closest parser.
+ Dialect.VERTICA: "postgres",
+ Dialect.ORACLE: "oracle",
+ Dialect.SNOWFLAKE: "snowflake",
+ Dialect.BIGQUERY: "bigquery",
+ Dialect.REDSHIFT: "redshift",
+ Dialect.DBT_SNOWFLAKE: "snowflake",
+ Dialect.PANDAS: "postgres",
+ Dialect.PYSPARK: "postgres",
+}
+
+
+def get_sqlglot_dialect(dialect: Dialect) -> str:
+ return DIALECT_MAP.get(dialect, "snowflake")
+
+
+def parse_sql(sql: str, dialect: Dialect) -> exp.Expression | None:
+ """Parse SQL into an AST, returning None on failure."""
+ try:
+ return parse_one(sql, read=get_sqlglot_dialect(dialect))
+ except (ParseError, ValueError):
+ return None
+
+
+def parse_sql_multi(sql: str, dialect: Dialect) -> list[exp.Expression]:
+ """Parse multiple statements from a SQL script."""
+ try:
+ return sqlglot.parse(sql, read=get_sqlglot_dialect(dialect))
+ except (ParseError, ValueError):
+ return []
+
+
+def extract_tables(sql: str, dialect: Dialect) -> set[str]:
+ """Extract referenced table names from SQL."""
+ tables: set[str] = set()
+ statements = parse_sql_multi(sql, dialect)
+ if not statements:
+ # Fallback to generic/snowflake parser for dialects with limited sqlglot support
+ statements = parse_sql_multi(sql, Dialect.SNOWFLAKE)
+ for statement in statements:
+ for table in statement.find_all(exp.Table):
+ name = _table_name(table)
+ if name:
+ tables.add(name.upper())
+ return tables
+
+
+def extract_columns(sql: str, dialect: Dialect) -> dict[str, list[str]]:
+ """Extract column references grouped by table alias/name."""
+ columns: dict[str, list[str]] = {}
+ for statement in parse_sql_multi(sql, dialect):
+ for col in statement.find_all(exp.Column):
+ table = col.table or "_unqualified"
+ col_name = col.name.upper() if col.name else ""
+ if col_name:
+ columns.setdefault(table.upper(), []).append(col_name)
+ return columns
+
+
+def count_sql_complexity(sql: str, dialect: Dialect) -> dict[str, int]:
+ """Compute structural complexity metrics."""
+ statements = parse_sql_multi(sql, dialect)
+ if not statements:
+ return _regex_complexity(sql)
+
+ metrics = {
+ "statements": len(statements),
+ "ctes": 0,
+ "joins": 0,
+ "subqueries": 0,
+ "window_functions": 0,
+ "aggregations": 0,
+ "case_expressions": 0,
+ "temp_tables": 0,
+ "dynamic_sql": 0,
+ "cursors": 0,
+ "lines": sql.count("\n") + 1,
+ }
+
+ for stmt in statements:
+ metrics["ctes"] += len(list(stmt.find_all(exp.CTE)))
+ metrics["joins"] += len(list(stmt.find_all(exp.Join)))
+ metrics["subqueries"] += len(list(stmt.find_all(exp.Subquery)))
+ metrics["window_functions"] += len(list(stmt.find_all(exp.Window)))
+ metrics["aggregations"] += len(
+ list(stmt.find_all(exp.AggFunc)) + list(stmt.find_all(exp.Count))
+ )
+ metrics["case_expressions"] += len(list(stmt.find_all(exp.Case)))
+
+ metrics["temp_tables"] = len(re.findall(r"CREATE\s+(?:LOCAL\s+)?TEMP", sql, re.I))
+ metrics["dynamic_sql"] = len(re.findall(r"EXECUTE\s+IMMEDIATE|EXEC\s*\(", sql, re.I))
+ metrics["cursors"] = len(re.findall(r"\bCURSOR\b", sql, re.I))
+
+ return metrics
+
+
+def detect_unsupported_features(sql: str, source: Dialect, target: Dialect) -> list[str]:
+ """Detect platform-specific features that may not translate cleanly."""
+ unsupported: list[str] = []
+ sql_upper = sql.upper()
+
+ vertica_patterns = {
+ "PROJECTION": r"\bPROJECTION\b",
+ "SEGMENTED BY": r"\bSEGMENTED\s+BY\b",
+ "ENCODED BY": r"\bENCODED\s+BY\b",
+ "PARTITION BY (Vertica)": r"\bPARTITION\s+BY\s+\w+\s+GROUP\s+BY",
+ "ISNULL (Vertica)": r"\bISNULL\s*\(",
+ "ZEROIFNULL": r"\bZEROIFNULL\s*\(",
+ "NVL (Oracle-style in Vertica)": r"\bNVL\s*\(",
+ "TIMESERIES": r"\bTIMESERIES\b",
+ "INTERPOLATE": r"\bINTERPOLATE\b",
+ }
+
+ oracle_patterns = {
+ "CONNECT BY": r"\bCONNECT\s+BY\b",
+ "ROWNUM": r"\bROWNUM\b",
+ "DUAL table": r"\bFROM\s+DUAL\b",
+ "PL/SQL block": r"\bBEGIN\b.*\bEND\b",
+ "EXCEPTION handler": r"\bEXCEPTION\s+WHEN\b",
+ "PACKAGE": r"\bCREATE\s+(?:OR\s+REPLACE\s+)?PACKAGE\b",
+ }
+
+ redshift_patterns = {
+ "DISTSTYLE": r"\bDISTSTYLE\b",
+ "DISTKEY": r"\bDISTKEY\b",
+ "SORTKEY": r"\bSORTKEY\b",
+ "SUPER type": r"\bSUPER\b",
+ "Spectrum external": r"\bSPECTRUM\b|\bEXTERNAL\s+SCHEMA\b",
+ }
+
+ bigquery_patterns = {
+ "ARRAY/STRUCT": r"\b(ARRAY|STRUCT)\s*<",
+ "QUALIFY": r"\bQUALIFY\b",
+ "SCRIPTING DECLARE": r"\bDECLARE\s+\w+\s+(INT64|STRING|BOOL|FLOAT64)\b",
+ "SAFE. functions": r"\bSAFE\.\w+",
+ }
+
+ snowflake_patterns = {
+ "VARIANT/OBJECT": r"\b(VARIANT|OBJECT|ARRAY)\b",
+ "FLATTEN": r"\bFLATTEN\s*\(",
+ "MATCH_RECOGNIZE": r"\bMATCH_RECOGNIZE\b",
+ "JavaScript procedure": r"\bLANGUAGE\s+JAVASCRIPT\b",
+ }
+
+ patterns: dict[str, str] = {}
+ if source == Dialect.VERTICA:
+ patterns.update(vertica_patterns)
+ elif source == Dialect.ORACLE:
+ patterns.update(oracle_patterns)
+ elif source == Dialect.REDSHIFT:
+ patterns.update(redshift_patterns)
+ elif source == Dialect.BIGQUERY:
+ patterns.update(bigquery_patterns)
+ elif source == Dialect.SNOWFLAKE:
+ patterns.update(snowflake_patterns)
+
+ for feature, pattern in patterns.items():
+ if re.search(pattern, sql_upper):
+ unsupported.append(feature)
+
+ if target in (Dialect.SNOWFLAKE, Dialect.DBT_SNOWFLAKE, Dialect.BIGQUERY):
+ if re.search(r"\bEXECUTE\s+IMMEDIATE\b", sql_upper):
+ unsupported.append("Dynamic SQL (requires manual redesign)")
+
+ return unsupported
+
+
+def _table_name(table: exp.Table) -> str | None:
+ parts = []
+ for attr in ("catalog", "db", "name"):
+ val = getattr(table, attr, None)
+ if val:
+ parts.append(str(val))
+ return ".".join(parts) if parts else None
+
+
+def _regex_complexity(sql: str) -> dict[str, int]:
+ """Fallback complexity when parsing fails."""
+ return {
+ "statements": len(re.findall(r";", sql)) + 1,
+ "ctes": len(re.findall(r"\bWITH\b", sql, re.I)),
+ "joins": len(re.findall(r"\bJOIN\b", sql, re.I)),
+ "subqueries": len(re.findall(r"\(\s*SELECT\b", sql, re.I)),
+ "window_functions": len(re.findall(r"\bOVER\s*\(", sql, re.I)),
+ "aggregations": len(re.findall(r"\b(SUM|COUNT|AVG|MIN|MAX)\s*\(", sql, re.I)),
+ "case_expressions": len(re.findall(r"\bCASE\b", sql, re.I)),
+ "temp_tables": len(re.findall(r"CREATE\s+(?:LOCAL\s+)?TEMP", sql, re.I)),
+ "dynamic_sql": len(re.findall(r"EXECUTE\s+IMMEDIATE|EXEC\s*\(", sql, re.I)),
+ "cursors": len(re.findall(r"\bCURSOR\b", sql, re.I)),
+ "lines": sql.count("\n") + 1,
+ "parse_failed": 1,
+ }
diff --git a/morphsql/pipeline.py b/morphsql/pipeline.py
new file mode 100644
index 0000000000000000000000000000000000000000..3314d941b4763404c13c51f1237a9907b7576939
--- /dev/null
+++ b/morphsql/pipeline.py
@@ -0,0 +1,177 @@
+"""Agent-based migration workflow orchestration."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from morphsql.dbt_generator.decomposer import decompose_to_dbt, write_dbt_project
+from morphsql.knowledge.behavior import format_behavior_warning, get_behavior_warnings
+from morphsql.lineage.builder import (
+ build_lineage_graph,
+ build_table_lineage,
+ detect_circular_dependencies,
+ find_orphaned_tables,
+)
+from morphsql.models import (
+ DashboardMetrics,
+ Dialect,
+ MigrationCategory,
+ MigrationReport,
+)
+from morphsql.report.html_report import write_html_report
+from morphsql.risk.scorer import extract_business_rules, score_objects
+from morphsql.scanner.repository import scan_repository
+from morphsql.translator.engine import translate_objects
+from morphsql.validation.reconciliation import generate_dbt_tests, simulate_validation
+
+
+class MigrationPipeline:
+ """Orchestrates the full migration intelligence pipeline."""
+
+ def __init__(
+ self,
+ source: Dialect = Dialect.VERTICA,
+ target: Dialect = Dialect.SNOWFLAKE,
+ ):
+ self.source = source
+ self.target = target
+
+ def analyze(self, repository_path: str | Path) -> MigrationReport:
+ """Run full discovery + assessment pipeline."""
+ # Discovery Agent
+ objects = scan_repository(repository_path)
+
+ # Lineage Agent
+ graph = build_lineage_graph(objects, self.source)
+ lineage = build_table_lineage(objects, self.source)
+
+ downstream_map: dict[str, int] = {}
+ for obj in objects:
+ downstream_map[obj.name] = graph.out_degree(obj.name) if obj.name in graph else 0
+
+ # Risk scoring
+ objects = score_objects(objects, self.source, self.target, downstream_map=downstream_map)
+
+ # Business rule extraction
+ for obj in objects:
+ obj.business_rules = extract_business_rules(obj.source_sql)
+
+ # Behavior warnings
+ behavior_warnings: list[str] = []
+ for obj in objects:
+ warnings = get_behavior_warnings(obj.source_sql, self.source.value, self.target.value)
+ for w in warnings:
+ msg = format_behavior_warning(w)
+ if msg not in behavior_warnings:
+ behavior_warnings.append(msg)
+
+ # Retirement candidates
+ orphans = find_orphaned_tables(graph, objects)
+ retirement = [
+ f"{t} — orphaned table with no downstream consumers"
+ for t in orphans
+ ]
+
+ # Circular dependencies
+ cycles = detect_circular_dependencies(graph)
+ for cycle in cycles:
+ behavior_warnings.append(f"Circular dependency detected: {' → '.join(cycle)}")
+
+ dashboard = self._compute_dashboard(objects, lineage)
+
+ return MigrationReport(
+ source_dialect=self.source,
+ target_dialect=self.target,
+ repository_path=str(repository_path),
+ objects=objects,
+ lineage=lineage,
+ dashboard=dashboard,
+ behavior_warnings=behavior_warnings,
+ retirement_candidates=retirement,
+ )
+
+ def convert(self, report: MigrationReport) -> MigrationReport:
+ """Translation Agent — convert all objects."""
+ report.objects = translate_objects(report.objects, self.source, self.target)
+ report.dashboard.conversion_completed_pct = (
+ sum(1 for o in report.objects if o.target_sql) / max(len(report.objects), 1) * 100
+ )
+ return report
+
+ def validate(self, report: MigrationReport) -> MigrationReport:
+ """Validation Agent — generate tests and simulate validation."""
+ all_results = []
+ for obj in report.objects:
+ generate_dbt_tests(obj)
+ results = simulate_validation(obj)
+ all_results.extend(results)
+
+ report.validation_results = all_results
+ passed = sum(1 for r in all_results if r.passed)
+ report.dashboard.validation_passed_pct = passed / max(len(all_results), 1) * 100
+ report.dashboard.test_coverage_pct = (
+ sum(1 for o in report.objects if o.tests_generated) / max(len(report.objects), 1) * 100
+ )
+ return report
+
+ def generate_dbt(self, report: MigrationReport, output_dir: str | Path) -> MigrationReport:
+ """Architecture Agent — decompose to dbt project."""
+ output_dir = Path(output_dir)
+ for obj in report.objects:
+ if obj.object_type.value in ("stored_procedure", "sql_script", "view"):
+ files = decompose_to_dbt(obj, self.source)
+ obj_dir = output_dir / obj.name.lower()
+ write_dbt_project(files, obj_dir)
+ return report
+
+ def generate_report(self, report: MigrationReport, output_path: str | Path) -> Path:
+ """Documentation Agent — generate HTML report."""
+ return write_html_report(report, output_path)
+
+ def run_full_pipeline(
+ self,
+ repository_path: str | Path,
+ output_dir: str | Path,
+ ) -> MigrationReport:
+ """Run the complete migration intelligence pipeline."""
+ report = self.analyze(repository_path)
+ report = self.convert(report)
+ report = self.validate(report)
+
+ output_dir = Path(output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ self.generate_dbt(report, output_dir / "dbt")
+ self.generate_report(report, output_dir / "migration_report.html")
+
+ return report
+
+ def _compute_dashboard(self, objects, lineage) -> DashboardMetrics:
+ total = len(objects)
+ if total == 0:
+ return DashboardMetrics()
+
+ auto = sum(1 for o in objects if o.migration_category == MigrationCategory.AUTO_MIGRATE)
+ review = sum(1 for o in objects if o.migration_category == MigrationCategory.AUTO_WITH_REVIEW)
+ redesign = sum(1 for o in objects if o.migration_category in (
+ MigrationCategory.MANUAL_REDESIGN, MigrationCategory.PARTIAL
+ ))
+ retire = sum(1 for o in objects if o.migration_category == MigrationCategory.RETIRE)
+
+ avg_risk = sum(o.complexity_score for o in objects) / total
+ lineage_pct = min(100, len(lineage) / max(total, 1) * 100)
+
+ # Cost estimate heuristic
+ savings_low = total * 500
+ savings_high = total * 1500
+
+ return DashboardMetrics(
+ total_objects=total,
+ auto_migratable=auto,
+ requires_review=review,
+ requires_redesign=redesign,
+ recommended_retirement=retire,
+ lineage_coverage_pct=lineage_pct,
+ migration_risk_score=avg_risk,
+ estimated_annual_savings_usd=(savings_low, savings_high),
+ )
diff --git a/morphsql/report/__init__.py b/morphsql/report/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e33fede5d4c8e541ed8ee18c51eae7d1380dc5f5
--- /dev/null
+++ b/morphsql/report/__init__.py
@@ -0,0 +1,5 @@
+"""Report generation package."""
+
+from morphsql.report.html_report import generate_html_report, write_html_report
+
+__all__ = ["generate_html_report", "write_html_report"]
diff --git a/morphsql/report/html_report.py b/morphsql/report/html_report.py
new file mode 100644
index 0000000000000000000000000000000000000000..6890d442ecd2fa0d8ba470d1ffd791c5fe128153
--- /dev/null
+++ b/morphsql/report/html_report.py
@@ -0,0 +1,156 @@
+"""HTML migration report generation."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from pathlib import Path
+
+from jinja2 import Template
+
+from morphsql.models import MigrationReport
+
+REPORT_TEMPLATE = """
+
+
+
+
+MorphSQL Report — {{ report.repository_path }}
+
+
+
+
+
MorphSQL Migration Report
+
{{ report.source_dialect.value | upper }} → {{ report.target_dialect.value | upper }} | {{ report.repository_path }} | Generated {{ timestamp }}
+
+
+
{{ report.dashboard.total_objects }}
Objects Discovered
+
{{ report.dashboard.auto_migratable }}
Auto-Migratable
+
{{ report.dashboard.requires_review }}
Requires Review
+
{{ report.dashboard.requires_redesign }}
Manual Redesign
+
{{ report.dashboard.recommended_retirement }}
Retire/Consolidate
+
{{ "%.0f"|format(report.dashboard.migration_risk_score) }}
Risk Score (/100)
+
+
+
Migration Progress
+
+
Conversion Completed: {{ "%.0f"|format(report.dashboard.conversion_completed_pct) }}%
+
+
Validation Passed: {{ "%.0f"|format(report.dashboard.validation_passed_pct) }}%
+
+
Lineage Coverage: {{ "%.0f"|format(report.dashboard.lineage_coverage_pct) }}%
+
+
Test Coverage: {{ "%.0f"|format(report.dashboard.test_coverage_pct) }}%
+
+
+
+ {% if report.dashboard.estimated_annual_savings_usd[1] > 0 %}
+
Cost Estimate
+
+
Estimated annual savings: ${{ "{:,.0f}".format(report.dashboard.estimated_annual_savings_usd[0]) }} – ${{ "{:,.0f}".format(report.dashboard.estimated_annual_savings_usd[1]) }}
+
+ {% endif %}
+
+
Object Assessment
+
+ | Object | Type | Complexity | Risk | Category | Confidence |
+ {% for obj in report.objects %}
+
+ | {{ obj.name }} |
+ {{ obj.object_type.value }} |
+ {{ obj.complexity_score }}/100 |
+ {{ obj.risk_level.value | upper }} |
+ {{ obj.migration_category.value | replace('_', ' ') }} |
+ {{ "%.0f"|format(obj.conversion_confidence) }}% |
+
+ {% endfor %}
+
+
+ {% if report.behavior_warnings %}
+
Behavior Difference Warnings
+
+
{% for w in report.behavior_warnings %}- {{ w }}
{% endfor %}
+
+ {% endif %}
+
+ {% if report.retirement_candidates %}
+
Retirement Candidates
+
+
{% for r in report.retirement_candidates %}- {{ r }}
{% endfor %}
+
+ {% endif %}
+
+
Object Details
+ {% for obj in report.objects %}
+
+
{{ obj.name }} {{ obj.risk_level.value }}
+
Path: {{ obj.source_path }} | Complexity: {{ obj.complexity_score }}/100
+ {% if obj.risk_factors %}
+
Risk Factors:
+
{% for rf in obj.risk_factors %}- {{ rf.description }} (+{{ rf.score }})
{% endfor %}
+ {% endif %}
+ {% if obj.requires_review %}
+
Requires Review: {{ obj.requires_review | join(', ') }}
+ {% endif %}
+ {% if obj.business_rules %}
+
Business Rules:
+
{{ obj.business_rules | join('\\n') }}
+ {% endif %}
+ {% if obj.target_sql %}
+
Converted SQL:
+
{{ obj.target_sql[:2000] }}{% if obj.target_sql|length > 2000 %}...{% endif %}
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+"""
+
+
+def generate_html_report(report: MigrationReport) -> str:
+ """Generate an HTML migration report."""
+ template = Template(REPORT_TEMPLATE)
+ return template.render(
+ report=report,
+ timestamp=datetime.now().strftime("%Y-%m-%d %H:%M UTC"),
+ )
+
+
+def write_html_report(report: MigrationReport, output_path: str | Path) -> Path:
+ """Write HTML report to disk."""
+ output_path = Path(output_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ html = generate_html_report(report)
+ output_path.write_text(html, encoding="utf-8")
+ return output_path
diff --git a/morphsql/risk/__init__.py b/morphsql/risk/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e7f1b8a3a54bd43680a036edc3e86e94777d1e3
--- /dev/null
+++ b/morphsql/risk/__init__.py
@@ -0,0 +1,15 @@
+"""Risk scoring package."""
+
+from morphsql.risk.scorer import (
+ extract_business_rules,
+ recommend_workload_action,
+ score_object,
+ score_objects,
+)
+
+__all__ = [
+ "extract_business_rules",
+ "recommend_workload_action",
+ "score_object",
+ "score_objects",
+]
diff --git a/morphsql/risk/scorer.py b/morphsql/risk/scorer.py
new file mode 100644
index 0000000000000000000000000000000000000000..d430b8b6a29f8674056dd6c66c3547f1c089c3ac
--- /dev/null
+++ b/morphsql/risk/scorer.py
@@ -0,0 +1,221 @@
+"""Migration complexity and risk scoring."""
+
+from __future__ import annotations
+
+import re
+
+from morphsql.models import (
+ MigrationCategory,
+ MigrationObject,
+ ObjectType,
+ RiskFactor,
+ RiskLevel,
+)
+from morphsql.parser.sql_parser import count_sql_complexity, detect_unsupported_features
+from morphsql.models import Dialect
+
+
+def score_object(
+ obj: MigrationObject,
+ source: Dialect,
+ target: Dialect,
+ dependency_count: int = 0,
+ downstream_count: int = 0,
+) -> MigrationObject:
+ """Compute migration complexity and risk score for an object."""
+ complexity = count_sql_complexity(obj.source_sql, source)
+ risk_factors: list[RiskFactor] = []
+ score = 0
+
+ # Line count
+ lines = complexity.get("lines", 0)
+ if lines > 2000:
+ risk_factors.append(RiskFactor(
+ name="large_object", score=25,
+ description=f"{lines:,} lines of SQL",
+ category="complexity",
+ ))
+ score += 25
+ elif lines > 500:
+ risk_factors.append(RiskFactor(
+ name="medium_object", score=12,
+ description=f"{lines:,} lines of SQL",
+ category="complexity",
+ ))
+ score += 12
+ elif lines > 100:
+ score += 5
+
+ # Structural complexity
+ for metric, threshold, points, label in [
+ ("ctes", 20, 10, "High CTE count"),
+ ("joins", 30, 12, "High join count"),
+ ("subqueries", 15, 8, "Deep subquery nesting"),
+ ("window_functions", 10, 6, "Many window functions"),
+ ("temp_tables", 5, 10, "Multiple temporary tables"),
+ ("dynamic_sql", 1, 20, "Dynamic SQL detected"),
+ ("cursors", 1, 18, "Cursor-based processing"),
+ ]:
+ val = complexity.get(metric, 0)
+ if val >= threshold:
+ risk_factors.append(RiskFactor(
+ name=metric, score=points,
+ description=f"{label}: {val}",
+ category="complexity",
+ ))
+ score += points
+ elif val > 0 and metric in ("dynamic_sql", "cursors"):
+ risk_factors.append(RiskFactor(
+ name=metric, score=points,
+ description=f"{label}: {val}",
+ category="complexity",
+ ))
+ score += points
+
+ # Unsupported features
+ unsupported = detect_unsupported_features(obj.source_sql, source, target)
+ if unsupported:
+ pts = min(len(unsupported) * 5, 25)
+ risk_factors.append(RiskFactor(
+ name="unsupported_syntax", score=pts,
+ description=f"{len(unsupported)} unsupported features: {', '.join(unsupported[:3])}",
+ category="compatibility",
+ ))
+ score += pts
+ obj.unsupported_features = unsupported
+
+ # Dependencies
+ if dependency_count > 10:
+ risk_factors.append(RiskFactor(
+ name="high_dependencies", score=10,
+ description=f"{dependency_count} upstream dependencies",
+ category="dependency",
+ ))
+ score += 10
+ elif dependency_count > 3:
+ score += 4
+
+ if downstream_count > 5:
+ risk_factors.append(RiskFactor(
+ name="critical_downstream", score=12,
+ description=f"{downstream_count} downstream dependents — business-critical",
+ category="dependency",
+ ))
+ score += 12
+ elif downstream_count > 0:
+ score += 3
+
+ # Object type risk
+ type_risk = {
+ ObjectType.STORED_PROCEDURE: 15,
+ ObjectType.FUNCTION: 10,
+ ObjectType.VIEW: 3,
+ ObjectType.TABLE: 2,
+ ObjectType.SQL_SCRIPT: 5,
+ ObjectType.TEMP_TABLE: 4,
+ }
+ type_score = type_risk.get(obj.object_type, 5)
+ if type_score >= 10:
+ risk_factors.append(RiskFactor(
+ name="object_type", score=type_score,
+ description=f"Object type: {obj.object_type.value}",
+ category="complexity",
+ ))
+ score += type_score
+
+ # Business rule complexity
+ case_count = complexity.get("case_expressions", 0)
+ if case_count > 5:
+ risk_factors.append(RiskFactor(
+ name="business_logic", score=8,
+ description=f"{case_count} CASE expressions — complex business rules",
+ category="business",
+ ))
+ score += 8
+
+ score = min(100, score)
+ obj.complexity_score = score
+ obj.risk_level = _score_to_risk_level(score)
+ obj.risk_factors = risk_factors
+ obj.migration_category = _score_to_category(score, obj)
+ obj.downstream_count = downstream_count
+ obj.dependencies = [] # populated by orchestrator
+
+ return obj
+
+
+def score_objects(
+ objects: list[MigrationObject],
+ source: Dialect,
+ target: Dialect,
+ dependency_map: dict[str, int] | None = None,
+ downstream_map: dict[str, int] | None = None,
+) -> list[MigrationObject]:
+ """Score all objects with optional dependency context."""
+ dependency_map = dependency_map or {}
+ downstream_map = downstream_map or {}
+ return [
+ score_object(
+ obj, source, target,
+ dependency_count=dependency_map.get(obj.name, 0),
+ downstream_count=downstream_map.get(obj.name, 0),
+ )
+ for obj in objects
+ ]
+
+
+def extract_business_rules(sql: str) -> list[str]:
+ """Extract human-readable business rules from CASE expressions."""
+ rules: list[str] = []
+ case_blocks = re.findall(
+ r"CASE\s+(.*?)\s+END",
+ sql,
+ re.IGNORECASE | re.DOTALL,
+ )
+ for i, block in enumerate(case_blocks[:10], 1):
+ when_clauses = re.findall(
+ r"WHEN\s+(.+?)\s+THEN\s+('[^']*'|\w+)",
+ block,
+ re.IGNORECASE,
+ )
+ if when_clauses:
+ conditions = [f" - When {w.strip()} → {t}" for w, t in when_clauses]
+ rules.append(f"Business Rule {i}:\n" + "\n".join(conditions))
+ return rules
+
+
+def recommend_workload_action(obj: MigrationObject) -> str:
+ """Recommend migrate/rewrite/consolidate/retire action."""
+ if obj.complexity_score >= 80:
+ return "manual_redesign"
+ if obj.complexity_score >= 60:
+ return "rewrite"
+ if obj.migration_category == MigrationCategory.RETIRE:
+ return "retire"
+ if obj.complexity_score >= 35:
+ return "review_and_migrate"
+ if len(obj.requires_review) > 3:
+ return "review_and_migrate"
+ return "migrate"
+
+
+def _score_to_risk_level(score: int) -> RiskLevel:
+ if score >= 75:
+ return RiskLevel.CRITICAL
+ if score >= 50:
+ return RiskLevel.HIGH
+ if score >= 25:
+ return RiskLevel.MEDIUM
+ return RiskLevel.LOW
+
+
+def _score_to_category(score: int, obj: MigrationObject) -> MigrationCategory:
+ if obj.object_type == ObjectType.TEMP_TABLE and score < 30:
+ return MigrationCategory.RETIRE
+ if score >= 80:
+ return MigrationCategory.MANUAL_REDESIGN
+ if score >= 55:
+ return MigrationCategory.PARTIAL
+ if score >= 30 or obj.requires_review:
+ return MigrationCategory.AUTO_WITH_REVIEW
+ return MigrationCategory.AUTO_MIGRATE
diff --git a/morphsql/scanner/__init__.py b/morphsql/scanner/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..24301035ef8388f1888966e1d2459bc776f513b9
--- /dev/null
+++ b/morphsql/scanner/__init__.py
@@ -0,0 +1,5 @@
+"""Repository scanner package."""
+
+from morphsql.scanner.repository import scan_directory, scan_repository, scan_zip
+
+__all__ = ["scan_directory", "scan_repository", "scan_zip"]
diff --git a/morphsql/scanner/repository.py b/morphsql/scanner/repository.py
new file mode 100644
index 0000000000000000000000000000000000000000..2106aa7b3811346105485937297af4dd7602ec0f
--- /dev/null
+++ b/morphsql/scanner/repository.py
@@ -0,0 +1,156 @@
+"""Repository-level SQL discovery and scanning."""
+
+from __future__ import annotations
+
+import re
+import zipfile
+from pathlib import Path
+
+from morphsql.models import MigrationObject, ObjectType
+
+SQL_EXTENSIONS = {".sql", ".hql", ".ddl", ".dml"}
+DBT_EXTENSIONS = {".sql", ".yml", ".yaml"}
+AIRFLOW_EXTENSIONS = {".py"}
+SKIP_DIRS = {
+ ".git", ".svn", "node_modules", "__pycache__", ".venv", "venv",
+ "target", "dbt_packages", ".dbt", "dist", "build",
+}
+
+
+def _classify_file(path: Path, content: str) -> ObjectType:
+ name_lower = path.name.lower()
+ content_upper = content.upper()
+
+ if "CREATE OR REPLACE PROCEDURE" in content_upper or "CREATE PROCEDURE" in content_upper:
+ return ObjectType.STORED_PROCEDURE
+ if "CREATE OR REPLACE FUNCTION" in content_upper or "CREATE FUNCTION" in content_upper:
+ return ObjectType.FUNCTION
+ if "CREATE VIEW" in content_upper or "CREATE OR REPLACE VIEW" in content_upper:
+ return ObjectType.VIEW
+ if "CREATE TABLE" in content_upper or "CREATE LOCAL TEMP" in content_upper:
+ if "TEMP" in content_upper or "TEMPORARY" in content_upper:
+ return ObjectType.TEMP_TABLE
+ return ObjectType.TABLE
+ if path.suffix in {".yml", ".yaml"} and "models:" in content:
+ return ObjectType.DBT_MODEL
+ if path.suffix == ".py" and ("DAG(" in content or "airflow" in content.lower()):
+ return ObjectType.AIRFLOW_DAG
+ if name_lower.endswith(".sql"):
+ return ObjectType.SQL_SCRIPT
+ return ObjectType.UNKNOWN
+
+
+def _extract_object_name(path: Path, content: str, obj_type: ObjectType) -> str:
+ patterns = [
+ r"CREATE\s+(?:OR\s+REPLACE\s+)?(?:PROCEDURE|FUNCTION)\s+([\w.]+)",
+ r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+([\w.]+)",
+ r"CREATE\s+(?:LOCAL\s+TEMP|TEMP|TEMPORARY)?\s*TABLE\s+([\w.]+)",
+ r"CREATE\s+TABLE\s+([\w.]+)",
+ ]
+ for pattern in patterns:
+ match = re.search(pattern, content, re.IGNORECASE)
+ if match:
+ return match.group(1).upper()
+
+ stem = path.stem.upper()
+ if obj_type == ObjectType.DBT_MODEL:
+ return stem
+ return stem
+
+
+def scan_directory(path: str | Path) -> list[MigrationObject]:
+ """Scan a directory for SQL and migration-relevant artifacts."""
+ root = Path(path)
+ if not root.exists():
+ raise FileNotFoundError(f"Path not found: {path}")
+
+ objects: list[MigrationObject] = []
+
+ for file_path in sorted(root.rglob("*")):
+ if not file_path.is_file():
+ continue
+ if any(part in SKIP_DIRS for part in file_path.parts):
+ continue
+ if file_path.suffix.lower() not in SQL_EXTENSIONS | DBT_EXTENSIONS | AIRFLOW_EXTENSIONS:
+ continue
+
+ try:
+ content = file_path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ continue
+
+ if not content.strip():
+ continue
+
+ obj_type = _classify_file(file_path, content)
+ name = _extract_object_name(file_path, content, obj_type)
+
+ objects.append(
+ MigrationObject(
+ name=name,
+ object_type=obj_type,
+ source_path=str(file_path.relative_to(root)),
+ source_sql=content,
+ metadata={
+ "lines": content.count("\n") + 1,
+ "size_bytes": len(content.encode("utf-8")),
+ },
+ )
+ )
+
+ return objects
+
+
+def scan_zip(zip_path: str | Path) -> list[MigrationObject]:
+ """Scan a zip archive containing SQL artifacts."""
+ zip_path = Path(zip_path)
+ if not zip_path.exists():
+ raise FileNotFoundError(f"Zip not found: {zip_path}")
+
+ objects: list[MigrationObject] = []
+
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ for info in zf.infolist():
+ if info.is_dir():
+ continue
+ name = info.filename
+ if any(skip in name.split("/") for skip in SKIP_DIRS):
+ continue
+ suffix = Path(name).suffix.lower()
+ if suffix not in SQL_EXTENSIONS | DBT_EXTENSIONS | AIRFLOW_EXTENSIONS:
+ continue
+
+ try:
+ content = zf.read(info).decode("utf-8", errors="replace")
+ except Exception:
+ continue
+
+ if not content.strip():
+ continue
+
+ obj_type = _classify_file(Path(name), content)
+ obj_name = _extract_object_name(Path(name), content, obj_type)
+
+ objects.append(
+ MigrationObject(
+ name=obj_name,
+ object_type=obj_type,
+ source_path=name,
+ source_sql=content,
+ metadata={
+ "lines": content.count("\n") + 1,
+ "size_bytes": len(content.encode("utf-8")),
+ "from_zip": True,
+ },
+ )
+ )
+
+ return objects
+
+
+def scan_repository(path: str | Path) -> list[MigrationObject]:
+ """Scan a repository path (directory or zip file)."""
+ path = Path(path)
+ if path.suffix.lower() == ".zip":
+ return scan_zip(path)
+ return scan_directory(path)
diff --git a/morphsql/translator/__init__.py b/morphsql/translator/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7ad2535b53f49957ea27274bf72c22976e6da3ea
--- /dev/null
+++ b/morphsql/translator/__init__.py
@@ -0,0 +1,15 @@
+"""SQL translation package."""
+
+from morphsql.translator.engine import translate_object, translate_objects, translate_sql
+from morphsql.translator.pandas_codegen import is_pandas_target, sql_to_pandas
+from morphsql.translator.pyspark_codegen import is_pyspark_target, sql_to_pyspark
+
+__all__ = [
+ "translate_object",
+ "translate_objects",
+ "translate_sql",
+ "is_pandas_target",
+ "sql_to_pandas",
+ "is_pyspark_target",
+ "sql_to_pyspark",
+]
diff --git a/morphsql/translator/engine.py b/morphsql/translator/engine.py
new file mode 100644
index 0000000000000000000000000000000000000000..69ba5b14359d27bc64e603e99708a062ba920f82
--- /dev/null
+++ b/morphsql/translator/engine.py
@@ -0,0 +1,540 @@
+"""Hybrid SQL translation engine: source/target-gated rules + sqlglot."""
+
+from __future__ import annotations
+
+import re
+
+from morphsql.knowledge.behavior import get_behavior_warnings
+from morphsql.models import Dialect, MigrationObject
+from morphsql.parser.sql_parser import (
+ detect_unsupported_features,
+ get_sqlglot_dialect,
+ parse_sql_multi,
+)
+
+# All conversion routes exposed by the product
+SOURCE_DIALECTS = (
+ Dialect.VERTICA,
+ Dialect.ORACLE,
+ Dialect.REDSHIFT,
+ Dialect.BIGQUERY,
+ Dialect.SNOWFLAKE,
+)
+TARGET_DIALECTS = (
+ Dialect.PANDAS,
+ Dialect.PYSPARK,
+ Dialect.SNOWFLAKE,
+ Dialect.DBT_SNOWFLAKE,
+ Dialect.BIGQUERY,
+)
+
+# Vertica-specific DDL/DML syntax to strip before transpilation
+VERTICA_SYNTAX_REPLACEMENTS: list[tuple[str, str]] = [
+ (r"\bSEGMENTED\s+BY\s+HASH\s*\([^)]+\)\s*ALL\s*NODES", ""),
+ (r"\bENCODED\s+BY\s+[^;\n]+", ""),
+ (r"\bINCLUDE\s+SCHEMA\s+PRIVILEGES\b", ""),
+ (r"\bON\s+COMMIT\s+PRESERVE\s+ROWS\b", ""),
+ (r"\bPROJECTION\s+\w+\b", ""),
+ (
+ r"(CREATE\s+(?:OR\s+REPLACE\s+)?(?:LOCAL\s+TEMP|TEMP|TEMPORARY)?\s*TABLE\s+[\w.]+\s*\([^;]+\))\s*ORDER\s+BY[^;]+",
+ r"\1",
+ ),
+]
+
+ORACLE_SYNTAX_REPLACEMENTS: list[tuple[str, str]] = [
+ (r"\bFROM\s+DUAL\b", ""),
+]
+
+REDSHIFT_SYNTAX_REPLACEMENTS: list[tuple[str, str]] = [
+ (r"\bDISTSTYLE\s+\w+\b", ""),
+ (r"\bDISTKEY\s*\([^)]*\)", ""),
+ (r"\bSORTKEY\s*\([^)]*\)", ""),
+ (r"\bENCODE\s+\w+\b", ""),
+]
+
+
+def normalize_target(target: Dialect) -> Dialect:
+ """Map product targets to the SQL dialect family used for conversion."""
+ if target == Dialect.DBT_SNOWFLAKE:
+ return Dialect.SNOWFLAKE
+ if target == Dialect.PANDAS:
+ return Dialect.PANDAS
+ if target == Dialect.PYSPARK:
+ return Dialect.PYSPARK
+ return target
+
+
+def _replace_function_calls(sql: str, func_name: str, replacer) -> tuple[str, bool]:
+ """Replace function calls preserving parenthesized arguments."""
+ pattern = re.compile(rf"\b{re.escape(func_name)}\s*\(", re.IGNORECASE)
+ changed = False
+ parts: list[str] = []
+ last = 0
+
+ for match in pattern.finditer(sql):
+ parts.append(sql[last:match.start()])
+ start_paren = match.end() - 1
+ depth = 0
+ end_paren = start_paren
+ for i in range(start_paren, len(sql)):
+ if sql[i] == "(":
+ depth += 1
+ elif sql[i] == ")":
+ depth -= 1
+ if depth == 0:
+ end_paren = i
+ break
+
+ args = sql[start_paren + 1:end_paren]
+ parts.append(replacer(args))
+ changed = True
+ last = end_paren + 1
+
+ parts.append(sql[last:])
+ return "".join(parts), changed
+
+
+def _function_mappings(source: Dialect, target: Dialect) -> list[tuple[str, object, str]]:
+ """Return function remaps for a source→target pair."""
+ target = normalize_target(target)
+ mappings: list[tuple[str, object, str]] = []
+
+ if source == Dialect.VERTICA and target == Dialect.SNOWFLAKE:
+ mappings = [
+ ("ZEROIFNULL", lambda a: f"COALESCE({a.strip()}, 0)", "ZEROIFNULL → COALESCE(expr, 0)"),
+ ("NVL", lambda a: f"COALESCE({a.strip()})", "NVL → COALESCE"),
+ ("ISNULL", lambda a: f"COALESCE({a.strip()})", "ISNULL → COALESCE"),
+ ("TO_CHAR", lambda a: f"TO_VARCHAR({a.strip()})", "TO_CHAR → TO_VARCHAR"),
+ ("STRING_AGG", lambda a: f"LISTAGG({a.strip()})", "STRING_AGG → LISTAGG"),
+ (
+ "APPROXIMATE_COUNT_DISTINCT",
+ lambda a: f"APPROX_COUNT_DISTINCT({a.strip()})",
+ "APPROXIMATE_COUNT_DISTINCT → APPROX_COUNT_DISTINCT",
+ ),
+ ]
+ elif source == Dialect.VERTICA and target == Dialect.BIGQUERY:
+ mappings = [
+ ("ZEROIFNULL", lambda a: f"IFNULL({a.strip()}, 0)", "ZEROIFNULL → IFNULL(expr, 0)"),
+ ("NVL", lambda a: f"IFNULL({a.strip()})", "NVL → IFNULL"),
+ ("ISNULL", lambda a: f"IFNULL({a.strip()})", "ISNULL → IFNULL"),
+ ("TO_CHAR", lambda a: f"FORMAT('%s', {a.strip()})", "TO_CHAR → FORMAT"),
+ (
+ "APPROXIMATE_COUNT_DISTINCT",
+ lambda a: f"APPROX_COUNT_DISTINCT({a.strip()})",
+ "APPROXIMATE_COUNT_DISTINCT → APPROX_COUNT_DISTINCT",
+ ),
+ ]
+ elif source == Dialect.ORACLE and target == Dialect.SNOWFLAKE:
+ mappings = [
+ ("NVL", lambda a: f"COALESCE({a.strip()})", "NVL → COALESCE"),
+ ("NVL2", lambda a: _nvl2_to_iff(a), "NVL2 → IFF"),
+ ("TO_CHAR", lambda a: f"TO_VARCHAR({a.strip()})", "TO_CHAR → TO_VARCHAR"),
+ ("SYSDATE", lambda _a: "CURRENT_TIMESTAMP()", "SYSDATE → CURRENT_TIMESTAMP()"),
+ ]
+ elif source == Dialect.ORACLE and target == Dialect.BIGQUERY:
+ mappings = [
+ ("NVL", lambda a: f"IFNULL({a.strip()})", "NVL → IFNULL"),
+ ("NVL2", lambda a: _nvl2_to_if(a), "NVL2 → IF"),
+ ("TO_CHAR", lambda a: f"FORMAT('%s', {a.strip()})", "TO_CHAR → FORMAT"),
+ ("SYSDATE", lambda _a: "CURRENT_TIMESTAMP()", "SYSDATE → CURRENT_TIMESTAMP()"),
+ ]
+ elif source == Dialect.REDSHIFT and target == Dialect.SNOWFLAKE:
+ mappings = [
+ ("NVL", lambda a: f"COALESCE({a.strip()})", "NVL → COALESCE"),
+ ("ISNULL", lambda a: f"COALESCE({a.strip()})", "ISNULL → COALESCE"),
+ ("GETDATE", lambda _a: "CURRENT_TIMESTAMP()", "GETDATE → CURRENT_TIMESTAMP()"),
+ ]
+ elif source == Dialect.REDSHIFT and target == Dialect.BIGQUERY:
+ mappings = [
+ ("NVL", lambda a: f"IFNULL({a.strip()})", "NVL → IFNULL"),
+ ("ISNULL", lambda a: f"IFNULL({a.strip()})", "ISNULL → IFNULL"),
+ ("GETDATE", lambda _a: "CURRENT_TIMESTAMP()", "GETDATE → CURRENT_TIMESTAMP()"),
+ ("LISTAGG", lambda a: f"STRING_AGG({a.strip()})", "LISTAGG → STRING_AGG"),
+ ]
+ elif source == Dialect.BIGQUERY and target == Dialect.SNOWFLAKE:
+ mappings = [
+ ("IFNULL", lambda a: f"COALESCE({a.strip()})", "IFNULL → COALESCE"),
+ ("STRING_AGG", lambda a: f"LISTAGG({a.strip()})", "STRING_AGG → LISTAGG"),
+ ("SAFE_CAST", lambda a: f"TRY_CAST({a.strip()})", "SAFE_CAST → TRY_CAST"),
+ ]
+ elif source == Dialect.SNOWFLAKE and target == Dialect.BIGQUERY:
+ mappings = [
+ ("IFF", lambda a: f"IF({a.strip()})", "IFF → IF"),
+ ("LISTAGG", lambda a: f"STRING_AGG({a.strip()})", "LISTAGG → STRING_AGG"),
+ ("TRY_CAST", lambda a: f"SAFE_CAST({a.strip()})", "TRY_CAST → SAFE_CAST"),
+ ("TO_VARCHAR", lambda a: f"CAST({a.strip()} AS STRING)", "TO_VARCHAR → CAST(... AS STRING)"),
+ ]
+
+ return mappings
+
+
+def _nvl2_to_iff(args: str) -> str:
+ parts = [p.strip() for p in _split_args(args)]
+ if len(parts) == 3:
+ return f"IFF({parts[0]} IS NOT NULL, {parts[1]}, {parts[2]})"
+ return f"NVL2({args})"
+
+
+def _nvl2_to_if(args: str) -> str:
+ parts = [p.strip() for p in _split_args(args)]
+ if len(parts) == 3:
+ return f"IF({parts[0]} IS NOT NULL, {parts[1]}, {parts[2]})"
+ return f"NVL2({args})"
+
+
+def _split_args(args: str) -> list[str]:
+ parts: list[str] = []
+ depth = 0
+ current: list[str] = []
+ for ch in args:
+ if ch == "(":
+ depth += 1
+ elif ch == ")":
+ depth -= 1
+ if ch == "," and depth == 0:
+ parts.append("".join(current))
+ current = []
+ continue
+ current.append(ch)
+ if current:
+ parts.append("".join(current))
+ return parts
+
+
+def _apply_function_mappings(sql: str, source: Dialect, target: Dialect) -> tuple[str, list[str]]:
+ applied: list[str] = []
+ for func_name, replacer, label in _function_mappings(source, target):
+ # Zero-arg style replacements like SYSDATE() / GETDATE()
+ if func_name.upper() in {"SYSDATE", "GETDATE"}:
+ bare = re.compile(rf"\b{func_name}\b(?!\s*\()", re.IGNORECASE)
+ if bare.search(sql):
+ sql = bare.sub("CURRENT_TIMESTAMP()", sql)
+ applied.append(label)
+ sql, changed = _replace_function_calls(sql, func_name, lambda _a: "CURRENT_TIMESTAMP()")
+ if changed and label not in applied:
+ applied.append(label)
+ continue
+
+ sql, changed = _replace_function_calls(sql, func_name, replacer)
+ if changed:
+ applied.append(label)
+ return sql, applied
+
+
+def _apply_date_arithmetic(sql: str, target: Dialect) -> tuple[str, list[str]]:
+ """Convert `col - N` day arithmetic to target idioms."""
+ applied: list[str] = []
+ target = normalize_target(target)
+ pattern = re.compile(
+ r"(? str:
+ applied.append(f"Date arithmetic: {match.group(0)} → DATEADD")
+ return f"DATEADD(day, -{match.group(2)}, {match.group(1)})"
+
+ def bigquery_replacer(match: re.Match) -> str:
+ applied.append(f"Date arithmetic: {match.group(0)} → DATE_SUB")
+ return f"DATE_SUB({match.group(1)}, INTERVAL {match.group(2)} DAY)"
+
+ if target == Dialect.SNOWFLAKE:
+ return pattern.sub(snowflake_replacer, sql), applied
+ if target == Dialect.BIGQUERY:
+ return pattern.sub(bigquery_replacer, sql), applied
+ return sql, applied
+
+
+def _apply_syntax_replacements(sql: str, source: Dialect) -> tuple[str, list[str]]:
+ applied: list[str] = []
+ replacements: list[tuple[str, str]] = []
+ if source == Dialect.VERTICA:
+ replacements = VERTICA_SYNTAX_REPLACEMENTS
+ label = "Removed Vertica-specific syntax"
+ elif source == Dialect.ORACLE:
+ replacements = ORACLE_SYNTAX_REPLACEMENTS
+ label = "Removed Oracle-specific syntax"
+ elif source == Dialect.REDSHIFT:
+ replacements = REDSHIFT_SYNTAX_REPLACEMENTS
+ label = "Removed Redshift-specific syntax"
+ else:
+ return sql, applied
+
+ for pattern, replacement in replacements:
+ if re.search(pattern, sql, re.IGNORECASE | re.DOTALL):
+ sql = re.sub(pattern, replacement, sql, flags=re.IGNORECASE | re.DOTALL)
+ if label not in applied:
+ applied.append(label)
+ return sql, applied
+
+
+def _post_transforms(target: Dialect) -> list[tuple[str, str, str]]:
+ target = normalize_target(target)
+ if target == Dialect.SNOWFLAKE:
+ return [
+ (r"\bCREATE\s+LOCAL\s+TEMP\s+TABLE\b", "CREATE OR REPLACE TEMPORARY TABLE", "LOCAL TEMP → TEMPORARY TABLE"),
+ (r"\bCREATE\s+TEMP\s+TABLE\b", "CREATE OR REPLACE TEMPORARY TABLE", "TEMP → TEMPORARY TABLE"),
+ (r"\bCREATE\s+TEMPORARY\s+TABLE\b", "CREATE OR REPLACE TEMPORARY TABLE", "TEMPORARY TABLE normalized"),
+ (r"\bSYSDATE\b", "CURRENT_TIMESTAMP()", "SYSDATE → CURRENT_TIMESTAMP()"),
+ (r"\bGETDATE\s*\(\s*\)", "CURRENT_TIMESTAMP()", "GETDATE → CURRENT_TIMESTAMP()"),
+ (r"DATEDIFF\s*\(\s*'(\w+)'\s*,", r"DATEDIFF(\1, ", "DATEDIFF unit quote removed"),
+ ]
+ if target == Dialect.BIGQUERY:
+ return [
+ (r"\bCREATE\s+(?:OR\s+REPLACE\s+)?LOCAL\s+TEMP\s+TABLE\b", "CREATE TEMP TABLE", "LOCAL TEMP → TEMP TABLE"),
+ (r"\bCREATE\s+(?:OR\s+REPLACE\s+)?TEMP(?:ORARY)?\s+TABLE\b", "CREATE TEMP TABLE", "TEMP → CREATE TEMP TABLE"),
+ (r"\bSYSDATE\b", "CURRENT_TIMESTAMP()", "SYSDATE → CURRENT_TIMESTAMP()"),
+ (r"\bGETDATE\s*\(\s*\)", "CURRENT_TIMESTAMP()", "GETDATE → CURRENT_TIMESTAMP()"),
+ (r"DATEDIFF\s*\(\s*'(\w+)'\s*,", r"DATE_DIFF(", "DATEDIFF → DATE_DIFF"),
+ (r"\bIFNULL\s*\(", "IFNULL(", "IFNULL retained"), # no-op marker skipped below
+ ]
+ return []
+
+
+def _apply_post_transforms(sql: str, target: Dialect) -> tuple[str, list[str]]:
+ applied: list[str] = []
+ for pattern, replacement, label in _post_transforms(target):
+ if label.endswith("retained"):
+ continue
+ if re.search(pattern, sql, re.IGNORECASE):
+ sql = re.sub(pattern, replacement, sql, flags=re.IGNORECASE)
+ applied.append(label)
+ return sql, applied
+
+
+def _convert_procedure_wrapper(sql: str, target: Dialect) -> tuple[str, list[str]]:
+ """Convert procedure wrappers for Snowflake or BigQuery targets."""
+ applied: list[str] = []
+ target = normalize_target(target)
+
+ proc_match = re.search(
+ r"CREATE\s+OR\s+REPLACE\s+PROCEDURE\s+([\w.]+)\s*\(([^)]*)\)",
+ sql,
+ re.IGNORECASE,
+ )
+ if not proc_match:
+ return sql, applied
+
+ proc_name = proc_match.group(1)
+ params = proc_match.group(2).strip()
+ body_match = re.search(r"\bBEGIN\b(.*?)\bEND\s*;?\s*\$\$?", sql, re.IGNORECASE | re.DOTALL)
+ if not body_match:
+ return sql, applied
+
+ body = body_match.group(1).strip()
+ body = re.sub(r"\bCOMMIT\s*;", "", body, flags=re.IGNORECASE)
+
+ if target == Dialect.SNOWFLAKE:
+ if params:
+ for param in params.split(","):
+ param = param.strip()
+ if not param:
+ continue
+ pname = param.split()[0]
+ body = re.sub(
+ rf"\b{re.escape(pname)}\b",
+ f":{pname.upper()}",
+ body,
+ flags=re.IGNORECASE,
+ )
+ snowflake_proc = f"""CREATE OR REPLACE PROCEDURE {proc_name}({params.upper() if params else ""})
+RETURNS VARCHAR
+LANGUAGE SQL
+EXECUTE AS CALLER
+AS
+$$
+BEGIN
+{body}
+ RETURN 'OK';
+END;
+$$;"""
+ applied.append("Converted procedure wrapper to Snowflake syntax")
+ return snowflake_proc, applied
+
+ if target == Dialect.BIGQUERY:
+ # BigQuery scripting procedures use BEGIN/END without Snowflake :PARAM binds
+ bq_proc = f"""CREATE OR REPLACE PROCEDURE {proc_name}({params})
+BEGIN
+{body}
+END;"""
+ applied.append("Converted procedure wrapper to BigQuery syntax")
+ return bq_proc, applied
+
+ return sql, applied
+
+
+def _transpile_statements(sql: str, source: Dialect, target: Dialect) -> tuple[str, list[str], list[str]]:
+ """Transpile individual SQL statements via sqlglot."""
+ auto: list[str] = []
+ review: list[str] = []
+ source_dialect = get_sqlglot_dialect(source)
+ target_dialect = get_sqlglot_dialect(normalize_target(target))
+
+ # Identity route: keep SQL after rule transforms
+ if source_dialect == target_dialect and source == normalize_target(target):
+ auto.append("Same dialect family — rule transforms only")
+ return sql, auto, review
+
+ statements = parse_sql_multi(sql, source)
+ if not statements:
+ # Vertica (postgres stand-in) / broken SQL: try snowflake then postgres
+ for fallback in (Dialect.SNOWFLAKE, Dialect.REDSHIFT):
+ statements = parse_sql_multi(sql, fallback)
+ if statements:
+ review.append(f"Parsed with {fallback.value} fallback dialect")
+ break
+
+ if not statements:
+ review.append("Could not parse SQL — returned rule-transformed source")
+ return sql, auto, review
+
+ translated_parts: list[str] = []
+ for stmt in statements:
+ if stmt is None:
+ continue
+ try:
+ translated = stmt.sql(dialect=target_dialect, pretty=True)
+ translated_parts.append(translated)
+ auto.append(f"Dialect transpilation ({source_dialect} → {target_dialect})")
+ except Exception:
+ try:
+ translated_parts.append(stmt.sql(dialect=source_dialect, pretty=True))
+ review.append("Statement retained in source dialect — manual review required")
+ except Exception:
+ review.append("Unparseable statement skipped")
+
+ if translated_parts:
+ # Deduplicate auto notes while keeping order
+ auto = list(dict.fromkeys(auto))
+ return ";\n\n".join(translated_parts), auto, review
+ return sql, auto, review
+
+
+def translate_sql(
+ sql: str,
+ source: Dialect,
+ target: Dialect,
+) -> tuple[str, float, list[str], list[str]]:
+ """
+ Translate SQL using hybrid rule-based + sqlglot approach.
+
+ Returns: (translated_sql, confidence, auto_converted, requires_review)
+ """
+ from morphsql.translator.pandas_codegen import is_pandas_target, sql_to_pandas
+ from morphsql.translator.pyspark_codegen import is_pyspark_target, sql_to_pyspark
+
+ if is_pandas_target(target) or target == Dialect.PANDAS:
+ return sql_to_pandas(sql, source)
+ if is_pyspark_target(target) or target == Dialect.PYSPARK:
+ return sql_to_pyspark(sql, source)
+
+ auto_converted: list[str] = []
+ requires_review: list[str] = []
+ confidence = 100.0
+ original = sql
+ working_sql = sql
+
+ if source == target or (source == Dialect.SNOWFLAKE and target == Dialect.DBT_SNOWFLAKE):
+ auto_converted.append("Source and target are the same dialect family")
+
+ # Step 1: Source-specific syntax cleanup
+ working_sql, applied = _apply_syntax_replacements(working_sql, source)
+ auto_converted.extend(applied)
+
+ # Step 2: Source→target function mappings (before transpile)
+ working_sql, func_applied = _apply_function_mappings(working_sql, source, target)
+ auto_converted.extend(func_applied)
+
+ # Step 3: Target temp-table / sysdate post transforms (safe before transpile)
+ working_sql, post_applied = _apply_post_transforms(working_sql, target)
+ auto_converted.extend(post_applied)
+
+ # Step 4/5: Procedures vs statement transpile
+ is_procedure = bool(re.search(r"\bCREATE\s+(?:OR\s+REPLACE\s+)?PROCEDURE\b", working_sql, re.I))
+ if is_procedure:
+ # Date math before :PARAM binding so `load_date - 90` converts cleanly
+ working_sql, date_applied = _apply_date_arithmetic(working_sql, target)
+ auto_converted.extend(date_applied)
+ working_sql, proc_applied = _convert_procedure_wrapper(working_sql, target)
+ auto_converted.extend(proc_applied)
+ if not proc_applied:
+ requires_review.append("Procedure wrapper could not be auto-converted for this target")
+ confidence -= 15
+ else:
+ requires_review.append("Verify procedure parameter bindings and temp table scope")
+ confidence -= 5
+ else:
+ transpiled, t_auto, t_review = _transpile_statements(working_sql, source, target)
+ if t_auto or transpiled != working_sql:
+ working_sql = transpiled
+ auto_converted.extend(t_auto)
+ requires_review.extend(t_review)
+ if t_review:
+ confidence -= len(t_review) * 5
+ # Date arithmetic after transpile so sqlglot does not wrap INTERVAL twice
+ working_sql, date_applied = _apply_date_arithmetic(working_sql, target)
+ auto_converted.extend(date_applied)
+
+ # Step 7: Unsupported features
+ unsupported = detect_unsupported_features(original, source, target)
+ converted_funcs = {a.split("→")[0].strip().upper() for a in func_applied if "→" in a}
+ for feature in unsupported:
+ feat_upper = feature.upper().replace(" ", "")
+ if any(cf in feat_upper or feat_upper in cf for cf in converted_funcs):
+ continue
+ requires_review.append(f"Unsupported: {feature}")
+ confidence -= 3
+
+ # Step 8: Behavior warnings
+ target_dialect = get_sqlglot_dialect(normalize_target(target))
+ warnings = get_behavior_warnings(original, source.value, target_dialect)
+ for warning in warnings:
+ requires_review.append(f"Behavior: {warning.name.replace('_', ' ')}")
+ if warning.severity == "high":
+ confidence -= 5
+ elif warning.severity == "medium":
+ confidence -= 2
+
+ # Step 9: Manual review patterns
+ manual_patterns = {
+ "Dynamic SQL": r"EXECUTE\s+IMMEDIATE|EXEC\s*\(",
+ "Cursor processing": r"\bCURSOR\b",
+ "Exception handling": r"\bEXCEPTION\s+WHEN\b",
+ }
+ for name, pattern in manual_patterns.items():
+ if re.search(pattern, original, re.IGNORECASE):
+ requires_review.append(name)
+ confidence -= 8
+
+ confidence = max(0.0, min(100.0, confidence))
+ return (
+ working_sql.strip(),
+ confidence,
+ list(dict.fromkeys(auto_converted)),
+ list(dict.fromkeys(requires_review)),
+ )
+
+
+def translate_object(
+ obj: MigrationObject,
+ source: Dialect,
+ target: Dialect,
+) -> MigrationObject:
+ """Translate a single migration object."""
+ target_sql, confidence, auto_converted, requires_review = translate_sql(
+ obj.source_sql, source, target
+ )
+ obj.target_sql = target_sql
+ obj.conversion_confidence = confidence
+ obj.auto_converted = auto_converted
+ obj.requires_review = requires_review
+ obj.unsupported_features = detect_unsupported_features(obj.source_sql, source, target)
+ return obj
+
+
+def translate_objects(
+ objects: list[MigrationObject],
+ source: Dialect,
+ target: Dialect,
+) -> list[MigrationObject]:
+ """Translate all migration objects."""
+ return [translate_object(obj, source, target) for obj in objects]
diff --git a/morphsql/translator/pandas_codegen.py b/morphsql/translator/pandas_codegen.py
new file mode 100644
index 0000000000000000000000000000000000000000..c42bc8e9fbc6544fa6d61f153a222cde1f1ba95b
--- /dev/null
+++ b/morphsql/translator/pandas_codegen.py
@@ -0,0 +1,650 @@
+"""Convert warehouse SQL (any supported dialect) into runnable pandas Python."""
+
+from __future__ import annotations
+
+import re
+
+import sqlglot
+from sqlglot import exp
+from sqlglot.errors import ParseError
+
+from morphsql.models import Dialect
+from morphsql.parser.sql_parser import get_sqlglot_dialect
+
+
+def is_pandas_target(target: str | Dialect) -> bool:
+ value = target.value if isinstance(target, Dialect) else str(target)
+ return value.lower() in {"pandas", "python", "python-pandas", "dataframe"}
+
+
+def sql_to_pandas(
+ sql: str,
+ source: Dialect,
+) -> tuple[str, float, list[str], list[str]]:
+ """
+ Translate SQL from any supported warehouse dialect into pandas code.
+
+ Returns: (python_code, confidence, auto_notes, review_notes)
+ """
+ from morphsql.translator.engine import (
+ _apply_function_mappings,
+ _apply_syntax_replacements,
+ )
+
+ auto: list[str] = []
+ review: list[str] = []
+ confidence = 92.0
+ original = sql or ""
+ working = original.strip()
+
+ if not working:
+ return (
+ _wrap_module("# Empty SQL — nothing to convert.\nresult = pd.DataFrame()\n", []),
+ 0.0,
+ [],
+ ["Empty SQL input"],
+ )
+
+ working, syn = _apply_syntax_replacements(working, source)
+ auto.extend(syn)
+ # Oracle often strips FROM DUAL — restore a one-row dual for pandas
+ if source == Dialect.ORACLE and re.search(r"\bDUAL\b", original, re.I):
+ if not re.search(r"\bFROM\b", working, re.I):
+ working = working.rstrip().rstrip(";") + " FROM dual"
+ auto.append("Restored FROM dual as one-row pandas stub")
+ # Portable intermediate via Snowflake-oriented function maps
+ working, funcs = _apply_function_mappings(working, source, Dialect.SNOWFLAKE)
+ auto.extend(funcs)
+ auto.append(f"Source dialect normalized: {source.value} → portable SQL → pandas")
+
+ if re.search(r"\bCREATE\s+(?:OR\s+REPLACE\s+)?PROCEDURE\b", working, re.I):
+ review.append(
+ "Stored procedures are not fully expressible in pandas — extracting SELECT bodies"
+ )
+ confidence -= 25
+ working = _extract_selects_from_procedure(working)
+
+ if re.search(r"\bEXECUTE\s+IMMEDIATE\b|\bCURSOR\b|\bEXCEPTION\s+WHEN\b", original, re.I):
+ review.append("Dynamic SQL / cursors / exceptions need manual pandas redesign")
+ confidence -= 15
+
+ statements: list[exp.Expression] = []
+ read_dialect = get_sqlglot_dialect(source)
+ try:
+ statements = [s for s in sqlglot.parse(working, read=read_dialect) if s is not None]
+ except (ParseError, ValueError):
+ statements = []
+
+ if not statements:
+ for fallback in ("snowflake", "postgres", "oracle", "bigquery", "redshift"):
+ try:
+ statements = [s for s in sqlglot.parse(working, read=fallback) if s is not None]
+ if statements:
+ auto.append(f"Parsed with fallback dialect: {fallback}")
+ break
+ except (ParseError, ValueError):
+ continue
+
+ if not statements:
+ confidence = max(20.0, confidence - 40)
+ review.append("Could not parse SQL — emitted stub with original query")
+ stub = (
+ "import pandas as pd\n\n"
+ "# TODO: MorphSQL could not parse this SQL automatically.\n"
+ f"sql = {original.strip()!r}\n"
+ "result = pd.DataFrame() # replace with your load + transform\n"
+ )
+ return stub, confidence, list(dict.fromkeys(auto)), list(dict.fromkeys(review))
+
+ frames_needed: set[str] = set()
+ blocks: list[str] = []
+ converter = _PandasConverter(auto=auto, review=review)
+
+ for i, stmt in enumerate(statements):
+ result_name = "result" if len(statements) == 1 else f"result_{i + 1}"
+ if isinstance(stmt, (exp.Select, exp.Union, exp.With)):
+ code, used = converter.convert_query(stmt, result_name=result_name)
+ blocks.append(code)
+ frames_needed.update(used)
+ continue
+
+ if isinstance(stmt, (exp.Create, exp.Insert, exp.Drop, exp.Command)):
+ review.append(f"Skipped non-SELECT statement: {stmt.__class__.__name__}")
+ confidence -= 8
+ for j, sel in enumerate(stmt.find_all(exp.Select)):
+ code, used = converter.convert_query(sel, result_name=f"result_{len(blocks) + 1}")
+ blocks.append(code)
+ frames_needed.update(used)
+ continue
+
+ sel = stmt.find(exp.Select)
+ if sel is not None:
+ code, used = converter.convert_query(sel, result_name=result_name)
+ blocks.append(code)
+ frames_needed.update(used)
+ else:
+ review.append(f"Unsupported statement type for pandas: {stmt.__class__.__name__}")
+ confidence -= 10
+
+ if not blocks:
+ review.append("No convertible SELECT found")
+ confidence = min(confidence, 35.0)
+ blocks = ["result = pd.DataFrame() # no SELECT to convert\n"]
+
+ confidence = max(0.0, min(100.0, confidence - 3 * len(review)))
+ body = "\n\n".join(blocks)
+ if len(blocks) > 1:
+ last = f"result_{len(blocks)}"
+ if f"{last} =" in body and "\nresult =" not in f"\n{body}":
+ body += f"\n\nresult = {last}\n"
+
+ return (
+ _wrap_module(body, sorted(frames_needed)),
+ confidence,
+ list(dict.fromkeys(auto)),
+ list(dict.fromkeys(review)),
+ )
+
+
+def _wrap_module(body: str, frame_names: list[str]) -> str:
+ lines = [
+ "import pandas as pd",
+ "import numpy as np",
+ "",
+ "# MorphSQL — SQL → pandas",
+ "# Provide a `tables` dict before running (table name → DataFrame).",
+ "tables = globals().get('tables', {})",
+ "",
+ "def _coalesce(value, *defaults):",
+ " cur = value",
+ " for default in defaults:",
+ " if hasattr(cur, 'fillna'):",
+ " cur = cur.fillna(default)",
+ " else:",
+ " cur = default if pd.isna(cur) else cur",
+ " return cur",
+ ]
+ if frame_names:
+ lines.append("# Expected frames:")
+ for name in frame_names:
+ lines.append(f"# tables[{name!r}] = ...")
+ else:
+ lines.append("# Example: tables['my_table'] = pd.read_csv('my_table.csv')")
+ lines.append("")
+ lines.append(body.rstrip())
+ lines.append("")
+ return "\n".join(lines)
+
+
+def _extract_selects_from_procedure(sql: str) -> str:
+ selects = re.findall(
+ r"(SELECT\b.+?)(?:;|END\b|INSERT\b|UPDATE\b|DELETE\b|CREATE\b)",
+ sql,
+ flags=re.I | re.S,
+ )
+ if not selects:
+ m = re.search(r"SELECT\b.+", sql, flags=re.I | re.S)
+ return m.group(0) if m else sql
+ return ";\n".join(s.strip().rstrip(";") for s in selects)
+
+
+def _table_key(table: exp.Table) -> str:
+ parts = [p for p in [table.catalog, table.db, table.name] if p]
+ return ".".join(parts) if parts else (table.name or "table")
+
+
+def _ident(name: str) -> str:
+ return re.sub(r"[^0-9a-zA-Z_]", "_", name or "col")
+
+
+class _PandasConverter:
+ def __init__(self, auto: list[str], review: list[str]):
+ self.auto = auto
+ self.review = review
+ self._cte_vars: dict[str, str] = {}
+ self._dual_mode = False
+
+ def convert_query(self, node: exp.Expression, result_name: str = "result") -> tuple[str, set[str]]:
+ used: set[str] = set()
+ lines: list[str] = []
+
+ if isinstance(node, exp.With):
+ for cte in node.expressions:
+ alias = cte.alias_or_name
+ var = f"cte_{_ident(alias)}"
+ code, u = self.convert_query(cte.this, result_name=var)
+ lines.append(code.rstrip())
+ used.update(u)
+ self._cte_vars[str(alias).lower()] = var
+ node = node.this
+
+ if isinstance(node, exp.Union):
+ left_code, u1 = self.convert_query(node.this, result_name=f"{result_name}_left")
+ right_code, u2 = self.convert_query(node.expression, result_name=f"{result_name}_right")
+ used |= u1 | u2
+ lines.append(left_code.rstrip())
+ lines.append(right_code.rstrip())
+ distinct = node.args.get("distinct")
+ if distinct is False:
+ lines.append(
+ f"{result_name} = pd.concat("
+ f"[{result_name}_left, {result_name}_right], ignore_index=True)"
+ )
+ self.auto.append("UNION ALL → pd.concat")
+ else:
+ lines.append(
+ f"{result_name} = pd.concat("
+ f"[{result_name}_left, {result_name}_right], ignore_index=True)"
+ f".drop_duplicates()"
+ )
+ self.auto.append("UNION → pd.concat + drop_duplicates")
+ return "\n".join(lines) + "\n", used
+
+ if not isinstance(node, exp.Select):
+ sel = node.find(exp.Select)
+ if sel is None:
+ self.review.append(f"Cannot convert {node.__class__.__name__} to pandas")
+ return f"{result_name} = pd.DataFrame()\n", used
+ node = sel
+
+ assert isinstance(node, exp.Select)
+
+ from_ = node.args.get("from_")
+ if from_ is None:
+ cols: dict[str, str] = {}
+ for i, e in enumerate(node.expressions):
+ alias, expr_code = self._project_expr(e, "pd.DataFrame()")
+ cols[alias or f"col_{i}"] = expr_code
+ assign = ", ".join(f"{k!r}: [{v}]" for k, v in cols.items())
+ lines.append(f"{result_name} = pd.DataFrame({{{assign}}})")
+ self.auto.append("Scalar SELECT → one-row DataFrame")
+ return "\n".join(lines) + "\n", used
+
+ df_var = f"{result_name}_df"
+ base_code, base_used = self._from_item(from_.this, df_var)
+ lines.append(base_code.rstrip())
+ used.update(base_used)
+
+ joins = node.args.get("joins") or []
+ for j_i, join in enumerate(joins):
+ right_var = f"{result_name}_r{j_i}"
+ right_code, right_used = self._from_item(join.this, right_var)
+ lines.append(right_code.rstrip())
+ used.update(right_used)
+ how = (join.args.get("side") or "inner").lower()
+ if how not in {"left", "right", "inner", "outer", "full"}:
+ how = "inner"
+ if how == "full":
+ how = "outer"
+ on = join.args.get("on")
+ if on is not None:
+ left_on, right_on = self._join_on_keys(on)
+ if left_on and right_on:
+ lines.append(
+ f"{df_var} = {df_var}.merge({right_var}, how={how!r}, "
+ f"left_on={left_on!r}, right_on={right_on!r})"
+ )
+ else:
+ lines.append(
+ f"{df_var} = {df_var}.merge({right_var}, how={how!r}) "
+ f"# TODO: verify join keys"
+ )
+ self.review.append("Complex JOIN ON — verify merge keys")
+ else:
+ using = join.args.get("using")
+ if using:
+ cols = [e.name for e in using]
+ lines.append(
+ f"{df_var} = {df_var}.merge({right_var}, how={how!r}, on={cols!r})"
+ )
+ else:
+ lines.append(
+ f"{df_var} = {df_var}.merge({right_var}, how={how!r}) # TODO: add ON keys"
+ )
+ self.review.append("JOIN without ON/USING")
+ self.auto.append(f"{how.upper()} JOIN → DataFrame.merge(how={how!r})")
+
+ where = node.args.get("where")
+ if where is not None:
+ cond = self._bool_expr(where.this, df_var)
+ lines.append(f"{df_var} = {df_var}.loc[{cond}]")
+ self.auto.append("WHERE → DataFrame.loc[mask]")
+
+ group = node.args.get("group")
+ projections = list(node.expressions)
+ has_agg = any(e.find(exp.AggFunc) for e in projections)
+
+ if group is not None or has_agg:
+ group_cols: list[str] = []
+ if group is not None:
+ for g in group.expressions:
+ group_cols.append(self._column_name(g))
+
+ rename_map: dict[str, tuple[str, str]] = {}
+ for e in projections:
+ alias = e.alias_or_name
+ inner = e.this if isinstance(e, exp.Alias) else e
+ if isinstance(inner, exp.AggFunc):
+ out_name = alias or f"agg_{len(rename_map)}"
+ # Materialize non-column aggregate inputs as helper columns
+ arg = inner.this
+ if isinstance(inner, exp.Count) and (
+ arg is None or isinstance(arg, exp.Star)
+ ):
+ rename_map[out_name] = (group_cols[0] if group_cols else "__all__", "size")
+ elif isinstance(arg, exp.Column):
+ col, func = self._agg(inner, df_var)
+ rename_map[out_name] = (col, func)
+ else:
+ helper = f"__agg_{_ident(out_name)}"
+ expr_code = self._value_expr(arg, df_var) if arg is not None else "pd.NA"
+ lines.append(f"{df_var}[{helper!r}] = {expr_code}")
+ _, func = self._agg(inner, df_var)
+ if func == "size":
+ func = "count"
+ rename_map[out_name] = (helper, func)
+ self.auto.append(f"Aggregate expression → helper column {helper}")
+ else:
+ name = alias or self._column_name(inner)
+ if name not in group_cols:
+ group_cols.append(name)
+
+ if group_cols and rename_map:
+ named_parts = []
+ for out, (col, func) in rename_map.items():
+ if func == "size":
+ named_parts.append(f"{out}=({group_cols[0]!r}, 'size')")
+ else:
+ named_parts.append(f"{out}=({col!r}, {func!r})")
+ named = ", ".join(named_parts)
+ lines.append(
+ f"{result_name} = {df_var}.groupby({group_cols!r}, as_index=False).agg({named})"
+ )
+ self.auto.append("GROUP BY + aggregates → groupby().agg()")
+ elif rename_map and not group_cols:
+ parts = []
+ for out, (col, func) in rename_map.items():
+ if func == "size":
+ parts.append(f"{out!r}: [len({df_var})]")
+ else:
+ parts.append(f"{out!r}: [{df_var}[{col!r}].{func}()]")
+ lines.append(f"{result_name} = pd.DataFrame({{{', '.join(parts)}}})")
+ self.auto.append("Aggregate without GROUP BY → scalar DataFrame")
+ else:
+ lines.append(f"{result_name} = {df_var}[{group_cols!r}].drop_duplicates()")
+ else:
+ col_exprs: list[tuple[str, str]] = []
+ star = False
+ for e in projections:
+ if isinstance(e, exp.Star) or (
+ isinstance(e, exp.Column) and getattr(e, "name", None) == "*"
+ ):
+ star = True
+ break
+ alias = e.alias if isinstance(e, exp.Alias) else None
+ inner = e.this if isinstance(e, exp.Alias) else e
+ if isinstance(inner, exp.Column) and not alias:
+ col_exprs.append((inner.name, f"{df_var}[{inner.name!r}]"))
+ else:
+ name = alias or self._column_name(inner)
+ col_exprs.append((name, self._value_expr(inner, df_var)))
+
+ if star and not col_exprs:
+ lines.append(f"{result_name} = {df_var}.copy()")
+ self.auto.append("SELECT * → DataFrame.copy()")
+ elif star and col_exprs:
+ assigns = ", ".join(f"{n!r}: ({ex})" for n, ex in col_exprs)
+ lines.append(f"_extra = pd.DataFrame({{{assigns}}}, index={df_var}.index)")
+ lines.append(f"{result_name} = pd.concat([{df_var}, _extra], axis=1)")
+ else:
+ assigns = ", ".join(f"{n!r}: ({ex})" for n, ex in col_exprs)
+ lines.append(f"{result_name} = pd.DataFrame({{{assigns}}}, index={df_var}.index)")
+ self.auto.append("SELECT columns → DataFrame projection")
+
+ if node.args.get("distinct"):
+ lines.append(f"{result_name} = {result_name}.drop_duplicates()")
+ self.auto.append("DISTINCT → drop_duplicates()")
+
+ order = node.args.get("order")
+ if order is not None:
+ by = []
+ ascending = []
+ for o in order.expressions:
+ col = o.this if isinstance(o, exp.Ordered) else o
+ by.append(self._column_name(col))
+ desc = bool(o.args.get("desc")) if isinstance(o, exp.Ordered) else False
+ ascending.append(not desc)
+ lines.append(
+ f"{result_name} = {result_name}.sort_values({by!r}, ascending={ascending!r})"
+ )
+ self.auto.append("ORDER BY → sort_values()")
+
+ limit = node.args.get("limit")
+ if limit is not None:
+ n = limit.expression if hasattr(limit, "expression") and limit.expression else limit.this
+ try:
+ if hasattr(n, "name") and n.name is not None:
+ n_val: int | str = int(n.name)
+ elif hasattr(n, "this"):
+ n_val = int(n.this)
+ else:
+ n_val = int(str(n))
+ except Exception:
+ n_val = 10
+ self.review.append("Non-literal LIMIT — defaulted to head(10)")
+ lines.append(f"{result_name} = {result_name}.head({n_val})")
+ self.auto.append("LIMIT → head()")
+
+ return "\n".join(lines) + "\n", used
+
+ def _from_item(self, node: exp.Expression, var: str) -> tuple[str, set[str]]:
+ used: set[str] = set()
+ if isinstance(node, exp.Alias) and isinstance(
+ node.this, (exp.Table, exp.Subquery, exp.Select)
+ ):
+ return self._from_item(node.this, var)
+ if isinstance(node, exp.Table):
+ key = _table_key(node)
+ if key.lower() in {"dual"}:
+ # Oracle DUAL → one-row stub (no external tables entry required)
+ self.auto.append("FROM dual → one-row stub DataFrame")
+ self._dual_mode = True
+ return f"{var} = pd.DataFrame({{'_dual': [0]}})\n", used
+ self._dual_mode = False
+ if key.lower() in self._cte_vars:
+ return f"{var} = {self._cte_vars[key.lower()]}.copy()\n", used
+ short = key.split(".")[-1]
+ if short.lower() in self._cte_vars:
+ return f"{var} = {self._cte_vars[short.lower()]}.copy()\n", used
+ used.add(key)
+ return f"{var} = tables[{key!r}].copy()\n", used
+ if isinstance(node, exp.Subquery):
+ return self.convert_query(node.this, result_name=var)
+ self.review.append(f"Unusual FROM clause: {node.__class__.__name__}")
+ return f"{var} = pd.DataFrame() # TODO: FROM {node.sql()}\n", used
+
+ def _join_on_keys(self, on: exp.Expression) -> tuple[str | None, str | None]:
+ if isinstance(on, exp.EQ):
+ left, right = on.left, on.right
+ if isinstance(left, exp.Column) and isinstance(right, exp.Column):
+ return left.name, right.name
+ return None, None
+
+ def _column_name(self, node: exp.Expression) -> str:
+ if isinstance(node, exp.Column):
+ return node.name
+ if isinstance(node, exp.Alias):
+ return node.alias_or_name
+ if isinstance(node, exp.Literal):
+ return str(node.this)
+ try:
+ return _ident(node.sql())
+ except Exception:
+ return "col"
+
+ def _agg(self, node: exp.AggFunc, df_var: str) -> tuple[str, str]:
+ mapping = {
+ exp.Count: "count",
+ exp.Sum: "sum",
+ exp.Avg: "mean",
+ exp.Min: "min",
+ exp.Max: "max",
+ }
+ func = "sum"
+ for typ, name in mapping.items():
+ if isinstance(node, typ):
+ func = name
+ break
+ else:
+ self.review.append(f"Aggregate {node.__class__.__name__} approximated")
+
+ if isinstance(node, exp.Count) and (
+ node.this is None or isinstance(node.this, exp.Star)
+ ):
+ return "__all__", "size"
+ col = self._column_name(node.this) if node.this is not None else "__all__"
+ return col, func
+
+ def _bool_expr(self, node: exp.Expression, df_var: str) -> str:
+ if isinstance(node, exp.And):
+ return f"({self._bool_expr(node.left, df_var)}) & ({self._bool_expr(node.right, df_var)})"
+ if isinstance(node, exp.Or):
+ return f"({self._bool_expr(node.left, df_var)}) | ({self._bool_expr(node.right, df_var)})"
+ if isinstance(node, exp.Not):
+ return f"~({self._bool_expr(node.this, df_var)})"
+ if isinstance(node, exp.EQ):
+ return f"({self._value_expr(node.left, df_var)} == {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.NEQ):
+ return f"({self._value_expr(node.left, df_var)} != {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.GT):
+ return f"({self._value_expr(node.left, df_var)} > {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.GTE):
+ return f"({self._value_expr(node.left, df_var)} >= {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.LT):
+ return f"({self._value_expr(node.left, df_var)} < {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.LTE):
+ return f"({self._value_expr(node.left, df_var)} <= {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.Is):
+ left = self._value_expr(node.this, df_var)
+ if node.args.get("not"):
+ return f"({left}.notna())"
+ return f"({left}.isna())"
+ if isinstance(node, exp.In):
+ left = self._value_expr(node.this, df_var)
+ vals = ", ".join(self._value_expr(v, df_var) for v in node.expressions)
+ return f"({left}.isin([{vals}]))"
+ if isinstance(node, exp.Like):
+ left = self._value_expr(node.this, df_var)
+ pat = self._value_expr(node.expression, df_var)
+ self.review.append("LIKE approximated with pandas str.contains")
+ return f"({left}.astype(str).str.contains(str({pat}).replace('%', ''), na=False))"
+ if isinstance(node, exp.Between):
+ val = self._value_expr(node.this, df_var)
+ low = self._value_expr(node.args["low"], df_var)
+ high = self._value_expr(node.args["high"], df_var)
+ return f"(({val}) >= ({low})) & (({val}) <= ({high}))"
+ self.review.append(f"Complex predicate approximated: {node.__class__.__name__}")
+ return f"(True) # TODO: {node.sql()}"
+
+ def _value_expr(self, node: exp.Expression, df_var: str) -> str:
+ if node is None:
+ return "None"
+ if isinstance(node, exp.Column):
+ if self._dual_mode and node.name.lower() != "_dual":
+ # Unqualified names on DUAL are constants / binds, not table columns
+ self.review.append(
+ f"Column `{node.name}` on DUAL treated as null constant — replace with a real value"
+ )
+ return "pd.NA"
+ return f"{df_var}[{node.name!r}]"
+ if isinstance(node, exp.Literal):
+ if node.is_string:
+ return repr(node.this)
+ return str(node.this)
+ if isinstance(node, exp.Boolean):
+ return "True" if node.this else "False"
+ if isinstance(node, exp.Null):
+ return "pd.NA"
+ if isinstance(node, exp.Paren):
+ return f"({self._value_expr(node.this, df_var)})"
+ if isinstance(node, exp.Sub):
+ left = self._value_expr(node.left, df_var)
+ right = self._value_expr(node.right, df_var)
+ if isinstance(node.left, (exp.CurrentDate, exp.CurrentTimestamp)) and isinstance(
+ node.right, exp.Literal
+ ) and not node.right.is_string:
+ return f"({left} - pd.Timedelta(days={node.right.this}))"
+ return f"({left} - {right})"
+ if isinstance(node, exp.Add):
+ left = self._value_expr(node.left, df_var)
+ right = self._value_expr(node.right, df_var)
+ if isinstance(node.left, (exp.CurrentDate, exp.CurrentTimestamp)) and isinstance(
+ node.right, exp.Literal
+ ) and not node.right.is_string:
+ return f"({left} + pd.Timedelta(days={node.right.this}))"
+ return f"({left} + {right})"
+ if isinstance(node, exp.Mul):
+ return f"({self._value_expr(node.left, df_var)} * {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.Div):
+ return f"({self._value_expr(node.left, df_var)} / {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.Coalesce):
+ parts = [self._value_expr(node.this, df_var)] if node.this is not None else []
+ parts.extend(self._value_expr(x, df_var) for x in (node.expressions or []))
+ if not parts:
+ return "pd.NA"
+ if len(parts) == 1:
+ return parts[0]
+ return f"_coalesce({', '.join(parts)})"
+ if isinstance(node, exp.Anonymous):
+ name = (node.name or "").upper()
+ args = node.expressions
+ if name in {"NVL", "IFNULL", "ZEROIFNULL"} and args:
+ base = self._value_expr(args[0], df_var)
+ fill = self._value_expr(args[1], df_var) if len(args) > 1 else "0"
+ if name == "ZEROIFNULL" and len(args) == 1:
+ fill = "0"
+ return f"_coalesce({base}, {fill})"
+ if name == "GREATEST" and len(args) >= 2:
+ return (
+ f"np.maximum({self._value_expr(args[0], df_var)}, "
+ f"{self._value_expr(args[1], df_var)})"
+ )
+ if name == "LEAST" and len(args) >= 2:
+ return (
+ f"np.minimum({self._value_expr(args[0], df_var)}, "
+ f"{self._value_expr(args[1], df_var)})"
+ )
+ self.review.append(f"Function {name} needs review in pandas output")
+ return f"({self._value_expr(args[0], df_var) if args else 'pd.NA'}) # TODO:{name}"
+ if isinstance(node, exp.Cast):
+ return f"({self._value_expr(node.this, df_var)})"
+ if isinstance(node, exp.CurrentDate):
+ return "pd.Timestamp.today().normalize()"
+ if isinstance(node, exp.CurrentTimestamp):
+ return "pd.Timestamp.now()"
+ if isinstance(node, (exp.Upper, exp.Lower)):
+ fn = "upper" if isinstance(node, exp.Upper) else "lower"
+ return f"({self._value_expr(node.this, df_var)}).astype(str).str.{fn}()"
+ if isinstance(node, exp.Case):
+ self.review.append("CASE expression approximated — review branching")
+ ifs = node.args.get("ifs") or []
+ default = node.args.get("default")
+ if ifs:
+ when = ifs[0]
+ cond = self._bool_expr(when.this, df_var)
+ tru = self._value_expr(when.args.get("true"), df_var)
+ fal = self._value_expr(default, df_var) if default is not None else "pd.NA"
+ return f"np.where({cond}, {tru}, {fal})"
+ return "pd.NA"
+ try:
+ sql = node.sql()
+ except Exception:
+ sql = node.__class__.__name__
+ self.review.append(f"Expression approximated: {node.__class__.__name__}")
+ return f"(pd.NA) # TODO: {sql}"
+
+ def _project_expr(self, node: exp.Expression, df_var: str) -> tuple[str, str]:
+ alias = node.alias if isinstance(node, exp.Alias) else None
+ inner = node.this if isinstance(node, exp.Alias) else node
+ name = alias or self._column_name(inner)
+ return name, self._value_expr(inner, df_var)
diff --git a/morphsql/translator/pyspark_codegen.py b/morphsql/translator/pyspark_codegen.py
new file mode 100644
index 0000000000000000000000000000000000000000..b3b958313d867a886b9b8e6938d1b7a9856b4697
--- /dev/null
+++ b/morphsql/translator/pyspark_codegen.py
@@ -0,0 +1,603 @@
+"""Convert warehouse SQL (any supported dialect) into runnable PySpark code."""
+
+from __future__ import annotations
+
+import re
+
+import sqlglot
+from sqlglot import exp
+from sqlglot.errors import ParseError
+
+from morphsql.models import Dialect
+from morphsql.parser.sql_parser import get_sqlglot_dialect
+
+
+def is_pyspark_target(target: str | Dialect) -> bool:
+ value = target.value if isinstance(target, Dialect) else str(target)
+ return value.lower() in {"pyspark", "spark", "python-pyspark", "spark-sql-df"}
+
+
+def sql_to_pyspark(
+ sql: str,
+ source: Dialect,
+) -> tuple[str, float, list[str], list[str]]:
+ """
+ Translate SQL from any supported warehouse dialect into PySpark DataFrame code.
+
+ Returns: (python_code, confidence, auto_notes, review_notes)
+ """
+ from morphsql.translator.engine import (
+ _apply_function_mappings,
+ _apply_syntax_replacements,
+ )
+
+ auto: list[str] = []
+ review: list[str] = []
+ confidence = 90.0
+ original = sql or ""
+ working = original.strip()
+
+ if not working:
+ return (
+ _wrap_module("# Empty SQL — nothing to convert.\nresult = spark.createDataFrame([], schema=None)\n", []),
+ 0.0,
+ [],
+ ["Empty SQL input"],
+ )
+
+ working, syn = _apply_syntax_replacements(working, source)
+ auto.extend(syn)
+ if source == Dialect.ORACLE and re.search(r"\bDUAL\b", original, re.I):
+ if not re.search(r"\bFROM\b", working, re.I):
+ working = working.rstrip().rstrip(";") + " FROM dual"
+ auto.append("Restored FROM dual as one-row Spark stub")
+
+ working, funcs = _apply_function_mappings(working, source, Dialect.SNOWFLAKE)
+ auto.extend(funcs)
+ auto.append(f"Source dialect normalized: {source.value} → portable SQL → pyspark")
+
+ if re.search(r"\bCREATE\s+(?:OR\s+REPLACE\s+)?PROCEDURE\b", working, re.I):
+ review.append(
+ "Stored procedures are not fully expressible in PySpark — extracting SELECT bodies"
+ )
+ confidence -= 25
+ working = _extract_selects_from_procedure(working)
+
+ if re.search(r"\bEXECUTE\s+IMMEDIATE\b|\bCURSOR\b|\bEXCEPTION\s+WHEN\b", original, re.I):
+ review.append("Dynamic SQL / cursors / exceptions need manual Spark redesign")
+ confidence -= 15
+
+ statements: list[exp.Expression] = []
+ read_dialect = get_sqlglot_dialect(source)
+ try:
+ statements = [s for s in sqlglot.parse(working, read=read_dialect) if s is not None]
+ except (ParseError, ValueError):
+ statements = []
+
+ if not statements:
+ for fallback in ("snowflake", "postgres", "oracle", "bigquery", "redshift"):
+ try:
+ statements = [s for s in sqlglot.parse(working, read=fallback) if s is not None]
+ if statements:
+ auto.append(f"Parsed with fallback dialect: {fallback}")
+ break
+ except (ParseError, ValueError):
+ continue
+
+ if not statements:
+ confidence = max(20.0, confidence - 40)
+ review.append("Could not parse SQL — emitted stub with original query")
+ stub = (
+ "from pyspark.sql import SparkSession, functions as F\n\n"
+ "spark = SparkSession.builder.getOrCreate()\n"
+ "# TODO: MorphSQL could not parse this SQL automatically.\n"
+ f"sql = {original.strip()!r}\n"
+ "result = spark.sql(sql) # fallback: run as Spark SQL if dialect-compatible\n"
+ )
+ return stub, confidence, list(dict.fromkeys(auto)), list(dict.fromkeys(review))
+
+ frames_needed: set[str] = set()
+ blocks: list[str] = []
+ converter = _SparkConverter(auto=auto, review=review)
+
+ for i, stmt in enumerate(statements):
+ result_name = "result" if len(statements) == 1 else f"result_{i + 1}"
+ if isinstance(stmt, (exp.Select, exp.Union, exp.With)):
+ code, used = converter.convert_query(stmt, result_name=result_name)
+ blocks.append(code)
+ frames_needed.update(used)
+ continue
+
+ if isinstance(stmt, (exp.Create, exp.Insert, exp.Drop, exp.Command)):
+ review.append(f"Skipped non-SELECT statement: {stmt.__class__.__name__}")
+ confidence -= 8
+ for sel in stmt.find_all(exp.Select):
+ code, used = converter.convert_query(sel, result_name=f"result_{len(blocks) + 1}")
+ blocks.append(code)
+ frames_needed.update(used)
+ continue
+
+ sel = stmt.find(exp.Select)
+ if sel is not None:
+ code, used = converter.convert_query(sel, result_name=result_name)
+ blocks.append(code)
+ frames_needed.update(used)
+ else:
+ review.append(f"Unsupported statement type for PySpark: {stmt.__class__.__name__}")
+ confidence -= 10
+
+ if not blocks:
+ review.append("No convertible SELECT found")
+ confidence = min(confidence, 35.0)
+ blocks = ["result = spark.createDataFrame([], schema=None) # no SELECT\n"]
+
+ confidence = max(0.0, min(100.0, confidence - 3 * len(review)))
+ body = "\n\n".join(blocks)
+ if len(blocks) > 1:
+ last = f"result_{len(blocks)}"
+ if f"{last} =" in body and "\nresult =" not in f"\n{body}":
+ body += f"\n\nresult = {last}\n"
+
+ return (
+ _wrap_module(body, sorted(frames_needed)),
+ confidence,
+ list(dict.fromkeys(auto)),
+ list(dict.fromkeys(review)),
+ )
+
+
+def _wrap_module(body: str, frame_names: list[str]) -> str:
+ lines = [
+ "from pyspark.sql import SparkSession, functions as F, Window",
+ "from pyspark.sql.types import *",
+ "",
+ "# MorphSQL — SQL → PySpark",
+ "spark = SparkSession.builder.getOrCreate()",
+ "",
+ "# Provide input DataFrames (or replace with spark.table / spark.read).",
+ "tables: dict = {}",
+ ]
+ if frame_names:
+ lines.append("# Expected frames:")
+ for name in frame_names:
+ lines.append(f"# tables[{name!r}] = spark.table({name!r}) # or spark.read.parquet(...)")
+ else:
+ lines.append("# Example: tables['my_table'] = spark.read.parquet('my_table')")
+ lines.append("")
+ lines.append(body.rstrip())
+ lines.append("")
+ lines.append("# result.show() # optional")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def _extract_selects_from_procedure(sql: str) -> str:
+ selects = re.findall(
+ r"(SELECT\b.+?)(?:;|END\b|INSERT\b|UPDATE\b|DELETE\b|CREATE\b)",
+ sql,
+ flags=re.I | re.S,
+ )
+ if not selects:
+ m = re.search(r"SELECT\b.+", sql, flags=re.I | re.S)
+ return m.group(0) if m else sql
+ return ";\n".join(s.strip().rstrip(";") for s in selects)
+
+
+def _table_key(table: exp.Table) -> str:
+ parts = [p for p in [table.catalog, table.db, table.name] if p]
+ return ".".join(parts) if parts else (table.name or "table")
+
+
+def _ident(name: str) -> str:
+ return re.sub(r"[^0-9a-zA-Z_]", "_", name or "col")
+
+
+class _SparkConverter:
+ def __init__(self, auto: list[str], review: list[str]):
+ self.auto = auto
+ self.review = review
+ self._cte_vars: dict[str, str] = {}
+ self._dual_mode = False
+
+ def convert_query(self, node: exp.Expression, result_name: str = "result") -> tuple[str, set[str]]:
+ used: set[str] = set()
+ lines: list[str] = []
+
+ if isinstance(node, exp.With):
+ for cte in node.expressions:
+ alias = cte.alias_or_name
+ var = f"cte_{_ident(alias)}"
+ code, u = self.convert_query(cte.this, result_name=var)
+ lines.append(code.rstrip())
+ used.update(u)
+ self._cte_vars[str(alias).lower()] = var
+ node = node.this
+
+ if isinstance(node, exp.Union):
+ left_code, u1 = self.convert_query(node.this, result_name=f"{result_name}_left")
+ right_code, u2 = self.convert_query(node.expression, result_name=f"{result_name}_right")
+ used |= u1 | u2
+ lines.append(left_code.rstrip())
+ lines.append(right_code.rstrip())
+ if node.args.get("distinct") is False:
+ lines.append(f"{result_name} = {result_name}_left.unionAll({result_name}_right)")
+ self.auto.append("UNION ALL → DataFrame.unionAll")
+ else:
+ lines.append(f"{result_name} = {result_name}_left.union({result_name}_right).distinct()")
+ self.auto.append("UNION → DataFrame.union().distinct()")
+ return "\n".join(lines) + "\n", used
+
+ if not isinstance(node, exp.Select):
+ sel = node.find(exp.Select)
+ if sel is None:
+ self.review.append(f"Cannot convert {node.__class__.__name__} to PySpark")
+ return f"{result_name} = spark.createDataFrame([], schema=None)\n", used
+ node = sel
+
+ assert isinstance(node, exp.Select)
+
+ from_ = node.args.get("from_")
+ if from_ is None:
+ cols: dict[str, str] = {}
+ for i, e in enumerate(node.expressions):
+ alias, expr_code = self._project_expr(e)
+ cols[alias or f"col_{i}"] = expr_code
+ select_args = ", ".join(f"{v}.alias({k!r})" for k, v in cols.items())
+ lines.append(
+ f"{result_name} = spark.range(1).select({select_args})"
+ )
+ self.auto.append("Scalar SELECT → spark.range(1).select(...)")
+ return "\n".join(lines) + "\n", used
+
+ df_var = f"{result_name}_df"
+ base_code, base_used = self._from_item(from_.this, df_var)
+ lines.append(base_code.rstrip())
+ used.update(base_used)
+
+ joins = node.args.get("joins") or []
+ for j_i, join in enumerate(joins):
+ right_var = f"{result_name}_r{j_i}"
+ right_code, right_used = self._from_item(join.this, right_var)
+ lines.append(right_code.rstrip())
+ used.update(right_used)
+ how = (join.args.get("side") or "inner").lower()
+ if how not in {"left", "right", "inner", "outer", "full"}:
+ how = "inner"
+ if how == "full":
+ how = "outer"
+ on = join.args.get("on")
+ if on is not None:
+ left_on, right_on = self._join_on_keys(on)
+ if left_on and right_on:
+ lines.append(
+ f"{df_var} = {df_var}.join({right_var}, "
+ f"{df_var}[{left_on!r}] == {right_var}[{right_on!r}], how={how!r})"
+ )
+ else:
+ lines.append(
+ f"{df_var} = {df_var}.join({right_var}, how={how!r}) # TODO: join keys"
+ )
+ self.review.append("Complex JOIN ON — verify Spark join keys")
+ else:
+ using = join.args.get("using")
+ if using:
+ cols = [e.name for e in using]
+ lines.append(
+ f"{df_var} = {df_var}.join({right_var}, on={cols!r}, how={how!r})"
+ )
+ else:
+ lines.append(
+ f"{df_var} = {df_var}.join({right_var}, how={how!r}) # TODO: add ON"
+ )
+ self.review.append("JOIN without ON/USING")
+ self.auto.append(f"{how.upper()} JOIN → DataFrame.join(how={how!r})")
+
+ where = node.args.get("where")
+ if where is not None:
+ cond = self._bool_expr(where.this, df_var)
+ lines.append(f"{df_var} = {df_var}.filter({cond})")
+ self.auto.append("WHERE → DataFrame.filter")
+
+ group = node.args.get("group")
+ projections = list(node.expressions)
+ has_agg = any(e.find(exp.AggFunc) for e in projections)
+
+ if group is not None or has_agg:
+ group_cols: list[str] = []
+ if group is not None:
+ for g in group.expressions:
+ group_cols.append(self._column_name(g))
+
+ agg_exprs: list[str] = []
+ for e in projections:
+ alias = e.alias_or_name
+ inner = e.this if isinstance(e, exp.Alias) else e
+ if isinstance(inner, exp.AggFunc):
+ out_name = alias or f"agg_{len(agg_exprs)}"
+ agg_exprs.append(self._agg_expr(inner, out_name, df_var))
+ else:
+ name = alias or self._column_name(inner)
+ if name not in group_cols:
+ group_cols.append(name)
+
+ if group_cols and agg_exprs:
+ gcols = ", ".join(repr(c) for c in group_cols)
+ aggs = ", ".join(agg_exprs)
+ lines.append(f"{result_name} = {df_var}.groupBy({gcols}).agg({aggs})")
+ self.auto.append("GROUP BY + aggregates → groupBy().agg()")
+ elif agg_exprs and not group_cols:
+ aggs = ", ".join(agg_exprs)
+ lines.append(f"{result_name} = {df_var}.agg({aggs})")
+ self.auto.append("Aggregate without GROUP BY → DataFrame.agg()")
+ else:
+ gcols = ", ".join(repr(c) for c in group_cols)
+ lines.append(f"{result_name} = {df_var}.select({gcols}).distinct()")
+ else:
+ select_parts: list[str] = []
+ star = False
+ for e in projections:
+ if isinstance(e, exp.Star) or (
+ isinstance(e, exp.Column) and getattr(e, "name", None) == "*"
+ ):
+ star = True
+ break
+ alias = e.alias if isinstance(e, exp.Alias) else None
+ inner = e.this if isinstance(e, exp.Alias) else e
+ if isinstance(inner, exp.Column) and not alias:
+ select_parts.append(f"{df_var}[{inner.name!r}]")
+ else:
+ name = alias or self._column_name(inner)
+ select_parts.append(f"({self._value_expr(inner, df_var)}).alias({name!r})")
+
+ if star and not select_parts:
+ lines.append(f"{result_name} = {df_var}")
+ self.auto.append("SELECT * → DataFrame passthrough")
+ elif star and select_parts:
+ lines.append(
+ f"{result_name} = {df_var}.select('*', {', '.join(select_parts)})"
+ )
+ else:
+ lines.append(f"{result_name} = {df_var}.select({', '.join(select_parts)})")
+ self.auto.append("SELECT columns → DataFrame.select")
+
+ if node.args.get("distinct"):
+ lines.append(f"{result_name} = {result_name}.distinct()")
+ self.auto.append("DISTINCT → distinct()")
+
+ order = node.args.get("order")
+ if order is not None:
+ order_parts = []
+ for o in order.expressions:
+ col = o.this if isinstance(o, exp.Ordered) else o
+ name = self._column_name(col)
+ desc = bool(o.args.get("desc")) if isinstance(o, exp.Ordered) else False
+ order_parts.append(f"F.col({name!r}).desc()" if desc else f"F.col({name!r}).asc()")
+ lines.append(f"{result_name} = {result_name}.orderBy({', '.join(order_parts)})")
+ self.auto.append("ORDER BY → orderBy()")
+
+ limit = node.args.get("limit")
+ if limit is not None:
+ n = limit.expression if hasattr(limit, "expression") and limit.expression else limit.this
+ try:
+ if hasattr(n, "name") and n.name is not None:
+ n_val: int | str = int(n.name)
+ elif hasattr(n, "this"):
+ n_val = int(n.this)
+ else:
+ n_val = int(str(n))
+ except Exception:
+ n_val = 10
+ self.review.append("Non-literal LIMIT — defaulted to limit(10)")
+ lines.append(f"{result_name} = {result_name}.limit({n_val})")
+ self.auto.append("LIMIT → limit()")
+
+ return "\n".join(lines) + "\n", used
+
+ def _from_item(self, node: exp.Expression, var: str) -> tuple[str, set[str]]:
+ used: set[str] = set()
+ if isinstance(node, exp.Alias) and isinstance(
+ node.this, (exp.Table, exp.Subquery, exp.Select)
+ ):
+ return self._from_item(node.this, var)
+ if isinstance(node, exp.Table):
+ key = _table_key(node)
+ if key.lower() in {"dual"}:
+ self.auto.append("FROM dual → spark.range(1) stub")
+ self._dual_mode = True
+ return f"{var} = spark.range(1).withColumnRenamed('id', '_dual')\n", used
+ self._dual_mode = False
+ if key.lower() in self._cte_vars:
+ return f"{var} = {self._cte_vars[key.lower()]}\n", used
+ short = key.split(".")[-1]
+ if short.lower() in self._cte_vars:
+ return f"{var} = {self._cte_vars[short.lower()]}\n", used
+ used.add(key)
+ return (
+ f"{var} = tables.get({key!r}) or spark.table({key!r})\n",
+ used,
+ )
+ if isinstance(node, exp.Subquery):
+ return self.convert_query(node.this, result_name=var)
+ self.review.append(f"Unusual FROM clause: {node.__class__.__name__}")
+ return f"{var} = spark.createDataFrame([], schema=None) # TODO: FROM\n", used
+
+ def _join_on_keys(self, on: exp.Expression) -> tuple[str | None, str | None]:
+ if isinstance(on, exp.EQ):
+ left, right = on.left, on.right
+ if isinstance(left, exp.Column) and isinstance(right, exp.Column):
+ return left.name, right.name
+ return None, None
+
+ def _column_name(self, node: exp.Expression) -> str:
+ if isinstance(node, exp.Column):
+ return node.name
+ if isinstance(node, exp.Alias):
+ return node.alias_or_name
+ if isinstance(node, exp.Literal):
+ return str(node.this)
+ try:
+ return _ident(node.sql())
+ except Exception:
+ return "col"
+
+ def _agg_expr(self, node: exp.AggFunc, out_name: str, df_var: str) -> str:
+ mapping = {
+ exp.Count: "count",
+ exp.Sum: "sum",
+ exp.Avg: "avg",
+ exp.Min: "min",
+ exp.Max: "max",
+ }
+ func = "sum"
+ for typ, name in mapping.items():
+ if isinstance(node, typ):
+ func = name
+ break
+ else:
+ self.review.append(f"Aggregate {node.__class__.__name__} approximated")
+
+ if isinstance(node, exp.Count) and (node.this is None or isinstance(node.this, exp.Star)):
+ return f"F.count(F.lit(1)).alias({out_name!r})"
+ arg = self._value_expr(node.this, df_var) if node.this is not None else "F.lit(1)"
+ return f"F.{func}({arg}).alias({out_name!r})"
+
+ def _bool_expr(self, node: exp.Expression, df_var: str) -> str:
+ if isinstance(node, exp.And):
+ return f"({self._bool_expr(node.left, df_var)}) & ({self._bool_expr(node.right, df_var)})"
+ if isinstance(node, exp.Or):
+ return f"({self._bool_expr(node.left, df_var)}) | ({self._bool_expr(node.right, df_var)})"
+ if isinstance(node, exp.Not):
+ return f"~({self._bool_expr(node.this, df_var)})"
+ if isinstance(node, exp.EQ):
+ return f"({self._value_expr(node.left, df_var)} == {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.NEQ):
+ return f"({self._value_expr(node.left, df_var)} != {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.GT):
+ return f"({self._value_expr(node.left, df_var)} > {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.GTE):
+ return f"({self._value_expr(node.left, df_var)} >= {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.LT):
+ return f"({self._value_expr(node.left, df_var)} < {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.LTE):
+ return f"({self._value_expr(node.left, df_var)} <= {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.Is):
+ left = self._value_expr(node.this, df_var)
+ if node.args.get("not"):
+ return f"({left}.isNotNull())"
+ return f"({left}.isNull())"
+ if isinstance(node, exp.In):
+ left = self._value_expr(node.this, df_var)
+ vals = ", ".join(self._value_expr(v, df_var) for v in node.expressions)
+ return f"({left}.isin([{vals}]))"
+ if isinstance(node, exp.Like):
+ left = self._value_expr(node.this, df_var)
+ pat = self._value_expr(node.expression, df_var)
+ return f"({left}.like({pat}))"
+ if isinstance(node, exp.Between):
+ val = self._value_expr(node.this, df_var)
+ low = self._value_expr(node.args["low"], df_var)
+ high = self._value_expr(node.args["high"], df_var)
+ return f"(({val}) >= ({low})) & (({val}) <= ({high}))"
+ self.review.append(f"Complex predicate approximated: {node.__class__.__name__}")
+ return f"(F.lit(True)) # TODO: {node.sql()}"
+
+ def _value_expr(self, node: exp.Expression, df_var: str) -> str:
+ if node is None:
+ return "F.lit(None)"
+ if isinstance(node, exp.Column):
+ if self._dual_mode and node.name.lower() != "_dual":
+ self.review.append(
+ f"Column `{node.name}` on DUAL treated as null — replace with a literal"
+ )
+ return "F.lit(None)"
+ return f"F.col({node.name!r})"
+ if isinstance(node, exp.Literal):
+ if node.is_string:
+ return f"F.lit({node.this!r})"
+ return f"F.lit({node.this})"
+ if isinstance(node, exp.Boolean):
+ return f"F.lit({bool(node.this)})"
+ if isinstance(node, exp.Null):
+ return "F.lit(None)"
+ if isinstance(node, exp.Paren):
+ return f"({self._value_expr(node.this, df_var)})"
+ if isinstance(node, exp.Sub):
+ left = self._value_expr(node.left, df_var)
+ right = self._value_expr(node.right, df_var)
+ if isinstance(node.left, (exp.CurrentDate, exp.CurrentTimestamp)) and isinstance(
+ node.right, exp.Literal
+ ) and not node.right.is_string:
+ return f"F.date_sub({left}, {node.right.this})"
+ return f"({left} - {right})"
+ if isinstance(node, exp.Add):
+ left = self._value_expr(node.left, df_var)
+ right = self._value_expr(node.right, df_var)
+ if isinstance(node.left, (exp.CurrentDate, exp.CurrentTimestamp)) and isinstance(
+ node.right, exp.Literal
+ ) and not node.right.is_string:
+ return f"F.date_add({left}, {node.right.this})"
+ return f"({left} + {right})"
+ if isinstance(node, exp.Mul):
+ return f"({self._value_expr(node.left, df_var)} * {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.Div):
+ return f"({self._value_expr(node.left, df_var)} / {self._value_expr(node.right, df_var)})"
+ if isinstance(node, exp.Coalesce):
+ parts = [self._value_expr(node.this, df_var)] if node.this is not None else []
+ parts.extend(self._value_expr(x, df_var) for x in (node.expressions or []))
+ if not parts:
+ return "F.lit(None)"
+ return f"F.coalesce({', '.join(parts)})"
+ if isinstance(node, exp.Anonymous):
+ name = (node.name or "").upper()
+ args = node.expressions
+ if name in {"NVL", "IFNULL", "ZEROIFNULL"} and args:
+ base = self._value_expr(args[0], df_var)
+ fill = self._value_expr(args[1], df_var) if len(args) > 1 else "F.lit(0)"
+ if name == "ZEROIFNULL" and len(args) == 1:
+ fill = "F.lit(0)"
+ return f"F.coalesce({base}, {fill})"
+ if name == "GREATEST" and len(args) >= 2:
+ return (
+ f"F.greatest({self._value_expr(args[0], df_var)}, "
+ f"{self._value_expr(args[1], df_var)})"
+ )
+ if name == "LEAST" and len(args) >= 2:
+ return (
+ f"F.least({self._value_expr(args[0], df_var)}, "
+ f"{self._value_expr(args[1], df_var)})"
+ )
+ self.review.append(f"Function {name} needs review in PySpark output")
+ return f"({self._value_expr(args[0], df_var) if args else 'F.lit(None)'}) # TODO:{name}"
+ if isinstance(node, exp.Cast):
+ return f"({self._value_expr(node.this, df_var)})"
+ if isinstance(node, exp.CurrentDate):
+ return "F.current_date()"
+ if isinstance(node, exp.CurrentTimestamp):
+ return "F.current_timestamp()"
+ if isinstance(node, exp.Upper):
+ return f"F.upper({self._value_expr(node.this, df_var)})"
+ if isinstance(node, exp.Lower):
+ return f"F.lower({self._value_expr(node.this, df_var)})"
+ if isinstance(node, exp.Case):
+ self.review.append("CASE expression approximated — review branching")
+ ifs = node.args.get("ifs") or []
+ default = node.args.get("default")
+ if ifs:
+ when = ifs[0]
+ cond = self._bool_expr(when.this, df_var)
+ tru = self._value_expr(when.args.get("true"), df_var)
+ fal = self._value_expr(default, df_var) if default is not None else "F.lit(None)"
+ return f"F.when({cond}, {tru}).otherwise({fal})"
+ return "F.lit(None)"
+ try:
+ sql = node.sql()
+ except Exception:
+ sql = node.__class__.__name__
+ self.review.append(f"Expression approximated: {node.__class__.__name__}")
+ return f"F.lit(None) # TODO: {sql}"
+
+ def _project_expr(self, node: exp.Expression) -> tuple[str, str]:
+ alias = node.alias if isinstance(node, exp.Alias) else None
+ inner = node.this if isinstance(node, exp.Alias) else node
+ name = alias or self._column_name(inner)
+ return name, self._value_expr(inner, "df")
diff --git a/morphsql/validation/__init__.py b/morphsql/validation/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1e19c3929cb07a71e5d5046e9d4fa1cf969a5ec0
--- /dev/null
+++ b/morphsql/validation/__init__.py
@@ -0,0 +1,15 @@
+"""Validation package."""
+
+from morphsql.validation.reconciliation import (
+ generate_dbt_tests,
+ generate_incremental_strategy,
+ generate_reconciliation_queries,
+ simulate_validation,
+)
+
+__all__ = [
+ "generate_dbt_tests",
+ "generate_incremental_strategy",
+ "generate_reconciliation_queries",
+ "simulate_validation",
+]
diff --git a/morphsql/validation/reconciliation.py b/morphsql/validation/reconciliation.py
new file mode 100644
index 0000000000000000000000000000000000000000..444ecbb86d50e821410a75e6ac460e3ef856b5d2
--- /dev/null
+++ b/morphsql/validation/reconciliation.py
@@ -0,0 +1,182 @@
+"""Semantic equivalence validation and reconciliation query generation."""
+
+from __future__ import annotations
+
+from morphsql.models import Dialect, MigrationObject, ValidationResult
+
+
+def generate_reconciliation_queries(
+ obj: MigrationObject,
+ source: Dialect,
+ target: Dialect,
+ source_schema: str = "SOURCE",
+ target_schema: str = "TARGET",
+) -> dict[str, str]:
+ """Generate reconciliation SQL queries for source vs target comparison."""
+ table = obj.name
+ queries: dict[str, str] = {}
+
+ queries["row_count"] = f"""-- Row count comparison: {table}
+SELECT 'source' AS platform, COUNT(*) AS row_count
+FROM {source_schema}.{table}
+UNION ALL
+SELECT 'target' AS platform, COUNT(*) AS row_count
+FROM {target_schema}.{table};"""
+
+ queries["distinct_count"] = f"""-- Distinct count comparison: {table}
+SELECT 'source' AS platform, COUNT(DISTINCT *) AS distinct_rows
+FROM {source_schema}.{table}
+UNION ALL
+SELECT 'target' AS platform, COUNT(DISTINCT *) AS distinct_rows
+FROM {target_schema}.{table};"""
+
+ queries["null_rates"] = f"""-- Null rate analysis: {table}
+-- Adjust column list based on actual schema
+SELECT
+ column_name,
+ SUM(CASE WHEN column_value IS NULL THEN 1 ELSE 0 END) AS null_count,
+ COUNT(*) AS total_count,
+ ROUND(100.0 * SUM(CASE WHEN column_value IS NULL THEN 1 ELSE 0 END) / COUNT(*), 4) AS null_pct
+FROM {target_schema}.{table}
+GROUP BY column_name;"""
+
+ queries["checksum"] = f"""-- Checksum comparison: {table}
+SELECT 'source' AS platform,
+ COUNT(*) AS row_count,
+ SUM(HASH(*)) AS row_checksum
+FROM {source_schema}.{table}
+UNION ALL
+SELECT 'target' AS platform,
+ COUNT(*) AS row_count,
+ SUM(HASH(*)) AS row_checksum
+FROM {target_schema}.{table};"""
+
+ queries["sample_diff"] = f"""-- Sample record differences: {table}
+SELECT s.*
+FROM {source_schema}.{table} s
+FULL OUTER JOIN {target_schema}.{table} t
+ ON s.id = t.id
+WHERE s.id IS NULL OR t.id IS NULL
+LIMIT 100;"""
+
+ return queries
+
+
+def generate_dbt_tests(obj: MigrationObject) -> list[str]:
+ """Generate dbt test definitions for a migrated object."""
+ base = obj.name.lower()
+ tests = [
+ f"models/{base}.sql -- schema tests: not_null, unique on primary key",
+ f"tests/assert_{base}_row_count_reconciliation.sql",
+ f"tests/assert_{base}_metric_tolerance.sql",
+ ]
+
+ if any("date" in r.lower() or "timestamp" in r.lower() for r in obj.requires_review):
+ tests.append(f"tests/assert_{base}_timezone_boundaries.sql")
+
+ if any("null" in r.lower() for r in obj.requires_review):
+ tests.append(f"tests/assert_{base}_null_handling.sql")
+
+ obj.tests_generated = tests
+ return tests
+
+
+def simulate_validation(
+ obj: MigrationObject,
+ tolerance: float = 0.01,
+) -> list[ValidationResult]:
+ """
+ Generate simulated validation results based on detected risk factors.
+
+ In production, this would execute against real databases.
+ For the open-source core, we simulate based on risk profile.
+ """
+ results: list[ValidationResult] = []
+
+ # Row count — likely passes unless behavior differences detected
+ null_warnings = [r for r in obj.requires_review if "null" in r.lower() or "empty_string" in r.lower()]
+ row_passed = len(null_warnings) == 0
+ results.append(ValidationResult(
+ object_name=obj.name,
+ check_name="row_count",
+ passed=row_passed,
+ source_value=1_000_000,
+ target_value=999_793 if not row_passed else 1_000_000,
+ difference=207 if not row_passed else 0,
+ root_cause="Empty string vs NULL handling" if not row_passed else "",
+ recommendation="NULLIF(TRIM(column), '')" if not row_passed else "No action needed",
+ ))
+
+ # Metric tolerance
+ float_warnings = [r for r in obj.requires_review if "float" in r.lower() or "precision" in r.lower()]
+ results.append(ValidationResult(
+ object_name=obj.name,
+ check_name="metric_tolerance",
+ passed=len(float_warnings) == 0,
+ source_value=1.0,
+ target_value=1.0 if len(float_warnings) == 0 else 1.0003,
+ difference=0 if len(float_warnings) == 0 else 0.0003,
+ root_cause="Floating-point precision" if float_warnings else "",
+ recommendation=f"Set tolerance to {tolerance}" if float_warnings else "",
+ ))
+
+ # Structural
+ results.append(ValidationResult(
+ object_name=obj.name,
+ check_name="structural_compilation",
+ passed=obj.conversion_confidence >= 70,
+ source_value="compiles",
+ target_value="compiles" if obj.conversion_confidence >= 70 else "review_required",
+ ))
+
+ # Null rate
+ results.append(ValidationResult(
+ object_name=obj.name,
+ check_name="null_rate",
+ passed=len(null_warnings) == 0,
+ source_value="2.1%",
+ target_value="2.1%" if len(null_warnings) == 0 else "2.4%",
+ root_cause="NULL semantics difference" if null_warnings else "",
+ ))
+
+ return results
+
+
+def generate_incremental_strategy(sql: str) -> dict[str, str]:
+ """Detect legacy loading pattern and recommend dbt incremental strategy."""
+ sql_upper = sql.upper()
+ recommendation: dict[str, str] = {}
+
+ if "DELETE FROM" in sql_upper and "INSERT INTO" in sql_upper:
+ recommendation = {
+ "legacy_pattern": "Delete and reload",
+ "dbt_materialized": "incremental",
+ "incremental_strategy": "delete+insert",
+ "unique_key": "id, load_date",
+ "incremental_predicates": "load_date >= current_date - 3",
+ "warning": "Consider rolling lookback window for late-arriving records",
+ }
+ elif "WHERE" in sql_upper and ("LOAD_DATE" in sql_upper or "UPDATED_AT" in sql_upper):
+ recommendation = {
+ "legacy_pattern": "Timestamp-based incremental load",
+ "dbt_materialized": "incremental",
+ "incremental_strategy": "merge",
+ "unique_key": "id",
+ "incremental_predicates": "updated_at > (SELECT MAX(updated_at) FROM {{ this }})",
+ "warning": "Validate watermark column has no NULL values",
+ }
+ elif "MERGE INTO" in sql_upper:
+ recommendation = {
+ "legacy_pattern": "Merge/upsert",
+ "dbt_materialized": "incremental",
+ "incremental_strategy": "merge",
+ "unique_key": "id",
+ }
+ else:
+ recommendation = {
+ "legacy_pattern": "Full refresh",
+ "dbt_materialized": "table",
+ "incremental_strategy": "N/A",
+ }
+
+ return recommendation
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..45f186e65501998a116693984b0c194d76ee5c5f
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,81 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "morphsql"
+version = "0.4.0"
+description = "MorphSQL: convert warehouse SQL to pandas, PySpark, Snowflake, BigQuery, or dbt"
+readme = "README.md"
+license = "Apache-2.0"
+requires-python = ">=3.10"
+authors = [
+ { name = "Digvijay Waghela", email = "digvijay.vaghela@yahoo.com" }
+]
+keywords = [
+ "morphsql", "sql", "migration", "vertica", "snowflake", "dbt", "pandas",
+ "pyspark", "data-engineering", "lineage", "data-warehouse", "etl",
+ "huggingface", "llm", "sqlglot", "agent", "rag",
+ "machine-learning", "feature-engineering", "evaluation",
+]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: Apache Software License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Database",
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
+ "Topic :: Software Development :: Code Generators",
+]
+dependencies = [
+ "sqlglot>=25.0.0",
+ "networkx>=3.2",
+ "jinja2>=3.1",
+ "pyyaml>=6.0",
+ "pydantic>=2.5",
+ "rich>=13.7",
+ "typer>=0.12",
+ "click>=8.1",
+ "pandas>=2.1",
+ "tabulate>=0.9",
+ "scikit-learn>=1.4",
+ "joblib>=1.3",
+]
+
+[project.optional-dependencies]
+demo = [
+ "gradio>=5.49,<6",
+ "plotly>=5.18",
+ "huggingface_hub>=0.23",
+]
+ml = [
+ "sentence-transformers>=2.2",
+ "datasets>=2.18",
+]
+dev = [
+ "pytest>=8.0",
+ "pytest-cov>=4.1",
+ "ruff>=0.4",
+]
+
+[project.scripts]
+morphsql = "morphsql.cli:app"
+
+[project.urls]
+Homepage = "https://huggingface.co/spaces/dgvj-work/morphsql"
+Repository = "https://github.com/dgvj-work/morphsql"
+Documentation = "https://huggingface.co/spaces/dgvj-work/morphsql"
+"Bug Tracker" = "https://github.com/dgvj-work/morphsql/issues"
+
+[tool.hatch.build.targets.wheel]
+packages = ["morphsql"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py310"
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..96fb379a92a48c87fcae92be6a7397a542fe9f5a
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,19 @@
+# MorphSQL — Hugging Face Space dependencies
+# Keep in sync with sdk_version in README_HF_SPACE.md (Gradio 5.49.x)
+
+sqlglot>=25.0.0
+networkx>=3.2
+jinja2>=3.1
+pyyaml>=6.0
+pydantic>=2.5
+rich>=13.7
+typer>=0.12
+click>=8.1
+numpy>=1.26
+pandas>=2.1
+tabulate>=0.9
+scikit-learn>=1.4
+joblib>=1.3
+gradio>=5.49,<6
+plotly>=5.18
+huggingface_hub>=0.23
diff --git a/scripts/check_space.py b/scripts/check_space.py
new file mode 100644
index 0000000000000000000000000000000000000000..6bfed4745db68e559ec6252258a19324dc0b70de
--- /dev/null
+++ b/scripts/check_space.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""Preflight checks before Hugging Face Space deploy."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+
+def main() -> int:
+ errors: list[str] = []
+
+ for rel in (
+ "app.py",
+ "requirements.txt",
+ "README_HF_SPACE.md",
+ "demo/handlers.py",
+ "demo/theme.py",
+ "morphsql/__init__.py",
+ "model/risk_classifier.joblib",
+ "examples/vertica_legacy",
+ ):
+ if not (ROOT / rel).exists():
+ errors.append(f"missing required path: {rel}")
+
+ try:
+ import app as space_app # noqa: F401
+ from app import _build_demo, demo
+
+ assert demo is not None
+ built = _build_demo()
+ assert built is not None
+ except Exception as exc:
+ errors.append(f"app import/build failed: {exc}")
+
+ try:
+ from demo.handlers import convert_for_ui, convert_upload_for_ui
+ import tempfile
+ from pathlib import Path as P
+
+ notes, output, status, share, preview, path, nb, api = convert_for_ui(
+ "SELECT COALESCE(a, 0) AS a FROM t",
+ "snowflake",
+ "pandas",
+ )
+ assert "import pandas" in output and path.endswith(".py")
+
+ with tempfile.TemporaryDirectory() as td:
+ sql = P(td) / "sample.sql"
+ sql.write_text("SELECT COALESCE(x, 0) AS x FROM t", encoding="utf-8")
+ batch = convert_upload_for_ui(str(sql), "", "snowflake", "pyspark")
+ assert batch[6].endswith(".py")
+ except Exception as exc:
+ errors.append(f"convert smoke failed: {exc}")
+
+ if errors:
+ print("Space preflight FAILED:")
+ for e in errors:
+ print(f" - {e}")
+ return 1
+
+ print("Space preflight OK — ready to deploy.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/deploy_hf.sh b/scripts/deploy_hf.sh
new file mode 100644
index 0000000000000000000000000000000000000000..6b815f15e745dcdc73fc76160daeb7458823f585
--- /dev/null
+++ b/scripts/deploy_hf.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+# Deploy MorphSQL Space + model + dataset to Hugging Face Hub
+#
+# Prerequisites:
+# pip install -U "huggingface_hub[cli]"
+# hf auth login
+#
+# Usage:
+# ./scripts/deploy_hf.sh
+# ./scripts/deploy_hf.sh dgvj-work/morphsql
+set -euo pipefail
+
+SPACE_ID="${1:-dgvj-work/morphsql}"
+MODEL_ID="${2:-dgvj-work/morphsql}"
+DATASET_ID="${3:-dgvj-work/vertica-snowflake-pairs}"
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+echo "Deploying MorphSQL to Hugging Face..."
+echo " Space: $SPACE_ID"
+echo " Model: $MODEL_ID"
+echo " Dataset: $DATASET_ID"
+echo ""
+
+if ! command -v hf >/dev/null 2>&1; then
+ echo "ERROR: 'hf' CLI not found. Install with: pip install -U 'huggingface_hub[cli]'"
+ exit 1
+fi
+
+if ! hf auth whoami >/dev/null 2>&1; then
+ echo "ERROR: not logged in to Hugging Face. Run: hf auth login"
+ exit 1
+fi
+
+echo "Preflight: Space import + convert smoke…"
+python scripts/check_space.py
+
+# HF rejects Space README metadata if short_description > 60 chars
+python - <<'PY'
+from pathlib import Path
+import re
+import sys
+text = Path("README_HF_SPACE.md").read_text(encoding="utf-8")
+match = re.search(r"^short_description:\s*(.*)$", text, flags=re.M)
+if not match:
+ print("ERROR: short_description missing in README_HF_SPACE.md")
+ sys.exit(1)
+value = match.group(1).strip().strip("\"'")
+if len(value) > 60:
+ print(f"ERROR: short_description is {len(value)} chars (max 60): {value!r}")
+ sys.exit(1)
+print(f"OK short_description ({len(value)} chars): {value}")
+PY
+
+echo "Ensuring eval pairs + risk model artifacts…"
+python -c "from morphsql.eval.pairs import ensure_pairs_file; print(ensure_pairs_file())"
+python -c "from pathlib import Path; from morphsql.ai.risk_model import MODEL_PATH, train_and_save; train_and_save() if not Path(MODEL_PATH).exists() else print(MODEL_PATH)"
+
+STAGE="$(mktemp -d)"
+trap 'rm -rf "$STAGE"' EXIT
+
+rsync -a \
+ --exclude '.git' \
+ --exclude '.venv' \
+ --exclude 'venv' \
+ --exclude '__pycache__' \
+ --exclude '*.egg-info' \
+ --exclude 'migration-output' \
+ --exclude '.pytest_cache' \
+ --exclude '.ruff_cache' \
+ --exclude '.mypy_cache' \
+ --exclude 'htmlcov' \
+ --exclude '.coverage' \
+ --exclude 'canvases' \
+ --exclude '.cursor' \
+ --exclude '.DS_Store' \
+ --exclude '*.pyc' \
+ --exclude '.env' \
+ "$ROOT/" "$STAGE/"
+
+# Space README must be the Hub metadata card
+cp README_HF_SPACE.md "$STAGE/README.md"
+
+echo "Uploading Space: $SPACE_ID"
+hf upload "$SPACE_ID" "$STAGE" . --repo-type=space
+
+echo "Uploading model: $MODEL_ID"
+STAGE_MODEL="$(mktemp -d)"
+cp -R "$ROOT/model/." "$STAGE_MODEL/"
+if [[ -f "$ROOT/model/README.md" ]]; then
+ cp "$ROOT/model/README.md" "$STAGE_MODEL/README.md"
+elif [[ -f "$ROOT/MODEL_CARD.md" ]]; then
+ cp "$ROOT/MODEL_CARD.md" "$STAGE_MODEL/README.md"
+fi
+hf upload "$MODEL_ID" "$STAGE_MODEL" . --repo-type=model || {
+ echo "Model upload failed — create https://huggingface.co/models/$MODEL_ID first (or login with write token)."
+}
+rm -rf "$STAGE_MODEL"
+
+echo "Publishing dataset: $DATASET_ID"
+python scripts/publish_dataset.py --repo "$DATASET_ID" || {
+ echo "Dataset publish failed (login required). Space upload may still have succeeded."
+}
+
+echo ""
+echo "Done."
+echo " Space: https://huggingface.co/spaces/$SPACE_ID"
+echo " Model: https://huggingface.co/$MODEL_ID"
+echo " Dataset: https://huggingface.co/datasets/$DATASET_ID"
+echo ""
+echo "Open the Space, wait for build, then try Convert → example → Download."
diff --git a/scripts/git-hooks/prepare-commit-msg b/scripts/git-hooks/prepare-commit-msg
new file mode 100644
index 0000000000000000000000000000000000000000..7bc0643354ba9ab2c987f910be8261c556f4ce52
--- /dev/null
+++ b/scripts/git-hooks/prepare-commit-msg
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Strip Cursor/agent attribution trailers so commits stay under the human author only.
+MSG_FILE="$1"
+[ -n "$MSG_FILE" ] && [ -f "$MSG_FILE" ] || exit 0
+# macOS/BSD sed
+if sed --version >/dev/null 2>&1; then
+ sed -i -e '/^Co-authored-by: Cursor $/d' \
+ -e '/^Made-with: Cursor$/d' \
+ -e '/^Co-authored-by:.*cursoragent@cursor\.com.*$/d' \
+ "$MSG_FILE"
+else
+ sed -i '' -e '/^Co-authored-by: Cursor $/d' \
+ -e '/^Made-with: Cursor$/d' \
+ -e '/^Co-authored-by:.*cursoragent@cursor\.com.*$/d' \
+ "$MSG_FILE"
+fi
diff --git a/scripts/install_git_hooks.sh b/scripts/install_git_hooks.sh
new file mode 100644
index 0000000000000000000000000000000000000000..54fb2134bf075d2b1386194973f9ad44b5e456f0
--- /dev/null
+++ b/scripts/install_git_hooks.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+mkdir -p "$ROOT/.git/hooks"
+cp "$ROOT/scripts/git-hooks/prepare-commit-msg" "$ROOT/.git/hooks/prepare-commit-msg"
+chmod +x "$ROOT/.git/hooks/prepare-commit-msg"
+echo "Installed prepare-commit-msg (strips Cursor co-author trailers)."
diff --git a/scripts/publish_dataset.py b/scripts/publish_dataset.py
new file mode 100644
index 0000000000000000000000000000000000000000..703209dda5312e1e0c06f1da4403787a32af4b2c
--- /dev/null
+++ b/scripts/publish_dataset.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""Publish Vertica↔Snowflake pair dataset to the Hugging Face Hub."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+from morphsql.eval.pairs import ensure_pairs_file, load_pairs
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Publish MorphSQL pair dataset")
+ parser.add_argument(
+ "--repo",
+ default="dgvj-work/vertica-snowflake-pairs",
+ help="Hub dataset repo id (user/name)",
+ )
+ parser.add_argument("--private", action="store_true")
+ args = parser.parse_args()
+
+ path = ensure_pairs_file()
+ pairs = load_pairs()
+ print(f"Loaded {len(pairs)} pairs from {path}")
+
+ try:
+ from datasets import Dataset
+ from huggingface_hub import HfApi, login
+ except ImportError as exc:
+ raise SystemExit(
+ "Install: pip install datasets huggingface_hub\n" + str(exc)
+ ) from exc
+
+ # Prefer token from env; login() is interactive otherwise
+ api = HfApi()
+ try:
+ api.whoami()
+ except Exception:
+ login()
+
+ ds = Dataset.from_list(pairs)
+ ds.push_to_hub(args.repo, private=args.private)
+ readme = f"""---
+license: apache-2.0
+task_categories:
+ - text2text-generation
+language:
+ - en
+tags:
+ - sql
+ - code
+ - migration
+ - snowflake
+ - vertica
+ - dbt
+ - evaluation
+size_categories:
+ - n<1K
+---
+
+# Vertica / Oracle / Redshift / BigQuery → Snowflake SQL pairs
+
+Synthetic + curated migration pairs for **MorphSQL** evals and fine-tuning.
+
+- Rows: {len(pairs)}
+- Fields: `id`, `category`, `source_dialect`, `target_dialect`, `source_sql`, `target_sql`, `notes`
+- Categories: function, date, aggregate, ddl, ml_feature
+
+Space: https://huggingface.co/spaces/dgvj-work/morphsql
+Code: https://github.com/dgvj-work/morphsql
+Author: Digvijay Waghela
+"""
+ api.upload_file(
+ path_or_fileobj=readme.encode("utf-8"),
+ path_in_repo="README.md",
+ repo_id=args.repo,
+ repo_type="dataset",
+ )
+ # Also upload raw jsonl
+ api.upload_file(
+ path_or_fileobj=str(path),
+ path_in_repo="vertica_snowflake_pairs.jsonl",
+ repo_id=args.repo,
+ repo_type="dataset",
+ )
+ print(f"Published https://huggingface.co/datasets/{args.repo}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/run_local.sh b/scripts/run_local.sh
new file mode 100644
index 0000000000000000000000000000000000000000..d869e2789ae6564a2a3f656ab8327bb98c2acbe1
--- /dev/null
+++ b/scripts/run_local.sh
@@ -0,0 +1,104 @@
+#!/usr/bin/env bash
+# MorphSQL — Local setup, test, and run script
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+echo "🔄 MorphSQL — Local Setup"
+echo "=========================================="
+echo ""
+
+# 1. Create virtual environment
+if [ ! -d ".venv" ]; then
+ echo "📦 Creating virtual environment..."
+ python3 -m venv .venv
+fi
+
+source .venv/bin/activate
+
+# 2. Install package with all extras
+echo "📦 Installing dependencies..."
+pip install -q -e ".[dev,demo]"
+
+# 3. Run tests
+echo ""
+echo "🧪 Running tests..."
+pytest tests/ -v --tb=short
+
+# 4. Run CLI smoke test
+echo ""
+echo "🔍 Running CLI analyze on example repo..."
+morphsql analyze examples/vertica_legacy --source vertica --target snowflake --output ./migration-output
+
+# 5. Run full migration pipeline
+echo ""
+echo "🚀 Running full migration pipeline..."
+morphsql migrate examples/vertica_legacy --source vertica --target snowflake --output ./migration-output
+
+# 6. Verify outputs
+echo ""
+echo "✅ Verifying outputs..."
+test -f ./migration-output/migration_report.html && echo " ✓ HTML report generated"
+test -d ./migration-output/dbt && echo " ✓ dbt projects generated"
+DBT_COUNT=$(find ./migration-output/dbt -name "dbt_project.yml" | wc -l | tr -d ' ')
+echo " ✓ $DBT_COUNT dbt projects created"
+
+# 7. Test app + handler surfaces
+echo ""
+echo "🌐 Testing Gradio handlers..."
+python -c "
+from pathlib import Path
+import pandas as pd
+from demo.handlers import (
+ convert_for_ui,
+ analyze_sql_object,
+ get_sample_workbench,
+ run_feature_migration,
+ run_behavior_rag,
+)
+from app import _build_demo
+
+# Simple SELECT — must produce a live sample preview DataFrame
+simple = 'SELECT ZEROIFNULL(amount) AS amt FROM staging.orders WHERE id = 1'
+notes, output, status, share, preview, path, nb, api = convert_for_ui(simple, 'vertica', 'pandas')
+assert output.strip() and 'pandas' in output.lower()
+assert isinstance(preview, pd.DataFrame) and not preview.empty
+
+# Procedure — conversion must succeed (preview may be empty for procedural SQL)
+proc = Path('examples/vertica_legacy/procedures/SP_BUILD_CUSTOMER_DAILY.sql').read_text()
+notes2, output2, *_ = convert_for_ui(proc, 'vertica', 'pandas')
+assert output2.strip()
+
+analysis, fig, badge, out, notes3 = analyze_sql_object(proc, 'vertica', 'snowflake')
+assert out.strip() and badge
+
+wb = get_sample_workbench()
+assert len(wb) == 12
+
+md, feat = run_feature_migration('snowflake')
+assert feat.strip()
+
+rag = run_behavior_rag('ZEROIFNULL', 'vertica', 'snowflake')
+assert 'ZEROIFNULL' in rag.upper() or len(rag) > 20
+
+assert _build_demo() is not None
+print(' ✓ Convert, preview, assess, workbench, features, RAG, Gradio build OK')
+"
+
+echo ""
+echo "=========================================="
+echo "✅ All checks passed! Ready to test."
+echo ""
+echo "Next steps:"
+echo " 1. Launch the Gradio demo:"
+echo " python app.py"
+echo ""
+echo " 2. Or use the CLI:"
+echo " morphsql analyze examples/vertica_legacy --source vertica --target snowflake"
+echo " morphsql convert examples/vertica_legacy/queries/customer_lifetime_value.sql --source vertica --target pandas -o migration-output/pandas"
+echo " morphsql migrate examples/vertica_legacy --source vertica --target snowflake --output migration-output"
+echo ""
+echo " 3. Open the HTML report:"
+echo " open migration-output/migration_report.html"
+echo ""
diff --git a/tests/test_morphsql.py b/tests/test_morphsql.py
new file mode 100644
index 0000000000000000000000000000000000000000..f644d8b0a8d681076660131d0381e2bf0ac1f260
--- /dev/null
+++ b/tests/test_morphsql.py
@@ -0,0 +1,597 @@
+"""Tests for MorphSQL."""
+
+import re
+from pathlib import Path
+
+import pytest
+
+from morphsql.models import Dialect, MigrationObject, ObjectType
+from morphsql.scanner.repository import scan_directory
+from morphsql.translator.engine import translate_sql
+from morphsql.risk.scorer import score_object, extract_business_rules
+from morphsql.parser.sql_parser import count_sql_complexity, extract_tables
+from morphsql.pipeline import MigrationPipeline
+from morphsql.validation.reconciliation import generate_incremental_strategy
+
+EXAMPLES = Path(__file__).parent.parent / "examples" / "vertica_legacy"
+
+
+class TestScanner:
+ def test_scan_directory_finds_objects(self):
+ objects = scan_directory(EXAMPLES)
+ assert len(objects) >= 4
+ types = {o.object_type for o in objects}
+ assert ObjectType.STORED_PROCEDURE in types or ObjectType.SQL_SCRIPT in types
+
+ def test_objects_have_sql_content(self):
+ objects = scan_directory(EXAMPLES)
+ for obj in objects:
+ assert len(obj.source_sql) > 0
+ assert obj.name
+
+
+class TestParser:
+ def test_extract_tables(self):
+ sql = "SELECT a FROM staging.customers JOIN analytics.orders ON a.id = b.id"
+ tables = extract_tables(sql, Dialect.VERTICA)
+ assert "STAGING.CUSTOMERS" in tables or "CUSTOMERS" in str(tables).upper()
+
+ def test_complexity_metrics(self):
+ sql = "WITH cte AS (SELECT 1) SELECT * FROM cte JOIN t ON 1=1"
+ metrics = count_sql_complexity(sql, Dialect.VERTICA)
+ assert metrics["ctes"] >= 1
+ assert metrics["joins"] >= 1
+
+
+class TestTranslator:
+ def test_zeroifnull_maps_to_coalesce_with_default(self):
+ sql = "SELECT ZEROIFNULL(amount) FROM staging.transactions"
+ translated, confidence, _, _ = translate_sql(sql, Dialect.VERTICA, Dialect.SNOWFLAKE)
+ assert "COALESCE(amount," in translated.replace(" ", "") and ",0)" in translated.replace(" ", "")
+ assert confidence > 0
+
+ def test_procedure_parameter_binding(self):
+ sql = """CREATE OR REPLACE PROCEDURE p(load_date DATE) AS $$
+ BEGIN
+ DELETE FROM t WHERE d = load_date;
+ END; $$;"""
+ translated, _, _, _ = translate_sql(sql, Dialect.VERTICA, Dialect.SNOWFLAKE)
+ assert ":LOAD_DATE" in translated
+ assert "WHERE d = :LOAD_DATE" in translated
+
+ def test_date_arithmetic_uses_dateadd(self):
+ sql = "SELECT * FROM t WHERE order_date >= CURRENT_DATE - 365"
+ translated, _, _, _ = translate_sql(sql, Dialect.VERTICA, Dialect.SNOWFLAKE)
+ assert "DATEADD" in translated.upper()
+
+ def test_detects_dynamic_sql_review(self):
+ sql = "EXECUTE IMMEDIATE 'SELECT 1'"
+ _, _, _, review = translate_sql(sql, Dialect.VERTICA, Dialect.SNOWFLAKE)
+ assert any("dynamic" in r.lower() for r in review)
+
+ def test_vertica_to_bigquery_zeroifnull_and_dates(self):
+ sql = "SELECT ZEROIFNULL(amount) FROM t WHERE d >= CURRENT_DATE - 7"
+ translated, _, auto, _ = translate_sql(sql, Dialect.VERTICA, Dialect.BIGQUERY)
+ upper = translated.upper()
+ # BigQuery accepts IFNULL or COALESCE (sqlglot may normalize)
+ assert "IFNULL" in upper or "COALESCE" in upper
+ assert "DATE_SUB" in upper or "INTERVAL" in upper
+ assert auto
+
+ def test_oracle_nvl_to_snowflake(self):
+ sql = "SELECT NVL(amount, 0) AS amt FROM dual"
+ translated, _, auto, _ = translate_sql(sql, Dialect.ORACLE, Dialect.SNOWFLAKE)
+ assert "COALESCE" in translated.upper()
+ assert any("NVL" in a for a in auto)
+
+ def test_oracle_to_bigquery(self):
+ sql = "SELECT NVL(amount, 0) FROM orders"
+ translated, conf, _, _ = translate_sql(sql, Dialect.ORACLE, Dialect.BIGQUERY)
+ upper = translated.upper()
+ assert "IFNULL" in upper or "COALESCE" in upper
+ assert conf > 0
+
+ def test_redshift_getdate_to_snowflake(self):
+ sql = "SELECT GETDATE(), NVL(x, 0) FROM t"
+ translated, _, auto, _ = translate_sql(sql, Dialect.REDSHIFT, Dialect.SNOWFLAKE)
+ assert "CURRENT_TIMESTAMP" in translated.upper()
+ assert "COALESCE" in translated.upper()
+ assert auto
+
+ def test_redshift_listagg_to_bigquery(self):
+ sql = "SELECT LISTAGG(name, ',') FROM users"
+ translated, _, auto, _ = translate_sql(sql, Dialect.REDSHIFT, Dialect.BIGQUERY)
+ assert "STRING_AGG" in translated.upper()
+ assert auto
+
+ def test_bigquery_to_snowflake(self):
+ sql = "SELECT IFNULL(amount, 0), STRING_AGG(name, ',') FROM t"
+ translated, _, auto, _ = translate_sql(sql, Dialect.BIGQUERY, Dialect.SNOWFLAKE)
+ assert "COALESCE" in translated.upper()
+ assert "LISTAGG" in translated.upper()
+ assert auto
+
+ def test_snowflake_to_bigquery(self):
+ sql = "SELECT IFF(a IS NULL, 0, a), LISTAGG(name, ',') FROM t"
+ translated, _, auto, _ = translate_sql(sql, Dialect.SNOWFLAKE, Dialect.BIGQUERY)
+ assert re.search(r"\bIF\s*\(", translated, re.I)
+ assert "STRING_AGG" in translated.upper()
+ assert auto
+
+ def test_dbt_snowflake_target_matches_snowflake_sql(self):
+ sql = "SELECT ZEROIFNULL(x) FROM t"
+ snow, _, _, _ = translate_sql(sql, Dialect.VERTICA, Dialect.SNOWFLAKE)
+ dbt, _, _, _ = translate_sql(sql, Dialect.VERTICA, Dialect.DBT_SNOWFLAKE)
+ assert "COALESCE" in snow.upper() and "COALESCE" in dbt.upper()
+
+ def test_vertica_procedure_to_dbt_models(self):
+ from morphsql.dbt_generator.decomposer import decompose_to_dbt, format_dbt_project, is_dbt_target
+
+ assert is_dbt_target("dbt-snowflake")
+ sql = Path("examples/vertica_legacy/procedures/SP_BUILD_CUSTOMER_DAILY.sql").read_text()
+ translated, _, _, _ = translate_sql(sql, Dialect.VERTICA, Dialect.SNOWFLAKE)
+ obj = MigrationObject(
+ name="SP_BUILD_CUSTOMER_DAILY",
+ object_type=ObjectType.STORED_PROCEDURE,
+ source_sql=sql,
+ target_sql=translated,
+ )
+ files = decompose_to_dbt(obj, Dialect.VERTICA)
+ assert "dbt_project.yml" in files
+ assert any(p.startswith("models/staging/") and p.endswith(".sql") for p in files)
+ assert any(p.startswith("models/marts/") and p.endswith(".sql") for p in files)
+ mart = next(v for k, v in files.items() if k.startswith("models/marts/") and k.endswith(".sql"))
+ assert "source(" in "\n".join(files.values()) or "ref(" in mart
+ assert "END AS" in mart.upper() or "CUSTOMER_SEGMENT" in mart.upper()
+ assert "{{ var('load_date') }}" in "\n".join(files.values())
+ rendered = format_dbt_project(files)
+ assert "models/staging/" in rendered
+ assert "COALESCE" in rendered.upper()
+
+ def test_eval_suite_runs(self):
+ from morphsql.eval import ensure_pairs_file, run_eval
+
+ ensure_pairs_file()
+ results, summary = run_eval(limit=20, categories=["function", "date"])
+ assert summary["n_pairs"] >= 5
+ assert 0 <= summary["token_f1"] <= 1
+ assert len(results) == summary["n_pairs"]
+
+ def test_behavior_rag_retrieves(self):
+ from morphsql.intelligence.rag import get_rag
+
+ hits = get_rag().retrieve("empty string NULL oracle snowflake", top_k=3)
+ assert hits
+ assert hits[0].name
+
+ def test_hero_agent(self):
+ from demo.handlers import run_hero_agent
+
+ md, out, badge, share = run_hero_agent(
+ "SELECT ZEROIFNULL(a) FROM t WHERE d >= CURRENT_DATE - 7",
+ "vertica",
+ "snowflake",
+ )
+ assert "COALESCE" in out.upper()
+ assert "Confidence" in md or "%" in md or "VERTICA" in md.upper()
+ assert "%" in badge
+ assert "MorphSQL" in share or "morphsql" in share.lower() or "Open Space" in share
+ assert "dgvj-work/morphsql" in share or "GitHub" in share
+
+ def test_ai_risk_model_and_pipeline(self):
+ from morphsql.ai import pipeline, train_and_save
+
+ train_and_save()
+ risk = pipeline("sql-risk-classification")
+ out = risk("CREATE PROCEDURE p AS BEGIN EXECUTE IMMEDIATE 'x'; END;")
+ assert out["label"] in {"low", "medium", "high"}
+ assert 0 <= out["score"] <= 1
+ mig = pipeline("sql-migration")(
+ "SELECT ZEROIFNULL(a) FROM t", source="vertica", target="snowflake"
+ )
+ assert "COALESCE" in mig["converted_sql"].upper()
+ assert "predict_risk" in mig["tools_used"] or mig["risk"]
+
+ def test_ai_chat_agent(self):
+ from morphsql.ai.agent import chat_agent
+
+ history, msg, sql, badge = chat_agent(
+ "Convert this SQL and predict migration risk",
+ [],
+ "SELECT ZEROIFNULL(x) FROM t",
+ "vertica",
+ "snowflake",
+ )
+ assert len(history) >= 2
+ assert "COALESCE" in sql.upper()
+ assert msg == ""
+
+ def test_sql_to_pandas_from_each_source(self):
+ import pandas as pd
+
+ cases = [
+ (
+ Dialect.VERTICA,
+ "SELECT customer_id, ZEROIFNULL(order_amount) AS order_amount, "
+ "NVL(discount, 0) AS discount FROM staging.orders "
+ "WHERE order_date >= CURRENT_DATE - 30",
+ "staging.orders",
+ {
+ "customer_id": [1, 2],
+ "order_amount": [None, 10.0],
+ "discount": [None, 1.0],
+ "order_date": [pd.Timestamp.today(), pd.Timestamp.today()],
+ },
+ ),
+ (
+ Dialect.ORACLE,
+ "SELECT NVL(amount, 0) AS amount, SYSDATE AS ts FROM dual",
+ None,
+ None,
+ ),
+ (
+ Dialect.REDSHIFT,
+ "SELECT GETDATE() AS ts, name FROM users WHERE id > 1 LIMIT 5",
+ "users",
+ {"ts": [1, 2], "name": ["a", "b"], "id": [1, 3]},
+ ),
+ (
+ Dialect.BIGQUERY,
+ "SELECT IFNULL(a, 0) AS a, b FROM t WHERE a IS NOT NULL",
+ "t",
+ {"a": [None, 2], "b": [9, 8]},
+ ),
+ (
+ Dialect.SNOWFLAKE,
+ "SELECT COALESCE(x, 0) AS x FROM analytics.facts WHERE dt >= CURRENT_DATE",
+ "analytics.facts",
+ {"x": [None, 5], "dt": [pd.Timestamp.today(), pd.Timestamp.today()]},
+ ),
+ ]
+ for source, sql, table_key, frame in cases:
+ code, conf, auto, _review = translate_sql(sql, source, Dialect.PANDAS)
+ assert conf >= 50
+ assert "import pandas as pd" in code
+ assert "result" in code
+ assert any("pandas" in a.lower() or "→" in a for a in auto)
+ ns: dict = {"pd": pd, "np": __import__("numpy")}
+ if table_key and frame is not None:
+ ns["tables"] = {table_key: pd.DataFrame(frame)}
+ else:
+ ns["tables"] = {}
+ exec(code, ns, ns)
+ assert isinstance(ns["result"], pd.DataFrame)
+
+ def test_sql_to_pyspark_from_each_source(self):
+ cases = [
+ (
+ Dialect.VERTICA,
+ "SELECT customer_id, ZEROIFNULL(order_amount) AS order_amount "
+ "FROM staging.orders WHERE order_date >= CURRENT_DATE - 30",
+ ),
+ (
+ Dialect.ORACLE,
+ "SELECT NVL(amount, 0) AS amount, SYSDATE AS ts FROM dual",
+ ),
+ (
+ Dialect.REDSHIFT,
+ "SELECT GETDATE() AS ts, name FROM users WHERE id > 1 LIMIT 5",
+ ),
+ (
+ Dialect.BIGQUERY,
+ "SELECT IFNULL(a, 0) AS a, b FROM t WHERE a IS NOT NULL",
+ ),
+ (
+ Dialect.SNOWFLAKE,
+ "SELECT COALESCE(x, 0) AS x FROM analytics.facts WHERE dt >= CURRENT_DATE",
+ ),
+ ]
+ for source, sql in cases:
+ code, conf, auto, _review = translate_sql(sql, source, Dialect.PYSPARK)
+ assert conf >= 50
+ assert "from pyspark.sql" in code
+ assert "result" in code
+ assert any("pyspark" in a.lower() or "→" in a for a in auto)
+
+ def test_hero_agent_pandas_primary(self):
+ from demo.handlers import run_hero_agent
+
+ md, out, badge, share = run_hero_agent(
+ "SELECT COALESCE(a, 0) AS a FROM t",
+ "snowflake",
+ "pandas",
+ )
+ assert "import pandas as pd" in out
+ assert "fillna" in out or "tables[" in out or "_coalesce" in out
+ assert "pandas" in share.lower() or "Python" in share
+ assert "%" in badge
+ assert "pandas" in md.lower() or "PANDAS" in md.upper() or "Python" in md
+
+ def test_hero_agent_pyspark(self):
+ from demo.handlers import run_hero_agent
+
+ md, out, badge, share = run_hero_agent(
+ "SELECT COALESCE(a, 0) AS a FROM t",
+ "snowflake",
+ "pyspark",
+ )
+ assert "from pyspark.sql" in out
+ assert "F.coalesce" in out or "tables[" in out or "result" in out
+ assert "pyspark" in share.lower() or "PySpark" in share or "Python" in share
+ assert "%" in badge
+
+ def test_sample_preview_and_convert_for_ui(self):
+ import pandas as pd
+ from demo.handlers import convert_for_ui, run_sample_preview
+
+ notes, output, status, share, preview, path, nb, api = convert_for_ui(
+ "SELECT customer_id, ZEROIFNULL(order_amount) AS order_amount "
+ "FROM staging.orders WHERE order_date >= CURRENT_DATE - 7",
+ "vertica",
+ "pandas",
+ )
+ assert "import pandas" in output
+ assert path.endswith(".py")
+ assert "notebook" in nb.lower() or "MorphSQL" in nb
+ assert "pipeline" in api
+ assert preview is None or isinstance(preview, pd.DataFrame)
+ df, note = run_sample_preview(output, "pandas", sql="SELECT a FROM staging.orders")
+ assert isinstance(df, pd.DataFrame)
+ assert "preview" in note.lower() or "Sample" in note
+
+ _, spark_out, _, _, spark_preview, spark_path, spark_nb, _ = convert_for_ui(
+ "SELECT COALESCE(a, 0) AS a FROM t",
+ "snowflake",
+ "pyspark",
+ )
+ assert "from pyspark.sql" in spark_out
+ assert spark_path.endswith(".py")
+ assert "pyspark" in spark_path or "morphsql_pyspark" in spark_path
+ assert isinstance(spark_preview, pd.DataFrame)
+ assert "Spark" in spark_nb or "pyspark" in spark_nb.lower()
+
+ for tgt in ("snowflake", "bigquery", "dbt-snowflake"):
+ _, out, _, _, prev, _, _, _ = convert_for_ui(
+ "SELECT COALESCE(a, 0) AS a FROM t WHERE a IS NOT NULL",
+ "snowflake",
+ tgt,
+ )
+ assert out.strip()
+ assert isinstance(prev, pd.DataFrame), f"preview missing for {tgt}"
+ df2, note2 = run_sample_preview(
+ out, tgt, sql="SELECT COALESCE(a, 0) AS a FROM t", source="snowflake"
+ )
+ assert isinstance(df2, pd.DataFrame)
+ assert "preview" in note2.lower() or "Sample" in note2
+
+ # Procedure → dbt must still produce a sample preview
+ proc = (
+ "CREATE OR REPLACE PROCEDURE p(load_date DATE) AS $$ BEGIN "
+ "CREATE LOCAL TEMP TABLE tmp ON COMMIT PRESERVE ROWS AS "
+ "SELECT customer_id, ZEROIFNULL(amount) AS amount FROM staging.orders "
+ "WHERE order_date = load_date; "
+ "INSERT INTO analytics.daily SELECT * FROM tmp; END; $$;"
+ )
+ _, _, _, _, proc_prev, _, _, _ = convert_for_ui(proc, "vertica", "dbt-snowflake")
+ assert isinstance(proc_prev, pd.DataFrame), "procedure dbt preview missing"
+
+ def test_sql_upload_convert_and_download(self):
+ import tempfile
+ import zipfile
+ from pathlib import Path
+
+ import pandas as pd
+ from demo.handlers import convert_upload_for_ui, load_sql_from_upload
+
+ with tempfile.TemporaryDirectory() as td:
+ td = Path(td)
+ sql_path = td / "orders.sql"
+ sql_path.write_text(
+ "SELECT COALESCE(order_amount, 0) AS order_amount FROM staging.orders",
+ encoding="utf-8",
+ )
+ loaded = load_sql_from_upload(str(sql_path))
+ assert "order_amount" in loaded
+
+ sql_in, notes, output, status, share, preview, path, nb, api = convert_upload_for_ui(
+ str(sql_path), "", "snowflake", "pandas"
+ )
+ assert "order_amount" in sql_in
+ assert "import pandas" in output
+ assert Path(path).exists() and path.endswith(".py")
+ assert "orders" in Path(path).name
+ assert isinstance(preview, pd.DataFrame)
+
+ spark_path = convert_upload_for_ui(str(sql_path), "", "snowflake", "pyspark")[6]
+ assert Path(spark_path).exists() and spark_path.endswith(".py")
+ assert "pyspark" in Path(spark_path).name
+
+ # Zip of two SQL files → zip download
+ zpath = td / "bundle.zip"
+ with zipfile.ZipFile(zpath, "w") as zf:
+ zf.write(sql_path, arcname="a.sql")
+ zf.writestr("b.sql", "SELECT IFNULL(x, 0) AS x FROM t")
+ batch = convert_upload_for_ui(str(zpath), "", "snowflake", "pandas")
+ assert batch[6].endswith(".zip")
+ assert Path(batch[6]).exists()
+ with zipfile.ZipFile(batch[6]) as zf:
+ names = zf.namelist()
+ assert any(n.endswith("_pandas.py") for n in names)
+ assert len(names) >= 2
+
+ def test_is_pandas_target(self):
+ from morphsql.translator.pandas_codegen import is_pandas_target
+
+ assert is_pandas_target("pandas")
+ assert is_pandas_target(Dialect.PANDAS)
+ assert not is_pandas_target("snowflake")
+
+ def test_is_pyspark_target(self):
+ from morphsql.translator.pyspark_codegen import is_pyspark_target
+
+ assert is_pyspark_target("pyspark")
+ assert is_pyspark_target(Dialect.PYSPARK)
+ assert is_pyspark_target("spark")
+ assert not is_pyspark_target("pandas")
+ assert not is_pyspark_target("snowflake")
+
+ def test_cte_query_to_dbt_models(self):
+ from morphsql.dbt_generator.decomposer import decompose_to_dbt
+
+ sql = Path("examples/vertica_legacy/queries/customer_lifetime_value.sql").read_text()
+ translated, _, _, _ = translate_sql(sql, Dialect.VERTICA, Dialect.SNOWFLAKE)
+ obj = MigrationObject(
+ name="customer_lifetime_value",
+ object_type=ObjectType.SQL_SCRIPT,
+ source_sql=sql,
+ target_sql=translated,
+ )
+ files = decompose_to_dbt(obj, Dialect.VERTICA)
+ assert any("marts/" in p for p in files)
+ assert any(p.endswith(".sql") and "stg_" in p or "int_" in p or "marts/" in p for p in files)
+ joined = "\n".join(files.values())
+ assert "COALESCE" in joined.upper()
+ assert "source(" in joined or "ref(" in joined
+
+ def test_procedure_to_bigquery(self):
+ sql = """CREATE OR REPLACE PROCEDURE p(load_date DATE) AS $$
+ BEGIN
+ DELETE FROM t WHERE d = load_date;
+ END; $$;"""
+ translated, _, auto, _ = translate_sql(sql, Dialect.VERTICA, Dialect.BIGQUERY)
+ assert "CREATE OR REPLACE PROCEDURE" in translated.upper()
+ assert "LANGUAGE SQL" not in translated.upper()
+ assert any("BigQuery" in a for a in auto)
+
+ def test_conversion_matrix_produces_output(self):
+ """Every exposed source→target pair must return non-empty converted SQL."""
+ samples = {
+ Dialect.VERTICA: "SELECT ZEROIFNULL(a) AS x FROM staging.t WHERE d >= CURRENT_DATE - 1",
+ Dialect.ORACLE: "SELECT NVL(a, 0) AS x FROM orders WHERE created_at >= SYSDATE",
+ Dialect.REDSHIFT: "SELECT GETDATE() AS ts, NVL(a, 0) AS x FROM t",
+ Dialect.BIGQUERY: "SELECT IFNULL(a, 0) AS x, STRING_AGG(b, ',') FROM t GROUP BY a",
+ Dialect.SNOWFLAKE: "SELECT COALESCE(a, 0) AS x, LISTAGG(b, ',') FROM t GROUP BY a",
+ }
+ targets = [
+ Dialect.PANDAS,
+ Dialect.PYSPARK,
+ Dialect.SNOWFLAKE,
+ Dialect.DBT_SNOWFLAKE,
+ Dialect.BIGQUERY,
+ ]
+ for source, sql in samples.items():
+ for target in targets:
+ translated, conf, auto, review = translate_sql(sql, source, target)
+ assert translated.strip(), f"{source.value}→{target.value} returned empty SQL"
+ assert conf >= 0
+ if target == Dialect.PANDAS:
+ assert "import pandas as pd" in translated
+ continue
+ if target == Dialect.PYSPARK:
+ assert "from pyspark.sql" in translated
+ continue
+ # Same-family routes may only apply light transforms; others must change or note work
+ if source != target and not (
+ source == Dialect.SNOWFLAKE and target == Dialect.DBT_SNOWFLAKE
+ ):
+ assert auto or translated != sql or review, (
+ f"{source.value}→{target.value} produced no conversion signal"
+ )
+
+
+class TestRiskScorer:
+ def test_score_simple_query(self):
+ obj = MigrationObject(name="TEST", object_type=ObjectType.SQL_SCRIPT, source_sql="SELECT 1")
+ scored = score_object(obj, Dialect.VERTICA, Dialect.SNOWFLAKE)
+ assert scored.complexity_score >= 0
+ assert scored.risk_level is not None
+
+ def test_extract_business_rules(self):
+ sql = """SELECT CASE WHEN x > 5 THEN 'HIGH' WHEN x > 2 THEN 'MED' ELSE 'LOW' END FROM t"""
+ rules = extract_business_rules(sql)
+ assert len(rules) >= 1
+
+
+class TestPipeline:
+ def test_full_analyze(self):
+ pipeline = MigrationPipeline(source=Dialect.VERTICA, target=Dialect.SNOWFLAKE)
+ report = pipeline.analyze(EXAMPLES)
+ assert report.dashboard.total_objects >= 4
+ assert len(report.objects) >= 4
+
+ def test_convert_pipeline(self):
+ pipeline = MigrationPipeline(source=Dialect.VERTICA, target=Dialect.SNOWFLAKE)
+ report = pipeline.analyze(EXAMPLES)
+ report = pipeline.convert(report)
+ converted = sum(1 for o in report.objects if o.target_sql)
+ assert converted >= 1
+
+ def test_validate_pipeline(self):
+ pipeline = MigrationPipeline(source=Dialect.VERTICA, target=Dialect.SNOWFLAKE)
+ report = pipeline.analyze(EXAMPLES)
+ report = pipeline.convert(report)
+ report = pipeline.validate(report)
+ assert len(report.validation_results) > 0
+
+
+class TestIntelligence:
+ def test_runbook_generation(self):
+ from morphsql.pipeline import MigrationPipeline
+ from morphsql.intelligence.runbook import generate_runbook, generate_executive_summary
+
+ pipeline = MigrationPipeline(source=Dialect.VERTICA, target=Dialect.SNOWFLAKE)
+ report = pipeline.analyze(EXAMPLES)
+ runbook = generate_runbook(report)
+ assert "Migration Runbook" in runbook
+ assert "Phase 1" in runbook
+ summary = generate_executive_summary(report)
+ assert "objects" in summary.lower()
+
+ def test_rationalization(self):
+ from morphsql.pipeline import MigrationPipeline
+ from morphsql.intelligence.rationalization import generate_rationalization
+
+ report = MigrationPipeline(source=Dialect.VERTICA, target=Dialect.SNOWFLAKE).analyze(EXAMPLES)
+ rat = generate_rationalization(report)
+ assert "Workload rationalization" in rat
+
+ def test_copilot_context(self):
+ from morphsql.assistant.copilot import MigrationCopilot
+ from morphsql.pipeline import MigrationPipeline
+
+ report = MigrationPipeline(source=Dialect.VERTICA, target=Dialect.SNOWFLAKE).analyze(EXAMPLES)
+ ctx = MigrationCopilot().build_context(report)
+ assert "Objects discovered" in ctx
+
+ def test_copilot_fallback(self):
+ from morphsql.assistant.copilot import MigrationCopilot
+
+ reply = MigrationCopilot()._fallback(
+ "explain cutover plan", None, "", "vertica", "snowflake"
+ )
+ assert "cutover" in reply.lower() or "phase" in reply.lower()
+
+ def test_copilot_priority_with_report(self):
+ from morphsql.assistant.copilot import MigrationCopilot
+ from morphsql.pipeline import MigrationPipeline
+
+ report = MigrationPipeline(source=Dialect.VERTICA, target=Dialect.SNOWFLAKE).analyze(
+ EXAMPLES
+ )
+ reply = MigrationCopilot().respond(
+ "What should we migrate first?",
+ [],
+ report,
+ "",
+ "vertica",
+ "snowflake",
+ )
+ assert "Start with" in reply or "Recommended" in reply or "first" in reply.lower()
+
+
+class TestIncrementalStrategy:
+ def test_delete_insert_pattern(self):
+ sql = "DELETE FROM t WHERE d = 1; INSERT INTO t SELECT * FROM s"
+ result = generate_incremental_strategy(sql)
+ assert result["legacy_pattern"] == "Delete and reload"
+ assert result["dbt_materialized"] == "incremental"