From 4e502d0f51aecb5d3eb11f892e9d33a52570455c Mon Sep 17 00:00:00 2001 From: Crispen Gari Date: Mon, 26 Feb 2024 20:34:21 +0200 Subject: [PATCH 1/8] reference-table --- dataloom/exceptions/__init__.py | 4 + dataloom/keys.py | 2 +- dataloom/loom/__init__.py | 15 +- dataloom/model/__init__.py | 3 +- dataloom/statements/__init__.py | 139 +++++++++++++----- dataloom/statements/statements.py | 61 ++++++-- .../tests/mysql/test_create_tables_mysql.py | 5 +- .../tests/postgres/test_create_table_pg.py | 5 +- .../tests/sqlite3/test_create_table_sqlite.py | 5 +- dataloom/utils/__init__.py | 6 +- dataloom/utils/alter_table.py | 5 +- dataloom/utils/create_table.py | 49 +++++- hi.db | Bin 57344 -> 57344 bytes playground.py | 123 ++++++++++------ 14 files changed, 315 insertions(+), 107 deletions(-) diff --git a/dataloom/exceptions/__init__.py b/dataloom/exceptions/__init__.py index dc6b41d..f3bbe84 100644 --- a/dataloom/exceptions/__init__.py +++ b/dataloom/exceptions/__init__.py @@ -22,6 +22,10 @@ class TooManyPkException(Exception): pass +class TooManyFkException(Exception): + pass + + class UnsupportedDialectException(ValueError): pass diff --git a/dataloom/keys.py b/dataloom/keys.py index 59e757b..4410e1f 100644 --- a/dataloom/keys.py +++ b/dataloom/keys.py @@ -1,7 +1,7 @@ # Configuration file for unit testing. -push = True +push = False class PgConfig: diff --git a/dataloom/loom/__init__.py b/dataloom/loom/__init__.py index ee784a7..08d5663 100644 --- a/dataloom/loom/__init__.py +++ b/dataloom/loom/__init__.py @@ -1417,9 +1417,8 @@ def sync( for model in models: if drop or force: self._execute_sql(model._drop_sql(dialect=self.dialect)) - for sql in model._create_sql(dialect=self.dialect): - if sql is not None: - self._execute_sql(sql) + sql = model._create_sql(dialect=self.dialect) + self._execute_sql(sql) elif alter: # 1. we only alter the table if it does exists # 2. if not we just have to create a new table @@ -1449,13 +1448,11 @@ def sync( ) self._execute_sql(sql, _is_script=True) else: - for sql in model._create_sql(dialect=self.dialect): - if sql is not None: - self._execute_sql(sql) + sql = model._create_sql(dialect=self.dialect) + self._execute_sql(sql) else: - for sql in model._create_sql(dialect=self.dialect): - if sql is not None: - self._execute_sql(sql) + sql = model._create_sql(dialect=self.dialect) + self._execute_sql(sql) return self.tables except Exception as e: raise Exception(e) diff --git a/dataloom/model/__init__.py b/dataloom/model/__init__.py index 90000fb..8009e7f 100644 --- a/dataloom/model/__init__.py +++ b/dataloom/model/__init__.py @@ -57,10 +57,9 @@ class Model: @classmethod def _create_sql(cls, dialect: DIALECT_LITERAL): - sqls = GetStatement( + return GetStatement( dialect=dialect, model=cls, table_name=cls._get_table_name() )._get_create_table_command - return sqls @classmethod def _alter_sql(cls, dialect: DIALECT_LITERAL, old_columns: list[str]): diff --git a/dataloom/statements/__init__.py b/dataloom/statements/__init__.py index a2a6be8..8cf5c86 100644 --- a/dataloom/statements/__init__.py +++ b/dataloom/statements/__init__.py @@ -6,6 +6,7 @@ PkNotDefinedException, TooManyPkException, UnsupportedDialectException, + TooManyFkException, ) from dataloom.statements.statements import ( MySqlStatements, @@ -23,6 +24,7 @@ get_create_table_params, get_table_fields, AlterTable, + get_create_reference_table_params, ) @@ -152,61 +154,119 @@ def _get_tables_command(self) -> Optional[str]: return sql @property - def _get_create_table_command(self) -> Optional[list[str]]: + def _get_create_table_command(self) -> str: # is the primary key defined in this table? - pks, user_fields, predefined_fields = get_create_table_params( + pks, fks, user_fields, predefined_fields = get_create_table_params( dialect=self.dialect, model=self.model, ) - - if len(pks) == 0: + if len(fks) > 2: + raise TooManyFkException( + f"Reference table '{self.table_name}' can not have more than 2 foreign keys." + ) + if len(pks) == 0 and len(fks) != 2: raise PkNotDefinedException( - "Your table does not have a primary key column." + f"Your table '{self.table_name}' does not have a primary key column and it is not a reference table." ) if len(pks) > 1: raise TooManyPkException( f"You have defined many field as primary keys which is not allowed. Fields ({', '.join(pks)}) are primary keys." ) + fields = [*user_fields, *predefined_fields] fields_name = ", ".join(f for f in [" ".join(field) for field in fields]) if self.dialect == "postgres": - sql = ( - PgStatements.CREATE_NEW_TABLE.format( - table_name=f'"{self.table_name}"', fields_name=fields_name + if len(fks) == 2: + pks, user_fields, fks = get_create_reference_table_params( + dialect=self.dialect, + model=self.model, ) - if not self.ignore_exists - else PgStatements.CREATE_NEW_TABLE_IF_NOT_EXITS.format( - table_name=f'"{self.table_name}"', fields_name=fields_name + sql = ( + PgStatements.CREATE_REFERENCE_TABLE.format( + table_name=f'"{self.table_name}"', fields_name=fields_name + ) + if not self.ignore_exists + else PgStatements.CREATE_REFERENCE_TABLE_IF_NOT_EXISTS.format( + table_name=f'"{self.table_name}"', + fields_names=", ".join(user_fields), + fks=", ".join(fks), + pks=", ".join(pks), + ) + ) + else: + sql = ( + PgStatements.CREATE_NEW_TABLE.format( + table_name=f'"{self.table_name}"', fields_name=fields_name + ) + if not self.ignore_exists + else PgStatements.CREATE_NEW_TABLE_IF_NOT_EXITS.format( + table_name=f'"{self.table_name}"', fields_name=fields_name + ) ) - ) elif self.dialect == "mysql": - sql = ( - MySqlStatements.CREATE_NEW_TABLE.format( - table_name=f"`{self.table_name}`", fields_name=fields_name + if len(fks) == 2: + pks, user_fields, fks = get_create_reference_table_params( + dialect=self.dialect, + model=self.model, ) - if not self.ignore_exists - else MySqlStatements.CREATE_NEW_TABLE_IF_NOT_EXITS.format( - table_name=f"`{self.table_name}`", fields_name=fields_name + sql = ( + MySqlStatements.CREATE_REFERENCE_TABLE.format( + table_name=f"`{self.table_name}`", fields_name=fields_name + ) + if not self.ignore_exists + else MySqlStatements.CREATE_REFERENCE_TABLE_IF_NOT_EXISTS.format( + table_name=f"`{self.table_name}`", + fields_names=", ".join(user_fields), + fks=", ".join(fks), + pks=", ".join(pks), + ) + ) + else: + sql = ( + MySqlStatements.CREATE_NEW_TABLE.format( + table_name=f"`{self.table_name}`", fields_name=fields_name + ) + if not self.ignore_exists + else MySqlStatements.CREATE_NEW_TABLE_IF_NOT_EXITS.format( + table_name=f"`{self.table_name}`", fields_name=fields_name + ) ) - ) elif self.dialect == "sqlite": - sql = ( - Sqlite3Statements.CREATE_NEW_TABLE.format( - table_name=f"`{self.table_name}`", fields_name=fields_name + if len(fks) == 2: + pks, user_fields, fks = get_create_reference_table_params( + dialect=self.dialect, + model=self.model, ) - if not self.ignore_exists - else Sqlite3Statements.CREATE_NEW_TABLE_IF_NOT_EXITS.format( - table_name=f"`{self.table_name}`", fields_name=fields_name + sql = ( + Sqlite3Statements.CREATE_REFERENCE_TABLE.format( + table_name=f"`{self.table_name}`", fields_name=fields_name + ) + if not self.ignore_exists + else Sqlite3Statements.CREATE_REFERENCE_TABLE_IF_NOT_EXISTS.format( + table_name=f"`{self.table_name}`", + fields_names=", ".join(user_fields), + fks=", ".join(fks), + pks=", ".join(pks), + ) + ) + else: + sql = ( + Sqlite3Statements.CREATE_NEW_TABLE.format( + table_name=f"`{self.table_name}`", fields_name=fields_name + ) + if not self.ignore_exists + else Sqlite3Statements.CREATE_NEW_TABLE_IF_NOT_EXITS.format( + table_name=f"`{self.table_name}`", fields_name=fields_name + ) ) - ) else: raise UnsupportedDialectException( "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" ) - return [sql] + return sql def _get_select_where_command( self, @@ -911,21 +971,27 @@ def _get_alter_table_command(self, old_columns: list[str]) -> str: 2. check if is new column 3. check if the column has been removed """ - pks, alterations = AlterTable( + pks, fks, alterations = AlterTable( dialect=self.dialect, model=self.model, old_columns=old_columns ).get_alter_table_params alterations = " ".join(alterations) if self.dialect != "sqlite" else "" # do we have a single primary key or not? - if len(pks) == 0: + if len(pks) == 0 and len(fks) != 2: raise PkNotDefinedException( - "Your table does not have a primary key column." + f"Your table '{self.table_name}' does not have a primary key column and it is not a reference table." ) if len(pks) > 1: raise TooManyPkException( f"You have defined many field as primary keys which is not allowed. Fields ({', '.join(pks)}) are primary keys." ) + + if len(fks) > 2: + raise TooManyFkException( + f"Reference table '{self.table_name}' can not have more than 2 foreign keys." + ) + if self.dialect == "postgres": sql = PgStatements.ALTER_TABLE_COMMAND.format(alterations=alterations) elif self.dialect == "mysql": @@ -937,24 +1003,29 @@ def _get_alter_table_command(self, old_columns: list[str]) -> str: old_table_name = f"`{self.table_name}`" columns, _, _, _ = get_table_fields(self.model, dialect=self.dialect) new_table_name = f"`{self.table_name}_new`" - pks, user_fields, predefined_fields = get_create_table_params( + pks, fks, user_fields, predefined_fields = get_create_table_params( dialect=self.dialect, model=self.model, ) - if len(pks) == 0: + + if len(pks) == 0 and len(fks) != 2: raise PkNotDefinedException( - "Your table does not have a primary key column." + f"Your table '{self.table_name}' does not have a primary key column and it is not a reference table." ) if len(pks) > 1: raise TooManyPkException( f"You have defined many field as primary keys which is not allowed. Fields ({', '.join(pks)}) are primary keys." ) + if len(fks) > 2: + raise TooManyFkException( + f"Reference table '{self.table_name}' can not have more than 2 foreign keys." + ) fields = [*user_fields, *predefined_fields] + fields_name = ", ".join(f for f in [" ".join(field) for field in fields]) create_command = Sqlite3Statements.CREATE_NEW_TABLE_IF_NOT_EXITS.format( table_name=new_table_name, fields_name=fields_name ) - sql = Sqlite3Statements.ALTER_TABLE_COMMAND.format( create_new_table_command=create_command, new_table_name=new_table_name, diff --git a/dataloom/statements/statements.py b/dataloom/statements/statements.py index 8d7f942..7270947 100644 --- a/dataloom/statements/statements.py +++ b/dataloom/statements/statements.py @@ -2,10 +2,10 @@ class MySqlStatements: # Altering tables ALTER_TABLE_COMMAND = """ - -- Begin a transaction + -- Begin a transaction; START TRANSACTION; {alterations} - -- Commit the transaction + -- Commit the transaction; COMMIT; """ @@ -78,6 +78,22 @@ class MySqlStatements: CREATE_NEW_TABLE_IF_NOT_EXITS = ( "CREATE TABLE IF NOT EXISTS {table_name} ({fields_name});" ) + CREATE_REFERENCE_TABLE = """ + CREATE TABLE {table_name} ( + {fields_names}, + PRIMARY KEY ({pks}), + {fks} + ); + """ + + CREATE_REFERENCE_TABLE_IF_NOT_EXISTS = """ + CREATE TABLE IF NOT EXISTS {table_name} ( + {fields_names}, + PRIMARY KEY ({pks}), + {fks} + ); + """ + # insert INSERT_COMMAND_ONE = ( "INSERT INTO {table_name} ({column_names}) VALUES ({placeholder_values});" @@ -166,24 +182,21 @@ class Sqlite3Statements: # Altering tables ALTER_TABLE_COMMAND = """ - -- Begin a transaction + -- Begin a transaction; BEGIN TRANSACTION; - - -- Create a new table with the desired schema {create_new_table_command} - -- Copy data from the old table to the new one INSERT INTO {new_table_name} ({new_table_columns}) SELECT {new_table_columns} FROM {old_table_name}; - -- Drop the old table + -- Drop the old table; DROP TABLE {old_table_name}; - -- Rename the new table to the original table name + -- Rename the new table to the original table name; ALTER TABLE {new_table_name} RENAME TO {old_table_name}; - -- Commit the transaction + -- Commit the transaction; COMMIT; """ # describing table @@ -308,6 +321,21 @@ class Sqlite3Statements: CREATE_NEW_TABLE_IF_NOT_EXITS = ( "CREATE TABLE IF NOT EXISTS {table_name} ({fields_name});" ) + CREATE_REFERENCE_TABLE = """ + CREATE TABLE {table_name} ( + {fields_names}, + PRIMARY KEY ({pks}), + {fks} + ); + """ + + CREATE_REFERENCE_TABLE_IF_NOT_EXISTS = """ + CREATE TABLE IF NOT EXISTS {table_name} ( + {fields_names}, + PRIMARY KEY ({pks}), + {fks} + ); + """ # insterting INSERT_COMMAND_ONE = ( "INSERT INTO {table_name} ({column_names}) VALUES ({placeholder_values});" @@ -447,6 +475,21 @@ class PgStatements: CREATE_NEW_TABLE_IF_NOT_EXITS = ( "CREATE TABLE IF NOT EXISTS {table_name} ({fields_name});" ) + CREATE_REFERENCE_TABLE = """ + CREATE TABLE {table_name} ( + {fields_names}, + PRIMARY KEY ({pks}), + {fks} + ); + """ + + CREATE_REFERENCE_TABLE_IF_NOT_EXISTS = """ + CREATE TABLE IF NOT EXISTS {table_name} ( + {fields_names}, + PRIMARY KEY ({pks}), + {fks} + ); + """ # altering tables # getting tables diff --git a/dataloom/tests/mysql/test_create_tables_mysql.py b/dataloom/tests/mysql/test_create_tables_mysql.py index ef07460..753c77d 100644 --- a/dataloom/tests/mysql/test_create_tables_mysql.py +++ b/dataloom/tests/mysql/test_create_tables_mysql.py @@ -47,7 +47,10 @@ class User(Model): with pytest.raises(Exception) as exc_info: _ = mysql_loom.sync([User], drop=True, force=True) - assert str(exc_info.value) == "Your table does not have a primary key column." + assert ( + str(exc_info.value) + == "Your table 'users' does not have a primary key column and it is not a reference table." + ) conn.close() def test_table_name(self): diff --git a/dataloom/tests/postgres/test_create_table_pg.py b/dataloom/tests/postgres/test_create_table_pg.py index aab3320..1d5133a 100644 --- a/dataloom/tests/postgres/test_create_table_pg.py +++ b/dataloom/tests/postgres/test_create_table_pg.py @@ -47,7 +47,10 @@ class User(Model): with pytest.raises(Exception) as exc_info: _ = pg_loom.sync([User], drop=True, force=True) - assert str(exc_info.value) == "Your table does not have a primary key column." + assert ( + str(exc_info.value) + == "Your table 'users' does not have a primary key column and it is not a reference table." + ) conn.close() def test_table_name(self): diff --git a/dataloom/tests/sqlite3/test_create_table_sqlite.py b/dataloom/tests/sqlite3/test_create_table_sqlite.py index 0ae8c10..4769ac4 100644 --- a/dataloom/tests/sqlite3/test_create_table_sqlite.py +++ b/dataloom/tests/sqlite3/test_create_table_sqlite.py @@ -37,7 +37,10 @@ class User(Model): with pytest.raises(Exception) as exc_info: _ = sqlite_loom.sync([User], drop=True, force=True) - assert str(exc_info.value) == "Your table does not have a primary key column." + assert ( + str(exc_info.value) + == "Your table 'users' does not have a primary key column and it is not a reference table." + ) conn.close() def test_table_name(self): diff --git a/dataloom/utils/__init__.py b/dataloom/utils/__init__.py index 784e375..1afe339 100644 --- a/dataloom/utils/__init__.py +++ b/dataloom/utils/__init__.py @@ -1,7 +1,10 @@ from dataloom.statements import MySqlStatements, PgStatements, Sqlite3Statements from dataloom.utils.logger import console_logger, file_logger -from dataloom.utils.create_table import get_create_table_params +from dataloom.utils.create_table import ( + get_create_table_params, + get_create_reference_table_params, +) from dataloom.utils.alter_table import AlterTable from dataloom.utils.aggregations import get_groups from dataloom.utils.helpers import is_collection @@ -164,4 +167,5 @@ def get_formatted_query( is_collection, get_groups, AlterTable, + get_create_reference_table_params, ] diff --git a/dataloom/utils/alter_table.py b/dataloom/utils/alter_table.py index e63878a..e196b8e 100644 --- a/dataloom/utils/alter_table.py +++ b/dataloom/utils/alter_table.py @@ -193,6 +193,7 @@ def foreign_key_alteration(self, name: str, field: ForeignKeyColumn) -> str: @property def get_alter_table_params(self): pks = [] + fks = [] alterations = [] # add or modify columns for name, field in inspect.getmembers(self.model): @@ -211,6 +212,8 @@ def get_alter_table_params(self): elif isinstance(field, UpdatedAtColumn): alterations.append(self.updated_at_alteration(name=name, field=field)) elif isinstance(field, ForeignKeyColumn): + col = f'"{name}"' if self.dialect == "postgres" else f"`{name}`" + fks.append(col) alterations.append(self.foreign_key_alteration(name=name, field=field)) # delete columns @@ -221,4 +224,4 @@ def get_alter_table_params(self): elif self.dialect == "postgres": alterations.append(f"ALTER TABLE {self.table_name} DROP COLUMN {col};") - return pks, list(reversed(alterations)) + return pks, fks, list(reversed(alterations)) diff --git a/dataloom/utils/create_table.py b/dataloom/utils/create_table.py index db167bf..731d52a 100644 --- a/dataloom/utils/create_table.py +++ b/dataloom/utils/create_table.py @@ -14,6 +14,7 @@ def get_create_table_params( model, dialect: DIALECT_LITERAL, ): + fks = [] pks = [] user_fields = [] predefined_fields = [] @@ -75,6 +76,7 @@ def get_create_table_params( # 4. What is the relationship type being mapped? pk, pk_type = field.table._get_pk_attributes(dialect=dialect) parent_table_name = field.table._get_table_name() + fks.append(f'"{name}"' if dialect == "postgres" else f"`{name}`") value = ( "{pk_type} {unique} {nullable} REFERENCES {parent_table_name}({pk}) ON DELETE {onDelete} ON UPDATE {onUpdate}".format( @@ -100,5 +102,50 @@ def get_create_table_params( predefined_fields.append( (f'"{name}"' if dialect == "postgres" else f"`{name}`", value) ) + return pks, fks, user_fields, predefined_fields - return pks, user_fields, predefined_fields + +def get_create_reference_table_params( + model, + dialect: DIALECT_LITERAL, +): + pks = [] + user_fields = [] + fks = [] + + for name, field in inspect.getmembers(model): + if isinstance(field, PrimaryKeyColumn) or isinstance(field, Column): + raise Exception( + "Primary keys and columns can not be manually added in a reference table." + ) + elif isinstance(field, CreatedAtColumn) or isinstance(field, UpdatedAtColumn): + raise Exception( + "Created at and Updated at columns are not allowed in reference tables." + ) + elif isinstance(field, ForeignKeyColumn): + columnName = f'"{name}"' if dialect == "postgres" else f"`{name}`" + pk, pk_type = field.table._get_pk_attributes(dialect=dialect) + pk = f'"{pk}"' if dialect == "postgres" else f"`{pk}`" + table_name = ( + f'"{field.table._get_table_name()}"' + if dialect == "postgres" + else f"`{field.table._get_table_name()}`" + ) + user_fields.append( + "{columnName} {pk_type} {nullable}".format( + pk_type=pk_type, + nullable="NOT NULL" if field.required else "", + columnName=columnName, + ) + ) + pks.append(columnName) + fks.append( + "FOREIGN KEY ({columnName}) REFERENCES {table_name}({pk}) ON DELETE {onDelete} ON UPDATE {onUpdate}".format( + pk=pk, + table_name=table_name, + onDelete=field.onDelete, + onUpdate=field.onUpdate, + columnName=columnName, + ) + ) + return pks, user_fields, fks diff --git a/hi.db b/hi.db index 626c074160bb92e1ef10a3bc1d38ec3bdb573308..773be15bbaebc7c67cfe2bcedb45a55b6fd769eb 100644 GIT binary patch delta 523 zcmZoTz}#?vd4iNsP7nhF10N7`0WlK;gN@Hb9V4ckpp6N8SegE4PnKhoW8~dz%f`aU z`Nxl4TwI*7#dH#<;$&aWwUf2E!Y1c&$TR&Zo7~Tl!StsZNNKP%#r(AfGSVjVaH~vS z&v}{YuifOmd`cpQrNya5#T?vM890GfF)%cy{>huXi|ZNNpL{lPcg4vE`6MRq<5uMU zQ_d!CE6&KE`PT-hPi8V355N4MVs>#^S;l7RlEkE()Pkb?w9K5;Vi?Ud*@Wi=)1T7G z2E3}gMlexc{XZo@5j{rc$??1fOn)sVPvjM2WS^|Zmo43>{MQ!jnI=`RiD0LJ36Oj* z(_ed_)eD&Z7)_4nU%r_w;g|d-13P(XLo+K=b1PG0BLgE7T>~RsBQph<(Bw#eHGdcf zi@XsNI|qZfu`#DdYEDi*HU(JBJm)Wt&(2K&Yyz9v1b)dcY7meChbaRCBaj9~Cn$_L kfS4VGHwy~f1!5)!21~PvIz~)sdp0KQVP*PSGg*#Jj*)A#EgK6X z=QnwFadC0R7Sl_56!V=2$Vi*a!>uxT zJ?CYn@BEYZ@+pZJmKLWL6*F^RW#9x_#lX;*`ptgwF0N;6-yGP)-4!Pv~8o@-l^uM_RMf4asCdcy{Fn#BoJdsz7k!i9XU$%6k@^?P4XPQ*OCW4&?CP4DN zOy327Rxe=s#ymNmfB9y%gkSQT4D95k4J@n-Ev<}Aj0}uSbPbGjjm#8aLX#u?)%;-` zEb>N7>>Lc@#>Si;sX00M*c4zf^PImpK07xBunBBt6Zj>+s6k+p0;9lY#s&Z7Wx#>U oz`zKkLGcO-Y-S(^#w`QWW>`cK`qY diff --git a/playground.py b/playground.py index 41af0ce..b3ab271 100644 --- a/playground.py +++ b/playground.py @@ -15,16 +15,7 @@ Having, ) -""" - ALTER TABLE "users" ALTER COLUMN "bio" TYPE VARCHAR(200), - ALTER COLUMN "bio" DROP DEFAULT, ALTER COLUMN "bio" SET DEFAULT 'Hello world', ALTER COLUMN "bio" DROP NOT NULL, DROP CONSTRAINT IF EXISTS "unique_bio"; - - ALTER TABLE "users" ALTER COLUMN "name" TYPE TEXT, ALTER COLUMN "name" DROP DEFAULT, ALTER COLUMN "name" SET DEFAULT 'Bob', ALTER COLUMN "name" DROP NOT NULL, ALTER COLUMN "name" SET NOT NULL, DROP CONSTRAINT IF EXISTS "unique_name"; - ALTER TABLE "users" ADD "p" BIGSERIAL REFERENCES "profiles"("id") ON DELETE SET NULL; - ALTER TABLE "users" ALTER COLUMN "tokenVersion" TYPE INTEGER, ALTER COLUMN "tokenVersion" DROP DEFAULT, ALTER COLUMN "tokenVersion" SET DEFAULT '0', ALTER COLUMN "tokenVersion" DROP NOT NULL, DROP CONSTRAINT IF EXISTS "unique_tokenVersion"; - ALTER TABLE "users" ALTER COLUMN "username" TYPE VARCHAR(255), ALTER COLUMN "username" DROP DEFAULT, ALTER COLUMN "username" DROP NOT NULL, DROP CONSTRAINT IF EXISTS "unique_username", ADD CONSTRAINT "unique_username" UNIQUE ("username"); - ALTER TABLE "users" DROP COLUMN "p"; -""" + from dataloom.decorators import initialize import json, time from typing import Optional @@ -52,49 +43,89 @@ ) -class User(Model): - __tablename__: TableColumn = TableColumn(name="users") +class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + supervisorId = ForeignKeyColumn( + "Employee", maps_to="1-1", type="int", required=False + ) + + +class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") id = PrimaryKeyColumn(type="int", auto_increment=True) name = Column(type="text", nullable=False, default="Bob") - username = Column(type="varchar", unique=True, length=255) - tokenVersion = Column(type="int", default=0) - - -class Post(Model): - __tablename__: TableColumn = TableColumn(name="posts") - id = PrimaryKeyColumn(type="int", auto_increment=True, nullable=False, unique=True) - completed = Column(type="boolean", default=False) - title = Column(type="varchar", length=255, nullable=False) - # timestamps - createdAt = CreatedAtColumn() - # relations - userId = ForeignKeyColumn( - User, - maps_to="1-N", - type="int", - required=True, - onDelete="CASCADE", - onUpdate="CASCADE", + supervisorId = ForeignKeyColumn( + "Employee", maps_to="1-1", type="int", required=False ) -conn, tables = mysql_loom.connect_and_sync([User, Post], drop=True, force=True) +class Course(Model): + __tablename__: TableColumn = TableColumn(name="courses") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + +class Student(Model): + __tablename__: TableColumn = TableColumn(name="students") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + +# the only table that is allowed to have no primary key is the one that maps n-n and must have 2 foreign key columns + + +class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + + +""" +CREATE TABLE students ( + student_id INT PRIMARY KEY, + student_name VARCHAR(100) +); + +CREATE TABLE courses ( + course_id INT PRIMARY KEY, + course_name VARCHAR(100) +); + +CREATE TABLE student_courses ( + student_id INT, + course_id INT, + PRIMARY KEY (student_id, course_id), + FOREIGN KEY (student_id) REFERENCES students(student_id), + FOREIGN KEY (course_id) REFERENCES courses(course_id) +); + +""" -userId = mysql_loom.insert_one( - instance=User, - values=ColumnValue(name="username", value="@miller"), +""" +INSERT INTO employees (id, name, supervisor_id) VALUES +(1, 'John Doe', NULL), -- John Doe doesn't have a supervisor +(2, 'Jane Smith', 1), -- Jane Smith's supervisor is John Doe +(3, 'Michael Johnson', 1); -- Michael Johnson's supervisor is also John Doe +""" + + +conn, tables = sqlite_loom.connect_and_sync( + [Student, Course, StudentCourses], alter=True ) -for title in ["Hey", "Hello", "What are you doing", "Coding"]: - mysql_loom.insert_one( - instance=Post, - values=[ - ColumnValue(name="userId", value=userId), - ColumnValue(name="title", value=title), - ], - ) +# userId = mysql_loom.insert_one( +# instance=User, +# values=ColumnValue(name="username", value="@miller"), +# ) -qb = mysql_loom.getQueryBuilder() -res = qb.run("select id from posts;", fetchall=True) -print(qb) +# for title in ["Hey", "Hello", "What are you doing", "Coding"]: +# mysql_loom.insert_one( +# instance=Post, +# values=[ +# ColumnValue(name="userId", value=userId), +# ColumnValue(name="title", value=title), +# ], +# ) From 0eb5c3f1189d7542ee22deac780f94f8e5b4a25d Mon Sep 17 00:00:00 2001 From: Crispen Gari Date: Tue, 27 Feb 2024 08:34:51 +0200 Subject: [PATCH 2/8] create table in self relations --- README.md | 12 ++++++------ dataloom/exceptions/__init__.py | 8 ++++++++ dataloom/loom/__init__.py | 14 +++++++++++++- dataloom/utils/create_table.py | 25 +++++++++++++++++++----- playground.py | 34 ++------------------------------- 5 files changed, 49 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index c5a56aa..2296a70 100644 --- a/README.md +++ b/README.md @@ -664,12 +664,12 @@ print(tables) The method returns a list of table names that have been created or that exist in your database. The `sync` method accepts the following arguments: -| Argument | Description | Type | Default | -| -------- | --------------------------------------------------------------- | ------ | ------- | -| `models` | A list of your table classes that inherit from the Model class. | `list` | `[]` | -| `drop` | Whether to drop tables during syncing or not. | `bool` | `False` | -| `force` | Forcefully drop tables during syncing or not. | `bool` | `False` | -| `alter` | Alter tables instead of dropping them during syncing or not. | `bool` | `False` | +| Argument | Description | Type | Default | +| -------- | --------------------------------------------------------------------------------------------------------------------------- | ------ | ------- | +| `models` | A list of your table classes that inherit from the Model class. | `list` | `[]` | +| `drop` | Whether to drop tables during syncing or not. | `bool` | `False` | +| `force` | Forcefully drop tables during syncing. In `mysql` this will temporarily disable foreign key checks when droping the tables. | `bool` | `False` | +| `alter` | Alter tables instead of dropping them during syncing or not. | `bool` | `False` | > 🥇 **We recommend you to use `drop` or `force` if you are going to change or modify `foreign` and `primary` keys. This is because setting the option `alter` doe not have an effect on `primary` key columns.** diff --git a/dataloom/exceptions/__init__.py b/dataloom/exceptions/__init__.py index f3bbe84..4d4bf18 100644 --- a/dataloom/exceptions/__init__.py +++ b/dataloom/exceptions/__init__.py @@ -6,6 +6,14 @@ class InvalidArgumentsException(Exception): pass +class InvalidReferenceNameException(Exception): + pass + + +class IllegalColumnException(Exception): + pass + + class InvalidPropertyException(Exception): pass diff --git a/dataloom/loom/__init__.py b/dataloom/loom/__init__.py index 08d5663..a037819 100644 --- a/dataloom/loom/__init__.py +++ b/dataloom/loom/__init__.py @@ -1415,7 +1415,19 @@ def sync( """ try: for model in models: - if drop or force: + if force: + if self.dialect == "mysql": + # temporarily disable fk checks. + self._execute_sql("SET FOREIGN_KEY_CHECKS = 0;", _verbose=0) + self._execute_sql(model._drop_sql(dialect=self.dialect)) + sql = model._create_sql(dialect=self.dialect) + self._execute_sql(sql) + self._execute_sql("SET FOREIGN_KEY_CHECKS = 1;", _verbose=0) + else: + self._execute_sql(model._drop_sql(dialect=self.dialect)) + sql = model._create_sql(dialect=self.dialect) + self._execute_sql(sql) + elif drop or force: self._execute_sql(model._drop_sql(dialect=self.dialect)) sql = model._create_sql(dialect=self.dialect) self._execute_sql(sql) diff --git a/dataloom/utils/create_table.py b/dataloom/utils/create_table.py index 731d52a..9181ce2 100644 --- a/dataloom/utils/create_table.py +++ b/dataloom/utils/create_table.py @@ -8,6 +8,7 @@ ) from dataloom.types import DIALECT_LITERAL import re +from dataloom.exceptions import InvalidReferenceNameException, IllegalColumnException def get_create_table_params( @@ -74,10 +75,24 @@ def get_create_table_params( # 2. what is the type of the parent table pk? # 3. what is the name of the parent table? # 4. What is the relationship type being mapped? - pk, pk_type = field.table._get_pk_attributes(dialect=dialect) - parent_table_name = field.table._get_table_name() - fks.append(f'"{name}"' if dialect == "postgres" else f"`{name}`") + # 5. Is it a self relations + # + in a self relation the field.table is a string that matches field.model.__class__.__name__ + + if isinstance(field.table, str): + # it is a self relation + if field.table.lower() == model.__name__.lower(): + pk, pk_type = model._get_pk_attributes(dialect=dialect) + parent_table_name = model._get_table_name() + else: + raise InvalidReferenceNameException( + f"It seems like you are trying to create self relations on model '{ model.__name__}', however reference model is a string that does not match this model class definition signature." + ) + else: + pk, pk_type = field.table._get_pk_attributes(dialect=dialect) + parent_table_name = field.table._get_table_name() + + fks.append(f'"{name}"' if dialect == "postgres" else f"`{name}`") value = ( "{pk_type} {unique} {nullable} REFERENCES {parent_table_name}({pk}) ON DELETE {onDelete} ON UPDATE {onUpdate}".format( onDelete=field.onDelete, @@ -115,11 +130,11 @@ def get_create_reference_table_params( for name, field in inspect.getmembers(model): if isinstance(field, PrimaryKeyColumn) or isinstance(field, Column): - raise Exception( + raise IllegalColumnException( "Primary keys and columns can not be manually added in a reference table." ) elif isinstance(field, CreatedAtColumn) or isinstance(field, UpdatedAtColumn): - raise Exception( + raise IllegalColumnException( "Created at and Updated at columns are not allowed in reference tables." ) elif isinstance(field, ForeignKeyColumn): diff --git a/playground.py b/playground.py index b3ab271..b265d65 100644 --- a/playground.py +++ b/playground.py @@ -43,15 +43,6 @@ ) -class Employee(Model): - __tablename__: TableColumn = TableColumn(name="employees") - id = PrimaryKeyColumn(type="int", auto_increment=True) - name = Column(type="text", nullable=False, default="Bob") - supervisorId = ForeignKeyColumn( - "Employee", maps_to="1-1", type="int", required=False - ) - - class Employee(Model): __tablename__: TableColumn = TableColumn(name="employees") id = PrimaryKeyColumn(type="int", auto_increment=True) @@ -82,27 +73,6 @@ class StudentCourses(Model): courseId = ForeignKeyColumn(table=Course, type="int") -""" -CREATE TABLE students ( - student_id INT PRIMARY KEY, - student_name VARCHAR(100) -); - -CREATE TABLE courses ( - course_id INT PRIMARY KEY, - course_name VARCHAR(100) -); - -CREATE TABLE student_courses ( - student_id INT, - course_id INT, - PRIMARY KEY (student_id, course_id), - FOREIGN KEY (student_id) REFERENCES students(student_id), - FOREIGN KEY (course_id) REFERENCES courses(course_id) -); - -""" - """ INSERT INTO employees (id, name, supervisor_id) VALUES (1, 'John Doe', NULL), -- John Doe doesn't have a supervisor @@ -111,8 +81,8 @@ class StudentCourses(Model): """ -conn, tables = sqlite_loom.connect_and_sync( - [Student, Course, StudentCourses], alter=True +conn, tables = mysql_loom.connect_and_sync( + [Student, Course, StudentCourses, Employee], force=True ) From a443a3fe6e1de77236c395d867c1d3e898b26434 Mon Sep 17 00:00:00 2001 From: Crispen Gari Date: Tue, 27 Feb 2024 08:36:40 +0200 Subject: [PATCH 3/8] create table in self relations --- dataloom/columns/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dataloom/columns/__init__.py b/dataloom/columns/__init__.py index 1936453..18ec798 100644 --- a/dataloom/columns/__init__.py +++ b/dataloom/columns/__init__.py @@ -11,6 +11,7 @@ ) from dataclasses import dataclass from dataloom.exceptions import UnsupportedTypeException, UnsupportedDialectException +from dataloom.model import Model class CreatedAtColumn: @@ -257,8 +258,8 @@ class ForeignKeyColumn: Parameters ---------- - table : Model - The referenced model to which the foreign key points. + table : Model | str + The referenced model to which the foreign key points. It takes in a model or a string, string only if you are trying to map self relations. maps_to : '1-1' | '1-N' | 'N-1' | 'N-N' The relationship type between the current model and the referenced model. For example, "1-N" for one-to-many. type : str @@ -313,7 +314,7 @@ class ForeignKeyColumn: def __init__( self, - table, + table: Model | str, type: MYSQL_SQL_TYPES_LITERAL | POSTGRES_SQL_TYPES_LITERAL | SQLITE3_SQL_TYPES_LITERAL, From ce0ee28fe404e3b2c391083e0f3ee3520e2bb37b Mon Sep 17 00:00:00 2001 From: Crispen Gari Date: Tue, 27 Feb 2024 09:02:07 +0200 Subject: [PATCH 4/8] relations-tests --- dataloom/columns/__init__.py | 4 +- dataloom/exceptions/__init__.py | 4 +- dataloom/loom/__init__.py | 112 +++++++------- .../tests/mysql/test_create_tables_mysql.py | 138 ++++++++++++++++++ .../tests/postgres/test_create_table_pg.py | 138 ++++++++++++++++++ .../tests/sqlite3/test_create_table_sqlite.py | 126 ++++++++++++++++ hi.db | Bin 57344 -> 65536 bytes playground.py | 6 +- 8 files changed, 461 insertions(+), 67 deletions(-) diff --git a/dataloom/columns/__init__.py b/dataloom/columns/__init__.py index 18ec798..e69cf6c 100644 --- a/dataloom/columns/__init__.py +++ b/dataloom/columns/__init__.py @@ -11,7 +11,7 @@ ) from dataclasses import dataclass from dataloom.exceptions import UnsupportedTypeException, UnsupportedDialectException -from dataloom.model import Model +from typing import Any class CreatedAtColumn: @@ -314,7 +314,7 @@ class ForeignKeyColumn: def __init__( self, - table: Model | str, + table: Any | str, type: MYSQL_SQL_TYPES_LITERAL | POSTGRES_SQL_TYPES_LITERAL | SQLITE3_SQL_TYPES_LITERAL, diff --git a/dataloom/exceptions/__init__.py b/dataloom/exceptions/__init__.py index 4d4bf18..20124c8 100644 --- a/dataloom/exceptions/__init__.py +++ b/dataloom/exceptions/__init__.py @@ -6,11 +6,11 @@ class InvalidArgumentsException(Exception): pass -class InvalidReferenceNameException(Exception): +class InvalidReferenceNameException(ValueError): pass -class IllegalColumnException(Exception): +class IllegalColumnException(ValueError): pass diff --git a/dataloom/loom/__init__.py b/dataloom/loom/__init__.py index a037819..772557e 100644 --- a/dataloom/loom/__init__.py +++ b/dataloom/loom/__init__.py @@ -1348,18 +1348,16 @@ def connect_and_sync( ... conn.close() """ - try: - self.conn = self.connect() - self.sql_obj = SQL( - conn=self.conn, - dialect=self.dialect, - sql_logger=self.sql_logger, - logs_filename=self.logs_filename, - ) - tables = self.sync(models=models, drop=drop, force=force, alter=alter) - return self.conn, tables - except Exception as e: - raise Exception(e) + + self.conn = self.connect() + self.sql_obj = SQL( + conn=self.conn, + dialect=self.dialect, + sql_logger=self.sql_logger, + logs_filename=self.logs_filename, + ) + tables = self.sync(models=models, drop=drop, force=force, alter=alter) + return self.conn, tables def sync( self, models: list[Model], drop=False, force=False, alter=False @@ -1413,61 +1411,57 @@ def sync( ... ) """ - try: - for model in models: - if force: - if self.dialect == "mysql": - # temporarily disable fk checks. - self._execute_sql("SET FOREIGN_KEY_CHECKS = 0;", _verbose=0) - self._execute_sql(model._drop_sql(dialect=self.dialect)) - sql = model._create_sql(dialect=self.dialect) - self._execute_sql(sql) - self._execute_sql("SET FOREIGN_KEY_CHECKS = 1;", _verbose=0) - else: - self._execute_sql(model._drop_sql(dialect=self.dialect)) - sql = model._create_sql(dialect=self.dialect) - self._execute_sql(sql) - elif drop or force: + + for model in models: + if force: + if self.dialect == "mysql": + # temporarily disable fk checks. + self._execute_sql("SET FOREIGN_KEY_CHECKS = 0;", _verbose=0) + self._execute_sql(model._drop_sql(dialect=self.dialect)) + sql = model._create_sql(dialect=self.dialect) + self._execute_sql(sql) + self._execute_sql("SET FOREIGN_KEY_CHECKS = 1;", _verbose=0) + else: self._execute_sql(model._drop_sql(dialect=self.dialect)) sql = model._create_sql(dialect=self.dialect) self._execute_sql(sql) - elif alter: - # 1. we only alter the table if it does exists - # 2. if not we just have to create a new table - if model._get_table_name() in self.tables: - sql1 = model._get_describe_stm( - dialect=self.dialect, fields=["column_name"] - ) - args = None + elif drop or force: + self._execute_sql(model._drop_sql(dialect=self.dialect)) + sql = model._create_sql(dialect=self.dialect) + self._execute_sql(sql) + elif alter: + # 1. we only alter the table if it does exists + # 2. if not we just have to create a new table + if model._get_table_name() in self.tables: + sql1 = model._get_describe_stm( + dialect=self.dialect, fields=["column_name"] + ) + args = None + if self.dialect == "mysql": + args = (self.database, model._get_table_name()) + elif self.dialect == "postgres": + args = ("public", model._get_table_name()) + elif self.dialect == "sqlite": + args = () + cols = self._execute_sql(sql1, _verbose=0, args=args, fetchall=True) + if cols is not None: if self.dialect == "mysql": - args = (self.database, model._get_table_name()) + old_columns = [col for (col,) in cols] elif self.dialect == "postgres": - args = ("public", model._get_table_name()) - elif self.dialect == "sqlite": - args = () - cols = self._execute_sql( - sql1, _verbose=0, args=args, fetchall=True - ) - if cols is not None: - if self.dialect == "mysql": - old_columns = [col for (col,) in cols] - elif self.dialect == "postgres": - old_columns = [col for (col,) in cols] - else: - old_columns = [col[1] for col in cols] - sql = model._alter_sql( - dialect=self.dialect, old_columns=old_columns - ) - self._execute_sql(sql, _is_script=True) - else: - sql = model._create_sql(dialect=self.dialect) - self._execute_sql(sql) + old_columns = [col for (col,) in cols] + else: + old_columns = [col[1] for col in cols] + sql = model._alter_sql( + dialect=self.dialect, old_columns=old_columns + ) + self._execute_sql(sql, _is_script=True) else: sql = model._create_sql(dialect=self.dialect) self._execute_sql(sql) - return self.tables - except Exception as e: - raise Exception(e) + else: + sql = model._create_sql(dialect=self.dialect) + self._execute_sql(sql) + return self.tables def sum( self, diff --git a/dataloom/tests/mysql/test_create_tables_mysql.py b/dataloom/tests/mysql/test_create_tables_mysql.py index 753c77d..1c0da86 100644 --- a/dataloom/tests/mysql/test_create_tables_mysql.py +++ b/dataloom/tests/mysql/test_create_tables_mysql.py @@ -135,3 +135,141 @@ class User(Model): assert len(tables) >= 2 assert "users" in tables and "posts" in tables conn.close() + + def test_create_self_relations_table(self): + from dataloom import ( + Loom, + Model, + TableColumn, + Column, + PrimaryKeyColumn, + ForeignKeyColumn, + ) + from dataloom.exceptions import InvalidReferenceNameException + from dataloom.keys import MySQLConfig + import pytest + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + with pytest.raises(InvalidReferenceNameException) as info: + + class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + supervisorId = ForeignKeyColumn( + "Employees", maps_to="1-1", type="int", required=False + ) + + conn, tables = mysql_loom.connect_and_sync( + [Employee], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "It seems like you are trying to create self relations on model 'Employee', however reference model is a string that does not match this model class definition signature." + ) + + def test_create_n2n_relations_tables(self): + from dataloom import ( + Loom, + Model, + TableColumn, + Column, + PrimaryKeyColumn, + ForeignKeyColumn, + CreatedAtColumn, + ) + from dataloom.exceptions import ( + IllegalColumnException, + PkNotDefinedException, + TooManyFkException, + ) + from dataloom.keys import MySQLConfig + import pytest + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class Course(Model): + __tablename__: TableColumn = TableColumn(name="courses") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class Student(Model): + __tablename__: TableColumn = TableColumn(name="students") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + with pytest.raises(PkNotDefinedException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + + conn, tables = mysql_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Your table 'students_courses' does not have a primary key column and it is not a reference table." + ) + + with pytest.raises(TooManyFkException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + coursed = ForeignKeyColumn(table=Course, type="int") + + conn, tables = mysql_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Reference table 'students_courses' can not have more than 2 foreign keys." + ) + with pytest.raises(IllegalColumnException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + id = PrimaryKeyColumn(type="int") + + conn, tables = mysql_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Primary keys and columns can not be manually added in a reference table." + ) + with pytest.raises(IllegalColumnException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + c = CreatedAtColumn() + + conn, tables = mysql_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Created at and Updated at columns are not allowed in reference tables." + ) diff --git a/dataloom/tests/postgres/test_create_table_pg.py b/dataloom/tests/postgres/test_create_table_pg.py index 1d5133a..024de56 100644 --- a/dataloom/tests/postgres/test_create_table_pg.py +++ b/dataloom/tests/postgres/test_create_table_pg.py @@ -135,3 +135,141 @@ class User(Model): assert len(tables) >= 2 assert "users" in tables and "posts" in tables conn.close() + + def test_create_self_relations_table(self): + from dataloom import ( + Loom, + Model, + TableColumn, + Column, + PrimaryKeyColumn, + ForeignKeyColumn, + ) + from dataloom.exceptions import InvalidReferenceNameException + import pytest + + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + with pytest.raises(InvalidReferenceNameException) as info: + + class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + supervisorId = ForeignKeyColumn( + "Employees", maps_to="1-1", type="int", required=False + ) + + conn, tables = pg_loom.connect_and_sync([Employee], drop=True, force=True) + conn.close() + assert ( + str(info.value) + == "It seems like you are trying to create self relations on model 'Employee', however reference model is a string that does not match this model class definition signature." + ) + + def test_create_n2n_relations_tables(self): + from dataloom import ( + Loom, + Model, + TableColumn, + Column, + PrimaryKeyColumn, + ForeignKeyColumn, + CreatedAtColumn, + ) + from dataloom.exceptions import ( + IllegalColumnException, + PkNotDefinedException, + TooManyFkException, + ) + import pytest + + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class Course(Model): + __tablename__: TableColumn = TableColumn(name="courses") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class Student(Model): + __tablename__: TableColumn = TableColumn(name="students") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + with pytest.raises(PkNotDefinedException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + + conn, tables = pg_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Your table 'students_courses' does not have a primary key column and it is not a reference table." + ) + + with pytest.raises(TooManyFkException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + coursed = ForeignKeyColumn(table=Course, type="int") + + conn, tables = pg_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Reference table 'students_courses' can not have more than 2 foreign keys." + ) + with pytest.raises(IllegalColumnException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + id = PrimaryKeyColumn(type="int") + + conn, tables = pg_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Primary keys and columns can not be manually added in a reference table." + ) + with pytest.raises(IllegalColumnException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + c = CreatedAtColumn() + + conn, tables = pg_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Created at and Updated at columns are not allowed in reference tables." + ) diff --git a/dataloom/tests/sqlite3/test_create_table_sqlite.py b/dataloom/tests/sqlite3/test_create_table_sqlite.py index 4769ac4..7da112d 100644 --- a/dataloom/tests/sqlite3/test_create_table_sqlite.py +++ b/dataloom/tests/sqlite3/test_create_table_sqlite.py @@ -110,3 +110,129 @@ class User(Model): assert len(tables) >= 2 assert "users" in tables and "posts" in tables conn.close() + + def test_create_self_relations_table(self): + from dataloom import ( + Loom, + Model, + TableColumn, + Column, + PrimaryKeyColumn, + ForeignKeyColumn, + ) + from dataloom.exceptions import InvalidReferenceNameException + import pytest + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + with pytest.raises(InvalidReferenceNameException) as info: + + class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + supervisorId = ForeignKeyColumn( + "Employees", maps_to="1-1", type="int", required=False + ) + + conn, tables = sqlite_loom.connect_and_sync( + [Employee], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "It seems like you are trying to create self relations on model 'Employee', however reference model is a string that does not match this model class definition signature." + ) + + def test_create_n2n_relations_tables(self): + from dataloom import ( + Loom, + Model, + TableColumn, + Column, + PrimaryKeyColumn, + ForeignKeyColumn, + CreatedAtColumn, + ) + from dataloom.exceptions import ( + IllegalColumnException, + PkNotDefinedException, + TooManyFkException, + ) + import pytest + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class Course(Model): + __tablename__: TableColumn = TableColumn(name="courses") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class Student(Model): + __tablename__: TableColumn = TableColumn(name="students") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + with pytest.raises(PkNotDefinedException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + + conn, tables = sqlite_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Your table 'students_courses' does not have a primary key column and it is not a reference table." + ) + + with pytest.raises(TooManyFkException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + coursed = ForeignKeyColumn(table=Course, type="int") + + conn, tables = sqlite_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Reference table 'students_courses' can not have more than 2 foreign keys." + ) + with pytest.raises(IllegalColumnException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + id = PrimaryKeyColumn(type="int") + + conn, tables = sqlite_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Primary keys and columns can not be manually added in a reference table." + ) + with pytest.raises(IllegalColumnException) as info: + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + c = CreatedAtColumn() + + conn, tables = sqlite_loom.connect_and_sync( + [Student, Course, StudentCourses], drop=True, force=True + ) + conn.close() + assert ( + str(info.value) + == "Created at and Updated at columns are not allowed in reference tables." + ) diff --git a/hi.db b/hi.db index 773be15bbaebc7c67cfe2bcedb45a55b6fd769eb..e51698b4862b067e2fbb64b232ca33c748fc2ce5 100644 GIT binary patch delta 940 zcmah|&rj1(9DiNcpR|304NDls4)Cx5(sddlAv#9`^2oPD%9$|FCG(~6g_MDwbc!A|lSNSgF zjABFXQpDrb2F5q_(NSo-0mRGw#&Y zOi@x`P{S|9G=nVU43lh=??g^LtM*0R#)H23Ou|nfszpv6r)nC7ir$pfUeze?oYRMK zO1Z$sVJ~!VGXcP?Jev(8}fVy9xg7YEYym?A6TwzhaN;O(f?j2rB??!5{D&tiVre zwV@5jGBiao%vvQA(;1#>kU|TCM&SmRI22 q6@7U;7}y3RcNzchl7FjC34eoP6%4sb3T_cYfF^0cBN#x_g76F5!t@>h delta 749 zcmZo@U}-qOJV8n*Cy0T8fe(nefS8Ga!NzBzj*$jXR4?lSFV|WIZcarOzE@n^xaac- z^Xl^a=2YZ<&L_;($hV6}gGXbtpuiN?q-ME4+U(-;@{DcVC5cHnsinoKMaA)XspW76 zuXB*AV~DFlh@+E_t3m=?JV8MtAt^IIK_SdB$l1d&NWsO`%`wy`L_ysnH76%up*+7R zCq-RHVe$vgD0>}+gp&O1)VwgD>6!U?2@0NmA+GMONSX|QO2LNbCFZ6g3=Q@33=DOh z{FhaWrAhmb-{xKpZAKPOUxw<*2HcV?O)-DUCdaa-ff&_5h6a%F*Lrg-YYZc^=3l$X z@f@1G+*cVmf#Jiz(3tusZ?X@M^W-90M|Y$bN6s>#pz zGA46#^Kv&T{xM<~7Z+!2Qk>k+GIR5Pep`E4GX+C4D^qhTQ)43oBNJT%BV8ke(Bw#e zm3jmhiwYwqb`Az{V`ENm)L>J9X{rGuP>(QJPbFa;EFiaS+Q1^PnI(Z;VY86JD}Hq@ zW@%1`+{~Pu)FN=OGB7X#X;6X%MFQQYMpC?Jfx@N&Hi6A-3cvUPGuz07 diff --git a/playground.py b/playground.py index b265d65..7157c43 100644 --- a/playground.py +++ b/playground.py @@ -48,7 +48,7 @@ class Employee(Model): id = PrimaryKeyColumn(type="int", auto_increment=True) name = Column(type="text", nullable=False, default="Bob") supervisorId = ForeignKeyColumn( - "Employee", maps_to="1-1", type="int", required=False + "Employees", maps_to="1-1", type="int", required=False ) @@ -64,13 +64,11 @@ class Student(Model): name = Column(type="text", nullable=False, default="Bob") -# the only table that is allowed to have no primary key is the one that maps n-n and must have 2 foreign key columns - - class StudentCourses(Model): __tablename__: TableColumn = TableColumn(name="students_courses") studentId = ForeignKeyColumn(table=Student, type="int") courseId = ForeignKeyColumn(table=Course, type="int") + id = PrimaryKeyColumn(type="int") """ From 4cffd005da87a0e2bc4b569f5787451b17ef67f1 Mon Sep 17 00:00:00 2001 From: Crispen Gari Date: Tue, 27 Feb 2024 11:35:59 +0200 Subject: [PATCH 5/8] testing and fixing bugs --- Changelog.md | 67 +++++++++ README.md | 136 +++++++++++++++--- dataloom/columns/__init__.py | 30 ++-- dataloom/loom/__init__.py | 15 +- dataloom/loom/subqueries.py | 47 ++++-- .../tests/mysql/test_eager_loading_mysql.py | 85 +++++++++++ .../tests/postgres/test_eager_loading_pg.py | 85 +++++++++++ .../sqlite3/test_eager_loading_sqlite.py | 79 ++++++++++ dataloom/types/__init__.py | 13 +- dataloom/utils/create_table.py | 6 +- dataloom/utils/tables.py | 6 +- hi.db | Bin 65536 -> 81920 bytes playground.py | 54 ++++--- pyproject.toml | 2 +- 14 files changed, 538 insertions(+), 87 deletions(-) diff --git a/Changelog.md b/Changelog.md index ca21e49..0396d6f 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,70 @@ +=== +Dataloom **`2.4.0`** +=== + +### Release Notes - `dataloom` + +We have release the new `dataloom` Version `2.6.0` (`2024-02-27`) + +##### Features + +- `sync` and `connect_and_sync` function can now take in a collection of `Model` or a single `Model` instance. +- Updated documentation. +- Fixing `ForeignKeyColumn` bugs. +- Adding the `alias` as an argument to `Include` class so that developers can flexibly use their own alias for eager model inclusion rather than letting `dataloom` decide for them. +- Introducing self relations + + - now you can define self relations in `dataloom` + + ```py + class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + supervisorId = ForeignKeyColumn( + "Employee", maps_to="1-1", type="int", required=False + ) + ``` + + - You can also do eager self relations queries + + ```py + emp_and_sup = mysql_loom.find_by_pk( + instance=Employee, + pk=2, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), + ) + print(emp_and_sup) # ? = {'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'supervisor': {'id': 1, 'name': 'John Doe'}} + + ``` + +- Introducing `N-N` relationship + + - with this version of `dataloom` `n-n` relationships are now available. However you will need to define a reference table manual. We recommend you to follow our documentation to get the best out of it. + + ```py + class Course(Model): + __tablename__: TableColumn = TableColumn(name="courses") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class Student(Model): + __tablename__: TableColumn = TableColumn(name="students") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + ``` + === Dataloom **`2.3.0`** === diff --git a/README.md b/README.md index 2296a70..a9748d4 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ - [4. What about bidirectional queries?](#4-what-about-bidirectional-queries) - [1. Child to Parent](#1-child-to-parent) - [2. Parent to Child](#2-parent-to-child) + - [5. `Self` Association](#5-self-association) + - [Inserting](#inserting-3) + - [6. `N-N` Relationship](#6-n-n-relationship) + - [Retrieving Records](#retrieving-records-3) - [Query Builder.](#query-builder) - [Why Use Query Builder?](#why-use-query-builder) - [What is coming next?](#what-is-coming-next) @@ -514,7 +518,7 @@ class Post(Model): This column accepts the following arguments: | Argument | Description | Type | Default | | ---------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ----------- | -| `table` | Required. This is the parent table that the current model references. In our example, this is referred to as `User`. | `Model` | | +| `table` | Required. This is the parent table that the current model references. In our example, this is referred to as `User`. It can be used as a string in self relations. | `Model`\| `str` | | | `type` | Optional. Specifies the data type of the foreign key. If not provided, dataloom can infer it from the parent table. | `str` \| `None` | `None` | | `required` | Optional. Indicates whether the foreign key is required or not. | `bool` | `False` | | `onDelete` | Optional. Specifies the action to be taken when the associated record in the parent table is deleted. | `str` [`"NO ACTION"`, `"SET NULL"`, `"CASCADE"`] | `"NO ACTION"` | @@ -617,15 +621,16 @@ posts = pg_loom.find_all( The `Include` class facilitates eager loading for models with relationships. Below is a table detailing the parameters available for the `Include` class: -| Argument | Description | Type | Default | Required | -| --------- | ----------------------------------------------------------------------- | ----------------------------- | -------- | -------- | -| `model` | The model to be included when eagerly fetching records. | `Model` | - | Yes | -| `order` | The list of order specifications for sorting the included data. | `list[Order]`, optional | `[]` | No | -| `limit` | The maximum number of records to include. | `int \| None`, optional | `0` | No | -| `offset` | The number of records to skip before including. | `int \| None`, optional | `0` | No | -| `select` | The list of columns to include. | `list[str] \| None`, optional | `None` | No | -| `has` | The relationship type between the current model and the included model. | `INCLUDE_LITERAL`, optional | `"many"` | No | -| `include` | The extra included models. | `list[Include]`, optional | `[]` | No | +| Argument | Description | Type | Default | Required | +| --------- | ---------------------------------------------------------------------------------- | ------------------- | -------- | -------- | +| `model` | The model to be included when eagerly fetching records. | `Model` | - | Yes | +| `order` | The list of order specifications for sorting the included data. | `list[Order]` | `[]` | No | +| `limit` | The maximum number of records to include. | `int \| None` | `0` | No | +| `offset` | The number of records to skip before including. | `int \| None` | `0` | No | +| `select` | The list of columns to include. | `list[str] \| None` | `None` | No | +| `has` | The relationship type between the current model and the included model. | `INCLUDE_LITERAL` | `"many"` | No | +| `include` | The extra included models. | `list[Include]` | `[]` | No | +| `alias` | The alias name for the included model. Very important when mapping self relations. | `str` | `None` | No | #### `Group` Class @@ -664,12 +669,12 @@ print(tables) The method returns a list of table names that have been created or that exist in your database. The `sync` method accepts the following arguments: -| Argument | Description | Type | Default | -| -------- | --------------------------------------------------------------------------------------------------------------------------- | ------ | ------- | -| `models` | A list of your table classes that inherit from the Model class. | `list` | `[]` | -| `drop` | Whether to drop tables during syncing or not. | `bool` | `False` | -| `force` | Forcefully drop tables during syncing. In `mysql` this will temporarily disable foreign key checks when droping the tables. | `bool` | `False` | -| `alter` | Alter tables instead of dropping them during syncing or not. | `bool` | `False` | +| Argument | Description | Type | Default | +| -------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------- | +| `models` | A collection or a single instance(s) of your table classes that inherit from the Model class. | `list[Model]\|Any` | `[]` | +| `drop` | Whether to drop tables during syncing or not. | `bool` | `False` | +| `force` | Forcefully drop tables during syncing. In `mysql` this will temporarily disable foreign key checks when droping the tables. | `bool` | `False` | +| `alter` | Alter tables instead of dropping them during syncing or not. | `bool` | `False` | > 🥇 **We recommend you to use `drop` or `force` if you are going to change or modify `foreign` and `primary` keys. This is because setting the option `alter` doe not have an effect on `primary` key columns.** @@ -2114,6 +2119,104 @@ print(user_post) """ ? = ``` +#### 5. `Self` Association + +Let's consider a scenario where we have a table `Employee`, where an employee can have a supervisor, which in this case a supervisor is also an employee. This is an example of self relations. The model definition for this can be done as follows in `dataloom`. + +```py +class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + supervisorId = ForeignKeyColumn( + "Employee", maps_to="1-1", type="int", required=False + ) +``` + +So clearly we can see that when creating a `employee` it is not a must to have a `supervisorId` as this relationship is optional. + +> 👍 **Pro Tip:** Note that when doing self relations the referenced table must be a string that matches the table class name irrespective of case. In our case we used `"Employee"` and also `"employee"` and `"EMPLOYEe"` will be valid, however `"Employees"` and also `"employees"` and `"EMPLOYEEs"` are invalid. + +##### Inserting + +Here is how we can insert employees to this table and we will make `John` the supervisor of other employees. + +```py +empId = mysql_loom.insert_one( + instance=Employee, values=ColumnValue(name="name", value="John Doe") +) + +rows = mysql_loom.insert_bulk( + instance=Employee, + values=[ + [ + ColumnValue(name="name", value="Michael Johnson"), + ColumnValue(name="supervisorId", value=empId), + ], + [ + ColumnValue(name="name", value="Jane Smith"), + ColumnValue(name="supervisorId", value=empId), + ], + ], +) + +``` + +- Some employees is are associated with a supervisor `John` which are `Jane` and `Michael`. +- However the employee `John` does not have a supervisor. + +#### 6. `N-N` Relationship + +##### Retrieving Records + +Now let's query employee `Michael` with his supervisor. + +```py +emp = mysql_loom.find_by_pk( + instance=Employee, pk=2, select=["id", "name", "supervisorId"] +) +sup = mysql_loom.find_by_pk( + instance=Employee, select=["id", "name"], pk=emp["supervisorId"] +) +emp_and_sup = {**emp, "supervisor": sup} +print(emp_and_sup) # ? = {'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'supervisor': {'id': 1, 'name': 'John Doe'}} +``` + +We're querying the database to retrieve information about a `employee` and their associated `supervisor`. + +1. **Querying an Employee**: + + - We use `mysql_loom.find_by_pk()` to fetch a single employee record from the database. + - The employee's ID is specified as `2`. + +2. **Querying Supervisor**: + + - We use `mysql_loom.find_by_pk()` to retrieve a supervisor that is associated with this employee. + - We create a dictionary `emp_and_sup` containing the `employee` information and their `supervisor`. + +With eager loading this can be done in one query as follows the above can be done as follows: + +```py +emp_and_sup = mysql_loom.find_by_pk( + instance=Employee, + pk=2, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), +) + +print(emp_and_sup) # ? = {'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'supervisor': {'id': 1, 'name': 'John Doe'}} +``` + +- We use `mysql_loom.find_by_pk()` to fetch a single an employee record from the database. +- Additionally, we include associated `employee` record using `eager` loading with an `alias` of `supervisor`. + +> 👍 **Pro Tip:** Note that the `alias` is very important in this situation because it allows you to get the included relationships with objects that are named well, if you don't give an alias dataloom will just use the model class name as the alias of your included models, in this case you will get an object that looks like `{'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'employee': {'id': 1, 'name': 'John Doe'}}`, which practically and theoretically doesn't make sense. + ### Query Builder. Dataloom exposes a method called `getQueryBuilder`, which allows you to obtain a `qb` object. This object enables you to execute SQL queries directly from SQL scripts. @@ -2188,7 +2291,6 @@ The `run` method takes the following as arguments: ### What is coming next? 1. N-N associations -2. Self relations ### Contributing diff --git a/dataloom/columns/__init__.py b/dataloom/columns/__init__.py index e69cf6c..e03550a 100644 --- a/dataloom/columns/__init__.py +++ b/dataloom/columns/__init__.py @@ -394,26 +394,18 @@ def __init__( def sql_type(self, dialect: DIALECT_LITERAL): if dialect == "postgres": if self.type in POSTGRES_SQL_TYPES: - return ( - f"{POSTGRES_SQL_TYPES[self.type]}({self.length})" - if self.length - else POSTGRES_SQL_TYPES[self.type] - ) + return POSTGRES_SQL_TYPES[self.type] + else: types = POSTGRES_SQL_TYPES.keys() - raise UnsupportedTypeException( - f"Unsupported column type: {self.type} for dialect '{dialect}' supported types are ({', '.join(types)})" - ) + raise UnsupportedTypeException( + f"Unsupported column type: {self.type} for dialect '{dialect}' supported types are ({', '.join(types)})" + ) elif dialect == "mysql": if self.type in MYSQL_SQL_TYPES: - if (self.unique or self.default) and self.type == "text": - return f"{MYSQL_SQL_TYPES['varchar']}({self.length if self.length is not None else 255})" - return ( - f"{MYSQL_SQL_TYPES[self.type]}({self.length})" - if self.length - else MYSQL_SQL_TYPES[self.type] - ) + return MYSQL_SQL_TYPES[self.type] + else: types = MYSQL_SQL_TYPES.keys() raise UnsupportedTypeException( @@ -421,13 +413,7 @@ def sql_type(self, dialect: DIALECT_LITERAL): ) elif dialect == "sqlite": if self.type in SQLITE3_SQL_TYPES: - if self.length and self.type == "text": - return f"{SQLITE3_SQL_TYPES['varchar']}({self.length})" - return ( - f"{SQLITE3_SQL_TYPES[self.type]}({self.length})" - if self.length - else SQLITE3_SQL_TYPES[self.type] - ) + return SQLITE3_SQL_TYPES[self.type] else: types = SQLITE3_SQL_TYPES.keys() raise UnsupportedTypeException( diff --git a/dataloom/loom/__init__.py b/dataloom/loom/__init__.py index 772557e..b4dd549 100644 --- a/dataloom/loom/__init__.py +++ b/dataloom/loom/__init__.py @@ -28,6 +28,7 @@ from dataloom.loom.interfaces import ILoom from dataloom.loom.math import math from dataloom.loom.qb import qb +from dataloom.utils import is_collection class Loom(ILoom): @@ -1292,7 +1293,7 @@ def connect( return self.conn def connect_and_sync( - self, models: list[Model], drop=False, force=False, alter=False + self, models: list[Model] | Model, drop=False, force=False, alter=False ) -> tuple[ Any | PooledMySQLConnection | MySQLConnectionAbstract | Connection, list[str] ]: @@ -1304,8 +1305,8 @@ def connect_and_sync( Parameters ---------- - models : list[Model] - A list of Python classes that inherit from a Model class with some Column fields defined as the column names of the table. + models : list[Model] | Model + A collection of Python classes or a Python Class that inherit from a Model class with some Column fields defined as the column names of the table. drop : bool, optional Whether or not to drop existing tables when the method is called again. Defaults to False. force : bool, optional @@ -1360,7 +1361,7 @@ def connect_and_sync( return self.conn, tables def sync( - self, models: list[Model], drop=False, force=False, alter=False + self, models: list[Model] | Model, drop=False, force=False, alter=False ) -> list[str]: """ sync @@ -1370,8 +1371,8 @@ def sync( Parameters ---------- - models : list[Model] - A list of Python classes that inherit from a Model class with some Column fields defined as the column names of the table. + models : list[Model] | Model + A collection of Python classes or a Python Class that inherit from a Model class with some Column fields defined as the column names of the table. drop : bool, optional Whether or not to drop existing tables before synchronization. Defaults to False. force : bool, optional @@ -1411,6 +1412,8 @@ def sync( ... ) """ + if not is_collection(models): + models = [models] for model in models: if force: diff --git a/dataloom/loom/subqueries.py b/dataloom/loom/subqueries.py index 795d701..3bf4dbf 100644 --- a/dataloom/loom/subqueries.py +++ b/dataloom/loom/subqueries.py @@ -195,7 +195,13 @@ def get_find_by_pk_relations(self, parent: Model, pk, includes: list[Include] = else: has_one = include.has == "one" table_name = include.model._get_table_name().lower() - key = include.model.__name__.lower() if has_one else table_name + key = ( + include.model.__name__.lower() + if has_one + else table_name + if include.alias is None + else include.alias + ) relations = { **relations, **self.get_one_by_pk( @@ -315,7 +321,23 @@ def get_one_by_pk( has_one = include.has == "one" has_many = include.has == "many" table_name = include.model._get_table_name().lower() - key = include.model.__name__.lower() if has_one else table_name + key = ( + (include.model.__name__.lower() if has_one else table_name) + if include.alias is None + else include.alias + ) + + # let's check for self relations here. + self_rel = parent._get_table_name() == include.model._get_table_name() + if self_rel: + fk = fks[parent._get_table_name()] + sql, _, _ = parent._get_select_by_pk_stm(dialect=self.dialect, select=fk) + res = self._execute_sql( + sql, args=(pk,), fetchone=has_one, _verbose=0, fetchall=has_many + ) + # change the pk value here + pk = None if res is None else res[0] + if len(fks) == 0: here = [fk for fk in parent_fks if include.model._get_table_name() in fk] parent_fks = dict() if len(here) == 0 else here[0] @@ -331,11 +353,12 @@ def get_one_by_pk( select=include.select, parent_pk_name=parent_pk_name, parent_table_name=parent._get_table_name(), - child_foreign_key_name=fk, + child_foreign_key_name=parent_pk_name if self_rel else fk, limit=None if has_one else include.limit, offset=None if has_one else include.offset, order=None if has_one else include.order, ) + if has_one: rows = self._execute_sql(sql, args=(pk,), fetchone=has_one) relations[key] = dict(zip(selected, rows)) if rows is not None else None @@ -363,7 +386,7 @@ def get_one_by_pk( select=include.select, child_pk_name=child_pk_name, child_table_name=parent._get_table_name(), - parent_fk_name=fk, + parent_fk_name=re.sub(r'"|`', "", child_pk_name) if self_rel else fk, limit=None if has_one else include.limit, offset=None if has_one else include.offset, order=None if has_one else include.order, @@ -374,12 +397,16 @@ def get_one_by_pk( relations[key] = dict(zip(selected, rows)) if rows is not None else None elif has_many: # get them by fks - """SELECT FROM POSTS WHERE USERID = ID LIMIT=10, """ - args = [ - arg - for arg in [pk, include.limit, include.offset] - if arg is not None - ] + args = ( + pk + if self_rel + else [ + arg + for arg in [pk, include.limit, include.offset] + if arg is not None + ] + ) + rows = self._execute_sql(sql, args=args, fetchall=True) relations[key] = [dict(zip(selected, row)) for row in rows] diff --git a/dataloom/tests/mysql/test_eager_loading_mysql.py b/dataloom/tests/mysql/test_eager_loading_mysql.py index 667cc64..fde995c 100644 --- a/dataloom/tests/mysql/test_eager_loading_mysql.py +++ b/dataloom/tests/mysql/test_eager_loading_mysql.py @@ -1032,3 +1032,88 @@ class Category(Model): == 'The model "profiles" does not maps to "many" of "users".' ) conn.close() + + def test_self_relations_eager(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + ) + from dataloom.keys import MySQLConfig + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + supervisorId = ForeignKeyColumn( + "Employee", maps_to="1-1", type="int", required=False + ) + + conn, tables = mysql_loom.connect_and_sync([Employee], force=True) + + empId = mysql_loom.insert_one( + instance=Employee, values=ColumnValue(name="name", value="John Doe") + ) + + _ = mysql_loom.insert_bulk( + instance=Employee, + values=[ + [ + ColumnValue(name="name", value="Michael Johnson"), + ColumnValue(name="supervisorId", value=empId), + ], + [ + ColumnValue(name="name", value="Jane Smith"), + ColumnValue(name="supervisorId", value=empId), + ], + ], + ) + + emp_and_sup = mysql_loom.find_by_pk( + instance=Employee, + pk=2, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), + ) + + assert emp_and_sup == { + "id": 2, + "name": "Michael Johnson", + "supervisorId": 1, + "supervisor": {"id": 1, "name": "John Doe"}, + } + emp_and_sup = mysql_loom.find_by_pk( + instance=Employee, + pk=1, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), + ) + assert emp_and_sup == { + "id": 1, + "name": "John Doe", + "supervisorId": None, + "supervisor": None, + } + conn.close() diff --git a/dataloom/tests/postgres/test_eager_loading_pg.py b/dataloom/tests/postgres/test_eager_loading_pg.py index 1233c67..916c136 100644 --- a/dataloom/tests/postgres/test_eager_loading_pg.py +++ b/dataloom/tests/postgres/test_eager_loading_pg.py @@ -1088,3 +1088,88 @@ class Category(Model): conn.close() conn.close() + + def test_self_relations_eager(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + ) + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + supervisorId = ForeignKeyColumn( + "Employee", maps_to="1-1", type="int", required=False + ) + + conn, tables = pg_loom.connect_and_sync([Employee], force=True) + + empId = pg_loom.insert_one( + instance=Employee, values=ColumnValue(name="name", value="John Doe") + ) + + _ = pg_loom.insert_bulk( + instance=Employee, + values=[ + [ + ColumnValue(name="name", value="Michael Johnson"), + ColumnValue(name="supervisorId", value=empId), + ], + [ + ColumnValue(name="name", value="Jane Smith"), + ColumnValue(name="supervisorId", value=empId), + ], + ], + ) + + emp_and_sup = pg_loom.find_by_pk( + instance=Employee, + pk=2, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), + ) + + assert emp_and_sup == { + "id": 2, + "name": "Michael Johnson", + "supervisorId": 1, + "supervisor": {"id": 1, "name": "John Doe"}, + } + emp_and_sup = pg_loom.find_by_pk( + instance=Employee, + pk=1, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), + ) + assert emp_and_sup == { + "id": 1, + "name": "John Doe", + "supervisorId": None, + "supervisor": None, + } + conn.close() diff --git a/dataloom/tests/sqlite3/test_eager_loading_sqlite.py b/dataloom/tests/sqlite3/test_eager_loading_sqlite.py index 839d551..75720dd 100644 --- a/dataloom/tests/sqlite3/test_eager_loading_sqlite.py +++ b/dataloom/tests/sqlite3/test_eager_loading_sqlite.py @@ -1008,3 +1008,82 @@ class Category(Model): conn.close() conn.close() + + def test_self_relations_eager(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + ) + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class Employee(Model): + __tablename__: TableColumn = TableColumn(name="employees") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + supervisorId = ForeignKeyColumn( + "Employee", maps_to="1-1", type="int", required=False + ) + + conn, tables = sqlite_loom.connect_and_sync([Employee], force=True) + + empId = sqlite_loom.insert_one( + instance=Employee, values=ColumnValue(name="name", value="John Doe") + ) + + _ = sqlite_loom.insert_bulk( + instance=Employee, + values=[ + [ + ColumnValue(name="name", value="Michael Johnson"), + ColumnValue(name="supervisorId", value=empId), + ], + [ + ColumnValue(name="name", value="Jane Smith"), + ColumnValue(name="supervisorId", value=empId), + ], + ], + ) + + emp_and_sup = sqlite_loom.find_by_pk( + instance=Employee, + pk=2, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), + ) + + assert emp_and_sup == { + "id": 2, + "name": "Michael Johnson", + "supervisorId": 1, + "supervisor": {"id": 1, "name": "John Doe"}, + } + emp_and_sup = sqlite_loom.find_by_pk( + instance=Employee, + pk=1, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), + ) + assert emp_and_sup == { + "id": 1, + "name": "John Doe", + "supervisorId": None, + "supervisor": None, + } + conn.close() diff --git a/dataloom/types/__init__.py b/dataloom/types/__init__.py index b4b53ca..0a00bd5 100644 --- a/dataloom/types/__init__.py +++ b/dataloom/types/__init__.py @@ -350,6 +350,8 @@ class Include[Model]: The relationship type between the current model and the included model. Default is "many". include : list[Include], optional The extra included models. + alias : str, optional + The alias name for the included model. Very important when mapping self relations. Returns ------- @@ -377,11 +379,12 @@ class Include[Model]: model: Model = field(repr=False) order: list[Order] = field(repr=False, default_factory=list) - limit: Optional[int] = field(default=None) - offset: Optional[int] = field(default=None) - select: Optional[list[str]] = field(default_factory=list) - include: list["Include"] = field(default_factory=list) - has: INCLUDE_LITERAL = field(default="many") + limit: Optional[int] = field(default=None, repr=False) + offset: Optional[int] = field(default=None, repr=False) + select: Optional[list[str]] = field(default_factory=list, repr=False) + include: list["Include"] = field(default_factory=list, repr=False) + has: INCLUDE_LITERAL = field(default="many", repr=False) + alias: Optional[str] = field(default=None, repr=False) POSTGRES_SQL_TYPES = { diff --git a/dataloom/utils/create_table.py b/dataloom/utils/create_table.py index 9181ce2..86fb463 100644 --- a/dataloom/utils/create_table.py +++ b/dataloom/utils/create_table.py @@ -97,7 +97,7 @@ def get_create_table_params( "{pk_type} {unique} {nullable} REFERENCES {parent_table_name}({pk}) ON DELETE {onDelete} ON UPDATE {onUpdate}".format( onDelete=field.onDelete, onUpdate=field.onUpdate, - pk_type=pk_type, + pk_type=field.sql_type(dialect=dialect), parent_table_name=f'"{parent_table_name}"' if dialect == "postgres" else f"`{parent_table_name}`", @@ -107,7 +107,7 @@ def get_create_table_params( ) if field.required else "{pk_type} REFERENCES {parent_table_name}({pk}) ON DELETE SET NULL".format( - pk_type=pk_type, + pk_type=field.sql_type(dialect=dialect), parent_table_name=f'"{parent_table_name}"' if dialect == "postgres" else f"`{parent_table_name}`", @@ -148,7 +148,7 @@ def get_create_reference_table_params( ) user_fields.append( "{columnName} {pk_type} {nullable}".format( - pk_type=pk_type, + pk_type=field.sql_type(dialect=dialect), nullable="NOT NULL" if field.required else "", columnName=columnName, ) diff --git a/dataloom/utils/tables.py b/dataloom/utils/tables.py index b02930b..c006dc4 100644 --- a/dataloom/utils/tables.py +++ b/dataloom/utils/tables.py @@ -224,7 +224,11 @@ def get_table_fields(model, dialect: DIALECT_LITERAL): fields.append(name) elif isinstance(field, ForeignKeyColumn): fields.append(name) - table_name = field.table._get_table_name() + table_name = ( + model._get_table_name() + if isinstance(field.table, str) + else field.table._get_table_name() + ) fks.append({table_name: name, "mapped_to": field.maps_to}) elif isinstance(field, PrimaryKeyColumn): fields.append(name) diff --git a/hi.db b/hi.db index e51698b4862b067e2fbb64b232ca33c748fc2ce5..91203e54f4d229fa28aee6859eb486285a89c21c 100644 GIT binary patch delta 1302 zcmaJ>O>7%Q6rT0|c-QuN?6~cw4#8tmDREmjj+0WgDp1$iHmQu$I!>iROY9^ovWb5p zuiLaBq)Y+`uTVoYq7rZel_P&!iBoB>Ahm$FA%UPOAPR>H0RpKJ%x>cCEr&Pz=KXx{ zdvA7TX^C4};@^mNEPew3*hVEx#RI_6&)4>*hp2Vm+*dODCkT6pH+U~FC4TGwQhMF@ zlhh{^t#Pjsi!rbIo8_sB zcHYn$#$-)fbQBtBE?VdKvkWhUnXV@JOz0--LiKR7KCjhI(@?!$E1}VRQ9Z5}(6D+e zGm$Hz-c;{iRI(9j<*HUfr!$4@NTz@$@}s9FRI<*W^GhqEg#Vew3d(MF|?`7qrF&-KaN4XC5|DP>-nvHJ??-A7&V(zLGvuN}#bkZ8E1) z%yG4d@)NmSLcGGHC`H|-7+RW>O;Dat7`wX*)aed@ z#?08kL8=^ZgAE(q9)Q@7&6J>Iy9t&%$&+FvD9Iq>#wG34?bNh6J3798+|u2Ip+KZZ zngm_#gx0fd7Og7P2Cyd)>Nz&}hMbwmTpJT{wN zMNya)o%|xaLr-@~NMH?{eIysilHcVtk>mv)2XSkTT$e8MIE-7f*BT7@Gp zzy@SCCb<_xl30atxNOl9P_1Ef-Bk%cd zhAjWhf1$;p2XL2j;{agj*M4s#Dk2px6+adF_5d#($k%YEkL{9AG3)22;YL`swPHBoAcU?7Y7LFx}pKnPYttwN(sMcif5 zmu-Ku5L{H+{;GyI<+j=15ZhAib9F%d@4{ zmZyjm&M+sJ)Qg~^6RXZ z6@#^e!pX=)0!^xQEZ9JejY-s0jy2#x z<|O%s(#dBPkEtDo6HT?h;pM4&3D)-g?YGRA(J8`A=6Lx__nc4r0TPmx@Ix>;6=lvr z$r=qpAj)<%I~2uIwPFRsgN5b<52;a29|h4PavyW9G=zkmXU=K<+!)}t`4EdVk4PIN zW*i*w^NN-rEW@1R;`R=QnbXb})G%SK%xU8j`XZtGz+KGK^bUV-Uh+7w*0UI%ll^CS zJ07&)fQjiEq-FYvZqPNlBL3($jZrt!b=0laW|*GJTvHWf!OPj)Ob(kE1>w1~Gnt9( z^kgQ76&YE7gxD>WKJPzCeZwR6&`2~Eu_L>~b|f4b4%l^wr^a?|Mc*9rZAF*Iwuk<2 zu8Dufv;yca`khv3g}xQ9c|mVf2O)M5QmUkb0jR75;48X#R5oPfffqK9@$9&(^lg4y e92%w8g|C7nOY3f^HG`^~AmQy2z!!=UQq|v+7Sp`| diff --git a/playground.py b/playground.py index 7157c43..9a09aeb 100644 --- a/playground.py +++ b/playground.py @@ -48,7 +48,7 @@ class Employee(Model): id = PrimaryKeyColumn(type="int", auto_increment=True) name = Column(type="text", nullable=False, default="Bob") supervisorId = ForeignKeyColumn( - "Employees", maps_to="1-1", type="int", required=False + "Employee", maps_to="1-1", type="int", required=False ) @@ -68,32 +68,42 @@ class StudentCourses(Model): __tablename__: TableColumn = TableColumn(name="students_courses") studentId = ForeignKeyColumn(table=Student, type="int") courseId = ForeignKeyColumn(table=Course, type="int") - id = PrimaryKeyColumn(type="int") -""" -INSERT INTO employees (id, name, supervisor_id) VALUES -(1, 'John Doe', NULL), -- John Doe doesn't have a supervisor -(2, 'Jane Smith', 1), -- Jane Smith's supervisor is John Doe -(3, 'Michael Johnson', 1); -- Michael Johnson's supervisor is also John Doe -""" +conn, tables = pg_loom.connect_and_sync( + [Student, Course, StudentCourses, Employee], force=True +) -conn, tables = mysql_loom.connect_and_sync( - [Student, Course, StudentCourses, Employee], force=True +empId = pg_loom.insert_one( + instance=Employee, values=ColumnValue(name="name", value="John Doe") +) + +rows = pg_loom.insert_bulk( + instance=Employee, + values=[ + [ + ColumnValue(name="name", value="Michael Johnson"), + ColumnValue(name="supervisorId", value=empId), + ], + [ + ColumnValue(name="name", value="Jane Smith"), + ColumnValue(name="supervisorId", value=empId), + ], + ], ) -# userId = mysql_loom.insert_one( -# instance=User, -# values=ColumnValue(name="username", value="@miller"), -# ) +emp_and_sup = pg_loom.find_by_pk( + instance=Employee, + pk=1, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), +) -# for title in ["Hey", "Hello", "What are you doing", "Coding"]: -# mysql_loom.insert_one( -# instance=Post, -# values=[ -# ColumnValue(name="userId", value=userId), -# ColumnValue(name="title", value=title), -# ], -# ) +print(emp_and_sup) diff --git a/pyproject.toml b/pyproject.toml index 549cdf4..2d3ca6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dataloom" -version = "2.3.0" +version = "2.4.0" authors = [ {name = "Crispen Gari", email = "crispengari@gmail.com"}, ] From 9818001ec7f00b317454eb46d8426a85b2f6c4d0 Mon Sep 17 00:00:00 2001 From: Crispen Gari Date: Tue, 27 Feb 2024 12:05:33 +0200 Subject: [PATCH 6/8] updates and keys --- dataloom/keys.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dataloom/keys.py b/dataloom/keys.py index 4410e1f..59e757b 100644 --- a/dataloom/keys.py +++ b/dataloom/keys.py @@ -1,7 +1,7 @@ # Configuration file for unit testing. -push = False +push = True class PgConfig: From e4fd7a0dd791e303a250c1de09d70ddba6fe5313 Mon Sep 17 00:00:00 2001 From: Crispen Gari Date: Tue, 27 Feb 2024 16:52:31 +0200 Subject: [PATCH 7/8] updates and keys --- README.md | 172 +++++++++++++++++++++++++++++- dataloom/keys.py | 2 +- dataloom/loom/subqueries.py | 72 ++++++++++--- dataloom/model/__init__.py | 2 + dataloom/statements/__init__.py | 16 ++- dataloom/statements/statements.py | 6 +- dataloom/types/__init__.py | 3 + hi.db | Bin 81920 -> 90112 bytes playground.py | 119 +++++++++++++++------ 9 files changed, 335 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index a9748d4..f8f2664 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,10 @@ - [2. Parent to Child](#2-parent-to-child) - [5. `Self` Association](#5-self-association) - [Inserting](#inserting-3) - - [6. `N-N` Relationship](#6-n-n-relationship) - [Retrieving Records](#retrieving-records-3) + - [6. `N-N` Relationship](#6-n-n-relationship) + - [Inserting](#inserting-4) + - [Retrieving Records](#retrieving-records-4) - [Query Builder.](#query-builder) - [Why Use Query Builder?](#why-use-query-builder) - [What is coming next?](#what-is-coming-next) @@ -2165,8 +2167,176 @@ rows = mysql_loom.insert_bulk( - Some employees is are associated with a supervisor `John` which are `Jane` and `Michael`. - However the employee `John` does not have a supervisor. +##### Retrieving Records + +Now let's query employee `Michael` with his supervisor. + +```py +emp = mysql_loom.find_by_pk( + instance=Employee, pk=2, select=["id", "name", "supervisorId"] +) +sup = mysql_loom.find_by_pk( + instance=Employee, select=["id", "name"], pk=emp["supervisorId"] +) +emp_and_sup = {**emp, "supervisor": sup} +print(emp_and_sup) # ? = {'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'supervisor': {'id': 1, 'name': 'John Doe'}} +``` + +We're querying the database to retrieve information about a `employee` and their associated `supervisor`. + +1. **Querying an Employee**: + + - We use `mysql_loom.find_by_pk()` to fetch a single employee record from the database. + - The employee's ID is specified as `2`. + +2. **Querying Supervisor**: + + - We use `mysql_loom.find_by_pk()` to retrieve a supervisor that is associated with this employee. + - We create a dictionary `emp_and_sup` containing the `employee` information and their `supervisor`. + +With eager loading this can be done in one query as follows the above can be done as follows: + +```py +emp_and_sup = mysql_loom.find_by_pk( + instance=Employee, + pk=2, + select=["id", "name", "supervisorId"], + include=Include( + model=Employee, + has="one", + select=["id", "name"], + alias="supervisor", + ), +) + +print(emp_and_sup) # ? = {'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'supervisor': {'id': 1, 'name': 'John Doe'}} +``` + +- We use `mysql_loom.find_by_pk()` to fetch a single an employee record from the database. +- Additionally, we include associated `employee` record using `eager` loading with an `alias` of `supervisor`. + +> 👍 **Pro Tip:** Note that the `alias` is very important in this situation because it allows you to get the included relationships with objects that are named well, if you don't give an alias dataloom will just use the model class name as the alias of your included models, in this case you will get an object that looks like `{'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'employee': {'id': 1, 'name': 'John Doe'}}`, which practically and theoretically doesn't make sense. + #### 6. `N-N` Relationship +Let's consider a scenario where we have tables for `Students` and `Courses`. In this scenario, a student can enroll in many courses, and a single course can have many students enrolled. This represents a `Many-to-Many` relationship. The model definitions for this scenario can be done as follows in `dataloom`: + +**Table: Student** +| Column Name | Data Type | +|-------------|-----------| +| `id` | `INT` | +| `name`| `VARCHAR` | + +**Table: Course** +| Column Name | Data Type | +|-------------|-----------| +| `id` | `INT` | +| `name` | `VARCHAR` | +| ... | ... | + +**Table: Student_Courses** (junction table) +| Column Name | Data Type | +|-------------|-----------| +| `studentId` | `INT` | +| `courseId` | `INT` | + +> 👍 **Pro Tip:** Note that the `junction` table can also be called `association`-table or `reference`-table or `joint`-table. + +In `dataloom` we can model the above relations as follows: + +```py +class Course(Model): + __tablename__: TableColumn = TableColumn(name="courses") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + +class Student(Model): + __tablename__: TableColumn = TableColumn(name="students") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + +class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + +``` + +- The tables `students` and `courses` will not have foreign keys. +- The `students_courses` table will have two columns that joins these two tables together in an `N-N` relational mapping. + +> 👍 **Pro Tip:** In a joint table no other columns such as `CreateAtColumn`, `UpdatedAtColumn`, `Column` and `PrimaryKeyColumn` are allowed and only exactly `2` foreign keys should be in this table. + +##### Inserting + +Here is how we can insert `students` and `courses` in their respective tables. + +```py + +# insert the courses +mathId = mysql_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Mathematics") +) +engId = mysql_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="English") +) +phyId = mysql_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Physics") +) + +# create students + +stud1 = mysql_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Alice") +) +stud2 = mysql_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Bob") +) +stud3 = mysql_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Lisa") +) + +``` + +- You will notice that we are keeping in track of the `studentIds` and the `courseIds` because we will need them in the `joint-table` or `association-table`. +- Now we can enrol students to their courses by inserting them in their id's in the association table. + +```py +# enrolling students +mysql_loom.insert_bulk( + instance=StudentCourses, + values=[ + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=mathId), + ], # enrolling Alice to mathematics + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Alice to physics + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=engId), + ], # enrolling Alice to english + [ + ColumnValue(name="studentId", value=stud2), + ColumnValue(name="courseId", value=engId), + ], # enrolling Bob to english + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Lisa to physics + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=engId), + ], # enrolling Lisa to english + ], +) + +``` + ##### Retrieving Records Now let's query employee `Michael` with his supervisor. diff --git a/dataloom/keys.py b/dataloom/keys.py index 59e757b..4410e1f 100644 --- a/dataloom/keys.py +++ b/dataloom/keys.py @@ -1,7 +1,7 @@ # Configuration file for unit testing. -push = True +push = False class PgConfig: diff --git a/dataloom/loom/subqueries.py b/dataloom/loom/subqueries.py index 3bf4dbf..25ec5d9 100644 --- a/dataloom/loom/subqueries.py +++ b/dataloom/loom/subqueries.py @@ -311,11 +311,24 @@ def get_find_by_pk_relations(self, parent: Model, pk, includes: list[Include] = def get_one_by_pk( self, parent: Model, include: Include, pk: Any, foreign_keys: list[dict] ): + # let's check for self relations here. + self_rel = parent._get_table_name() == include.model._get_table_name() + # let's check if the table has a juction table + many2many = include.junction_table is not None _, parent_pk_name, parent_fks, _ = get_table_fields( - parent, dialect=self.dialect + parent if not many2many else include.junction_table, dialect=self.dialect ) - here = [fk for fk in foreign_keys if parent._get_table_name() in fk] - fks = here[0] if len(here) == 1 else dict() + if not many2many: + here = [fk for fk in foreign_keys if parent._get_table_name() in fk] + fks = here[0] if len(here) == 1 else dict() + else: + c = include.model._get_table_name() + for item in parent_fks: + if c in item: + fks = item + else: + child_fks = item + relations = dict() has_one = include.has == "one" @@ -327,8 +340,6 @@ def get_one_by_pk( else include.alias ) - # let's check for self relations here. - self_rel = parent._get_table_name() == include.model._get_table_name() if self_rel: fk = fks[parent._get_table_name()] sql, _, _ = parent._get_select_by_pk_stm(dialect=self.dialect, select=fk) @@ -379,18 +390,45 @@ def get_one_by_pk( else: # this table is a parent table. then the child is now the parent parent_table_name = parent._get_table_name() - fk = fks[parent_table_name] - child_pk_name = parent_pk_name - sql, selected = include.model._get_select_parent_by_pk_stm( - dialect=self.dialect, - select=include.select, - child_pk_name=child_pk_name, - child_table_name=parent._get_table_name(), - parent_fk_name=re.sub(r'"|`', "", child_pk_name) if self_rel else fk, - limit=None if has_one else include.limit, - offset=None if has_one else include.offset, - order=None if has_one else include.order, - ) + if many2many: + child_table_name = include.model._get_table_name() + parent_pk_name = child_fks[parent_table_name] + + fk = fks[child_table_name] + _, child_pk_name, parent_fks, _ = get_table_fields( + include.model, + dialect=self.dialect, + ) + sql, selected = include.model._get_select_parent_by_pk_stm( + dialect=self.dialect, + select=include.select, + child_pk_name=f'"{fk}"' + if self.dialect == "postgres" + else f"`{fk}`", + parent_pk_name=f'"{parent_pk_name}"' + if self.dialect == "postgres" + else f"`{parent_pk_name}`", + child_table_name=include.junction_table._get_table_name(), + parent_fk_name=re.sub(r'"|`', "", child_pk_name), + limit=None if has_one else include.limit, + offset=None if has_one else include.offset, + order=None if has_one else include.order, + ) + else: + fk = fks[parent_table_name] + child_pk_name = parent_pk_name + sql, selected = include.model._get_select_parent_by_pk_stm( + dialect=self.dialect, + select=include.select, + child_pk_name=child_pk_name, + child_table_name=parent._get_table_name(), + parent_fk_name=re.sub(r'"|`', "", child_pk_name) + if self_rel + else fk, + limit=None if has_one else include.limit, + offset=None if has_one else include.offset, + order=None if has_one else include.order, + ) if has_one: rows = self._execute_sql(sql, args=(pk,), fetchone=has_one) diff --git a/dataloom/model/__init__.py b/dataloom/model/__init__.py index 8009e7f..78a53bd 100644 --- a/dataloom/model/__init__.py +++ b/dataloom/model/__init__.py @@ -673,6 +673,7 @@ def _get_select_parent_by_pk_stm( child_pk_name: str, child_table_name: str, parent_fk_name: str, + parent_pk_name: Optional[str] = None, select: Optional[list[str] | str] = [], limit: Optional[int] = None, offset: Optional[int] = None, @@ -714,6 +715,7 @@ def _get_select_parent_by_pk_stm( limit=limit, offset=offset, orders=orders, + parent_pk_name=parent_pk_name, ) else: raise UnsupportedDialectException( diff --git a/dataloom/statements/__init__.py b/dataloom/statements/__init__.py index 8cf5c86..d9cd889 100644 --- a/dataloom/statements/__init__.py +++ b/dataloom/statements/__init__.py @@ -176,7 +176,7 @@ def _get_create_table_command(self) -> str: fields = [*user_fields, *predefined_fields] fields_name = ", ".join(f for f in [" ".join(field) for field in fields]) if self.dialect == "postgres": - if len(fks) == 2: + if len(fks) == 2 and len(pks) == 0: pks, user_fields, fks = get_create_reference_table_params( dialect=self.dialect, model=self.model, @@ -205,7 +205,7 @@ def _get_create_table_command(self) -> str: ) elif self.dialect == "mysql": - if len(fks) == 2: + if len(fks) == 2 and len(pks) == 0: pks, user_fields, fks = get_create_reference_table_params( dialect=self.dialect, model=self.model, @@ -234,7 +234,7 @@ def _get_create_table_command(self) -> str: ) elif self.dialect == "sqlite": - if len(fks) == 2: + if len(fks) == 2 and len(pks) == 0: pks, user_fields, fks = get_create_reference_table_params( dialect=self.dialect, model=self.model, @@ -875,6 +875,7 @@ def _get_select_parent_by_pk_stm( child_pk_name: str, child_table_name: str, parent_fk_name: str, + parent_pk_name: Optional[str] = None, fields: list = [], limit: Optional[int] = None, offset: Optional[int] = None, @@ -887,6 +888,9 @@ def _get_select_parent_by_pk_stm( parent_fk_name=f'"{parent_fk_name}"', child_table_name=f'"{child_table_name}"', child_pk_name=child_pk_name, + parent_pk_name=parent_pk_name + if parent_pk_name is not None + else child_pk_name, child_pk="%s", limit="" if limit is None else "LIMIT %s", offset="" if offset is None else "OFFSET %s", @@ -899,6 +903,9 @@ def _get_select_parent_by_pk_stm( parent_fk_name=f"`{parent_fk_name}`", child_table_name=f"`{child_table_name}`", child_pk_name=child_pk_name, + parent_pk_name=parent_pk_name + if parent_pk_name is not None + else child_pk_name, child_pk="%s", limit="" if limit is None else "LIMIT %s", offset="" if offset is None else "OFFSET %s", @@ -911,6 +918,9 @@ def _get_select_parent_by_pk_stm( parent_fk_name=f"`{parent_fk_name}`", child_table_name=f"`{child_table_name}`", child_pk_name=child_pk_name, + parent_pk_name=parent_pk_name + if parent_pk_name is not None + else child_pk_name, child_pk="?", limit="" if limit is None else "LIMIT ?", offset="" if offset is None else "OFFSET ?", diff --git a/dataloom/statements/statements.py b/dataloom/statements/statements.py index 7270947..c159e4e 100644 --- a/dataloom/statements/statements.py +++ b/dataloom/statements/statements.py @@ -135,7 +135,7 @@ class MySqlStatements: SELECT_PARENT_BY_PK = """ SELECT {parent_column_names} FROM {parent_table_name} WHERE {parent_fk_name} IN ( SELECT {child_pk_name} FROM ( - SELECT {child_pk_name} FROM {child_table_name} WHERE {child_pk_name} = {child_pk} + SELECT {child_pk_name} FROM {child_table_name} WHERE {parent_pk_name} = {child_pk} ) AS subquery ) {orders} {limit} {offset}; """ @@ -271,7 +271,7 @@ class Sqlite3Statements: """ SELECT_PARENT_BY_PK = """ SELECT {parent_column_names} FROM {parent_table_name} WHERE {parent_fk_name} IN ( - SELECT {child_pk_name} FROM {child_table_name} WHERE {child_pk_name} = {child_pk} + SELECT {child_pk_name} FROM {child_table_name} WHERE {parent_pk_name} = {child_pk} ) {orders} {limit} {offset}; """ GET_PK_COMMAND = "SELECT {pk_name} FROM {table_name} {filters} {options};".strip() @@ -426,7 +426,7 @@ class PgStatements: """ SELECT_PARENT_BY_PK = """ SELECT {parent_column_names} FROM {parent_table_name} WHERE {parent_fk_name} IN ( - SELECT {child_pk_name} FROM {child_table_name} WHERE {child_pk_name} = {child_pk} + SELECT {child_pk_name} FROM {child_table_name} WHERE {parent_pk_name} = {child_pk} ) {orders} {limit} {offset}; """ diff --git a/dataloom/types/__init__.py b/dataloom/types/__init__.py index 0a00bd5..c60ff98 100644 --- a/dataloom/types/__init__.py +++ b/dataloom/types/__init__.py @@ -338,6 +338,8 @@ class Include[Model]: ---------- model : Model The model to be included when eger fetching records. + junction_table: Model|None, optional + A junction table is a reference table for models that maps N-N relationship. order : list[Order], optional The list of order specifications for sorting the included data. Default is an empty list. limit : int | None, optional @@ -378,6 +380,7 @@ class Include[Model]: """ model: Model = field(repr=False) + junction_table: Optional[Model] = field(repr=False, default=None) order: list[Order] = field(repr=False, default_factory=list) limit: Optional[int] = field(default=None, repr=False) offset: Optional[int] = field(default=None, repr=False) diff --git a/hi.db b/hi.db index 91203e54f4d229fa28aee6859eb486285a89c21c..418fd18048fe531fbce33c768f33472802f72507 100644 GIT binary patch delta 1033 zcmaKrOH30%7{_PZeRlhz6jrIgf)A)tXuA*r1zbxBV5I~Kgap$Sx-m7C#uh{qRHhIP z9^94DgBJxg9y}N$Q4F)TLU2`JIQ>%?=k!PbTF=GCVF%L%RiyM;rI`W5^>nD0ct_0R}+L-ly3I<q)%ntO#WG9LQs9Z!fj8Mrgf)^y$!eJmf9+A%{&&Hw?ayWg>x=q*I zNx%qH_2|8+7P-w&aZNRmQJpf`7+V`u+DNY7l|G<4IWZZL$0w5EvG`;nDJR38t)#$o z9XQ=6Y;l;O43z}MRaC$cs9ZurWEo~h=g1WzotQ>H4PTAg%e`sZ5OZb$f7$I!bwQ<- z;#@9l&Y$th*7;jw>t^TV)94TDBR5o{C4J?QtYE2Hibfk0CrFVv+0 z?HlNAPunohYIrF-F+GIb*rO%ZUYal2t_}S#_dm9_wmCfY&4SR}Ab8#0dY9Yl@*d=K z2n`4K1#`hRd9RM50VfDd#_8J?Y%}v+ko(`6cX}5W7V*3ID!+`cQ&z0_h7ogM2JJU} LYkBs|g0b$u#TqPn delta 867 zcmaJ;O-NKx7`=Dq{oQ%<=FPK#CyqD{R`{$pe@_#okb**jaLASYAesZsG>z$4S`m2F zqW9=eKGGxzD_TZ{kt->rjV&x_*IGA0yMi`R*O_^P3u$pL-#z!7?|kRp$q)SGB>$kU zX8bEcD8!b=mIt9DYqR@PtqhIjz9`ubGeuqE%p~OWo&EBbC+)C(WT3mF7w< zJ4{qL1wlWg<1L>n+u1`dJHXiORd$iV1{bGJ0%GzW2#US{QPAP)z3Iv{23I1m6me>2 znV=qS$OKM^z6j|@VOM0Ic1N%;vdW^40cm1!WCfMm<$!VnW4-%6QeL&r!kNu7Oyel!A9sD*YC5_YKvk<|ppd@-I5Q76u-4R6{=!%MZd zh2{OI>&Byza;$7&h??}~fwEl(jndmq6>C`YE-d|3ySl=Oj!vtsGuf6(q*~2H%1pI4 zx)AjB?Co(6JKU!{qJ(tIBHfvMet0Ls4n5a<^#6H2OE10)VmXLI^ZrI0UJ3=*)#YDt HEGPZ}P_y6W diff --git a/playground.py b/playground.py index 9a09aeb..aa83b23 100644 --- a/playground.py +++ b/playground.py @@ -43,15 +43,6 @@ ) -class Employee(Model): - __tablename__: TableColumn = TableColumn(name="employees") - id = PrimaryKeyColumn(type="int", auto_increment=True) - name = Column(type="text", nullable=False, default="Bob") - supervisorId = ForeignKeyColumn( - "Employee", maps_to="1-1", type="int", required=False - ) - - class Course(Model): __tablename__: TableColumn = TableColumn(name="courses") id = PrimaryKeyColumn(type="int", auto_increment=True) @@ -70,40 +61,104 @@ class StudentCourses(Model): courseId = ForeignKeyColumn(table=Course, type="int") -conn, tables = pg_loom.connect_and_sync( - [Student, Course, StudentCourses, Employee], force=True +conn, tables = mysql_loom.connect_and_sync( + [Student, Course, StudentCourses], force=True ) +# insert the courses +mathId = mysql_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Mathematics") +) +engId = mysql_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="English") +) +phyId = mysql_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Physics") +) + +# create students -empId = pg_loom.insert_one( - instance=Employee, values=ColumnValue(name="name", value="John Doe") +stud1 = mysql_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Alice") +) +stud2 = mysql_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Bob") +) +stud3 = mysql_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Lisa") ) -rows = pg_loom.insert_bulk( - instance=Employee, +# enrolling students +mysql_loom.insert_bulk( + instance=StudentCourses, values=[ [ - ColumnValue(name="name", value="Michael Johnson"), - ColumnValue(name="supervisorId", value=empId), - ], + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=mathId), + ], # enrolling Alice to mathematics + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Alice to physics [ - ColumnValue(name="name", value="Jane Smith"), - ColumnValue(name="supervisorId", value=empId), - ], + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=engId), + ], # enrolling Alice to english + [ + ColumnValue(name="studentId", value=stud2), + ColumnValue(name="courseId", value=engId), + ], # enrolling Bob to english + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Lisa to physics + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=engId), + ], # enrolling Lisa to english ], ) - -emp_and_sup = pg_loom.find_by_pk( - instance=Employee, - pk=1, - select=["id", "name", "supervisorId"], - include=Include( - model=Employee, - has="one", - select=["id", "name"], - alias="supervisor", +s = mysql_loom.find_by_pk( + Student, + pk=stud1, + select=["id", "name"], +) +c = mysql_loom.find_many( + StudentCourses, + filters=Filter(column="studentId", value=stud1), + select=["courseId"], +) +courses = mysql_loom.find_many( + Course, + filters=Filter( + column="courseId", operator="in", value=[list(i.values())[0] for i in c] ), + select=["id", "name"], ) -print(emp_and_sup) +alice = {**s, "courses": courses} +print(courses) + +# alice = mysql_loom.find_by_pk( +# Student, +# pk=stud1, +# select=["id", "name"], +# include=Include( +# model=Course, junction_table=StudentCourses, alias="courses", has="many" +# ), +# ) + +# print(alice) + + +# english = mysql_loom.find_by_pk( +# Course, +# pk=engId, +# select=["id", "name"], +# include=Include( +# model=Student, junction_table=StudentCourses, alias="students", has="many" +# ), +# ) + +# print(english) From c8f673e7e8334e3fe8a1a985d0b4bd3b8d7d7a1e Mon Sep 17 00:00:00 2001 From: Crispen Gari Date: Tue, 27 Feb 2024 17:40:49 +0200 Subject: [PATCH 8/8] test and fixing bugs --- Changelog.md | 14 +- README.md | 103 ++++++----- dataloom/keys.py | 2 +- dataloom/statements/__init__.py | 28 ++- .../tests/mysql/test_eager_loading_mysql.py | 128 ++++++++++++++ .../tests/postgres/test_eager_loading_pg.py | 128 ++++++++++++++ .../sqlite3/test_eager_loading_sqlite.py | 122 +++++++++++++ dataloom/types/__init__.py | 2 +- hi.db | Bin 90112 -> 90112 bytes playground.py | 165 +----------------- pyproject.toml | 3 +- todo.txt | 11 +- 12 files changed, 472 insertions(+), 234 deletions(-) diff --git a/Changelog.md b/Changelog.md index 0396d6f..e1c2b74 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,7 +4,7 @@ Dataloom **`2.4.0`** ### Release Notes - `dataloom` -We have release the new `dataloom` Version `2.6.0` (`2024-02-27`) +We have release the new `dataloom` Version `2.4.0` (`2024-02-27`) ##### Features @@ -12,6 +12,7 @@ We have release the new `dataloom` Version `2.6.0` (`2024-02-27`) - Updated documentation. - Fixing `ForeignKeyColumn` bugs. - Adding the `alias` as an argument to `Include` class so that developers can flexibly use their own alias for eager model inclusion rather than letting `dataloom` decide for them. +- Adding the `junction_table` as an argument to the `Include` so that we can use this table as a reference for `N-N` associations. - Introducing self relations - now you can define self relations in `dataloom` @@ -65,6 +66,17 @@ We have release the new `dataloom` Version `2.6.0` (`2024-02-27`) courseId = ForeignKeyColumn(table=Course, type="int") ``` + - you can do `eager` data fetching in this type of relationship, however you need to specify the `junction_table`. Here is an example: + + ```py + english = mysql_loom.find_by_pk( + Course, + pk=engId, + select=["id", "name"], + include=Include(model=Student, junction_table=StudentCourses, has="many"), + ) + ``` + === Dataloom **`2.3.0`** === diff --git a/README.md b/README.md index f8f2664..a0bf1ff 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ - [Retrieving Records](#retrieving-records-4) - [Query Builder.](#query-builder) - [Why Use Query Builder?](#why-use-query-builder) -- [What is coming next?](#what-is-coming-next) - [Contributing](#contributing) - [License](#license) @@ -623,16 +622,17 @@ posts = pg_loom.find_all( The `Include` class facilitates eager loading for models with relationships. Below is a table detailing the parameters available for the `Include` class: -| Argument | Description | Type | Default | Required | -| --------- | ---------------------------------------------------------------------------------- | ------------------- | -------- | -------- | -| `model` | The model to be included when eagerly fetching records. | `Model` | - | Yes | -| `order` | The list of order specifications for sorting the included data. | `list[Order]` | `[]` | No | -| `limit` | The maximum number of records to include. | `int \| None` | `0` | No | -| `offset` | The number of records to skip before including. | `int \| None` | `0` | No | -| `select` | The list of columns to include. | `list[str] \| None` | `None` | No | -| `has` | The relationship type between the current model and the included model. | `INCLUDE_LITERAL` | `"many"` | No | -| `include` | The extra included models. | `list[Include]` | `[]` | No | -| `alias` | The alias name for the included model. Very important when mapping self relations. | `str` | `None` | No | +| Argument | Description | Type | Default | Required | +| ---------------- | ------------------------------------------------------------------------------------------- | ------------------- | -------- | -------- | +| `model` | The model to be included when eagerly fetching records. | `Model` | - | Yes | +| `junction_table` | The `junction_table` model that is used as a reference table in a many to many association. | `Model` | `None` | No | +| `order` | The list of order specifications for sorting the included data. | `list[Order]` | `[]` | No | +| `limit` | The maximum number of records to include. | `int \| None` | `0` | No | +| `offset` | The number of records to skip before including. | `int \| None` | `0` | No | +| `select` | The list of columns to include. | `list[str] \| None` | `None` | No | +| `has` | The relationship type between the current model and the included model. | `INCLUDE_LITERAL` | `"many"` | No | +| `include` | The extra included models. | `list[Include]` | `[]` | No | +| `alias` | The alias name for the included model. Very important when mapping self relations. | `str` | `None` | No | #### `Group` Class @@ -2339,53 +2339,74 @@ mysql_loom.insert_bulk( ##### Retrieving Records -Now let's query employee `Michael` with his supervisor. +Now let's query a student called `Alice` with her courses. We can do it as follows: ```py -emp = mysql_loom.find_by_pk( - instance=Employee, pk=2, select=["id", "name", "supervisorId"] +s = mysql_loom.find_by_pk( + Student, + pk=stud1, + select=["id", "name"], ) -sup = mysql_loom.find_by_pk( - instance=Employee, select=["id", "name"], pk=emp["supervisorId"] +c = mysql_loom.find_many( + StudentCourses, + filters=Filter(column="studentId", value=stud1), + select=["courseId"], +) +courses = mysql_loom.find_many( + Course, + filters=Filter(column="id", operator="in", value=[list(i.values())[0] for i in c]), + select=["id", "name"], ) -emp_and_sup = {**emp, "supervisor": sup} -print(emp_and_sup) # ? = {'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'supervisor': {'id': 1, 'name': 'John Doe'}} -``` -We're querying the database to retrieve information about a `employee` and their associated `supervisor`. +alice = {**s, "courses": courses} +print(courses) # ? = {'id': 1, 'name': 'Alice', 'courses': [{'id': 1, 'name': 'Mathematics'}, {'id': 2, 'name': 'English'}, {'id': 3, 'name': 'Physics'}]} +``` -1. **Querying an Employee**: +We're querying the database to retrieve information about a `student` and their associated `courses`. Here are the steps in achieving that: - - We use `mysql_loom.find_by_pk()` to fetch a single employee record from the database. - - The employee's ID is specified as `2`. +1. **Querying Student**: -2. **Querying Supervisor**: + - We use `mysql_loom.find_by_pk()` to fetch a single `student` record from the database in the table `students`. - - We use `mysql_loom.find_by_pk()` to retrieve a supervisor that is associated with this employee. - - We create a dictionary `emp_and_sup` containing the `employee` information and their `supervisor`. +2. **Querying Course Id's**: + - Next we are going to query all the course ids of that student and store them in `c` in the joint table `students_courses`. + - We use `mysql_loom.find_many()` to retrieve the course `ids` of `alice`. +3. **Querying Course**: + - Next we will query all the courses using the operator `in` in the `courses` table based on the id's we obtained previously. -With eager loading this can be done in one query as follows the above can be done as follows: +As you can see we are doing a lot of work to get the information about `Alice`. With eager loading this can be done in one query as follows the above can be done as follows: ```py -emp_and_sup = mysql_loom.find_by_pk( - instance=Employee, - pk=2, - select=["id", "name", "supervisorId"], +alice = mysql_loom.find_by_pk( + Student, + pk=stud1, + select=["id", "name"], include=Include( - model=Employee, - has="one", - select=["id", "name"], - alias="supervisor", + model=Course, junction_table=StudentCourses, alias="courses", has="many" ), ) -print(emp_and_sup) # ? = {'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'supervisor': {'id': 1, 'name': 'John Doe'}} +print(alice) # ? = {'id': 1, 'name': 'Alice', 'courses': [{'id': 1, 'name': 'Mathematics'}, {'id': 2, 'name': 'English'}, {'id': 3, 'name': 'Physics'}]} ``` -- We use `mysql_loom.find_by_pk()` to fetch a single an employee record from the database. -- Additionally, we include associated `employee` record using `eager` loading with an `alias` of `supervisor`. +- We use `mysql_loom.find_by_pk()` to retrieve a single student record from the database. +- Furthermore, we include the associated `course` records using `eager` loading with an `alias` of `courses`. +- We specify a `junction_table` in our `Include` statement. This allows dataloom to recognize the relationship between the `students` and `courses` tables through this `junction_table`. -> 👍 **Pro Tip:** Note that the `alias` is very important in this situation because it allows you to get the included relationships with objects that are named well, if you don't give an alias dataloom will just use the model class name as the alias of your included models, in this case you will get an object that looks like `{'id': 2, 'name': 'Michael Johnson', 'supervisorId': 1, 'employee': {'id': 1, 'name': 'John Doe'}}`, which practically and theoretically doesn't make sense. +> 👍 **Pro Tip:** It is crucial to specify the `junction_table` when querying in a many-to-many (`N-N`) relationship. This is because, by default, the models will not establish a direct many-to-many relationship without referencing the `junction_table`. They lack foreign key columns within them to facilitate this relationship. + +As for our last example let's query all the students that are enrolled in the `English` class. We can easily do it as follows: + +```py +english = mysql_loom.find_by_pk( + Course, + pk=engId, + select=["id", "name"], + include=Include(model=Student, junction_table=StudentCourses, has="many"), +) + +print(english) # ? = {'id': 2, 'name': 'English', 'students': [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}, {'id': 3, 'name': 'Lisa'}]} +``` ### Query Builder. @@ -2458,10 +2479,6 @@ The `run` method takes the following as arguments: print(result) ``` -### What is coming next? - -1. N-N associations - ### Contributing Contributions to `dataloom` are welcome! Feel free to submit bug reports, feature requests, or pull requests on [GitHub](https://github.com/CrispenGari/dataloom). diff --git a/dataloom/keys.py b/dataloom/keys.py index 4410e1f..59e757b 100644 --- a/dataloom/keys.py +++ b/dataloom/keys.py @@ -1,7 +1,7 @@ # Configuration file for unit testing. -push = False +push = True class PgConfig: diff --git a/dataloom/statements/__init__.py b/dataloom/statements/__init__.py index d9cd889..3fa8736 100644 --- a/dataloom/statements/__init__.py +++ b/dataloom/statements/__init__.py @@ -160,7 +160,7 @@ def _get_create_table_command(self) -> str: dialect=self.dialect, model=self.model, ) - if len(fks) > 2: + if len(fks) > 2 and len(pks) == 0: raise TooManyFkException( f"Reference table '{self.table_name}' can not have more than 2 foreign keys." ) @@ -176,7 +176,7 @@ def _get_create_table_command(self) -> str: fields = [*user_fields, *predefined_fields] fields_name = ", ".join(f for f in [" ".join(field) for field in fields]) if self.dialect == "postgres": - if len(fks) == 2 and len(pks) == 0: + if len(fks) == 2 or len(pks) == 0: pks, user_fields, fks = get_create_reference_table_params( dialect=self.dialect, model=self.model, @@ -205,7 +205,7 @@ def _get_create_table_command(self) -> str: ) elif self.dialect == "mysql": - if len(fks) == 2 and len(pks) == 0: + if len(fks) == 2 or len(pks) == 0: pks, user_fields, fks = get_create_reference_table_params( dialect=self.dialect, model=self.model, @@ -234,7 +234,7 @@ def _get_create_table_command(self) -> str: ) elif self.dialect == "sqlite": - if len(fks) == 2 and len(pks) == 0: + if len(fks) == 2 or len(pks) == 0: pks, user_fields, fks = get_create_reference_table_params( dialect=self.dialect, model=self.model, @@ -986,7 +986,10 @@ def _get_alter_table_command(self, old_columns: list[str]) -> str: ).get_alter_table_params alterations = " ".join(alterations) if self.dialect != "sqlite" else "" - + if len(fks) > 2 and len(pks) == 0: + raise TooManyFkException( + f"Reference table '{self.table_name}' can not have more than 2 foreign keys." + ) # do we have a single primary key or not? if len(pks) == 0 and len(fks) != 2: raise PkNotDefinedException( @@ -997,11 +1000,6 @@ def _get_alter_table_command(self, old_columns: list[str]) -> str: f"You have defined many field as primary keys which is not allowed. Fields ({', '.join(pks)}) are primary keys." ) - if len(fks) > 2: - raise TooManyFkException( - f"Reference table '{self.table_name}' can not have more than 2 foreign keys." - ) - if self.dialect == "postgres": sql = PgStatements.ALTER_TABLE_COMMAND.format(alterations=alterations) elif self.dialect == "mysql": @@ -1017,7 +1015,10 @@ def _get_alter_table_command(self, old_columns: list[str]) -> str: dialect=self.dialect, model=self.model, ) - + if len(fks) > 2 and len(pks) == 0: + raise TooManyFkException( + f"Reference table '{self.table_name}' can not have more than 2 foreign keys." + ) if len(pks) == 0 and len(fks) != 2: raise PkNotDefinedException( f"Your table '{self.table_name}' does not have a primary key column and it is not a reference table." @@ -1026,10 +1027,7 @@ def _get_alter_table_command(self, old_columns: list[str]) -> str: raise TooManyPkException( f"You have defined many field as primary keys which is not allowed. Fields ({', '.join(pks)}) are primary keys." ) - if len(fks) > 2: - raise TooManyFkException( - f"Reference table '{self.table_name}' can not have more than 2 foreign keys." - ) + fields = [*user_fields, *predefined_fields] fields_name = ", ".join(f for f in [" ".join(field) for field in fields]) diff --git a/dataloom/tests/mysql/test_eager_loading_mysql.py b/dataloom/tests/mysql/test_eager_loading_mysql.py index fde995c..6ee777c 100644 --- a/dataloom/tests/mysql/test_eager_loading_mysql.py +++ b/dataloom/tests/mysql/test_eager_loading_mysql.py @@ -1117,3 +1117,131 @@ class Employee(Model): "supervisor": None, } conn.close() + + def test_n2n_relations_eager(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + ) + from dataloom.keys import MySQLConfig + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class Course(Model): + __tablename__: TableColumn = TableColumn(name="courses") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class Student(Model): + __tablename__: TableColumn = TableColumn(name="students") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + + conn, tables = mysql_loom.connect_and_sync( + [Student, Course, StudentCourses], force=True + ) + + # insert the courses + mathId = mysql_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Mathematics") + ) + engId = mysql_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="English") + ) + phyId = mysql_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Physics") + ) + + # create students + + stud1 = mysql_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Alice") + ) + stud2 = mysql_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Bob") + ) + stud3 = mysql_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Lisa") + ) + + # enrolling students + mysql_loom.insert_bulk( + instance=StudentCourses, + values=[ + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=mathId), + ], # enrolling Alice to mathematics + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Alice to physics + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=engId), + ], # enrolling Alice to english + [ + ColumnValue(name="studentId", value=stud2), + ColumnValue(name="courseId", value=engId), + ], # enrolling Bob to english + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Lisa to physics + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=engId), + ], # enrolling Lisa to english + ], + ) + + alice = mysql_loom.find_by_pk( + Student, + pk=stud1, + select=["id", "name"], + include=Include(model=Course, junction_table=StudentCourses, has="many"), + ) + + assert alice == { + "id": 1, + "name": "Alice", + "courses": [ + {"id": 1, "name": "Mathematics"}, + {"id": 2, "name": "English"}, + {"id": 3, "name": "Physics"}, + ], + } + + english = mysql_loom.find_by_pk( + Course, + pk=engId, + select=["id", "name"], + include=Include(model=Student, junction_table=StudentCourses, has="many"), + ) + + assert english == { + "id": 2, + "name": "English", + "students": [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + {"id": 3, "name": "Lisa"}, + ], + } + conn.close() diff --git a/dataloom/tests/postgres/test_eager_loading_pg.py b/dataloom/tests/postgres/test_eager_loading_pg.py index 916c136..69c62d2 100644 --- a/dataloom/tests/postgres/test_eager_loading_pg.py +++ b/dataloom/tests/postgres/test_eager_loading_pg.py @@ -1173,3 +1173,131 @@ class Employee(Model): "supervisor": None, } conn.close() + + def test_n2n_relations_eager(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + ) + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class Course(Model): + __tablename__: TableColumn = TableColumn(name="courses") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class Student(Model): + __tablename__: TableColumn = TableColumn(name="students") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + + conn, tables = pg_loom.connect_and_sync( + [Student, Course, StudentCourses], force=True + ) + + # insert the courses + mathId = pg_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Mathematics") + ) + engId = pg_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="English") + ) + phyId = pg_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Physics") + ) + + # create students + + stud1 = pg_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Alice") + ) + stud2 = pg_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Bob") + ) + stud3 = pg_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Lisa") + ) + + # enrolling students + pg_loom.insert_bulk( + instance=StudentCourses, + values=[ + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=mathId), + ], # enrolling Alice to mathematics + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Alice to physics + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=engId), + ], # enrolling Alice to english + [ + ColumnValue(name="studentId", value=stud2), + ColumnValue(name="courseId", value=engId), + ], # enrolling Bob to english + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Lisa to physics + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=engId), + ], # enrolling Lisa to english + ], + ) + + alice = pg_loom.find_by_pk( + Student, + pk=stud1, + select=["id", "name"], + include=Include(model=Course, junction_table=StudentCourses, has="many"), + ) + + assert alice == { + "id": 1, + "name": "Alice", + "courses": [ + {"id": 1, "name": "Mathematics"}, + {"id": 2, "name": "English"}, + {"id": 3, "name": "Physics"}, + ], + } + + english = pg_loom.find_by_pk( + Course, + pk=engId, + select=["id", "name"], + include=Include(model=Student, junction_table=StudentCourses, has="many"), + ) + + assert english == { + "id": 2, + "name": "English", + "students": [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + {"id": 3, "name": "Lisa"}, + ], + } + conn.close() diff --git a/dataloom/tests/sqlite3/test_eager_loading_sqlite.py b/dataloom/tests/sqlite3/test_eager_loading_sqlite.py index 75720dd..d18be6d 100644 --- a/dataloom/tests/sqlite3/test_eager_loading_sqlite.py +++ b/dataloom/tests/sqlite3/test_eager_loading_sqlite.py @@ -1087,3 +1087,125 @@ class Employee(Model): "supervisor": None, } conn.close() + + def test_n2n_relations_eager(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + ) + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class Course(Model): + __tablename__: TableColumn = TableColumn(name="courses") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class Student(Model): + __tablename__: TableColumn = TableColumn(name="students") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + + class StudentCourses(Model): + __tablename__: TableColumn = TableColumn(name="students_courses") + studentId = ForeignKeyColumn(table=Student, type="int") + courseId = ForeignKeyColumn(table=Course, type="int") + + conn, tables = sqlite_loom.connect_and_sync( + [Student, Course, StudentCourses], force=True + ) + + # insert the courses + mathId = sqlite_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Mathematics") + ) + engId = sqlite_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="English") + ) + phyId = sqlite_loom.insert_one( + instance=Course, values=ColumnValue(name="name", value="Physics") + ) + + # create students + + stud1 = sqlite_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Alice") + ) + stud2 = sqlite_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Bob") + ) + stud3 = sqlite_loom.insert_one( + instance=Student, values=ColumnValue(name="name", value="Lisa") + ) + + # enrolling students + sqlite_loom.insert_bulk( + instance=StudentCourses, + values=[ + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=mathId), + ], # enrolling Alice to mathematics + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Alice to physics + [ + ColumnValue(name="studentId", value=stud1), + ColumnValue(name="courseId", value=engId), + ], # enrolling Alice to english + [ + ColumnValue(name="studentId", value=stud2), + ColumnValue(name="courseId", value=engId), + ], # enrolling Bob to english + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=phyId), + ], # enrolling Lisa to physics + [ + ColumnValue(name="studentId", value=stud3), + ColumnValue(name="courseId", value=engId), + ], # enrolling Lisa to english + ], + ) + + alice = sqlite_loom.find_by_pk( + Student, + pk=stud1, + select=["id", "name"], + include=Include(model=Course, junction_table=StudentCourses, has="many"), + ) + + assert alice == { + "id": 1, + "name": "Alice", + "courses": [ + {"id": 1, "name": "Mathematics"}, + {"id": 2, "name": "English"}, + {"id": 3, "name": "Physics"}, + ], + } + + english = sqlite_loom.find_by_pk( + Course, + pk=engId, + select=["id", "name"], + include=Include(model=Student, junction_table=StudentCourses, has="many"), + ) + + assert english == { + "id": 2, + "name": "English", + "students": [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + {"id": 3, "name": "Lisa"}, + ], + } + conn.close() diff --git a/dataloom/types/__init__.py b/dataloom/types/__init__.py index c60ff98..c464987 100644 --- a/dataloom/types/__init__.py +++ b/dataloom/types/__init__.py @@ -338,7 +338,7 @@ class Include[Model]: ---------- model : Model The model to be included when eger fetching records. - junction_table: Model|None, optional + junction_table: Model | None, optional A junction table is a reference table for models that maps N-N relationship. order : list[Order], optional The list of order specifications for sorting the included data. Default is an empty list. diff --git a/hi.db b/hi.db index 418fd18048fe531fbce33c768f33472802f72507..8ee34ffc891870b9fb7792b3f71f7ebebe8cb2c1 100644 GIT binary patch delta 1594 zcmaJ>YfKzf6rMYGUU&9!7k10QQrIb!$SRAl(9lxLqc{>GuPoR|Qtd9VEnAjdWR@5c z8+SnY;j5N|8vJ3jY5K>cm~NB)GR4GbG?D0!#@eWfQH;h^Yoh+}efI$kNSNfzIrn_` z`_9}u=icw`m?n?f>y>wuXVs6DA;)3TNCd^Bc$mhPXTH)}EQ4nL3Or($q1t&Lyny$? z6~Qe}&m>RA;!#N0wf$@sIAqP|LkqT4WHcU~HKQ};iA41A&A<&q{L8zDlx_cY%UU3S7HS~wtU(@_R!W{aF_xU2 zF{5T!uT}LOo^T%T84v3{{liAL5z_ky@{6g{!)9uBJeo-Di0&vi80zWk42|dy8Y6lw z)9%U^XY?&v{<olZh`6tnHptV!8_mOhZwzI`=6fQa#}C}XXO`_Mhl{n zX86F8N}+{#M0b+~V=zdXyh@rYk+dTk;$jKOS~?`6}mB`ShjwpAWRVa2`bFl-t7Q=!|qBlAEC;g57 zOuwgJ)0=GK`?OV*FhV>QMdmOTh3tqIFh+zIc=l!yCL~)$qSv$hTXdaXr@zuK8UI~+ ek1U`pU&?tVkRS7cBp{N_XC3ARfe>O-vI(6rQEKv%CG9mcNBk=oTf4SfDKg1QC(cXsl?I#E@V>WlbQJB3q&+hzy_y zW8C7H7(EeUIA9b)@rdBbV3c?ljR%bqP9E@}9-Q6MAZXa+&D;0B@4emklNns#1{b)v zE4}|703e%629-1bLW84w1J%@9+V#%H6@U!W96-;6CVnS>TgbP!Th3Z1#js^jl*|qA zt7VUA4o-8A;WZc*mWAbn)A5kuh6}J(Jo;qTJye3@4ZI{t%t&SI-1&&o6IElXad2eC zJXxX>4sQ^KT56?;bJJ$A-M*c4A z!&)7g5`7$YVy%X(ilcUM7zCk2gUzYn&Gq=Pb^}Twk6tPH&S!F2L9AUTW+9ko83Ql$ zAO>`HcPLlY%aP8Q(x$hoY*V{j-VPi$@lt+1Gn|(kLT~h9XGBqZ)^!>=X??}gk~vyl zQFLJe5KS}!koy|@)?1LT{nfXO7h zeIZTjCyVwkZrc7g`;zgO{xj|=Tk+6QtR3JbpN|;{kvm*bjziDe9Fm(G4uwNw_RUz*!h2#OO7E-9;Sc6sMDQZc;gTFi(=m+4?EQ6NNJX%2?(JY#vyR9KN zvYM^|DnkNlB2k@MN_I7dwg7W?A96dVW{XAibzGE!c(zOp_hyCZl;ZVi^!Vhd=8nPX#A7>2almZ5&!@I diff --git a/playground.py b/playground.py index aa83b23..7c8fceb 100644 --- a/playground.py +++ b/playground.py @@ -1,164 +1 @@ -from dataloom import ( - Loom, - Model, - PrimaryKeyColumn, - Column, - CreatedAtColumn, - UpdatedAtColumn, - TableColumn, - ForeignKeyColumn, - Filter, - ColumnValue, - Include, - Order, - Group, - Having, -) - - -from dataloom.decorators import initialize -import json, time -from typing import Optional -from dataclasses import dataclass - -sqlite_loom = Loom( - connection_uri="sqlite://hello/database.db", - dialect="sqlite", - database="hi.db", - logs_filename="sqlite-logs.sql", - sql_logger="console", -) - - -pg_loom = Loom( - connection_uri="postgresql://postgres:root@localhost:5432/hi", - dialect="postgres", - sql_logger="console", -) - -mysql_loom = Loom( - connection_uri="mysql://root:root@localhost:3306/hi", - dialect="mysql", - sql_logger="console", -) - - -class Course(Model): - __tablename__: TableColumn = TableColumn(name="courses") - id = PrimaryKeyColumn(type="int", auto_increment=True) - name = Column(type="text", nullable=False, default="Bob") - - -class Student(Model): - __tablename__: TableColumn = TableColumn(name="students") - id = PrimaryKeyColumn(type="int", auto_increment=True) - name = Column(type="text", nullable=False, default="Bob") - - -class StudentCourses(Model): - __tablename__: TableColumn = TableColumn(name="students_courses") - studentId = ForeignKeyColumn(table=Student, type="int") - courseId = ForeignKeyColumn(table=Course, type="int") - - -conn, tables = mysql_loom.connect_and_sync( - [Student, Course, StudentCourses], force=True -) - -# insert the courses -mathId = mysql_loom.insert_one( - instance=Course, values=ColumnValue(name="name", value="Mathematics") -) -engId = mysql_loom.insert_one( - instance=Course, values=ColumnValue(name="name", value="English") -) -phyId = mysql_loom.insert_one( - instance=Course, values=ColumnValue(name="name", value="Physics") -) - -# create students - -stud1 = mysql_loom.insert_one( - instance=Student, values=ColumnValue(name="name", value="Alice") -) -stud2 = mysql_loom.insert_one( - instance=Student, values=ColumnValue(name="name", value="Bob") -) -stud3 = mysql_loom.insert_one( - instance=Student, values=ColumnValue(name="name", value="Lisa") -) - -# enrolling students -mysql_loom.insert_bulk( - instance=StudentCourses, - values=[ - [ - ColumnValue(name="studentId", value=stud1), - ColumnValue(name="courseId", value=mathId), - ], # enrolling Alice to mathematics - [ - ColumnValue(name="studentId", value=stud1), - ColumnValue(name="courseId", value=phyId), - ], # enrolling Alice to physics - [ - ColumnValue(name="studentId", value=stud1), - ColumnValue(name="courseId", value=engId), - ], # enrolling Alice to english - [ - ColumnValue(name="studentId", value=stud2), - ColumnValue(name="courseId", value=engId), - ], # enrolling Bob to english - [ - ColumnValue(name="studentId", value=stud3), - ColumnValue(name="courseId", value=phyId), - ], # enrolling Lisa to physics - [ - ColumnValue(name="studentId", value=stud3), - ColumnValue(name="courseId", value=engId), - ], # enrolling Lisa to english - ], -) - -s = mysql_loom.find_by_pk( - Student, - pk=stud1, - select=["id", "name"], -) -c = mysql_loom.find_many( - StudentCourses, - filters=Filter(column="studentId", value=stud1), - select=["courseId"], -) -courses = mysql_loom.find_many( - Course, - filters=Filter( - column="courseId", operator="in", value=[list(i.values())[0] for i in c] - ), - select=["id", "name"], -) - -alice = {**s, "courses": courses} -print(courses) - -# alice = mysql_loom.find_by_pk( -# Student, -# pk=stud1, -# select=["id", "name"], -# include=Include( -# model=Course, junction_table=StudentCourses, alias="courses", has="many" -# ), -# ) - -# print(alice) - - -# english = mysql_loom.find_by_pk( -# Course, -# pk=engId, -# select=["id", "name"], -# include=Include( -# model=Student, junction_table=StudentCourses, alias="students", has="many" -# ), -# ) - -# print(english) +print("Hello from dataloom!!") diff --git a/pyproject.toml b/pyproject.toml index 2d3ca6d..53b8938 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,8 @@ dependencies =[ ] keywords = ["ORM", "database", "data management", "SQLAlchemy" ] classifiers = [ - "Development Status :: 1 - Planning", + 'Development Status :: 5 - Production/Stable', + 'Development Status :: 6 - Mature', "Intended Audience :: Developers", "Programming Language :: Python :: 3.12", "Operating System :: Unix", diff --git a/todo.txt b/todo.txt index 67cb63e..cde50d6 100644 --- a/todo.txt +++ b/todo.txt @@ -16,19 +16,14 @@ 16. distinct ✅ 17. sum, avg,count, min & max, mean ✅ 18. Not ✅ -19. self relations -20. N-N relations -21. query builder - +19. self relations ✅ +20. N-N relations ✅ +21. query builder ✅ --------- conn - 1. create 3 connections for: * postgres ✅ * mysql ✅ * sqlite3 ✅ - - - ---------- bugs 1. fixing logger index ✅ \ No newline at end of file