diff --git a/Changelog.md b/Changelog.md index 0daaa81..ca21e49 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,10 +1,44 @@ +=== +Dataloom **`2.3.0`** +=== + +### Release Notes - `dataloom` + +We have release the new `dataloom` Version `2.3.0` (`2024-02-26`) + +##### Features + +- updated documentation. +- Query Builder in executing queries and SQL Scripts. + +```py +qb = loom.getQueryBuilder() +res = qb.run("select id from posts;", fetchall=True) +print(res) +``` + +We can use the query builder to execute the SQL as follows: + +```py +with open("qb.sql", "r") as reader: + sql = reader.read() +res = qb.run( + sql, + fetchall=True, + is_script=True, +) +print(res) +``` + +> 👍 **Pro Tip:** Executing a script using query builder does not return a result. The result value is always `None`. + === Dataloom **`2.2.0`** === ### Release Notes - `dataloom` -We have release the new `dataloom` Version `2.2.0` (`2024-02-24`) +We have release the new `dataloom` Version `2.2.0` (`2024-02-25`) ##### Features diff --git a/README.md b/README.md index 65a897c..c5a56aa 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ - [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) +- [Query Builder.](#query-builder) + - [Why Use Query Builder?](#why-use-query-builder) - [What is coming next?](#what-is-coming-next) - [Contributing](#contributing) - [License](#license) @@ -2112,10 +2114,81 @@ print(user_post) """ ? = ``` +### 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. + +```py +qb = loom.getQueryBuilder() + +print(qb) # ? = Loom QB +``` + +The `qb` object contains the method called `run`, which is used to execute SQL scripts or SQL queries. + +```py +ids = qb.run("select id from posts;", fetchall=True) +print(ids) # ? = [(1,), (2,), (3,), (4,)] +``` + +You can also execute SQL files. In the following example, we will demonstrate how you can execute SQL scripts using the `qb`. Let's say we have an SQL file called `qb.sql` which contains the following SQL code: + +```SQL +SELECT id, title FROM posts WHERE id IN (1, 3, 2, 4) LIMIT 4 OFFSET 1; +SELECT COUNT(*) FROM ( + SELECT DISTINCT `id` + FROM `posts` + WHERE `id` < 5 + LIMIT 3 OFFSET 2 +) AS subquery; +``` + +We can use the query builder to execute the SQL as follows: + +```py +with open("qb.sql", "r") as reader: + sql = reader.read() +res = qb.run( + sql, + fetchall=True, + is_script=True, +) +print(res) +``` + +> 👍 **Pro Tip:** Executing a script using query builder does not return a result. The result value is always `None`. + +The `run` method takes the following as arguments: + +| Argument | Description | Type | Required | Default | +| --------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------- | -------- | ------- | +| `sql` | SQL query to execute. | `str` | Yes | | +| `args` | Parameters for the SQL query. | `Any \| None` | No | `None` | +| `fetchone` | Whether to fetch only one result. | `bool` | No | `False` | +| `fetchmany` | Whether to fetch multiple results. | `bool` | No | `False` | +| `fetchall` | Whether to fetch all results. | `bool` | No | `False` | +| `mutation` | Whether the query is a mutation (insert, update, delete). | `bool` | No | `True` | +| `bulk` | Whether the query is a bulk operation. | `bool` | No | `False` | +| `affected_rows` | Whether to return affected rows. | `bool` | No | `False` | +| `operation` | Type of operation being performed. | `'insert', 'update', 'delete', 'read' \| None` | No | `None` | +| `verbose` | Verbosity level for logging . Set this option to `0` if you don't want logging at all. | `int` | No | `1` | +| `is_script` | Whether the SQL is a script. | `bool` | No | `False` | + +#### Why Use Query Builder? + +- The query builder empowers developers to seamlessly execute `SQL` queries directly. +- While Dataloom primarily utilizes `subqueries` for eager data fetching on models, developers may prefer to employ JOIN operations, which are achievable through the `qb` object. + + ```python + qb = loom.getQueryBuilder() + result = qb.run("SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.table1_id;") + print(result) + ``` + ### What is coming next? 1. N-N associations -2. Query Builder +2. Self relations ### Contributing diff --git a/dataloom/loom/__init__.py b/dataloom/loom/__init__.py index 9bb32ca..ee784a7 100644 --- a/dataloom/loom/__init__.py +++ b/dataloom/loom/__init__.py @@ -12,7 +12,7 @@ from dataloom.model import Model from dataloom.statements import GetStatement from dataloom.conn import ConnectionOptionsFactory -from typing import Optional, Any +from typing import Optional, Any, Literal from mysql.connector.pooling import PooledMySQLConnection from sqlite3 import Connection from mysql.connector.connection import MySQLConnectionAbstract @@ -27,6 +27,7 @@ ) from dataloom.loom.interfaces import ILoom from dataloom.loom.math import math +from dataloom.loom.qb import qb class Loom(ILoom): @@ -1165,7 +1166,7 @@ def _execute_sql( mutation=True, bulk: bool = False, affected_rows: bool = False, - operation: Optional[str] = None, + operation: Optional[Literal["insert", "update", "delete", "read"]] = None, _verbose: int = 1, _is_script: bool = False, ) -> Any: @@ -1785,3 +1786,28 @@ def count( distinct=distinct, filters=filters, ) + + # qb + + def getQueryBuilder(self): + """ + getQueryBuilder + --------------- + Retrieves a query builder instance. + + Parameters + ---------- + No parameters + + Returns + ------- + qb + Query builder instance. + + Examples + -------- + >>> qb = loom.getQueryBuilder() + ... print(qb) + """ + builder = qb(_execute_sql=self._execute_sql, dialect=self.dialect) + return builder diff --git a/dataloom/loom/interfaces.py b/dataloom/loom/interfaces.py index 7cacd4a..38732d2 100644 --- a/dataloom/loom/interfaces.py +++ b/dataloom/loom/interfaces.py @@ -218,21 +218,6 @@ def delete_bulk( def tables(self): raise NotImplementedError("The tables property was not implemented") - @abstractclassmethod - def _execute_sql( - self, - sql: str, - args=None, - fetchone=False, - fetchmany=False, - fetchall=False, - mutation=True, - bulk: bool = False, - affected_rows: bool = False, - operation: Optional[str] = None, - ) -> Any: - raise NotImplementedError("The _execute_sql method was not implemented.") - def connect( self, ) -> Any | PooledMySQLConnection | MySQLConnectionAbstract | Connection: diff --git a/dataloom/loom/qb.py b/dataloom/loom/qb.py new file mode 100644 index 0000000..64c33ed --- /dev/null +++ b/dataloom/loom/qb.py @@ -0,0 +1,88 @@ +from typing import Callable, Any, Literal, Optional + +from dataloom.types import DIALECT_LITERAL + + +class qb: + def __repr__(self) -> str: + return f"Loom QB<{self.dialect}>" + + def __str__(self) -> str: + return f"Loom QB<{self.dialect}>" + + def __init__( + self, _execute_sql: Callable[..., Any], dialect: DIALECT_LITERAL + ) -> None: + self.__exc = _execute_sql + self.dialect = dialect + + def run( + self, + sql: str, + args: Any | None = None, + fetchone: bool = False, + fetchmany: bool = False, + fetchall: bool = False, + mutation: bool = True, + bulk: bool = False, + affected_rows: bool = False, + operation: Optional[Literal["insert", "update", "delete", "read"]] = None, + verbose: int = 1, + is_script: bool = False, + ): + """ + run + ----------- + + Execute SQL query with optional parameters. + + Parameters + ---------- + sql : str + SQL query to execute. + args : Any | None, optional + Parameters for the SQL query. Defaults to None. + fetchone : bool, optional + Whether to fetch only one result. Defaults to False. + fetchmany : bool, optional + Whether to fetch multiple results. Defaults to False. + fetchall : bool, optional + Whether to fetch all results. Defaults to False. + mutation : bool, optional + Whether the query is a mutation (insert, update, delete). Defaults to True. + bulk : bool, optional + Whether the query is a bulk operation. Defaults to False. + affected_rows : bool, optional + Whether to return affected rows. Defaults to False. + operation : Literal['insert', 'update', 'delete', 'read'] | None, optional + Type of operation being performed. Defaults to None. + verbose : int, optional + Verbosity level for logging. Defaults to 1. + is_script : bool, optional + Whether the SQL is a script. Defaults to False. + + Returns + ------- + Any + Query result. + + Examples + -------- + >>> qb = loom.getQueryBuilder() + ... ids = qb.run("select id from posts;", fetchall=True) + ... print(ids) + ... + """ + return self.__exc( + sql, + args=args, + fetchall=fetchall, + fetchmany=fetchmany, + fetchone=fetchone, + mutation=mutation, + bulk=bulk, + affected_rows=affected_rows, + operation=operation, + _verbose=verbose, + _is_script=is_script, + ) diff --git a/dataloom/loom/sql.py b/dataloom/loom/sql.py index 54fef37..0714166 100644 --- a/dataloom/loom/sql.py +++ b/dataloom/loom/sql.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Optional, Literal from dataloom.types import DIALECT_LITERAL, SQL_LOGGER_LITERAL from dataloom.exceptions import UnsupportedDialectException from sqlite3 import Connection @@ -59,7 +59,7 @@ def execute_sql( mutation=True, bulk: bool = False, affected_rows: bool = False, - operation: Optional[str] = None, + operation: Optional[Literal["insert", "update", "delete", "read"]] = None, _verbose: int = 1, _is_script: bool = False, ) -> Any: @@ -110,7 +110,6 @@ def execute_sql( except Exception: pass return None - if args is None: cursor.execute(sql) else: diff --git a/dataloom/tests/mysql/test_qb_mysql.py b/dataloom/tests/mysql/test_qb_mysql.py new file mode 100644 index 0000000..bffc9c4 --- /dev/null +++ b/dataloom/tests/mysql/test_qb_mysql.py @@ -0,0 +1,63 @@ +class TestQBMySQL: + def test_qb(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + UpdatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + ) + from dataloom.keys import MySQLConfig + from typing import Optional + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class User(Model): + __tablename__: Optional[TableColumn] = TableColumn(name="users") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + username = Column(type="varchar", unique=True, length=255) + + class Post(Model): + __tablename__: Optional[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() + updatedAt = UpdatedAtColumn() + # relations + userId = ForeignKeyColumn( + User, type="int", required=True, onDelete="CASCADE", onUpdate="CASCADE" + ) + + conn, _ = mysql_loom.connect_and_sync([Post, User], drop=True, force=True) + userId = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + 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), + ], + ) + qb = mysql_loom.getQueryBuilder() + res = qb.run("select id from posts;", fetchall=True) + assert str(qb) == "Loom QB" + assert len(res) == 4 + conn.close() diff --git a/dataloom/tests/postgres/test_qb_pg.py b/dataloom/tests/postgres/test_qb_pg.py new file mode 100644 index 0000000..194922c --- /dev/null +++ b/dataloom/tests/postgres/test_qb_pg.py @@ -0,0 +1,63 @@ +class TestQBPG: + def test_qb(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + UpdatedAtColumn, + ) + from dataloom.keys import PgConfig + from typing import Optional + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class User(Model): + __tablename__: Optional[TableColumn] = TableColumn(name="users") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + username = Column(type="varchar", unique=True, length=255) + + class Post(Model): + __tablename__: Optional[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() + updatedAt = UpdatedAtColumn() + # relations + userId = ForeignKeyColumn( + User, type="int", required=True, onDelete="CASCADE", onUpdate="CASCADE" + ) + + conn, _ = pg_loom.connect_and_sync([Post, User], drop=True, force=True) + userId = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + for title in ["Hey", "Hello", "What are you doing", "Coding"]: + pg_loom.insert_one( + instance=Post, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="title", value=title), + ], + ) + qb = pg_loom.getQueryBuilder() + res = qb.run("select id from posts;", fetchall=True) + assert str(qb) == "Loom QB" + assert len(res) == 4 + conn.close() diff --git a/dataloom/tests/sqlite3/test_qb_sqlite.py b/dataloom/tests/sqlite3/test_qb_sqlite.py new file mode 100644 index 0000000..06f11cf --- /dev/null +++ b/dataloom/tests/sqlite3/test_qb_sqlite.py @@ -0,0 +1,58 @@ +class TestQBSQlite: + def test_qb(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + UpdatedAtColumn, + ) + + from typing import Optional + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class User(Model): + __tablename__: Optional[TableColumn] = TableColumn(name="users") + id = PrimaryKeyColumn(type="int", auto_increment=True) + name = Column(type="text", nullable=False, default="Bob") + username = Column(type="varchar", unique=True, length=255) + + class Post(Model): + __tablename__: Optional[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() + updatedAt = UpdatedAtColumn() + # relations + userId = ForeignKeyColumn( + User, type="int", required=True, onDelete="CASCADE", onUpdate="CASCADE" + ) + + conn, _ = sqlite_loom.connect_and_sync([Post, User], drop=True, force=True) + userId = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + for title in ["Hey", "Hello", "What are you doing", "Coding"]: + sqlite_loom.insert_one( + instance=Post, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="title", value=title), + ], + ) + qb = sqlite_loom.getQueryBuilder() + res = qb.run("select id from posts;", fetchall=True) + assert str(qb) == "Loom QB" + assert len(res) == 4 + conn.close() diff --git a/hi.db b/hi.db index 2d389e7..626c074 100644 Binary files a/hi.db and b/hi.db differ diff --git a/playground.py b/playground.py index 1c2828c..41af0ce 100644 --- a/playground.py +++ b/playground.py @@ -95,10 +95,6 @@ class Post(Model): ) -avg = mysql_loom.avg( - instance=Post, - column="id", - distinct=True, -) - -print(avg) +qb = mysql_loom.getQueryBuilder() +res = qb.run("select id from posts;", fetchall=True) +print(qb) diff --git a/pyproject.toml b/pyproject.toml index bc0ea31..549cdf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dataloom" -version = "2.2.0" +version = "2.3.0" authors = [ {name = "Crispen Gari", email = "crispengari@gmail.com"}, ]