Skip to content
Closed
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
50 changes: 50 additions & 0 deletions compiler/clientsql/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
load("@aspect_rules_js//js:defs.bzl", "js_binary")
load("@rules_python//python:defs.bzl", "py_test")

py_library(
name = "clientsql_lib",
srcs = glob(["src/clientsql/*.py"]),
imports = ["src"],
visibility = ["//visibility:private"],
)

py_binary(
name = "clientsql",
srcs = ["src/clientsql_main.py"],
main = "src/clientsql_main.py",
visibility = ["//visibility:public"],
deps = [":clientsql_lib"],
)

py_binary(
name = "package_clientsql",
srcs = ["package_clientsql.py"],
data = glob(["src/clientsql/*.py"]),
main = "package_clientsql.py",
visibility = ["//visibility:private"],
)

js_binary(
name = "clientsql_test_javascript_runner",
entry_point = "run_generated_javascript.js",
testonly = True,
)

py_test(
name = "test_clientsql",
srcs = ["test_clientsql.py"],
data = [
":clientsql",
":clientsql_test_javascript_runner",
":package_clientsql",
"@npm_typescript//:tsc",
],
env = {
"BAZEL_BINDIR": ".",
"CLIENTSQL_TEST_GENERATOR": "$(rootpath :clientsql)",
"CLIENTSQL_TEST_JAVASCRIPT_RUNNER": "$(rootpath :clientsql_test_javascript_runner)",
"CLIENTSQL_TEST_TYPESCRIPT_COMPILER": "$(rootpath @npm_typescript//:tsc)",
},
size = "medium",
deps = [":clientsql_lib"],
)
27 changes: 27 additions & 0 deletions compiler/clientsql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ClientSQL generator

This directory contains the canonical, reviewable Python source for Valdi's
SQLDelight-style ClientSQL generator.

The source is divided by responsibility:

- `cli.py` owns command-line parsing and generation orchestration.
- `model.py` defines the schema and query model.
- `sql.py` parses and validates SQL, migrations, parameters, and result shapes.
- `typescript.py` emits generated TypeScript bindings and database classes.

The public Valdi toolchain continues to supply its ClientSQL executable through
the existing `sqldelight_compiler` target. This source package intentionally
does not replace that toolchain binary or check in a generated executable. Use
the Bazel `//compiler/clientsql:clientsql` target for source builds, or create a
deterministic standalone zipapp at an explicit local path:

```bash
python3 compiler/clientsql/package_clientsql.py --output /tmp/clientsql
```

An explicitly supplied executable can be checked against the canonical source:

```bash
python3 compiler/clientsql/package_clientsql.py --output /tmp/clientsql --check
```
76 changes: 76 additions & 0 deletions compiler/clientsql/package_clientsql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env python3

from __future__ import annotations

import argparse
import os
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import Sequence


SOURCE_ROOT = Path(__file__).resolve().parent / "src"
FIXED_ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
ENTRYPOINT = "from clientsql.cli import entrypoint\n\nentrypoint()\n"


def write_zip_entry(archive: zipfile.ZipFile, name: str, content: bytes) -> None:
info = zipfile.ZipInfo(name, date_time=FIXED_ZIP_TIMESTAMP)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100644 << 16
archive.writestr(info, content, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)


def package_clientsql(output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=output.parent, prefix=f".{output.name}.", delete=False) as temporary:
temporary_path = Path(temporary.name)
temporary.write(b"#!/usr/bin/env python3\n")

try:
with zipfile.ZipFile(temporary_path, mode="a") as archive:
write_zip_entry(archive, "__main__.py", ENTRYPOINT.encode("utf-8"))
for source_path in sorted(SOURCE_ROOT.rglob("*.py")):
archive_path = source_path.relative_to(SOURCE_ROOT).as_posix()
write_zip_entry(archive, archive_path, source_path.read_bytes())
os.chmod(temporary_path, 0o755)
temporary_path.replace(output)
finally:
temporary_path.unlink(missing_ok=True)


def main(argv: Sequence[str]) -> int:
parser = argparse.ArgumentParser(description="Package the modular ClientSQL generator as one executable zipapp")
parser.add_argument(
"--output",
type=Path,
required=True,
help="Path for the generated executable zipapp",
)
parser.add_argument(
"--check",
action="store_true",
help="Verify that the supplied executable matches the canonical source",
)
args = parser.parse_args(argv)

if not args.check:
package_clientsql(args.output)
return 0

with tempfile.TemporaryDirectory(prefix="clientsql-package-check-") as temporary_directory:
candidate = Path(temporary_directory) / "clientsql"
package_clientsql(candidate)
if not args.output.is_file() or candidate.read_bytes() != args.output.read_bytes():
print(
f"{args.output} is stale; rerun this command without --check",
file=sys.stderr,
)
return 1
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
9 changes: 9 additions & 0 deletions compiler/clientsql/run_generated_javascript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use strict';

const path = require('node:path');

if (process.argv.length !== 3) {
throw new Error('Expected one generated JavaScript entrypoint');
}

require(path.resolve(process.argv[2]));
1 change: 1 addition & 0 deletions compiler/clientsql/src/clientsql/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Typed SQLite code generation for Valdi ClientSQL."""
4 changes: 4 additions & 0 deletions compiler/clientsql/src/clientsql/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .cli import entrypoint


entrypoint()
97 changes: 97 additions & 0 deletions compiler/clientsql/src/clientsql/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

import argparse
import sys
from pathlib import Path
from typing import Optional, Sequence

from .model import ClientSqlError
from .sql import (
collect_create_statements,
collect_migrations,
collect_tables,
load_type_mapping,
parse_sql_file,
sanitize_type_name,
validate_schema_and_queries,
)
from .typescript import write_database_file, write_queries_file, write_types_file


VERSION = "valdi-clientsql 0.2.0"


def main(argv: Sequence[str]) -> int:
if "-version" in argv or "--version" in argv:
print(VERSION)
return 0

parser = argparse.ArgumentParser(prog="clientsql")
parser.add_argument("-s", "--source", required=True, help="SQL source directory")
parser.add_argument("-p", "--package", required=True, help="Database package/name")
parser.add_argument("-c", "--class", dest="class_name", required=True, help="Database class name")
parser.add_argument("-m", "--module", required=True, help="Module name")
parser.add_argument("-o", "--output", required=True, help="Output directory")
parser.add_argument("-l", "--language", required=True, choices=["typescript"], help="Output language")
parser.add_argument("-tm", "--type-mapping", dest="type_mapping", help="Optional sql_types.yaml")
args = parser.parse_args(argv)

try:
generate(
sql_dir=Path(args.source),
package_name=args.package,
class_name=args.class_name,
output_dir=Path(args.output),
type_mapping=args.type_mapping,
)
except ClientSqlError as exc:
print(f"ClientSQL error: {exc}", file=sys.stderr)
return 1

return 0


def generate(
sql_dir: Path,
package_name: str,
class_name: str,
output_dir: Path,
type_mapping: Optional[str],
) -> None:
package_dir = sql_dir / package_name
if not package_dir.is_dir():
raise ClientSqlError(f"SQL package directory does not exist: {package_dir}")

output_dir.mkdir(parents=True, exist_ok=True)
custom_types = load_type_mapping(sql_dir, type_mapping)
sql_paths = sorted(package_dir.rglob("*.sq"))
if not sql_paths:
raise ClientSqlError(f"No .sq files found under {package_dir}")

sql_text_by_path = {path: path.read_text(encoding="utf-8") for path in sql_paths}
tables = collect_tables(sql_text_by_path.values(), custom_types)
sql_files = [
parse_sql_file(path, package_dir, tables)
for path in sql_paths
]

create_statements = collect_create_statements(sql_text_by_path.values())
migrations = collect_migrations(sql_dir)
validate_schema_and_queries(create_statements, sql_files)

for sql_file in sql_files:
write_types_file(output_dir, sql_file, tables)
write_queries_file(output_dir, sql_file)

write_database_file(
output_dir=output_dir,
class_name=sanitize_type_name(class_name),
db_name=package_name,
sql_files=sql_files,
create_statements=create_statements,
migrations=migrations,
)


def entrypoint() -> None:
sys.exit(main(sys.argv[1:]))
59 changes: 59 additions & 0 deletions compiler/clientsql/src/clientsql/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import List


@dataclass
class Column:
name: str
sql_type: str
ts_type: str
nullable: bool


@dataclass
class Table:
name: str
columns: List[Column]


@dataclass
class Parameter:
name: str
ts_type: str


@dataclass
class ParamOccurrence:
start: int
end: int
name: str
nullable: bool


@dataclass
class Query:
name: str
sql: str
runtime_sql: str
param_order: List[str]
params: List[Parameter]
result_type: str
result_fields: List[Column]
returns_rows: bool
read_tables: List[str]
changed_tables: List[str]


@dataclass
class SqlFile:
path: Path
rel_to_package: Path
stem_path: Path
queries: List[Query]


class ClientSqlError(Exception):
pass
Loading
Loading