├── README.md ├── .python-version ├── uv.lock ├── pyproject.toml ├── .github └── workflows │ └── release.yaml └── main.py /README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 3 3 | requires-python = ">=3.13" 4 | 5 | [[package]] 6 | name = "solite-codegen-python-sqlite3" 7 | version = "0.0.1a4" 8 | source = { virtual = "." } 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "solite-codegen-python-sqlite3" 3 | version = "0.0.1a4" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [] 8 | 9 | [project.scripts] 10 | solite-codegen-python-sqlite3 = "main:main" 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | on: 3 | release: 4 | types: [published] 5 | permissions: 6 | contents: read 7 | id-token: write 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | environment: release 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: astral-sh/setup-uv@v6 15 | - run: uv build 16 | - run: uv publish -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import sys 4 | import ast 5 | from pathlib import Path 6 | import hashlib 7 | from dataclasses import dataclass 8 | from typing import Literal 9 | 10 | def to_camel_case(snake_str): 11 | return "".join(x.capitalize() for x in snake_str.lower().split("_")) 12 | 13 | 14 | @dataclass 15 | class Parameter: 16 | full_name: str 17 | name: str 18 | annotated_type: str | None = None 19 | 20 | @dataclass 21 | class Column: 22 | name: str 23 | origin_database: str | None = None 24 | origin_table: str | None = None 25 | origin_column: str | None = None 26 | decltype: str | None = None 27 | 28 | @staticmethod 29 | def from_json(data): 30 | return Column( 31 | **data, 32 | ) 33 | 34 | @dataclass 35 | class Export: 36 | name: str 37 | columns: list[Column] 38 | parameters: list[Parameter] 39 | sql: str 40 | result_type: Literal['Void'] | Literal['Rows'] | Literal['Row'] | Literal['Value'] | Literal['List'] 41 | 42 | @staticmethod 43 | def from_json(data: dict): 44 | return Export( 45 | name=data['name'], 46 | columns=[Column(**column) for column in data["columns"]], 47 | parameters=[Parameter(**param) for param in data.get('parameters', [])], 48 | sql=data['sql'], 49 | result_type=data.get('result_type', 'Void') 50 | ) 51 | def return_type_classname(self) -> str | None: 52 | """Return the class name for the return type, if applicable.""" 53 | class_name = f"{to_camel_case(self.name)}Result" 54 | # Convert to CamelCase, 1st letter should be uppercase 55 | class_name = class_name[0].upper() + class_name[1:] 56 | return class_name 57 | 58 | 59 | @dataclass 60 | class Report: 61 | setup: list[str] 62 | exports: list[Export] 63 | 64 | @staticmethod 65 | def from_json(data: dict): 66 | return Report( 67 | setup=data.get('setup', []), 68 | exports=[Export.from_json(export) for export in data.get('exports', [])] 69 | ) 70 | 71 | def serialize_string(value: str) -> str: 72 | expr = ast.fix_missing_locations(ast.Expression(ast.Constant(value=value))) 73 | compile(expr, filename="", mode="eval") 74 | return ast.unparse(expr) 75 | 76 | def serialize_variable_name(value: str) -> str: 77 | expr = ast.fix_missing_locations(ast.Expression(ast.Name(id=value, ctx=ast.Load()))) 78 | compile(expr, filename="", mode="eval") 79 | return ast.unparse(expr) 80 | 81 | def to_snake_case(name: str) -> str: 82 | """Convert a string to snake_case.""" 83 | name = re.sub(r'(? str: 87 | if annotated_type is None: 88 | return 'Any' 89 | if annotated_type in ["text", "str"]: 90 | return 'str' 91 | if annotated_type in ["int", "integer", "bigint"]: 92 | return 'int' 93 | if annotated_type in ["real", "float"]: 94 | return 'float' 95 | if annotated_type in ["blob"]: 96 | return 'bytes' 97 | return 'Any' 98 | 99 | def py_type_from_decltype(decltype: str | None) -> str: 100 | if decltype is None: 101 | return 'Any' 102 | if decltype in ["TEXT"]: 103 | return 'str' 104 | if decltype in ["INT", "INTEGER", "BIGINT"]: 105 | return 'int' 106 | if decltype in ["REAL", "FLOAT"]: 107 | return 'float' 108 | if decltype in ["BLOB"]: 109 | return 'bytes' 110 | # TODO other types 111 | return 'Any' 112 | 113 | def generate_code(report: Report): 114 | functions = [] 115 | classes = {} 116 | 117 | for idx, export in enumerate(report.exports): 118 | 119 | if len(export.columns) > 0: 120 | class_name = export.return_type_classname() 121 | if export.result_type in ['Rows', 'Row']: 122 | classes[class_name] = [col for col in export.columns] 123 | 124 | func_name = serialize_variable_name(to_snake_case(export.name)) 125 | 126 | func_lines = [] 127 | sql_literal = serialize_string(export.sql) 128 | 129 | arguments = '' 130 | for p in export.parameters: 131 | arguments += f", {serialize_variable_name(to_snake_case(p.name))}: {py_type_from_annotated_type(p.annotated_type)}" 132 | 133 | py_return_type = None 134 | match export.result_type: 135 | case 'Void': 136 | py_return_type = 'None' 137 | case 'Rows': 138 | assert class_name is not None 139 | py_return_type = f'list[{class_name}]' 140 | case 'Row': 141 | assert class_name is not None 142 | py_return_type = f'{class_name} | None' 143 | case 'Value': 144 | 145 | py_return_type = py_type_from_decltype(export.columns[0].decltype) 146 | case 'List': 147 | py_return_type = 'list[{}]'.format(py_type_from_decltype(export.columns[0].decltype)) 148 | 149 | func_lines.append(f"\n def {func_name}(self{arguments}) -> Optional[{py_return_type}]:") 150 | func_lines.append(f' sql = {sql_literal}') 151 | if len(export.parameters) > 0: 152 | gen_params = '{' 153 | gen_params += ', '.join([f'{serialize_string(to_snake_case(param.full_name[1:]))}: {serialize_variable_name(to_snake_case(param.name))}' for param in export.parameters]) 154 | gen_params += '}' 155 | func_lines.append(f' params = {gen_params}') 156 | else: 157 | func_lines.append(' params = ()') 158 | 159 | if export.result_type != 'Void': 160 | func_lines.append(' result = self.connection.execute(sql, params)') 161 | else: 162 | func_lines.append(' self.connection.execute(sql, params)') 163 | match export.result_type: 164 | case 'Void': 165 | pass 166 | case 'Rows': 167 | func_lines.append(f' return [{class_name}(*row) for row in result.fetchall()]') 168 | case 'Row': 169 | func_lines.append(f' return {class_name}(*(result.fetchone())) if result else None') 170 | case 'Value': 171 | func_lines.append(' row = result.fetchone()') 172 | func_lines.append(' return row[0] if row else None') 173 | case 'List': 174 | func_lines.append(' return [row[0] for row in result.fetchall()]') 175 | 176 | #if class_name is not None: 177 | # func_lines.append(f' return [{class_name}(*row) for row in results.fetchall()]') 178 | #else: 179 | # func_lines.append(' return') 180 | functions.append('\n'.join(func_lines)) 181 | 182 | lines = [ 183 | "import sqlite3\n", 184 | "from typing import Any, Optional\n", 185 | ] 186 | if len(classes) > 0: 187 | lines.append("from dataclasses import dataclass\n") 188 | 189 | # define all the Result dataclasses 190 | for class_name, columns in classes.items(): 191 | lines.append(f"@dataclass\nclass {class_name}:") 192 | for c in columns: 193 | lines.append(f" {serialize_variable_name(c.name)}: {py_type_from_decltype(c.decltype)}") # assuming int for simplicity 194 | lines.append('') 195 | # define the Db class 196 | lines.append("class Db:") 197 | lines.append(" def __init__(self, *kwargs):") 198 | lines.append(" self.connection = sqlite3.connect(*kwargs)") 199 | lines.append(f" sql = {serialize_string(";".join(report.setup))}") 200 | lines.append(" self.connection.executescript(sql)\n") 201 | lines.append(" def __enter__(self):") 202 | lines.append(" self.connection = self.connection.__enter__()") 203 | lines.append(" return self") 204 | lines.append("") 205 | lines.append(" def __exit__(self, exc_type, exc_value, traceback):") 206 | lines.append(" return self.connection.__exit__(exc_type, exc_value, traceback)") 207 | 208 | # defin the Db class methods 209 | lines.extend(functions) 210 | 211 | return '\n'.join(lines) 212 | 213 | 214 | def main(): 215 | if not sys.stdin.isatty(): 216 | data = json.loads(sys.stdin.read()) 217 | else: 218 | if len(sys.argv) != 2: 219 | print(f"Usage: python { sys.argv[0] } ") 220 | sys.exit(1) 221 | input_path = Path(sys.argv[1]) 222 | with input_path.open() as f: 223 | data = json.load(f) 224 | 225 | report = Report.from_json(data) 226 | 227 | output = generate_code(report) 228 | hash = hashlib.sha256(output.encode()).hexdigest() 229 | print(f"# hash: {hash}") 230 | print(output) 231 | 232 | if __name__ == '__main__': 233 | main() 234 | --------------------------------------------------------------------------------