├── .github └── workflows │ └── publish.yml ├── .idea ├── .gitignore ├── SQL-Mongo-Queries-Converter.iml ├── google-java-format.xml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── build.sh ├── build └── lib │ └── sql_mongo_converter │ ├── __init__.py │ ├── converter.py │ ├── mongo_to_sql.py │ └── sql_to_mongo.py ├── dist ├── sql_mongo_converter-1.2.2-py3-none-any.whl └── sql_mongo_converter-1.2.2.tar.gz ├── push-image.sh ├── pyproject.toml ├── requirements.txt ├── setup.py ├── sql_mongo_converter.egg-info ├── PKG-INFO ├── SOURCES.txt ├── dependency_links.txt ├── requires.txt └── top_level.txt ├── sql_mongo_converter ├── __init__.py ├── converter.py ├── mongo_to_sql.py └── sql_to_mongo.py └── tests ├── demo.py └── test_converter.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Set up QEMU 15 | uses: docker/setup-qemu-action@v2 16 | 17 | - name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v2 19 | 20 | - name: Publish to GHCR 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | run: | 24 | chmod +x push-image.sh 25 | ./push-image.sh 26 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/SQL-Mongo-Queries-Converter.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/google-java-format.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 105 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 1) Build stage: install the package into a clean image 2 | FROM python:3.9-slim AS builder 3 | 4 | LABEL org.opencontainers.image.source="https://github.com/hoangsonww/SQL-Mongo-Query-Converter" 5 | LABEL org.opencontainers.image.description="sql_mongo_converter: convert SQL ↔ MongoDB queries." 6 | 7 | WORKDIR /app 8 | 9 | # copy only what's needed to install 10 | COPY setup.py README.md ./ 11 | COPY sql_mongo_converter/ ./sql_mongo_converter/ 12 | 13 | # install package (and cache dependencies) 14 | RUN pip install --no-cache-dir . 15 | 16 | # 2) Final runtime image 17 | FROM python:3.9-slim 18 | 19 | WORKDIR /app 20 | 21 | # copy installed package from builder 22 | COPY --from=builder /usr/local/lib/python3.9/site-packages/sql_mongo_converter* \ 23 | /usr/local/lib/python3.9/site-packages/ 24 | 25 | # copy any entrypoint script if you have one, or just expose it 26 | # e.g. an entrypoint.py that calls your package 27 | # COPY entrypoint.py ./ 28 | 29 | # default command: show help 30 | CMD ["python", "-m", "sql_mongo_converter", "--help"] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Son Nguyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for sql_mongo_converter Docker image 2 | 3 | # ← our GHCR namespace & repo 4 | IMAGE ?= ghcr.io/hoangsonww/sql-mongo-converter 5 | REGISTRY ?= ghcr.io 6 | USER ?= hoangsonww 7 | 8 | # ← parse version="x.y.z" from setup.py (portable sed) 9 | VERSION := $(shell sed -nE "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*[\"']([0-9]+\.[0-9]+\.[0-9]+)[\"'].*$$/\1/p" setup.py) 10 | 11 | .PHONY: all login build push clean version 12 | 13 | all: login build push 14 | 15 | version: 16 | @echo $(VERSION) 17 | 18 | login: 19 | @# ensure we have a token 20 | @test -n "$(GITHUB_TOKEN)" || (echo "Error: GITHUB_TOKEN not set" && exit 1) 21 | @echo "🔑 Logging into $(REGISTRY) as $(USER)" 22 | @echo "$(GITHUB_TOKEN)" | docker login $(REGISTRY) -u $(USER) --password-stdin 23 | 24 | build: 25 | @echo "🔨 Building Docker image $(IMAGE):$(VERSION)" 26 | @docker build --pull -t $(IMAGE):$(VERSION) -t $(IMAGE):latest . 27 | 28 | push: 29 | @echo "🚀 Pushing $(IMAGE):$(VERSION) and $(IMAGE):latest" 30 | @docker push $(IMAGE):$(VERSION) 31 | @docker push $(IMAGE):latest 32 | 33 | clean: 34 | @echo "🗑 Removing images" 35 | -@docker rmi $(IMAGE):$(VERSION) $(IMAGE):latest || true 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQL-Mongo Converter - A Lightweight SQL to MongoDB (and Vice Versa) Query Converter 🍃 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat&logo=opensource)](LICENSE) 4 | [![Python Version](https://img.shields.io/badge/Python-%3E=3.7-brightgreen.svg?style=flat&logo=python)](https://www.python.org/) 5 | [![SQL](https://img.shields.io/badge/SQL-%23E34F26.svg?style=flat&logo=postgresql)](https://www.postgresql.org/) 6 | [![MongoDB](https://img.shields.io/badge/MongoDB-%23471240.svg?style=flat&logo=mongodb)](https://www.mongodb.com/) 7 | [![PyPI](https://img.shields.io/pypi/v/sql-mongo-converter.svg?style=flat&logo=pypi)](https://pypi.org/project/sql-mongo-converter/) 8 | 9 | **SQL-Mongo Converter** is a lightweight Python library for converting SQL queries into MongoDB query dictionaries and converting MongoDB query dictionaries into SQL statements. It is designed for developers who need to quickly migrate or prototype between SQL-based and MongoDB-based data models without the overhead of a full ORM. 10 | 11 | **Currently live on PyPI:** [https://pypi.org/project/sql-mongo-converter/](https://pypi.org/project/sql-mongo-converter/) 12 | 13 | --- 14 | 15 | ## Table of Contents 16 | 17 | - [Features](#features) 18 | - [Installation](#installation) 19 | - [Usage](#usage) 20 | - [Converting SQL to MongoDB](#converting-sql-to-mongodb) 21 | - [Converting MongoDB to SQL](#converting-mongodb-to-sql) 22 | - [API Reference](#api-reference) 23 | - [Testing](#testing) 24 | - [Building & Publishing](#building--publishing) 25 | - [Contributing](#contributing) 26 | - [License](#license) 27 | - [Final Remarks](#final-remarks) 28 | 29 | --- 30 | 31 | ## Features 32 | 33 | - **SQL to MongoDB Conversion:** 34 | Convert SQL SELECT queries—including complex WHERE clauses with multiple conditions—into MongoDB query dictionaries with filters and projections. 35 | 36 | - **MongoDB to SQL Conversion:** 37 | Translate MongoDB find dictionaries, including support for comparison operators, logical operators, and list conditions, into SQL SELECT statements with WHERE clauses, ORDER BY, and optional LIMIT/OFFSET. 38 | 39 | - **Extensible & Robust:** 40 | Built to handle a wide range of query patterns. Easily extended to support additional SQL functions, advanced operators, and more complex query structures. 41 | 42 | --- 43 | 44 | ## Installation 45 | 46 | ### Prerequisites 47 | 48 | - Python 3.7 or higher 49 | - pip 50 | 51 | ### Install via PyPI 52 | 53 | ```bash 54 | pip install sql-mongo-converter 55 | ``` 56 | 57 | ### Installing from Source 58 | 59 | Clone the repository and install dependencies: 60 | 61 | ```bash 62 | git clone https://github.com/yourusername/sql-mongo-converter.git 63 | cd sql-mongo-converter 64 | pip install -r requirements.txt 65 | python setup.py install 66 | ``` 67 | 68 | --- 69 | 70 | ## Usage 71 | 72 | ### Converting SQL to MongoDB 73 | 74 | Use the `sql_to_mongo` function to convert a SQL SELECT query into a MongoDB query dictionary. The output dictionary contains: 75 | - **collection:** The table name. 76 | - **find:** The filter dictionary derived from the WHERE clause. 77 | - **projection:** The columns to return (if not all). 78 | 79 | #### Example 80 | 81 | ```python 82 | from sql_mongo_converter import sql_to_mongo 83 | 84 | sql_query = "SELECT name, age FROM users WHERE age > 30 AND name = 'Alice';" 85 | mongo_query = sql_to_mongo(sql_query) 86 | print(mongo_query) 87 | # Expected output: 88 | # { 89 | # "collection": "users", 90 | # "find": { "age": {"$gt": 30}, "name": "Alice" }, 91 | # "projection": { "name": 1, "age": 1 } 92 | # } 93 | ``` 94 | 95 | ### Converting MongoDB to SQL 96 | 97 | Use the `mongo_to_sql` function to convert a MongoDB query dictionary into a SQL SELECT statement. It supports operators such as `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, and `$regex`, as well as logical operators like `$and` and `$or`. 98 | 99 | #### Example 100 | 101 | ```python 102 | from sql_mongo_converter import mongo_to_sql 103 | 104 | mongo_obj = { 105 | "collection": "users", 106 | "find": { 107 | "$or": [ 108 | {"age": {"$gte": 25}}, 109 | {"status": "ACTIVE"} 110 | ], 111 | "tags": {"$in": ["dev", "qa"]} 112 | }, 113 | "projection": {"age": 1, "status": 1, "tags": 1}, 114 | "sort": [("age", 1), ("name", -1)], 115 | "limit": 10, 116 | "skip": 5 117 | } 118 | sql_query = mongo_to_sql(mongo_obj) 119 | print(sql_query) 120 | # Example output: 121 | # SELECT age, status, tags FROM users WHERE ((age >= 25) OR (status = 'ACTIVE')) AND (tags IN ('dev', 'qa')) 122 | # ORDER BY age ASC, name DESC LIMIT 10 OFFSET 5; 123 | ``` 124 | 125 | --- 126 | 127 | ## API Reference 128 | 129 | ### `sql_to_mongo(sql_query: str) -> dict` 130 | - **Description:** 131 | Parses a SQL SELECT query and converts it into a MongoDB query dictionary. 132 | - **Parameters:** 133 | - `sql_query`: A valid SQL SELECT query string. 134 | - **Returns:** 135 | A dictionary containing: 136 | - `collection`: The table name. 137 | - `find`: The filter derived from the WHERE clause. 138 | - `projection`: A dictionary specifying the columns to return. 139 | 140 | ### `mongo_to_sql(mongo_obj: dict) -> str` 141 | - **Description:** 142 | Converts a MongoDB query dictionary into a SQL SELECT statement. 143 | - **Parameters:** 144 | - `mongo_obj`: A dictionary representing a MongoDB find query, including keys such as `collection`, `find`, `projection`, `sort`, `limit`, and `skip`. 145 | - **Returns:** 146 | A SQL SELECT statement as a string. 147 | 148 | --- 149 | 150 | ## Testing 151 | 152 | The package includes a unittest suite to verify conversion functionality. 153 | 154 | ### Running Tests 155 | 156 | 1. **Create a virtual environment (optional but recommended):** 157 | 158 | ```bash 159 | python -m venv venv 160 | source venv/bin/activate # On Windows: venv\Scripts\activate 161 | ``` 162 | 163 | 2. **Install test dependencies:** 164 | 165 | ```bash 166 | pip install -r requirements.txt 167 | pip install pytest 168 | ``` 169 | 170 | 3. **Run tests:** 171 | 172 | ```bash 173 | python -m unittest discover tests 174 | # or using pytest: 175 | pytest --maxfail=1 --disable-warnings -q 176 | ``` 177 | 178 | ### Demo Script 179 | 180 | A demo script in the `tests` directory is provided to showcase the conversion capabilities. It can be run directly to see examples of SQL to MongoDB and MongoDB to SQL conversions. 181 | 182 | ```bash 183 | python demo.py 184 | ``` 185 | 186 | The script demonstrates various conversion scenarios. 187 | 188 | --- 189 | 190 | ## Building & Publishing 191 | 192 | ### Building the Package 193 | 194 | 1. **Ensure you have setuptools and wheel installed:** 195 | 196 | ```bash 197 | pip install setuptools wheel 198 | ``` 199 | 200 | 2. **Build the package:** 201 | 202 | ```bash 203 | python setup.py sdist bdist_wheel 204 | ``` 205 | 206 | This creates a `dist/` folder with the distribution files. 207 | 208 | ### Publishing to PyPI 209 | 210 | 1. **Install Twine:** 211 | 212 | ```bash 213 | pip install twine 214 | ``` 215 | 216 | 2. **Upload your package:** 217 | 218 | ```bash 219 | twine upload dist/* 220 | ``` 221 | 222 | 3. **Follow the prompts** for your PyPI credentials. 223 | 224 | --- 225 | 226 | ## Contributing 227 | 228 | Contributions are welcome! To contribute: 229 | 230 | 1. **Fork the Repository** 231 | 2. **Create a Feature Branch:** 232 | 233 | ```bash 234 | git checkout -b feature/my-new-feature 235 | ``` 236 | 237 | 3. **Commit Your Changes:** 238 | 239 | ```bash 240 | git commit -am "Add new feature or fix bug" 241 | ``` 242 | 243 | 4. **Push Your Branch:** 244 | 245 | ```bash 246 | git push origin feature/my-new-feature 247 | ``` 248 | 249 | 5. **Submit a Pull Request** on GitHub. 250 | 251 | For major changes, please open an issue first to discuss your ideas. 252 | 253 | --- 254 | 255 | ## License 256 | 257 | This project is licensed under the [MIT License](LICENSE). 258 | 259 | --- 260 | 261 | ## Final Remarks 262 | 263 | **SQL-Mongo Converter** is a powerful, lightweight tool that bridges SQL and MongoDB query languages. It is ideal for developers migrating between SQL and MongoDB data models, or those who want to prototype and test queries quickly. Extend and customize the converter as needed to support more advanced queries or additional SQL constructs. 264 | 265 | Happy converting! 🍃 266 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # build.sh — clean + test + build your Python package 3 | 4 | set -euo pipefail 5 | 6 | # 1. Clean up old builds 7 | echo "🧹 Cleaning up previous builds…" 8 | rm -rf build/ dist/ *.egg-info/ 9 | 10 | # 2. Run your test suite (if you have one) 11 | if command -v pytest &> /dev/null; then 12 | echo "🧪 Running tests…" 13 | pytest 14 | else 15 | echo "⚠️ pytest not found — skipping tests" 16 | fi 17 | 18 | # 3. Ensure build tools are installed 19 | echo "📦 Ensuring latest build tools…" 20 | python3 -m pip install --upgrade build wheel twine 21 | 22 | # 4. Build source & wheel 23 | echo "🚧 Building source and wheel distributions…" 24 | python3 -m build 25 | 26 | # 5. Verify the distributions 27 | echo "🔍 Checking distribution integrity…" 28 | python3 -m twine check dist/* 29 | 30 | echo "✅ Build complete — artifacts in dist/" 31 | -------------------------------------------------------------------------------- /build/lib/sql_mongo_converter/__init__.py: -------------------------------------------------------------------------------- 1 | from .converter import sql_to_mongo, mongo_to_sql 2 | 3 | __all__ = ["sql_to_mongo", "mongo_to_sql"] 4 | -------------------------------------------------------------------------------- /build/lib/sql_mongo_converter/converter.py: -------------------------------------------------------------------------------- 1 | from .sql_to_mongo import sql_select_to_mongo 2 | from .mongo_to_sql import mongo_find_to_sql 3 | 4 | 5 | def sql_to_mongo(sql_query: str): 6 | """ 7 | Converts a SQL SELECT query to a naive MongoDB find dict. 8 | 9 | :param sql_query: The SQL SELECT query as a string. 10 | :return: A naive MongoDB find dict. 11 | """ 12 | return sql_select_to_mongo(sql_query) 13 | 14 | 15 | def mongo_to_sql(mongo_obj: dict): 16 | """ 17 | Converts a naive Mongo 'find' dict to a basic SQL SELECT. 18 | 19 | :param mongo_obj: The MongoDB find dict. 20 | :return: The SQL SELECT query as a string. 21 | """ 22 | return mongo_find_to_sql(mongo_obj) 23 | -------------------------------------------------------------------------------- /build/lib/sql_mongo_converter/mongo_to_sql.py: -------------------------------------------------------------------------------- 1 | def mongo_find_to_sql(mongo_obj: dict) -> str: 2 | """ 3 | Convert a Mongo find dictionary into an extended SQL SELECT query. 4 | 5 | Example input: 6 | { 7 | "collection": "users", 8 | "find": { 9 | "$or": [ 10 | {"age": {"$gte": 25}}, 11 | {"status": "ACTIVE"} 12 | ] 13 | }, 14 | "projection": {"age": 1, "status": 1}, 15 | "sort": [("age", 1), ("name", -1)], 16 | "limit": 10, 17 | "skip": 5 18 | } 19 | 20 | => Output: 21 | SELECT age, status FROM users 22 | WHERE ((age >= 25) OR (status = 'ACTIVE')) 23 | ORDER BY age ASC, name DESC 24 | LIMIT 10 OFFSET 5; 25 | 26 | :param mongo_obj: The MongoDB find dict. 27 | :return: The SQL SELECT query as a string. 28 | """ 29 | table = mongo_obj.get("collection", "unknown_table") 30 | find_filter = mongo_obj.get("find", {}) 31 | projection = mongo_obj.get("projection", {}) 32 | sort_clause = mongo_obj.get("sort", []) # e.g. [("field", 1), ("other", -1)] 33 | limit_val = mongo_obj.get("limit", None) 34 | skip_val = mongo_obj.get("skip", None) 35 | 36 | # 1) Build the column list from projection 37 | columns = "*" 38 | if isinstance(projection, dict) and len(projection) > 0: 39 | # e.g. { "age":1, "status":1 } 40 | col_list = [] 41 | for field, include in projection.items(): 42 | if include == 1: 43 | col_list.append(field) 44 | if col_list: 45 | columns = ", ".join(col_list) 46 | 47 | # 2) Build WHERE from find_filter 48 | where_sql = build_where_sql(find_filter) 49 | 50 | # 3) Build ORDER BY from sort 51 | order_sql = build_order_by_sql(sort_clause) 52 | 53 | # 4) Combine everything 54 | sql = f"SELECT {columns} FROM {table}" 55 | 56 | if where_sql: 57 | sql += f" WHERE {where_sql}" 58 | 59 | if order_sql: 60 | sql += f" ORDER BY {order_sql}" 61 | 62 | # 5) Limit + Skip 63 | # skip in Mongo ~ "OFFSET" in SQL 64 | if isinstance(limit_val, int) and limit_val > 0: 65 | sql += f" LIMIT {limit_val}" 66 | if isinstance(skip_val, int) and skip_val > 0: 67 | sql += f" OFFSET {skip_val}" 68 | else: 69 | # If no limit but skip is provided, you can handle or ignore 70 | if isinstance(skip_val, int) and skip_val > 0: 71 | # Some SQL dialects allow "OFFSET" without a limit, others do not 72 | sql += f" LIMIT 999999999 OFFSET {skip_val}" 73 | 74 | sql += ";" 75 | return sql 76 | 77 | 78 | def build_where_sql(find_filter) -> str: 79 | """ 80 | Convert a 'find' dict into a SQL condition string. 81 | Supports: 82 | - direct equality: {field: value} 83 | - comparison operators: {field: {"$gt": val, ...}} 84 | - $in / $nin 85 | - $regex => LIKE 86 | - $and / $or => combine subclauses 87 | 88 | :param find_filter: The 'find' dict from MongoDB. 89 | :return: The SQL WHERE clause as a string. 90 | """ 91 | if not find_filter: 92 | return "" 93 | 94 | # If top-level is a dictionary with $and / $or 95 | if isinstance(find_filter, dict): 96 | # check for $and / $or in the top-level 97 | if "$and" in find_filter: 98 | conditions = [build_where_sql(sub) for sub in find_filter["$and"]] 99 | # e.g. (cond1) AND (cond2) 100 | return "(" + ") AND (".join(cond for cond in conditions if cond) + ")" 101 | elif "$or" in find_filter: 102 | conditions = [build_where_sql(sub) for sub in find_filter["$or"]] 103 | return "(" + ") OR (".join(cond for cond in conditions if cond) + ")" 104 | else: 105 | # parse normal fields 106 | return build_basic_conditions(find_filter) 107 | 108 | # If top-level is a list => not typical, handle or skip 109 | if isinstance(find_filter, list): 110 | # e.g. $or array 111 | # but typically you'd see it as { "$or": [ {}, {} ] } 112 | subclauses = [build_where_sql(sub) for sub in find_filter] 113 | return "(" + ") AND (".join(sc for sc in subclauses if sc) + ")" 114 | 115 | # fallback: if it's a scalar or something unexpected 116 | return "" 117 | 118 | 119 | def build_basic_conditions(condition_dict: dict) -> str: 120 | """ 121 | For each field in condition_dict: 122 | if it's a direct scalar => field = value 123 | if it's an operator dict => interpret $gt, $in, etc. 124 | Return "field1 = val1 AND field2 >= val2" etc. combined. 125 | 126 | :param condition_dict: A dictionary of conditions. 127 | :return: A SQL condition string. 128 | """ 129 | clauses = [] 130 | for field, expr in condition_dict.items(): 131 | # e.g. field => "status", expr => "ACTIVE" 132 | if isinstance(expr, dict): 133 | # parse operator e.g. {"$gt": 30} 134 | for op, val in expr.items(): 135 | clause = convert_operator(field, op, val) 136 | if clause: 137 | clauses.append(clause) 138 | else: 139 | # direct equality 140 | if isinstance(expr, (int, float)): 141 | clauses.append(f"{field} = {expr}") 142 | else: 143 | clauses.append(f"{field} = '{escape_quotes(str(expr))}'") 144 | 145 | return " AND ".join(clauses) 146 | 147 | 148 | def convert_operator(field: str, op: str, val): 149 | """ 150 | Handle operators like $gt, $in, $regex, etc. 151 | 152 | :param field: The field name. 153 | :param op: The operator (e.g., "$gt", "$in"). 154 | """ 155 | # Convert val to string with quotes if needed 156 | if isinstance(val, (int, float)): 157 | val_str = str(val) 158 | elif isinstance(val, list): 159 | # handle lists for $in, $nin 160 | val_str = ", ".join(quote_if_needed(item) for item in val) 161 | else: 162 | # string or other 163 | val_str = f"'{escape_quotes(str(val))}'" 164 | 165 | op_map = { 166 | "$gt": ">", 167 | "$gte": ">=", 168 | "$lt": "<", 169 | "$lte": "<=", 170 | "$eq": "=", 171 | "$ne": "<>", 172 | "$regex": "LIKE" 173 | } 174 | 175 | if op in op_map: 176 | sql_op = op_map[op] 177 | # e.g. "field > 30" or "field LIKE '%abc%'" 178 | return f"{field} {sql_op} {val_str}" 179 | elif op == "$in": 180 | # e.g. field IN (1,2,3) 181 | return f"{field} IN ({val_str})" 182 | elif op == "$nin": 183 | return f"{field} NOT IN ({val_str})" 184 | else: 185 | # fallback 186 | return f"{field} /*unknown op {op}*/ {val_str}" 187 | 188 | 189 | def build_order_by_sql(sort_list): 190 | """ 191 | If we have "sort": [("age", 1), ("name", -1)], 192 | => "age ASC, name DESC" 193 | 194 | :param sort_list: List of tuples (field, direction) 195 | :return: SQL ORDER BY clause as a string. 196 | """ 197 | if not sort_list or not isinstance(sort_list, list): 198 | return "" 199 | order_parts = [] 200 | for field_dir in sort_list: 201 | if isinstance(field_dir, tuple) and len(field_dir) == 2: 202 | field, direction = field_dir 203 | dir_sql = "ASC" if direction == 1 else "DESC" 204 | order_parts.append(f"{field} {dir_sql}") 205 | return ", ".join(order_parts) 206 | 207 | 208 | def quote_if_needed(val): 209 | """ 210 | Return a numeric or quoted string 211 | 212 | :param val: The value to quote if it's a string. 213 | :return: The value as a string, quoted if it's a string. 214 | """ 215 | if isinstance(val, (int, float)): 216 | return str(val) 217 | return f"'{escape_quotes(str(val))}'" 218 | 219 | 220 | def escape_quotes(s: str) -> str: 221 | """ 222 | Simple approach to escape single quotes 223 | 224 | :param s: The string to escape. 225 | :return: The escaped string. 226 | """ 227 | return s.replace("'", "''") 228 | -------------------------------------------------------------------------------- /build/lib/sql_mongo_converter/sql_to_mongo.py: -------------------------------------------------------------------------------- 1 | import sqlparse 2 | from sqlparse.sql import ( 3 | IdentifierList, 4 | Identifier, 5 | Where, 6 | Token, 7 | Parenthesis, 8 | ) 9 | from sqlparse.tokens import Keyword, DML 10 | 11 | 12 | def sql_select_to_mongo(sql_query: str): 13 | """ 14 | Convert a SELECT...FROM...WHERE...ORDER BY...GROUP BY...LIMIT... 15 | into a Mongo dict: 16 | 17 | { 18 | "collection": , 19 | "find": { ...where... }, 20 | "projection": { col1:1, col2:1 } or None, 21 | "sort": [...], 22 | "limit": int, 23 | "group": { ... } 24 | } 25 | 26 | :param sql_query: The SQL SELECT query as a string. 27 | :return: A naive MongoDB find dict. 28 | """ 29 | parsed = sqlparse.parse(sql_query) 30 | if not parsed: 31 | return {} 32 | 33 | statement = parsed[0] 34 | columns, table_name, where_clause, order_by, group_by, limit_val = parse_select_statement(statement) 35 | 36 | return build_mongo_query( 37 | table_name, columns, where_clause, order_by, group_by, limit_val 38 | ) 39 | 40 | 41 | def parse_select_statement(statement): 42 | """ 43 | Parse: 44 | SELECT FROM
45 | [WHERE ...] 46 | [GROUP BY ...] 47 | [ORDER BY ...] 48 | [LIMIT ...] 49 | in that approximate order. 50 | 51 | Returns: 52 | columns, table_name, where_clause_dict, order_by_list, group_by_list, limit_val 53 | 54 | :param statement: The parsed SQL statement. 55 | :return: A tuple containing columns, table_name, where_clause_dict, order_by_list, group_by_list, limit_val 56 | """ 57 | columns = [] 58 | table_name = None 59 | where_clause = {} 60 | order_by = [] # e.g. [("age", 1), ("name", -1)] 61 | group_by = [] # e.g. ["department", "role"] 62 | limit_val = None 63 | 64 | found_select = False 65 | reading_columns = False 66 | reading_from = False 67 | 68 | tokens = [t for t in statement.tokens if not t.is_whitespace] 69 | 70 | # We'll do multiple passes or a single pass with states 71 | # Single pass approach: 72 | i = 0 73 | while i < len(tokens): 74 | token = tokens[i] 75 | 76 | # detect SELECT 77 | if token.ttype is DML and token.value.upper() == "SELECT": 78 | found_select = True 79 | reading_columns = True 80 | i += 1 81 | continue 82 | 83 | # parse columns until we see FROM 84 | if reading_columns: 85 | if token.ttype is Keyword and token.value.upper() == "FROM": 86 | reading_columns = False 87 | reading_from = True 88 | i += 1 89 | continue 90 | else: 91 | possible_cols = extract_columns(token) 92 | if possible_cols: 93 | columns = possible_cols 94 | i += 1 95 | continue 96 | 97 | # parse table name right after FROM 98 | if reading_from: 99 | # if token is Keyword (like WHERE, GROUP, ORDER), we skip 100 | if token.ttype is Keyword: 101 | # no table name found => might be incomplete 102 | reading_from = False 103 | # don't advance i, we'll handle logic below 104 | else: 105 | # assume table name 106 | table_name = str(token).strip() 107 | reading_from = False 108 | i += 1 109 | continue 110 | 111 | # check if token is a Where object => parse WHERE 112 | if isinstance(token, Where): 113 | where_clause = extract_where_clause(token) 114 | i += 1 115 | continue 116 | 117 | # or check if token is a simple 'WHERE' keyword 118 | if token.ttype is Keyword and token.value.upper() == "WHERE": 119 | # next token might be the actual conditions or a Where 120 | # try to gather the text 121 | # but often sqlparse lumps everything into a Where 122 | if i + 1 < len(tokens): 123 | next_token = tokens[i + 1] 124 | if isinstance(next_token, Where): 125 | where_clause = extract_where_clause(next_token) 126 | i += 2 127 | continue 128 | else: 129 | # fallback substring approach if needed 130 | where_clause_text = str(next_token).strip() 131 | where_clause = parse_where_conditions(where_clause_text) 132 | i += 2 133 | continue 134 | i += 1 135 | continue 136 | 137 | # handle ORDER BY 138 | if token.ttype is Keyword and token.value.upper() == "ORDER": 139 | # next token should be BY 140 | i += 1 141 | if i < len(tokens): 142 | nxt = tokens[i] 143 | if nxt.ttype is Keyword and nxt.value.upper() == "BY": 144 | i += 1 145 | # parse the next token as columns 146 | if i < len(tokens): 147 | order_by = parse_order_by(tokens[i]) 148 | i += 1 149 | continue 150 | else: 151 | i += 1 152 | continue 153 | 154 | # handle GROUP BY 155 | if token.ttype is Keyword and token.value.upper() == "GROUP": 156 | # next token should be BY 157 | i += 1 158 | if i < len(tokens): 159 | nxt = tokens[i] 160 | if nxt.ttype is Keyword and nxt.value.upper() == "BY": 161 | i += 1 162 | # parse group by columns 163 | if i < len(tokens): 164 | group_by = parse_group_by(tokens[i]) 165 | i += 1 166 | continue 167 | else: 168 | i += 1 169 | continue 170 | 171 | # handle LIMIT 172 | if token.ttype is Keyword and token.value.upper() == "LIMIT": 173 | # next token might be the limit number 174 | if i + 1 < len(tokens): 175 | limit_val = parse_limit_value(tokens[i + 1]) 176 | i += 2 177 | continue 178 | 179 | i += 1 180 | 181 | return columns, table_name, where_clause, order_by, group_by, limit_val 182 | 183 | 184 | def extract_columns(token): 185 | """ 186 | If token is an IdentifierList => multiple columns 187 | If token is an Identifier => single column 188 | If token is '*' => wildcard 189 | 190 | Return a list of columns. 191 | If no columns found, return an empty list. 192 | 193 | :param token: The SQL token to extract columns from. 194 | :return: A list of columns. 195 | """ 196 | from sqlparse.sql import IdentifierList, Identifier 197 | if isinstance(token, IdentifierList): 198 | return [str(ident).strip() for ident in token.get_identifiers()] 199 | elif isinstance(token, Identifier): 200 | return [str(token).strip()] 201 | else: 202 | raw = str(token).strip() 203 | raw = raw.replace(" ", "") 204 | if not raw: 205 | return [] 206 | return [raw] 207 | 208 | 209 | def extract_where_clause(where_token): 210 | """ 211 | If where_token is a Where object => parse out 'WHERE' prefix, then parse conditions 212 | If where_token is a simple 'WHERE' keyword => parse conditions directly 213 | 214 | Return a dict of conditions. 215 | 216 | :param where_token: The SQL token to extract the WHERE clause from. 217 | :return: A dict of conditions. 218 | """ 219 | raw = str(where_token).strip() 220 | if raw.upper().startswith("WHERE"): 221 | raw = raw[5:].strip() 222 | return parse_where_conditions(raw) 223 | 224 | 225 | def parse_where_conditions(text: str): 226 | """ 227 | e.g. "age > 30 AND name = 'Alice'" 228 | => { "age":{"$gt":30}, "name":"Alice" } 229 | We'll strip trailing semicolon as well. 230 | 231 | Supports: 232 | - direct equality: {field: value} 233 | - inequality: {field: {"$gt": value}} 234 | - other operators: {field: {"$op?": value}} 235 | 236 | :param text: The WHERE clause text. 237 | :return: A dict of conditions. 238 | """ 239 | text = text.strip().rstrip(";") 240 | if not text: 241 | return {} 242 | 243 | # naive split on " AND " 244 | parts = text.split(" AND ") 245 | out = {} 246 | for part in parts: 247 | tokens = part.split(None, 2) # e.g. ["age", ">", "30"] 248 | if len(tokens) < 3: 249 | continue 250 | field, op, val = tokens[0], tokens[1], tokens[2] 251 | val = val.strip().rstrip(";").strip("'").strip('"') 252 | if op == "=": 253 | out[field] = val 254 | elif op == ">": 255 | out[field] = {"$gt": convert_value(val)} 256 | elif op == "<": 257 | out[field] = {"$lt": convert_value(val)} 258 | elif op == ">=": 259 | out[field] = {"$gte": convert_value(val)} 260 | elif op == "<=": 261 | out[field] = {"$lte": convert_value(val)} 262 | else: 263 | out[field] = {"$op?": val} 264 | return out 265 | 266 | 267 | def parse_order_by(token): 268 | """ 269 | e.g. "age ASC, name DESC" 270 | Return [("age",1), ("name",-1)] 271 | 272 | :param token: The SQL token to extract the ORDER BY clause from. 273 | :return: A list of tuples (field, direction). 274 | """ 275 | raw = str(token).strip().rstrip(";") 276 | if not raw: 277 | return [] 278 | # might be multiple columns 279 | parts = raw.split(",") 280 | order_list = [] 281 | for part in parts: 282 | sub = part.strip().split() 283 | if len(sub) == 1: 284 | # e.g. "age" 285 | order_list.append((sub[0], 1)) # default ASC 286 | elif len(sub) == 2: 287 | # e.g. "age ASC" or "name DESC" 288 | field, direction = sub[0], sub[1].upper() 289 | if direction == "ASC": 290 | order_list.append((field, 1)) 291 | elif direction == "DESC": 292 | order_list.append((field, -1)) 293 | else: 294 | order_list.append((field, 1)) # fallback 295 | else: 296 | # fallback 297 | order_list.append((part.strip(), 1)) 298 | return order_list 299 | 300 | 301 | def parse_group_by(token): 302 | """ 303 | e.g. "department, role" 304 | => ["department", "role"] 305 | 306 | :param token: The SQL token to extract the GROUP BY clause from. 307 | :return: A list of columns. 308 | """ 309 | raw = str(token).strip().rstrip(";") 310 | if not raw: 311 | return [] 312 | return [x.strip() for x in raw.split(",")] 313 | 314 | 315 | def parse_limit_value(token): 316 | """ 317 | e.g. "100" 318 | => 100 (int) 319 | 320 | :param token: The SQL token to extract the LIMIT value from. 321 | :return: The LIMIT value as an integer, or None if not a valid integer. 322 | """ 323 | raw = str(token).strip().rstrip(";") 324 | try: 325 | return int(raw) 326 | except ValueError: 327 | return None 328 | 329 | 330 | def convert_value(val: str): 331 | """ 332 | Convert a value to an int, float, or string. 333 | 334 | :param val: The value to convert. 335 | :return: The value as an int, float, or string. 336 | """ 337 | try: 338 | return int(val) 339 | except ValueError: 340 | try: 341 | return float(val) 342 | except ValueError: 343 | return val 344 | 345 | 346 | def build_mongo_find(table_name, where_clause, columns): 347 | """ 348 | Build a MongoDB find query. 349 | 350 | :param table_name: The name of the collection. 351 | :param where_clause: The WHERE clause as a dict. 352 | :param columns: The list of columns to select. 353 | :return: A dict representing the MongoDB find query. 354 | """ 355 | filter_query = where_clause or {} 356 | projection = {} 357 | if columns and "*" not in columns: 358 | for col in columns: 359 | projection[col] = 1 360 | return { 361 | "collection": table_name, 362 | "find": filter_query, 363 | "projection": projection if projection else None 364 | } 365 | 366 | 367 | def build_mongo_query(table_name, columns, where_clause, order_by, group_by, limit_val): 368 | """ 369 | Build a MongoDB query object from parsed SQL components. 370 | 371 | We'll store everything in a single dict: 372 | { 373 | "collection": table_name, 374 | "find": {...}, 375 | "projection": {...}, 376 | "sort": [("col",1),("col2",-1)], 377 | "limit": int or None, 378 | "group": {...} 379 | } 380 | 381 | :param table_name: The name of the collection. 382 | :param columns: The list of columns to select. 383 | """ 384 | query_obj = build_mongo_find(table_name, where_clause, columns) 385 | 386 | # Add sort 387 | if order_by: 388 | query_obj["sort"] = order_by 389 | 390 | # Add limit 391 | if limit_val is not None: 392 | query_obj["limit"] = limit_val 393 | 394 | # If group_by is used: 395 | if group_by: 396 | # e.g. group_by = ["department","role"] 397 | # We'll store a $group pipeline 398 | # Real logic depends on what columns are selected 399 | group_pipeline = { 400 | "$group": { 401 | "_id": {}, 402 | "count": {"$sum": 1} 403 | } 404 | } 405 | # e.g. _id => { department: "$department", role: "$role" } 406 | _id_obj = {} 407 | for gb in group_by: 408 | _id_obj[gb] = f"${gb}" 409 | group_pipeline["$group"]["_id"] = _id_obj 410 | query_obj["group"] = group_pipeline 411 | return query_obj 412 | -------------------------------------------------------------------------------- /dist/sql_mongo_converter-1.2.2-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/SQL-Mongo-Query-Converter/7f1676e47202358f70af13836cfc720c360301fc/dist/sql_mongo_converter-1.2.2-py3-none-any.whl -------------------------------------------------------------------------------- /dist/sql_mongo_converter-1.2.2.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/SQL-Mongo-Query-Converter/7f1676e47202358f70af13836cfc720c360301fc/dist/sql_mongo_converter-1.2.2.tar.gz -------------------------------------------------------------------------------- /push-image.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | GH_USER="hoangsonww" 5 | IMAGE="ghcr.io/${GH_USER}/sql-mongo-converter" 6 | 7 | # 1) extract version from setup.py (portable) 8 | # works on both macOS and Linux 9 | VERSION=$(sed -nE "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*['\"]([0-9]+\.[0-9]+\.[0-9]+)['\"].*/\1/p" setup.py) 10 | 11 | if [ -z "${VERSION}" ]; then 12 | echo "❌ Could not parse version from setup.py" 13 | exit 1 14 | fi 15 | 16 | echo "ℹ️ Building and pushing version ${VERSION}" 17 | 18 | # 2) require GITHUB_TOKEN 19 | if [ -z "${GITHUB_TOKEN:-}" ]; then 20 | echo "❌ Please export GITHUB_TOKEN (with write:packages scope)." 21 | exit 1 22 | fi 23 | 24 | # 3) login to GitHub Container Registry 25 | echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${GH_USER}" --password-stdin 26 | 27 | # 4) build & tag 28 | docker build \ 29 | --pull \ 30 | -t "${IMAGE}:${VERSION}" \ 31 | -t "${IMAGE}:latest" \ 32 | . 33 | 34 | # 5) push 35 | docker push "${IMAGE}:${VERSION}" 36 | docker push "${IMAGE}:latest" 37 | 38 | echo "✅ Pushed:" 39 | echo " • ${IMAGE}:${VERSION}" 40 | echo " • ${IMAGE}:latest" 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | backports.tarfile==1.2.0 2 | certifi==2025.1.31 3 | charset-normalizer==3.4.1 4 | docutils==0.21.2 5 | id==1.5.0 6 | idna==3.10 7 | importlib_metadata==8.6.1 8 | jaraco.classes==3.4.0 9 | jaraco.context==6.0.1 10 | jaraco.functools==4.1.0 11 | keyring==25.6.0 12 | markdown-it-py==3.0.0 13 | mdurl==0.1.2 14 | more-itertools==10.6.0 15 | nh3==0.2.21 16 | packaging==24.2 17 | Pygments==2.19.1 18 | readme_renderer==44.0 19 | requests==2.32.3 20 | requests-toolbelt==1.0.0 21 | rfc3986==2.0.0 22 | rich==13.9.4 23 | sqlparse==0.5.3 24 | twine==6.1.0 25 | typing_extensions==4.12.2 26 | urllib3==2.3.0 27 | zipp==3.21.0 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | # Get the directory where setup.py resides 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | # Read the long description from README.md 8 | with open(os.path.join(here, "README.md"), encoding="utf-8") as f: 9 | long_description = f.read() 10 | 11 | setup( 12 | name="sql_mongo_converter", 13 | version="1.2.2", 14 | description="Convert SQL queries to MongoDB queries and vice versa.", 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | author="Son Nguyen", 18 | author_email="hoangson091104@gmail.com", 19 | url="https://github.com/hoangsonww/SQL-Mongo-Query-Converter", 20 | packages=find_packages(), 21 | install_requires=[ 22 | "sqlparse", 23 | ], 24 | classifiers=[ 25 | "Programming Language :: Python :: 3", 26 | "License :: OSI Approved :: MIT License", 27 | "Operating System :: OS Independent", 28 | ], 29 | python_requires=">=3.7", 30 | ) 31 | -------------------------------------------------------------------------------- /sql_mongo_converter.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: sql_mongo_converter 3 | Version: 1.2.2 4 | Summary: Convert SQL queries to MongoDB queries and vice versa. 5 | Home-page: https://github.com/hoangsonww/SQL-Mongo-Query-Converter 6 | Author: Son Nguyen 7 | Author-email: hoangson091104@gmail.com 8 | Classifier: Programming Language :: Python :: 3 9 | Classifier: License :: OSI Approved :: MIT License 10 | Classifier: Operating System :: OS Independent 11 | Requires-Python: >=3.7 12 | Description-Content-Type: text/markdown 13 | License-File: LICENSE 14 | Requires-Dist: sqlparse 15 | 16 | # SQL-Mongo Converter - A Lightweight SQL to MongoDB (and Vice Versa) Query Converter 🍃 17 | 18 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat&logo=opensource)](LICENSE) 19 | [![Python Version](https://img.shields.io/badge/Python-%3E=3.7-brightgreen.svg?style=flat&logo=python)](https://www.python.org/) 20 | [![SQL](https://img.shields.io/badge/SQL-%23E34F26.svg?style=flat&logo=postgresql)](https://www.postgresql.org/) 21 | [![MongoDB](https://img.shields.io/badge/MongoDB-%23471240.svg?style=flat&logo=mongodb)](https://www.mongodb.com/) 22 | [![PyPI](https://img.shields.io/pypi/v/sql-mongo-converter.svg?style=flat&logo=pypi)](https://pypi.org/project/sql-mongo-converter/) 23 | 24 | **SQL-Mongo Converter** is a lightweight Python library for converting SQL queries into MongoDB query dictionaries and converting MongoDB query dictionaries into SQL statements. It is designed for developers who need to quickly migrate or prototype between SQL-based and MongoDB-based data models without the overhead of a full ORM. 25 | 26 | **Currently live on PyPI:** [https://pypi.org/project/sql-mongo-converter/](https://pypi.org/project/sql-mongo-converter/) 27 | 28 | --- 29 | 30 | ## Table of Contents 31 | 32 | - [Features](#features) 33 | - [Installation](#installation) 34 | - [Usage](#usage) 35 | - [Converting SQL to MongoDB](#converting-sql-to-mongodb) 36 | - [Converting MongoDB to SQL](#converting-mongodb-to-sql) 37 | - [API Reference](#api-reference) 38 | - [Testing](#testing) 39 | - [Building & Publishing](#building--publishing) 40 | - [Contributing](#contributing) 41 | - [License](#license) 42 | - [Final Remarks](#final-remarks) 43 | 44 | --- 45 | 46 | ## Features 47 | 48 | - **SQL to MongoDB Conversion:** 49 | Convert SQL SELECT queries—including complex WHERE clauses with multiple conditions—into MongoDB query dictionaries with filters and projections. 50 | 51 | - **MongoDB to SQL Conversion:** 52 | Translate MongoDB find dictionaries, including support for comparison operators, logical operators, and list conditions, into SQL SELECT statements with WHERE clauses, ORDER BY, and optional LIMIT/OFFSET. 53 | 54 | - **Extensible & Robust:** 55 | Built to handle a wide range of query patterns. Easily extended to support additional SQL functions, advanced operators, and more complex query structures. 56 | 57 | --- 58 | 59 | ## Installation 60 | 61 | ### Prerequisites 62 | 63 | - Python 3.7 or higher 64 | - pip 65 | 66 | ### Install via PyPI 67 | 68 | ```bash 69 | pip install sql-mongo-converter 70 | ``` 71 | 72 | ### Installing from Source 73 | 74 | Clone the repository and install dependencies: 75 | 76 | ```bash 77 | git clone https://github.com/yourusername/sql-mongo-converter.git 78 | cd sql-mongo-converter 79 | pip install -r requirements.txt 80 | python setup.py install 81 | ``` 82 | 83 | --- 84 | 85 | ## Usage 86 | 87 | ### Converting SQL to MongoDB 88 | 89 | Use the `sql_to_mongo` function to convert a SQL SELECT query into a MongoDB query dictionary. The output dictionary contains: 90 | - **collection:** The table name. 91 | - **find:** The filter dictionary derived from the WHERE clause. 92 | - **projection:** The columns to return (if not all). 93 | 94 | #### Example 95 | 96 | ```python 97 | from sql_mongo_converter import sql_to_mongo 98 | 99 | sql_query = "SELECT name, age FROM users WHERE age > 30 AND name = 'Alice';" 100 | mongo_query = sql_to_mongo(sql_query) 101 | print(mongo_query) 102 | # Expected output: 103 | # { 104 | # "collection": "users", 105 | # "find": { "age": {"$gt": 30}, "name": "Alice" }, 106 | # "projection": { "name": 1, "age": 1 } 107 | # } 108 | ``` 109 | 110 | ### Converting MongoDB to SQL 111 | 112 | Use the `mongo_to_sql` function to convert a MongoDB query dictionary into a SQL SELECT statement. It supports operators such as `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, and `$regex`, as well as logical operators like `$and` and `$or`. 113 | 114 | #### Example 115 | 116 | ```python 117 | from sql_mongo_converter import mongo_to_sql 118 | 119 | mongo_obj = { 120 | "collection": "users", 121 | "find": { 122 | "$or": [ 123 | {"age": {"$gte": 25}}, 124 | {"status": "ACTIVE"} 125 | ], 126 | "tags": {"$in": ["dev", "qa"]} 127 | }, 128 | "projection": {"age": 1, "status": 1, "tags": 1}, 129 | "sort": [("age", 1), ("name", -1)], 130 | "limit": 10, 131 | "skip": 5 132 | } 133 | sql_query = mongo_to_sql(mongo_obj) 134 | print(sql_query) 135 | # Example output: 136 | # SELECT age, status, tags FROM users WHERE ((age >= 25) OR (status = 'ACTIVE')) AND (tags IN ('dev', 'qa')) 137 | # ORDER BY age ASC, name DESC LIMIT 10 OFFSET 5; 138 | ``` 139 | 140 | --- 141 | 142 | ## API Reference 143 | 144 | ### `sql_to_mongo(sql_query: str) -> dict` 145 | - **Description:** 146 | Parses a SQL SELECT query and converts it into a MongoDB query dictionary. 147 | - **Parameters:** 148 | - `sql_query`: A valid SQL SELECT query string. 149 | - **Returns:** 150 | A dictionary containing: 151 | - `collection`: The table name. 152 | - `find`: The filter derived from the WHERE clause. 153 | - `projection`: A dictionary specifying the columns to return. 154 | 155 | ### `mongo_to_sql(mongo_obj: dict) -> str` 156 | - **Description:** 157 | Converts a MongoDB query dictionary into a SQL SELECT statement. 158 | - **Parameters:** 159 | - `mongo_obj`: A dictionary representing a MongoDB find query, including keys such as `collection`, `find`, `projection`, `sort`, `limit`, and `skip`. 160 | - **Returns:** 161 | A SQL SELECT statement as a string. 162 | 163 | --- 164 | 165 | ## Testing 166 | 167 | The package includes a unittest suite to verify conversion functionality. 168 | 169 | ### Running Tests 170 | 171 | 1. **Create a virtual environment (optional but recommended):** 172 | 173 | ```bash 174 | python -m venv venv 175 | source venv/bin/activate # On Windows: venv\Scripts\activate 176 | ``` 177 | 178 | 2. **Install test dependencies:** 179 | 180 | ```bash 181 | pip install -r requirements.txt 182 | pip install pytest 183 | ``` 184 | 185 | 3. **Run tests:** 186 | 187 | ```bash 188 | python -m unittest discover tests 189 | # or using pytest: 190 | pytest --maxfail=1 --disable-warnings -q 191 | ``` 192 | 193 | ### Demo Script 194 | 195 | A demo script in the `tests` directory is provided to showcase the conversion capabilities. It can be run directly to see examples of SQL to MongoDB and MongoDB to SQL conversions. 196 | 197 | ```bash 198 | python demo.py 199 | ``` 200 | 201 | The script demonstrates various conversion scenarios. 202 | 203 | --- 204 | 205 | ## Building & Publishing 206 | 207 | ### Building the Package 208 | 209 | 1. **Ensure you have setuptools and wheel installed:** 210 | 211 | ```bash 212 | pip install setuptools wheel 213 | ``` 214 | 215 | 2. **Build the package:** 216 | 217 | ```bash 218 | python setup.py sdist bdist_wheel 219 | ``` 220 | 221 | This creates a `dist/` folder with the distribution files. 222 | 223 | ### Publishing to PyPI 224 | 225 | 1. **Install Twine:** 226 | 227 | ```bash 228 | pip install twine 229 | ``` 230 | 231 | 2. **Upload your package:** 232 | 233 | ```bash 234 | twine upload dist/* 235 | ``` 236 | 237 | 3. **Follow the prompts** for your PyPI credentials. 238 | 239 | --- 240 | 241 | ## Contributing 242 | 243 | Contributions are welcome! To contribute: 244 | 245 | 1. **Fork the Repository** 246 | 2. **Create a Feature Branch:** 247 | 248 | ```bash 249 | git checkout -b feature/my-new-feature 250 | ``` 251 | 252 | 3. **Commit Your Changes:** 253 | 254 | ```bash 255 | git commit -am "Add new feature or fix bug" 256 | ``` 257 | 258 | 4. **Push Your Branch:** 259 | 260 | ```bash 261 | git push origin feature/my-new-feature 262 | ``` 263 | 264 | 5. **Submit a Pull Request** on GitHub. 265 | 266 | For major changes, please open an issue first to discuss your ideas. 267 | 268 | --- 269 | 270 | ## License 271 | 272 | This project is licensed under the [MIT License](LICENSE). 273 | 274 | --- 275 | 276 | ## Final Remarks 277 | 278 | **SQL-Mongo Converter** is a powerful, lightweight tool that bridges SQL and MongoDB query languages. It is ideal for developers migrating between SQL and MongoDB data models, or those who want to prototype and test queries quickly. Extend and customize the converter as needed to support more advanced queries or additional SQL constructs. 279 | 280 | Happy converting! 🍃 281 | -------------------------------------------------------------------------------- /sql_mongo_converter.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | LICENSE 2 | README.md 3 | pyproject.toml 4 | setup.py 5 | sql_mongo_converter/__init__.py 6 | sql_mongo_converter/converter.py 7 | sql_mongo_converter/mongo_to_sql.py 8 | sql_mongo_converter/sql_to_mongo.py 9 | sql_mongo_converter.egg-info/PKG-INFO 10 | sql_mongo_converter.egg-info/SOURCES.txt 11 | sql_mongo_converter.egg-info/dependency_links.txt 12 | sql_mongo_converter.egg-info/requires.txt 13 | sql_mongo_converter.egg-info/top_level.txt 14 | tests/test_converter.py -------------------------------------------------------------------------------- /sql_mongo_converter.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /sql_mongo_converter.egg-info/requires.txt: -------------------------------------------------------------------------------- 1 | sqlparse 2 | -------------------------------------------------------------------------------- /sql_mongo_converter.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | sql_mongo_converter 2 | -------------------------------------------------------------------------------- /sql_mongo_converter/__init__.py: -------------------------------------------------------------------------------- 1 | from .converter import sql_to_mongo, mongo_to_sql 2 | 3 | __all__ = ["sql_to_mongo", "mongo_to_sql"] 4 | -------------------------------------------------------------------------------- /sql_mongo_converter/converter.py: -------------------------------------------------------------------------------- 1 | from .sql_to_mongo import sql_select_to_mongo 2 | from .mongo_to_sql import mongo_find_to_sql 3 | 4 | 5 | def sql_to_mongo(sql_query: str): 6 | """ 7 | Converts a SQL SELECT query to a naive MongoDB find dict. 8 | 9 | :param sql_query: The SQL SELECT query as a string. 10 | :return: A naive MongoDB find dict. 11 | """ 12 | return sql_select_to_mongo(sql_query) 13 | 14 | 15 | def mongo_to_sql(mongo_obj: dict): 16 | """ 17 | Converts a naive Mongo 'find' dict to a basic SQL SELECT. 18 | 19 | :param mongo_obj: The MongoDB find dict. 20 | :return: The SQL SELECT query as a string. 21 | """ 22 | return mongo_find_to_sql(mongo_obj) 23 | -------------------------------------------------------------------------------- /sql_mongo_converter/mongo_to_sql.py: -------------------------------------------------------------------------------- 1 | def mongo_find_to_sql(mongo_obj: dict) -> str: 2 | """ 3 | Convert a Mongo find dictionary into an extended SQL SELECT query. 4 | 5 | Example input: 6 | { 7 | "collection": "users", 8 | "find": { 9 | "$or": [ 10 | {"age": {"$gte": 25}}, 11 | {"status": "ACTIVE"} 12 | ] 13 | }, 14 | "projection": {"age": 1, "status": 1}, 15 | "sort": [("age", 1), ("name", -1)], 16 | "limit": 10, 17 | "skip": 5 18 | } 19 | 20 | => Output: 21 | SELECT age, status FROM users 22 | WHERE ((age >= 25) OR (status = 'ACTIVE')) 23 | ORDER BY age ASC, name DESC 24 | LIMIT 10 OFFSET 5; 25 | 26 | :param mongo_obj: The MongoDB find dict. 27 | :return: The SQL SELECT query as a string. 28 | """ 29 | table = mongo_obj.get("collection", "unknown_table") 30 | find_filter = mongo_obj.get("find", {}) 31 | projection = mongo_obj.get("projection", {}) 32 | sort_clause = mongo_obj.get("sort", []) # e.g. [("field", 1), ("other", -1)] 33 | limit_val = mongo_obj.get("limit", None) 34 | skip_val = mongo_obj.get("skip", None) 35 | 36 | # 1) Build the column list from projection 37 | columns = "*" 38 | if isinstance(projection, dict) and len(projection) > 0: 39 | # e.g. { "age":1, "status":1 } 40 | col_list = [] 41 | for field, include in projection.items(): 42 | if include == 1: 43 | col_list.append(field) 44 | if col_list: 45 | columns = ", ".join(col_list) 46 | 47 | # 2) Build WHERE from find_filter 48 | where_sql = build_where_sql(find_filter) 49 | 50 | # 3) Build ORDER BY from sort 51 | order_sql = build_order_by_sql(sort_clause) 52 | 53 | # 4) Combine everything 54 | sql = f"SELECT {columns} FROM {table}" 55 | 56 | if where_sql: 57 | sql += f" WHERE {where_sql}" 58 | 59 | if order_sql: 60 | sql += f" ORDER BY {order_sql}" 61 | 62 | # 5) Limit + Skip 63 | # skip in Mongo ~ "OFFSET" in SQL 64 | if isinstance(limit_val, int) and limit_val > 0: 65 | sql += f" LIMIT {limit_val}" 66 | if isinstance(skip_val, int) and skip_val > 0: 67 | sql += f" OFFSET {skip_val}" 68 | else: 69 | # If no limit but skip is provided, you can handle or ignore 70 | if isinstance(skip_val, int) and skip_val > 0: 71 | # Some SQL dialects allow "OFFSET" without a limit, others do not 72 | sql += f" LIMIT 999999999 OFFSET {skip_val}" 73 | 74 | sql += ";" 75 | return sql 76 | 77 | 78 | def build_where_sql(find_filter) -> str: 79 | """ 80 | Convert a 'find' dict into a SQL condition string. 81 | Supports: 82 | - direct equality: {field: value} 83 | - comparison operators: {field: {"$gt": val, ...}} 84 | - $in / $nin 85 | - $regex => LIKE 86 | - $and / $or => combine subclauses 87 | 88 | :param find_filter: The 'find' dict from MongoDB. 89 | :return: The SQL WHERE clause as a string. 90 | """ 91 | if not find_filter: 92 | return "" 93 | 94 | # If top-level is a dictionary with $and / $or 95 | if isinstance(find_filter, dict): 96 | # check for $and / $or in the top-level 97 | if "$and" in find_filter: 98 | conditions = [build_where_sql(sub) for sub in find_filter["$and"]] 99 | # e.g. (cond1) AND (cond2) 100 | return "(" + ") AND (".join(cond for cond in conditions if cond) + ")" 101 | elif "$or" in find_filter: 102 | conditions = [build_where_sql(sub) for sub in find_filter["$or"]] 103 | return "(" + ") OR (".join(cond for cond in conditions if cond) + ")" 104 | else: 105 | # parse normal fields 106 | return build_basic_conditions(find_filter) 107 | 108 | # If top-level is a list => not typical, handle or skip 109 | if isinstance(find_filter, list): 110 | # e.g. $or array 111 | # but typically you'd see it as { "$or": [ {}, {} ] } 112 | subclauses = [build_where_sql(sub) for sub in find_filter] 113 | return "(" + ") AND (".join(sc for sc in subclauses if sc) + ")" 114 | 115 | # fallback: if it's a scalar or something unexpected 116 | return "" 117 | 118 | 119 | def build_basic_conditions(condition_dict: dict) -> str: 120 | """ 121 | For each field in condition_dict: 122 | if it's a direct scalar => field = value 123 | if it's an operator dict => interpret $gt, $in, etc. 124 | Return "field1 = val1 AND field2 >= val2" etc. combined. 125 | 126 | :param condition_dict: A dictionary of conditions. 127 | :return: A SQL condition string. 128 | """ 129 | clauses = [] 130 | for field, expr in condition_dict.items(): 131 | # e.g. field => "status", expr => "ACTIVE" 132 | if isinstance(expr, dict): 133 | # parse operator e.g. {"$gt": 30} 134 | for op, val in expr.items(): 135 | clause = convert_operator(field, op, val) 136 | if clause: 137 | clauses.append(clause) 138 | else: 139 | # direct equality 140 | if isinstance(expr, (int, float)): 141 | clauses.append(f"{field} = {expr}") 142 | else: 143 | clauses.append(f"{field} = '{escape_quotes(str(expr))}'") 144 | 145 | return " AND ".join(clauses) 146 | 147 | 148 | def convert_operator(field: str, op: str, val): 149 | """ 150 | Handle operators like $gt, $in, $regex, etc. 151 | 152 | :param field: The field name. 153 | :param op: The operator (e.g., "$gt", "$in"). 154 | """ 155 | # Convert val to string with quotes if needed 156 | if isinstance(val, (int, float)): 157 | val_str = str(val) 158 | elif isinstance(val, list): 159 | # handle lists for $in, $nin 160 | val_str = ", ".join(quote_if_needed(item) for item in val) 161 | else: 162 | # string or other 163 | val_str = f"'{escape_quotes(str(val))}'" 164 | 165 | op_map = { 166 | "$gt": ">", 167 | "$gte": ">=", 168 | "$lt": "<", 169 | "$lte": "<=", 170 | "$eq": "=", 171 | "$ne": "<>", 172 | "$regex": "LIKE" 173 | } 174 | 175 | if op in op_map: 176 | sql_op = op_map[op] 177 | # e.g. "field > 30" or "field LIKE '%abc%'" 178 | return f"{field} {sql_op} {val_str}" 179 | elif op == "$in": 180 | # e.g. field IN (1,2,3) 181 | return f"{field} IN ({val_str})" 182 | elif op == "$nin": 183 | return f"{field} NOT IN ({val_str})" 184 | else: 185 | # fallback 186 | return f"{field} /*unknown op {op}*/ {val_str}" 187 | 188 | 189 | def build_order_by_sql(sort_list): 190 | """ 191 | If we have "sort": [("age", 1), ("name", -1)], 192 | => "age ASC, name DESC" 193 | 194 | :param sort_list: List of tuples (field, direction) 195 | :return: SQL ORDER BY clause as a string. 196 | """ 197 | if not sort_list or not isinstance(sort_list, list): 198 | return "" 199 | order_parts = [] 200 | for field_dir in sort_list: 201 | if isinstance(field_dir, tuple) and len(field_dir) == 2: 202 | field, direction = field_dir 203 | dir_sql = "ASC" if direction == 1 else "DESC" 204 | order_parts.append(f"{field} {dir_sql}") 205 | return ", ".join(order_parts) 206 | 207 | 208 | def quote_if_needed(val): 209 | """ 210 | Return a numeric or quoted string 211 | 212 | :param val: The value to quote if it's a string. 213 | :return: The value as a string, quoted if it's a string. 214 | """ 215 | if isinstance(val, (int, float)): 216 | return str(val) 217 | return f"'{escape_quotes(str(val))}'" 218 | 219 | 220 | def escape_quotes(s: str) -> str: 221 | """ 222 | Simple approach to escape single quotes 223 | 224 | :param s: The string to escape. 225 | :return: The escaped string. 226 | """ 227 | return s.replace("'", "''") 228 | -------------------------------------------------------------------------------- /sql_mongo_converter/sql_to_mongo.py: -------------------------------------------------------------------------------- 1 | import sqlparse 2 | from sqlparse.sql import ( 3 | IdentifierList, 4 | Identifier, 5 | Where, 6 | Token, 7 | Parenthesis, 8 | ) 9 | from sqlparse.tokens import Keyword, DML 10 | 11 | 12 | def sql_select_to_mongo(sql_query: str): 13 | """ 14 | Convert a SELECT...FROM...WHERE...ORDER BY...GROUP BY...LIMIT... 15 | into a Mongo dict: 16 | 17 | { 18 | "collection":
, 19 | "find": { ...where... }, 20 | "projection": { col1:1, col2:1 } or None, 21 | "sort": [...], 22 | "limit": int, 23 | "group": { ... } 24 | } 25 | 26 | :param sql_query: The SQL SELECT query as a string. 27 | :return: A naive MongoDB find dict. 28 | """ 29 | parsed = sqlparse.parse(sql_query) 30 | if not parsed: 31 | return {} 32 | 33 | statement = parsed[0] 34 | columns, table_name, where_clause, order_by, group_by, limit_val = parse_select_statement(statement) 35 | 36 | return build_mongo_query( 37 | table_name, columns, where_clause, order_by, group_by, limit_val 38 | ) 39 | 40 | 41 | def parse_select_statement(statement): 42 | """ 43 | Parse: 44 | SELECT FROM
45 | [WHERE ...] 46 | [GROUP BY ...] 47 | [ORDER BY ...] 48 | [LIMIT ...] 49 | in that approximate order. 50 | 51 | Returns: 52 | columns, table_name, where_clause_dict, order_by_list, group_by_list, limit_val 53 | 54 | :param statement: The parsed SQL statement. 55 | :return: A tuple containing columns, table_name, where_clause_dict, order_by_list, group_by_list, limit_val 56 | """ 57 | columns = [] 58 | table_name = None 59 | where_clause = {} 60 | order_by = [] # e.g. [("age", 1), ("name", -1)] 61 | group_by = [] # e.g. ["department", "role"] 62 | limit_val = None 63 | 64 | found_select = False 65 | reading_columns = False 66 | reading_from = False 67 | 68 | tokens = [t for t in statement.tokens if not t.is_whitespace] 69 | 70 | # We'll do multiple passes or a single pass with states 71 | # Single pass approach: 72 | i = 0 73 | while i < len(tokens): 74 | token = tokens[i] 75 | 76 | # detect SELECT 77 | if token.ttype is DML and token.value.upper() == "SELECT": 78 | found_select = True 79 | reading_columns = True 80 | i += 1 81 | continue 82 | 83 | # parse columns until we see FROM 84 | if reading_columns: 85 | if token.ttype is Keyword and token.value.upper() == "FROM": 86 | reading_columns = False 87 | reading_from = True 88 | i += 1 89 | continue 90 | else: 91 | possible_cols = extract_columns(token) 92 | if possible_cols: 93 | columns = possible_cols 94 | i += 1 95 | continue 96 | 97 | # parse table name right after FROM 98 | if reading_from: 99 | # if token is Keyword (like WHERE, GROUP, ORDER), we skip 100 | if token.ttype is Keyword: 101 | # no table name found => might be incomplete 102 | reading_from = False 103 | # don't advance i, we'll handle logic below 104 | else: 105 | # assume table name 106 | table_name = str(token).strip() 107 | reading_from = False 108 | i += 1 109 | continue 110 | 111 | # check if token is a Where object => parse WHERE 112 | if isinstance(token, Where): 113 | where_clause = extract_where_clause(token) 114 | i += 1 115 | continue 116 | 117 | # or check if token is a simple 'WHERE' keyword 118 | if token.ttype is Keyword and token.value.upper() == "WHERE": 119 | # next token might be the actual conditions or a Where 120 | # try to gather the text 121 | # but often sqlparse lumps everything into a Where 122 | if i + 1 < len(tokens): 123 | next_token = tokens[i + 1] 124 | if isinstance(next_token, Where): 125 | where_clause = extract_where_clause(next_token) 126 | i += 2 127 | continue 128 | else: 129 | # fallback substring approach if needed 130 | where_clause_text = str(next_token).strip() 131 | where_clause = parse_where_conditions(where_clause_text) 132 | i += 2 133 | continue 134 | i += 1 135 | continue 136 | 137 | # handle ORDER BY 138 | if token.ttype is Keyword and token.value.upper() == "ORDER": 139 | # next token should be BY 140 | i += 1 141 | if i < len(tokens): 142 | nxt = tokens[i] 143 | if nxt.ttype is Keyword and nxt.value.upper() == "BY": 144 | i += 1 145 | # parse the next token as columns 146 | if i < len(tokens): 147 | order_by = parse_order_by(tokens[i]) 148 | i += 1 149 | continue 150 | else: 151 | i += 1 152 | continue 153 | 154 | # handle GROUP BY 155 | if token.ttype is Keyword and token.value.upper() == "GROUP": 156 | # next token should be BY 157 | i += 1 158 | if i < len(tokens): 159 | nxt = tokens[i] 160 | if nxt.ttype is Keyword and nxt.value.upper() == "BY": 161 | i += 1 162 | # parse group by columns 163 | if i < len(tokens): 164 | group_by = parse_group_by(tokens[i]) 165 | i += 1 166 | continue 167 | else: 168 | i += 1 169 | continue 170 | 171 | # handle LIMIT 172 | if token.ttype is Keyword and token.value.upper() == "LIMIT": 173 | # next token might be the limit number 174 | if i + 1 < len(tokens): 175 | limit_val = parse_limit_value(tokens[i + 1]) 176 | i += 2 177 | continue 178 | 179 | i += 1 180 | 181 | return columns, table_name, where_clause, order_by, group_by, limit_val 182 | 183 | 184 | def extract_columns(token): 185 | """ 186 | If token is an IdentifierList => multiple columns 187 | If token is an Identifier => single column 188 | If token is '*' => wildcard 189 | 190 | Return a list of columns. 191 | If no columns found, return an empty list. 192 | 193 | :param token: The SQL token to extract columns from. 194 | :return: A list of columns. 195 | """ 196 | from sqlparse.sql import IdentifierList, Identifier 197 | if isinstance(token, IdentifierList): 198 | return [str(ident).strip() for ident in token.get_identifiers()] 199 | elif isinstance(token, Identifier): 200 | return [str(token).strip()] 201 | else: 202 | raw = str(token).strip() 203 | raw = raw.replace(" ", "") 204 | if not raw: 205 | return [] 206 | return [raw] 207 | 208 | 209 | def extract_where_clause(where_token): 210 | """ 211 | If where_token is a Where object => parse out 'WHERE' prefix, then parse conditions 212 | If where_token is a simple 'WHERE' keyword => parse conditions directly 213 | 214 | Return a dict of conditions. 215 | 216 | :param where_token: The SQL token to extract the WHERE clause from. 217 | :return: A dict of conditions. 218 | """ 219 | raw = str(where_token).strip() 220 | if raw.upper().startswith("WHERE"): 221 | raw = raw[5:].strip() 222 | return parse_where_conditions(raw) 223 | 224 | 225 | def parse_where_conditions(text: str): 226 | """ 227 | e.g. "age > 30 AND name = 'Alice'" 228 | => { "age":{"$gt":30}, "name":"Alice" } 229 | We'll strip trailing semicolon as well. 230 | 231 | Supports: 232 | - direct equality: {field: value} 233 | - inequality: {field: {"$gt": value}} 234 | - other operators: {field: {"$op?": value}} 235 | 236 | :param text: The WHERE clause text. 237 | :return: A dict of conditions. 238 | """ 239 | text = text.strip().rstrip(";") 240 | if not text: 241 | return {} 242 | 243 | # naive split on " AND " 244 | parts = text.split(" AND ") 245 | out = {} 246 | for part in parts: 247 | tokens = part.split(None, 2) # e.g. ["age", ">", "30"] 248 | if len(tokens) < 3: 249 | continue 250 | field, op, val = tokens[0], tokens[1], tokens[2] 251 | val = val.strip().rstrip(";").strip("'").strip('"') 252 | if op == "=": 253 | out[field] = val 254 | elif op == ">": 255 | out[field] = {"$gt": convert_value(val)} 256 | elif op == "<": 257 | out[field] = {"$lt": convert_value(val)} 258 | elif op == ">=": 259 | out[field] = {"$gte": convert_value(val)} 260 | elif op == "<=": 261 | out[field] = {"$lte": convert_value(val)} 262 | else: 263 | out[field] = {"$op?": val} 264 | return out 265 | 266 | 267 | def parse_order_by(token): 268 | """ 269 | e.g. "age ASC, name DESC" 270 | Return [("age",1), ("name",-1)] 271 | 272 | :param token: The SQL token to extract the ORDER BY clause from. 273 | :return: A list of tuples (field, direction). 274 | """ 275 | raw = str(token).strip().rstrip(";") 276 | if not raw: 277 | return [] 278 | # might be multiple columns 279 | parts = raw.split(",") 280 | order_list = [] 281 | for part in parts: 282 | sub = part.strip().split() 283 | if len(sub) == 1: 284 | # e.g. "age" 285 | order_list.append((sub[0], 1)) # default ASC 286 | elif len(sub) == 2: 287 | # e.g. "age ASC" or "name DESC" 288 | field, direction = sub[0], sub[1].upper() 289 | if direction == "ASC": 290 | order_list.append((field, 1)) 291 | elif direction == "DESC": 292 | order_list.append((field, -1)) 293 | else: 294 | order_list.append((field, 1)) # fallback 295 | else: 296 | # fallback 297 | order_list.append((part.strip(), 1)) 298 | return order_list 299 | 300 | 301 | def parse_group_by(token): 302 | """ 303 | e.g. "department, role" 304 | => ["department", "role"] 305 | 306 | :param token: The SQL token to extract the GROUP BY clause from. 307 | :return: A list of columns. 308 | """ 309 | raw = str(token).strip().rstrip(";") 310 | if not raw: 311 | return [] 312 | return [x.strip() for x in raw.split(",")] 313 | 314 | 315 | def parse_limit_value(token): 316 | """ 317 | e.g. "100" 318 | => 100 (int) 319 | 320 | :param token: The SQL token to extract the LIMIT value from. 321 | :return: The LIMIT value as an integer, or None if not a valid integer. 322 | """ 323 | raw = str(token).strip().rstrip(";") 324 | try: 325 | return int(raw) 326 | except ValueError: 327 | return None 328 | 329 | 330 | def convert_value(val: str): 331 | """ 332 | Convert a value to an int, float, or string. 333 | 334 | :param val: The value to convert. 335 | :return: The value as an int, float, or string. 336 | """ 337 | try: 338 | return int(val) 339 | except ValueError: 340 | try: 341 | return float(val) 342 | except ValueError: 343 | return val 344 | 345 | 346 | def build_mongo_find(table_name, where_clause, columns): 347 | """ 348 | Build a MongoDB find query. 349 | 350 | :param table_name: The name of the collection. 351 | :param where_clause: The WHERE clause as a dict. 352 | :param columns: The list of columns to select. 353 | :return: A dict representing the MongoDB find query. 354 | """ 355 | filter_query = where_clause or {} 356 | projection = {} 357 | if columns and "*" not in columns: 358 | for col in columns: 359 | projection[col] = 1 360 | return { 361 | "collection": table_name, 362 | "find": filter_query, 363 | "projection": projection if projection else None 364 | } 365 | 366 | 367 | def build_mongo_query(table_name, columns, where_clause, order_by, group_by, limit_val): 368 | """ 369 | Build a MongoDB query object from parsed SQL components. 370 | 371 | We'll store everything in a single dict: 372 | { 373 | "collection": table_name, 374 | "find": {...}, 375 | "projection": {...}, 376 | "sort": [("col",1),("col2",-1)], 377 | "limit": int or None, 378 | "group": {...} 379 | } 380 | 381 | :param table_name: The name of the collection. 382 | :param columns: The list of columns to select. 383 | """ 384 | query_obj = build_mongo_find(table_name, where_clause, columns) 385 | 386 | # Add sort 387 | if order_by: 388 | query_obj["sort"] = order_by 389 | 390 | # Add limit 391 | if limit_val is not None: 392 | query_obj["limit"] = limit_val 393 | 394 | # If group_by is used: 395 | if group_by: 396 | # e.g. group_by = ["department","role"] 397 | # We'll store a $group pipeline 398 | # Real logic depends on what columns are selected 399 | group_pipeline = { 400 | "$group": { 401 | "_id": {}, 402 | "count": {"$sum": 1} 403 | } 404 | } 405 | # e.g. _id => { department: "$department", role: "$role" } 406 | _id_obj = {} 407 | for gb in group_by: 408 | _id_obj[gb] = f"${gb}" 409 | group_pipeline["$group"]["_id"] = _id_obj 410 | query_obj["group"] = group_pipeline 411 | return query_obj 412 | -------------------------------------------------------------------------------- /tests/demo.py: -------------------------------------------------------------------------------- 1 | from sql_to_mongo import sql_select_to_mongo 2 | 3 | sql_query = """ 4 | SELECT name, age 5 | FROM employees 6 | WHERE age >= 25 AND department = 'Sales' 7 | GROUP BY department 8 | ORDER BY age DESC, name ASC 9 | LIMIT 100; 10 | """ 11 | 12 | mongo_obj = sql_select_to_mongo(sql_query) 13 | print(mongo_obj) 14 | 15 | # Should Output: 16 | # { 17 | # 'collection': 'employees', 18 | # 'find': { 19 | # 'age': {'$gte': 25}, 20 | # 'department': 'Sales' 21 | # }, 22 | # 'projection': {'name': 1, 'age': 1}, 23 | # 'sort': [('age', -1), ('name', 1)], 24 | # 'limit': 100, 25 | # 'group': { 26 | # '$group': { 27 | # '_id': { 'department': '$department' }, 28 | # 'count': { '$sum': 1 } 29 | # } 30 | # } 31 | # } 32 | -------------------------------------------------------------------------------- /tests/test_converter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from sql_mongo_converter import sql_to_mongo, mongo_to_sql 3 | 4 | 5 | class TestConverter(unittest.TestCase): 6 | """ 7 | Unit tests for the SQL to MongoDB and MongoDB to SQL conversion functions. 8 | """ 9 | 10 | def test_sql_to_mongo_basic(self): 11 | """ 12 | Test basic SQL to MongoDB conversion. 13 | 14 | :return: None 15 | """ 16 | sql = "SELECT name, age FROM users WHERE age > 30 AND name = 'Alice';" 17 | result = sql_to_mongo(sql) 18 | expected_filter = { 19 | "age": {"$gt": 30}, 20 | "name": "Alice" 21 | } 22 | self.assertEqual(result["collection"], "users") 23 | self.assertEqual(result["find"], expected_filter) 24 | self.assertEqual(result["projection"], {"name": 1, "age": 1}) 25 | 26 | def test_mongo_to_sql_basic(self): 27 | """ 28 | Test basic MongoDB to SQL conversion. 29 | 30 | :return: None 31 | """ 32 | mongo_obj = { 33 | "collection": "users", 34 | "find": { 35 | "age": {"$gte": 25}, 36 | "status": "ACTIVE" 37 | }, 38 | "projection": {"age": 1, "status": 1} 39 | } 40 | sql = mongo_to_sql(mongo_obj) 41 | # e.g. SELECT age, status FROM users WHERE age >= 25 AND status = 'ACTIVE'; 42 | self.assertIn("SELECT age, status FROM users WHERE age >= 25 AND status = 'ACTIVE';", sql) 43 | 44 | 45 | if __name__ == "__main__": 46 | unittest.main() 47 | 48 | # Should output: 49 | # Testing started at 18:02 ... 50 | # Launching unittests with arguments python -m unittest /Users/davidnguyen/PycharmProjects/SQL-Mongo-Queries-Converter/tests/test_converter.py in /Users/davidnguyen/PycharmProjects/SQL-Mongo-Queries-Converter/tests 51 | # 52 | # 53 | # Ran 2 tests in 0.004s 54 | # 55 | # OK 56 | # 57 | # Process finished with exit code 0 58 | --------------------------------------------------------------------------------