From 0dc504b6d23df0d9d9ab24a23d20ee2cef0540d5 Mon Sep 17 00:00:00 2001 From: nucccc Date: Thu, 13 Nov 2025 21:45:02 +0100 Subject: [PATCH 1/5] wrote code for sql schema building after generic collection, written code for mysql code generation --- pyproject.toml | 7 +- src/sqlmodelgen/__init__.py | 7 +- src/sqlmodelgen/ir/mysql/__init__.py | 212 ++++++++++++++++++++ src/sqlmodelgen/ir/query/__init__.py | 89 ++++++++ src/sqlmodelgen/sqlmodelgen.py | 22 +- src/sqlmodelgen/utils/dependency_checker.py | 10 +- tests/helpers/mysql_container.py | 38 ++++ uv.lock | 42 +++- 8 files changed, 421 insertions(+), 6 deletions(-) create mode 100644 src/sqlmodelgen/ir/mysql/__init__.py create mode 100644 src/sqlmodelgen/ir/query/__init__.py create mode 100644 tests/helpers/mysql_container.py diff --git a/pyproject.toml b/pyproject.toml index f1e1338..af9a30f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,13 +16,18 @@ classifiers = [ "Topic :: Software Development :: Code Generators", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14" ] [project.optional-dependencies] postgres = [ "psycopg[binary]>=3.2.6", ] +mysql = [ + "mysql-connector-python>=9.5.0", +] [tool.uv] dev-dependencies = [ diff --git a/src/sqlmodelgen/__init__.py b/src/sqlmodelgen/__init__.py index a0c9b85..07c6ad2 100644 --- a/src/sqlmodelgen/__init__.py +++ b/src/sqlmodelgen/__init__.py @@ -3,9 +3,14 @@ gen_code_from_sqlite, ) -from .utils.dependency_checker import check_postgres_deps +from .utils.dependency_checker import check_postgres_deps, check_mysql_deps if check_postgres_deps(): from .sqlmodelgen import ( gen_code_from_postgres + ) + +if check_mysql_deps(): + from .sqlmodelgen import ( + gen_code_from_mysql ) \ No newline at end of file diff --git a/src/sqlmodelgen/ir/mysql/__init__.py b/src/sqlmodelgen/ir/mysql/__init__.py new file mode 100644 index 0000000..34f4a9d --- /dev/null +++ b/src/sqlmodelgen/ir/mysql/__init__.py @@ -0,0 +1,212 @@ +from typing import Iterator + +from mysql.connector.connection_cext import CMySQLConnection +from mysql.connector.cursor_cext import CMySQLCursor + +from sqlmodelgen.ir.ir import ( + ColIR, + TableIR, + SchemaIR, + FKIR +) +from sqlmodelgen.ir.query import ColQueryData, ContraintsData, ir_build + +class MySQLCollector: + + def __init__( + self, + cnx: CMySQLConnection, + dbname: str, + ): + self.cnx = cnx + self.dbname = dbname + + + def collect_table_names(self) -> Iterator[str]: + cur = self.cnx.cursor() + yield from collect_tables(cur, self.dbname) + + + def collect_columns(self, table_name: str) -> Iterator[ColQueryData]: + cur = self.cnx.cursor() + yield from collect_columns(cur, self.dbname, table_name) + + + def collect_constraints(self) -> ContraintsData: + cur = self.cnx.cursor() + + uniques = collect_uniques(cur, self.dbname) + primary_keys = collect_primary_keys(cur, self.dbname) + foreign_keys = collect_foreign_keys(cur, self.dbname) + + return ContraintsData( + uniques=uniques, + primary_keys=primary_keys, + foreign_keys=foreign_keys, + ) + + +def collect_mysql_ir(cnx: CMySQLConnection, dbname: str) -> SchemaIR: + return ir_build(collector=MySQLCollector( + cnx=cnx, + dbname=dbname + )) + + +def collect_columns( + cur: CMySQLCursor, + schema_name: str, + table_name: str, +) -> Iterator[ColQueryData]: + cur.execute(f'''SELECT + COLUMN_NAME, + ORDINAL_POSITION, + IS_NULLABLE, + DATA_TYPE, + COLUMN_TYPE + FROM + information_schema.COLUMNS + WHERE + TABLE_SCHEMA = '{schema_name}' + AND TABLE_NAME = '{table_name}' + ORDER BY + TABLE_NAME, + ORDINAL_POSITION;''') + + for col_name, ord_pos, is_nullable, data_type, col_type in cur.fetchall(): + yield ColQueryData( + name=col_name, + data_type=data_type, + is_nullable=is_nullable + ) + + +def collect_tables(cur: CMySQLCursor, schema_name: str) -> Iterator[str]: + cur.execute(f'''SELECT + TABLE_NAME + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = '{schema_name}' + ORDER BY + TABLE_NAME;''') + + for elem in cur.fetchall(): + yield elem[0] + + +def collect_uniques( + cur: CMySQLCursor, + schema_name: str, +): + cur.execute('''SELECT + tc.TABLE_SCHEMA, + tc.TABLE_NAME, + tc.CONSTRAINT_NAME, + kcu.COLUMN_NAME + FROM + information_schema.TABLE_CONSTRAINTS tc + JOIN + information_schema.KEY_COLUMN_USAGE kcu + ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA + AND tc.TABLE_NAME = kcu.TABLE_NAME + WHERE + tc.CONSTRAINT_TYPE = 'UNIQUE' + ORDER BY + tc.TABLE_SCHEMA, + tc.TABLE_NAME, + kcu.ORDINAL_POSITION;''') + + result: dict[str, set[str]] = dict() + + for table_schema, table_name, constraint_name, column_name in cur.fetchall(): + if table_schema != schema_name: + continue + + if table_name not in result.keys(): + result[table_name] = set() + + result[table_name].add(column_name) + + return result + + +def collect_primary_keys( + cur: CMySQLCursor, + schema_name: str, +) -> dict[str, set[str]]: + cur.execute(f'''SELECT + TABLE_NAME, + COLUMN_NAME + FROM + information_schema.COLUMNS + WHERE + IS_NULLABLE = 'PRIMARY' + AND TABLE_SCHEMA = 'f{schema_name}' + ORDER BY + TABLE_SCHEMA, + TABLE_NAME, + ORDINAL_POSITION;''') + + result: dict[str, set[str]] = dict() + + for table_name, col_name in cur.fetchall(): + if table_name not in result.keys(): + result[table_name] = set() + + result[table_name].add(col_name) + + return result + + +def collect_foreign_keys( + cur: CMySQLCursor, + schema_name: str, +) -> dict[str, dict[str, FKIR]]: + cur.execute(f'''SELECT + kcu.TABLE_SCHEMA, + kcu.TABLE_NAME, + kcu.CONSTRAINT_NAME, + kcu.COLUMN_NAME, + kcu.REFERENCED_TABLE_SCHEMA, + kcu.REFERENCED_TABLE_NAME, + kcu.REFERENCED_COLUMN_NAME + FROM + information_schema.KEY_COLUMN_USAGE kcu + JOIN + information_schema.TABLE_CONSTRAINTS tc + ON kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND kcu.TABLE_NAME = tc.TABLE_NAME + WHERE + tc.CONSTRAINT_TYPE = 'FOREIGN KEY' + AND kcu.TABLE_SCHEMA = '{schema_name}' + AND kcu.REFERENCED_TABLE_SCHEMA = '{schema_name}' + ORDER BY + kcu.TABLE_SCHEMA, + kcu.TABLE_NAME, + kcu.ORDINAL_POSITION;''') + + result: dict[str, dict[str, FKIR]] = dict() + + for ( + table_schema, + table_name, + constraint_name, + column_name, + referenced_table_schema, + referenced_table_name, + referenced_column_name, + ) in cur.fetchall(): + table_fks = result.get(table_name) + if table_fks is None: + table_fks = dict() + result[table_name] = table_fks + + table_fks[column_name] = FKIR( + target_table=referenced_table_name, + target_column=referenced_column_name + ) + + return result \ No newline at end of file diff --git a/src/sqlmodelgen/ir/query/__init__.py b/src/sqlmodelgen/ir/query/__init__.py new file mode 100644 index 0000000..e0f972c --- /dev/null +++ b/src/sqlmodelgen/ir/query/__init__.py @@ -0,0 +1,89 @@ +from dataclasses import dataclass +from typing import Iterable, Iterator, Protocol + +from sqlmodelgen.ir.ir import ( + ColIR, + TableIR, + SchemaIR, + FKIR +) + +@dataclass +class ColQueryData: + name: str + data_type: str + is_nullable: bool = True + +@dataclass +class ContraintsData: + uniques: dict[str, set[str]] + primary_keys: dict[str, set[str]] + foreign_keys: dict[str, dict[str, FKIR]] + + def is_unique(self, table_name: str, column_name: str) -> bool: + return column_name in self.uniques.get(table_name, set()) + + def is_primary_key(self, table_name: str, column_name: str) -> bool: + return column_name in self.primary_keys.get(table_name, set()) + + def get_foreign_key(self, table_name: str, column_name: str) -> FKIR | None: + table_fks = self.foreign_keys.get(table_name) + + if table_fks is None: + return None + + return table_fks.get(column_name) + +class QCollector(Protocol): + ''' + a protocol for collection of stuff from sql, that is which a sql collector shall satisfy + ''' + + def collect_table_names(self) -> Iterator[str]: + pass + + def collect_columns(self, table_name: str) -> Iterator[ColQueryData]: + pass + + def collect_constraints(self) -> ContraintsData: + pass + + +def ir_build(collector: QCollector) -> SchemaIR: + constraints = collector.collect_constraints() + + tables_names = list(collector.collect_table_names()) + + table_irs: list[TableIR] = list() + for table_name in tables_names: + cols_data = collector.collect_columns(table_name) + + table_irs.append(TableIR( + name=table_name, + col_irs=list(build_cols_ir( + cols_data=cols_data, + table_name=table_name, + constraints=constraints, + )) + )) + + return SchemaIR( + table_irs=table_irs + ) + +def build_cols_ir( + cols_data: Iterable[ColQueryData], + table_name: str, + constraints: ContraintsData +) -> Iterator[ColIR]: + for col_data in cols_data: + # TODO: a lot of ORs here for new constraints coming from structure, no? + # in theory what arrives from the constraints should have priority I guess + yield ColIR( + name=col_data.name, + data_type=col_data.data_type, + primary_key=constraints.is_primary_key(table_name, col_data.name), + not_null=not col_data.is_nullable, # TODO: handle this into a bool + unique=constraints.is_unique(table_name, col_data.name), + foreign_key=constraints.get_foreign_key(table_name, col_data.name) + ) \ No newline at end of file diff --git a/src/sqlmodelgen/sqlmodelgen.py b/src/sqlmodelgen/sqlmodelgen.py index 9cffe6d..88fe898 100644 --- a/src/sqlmodelgen/sqlmodelgen.py +++ b/src/sqlmodelgen/sqlmodelgen.py @@ -3,7 +3,7 @@ from .codegen.codegen import gen_code from .ir.parse.ir_parse import ir_parse from .ir.sqlite.sqlite_parse import collect_sqlite_ir -from .utils.dependency_checker import check_postgres_deps +from .utils.dependency_checker import check_postgres_deps, check_mysql_deps def gen_code_from_sql( @@ -36,6 +36,26 @@ def gen_code_from_postgres( table_name_transform=table_name_transform, column_name_transform=column_name_transform, ) + + +if check_mysql_deps(): + from mysql.connector import CMySQLConnection + + from .ir.mysql import collect_mysql_ir + + def gen_code_from_mysql( + conn: CMySQLConnection, + dbname: str, + generate_relationships: bool = False, + table_name_transform: Callable[[str], str] | None = None, + column_name_transform: Callable[[str], str] | None = None, + ): + return gen_code( + schema_ir=collect_mysql_ir(cnx=conn, dbname=dbname), + generate_relationships=generate_relationships, + table_name_transform=table_name_transform, + column_name_transform=column_name_transform, + ) def gen_code_from_sqlite( diff --git a/src/sqlmodelgen/utils/dependency_checker.py b/src/sqlmodelgen/utils/dependency_checker.py index 43a2572..ab74999 100644 --- a/src/sqlmodelgen/utils/dependency_checker.py +++ b/src/sqlmodelgen/utils/dependency_checker.py @@ -3,4 +3,12 @@ def check_postgres_deps() -> bool: import psycopg except ImportError: return False - return True \ No newline at end of file + return True + + +def check_mysql_deps() -> bool: + try: + import mysql.connector + except ImportError: + return False + return True diff --git a/tests/helpers/mysql_container.py b/tests/helpers/mysql_container.py new file mode 100644 index 0000000..0de30f7 --- /dev/null +++ b/tests/helpers/mysql_container.py @@ -0,0 +1,38 @@ +import time +from contextlib import contextmanager + +import docker +import mysql.connector + + +def wait_until_conn(n_attempts: int = 100, delay: int = 1) -> mysql.connector.CMySQLConnection: + for _ in range(n_attempts): + try: + conn = mysql.connector.connect(host='localhost', port=3306, user='root', password='my-secret-pw') + except mysql.connector.errors.OperationalError: + time.sleep(delay) + else: + return conn + + +@contextmanager +def mysql_docker(): + client = docker.from_env() + container = client.containers.run( + 'mysql:9.5.0', + detach=True, + environment={ + "MYSQL_ROOT_PASSWORD":"my-secret-pw" + }, + ports={f'3306/tcp': 3306}, + hostname='127.0.0.1', + remove=True, + ) + try: + container.start() + conn = wait_until_conn() + yield (container, conn) + finally: + container.stop() + conn.close() + client.close() \ No newline at end of file diff --git a/uv.lock b/uv.lock index dce15d7..946b5aa 100644 --- a/uv.lock +++ b/uv.lock @@ -188,6 +188,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, ] +[[package]] +name = "mysql-connector-python" +version = "9.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/33/b332b001bc8c5ee09255a0d4b09a254da674450edd6a3e5228b245ca82a0/mysql_connector_python-9.5.0.tar.gz", hash = "sha256:92fb924285a86d8c146ebd63d94f9eaefa548da7813bc46271508fdc6cc1d596", size = 12251077 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/5d/30210fcf7ba98d1e03de0c47a58218ab5313d82f2e01ae53b47f45c36b9d/mysql_connector_python-9.5.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:77d14c9fde90726de22443e8c5ba0912a4ebb632cc1ade52a349dacbac47b140", size = 17579085 }, + { url = "https://files.pythonhosted.org/packages/77/92/ea79a0875436665330a81e82b4b73a6d52aebcfb1cf4d97f4ad4bd4dedf5/mysql_connector_python-9.5.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:4d603b55de310b9689bb3cb5e57fe97e98756e36d62f8f308f132f2c724f62b8", size = 18445098 }, + { url = "https://files.pythonhosted.org/packages/5f/f2/4578b5093f46985c659035e880e70e8b0bed44d4a59ad4e83df5d49b9c69/mysql_connector_python-9.5.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:48ffa71ba748afaae5c45ed9a085a72604368ce611fe81c3fdc146ef60181d51", size = 33660118 }, + { url = "https://files.pythonhosted.org/packages/c5/60/63135610ae0cee1260ce64874c1ddbf08e7fb560c21a3d9cce88b0ddc266/mysql_connector_python-9.5.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:77c71df48293d3c08713ff7087cf483804c8abf41a4bb4aefea7317b752c8e9a", size = 34096212 }, + { url = "https://files.pythonhosted.org/packages/3e/b1/78dc693552cfbb45076b3638ca4c402fae52209af8f276370d02d78367a0/mysql_connector_python-9.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:4f8d2d9d586c34dc9508a44d19cf30ccafabbbd12d7f8ab58da3af118636843c", size = 16512395 }, + { url = "https://files.pythonhosted.org/packages/05/03/77347d58b0027ce93a41858477e08422e498c6ebc24348b1f725ed7a67ae/mysql_connector_python-9.5.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:653e70cd10cf2d18dd828fae58dff5f0f7a5cf7e48e244f2093314dddf84a4b9", size = 17578984 }, + { url = "https://files.pythonhosted.org/packages/a5/bb/0f45c7ee55ebc56d6731a593d85c0e7f25f83af90a094efebfd5be9fe010/mysql_connector_python-9.5.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:5add93f60b3922be71ea31b89bc8a452b876adbb49262561bd559860dae96b3f", size = 18445067 }, + { url = "https://files.pythonhosted.org/packages/1c/ec/054de99d4aa50d851a37edca9039280f7194cc1bfd30aab38f5bd6977ebe/mysql_connector_python-9.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:20950a5e44896c03e3dc93ceb3a5e9b48c9acae18665ca6e13249b3fe5b96811", size = 33668029 }, + { url = "https://files.pythonhosted.org/packages/90/a2/e6095dc3a7ad5c959fe4a65681db63af131f572e57cdffcc7816bc84e3ad/mysql_connector_python-9.5.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7fdd3205b9242c284019310fa84437f3357b13f598e3f9b5d80d337d4a6406b8", size = 34101687 }, + { url = "https://files.pythonhosted.org/packages/9c/88/bc13c33fca11acaf808bd1809d8602d78f5bb84f7b1e7b1a288c383a14fd/mysql_connector_python-9.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c021d8b0830958b28712c70c53b206b4cf4766948dae201ea7ca588a186605e0", size = 16511749 }, + { url = "https://files.pythonhosted.org/packages/02/89/167ebee82f4b01ba7339c241c3cc2518886a2be9f871770a1efa81b940a0/mysql_connector_python-9.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72c2ef9d50b84f3c567c31b3bf30901af740686baa2a4abead5f202e0b7ea61", size = 17581904 }, + { url = "https://files.pythonhosted.org/packages/67/46/630ca969ce10b30fdc605d65dab4a6157556d8cc3b77c724f56c2d83cb79/mysql_connector_python-9.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bd9ba5a946cfd3b3b2688a75135357e862834b0321ed936fd968049be290872b", size = 18448195 }, + { url = "https://files.pythonhosted.org/packages/f6/87/4c421f41ad169d8c9065ad5c46673c7af889a523e4899c1ac1d6bfd37262/mysql_connector_python-9.5.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5ef7accbdf8b5f6ec60d2a1550654b7e27e63bf6f7b04020d5fb4191fb02bc4d", size = 33668638 }, + { url = "https://files.pythonhosted.org/packages/a6/01/67cf210d50bfefbb9224b9a5c465857c1767388dade1004c903c8e22a991/mysql_connector_python-9.5.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a6e0a4a0274d15e3d4c892ab93f58f46431222117dba20608178dfb2cc4d5fd8", size = 34102899 }, + { url = "https://files.pythonhosted.org/packages/cd/ef/3d1a67d503fff38cc30e11d111cf28f0976987fb175f47b10d44494e1080/mysql_connector_python-9.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:b6c69cb37600b7e22f476150034e2afbd53342a175e20aea887f8158fc5e3ff6", size = 16512684 }, + { url = "https://files.pythonhosted.org/packages/72/18/f221aeac49ce94ac119a427afbd51fe1629d48745b571afc0de49647b528/mysql_connector_python-9.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:1f5f7346b0d5edb2e994c1bd77b3f5eed88b0ca368ad6788d1012c7e56d7bf68", size = 17581933 }, + { url = "https://files.pythonhosted.org/packages/de/8e/14d44db7353350006a12e46d61c3a995bba06acd7547fc78f9bb32611e0c/mysql_connector_python-9.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:07bf52591b4215cb4318b4617c327a6d84c31978c11e3255f01a627bcda2618e", size = 18448446 }, + { url = "https://files.pythonhosted.org/packages/6b/f5/ab306f292a99bff3544ff44ad53661a031dc1a11e5b1ad64b9e5b5290ef9/mysql_connector_python-9.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8972c1f960b30d487f34f9125ec112ea2b3200bd02c53e5e32ee7a43be6d64c1", size = 33668933 }, + { url = "https://files.pythonhosted.org/packages/e8/ee/d146d2642552ebb5811cf551f06aca7da536c80b18fb6c75bdbc29723388/mysql_connector_python-9.5.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f6d32d7aa514d2f6f8709ba1e018314f82ab2acea2e6af30d04c1906fe9171b9", size = 34103214 }, + { url = "https://files.pythonhosted.org/packages/e7/f8/5e88e5eda1fe58f7d146b73744f691d85dce76fb42e7ce3de53e49911da3/mysql_connector_python-9.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:edd47048eb65c196b28aa9d2c0c6a017d8ca084a9a7041cd317301c829eb5a05", size = 16512689 }, + { url = "https://files.pythonhosted.org/packages/14/42/52bef145028af1b8e633eb77773278a04b2cd9f824117209aba093018445/mysql_connector_python-9.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:6effda35df1a96d9a096f04468d40f2324ea36b34d0e9632e81daae8be97b308", size = 17581903 }, + { url = "https://files.pythonhosted.org/packages/f4/a6/bd800b42bde86bf2e9468dfabcbd7538c66daff9d1a9fc97d2cc897f96fa/mysql_connector_python-9.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:fd057bd042464eedbf5337d1ceea7f2a4ab075a1cf6d1d62ffd5184966a656dd", size = 18448394 }, + { url = "https://files.pythonhosted.org/packages/4a/21/a1a3247775d0dfee094499cb915560755eaa1013ac3b03e34a98b0e16e49/mysql_connector_python-9.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2797dd7bbefb1d1669d984cfb284ea6b34401bbd9c1b3bf84e646d0bd3a82197", size = 33669845 }, + { url = "https://files.pythonhosted.org/packages/58/b7/dcab48349ab8abafd6f40f113101549e0cf107e43dd9c7e1fae79799604b/mysql_connector_python-9.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a5fff063ed48281b7374a4da6b9ef4293d390c153f79b1589ee547ea08c92310", size = 34104103 }, + { url = "https://files.pythonhosted.org/packages/21/3a/be129764fe5f5cd89a5aa3f58e7a7471284715f4af71097a980d24ebec0a/mysql_connector_python-9.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:56104693478fd447886c470a6d0558ded0fe2577df44c18232a6af6a2bbdd3e9", size = 17001255 }, + { url = "https://files.pythonhosted.org/packages/95/e1/45373c06781340c7b74fe9b88b85278ac05321889a307eaa5be079a997d4/mysql_connector_python-9.5.0-py2.py3-none-any.whl", hash = "sha256:ace137b88eb6fdafa1e5b2e03ac76ce1b8b1844b3a4af1192a02ae7c1a45bdee", size = 479047 }, +] + [[package]] name = "packaging" version = "25.0" @@ -328,13 +362,16 @@ wheels = [ [[package]] name = "sqlmodelgen" -version = "0.0.11" +version = "0.0.14" source = { editable = "." } dependencies = [ { name = "sqloxide" }, ] [package.optional-dependencies] +mysql = [ + { name = "mysql-connector-python" }, +] postgres = [ { name = "psycopg", extra = ["binary"] }, ] @@ -348,8 +385,9 @@ dev = [ [package.metadata] requires-dist = [ + { name = "mysql-connector-python", marker = "extra == 'mysql'", specifier = ">=9.5.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2.6" }, - { name = "sqloxide", specifier = ">=0.1.51" }, + { name = "sqloxide", specifier = ">=0.1.56" }, ] [package.metadata.requires-dev] From 8f90ffa6c059a26100f1f17a613ef85f02fa1639 Mon Sep 17 00:00:00 2001 From: nucccc Date: Sat, 15 Nov 2025 11:07:33 +0100 Subject: [PATCH 2/5] fixed constraints collection --- src/sqlmodelgen/ir/mysql/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sqlmodelgen/ir/mysql/__init__.py b/src/sqlmodelgen/ir/mysql/__init__.py index 34f4a9d..5543d05 100644 --- a/src/sqlmodelgen/ir/mysql/__init__.py +++ b/src/sqlmodelgen/ir/mysql/__init__.py @@ -47,7 +47,7 @@ def collect_constraints(self) -> ContraintsData: def collect_mysql_ir(cnx: CMySQLConnection, dbname: str) -> SchemaIR: - return ir_build(collector=MySQLCollector( + return ir_build(collector=MySQLCollector( cnx=cnx, dbname=dbname )) @@ -77,7 +77,7 @@ def collect_columns( yield ColQueryData( name=col_name, data_type=data_type, - is_nullable=is_nullable + is_nullable=is_nullable=='YES' ) @@ -140,15 +140,15 @@ def collect_primary_keys( TABLE_NAME, COLUMN_NAME FROM - information_schema.COLUMNS + information_schema.KEY_COLUMN_USAGE WHERE - IS_NULLABLE = 'PRIMARY' - AND TABLE_SCHEMA = 'f{schema_name}' + CONSTRAINT_NAME = 'PRIMARY' + AND TABLE_SCHEMA = '{schema_name}' ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION;''') - + result: dict[str, set[str]] = dict() for table_name, col_name in cur.fetchall(): From 68fa17419bb266cf17275f394854455f48bfee44 Mon Sep 17 00:00:00 2001 From: nucccc Date: Sat, 15 Nov 2025 11:17:22 +0100 Subject: [PATCH 3/5] written basic test for mysql code generation --- tests/test_gen_from_mysql.py | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_gen_from_mysql.py diff --git a/tests/test_gen_from_mysql.py b/tests/test_gen_from_mysql.py new file mode 100644 index 0000000..6e41c18 --- /dev/null +++ b/tests/test_gen_from_mysql.py @@ -0,0 +1,59 @@ +import time + +import mysql.connector + +from sqlmodelgen import gen_code_from_mysql + +from helpers.helpers import collect_code_info +from helpers.mysql_container import mysql_docker + + +def test_mysql(): + with mysql_docker() as (mysqld, conn): + cur = conn.cursor() + + sqls = ['''CREATE TABLE IF NOT EXISTS Hero ( + id INT, + name VARCHAR(255), + secret_name VARCHAR(255) UNIQUE, + age INT + );''', + '''CREATE TABLE Persons ( + ID int NOT NULL, + LastName varchar(255) NOT NULL, + FirstName varchar(255), + Age int, + PRIMARY KEY (ID) + );'''] + + cur.execute('CREATE DATABASE IF NOT EXISTS nucdb') + + cur.execute('USE nucdb') + + for sql in sqls: + cur.execute(sql) + + conn.commit() + + code = gen_code_from_mysql(conn, 'nucdb') + + print(code) + + expected_code ='''from sqlmodel import SQLModel, Field, UniqueConstraint + +class Hero(SQLModel, table=True): + __tablename__ = 'Hero' + __table_args__ = [UniqueConstraint('secret_name')] + id: int | None + name: str | None + secret_name: str | None + age: int | None + +class Persons(SQLModel, table=True): + __tablename__ = 'Persons' + ID: int = Field(primary_key=True) + LastName: str + FirstName: str | None + Age: int | None''' + + assert collect_code_info(code) == collect_code_info(expected_code) From 6593df4603b7b507632f5207e190501343929ce8 Mon Sep 17 00:00:00 2001 From: nucccc Date: Sun, 16 Nov 2025 22:19:42 +0100 Subject: [PATCH 4/5] added test for mysql foreign key relationship --- tests/test_gen_from_mysql.py | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_gen_from_mysql.py b/tests/test_gen_from_mysql.py index 6e41c18..e96d5cb 100644 --- a/tests/test_gen_from_mysql.py +++ b/tests/test_gen_from_mysql.py @@ -57,3 +57,55 @@ class Persons(SQLModel, table=True): Age: int | None''' assert collect_code_info(code) == collect_code_info(expected_code) + + +def test_mysql_fk_rel(): + with mysql_docker() as (mysqld, conn): + cur = conn.cursor() + + sqls = ['''CREATE TABLE nations( + id INT PRIMARY KEY, + name TEXT NOT NULL + );''', + '''CREATE TABLE athletes( + id INT PRIMARY KEY, + name TEXT NOT NULL, + nation_id INT, + FOREIGN KEY (nation_id) REFERENCES nations(id), + height INTEGER, + weight INTEGER, + bio TEXT, + nickname TEXT + );'''] + + cur.execute('CREATE DATABASE IF NOT EXISTS nucdb') + + cur.execute('USE nucdb') + + for sql in sqls: + cur.execute(sql) + + conn.commit() + + code = gen_code_from_mysql(conn, 'nucdb') + + print(code) + + expected_code ='''from sqlmodel import SQLModel, Field + +class Athletes(SQLModel, table=True): + __tablename__ = 'athletes' + id: int = Field(primary_key=True) + name: str + nation_id: int | None = Field(foreign_key='nations.id') + height: int | None + weight: int | None + bio: str | None + nickname: str | None + +class Nations(SQLModel, table=True): + __tablename__ = 'nations' + id: int = Field(primary_key=True) + name: str''' + + assert collect_code_info(code) == collect_code_info(expected_code) From 54557691aef0ba5ebaebb8d9517b9eb575545d56 Mon Sep 17 00:00:00 2001 From: nucccc Date: Sun, 16 Nov 2025 22:34:26 +0100 Subject: [PATCH 5/5] updated readme and version --- README.md | 16 ++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a79764d..e55c549 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ It accepts in input the following sources: * direct `CREATE TABLE` sql statements * sqlite file path * postgres connection string +* mysql connection from the [mysql-connector-python](https://github.com/mysql/mysql-connector-python) library ## Installation @@ -67,6 +68,20 @@ from sqlmodelgen import gen_code_from_postgres code = gen_code_from_postgres('postgres://USER:PASSWORD@HOST:PORT/DBNAME') ``` +### Generating from MYSQL + +The separate `mysql` extension is required, it can be installed with `pip install sqlmodelgen[mysql]`. + +```python +import mysql.connector +from sqlmodelgen import gen_code_from_mysql + +# instantiate your connection +conn = mysql.connector.connect(host='YOURHOST', port=3306, user='YOURUSER', password='YOURPASSWORD') + +code = gen_code_from_mysql(conn, 'YOURDBNAME') +``` + ### Relationships `sqlmodelgen` allows to build relationships by passing the argument `generate_relationships=True` to the functions: @@ -74,6 +89,7 @@ code = gen_code_from_postgres('postgres://USER:PASSWORD@HOST:PORT/DBNAME') * `gen_code_from_sql` * `gen_code_from_sqlite` * `gen_code_from_postgres` +* `gen_code_from_mysql` In such case `sqlmodelgen` is going to generate relationships between classes based on the foreign keys retrieved. The following example diff --git a/pyproject.toml b/pyproject.toml index af9a30f..db1b685 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sqlmodelgen" -version = "0.0.14" +version = "0.0.15" description = "Generate SQLModel code from SQL" license = {file = "LICENSE"} readme = "README.md"