From 199fff8850fee096230f328610337869c95c9123 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 19:18:48 -0300 Subject: [PATCH 01/40] Added make_migrations and migrations --- jetbase/cli/main.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/jetbase/cli/main.py b/jetbase/cli/main.py index 0ba6bbc..221b086 100644 --- a/jetbase/cli/main.py +++ b/jetbase/cli/main.py @@ -6,6 +6,7 @@ from jetbase.commands.history import history_cmd from jetbase.commands.init import initialize_cmd from jetbase.commands.lock_status import lock_status_cmd +from jetbase.commands.make_migrations import make_migrations_cmd from jetbase.commands.new import generate_new_migration_file_cmd from jetbase.commands.rollback import rollback_cmd from jetbase.commands.status import status_cmd @@ -192,6 +193,64 @@ def new( generate_new_migration_file_cmd(description=description, version=version) +@app.command() +def make_migrations( + description: str = typer.Option( + None, "--description", "-d", help="Description for the generated migration" + ), +) -> None: + """Automatically generate SQL migration files from SQLAlchemy model definitions. + + Reads model file paths from JETBASE_MODELS environment variable, + compares models against current database schema, and generates + migration files with upgrade and rollback SQL statements. + """ + validate_jetbase_directory() + make_migrations_cmd(description=description) + + +@app.command() +def migrate( + count: int = typer.Option( + None, "--count", "-c", help="Number of migrations to apply" + ), + to_version: str | None = typer.Option( + None, "--to-version", "-t", help="Migrate to a specific version" + ), + dry_run: bool = typer.Option( + False, "--dry-run", "-d", help="Simulate the migration without making changes" + ), + skip_validation: bool = typer.Option( + False, + "--skip-validation", + help="Skip both checksum and file version validation when running migrations", + ), + skip_checksum_validation: bool = typer.Option( + False, + "--skip-checksum-validation", + help="Skip checksum validation when running migrations", + ), + skip_file_validation: bool = typer.Option( + False, + "--skip-file-validation", + help="Skip file version validation when running migrations", + ), +) -> None: + """Apply pending migrations to the database. + + This is an alias for the 'upgrade' command. + """ + validate_jetbase_directory() + upgrade_cmd( + count=count, + to_version=to_version.replace("_", ".") if to_version else None, + dry_run=dry_run, + skip_validation=skip_validation, + skip_checksum_validation=skip_checksum_validation, + skip_file_validation=skip_file_validation, + ) + + def main() -> None: """ Entry point for the Jetbase CLI application. From 1522a9dafe28af8ac299296d920b9b8191a9206a Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 19:18:58 -0300 Subject: [PATCH 02/40] Model Paths --- jetbase/config.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/jetbase/config.py b/jetbase/config.py index 059fccd..6432a91 100644 --- a/jetbase/config.py +++ b/jetbase/config.py @@ -29,6 +29,8 @@ class JetbaseConfig: migration files exist. Defaults to False. skip_validation (bool): If True, skips all validations. Defaults to False. + model_paths (list[str] | None): Optional list of paths to SQLAlchemy + model files for automatic migration generation. Defaults to None. Raises: TypeError: If any boolean field receives a non-boolean value. @@ -41,6 +43,7 @@ class JetbaseConfig: skip_validation: bool = False snowflake_private_key: str | None = None snowflake_private_key_password: str | None = None + model_paths: list[str] | None = None def __post_init__(self): # Validate skip_checksum_validation @@ -77,6 +80,7 @@ def __post_init__(self): "postgres_schema": None, "snowflake_private_key": None, "snowflake_private_key_password": None, + "model_paths": None, } REQUIRED_KEYS: set[str] = { @@ -295,6 +299,7 @@ def _get_config_from_env_var(key: str) -> Any | None: Load a configuration value from a JETBASE_{KEY} environment variable. Converts "true" and "false" string values to boolean True and False. + For model_paths, parses comma-separated paths into a list. Args: key (str): The configuration key to retrieve. Will be converted @@ -303,14 +308,22 @@ def _get_config_from_env_var(key: str) -> Any | None: Returns: Any | None: The environment variable value if set, otherwise None. Boolean strings are converted to Python booleans. + model_paths is converted to a list of strings. """ env_var_name = f"JETBASE_{key.upper()}" config_value: str | None = os.getenv(env_var_name, None) - if config_value: - if config_value.lower() == "true": - return True - if config_value.lower() == "false": - return False + + if config_value is None: + return None + + if config_value.lower() == "true": + return True + if config_value.lower() == "false": + return False + if key == "model_paths": + paths = [p.strip() for p in config_value.split(",") if p.strip()] + return paths if paths else None + return config_value From 295e81d6fc575f9293a4d348b892c1310fea4fdd Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 19:19:25 -0300 Subject: [PATCH 03/40] Create make_migrations.py --- jetbase/commands/make_migrations.py | 162 ++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 jetbase/commands/make_migrations.py diff --git a/jetbase/commands/make_migrations.py b/jetbase/commands/make_migrations.py new file mode 100644 index 0000000..4c42ef9 --- /dev/null +++ b/jetbase/commands/make_migrations.py @@ -0,0 +1,162 @@ +import os + +from sqlalchemy.engine import Connection + +from jetbase.commands.new import _generate_new_filename +from jetbase.constants import MIGRATIONS_DIR +from jetbase.database.connection import get_db_connection +from jetbase.engine.model_discovery import ( + ModelDiscoveryError, + discover_all_models, +) +from jetbase.engine.schema_diff import compare_schemas, has_changes +from jetbase.engine.schema_introspection import introspect_database +from jetbase.engine.sql_generator import ( + generate_drop_table_sql, + generate_add_column_sql, + generate_drop_column_sql, + generate_create_index_sql, + generate_drop_index_sql, + generate_add_foreign_key_sql, + generate_drop_foreign_key_sql, + get_db_type, +) + + +class MakeMigrationsError(Exception): + """Base exception for make-migrations errors.""" + + pass + + +class NoChangesDetectedError(MakeMigrationsError): + """Raised when no schema changes are detected.""" + + pass + + +def generate_create_table_from_model(model_class, connection: Connection) -> str: + """ + Generate CREATE TABLE SQL from a SQLAlchemy model class. + + Args: + model_class: The SQLAlchemy model class. + connection: Database connection. + + Returns: + str: CREATE TABLE SQL statement. + """ + table = model_class.__table__ + + columns = [] + for col in table.columns: + col_def = f"{col.name} {col.type.compile(dialect=connection.engine.dialect)}" + if not col.nullable: + col_def += " NOT NULL" + if col.primary_key: + col_def += " PRIMARY KEY" + columns.append(col_def) + + pk_cols = ", ".join([c.name for c in table.primary_key.columns]) + cols_sql = ",\n ".join(columns) + return f"CREATE TABLE {table.name} (\n {cols_sql}\n);" + + +def make_migrations_cmd(description: str | None = None) -> None: + """ + Generate migration files automatically from model definitions. + + This command: + 1. Reads JETBASE_MODELS env var + 2. Validates model paths exist + 3. Discovers SQLAlchemy models + 4. Introspects current database schema + 5. Generates diff between models and database + 6. Generates upgrade and rollback SQL + 7. Creates migration file using existing _generate_new_filename + + Args: + description (str | None): Optional description for the migration. + If not provided, uses "auto_generated". + + Raises: + MakeMigrationsError: If migration generation fails. + NoChangesDetectedError: If no schema changes are detected. + """ + try: + _, models = discover_all_models() + except ModelDiscoveryError as e: + raise MakeMigrationsError(f"Failed to discover models: {e}") + + try: + with get_db_connection() as connection: + database_schema = introspect_database(connection) + except Exception as e: + raise MakeMigrationsError(f"Failed to introspect database: {e}") + + diff = compare_schemas(models, database_schema, connection) + + if not has_changes(diff): + print("No changes detected.") + return + + db_type = get_db_type() + + upgrade_statements = [] + rollback_statements = [] + + with get_db_connection() as connection: + for table_name in diff.tables_to_create: + model_class = models[table_name] + sql = generate_create_table_from_model(model_class, connection) + upgrade_statements.append(sql) + rollback_statements.append(generate_drop_table_sql(table_name, db_type)) + + for table_name in diff.columns_to_add: + for column in diff.columns_to_add[table_name]: + upgrade_statements.append( + generate_add_column_sql(table_name, column, db_type) + ) + rollback_statements.append( + generate_drop_column_sql(table_name, column.name, db_type) + ) + + for table_name in diff.indexes_to_create: + for index_info in diff.indexes_to_create[table_name]: + upgrade_statements.append( + generate_create_index_sql(table_name, index_info, db_type) + ) + rollback_statements.append( + generate_drop_index_sql(index_info["name"], table_name, db_type) + ) + + for table_name in diff.foreign_keys_to_create: + for fk_info in diff.foreign_keys_to_create[table_name]: + upgrade_statements.append( + generate_add_foreign_key_sql(table_name, fk_info, db_type) + ) + rollback_statements.append( + generate_drop_foreign_key_sql(table_name, fk_info["name"], db_type) + ) + + upgrade_sql = "\n\n".join(upgrade_statements) + rollback_sql = "\n\n".join(rollback_statements) + + migration_description = description or "auto_generated" + + filename = _generate_new_filename(description=migration_description) + filepath = os.path.join(os.getcwd(), MIGRATIONS_DIR, filename) + + migration_content = f"""-- upgrade + +{upgrade_sql} + +-- rollback + +{rollback_sql} +""" + + with open(filepath, "w") as f: + f.write(migration_content) + + print(f"Created migration file: {filename}") From 73ef033b36b60fe97177ae14cd133dce80c67466 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 19:19:28 -0300 Subject: [PATCH 04/40] Create model_discovery.py --- jetbase/engine/model_discovery.py | 256 ++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 jetbase/engine/model_discovery.py diff --git a/jetbase/engine/model_discovery.py b/jetbase/engine/model_discovery.py new file mode 100644 index 0000000..8d3aba7 --- /dev/null +++ b/jetbase/engine/model_discovery.py @@ -0,0 +1,256 @@ +import importlib.machinery +import importlib.util +import os +from pathlib import Path +from types import ModuleType +from typing import Any + +from sqlalchemy import MetaData +from sqlalchemy.orm import DeclarativeBase + + +class ModelDiscoveryError(Exception): + """Base exception for model discovery errors.""" + + pass + + +class ModelPathsNotSetError(ModelDiscoveryError): + """Raised when JETBASE_MODELS environment variable is not set.""" + + pass + + +class ModelFileNotFoundError(ModelDiscoveryError): + """Raised when a model file path does not exist.""" + + pass + + +class ModelImportError(ModelDiscoveryError): + """Raised when a model file cannot be imported.""" + + pass + + +class NoModelsFoundError(ModelDiscoveryError): + """Raised when no SQLAlchemy models are found in the provided paths.""" + + pass + + +def get_model_paths_from_env() -> list[str]: + """ + Get model paths from JETBASE_MODELS environment variable. + + Returns: + list[str]: List of model file paths from the environment variable. + + Raises: + ModelPathsNotSetError: If JETBASE_MODELS is not set. + """ + model_paths_str = os.getenv("JETBASE_MODELS", "") + if not model_paths_str: + raise ModelPathsNotSetError( + "JETBASE_MODELS environment variable is not set. " + "Please set it to the path(s) of your SQLAlchemy model files.\n" + "Example: export JETBASE_MODELS='./models/user.py,./models/product.py'" + ) + + paths = [p.strip() for p in model_paths_str.split(",") if p.strip()] + return paths + + +def validate_model_paths(model_paths: list[str]) -> None: + """ + Validate that all model paths exist. + + Args: + model_paths (list[str]): List of paths to model files. + + Raises: + ModelFileNotFoundError: If any model file path does not exist. + """ + for path in model_paths: + resolved_path = Path(path) + if not resolved_path.exists(): + raise ModelFileNotFoundError( + f"Model file not found: {path}\n" + f"Please check that the path exists and is correct." + ) + + +def import_model_file(model_path: str) -> ModuleType: + """ + Dynamically import a Python model file. + + Args: + model_path (str): Path to the Python model file. + + Returns: + ModuleType: The imported module. + + Raises: + ModelImportError: If the file cannot be imported. + """ + try: + spec = importlib.util.spec_from_file_location( + name=Path(model_path).stem, location=model_path + ) + if spec is None or spec.loader is None: + raise ModelImportError(f"Cannot load spec for model file: {model_path}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + except SyntaxError as e: + raise ModelImportError(f"Syntax error in model file {model_path}: {e}") + except Exception as e: + raise ModelImportError(f"Error importing model file {model_path}: {e}") + + +def find_declarative_base_in_module(module: ModuleType) -> type | None: + """ + Find a SQLAlchemy DeclarativeBase class in a module. + + Args: + module (ModuleType): The module to search. + + Returns: + type | None: The found DeclarativeBase class, or None. + """ + for attr_name in dir(module): + try: + attr = getattr(module, attr_name) + if ( + isinstance(attr, type) + and hasattr(attr, "registry") + and attr is not type(attr) # Exclude the registry class itself + ): + return attr + except TypeError: + continue + return None + + +def discover_models_from_path(model_path: str) -> list[type[Any]]: + """ + Discover SQLAlchemy models from a single model file. + + Args: + model_path (str): Path to the Python model file. + + Returns: + list[type[Any]]: List of discovered model classes. + """ + module = import_model_file(model_path) + base = find_declarative_base_in_module(module) + + if base is None: + return [] + + models = [] + for attr_name in dir(module): + try: + attr = getattr(module, attr_name) + if ( + isinstance(attr, type) + and issubclass(attr, base) + and attr is not base + and hasattr(attr, "__tablename__") + ): + models.append(attr) + except TypeError: + continue + + return models + + +def discover_all_models( + model_paths: list[str] | None = None, +) -> tuple[type, dict[str, type[Any]]]: + """ + Discover all SQLAlchemy models from provided paths. + + Args: + model_paths (list[str] | None): List of paths to model files. + If None, reads from JETBASE_MODELS environment variable. + + Returns: + tuple: A tuple containing: + - The discovered DeclarativeBase class + - Dictionary mapping table names to model classes + + Raises: + ModelPathsNotSetError: If model_paths is None and JETBASE_MODELS is not set. + ModelFileNotFoundError: If any model file path does not exist. + ModelImportError: If a model file cannot be imported. + NoModelsFoundError: If no SQLAlchemy models are found. + """ + if model_paths is None: + model_paths = get_model_paths_from_env() + + validate_model_paths(model_paths) + + all_models: dict[str, type[Any]] = {} + discovered_base: type[DeclarativeBase] | None = None + + for path in model_paths: + module = import_model_file(path) + base = find_declarative_base_in_module(module) + + if base is not None: + if discovered_base is None: + discovered_base = base + elif discovered_base is not base: + pass + + for attr_name in dir(module): + try: + attr = getattr(module, attr_name) + if isinstance(attr, type) and hasattr(attr, "__tablename__"): + if discovered_base is not None and issubclass( + attr, discovered_base + ): + if attr is not discovered_base: + table_name = attr.__tablename__ + if table_name not in all_models: + all_models[table_name] = attr + elif base is not None and issubclass(attr, base): + if attr is not base: + table_name = attr.__tablename__ + if table_name not in all_models: + all_models[table_name] = attr + except TypeError: + continue + + if discovered_base is None and all_models: + pass + + if not all_models: + raise NoModelsFoundError( + "No SQLAlchemy models found in the provided model paths.\n" + "Make sure your model files define classes that inherit from " + "a SQLAlchemy DeclarativeBase and have __tablename__ defined." + ) + + return discovered_base, all_models + + +def get_model_metadata(models: dict[str, type[Any]]) -> MetaData: + """ + Extract metadata from discovered models. + + Args: + models (dict[str, type[Any]]): Dictionary mapping table names to model classes. + + Returns: + MetaData: SQLAlchemy MetaData object containing all table definitions. + """ + metadata = MetaData() + + for table_name, model_class in models.items(): + if hasattr(model_class, "__table__"): + metadata._add_table(model_class.__table__) + + return metadata From 3d4caf21a284bd913026969cc4d826b641d884b7 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 19:19:31 -0300 Subject: [PATCH 05/40] Create schema_diff.py --- jetbase/engine/schema_diff.py | 266 ++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 jetbase/engine/schema_diff.py diff --git a/jetbase/engine/schema_diff.py b/jetbase/engine/schema_diff.py new file mode 100644 index 0000000..4bcfc70 --- /dev/null +++ b/jetbase/engine/schema_diff.py @@ -0,0 +1,266 @@ +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy import Column +from sqlalchemy.engine import Connection + +from jetbase.engine.schema_introspection import ( + ColumnInfo, + SchemaInfo, + TableInfo, + introspect_database, +) + + +@dataclass +class SchemaDiff: + """Represents the differences between model schema and database schema.""" + + tables_to_create: list[str] = field(default_factory=list) + tables_to_drop: list[str] = field(default_factory=list) + columns_to_add: dict[str, list[ColumnInfo]] = field(default_factory=dict) + columns_to_remove: dict[str, list[str]] = field(default_factory=dict) + columns_to_modify: dict[str, dict[str, tuple[ColumnInfo, ColumnInfo]]] = field( + default_factory=dict + ) + indexes_to_create: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + indexes_to_drop: dict[str, list[str]] = field(default_factory=dict) + foreign_keys_to_create: dict[str, list[dict[str, Any]]] = field( + default_factory=dict + ) + foreign_keys_to_drop: dict[str, list[str]] = field(default_factory=dict) + + +def get_model_table_columns(model_class: type) -> list[ColumnInfo]: + """ + Extract column information from a SQLAlchemy model class. + + Args: + model_class: The SQLAlchemy model class. + + Returns: + list[ColumnInfo]: List of column information. + """ + columns = [] + for column in model_class.__table__.columns: + col_info = ColumnInfo( + name=column.name, + type=str(column.type), + nullable=column.nullable, + default=str(column.default) if column.default else None, + primary_key=column.primary_key, + autoincrement=column.autoincrement + if hasattr(column, "autoincrement") + else False, + ) + columns.append(col_info) + return columns + + +def get_model_table_info(model_class: type) -> TableInfo: + """ + Extract table information from a SQLAlchemy model class. + + Args: + model_class: The SQLAlchemy model class. + + Returns: + TableInfo: Table information extracted from the model. + """ + table = model_class.__table__ + table_info = TableInfo( + name=table.name, + columns=get_model_table_columns(model_class), + primary_keys=[c.name for c in table.primary_key.columns], + ) + + for fk in table.foreign_keys: + table_info.foreign_keys.append( + { + "name": fk.name, + "constrained_columns": list(fk.constrained.columns), + "referred_table": fk.column.table.name, + "referred_columns": [ + c.name for c in fk.column.table.primary_key.columns + ], + } + ) + + for index in table.indexes: + table_info.indexes.append( + { + "name": index.name, + "column_names": list(index.columns), + "unique": index.unique, + } + ) + + return table_info + + +def get_models_schema_info(models: dict[str, type]) -> dict[str, TableInfo]: + """ + Get schema information from model classes. + + Args: + models (dict[str, type]): Dictionary mapping table names to model classes. + + Returns: + dict[str, TableInfo]: Dictionary mapping table names to TableInfo. + """ + return { + table_name: get_model_table_info(model_class) + for table_name, model_class in models.items() + } + + +def compare_schemas( + models: dict[str, type], + database_schema: SchemaInfo, + connection: Connection, +) -> SchemaDiff: + """ + Compare model schema against database schema to detect differences. + + Args: + models (dict[str, type]): Dictionary mapping table names to model classes. + database_schema (SchemaInfo): The current database schema. + connection (Connection): Database connection. + + Returns: + SchemaDiff: Object containing all detected differences. + """ + diff = SchemaDiff() + models_schema = get_models_schema_info(models) + + model_table_names = set(models_schema.keys()) + db_table_names = set(database_schema.tables.keys()) + + diff.tables_to_create = sorted(list(model_table_names - db_table_names)) + diff.tables_to_drop = sorted(list(db_table_names - model_table_names)) + + for table_name in model_table_names & db_table_names: + model_table = models_schema[table_name] + db_table = database_schema.tables[table_name] + + compare_tables(model_table, db_table, diff, table_name) + + return diff + + +def compare_tables( + model_table: TableInfo, + db_table: TableInfo, + diff: SchemaDiff, + table_name: str, +) -> None: + """ + Compare a single model's table against the database table. + + Args: + model_table (TableInfo): The model's table definition. + db_table (TableInfo): The database table definition. + diff (SchemaDiff): The diff object to update. + table_name (str): Name of the table being compared. + """ + model_column_map = {col.name: col for col in model_table.columns} + db_column_map = {col.name: col for col in db_table.columns} + + columns_to_add = [ + col for col in model_table.columns if col.name not in db_column_map + ] + if columns_to_add: + diff.columns_to_add[table_name] = columns_to_add + + columns_to_remove = [ + col.name for col in db_table.columns if col.name not in model_column_map + ] + if columns_to_remove: + diff.columns_to_remove[table_name] = columns_to_remove + + columns_to_modify = {} + for col_name in model_column_map: + if col_name in db_column_map: + model_col = model_column_map[col_name] + db_col = db_column_map[col_name] + if not columns_match(model_col, db_col): + columns_to_modify[col_name] = (model_col, db_col) + if columns_to_modify: + diff.columns_to_modify[table_name] = columns_to_modify + + model_fk_names = {fk["name"] for fk in model_table.foreign_keys} + db_fk_names = {fk["name"] for fk in db_table.foreign_keys} + + fks_to_create = [ + fk for fk in model_table.foreign_keys if fk["name"] not in db_fk_names + ] + if fks_to_create: + diff.foreign_keys_to_create[table_name] = fks_to_create + + fks_to_drop = [ + fk["name"] for fk in db_table.foreign_keys if fk["name"] not in model_fk_names + ] + if fks_to_drop: + diff.foreign_keys_to_drop[table_name] = fks_to_drop + + model_index_names = {idx["name"] for idx in model_table.indexes} + db_index_names = {idx["name"] for idx in db_table.indexes} + + indexes_to_create = [ + idx for idx in model_table.indexes if idx["name"] not in db_index_names + ] + if indexes_to_create: + diff.indexes_to_create[table_name] = indexes_to_create + + indexes_to_drop = [ + idx["name"] for idx in db_table.indexes if idx["name"] not in model_index_names + ] + if indexes_to_drop: + diff.indexes_to_drop[table_name] = indexes_to_drop + + +def columns_match(col1: ColumnInfo, col2: ColumnInfo) -> bool: + """ + Check if two columns match in their essential properties. + + Args: + col1 (ColumnInfo): First column. + col2 (ColumnInfo): Second column. + + Returns: + bool: True if columns match, False otherwise. + """ + type1 = col1.type.lower() + type2 = col2.type.lower() + + type1 = type1.replace("(", "").replace(")", "").replace(" ", "") + type2 = type2.replace("(", "").replace(")", "").replace(" ", "") + + return ( + type1 == type2 + and col1.nullable == col2.nullable + and col1.primary_key == col2.primary_key + ) + + +def has_changes(diff: SchemaDiff) -> bool: + """ + Check if the schema diff contains any changes. + + Args: + diff (SchemaDiff): The schema diff to check. + + Returns: + bool: True if there are changes, False otherwise. + """ + return ( + bool(diff.tables_to_create) + or bool(diff.tables_to_drop) + or bool(diff.columns_to_add) + or bool(diff.columns_to_remove) + or bool(diff.columns_to_modify) + or bool(diff.indexes_to_create) + or bool(diff.indexes_to_drop) + or bool(diff.foreign_keys_to_create) + or bool(diff.foreign_keys_to_drop) + ) From 631eeb03a8799c5b26e894c5c069c13c53fe7c5d Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 19:19:33 -0300 Subject: [PATCH 06/40] Create schema_introspection.py --- jetbase/engine/schema_introspection.py | 221 +++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 jetbase/engine/schema_introspection.py diff --git a/jetbase/engine/schema_introspection.py b/jetbase/engine/schema_introspection.py new file mode 100644 index 0000000..bb537d8 --- /dev/null +++ b/jetbase/engine/schema_introspection.py @@ -0,0 +1,221 @@ +from dataclasses import dataclass, field +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from sqlalchemy.engine import Inspector + +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.engine import Connection + +from jetbase.config import get_config +from jetbase.database.queries.base import detect_db +from jetbase.enums import DatabaseType + + +@dataclass +class ColumnInfo: + """Represents information about a database column.""" + + name: str + type: str + nullable: bool + default: str | None + primary_key: bool + autoincrement: bool + + +@dataclass +class TableInfo: + """Represents information about a database table.""" + + name: str + columns: list[ColumnInfo] = field(default_factory=list) + primary_keys: list[str] = field(default_factory=list) + foreign_keys: list[dict[str, Any]] = field(default_factory=list) + indexes: list[dict[str, Any]] = field(default_factory=list) + unique_constraints: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class SchemaInfo: + """Represents the complete database schema.""" + + tables: dict[str, TableInfo] = field(default_factory=dict) + + +class SchemaIntrospectionError(Exception): + """Base exception for schema introspection errors.""" + + pass + + +class DatabaseConnectionError(SchemaIntrospectionError): + """Raised when database connection fails.""" + + pass + + +def get_inspector() -> "Inspector": + """ + Get a SQLAlchemy Inspector for the configured database. + + Returns: + Inspector: SQLAlchemy Inspector object. + + Raises: + DatabaseConnectionError: If database connection fails. + """ + try: + sqlalchemy_url = get_config(required={"sqlalchemy_url"}).sqlalchemy_url + engine = create_engine(sqlalchemy_url) + return inspect(engine) + except Exception as e: + raise DatabaseConnectionError(f"Failed to connect to database: {e}") + + +def introspect_database( + connection: Connection, schema: str | None = None +) -> SchemaInfo: + """ + Introspect the current database schema. + + Args: + connection (Connection): SQLAlchemy database connection. + schema (str | None): Optional schema name for PostgreSQL. + + Returns: + SchemaInfo: Object containing all schema information. + """ + inspector = inspect(connection) + schema_info = SchemaInfo() + + db_type = detect_db(sqlalchemy_url=str(connection.engine.url)) + + if db_type == DatabaseType.POSTGRESQL: + schema = schema or get_config().postgres_schema or "public" + + table_names = inspector.get_table_names(schema=schema) + + for table_name in table_names: + table_info = introspect_table(inspector, table_name, schema) + schema_info.tables[table_name] = table_info + + return schema_info + + +def introspect_table( + inspector: "Inspector", table_name: str, schema: str | None = None +) -> TableInfo: + """ + Introspect a single table. + + Args: + inspector (Inspector): SQLAlchemy Inspector. + table_name (str): Name of the table to introspect. + schema (str | None): Optional schema name. + + Returns: + TableInfo: Object containing table information. + """ + columns = inspector.get_columns(table_name, schema=schema) + pk_columns = inspector.get_pk_constraint(table_name, schema=schema) + foreign_keys = inspector.get_foreign_keys(table_name, schema=schema) + indexes = inspector.get_indexes(table_name, schema=schema) + + table_info = TableInfo(name=table_name) + + for col in columns: + column_info = ColumnInfo( + name=col["name"], + type=str(col["type"]), + nullable=col.get("nullable", True), + default=str(col.get("default")) if col.get("default") else None, + primary_key=col["name"] in pk_columns.get("constrained_columns", []), + autoincrement=col.get("autoincrement", False), + ) + table_info.columns.append(column_info) + + table_info.primary_keys = pk_columns.get("constrained_columns", []) + table_info.foreign_keys = [ + { + "name": fk["name"], + "constrained_columns": fk["constrained_columns"], + "referred_table": fk["referred_table"], + "referred_columns": fk["referred_columns"], + } + for fk in foreign_keys + ] + + table_info.indexes = [ + { + "name": idx["name"], + "column_names": idx["column_names"], + "unique": idx.get("unique", False), + } + for idx in indexes + if not idx.get("primary_key", False) + ] + + unique_constraints = inspector.get_unique_constraints(table_name, schema=schema) + table_info.unique_constraints = [ + { + "name": uc["name"], + "column_names": uc["column_names"], + } + for uc in unique_constraints + ] + + return table_info + + +def compare_column(col1: ColumnInfo, col2: ColumnInfo) -> bool: + """ + Compare two columns for equality. + + Args: + col1 (ColumnInfo): First column. + col2 (ColumnInfo): Second column. + + Returns: + bool: True if columns are equivalent, False otherwise. + """ + return ( + col1.name == col2.name + and col1.type.lower() == col2.type.lower() + and col1.nullable == col2.nullable + and col1.primary_key == col2.primary_key + ) + + +def schema_info_to_dict(schema_info: SchemaInfo) -> dict[str, Any]: + """ + Convert SchemaInfo to a serializable dictionary. + + Args: + schema_info (SchemaInfo): The schema information. + + Returns: + dict: Serializable dictionary representation. + """ + result = {"tables": {}} + + for table_name, table in schema_info.tables.items(): + result["tables"][table_name] = { + "columns": [ + { + "name": col.name, + "type": col.type, + "nullable": col.nullable, + "default": col.default, + "primary_key": col.primary_key, + "autoincrement": col.autoincrement, + } + for col in table.columns + ], + "primary_keys": table.primary_keys, + "foreign_keys": table.foreign_keys, + "indexes": table.indexes, + "unique_constraints": table.unique_constraints, + } + + return result From 8cb5d8b6cadf5a2106af821c409887a272676b02 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 19:19:35 -0300 Subject: [PATCH 07/40] Create sql_generator.py --- jetbase/engine/sql_generator.py | 469 ++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 jetbase/engine/sql_generator.py diff --git a/jetbase/engine/sql_generator.py b/jetbase/engine/sql_generator.py new file mode 100644 index 0000000..6289724 --- /dev/null +++ b/jetbase/engine/sql_generator.py @@ -0,0 +1,469 @@ +from typing import Any + +from sqlalchemy import Table, create_engine, text +from sqlalchemy.engine import Connection + +from jetbase.config import get_config +from jetbase.database.queries.base import detect_db +from jetbase.engine.schema_diff import SchemaDiff +from jetbase.engine.schema_introspection import ColumnInfo +from jetbase.enums import DatabaseType + + +class SQLGeneratorError(Exception): + """Base exception for SQL generation errors.""" + + pass + + +def get_db_type() -> DatabaseType: + """ + Get the current database type. + + Returns: + DatabaseType: The detected database type. + """ + sqlalchemy_url = get_config(required={"sqlalchemy_url"}).sqlalchemy_url + return detect_db(sqlalchemy_url=sqlalchemy_url) + + +def sql_type_to_string(col_type: str, db_type: DatabaseType) -> str: + """ + Convert a SQLAlchemy column type to a database-specific SQL string. + + Args: + col_type (str): The SQLAlchemy column type string. + db_type (DatabaseType): The target database type. + + Returns: + str: The database-specific SQL type string. + """ + type_map = { + DatabaseType.POSTGRESQL: { + "integer": "INTEGER", + "bigint": "BIGINT", + "smallint": "SMALLINT", + "float": "DOUBLE PRECISION", + "numeric": "NUMERIC", + "decimal": "DECIMAL", + "varchar": "VARCHAR", + "char": "CHAR", + "text": "TEXT", + "boolean": "BOOLEAN", + "date": "DATE", + "datetime": "TIMESTAMP", + "timestamp": "TIMESTAMP", + "time": "TIME", + "json": "JSONB", + "uuid": "UUID", + "bytea": "BYTEA", + }, + DatabaseType.MYSQL: { + "integer": "INT", + "bigint": "BIGINT", + "smallint": "SMALLINT", + "float": "DOUBLE", + "numeric": "DECIMAL", + "decimal": "DECIMAL", + "varchar": "VARCHAR", + "char": "CHAR", + "text": "TEXT", + "boolean": "TINYINT(1)", + "date": "DATE", + "datetime": "DATETIME", + "timestamp": "TIMESTAMP", + "time": "TIME", + "json": "JSON", + "uuid": "CHAR(36)", + "bytea": "BLOB", + }, + DatabaseType.SQLITE: { + "integer": "INTEGER", + "bigint": "INTEGER", + "smallint": "INTEGER", + "float": "REAL", + "numeric": "NUMERIC", + "decimal": "NUMERIC", + "varchar": "TEXT", + "char": "TEXT", + "text": "TEXT", + "boolean": "INTEGER", + "date": "TEXT", + "datetime": "TEXT", + "timestamp": "TEXT", + "time": "TEXT", + "json": "TEXT", + "uuid": "TEXT", + "bytea": "BLOB", + }, + DatabaseType.SNOWFLAKE: { + "integer": "INTEGER", + "bigint": "BIGINT", + "smallint": "INTEGER", + "float": "FLOAT", + "numeric": "NUMERIC", + "decimal": "DECIMAL", + "varchar": "VARCHAR", + "char": "CHAR", + "text": "TEXT", + "boolean": "BOOLEAN", + "date": "DATE", + "datetime": "TIMESTAMP", + "timestamp": "TIMESTAMP", + "time": "TIME", + "json": "VARIANT", + "uuid": "UUID", + "bytea": "BINARY", + }, + DatabaseType.DATABRICKS: { + "integer": "INT", + "bigint": "BIGINT", + "smallint": "SMALLINT", + "float": "DOUBLE", + "numeric": "DECIMAL", + "decimal": "DECIMAL", + "varchar": "STRING", + "char": "STRING", + "text": "STRING", + "boolean": "BOOLEAN", + "date": "DATE", + "datetime": "TIMESTAMP", + "timestamp": "TIMESTAMP", + "time": "STRING", + "json": "STRING", + "uuid": "STRING", + "bytea": "BINARY", + }, + } + + type_lower = col_type.lower() + + if "(" in type_lower: + base_type = type_lower.split("(")[0].strip() + if base_type in type_map.get(db_type, {}): + params = type_lower.split("(")[1].rstrip(")") + return f"{type_map[db_type][base_type]}({params})" + else: + if type_lower in type_map.get(db_type, {}): + return type_map[db_type][type_lower] + + return col_type.upper() + + +def generate_create_table_sql( + table_name: str, + columns: list[ColumnInfo], + primary_keys: list[str], + db_type: DatabaseType, +) -> str: + """ + Generate CREATE TABLE SQL statement. + + Args: + table_name (str): Name of the table to create. + columns (list[ColumnInfo]): List of column definitions. + primary_keys (list[str]): List of primary key column names. + db_type (DatabaseType): The database type. + + Returns: + str: CREATE TABLE SQL statement. + """ + col_defs = [] + + for col in columns: + col_sql = f"{col.name} {sql_type_to_string(col.type, db_type)}" + + if col.primary_key: + pass + elif not col.nullable: + col_sql += " NOT NULL" + + if col.default: + if db_type == DatabaseType.POSTGRESQL: + col_sql += f" DEFAULT {col.default}" + elif db_type == DatabaseType.MYSQL: + col_sql += f" DEFAULT {col.default}" + elif db_type == DatabaseType.SQLITE: + if "CURRENT_TIMESTAMP" in str(col.default): + col_sql += " DEFAULT CURRENT_TIMESTAMP" + elif db_type == DatabaseType.SNOWFLAKE: + col_sql += f" DEFAULT {col.default}" + elif db_type == DatabaseType.DATABRICKS: + col_sql += f" DEFAULT {col.default}" + + col_defs.append(col_sql) + + if primary_keys: + pk_cols = ", ".join(primary_keys) + col_defs.append(f"PRIMARY KEY ({pk_cols})") + + cols_sql = ",\n ".join(col_defs) + + if db_type == DatabaseType.POSTGRESQL: + return f"CREATE TABLE {table_name} (\n {cols_sql}\n);" + elif db_type == DatabaseType.MYSQL: + return f"CREATE TABLE {table_name} (\n {cols_sql}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" + elif db_type == DatabaseType.SQLITE: + return f"CREATE TABLE {table_name} (\n {cols_sql}\n);" + elif db_type == DatabaseType.SNOWFLAKE: + return f"CREATE TABLE {table_name} (\n {cols_sql}\n);" + elif db_type == DatabaseType.DATABRICKS: + return f"CREATE TABLE {table_name} (\n {cols_sql}\n);" + else: + return f"CREATE TABLE {table_name} (\n {cols_sql}\n);" + + +def generate_drop_table_sql(table_name: str, db_type: DatabaseType) -> str: + """ + Generate DROP TABLE SQL statement. + + Args: + table_name (str): Name of the table to drop. + db_type (DatabaseType): The database type. + + Returns: + str: DROP TABLE SQL statement. + """ + if db_type == DatabaseType.DATABRICKS: + return f"DROP TABLE IF EXISTS {table_name};" + return f"DROP TABLE {table_name};" + + +def generate_add_column_sql( + table_name: str, + column: ColumnInfo, + db_type: DatabaseType, +) -> str: + """ + Generate ALTER TABLE ADD COLUMN SQL statement. + + Args: + table_name (str): Name of the table. + column (ColumnInfo): Column to add. + db_type (DatabaseType): The database type. + + Returns: + str: ALTER TABLE ADD COLUMN SQL statement. + """ + col_sql = f"{column.name} {sql_type_to_string(column.type, db_type)}" + + if not column.nullable: + col_sql += " NOT NULL" + + if column.default: + if db_type == DatabaseType.POSTGRESQL: + col_sql += f" DEFAULT {column.default}" + elif db_type == DatabaseType.MYSQL: + col_sql += f" DEFAULT {column.default}" + elif db_type == DatabaseType.SQLITE: + if "CURRENT_TIMESTAMP" in str(column.default): + col_sql += " DEFAULT CURRENT_TIMESTAMP" + elif db_type == DatabaseType.SNOWFLAKE: + col_sql += f" DEFAULT {column.default}" + elif db_type == DatabaseType.DATABRICKS: + col_sql += f" DEFAULT {column.default}" + + return f"ALTER TABLE {table_name} ADD COLUMN {col_sql};" + + +def generate_drop_column_sql( + table_name: str, column_name: str, db_type: DatabaseType +) -> str: + """ + Generate ALTER TABLE DROP COLUMN SQL statement. + + Args: + table_name (str): Name of the table. + column_name (str): Name of the column to drop. + db_type (DatabaseType): The database type. + + Returns: + str: ALTER TABLE DROP COLUMN SQL statement. + """ + if db_type == DatabaseType.DATABRICKS: + return f"ALTER TABLE {table_name} DROP COLUMN {column_name};" + return f"ALTER TABLE {table_name} DROP COLUMN {column_name};" + + +def generate_create_index_sql( + table_name: str, + index_info: dict[str, Any], + db_type: DatabaseType, +) -> str: + """ + Generate CREATE INDEX SQL statement. + + Args: + table_name (str): Name of the table. + index_info (dict): Index information. + db_type (DatabaseType): The database type. + + Returns: + str: CREATE INDEX SQL statement. + """ + index_name = index_info["name"] + columns = ", ".join(index_info["column_names"]) + + if index_info.get("unique"): + if db_type == DatabaseType.DATABRICKS: + return f"CREATE UNIQUE INDEX {index_name} ON {table_name} ({columns});" + return f"CREATE UNIQUE INDEX {index_name} ON {table_name} ({columns});" + else: + return f"CREATE INDEX {index_name} ON {table_name} ({columns});" + + +def generate_drop_index_sql( + index_name: str, table_name: str, db_type: DatabaseType +) -> str: + """ + Generate DROP INDEX SQL statement. + + Args: + index_name (str): Name of the index. + table_name (str): Name of the table. + db_type (DatabaseType): The database type. + + Returns: + str: DROP INDEX SQL statement. + """ + if db_type == DatabaseType.DATABRICKS: + return f"DROP INDEX {index_name} ON {table_name};" + return f"DROP INDEX {index_name} ON {table_name};" + + +def generate_add_foreign_key_sql( + table_name: str, + fk_info: dict[str, Any], + db_type: DatabaseType, +) -> str: + """ + Generate ALTER TABLE ADD FOREIGN KEY SQL statement. + + Args: + table_name (str): Name of the table. + fk_info (dict): Foreign key information. + db_type (DatabaseType): The database type. + + Returns: + str: ALTER TABLE ADD FOREIGN KEY SQL statement. + """ + fk_name = fk_info["name"] + columns = ", ".join(fk_info["constrained_columns"]) + ref_table = fk_info["referred_table"] + ref_columns = ", ".join(fk_info["referred_columns"]) + + return ( + f"ALTER TABLE {table_name} ADD CONSTRAINT {fk_name} " + f"FOREIGN KEY ({columns}) REFERENCES {ref_table} ({ref_columns});" + ) + + +def generate_drop_foreign_key_sql( + table_name: str, + fk_name: str, + db_type: DatabaseType, +) -> str: + """ + Generate ALTER TABLE DROP FOREIGN KEY SQL statement. + + Args: + table_name (str): Name of the table. + fk_name (str): Name of the foreign key constraint. + db_type (DatabaseType): The database type. + + Returns: + str: ALTER TABLE DROP FOREIGN KEY SQL statement. + """ + return f"ALTER TABLE {table_name} DROP CONSTRAINT {fk_name};" + + +def generate_upgrade_sql(diff: SchemaDiff, db_type: DatabaseType) -> str: + """ + Generate the upgrade SQL from a schema diff. + + Args: + diff (SchemaDiff): The schema diff. + db_type (DatabaseType): The database type. + + Returns: + str: Complete upgrade SQL statement. + """ + statements = [] + + for table_name in diff.tables_to_create: + pass + + for table_name in diff.columns_to_add: + for column in diff.columns_to_add[table_name]: + statements.append(generate_add_column_sql(table_name, column, db_type)) + + for table_name in diff.indexes_to_create: + for index_info in diff.indexes_to_create[table_name]: + statements.append( + generate_create_index_sql(table_name, index_info, db_type) + ) + + for table_name in diff.foreign_keys_to_create: + for fk_info in diff.foreign_keys_to_create[table_name]: + statements.append( + generate_add_foreign_key_sql(table_name, fk_info, db_type) + ) + + return "\n\n".join(statements) + + +def generate_rollback_sql(diff: SchemaDiff, db_type: DatabaseType) -> str: + """ + Generate the rollback SQL from a schema diff. + + Args: + diff (SchemaDiff): The schema diff. + db_type (DatabaseType): The database type. + + Returns: + str: Complete rollback SQL statement. + """ + statements = [] + + for table_name in reversed(diff.tables_to_create): + statements.append(generate_drop_table_sql(table_name, db_type)) + + for table_name in diff.foreign_keys_to_drop: + for fk_name in diff.foreign_keys_to_drop[table_name]: + statements.append( + generate_drop_foreign_key_sql(table_name, fk_name, db_type) + ) + + for table_name in diff.indexes_to_drop: + for index_name in diff.indexes_to_drop[table_name]: + statements.append(generate_drop_index_sql(index_name, table_name, db_type)) + + for table_name in diff.columns_to_remove: + for column_name in diff.columns_to_remove[table_name]: + statements.append( + generate_drop_column_sql(table_name, column_name, db_type) + ) + + for table_name in diff.columns_to_add: + for column in diff.columns_to_add[table_name]: + statements.append( + generate_drop_column_sql(table_name, column.name, db_type) + ) + + return "\n\n".join(statements) + + +def generate_migration_sql(diff: SchemaDiff) -> tuple[str, str]: + """ + Generate both upgrade and rollback SQL from a schema diff. + + Args: + diff (SchemaDiff): The schema diff. + + Returns: + tuple: (upgrade_sql, rollback_sql) + """ + db_type = get_db_type() + upgrade_sql = generate_upgrade_sql(diff, db_type) + rollback_sql = generate_rollback_sql(diff, db_type) + return upgrade_sql, rollback_sql From 3ab6a2fd731760ee232c235024f424fe03f4793c Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 19:21:20 -0300 Subject: [PATCH 08/40] Update README.md --- README.md | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/README.md b/README.md index 4e205c9..d268ada 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Jetbase helps you manage database migrations in a simple, version-controlled way - **🔒 Migration Locking** — Prevents conflicts when multiple processes try to migrate - **✅ Checksum Validation** — Detects if migration files have been modified - **🔄 Repeatable Migrations** — Support for migrations that run on every upgrade +- **🤖 Auto-Generation** — Automatically generate SQL migrations from SQLAlchemy models [📚 Full Documentation](https://jetbase-hq.github.io/jetbase/) @@ -126,6 +127,111 @@ pip install psycopg2 You can also use another compatible driver if you prefer (such as `asyncpg`, `pg8000`, etc.). +--- + +## Automatic Migration Generation 🤖 + +Jetbase can automatically generate SQL migration files from your SQLAlchemy model definitions. This feature is similar to Django's `makemigrations` command. + +### Prerequisites + +1. Create SQLAlchemy model files with declarative base classes: + +```python +# models.py +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) + name = Column(String(100)) + +class Product(Base): + __tablename__ = "products" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + price = Column(Integer) +``` + +2. Set the `JETBASE_MODELS` environment variable to point to your model files: + +```bash +# Single model file +export JETBASE_MODELS="./models.py" + +# Multiple model files (comma-separated) +export JETBASE_MODELS="./models/user.py,./models/product.py,./models/order.py" +``` + +### Generate Migrations Automatically + +```bash +jetbase make-migrations --description "create users and products" +``` + +This will: +1. Read your model definitions from the paths specified in `JETBASE_MODELS` +2. Introspect your current database schema +3. Compare models against the database +4. Generate a migration file with upgrade and rollback SQL + +Example generated migration: + +```sql +-- upgrade + +CREATE TABLE users ( + id INTEGER NOT NULL PRIMARY KEY, + email VARCHAR(255) NOT NULL, + name VARCHAR(100) +); + +CREATE TABLE products ( + id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + price INTEGER +); + +-- rollback + +DROP TABLE products; +DROP TABLE users; +``` + +### Apply the Generated Migration + +```bash +# Using the new migrate command (alias for upgrade) +jetbase migrate + +# Or use the traditional upgrade command +jetbase upgrade +``` + +### Supported Operations + +The auto-generation feature detects: +- ✅ New tables to create +- ✅ Columns added/removed/modified +- ✅ Index creation/removal +- ✅ Foreign key creation/removal + +### Environment Variable Configuration + +| Variable | Description | +|----------|-------------| +| `JETBASE_MODELS` | Comma-separated paths to SQLAlchemy model files | + +You can also configure model paths in `jetbase/env.py`: + +```python +model_paths = ["./models/user.py", "./models/product.py"] +``` + ## Supported Databases Jetbase currently supports: @@ -136,6 +242,20 @@ Jetbase currently supports: - ✅ Databricks - ✅ MySQL +## Commands Reference + +| Command | Description | +|---------|-------------| +| `jetbase init` | Initialize a new Jetbase project | +| `jetbase new "description"` | Create a new manual migration file | +| `jetbase make-migrations` | Auto-generate SQL from SQLAlchemy models | +| `jetbase upgrade` | Apply pending migrations | +| `jetbase migrate` | Apply migrations (alias for upgrade) | +| `jetbase rollback` | Rollback migrations | +| `jetbase status` | Show migration status | +| `jetbase history` | Show migration history | +| `jetbase current` | Show current version | + ## Need Help? Open an issue on GitHub! From cd6ff24b830603fa4bd6d391ee0a9fa21a046174 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 19:21:24 -0300 Subject: [PATCH 09/40] Tests --- tests/unit/commands/test_make_migrations.py | 72 ++++ tests/unit/commands/test_migrate.py | 125 +++++++ tests/unit/config/test_config_models.py | 132 +++++++ tests/unit/engine/test_model_discovery.py | 251 +++++++++++++ tests/unit/engine/test_schema_diff.py | 295 +++++++++++++++ .../unit/engine/test_schema_introspection.py | 300 ++++++++++++++++ tests/unit/engine/test_sql_generator.py | 335 ++++++++++++++++++ 7 files changed, 1510 insertions(+) create mode 100644 tests/unit/commands/test_make_migrations.py create mode 100644 tests/unit/commands/test_migrate.py create mode 100644 tests/unit/config/test_config_models.py create mode 100644 tests/unit/engine/test_model_discovery.py create mode 100644 tests/unit/engine/test_schema_diff.py create mode 100644 tests/unit/engine/test_schema_introspection.py create mode 100644 tests/unit/engine/test_sql_generator.py diff --git a/tests/unit/commands/test_make_migrations.py b/tests/unit/commands/test_make_migrations.py new file mode 100644 index 0000000..3a558d3 --- /dev/null +++ b/tests/unit/commands/test_make_migrations.py @@ -0,0 +1,72 @@ +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from jetbase.commands.make_migrations import ( + MakeMigrationsError, + make_migrations_cmd, +) +from jetbase.constants import MIGRATIONS_DIR + + +@pytest.fixture +def sample_model_file(tmp_path): + """Create a temporary model file with SQLAlchemy models.""" + model_content = """ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) +""" + model_path = tmp_path / "models.py" + model_path.write_text(model_content) + return str(model_path) + + +@pytest.fixture +def migrations_dir(tmp_path): + """Create a temporary migrations directory.""" + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + return migrations_dir + + +class TestMakeMigrationsCmd: + """Tests for make_migrations_cmd function.""" + + def test_make_migrations_model_paths_not_set(self, tmp_path, migrations_dir): + """Test error when JETBASE_MODELS is not set.""" + os.chdir(tmp_path) + + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(MakeMigrationsError) as exc_info: + make_migrations_cmd() + + assert "JETBASE_MODELS" in str(exc_info.value) + + def test_make_migrations_model_file_not_found(self, tmp_path, migrations_dir): + """Test error when model file path doesn't exist.""" + os.chdir(tmp_path) + + with patch.dict(os.environ, {"JETBASE_MODELS": "./nonexistent/models.py"}): + with pytest.raises(MakeMigrationsError) as exc_info: + make_migrations_cmd() + + assert "not found" in str(exc_info.value).lower() + + +class TestMakeMigrationsError: + """Tests for MakeMigrationsError exception.""" + + def test_make_migrations_error(self): + """Test MakeMigrationsError can be raised.""" + with pytest.raises(MakeMigrationsError): + raise MakeMigrationsError("Test error message") diff --git a/tests/unit/commands/test_migrate.py b/tests/unit/commands/test_migrate.py new file mode 100644 index 0000000..41bcd9f --- /dev/null +++ b/tests/unit/commands/test_migrate.py @@ -0,0 +1,125 @@ +from unittest.mock import patch, MagicMock + +import pytest +from typer.testing import CliRunner + +from jetbase.cli.main import app + + +@pytest.fixture +def runner(): + """Create a CLI runner for testing.""" + return CliRunner() + + +class TestMigrateCommand: + """Tests for the migrate CLI command (alias to upgrade).""" + + def test_migrate_with_count_option(self, runner): + """Test migrate with --count option.""" + with patch("jetbase.cli.main.upgrade_cmd") as mock_upgrade: + with patch("jetbase.cli.main.validate_jetbase_directory"): + result = runner.invoke(app, ["migrate", "--count", "1"]) + + mock_upgrade.assert_called_once() + call_kwargs = mock_upgrade.call_args[1] + assert call_kwargs["count"] == 1 + + def test_migrate_with_to_version_option(self, runner): + """Test migrate with --to-version option.""" + with patch("jetbase.cli.main.upgrade_cmd") as mock_upgrade: + with patch("jetbase.cli.main.validate_jetbase_directory"): + result = runner.invoke(app, ["migrate", "--to-version", "1_5"]) + + mock_upgrade.assert_called_once() + call_kwargs = mock_upgrade.call_args[1] + assert call_kwargs["to_version"] == "1.5" + + def test_migrate_with_dry_run_option(self, runner): + """Test migrate with --dry-run option.""" + with patch("jetbase.cli.main.upgrade_cmd") as mock_upgrade: + with patch("jetbase.cli.main.validate_jetbase_directory"): + result = runner.invoke(app, ["migrate", "--dry-run"]) + + mock_upgrade.assert_called_once() + call_kwargs = mock_upgrade.call_args[1] + assert call_kwargs["dry_run"] is True + + def test_migrate_with_skip_validation(self, runner): + """Test migrate with --skip-validation option.""" + with patch("jetbase.cli.main.upgrade_cmd") as mock_upgrade: + with patch("jetbase.cli.main.validate_jetbase_directory"): + result = runner.invoke(app, ["migrate", "--skip-validation"]) + + mock_upgrade.assert_called_once() + call_kwargs = mock_upgrade.call_args[1] + assert call_kwargs["skip_validation"] is True + + def test_migrate_with_skip_checksum_validation(self, runner): + """Test migrate with --skip-checksum-validation option.""" + with patch("jetbase.cli.main.upgrade_cmd") as mock_upgrade: + with patch("jetbase.cli.main.validate_jetbase_directory"): + result = runner.invoke(app, ["migrate", "--skip-checksum-validation"]) + + mock_upgrade.assert_called_once() + call_kwargs = mock_upgrade.call_args[1] + assert call_kwargs["skip_checksum_validation"] is True + + def test_migrate_with_skip_file_validation(self, runner): + """Test migrate with --skip-file-validation option.""" + with patch("jetbase.cli.main.upgrade_cmd") as mock_upgrade: + with patch("jetbase.cli.main.validate_jetbase_directory"): + result = runner.invoke(app, ["migrate", "--skip-file-validation"]) + + mock_upgrade.assert_called_once() + call_kwargs = mock_upgrade.call_args[1] + assert call_kwargs["skip_file_validation"] is True + + def test_migrate_with_multiple_options(self, runner): + """Test migrate with multiple options.""" + with patch("jetbase.cli.main.upgrade_cmd") as mock_upgrade: + with patch("jetbase.cli.main.validate_jetbase_directory"): + result = runner.invoke( + app, + [ + "migrate", + "--count", + "5", + "--dry-run", + "--skip-validation", + ], + ) + + mock_upgrade.assert_called_once() + call_kwargs = mock_upgrade.call_args[1] + assert call_kwargs["count"] == 5 + assert call_kwargs["dry_run"] is True + assert call_kwargs["skip_validation"] is True + + +class TestMakeMigrationsCommand: + """Tests for the make-migrations CLI command.""" + + def test_make_migrations_command_exists(self, runner): + """Test that make-migrations command is registered.""" + result = runner.invoke(app, ["make-migrations", "--help"]) + assert result.exit_code == 0 + assert "Automatically generate SQL migration files" in result.output + + def test_make_migrations_with_description(self, runner): + """Test make-migrations with --description option.""" + with patch("jetbase.cli.main.make_migrations_cmd") as mock_make: + with patch("jetbase.cli.main.validate_jetbase_directory"): + result = runner.invoke( + app, ["make-migrations", "--description", "create users"] + ) + + mock_make.assert_called_once_with(description="create users") + + def test_make_migrations_without_description(self, runner): + """Test make-migrations without --description option.""" + with patch("jetbase.cli.main.make_migrations_cmd") as mock_make: + with patch("jetbase.cli.main.validate_jetbase_directory"): + result = runner.invoke(app, ["make-migrations"]) + + mock_make.assert_called_once_with(description=None) diff --git a/tests/unit/config/test_config_models.py b/tests/unit/config/test_config_models.py new file mode 100644 index 0000000..be96569 --- /dev/null +++ b/tests/unit/config/test_config_models.py @@ -0,0 +1,132 @@ +import os +from unittest.mock import patch + +import pytest + +from jetbase.config import ( + JetbaseConfig, + _get_config_from_env_var, + get_config, +) + + +class TestJetbaseConfigModelPaths: + """Tests for model_paths configuration.""" + + def test_jetbase_config_with_model_paths(self): + """Test JetbaseConfig with model_paths.""" + config = JetbaseConfig( + sqlalchemy_url="postgresql://localhost/testdb", + model_paths=["./models/user.py", "./models/product.py"], + ) + + assert config.model_paths == ["./models/user.py", "./models/product.py"] + + def test_jetbase_config_without_model_paths(self): + """Test JetbaseConfig without model_paths defaults to None.""" + config = JetbaseConfig( + sqlalchemy_url="postgresql://localhost/testdb", + ) + + assert config.model_paths is None + + def test_jetbase_config_with_empty_model_paths(self): + """Test JetbaseConfig with empty model_paths list.""" + config = JetbaseConfig( + sqlalchemy_url="postgresql://localhost/testdb", + model_paths=[], + ) + + assert config.model_paths == [] + + +class TestGetConfigFromEnvVar: + """Tests for _get_config_from_env_var with model_paths.""" + + def test_model_paths_env_var_single_path(self): + """Test parsing single model path from env var.""" + with patch.dict(os.environ, {"JETBASE_MODEL_PATHS": "./models.py"}): + result = _get_config_from_env_var("model_paths") + + assert result == ["./models.py"] + + def test_model_paths_env_var_multiple_paths(self): + """Test parsing multiple model paths from env var.""" + with patch.dict( + os.environ, + { + "JETBASE_MODEL_PATHS": "./models/user.py,./models/product.py,./models/order.py" + }, + ): + result = _get_config_from_env_var("model_paths") + + assert result == [ + "./models/user.py", + "./models/product.py", + "./models/order.py", + ] + + def test_model_paths_env_var_with_spaces(self): + """Test parsing model paths with spaces around paths.""" + with patch.dict( + os.environ, + {"JETBASE_MODEL_PATHS": " ./models/user.py , ./models/product.py "}, + ): + result = _get_config_from_env_var("model_paths") + + assert result == ["./models/user.py", "./models/product.py"] + + def test_model_paths_env_var_empty(self): + """Test parsing empty model paths from env var.""" + with patch.dict(os.environ, {"JETBASE_MODEL_PATHS": ""}): + result = _get_config_from_env_var("model_paths") + + assert result is None + + def test_model_paths_env_var_whitespace_only(self): + """Test parsing whitespace-only model paths from env var.""" + with patch.dict(os.environ, {"JETBASE_MODEL_PATHS": " , "}): + result = _get_config_from_env_var("model_paths") + + assert result is None + + def test_model_paths_env_var_not_set(self): + """Test model_paths returns None when env var not set.""" + with patch.dict(os.environ, {}, clear=True): + result = _get_config_from_env_var("model_paths") + + assert result is None + + +class TestGetConfig: + """Tests for get_config with model_paths.""" + + def test_get_config_with_model_paths(self): + """Test get_config loads model_paths from env var.""" + with patch.dict( + os.environ, {"JETBASE_MODEL_PATHS": "./models/user.py,./models/product.py"} + ): + config = get_config() + + assert config.model_paths == ["./models/user.py", "./models/product.py"] + + def test_get_config_model_paths_not_in_env(self): + """Test get_config returns None for model_paths when not in env.""" + with patch.dict(os.environ, {}, clear=True): + config = get_config() + + assert config.model_paths is None + + def test_get_config_boolean_still_works(self): + """Test that boolean config values still work.""" + with patch.dict(os.environ, {"JETBASE_SKIP_VALIDATION": "true"}): + config = get_config() + + assert config.skip_validation is True + + def test_get_config_boolean_false(self): + """Test that boolean false config values work.""" + with patch.dict(os.environ, {"JETBASE_SKIP_VALIDATION": "false"}): + config = get_config() + + assert config.skip_validation is False diff --git a/tests/unit/engine/test_model_discovery.py b/tests/unit/engine/test_model_discovery.py new file mode 100644 index 0000000..e27d235 --- /dev/null +++ b/tests/unit/engine/test_model_discovery.py @@ -0,0 +1,251 @@ +import os +import tempfile +from unittest.mock import patch + +import pytest + +from jetbase.engine.model_discovery import ( + ModelDiscoveryError, + ModelFileNotFoundError, + ModelImportError, + ModelPathsNotSetError, + NoModelsFoundError, + discover_all_models, + discover_models_from_path, + find_declarative_base_in_module, + get_model_paths_from_env, + import_model_file, + validate_model_paths, +) + + +def test_get_model_paths_from_env_set(): + """Test getting model paths from environment variable.""" + with patch.dict( + os.environ, {"JETBASE_MODELS": "./models/user.py,./models/product.py"} + ): + paths = get_model_paths_from_env() + assert paths == ["./models/user.py", "./models/product.py"] + + +def test_get_model_paths_from_env_not_set(): + """Test error when JETBASE_MODELS is not set.""" + with patch.dict(os.environ, {"JETBASE_MODELS": ""}, clear=True): + with pytest.raises(ModelPathsNotSetError) as exc_info: + get_model_paths_from_env() + + assert "JETBASE_MODELS" in str(exc_info.value) + + +def test_get_model_paths_from_env_with_spaces(): + """Test getting model paths with spaces around paths.""" + with patch.dict( + os.environ, {"JETBASE_MODELS": " ./models/user.py , ./models/product.py "} + ): + paths = get_model_paths_from_env() + assert paths == ["./models/user.py", "./models/product.py"] + + +def test_validate_model_paths_valid(): + """Test validation passes with valid paths.""" + with tempfile.TemporaryDirectory() as tmpdir: + model_file = os.path.join(tmpdir, "models.py") + with open(model_file, "w") as f: + f.write("# test model") + + validate_model_paths([model_file]) + + +def test_validate_model_paths_invalid(): + """Test validation fails with invalid paths.""" + with pytest.raises(ModelFileNotFoundError) as exc_info: + validate_model_paths(["./nonexistent/path.py"]) + + assert "not found" in str(exc_info.value).lower() + + +def test_import_model_file(): + """Test importing a valid Python model file.""" + with tempfile.TemporaryDirectory() as tmpdir: + model_file = os.path.join(tmpdir, "models.py") + with open(model_file, "w") as f: + f.write("x = 1") + + module = import_model_file(model_file) + assert module.x == 1 + + +def test_import_model_file_syntax_error(): + """Test import fails with syntax error.""" + with tempfile.TemporaryDirectory() as tmpdir: + model_file = os.path.join(tmpdir, "models.py") + with open(model_file, "w") as f: + f.write("def invalid syntax here") + + with pytest.raises(ModelImportError) as exc_info: + import_model_file(model_file) + + assert "Syntax error" in str(exc_info.value) + + +def test_find_declarative_base_in_module(): + """Test finding DeclarativeBase in a module.""" + from sqlalchemy.orm import DeclarativeBase + + with tempfile.TemporaryDirectory() as tmpdir: + model_file = os.path.join(tmpdir, "models.py") + with open(model_file, "w") as f: + f.write(""" +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass +""") + + module = import_model_file(model_file) + base = find_declarative_base_in_module(module) + + assert base is not None + assert issubclass(base, DeclarativeBase) + + +def test_find_declarative_base_in_module_not_found(): + """Test finding DeclarativeBase returns None when not present.""" + with tempfile.TemporaryDirectory() as tmpdir: + model_file = os.path.join(tmpdir, "models.py") + with open(model_file, "w") as f: + f.write("x = 1") + + module = import_model_file(model_file) + base = find_declarative_base_in_module(module) + + assert base is None + + +def test_discover_models_from_path_with_models(): + """Test discovering models from a file with SQLAlchemy models.""" + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy import Column, Integer, String + + with tempfile.TemporaryDirectory() as tmpdir: + model_file = os.path.join(tmpdir, "models.py") + with open(model_file, "w") as f: + f.write(""" +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import Column, Integer, String + +class Base(DeclarativeBase): + pass + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) +""") + + models = discover_models_from_path(model_file) + + assert len(models) == 1 + assert models[0].__tablename__ == "users" + + +def test_discover_models_from_path_no_models(): + """Test discovering models returns empty list when no models present.""" + with tempfile.TemporaryDirectory() as tmpdir: + model_file = os.path.join(tmpdir, "models.py") + with open(model_file, "w") as f: + f.write("x = 1") + + models = discover_models_from_path(model_file) + + assert models == [] + + +def test_discover_all_models(): + """Test discovering all models from multiple paths.""" + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy import Column, Integer, String + + with tempfile.TemporaryDirectory() as tmpdir: + user_file = os.path.join(tmpdir, "user.py") + with open(user_file, "w") as f: + f.write(""" +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import Column, Integer, String + +class Base(DeclarativeBase): + pass + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) +""") + + product_file = os.path.join(tmpdir, "product.py") + with open(product_file, "w") as f: + f.write(""" +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import Column, Integer, String + +class Base(DeclarativeBase): + pass + +class Product(Base): + __tablename__ = "products" + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) +""") + + with patch.dict(os.environ, {"JETBASE_MODELS": f"{user_file},{product_file}"}): + base, models = discover_all_models() + + assert len(models) == 2 + assert "users" in models + assert "products" in models + + +def test_discover_all_models_no_models_found(): + """Test error when no models are found.""" + with tempfile.TemporaryDirectory() as tmpdir: + model_file = os.path.join(tmpdir, "models.py") + with open(model_file, "w") as f: + f.write("x = 1") + + with patch.dict(os.environ, {"JETBASE_MODELS": model_file}): + with pytest.raises(NoModelsFoundError) as exc_info: + discover_all_models() + + assert "No SQLAlchemy models found" in str(exc_info.value) + + +def test_discover_all_models_with_relative_paths(): + """Test discovering models with relative paths.""" + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy import Column, Integer + + with tempfile.TemporaryDirectory() as tmpdir: + model_file = os.path.join(tmpdir, "models.py") + with open(model_file, "w") as f: + f.write(""" +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import Column, Integer + +class Base(DeclarativeBase): + pass + +class Test(Base): + __tablename__ = "tests" + id = Column(Integer, primary_key=True) +""") + + original_dir = os.getcwd() + try: + os.chdir(tmpdir) + with patch.dict(os.environ, {"JETBASE_MODELS": "./models.py"}): + base, models = discover_all_models() + + assert len(models) == 1 + assert "tests" in models + finally: + os.chdir(original_dir) diff --git a/tests/unit/engine/test_schema_diff.py b/tests/unit/engine/test_schema_diff.py new file mode 100644 index 0000000..c12eccd --- /dev/null +++ b/tests/unit/engine/test_schema_diff.py @@ -0,0 +1,295 @@ +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import Column, Integer, String, create_engine, text +from sqlalchemy.orm import declarative_base + +from jetbase.engine.schema_diff import ( + SchemaDiff, + compare_schemas, + compare_tables, + get_model_table_columns, + get_model_table_info, + get_models_schema_info, + has_changes, +) +from jetbase.engine.schema_introspection import ( + ColumnInfo, + SchemaInfo, + TableInfo, +) + + +Base = declarative_base() + + +class UserModel(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) + name = Column(String(100), nullable=True) + + +class ProductModel(Base): + __tablename__ = "products" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + price = Column(Integer, nullable=False) + + +def test_schema_diff_defaults(): + """Test SchemaDiff default values.""" + diff = SchemaDiff() + + assert diff.tables_to_create == [] + assert diff.tables_to_drop == [] + assert diff.columns_to_add == {} + assert diff.columns_to_remove == {} + assert diff.columns_to_modify == {} + assert diff.indexes_to_create == {} + assert diff.indexes_to_drop == {} + + +def test_schema_diff_with_values(): + """Test SchemaDiff with values.""" + diff = SchemaDiff( + tables_to_create=["new_table"], + tables_to_drop=["old_table"], + columns_to_add={ + "users": [ColumnInfo("age", "INTEGER", True, None, False, False)] + }, + columns_to_remove={"users": ["old_col"]}, + ) + + assert diff.tables_to_create == ["new_table"] + assert diff.tables_to_drop == ["old_table"] + assert "users" in diff.columns_to_add + assert "users" in diff.columns_to_remove + + +def test_get_model_table_columns(): + """Test extracting columns from model.""" + columns = get_model_table_columns(UserModel) + + column_names = [col.name for col in columns] + assert "id" in column_names + assert "email" in column_names + assert "name" in column_names + + +def test_get_model_table_columns_pk(): + """Test that primary key is detected.""" + columns = get_model_table_columns(UserModel) + + for col in columns: + if col.name == "id": + assert col.primary_key is True + else: + assert col.primary_key is False + + +def test_get_model_table_columns_nullable(): + """Test that nullable is detected.""" + columns = get_model_table_columns(UserModel) + + for col in columns: + if col.name == "email": + assert col.nullable is False + elif col.name == "name": + assert col.nullable is True + + +def test_get_model_table_info(): + """Test extracting table info from model.""" + table_info = get_model_table_info(UserModel) + + assert table_info.name == "users" + assert len(table_info.columns) >= 2 + assert "id" in table_info.primary_keys + + +def test_get_models_schema_info(): + """Test extracting schema info from multiple models.""" + models = { + "users": UserModel, + "products": ProductModel, + } + + schema_info = get_models_schema_info(models) + + assert "users" in schema_info + assert "products" in schema_info + assert schema_info["users"].name == "users" + assert schema_info["products"].name == "products" + + +def test_compare_schemas_no_changes(): + """Test comparing identical schemas.""" + models = {"users": UserModel} + database_schema = SchemaInfo( + tables={ + "users": TableInfo( + name="users", + columns=[ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo("email", "VARCHAR(255)", False, None, False, False), + ColumnInfo("name", "VARCHAR(100)", True, None, False, False), + ], + primary_keys=["id"], + ) + } + ) + + connection = MagicMock() + + diff = compare_schemas(models, database_schema, connection) + + assert diff.tables_to_create == [] + assert diff.tables_to_drop == [] + assert not has_changes(diff) + + +def test_compare_schemas_new_table(): + """Test detecting new tables.""" + models = {"users": UserModel, "products": ProductModel} + database_schema = SchemaInfo( + tables={ + "users": TableInfo( + name="users", + columns=[ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo("email", "VARCHAR(255)", False, None, False, False), + ], + primary_keys=["id"], + ) + } + ) + + connection = MagicMock() + + diff = compare_schemas(models, database_schema, connection) + + assert "products" in diff.tables_to_create + assert "products" == diff.tables_to_create[0] + + +def test_compare_schemas_dropped_table(): + """Test detecting tables to drop.""" + models = {"users": UserModel} + database_schema = SchemaInfo( + tables={ + "users": TableInfo( + name="users", + columns=[ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo("email", "VARCHAR(255)", False, None, False, False), + ], + primary_keys=["id"], + ), + "old_table": TableInfo( + name="old_table", + columns=[ + ColumnInfo("id", "INTEGER", False, None, True, False), + ], + primary_keys=["id"], + ), + } + ) + + connection = MagicMock() + + diff = compare_schemas(models, database_schema, connection) + + assert "old_table" in diff.tables_to_drop + + +def test_compare_schemas_new_column(): + """Test detecting new columns.""" + models = {"users": UserModel} + database_schema = SchemaInfo( + tables={ + "users": TableInfo( + name="users", + columns=[ + ColumnInfo("id", "INTEGER", False, None, True, False), + ], + primary_keys=["id"], + ) + } + ) + + connection = MagicMock() + + diff = compare_schemas(models, database_schema, connection) + + assert "users" in diff.columns_to_add + column_names = [col.name for col in diff.columns_to_add["users"]] + assert "email" in column_names + assert "name" in column_names + + +def test_compare_schemas_removed_column(): + """Test detecting removed columns.""" + models = {"users": UserModel} + database_schema = SchemaInfo( + tables={ + "users": TableInfo( + name="users", + columns=[ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo("email", "VARCHAR(255)", False, None, False, False), + ColumnInfo("old_column", "INTEGER", True, None, False, False), + ], + primary_keys=["id"], + ) + } + ) + + connection = MagicMock() + + diff = compare_schemas(models, database_schema, connection) + + assert "users" in diff.columns_to_remove + assert "old_column" in diff.columns_to_remove["users"] + + +def test_compare_tables(): + """Test comparing individual tables.""" + model_table = TableInfo( + name="users", + columns=[ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo("email", "VARCHAR(255)", False, None, False, False), + ], + primary_keys=["id"], + ) + + db_table = TableInfo( + name="users", + columns=[ + ColumnInfo("id", "INTEGER", False, None, True, False), + ], + primary_keys=["id"], + ) + + diff = SchemaDiff() + compare_tables(model_table, db_table, diff, "users") + + assert "users" in diff.columns_to_add + assert "email" in [col.name for col in diff.columns_to_add["users"]] + + +def test_has_changes(): + """Test has_changes function.""" + diff_no_changes = SchemaDiff() + assert has_changes(diff_no_changes) is False + + diff_with_changes = SchemaDiff(tables_to_create=["new_table"]) + assert has_changes(diff_with_changes) is True + + diff_with_column_changes = SchemaDiff( + columns_to_add={ + "users": [ColumnInfo("new", "INTEGER", True, None, False, False)] + } + ) + assert has_changes(diff_with_column_changes) is True diff --git a/tests/unit/engine/test_schema_introspection.py b/tests/unit/engine/test_schema_introspection.py new file mode 100644 index 0000000..a4c031d --- /dev/null +++ b/tests/unit/engine/test_schema_introspection.py @@ -0,0 +1,300 @@ +import tempfile +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy import Column, Integer, String, create_engine, inspect, text +from sqlalchemy.orm import declarative_base + +from jetbase.engine.schema_introspection import ( + ColumnInfo, + SchemaInfo, + TableInfo, + compare_column, + introspect_database, + introspect_table, + schema_info_to_dict, +) + + +Base = declarative_base() + + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) + name = Column(String(100), nullable=True) + + +class Product(Base): + __tablename__ = "products" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + price = Column(Integer, nullable=False) + + +@pytest.fixture +def sqlite_engine(tmp_path): + """Create a SQLite engine for testing.""" + db_file = tmp_path / "test.db" + engine = create_engine(f"sqlite:///{db_file}") + return engine + + +@pytest.fixture +def populated_database(sqlite_engine): + """Create a database with tables for testing.""" + Base.metadata.create_all(sqlite_engine) + yield sqlite_engine + + +def test_column_info_creation(): + """Test ColumnInfo dataclass creation.""" + col = ColumnInfo( + name="id", + type="INTEGER", + nullable=False, + default=None, + primary_key=True, + autoincrement=True, + ) + + assert col.name == "id" + assert col.type == "INTEGER" + assert col.nullable is False + assert col.primary_key is True + assert col.autoincrement is True + + +def test_table_info_creation(): + """Test TableInfo dataclass creation.""" + table = TableInfo( + name="users", + columns=[ + ColumnInfo( + name="id", + type="INTEGER", + nullable=False, + default=None, + primary_key=True, + autoincrement=False, + ) + ], + primary_keys=["id"], + ) + + assert table.name == "users" + assert len(table.columns) == 1 + assert table.columns[0].name == "id" + assert table.primary_keys == ["id"] + + +def test_schema_info_creation(): + """Test SchemaInfo dataclass creation.""" + schema = SchemaInfo( + tables={ + "users": TableInfo( + name="users", + columns=[], + primary_keys=[], + ) + } + ) + + assert "users" in schema.tables + assert schema.tables["users"].name == "users" + + +def test_compare_column_equal(): + """Test comparing identical columns.""" + col1 = ColumnInfo( + name="id", + type="INTEGER", + nullable=False, + default=None, + primary_key=True, + autoincrement=False, + ) + col2 = ColumnInfo( + name="id", + type="INTEGER", + nullable=False, + default=None, + primary_key=True, + autoincrement=False, + ) + + assert compare_column(col1, col2) is True + + +def test_compare_column_different_name(): + """Test comparing columns with different names.""" + col1 = ColumnInfo( + name="id", + type="INTEGER", + nullable=False, + default=None, + primary_key=True, + autoincrement=False, + ) + col2 = ColumnInfo( + name="user_id", + type="INTEGER", + nullable=False, + default=None, + primary_key=True, + autoincrement=False, + ) + + assert compare_column(col1, col2) is False + + +def test_compare_column_different_type(): + """Test comparing columns with different types.""" + col1 = ColumnInfo( + name="id", + type="INTEGER", + nullable=False, + default=None, + primary_key=True, + autoincrement=False, + ) + col2 = ColumnInfo( + name="id", + type="VARCHAR(255)", + nullable=False, + default=None, + primary_key=True, + autoincrement=False, + ) + + assert compare_column(col1, col2) is False + + +def test_compare_column_different_nullability(): + """Test comparing columns with different nullability.""" + col1 = ColumnInfo( + name="email", + type="VARCHAR(255)", + nullable=False, + default=None, + primary_key=False, + autoincrement=False, + ) + col2 = ColumnInfo( + name="email", + type="VARCHAR(255)", + nullable=True, + default=None, + primary_key=False, + autoincrement=False, + ) + + assert compare_column(col1, col2) is False + + +def test_schema_info_to_dict(): + """Test converting SchemaInfo to dictionary.""" + schema = SchemaInfo( + tables={ + "users": TableInfo( + name="users", + columns=[ + ColumnInfo( + name="id", + type="INTEGER", + nullable=False, + default=None, + primary_key=True, + autoincrement=True, + ) + ], + primary_keys=["id"], + ) + } + ) + + result = schema_info_to_dict(schema) + + assert "tables" in result + assert "users" in result["tables"] + assert len(result["tables"]["users"]["columns"]) == 1 + assert result["tables"]["users"]["columns"][0]["name"] == "id" + + +def test_introspect_empty_database(sqlite_engine): + """Test introspecting an empty database.""" + with sqlite_engine.connect() as connection: + schema = introspect_database(connection) + + assert len(schema.tables) == 0 + + +def test_introspect_populated_database(populated_database): + """Test introspecting a database with tables.""" + with populated_database.connect() as connection: + schema = introspect_database(connection) + + assert "users" in schema.tables + assert "products" in schema.tables + + +def test_introspect_table_columns(populated_database): + """Test introspecting table columns.""" + inspector = inspect(populated_database) + + table_info = introspect_table(inspector, "users") + + assert table_info.name == "users" + column_names = [col.name for col in table_info.columns] + assert "id" in column_names + assert "email" in column_names + assert "name" in column_names + + +def test_introspect_table_primary_keys(populated_database): + """Test introspecting table primary keys.""" + inspector = inspect(populated_database) + + table_info = introspect_table(inspector, "users") + + assert "id" in table_info.primary_keys + + +def test_introspect_table_foreign_keys(populated_database): + """Test introspecting table foreign keys (empty in test case).""" + inspector = inspect(populated_database) + + table_info = introspect_table(inspector, "users") + + assert table_info.foreign_keys == [] + + +def test_introspect_table_indexes(populated_database): + """Test introspecting table indexes.""" + inspector = inspect(populated_database) + + table_info = introspect_table(inspector, "users") + + assert isinstance(table_info.indexes, list) + + +def test_introspect_with_schema(): + """Test that introspect_table function accepts schema parameter.""" + with patch("jetbase.engine.schema_introspection.get_config") as mock_config: + mock_config.return_value.postgres_schema = "public" + + with patch("jetbase.engine.schema_introspection.detect_db") as mock_detect: + mock_detect.return_value = "postgresql" + + inspector = MagicMock() + inspector.get_columns.return_value = [] + inspector.get_pk_constraint.return_value = {"constrained_columns": []} + inspector.get_foreign_keys.return_value = [] + inspector.get_indexes.return_value = [] + inspector.get_unique_constraints.return_value = [] + + table_info = introspect_table(inspector, "users", schema="public") + + assert table_info.name == "users" + inspector.get_columns.assert_called_with("users", schema="public") diff --git a/tests/unit/engine/test_sql_generator.py b/tests/unit/engine/test_sql_generator.py new file mode 100644 index 0000000..667f7b4 --- /dev/null +++ b/tests/unit/engine/test_sql_generator.py @@ -0,0 +1,335 @@ +from unittest.mock import patch + +import pytest + +from jetbase.engine.schema_diff import SchemaDiff +from jetbase.engine.schema_introspection import ColumnInfo +from jetbase.engine.sql_generator import ( + generate_add_column_sql, + generate_add_foreign_key_sql, + generate_create_index_sql, + generate_create_table_sql, + generate_drop_column_sql, + generate_drop_foreign_key_sql, + generate_drop_index_sql, + generate_drop_table_sql, + generate_migration_sql, + generate_rollback_sql, + generate_upgrade_sql, + get_db_type, + sql_type_to_string, +) +from jetbase.enums import DatabaseType + + +class TestSQLTypeToString: + """Tests for sql_type_to_string function.""" + + def test_integer_postgresql(self): + """Test INTEGER type conversion for PostgreSQL.""" + result = sql_type_to_string("INTEGER", DatabaseType.POSTGRESQL) + assert result == "INTEGER" + + def test_varchar_postgresql(self): + """Test VARCHAR type conversion for PostgreSQL.""" + result = sql_type_to_string("VARCHAR(255)", DatabaseType.POSTGRESQL) + assert result == "VARCHAR(255)" + + def test_integer_mysql(self): + """Test INTEGER type conversion for MySQL.""" + result = sql_type_to_string("INTEGER", DatabaseType.MYSQL) + assert result == "INT" + + def test_boolean_mysql(self): + """Test BOOLEAN type conversion for MySQL.""" + result = sql_type_to_string("BOOLEAN", DatabaseType.MYSQL) + assert result == "TINYINT(1)" + + def test_text_sqlite(self): + """Test TEXT type conversion for SQLite.""" + result = sql_type_to_string("TEXT", DatabaseType.SQLITE) + assert result == "TEXT" + + def test_integer_sqlite(self): + """Test INTEGER type conversion for SQLite.""" + result = sql_type_to_string("INTEGER", DatabaseType.SQLITE) + assert result == "INTEGER" + + def test_unknown_type(self): + """Test unknown type returns as-is.""" + result = sql_type_to_string("CUSTOM_TYPE", DatabaseType.POSTGRESQL) + assert result == "CUSTOM_TYPE" + + +class TestGenerateCreateTableSQL: + """Tests for generate_create_table_sql function.""" + + def test_create_simple_table_postgresql(self): + """Test generating CREATE TABLE for PostgreSQL.""" + columns = [ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo("name", "VARCHAR(255)", False, None, False, False), + ] + + sql = generate_create_table_sql( + table_name="users", + columns=columns, + primary_keys=["id"], + db_type=DatabaseType.POSTGRESQL, + ) + + assert "CREATE TABLE users" in sql + assert "id INTEGER" in sql + assert "name VARCHAR(255)" in sql + assert "PRIMARY KEY (id)" in sql + + def test_create_table_with_not_null(self): + """Test generating CREATE TABLE with NOT NULL constraints.""" + columns = [ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo("email", "VARCHAR(255)", False, None, False, False), + ] + + sql = generate_create_table_sql( + table_name="users", + columns=columns, + primary_keys=["id"], + db_type=DatabaseType.POSTGRESQL, + ) + + assert "NOT NULL" in sql + + def test_create_table_mysql(self): + """Test generating CREATE TABLE for MySQL.""" + columns = [ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo("name", "VARCHAR(255)", False, None, False, False), + ] + + sql = generate_create_table_sql( + table_name="users", + columns=columns, + primary_keys=["id"], + db_type=DatabaseType.MYSQL, + ) + + assert "ENGINE=InnoDB" in sql + assert "CHARSET=utf8mb4" in sql + + def test_create_table_sqlite(self): + """Test generating CREATE TABLE for SQLite.""" + columns = [ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo("name", "VARCHAR(255)", False, None, False, False), + ] + + sql = generate_create_table_sql( + table_name="users", + columns=columns, + primary_keys=["id"], + db_type=DatabaseType.SQLITE, + ) + + assert "CREATE TABLE users" in sql + + +class TestGenerateDropTableSQL: + """Tests for generate_drop_table_sql function.""" + + def test_drop_table_postgresql(self): + """Test generating DROP TABLE for PostgreSQL.""" + sql = generate_drop_table_sql("users", DatabaseType.POSTGRESQL) + assert sql == "DROP TABLE users;" + + def test_drop_table_databricks(self): + """Test generating DROP TABLE for Databricks with IF EXISTS.""" + sql = generate_drop_table_sql("users", DatabaseType.DATABRICKS) + assert sql == "DROP TABLE IF EXISTS users;" + + +class TestGenerateAddColumnSQL: + """Tests for generate_add_column_sql function.""" + + def test_add_column_postgresql(self): + """Test generating ALTER TABLE ADD COLUMN for PostgreSQL.""" + column = ColumnInfo("age", "INTEGER", True, None, False, False) + + sql = generate_add_column_sql("users", column, DatabaseType.POSTGRESQL) + + assert "ALTER TABLE users ADD COLUMN" in sql + assert "age INTEGER" in sql + + def test_add_column_with_not_null(self): + """Test adding NOT NULL column.""" + column = ColumnInfo("email", "VARCHAR(255)", False, None, False, False) + + sql = generate_add_column_sql("users", column, DatabaseType.POSTGRESQL) + + assert "NOT NULL" in sql + + +class TestGenerateDropColumnSQL: + """Tests for generate_drop_column_sql function.""" + + def test_drop_column(self): + """Test generating ALTER TABLE DROP COLUMN.""" + sql = generate_drop_column_sql("users", "old_column", DatabaseType.POSTGRESQL) + + assert "ALTER TABLE users DROP COLUMN old_column;" in sql + + +class TestGenerateCreateIndexSQL: + """Tests for generate_create_index_sql function.""" + + def test_create_index(self): + """Test generating CREATE INDEX.""" + index_info = { + "name": "idx_users_email", + "column_names": ["email"], + "unique": False, + } + + sql = generate_create_index_sql("users", index_info, DatabaseType.POSTGRESQL) + + assert "CREATE INDEX idx_users_email ON users (email);" in sql + + def test_create_unique_index(self): + """Test generating CREATE UNIQUE INDEX.""" + index_info = { + "name": "uniq_users_email", + "column_names": ["email"], + "unique": True, + } + + sql = generate_create_index_sql("users", index_info, DatabaseType.POSTGRESQL) + + assert "CREATE UNIQUE INDEX" in sql + + +class TestGenerateDropIndexSQL: + """Tests for generate_drop_index_sql function.""" + + def test_drop_index(self): + """Test generating DROP INDEX.""" + sql = generate_drop_index_sql( + "idx_users_email", "users", DatabaseType.POSTGRESQL + ) + + assert "DROP INDEX idx_users_email ON users;" in sql + + +class TestGenerateAddForeignKeySQL: + """Tests for generate_add_foreign_key_sql function.""" + + def test_add_foreign_key(self): + """Test generating ALTER TABLE ADD FOREIGN KEY.""" + fk_info = { + "name": "fk_users_address", + "constrained_columns": ["address_id"], + "referred_table": "addresses", + "referred_columns": ["id"], + } + + sql = generate_add_foreign_key_sql("users", fk_info, DatabaseType.POSTGRESQL) + + assert "ALTER TABLE users ADD CONSTRAINT fk_users_address" in sql + assert "FOREIGN KEY (address_id) REFERENCES addresses (id)" in sql + + +class TestGenerateDropForeignKeySQL: + """Tests for generate_drop_foreign_key_sql function.""" + + def test_drop_foreign_key(self): + """Test generating ALTER TABLE DROP FOREIGN KEY.""" + sql = generate_drop_foreign_key_sql( + "users", "fk_users_address", DatabaseType.POSTGRESQL + ) + + assert "ALTER TABLE users DROP CONSTRAINT fk_users_address;" in sql + + +class TestGenerateUpgradeSQL: + """Tests for generate_upgrade_sql function.""" + + def test_upgrade_with_columns_to_add(self): + """Test generating upgrade SQL with columns to add.""" + diff = SchemaDiff( + columns_to_add={ + "users": [ColumnInfo("age", "INTEGER", True, None, False, False)] + } + ) + + sql = generate_upgrade_sql(diff, DatabaseType.POSTGRESQL) + + assert "ALTER TABLE users ADD COLUMN" in sql + assert "age INTEGER" in sql + + def test_upgrade_with_indexes(self): + """Test generating upgrade SQL with indexes.""" + diff = SchemaDiff( + indexes_to_create={ + "users": [ + { + "name": "idx_users_email", + "column_names": ["email"], + "unique": False, + } + ] + } + ) + + sql = generate_upgrade_sql(diff, DatabaseType.POSTGRESQL) + + assert "CREATE INDEX" in sql + + +class TestGenerateRollbackSQL: + """Tests for generate_rollback_sql function.""" + + def test_rollback_with_tables_to_drop(self): + """Test generating rollback SQL with tables to drop.""" + diff = SchemaDiff(tables_to_create=["new_table"]) + + sql = generate_rollback_sql(diff, DatabaseType.POSTGRESQL) + + assert "DROP TABLE" in sql + + def test_rollback_with_columns_to_remove(self): + """Test generating rollback SQL with columns to remove.""" + diff = SchemaDiff(columns_to_remove={"users": ["new_column"]}) + + sql = generate_rollback_sql(diff, DatabaseType.POSTGRESQL) + + assert "DROP COLUMN" in sql + + +class TestGenerateMigrationSQL: + """Tests for generate_migration_sql function.""" + + def test_generate_migration_sql(self): + """Test generating complete migration SQL.""" + diff = SchemaDiff( + columns_to_add={ + "users": [ColumnInfo("phone", "VARCHAR(20)", True, None, False, False)] + } + ) + + with patch("jetbase.engine.sql_generator.get_db_type") as mock_db_type: + mock_db_type.return_value = DatabaseType.POSTGRESQL + + upgrade_sql, rollback_sql = generate_migration_sql(diff) + + assert "ALTER TABLE users ADD COLUMN phone" in upgrade_sql + assert "ALTER TABLE users DROP COLUMN phone" in rollback_sql + + def test_empty_migration(self): + """Test generating SQL for empty diff.""" + diff = SchemaDiff() + + with patch("jetbase.engine.sql_generator.get_db_type") as mock_db_type: + mock_db_type.return_value = DatabaseType.POSTGRESQL + + upgrade_sql, rollback_sql = generate_migration_sql(diff) + + assert upgrade_sql == "" + assert rollback_sql == "" From c06b9db89747950091888eca875305b6bf5a3fd9 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:41:20 -0300 Subject: [PATCH 10/40] Auto resolve Paths --- jetbase/commands/fix_checksums.py | 22 ++++++++++++++++++++-- jetbase/commands/fix_files.py | 11 ++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/jetbase/commands/fix_checksums.py b/jetbase/commands/fix_checksums.py index 619f3a6..363d124 100644 --- a/jetbase/commands/fix_checksums.py +++ b/jetbase/commands/fix_checksums.py @@ -3,6 +3,7 @@ from jetbase.constants import MIGRATIONS_DIR from jetbase.engine.checksum import calculate_checksum from jetbase.engine.file_parser import parse_upgrade_statements +from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.engine.lock import migration_lock from jetbase.engine.validation import run_migration_validations from jetbase.engine.version import get_migration_filepaths_by_version @@ -35,6 +36,11 @@ def fix_checksums_cmd(audit_only: bool = False) -> None: MigrationVersionMismatchError: If there is a mismatch between expected and actual migration versions during processing. """ + jetbase_dir = find_jetbase_directory() + if not jetbase_dir: + raise RuntimeError("Jetbase directory not found") + + migrations_dir = os.path.join(jetbase_dir, MIGRATIONS_DIR) migrated_versions_and_checksums: list[tuple[str, str]] = get_checksums_by_version() if not migrated_versions_and_checksums: @@ -51,6 +57,7 @@ def fix_checksums_cmd(audit_only: bool = False) -> None: versions_and_checksums_to_repair: list[tuple[str, str]] = _find_checksum_mismatches( migrated_versions_and_checksums=migrated_versions_and_checksums, latest_migrated_version=latest_migrated_version, + migrations_dir=migrations_dir, ) if not versions_and_checksums_to_repair: @@ -116,7 +123,9 @@ def _repair_checksums(versions_and_checksums_to_repair: list[tuple[str, str]]) - def _find_checksum_mismatches( - migrated_versions_and_checksums: list[tuple[str, str]], latest_migrated_version: str + migrated_versions_and_checksums: list[tuple[str, str]], + latest_migrated_version: str, + migrations_dir: str | None = None, ) -> list[tuple[str, str]]: """ Find migrations where the file checksum differs from the stored checksum. @@ -129,6 +138,8 @@ def _find_checksum_mismatches( containing (version, stored_checksum) from the database. latest_migrated_version (str): The most recent version that has been migrated, used to limit the scope of files checked. + migrations_dir (str | None): Path to migrations directory. + Defaults to jetbase/migrations relative to cwd. Returns: list[tuple[str, str]]: List of (version, new_checksum) tuples for @@ -142,8 +153,15 @@ def _find_checksum_mismatches( >>> _find_checksum_mismatches([("1.0", "abc123")], "1.0") [("1.0", "def456")] # If file changed """ + if migrations_dir is None: + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + migrations_dir = os.path.join(jetbase_dir, MIGRATIONS_DIR) + else: + migrations_dir = os.path.join(os.getcwd(), MIGRATIONS_DIR) + migration_filepaths_by_version: dict[str, str] = get_migration_filepaths_by_version( - directory=os.path.join(os.getcwd(), MIGRATIONS_DIR), + directory=migrations_dir, end_version=latest_migrated_version, ) diff --git a/jetbase/commands/fix_files.py b/jetbase/commands/fix_files.py index 9591450..64c6481 100644 --- a/jetbase/commands/fix_files.py +++ b/jetbase/commands/fix_files.py @@ -1,5 +1,7 @@ import os +from jetbase.constants import MIGRATIONS_DIR +from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.engine.lock import migration_lock from jetbase.engine.repeatable import get_repeatable_filenames from jetbase.engine.version import ( @@ -32,15 +34,18 @@ def fix_files_cmd(audit_only: bool = False) -> None: Returns: None: Prints audit report or removal status to stdout. """ + jetbase_dir = find_jetbase_directory() + if not jetbase_dir: + raise RuntimeError("Jetbase directory not found") + + migrations_dir = os.path.join(jetbase_dir, MIGRATIONS_DIR) create_lock_table_if_not_exists() create_migrations_table_if_not_exists() migrated_versions: list[str] = get_migrated_versions() current_migration_filepaths_by_version: dict[str, str] = ( - get_migration_filepaths_by_version( - directory=os.path.join(os.getcwd(), "migrations") - ) + get_migration_filepaths_by_version(directory=migrations_dir) ) repeatable_migrations: list[MigrationRecord] = fetch_repeatable_migrations() all_repeatable_filenames: list[str] = get_repeatable_filenames() From 37bf373c8d2377a810ed98864e0e86863e29ea7a Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:41:24 -0300 Subject: [PATCH 11/40] Update make_migrations.py --- jetbase/commands/make_migrations.py | 127 +++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 10 deletions(-) diff --git a/jetbase/commands/make_migrations.py b/jetbase/commands/make_migrations.py index 4c42ef9..a70783d 100644 --- a/jetbase/commands/make_migrations.py +++ b/jetbase/commands/make_migrations.py @@ -1,10 +1,17 @@ +import asyncio import os +from sqlalchemy import create_engine from sqlalchemy.engine import Connection from jetbase.commands.new import _generate_new_filename from jetbase.constants import MIGRATIONS_DIR -from jetbase.database.connection import get_db_connection +from jetbase.database.connection import ( + get_db_connection, + get_async_db_connection, + get_connection, + is_async_url, +) from jetbase.engine.model_discovery import ( ModelDiscoveryError, discover_all_models, @@ -12,13 +19,13 @@ from jetbase.engine.schema_diff import compare_schemas, has_changes from jetbase.engine.schema_introspection import introspect_database from jetbase.engine.sql_generator import ( - generate_drop_table_sql, generate_add_column_sql, - generate_drop_column_sql, - generate_create_index_sql, - generate_drop_index_sql, generate_add_foreign_key_sql, + generate_create_index_sql, + generate_drop_column_sql, generate_drop_foreign_key_sql, + generate_drop_index_sql, + generate_drop_table_sql, get_db_type, ) @@ -35,7 +42,7 @@ class NoChangesDetectedError(MakeMigrationsError): pass -def generate_create_table_from_model(model_class, connection: Connection) -> str: +def _generate_create_table_from_model(model_class, connection: Connection) -> str: """ Generate CREATE TABLE SQL from a SQLAlchemy model class. @@ -67,7 +74,7 @@ def make_migrations_cmd(description: str | None = None) -> None: Generate migration files automatically from model definitions. This command: - 1. Reads JETBASE_MODELS env var + 1. Reads model paths from config/env var or auto-discovers from model/models directories 2. Validates model paths exist 3. Discovers SQLAlchemy models 4. Introspects current database schema @@ -83,13 +90,27 @@ def make_migrations_cmd(description: str | None = None) -> None: MakeMigrationsError: If migration generation fails. NoChangesDetectedError: If no schema changes are detected. """ + from jetbase.config import get_config + try: _, models = discover_all_models() except ModelDiscoveryError as e: raise MakeMigrationsError(f"Failed to discover models: {e}") + sqlalchemy_url = get_config(required={"sqlalchemy_url"}).sqlalchemy_url + + if is_async_url(sqlalchemy_url): + asyncio.run(_make_migrations_async(models, description)) + else: + _make_migrations_sync(models, description) + + +def _make_migrations_sync(models: dict, description: str | None) -> None: + """ + Generate migrations using sync database connection. + """ try: - with get_db_connection() as connection: + with get_connection() as connection: database_schema = introspect_database(connection) except Exception as e: raise MakeMigrationsError(f"Failed to introspect database: {e}") @@ -105,10 +126,85 @@ def make_migrations_cmd(description: str | None = None) -> None: upgrade_statements = [] rollback_statements = [] - with get_db_connection() as connection: + with get_connection() as connection: + for table_name in diff.tables_to_create: + model_class = models[table_name] + sql = _generate_create_table_from_model(model_class, connection) + upgrade_statements.append(sql) + rollback_statements.append(generate_drop_table_sql(table_name, db_type)) + + for table_name in diff.columns_to_add: + for column in diff.columns_to_add[table_name]: + upgrade_statements.append( + generate_add_column_sql(table_name, column, db_type) + ) + rollback_statements.append( + generate_drop_column_sql(table_name, column.name, db_type) + ) + + for table_name in diff.indexes_to_create: + for index_info in diff.indexes_to_create[table_name]: + upgrade_statements.append( + generate_create_index_sql(table_name, index_info, db_type) + ) + rollback_statements.append( + generate_drop_index_sql(index_info["name"], table_name, db_type) + ) + + for table_name in diff.foreign_keys_to_create: + for fk_info in diff.foreign_keys_to_create[table_name]: + upgrade_statements.append( + generate_add_foreign_key_sql(table_name, fk_info, db_type) + ) + rollback_statements.append( + generate_drop_foreign_key_sql(table_name, fk_info["name"], db_type) + ) + + _write_migration_file(upgrade_statements, rollback_statements, description) + + +async def _make_migrations_async(models: dict, description: str | None) -> None: + """ + Generate migrations using async database connection. + + Note: Schema introspection uses a sync connection because async introspection + is more complex and schema reading doesn't need to be async. + """ + from sqlalchemy import create_engine + + from jetbase.config import get_config + + try: + config = get_config() + sync_url = ( + config.sqlalchemy_url.replace("+asyncpg", "") + .replace("+async", "") + .replace("+aiosqlite", "") + ) + sync_url = sync_url.replace("postgresql+asyncpg:", "postgresql:").replace( + "sqlite+aiosqlite:", "sqlite:" + ) + engine = create_engine(sync_url) + with engine.connect() as sync_conn: + database_schema = introspect_database(sync_conn) + except Exception as e: + raise MakeMigrationsError(f"Failed to introspect database: {e}") + + diff = compare_schemas(models, database_schema, sync_conn) + + if not has_changes(diff): + print("No changes detected.") + return + + db_type = get_db_type() + + upgrade_statements = [] + rollback_statements = [] + + async with get_async_db_connection() as connection: for table_name in diff.tables_to_create: model_class = models[table_name] - sql = generate_create_table_from_model(model_class, connection) + sql = _generate_create_table_from_model(model_class, connection) upgrade_statements.append(sql) rollback_statements.append(generate_drop_table_sql(table_name, db_type)) @@ -139,6 +235,17 @@ def make_migrations_cmd(description: str | None = None) -> None: generate_drop_foreign_key_sql(table_name, fk_info["name"], db_type) ) + _write_migration_file(upgrade_statements, rollback_statements, description) + + +def _write_migration_file( + upgrade_statements: list[str], + rollback_statements: list[str], + description: str | None, +) -> None: + """ + Write the migration file to disk. + """ upgrade_sql = "\n\n".join(upgrade_statements) rollback_sql = "\n\n".join(rollback_statements) From 1785c7c966d54ec8204437d9fd5f0e4a6e985c0b Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:41:28 -0300 Subject: [PATCH 12/40] Update new.py --- jetbase/commands/new.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/jetbase/commands/new.py b/jetbase/commands/new.py index dd0a76d..cd51b50 100644 --- a/jetbase/commands/new.py +++ b/jetbase/commands/new.py @@ -2,9 +2,13 @@ import os from jetbase.constants import MIGRATIONS_DIR, NEW_MIGRATION_FILE_CONTENT -from jetbase.exceptions import DirectoryNotFoundError -from jetbase.engine.file_parser import is_valid_version, is_filename_length_valid -from jetbase.exceptions import MigrationFilenameTooLongError, InvalidVersionError +from jetbase.engine.file_parser import is_filename_length_valid, is_valid_version +from jetbase.engine.jetbase_locator import find_jetbase_directory +from jetbase.exceptions import ( + DirectoryNotFoundError, + InvalidVersionError, + MigrationFilenameTooLongError, +) def generate_new_migration_file_cmd( @@ -35,13 +39,18 @@ def generate_new_migration_file_cmd( >>> generate_new_migration_file_cmd("create users table") Created migration file: V20251201.120000__create_users_table.sql """ + jetbase_dir = find_jetbase_directory() + if not jetbase_dir: + raise DirectoryNotFoundError( + "Jetbase directory not found. Run 'jetbase init' to initialize jetbase." + ) - migrations_dir_path: str = os.path.join(os.getcwd(), MIGRATIONS_DIR) + migrations_dir_path: str = os.path.join(jetbase_dir, MIGRATIONS_DIR) if not os.path.exists(migrations_dir_path): raise DirectoryNotFoundError( - "Migrations directory not found. Run 'jetbase initialize' to set up jetbase.\n" - "If you have already done so, run this command from the jetbase directory." + f"Migrations directory not found at {migrations_dir_path}.\n" + "Run 'jetbase init' to create the migrations directory." ) filename: str = _generate_new_filename(description=description, version=version) From 031a5519c50278cfba186ab26ecd6c2993c967ab Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:41:44 -0300 Subject: [PATCH 13/40] Update rollback.py --- jetbase/commands/rollback.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/jetbase/commands/rollback.py b/jetbase/commands/rollback.py index fbc9416..6ecbfb4 100644 --- a/jetbase/commands/rollback.py +++ b/jetbase/commands/rollback.py @@ -1,7 +1,9 @@ import os +from jetbase.constants import MIGRATIONS_DIR from jetbase.engine.dry_run import process_dry_run from jetbase.engine.file_parser import parse_rollback_statements +from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.engine.lock import ( migration_lock, ) @@ -41,6 +43,12 @@ def rollback_cmd( ValueError: If both count and to_version are specified. VersionNotFoundError: If a required migration file is missing. """ + jetbase_dir = find_jetbase_directory() + if not jetbase_dir: + raise RuntimeError("Jetbase directory not found") + + migrations_dir = os.path.join(jetbase_dir, MIGRATIONS_DIR) + create_migrations_table_if_not_exists() create_lock_table_if_not_exists() @@ -61,7 +69,8 @@ def rollback_cmd( return versions_to_rollback: dict[str, str] = _get_versions_to_rollback( - latest_migration_versions=latest_migration_versions + latest_migration_versions=latest_migration_versions, + migrations_dir=migrations_dir, ) _validate_rollback_files_exist( @@ -121,7 +130,10 @@ def _get_latest_migration_versions( return get_latest_versions(limit=1) -def _get_versions_to_rollback(latest_migration_versions: list[str]) -> dict[str, str]: +def _get_versions_to_rollback( + latest_migration_versions: list[str], + migrations_dir: str | None = None, +) -> dict[str, str]: """ Get migration file paths for versions to rollback. @@ -131,13 +143,22 @@ def _get_versions_to_rollback(latest_migration_versions: list[str]) -> dict[str, Args: latest_migration_versions (list[str]): List of version strings to rollback, ordered oldest to newest. + migrations_dir (str | None): Path to migrations directory. + Defaults to jetbase/migrations relative to cwd. Returns: dict[str, str]: Mapping of version to file path, reversed so newest versions are rolled back first. """ + if migrations_dir is None: + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + migrations_dir = os.path.join(jetbase_dir, MIGRATIONS_DIR) + else: + migrations_dir = os.path.join(os.getcwd(), MIGRATIONS_DIR) + versions_to_rollback: dict[str, str] = get_migration_filepaths_by_version( - directory=os.path.join(os.getcwd(), "migrations"), + directory=migrations_dir, version_to_start_from=latest_migration_versions[-1], end_version=latest_migration_versions[0], ) From 44d7f714fc05949a268048b1c19859581e42e5c6 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:41:46 -0300 Subject: [PATCH 14/40] Update status.py to allow running everywhere --- jetbase/commands/status.py | 15 ++++++++++----- jetbase/commands/upgrade.py | 25 +++++++++++++++++++++---- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/jetbase/commands/status.py b/jetbase/commands/status.py index 4d8ee3e..0f12653 100644 --- a/jetbase/commands/status.py +++ b/jetbase/commands/status.py @@ -5,6 +5,7 @@ from jetbase.engine.file_parser import get_description_from_filename from jetbase.engine.formatters import get_display_version +from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.engine.repeatable import get_ra_filenames, get_runs_on_change_filepaths from jetbase.engine.version import get_migration_filepaths_by_version from jetbase.enums import MigrationType @@ -28,6 +29,12 @@ def status_cmd() -> None: Returns: None: Prints formatted tables to stdout showing migration status. """ + jetbase_dir = find_jetbase_directory() + if not jetbase_dir: + raise RuntimeError("Jetbase directory not found") + + migrations_dir = os.path.join(jetbase_dir, "migrations") + is_migrations_table: bool = migrations_table_exists() if not is_migrations_table: create_migrations_table_if_not_exists() @@ -48,7 +55,7 @@ def status_cmd() -> None: ) pending_versioned_filepaths: dict[str, str] = get_migration_filepaths_by_version( - directory=os.path.join(os.getcwd(), "migrations"), + directory=migrations_dir, version_to_start_from=latest_migrated_version, ) @@ -62,7 +69,7 @@ def status_cmd() -> None: roc_filenames_changed_only: list[str] = [ os.path.basename(filepath) for filepath in get_runs_on_change_filepaths( - directory=os.path.join(os.getcwd(), "migrations"), changed_only=True + directory=migrations_dir, changed_only=True ) ] @@ -72,9 +79,7 @@ def status_cmd() -> None: all_roc_filenames: list[str] = [ os.path.basename(filepath) - for filepath in get_runs_on_change_filepaths( - directory=os.path.join(os.getcwd(), "migrations") - ) + for filepath in get_runs_on_change_filepaths(directory=migrations_dir) ] console = Console() diff --git a/jetbase/commands/upgrade.py b/jetbase/commands/upgrade.py index 350c9b1..f819025 100644 --- a/jetbase/commands/upgrade.py +++ b/jetbase/commands/upgrade.py @@ -3,6 +3,7 @@ from jetbase.constants import MIGRATIONS_DIR from jetbase.engine.dry_run import process_dry_run from jetbase.engine.file_parser import parse_upgrade_statements +from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.engine.lock import migration_lock from jetbase.engine.repeatable import ( get_repeatable_always_filepaths, @@ -47,7 +48,6 @@ def upgrade_cmd( Raises: ValueError: If both count and to_version are specified. """ - if count is not None and to_version is not None: raise ValueError( "Cannot specify both 'count' and 'to_version' for upgrade. " @@ -58,6 +58,12 @@ def upgrade_cmd( if count < 1 or not isinstance(count, int): raise ValueError("'count' must be a positive integer.") + jetbase_dir = find_jetbase_directory() + if not jetbase_dir: + raise RuntimeError("Jetbase directory not found") + + migrations_dir = os.path.join(jetbase_dir, MIGRATIONS_DIR) + create_migrations_table_if_not_exists() create_lock_table_if_not_exists() @@ -75,14 +81,15 @@ def upgrade_cmd( latest_migration=latest_migration, count=count, to_version=to_version, + migrations_dir=migrations_dir, ) repeatable_always_filepaths: list[str] = get_repeatable_always_filepaths( - directory=os.path.join(os.getcwd(), MIGRATIONS_DIR) + directory=migrations_dir ) runs_on_change_filepaths: list[str] = get_runs_on_change_filepaths( - directory=os.path.join(os.getcwd(), MIGRATIONS_DIR), + directory=migrations_dir, changed_only=True, ) @@ -122,6 +129,7 @@ def _get_filepaths_by_version( latest_migration: MigrationRecord | None, count: int | None = None, to_version: str | None = None, + migrations_dir: str | None = None, ) -> dict[str, str]: """ Get pending migration file paths filtered by count or target version. @@ -132,6 +140,8 @@ def _get_filepaths_by_version( count (int | None): Limit to this many migrations. Defaults to None. to_version (str | None): Include migrations up to this version. Defaults to None. + migrations_dir (str | None): Path to migrations directory. + Defaults to jetbase/migrations relative to cwd. Returns: dict[str, str]: Mapping of version to file path for pending migrations. @@ -139,8 +149,15 @@ def _get_filepaths_by_version( Raises: FileNotFoundError: If to_version is not found in pending migrations. """ + if migrations_dir is None: + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + migrations_dir = os.path.join(jetbase_dir, MIGRATIONS_DIR) + else: + migrations_dir = os.path.join(os.getcwd(), MIGRATIONS_DIR) + filepaths_by_version: dict[str, str] = get_migration_filepaths_by_version( - directory=os.path.join(os.getcwd(), MIGRATIONS_DIR), + directory=migrations_dir, version_to_start_from=latest_migration.version if latest_migration else None, ) From 281ee0232f5911928eea6b22987aa0fe787d01be Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:42:13 -0300 Subject: [PATCH 15/40] Update validators.py --- jetbase/commands/validators.py | 42 +++++++++++++++++----------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/jetbase/commands/validators.py b/jetbase/commands/validators.py index 0cf878b..0433567 100644 --- a/jetbase/commands/validators.py +++ b/jetbase/commands/validators.py @@ -1,37 +1,37 @@ from pathlib import Path +from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.exceptions import DirectoryNotFoundError def validate_jetbase_directory() -> None: """ - Ensure command is run from jetbase directory with migrations folder. + Ensure command is run from jetbase directory or a parent project directory. - Validates that the current working directory is named 'jetbase' and - contains a 'migrations' subdirectory. This validation is required - before running most Jetbase CLI commands. + Validates that: + 1. The current directory is named 'jetbase' and contains a 'migrations' folder, OR + 2. A 'jetbase' directory with 'migrations' folder exists in the current or parent directory Returns: None: Returns silently if validation passes. Raises: - DirectoryNotFoundError: If the current directory is not named - 'jetbase' or if the 'migrations' subdirectory does not exist. + DirectoryNotFoundError: If no valid jetbase directory is found. """ current_dir = Path.cwd() - # Check if current directory is named 'jetbase' - if current_dir.name != "jetbase": - raise DirectoryNotFoundError( - "Command must be run from the 'jetbase' directory.\n" - "You can run 'jetbase init' to create a Jetbase project." - ) - - # Check if migrations directory exists - migrations_dir = current_dir / "migrations" - if not migrations_dir.exists() or not migrations_dir.is_dir(): - raise DirectoryNotFoundError( - f"'migrations' directory not found in {current_dir}.\n" - "Add a migrations directory inside the 'jetbase' directory to proceed.\n" - "You can also run 'jetbase init' to create a Jetbase project." - ) + if current_dir.name == "jetbase": + migrations_dir = current_dir / "migrations" + if migrations_dir.exists() and migrations_dir.is_dir(): + return + + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + return + + raise DirectoryNotFoundError( + "Jetbase directory not found. Please ensure you are either:\n" + " - In a directory named 'jetbase' with a 'migrations' subdirectory, OR\n" + " - In a project directory that contains a 'jetbase/' subdirectory with migrations\n" + "You can run 'jetbase init' to create a Jetbase project." + ) From 8162c86b84bdfcdbea989ae4e7c757eb09ad18fb Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:42:17 -0300 Subject: [PATCH 16/40] Update config.py --- jetbase/config.py | 68 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/jetbase/config.py b/jetbase/config.py index 6432a91..0154ba0 100644 --- a/jetbase/config.py +++ b/jetbase/config.py @@ -1,6 +1,7 @@ import importlib.machinery import importlib.util import os +import sys from dataclasses import dataclass from pathlib import Path from types import ModuleType @@ -9,6 +10,7 @@ import tomli from jetbase.constants import ENV_FILE +from jetbase.engine.jetbase_locator import find_jetbase_directory @dataclass @@ -97,7 +99,7 @@ def get_config( Load configuration from env.py, environment variables, or TOML files. Searches for configuration values in the following priority order: - 1. env.py file in the current directory + 1. env.py file in the jetbase directory (or current directory) 2. Environment variables (JETBASE_{KEY_IN_UPPERCASE}) 3. jetbase.toml file 4. pyproject.toml [tool.jetbase] section @@ -155,7 +157,7 @@ def _get_config_value(key: str) -> Any | None: Any | None: The configuration value from the first available source, or None if not found in any source. """ - # Try env.py + # Try env.py (from jetbase directory or cwd) value = _get_config_from_env_py(key) if value is not None: return value @@ -182,39 +184,71 @@ def _get_config_value(key: str) -> Any | None: return None -def _get_config_from_env_py(key: str, filepath: str = ENV_FILE) -> Any | None: +def _get_config_from_env_py(key: str) -> Any | None: """ Load a configuration value from the env.py file. Dynamically imports the env.py file and retrieves the specified attribute. + Looks in the jetbase directory first, then falls back to current directory. Args: key (str): The configuration key to retrieve. - filepath (str): Path to the env.py file relative to current directory. - Defaults to ENV_FILE. Returns: Any | None: The configuration value if the attribute exists, otherwise None. """ - config_path: str = os.path.join(os.getcwd(), filepath) + # First try jetbase directory + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + config_path = os.path.join(jetbase_dir, ENV_FILE) + if os.path.exists(config_path): + return _load_config_from_path(config_path, key) + + # Fall back to current directory + config_path = os.path.join(os.getcwd(), ENV_FILE) + if os.path.exists(config_path): + return _load_config_from_path(config_path, key) - if not os.path.exists(config_path): - return None + return None - spec: importlib.machinery.ModuleSpec | None = ( - importlib.util.spec_from_file_location("config", config_path) - ) - assert spec is not None - assert spec.loader is not None +def _load_config_from_path(config_path: str, key: str) -> Any | None: + """ + Load a configuration value from a specific env.py file path. - config: ModuleType = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module=config) + Automatically adds the project root (parent of jetbase directory) to sys.path + so that imports like 'from app.core.config import ...' work correctly. - config_value: Any | None = getattr(config, key, None) + Args: + config_path (str): Path to the env.py file. + key (str): The configuration key to retrieve. - return config_value + Returns: + Any | None: The configuration value if the attribute exists, + otherwise None. + """ + try: + jetbase_dir = os.path.dirname(config_path) + project_root = os.path.dirname(jetbase_dir) + + if project_root not in sys.path: + sys.path.insert(0, project_root) + + spec: importlib.machinery.ModuleSpec | None = ( + importlib.util.spec_from_file_location("config", config_path) + ) + + if spec is None or spec.loader is None: + return None + + config: ModuleType = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module=config) + + config_value: Any | None = getattr(config, key, None) + return config_value + except Exception: + return None def _get_config_from_jetbase_toml( From bdef28b56cee0f4c16d909f64c71282b85d5c447 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:42:26 -0300 Subject: [PATCH 17/40] Added Async Support --- jetbase/database/connection.py | 193 ++++++++++++++++++++------------- 1 file changed, 117 insertions(+), 76 deletions(-) diff --git a/jetbase/database/connection.py b/jetbase/database/connection.py index 7a2065c..2cec41d 100644 --- a/jetbase/database/connection.py +++ b/jetbase/database/connection.py @@ -1,46 +1,72 @@ import logging -from contextlib import contextmanager +import os +from contextlib import asynccontextmanager, contextmanager from functools import lru_cache -from typing import Any, Generator +from typing import AsyncGenerator, Generator -from sqlalchemy import Connection, Engine, create_engine, text -from sqlalchemy.engine import URL, make_url +from sqlalchemy import Connection, create_engine, text +from sqlalchemy.engine import Engine +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine from jetbase.config import get_config from jetbase.database.queries.base import detect_db from jetbase.enums import DatabaseType +def is_async_url(sqlalchemy_url: str) -> bool: + """ + Check if the SQLAlchemy URL uses an async driver. + """ + return ( + "+asyncpg" in sqlalchemy_url + or "+aiosqlite" in sqlalchemy_url + or "+async" in sqlalchemy_url + ) + + +def is_async_enabled() -> bool: + """ + Check if async mode is enabled via ASYNC environment variable. + """ + return os.getenv("ASYNC", "").lower() in ("true", "1", "yes") + + @contextmanager def get_db_connection() -> Generator[Connection, None, None]: """ Context manager that yields a database connection with a transaction. - Creates a database connection using the configured SQLAlchemy URL, - opens a transaction, and yields the connection. For PostgreSQL, - sets the search_path if a schema is configured. + For async databases, use get_async_db_connection() instead. - Yields: - Connection: A SQLAlchemy Connection object within an active - transaction. + Raises: + RuntimeError: If ASYNC=true. Example: >>> with get_db_connection() as conn: ... conn.execute(query) """ + if is_async_enabled(): + raise RuntimeError( + "ASYNC=true but using get_db_connection(). " + "Use 'async with get_async_db_connection()' for async mode." + ) + + config = get_config(required={"sqlalchemy_url"}) + url = config.sqlalchemy_url - engine: Engine = _get_engine() - db_type: DatabaseType = detect_db(sqlalchemy_url=str(engine.url)) + url = _make_sync_url(url) + + engine: Engine = create_engine(url=url) + db_type = detect_db(sqlalchemy_url=str(engine.url)) if db_type == DatabaseType.DATABRICKS: - # Suppress databricks warnings during connection with _suppress_databricks_warnings(): with engine.begin() as connection: yield connection else: with engine.begin() as connection: if db_type == DatabaseType.POSTGRESQL: - postgres_schema: str | None = get_config().postgres_schema + postgres_schema = config.postgres_schema if postgres_schema: connection.execute( text("SET search_path TO :postgres_schema"), @@ -49,89 +75,104 @@ def get_db_connection() -> Generator[Connection, None, None]: yield connection -@lru_cache(maxsize=1) -def _get_engine() -> Engine: +def _make_sync_url(url: str) -> str: + """ + Convert an async URL to sync by removing async driver suffixes. """ - Get or create the singleton SQLAlchemy Engine. + url = url.replace("+asyncpg", "") + url = url.replace("+async", "") + url = url.replace("+aiosqlite", "") + return url - Creates the engine on first call and caches it for subsequent calls. - The engine manages its own connection pool internally. - Returns: - Engine: A SQLAlchemy Engine instance. +@asynccontextmanager +async def get_async_db_connection() -> AsyncGenerator[AsyncConnection, None]: """ - sqlalchemy_url: str = get_config(required={"sqlalchemy_url"}).sqlalchemy_url - db_type: DatabaseType = detect_db(sqlalchemy_url=sqlalchemy_url) + Context manager that yields an async database connection with a transaction. - connect_args: dict[str, Any] = {} + Raises: + RuntimeError: If ASYNC=false. - if db_type == DatabaseType.SNOWFLAKE: - snowflake_url: URL = make_url(sqlalchemy_url) + Example: + >>> async with get_async_db_connection() as conn: + ... await conn.execute(query) + """ + if not is_async_enabled(): + raise RuntimeError( + "ASYNC=false but using get_async_db_connection(). " + "Use 'with get_db_connection()' for sync mode, " + "or set ASYNC=true." + ) - if not snowflake_url.password: - connect_args["private_key"] = _get_snowflake_private_key_der() + config = get_config(required={"sqlalchemy_url"}) + async_engine: AsyncEngine = create_async_engine(url=config.sqlalchemy_url) - return create_engine(url=sqlalchemy_url, connect_args=connect_args) + async with async_engine.begin() as connection: + db_type = detect_db(sqlalchemy_url=str(async_engine.url)) + if db_type == DatabaseType.POSTGRESQL: + postgres_schema = config.postgres_schema + if postgres_schema: + await connection.execute( + text("SET search_path TO :postgres_schema"), + parameters={"postgres_schema": postgres_schema}, + ) + yield connection -def _get_snowflake_private_key_der() -> bytes: +class _ConnectionWrapper: """ - Retrieves the Snowflake private key in DER format for key pair authentication. - - Loads the private key from configuration (PEM format), optionally decrypts it with a password, - and returns the key encoded as DER bytes, suitable for use with Snowflake's SQLAlchemy driver. + Wrapper that provides both sync and async context manager protocols. - Returns: - bytes | None: The DER-encoded private key bytes, or None if not set. - - Raises: - ValueError: If neither Snowflake private key nor password are set in configuration. + Usage: + ASYNC=false: with get_connection() as conn: + ASYNC=true: async with get_connection() as conn: """ - # Lazy import - only needed for Snowflake key pair auth - from cryptography.hazmat.backends import ( - default_backend, # type: ignore[missing-import] - ) - from cryptography.hazmat.primitives import ( - serialization, # type: ignore[missing-import] - ) - from cryptography.hazmat.primitives.asymmetric.types import ( - PrivateKeyTypes, # type: ignore[missing-import] - ) - - snowflake_private_key: str | None = get_config().snowflake_private_key - if not snowflake_private_key: - raise ValueError( - "Snowflake private key is not set. " - "You can set it as 'JETBASE_SNOWFLAKE_PRIVATE_KEY' in an environment variable. " - "Alternatively, you can add the password to the SQLAlchemy URL." - ) + def __enter__(self): + if is_async_enabled(): + raise RuntimeError( + "ASYNC=true but using 'with' instead of 'async with'.\n" + "Use 'async with get_connection() as conn:' for async mode." + ) + cm = get_db_connection() + self._sync_cm = cm + return cm.__enter__() + + def __exit__(self, *args): + return self._sync_cm.__exit__(*args) + + async def __aenter__(self): + if not is_async_enabled(): + raise RuntimeError( + "ASYNC=false but using 'async with'.\n" + "Use 'with get_connection() as conn:' for sync mode." + ) + cm = get_async_db_connection() + self._async_cm = cm + return await cm.__aenter__() + + async def __aexit__(self, *args): + return await self._async_cm.__aexit__(*args) + + +def get_connection() -> "_ConnectionWrapper": + """ + Context manager that works with both sync and async based on ASYNC env var. - password_str: str | None = get_config().snowflake_private_key_password - password: bytes | None = password_str.encode("utf-8") if password_str else None + Usage: + ASYNC=false: with get_connection() as conn: + ASYNC=true: async with get_connection() as conn: - private_key: PrivateKeyTypes = serialization.load_pem_private_key( - snowflake_private_key.encode("utf-8"), - password=password, - backend=default_backend(), - ) - - private_key_bytes: bytes = private_key.private_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - - return private_key_bytes + Returns: + _ConnectionWrapper: A wrapper that supports both sync and async context manager protocols. + """ + return _ConnectionWrapper() @contextmanager def _suppress_databricks_warnings(): """ - Temporarily sets the databricks logger to ERROR level to suppress - the deprecated _user_agent_entry warning coming from the databricks-sqlalchemy dependency. - - Databricks-sqlalchemy is a dependency of databricks-sql-connector (which is triggering the warning), so we need to suppress the warning here until databricks-sqlalchemy is updated to fix the warning. + Temporarily sets the databricks logger to ERROR level to suppress warnings. """ logger = logging.getLogger("databricks") original_level = logger.level From 6eab29dca53255d00bb9671c69c1dd5f2cd8be88 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:42:29 -0300 Subject: [PATCH 18/40] Create jetbase_locator.py --- jetbase/engine/jetbase_locator.py | 91 +++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 jetbase/engine/jetbase_locator.py diff --git a/jetbase/engine/jetbase_locator.py b/jetbase/engine/jetbase_locator.py new file mode 100644 index 0000000..f6c203e --- /dev/null +++ b/jetbase/engine/jetbase_locator.py @@ -0,0 +1,91 @@ +import os +from pathlib import Path +from typing import Optional + +from jetbase.constants import MIGRATIONS_DIR + + +def find_jetbase_directory(start: Optional[Path] = None) -> Optional[Path]: + """ + Find the jetbase directory by searching up the directory tree. + + Searches for a directory named 'jetbase' that contains a 'migrations' + subdirectory. Can start searching from any directory; defaults to current + working directory. + + Args: + start (Path | None): Starting directory for the search. + Defaults to current working directory. + + Returns: + Path | None: The jetbase directory path if found, otherwise None. + """ + if start is None: + start = Path.cwd() + + current = start.resolve() + + while current != current.parent: + jetbase_candidate = current / "jetbase" + migrations_dir = jetbase_candidate / MIGRATIONS_DIR + + if jetbase_candidate.is_dir() and migrations_dir.is_dir(): + return jetbase_candidate + + if current.name == "jetbase" and (current / MIGRATIONS_DIR).is_dir(): + return current + + current = current.parent + + return None + + +def find_jetbase_from_subdir() -> Optional[Path]: + """ + Check if current directory is a subdirectory of jetbase and return jetbase path. + + For example, if running from project/app/ and jetbase is in project/jetbase/, + this will find and return project/jetbase/. + + Returns: + Path | None: The jetbase directory path if found, otherwise None. + """ + return find_jetbase_directory() + + +def get_jetbase_migrations_dir() -> Optional[Path]: + """ + Get the migrations directory from the jetbase folder. + + Searches for the jetbase directory and returns the migrations path. + + Returns: + Path | None: The migrations directory path if jetbase is found, otherwise None. + """ + jetbase_dir = find_jetbase_from_subdir() + if jetbase_dir: + return jetbase_dir / MIGRATIONS_DIR + return None + + +def get_jetbase_env_path() -> Optional[Path]: + """ + Get the env.py path from the jetbase folder. + + Returns: + Path | None: The env.py path if jetbase is found, otherwise None. + """ + jetbase_dir = find_jetbase_from_subdir() + if jetbase_dir: + return jetbase_dir / "env.py" + return None + + +def is_jetbase_subdir() -> bool: + """ + Check if the current directory is inside a jetbase project. + + Returns: + bool: True if inside a jetbase project, False otherwise. + """ + return find_jetbase_from_subdir() is not None From 69f8e56f91ec4b01978d6229cfb0c733456abf3a Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:43:59 -0300 Subject: [PATCH 19/40] Update model_discovery.py --- jetbase/engine/model_discovery.py | 187 +++++++++++++++++++++++++++--- 1 file changed, 173 insertions(+), 14 deletions(-) diff --git a/jetbase/engine/model_discovery.py b/jetbase/engine/model_discovery.py index 8d3aba7..d2161a5 100644 --- a/jetbase/engine/model_discovery.py +++ b/jetbase/engine/model_discovery.py @@ -1,6 +1,7 @@ import importlib.machinery import importlib.util import os +import sys from pathlib import Path from types import ModuleType from typing import Any @@ -8,6 +9,9 @@ from sqlalchemy import MetaData from sqlalchemy.orm import DeclarativeBase +from jetbase.config import get_config +from jetbase.engine.jetbase_locator import find_jetbase_directory + class ModelDiscoveryError(Exception): """Base exception for model discovery errors.""" @@ -39,6 +43,128 @@ class NoModelsFoundError(ModelDiscoveryError): pass +def _add_project_root_to_path(model_path: str) -> None: + """ + Add the project root to sys.path if needed for imports to work. + + Finds the project root (where pyproject.toml or jetbase/ is located) + and adds it to sys.path so that imports like 'from app.core.database import Base' + work correctly. + + Args: + model_path: Path to the model file being imported. + """ + model_file = Path(model_path).resolve() + + candidates = [] + + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + candidates.append(jetbase_dir.parent) + + candidates.append(model_file.parent) + + for candidate in candidates: + if candidate.exists(): + str_path = str(candidate) + if str_path not in sys.path: + sys.path.insert(0, str_path) + + +def discover_model_paths_auto() -> list[str]: + """ + Automatically discover model paths by searching for model/models directories. + + Searches the entire project tree recursively for any directory named + 'models' or 'model' and collects all Python files from them. + + Excludes common virtual environment directories (.venv, venv, node_modules, etc.) + + Returns: + list[str]: List of paths to discovered Python model files. + + Raises: + ModelDiscoveryError: If no model directories are found. + """ + discovered_paths: list[str] = [] + model_file_extensions = (".py",) + + jetbase_dir = find_jetbase_directory() + + if jetbase_dir: + project_root = jetbase_dir.parent + else: + project_root = Path.cwd() + + current = project_root.resolve() + while current != current.parent: + if current.exists(): + break + current = current.parent + + seen_files: set[str] = set() + + exclude_dirs = { + ".venv", + "venv", + "node_modules", + "__pycache__", + ".git", + ".tox", + ".nox", + "build", + "dist", + } + + for root, dirs, files in os.walk(current): + dirs[:] = [d for d in dirs if d not in exclude_dirs] + + dirname = os.path.basename(root) + if dirname == "models" or dirname == "model": + for file in files: + if file.endswith(model_file_extensions) and not file.startswith("_"): + full_path = os.path.join(root, file) + if full_path not in seen_files: + seen_files.add(full_path) + discovered_paths.append(full_path) + + return discovered_paths + + +def get_model_paths_from_config() -> list[str] | None: + """ + Get model paths from Jetbase configuration. + + Checks config.model_paths first, then falls back to JETBASE_MODELS env var. + Resolves paths relative to the jetbase directory. + + Returns: + list[str] | None: List of model paths from config, or None if not set. + """ + jetbase_dir = find_jetbase_directory() + + try: + config = get_config() + if config.model_paths: + if jetbase_dir: + return [ + os.path.normpath(os.path.join(jetbase_dir, p)) + for p in config.model_paths + ] + return config.model_paths + except ValueError: + pass + + model_paths_str = os.getenv("JETBASE_MODELS", "") + if model_paths_str: + paths = [p.strip() for p in model_paths_str.split(",") if p.strip()] + if jetbase_dir: + return [os.path.normpath(os.path.join(jetbase_dir, p)) for p in paths] + return paths + + return None + + def get_model_paths_from_env() -> list[str]: """ Get model paths from JETBASE_MODELS environment variable. @@ -49,22 +175,25 @@ def get_model_paths_from_env() -> list[str]: Raises: ModelPathsNotSetError: If JETBASE_MODELS is not set. """ - model_paths_str = os.getenv("JETBASE_MODELS", "") - if not model_paths_str: - raise ModelPathsNotSetError( - "JETBASE_MODELS environment variable is not set. " - "Please set it to the path(s) of your SQLAlchemy model files.\n" - "Example: export JETBASE_MODELS='./models/user.py,./models/product.py'" - ) + model_paths = get_model_paths_from_config() + if model_paths: + return model_paths - paths = [p.strip() for p in model_paths_str.split(",") if p.strip()] - return paths + raise ModelPathsNotSetError( + "Model paths not configured. Please set one of the following:\n" + "1. model_paths in jetbase/env.py\n" + "2. JETBASE_MODELS environment variable\n" + "3. Place your models in a 'models/' or 'model/' directory\n" + " relative to your project or jetbase directory" + ) def validate_model_paths(model_paths: list[str]) -> None: """ Validate that all model paths exist. + Checks paths as absolute first, then tries resolving relative to jetbase directory. + Args: model_paths (list[str]): List of paths to model files. @@ -73,17 +202,35 @@ def validate_model_paths(model_paths: list[str]) -> None: """ for path in model_paths: resolved_path = Path(path) - if not resolved_path.exists(): + + if resolved_path.exists(): + continue + + if resolved_path.is_absolute(): raise ModelFileNotFoundError( f"Model file not found: {path}\n" f"Please check that the path exists and is correct." ) + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + alt_path = Path(jetbase_dir) / path + if alt_path.exists(): + continue + + raise ModelFileNotFoundError( + f"Model file not found: {path}\n" + f"Please check that the path exists and is correct." + ) + def import_model_file(model_path: str) -> ModuleType: """ Dynamically import a Python model file. + Automatically adds the project root to sys.path so that relative imports + (like 'from app.core.database import Base') work correctly. + Args: model_path (str): Path to the Python model file. @@ -94,6 +241,8 @@ def import_model_file(model_path: str) -> ModuleType: ModelImportError: If the file cannot be imported. """ try: + _add_project_root_to_path(model_path) + spec = importlib.util.spec_from_file_location( name=Path(model_path).stem, location=model_path ) @@ -174,7 +323,8 @@ def discover_all_models( Args: model_paths (list[str] | None): List of paths to model files. - If None, reads from JETBASE_MODELS environment variable. + If None, first tries config/env var, then auto-discovers from + model/models directories. Returns: tuple: A tuple containing: @@ -182,13 +332,22 @@ def discover_all_models( - Dictionary mapping table names to model classes Raises: - ModelPathsNotSetError: If model_paths is None and JETBASE_MODELS is not set. ModelFileNotFoundError: If any model file path does not exist. ModelImportError: If a model file cannot be imported. NoModelsFoundError: If no SQLAlchemy models are found. """ if model_paths is None: - model_paths = get_model_paths_from_env() + model_paths = get_model_paths_from_config() + if model_paths is None: + model_paths = discover_model_paths_auto() + if not model_paths: + raise NoModelsFoundError( + "No SQLAlchemy models found.\n" + "Please either:\n" + " 1. Set model_paths in jetbase/env.py\n" + " 2. Set JETBASE_MODELS environment variable\n" + " 3. Place your models in a 'models/' or 'model/' directory" + ) validate_model_paths(model_paths) @@ -234,7 +393,7 @@ def discover_all_models( "a SQLAlchemy DeclarativeBase and have __tablename__ defined." ) - return discovered_base, all_models + return discovered_base or type("DeclarativeBase", (), {}), all_models def get_model_metadata(models: dict[str, type[Any]]) -> MetaData: From 533b888db418f978477948850923bb12d403a84e Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:44:09 -0300 Subject: [PATCH 20/40] Auto path --- jetbase/engine/repeatable.py | 19 +++++++++++++++---- jetbase/engine/schema_diff.py | 3 ++- jetbase/engine/validation.py | 8 ++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/jetbase/engine/repeatable.py b/jetbase/engine/repeatable.py index a6bfa4b..a4d1df2 100644 --- a/jetbase/engine/repeatable.py +++ b/jetbase/engine/repeatable.py @@ -6,6 +6,7 @@ parse_upgrade_statements, validate_filename_format, ) +from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.repositories.migrations_repo import ( get_existing_on_change_filenames_to_checksums, ) @@ -91,14 +92,19 @@ def get_ra_filenames() -> list[str]: """ Get all runs-always (RA__) migration filenames from the migrations directory. - Scans the 'migrations' subdirectory in the current working directory + Scans the 'migrations' subdirectory in the jetbase directory for files starting with the RA__ prefix. Returns: list[str]: List of RA__ migration filenames (not full paths). """ + jetbase_dir = find_jetbase_directory() + if not jetbase_dir: + return [] + + migrations_dir = os.path.join(jetbase_dir, "migrations") ra_filenames: list[str] = [] - for root, _, files in os.walk(os.path.join(os.getcwd(), "migrations")): + for root, _, files in os.walk(migrations_dir): for filename in files: if filename.startswith(RUNS_ALWAYS_FILE_PREFIX): ra_filenames.append(filename) @@ -109,14 +115,19 @@ def get_repeatable_filenames() -> list[str]: """ Get all repeatable migration filenames from the migrations directory. - Scans the 'migrations' subdirectory in the current working directory + Scans the 'migrations' subdirectory in the jetbase directory for files starting with either RA__ or ROC__ prefix. Returns: list[str]: List of all repeatable migration filenames (not full paths). """ + jetbase_dir = find_jetbase_directory() + if not jetbase_dir: + return [] + + migrations_dir = os.path.join(jetbase_dir, "migrations") repeatable_filenames: list[str] = [] - for root, _, files in os.walk(os.path.join(os.getcwd(), "migrations")): + for root, _, files in os.walk(migrations_dir): for filename in files: if filename.startswith(RUNS_ALWAYS_FILE_PREFIX) or filename.startswith( RUNS_ON_CHANGE_FILE_PREFIX diff --git a/jetbase/engine/schema_diff.py b/jetbase/engine/schema_diff.py index 4bcfc70..1f843d9 100644 --- a/jetbase/engine/schema_diff.py +++ b/jetbase/engine/schema_diff.py @@ -75,10 +75,11 @@ def get_model_table_info(model_class: type) -> TableInfo: ) for fk in table.foreign_keys: + constrained_columns = [c.name for c in table.c if c.foreign_keys] table_info.foreign_keys.append( { "name": fk.name, - "constrained_columns": list(fk.constrained.columns), + "constrained_columns": constrained_columns, "referred_table": fk.column.table.name, "referred_columns": [ c.name for c in fk.column.table.primary_key.columns diff --git a/jetbase/engine/validation.py b/jetbase/engine/validation.py index 181cc8c..8ecffaa 100644 --- a/jetbase/engine/validation.py +++ b/jetbase/engine/validation.py @@ -6,6 +6,7 @@ from jetbase.constants import MIGRATIONS_DIR from jetbase.engine.checksum import calculate_checksum from jetbase.engine.file_parser import parse_upgrade_statements +from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.engine.repeatable import get_repeatable_filenames from jetbase.engine.version import ( get_migration_filepaths_by_version, @@ -192,12 +193,15 @@ def run_migration_validations( OutOfOrderMigrationError: If out-of-order migrations are detected. ChecksumMismatchError: If file checksums don't match stored values. """ - skip_validation_config: bool = get_config().skip_validation skip_checksum_validation_config: bool = get_config().skip_checksum_validation skip_file_validation_config: bool = get_config().skip_file_validation - migrations_directory_path: str = os.path.join(os.getcwd(), MIGRATIONS_DIR) + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + migrations_directory_path: str = os.path.join(jetbase_dir, MIGRATIONS_DIR) + else: + migrations_directory_path: str = os.path.join(os.getcwd(), MIGRATIONS_DIR) migration_filepaths_by_version: dict[str, str] = get_migration_filepaths_by_version( directory=migrations_directory_path From 7b8e68bee77f49c5b2c65b9600bbad509dcf88cc Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:44:17 -0300 Subject: [PATCH 21/40] Update lock_repo.py --- jetbase/repositories/lock_repo.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/jetbase/repositories/lock_repo.py b/jetbase/repositories/lock_repo.py index d48f984..91dbfc3 100644 --- a/jetbase/repositories/lock_repo.py +++ b/jetbase/repositories/lock_repo.py @@ -3,7 +3,7 @@ from sqlalchemy import Result, Row from sqlalchemy.engine import CursorResult -from jetbase.database.connection import get_db_connection +from jetbase.database.connection import get_db_connection, get_connection from jetbase.database.queries.base import QueryMethod from jetbase.database.queries.query_loader import get_query from jetbase.models import LockStatus @@ -18,7 +18,7 @@ def lock_table_exists() -> bool: Returns: bool: True if the jetbase_lock table exists, False otherwise. """ - with get_db_connection() as connection: + with get_connection() as connection: result: Result[tuple[bool]] = connection.execute( statement=get_query(QueryMethod.CHECK_IF_LOCK_TABLE_EXISTS_QUERY) ) @@ -37,7 +37,7 @@ def create_lock_table_if_not_exists() -> None: Returns: None: Table is created as a side effect. """ - with get_db_connection() as connection: + with get_connection() as connection: connection.execute(get_query(query_name=QueryMethod.CREATE_LOCK_TABLE_STMT)) # Initialize with single row if empty @@ -57,7 +57,7 @@ def fetch_lock_status() -> LockStatus: LockStatus: A dataclass containing is_locked (bool) and locked_at (datetime | None) fields. """ - with get_db_connection() as connection: + with get_connection() as connection: result: Row[Any] | None = connection.execute( get_query(query_name=QueryMethod.CHECK_LOCK_STATUS_STMT) ).first() @@ -77,7 +77,7 @@ def unlock_database() -> None: Returns: None: Lock is released as a side effect. """ - with get_db_connection() as connection: + with get_connection() as connection: connection.execute(get_query(query_name=QueryMethod.FORCE_UNLOCK_STMT)) @@ -96,7 +96,7 @@ def lock_database(process_id: str) -> CursorResult: CursorResult: Result object where rowcount=1 indicates success, rowcount=0 indicates the lock is already held. """ - with get_db_connection() as connection: + with get_connection() as connection: result = connection.execute( get_query(query_name=QueryMethod.ACQUIRE_LOCK_STMT), { @@ -122,7 +122,7 @@ def release_lock(process_id: str) -> None: None: Lock is released as a side effect. """ - with get_db_connection() as connection: + with get_connection() as connection: connection.execute( get_query(query_name=QueryMethod.RELEASE_LOCK_STMT), {"process_id": process_id}, From 59e108bf0360971eed5ad9f858f09a582cfe81e6 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:44:20 -0300 Subject: [PATCH 22/40] Update migrations_repo.py --- jetbase/repositories/migrations_repo.py | 34 ++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/jetbase/repositories/migrations_repo.py b/jetbase/repositories/migrations_repo.py index 4a2b4c4..4393285 100644 --- a/jetbase/repositories/migrations_repo.py +++ b/jetbase/repositories/migrations_repo.py @@ -1,6 +1,6 @@ from sqlalchemy import Result, Row, text -from jetbase.database.connection import get_db_connection +from jetbase.database.connection import get_db_connection, get_connection from jetbase.database.queries.base import QueryMethod from jetbase.database.queries.query_loader import get_query from jetbase.engine.checksum import calculate_checksum @@ -45,7 +45,7 @@ def run_migration( if migration_operation == MigrationDirectionType.UPGRADE and filename is None: raise ValueError("Filename must be provided for upgrade migrations.") - with get_db_connection() as connection: + with get_connection() as connection: for statement in sql_statements: connection.execute(text(statement)) @@ -95,7 +95,7 @@ def run_update_repeatable_migration( """ checksum: str = calculate_checksum(sql_statements=sql_statements) - with get_db_connection() as connection: + with get_connection() as connection: for statement in sql_statements: connection.execute(text(statement)) @@ -125,7 +125,7 @@ def fetch_latest_versioned_migration() -> MigrationRecord | None: if not table_exists: return None - with get_db_connection() as connection: + with get_connection() as connection: result: Result[tuple[str]] = connection.execute( get_query( QueryMethod.MIGRATION_RECORDS_QUERY, @@ -150,7 +150,7 @@ def create_migrations_table_if_not_exists() -> None: None: Table is created as a side effect. """ - with get_db_connection() as connection: + with get_connection() as connection: connection.execute( statement=get_query(QueryMethod.CREATE_MIGRATIONS_TABLE_STMT) ) @@ -192,7 +192,7 @@ def get_latest_versions( latest_versions: list[str] = [] if limit: - with get_db_connection() as connection: + with get_connection() as connection: result: Result[tuple[str]] = connection.execute( statement=get_query(QueryMethod.LATEST_VERSIONS_QUERY), parameters={"limit": limit}, @@ -200,7 +200,7 @@ def get_latest_versions( latest_versions: list[str] = [row[0] for row in result.fetchall()] if starting_version: - with get_db_connection() as connection: + with get_connection() as connection: version_exists_result: Result[tuple[int]] = connection.execute( statement=get_query(QueryMethod.CHECK_IF_VERSION_EXISTS_QUERY), parameters={"version": starting_version}, @@ -235,7 +235,7 @@ def migrations_table_exists() -> bool: Returns: bool: True if the jetbase_migrations table exists, False otherwise. """ - with get_db_connection() as connection: + with get_connection() as connection: result: Result[tuple[bool]] = connection.execute( statement=get_query(QueryMethod.CHECK_IF_MIGRATIONS_TABLE_EXISTS_QUERY) ) @@ -255,7 +255,7 @@ def get_migration_records() -> list[MigrationRecord]: list[MigrationRecord]: List of all migration records in chronological order. """ - with get_db_connection() as connection: + with get_connection() as connection: results: Result[tuple[str, int, str]] = connection.execute( statement=get_query(QueryMethod.MIGRATION_RECORDS_QUERY) ) @@ -286,7 +286,7 @@ def get_checksums_by_version() -> list[tuple[str, str]]: list[tuple[str, str]]: List of (version, checksum) tuples in order of application. """ - with get_db_connection() as connection: + with get_connection() as connection: results: Result[tuple[str, str]] = connection.execute( statement=get_query(QueryMethod.GET_VERSION_CHECKSUMS_QUERY) ) @@ -307,7 +307,7 @@ def get_migrated_versions() -> list[str]: Returns: list[str]: List of version strings in order of application. """ - with get_db_connection() as connection: + with get_connection() as connection: results: Result[tuple[str]] = connection.execute( statement=get_query(QueryMethod.GET_VERSION_CHECKSUMS_QUERY) ) @@ -330,7 +330,7 @@ def update_migration_checksums(versions_and_checksums: list[tuple[str, str]]) -> Returns: None: Checksums are updated as a side effect. """ - with get_db_connection() as connection: + with get_connection() as connection: for version, checksum in versions_and_checksums: connection.execute( statement=get_query(QueryMethod.REPAIR_MIGRATION_CHECKSUM_STMT), @@ -349,7 +349,7 @@ def get_existing_on_change_filenames_to_checksums() -> dict[str, str]: dict[str, str]: Dictionary mapping filenames to their stored checksum values. """ - with get_db_connection() as connection: + with get_connection() as connection: results: Result[tuple[str, str]] = connection.execute( statement=get_query(QueryMethod.GET_RUNS_ON_CHANGE_MIGRATIONS_QUERY), ) @@ -370,7 +370,7 @@ def get_existing_repeatable_always_migration_filenames() -> set[str]: Returns: set[str]: Set of runs-always migration filenames. """ - with get_db_connection() as connection: + with get_connection() as connection: results: Result[tuple[str]] = connection.execute( statement=get_query(QueryMethod.GET_RUNS_ALWAYS_MIGRATIONS_QUERY), ) @@ -392,7 +392,7 @@ def delete_missing_versions(versions: list[str]) -> None: Returns: None: Records are deleted as a side effect. """ - with get_db_connection() as connection: + with get_connection() as connection: for version in versions: connection.execute( statement=get_query(QueryMethod.DELETE_MISSING_VERSION_STMT), @@ -413,7 +413,7 @@ def delete_missing_repeatables(repeatable_filenames: list[str]) -> None: Returns: None: Records are deleted as a side effect. """ - with get_db_connection() as connection: + with get_connection() as connection: for r_file in repeatable_filenames: connection.execute( statement=get_query(QueryMethod.DELETE_MISSING_REPEATABLE_STMT), @@ -431,7 +431,7 @@ def fetch_repeatable_migrations() -> list[MigrationRecord]: Returns: list[MigrationRecord]: List of all repeatable migration records. """ - with get_db_connection() as connection: + with get_connection() as connection: results: Result[tuple[str]] = connection.execute( statement=get_query( QueryMethod.MIGRATION_RECORDS_QUERY, all_repeatables=True From 45b216c37e3d92627d4120758e3227cb50ce751a Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Tue, 3 Feb 2026 22:44:28 -0300 Subject: [PATCH 23/40] tests --- tests/unit/commands/test_new.py | 36 ++++++++++++++-------------- tests/unit/engine/test_repeatable.py | 26 ++++++++++++++++++-- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/tests/unit/commands/test_new.py b/tests/unit/commands/test_new.py index 3ad1e46..65fe14c 100644 --- a/tests/unit/commands/test_new.py +++ b/tests/unit/commands/test_new.py @@ -1,9 +1,10 @@ import tempfile +from pathlib import Path from unittest.mock import patch import pytest -from jetbase.commands.new import generate_new_migration_file_cmd, _generate_new_filename +from jetbase.commands.new import _generate_new_filename, generate_new_migration_file_cmd from jetbase.constants import MIGRATIONS_DIR from jetbase.exceptions import ( DirectoryNotFoundError, @@ -14,48 +15,37 @@ def test_generate_new_migration_file_cmd_success(tmp_path, capsys): """Test successful generation of a new migration file.""" - # Create migrations directory migrations_dir = tmp_path / MIGRATIONS_DIR migrations_dir.mkdir(parents=True) - # Mock os.getcwd to return tmp_path - with patch("os.getcwd", return_value=str(tmp_path)): - # Mock datetime to get predictable timestamp + with patch("jetbase.commands.new.find_jetbase_directory", return_value=tmp_path): with patch("jetbase.commands.new.dt") as mock_dt: mock_dt.datetime.now.return_value.strftime.return_value = "20251214.153000" - # Generate migration file generate_new_migration_file_cmd("create users table") - # Check file was created with correct name expected_filename = "V20251214.153000__create_users_table.sql" expected_filepath = migrations_dir / expected_filename assert expected_filepath.exists() assert expected_filepath.is_file() - # Check console output captured = capsys.readouterr() assert f"Created migration file: {expected_filename}" in captured.out def test_generate_new_migration_file_cmd_with_custom_version(tmp_path, capsys): """Test successful generation of a migration file with a custom version.""" - # Create migrations directory migrations_dir = tmp_path / MIGRATIONS_DIR migrations_dir.mkdir(parents=True) - # Mock os.getcwd to return tmp_path - with patch("os.getcwd", return_value=str(tmp_path)): - # Generate migration file with custom version + with patch("jetbase.commands.new.find_jetbase_directory", return_value=tmp_path): generate_new_migration_file_cmd("create users table", version="1.5") - # Check file was created with correct name using the custom version expected_filename = "V1.5__create_users_table.sql" expected_filepath = migrations_dir / expected_filename assert expected_filepath.exists() assert expected_filepath.is_file() - # Check console output captured = capsys.readouterr() assert f"Created migration file: {expected_filename}" in captured.out @@ -63,14 +53,24 @@ def test_generate_new_migration_file_cmd_with_custom_version(tmp_path, capsys): def test_generate_new_migration_file_cmd_directory_not_found(): """Test that DirectoryNotFoundError is raised when migrations directory doesn't exist.""" with tempfile.TemporaryDirectory() as tmpdir: - # Mock os.getcwd to return directory without migrations folder - with patch("os.getcwd", return_value=tmpdir): + tmp_path = Path(tmpdir) + + with patch( + "jetbase.commands.new.find_jetbase_directory", return_value=tmp_path + ): with pytest.raises(DirectoryNotFoundError) as exc_info: generate_new_migration_file_cmd("create users table") - # Check error message assert "Migrations directory not found" in str(exc_info.value) - assert "jetbase initialize" in str(exc_info.value) + + +def test_generate_new_migration_file_cmd_jetbase_not_found(): + """Test that DirectoryNotFoundError is raised when jetbase directory doesn't exist.""" + with patch("jetbase.commands.new.find_jetbase_directory", return_value=None): + with pytest.raises(DirectoryNotFoundError) as exc_info: + generate_new_migration_file_cmd("create users table") + + assert "Jetbase directory not found" in str(exc_info.value) def test_generate_new_filename_with_timestamp(): diff --git a/tests/unit/engine/test_repeatable.py b/tests/unit/engine/test_repeatable.py index ed4e830..e205cd5 100644 --- a/tests/unit/engine/test_repeatable.py +++ b/tests/unit/engine/test_repeatable.py @@ -68,11 +68,22 @@ def test_returns_ra_filenames(self, tmp_path: Path) -> None: (migrations_dir / "RA__test.sql").touch() (migrations_dir / "V1__other.sql").touch() - with patch("jetbase.engine.repeatable.os.getcwd", return_value=str(tmp_path)): + with patch( + "jetbase.engine.repeatable.find_jetbase_directory", return_value=tmp_path + ): result = get_ra_filenames() assert result == ["RA__test.sql"] + def test_returns_empty_when_no_jetbase(self) -> None: + """Test that empty list is returned when jetbase directory not found.""" + with patch( + "jetbase.engine.repeatable.find_jetbase_directory", return_value=None + ): + result = get_ra_filenames() + + assert result == [] + class TestGetRepeatableFilenames: """Tests for the get_repeatable_filenames function.""" @@ -85,10 +96,21 @@ def test_returns_all_repeatable_filenames(self, tmp_path: Path) -> None: (migrations_dir / "ROC__test.sql").touch() (migrations_dir / "V1__other.sql").touch() - with patch("jetbase.engine.repeatable.os.getcwd", return_value=str(tmp_path)): + with patch( + "jetbase.engine.repeatable.find_jetbase_directory", return_value=tmp_path + ): result = get_repeatable_filenames() assert len(result) == 2 assert "RA__test.sql" in result assert "ROC__test.sql" in result assert "V1__other.sql" not in result + + def test_returns_empty_when_no_jetbase(self) -> None: + """Test that empty list is returned when jetbase directory not found.""" + with patch( + "jetbase.engine.repeatable.find_jetbase_directory", return_value=None + ): + result = get_repeatable_filenames() + + assert result == [] From a385fd92f8d61be1d3d9451cdf3f0dc861c81d48 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 01:30:50 -0300 Subject: [PATCH 24/40] Update README.md --- README.md | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 139 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d268ada..4dee2cb 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ Jetbase helps you manage database migrations in a simple, version-controlled way - **✅ Checksum Validation** — Detects if migration files have been modified - **🔄 Repeatable Migrations** — Support for migrations that run on every upgrade - **🤖 Auto-Generation** — Automatically generate SQL migrations from SQLAlchemy models +- **🔀 Async/Sync Support** — Works with both sync and async SQLAlchemy drivers +- **🎯 Auto-Discovery** — Automatically finds models in `models/` or `model/` directories +- **📁 Portable** — Run jetbase from any directory in your project [📚 Full Documentation](https://jetbase-hq.github.io/jetbase/) @@ -52,7 +55,6 @@ uv add jetbase ```bash jetbase init -cd jetbase ``` This creates a `jetbase/` directory with: @@ -60,6 +62,8 @@ This creates a `jetbase/` directory with: - A `migrations/` folder for your SQL files - An `env.py` configuration file +> **Tip:** Run `jetbase` commands from **any directory** in your project. Jetbase automatically finds the `jetbase/` directory by searching up the directory tree. + ### Configure Your Database Edit `jetbase/env.py` with your database connection string (currently support for postgres, sqlite snowflake, databricks): @@ -157,15 +161,43 @@ class Product(Base): price = Column(Integer) ``` -2. Set the `JETBASE_MODELS` environment variable to point to your model files: +### Auto-Discovery of Models 🎯 -```bash -# Single model file -export JETBASE_MODELS="./models.py" +Jetbase automatically discovers models in your project without needing to configure paths: -# Multiple model files (comma-separated) -export JETBASE_MODELS="./models/user.py,./models/product.py,./models/order.py" -``` +1. **Option 1: Auto-discovery** - Jetbase looks for models in: + - `models/` directory + - `model/` directory + - Searches recursively through subdirectories + + ``` + your-project/ + ├── models/ + │ ├── user.py + │ ├── product/ + │ │ └── __init__.py + │ └── order.py + └── jetbase/ + ``` + +2. **Option 2: Explicit configuration** - Set the `JETBASE_MODELS` environment variable: + + ```bash + # Single model file + export JETBASE_MODELS="./models.py" + + # Multiple model files (comma-separated) + export JETBASE_MODELS="./models/user.py,./models/product.py,./models/order.py" + ``` + +3. **Option 3: Configure in env.py** - Add model paths in your configuration: + + ```python + # jetbase/env.py + model_paths = ["./models/user.py", "./models/product.py"] + ``` + +> **Note:** Jetbase automatically adds your project root to `sys.path` so models can use absolute imports. ### Generate Migrations Automatically @@ -222,9 +254,11 @@ The auto-generation feature detects: ### Environment Variable Configuration -| Variable | Description | -|----------|-------------| -| `JETBASE_MODELS` | Comma-separated paths to SQLAlchemy model files | +| Variable | Description | Default | +|----------|-------------|---------| +| `ASYNC` | Enable async mode (`true`/`false`) | `false` | +| `JETBASE_SQLALCHEMY_URL` | Database connection URL | Required | +| `JETBASE_MODELS` | Paths to SQLAlchemy models | Optional | You can also configure model paths in `jetbase/env.py`: @@ -232,6 +266,8 @@ You can also configure model paths in `jetbase/env.py`: model_paths = ["./models/user.py", "./models/product.py"] ``` +> **Note:** When `ASYNC=false` (default), Jetbase automatically strips async driver suffixes (`+asyncpg`, `+aiosqlite`, `+async`) from the URL, allowing you to use async URLs in sync mode. + ## Supported Databases Jetbase currently supports: @@ -242,6 +278,86 @@ Jetbase currently supports: - ✅ Databricks - ✅ MySQL +--- + +## Async and Sync Database Support ⚡ + +Jetbase supports both synchronous and asynchronous database connections. The mode is controlled **exclusively** by the `ASYNC` environment variable. + +### Configuration + +Set the `ASYNC` environment variable before running Jetbase commands: + +```bash +export ASYNC=true # for async mode +export ASYNC=false # for sync mode (default) +jetbase status +``` + +You can also set it temporarily per command: + +```bash +ASYNC=true jetbase status # async mode +ASYNC=false jetbase upgrade # sync mode +``` + +### Sync Mode (Default) + +Use sync drivers or async drivers (async suffix is automatically stripped): + +```python +# jetbase/env.py +sqlalchemy_url = "postgresql+psycopg2://user:password@localhost:5432/mydb" +# or +sqlalchemy_url = "sqlite:///mydb.db" +# or even async URLs (suffix is stripped automatically) +sqlalchemy_url = "postgresql+asyncpg://user:password@localhost:5432/mydb" +``` + +Sync mode is the default. Just run: + +```bash +jetbase upgrade +``` + +### Async Mode + +Use async drivers: + +```python +# jetbase/env.py +sqlalchemy_url = "postgresql+asyncpg://user:password@localhost:5432/mydb" +# or +sqlalchemy_url = "sqlite+aiosqlite:///mydb.db" +``` + +Set `ASYNC=true`: + +```bash +export ASYNC=true +jetbase upgrade +``` + +### Environment Variable Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASYNC` | Enable async mode (`true`/`false`) | `false` | +| `JETBASE_SQLALCHEMY_URL` | Database connection URL | Required | +| `JETBASE_MODELS` | Paths to SQLAlchemy models | Optional | + +### URL Format Reference + +| Database | Sync URL | Async URL | +|----------|----------|-----------| +| PostgreSQL | `postgresql+psycopg2://...` | `postgresql+asyncpg://...` | +| SQLite | `sqlite:///path.db` | `sqlite+aiosqlite:///path.db` | +| Snowflake | `snowflake://...` | Not supported | +| MySQL | `mysql+pymysql://...` | Not supported | +| Databricks | `databricks+connector://...` | Not supported | + +> **Note:** Only PostgreSQL and SQLite support async mode. Other databases use sync connections regardless of the `ASYNC` setting. + ## Commands Reference | Command | Description | @@ -255,6 +371,18 @@ Jetbase currently supports: | `jetbase status` | Show migration status | | `jetbase history` | Show migration history | | `jetbase current` | Show current version | +| `jetbase validate` | Validate migration files and checksums | +| `jetbase lock` | Acquire migration lock | +| `jetbase unlock` | Release migration lock | + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `ASYNC` | Enable async mode (`true`/`false`) | +| `JETBASE_SQLALCHEMY_URL` | Database connection URL | +| `JETBASE_MODELS` | Paths to SQLAlchemy model files | +| `JETBASE_POSTGRES_SCHEMA` | PostgreSQL schema search path | ## Need Help? From 39f4cc8c20f2efc3a981f67c4ce747d9b0716442 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 01:30:52 -0300 Subject: [PATCH 25/40] Create test_connection.py --- tests/unit/database/test_connection.py | 212 +++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 tests/unit/database/test_connection.py diff --git a/tests/unit/database/test_connection.py b/tests/unit/database/test_connection.py new file mode 100644 index 0000000..54c09a5 --- /dev/null +++ b/tests/unit/database/test_connection.py @@ -0,0 +1,212 @@ +"""Unit tests for database connection module.""" + +import os +import sys +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest +from sqlalchemy import text + +from jetbase.database.connection import ( + _get_engine, + get_async_db_connection, + get_connection, + get_db_connection, + is_async_enabled, +) + + +class TestIsAsyncEnabled: + """Tests for the is_async_enabled function.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Clear ASYNC env var before each test.""" + if "ASYNC" in os.environ: + original = os.environ["ASYNC"] + del os.environ["ASYNC"] + yield + os.environ["ASYNC"] = original + else: + yield + _get_engine.cache_clear() + + def test_defaults_to_false(self): + """Test that ASYNC defaults to False when not set.""" + assert is_async_enabled() is False + + def test_false_values(self): + """Test that common false values return False.""" + false_values = ["false", "False", "FALSE", "0", "no", "No", "NO", ""] + for val in false_values: + os.environ["ASYNC"] = val + assert is_async_enabled() is False, f"Failed for value: {val}" + + def test_true_values(self): + """Test that common true values return True.""" + true_values = ["true", "True", "TRUE", "1", "yes", "Yes", "YES"] + for val in true_values: + os.environ["ASYNC"] = val + assert is_async_enabled() is True, f"Failed for value: {val}" + + +class TestSyncConnection: + """Tests for sync database connection.""" + + @pytest.fixture(autouse=True) + def setup(self, tmp_path): + """Set up test database for sync tests.""" + _get_engine.cache_clear() + db_file = tmp_path / "test_sync.db" + url = f"sqlite:///{db_file}" + os.environ["ASYNC"] = "false" + os.environ["JETBASE_SQLALCHEMY_URL"] = url + yield + _get_engine.cache_clear() + if "ASYNC" in os.environ: + del os.environ["ASYNC"] + if "JETBASE_SQLALCHEMY_URL" in os.environ: + del os.environ["JETBASE_SQLALCHEMY_URL"] + + def test_get_db_connection_returns_connection(self): + """Test that get_db_connection returns a working sync connection.""" + with get_db_connection() as connection: + result = connection.execute(text("SELECT 1")) + assert result.fetchone() == (1,) + + def test_get_db_connection_can_create_table(self): + """Test that we can create and query a table with sync connection.""" + with get_db_connection() as connection: + connection.execute( + text("CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)") + ) + connection.execute(text("INSERT INTO test_table (name) VALUES ('test')")) + result = connection.execute(text("SELECT name FROM test_table")) + assert result.fetchone() == ("test",) + + def test_get_db_connection_multiple_queries(self): + """Test multiple queries in a single connection.""" + with get_db_connection() as connection: + connection.execute( + text("CREATE TABLE multi_test (id INTEGER PRIMARY KEY, value INTEGER)") + ) + connection.execute(text("INSERT INTO multi_test (value) VALUES (10)")) + connection.execute(text("INSERT INTO multi_test (value) VALUES (20)")) + result = connection.execute(text("SELECT SUM(value) FROM multi_test")) + assert result.fetchone() == (30,) + + def test_get_connection_sync_mode(self): + """Test that get_connection works in sync mode.""" + with get_connection() as connection: + result = connection.execute(text("SELECT 1")) + assert result.fetchone() == (1,) + + +class TestAsyncConnection: + """Tests for async database connection.""" + + @pytest.fixture(autouse=True) + def setup(self, tmp_path): + """Set up test database for async tests.""" + _get_engine.cache_clear() + db_file = tmp_path / "test_async.db" + url = f"sqlite+aiosqlite:///{db_file}" + os.environ["ASYNC"] = "true" + os.environ["JETBASE_SQLALCHEMY_URL"] = url + yield + _get_engine.cache_clear() + if "ASYNC" in os.environ: + del os.environ["ASYNC"] + if "JETBASE_SQLALCHEMY_URL" in os.environ: + del os.environ["JETBASE_SQLALCHEMY_URL"] + + @pytest.mark.asyncio + async def test_get_async_db_connection_returns_connection(self): + """Test that get_async_db_connection returns a working async connection.""" + async with get_async_db_connection() as connection: + result = await connection.execute(text("SELECT 1")) + assert result.fetchone() == (1,) + + @pytest.mark.asyncio + async def test_get_async_db_connection_can_create_table(self): + """Test that we can create and query a table with async connection.""" + async with get_async_db_connection() as connection: + await connection.execute( + text("CREATE TABLE async_test (id INTEGER PRIMARY KEY, name TEXT)") + ) + await connection.execute( + text("INSERT INTO async_test (name) VALUES ('async')") + ) + result = await connection.execute(text("SELECT name FROM async_test")) + assert result.fetchone() == ("async",) + + @pytest.mark.asyncio + async def test_get_async_db_connection_multiple_queries(self): + """Test multiple queries in a single async connection.""" + async with get_async_db_connection() as connection: + await connection.execute( + text("CREATE TABLE async_multi (id INTEGER PRIMARY KEY, value INTEGER)") + ) + await connection.execute( + text("INSERT INTO async_multi (value) VALUES (100)") + ) + await connection.execute( + text("INSERT INTO async_multi (value) VALUES (200)") + ) + result = await connection.execute( + text("SELECT SUM(value) FROM async_multi") + ) + assert result.fetchone() == (300,) + + @pytest.mark.asyncio + async def test_get_connection_async_mode(self): + """Test that get_connection works in async mode.""" + async with get_connection() as connection: + result = await connection.execute(text("SELECT 1")) + assert result.fetchone() == (1,) + + +class TestConnectionWrapper: + """Tests for the get_connection wrapper that handles both modes.""" + + @pytest.fixture(autouse=True) + def setup(self, tmp_path): + """Set up test database for wrapper tests.""" + _get_engine.cache_clear() + self.sync_db = tmp_path / "wrapper_sync.db" + self.async_db = tmp_path / "wrapper_async.db" + yield + _get_engine.cache_clear() + if "ASYNC" in os.environ: + del os.environ["ASYNC"] + if "JETBASE_SQLALCHEMY_URL" in os.environ: + del os.environ["JETBASE_SQLALCHEMY_URL"] + + def test_wrapper_sync_mode(self): + """Test get_connection in sync mode.""" + os.environ["ASYNC"] = "false" + os.environ["JETBASE_SQLALCHEMY_URL"] = f"sqlite:///{self.sync_db}" + + with get_connection() as connection: + result = connection.execute(text("SELECT 1")) + assert result.fetchone() == (1,) + + @pytest.mark.asyncio + async def test_wrapper_async_mode(self): + """Test get_connection in async mode.""" + os.environ["ASYNC"] = "true" + os.environ["JETBASE_SQLALCHEMY_URL"] = f"sqlite+aiosqlite:///{self.async_db}" + + async with get_connection() as connection: + result = await connection.execute(text("SELECT 1")) + assert result.fetchone() == (1,) + + def test_wrapper_works_with_sync_syntax_even_when_async_true(self): + """Test that using 'with' works even when ASYNC=true by stripping async suffix.""" + os.environ["ASYNC"] = "true" + os.environ["JETBASE_SQLALCHEMY_URL"] = f"sqlite+aiosqlite:///{self.async_db}" + + with get_connection() as connection: + result = connection.execute(text("SELECT 1")) + assert result.fetchone() == (1,) From ded403b12074d2e7545a689326b769b56ed139ad Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 01:30:56 -0300 Subject: [PATCH 26/40] Update make_migrations.py --- jetbase/commands/make_migrations.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/jetbase/commands/make_migrations.py b/jetbase/commands/make_migrations.py index a70783d..a40bc8a 100644 --- a/jetbase/commands/make_migrations.py +++ b/jetbase/commands/make_migrations.py @@ -1,8 +1,11 @@ import asyncio import os +from typing import Union + from sqlalchemy import create_engine from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import AsyncConnection from jetbase.commands.new import _generate_new_filename from jetbase.constants import MIGRATIONS_DIR @@ -10,8 +13,9 @@ get_db_connection, get_async_db_connection, get_connection, - is_async_url, + is_async_enabled, ) +from jetbase.config import get_config from jetbase.engine.model_discovery import ( ModelDiscoveryError, discover_all_models, @@ -42,13 +46,15 @@ class NoChangesDetectedError(MakeMigrationsError): pass -def _generate_create_table_from_model(model_class, connection: Connection) -> str: +def _generate_create_table_from_model( + model_class, connection: Union[Connection, AsyncConnection] +) -> str: """ Generate CREATE TABLE SQL from a SQLAlchemy model class. Args: model_class: The SQLAlchemy model class. - connection: Database connection. + connection: Database connection (sync or async). Returns: str: CREATE TABLE SQL statement. @@ -91,6 +97,7 @@ def make_migrations_cmd(description: str | None = None) -> None: NoChangesDetectedError: If no schema changes are detected. """ from jetbase.config import get_config + from jetbase.engine.jetbase_locator import find_jetbase_directory try: _, models = discover_all_models() @@ -98,11 +105,14 @@ def make_migrations_cmd(description: str | None = None) -> None: raise MakeMigrationsError(f"Failed to discover models: {e}") sqlalchemy_url = get_config(required={"sqlalchemy_url"}).sqlalchemy_url + jetbase_dir = find_jetbase_directory() + if not jetbase_dir: + raise MakeMigrationsError("Jetbase directory not found") - if is_async_url(sqlalchemy_url): - asyncio.run(_make_migrations_async(models, description)) + if is_async_enabled(): + asyncio.run(_make_migrations_async(models, description, jetbase_dir)) else: - _make_migrations_sync(models, description) + _make_migrations_sync(models, description, jetbase_dir) def _make_migrations_sync(models: dict, description: str | None) -> None: @@ -242,6 +252,7 @@ def _write_migration_file( upgrade_statements: list[str], rollback_statements: list[str], description: str | None, + jetbase_dir: str, ) -> None: """ Write the migration file to disk. @@ -252,7 +263,7 @@ def _write_migration_file( migration_description = description or "auto_generated" filename = _generate_new_filename(description=migration_description) - filepath = os.path.join(os.getcwd(), MIGRATIONS_DIR, filename) + filepath = os.path.join(jetbase_dir, MIGRATIONS_DIR, filename) migration_content = f"""-- upgrade From 91a0593c36a85b2e2b51954f452f5cd9905ccd4b Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 01:31:11 -0300 Subject: [PATCH 27/40] Update config.py --- jetbase/config.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/jetbase/config.py b/jetbase/config.py index 0154ba0..3d9a348 100644 --- a/jetbase/config.py +++ b/jetbase/config.py @@ -33,6 +33,8 @@ class JetbaseConfig: Defaults to False. model_paths (list[str] | None): Optional list of paths to SQLAlchemy model files for automatic migration generation. Defaults to None. + async_mode (bool): If True, uses async database connections. + Defaults to False. Raises: TypeError: If any boolean field receives a non-boolean value. @@ -46,6 +48,7 @@ class JetbaseConfig: snowflake_private_key: str | None = None snowflake_private_key_password: str | None = None model_paths: list[str] | None = None + async_mode: bool = False def __post_init__(self): # Validate skip_checksum_validation @@ -69,6 +72,13 @@ def __post_init__(self): f"Value: {self.skip_validation!r}" ) + # Validate async_mode + if not isinstance(self.async_mode, bool): + raise TypeError( + f"async_mode must be bool, got {type(self.async_mode).__name__}. " + f"Value: {self.async_mode!r}" + ) + ALL_KEYS: list[str] = [ field.name for field in JetbaseConfig.__dataclass_fields__.values() @@ -83,6 +93,7 @@ def __post_init__(self): "snowflake_private_key": None, "snowflake_private_key_password": None, "model_paths": None, + "async_mode": False, } REQUIRED_KEYS: set[str] = { From e712f17193ef5180a58b7861d5f7c16720fae2a5 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 01:31:13 -0300 Subject: [PATCH 28/40] Update connection.py --- jetbase/database/connection.py | 94 +++++++++++++--------------------- 1 file changed, 35 insertions(+), 59 deletions(-) diff --git a/jetbase/database/connection.py b/jetbase/database/connection.py index 2cec41d..f9702de 100644 --- a/jetbase/database/connection.py +++ b/jetbase/database/connection.py @@ -13,22 +13,32 @@ from jetbase.enums import DatabaseType -def is_async_url(sqlalchemy_url: str) -> bool: - """ - Check if the SQLAlchemy URL uses an async driver. - """ - return ( - "+asyncpg" in sqlalchemy_url - or "+aiosqlite" in sqlalchemy_url - or "+async" in sqlalchemy_url - ) +@lru_cache(maxsize=1) +def _get_engine(url: str) -> Engine: + return create_engine(url=url) def is_async_enabled() -> bool: """ - Check if async mode is enabled via ASYNC environment variable. + Check if async mode is enabled. + + Only checks the ASYNC environment variable: + - "true", "1", "yes" -> async mode + - "false", "0", "no", or not set -> sync mode + + Returns: + bool: True if async mode is enabled, False otherwise. """ - return os.getenv("ASYNC", "").lower() in ("true", "1", "yes") + async_env = os.getenv("ASYNC", "").lower() + return async_env in ("true", "1", "yes") + + +def _make_sync_url(url: str) -> str: + """Convert an async URL to sync by removing async driver suffixes.""" + url = url.replace("+asyncpg", "") + url = url.replace("+async", "") + url = url.replace("+aiosqlite", "") + return url @contextmanager @@ -36,25 +46,18 @@ def get_db_connection() -> Generator[Connection, None, None]: """ Context manager that yields a database connection with a transaction. - For async databases, use get_async_db_connection() instead. - - Raises: - RuntimeError: If ASYNC=true. + Always works in sync mode. If ASYNC=true, strips the async driver suffix + from the URL to allow sync connections. Example: >>> with get_db_connection() as conn: ... conn.execute(query) """ - if is_async_enabled(): - raise RuntimeError( - "ASYNC=true but using get_db_connection(). " - "Use 'async with get_async_db_connection()' for async mode." - ) - config = get_config(required={"sqlalchemy_url"}) url = config.sqlalchemy_url - url = _make_sync_url(url) + if is_async_enabled(): + url = _make_sync_url(url) engine: Engine = create_engine(url=url) db_type = detect_db(sqlalchemy_url=str(engine.url)) @@ -75,23 +78,12 @@ def get_db_connection() -> Generator[Connection, None, None]: yield connection -def _make_sync_url(url: str) -> str: - """ - Convert an async URL to sync by removing async driver suffixes. - """ - url = url.replace("+asyncpg", "") - url = url.replace("+async", "") - url = url.replace("+aiosqlite", "") - return url - - @asynccontextmanager async def get_async_db_connection() -> AsyncGenerator[AsyncConnection, None]: """ Context manager that yields an async database connection with a transaction. - Raises: - RuntimeError: If ASYNC=false. + Only works when ASYNC=true. Raises RuntimeError otherwise. Example: >>> async with get_async_db_connection() as conn: @@ -100,8 +92,7 @@ async def get_async_db_connection() -> AsyncGenerator[AsyncConnection, None]: if not is_async_enabled(): raise RuntimeError( "ASYNC=false but using get_async_db_connection(). " - "Use 'with get_db_connection()' for sync mode, " - "or set ASYNC=true." + "Set ASYNC=true to use async mode, or use get_db_connection() for sync mode." ) config = get_config(required={"sqlalchemy_url"}) @@ -121,35 +112,23 @@ async def get_async_db_connection() -> AsyncGenerator[AsyncConnection, None]: class _ConnectionWrapper: """ - Wrapper that provides both sync and async context manager protocols. + Context manager wrapper that provides both sync and async protocols. Usage: - ASYNC=false: with get_connection() as conn: ASYNC=true: async with get_connection() as conn: + ASYNC=false: with get_connection() as conn: """ def __enter__(self): - if is_async_enabled(): - raise RuntimeError( - "ASYNC=true but using 'with' instead of 'async with'.\n" - "Use 'async with get_connection() as conn:' for async mode." - ) - cm = get_db_connection() - self._sync_cm = cm - return cm.__enter__() + self._sync_cm = get_db_connection() + return self._sync_cm.__enter__() def __exit__(self, *args): return self._sync_cm.__exit__(*args) async def __aenter__(self): - if not is_async_enabled(): - raise RuntimeError( - "ASYNC=false but using 'async with'.\n" - "Use 'with get_connection() as conn:' for sync mode." - ) - cm = get_async_db_connection() - self._async_cm = cm - return await cm.__aenter__() + self._async_cm = get_async_db_connection() + return await self._async_cm.__aenter__() async def __aexit__(self, *args): return await self._async_cm.__aexit__(*args) @@ -160,20 +139,17 @@ def get_connection() -> "_ConnectionWrapper": Context manager that works with both sync and async based on ASYNC env var. Usage: - ASYNC=false: with get_connection() as conn: ASYNC=true: async with get_connection() as conn: + ASYNC=false: with get_connection() as conn: Returns: - _ConnectionWrapper: A wrapper that supports both sync and async context manager protocols. + _ConnectionWrapper: A wrapper that supports both sync and async context managers. """ return _ConnectionWrapper() @contextmanager def _suppress_databricks_warnings(): - """ - Temporarily sets the databricks logger to ERROR level to suppress warnings. - """ logger = logging.getLogger("databricks") original_level = logger.level logger.setLevel(logging.ERROR) From 3949dadfd5bfedd25d4a937cebc4f5121c7063ac Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 01:31:19 -0300 Subject: [PATCH 29/40] Update pyproject.toml --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 18174ea..161c8c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dev = [ "psycopg2-binary>=2.9.11", "pyrefly>=0.38.2", "pytest>=8.4.2", + "pytest-asyncio>=0.24.0", "ruff>=0.14.2", "snowflake-sqlalchemy>=1.8.2", "cryptography>=46.0.3", @@ -47,9 +48,10 @@ databricks = [ [tool.pytest.ini_options] +asyncio_mode = "auto" markers = [ "basic: tests commands with no params", - "count: tests --count param", + "count: tests --count param", "to_version: tests --to-version param", "dry_run: tests --dry-run param", "snowflake: tests for snowflake database", From b789cefbfcbdab066f349f3f9e8184681a8f3f56 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 09:15:24 -0300 Subject: [PATCH 30/40] Fixed SQL query --- jetbase/commands/make_migrations.py | 13 +- tests/unit/commands/test_make_migrations.py | 509 ++++++++++++++++++++ 2 files changed, 514 insertions(+), 8 deletions(-) diff --git a/jetbase/commands/make_migrations.py b/jetbase/commands/make_migrations.py index a40bc8a..0014123 100644 --- a/jetbase/commands/make_migrations.py +++ b/jetbase/commands/make_migrations.py @@ -97,7 +97,6 @@ def make_migrations_cmd(description: str | None = None) -> None: NoChangesDetectedError: If no schema changes are detected. """ from jetbase.config import get_config - from jetbase.engine.jetbase_locator import find_jetbase_directory try: _, models = discover_all_models() @@ -105,14 +104,11 @@ def make_migrations_cmd(description: str | None = None) -> None: raise MakeMigrationsError(f"Failed to discover models: {e}") sqlalchemy_url = get_config(required={"sqlalchemy_url"}).sqlalchemy_url - jetbase_dir = find_jetbase_directory() - if not jetbase_dir: - raise MakeMigrationsError("Jetbase directory not found") if is_async_enabled(): - asyncio.run(_make_migrations_async(models, description, jetbase_dir)) + asyncio.run(_make_migrations_async(models, description)) else: - _make_migrations_sync(models, description, jetbase_dir) + _make_migrations_sync(models, description) def _make_migrations_sync(models: dict, description: str | None) -> None: @@ -252,7 +248,6 @@ def _write_migration_file( upgrade_statements: list[str], rollback_statements: list[str], description: str | None, - jetbase_dir: str, ) -> None: """ Write the migration file to disk. @@ -263,7 +258,9 @@ def _write_migration_file( migration_description = description or "auto_generated" filename = _generate_new_filename(description=migration_description) - filepath = os.path.join(jetbase_dir, MIGRATIONS_DIR, filename) + migrations_dir = os.path.join(os.getcwd(), MIGRATIONS_DIR) + os.makedirs(migrations_dir, exist_ok=True) + filepath = os.path.join(migrations_dir, filename) migration_content = f"""-- upgrade diff --git a/tests/unit/commands/test_make_migrations.py b/tests/unit/commands/test_make_migrations.py index 3a558d3..06bd06f 100644 --- a/tests/unit/commands/test_make_migrations.py +++ b/tests/unit/commands/test_make_migrations.py @@ -4,14 +4,53 @@ from unittest.mock import MagicMock, patch import pytest +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, create_engine, text +from sqlalchemy.orm import declarative_base from jetbase.commands.make_migrations import ( MakeMigrationsError, + NoChangesDetectedError, + _generate_create_table_from_model, + _make_migrations_sync, + _write_migration_file, make_migrations_cmd, ) from jetbase.constants import MIGRATIONS_DIR +Base = declarative_base() + + +class UserModel(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) + name = Column(String(100), nullable=True) + + +class ProductModel(Base): + __tablename__ = "products" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + price = Column(Integer, nullable=False) + + +class OrderModel(Base): + __tablename__ = "orders" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + + +class ProfileModel(Base): + __tablename__ = "profiles" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False) + bio = Column(String(500), nullable=True) + active = Column(Boolean, default=True) + + @pytest.fixture def sample_model_file(tmp_path): """Create a temporary model file with SQLAlchemy models.""" @@ -25,6 +64,40 @@ class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) email = Column(String(255), nullable=False) + name = Column(String(100), nullable=True) +""" + model_path = tmp_path / "models.py" + model_path.write_text(model_content) + return str(model_path) + + +@pytest.fixture +def complex_model_file(tmp_path): + """Create a temporary model file with multiple models including FKs.""" + model_content = """ +from sqlalchemy import Column, ForeignKey, Integer, String, Boolean +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) + name = Column(String(100), nullable=True) + +class Product(Base): + __tablename__ = "products" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + price = Column(Integer, nullable=False) + +class Order(Base): + __tablename__ = "orders" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, nullable=False, default=1) """ model_path = tmp_path / "models.py" model_path.write_text(model_content) @@ -39,6 +112,154 @@ def migrations_dir(tmp_path): return migrations_dir +@pytest.fixture +def sync_db(tmp_path): + """Create a temporary SQLite database for sync tests.""" + db_file = tmp_path / "test.db" + engine = create_engine(f"sqlite:///{db_file}") + return engine + + +class TestGenerateCreateTableFromModel: + """Tests for _generate_create_table_from_model function.""" + + def test_generate_simple_table(self, sync_db): + """Test generating CREATE TABLE for a simple model.""" + with sync_db.connect() as conn: + sql = _generate_create_table_from_model(UserModel, conn) + + assert "CREATE TABLE users" in sql + assert "id INTEGER" in sql + assert "PRIMARY KEY" in sql + + def test_generate_table_with_not_null(self, sync_db): + """Test generating CREATE TABLE with NOT NULL constraints.""" + with sync_db.connect() as conn: + sql = _generate_create_table_from_model(UserModel, conn) + + assert "NOT NULL" in sql + assert "email" in sql + + def test_generate_table_with_nullable(self, sync_db): + """Test generating CREATE TABLE with nullable columns.""" + with sync_db.connect() as conn: + sql = _generate_create_table_from_model(UserModel, conn) + + assert "name" in sql + + def test_generate_table_with_foreign_key(self, sync_db): + """Test generating CREATE TABLE with foreign keys.""" + with sync_db.connect() as conn: + sql = _generate_create_table_from_model(OrderModel, conn) + + assert "CREATE TABLE orders" in sql + assert "user_id" in sql + assert "product_id" in sql + + def test_generate_table_with_boolean(self, sync_db): + """Test generating CREATE TABLE with Boolean column.""" + with sync_db.connect() as conn: + sql = _generate_create_table_from_model(ProfileModel, conn) + + assert "active" in sql + + +class TestMakeMigrationsSync: + """Tests for _make_migrations_sync function.""" + + def test_make_migrations_sync_new_tables(self, tmp_path, sync_db): + """Test generating migrations for new tables.""" + models = {"users": UserModel, "products": ProductModel} + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + + with patch("jetbase.commands.make_migrations.get_connection") as mock_conn: + mock_connection = MagicMock() + mock_conn.return_value.__enter__ = MagicMock(return_value=mock_connection) + mock_conn.return_value.__exit__ = MagicMock(return_value=False) + + with patch( + "jetbase.commands.make_migrations.introspect_database" + ) as mock_introspect: + from jetbase.engine.schema_introspection import SchemaInfo + + mock_introspect.return_value = SchemaInfo(tables={}) + + with patch( + "jetbase.commands.make_migrations.compare_schemas" + ) as mock_compare: + from jetbase.engine.schema_diff import SchemaDiff + + mock_compare.return_value = SchemaDiff( + tables_to_create=["users", "products"] + ) + + with patch( + "jetbase.commands.make_migrations.get_db_type" + ) as mock_db: + mock_db.return_value = "sqlite" + + with patch( + "jetbase.commands.make_migrations._write_migration_file" + ) as mock_write: + _make_migrations_sync(models, "test description") + + mock_write.assert_called_once() + args, kwargs = mock_write.call_args + assert len(args[0]) == 2 + assert "users" in args[0][0] + assert "products" in args[0][1] + + def test_make_migrations_sync_no_changes(self, tmp_path, sync_db): + """Test that no migration file is created when there are no changes.""" + models = {"users": UserModel} + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + + with patch("jetbase.commands.make_migrations.get_connection") as mock_conn: + mock_connection = MagicMock() + mock_conn.return_value.__enter__ = MagicMock(return_value=mock_connection) + mock_conn.return_value.__exit__ = MagicMock(return_value=False) + + with patch( + "jetbase.commands.make_migrations.introspect_database" + ) as mock_introspect: + from jetbase.engine.schema_introspection import ( + SchemaInfo, + TableInfo, + ColumnInfo, + ) + + mock_introspect.return_value = SchemaInfo( + tables={ + "users": TableInfo( + name="users", + columns=[ + ColumnInfo("id", "INTEGER", False, None, True, False), + ColumnInfo( + "email", "VARCHAR(255)", False, None, False, False + ), + ], + primary_keys=["id"], + ) + } + ) + + with patch( + "jetbase.commands.make_migrations.compare_schemas" + ) as mock_compare: + from jetbase.engine.schema_diff import SchemaDiff + + mock_compare.return_value = SchemaDiff() + + with patch( + "jetbase.commands.make_migrations._write_migration_file" + ) as mock_write: + _make_migrations_sync(models, "test description") + + mock_write.assert_not_called() + + class TestMakeMigrationsCmd: """Tests for make_migrations_cmd function.""" @@ -62,6 +283,126 @@ def test_make_migrations_model_file_not_found(self, tmp_path, migrations_dir): assert "not found" in str(exc_info.value).lower() + def test_make_migrations_with_model_file(self, tmp_path, sample_model_file): + """Test make_migrations_cmd with a valid model file.""" + os.chdir(tmp_path) + + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + + with patch.dict( + os.environ, + { + "JETBASE_MODELS": sample_model_file, + "ASYNC": "false", + "JETBASE_SQLALCHEMY_URL": "sqlite:///./test.db", + }, + clear=False, + ): + with patch("jetbase.commands.make_migrations.get_connection") as mock_conn: + mock_connection = MagicMock() + mock_conn.return_value.__enter__ = MagicMock( + return_value=mock_connection + ) + mock_conn.return_value.__exit__ = MagicMock(return_value=False) + + with patch( + "jetbase.commands.make_migrations.introspect_database" + ) as mock_introspect: + from jetbase.engine.schema_introspection import SchemaInfo + + mock_introspect.return_value = SchemaInfo(tables={}) + + with patch( + "jetbase.commands.make_migrations.compare_schemas" + ) as mock_compare: + from jetbase.engine.schema_diff import SchemaDiff + from jetbase.engine.model_discovery import discover_all_models + + mock_compare.return_value = SchemaDiff( + tables_to_create=["users"] + ) + + with patch( + "jetbase.commands.make_migrations.get_db_type" + ) as mock_db: + mock_db.return_value = "sqlite" + + with patch( + "jetbase.commands.make_migrations._write_migration_file" + ) as mock_write: + make_migrations_cmd(description="create users") + + mock_write.assert_called_once() + + +class TestWriteMigrationFile: + """Tests for _write_migration_file function.""" + + def test_write_migration_file_creates_directory(self, tmp_path): + """Test that migrations directory is created if it doesn't exist.""" + os.chdir(tmp_path) + + upgrade_statements = ["CREATE TABLE users (id INTEGER PRIMARY KEY);"] + rollback_statements = ["DROP TABLE users;"] + + with patch( + "jetbase.commands.make_migrations._generate_new_filename" + ) as mock_filename: + mock_filename.return_value = "V1__test.sql" + + _write_migration_file(upgrade_statements, rollback_statements, "test") + + migrations_dir = tmp_path / MIGRATIONS_DIR + assert migrations_dir.exists() + + migration_file = migrations_dir / "V1__test.sql" + assert migration_file.exists() + + def test_write_migration_file_content(self, tmp_path): + """Test that migration file contains correct content.""" + os.chdir(tmp_path) + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + + upgrade_statements = [ + "CREATE TABLE users (id INTEGER PRIMARY KEY);", + "CREATE TABLE products (id INTEGER PRIMARY KEY);", + ] + rollback_statements = [ + "DROP TABLE products;", + "DROP TABLE users;", + ] + + _write_migration_file(upgrade_statements, rollback_statements, "test") + + files = list(migrations_dir.glob("V*__test.sql")) + assert len(files) == 1 + content = files[0].read_text() + + assert "-- upgrade" in content + assert "-- rollback" in content + assert "CREATE TABLE users" in content + assert "CREATE TABLE products" in content + assert "DROP TABLE users" in content + assert "DROP TABLE products" in content + + def test_write_migration_file_with_description(self, tmp_path): + """Test that migration file uses custom description.""" + os.chdir(tmp_path) + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + + upgrade_statements = ["CREATE TABLE users (id INTEGER PRIMARY KEY);"] + rollback_statements = ["DROP TABLE users;"] + + _write_migration_file( + upgrade_statements, rollback_statements, "my_custom_description" + ) + + files = list(migrations_dir.glob("V*__my_custom_description.sql")) + assert len(files) == 1 + class TestMakeMigrationsError: """Tests for MakeMigrationsError exception.""" @@ -70,3 +411,171 @@ def test_make_migrations_error(self): """Test MakeMigrationsError can be raised.""" with pytest.raises(MakeMigrationsError): raise MakeMigrationsError("Test error message") + + def test_no_changes_detected_error(self): + """Test NoChangesDetectedError can be raised.""" + with pytest.raises(NoChangesDetectedError): + raise NoChangesDetectedError("No changes detected") + + +class TestSQLGenerationFromModels: + """Integration tests for SQL generation from SQLAlchemy models.""" + + def test_generate_sql_for_user_model(self, tmp_path): + """Test SQL generation for User model.""" + from jetbase.engine.schema_introspection import SchemaInfo + from jetbase.engine.schema_diff import SchemaDiff + + models = {"users": UserModel} + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + + db_file = tmp_path / "test.db" + engine = create_engine(f"sqlite:///{db_file}") + + with engine.connect() as connection: + with patch( + "jetbase.commands.make_migrations.introspect_database" + ) as mock_introspect: + mock_introspect.return_value = SchemaInfo(tables={}) + + with patch( + "jetbase.commands.make_migrations.compare_schemas" + ) as mock_compare: + mock_compare.return_value = SchemaDiff(tables_to_create=["users"]) + + with patch( + "jetbase.commands.make_migrations.get_db_type" + ) as mock_db: + mock_db.return_value = "sqlite" + + with patch( + "jetbase.commands.make_migrations._write_migration_file" + ) as mock_write: + with patch( + "jetbase.commands.make_migrations.get_connection" + ) as mock_get_conn: + mock_get_conn.return_value.__enter__ = MagicMock( + return_value=connection + ) + mock_get_conn.return_value.__exit__ = MagicMock( + return_value=False + ) + + _make_migrations_sync(models, "create users") + + mock_write.assert_called_once() + args, kwargs = mock_write.call_args + upgrade_sql = "\n\n".join(args[0]) + + assert "CREATE TABLE users" in upgrade_sql + assert "id INTEGER" in upgrade_sql + assert "email" in upgrade_sql + assert "NOT NULL" in upgrade_sql + + def test_generate_sql_with_foreign_keys(self, tmp_path): + """Test SQL generation for models with foreign keys.""" + from jetbase.engine.schema_introspection import SchemaInfo + from jetbase.engine.schema_diff import SchemaDiff + + models = {"orders": OrderModel} + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + + db_file = tmp_path / "test.db" + engine = create_engine(f"sqlite:///{db_file}") + + with engine.connect() as connection: + with patch( + "jetbase.commands.make_migrations.introspect_database" + ) as mock_introspect: + mock_introspect.return_value = SchemaInfo(tables={}) + + with patch( + "jetbase.commands.make_migrations.compare_schemas" + ) as mock_compare: + mock_compare.return_value = SchemaDiff(tables_to_create=["orders"]) + + with patch( + "jetbase.commands.make_migrations.get_db_type" + ) as mock_db: + mock_db.return_value = "sqlite" + + with patch( + "jetbase.commands.make_migrations._write_migration_file" + ) as mock_write: + with patch( + "jetbase.commands.make_migrations.get_connection" + ) as mock_get_conn: + mock_get_conn.return_value.__enter__ = MagicMock( + return_value=connection + ) + mock_get_conn.return_value.__exit__ = MagicMock( + return_value=False + ) + + _make_migrations_sync(models, "create orders") + + mock_write.assert_called_once() + args, kwargs = mock_write.call_args + upgrade_sql = "\n\n".join(args[0]) + + assert "CREATE TABLE orders" in upgrade_sql + assert "user_id" in upgrade_sql + assert "product_id" in upgrade_sql + + def test_generate_sql_multiple_tables(self, tmp_path): + """Test SQL generation for multiple tables.""" + from jetbase.engine.schema_introspection import SchemaInfo + from jetbase.engine.schema_diff import SchemaDiff + + models = {"users": UserModel, "products": ProductModel} + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + + db_file = tmp_path / "test.db" + engine = create_engine(f"sqlite:///{db_file}") + + with engine.connect() as connection: + with patch( + "jetbase.commands.make_migrations.introspect_database" + ) as mock_introspect: + mock_introspect.return_value = SchemaInfo(tables={}) + + with patch( + "jetbase.commands.make_migrations.compare_schemas" + ) as mock_compare: + mock_compare.return_value = SchemaDiff( + tables_to_create=["users", "products"] + ) + + with patch( + "jetbase.commands.make_migrations.get_db_type" + ) as mock_db: + mock_db.return_value = "sqlite" + + with patch( + "jetbase.commands.make_migrations._write_migration_file" + ) as mock_write: + with patch( + "jetbase.commands.make_migrations.get_connection" + ) as mock_get_conn: + mock_get_conn.return_value.__enter__ = MagicMock( + return_value=connection + ) + mock_get_conn.return_value.__exit__ = MagicMock( + return_value=False + ) + + _make_migrations_sync(models, "create all tables") + + mock_write.assert_called_once() + args, kwargs = mock_write.call_args + upgrade_sql = "\n\n".join(args[0]) + + assert "CREATE TABLE users" in upgrade_sql + assert "CREATE TABLE products" in upgrade_sql + + rollback_sql = "\n\n".join(args[1]) + assert "DROP TABLE products" in rollback_sql + assert "DROP TABLE users" in rollback_sql From 1d9c89b174a5826d4a0b804de44daf1287f8498b Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 09:21:52 -0300 Subject: [PATCH 31/40] Docs update! --- README.md | 257 ++++++++++++++++++--------- docs/commands/index.md | 32 ++-- docs/commands/make-migrations.md | 267 ++++++++++++++++++++++++++++ docs/database-connections.md | 288 ++++++++++++++++++++++++++++++- docs/getting-started.md | 107 +++++++++--- docs/index.md | 64 ++++++- 6 files changed, 898 insertions(+), 117 deletions(-) create mode 100644 docs/commands/make-migrations.md diff --git a/README.md b/README.md index 4dee2cb..b090b72 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Jetbase helps you manage database migrations in a simple, version-controlled way --- + **Created and maintained by [Jaz](https://github.com/jaz-alli) 🛠️** --- @@ -41,7 +42,7 @@ pip install jetbase uv add jetbase ``` -> **Note for Snowflake and Databricks Users:** +> **Note for Snowflake and Databricks Users:** > To use Jetbase with Snowflake or Databricks, install the appropriate extras: > > ```shell @@ -50,7 +51,6 @@ uv add jetbase > ``` - ### Initialize Your Project ```bash @@ -86,7 +86,7 @@ jetbase new "create users table" -v 1 This creates a new SQL file called `V1__create_users_table.sql`. -> **Tip:** +> **Tip:** > You can also create migrations manually by adding SQL files in the `jetbase/migrations` directory, using the `V__.sql` naming convention (e.g., `V1__add_users_table.sql`, `V2.4__add_users_table.sql`). @@ -120,9 +120,9 @@ jetbase upgrade That's it! Your database is now up to date. 🎉 -> **Note:** -> Jetbase uses SQLAlchemy under the hood to manage database connections. -> For any database other than SQLite, you must install the appropriate Python database driver. +> **Note:** +> Jetbase uses SQLAlchemy under the hood to manage database connections. +> For any database other than SQLite, you must install the appropriate Python database driver. > For example, to use Jetbase with PostgreSQL: ```bash @@ -137,13 +137,23 @@ You can also use another compatible driver if you prefer (such as `asyncpg`, `pg Jetbase can automatically generate SQL migration files from your SQLAlchemy model definitions. This feature is similar to Django's `makemigrations` command. -### Prerequisites +### How It Works + +The `make-migrations` command: -1. Create SQLAlchemy model files with declarative base classes: +1. **Discovers models** - Finds SQLAlchemy models from configured paths or auto-discovery +2. **Introspects database** - Reads your current database schema +3. **Compares schemas** - Identifies differences between models and database +4. **Generates SQL** - Creates upgrade and rollback statements +5. **Writes migration file** - Saves the migration to `jetbase/migrations/` + +### Create SQLAlchemy Models + +First, create your SQLAlchemy models with declarative base classes: ```python # models.py -from sqlalchemy import Column, Integer, String +from sqlalchemy import Column, Integer, String, ForeignKey from sqlalchemy.orm import declarative_base Base = declarative_base() @@ -151,93 +161,89 @@ Base = declarative_base() class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) - email = Column(String(255), nullable=False) - name = Column(String(100)) + email = Column(String(255), nullable=False, unique=True) + name = Column(String(100), nullable=True) + -class Product(Base): - __tablename__ = "products" +class Post(Base): + __tablename__ = "posts" id = Column(Integer, primary_key=True) title = Column(String(255), nullable=False) - price = Column(Integer) + content = Column(String, nullable=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) ``` -### Auto-Discovery of Models 🎯 +### Configure Model Paths -Jetbase automatically discovers models in your project without needing to configure paths: +Jetbase supports multiple ways to specify model paths: -1. **Option 1: Auto-discovery** - Jetbase looks for models in: - - `models/` directory - - `model/` directory - - Searches recursively through subdirectories +**Option 1: Auto-discovery (Recommended)** +Jetbase automatically finds models in `models/` or `model/` directories: - ``` - your-project/ - ├── models/ - │ ├── user.py - │ ├── product/ - │ │ └── __init__.py - │ └── order.py - └── jetbase/ - ``` - -2. **Option 2: Explicit configuration** - Set the `JETBASE_MODELS` environment variable: - - ```bash - # Single model file - export JETBASE_MODELS="./models.py" - - # Multiple model files (comma-separated) - export JETBASE_MODELS="./models/user.py,./models/product.py,./models/order.py" - ``` +``` +your-project/ +├── models/ +│ ├── user.py +│ ├── post.py +│ └── __init__.py +└── jetbase/ +``` -3. **Option 3: Configure in env.py** - Add model paths in your configuration: +**Option 2: Environment Variable** +```bash +export JETBASE_MODELS="./models/user.py,./models/post.py" +``` - ```python - # jetbase/env.py - model_paths = ["./models/user.py", "./models/product.py"] - ``` +**Option 3: Configure in env.py** +```python +# jetbase/env.py +model_paths = ["./models/user.py", "./models/post.py"] +``` -> **Note:** Jetbase automatically adds your project root to `sys.path` so models can use absolute imports. +### Generate Migrations -### Generate Migrations Automatically +Run the make-migrations command: ```bash -jetbase make-migrations --description "create users and products" +# With custom description +jetbase make-migrations --description "add user profile" + +# With default description +jetbase make-migrations ``` -This will: -1. Read your model definitions from the paths specified in `JETBASE_MODELS` -2. Introspect your current database schema -3. Compare models against the database -4. Generate a migration file with upgrade and rollback SQL +Jetbase will: +1. Compare your models against the current database schema +2. Generate a migration file if there are changes +3. Save it to `jetbase/migrations/` Example generated migration: ```sql -- upgrade -CREATE TABLE users ( +CREATE TABLE profiles ( id INTEGER NOT NULL PRIMARY KEY, - email VARCHAR(255) NOT NULL, - name VARCHAR(100) + user_id INTEGER NOT NULL, + bio VARCHAR(500), + active BOOLEAN, + CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id) ); -CREATE TABLE products ( - id INTEGER NOT NULL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - price INTEGER -); +CREATE INDEX ix_profiles_user_id ON profiles (user_id); + -- rollback -DROP TABLE products; -DROP TABLE users; +DROP INDEX ix_profiles_user_id ON profiles; + +DROP TABLE profiles; ``` ### Apply the Generated Migration ```bash -# Using the new migrate command (alias for upgrade) +# Using the migrate command (alias for upgrade) jetbase migrate # Or use the traditional upgrade command @@ -247,26 +253,111 @@ jetbase upgrade ### Supported Operations The auto-generation feature detects: -- ✅ New tables to create -- ✅ Columns added/removed/modified -- ✅ Index creation/removal -- ✅ Foreign key creation/removal -### Environment Variable Configuration +| Operation | Upgrade SQL | Rollback SQL | +|-----------|-------------|--------------| +| New table | `CREATE TABLE` | `DROP TABLE` | +| New column | `ALTER TABLE ADD COLUMN` | `ALTER TABLE DROP COLUMN` | +| New index | `CREATE INDEX` / `CREATE UNIQUE INDEX` | `DROP INDEX` | +| New foreign key | `ALTER TABLE ADD CONSTRAINT` | `ALTER TABLE DROP CONSTRAINT` | -| Variable | Description | Default | -|----------|-------------|---------| -| `ASYNC` | Enable async mode (`true`/`false`) | `false` | -| `JETBASE_SQLALCHEMY_URL` | Database connection URL | Required | -| `JETBASE_MODELS` | Paths to SQLAlchemy models | Optional | - -You can also configure model paths in `jetbase/env.py`: +### Complete Example ```python -model_paths = ["./models/user.py", "./models/product.py"] +# models.py +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Text +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class Category(Base): + __tablename__ = "categories" + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + slug = Column(String(100), unique=True, nullable=False) + + +class Article(Base): + __tablename__ = "articles" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + slug = Column(String(255), unique=True, nullable=False) + content = Column(Text, nullable=True) + is_published = Column(Boolean, default=False) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) +``` + +Run migration generation: + +```bash +$ jetbase make-migrations --description "add blogging schema" +Created migration file: V20260204_120000__add_blogging_schema.sql ``` -> **Note:** When `ASYNC=false` (default), Jetbase automatically strips async driver suffixes (`+asyncpg`, `+aiosqlite`, `+async`) from the URL, allowing you to use async URLs in sync mode. +Generated SQL: + +```sql +-- upgrade + +CREATE TABLE categories ( + id INTEGER NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL +); + +CREATE UNIQUE INDEX uniq_categories_slug ON categories (slug); + +CREATE TABLE articles ( + id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + content TEXT, + is_published BOOLEAN, + category_id INTEGER NOT NULL, + CONSTRAINT fk_article_category FOREIGN KEY (category_id) REFERENCES categories (id) +); + +CREATE INDEX ix_articles_category_id ON articles (category_id); + + +-- rollback + +DROP INDEX ix_articles_category_id ON articles; + +DROP TABLE articles; + +DROP UNIQUE INDEX uniq_categories_slug ON categories; + +DROP TABLE categories; +``` + +Apply it: + +```bash +jetbase upgrade +``` + +### Detecting Schema Changes + +Jetbase compares your models against the **actual database** to detect: + +- **New tables** not yet in the database +- **New columns** added to existing tables +- **Removed tables** that exist in DB but not in models +- **Removed columns** that exist in DB but not in models +- **New indexes** defined in models +- **New foreign keys** defined in models + +### Rollback Support + +Every auto-generated migration includes proper rollback SQL: + +- Tables → `DROP TABLE` +- Columns → `DROP COLUMN` +- Indexes → `DROP INDEX` +- Foreign Keys → `DROP CONSTRAINT` + +--- ## Supported Databases @@ -358,6 +449,8 @@ jetbase upgrade > **Note:** Only PostgreSQL and SQLite support async mode. Other databases use sync connections regardless of the `ASYNC` setting. +--- + ## Commands Reference | Command | Description | @@ -375,6 +468,16 @@ jetbase upgrade | `jetbase lock` | Acquire migration lock | | `jetbase unlock` | Release migration lock | +### make-migrations Command Options + +```bash +jetbase make-migrations [OPTIONS] + +Options: + -d, --description TEXT Description for the migration (default: "auto_generated") + --help Show this message and exit +``` + ### Environment Variables | Variable | Description | diff --git a/docs/commands/index.md b/docs/commands/index.md index 937d1c7..ce4abf0 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -7,19 +7,20 @@ Jetbase provides a set of intuitive commands to manage your database migrations. | Command | Description | | --------------------------------------------- | ------------------------------------------------------------------ | | [`init`](init.md) | Initialize Jetbase in current directory | -| [`new`](new.md) | Create a new migration file | +| [`new`](new.md) | Create a new manual migration file | +| [`make-migrations`](make-migrations.md) | Auto-generate SQL from SQLAlchemy models | | [`upgrade`](upgrade.md) | Apply pending migrations | -| [`rollback`](rollback.md) | Undo migrations | +| [`rollback`](rollback.md) | Undo migrations | | [`status`](status.md) | Show migration status of all migration files (applied vs. pending) | -| [`history`](history.md) | Show migration history | -| [`current`](current.md) | Show latest version migrated | -| [`lock-status`](lock-status.md) | Check if migrations are locked | -| [`unlock`](unlock.md) | Remove migration lock | +| [`history`](history.md) | Show migration history | +| [`current`](current.md) | Show latest version migrated | +| [`lock-status`](lock-status.md) | Check if migrations are locked | +| [`unlock`](unlock.md) | Remove migration lock | | [`validate-checksums`](validate-checksums.md) | Verify migration file integrity | | [`validate-files`](validate-files.md) | Check for missing migration files | -| [`fix`](fix.md) | Fix migration issues | +| [`fix`](fix.md) | Fix migration issues | | [`fix-files`](validate-files.md) | Fix missing migration files (same as `validate-files --fix`) | -| [`fix-checksums`](validate-checksums.md) | Fix migration file checksums (same as `validate-checksums --fix`) | +| [`fix-checksums`](validate-checksums.md) | Fix migration file checksums (same as `validate-checksums --fix`) | ## Command Categories @@ -33,7 +34,8 @@ Commands to initialize and set up your migration environment: Commands to create and run migrations: -- **[`new`](new.md)** — Generate a new migration file +- **[`new`](new.md)** — Generate a new manual migration file +- **[`make-migrations`](make-migrations.md)** — Auto-generate SQL from SQLAlchemy models - **[`upgrade`](upgrade.md)** — Apply pending migrations to the database - **[`rollback`](rollback.md)** — Undo one or more migrations @@ -67,5 +69,15 @@ Every command has a `--help` option: ```bash jetbase --help # General help jetbase upgrade --help # Help for upgrade command -jetbase rollback --help # Help for rollback command +jetbase make-migrations --help # Help for make-migrations command ``` + +## Choosing the Right Command + +| Scenario | Recommended Command | +|----------|---------------------| +| Manual SQL migration | [`new`](new.md) | +| Generate from SQLAlchemy models | [`make-migrations`](make-migrations.md) | +| Apply pending migrations | [`upgrade`](upgrade.md) | +| Undo last migration | [`rollback`](rollback.md) | +| See what's been applied | [`status`](status.md) | diff --git a/docs/commands/make-migrations.md b/docs/commands/make-migrations.md new file mode 100644 index 0000000..0e388ec --- /dev/null +++ b/docs/commands/make-migrations.md @@ -0,0 +1,267 @@ +# jetbase make-migrations + +Automatically generate SQL migration files from your SQLAlchemy model definitions. + +## Usage + +```bash +jetbase make-migrations [OPTIONS] +``` + +## Description + +The `make-migrations` command automatically generates SQL migration files by: + +1. **Discovering models** - Finds SQLAlchemy models from configured paths +2. **Introspecting database** - Reads your current database schema +3. **Comparing schemas** - Identifies differences between models and database +4. **Generating SQL** - Creates upgrade and rollback statements +5. **Writing migration file** - Saves the migration to `jetbase/migrations/` + +## Options + +| Option | Required | Description | +|--------|----------|-------------| +| `-d`, `--description` | No | Description for the migration (default: "auto_generated") | + +## Arguments + +This command has no positional arguments. + +## How It Works + +### Step 1: Model Discovery + +Jetbase finds your SQLAlchemy models using one of these methods: + +**Auto-discovery** (recommended): +``` +your-project/ +├── models/ +│ ├── user.py +│ ├── post.py +│ └── __init__.py +└── jetbase/ +``` + +**Explicit configuration** via `JETBASE_MODELS`: +```bash +export JETBASE_MODELS="./models/user.py,./models/post.py" +``` + +**Or in env.py**: +```python +# jetbase/env.py +model_paths = ["./models/user.py", "./models/post.py"] +``` + +### Step 2: Database Introspection + +Jetbase connects to your database and reads the current schema. + +### Step 3: Schema Comparison + +Jetbase compares your models against the database to detect: +- New tables to create +- New columns to add +- Tables/columns to remove +- New indexes +- New foreign keys + +### Step 4: SQL Generation + +Jetbase generates appropriate SQL for each change. + +### Step 5: File Creation + +Creates a migration file in `jetbase/migrations/`. + +## Prerequisites + +### Create SQLAlchemy Models + +Your models must use declarative base: + +```python +# models.py +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False, unique=True) + name = Column(String(100)) + + +class Post(Base): + __tablename__ = "posts" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) +``` + +## Supported Operations + +| Operation | Upgrade SQL | Rollback SQL | +|-----------|-------------|--------------| +| New table | `CREATE TABLE` | `DROP TABLE` | +| New column | `ALTER TABLE ADD COLUMN` | `ALTER TABLE DROP COLUMN` | +| New index | `CREATE INDEX` | `DROP INDEX` | +| New unique index | `CREATE UNIQUE INDEX` | `DROP INDEX` | +| New foreign key | `ALTER TABLE ADD CONSTRAINT` | `ALTER TABLE DROP CONSTRAINT` | + +## Examples + +### Basic Usage + +```bash +jetbase make-migrations +``` + +Output: + +``` +Created migration file: V20260204.120000__auto_generated.sql +``` + +### With Custom Description + +```bash +jetbase make-migrations --description "add user profile" +``` + +Output: + +``` +Created migration file: V20260204.120000__add_user_profile.sql +``` + +## Generated File Example + +Input models: + +```python +class Category(Base): + __tablename__ = "categories" + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + slug = Column(String(100), unique=True) + + +class Article(Base): + __tablename__ = "articles" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + content = Column(String) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) +``` + +Generated migration: + +```sql +-- upgrade + +CREATE TABLE categories ( + id INTEGER NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) +); + +CREATE UNIQUE INDEX uniq_categories_slug ON categories (slug); + +CREATE TABLE articles ( + id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT, + category_id INTEGER NOT NULL, + CONSTRAINT fk_article_category FOREIGN KEY (category_id) REFERENCES categories (id) +); + +CREATE INDEX ix_articles_category_id ON articles (category_id); + + +-- rollback + +DROP INDEX ix_articles_category_id ON articles; + +DROP TABLE articles; + +DROP UNIQUE INDEX uniq_categories_slug ON categories; + +DROP TABLE categories; +``` + +## Apply the Migration + +```bash +jetbase upgrade +``` + +Or use the alias: + +```bash +jetbase migrate +``` + +## Configuration + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `JETBASE_MODELS` | Comma-separated paths to SQLAlchemy model files | +| `ASYNC` | Enable async mode (`true`/`false`, default: `false`) | + +### Model Path Configuration + +=== "Auto-discovery (Recommended)" + Place your models in `models/` or `model/` directory: + ``` + your-project/ + ├── models/ + │ ├── user.py + │ └── post.py + └── jetbase/ + ``` + +=== "Environment Variable" + ```bash + export JETBASE_MODELS="./models/user.py,./models/post.py" + ``` + +=== "env.py Configuration" + ```python + # jetbase/env.py + model_paths = ["./models/user.py", "./models/post.py"] + ``` + +## Async Mode + +The `make-migrations` command respects the `ASYNC` environment variable: + +```bash +# Sync mode (default) +jetbase make-migrations + +# Async mode +ASYNC=true jetbase make-migrations +``` + +When `ASYNC=true`, Jetbase uses async database connections for introspection. + +## Notes + +- Jetbase compares models against the **actual database**, not against previous migrations +- Models must use `declarative_base()` from SQLAlchemy +- The `__tablename__` attribute is required +- Foreign keys require `ForeignKey` imports from SQLAlchemy +- Migration files are saved to `jetbase/migrations/` + +## See Also + +- [Writing Migrations](../migrations/writing-migrations.md) +- [Commands Reference](index.md) +- [Configuration](../configuration.md) diff --git a/docs/database-connections.md b/docs/database-connections.md index 2994ae8..ccea107 100644 --- a/docs/database-connections.md +++ b/docs/database-connections.md @@ -1,6 +1,79 @@ # Database Connections 🔌 -Jetbase supports multiple databases. This guide covers how to connect to each supported database using a SQLAlchemy url. +Jetbase supports multiple databases with both synchronous and asynchronous connections. This guide covers how to connect to each supported database and configure async/sync modes. + +## Async and Sync Modes ⚡ + +Jetbase supports both synchronous and asynchronous database connections. The mode is controlled **exclusively** by the `ASYNC` environment variable. + +### Configuration + +Set the `ASYNC` environment variable before running Jetbase commands: + +```bash +export ASYNC=true # for async mode +export ASYNC=false # for sync mode (default) +jetbase status +``` + +You can also set it temporarily per command: + +```bash +ASYNC=true jetbase status # async mode +ASYNC=false jetbase upgrade # sync mode +``` + +### Sync Mode (Default) + +Use sync drivers or async drivers (async suffix is automatically stripped): + +```python +# jetbase/env.py +sqlalchemy_url = "postgresql+psycopg2://user:password@localhost:5432/mydb" +# or +sqlalchemy_url = "sqlite:///mydb.db" +# or even async URLs (suffix is stripped automatically) +sqlalchemy_url = "postgresql+asyncpg://user:password@localhost:5432/mydb" +``` + +Sync mode is the default. Just run: + +```bash +jetbase upgrade +``` + +### Async Mode + +Use async drivers: + +```python +# jetbase/env.py +sqlalchemy_url = "postgresql+asyncpg://user:password@localhost:5432/mydb" +# or +sqlalchemy_url = "sqlite+aiosqlite:///mydb.db" +``` + +Set `ASYNC=true`: + +```bash +export ASYNC=true +jetbase upgrade +``` + +### URL Format Reference + +| Database | Sync URL | Async URL | +|----------|----------|-----------| +| PostgreSQL | `postgresql+psycopg2://...` | `postgresql+asyncpg://...` | +| SQLite | `sqlite:///path.db` | `sqlite+aiosqlite:///path.db` | +| Snowflake | `snowflake://...` | Not supported | +| MySQL | `mysql+pymysql://...` | Not supported | +| Databricks | `databricks+connector://...` | Not supported | + +!!! note + Only PostgreSQL and SQLite support async mode. Other databases use sync connections regardless of the `ASYNC` setting. + +--- ## PostgreSQL @@ -35,6 +108,219 @@ sqlalchemy_url = "postgresql://myuser:mypassword@localhost:5432/myapp" postgres_schema = "public" ``` +### Async Mode + +```python +# jetbase/env.py +sqlalchemy_url = "postgresql+asyncpg://myuser:mypassword@localhost:5432/myapp" +``` + +Run with async mode: + +```bash +ASYNC=true jetbase upgrade +``` + +--- + +## Snowflake + +Snowflake is a cloud-based data warehouse. Jetbase supports both username/password and key pair authentication. + +### Installing the Driver + +Snowflake requires additional dependencies. Install Jetbase with the Snowflake extra: + +```bash +pip install "jetbase[snowflake]" +``` + +### Connection String Format + +```python +sqlalchemy_url = "snowflake://username:password@account/database/schema?warehouse=WAREHOUSE_NAME" +``` + +| Component | Description | +|-----------|-------------| +| `username` | Your Snowflake username | +| `password` | Your Snowflake password (omit for key pair auth) | +| `account` | Your Snowflake account identifier (e.g., `abc12345.us-east-1`) | +| `database` | Target database name | +| `schema` | Target schema name | +| `warehouse` | Compute warehouse to use | + +### Username & Password Authentication + +The simplest way to connect is with username and password: + +```python +# jetbase/env.py +sqlalchemy_url = "snowflake://myuser:mypassword@myaccount.us-east-1/my_db/public?warehouse=COMPUTE_WH" +``` + +### Key Pair Authentication + +For enhanced security, Snowflake supports key pair authentication. To use it, omit the password from your connection string and configure your private key. + +**Step 1:** Create a connection string without a password: + +```python +# jetbase/env.py +sqlalchemy_url = "snowflake://myuser@myaccount.us-east-1/my_db/public?warehouse=COMPUTE_WH" +``` + +**Step 2:** Configure your private key as an environment variable: + +```bash +# Set the private key (PEM format) +export JETBASE_SNOWFLAKE_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASC... +-----END PRIVATE KEY-----" + +# Optional: if your private key is encrypted +export JETBASE_SNOWFLAKE_PRIVATE_KEY_PASSWORD="your-key-password" +``` + +!!! tip + It's best to read your private key file directly into the environment variable locally: + + ```bash + export JETBASE_SNOWFLAKE_PRIVATE_KEY=$(cat snowflake_private_key.pem) + ``` + +--- + +## SQLite + +### Connection String + +SQLite doesn't require any additional drivers. Just connect with the connection string. + +```python +sqlalchemy_url = "sqlite:///path/to/database.db" +``` + +### Examples + +**Relative path** (relative to where you run Jetbase): + +```python +# jetbase/env.py +sqlalchemy_url = "sqlite:///myapp.db" +``` + +**In-memory database** (useful for testing): + +```python +# jetbase/env.py +sqlalchemy_url = "sqlite:///:memory:" +``` + +**Async mode**: + +```python +# jetbase/env.py +sqlalchemy_url = "sqlite+aiosqlite:///myapp.db" +``` + +Run with async mode: + +```bash +ASYNC=true jetbase upgrade +``` + +--- + +## MySQL + +### Installing a Driver + +MySQL requires the PyMySQL driver: + +```bash +pip install pymysql +``` + +### Connection String + +```python +sqlalchemy_url = "mysql+pymysql://username:password@host:port/database" +``` + +### Example + +```python +# jetbase/env.py +sqlalchemy_url = "mysql+pymysql://myuser:mypassword@localhost:3306/myapp" +``` + +--- + +## Databricks + +### Installing the Driver + +Databricks requires additional dependencies. Install Jetbase with the Databricks extra: + +```bash +pip install "jetbase[databricks]" +``` + +### Connection String Format + +```python +sqlalchemy_url = "databricks://token:ACCESS_TOKEN@HOSTNAME?http_path=HTTP_PATH&catalog=CATALOG&schema=SCHEMA" +``` + +| Component | Description | +|-----------|-------------| +| `ACCESS_TOKEN` | Your Databricks personal access token | +| `HOSTNAME` | Your Databricks workspace hostname (e.g., `adb-1234567890123456.cloud.databricks.com`) | +| `HTTP_PATH` | The HTTP path to your SQL warehouse or cluster (e.g., `/sql/1.0/warehouses/abc`) | +| `CATALOG` | The Unity Catalog name to use | +| `SCHEMA` | The schema name within the catalog | + +### Example + +```python +# jetbase/env.py +sqlalchemy_url = "databricks://token:dapi1234567890abcdef@adb-1234567890123456.cloud.databricks.comt?http_path=/sql/1.0/warehouses/abc123def456&catalog=main&schema=default" +``` + +--- + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASYNC` | Enable async mode (`true`/`false`) | `false` | +| `JETBASE_SQLALCHEMY_URL` | Database connection URL | Required | +| `JETBASE_POSTGRES_SCHEMA` | PostgreSQL schema search path | `public` | +| `JETBASE_SNOWFLAKE_PRIVATE_KEY` | Snowflake private key (PEM format) | Optional | +| `JETBASE_SNOWFLAKE_PRIVATE_KEY_PASSWORD` | Snowflake private key password | Optional | + +### Connection String + +```python +sqlalchemy_url = "postgresql+driver://username:password@host:port/database" +``` + +### Example + +```python +# jetbase/env.py +sqlalchemy_url = "postgresql+psycopg2://myuser:mypassword@localhost:5432/myapp" +``` + +With a specific schema: + +```python +# jetbase/env.py +sqlalchemy_url = "postgresql://myuser:mypassword@localhost:5432/myapp" +postgres_schema = "public" +``` + --- ## Snowflake diff --git a/docs/getting-started.md b/docs/getting-started.md index 2e1f75f..93bd402 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -98,7 +98,9 @@ Open `env.py` and update the `sqlalchemy_url` with your database connection stri ## Creating Your First Migration -### Step 1: Generate a Migration File +Jetbase offers two ways to create migrations: + +### Option 1: Manual Migration (Write SQL Yourself) Use the `new` command to create a migration file: @@ -115,16 +117,14 @@ migrations/V1__create_users_table.sql The filename format is: `V{version}__{description}.sql` !!! tip "Manual Migration Files" -You can also create migration files manually if you prefer! Simply add your migration file to the `jetbase/migrations/` folder and follow the required filename format: -`V{version}__{description}.sql` -**Example:** -`V2.4__create_users_table.sql` - -Be sure your file starts with `V`, followed by a version (like `2.4`), then `__`, a short description (use underscores for spaces), and ends with `.sql`. + You can also create migration files manually if you prefer! Simply add your migration file to the `jetbase/migrations/` folder and follow the required filename format: + `V{version}__{description}.sql` + **Example:** + `V2.4__create_users_table.sql` -### Step 2: Write Your Migration SQL + Be sure your file starts with `V`, followed by a version (like `2.4`), then `__`, a short description (use underscores for spaces), and ends with `.sql`. -Open the newly created file and add your SQL statements: +Write your migration SQL: ```sql -- upgrade @@ -147,8 +147,56 @@ DROP TABLE users; !!! note "Migration File Structure" - The `-- rollback` section contains *only* SQL to undo the migration, and any rollback statements must go **after** `-- rollback` +### Option 2: Auto-Generate from SQLAlchemy Models + +Jetbase can automatically generate SQL migrations from your SQLAlchemy models. + +**Step 1:** Create your models: + +```python +# models.py +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) + name = Column(String(100)) + -### Step 3: Apply the Migration +class Post(Base): + __tablename__ = "posts" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) +``` + +**Step 2:** Configure model paths in `jetbase/env.py`: + +```python +model_paths = ["../models.py"] +``` + +Or use auto-discovery by placing models in `models/` directory. + +**Step 3:** Generate migrations: + +```bash +jetbase make-migrations --description "add user and post models" +``` + +Jetbase will: +1. Discover your models +2. Introspect the database +3. Compare schemas +4. Generate SQL with upgrade and rollback + +[Learn more about auto-generation](commands/make-migrations.md) + +## Apply the Migration Run the upgrade command: @@ -162,16 +210,16 @@ Output: Migration applied successfully: V1__create_users_table.sql ``` -> **Note:** -Jetbase uses SQLAlchemy under the hood to manage database connections. -For any database other than SQLite, you must install the appropriate Python database driver. -For example, to use Jetbase with PostgreSQL: -``` -pip install psycopg2 -``` -You can also use another compatible driver if you prefer (such as `asyncpg`, `pg8000`, etc.). +> **Note:** +> Jetbase uses SQLAlchemy under the hood to manage database connections. +> For any database other than SQLite, you must install the appropriate Python database driver. +> For example, to use Jetbase with PostgreSQL: +> ``` +> pip install psycopg2 +> ``` +> You can also use another compatible driver if you prefer (such as `asyncpg`, `pg8000`, etc.). -### Step 4: Verify the Migration +## Verify the Migration Check the migration status: @@ -184,24 +232,39 @@ You'll see a table showing: - ✅ Applied migrations - 📋 Pending migrations (if any) +## Async and Sync Support + +Jetbase supports both synchronous and asynchronous database connections: + +```bash +# Sync mode (default) +jetbase upgrade + +# Async mode +ASYNC=true jetbase upgrade +``` + ## What's Next? Now that you've set up your first migration, explore these topics: +- [Auto-Generation from Models](commands/make-migrations.md) — Automatically generate migrations from SQLAlchemy models - [Writing Migrations](migrations/writing-migrations.md) — Learn about migration file syntax and best practices - [Commands Reference](commands/index.md) — Discover all available commands - [Rollbacks](commands/rollback.md) — Learn how to safely undo migrations - [Configuration Options](configuration.md) — Customize Jetbase behavior +- [Database Connections](database-connections.md) — Connect to different databases with async/sync support ## Quick Command Reference | Command | Description | | ------------------------------------------------------- | --------------------------------------- | | [`init`](commands/init.md) | Initialize Jetbase in current directory | -| [`new`](commands/new.md) | Create a new migration file | +| [`new`](commands/new.md) | Create a new manual migration file | +| [`make-migrations`](commands/make-migrations.md) | Auto-generate SQL from SQLAlchemy models | | [`upgrade`](commands/upgrade.md) | Apply pending migrations | | [`rollback`](commands/rollback.md) | Undo migrations | -| [`status`](commands/status.md) | Show migration status of all migration files (applied vs. pending) | +| [`status`](commands/status.md) | Show migration status | | [`history`](commands/history.md) | Show migration history | | [`current`](commands/current.md) | Show latest version migrated | | [`lock-status`](commands/lock-status.md) | Check if migrations are locked | @@ -209,6 +272,4 @@ Now that you've set up your first migration, explore these topics: | [`validate-checksums`](commands/validate-checksums.md) | Verify migration file integrity | | [`validate-files`](commands/validate-files.md) | Check for missing migration files | | [`fix`](commands/fix.md) | Fix migration issues | -| [`fix-files`](commands/validate-files.md) | Fix missing migration files (same as `validate-files --fix`) | -| [`fix-checksums`](commands/validate-checksums.md) | Fix migration file checksums (same as `validate-checksums --fix`) | diff --git a/docs/index.md b/docs/index.md index ad7c695..04e7a4e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,10 @@ Jetbase helps you manage database migrations in a simple, version-controlled way - **🔒 Migration Locking** — Prevents conflicts when multiple processes try to migrate - **✅ Checksum Validation** — Detects if migration files have been modified - **🔄 Repeatable Migrations** — Support for migrations that run on every upgrade +- **🤖 Auto-Generation** — Automatically generate SQL migrations from SQLAlchemy models +- **🔀 Async/Sync Support** — Works with both sync and async SQLAlchemy drivers +- **🎯 Auto-Discovery** — Automatically finds models in `models/` or `model/` directories +- **📁 Portable** — Run jetbase from any directory in your project ## Quick Start 🏃‍♂️ @@ -108,16 +112,62 @@ jetbase upgrade That's it! Your database is now up to date. 🎉 -> **Note:** -> Jetbase uses SQLAlchemy under the hood to manage database connections. -> For any database other than SQLite, you must install the appropriate Python database driver. -> For example, to use Jetbase with PostgreSQL: +!!! note + Jetbase uses SQLAlchemy under the hood to manage database connections. + For any database other than SQLite, you must install the appropriate Python database driver. + For example, to use Jetbase with PostgreSQL: + ``` + pip install psycopg2 + ``` + + You can also use another compatible driver if you prefer (such as `asyncpg`, `pg8000`, etc.). + +## Auto-Generation from SQLAlchemy Models 🤖 + +Jetbase can automatically generate SQL migrations from your SQLAlchemy model definitions: + +```python +# models.py +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), nullable=False) + name = Column(String(100)) +``` + +Generate migrations automatically: + +```bash +jetbase make-migrations --description "add user model" ``` -pip install psycopg2 + +Jetbase will: +1. Discover your models from `models/` directory or configured paths +2. Introspect your current database schema +3. Compare models against the database +4. Generate a migration file with upgrade and rollback SQL + +[Learn more about auto-generation](commands/make-migrations.md) + +## Async and Sync Support ⚡ + +Jetbase supports both synchronous and asynchronous database connections: + +```bash +# Sync mode (default) +jetbase upgrade + +# Async mode +ASYNC=true jetbase upgrade ``` -You can also use another compatible driver if you prefer (such as `asyncpg`, `pg8000`, etc.). +[Learn more about async/sync support](database-connections.md#async-and-sync-modes) ## Next Steps @@ -125,6 +175,7 @@ You can also use another compatible driver if you prefer (such as `asyncpg`, `pg - 🛠️ [Commands Reference](commands/index.md) — Learn all available commands - 📝 [Writing Migrations](migrations/index.md) — Best practices for migration files - ⚙️ [Configuration](configuration.md) — Customize Jetbase for your needs +- 🔌 [Database Connections](database-connections.md) — Connect to different databases ## Supported Databases @@ -134,3 +185,4 @@ Jetbase currently supports: - ✅ SQLite - ✅ Snowflake - ✅ MySQL +- ✅ Databricks From 609fd3ed031ee58295be1e21f1dbafc7f18796f4 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 11:58:43 -0300 Subject: [PATCH 32/40] Better env.py handling --- jetbase/cli/main.py | 21 ++++++++++----------- jetbase/config.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/jetbase/cli/main.py b/jetbase/cli/main.py index 221b086..06a39be 100644 --- a/jetbase/cli/main.py +++ b/jetbase/cli/main.py @@ -29,10 +29,10 @@ def upgrade( None, "--count", "-c", help="Number of migrations to apply" ), to_version: str | None = typer.Option( - None, "--to-version", "-t", help="Rollback to a specific version" + None, "--to-version", "-t", help="Migrate to a specific version" ), dry_run: bool = typer.Option( - False, "--dry-run", "-d", help="Simulate the upgrade without making changes" + False, "--dry-run", "-d", help="Simulate the migration without making changes" ), skip_validation: bool = typer.Option( False, @@ -49,12 +49,14 @@ def upgrade( "--skip-file-validation", help="Skip file version validation when running migrations", ), -): - """Execute pending migrations""" - validate_jetbase_directory() - upgrade_cmd( +) -> None: + """Apply pending migrations to the database. + + This is an alias for the 'migrate' command. + """ + migrate( count=count, - to_version=to_version.replace("_", ".") if to_version else None, + to_version=to_version, dry_run=dry_run, skip_validation=skip_validation, skip_checksum_validation=skip_checksum_validation, @@ -236,10 +238,7 @@ def migrate( help="Skip file version validation when running migrations", ), ) -> None: - """Apply pending migrations to the database. - - This is an alias for the 'upgrade' command. - """ + """Apply pending migrations to the database.""" validate_jetbase_directory() upgrade_cmd( count=count, diff --git a/jetbase/config.py b/jetbase/config.py index 3d9a348..0db8398 100644 --- a/jetbase/config.py +++ b/jetbase/config.py @@ -231,6 +231,16 @@ def _load_config_from_path(config_path: str, key: str) -> Any | None: Automatically adds the project root (parent of jetbase directory) to sys.path so that imports like 'from app.core.config import ...' work correctly. + Supports two configuration patterns: + 1. Module-level variables (original): Define variables directly at module level + (e.g., `sqlalchemy_url = "postgresql://..."`) + + 2. get_env_vars() function (recommended): Define a function that returns a dict + with all configuration values (e.g., `def get_env_vars(): return {"sqlalchemy_url": "..."}`) + + The get_env_vars() pattern allows for more complex logic and configuration + while keeping the configuration interface simple. + Args: config_path (str): Path to the env.py file. key (str): The configuration key to retrieve. @@ -256,6 +266,11 @@ def _load_config_from_path(config_path: str, key: str) -> Any | None: config: ModuleType = importlib.util.module_from_spec(spec) spec.loader.exec_module(module=config) + if hasattr(config, "get_env_vars") and callable(config.get_env_vars): + env_vars = config.get_env_vars() + if isinstance(env_vars, dict) and key in env_vars: + return env_vars[key] + config_value: Any | None = getattr(config, key, None) return config_value except Exception: From fe11d540a5f8e002d3d540b0885b14e198a3f299 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 11:58:50 -0300 Subject: [PATCH 33/40] Doc update! --- README.md | 73 ++++++++-- docs/advanced/migration-locking.md | 12 +- docs/advanced/migration-types.md | 4 +- docs/commands/index.md | 18 ++- docs/commands/init.md | 2 +- docs/commands/make-migrations.md | 6 - docs/commands/upgrade.md | 39 +++-- docs/configuration.md | 6 +- docs/database-connections.md | 226 ++++++++++++++++++++++++++--- docs/getting-started.md | 66 ++++++++- docs/index.md | 16 +- docs/migrations/index.md | 10 +- docs/validations/index.md | 8 +- 13 files changed, 394 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index b090b72..0534455 100644 --- a/README.md +++ b/README.md @@ -66,18 +66,67 @@ This creates a `jetbase/` directory with: ### Configure Your Database -Edit `jetbase/env.py` with your database connection string (currently support for postgres, sqlite snowflake, databricks): +Edit `jetbase/env.py` with your database connection string. Jetbase supports two configuration patterns: + +#### Pattern 1: Module-level Variables (Simple) + +Define configuration variables directly at module level: -**PostgreSQL example:** ```python sqlalchemy_url = "postgresql+psycopg2://user:password@localhost:5432/mydb" +async_mode = True ``` -**SQLite example:** +**Examples:** + +PostgreSQL: +```python +sqlalchemy_url = "postgresql+psycopg2://user:password@localhost:5432/mydb" +``` + +SQLite: ```python sqlalchemy_url = "sqlite:///mydb.db" ``` +SQLite Async: +```python +sqlalchemy_url = "sqlite+aiosqlite:///mydb.db" +async_mode = True +``` + +#### Pattern 2: get_env_vars() Function (Recommended) + +For more complex configurations, define a `get_env_vars()` function that returns a dictionary: + +```python +from dotenv import load_dotenv +import os + +load_dotenv() + +ENVIRONMENT = os.getenv("ENVIRONMENT") + +if ENVIRONMENT == "DEV": + def get_env_vars(): + return { + "sqlalchemy_url": "sqlite+aiosqlite:///./egos.db", + "async_mode": True, + } +else: + def get_env_vars(): + return { + "sqlalchemy_url": "postgresql+asyncpg://user:password@localhost:5432/mydb", + "async_mode": True, + } +``` + +**Why use get_env_vars()?** +- Keep all configuration logic in one place +- Access to full Python logic (if/else, imports, etc.) +- Easy to see all Jetbase configuration at a glance +- Better separation of concerns + ### Create Your First Migration ```bash @@ -115,7 +164,7 @@ DROP TABLE users; ### Apply the Migration ```bash -jetbase upgrade +jetbase migrate ``` That's it! Your database is now up to date. 🎉 @@ -243,11 +292,7 @@ DROP TABLE profiles; ### Apply the Generated Migration ```bash -# Using the migrate command (alias for upgrade) jetbase migrate - -# Or use the traditional upgrade command -jetbase upgrade ``` ### Supported Operations @@ -334,7 +379,7 @@ DROP TABLE categories; Apply it: ```bash -jetbase upgrade +jetbase migrate ``` ### Detecting Schema Changes @@ -389,7 +434,7 @@ You can also set it temporarily per command: ```bash ASYNC=true jetbase status # async mode -ASYNC=false jetbase upgrade # sync mode +ASYNC=false jetbase migrate # sync mode ``` ### Sync Mode (Default) @@ -408,7 +453,7 @@ sqlalchemy_url = "postgresql+asyncpg://user:password@localhost:5432/mydb" Sync mode is the default. Just run: ```bash -jetbase upgrade +jetbase migrate ``` ### Async Mode @@ -426,7 +471,7 @@ Set `ASYNC=true`: ```bash export ASYNC=true -jetbase upgrade +jetbase migrate ``` ### Environment Variable Configuration @@ -458,8 +503,8 @@ jetbase upgrade | `jetbase init` | Initialize a new Jetbase project | | `jetbase new "description"` | Create a new manual migration file | | `jetbase make-migrations` | Auto-generate SQL from SQLAlchemy models | -| `jetbase upgrade` | Apply pending migrations | -| `jetbase migrate` | Apply migrations (alias for upgrade) | +| `jetbase migrate` | Apply pending migrations | +| `jetbase upgrade` | Apply pending migrations (alias for migrate) | | `jetbase rollback` | Rollback migrations | | `jetbase status` | Show migration status | | `jetbase history` | Show migration history | diff --git a/docs/advanced/migration-locking.md b/docs/advanced/migration-locking.md index 8222e62..7f9b577 100644 --- a/docs/advanced/migration-locking.md +++ b/docs/advanced/migration-locking.md @@ -1,16 +1,16 @@ # Migration Locking -Suppose two developers or AI agents both run `jetbase upgrade` at the same time. +Suppose two developers or AI agents both run `jetbase migrate` at the same time. Without any locking, both processes could try to change the database together. This might cause errors, data corruption, or broken migrations. -Jetbase solves this with **automatic migration locking**. Only one process can run migrations at a time. +Jetbase solves this with **automatic migration locking**. Only one process can run migrations at a time. ## What Is Migration Locking? -When you run `jetbase upgrade`, Jetbase grabs a lock before touching your database. Think of it like putting a "Do Not Disturb" sign on your migrations. No other process can run migrations until the first one finishes. +When you run `jetbase migrate`, Jetbase grabs a lock before touching your database. Think of it like putting a "Do Not Disturb" sign on your migrations. No other process can run migrations until the first one finishes. -Jetbase automatically acquires a lock whenever you run any command that might modify the `jetbase_migrations` table. This includes commands like `jetbase upgrade`, `jetbase rollback`, and all `fix` operations. By doing this, Jetbase ensures that your migrations always run safely without any risk of collision. +Jetbase automatically acquires a lock whenever you run any command that might modify the `jetbase_migrations` table. This includes commands like `jetbase migrate`, `jetbase rollback`, and all `fix` operations. By doing this, Jetbase ensures that your migrations always run safely without any risk of collision. You shouldn't need to think about locking at all! With Jetbase, it just works out of the box! @@ -119,10 +119,10 @@ Unlock successful. ```bash # Server 1 -jetbase upgrade # Acquires lock, runs migrations... +jetbase migrate # Acquires lock, runs migrations... # Server 2 (at the same time) -jetbase upgrade # Fails immediately with "Lock is already held" +jetbase migrate # Fails immediately with "Lock is already held" ``` Server 2 gets a clear error. No corruption, no race conditions. diff --git a/docs/advanced/migration-types.md b/docs/advanced/migration-types.md index 772330c..60cc09a 100644 --- a/docs/advanced/migration-types.md +++ b/docs/advanced/migration-types.md @@ -211,14 +211,14 @@ ROC__order_functions.sql # Just edit this file ## Execution Order -When you run `jetbase upgrade`, migrations execute in this order: +When you run `jetbase migrate`, migrations execute in this order: 1. **Versioned migrations** — In version order (oldest first) 2. **Repeatable On Change** — Files that have changed 3. **Repeatable Always** — All RA files ``` -[Upgrade Start] +[Migrate Start] │ ├── V20251225.100000__create_users.sql ├── V20251225.110000__create_orders.sql diff --git a/docs/commands/index.md b/docs/commands/index.md index ce4abf0..08325ea 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -9,7 +9,8 @@ Jetbase provides a set of intuitive commands to manage your database migrations. | [`init`](init.md) | Initialize Jetbase in current directory | | [`new`](new.md) | Create a new manual migration file | | [`make-migrations`](make-migrations.md) | Auto-generate SQL from SQLAlchemy models | -| [`upgrade`](upgrade.md) | Apply pending migrations | +| [`migrate`](upgrade.md) | Apply pending migrations | +| [`upgrade`](upgrade.md) | Apply pending migrations (alias for migrate) | | [`rollback`](rollback.md) | Undo migrations | | [`status`](status.md) | Show migration status of all migration files (applied vs. pending) | | [`history`](history.md) | Show migration history | @@ -20,7 +21,7 @@ Jetbase provides a set of intuitive commands to manage your database migrations. | [`validate-files`](validate-files.md) | Check for missing migration files | | [`fix`](fix.md) | Fix migration issues | | [`fix-files`](validate-files.md) | Fix missing migration files (same as `validate-files --fix`) | -| [`fix-checksums`](validate-checksums.md) | Fix migration file checksums (same as `validate-checksums --fix`) | +| [`fix-checksums`](validate-checksums.md) | Fix migration file checksums (same as `validate-checksums --fix`) | ## Command Categories @@ -36,7 +37,8 @@ Commands to create and run migrations: - **[`new`](new.md)** — Generate a new manual migration file - **[`make-migrations`](make-migrations.md)** — Auto-generate SQL from SQLAlchemy models -- **[`upgrade`](upgrade.md)** — Apply pending migrations to the database +- **[`migrate`](upgrade.md)** — Apply pending migrations to the database +- **[`upgrade`](upgrade.md)** — Apply pending migrations (alias for migrate) - **[`rollback`](rollback.md)** — Undo one or more migrations ### 📊 Status Commands @@ -68,16 +70,22 @@ Every command has a `--help` option: ```bash jetbase --help # General help -jetbase upgrade --help # Help for upgrade command +jetbase migrate --help # Help for migrate command jetbase make-migrations --help # Help for make-migrations command ``` +!!! tip "Running Jetbase" + If you encounter errors, run Jetbase using your project's Python environment: + ```bash + uv run jetbase migrate + ``` + ## Choosing the Right Command | Scenario | Recommended Command | |----------|---------------------| | Manual SQL migration | [`new`](new.md) | | Generate from SQLAlchemy models | [`make-migrations`](make-migrations.md) | -| Apply pending migrations | [`upgrade`](upgrade.md) | +| Apply pending migrations | [`migrate`](upgrade.md) | | Undo last migration | [`rollback`](rollback.md) | | See what's been applied | [`status`](status.md) | diff --git a/docs/commands/init.md b/docs/commands/init.md index 01a87d8..8504b63 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -78,7 +78,7 @@ cd jetbase jetbase new "create users table" -v 1 # 5. Apply migrations -jetbase upgrade +jetbase migrate ``` ## Notes diff --git a/docs/commands/make-migrations.md b/docs/commands/make-migrations.md index 0e388ec..e6aaf6f 100644 --- a/docs/commands/make-migrations.md +++ b/docs/commands/make-migrations.md @@ -196,12 +196,6 @@ DROP TABLE categories; ## Apply the Migration -```bash -jetbase upgrade -``` - -Or use the alias: - ```bash jetbase migrate ``` diff --git a/docs/commands/upgrade.md b/docs/commands/upgrade.md index ae46dfa..23f8f1d 100644 --- a/docs/commands/upgrade.md +++ b/docs/commands/upgrade.md @@ -1,16 +1,24 @@ -# jetbase upgrade +# jetbase migrate Apply pending migrations to your database. ## Usage ```bash -jetbase upgrade +jetbase migrate ``` ## Description -The `upgrade` command applies all pending migrations to your database in order. It's the most commonly used command for keeping your database schema up to date. +The `migrate` command applies all pending migrations to your database in order. It's the most commonly used command for keeping your database schema up to date. + +> **Note:** `jetbase upgrade` is also available as an alias for `migrate`. + +!!! tip "Running Jetbase" + If you encounter errors, run Jetbase using your project's Python environment: + ```bash + uv run jetbase migrate + ``` ## Options @@ -28,7 +36,7 @@ The `upgrade` command applies all pending migrations to your database in order. ### Apply All Pending Migrations ```bash -jetbase upgrade +jetbase migrate ``` This applies all pending migrations in order. @@ -37,21 +45,21 @@ This applies all pending migrations in order. ```bash # Apply only the next 2 migrations -jetbase upgrade --count 2 +jetbase migrate --count 2 ``` ### Apply Up to a Specific Version ```bash # Apply all migrations up to and including version 5 -jetbase upgrade --to-version 5 +jetbase migrate --to-version 5 ``` ### Preview Changes (Dry Run) ```bash -jetbase upgrade --dry-run +jetbase migrate --dry-run ``` This shows you what migrations would be applied without actually running them. Great for verifying before deployment! @@ -78,13 +86,13 @@ ALTER TABLE users ADD COLUMN email VARCHAR(255); ```bash # Skip all validation (use with caution!) -jetbase upgrade --skip-validation +jetbase migrate --skip-validation # Skip only checksum validation -jetbase upgrade --skip-checksum-validation +jetbase migrate --skip-checksum-validation # Skip only file validation -jetbase upgrade --skip-file-validation +jetbase migrate --skip-file-validation ``` !!! warning @@ -94,7 +102,7 @@ jetbase upgrade --skip-file-validation ## Migration Types -During upgrade, Jetbase processes three types of migrations. Most developers will only ever need to worry about the standard Versioned Migrations. +During migration, Jetbase processes three types of migrations. Most developers will only ever need to worry about the standard Versioned Migrations. ### Versioned Migrations (`V*`) @@ -106,7 +114,7 @@ V20251225.143022__create_users_table.sql ### Runs Always (`RA__*`) -Migrations that run on every upgrade. +Migrations that run on every migrate. ``` RA__refresh_views.sql @@ -124,13 +132,14 @@ Learn more in [Migration Types](../advanced/migration-types.md). + ## Error Handling -Jetbase is designed to keep your database safe even when something goes wrong. If a migration fails during an upgrade: +Jetbase is designed to keep your database safe even when something goes wrong. If a migration fails: 1. **No Partial Changes:** The failed migration file will *not* be applied at all. Any statements within that file are rolled back, so your database remains unchanged by that migration. -2. **Orderly Progress:** All prior migration files that completed successfully in during that same upgrade command remain applied. Any migration files scheduled to run *after* the failed one are skipped. -3. **Clear Feedback:** You’ll see a descriptive error message explaining what went wrong, so you can fix the issue and try again. +2. **Orderly Progress:** All prior migration files that completed successfully during that same migrate command remain applied. Any migration files scheduled to run *after* the failed one are skipped. +3. **Clear Feedback:** You'll see a descriptive error message explaining what went wrong, so you can fix the issue and try again. Jetbase stops safely at the first sign of trouble, preventing partial or out-of-order migrations. diff --git a/docs/configuration.md b/docs/configuration.md index 957acd5..561d678 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,13 +173,13 @@ Some configuration options can be overridden via command-line flags: ```bash # Skip all validation -jetbase upgrade --skip-validation +jetbase migrate --skip-validation # Skip only checksum validation -jetbase upgrade --skip-checksum-validation +jetbase migrate --skip-checksum-validation # Skip only file validation -jetbase upgrade --skip-file-validation +jetbase migrate --skip-file-validation ``` ## Database Tables diff --git a/docs/database-connections.md b/docs/database-connections.md index ccea107..9ed593b 100644 --- a/docs/database-connections.md +++ b/docs/database-connections.md @@ -2,13 +2,212 @@ Jetbase supports multiple databases with both synchronous and asynchronous connections. This guide covers how to connect to each supported database and configure async/sync modes. +## Configuration Methods + +Jetbase supports two ways to configure your database connection: + +### Method 1: Module-level Variables + +Define configuration variables directly at module level in `jetbase/env.py`: + +```python +sqlalchemy_url = "postgresql+psycopg2://user:password@localhost:5432/mydb" +async_mode = True +``` + +### Method 2: get_env_vars() Function (Recommended) + +For more complex configurations, define a `get_env_vars()` function that returns a dictionary: + +```python +from dotenv import load_dotenv +import os + +load_dotenv() + +ENVIRONMENT = os.getenv("ENVIRONMENT") + +if ENVIRONMENT == "DEV": + def get_env_vars(): + return { + "sqlalchemy_url": "sqlite+aiosqlite:///./egos.db", + "async_mode": True, + } +else: + def get_env_vars(): + return { + "sqlalchemy_url": "postgresql+asyncpg://user:password@localhost:5432/mydb", + "async_mode": True, + } +``` + +**Benefits of get_env_vars():** +- All configuration logic in one place +- Access to full Python expressions and imports +- Easy to maintain and understand +- Clean separation from other code + +--- + +## Complete env.py Examples + +### Example 1: Environment-based Configuration with dotenv + +```python +# jetbase/env.py +from dotenv import load_dotenv +import os + +load_dotenv() + +ENVIRONMENT = os.getenv("ENVIRONMENT") +POSTGRES_USER = os.getenv("POSTGRES_USER") +POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD") +POSTGRES_HOST = os.getenv("POSTGRES_HOST", "localhost") +POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") +POSTGRES_DB = os.getenv("POSTGRES_DB") + +if ENVIRONMENT == "DEV": + def get_env_vars(): + return { + "sqlalchemy_url": "sqlite+aiosqlite:///./egos.db", + "async_mode": True, + } +else: + def get_env_vars(): + return { + "sqlalchemy_url": ( + f"postgresql+asyncpg://" + f"{POSTGRES_USER}:{POSTGRES_PASSWORD}" + f"@{POSTGRES_HOST}:{POSTGRES_PORT}" + f"/{POSTGRES_DB}" + ), + "async_mode": True, + } +``` + +### Example 2: Simple SQLite Configuration + +```python +# jetbase/env.py +sqlalchemy_url = "sqlite:///./mydb.db" +``` + +### Example 3: Simple PostgreSQL Configuration + +```python +# jetbase/env.py +sqlalchemy_url = "postgresql+psycopg2://myuser:mypassword@localhost:5432/myapp" +postgres_schema = "public" +``` + +### Example 4: Multi-database with get_env_vars() + +```python +# jetbase/env.py +from dotenv import load_dotenv +import os + +load_dotenv() + +ENVIRONMENT = os.getenv("ENVIRONMENT", "DEV") + +if ENVIRONMENT == "LOCAL": + def get_env_vars(): + return { + "sqlalchemy_url": "sqlite+aiosqlite:///./local.db", + "async_mode": True, + } + +elif ENVIRONMENT == "DEV": + def get_env_vars(): + return { + "sqlalchemy_url": "sqlite+aiosqlite:///./dev.db", + "async_mode": True, + } + +elif ENVIRONMENT == "STAGING": + def get_env_vars(): + return { + "sqlalchemy_url": "postgresql+psycopg2://staging_user:staging_pass@staging-db.example.com:5432/staging_db", + "async_mode": False, + "postgres_schema": "public", + } + +else: # Production + def get_env_vars(): + return { + "sqlalchemy_url": "postgresql+asyncpg://prod_user:prod_pass@prod-db.example.com:5432/prod_db", + "async_mode": True, + "postgres_schema": "public", + } +``` + +### Example 5: With Model Paths for Auto-migration + +```python +# jetbase/env.py +from dotenv import load_dotenv +import os + +load_dotenv() + +ENVIRONMENT = os.getenv("ENVIRONMENT", "DEV") + +if ENVIRONMENT == "DEV": + def get_env_vars(): + return { + "sqlalchemy_url": "sqlite+aiosqlite:///./dev.db", + "async_mode": True, + "model_paths": ["../app/models.py"], + } +else: + def get_env_vars(): + return { + "sqlalchemy_url": "postgresql+asyncpg://user:pass@db:5432/app", + "async_mode": True, + "model_paths": ["../app/models.py"], + } +``` + +### Example 6: SQLite with Checksum Validation Disabled + +```python +# jetbase/env.py +sqlalchemy_url = "sqlite:///./mydb.db" +skip_checksum_validation = True +skip_file_validation = False +``` + +--- + +## Configuration Reference + +| Variable | Type | Description | Default | +|----------|------|-------------|---------| +| `sqlalchemy_url` | str | Database connection URL | Required | +| `async_mode` | bool | Enable async database connections | `False` | +| `postgres_schema` | str | PostgreSQL schema search path | `None` | +| `model_paths` | list[str] | Paths to SQLAlchemy model files | `None` | +| `skip_checksum_validation` | bool | Skip migration file checksum validation | `False` | +| `skip_file_validation` | bool | Skip migration file existence validation | `False` | +| `skip_validation` | bool | Skip all validations | `False` | + ## Async and Sync Modes ⚡ -Jetbase supports both synchronous and asynchronous database connections. The mode is controlled **exclusively** by the `ASYNC` environment variable. +Jetbase supports both synchronous and asynchronous database connections. + +### Configuration Options -### Configuration +**Option 1: Using async_mode in env.py:** -Set the `ASYNC` environment variable before running Jetbase commands: +```python +# jetbase/env.py +sqlalchemy_url = "sqlite+aiosqlite:///mydb.db" +async_mode = True +``` + +**Option 2: Using ASYNC environment variable:** ```bash export ASYNC=true # for async mode @@ -16,11 +215,11 @@ export ASYNC=false # for sync mode (default) jetbase status ``` -You can also set it temporarily per command: +Or per command: ```bash ASYNC=true jetbase status # async mode -ASYNC=false jetbase upgrade # sync mode +ASYNC=false jetbase migrate # sync mode ``` ### Sync Mode (Default) @@ -36,12 +235,6 @@ sqlalchemy_url = "sqlite:///mydb.db" sqlalchemy_url = "postgresql+asyncpg://user:password@localhost:5432/mydb" ``` -Sync mode is the default. Just run: - -```bash -jetbase upgrade -``` - ### Async Mode Use async drivers: @@ -53,12 +246,7 @@ sqlalchemy_url = "postgresql+asyncpg://user:password@localhost:5432/mydb" sqlalchemy_url = "sqlite+aiosqlite:///mydb.db" ``` -Set `ASYNC=true`: - -```bash -export ASYNC=true -jetbase upgrade -``` +Enable async mode with `async_mode = True` in your config. ### URL Format Reference @@ -118,7 +306,7 @@ sqlalchemy_url = "postgresql+asyncpg://myuser:mypassword@localhost:5432/myapp" Run with async mode: ```bash -ASYNC=true jetbase upgrade +ASYNC=true jetbase migrate ``` --- @@ -227,7 +415,7 @@ sqlalchemy_url = "sqlite+aiosqlite:///myapp.db" Run with async mode: ```bash -ASYNC=true jetbase upgrade +ASYNC=true jetbase migrate ``` --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 93bd402..5c0682b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -63,7 +63,9 @@ All Jetbase commands must be run from inside the `jetbase/` directory. ### Step 3: Configure Your Database Connection -Open `env.py` and update the `sqlalchemy_url` with your database connection string: +Open `env.py` and configure your database connection. Jetbase supports two patterns: + +#### Pattern 1: Simple Module-level Variables === "PostgreSQL" ```python @@ -75,6 +77,12 @@ Open `env.py` and update the `sqlalchemy_url` with your database connection stri sqlalchemy_url = "sqlite:///mydb.db" ``` +=== "SQLite Async" + ```python + sqlalchemy_url = "sqlite+aiosqlite:///mydb.db" + async_mode = True + ``` + === "MySQL" ```python sqlalchemy_url = "mysql+pymysql://user:password@localhost:3306/mydb" @@ -94,6 +102,37 @@ Open `env.py` and update the `sqlalchemy_url` with your database connection stri ) ``` +#### Pattern 2: get_env_vars() Function (Recommended) + +For complex configurations (environment-based settings, etc.), use a function: + +```python +from dotenv import load_dotenv +import os + +load_dotenv() + +ENVIRONMENT = os.getenv("ENVIRONMENT") + +if ENVIRONMENT == "DEV": + def get_env_vars(): + return { + "sqlalchemy_url": "sqlite+aiosqlite:///./egos.db", + "async_mode": True, + } +else: + def get_env_vars(): + return { + "sqlalchemy_url": "postgresql+asyncpg://user:password@localhost:5432/mydb", + "async_mode": True, + } +``` + +!!! tip "Why use get_env_vars()?" + - Keep all configuration logic in one place + - Access to full Python logic (if/else, imports, etc.) + - Easy to see all Jetbase configuration at a glance + ## Creating Your First Migration @@ -198,10 +237,10 @@ Jetbase will: ## Apply the Migration -Run the upgrade command: +Run the migrate command: ```bash -jetbase upgrade +jetbase migrate ``` Output: @@ -234,16 +273,28 @@ You'll see a table showing: ## Async and Sync Support -Jetbase supports both synchronous and asynchronous database connections: +Jetbase supports both synchronous and asynchronous database connections. + +### Using async_mode in env.py + +```python +sqlalchemy_url = "sqlite+aiosqlite:///mydb.db" +async_mode = True +``` + +### Using ASYNC Environment Variable ```bash # Sync mode (default) -jetbase upgrade +jetbase migrate # Async mode -ASYNC=true jetbase upgrade +ASYNC=true jetbase migrate ``` +!!! note + For SQLite async support, use `sqlite+aiosqlite://` URL scheme and set `async_mode = True` + ## What's Next? Now that you've set up your first migration, explore these topics: @@ -262,7 +313,8 @@ Now that you've set up your first migration, explore these topics: | [`init`](commands/init.md) | Initialize Jetbase in current directory | | [`new`](commands/new.md) | Create a new manual migration file | | [`make-migrations`](commands/make-migrations.md) | Auto-generate SQL from SQLAlchemy models | -| [`upgrade`](commands/upgrade.md) | Apply pending migrations | +| [`migrate`](commands/upgrade.md) | Apply pending migrations | +| [`upgrade`](commands/upgrade.md) | Apply pending migrations (alias) | | [`rollback`](commands/rollback.md) | Undo migrations | | [`status`](commands/status.md) | Show migration status | | [`history`](commands/history.md) | Show migration history | diff --git a/docs/index.md b/docs/index.md index 04e7a4e..1ca9866 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,12 +7,12 @@ Jetbase helps you manage database migrations in a simple, version-controlled way ### Key Features ✨ - **📦 Simple Setup** — Get started with just one command -- **⬆️ Easy Upgrades** — Apply pending migrations with confidence +- **⬆️ Easy Migrations** — Apply pending migrations with confidence - **⬇️ Safe Rollbacks** — Made a mistake? No problem, roll it back! - **📊 Clear Status** — Always know which migrations have been applied and which are pending - **🔒 Migration Locking** — Prevents conflicts when multiple processes try to migrate - **✅ Checksum Validation** — Detects if migration files have been modified -- **🔄 Repeatable Migrations** — Support for migrations that run on every upgrade +- **🔄 Repeatable Migrations** — Support for migrations that run on every migrate - **🤖 Auto-Generation** — Automatically generate SQL migrations from SQLAlchemy models - **🔀 Async/Sync Support** — Works with both sync and async SQLAlchemy drivers - **🎯 Auto-Discovery** — Automatically finds models in `models/` or `model/` directories @@ -107,11 +107,17 @@ DROP TABLE users; ### Apply the Migration ```bash -jetbase upgrade +jetbase migrate ``` That's it! Your database is now up to date. 🎉 +!!! tip "Running Jetbase" + If you encounter errors, run Jetbase using your project's Python environment: + ```bash + uv run jetbase migrate + ``` + !!! note Jetbase uses SQLAlchemy under the hood to manage database connections. For any database other than SQLite, you must install the appropriate Python database driver. @@ -161,10 +167,10 @@ Jetbase supports both synchronous and asynchronous database connections: ```bash # Sync mode (default) -jetbase upgrade +jetbase migrate # Async mode -ASYNC=true jetbase upgrade +ASYNC=true jetbase migrate ``` [Learn more about async/sync support](database-connections.md#async-and-sync-modes) diff --git a/docs/migrations/index.md b/docs/migrations/index.md index 8df9f2d..7b3ccd7 100644 --- a/docs/migrations/index.md +++ b/docs/migrations/index.md @@ -56,10 +56,10 @@ This simple check helps keep your migration history clean, safe, and easy to fol If you ever need to bypass this check *(not recommended)*, you have two options: -- **Command-line:** - Add `--skip-file-validation` when running `jetbase upgrade`: +- **Command-line:** + Add `--skip-file-validation` when running `jetbase migrate`: ```bash - jetbase upgrade --skip-file-validation + jetbase migrate --skip-file-validation ``` @@ -137,7 +137,7 @@ CREATE TABLE items ( ### Apply It ```bash -jetbase upgrade +jetbase migrate ``` ## Migration Lifecycle @@ -145,7 +145,7 @@ jetbase upgrade ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Create │ ──▶ │ Write │ ──▶ │ Apply │ -│ jetbase new │ │ SQL │ │ upgrade │ +│ jetbase new │ │ SQL │ │ migrate │ └─────────────┘ └─────────────┘ └─────────────┘ │ ▼ diff --git a/docs/validations/index.md b/docs/validations/index.md index e0a5c4b..7b4a0b1 100644 --- a/docs/validations/index.md +++ b/docs/validations/index.md @@ -1,6 +1,6 @@ # Validations -Jetbase performs several validations before applying migrations to ensure database integrity and prevent common mistakes. These checks run automatically during `jetbase upgrade` and help catch issues before they cause problems in your database. +Jetbase performs several validations before applying migrations to ensure database integrity and prevent common mistakes. These checks run automatically during `jetbase migrate` and help catch issues before they cause problems in your database. Jetbase's validations act as guardrails to: @@ -125,13 +125,13 @@ Files have been changed since migration. ```bash # Skip all validations -jetbase upgrade --skip-validation +jetbase migrate --skip-validation # Skip only file validation -jetbase upgrade --skip-file-validation +jetbase migrate --skip-file-validation # Skip only checksum validation -jetbase upgrade --skip-checksum-validation +jetbase migrate --skip-checksum-validation ``` ### Via Configuration From bc8b1c6bc385210e2e6c6a4df9706a46823d579a Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 12:09:29 -0300 Subject: [PATCH 34/40] Forgot to remove upgrade from --help and docs --- README.md | 1 - docs/commands/index.md | 2 -- docs/commands/upgrade.md | 2 -- docs/getting-started.md | 1 - jetbase/cli/main.py | 41 ---------------------------------------- 5 files changed, 47 deletions(-) diff --git a/README.md b/README.md index 0534455..2dc9bb2 100644 --- a/README.md +++ b/README.md @@ -504,7 +504,6 @@ jetbase migrate | `jetbase new "description"` | Create a new manual migration file | | `jetbase make-migrations` | Auto-generate SQL from SQLAlchemy models | | `jetbase migrate` | Apply pending migrations | -| `jetbase upgrade` | Apply pending migrations (alias for migrate) | | `jetbase rollback` | Rollback migrations | | `jetbase status` | Show migration status | | `jetbase history` | Show migration history | diff --git a/docs/commands/index.md b/docs/commands/index.md index 08325ea..053af2d 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -10,7 +10,6 @@ Jetbase provides a set of intuitive commands to manage your database migrations. | [`new`](new.md) | Create a new manual migration file | | [`make-migrations`](make-migrations.md) | Auto-generate SQL from SQLAlchemy models | | [`migrate`](upgrade.md) | Apply pending migrations | -| [`upgrade`](upgrade.md) | Apply pending migrations (alias for migrate) | | [`rollback`](rollback.md) | Undo migrations | | [`status`](status.md) | Show migration status of all migration files (applied vs. pending) | | [`history`](history.md) | Show migration history | @@ -38,7 +37,6 @@ Commands to create and run migrations: - **[`new`](new.md)** — Generate a new manual migration file - **[`make-migrations`](make-migrations.md)** — Auto-generate SQL from SQLAlchemy models - **[`migrate`](upgrade.md)** — Apply pending migrations to the database -- **[`upgrade`](upgrade.md)** — Apply pending migrations (alias for migrate) - **[`rollback`](rollback.md)** — Undo one or more migrations ### 📊 Status Commands diff --git a/docs/commands/upgrade.md b/docs/commands/upgrade.md index 23f8f1d..24632d8 100644 --- a/docs/commands/upgrade.md +++ b/docs/commands/upgrade.md @@ -12,8 +12,6 @@ jetbase migrate The `migrate` command applies all pending migrations to your database in order. It's the most commonly used command for keeping your database schema up to date. -> **Note:** `jetbase upgrade` is also available as an alias for `migrate`. - !!! tip "Running Jetbase" If you encounter errors, run Jetbase using your project's Python environment: ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 5c0682b..a19721c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -314,7 +314,6 @@ Now that you've set up your first migration, explore these topics: | [`new`](commands/new.md) | Create a new manual migration file | | [`make-migrations`](commands/make-migrations.md) | Auto-generate SQL from SQLAlchemy models | | [`migrate`](commands/upgrade.md) | Apply pending migrations | -| [`upgrade`](commands/upgrade.md) | Apply pending migrations (alias) | | [`rollback`](commands/rollback.md) | Undo migrations | | [`status`](commands/status.md) | Show migration status | | [`history`](commands/history.md) | Show migration history | diff --git a/jetbase/cli/main.py b/jetbase/cli/main.py index 06a39be..12dd1f3 100644 --- a/jetbase/cli/main.py +++ b/jetbase/cli/main.py @@ -23,47 +23,6 @@ def init(): initialize_cmd() -@app.command() -def upgrade( - count: int = typer.Option( - None, "--count", "-c", help="Number of migrations to apply" - ), - to_version: str | None = typer.Option( - None, "--to-version", "-t", help="Migrate to a specific version" - ), - dry_run: bool = typer.Option( - False, "--dry-run", "-d", help="Simulate the migration without making changes" - ), - skip_validation: bool = typer.Option( - False, - "--skip-validation", - help="Skip both checksum and file version validation when running migrations", - ), - skip_checksum_validation: bool = typer.Option( - False, - "--skip-checksum-validation", - help="Skip checksum validation when running migrations", - ), - skip_file_validation: bool = typer.Option( - False, - "--skip-file-validation", - help="Skip file version validation when running migrations", - ), -) -> None: - """Apply pending migrations to the database. - - This is an alias for the 'migrate' command. - """ - migrate( - count=count, - to_version=to_version, - dry_run=dry_run, - skip_validation=skip_validation, - skip_checksum_validation=skip_checksum_validation, - skip_file_validation=skip_file_validation, - ) - - @app.command() def rollback( count: int = typer.Option( From 7755bc7f25771b130c6d5c6462b6204dc4a76952 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 12:30:38 -0300 Subject: [PATCH 35/40] Fixed migrations folder creating elsewhere Idk how tf i broke that lol --- jetbase/commands/make_migrations.py | 7 ++++++- jetbase/engine/schema_diff.py | 2 +- jetbase/engine/sql_generator.py | 16 +++++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/jetbase/commands/make_migrations.py b/jetbase/commands/make_migrations.py index 0014123..2197fc9 100644 --- a/jetbase/commands/make_migrations.py +++ b/jetbase/commands/make_migrations.py @@ -15,6 +15,7 @@ get_connection, is_async_enabled, ) +from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.config import get_config from jetbase.engine.model_discovery import ( ModelDiscoveryError, @@ -258,7 +259,11 @@ def _write_migration_file( migration_description = description or "auto_generated" filename = _generate_new_filename(description=migration_description) - migrations_dir = os.path.join(os.getcwd(), MIGRATIONS_DIR) + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + migrations_dir = os.path.join(jetbase_dir, MIGRATIONS_DIR) + else: + migrations_dir = os.path.join(os.getcwd(), MIGRATIONS_DIR) os.makedirs(migrations_dir, exist_ok=True) filepath = os.path.join(migrations_dir, filename) diff --git a/jetbase/engine/schema_diff.py b/jetbase/engine/schema_diff.py index 1f843d9..036958c 100644 --- a/jetbase/engine/schema_diff.py +++ b/jetbase/engine/schema_diff.py @@ -91,7 +91,7 @@ def get_model_table_info(model_class: type) -> TableInfo: table_info.indexes.append( { "name": index.name, - "column_names": list(index.columns), + "column_names": [c.name for c in index.columns], "unique": index.unique, } ) diff --git a/jetbase/engine/sql_generator.py b/jetbase/engine/sql_generator.py index 6289724..fa45cbf 100644 --- a/jetbase/engine/sql_generator.py +++ b/jetbase/engine/sql_generator.py @@ -347,15 +347,21 @@ def generate_add_foreign_key_sql( Returns: str: ALTER TABLE ADD FOREIGN KEY SQL statement. """ - fk_name = fk_info["name"] + fk_name = fk_info.get("name") columns = ", ".join(fk_info["constrained_columns"]) ref_table = fk_info["referred_table"] ref_columns = ", ".join(fk_info["referred_columns"]) - return ( - f"ALTER TABLE {table_name} ADD CONSTRAINT {fk_name} " - f"FOREIGN KEY ({columns}) REFERENCES {ref_table} ({ref_columns});" - ) + if fk_name: + return ( + f"ALTER TABLE {table_name} ADD CONSTRAINT {fk_name} " + f"FOREIGN KEY ({columns}) REFERENCES {ref_table} ({ref_columns});" + ) + else: + return ( + f"ALTER TABLE {table_name} ADD FOREIGN KEY ({columns}) " + f"REFERENCES {ref_table} ({ref_columns});" + ) def generate_drop_foreign_key_sql( From f5609836863aeb2bc039ec21bbce414af5f024c2 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 12:37:31 -0300 Subject: [PATCH 36/40] SQLite FK limitation fix --- jetbase/commands/make_migrations.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/jetbase/commands/make_migrations.py b/jetbase/commands/make_migrations.py index 2197fc9..a72bbd2 100644 --- a/jetbase/commands/make_migrations.py +++ b/jetbase/commands/make_migrations.py @@ -15,6 +15,7 @@ get_connection, is_async_enabled, ) +from jetbase.enums import DatabaseType from jetbase.engine.jetbase_locator import find_jetbase_directory from jetbase.config import get_config from jetbase.engine.model_discovery import ( @@ -63,6 +64,7 @@ def _generate_create_table_from_model( table = model_class.__table__ columns = [] + foreign_keys = [] for col in table.columns: col_def = f"{col.name} {col.type.compile(dialect=connection.engine.dialect)}" if not col.nullable: @@ -70,9 +72,13 @@ def _generate_create_table_from_model( if col.primary_key: col_def += " PRIMARY KEY" columns.append(col_def) + if col.foreign_keys: + for fk in col.foreign_keys: + fk_def = f"FOREIGN KEY ({col.name}) REFERENCES {fk.column.table.name} ({fk.column.name})" + foreign_keys.append(fk_def) pk_cols = ", ".join([c.name for c in table.primary_key.columns]) - cols_sql = ",\n ".join(columns) + cols_sql = ",\n ".join(columns + foreign_keys) return f"CREATE TABLE {table.name} (\n {cols_sql}\n);" @@ -159,6 +165,8 @@ def _make_migrations_sync(models: dict, description: str | None) -> None: ) for table_name in diff.foreign_keys_to_create: + if db_type == DatabaseType.SQLITE: + continue for fk_info in diff.foreign_keys_to_create[table_name]: upgrade_statements.append( generate_add_foreign_key_sql(table_name, fk_info, db_type) @@ -234,6 +242,8 @@ async def _make_migrations_async(models: dict, description: str | None) -> None: ) for table_name in diff.foreign_keys_to_create: + if db_type == DatabaseType.SQLITE: + continue for fk_info in diff.foreign_keys_to_create[table_name]: upgrade_statements.append( generate_add_foreign_key_sql(table_name, fk_info, db_type) From b61e8bf71ce3abdd3eb4dce7fc1e0349dc870c60 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Wed, 4 Feb 2026 15:03:30 -0300 Subject: [PATCH 37/40] Updated Dependencies --- pyproject.toml | 9 + tests/unit/database/test_async_sync_db.py | 1116 +++++++++++++++++++++ uv.lock | 145 ++- 3 files changed, 1262 insertions(+), 8 deletions(-) create mode 100644 tests/unit/database/test_async_sync_db.py diff --git a/pyproject.toml b/pyproject.toml index 161c8c5..82ad010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ authors = [ ] requires-python = ">=3.10" dependencies = [ + "aiosqlite>=0.22.1", + "asyncpg>=0.29.0,<0.32.0", "packaging>=25.0", "rich>=12.2.0", "sqlalchemy>=2.0.10", @@ -45,6 +47,13 @@ snowflake = [ databricks = [ "databricks-sqlalchemy>=2.0.1", ] +sqlite-async = [ + "aiosqlite>=0.22.1", +] +postgres-async = [ + "asyncpg>=0.29.0,<0.32.0", + "sqlalchemy[postgresql]>=2.0.10", +] [tool.pytest.ini_options] diff --git a/tests/unit/database/test_async_sync_db.py b/tests/unit/database/test_async_sync_db.py new file mode 100644 index 0000000..c80dd7b --- /dev/null +++ b/tests/unit/database/test_async_sync_db.py @@ -0,0 +1,1116 @@ +"""Comprehensive tests for async and sync database operations.""" + +import asyncio +import os +import tempfile +from pathlib import Path +from typing import AsyncGenerator +from unittest.mock import MagicMock, patch, AsyncMock + +import pytest +from sqlalchemy import ( + Boolean, + Column, + create_engine, + Integer, + String, + text, + DateTime, + JSON, +) +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import declarative_base, sessionmaker + +from jetbase.database.connection import ( + _get_engine, + get_async_db_connection, + get_connection, + get_db_connection, + is_async_enabled, +) + + +Base = declarative_base() + + +class AuthorModel(Base): + __tablename__ = "authors" + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + email = Column(String(255), unique=True) + + +class BookModel(Base): + __tablename__ = "books" + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + author_id = Column(Integer, nullable=False) + published_date = Column(DateTime) + is_available = Column(Boolean, default=True) + book_metadata = Column(JSON) + + +class PublisherModel(Base): + __tablename__ = "publishers" + id = Column(Integer, primary_key=True) + name = Column(String(200), nullable=False) + location = Column(String(100)) + + +class ReviewModel(Base): + __tablename__ = "reviews" + id = Column(Integer, primary_key=True) + book_id = Column(Integer, nullable=False) + rating = Column(Integer, nullable=False) + comment = Column(String(1000)) + + +@pytest.fixture +def sync_engine(tmp_path): + """Create a sync SQLAlchemy engine for testing.""" + db_file = tmp_path / "test_sync.db" + engine = create_engine(f"sqlite:///{db_file}") + Base.metadata.create_all(engine) + yield engine + engine.dispose() + + +@pytest.fixture +def async_engine(tmp_path): + """Create an async SQLAlchemy engine for testing.""" + db_file = tmp_path / "test_async.db" + engine = create_async_engine(f"sqlite+aiosqlite:///{db_file}") + yield engine + engine.dispose() + + +@pytest.fixture +def sync_session_factory(sync_engine): + """Create a sync session factory.""" + return sessionmaker(bind=sync_engine) + + +@pytest.fixture +def async_session_factory(async_engine): + """Create an async session factory.""" + return async_sessionmaker( + bind=async_engine, class_=AsyncSession, expire_on_commit=False + ) + + +@pytest.fixture(autouse=True) +def cleanup_env(): + """Clean up environment variables after each test.""" + yield + _get_engine.cache_clear() + if "ASYNC" in os.environ: + del os.environ["ASYNC"] + if "JETBASE_SQLALCHEMY_URL" in os.environ: + del os.environ["JETBASE_SQLALCHEMY_URL"] + + +class TestSyncDBSimpleOperations: + """Tests for basic sync database operations.""" + + def test_create_table(self, sync_engine): + """Test creating a table in sync mode.""" + with sync_engine.connect() as conn: + conn.execute( + text("CREATE TABLE test_sync (id INTEGER PRIMARY KEY, value TEXT)") + ) + conn.commit() + + conn.execute(text("INSERT INTO test_sync (value) VALUES ('hello')")) + conn.commit() + + result = conn.execute(text("SELECT value FROM test_sync")) + assert result.fetchone() == ("hello",) + + def test_insert_multiple_rows(self, sync_engine): + """Test inserting multiple rows in sync mode.""" + with sync_engine.connect() as conn: + conn.execute( + text( + "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, quantity INTEGER)" + ) + ) + conn.commit() + + items = [("item1", 10), ("item2", 20), ("item3", 30)] + for name, qty in items: + conn.execute( + text("INSERT INTO items (name, quantity) VALUES (:name, :qty)"), + {"name": name, "qty": qty}, + ) + conn.commit() + + result = conn.execute(text("SELECT COUNT(*) FROM items")) + assert result.fetchone() == (3,) + + def test_update_rows(self, sync_engine): + """Test updating rows in sync mode.""" + with sync_engine.connect() as conn: + conn.execute( + text( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, active INTEGER)" + ) + ) + conn.commit() + + conn.execute(text("INSERT INTO users (name, active) VALUES ('Alice', 1)")) + conn.execute(text("INSERT INTO users (name, active) VALUES ('Bob', 1)")) + conn.commit() + + conn.execute(text("UPDATE users SET active = 0 WHERE name = 'Bob'")) + conn.commit() + + result = conn.execute(text("SELECT name FROM users WHERE active = 1")) + names = [row[0] for row in result.fetchall()] + assert names == ["Alice"] + + def test_delete_rows(self, sync_engine): + """Test deleting rows in sync mode.""" + with sync_engine.connect() as conn: + conn.execute( + text("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT)") + ) + conn.commit() + + conn.execute(text("INSERT INTO products (name) VALUES ('Product A')")) + conn.execute(text("INSERT INTO products (name) VALUES ('Product B')")) + conn.commit() + + conn.execute(text("DELETE FROM products WHERE name = 'Product A'")) + conn.commit() + + result = conn.execute(text("SELECT COUNT(*) FROM products")) + assert result.fetchone() == (1,) + + def test_transaction_rollback(self, sync_engine): + """Test transaction rollback in sync mode.""" + with sync_engine.connect() as conn: + conn.execute( + text("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)") + ) + conn.commit() + + conn.execute(text("INSERT INTO accounts (balance) VALUES (1000)")) + conn.commit() + + try: + with conn.begin(): + conn.execute(text("UPDATE accounts SET balance = balance - 100")) + raise Exception("Simulated error") + except Exception: + pass + + result = conn.execute(text("SELECT balance FROM accounts")) + assert result.fetchone() == (1000,) + + def test_aggregate_functions(self, sync_engine): + """Test aggregate functions in sync mode.""" + with sync_engine.connect() as conn: + conn.execute( + text("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER)") + ) + conn.commit() + + conn.execute(text("INSERT INTO orders (amount) VALUES (100)")) + conn.execute(text("INSERT INTO orders (amount) VALUES (200)")) + conn.execute(text("INSERT INTO orders (amount) VALUES (300)")) + conn.commit() + + result = conn.execute( + text( + "SELECT SUM(amount), AVG(amount), MIN(amount), MAX(amount) FROM orders" + ) + ) + row = result.fetchone() + assert row == (600, 200, 100, 300) + + +class TestAsyncDBSimpleOperations: + """Tests for basic async database operations.""" + + @pytest.mark.asyncio + async def test_create_table(self, async_engine): + """Test creating a table in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text("CREATE TABLE test_async (id INTEGER PRIMARY KEY, value TEXT)") + ) + await conn.commit() + + await conn.execute( + text("INSERT INTO test_async (value) VALUES ('async_hello')") + ) + await conn.commit() + + result = await conn.execute(text("SELECT value FROM test_async")) + assert result.fetchone() == ("async_hello",) + + @pytest.mark.asyncio + async def test_insert_multiple_rows(self, async_engine): + """Test inserting multiple rows in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text("CREATE TABLE async_items (id INTEGER PRIMARY KEY, name TEXT)") + ) + await conn.commit() + + await conn.execute( + text("INSERT INTO async_items (name) VALUES ('async_item1')") + ) + await conn.execute( + text("INSERT INTO async_items (name) VALUES ('async_item2')") + ) + await conn.commit() + + result = await conn.execute(text("SELECT COUNT(*) FROM async_items")) + assert result.fetchone() == (2,) + + @pytest.mark.asyncio + async def test_update_rows(self, async_engine): + """Test updating rows in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text("CREATE TABLE async_users (id INTEGER PRIMARY KEY, name TEXT)") + ) + await conn.commit() + + await conn.execute(text("INSERT INTO async_users (name) VALUES ('User1')")) + await conn.execute(text("INSERT INTO async_users (name) VALUES ('User2')")) + await conn.commit() + + await conn.execute( + text("UPDATE async_users SET name = 'Updated' WHERE name = 'User1'") + ) + await conn.commit() + + result = await conn.execute( + text("SELECT name FROM async_users WHERE name = 'Updated'") + ) + assert result.fetchone() == ("Updated",) + + @pytest.mark.asyncio + async def test_delete_rows(self, async_engine): + """Test deleting rows in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text("CREATE TABLE async_products (id INTEGER PRIMARY KEY, name TEXT)") + ) + await conn.commit() + + await conn.execute(text("INSERT INTO async_products (name) VALUES ('P1')")) + await conn.execute(text("INSERT INTO async_products (name) VALUES ('P2')")) + await conn.commit() + + await conn.execute(text("DELETE FROM async_products WHERE name = 'P1'")) + await conn.commit() + + result = await conn.execute(text("SELECT COUNT(*) FROM async_products")) + assert result.fetchone() == (1,) + + @pytest.mark.asyncio + async def test_transaction_commit(self, async_engine): + """Test transaction commit in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text("CREATE TABLE transactions (id INTEGER PRIMARY KEY, status TEXT)") + ) + await conn.commit() + + async with conn.begin(): + await conn.execute( + text("INSERT INTO transactions (status) VALUES ('processing')") + ) + + result = await conn.execute(text("SELECT status FROM transactions")) + assert result.fetchone() == ("processing",) + + @pytest.mark.asyncio + async def test_aggregate_functions(self, async_engine): + """Test aggregate functions in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text( + "CREATE TABLE async_orders (id INTEGER PRIMARY KEY, total INTEGER)" + ) + ) + await conn.commit() + + await conn.execute(text("INSERT INTO async_orders (total) VALUES (50)")) + await conn.execute(text("INSERT INTO async_orders (total) VALUES (150)")) + await conn.execute(text("INSERT INTO async_orders (total) VALUES (100)")) + await conn.commit() + + result = await conn.execute( + text("SELECT SUM(total), AVG(total) FROM async_orders") + ) + row = result.fetchone() + assert row == (300, 100) + + +class TestSyncDBWithModels: + """Tests for sync database operations using SQLAlchemy models.""" + + def test_insert_and_query_author(self, sync_engine): + """Test inserting and querying an Author model.""" + Session = sessionmaker(bind=sync_engine) + with Session() as session: + author = AuthorModel(name="J.K. Rowling", email="jk@example.com") + session.add(author) + session.commit() + + session.refresh(author) + + assert author.id is not None + assert author.name == "J.K. Rowling" + + result = session.execute( + text("SELECT name, email FROM authors WHERE id = :id"), + {"id": author.id}, + ) + row = result.fetchone() + assert row == ("J.K. Rowling", "jk@example.com") + + def test_insert_multiple_authors(self, sync_engine): + """Test inserting multiple authors.""" + Session = sessionmaker(bind=sync_engine) + authors_data = [ + AuthorModel(name="Author 1", email="author1@example.com"), + AuthorModel(name="Author 2", email="author2@example.com"), + AuthorModel(name="Author 3", email="author3@example.com"), + ] + + with Session() as session: + for author in authors_data: + session.add(author) + session.commit() + + result = session.execute(text("SELECT COUNT(*) FROM authors")) + assert result.fetchone() == (3,) + + def test_query_with_filters(self, sync_engine): + """Test querying with filters.""" + Session = sessionmaker(bind=sync_engine) + with Session() as session: + session.add(AuthorModel(name="Author A", email="a@example.com")) + session.add(AuthorModel(name="Author B", email="b@example.com")) + session.add(AuthorModel(name="Author C", email="c@example.com")) + session.commit() + + result = session.execute( + text( + "SELECT name FROM authors WHERE name LIKE 'Author %' ORDER BY name" + ) + ) + names = [row[0] for row in result.fetchall()] + assert names == ["Author A", "Author B", "Author C"] + + def test_update_model(self, sync_engine): + """Test updating a model.""" + Session = sessionmaker(bind=sync_engine) + with Session() as session: + author = AuthorModel(name="Original Name", email="original@example.com") + session.add(author) + session.commit() + author_id = author.id + + author.name = "Updated Name" + session.commit() + + result = session.execute( + text("SELECT name FROM authors WHERE id = :id"), {"id": author_id} + ) + assert result.fetchone()[0] == "Updated Name" + + def test_delete_model(self, sync_engine): + """Test deleting a model.""" + Session = sessionmaker(bind=sync_engine) + with Session() as session: + author = AuthorModel(name="To Delete", email="delete@example.com") + session.add(author) + session.commit() + author_id = author.id + + session.delete(author) + session.commit() + + result = session.execute( + text("SELECT COUNT(*) FROM authors WHERE id = :id"), {"id": author_id} + ) + assert result.fetchone() == (0,) + + +class TestAsyncDBWithModels: + """Tests for async database operations using SQLAlchemy models.""" + + @pytest.mark.asyncio + async def test_insert_and_query_author(self, async_engine): + """Test inserting and querying an Author model asynchronously.""" + async with async_engine.connect() as conn: + author = AuthorModel(name="George Orwell", email="orwell@example.com") + conn.add(author) + await conn.commit() + await conn.refresh(author) + + assert author.id is not None + assert author.name == "George Orwell" + + result = await conn.execute( + text("SELECT name, email FROM authors WHERE id = :id"), + {"id": author.id}, + ) + row = result.fetchone() + assert row == ("George Orwell", "orwell@example.com") + + @pytest.mark.asyncio + async def test_insert_multiple_authors(self, async_engine): + """Test inserting multiple authors asynchronously.""" + async with async_engine.connect() as conn: + authors_data = [ + AuthorModel(name="Async Author 1", email="async1@example.com"), + AuthorModel(name="Async Author 2", email="async2@example.com"), + ] + + for author in authors_data: + conn.add(author) + await conn.commit() + + result = await conn.execute(text("SELECT COUNT(*) FROM authors")) + assert result.fetchone() == (2,) + + @pytest.mark.asyncio + async def test_query_with_filters(self, async_engine): + """Test querying with filters asynchronously.""" + async with async_engine.connect() as conn: + conn.add(AuthorModel(name="Filter Test A", email="filter_a@example.com")) + conn.add(AuthorModel(name="Filter Test B", email="filter_b@example.com")) + conn.add(AuthorModel(name="Other", email="other@example.com")) + await conn.commit() + + result = await conn.execute( + text("SELECT name FROM authors WHERE name LIKE 'Filter Test %'") + ) + names = [row[0] for row in result.fetchall()] + assert names == ["Filter Test A", "Filter Test B"] + + @pytest.mark.asyncio + async def test_update_model(self, async_engine): + """Test updating a model asynchronously.""" + async with async_engine.connect() as conn: + author = AuthorModel(name="Update Me", email="update@example.com") + conn.add(author) + await conn.commit() + await conn.refresh(author) + + author.name = "Updated Async" + await conn.commit() + await conn.refresh(author) + + assert author.name == "Updated Async" + + @pytest.mark.asyncio + async def test_delete_model(self, async_engine): + """Test deleting a model asynchronously.""" + async with async_engine.connect() as conn: + author = AuthorModel( + name="Delete Me Async", email="delete_async@example.com" + ) + conn.add(author) + await conn.commit() + author_id = author.id + + await conn.delete(author) + await conn.commit() + + result = await conn.execute( + text("SELECT COUNT(*) FROM authors WHERE id = :id"), {"id": author_id} + ) + assert result.fetchone() == (0,) + + +class TestSyncDBComplexOperations: + """Tests for complex sync database operations.""" + + def test_join_query(self, sync_engine): + """Test join queries in sync mode.""" + with sync_engine.connect() as conn: + conn.execute( + text(""" + CREATE TABLE sync_authors ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ) + """) + ) + conn.execute( + text(""" + CREATE TABLE sync_books ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + author_id INTEGER NOT NULL + ) + """) + ) + conn.commit() + + conn.execute(text("INSERT INTO sync_authors (name) VALUES ('Author 1')")) + conn.execute( + text("INSERT INTO sync_books (title, author_id) VALUES ('Book 1', 1)") + ) + conn.execute( + text("INSERT INTO sync_books (title, author_id) VALUES ('Book 2', 1)") + ) + conn.commit() + + result = conn.execute( + text(""" + SELECT a.name, b.title + FROM sync_authors a + JOIN sync_books b ON a.id = b.author_id + ORDER BY b.title + """) + ) + rows = result.fetchall() + assert len(rows) == 2 + assert rows[0] == ("Author 1", "Book 1") + assert rows[1] == ("Author 1", "Book 2") + + def test_subquery(self, sync_engine): + """Test subqueries in sync mode.""" + Session = sessionmaker(bind=sync_engine) + with Session() as session: + session.execute( + text(""" + CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + customer_id INTEGER, + amount INTEGER + ) + """) + ) + session.execute( + text(""" + CREATE TABLE customers ( + id INTEGER PRIMARY KEY, + name TEXT + ) + """) + ) + session.commit() + + session.execute(text("INSERT INTO customers (name) VALUES ('Customer A')")) + session.execute(text("INSERT INTO customers (name) VALUES ('Customer B')")) + session.execute( + text("INSERT INTO orders (customer_id, amount) VALUES (1, 100)") + ) + session.execute( + text("INSERT INTO orders (customer_id, amount) VALUES (1, 200)") + ) + session.execute( + text("INSERT INTO orders (customer_id, amount) VALUES (2, 50)") + ) + session.commit() + + result = session.execute( + text(""" + SELECT c.name, SUM(o.amount) as total + FROM customers c + JOIN orders o ON c.id = o.customer_id + WHERE o.amount > 50 + GROUP BY c.id + """) + ) + rows = result.fetchall() + assert len(rows) == 1 + assert rows[0] == ("Customer A", 300) + + def test_index_usage(self, sync_engine): + """Test index usage for query optimization.""" + with sync_engine.connect() as conn: + conn.execute( + text(""" + CREATE TABLE indexed_table ( + id INTEGER PRIMARY KEY, + category TEXT, + value INTEGER + ) + """) + ) + conn.execute(text("CREATE INDEX idx_category ON indexed_table (category)")) + conn.commit() + + for i in range(100): + category = "A" if i % 2 == 0 else "B" + conn.execute( + text( + "INSERT INTO indexed_table (category, value) VALUES (:cat, :val)" + ), + {"cat": category, "val": i}, + ) + conn.commit() + + result = conn.execute( + text("SELECT COUNT(*) FROM indexed_table WHERE category = 'A'") + ) + assert result.fetchone()[0] == 50 + + def test_cte_query(self, sync_engine): + """Test Common Table Expression (CTE) queries.""" + with sync_engine.connect() as conn: + conn.execute( + text(""" + CREATE TABLE employees ( + id INTEGER PRIMARY KEY, + name TEXT, + manager_id INTEGER + ) + """) + ) + conn.commit() + + conn.execute( + text("INSERT INTO employees (name, manager_id) VALUES ('CEO', NULL)") + ) + conn.execute( + text("INSERT INTO employees (name, manager_id) VALUES ('Manager1', 1)") + ) + conn.execute( + text("INSERT INTO employees (name, manager_id) VALUES ('Manager2', 1)") + ) + conn.execute( + text("INSERT INTO employees (name, manager_id) VALUES ('Employee1', 2)") + ) + conn.commit() + + result = conn.execute( + text(""" + WITH RECURSIVE hierarchy AS ( + SELECT id, name, 0 as level + FROM employees + WHERE manager_id IS NULL + UNION ALL + SELECT e.id, e.name, h.level + 1 + FROM employees e + JOIN hierarchy h ON e.manager_id = h.id + ) + SELECT name, level FROM hierarchy WHERE level > 0 + """) + ) + rows = result.fetchall() + assert len(rows) == 3 + + +class TestAsyncDBComplexOperations: + """Tests for complex async database operations.""" + + @pytest.mark.asyncio + async def test_join_query(self, async_engine): + """Test join queries in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text(""" + CREATE TABLE async_authors ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ) + """) + ) + await conn.execute( + text(""" + CREATE TABLE async_books ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + author_id INTEGER NOT NULL + ) + """) + ) + await conn.commit() + + await conn.execute( + text("INSERT INTO async_authors (name) VALUES ('Async Author')") + ) + await conn.execute( + text( + "INSERT INTO async_books (title, author_id) VALUES ('Async Book 1', 1)" + ) + ) + await conn.execute( + text( + "INSERT INTO async_books (title, author_id) VALUES ('Async Book 2', 1)" + ) + ) + await conn.commit() + + result = await conn.execute( + text(""" + SELECT a.name, b.title + FROM async_authors a + JOIN async_books b ON a.id = b.author_id + """) + ) + rows = result.fetchall() + assert len(rows) == 2 + + @pytest.mark.asyncio + async def test_subquery(self, async_engine): + """Test subqueries in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text(""" + CREATE TABLE async_orders ( + id INTEGER PRIMARY KEY, + product_id INTEGER, + quantity INTEGER + ) + """) + ) + await conn.execute( + text(""" + CREATE TABLE async_products ( + id INTEGER PRIMARY KEY, + name TEXT + ) + """) + ) + await conn.commit() + + await conn.execute( + text("INSERT INTO async_products (name) VALUES ('Product 1')") + ) + await conn.execute( + text("INSERT INTO async_orders (product_id, quantity) VALUES (1, 5)") + ) + await conn.execute( + text("INSERT INTO async_orders (product_id, quantity) VALUES (1, 3)") + ) + await conn.commit() + + result = await conn.execute( + text(""" + SELECT p.name, SUM(o.quantity) as total + FROM async_products p + WHERE p.id IN ( + SELECT product_id FROM async_orders WHERE quantity >= 3 + ) + GROUP BY p.id + """) + ) + rows = result.fetchall() + assert len(rows) == 1 + assert rows[0][0] == "Product 1" + + @pytest.mark.asyncio + async def test_cte_query(self, async_engine): + """Test CTE queries in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text(""" + CREATE TABLE async_employees ( + id INTEGER PRIMARY KEY, + name TEXT, + department TEXT + ) + """) + ) + await conn.commit() + + await conn.execute( + text( + "INSERT INTO async_employees (name, department) VALUES ('E1', 'Dept A')" + ) + ) + await conn.execute( + text( + "INSERT INTO async_employees (name, department) VALUES ('E2', 'Dept B')" + ) + ) + await conn.execute( + text( + "INSERT INTO async_employees (name, department) VALUES ('E3', 'Dept A')" + ) + ) + await conn.commit() + + result = await conn.execute( + text(""" + SELECT department, COUNT(*) as count + FROM async_employees + GROUP BY department + """) + ) + rows = result.fetchall() + departments = {row[0]: row[1] for row in rows} + assert departments["Dept A"] == 2 + assert departments["Dept B"] == 1 + + +class TestConnectionModeSwitching: + """Tests for switching between sync and async connection modes.""" + + def test_sync_mode_after_async_env(self, tmp_path): + """Test that sync mode works after async environment is set.""" + os.environ["ASYNC"] = "true" + os.environ["JETBASE_SQLALCHEMY_URL"] = f"sqlite+aiosqlite:///{tmp_path}/test.db" + + _get_engine.cache_clear() + + with get_connection() as conn: + result = conn.execute(text("SELECT 1")) + assert result.fetchone() == (1,) + + @pytest.mark.asyncio + async def test_async_mode_after_sync_env(self, tmp_path): + """Test that async mode works after sync environment is set.""" + os.environ["ASYNC"] = "true" + os.environ["JETBASE_SQLALCHEMY_URL"] = f"sqlite+aiosqlite:///{tmp_path}/test.db" + + _get_engine.cache_clear() + + async with get_async_db_connection() as conn: + result = await conn.execute(text("SELECT 1")) + assert result.fetchone() == (1,) + + +class TestDBConnectionEdgeCases: + """Tests for edge cases in database connections.""" + + def test_empty_result_set(self, sync_engine): + """Test querying empty result set.""" + with sync_engine.connect() as conn: + conn.execute(text("CREATE TABLE empty_table (id INTEGER PRIMARY KEY)")) + conn.commit() + + result = conn.execute(text("SELECT * FROM empty_table")) + rows = result.fetchall() + assert len(rows) == 0 + + @pytest.mark.asyncio + async def test_async_empty_result_set(self, async_engine): + """Test querying empty result set in async mode.""" + async with async_engine.connect() as conn: + await conn.execute( + text("CREATE TABLE async_empty (id INTEGER PRIMARY KEY)") + ) + await conn.commit() + + result = await conn.execute(text("SELECT * FROM async_empty")) + rows = result.fetchall() + assert len(rows) == 0 + + def test_null_handling(self, sync_engine): + """Test handling NULL values.""" + with sync_engine.connect() as conn: + conn.execute( + text(""" + CREATE TABLE null_test ( + id INTEGER PRIMARY KEY, + value TEXT + ) + """) + ) + conn.commit() + + conn.execute(text("INSERT INTO null_test (value) VALUES (NULL)")) + conn.commit() + + result = conn.execute( + text("SELECT value FROM null_test WHERE value IS NULL") + ) + rows = result.fetchall() + assert len(rows) == 1 + + def test_concurrent_transactions(self, sync_engine): + """Test concurrent-like operations in sync mode.""" + Session = sessionmaker(bind=sync_engine) + with Session() as session: + session.execute( + text("CREATE TABLE counter (id INTEGER PRIMARY KEY, value INTEGER)") + ) + session.commit() + + session.execute(text("INSERT INTO counter (value) VALUES (0)")) + session.commit() + + for _ in range(10): + session.execute(text("UPDATE counter SET value = value + 1")) + session.commit() + + result = session.execute(text("SELECT value FROM counter")) + assert result.fetchone()[0] == 10 + + def test_like_pattern_matching(self, sync_engine): + """Test LIKE pattern matching.""" + with sync_engine.connect() as conn: + conn.execute( + text(""" + CREATE TABLE pattern_test ( + id INTEGER PRIMARY KEY, + text_value TEXT + ) + """) + ) + conn.commit() + + values = ["hello world", "hello there", "goodbye world", "test"] + for val in values: + conn.execute( + text("INSERT INTO pattern_test (text_value) VALUES (:val)"), + {"val": val}, + ) + conn.commit() + + result = conn.execute( + text( + "SELECT text_value FROM pattern_test WHERE text_value LIKE 'hello%'" + ) + ) + rows = result.fetchall() + assert len(rows) == 2 + + def test_order_by_and_limit(self, sync_engine): + """Test ORDER BY and LIMIT clauses.""" + with sync_engine.connect() as conn: + conn.execute( + text(""" + CREATE TABLE sorted_items ( + id INTEGER PRIMARY KEY, + value INTEGER, + name TEXT + ) + """) + ) + conn.commit() + + for i in range(10): + conn.execute( + text("INSERT INTO sorted_items (value, name) VALUES (:val, :name)"), + {"val": i, "name": f"Item {i}"}, + ) + conn.commit() + + result = conn.execute( + text(""" + SELECT value, name FROM sorted_items + ORDER BY value DESC + LIMIT 3 + """) + ) + rows = result.fetchall() + assert len(rows) == 3 + assert rows[0] == (9, "Item 9") + assert rows[1] == (8, "Item 8") + assert rows[2] == (7, "Item 7") + + def test_group_by_having(self, sync_engine): + """Test GROUP BY with HAVING clause.""" + Session = sessionmaker(bind=sync_engine) + with Session() as session: + session.execute( + text(""" + CREATE TABLE sales ( + id INTEGER PRIMARY KEY, + product TEXT, + amount INTEGER + ) + """) + ) + session.commit() + + sales_data = [ + ("Product A", 100), + ("Product A", 150), + ("Product B", 200), + ("Product B", 150), + ("Product C", 300), + ] + for product, amount in sales_data: + session.execute( + text("INSERT INTO sales (product, amount) VALUES (:prod, :amt)"), + {"prod": product, "amt": amount}, + ) + session.commit() + + result = session.execute( + text(""" + SELECT product, SUM(amount) as total + FROM sales + GROUP BY product + HAVING SUM(amount) > 200 + """) + ) + rows = result.fetchall() + assert len(rows) == 3 + + +class TestAsyncDBSessionManagement: + """Tests for async session management.""" + + @pytest.mark.asyncio + async def test_async_session_begin_commit(self, async_engine): + """Test async session begin and commit.""" + async with async_engine.connect() as conn: + await conn.execute( + text("CREATE TABLE session_test (id INTEGER PRIMARY KEY, data TEXT)") + ) + await conn.commit() + + async with conn.begin(): + await conn.execute( + text("INSERT INTO session_test (data) VALUES ('session_data')") + ) + + result = await conn.execute(text("SELECT data FROM session_test")) + assert result.fetchone()[0] == "session_data" + + @pytest.mark.asyncio + async def test_async_session_rollback(self, async_engine): + """Test async session rollback.""" + async with async_engine.connect() as conn: + await conn.execute( + text( + "CREATE TABLE rollback_test (id INTEGER PRIMARY KEY, value INTEGER)" + ) + ) + await conn.commit() + + try: + async with conn.begin(): + await conn.execute( + text("INSERT INTO rollback_test (value) VALUES (100)") + ) + raise Exception("Force rollback") + except Exception: + pass + + result = await conn.execute(text("SELECT COUNT(*) FROM rollback_test")) + assert result.fetchone()[0] == 0 + + @pytest.mark.asyncio + async def test_async_multiple_operations(self, async_engine): + """Test multiple async operations in sequence.""" + async with async_engine.connect() as conn: + await conn.execute( + text("CREATE TABLE multi_ops (id INTEGER PRIMARY KEY, name TEXT)") + ) + await conn.commit() + + for i in range(5): + await conn.execute( + text("INSERT INTO multi_ops (name) VALUES (:name)"), + {"name": f"Name {i}"}, + ) + await conn.commit() + + result = await conn.execute(text("SELECT COUNT(*) FROM multi_ops")) + assert result.fetchone()[0] == 5 + + await conn.execute( + text("UPDATE multi_ops SET name = 'Updated' WHERE id = 1") + ) + await conn.commit() + + result = await conn.execute(text("SELECT name FROM multi_ops WHERE id = 1")) + assert result.fetchone()[0] == "Updated" diff --git a/uv.lock b/uv.lock index 7e0ab3f..33c9edf 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", @@ -9,6 +9,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "asn1crypto" version = "1.5.1" @@ -18,6 +27,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" }, + { url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" }, + { url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "boto3" version = "1.42.25" @@ -395,7 +481,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" }, { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" }, { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" }, { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" }, { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, @@ -406,7 +491,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, @@ -417,7 +501,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, @@ -428,7 +511,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -439,7 +521,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, @@ -467,9 +548,11 @@ wheels = [ [[package]] name = "jetbase" -version = "0.17.1" +version = "0.17.2" source = { editable = "." } dependencies = [ + { name = "aiosqlite" }, + { name = "asyncpg" }, { name = "packaging" }, { name = "rich" }, { name = "sqlalchemy" }, @@ -481,10 +564,17 @@ dependencies = [ databricks = [ { name = "databricks-sqlalchemy" }, ] +postgres-async = [ + { name = "asyncpg" }, + { name = "sqlalchemy", extra = ["postgresql"] }, +] snowflake = [ { name = "cryptography" }, { name = "snowflake-sqlalchemy" }, ] +sqlite-async = [ + { name = "aiosqlite" }, +] [package.dev-dependencies] dev = [ @@ -493,22 +583,28 @@ dev = [ { name = "pymysql" }, { name = "pyrefly" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, { name = "snowflake-sqlalchemy" }, ] [package.metadata] requires-dist = [ + { name = "aiosqlite", specifier = ">=0.22.1" }, + { name = "aiosqlite", marker = "extra == 'sqlite-async'", specifier = ">=0.22.1" }, + { name = "asyncpg", specifier = ">=0.29.0,<0.32.0" }, + { name = "asyncpg", marker = "extra == 'postgres-async'", specifier = ">=0.29.0,<0.32.0" }, { name = "cryptography", marker = "extra == 'snowflake'", specifier = ">=46.0.3" }, { name = "databricks-sqlalchemy", marker = "extra == 'databricks'", specifier = ">=2.0.1" }, { name = "packaging", specifier = ">=25.0" }, { name = "rich", specifier = ">=12.2.0" }, { name = "snowflake-sqlalchemy", marker = "extra == 'snowflake'", specifier = ">=1.8.2" }, { name = "sqlalchemy", specifier = ">=2.0.10" }, + { name = "sqlalchemy", extras = ["postgresql"], marker = "extra == 'postgres-async'", specifier = ">=2.0.10" }, { name = "tomli", specifier = ">=2.0.2" }, { name = "typer", specifier = ">=0.12.3" }, ] -provides-extras = ["snowflake", "databricks"] +provides-extras = ["snowflake", "databricks", "sqlite-async", "postgres-async"] [package.metadata.requires-dev] dev = [ @@ -517,6 +613,7 @@ dev = [ { name = "pymysql", specifier = ">=1.1.2" }, { name = "pyrefly", specifier = ">=0.38.2" }, { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "ruff", specifier = ">=0.14.2" }, { name = "snowflake-sqlalchemy", specifier = ">=1.8.2" }, ] @@ -846,6 +943,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg2" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/8d/9d12bc8677c24dad342ec777529bce705b3e785fa05d85122b5502b9ab55/psycopg2-2.9.11.tar.gz", hash = "sha256:964d31caf728e217c697ff77ea69c2ba0865fa41ec20bb00f0977e62fdcc52e3", size = 379598, upload-time = "2025-10-10T11:14:46.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/ba/b7672ed9d0be238265972ef52a7a8c9e9e815ca2a7dc19a1b2e4b5b637f0/psycopg2-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:103e857f46bb76908768ead4e2d0ba1d1a130e7b8ed77d3ae91e8b33481813e8", size = 2713725, upload-time = "2025-10-10T11:10:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/86/fe/d6dce306fd7b61e312757ba4d068617f562824b9c6d3e4a39fc578ea2814/psycopg2-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:210daed32e18f35e3140a1ebe059ac29209dd96468f2f7559aa59f75ee82a5cb", size = 2713723, upload-time = "2025-10-10T11:10:12.957Z" }, + { url = "https://files.pythonhosted.org/packages/b5/bf/635fbe5dd10ed200afbbfbe98f8602829252ca1cce81cc48fb25ed8dadc0/psycopg2-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:e03e4a6dbe87ff81540b434f2e5dc2bddad10296db5eea7bdc995bf5f4162938", size = 2713969, upload-time = "2025-10-10T11:10:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/88/5a/18c8cb13fc6908dc41a483d2c14d927a7a3f29883748747e8cb625da6587/psycopg2-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:8dc379166b5b7d5ea66dcebf433011dfc51a7bb8a5fc12367fa05668e5fc53c8", size = 2714048, upload-time = "2025-10-10T11:10:19.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.11" @@ -1058,6 +1168,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1277,6 +1401,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, ] +[package.optional-dependencies] +postgresql = [ + { name = "psycopg2" }, +] + [[package]] name = "thrift" version = "0.20.0" From 3e196d7f8bdbd304c16410ca6a80105acb5128cd Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Thu, 5 Feb 2026 12:27:07 -0300 Subject: [PATCH 38/40] Fix My own personal config slipped lol --- docs/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index a19721c..f1cdcae 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -117,7 +117,7 @@ ENVIRONMENT = os.getenv("ENVIRONMENT") if ENVIRONMENT == "DEV": def get_env_vars(): return { - "sqlalchemy_url": "sqlite+aiosqlite:///./egos.db", + "sqlalchemy_url": "sqlite+aiosqlite:///./mydb.db", "async_mode": True, } else: From cdb6a5f68e15a598112ef1da5240089667e249e5 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Thu, 5 Feb 2026 19:50:27 -0300 Subject: [PATCH 39/40] Fixed a bug After creating migrations, if you dont migrate and create another migrations, they would be duplicate. --- jetbase/commands/make_migrations.py | 36 ++++++++++++--- jetbase/engine/schema_diff.py | 38 +++++++++++++++- jetbase/engine/sql_generator.py | 21 +++++++-- tests/unit/commands/test_make_migrations.py | 12 ++--- tests/unit/engine/test_schema_diff.py | 50 +++++++++++++++++++++ 5 files changed, 140 insertions(+), 17 deletions(-) diff --git a/jetbase/commands/make_migrations.py b/jetbase/commands/make_migrations.py index a72bbd2..7669844 100644 --- a/jetbase/commands/make_migrations.py +++ b/jetbase/commands/make_migrations.py @@ -22,8 +22,13 @@ ModelDiscoveryError, discover_all_models, ) -from jetbase.engine.schema_diff import compare_schemas, has_changes +from jetbase.engine.schema_diff import ( + compare_schemas, + get_tables_from_migration_files, + has_changes, +) from jetbase.engine.schema_introspection import introspect_database +from jetbase.engine.version import get_migration_filepaths_by_version from jetbase.engine.sql_generator import ( generate_add_column_sql, generate_add_foreign_key_sql, @@ -112,13 +117,28 @@ def make_migrations_cmd(description: str | None = None) -> None: sqlalchemy_url = get_config(required={"sqlalchemy_url"}).sqlalchemy_url + jetbase_dir = find_jetbase_directory() + if jetbase_dir: + migrations_dir = os.path.join(jetbase_dir, MIGRATIONS_DIR) + else: + migrations_dir = os.path.join(os.getcwd(), MIGRATIONS_DIR) + + existing_migration_files = list( + get_migration_filepaths_by_version(migrations_dir).values() + ) + already_migrated_tables = get_tables_from_migration_files(existing_migration_files) + if is_async_enabled(): - asyncio.run(_make_migrations_async(models, description)) + asyncio.run( + _make_migrations_async(models, description, already_migrated_tables) + ) else: - _make_migrations_sync(models, description) + _make_migrations_sync(models, description, already_migrated_tables) -def _make_migrations_sync(models: dict, description: str | None) -> None: +def _make_migrations_sync( + models: dict, description: str | None, already_migrated_tables: set[str] +) -> None: """ Generate migrations using sync database connection. """ @@ -128,7 +148,7 @@ def _make_migrations_sync(models: dict, description: str | None) -> None: except Exception as e: raise MakeMigrationsError(f"Failed to introspect database: {e}") - diff = compare_schemas(models, database_schema, connection) + diff = compare_schemas(models, database_schema, connection, already_migrated_tables) if not has_changes(diff): print("No changes detected.") @@ -178,7 +198,9 @@ def _make_migrations_sync(models: dict, description: str | None) -> None: _write_migration_file(upgrade_statements, rollback_statements, description) -async def _make_migrations_async(models: dict, description: str | None) -> None: +async def _make_migrations_async( + models: dict, description: str | None, already_migrated_tables: set[str] +) -> None: """ Generate migrations using async database connection. @@ -205,7 +227,7 @@ async def _make_migrations_async(models: dict, description: str | None) -> None: except Exception as e: raise MakeMigrationsError(f"Failed to introspect database: {e}") - diff = compare_schemas(models, database_schema, sync_conn) + diff = compare_schemas(models, database_schema, sync_conn, already_migrated_tables) if not has_changes(diff): print("No changes detected.") diff --git a/jetbase/engine/schema_diff.py b/jetbase/engine/schema_diff.py index 036958c..d834407 100644 --- a/jetbase/engine/schema_diff.py +++ b/jetbase/engine/schema_diff.py @@ -115,10 +115,40 @@ def get_models_schema_info(models: dict[str, type]) -> dict[str, TableInfo]: } +def get_tables_from_migration_files(migration_files: list[str]) -> set[str]: + """ + Extract table names from CREATE TABLE statements in migration files. + + Args: + migration_files (list[str]): List of migration file paths to parse. + + Returns: + set[str]: Set of table names found in CREATE TABLE statements. + """ + import re + + tables: set[str] = set() + create_table_pattern = re.compile( + r"CREATE\s+TABLE\s+[`\"']?(\w+)[`\"']?\s*\(", re.IGNORECASE + ) + + for file_path in migration_files: + try: + with open(file_path, "r") as f: + content = f.read() + matches = create_table_pattern.findall(content) + tables.update(matches) + except Exception: + continue + + return tables + + def compare_schemas( models: dict[str, type], database_schema: SchemaInfo, connection: Connection, + already_migrated_tables: set[str] | None = None, ) -> SchemaDiff: """ Compare model schema against database schema to detect differences. @@ -127,6 +157,8 @@ def compare_schemas( models (dict[str, type]): Dictionary mapping table names to model classes. database_schema (SchemaInfo): The current database schema. connection (Connection): Database connection. + already_migrated_tables (set[str] | None): Tables already defined in + existing migration files. These will be excluded from tables_to_create. Returns: SchemaDiff: Object containing all detected differences. @@ -137,7 +169,11 @@ def compare_schemas( model_table_names = set(models_schema.keys()) db_table_names = set(database_schema.tables.keys()) - diff.tables_to_create = sorted(list(model_table_names - db_table_names)) + excluded_tables = already_migrated_tables or set() + + diff.tables_to_create = sorted( + list(model_table_names - db_table_names - excluded_tables) + ) diff.tables_to_drop = sorted(list(db_table_names - model_table_names)) for table_name in model_table_names & db_table_names: diff --git a/jetbase/engine/sql_generator.py b/jetbase/engine/sql_generator.py index fa45cbf..2478906 100644 --- a/jetbase/engine/sql_generator.py +++ b/jetbase/engine/sql_generator.py @@ -5,7 +5,7 @@ from jetbase.config import get_config from jetbase.database.queries.base import detect_db -from jetbase.engine.schema_diff import SchemaDiff +from jetbase.engine.schema_diff import SchemaDiff, get_model_table_info from jetbase.engine.schema_introspection import ColumnInfo from jetbase.enums import DatabaseType @@ -383,21 +383,34 @@ def generate_drop_foreign_key_sql( return f"ALTER TABLE {table_name} DROP CONSTRAINT {fk_name};" -def generate_upgrade_sql(diff: SchemaDiff, db_type: DatabaseType) -> str: +def generate_upgrade_sql( + diff: SchemaDiff, db_type: DatabaseType, models: dict | None = None +) -> str: """ Generate the upgrade SQL from a schema diff. Args: diff (SchemaDiff): The schema diff. db_type (DatabaseType): The database type. + models (dict | None): Dictionary mapping table names to model classes. Returns: str: Complete upgrade SQL statement. """ statements = [] - for table_name in diff.tables_to_create: - pass + if models: + for table_name in diff.tables_to_create: + model_class = models[table_name] + table_info = get_model_table_info(model_class) + statements.append( + generate_create_table_sql( + table_name, + table_info.columns, + table_info.primary_keys, + db_type, + ) + ) for table_name in diff.columns_to_add: for column in diff.columns_to_add[table_name]: diff --git a/tests/unit/commands/test_make_migrations.py b/tests/unit/commands/test_make_migrations.py index 06bd06f..06b1fd8 100644 --- a/tests/unit/commands/test_make_migrations.py +++ b/tests/unit/commands/test_make_migrations.py @@ -202,7 +202,7 @@ def test_make_migrations_sync_new_tables(self, tmp_path, sync_db): with patch( "jetbase.commands.make_migrations._write_migration_file" ) as mock_write: - _make_migrations_sync(models, "test description") + _make_migrations_sync(models, "test description", set()) mock_write.assert_called_once() args, kwargs = mock_write.call_args @@ -255,7 +255,7 @@ def test_make_migrations_sync_no_changes(self, tmp_path, sync_db): with patch( "jetbase.commands.make_migrations._write_migration_file" ) as mock_write: - _make_migrations_sync(models, "test description") + _make_migrations_sync(models, "test description", set()) mock_write.assert_not_called() @@ -462,7 +462,7 @@ def test_generate_sql_for_user_model(self, tmp_path): return_value=False ) - _make_migrations_sync(models, "create users") + _make_migrations_sync(models, "create users", set()) mock_write.assert_called_once() args, kwargs = mock_write.call_args @@ -514,7 +514,7 @@ def test_generate_sql_with_foreign_keys(self, tmp_path): return_value=False ) - _make_migrations_sync(models, "create orders") + _make_migrations_sync(models, "create orders", set()) mock_write.assert_called_once() args, kwargs = mock_write.call_args @@ -567,7 +567,9 @@ def test_generate_sql_multiple_tables(self, tmp_path): return_value=False ) - _make_migrations_sync(models, "create all tables") + _make_migrations_sync( + models, "create all tables", set() + ) mock_write.assert_called_once() args, kwargs = mock_write.call_args diff --git a/tests/unit/engine/test_schema_diff.py b/tests/unit/engine/test_schema_diff.py index c12eccd..8ae4571 100644 --- a/tests/unit/engine/test_schema_diff.py +++ b/tests/unit/engine/test_schema_diff.py @@ -11,6 +11,7 @@ get_model_table_columns, get_model_table_info, get_models_schema_info, + get_tables_from_migration_files, has_changes, ) from jetbase.engine.schema_introspection import ( @@ -293,3 +294,52 @@ def test_has_changes(): } ) assert has_changes(diff_with_column_changes) is True + + +def test_get_tables_from_migration_files(tmp_path): + """Test extracting table names from migration files.""" + from jetbase.constants import MIGRATIONS_DIR + + migrations_dir = tmp_path / MIGRATIONS_DIR + migrations_dir.mkdir() + + migration_content = """-- upgrade + +CREATE TABLE users ( + id INTEGER NOT NULL PRIMARY KEY, + email VARCHAR(255) NOT NULL +); + +CREATE TABLE products ( + id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL +); + +-- rollback + +DROP TABLE users; + +DROP TABLE products; +""" + migration_file = migrations_dir / "V1__test.sql" + migration_file.write_text(migration_content) + + tables = get_tables_from_migration_files([str(migration_file)]) + + assert "users" in tables + assert "products" in tables + + +def test_compare_schemas_with_already_migrated_tables(): + """Test that compare_schemas excludes already migrated tables.""" + models = {"users": UserModel, "products": ProductModel} + database_schema = SchemaInfo(tables={}) + + connection = MagicMock() + + diff = compare_schemas( + models, database_schema, connection, already_migrated_tables={"users"} + ) + + assert "users" not in diff.tables_to_create + assert "products" in diff.tables_to_create From 759b11b15734432e86e92038e2aaad1afa9febbc Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Fri, 6 Feb 2026 15:20:39 -0300 Subject: [PATCH 40/40] fix: sync is_async_enabled() with async_mode from env.py The function only checked ASYNC env var, ignoring async_mode=True in env.py. Also fixed get_config call to include sqlalchemy_url in keys to avoid TypeError. --- jetbase/database/connection.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/jetbase/database/connection.py b/jetbase/database/connection.py index f9702de..40a8e1d 100644 --- a/jetbase/database/connection.py +++ b/jetbase/database/connection.py @@ -22,15 +22,27 @@ def is_async_enabled() -> bool: """ Check if async mode is enabled. - Only checks the ASYNC environment variable: - - "true", "1", "yes" -> async mode - - "false", "0", "no", or not set -> sync mode + Checks both the ASYNC environment variable and async_mode from config. + Returns True if either is enabled. Returns: bool: True if async mode is enabled, False otherwise. """ async_env = os.getenv("ASYNC", "").lower() - return async_env in ("true", "1", "yes") + if async_env in ("true", "1", "yes"): + return True + if async_env in ("false", "0", "no"): + return False + + try: + config = get_config( + keys=["async_mode", "sqlalchemy_url"], + defaults={"async_mode": False}, + required={"sqlalchemy_url"}, + ) + return config.async_mode + except Exception: + return False def _make_sync_url(url: str) -> str: