Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python-version }}

Expand All @@ -36,3 +36,18 @@ jobs:
- name: Run tests
run: |
python -m pytest tests/ -v --cov=src --cov-report=term-missing

js-wrapper:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Set up Node
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
with:
node-version: "20"

- name: Test the npm wrapper
run: node --test tests/*.test.js
31 changes: 28 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,35 @@
This repo converts JSON to SQL. Primary implementation is under `src/json2sql/`. CLI entrypoints live in `cli.py` and `__main__.py`.

## Commands
- Install/dev: `pip install -e .`
- Tests: `pytest`
- Lint/type checks (if configured): use tooling in `pyproject.toml`
- Install/dev: `pip install -e ".[dev]"`
- Tests: `pytest tests/ -v`
- Lint: `ruff check src/ tests/`
- Format: `ruff format src/ tests/`
- Type check: `mypy src/` (if configured)

## CI/CD
- **CI** (`.github/workflows/ci.yml`): Tests on Python 3.10-3.13 + ruff lint
- **Auto Code Review** (`.github/workflows/auto-code-review.yml`): Reusable org workflow
- **Pages** (`.github/workflows/pages.yml`): Deploy docs to GitHub Pages
- **Publish** (`.github/workflows/publish.yml`): PyPI publish on tags

## Agent workflow
1. `git checkout main && git pull origin main`
2. `git checkout -b improve/json2sql-<YYYYMMDD>`
3. Make changes (max 50 lines per run)
4. `ruff check src/ tests/ && ruff format src/ tests/`
5. `pytest tests/ -v` — ensure all tests pass
6. `git add -A && git commit -m "improve: <description>"`
7. `git push origin improve/json2sql-<YYYYMMDD>`
8. `gh pr create --title "improve: <description>" --body "Automated improvement by dev-engineer." --repo Coding-Dev-Tools/json2sql`

## Do not break
- Do not remove or weaken existing tests.
- Keep public CLI behavior stable unless an issue explicitly requests changing it.
- Do not change the `convert()` function signature or return type.
- Keep lazy imports inside `convert()` to preserve cold-start optimization.

## Business context
- Part of **Coding-Dev-Tools** under **Revenue Holdings**
- Revenue Holdings north star: "Generate revenue through CLI tools, SaaS products, and automated operations."
- This is a Tier 2 repo (developer tool / CLI utility)
16 changes: 13 additions & 3 deletions src/json2sql/dialects.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,20 @@ def sql_type_for(value: Any, dialect: Dialect) -> str:


def quote_identifier(name: str, dialect: Dialect) -> str:
"""Quote an identifier (table/column name) for the given dialect."""
"""Quote an identifier (table/column name) for the given dialect.

Embedded quote characters are escaped by doubling — the standard SQL rule
for quoted identifiers — mirroring how ``format_value`` escapes string
literals. Without this, a table/column name (which comes straight from
untrusted JSON object keys) containing a ``"`` (Postgres/SQLite) or a
backtick (MySQL) could break out of the quoted identifier and inject
arbitrary SQL into the generated statement.
"""
if dialect == Dialect.MYSQL:
return f"`{name}`"
return f'"{name}"'
escaped = name.replace("`", "``")
return f"`{escaped}`"
escaped = name.replace('"', '""')
return f'"{escaped}"'


def format_value(value: Any, dialect: Dialect) -> str:
Expand Down
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for json2sql."""
33 changes: 25 additions & 8 deletions tests/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,7 @@ def test_flatten_nested_array_column_value_count(self):
if cols and vals:
c_count = len([c for c in cols.group(1).split(",") if c.strip()])
v_count = len([v for v in vals.group(1).split(",") if v.strip()])
assert c_count == v_count, (
f"Column count ({c_count}) != value count ({v_count})"
)
assert c_count == v_count, f"Column count ({c_count}) != value count ({v_count})"

def test_flatten_mixed_nested_array_and_object_count(self):
"""
Expand All @@ -201,9 +199,7 @@ def test_flatten_mixed_nested_array_and_object_count(self):
if cols and vals:
c_count = len([c for c in cols.group(1).split(",") if c.strip()])
v_count = len([v for v in vals.group(1).split(",") if v.strip()])
assert c_count == v_count, (
f"Column count ({c_count}) != value count ({v_count})"
)
assert c_count == v_count, f"Column count ({c_count}) != value count ({v_count})"


class TestFlattenDetail:
Expand Down Expand Up @@ -401,6 +397,28 @@ def test_string_with_quotes(self, converter_postgres):
result = converter_postgres.convert(data, table_name="profiles")
assert "It''s a test" in result # SQL-escaped single quote

def test_key_with_embedded_double_quote_postgres(self, converter_postgres):
# A JSON key containing " must be escaped in the generated identifier
# to prevent SQL injection through untrusted object keys.
data = json.dumps({'col"evil': 1})
result = converter_postgres.convert(data, table_name="profiles")
assert '"col""evil"' in result
# Ensure the dangerous unescaped form is NOT present
assert '"col"evil"' not in result

def test_key_with_embedded_double_quote_sqlite(self, converter_sqlite):
data = json.dumps({'col"evil': 1})
result = converter_sqlite.convert(data, table_name="profiles")
assert '"col""evil"' in result
assert '"col"evil"' not in result

def test_key_with_embedded_backtick_mysql(self, converter_mysql):
# A JSON key containing a backtick must be escaped in MySQL identifiers.
data = json.dumps({"col`evil": 1})
result = converter_mysql.convert(data, table_name="profiles")
assert "`col``evil`" in result
assert "`col`evil`" not in result

def test_float_values(self, converter_postgres):
data = json.dumps({"price": 19.99})
result = converter_postgres.convert(data, table_name="products")
Expand Down Expand Up @@ -515,6 +533,5 @@ def test_version_in_init_matches_pyproject(self):
with open(pyproject, "rb") as f:
data = tomllib.load(f)
assert data["project"]["version"] == __version__, (
f"pyproject.toml version ({data['project']['version']}) != "
f"__init__.__version__ ({__version__})"
f"pyproject.toml version ({data['project']['version']}) != __init__.__version__ ({__version__})"
)
29 changes: 22 additions & 7 deletions tests/test_dialects.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,9 @@ def test_all_members(self):
class TestSQLTypeFor:
"""Python type → SQL type mapping per dialect."""

@pytest.mark.parametrize(
"dialect", [Dialect.POSTGRES, Dialect.MYSQL, Dialect.SQLITE]
)
@pytest.mark.parametrize("dialect", [Dialect.POSTGRES, Dialect.MYSQL, Dialect.SQLITE])
def test_string_type(self, dialect):
assert (
"TEXT" in sql_type_for("hello", dialect).upper()
or "VARCHAR" in sql_type_for("hello", dialect).upper()
)
assert "TEXT" in sql_type_for("hello", dialect).upper() or "VARCHAR" in sql_type_for("hello", dialect).upper()

def test_postgres_int(self):
assert sql_type_for(42, Dialect.POSTGRES) == "INTEGER"
Expand Down Expand Up @@ -115,6 +110,26 @@ def test_empty_name(self):
result = quote_identifier("", dialect)
assert len(result) >= 2

def test_embedded_quote_mysql(self):
"""MySQL: backtick embedded in identifier is escaped by doubling."""
assert quote_identifier("my`table", Dialect.MYSQL) == "`my``table`"

def test_embedded_quote_postgres(self):
"""Postgres: double-quote embedded in identifier is escaped by doubling."""
assert quote_identifier('my"table', Dialect.POSTGRES) == '"my""table"'

def test_embedded_quote_sqlite(self):
"""SQLite: double-quote embedded in identifier is escaped by doubling."""
assert quote_identifier('my"table', Dialect.SQLITE) == '"my""table"'

def test_multiple_embedded_quotes_mysql(self):
"""MySQL: multiple backticks in identifier are all escaped."""
assert quote_identifier("a`b`c", Dialect.MYSQL) == "`a``b``c`"

def test_multiple_embedded_quotes_postgres(self):
"""Postgres: multiple double-quotes in identifier are all escaped."""
assert quote_identifier('a"b"c', Dialect.POSTGRES) == '"a""b""c"'


# --- format_value ---

Expand Down
Loading