├── .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 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
103 |
104 |
105 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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)
4 | [](https://www.python.org/)
5 | [](https://www.postgresql.org/)
6 | [](https://www.mongodb.com/)
7 | [](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)
19 | [](https://www.python.org/)
20 | [](https://www.postgresql.org/)
21 | [](https://www.mongodb.com/)
22 | [](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 |
--------------------------------------------------------------------------------