diff --git a/Changelog.md b/Changelog.md index 21f2605..984c380 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,129 @@ -=============================================================================================================================================== -**`1.0.2`** -=============================================================================================================================================== +=== +Dataloom **`2.0.0`** +=== + +### Release Notes - `dataloom` + +We have release the new `dataloom` Version `2.0.0` (`2024-02-12`) + +##### Features + +- Renaming the class `Dataloom` to `Loom`. + ```py + from dataloom import Loom + ``` +- Eager data fetching in relationships + + - Now you can fetch your child relationship together in your query + + ```py + user = mysql_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + print(user) + ``` + + - You can apply limits, offsets, filters and orders to your child associations during queries + + ```py + post = mysql_loom.find_one( + instance=Post, + filters=[Filter(column="userId", value=userId)], + select=["title", "id"], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[Include(model=Profile, select=["avatar", "id"], has="one")], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + ), + ], + ) + ``` + +- Now `return_dict` has bee removed as an option in `dataloom` in the query functions like `find_by_pk`, `find_one`, `find_many` and `find_all` now works starting from this version. If you enjoy working with python objects you have to maneuver them manually using experimental features. + + ```py + from dataloom.decorators import initialize + + @initialize(repr=True, to_dict=True, init=True, repr_identifier="id") + class Profile(Model): + __tablename__: Optional[TableColumn] = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + # now you can do this + + profile = mysql_loom.find_many( + instance=Profile, + ) + print([Profile(**p) for p in profile]) # ? = [] + print([Profile(**p) for p in profile][0].id) # ? = 1 + ``` + + - These are `experimental` decorators they are little bit slow and they work perfect in a single instance, you can not nest relationships on them. + - You can use them if you know how your data is structured and also if you know how to manipulate dictionaries + +- Deprecated ~~`join_next_filter_with`~~ to the use of `join_next_with` +- Values that was required as `list` e.g, `select`, `include` etc can now be passed as a single value. + + - **Before** + + ```py + res = mysql_loom.find_by_pk(Profile, pk=profileId, select={"id", "avatar"}) # invalid + res = mysql_loom.find_by_pk(Profile, pk=profileId, select=("id", "avatar")) # invalid + res = mysql_loom.find_by_pk(Profile, pk=profileId, select="id") # invalid + res = mysql_loom.find_by_pk(Profile, pk=profileId, select=["id"]) # valid + + ``` + + - **Now** + + ```py + res = mysql_loom.find_by_pk(Profile, pk=profileId, select={"id", "avatar"}) # valid + res = mysql_loom.find_by_pk(Profile, pk=profileId, select=("id", "avatar")) # valid + res = mysql_loom.find_by_pk(Profile, pk=profileId, select="id") # valid + res = mysql_loom.find_by_pk(Profile, pk=profileId, select=["id"]) # valid + + ``` + +- Updated the documentation. +- Grouping data in queries will also be part of this release, using the class `Group` and `Having`. + +```py +posts = pg_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=True, + ), +) +``` + +===== +Dataloom **`1.0.2`** +===== We have release the new `dataloom` Version `1.0.2` (`2024-02-12`) @@ -8,10 +131,9 @@ We have release the new `dataloom` Version `1.0.2` (`2024-02-12`) We have updated the documentation so that it can look more colorful. -=============================================================================================================================================== -**`1.0.1`** -=============================================================================================================================================== - +===== +Dataloom **`1.0.1`** +===== Change logs for the `dataloom` Version `1.0.1` (`2024-02-12`) ### New Features @@ -19,9 +141,9 @@ Change logs for the `dataloom` Version `1.0.1` (`2024-02-12`) - **Docstring**: Now the functions and classes have a beautiful docstring that helps ypu with some examples and references in the editor. - **SQL Loggers**: The SQL Loggers can now log `timestamps` not the log index especially for the `console` logger. -=============================================================================================================================================== -**`1.0.0`** -=============================================================================================================================================== +===== +Dataloom **`1.0.0`** +===== ### Release Notes - `dataloom` diff --git a/README.md b/README.md index a243c8c..4fbe7f4 100644 --- a/README.md +++ b/README.md @@ -25,24 +25,27 @@ 6. **Cross-platform Compatibility**: `dataloom` works seamlessly across different operating systems, including `Windows`, `macOS`, and `Linux`. 7. **Scalability**: Scale your application effortlessly with `dataloom`, whether it's a small project or a large-scale enterprise application. -### ⚠️ Warning - -> **⚠️ Experimental Status of `dataloom`**: The `dataloom` module is currently in an experimental phase. As such, we strongly advise against using it in production environments until a major version is officially released and stability is ensured. During this experimental phase, the `dataloom` module may undergo significant changes, and its features are subject to refinement. We recommend monitoring the project updates and waiting for a stable release before incorporating it into production systems. Please exercise caution and consider alternative solutions for production use until the module reaches a stable release. - ### Table of Contents - [dataloom](#dataloom) - [Why choose `dataloom`?](#why-choose-dataloom) -- [⚠️ Warning](#️-warning) - [Table of Contents](#table-of-contents) - [Key Features:](#key-features) - [Installation](#installation) - [Python Version Compatibility](#python-version-compatibility) - [Usage](#usage) - [Connection](#connection) + - [`Postgres`](#postgres) + - [`MySQL`](#mysql) + - [`SQLite`](#sqlite) - [Dataloom Classes](#dataloom-classes) + - [`Loom` Class](#loom-class) - [`Model` Class](#model-class) - [`Column` Class](#column-class) + - [Column Datatypes](#column-datatypes) + - [1. `mysql`](#1-mysql) + - [2. `postgres`](#2-postgres) + - [3. `sqlite`](#3-sqlite) - [`PrimaryKeyColumn` Class](#primarykeycolumn-class) - [`ForeignKeyColumn` Class](#foreignkeycolumn-class) - [`CreatedAtColumn` Class](#createdatcolumn-class) @@ -50,7 +53,9 @@ - [`Filter` Class](#filter-class) - [`ColumnValue` Class](#columnvalue-class) - [`Order` Class](#order-class) - - [`Include`](#include) + - [`Include` Class](#include-class) + - [`Group` Class](#group-class) + - [`Having` Class](#having-class) - [Syncing Tables](#syncing-tables) - [1. The `sync` method.](#1-the-sync-method) - [2. The `connect_and_sync` method.](#2-the-connect_and_sync-method) @@ -75,8 +80,26 @@ - [Guidelines for Safe Usage](#guidelines-for-safe-usage) - [Ordering](#ordering) - [Filters](#filters) + - [Operators](#operators) +- [Data Aggregation](#data-aggregation) + - [Aggregation Functions](#aggregation-functions) - [Utilities](#utilities) - - [`inspect`](#inspect) + - [1. `inspect`](#1-inspect) + - [2. `decorators`](#2-decorators) + - [`@initialize`](#initialize) +- [Associations](#associations) + - [1. `1-1` Association](#1-1-1-association) + - [Inserting](#inserting) + - [Retrieving Records](#retrieving-records) + - [2. `N-1` Association](#2-n-1-association) + - [Inserting](#inserting-1) + - [Retrieving Records](#retrieving-records-1) + - [3. `1-N` Association](#3-1-n-association) + - [Inserting](#inserting-2) + - [Retrieving Records](#retrieving-records-2) + - [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) - [What is coming next?](#what-is-coming-next) - [Contributing](#contributing) - [License](#license) @@ -117,13 +140,17 @@ In this section we are going to go through how you can use our `orm` package in ### Connection -To use Dataloom, you need to establish a connection with a specific database `dialect`. The available dialect options are `mysql`, `postgres`, and `sqlite`. The following is an example of how you can establish a connection with postgres database. +To use Dataloom, you need to establish a connection with a specific database `dialect`. The available dialect options are `mysql`, `postgres`, and `sqlite`. + +#### `Postgres` + +The following is an example of how you can establish a connection with postgres database. ```python -from dataloom import Dataloom +from dataloom import Loom -# Create a Dataloom instance with PostgreSQL configuration -pg_loom = Dataloom( +# Create a Loom instance with PostgreSQL configuration +pg_loom = Loom( dialect="postgres", database="hi", password="root", @@ -143,13 +170,15 @@ if __name__ == "__main__": conn.close() ``` -To establish a connection with a `MySQL` database using Dataloom, you can use the following example: +#### `MySQL` + +To establish a connection with a `MySQL` database using `Loom`, you can use the following example: ```python -from dataloom import Dataloom +from dataloom import Loom -# Create a Dataloom instance with MySQL configuration -mysql_loom = Dataloom( +# Create a Loom instance with MySQL configuration +mysql_loom = Loom( dialect="mysql", database="hi", password="root", @@ -169,13 +198,15 @@ if __name__ == "__main__": ``` -To establish a connection with an `SQLite` database using Dataloom, you can use the following example: +#### `SQLite` + +To establish a connection with an `SQLite` database using `Loom`, you can use the following example: ```python -from dataloom import Dataloom +from dataloom import Loom -# Create a Dataloom instance with SQLite configuration -sqlite_loom = Dataloom( +# Create a Loom instance with SQLite configuration +sqlite_loom = Loom( dialect="sqlite", database="hi.db", logs_filename="sqlite-logs.sql", @@ -191,7 +222,29 @@ if __name__ == "__main__": conn.close() ``` -The `Dataloom` class takes in the following options: +### Dataloom Classes + +The following are the list of classes that are available in `dataloom`. + +#### `Loom` Class + +This class is used to create a loom object that will be use to perform actions to a database. The following example show how you can create a `loom` object using this class. + +```python +from dataloom import Loom +loom = Loom( + dialect="postgres", + database="hi", + password="root", + user="postgres", + host="localhost", + sql_logger="console", + logs_filename="logs.sql", + port=5432, +) +``` + +The `Loom` class takes in the following options: | Parameter | Description | Value Type | Default Value | Required | | --------------- | --------------------------------------------------------------------------------- | --------------- | -------------- | -------- | | `dialect` | Dialect for the database connection. Options are `mysql`, `postgres`, or `sqlite` | `str` or `None` | `None` | `Yes` | @@ -203,15 +256,13 @@ The `Dataloom` class takes in the following options: | `logs_filename` | Filename for the query logs | `str` or `None` | `dataloom.sql` | `No` | | `port` | Port number for the database connection (only for `mysql` and `postgres`) | `int` or `None` | `None` | `No` | -### Dataloom Classes - #### `Model` Class A model in Dataloom is a top-level class that facilitates the creation of complex SQL tables using regular Python classes. This example demonstrates how to define two tables, `User` and `Post`, by creating classes that inherit from the `Model` class. ```py from dataloom import ( - Dataloom, + Loom, Model, PrimaryKeyColumn, Column, @@ -280,77 +331,88 @@ Here are some other options that you can pass to the `Column`: > Talking about data types, each `dialect` has its own accepted values. Here is a list of types supported by each and every `dialect`: -1. `mysql` - - - `"int"` - Integer data type. - - `"smallint"` - Small integer data type. - - `"bigint"` - Big integer data type. - - `"float"` - Floating-point number data type. - - `"double"` - Double-precision floating-point number data type. - - `"numeric"` - Numeric or decimal data type. - - `"text"` - Text data type. - - `"varchar"` - Variable-length character data type. - - `"char"` - Fixed-length character data type. - - `"boolean"` - Boolean data type. - - `"date"` - Date data type. - - `"time"` - Time data type. - - `"timestamp"` - Timestamp data type. - - `"json"` - JSON (JavaScript Object Notation) data type. - - `"blob"` - Binary Large Object (BLOB) data type. - -2. `postgres` - - - `"int"` - Integer data type (Alias: `"INTEGER"`). - - `"smallint"` - Small integer data type (Alias: `"SMALLINT"`). - - `"bigint"` - Big integer data type (Alias: `"BIGINT"`). - - `"serial"` - Auto-incrementing integer data type (Alias: `"SERIAL"`). - - `"bigserial"` - Auto-incrementing big integer data type (Alias: `"BIGSERIAL"`). - - `"smallserial"` - Auto-incrementing small integer data type (Alias: `"SMALLSERIAL"`). - - `"float"` - Real number data type (Alias: `"REAL"`). - - `"double precision"` - Double-precision floating-point number data type (Alias: `"DOUBLE PRECISION"`). - - `"numeric"` - Numeric data type (Alias: `"NUMERIC"`). - - `"text"` - Text data type. - - `"varchar"` - Variable-length character data type. - - `"char"` - Fixed-length character data type. - - `"boolean"` - Boolean data type. - - `"date"` - Date data type. - - `"time"` - Time data type. - - `"timestamp"` - Timestamp data type. - - `"interval"` - Time interval data type. - - `"uuid"` - UUID (Universally Unique Identifier) data type. - - `"json"` - JSON (JavaScript Object Notation) data type. - - `"jsonb"` - Binary JSON (JavaScript Object Notation) data type. - - `"bytea"` - Binary data type (Array of bytes). - - `"array"` - Array data type. - - `"inet"` - IP network address data type. - - `"cidr"` - Classless Inter-Domain Routing (CIDR) address data type. - - `"macaddr"` - MAC (Media Access Control) address data type. - - `"tsvector"` - Text search vector data type. - - `"point"` - Geometric point data type. - - `"line"` - Geometric line data type. - - `"lseg"` - Geometric line segment data type. - - `"box"` - Geometric box data type. - - `"path"` - Geometric path data type. - - `"polygon"` - Geometric polygon data type. - - `"circle"` - Geometric circle data type. - - `"hstore"` - Key-value pair store data type. - -3. `sqlite` - - `"int"` - Integer data type (Alias: `"INTEGER"`). - - `"smallint"` - Small integer data type (Alias: `"SMALLINT"`). - - `"bigint"` - Big integer data type (Alias: `"BIGINT"`). - - `"float"` - Real number data type (Alias: `"REAL"`). - - `"double precision"` - Double-precision floating-point number data type (Alias: `"DOUBLE"`). - - `"numeric"` - Numeric data type (Alias: `"NUMERIC"`). - - `"text"` - Text data type. - - `"varchar"` - Variable-length character data type. - - `"char"` - Fixed-length character data type. - - `"boolean"` - Boolean data type. - - `"date"` - Date data type. - - `"time"` - Time data type. - - `"timestamp"` - Timestamp data type. - - `"json"` - JSON (JavaScript Object Notation) data type. - - `"blob"` - Binary Large Object (BLOB) data type. +##### Column Datatypes + +In this section we will list all the `datatypes` that are supported for each dialect. + +###### 1. `mysql` + +| Data Type | Description | +| ------------- | ------------------------------------------------- | +| `"int"` | Integer data type. | +| `"smallint"` | Small integer data type. | +| `"bigint"` | Big integer data type. | +| `"float"` | Floating-point number data type. | +| `"double"` | Double-precision floating-point number data type. | +| `"numeric"` | Numeric or decimal data type. | +| `"text"` | Text data type. | +| `"varchar"` | Variable-length character data type. | +| `"char"` | Fixed-length character data type. | +| `"boolean"` | Boolean data type. | +| `"date"` | Date data type. | +| `"time"` | Time data type. | +| `"timestamp"` | Timestamp data type. | +| `"json"` | JSON (JavaScript Object Notation) data type. | +| `"blob"` | Binary Large Object (BLOB) data type. | + +###### 2. `postgres` + +| Data Type | Description | +| -------------------- | ------------------------------------------------------------------------------- | +| `"int"` | Integer data type (Alias: `"INTEGER"`). | +| `"smallint"` | Small integer data type (Alias: `"SMALLINT"`). | +| `"bigint"` | Big integer data type (Alias: `"BIGINT"`). | +| `"serial"` | Auto-incrementing integer data type (Alias: `"SERIAL"`). | +| `"bigserial"` | Auto-incrementing big integer data type (Alias: `"BIGSERIAL"`). | +| `"smallserial"` | Auto-incrementing small integer data type (Alias: `"SMALLSERIAL"`). | +| `"float"` | Real number data type (Alias: `"REAL"`). | +| `"double precision"` | Double-precision floating-point number data type (Alias: `"DOUBLE PRECISION"`). | +| `"numeric"` | Numeric data type (Alias: `"NUMERIC"`). | +| `"text"` | Text data type. | +| `"varchar"` | Variable-length character data type. | +| `"char"` | Fixed-length character data type. | +| `"boolean"` | Boolean data type. | +| `"date"` | Date data type. | +| `"time"` | Time data type. | +| `"timestamp"` | Timestamp data type. | +| `"interval"` | Time interval data type. | +| `"uuid"` | UUID (Universally Unique Identifier) data type. | +| `"json"` | JSON (JavaScript Object Notation) data type. | +| `"jsonb"` | Binary JSON (JavaScript Object Notation) data type. | +| `"bytea"` | Binary data type (Array of bytes). | +| `"array"` | Array data type. | +| `"inet"` | IP network address data type. | +| `"cidr"` | Classless Inter-Domain Routing (CIDR) address data type. | +| `"macaddr"` | MAC (Media Access Control) address data type. | +| `"tsvector"` | Text search vector data type. | +| `"point"` | Geometric point data type. | +| `"line"` | Geometric line data type. | +| `"lseg"` | Geometric line segment data type. | +| `"box"` | Geometric box data type. | +| `"path"` | Geometric path data type. | +| `"polygon"` | Geometric polygon data type. | +| `"circle"` | Geometric circle data type. | +| `"hstore"` | Key-value pair store data type. | + +###### 3. `sqlite` + +| Data Type | Description | +| -------------------- | ------------------------------------------------- | +| `"int"` | Integer data type. | +| `"smallint"` | Small integer data type. | +| `"bigint"` | Big integer data type. | +| `"float"` | Real number data type. | +| `"double precision"` | Double-precision floating-point number data type. | +| `"numeric"` | Numeric data type. | +| `"text"` | Text data type. | +| `"varchar"` | Variable-length character data type. | +| `"char"` | Fixed-length character data type. | +| `"boolean"` | Boolean data type. | +| `"date"` | Date data type. | +| `"time"` | Time data type. | +| `"timestamp"` | Timestamp data type. | +| `"json"` | JSON (JavaScript Object Notation) data type. | +| `"blob"` | Binary Large Object (BLOB) data type. | > Note: Every table is required to have a primary key column and this column should be 1. Let's talk about the `PrimaryKeyColumn` @@ -369,7 +431,7 @@ class Post(Model): The following are the arguments that the `PrimaryKeyColumn` class accepts. | Argument | Description | Type | Default | | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------- | ------------- | -| `type` | The datatype of your primary key. | `str` | `"bigserial`" | +| `type` | The datatype of your primary key. | `str` | `"int`" | | `length` | Optional to specify the length of the type. If passed as `N` with type `T`, it yields an SQL statement with type `T(N)`. | `int` \| `None` | `None` | | `auto_increment`| Optional to specify if the column will automatically increment or not. |`bool` |`False` | |`default` | Optional to specify the default value in a column. |`any` |`None` | @@ -433,8 +495,8 @@ affected_rows = pg_loom.update_one( ColumnValue(name="completed", value=True), ], filters=[ - Filter(column="id", value=1, join_next_filter_with="AND"), - Filter(column="userId", value=1, join_next_filter_with="AND"), + Filter(column="id", value=1, join_next_with="AND"), + Filter(column="userId", value=1, join_next_with="AND"), ], ) ``` @@ -445,9 +507,9 @@ So from the above example we are applying filters while updating a `Post` here a | `column` | The name of the column to apply the filter on | `String` | - | | `value` | The value to filter against | `Any` | - | | `operator` | The comparison operator to use for the filter | `'eq'`, `'neq'`. `'lt'`, `'gt'`, `'leq'`, `'geq'`, `'in'`, `'notIn'`, `'like'` | `'eq'` | -| `join_next_filter_with` | The logical operator to join this filter with the next one | `'AND'`, `'OR'` | `'AND'` | +| `join_next_with` | The logical operator to join this filter with the next one | `'AND'`, `'OR'` | `'AND'` | -> 👉 : **Note:** You can apply either a list of filters or a single filter when filtering records. +> 👍**Pro Tip:** Note You can apply either a list of filters or a single filter when filtering records. #### `ColumnValue` Class @@ -463,8 +525,8 @@ re = pg_loom.update_one( ColumnValue(name="completed", value=True), ], filters=[ - Filter(column="id", value=1, join_next_filter_with="AND"), - Filter(column="userId", value=1, join_next_filter_with="AND"), + Filter(column="id", value=1, join_next_with="AND"), + Filter(column="userId", value=1, join_next_with="AND"), ], ) ``` @@ -493,14 +555,48 @@ posts = pg_loom.find_all( ) ``` -> 👉 **Note:** When utilizing a list of orders, they are applied sequentially, one after the other: +> 👍**Pro Tip:** Note when utilizing a list of orders, they are applied sequentially, one after the other: | Argument | Description | Type | Default | | -------- | ------------------------------------------------------------------------- | ------------------- | ------- | | `column` | The name of the column to order by. | `str` | - | | `order` | The order direction, either `"ASC"` (ascending) or `"DESC"` (descending). | `"ASC"` or `"DESC"` | `"ASC"` | -#### `Include` +#### `Include` Class + +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 | + +#### `Group` Class + +This class is used for data `aggregation` and grouping data in `dataloom`. Below is a table detailing the parameters available for the `Group` class: + +| Argument | Description | Type | Default | Required | +| --------------------------- | ------------------------------------------------------- | --------------------------------------------- | --------- | -------- | +| `column` | The name of the column to group by. | `str` | | Yes | +| `function` | The aggregation function to apply on the grouped data. | `"COUNT" \| "AVG" \| "SUM" \| "MIN" \| "MAX"` | `"COUNT"` | No | +| `having` | Filters to apply to the grouped data. | `list[Having] \| Having \| None` | `None` | No | +| `return_aggregation_column` | Whether to return the aggregation column in the result. | `bool` | `False` | No | + +#### `Having` Class + +This class method is used to specify the filters to be applied on `Grouped` data during `aggregation` in `dataloom`. Below is a table detailing the parameters available for the `Group` class: + +| Argument | Description | Type | Default | Required | +| ---------------- | --------------------------------------------- | -------------------------------------- | ------- | -------- | +| `column` | The name of the column to filter on. | `str` | | `Yes` | +| `operator` | The operator to use for the filter. | [`OPERATOR_LITERAL\|None`](#operators) | `"eq"` | `No` | +| `value` | The value to compare against. | `Any` | | `Yes` | +| `join_next_with` | The SQL operand to join the next filter with. | `"AND" \| "OR"\|None` | `"AND"` | `No` | ### Syncing Tables @@ -533,7 +629,7 @@ The `connect_and_sync` function proves to be very handy as it handles both the d ```py # .... -sqlite_loom = Dataloom( +sqlite_loom = Loom( dialect="sqlite", database="hi.db", logs_filename="sqlite-logs.sql", logging=True ) conn, tables = sqlite_loom.connect_and_sync([Post, User], drop=True, force=True) @@ -642,16 +738,17 @@ print(users) # ? [{'id': 1, 'username': '@miller'}] The `find_all()` method takes in the following arguments: -| Argument | Description | Type | Default | Required | -| ---------- | ---------------------------------------------- | ------------- | ------- | -------- | -| `instance` | The model class to retrieve documents from. | `Model` | `None` | `Yes` | -| `select` | List of fields to select from the documents. | `list[str]` | `None` | `No` | -| `limit` | Maximum number of documents to retrieve. | `int` | `None` | `No` | -| `offset` | Number of documents to skip before retrieving. | `int` | `0` | `No` | -| `order` | List of columns to order the documents by. | `list[Order]` | `None` | `No` | -| `include` | List of related models to eagerly load. | `list[Model]` | `None` | `No` | +| Argument | Description | Type | Default | Required | +| ---------- | ------------------------------------------------------------------------------------------ | ------------------------ | ------- | -------- | +| `instance` | The model class to retrieve documents from. | `Model` | `None` | `Yes` | +| `select` | Collection or a string of fields to select from the documents. | `list[str]\|str` | `None` | `No` | +| `limit` | Maximum number of documents to retrieve. | `int` | `None` | `No` | +| `offset` | Number of documents to skip before retrieving. | `int` | `0` | `No` | +| `order` | Collection of columns to order the documents by. | `list[Order]` | `None` | `No` | +| `include` | Collection or a `Include` of related models to eagerly load. | `list[Include]\|Include` | `None` | `No` | +| `group` | Collection of `Group` which specifies how you want your data to be grouped during queries. | `list[Group]\|Group` | `None` | `No` | -> 👉 **Note:** Note that the `include` argument is not working at the moment. This argument allows us to eagerly load child relationships from the parent model. +> 👍 **Pro Tip**: A collection can be any python iterable, the supported iterables are `list`, `set`, `tuple`. ##### 2. `find_many()` @@ -671,17 +768,18 @@ print(users) # ? [{'id': 1, 'username': '@miller'}] The `find_many()` method takes in the following arguments: -| Argument | Description | Type | Default | Required | -| ---------- | ---------------------------------------------- | ------------------------ | ------- | -------- | -| `instance` | The model class to retrieve documents from. | `Model` | `None` | `Yes` | -| `filters` | List of filters to apply to the query. | `list[Filter] \| Filter` | `None` | `No` | -| `select` | List of fields to select from the documents. | `list[str]` | `None` | `No` | -| `limit` | Maximum number of documents to retrieve. | `int` | `None` | `No` | -| `offset` | Number of documents to skip before retrieving. | `int` | `0` | `No` | -| `order` | List of columns to order the documents by. | `list[Order]` | `None` | `No` | -| `include` | List of related models to eagerly load. | `list[Model]` | `None` | `No` | +| Argument | Description | Type | Default | Required | +| ---------- | ------------------------------------------------------------------------------------------ | ------------------------ | ------- | -------- | +| `instance` | The model class to retrieve documents from. | `Model` | `None` | `Yes` | +| `select` | Collection or a string of fields to select from the documents. | `list[str]\|str` | `None` | `No` | +| `limit` | Maximum number of documents to retrieve. | `int` | `None` | `No` | +| `offset` | Number of documents to skip before retrieving. | `int` | `0` | `No` | +| `order` | Collection of columns to order the documents by. | `list[Order]` | `None` | `No` | +| `include` | Collection or a `Include` of related models to eagerly load. | `list[Include]\|Include` | `None` | `No` | +| `group` | Collection of `Group` which specifies how you want your data to be grouped during queries. | `list[Group]\|Group` | `None` | `No` | +| `filters` | Collection of `Filter` or a `Filter` to apply to the query. | `list[Filter] \| Filter` | `None` | `No` | -> The distinction between the `find_all()` and `find_many()` methods lies in the fact that `find_many()` enables you to apply specific filters, whereas `find_all()` retrieves all the documents within the specified model. +> 👍 **Pro Tip**: The distinction between the `find_all()` and `find_many()` methods lies in the fact that `find_many()` enables you to apply specific filters, whereas `find_all()` retrieves all the documents within the specified model. ##### 3. `find_one()` @@ -698,14 +796,13 @@ print(user) # ? {'id': 1, 'username': '@miller'} This method take the following as arguments -| Argument | Description | Type | Default | Required | -| ------------- | -------------------------------------------------------------------- | -------------------------------- | ------- | -------- | -| `instance` | The model class to retrieve instances from. | `Model` | | `Yes` | -| `filters` | Filter or list of filters to apply to the query. | `Filter \| list[Filter] \| None` | `None` | `No` | -| `select` | List of fields to select from the instances. | `list[str]` | `[]` | `No` | -| `include` | List of related models to eagerly load. | `list[Include]` | `[]` | `No` | -| `return_dict` | Flag indicating whether to return the result as a dictionary or not. | `bool` | `True` | `No` | -| `offset` | Number of instances to skip before retrieving. | `int \| None` | `None` | `No` | +| Argument | Description | Type | Default | Required | +| ---------- | ------------------------------------------------------------------------------------------ | -------------------------------- | ------- | -------- | +| `instance` | The model class to retrieve instances from. | `Model` | | `Yes` | +| `filters` | `Filter` or a collection of `Filter` to apply to the query. | `Filter \| list[Filter] \| None` | `None` | `No` | +| `select` | Collection of `str` or `str` of which is the name of the columns or column to be selected. | `list[str]\|str` | `[]` | `No` | +| `include` | Collection of `Include` or a single `Include` of related models to eagerly load. | `list[Include]\|Include` | `[]` | `No` | +| `offset` | Number of instances to skip before retrieving. | `int \| None` | `None` | `No` | ##### 4. `find_by_pk()` @@ -718,13 +815,12 @@ print(user) # ? {'id': 1, 'username': '@miller'} The method takes the following as arguments: -| Argument | Description | Type | Default | Required | -| ------------- | -------------------------------------------------------------------- | --------------- | ------- | -------- | -| `instance` | The model class to retrieve instances from. | `Model` | | `Yes` | -| `pk` | The primary key value to use for retrieval. | `Any` | | `Yes` | -| `select` | List of fields to select from the instances. | `list[str]` | `[]` | `No` | -| `include` | List of related models to eagerly load. | `list[Include]` | `[]` | `No` | -| `return_dict` | Flag indicating whether to return the result as a dictionary or not. | `bool` | `True` | `No` | +| Argument | Description | Type | Default | Required | +| ---------- | ---------------------------------------------------------------------------------- | --------------- | ------- | -------- | +| `instance` | The model class to retrieve instances from. | `Model` | | `Yes` | +| `pk` | The primary key value to use for retrieval. | `Any` | | `Yes` | +| `select` | Collection column names to select from the instances. | `list[str]` | `[]` | `No` | +| `include` | A Collection of `Include` or a single `Include` of related models to eagerly load. | `list[Include]` | `[]` | `No` | #### 3. Updating a record @@ -750,11 +846,11 @@ affected_rows = mysql_loom.update_by_pk( The above method takes in the following as arguments: -| Argument | Description | Type | Default | Required | -| ---------- | --------------------------------------------------------------- | ---------------------------------- | ------- | -------- | -| `instance` | The model class for which to update the instance. | `Model` | | `Yes` | -| `pk` | The primary key value of the instance to update. | `Any` | | `Yes` | -| `values` | Single or list of column-value pairs to update in the instance. | `ColumnValue \| list[ColumnValue]` | | `Yes` | +| Argument | Description | Type | Default | Required | +| ---------- | -------------------------------------------------------------------------------------- | ---------------------------------- | ------- | -------- | +| `instance` | The model class for which to update the instance. | `Model` | | `Yes` | +| `pk` | The primary key value of the instance to update. | `Any` | | `Yes` | +| `values` | Single or Collection of [`ColumnValue`](#columnvalue-class) to update in the instance. | `ColumnValue \| list[ColumnValue]` | | `Yes` | ##### 2. `update_one()` @@ -764,8 +860,8 @@ Here is an example illustrating how to use the `update_one()` method: affected_rows = mysql_loom.update_one( instance=Post, filters=[ - Filter(column="id", value=8, join_next_filter_with="OR"), - Filter(column="userId", value=1, join_next_filter_with="OR"), + Filter(column="id", value=8, join_next_with="OR"), + Filter(column="userId", value=1, join_next_with="OR"), ], values=[ ColumnValue(name="title", value="Updated?"), @@ -775,11 +871,11 @@ affected_rows = mysql_loom.update_one( The method takes the following as arguments: -| Argument | Description | Type | Default | Required | -| ---------- | --------------------------------------------------------------- | ---------------------------------- | ------- | -------- | -| `instance` | The model class for which to update the instance(s). | `Model` | | `Yes` | -| `filters` | Filter or list of filters to apply to the update query. | `Filter \| list[Filter] \| None` | | `Yes` | -| `values` | Single or list of column-value pairs to update in the instance. | `ColumnValue \| list[ColumnValue]` | | `Yes` | +| Argument | Description | Type | Default | Required | +| ---------- | --------------------------------------------------------------------- | ---------------------------------- | ------- | -------- | +| `instance` | The model class for which to update the instance(s). | `Model` | | `Yes` | +| `filters` | Filter or collection of filters to apply to the update query. | `Filter \| list[Filter] \| None` | | `Yes` | +| `values` | Single or collection of column-value pairs to update in the instance. | `ColumnValue \| list[ColumnValue]` | | `Yes` | ##### 3. `update_bulk()` @@ -789,8 +885,8 @@ The `update_bulk()` method updates all records that match a filter in a database affected_rows = mysql_loom.update_bulk( instance=Post, filters=[ - Filter(column="id", value=8, join_next_filter_with="OR"), - Filter(column="userId", value=1, join_next_filter_with="OR"), + Filter(column="id", value=8, join_next_with="OR"), + Filter(column="userId", value=1, join_next_with="OR"), ], values=[ ColumnValue(name="title", value="Updated?"), @@ -800,11 +896,11 @@ affected_rows = mysql_loom.update_bulk( The above method takes in the following as argument: -| Argument | Description | Type | Default | Required | -| ---------- | --------------------------------------------------------------- | ---------------------------------- | ------- | -------- | -| `instance` | The model class for which to update instances. | `Model` | | `Yes` | -| `filters` | Filter or list of filters to apply to the update query. | `Filter \| list[Filter] \| None` | | `Yes` | -| `values` | Single or list of column-value pairs to update in the instance. | `ColumnValue \| list[ColumnValue]` | | `Yes` | +| Argument | Description | Type | Default | Required | +| ---------- | --------------------------------------------------------------------- | ---------------------------------- | ------- | -------- | +| `instance` | The model class for which to update instances. | `Model` | | `Yes` | +| `filters` | Filter or collection of filters to apply to the update query. | `Filter \| list[Filter] \| None` | | `Yes` | +| `values` | Single or collection of column-value pairs to update in the instance. | `ColumnValue \| list[ColumnValue]` | | `Yes` | #### 4. Deleting a record @@ -841,12 +937,12 @@ affected_rows = mysql_loom.delete_one( The method takes in the following arguments: -| Argument | Description | Type | Default | Required | -| ---------- | ---------------------------------------------------------- | -------------------------------- | ------- | -------- | -| `instance` | The model class from which to delete the instance(s). | `Model` | | `Yes` | -| `filters` | Filter or list of filters to apply to the deletion query. | `Filter \| list[Filter] \| None` | `None` | `No` | -| `offset` | Number of instances to skip before deleting. | `int \| None` | `None` | `No` | -| `order` | List of columns to order the instances by before deletion. | `list[Order] \| None` | `[]` | `No` | +| Argument | Description | Type | Default | Required | +| ---------- | ------------------------------------------------------------------------------------- | -------------------------------- | ------- | -------- | +| `instance` | The model class from which to delete the instance(s). | `Model` | | `Yes` | +| `filters` | Filter or collection of filters to apply to the deletion query. | `Filter \| list[Filter] \| None` | `None` | `No` | +| `offset` | Number of instances to skip before deleting. | `int \| None` | `None` | `No` | +| `order` | Collection of `Order` or as single `Order` to order the instances by before deletion. | `list[Order] \| Order\| None` | `[]` | `No` | ##### 3. `delete_bulk()` @@ -860,13 +956,13 @@ affected_rows = mysql_loom.delete_bulk( The method takes the following as arguments: -| Argument | Description | Type | Default | Required | -| ---------- | ---------------------------------------------------------- | -------------------------------- | ------- | -------- | -| `instance` | The model class from which to delete instances. | `Model` | | `Yes` | -| `filters` | Filter or list of filters to apply to the deletion query. | `Filter \| list[Filter] \| None` | `None` | `No` | -| `limit` | Maximum number of instances to delete. | `int \| None` | `None` | `No` | -| `offset` | Number of instances to skip before deleting. | `int \| None` | `None` | `No` | -| `order` | List of columns to order the instances by before deletion. | `list[Order] \| None` | `[]` | `No` | +| Argument | Description | Type | Default | Required | +| ---------- | ------------------------------------------------------------------------------------ | -------------------------------- | ------- | -------- | +| `instance` | The model class from which to delete instances. | `Model` | | `Yes` | +| `filters` | Filter or collection of filters to apply to the deletion query. | `Filter \| list[Filter] \| None` | `None` | `No` | +| `limit` | Maximum number of instances to delete. | `int \| None` | `None` | `No` | +| `offset` | Number of instances to skip before deleting. | `int \| None` | `None` | `No` | +| `order` | Collection of `Order` or a single `Order` to order the instances by before deletion. | `list[Order] \|Order\| None` | `[]` | `No` | ###### Warning: Potential Risk with `delete_bulk()` @@ -933,7 +1029,7 @@ There are different find of filters that you can use when filtering documents fo res2 = mysql_loom.delete_one( instance=Post, offset=0, - order=[Order(column="id", order="DESC")], + order=Order(column="id", order="DESC"), filters=Filter(column="id", value=1, operator="gt"), ) ``` @@ -949,7 +1045,7 @@ res2 = mysql_loom.delete_one( ) ``` -As you have noticed, you can join your filters together and they will be applied sequentially using the [`join_next_filter_with`](#filter-class) which can be either `OR` or `AND` te default value is `AND`. Here is an of filter usage in sequential. +As you have noticed, you can join your filters together and they will be applied sequentially using the [`join_next_with`](#filter-class) which can be either `OR` or `AND` te default value is `AND`. Here is an of filter usage in sequential. ```py res2 = mysql_loom.delete_one( @@ -958,17 +1054,19 @@ res2 = mysql_loom.delete_one( order=[Order(column="id", order="DESC")], filters=[ Filter(column="id", value=1, operator="gt"), - Filter(column="userId", value=1, operator="eq", join_next_filter_with="OR"), + Filter(column="userId", value=1, operator="eq", join_next_with="OR"), Filter( column="title", value='"What are you doing general?"', operator="=", - join_next_filter_with="AND", + join_next_with="AND", ), ], ) ``` +##### Operators + You can use the `operator` to match the values. Here is the table of description for these filters. | Operator | Explanation | Expect | @@ -1125,13 +1223,72 @@ The following table show you some expression that you can use with this `like` o | `[!charlist]%` | Finds values that start with any character not in the specified character list. | | `_pattern_` | Finds values that have any single character followed by the specified pattern and then followed by any single character. | +### Data Aggregation + +With the [`Having`](#having-class) and the [`Group`](#group-class) classes you can perform some powerful powerful queries. In this section we are going to demonstrate an example of how we can do the aggregate queries. + +```py +posts = mysql_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=True, + ), +) +``` + +The following will be the output from the above query. + +```shell +[{'id': 2, 'MAX(`id`)': 2}, {'id': 3, 'MAX(`id`)': 3}, {'id': 4, 'MAX(`id`)': 4}] +``` + +However you can remove the aggregation column from the above query by specifying the `return_aggregation_column` to be `False`: + +```py +posts = mysql_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), +) +print(posts) +``` + +This will output: + +```shell +[{'id': 2}, {'id': 3}, {'id': 4}] +``` + +#### Aggregation Functions + +You can use the following aggregation functions that dataloom supports: + +| Aggregation Function | Description | +| -------------------- | ------------------------------------------------ | +| `"AVG"` | Computes the average of the values in the group. | +| `"COUNT"` | Counts the number of items in the group. | +| `"SUM"` | Computes the sum of the values in the group. | +| `"MAX"` | Retrieves the maximum value in the group. | +| `"MIN"` | Retrieves the minimum value in the group. | + +> 👍 **Pro Tip**: Note that data aggregation only works without `eager` loading and also works only with [`find_may()`](#2-find_many) and [`find_all()`](#1-find_all) in dataloom. + ### Utilities Dataloom comes up with some utility functions that works on an instance of a model. This is very useful when debuging your tables to see how do they look like. These function include: -1. `inspect()` - -#### `inspect` +#### 1. `inspect` This function takes in a model as argument and inspect the model fields or columns. The following examples show how we can use this handy function in inspecting table names. @@ -1167,11 +1324,583 @@ Output: The `inspect` function take the following arguments. +| Argument | Description | Type | Default | Required | +| ------------- | ------------------------------------------------------ | ----------- | ----------------------------------------- | -------- | +| `instance` | The model instance to inspect. | `Model` | - | Yes | +| `fields` | The list of fields to include in the inspection. | `list[str]` | `["name", "type", "nullable", "default"]` | No | +| `print_table` | Flag indicating whether to print the inspection table. | `bool` | `True` | No | + +#### 2. `decorators` + +These modules contain several decorators that can prove useful when creating models. These decorators originate from `dataloom.decorators`, and at this stage, we are referring to them as "experimental." + +##### `@initialize` + +Let's examine a model named `Profile`, which appears as follows: + +```py +class Profile(Model): + __tablename__: Optional[TableColumn] = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) +``` + +This is simply a Python class that inherits from the top-level class `Model`. However, it lacks some useful `dunder` methods such as `__init__` and `__repr__`. In standard Python, we can achieve this functionality by using `dataclasses`. For example, we can modify our class as follows: + +```py +from dataclasses import dataclass + +@dataclass +class Profile(Model): + # .... + +``` + +However, this approach doesn't function as expected in `dataloom` columns. Hence, we've devised these experimental decorators to handle the generation of essential dunder methods required for working with `dataloom`. If you prefer not to use decorators, you always have the option to manually create these dunder methods. Here's an example: + +```py +class Profile(Model): + # ... + def __init__(self, id: int | None, avatar: str | None, userId: int | None) -> None: + self.id = id + self.avatar = avatar + self.userId = userId + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}:id={self.id}>" + + @property + def to_dict(self): + return {"id": self.id, "avatar": self.avatar, "userId": self.userId} +``` + +However, by using the `initialize` decorator, this functionality will be automatically generated for you. Here's all you need to do: + +```py +from dataloom.decorators import initialize + +@initialize(repr=True, to_dict=True, init=True, repr_identifier="id") +class Profile(Model): + # ... +``` + +> 👉 **Tip**: Dataloom has a clever way of skipping the `TableColumn` because it doesn't matter in this case. + +The `initialize` decorator takes the following arguments: + +| Argument | Description | Type | Default | Required | +| ----------------- | ----------------------------------------------------------- | --------------- | ------- | -------- | +| `to_dict` | Flag indicating whether to generate a `to_dict` method. | `bool` | `False` | `No` | +| `init` | Flag indicating whether to generate an `__init__` method. | `bool` | `True` | `No` | +| `repr` | Flag indicating whether to generate a `__repr__` method. | `bool` | `False` | `No` | +| `repr_identifier` | Identifier for the attribute used in the `__repr__` method. | `str` or `None` | `None` | `No` | + +> 👍**Pro Tip:** Note that this `decorator` function allows us to interact with our data from the database in an object-oriented way in Python. Below is an example illustrating this concept: + +```py +profile = mysql_loom.find_by_pk(Profile, pk=1, select=["avatar", "id"]) +profile = Profile(**profile) +print(profile) # ? = +print(profile.avatar) # ? hello.jpg +``` + +### Associations + +In dataloom you can create association using the `foreign-keys` column during model creation. You just have to specify a single model to have a relationship with another model using the [`ForeignKeyColum`](#foreignkeycolumn-class). Just by doing that dataloom will be able to learn bidirectional relationship between your models. Let's have a look at the following examples: + +#### 1. `1-1` Association + +Let's consider an example where we want to map the relationship between a `User` and a `Profile`: + +```py +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) + tokenVersion = Column(type="int", default=0) + +class Profile(Model): + __tablename__: Optional[TableColumn] = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + +``` + +The above code demonstrates how to establish a `one-to-one` relationship between a `User` and a `Profile` using the `dataloom`. + +- `User` and `Profile` are two model classes inheriting from `Model`. +- Each model is associated with a corresponding table in the database, defined by the `__tablename__` attribute. +- Both models have a primary key column (`id`) defined using `PrimaryKeyColumn`. +- Additional columns (`name`, `username`, `tokenVersion` for `User` and `avatar`, `userId` for `Profile`) are defined using `Column`. +- The `userId` column in the `Profile` model establishes a foreign key relationship with the `id` column of the `User` model using `ForeignKeyColumn`. This relationship is specified to be a `one-to-one` relationship (`maps_to="1-1"`). +- Various constraints such as `nullable`, `unique`, `default`, and foreign key constraints (`onDelete`, `onUpdate`) are specified for the columns. + +##### Inserting + +In the following code example we are going to demonstrate how we can create a `user` with a `profile`, first we need to create a user first so that we get reference to the user of the profile that we will create. + +```py +userId = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), +) + +profileId = mysql_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], +) +``` + +This Python code snippet demonstrates how to insert data into the database using the `mysql_loom.insert_one` method, it also work on other methods like `insert_bulk`. + +1. **Inserting a User Record**: + + - The `mysql_loom.insert_one` method is used to insert a new record into the `User` table. + - The `instance=User` parameter specifies that the record being inserted belongs to the `User` model. + - The `values=ColumnValue(name="username", value="@miller")` parameter specifies the values to be inserted into the `User` table, where the `username` column will be set to `"@miller"`. + - The ID of the newly inserted record is obtained and assigned to the variable `userId`. + +2. **Inserting a Profile Record**: + - Again, the `mysql_loom.insert_one` method is called to insert a new record into the `Profile` table. + - The `instance=Profile` parameter specifies that the record being inserted belongs to the `Profile` model. + - The `values` parameter is a list containing two `ColumnValue` objects: + - The first `ColumnValue` object specifies that the `userId` column of the `Profile` table will be set to the `userId` value obtained from the previous insertion. + - The second `ColumnValue` object specifies that the `avatar` column of the `Profile` table will be set to `"hello.jpg"`. + - The ID of the newly inserted record is obtained and assigned to the variable `profileId`. + +##### Retrieving Records + +The following example shows you how you can retrieve the data in a associations + +```py +profile = mysql_loom.find_one( + instance=Profile, + select=["id", "avatar"], + filters=Filter(column="userId", value=userId), +) +user = mysql_loom.find_by_pk( + instance=User, + pk=userId, + select=["id", "username"], +) +user_with_profile = {**user, "profile": profile} +print(user_with_profile) # ? = {'id': 1, 'username': '@miller', 'profile': {'id': 1, 'avatar': 'hello.jpg'}} +``` + +This Python code snippet demonstrates how to query data from the database using the `mysql_loom.find_one` and `mysql_loom.find_by_pk` methods, and combine the results of these two records that have association. + +1. **Querying a Profile Record**: + + - The `mysql_loom.find_one` method is used to retrieve a single record from the `Profile` table. + - The `filters=Filter(column="userId", value=userId)` parameter filters the results to only include records where the `userId` column matches the `userId` value obtained from a previous insertion. + +2. **Querying a User Record**: + + - The `mysql_loom.find_by_pk` method is used to retrieve a single record from the `User` table based on its primary key (`pk=userId`). + - The `instance=User` parameter specifies that the record being retrieved belongs to the `User` model. + - The `select=["id", "username"]` parameter specifies that only the `id` and `username` columns should be selected. + - The retrieved user data is assigned to the variable `user`. + +3. **Combining User and Profile Data**: + - The user data (`user`) and profile data (`profile`) are combined into a single dictionary (`user_with_profile`) using dictionary unpacking (`{**user, "profile": profile}`). + - This dictionary represents a user with their associated profile. + +> 🏒 We have realized that we are performing three steps when querying records, which can be verbose. However, in dataloom, we have introduced `eager` data fetching for all methods that retrieve data from the database. The following example demonstrates how we can achieve the same result as before using eager loading: + +```python +# With eager loading +user_with_profile = mysql_loom.find_by_pk( + instance=User, + pk=userId, + select=["id", "username"], + include=[Include(model=Profile, select=["id", "avatar"], has="one")], +) +print(user_with_profile) # ? = {'id': 1, 'username': '@miller', 'profile': {'id': 1, 'avatar': 'hello.jpg'}} +``` + +This Python code snippet demonstrates how to use eager loading with the `mysql_loom.find_by_pk` method to efficiently retrieve data from the `User` and `Profile` tables in a single query. + +- Eager loading allows us to retrieve related data from multiple tables in a single database query, reducing the need for multiple queries and improving performance. +- In this example, the `include` parameter is used to specify eager loading for the `Profile` model associated with the `User` model. +- By including the `Profile` model with the `User` model in the `find_by_pk` method call, we instruct the database to retrieve both the user data (`id` and `username`) and the associated profile data (`id` and `avatar`) in a single query. +- This approach streamlines the data retrieval process and minimizes unnecessary database calls, leading to improved efficiency and performance in applications. + +#### 2. `N-1` Association + +Models can have `Many` to `One` relationship, it depends on how you define them. Let's have a look at the relationship between a `Category` and a `Post`. Many categories can belong to a single post. + +```py +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() + # relations + userId = ForeignKeyColumn( + User, + maps_to="1-N", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + +class Category(Model): + __tablename__: Optional[TableColumn] = TableColumn(name="categories") + id = PrimaryKeyColumn(type="int", auto_increment=True, nullable=False, unique=True) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + +``` + +In the provided code, we have two models: `Post` and `Category`. The relationship between these two models can be described as a `Many-to-One` relationship. + +This means that many categories can belong to a single post. In other words: + +- For each `Post` instance, there can be multiple `Category` instances associated with it. +- However, each `Category` instance can only be associated with one `Post`. + +For example, consider a blogging platform where each `Post` represents an article and each `Category` represents a topic or theme. Each article (post) can be assigned to multiple topics (categories), such as "Technology", "Travel", "Food", etc. However, each topic (category) can only be associated with one specific article (post). + +This relationship allows for a hierarchical organization of data, where posts can be categorized into different topics or themes represented by categories. + +##### Inserting + +Let's illustrate the following example where we insert categories into a post with the `id` 1. + +```py +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), + ], + ) + +for cat in ["general", "education", "tech", "sport"]: + mysql_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=1), + ColumnValue(name="type", value=cat), + ], + ) +``` + +- **Inserting Posts** + We're inserting new posts into the `Post` table. Each post is associated with a user (`userId`), and we're iterating over a list of titles to insert multiple posts. + +- **Inserting Categories** + We're inserting new categories into the `Category` table. Each category is associated with a specific post (`postId`), and we're inserting categories for a post with `id` 1. + +> In summary, we're creating a relationship between posts and categories by inserting records into their respective tables. Each category record is linked to a specific post record through the `postId` attribute. + +##### Retrieving Records + +Let's attempt to retrieve a post with an ID of `1` along with its corresponding categories. We can achieve this as follows: + +```py +post = mysql_loom.find_by_pk(Post, 1, select=["id", "title"]) +categories = mysql_loom.find_many( + Category, + select=["type", "id"], + filters=Filter(column="postId", value=1), + order=[ + Order(column="id", order="DESC"), + ], +) +post_with_categories = {**post, "categories": categories} +print(post_with_categories) # ? = {'id': 1, 'title': 'Hey', 'categories': [{'type': 'sport', 'id': 4}, {'type': 'tech', 'id': 3}, {'type': 'education', 'id': 2}, {'type': 'general', 'id': 1}]} +``` + +- We use the `mysql_loom.find_by_pk()` method to retrieve a single post (`Post`) with an `id` equal to 1. We select only specific columns (`id` and `title`) for the post. +- We use the `mysql_loom.find_many()` method to retrieve multiple categories (`Category`) associated with the post. We select only specific columns (`type` and `id`) for the categories. We apply a filter to only fetch categories associated with the post with `postId` equal to 1. We sort the categories based on the `id` column in descending order. +- We create a dictionary (`post_with_categories`) that contains the retrieved post and its associated categories. The post information is stored under the key `post`, and the categories information is stored under the key `categories`. + +> The above task can be accomplished using `eager` document retrieval as shown below. + +```py +post_with_categories = mysql_loom.find_by_pk( + Post, + 1, + select=["id", "title"], + include=[ + Include( + model=Category, + select=["type", "id"], + order=[ + Order(column="id", order="DESC"), + ], + ) + ], +) + +``` + +The code snippet queries a database to retrieve a post with an `id` of `1` along with its associated categories. Here's a breakdown: + +1. **Querying for Post**: + + - The `mysql_loom.find_by_pk()` method fetches a single post from the database. + - It specifies the `Post` model and ID `1`, retrieving only the `id` and `title` columns. + +2. **Including Categories**: + + - The `include` parameter specifies additional related data to fetch. + - Inside `include`, an `Include` instance is created for categories related to the post. + - It specifies the `Category` model and selects only the `type` and `id` columns. + - Categories are ordered by `id` in descending order. + +3. **Result**: + - The result is stored in `post_with_categories`, containing the post information and associated categories. + +> In summary, this code is retrieving a specific post along with its categories from the database, and it's using `eager` loading to efficiently fetch related data in a single query. + +#### 3. `1-N` Association + +Let's consider a scenario where a `User` has multiple `Post`. here is how the relationships are mapped. + +```py +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) + tokenVersion = Column(type="int", default=0) + +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() + # relations + userId = ForeignKeyColumn( + User, + maps_to="1-N", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE" + ) +``` + +So clearly we can see that when creating a `post` we need to have a `userId` + +##### Inserting + +Here is how we can insert a user and a post to the database tables. + +```py +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), + ], + ) +``` + +We're performing database operations to insert records for a user and multiple posts associated with that user. + +- We insert a user record into the database using `mysql_loom.insert_one()` method. +- We iterate over a list of titles. +- For each title in the list, we insert a new post record into the database. +- Each post is associated with the user we inserted earlier, identified by the `userId`. +- The titles for the posts are set based on the titles in the list. + +##### Retrieving Records + +Now let's query the user with his respective posts. we can do it as follows: + +```py +user = mysql_loom.find_by_pk( + User, + 1, + select=["id", "username"], +) +posts = mysql_loom.find_many( + Post, + filters=Filter(column="userId", value=userId, operator="eq"), + select=["id", "title"], + order=[Order(column="id", order="DESC")], + limit=2, + offset=1, +) + +user_with_posts = {**user, "posts": posts} +print( + user_with_posts +) # ? = {'id': 1, 'username': '@miller', 'posts': [{'id': 3, 'title': 'What are you doing'}, {'id': 2, 'title': 'Hello'}]} +``` + +We're querying the database to retrieve information about a `user` and their associated `posts`. + +1. **Querying User**: + + - We use `mysql_loom.find_by_pk()` to fetch a single user record from the database. + - The user's ID is specified as `1`. + - We select only the `id` and `username` columns for the user. + +2. **Querying Posts**: + + - We use `mysql_loom.find_many()` to retrieve multiple post records associated with the user. + - A filter is applied to only fetch posts where the `userId` matches the ID of the user retrieved earlier. + - We select only the `id` and `title` columns for the posts. + - The posts are ordered by the `id` column in descending order. + - We set a limit of `2` posts to retrieve, and we skip the first post using an offset of `1`. + - We create a dictionary `user_with_posts` containing the user information and a list of their associated posts under the key `"posts"`. + +With eager loading this can be done as follows the above can be done as follows: + +```py +user_with_posts = mysql_loom.find_by_pk( + User, + 1, + select=["id", "username"], + include=[ + Include( + model=Post, + select=["id", "title"], + order=[Order(column="id", order="DESC")], + limit=2, + offset=1, + ) + ], +) +print( + user_with_posts +) # ? = {'id': 1, 'username': '@miller', 'posts': [{'id': 3, 'title': 'What are you doing'}, {'id': 2, 'title': 'Hello'}]} +``` + +- We use `mysql_loom.find_by_pk()` to fetch a single user record from the database. +- The user's ID is specified as `1`. +- We select only the `id` and `username` columns for the user. +- Additionally, we include associated post records using `eager` loading. +- Inside the `include` parameter, we specify the `Post` model and select only the `id` and `title` columns for the posts. +- The posts are ordered by the `id` column in descending order. +- We set a limit of `2` posts to retrieve, and we skip the first post using an offset of `1`. + +#### 4. What about bidirectional queries? + +In Dataloom, we support bidirectional relations with eager loading on-the-fly. You can query from a `parent` to a `child` and from a `child` to a `parent`. You just need to know how the relationship is mapped between these two models. In this case, the `has` option is very important in the `Include` class. Here are some examples demonstrating bidirectional querying between `user` and `post`, where the `user` is the parent table and the `post` is the child table in this case. + +##### 1. Child to Parent + +Here is an example illustrating how we can query a parent from child table. + +```py +posts_users = mysql_loom.find_many( + Post, + limit=2, + offset=3, + order=[Order(column="id", order="DESC")], + select=["id", "title"], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ), + Include( + model=Category, + select=["id", "type"], + order=[Order(column="id", order="DESC")], + has="many", + limit=2, + ), + ], +) +print(posts_users) # ? = [{'id': 1, 'title': 'Hey', 'user': {'id': 1, 'username': '@miller', 'profile': {'id': 1, 'avatar': 'hello.jpg'}}, 'categories': [{'id': 4, 'type': 'sport'}, {'id': 3, 'type': 'tech'}]}] +``` + +##### 2. Parent to Child + +Here is an example of how we can query a child table from parent table + +```py +user_post = mysql_loom.find_by_pk( + User, + pk=userId, + select=["id", "username"], + include=[ + Include( + model=Post, + limit=2, + offset=3, + order=[Order(column="id", order="DESC")], + select=["id", "title"], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["id", "avatar"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + order=[Order(column="id", order="DESC")], + has="many", + limit=2, + ), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], +) + + +print(user_post) """ ? = +{'id': 1, 'username': '@miller', 'user': {'id': 1, 'username': '@miller', 'profile': {'id': 1, 'avatar': 'hello.jpg'}}, 'categories': [{'id': 4, 'type': 'sport'}, {'id': 3, 'type': 'tech'}], 'posts': [{'id': 1, 'title': 'Hey', 'user': {'id': 1, 'username': '@miller', 'profile': {'id': 1, 'avatar': 'hello.jpg'}}, 'categories': [{'id': 4, 'type': 'sport'}, {'id': 3, 'type': 'tech'}]}], 'profile': {'id': 1, 'avatar': 'hello.jpg'}} +""" + +``` + ### What is coming next? -1. Associations -2. Grouping -3. Altering tables +1. N-N associations +2. Altering tables ### Contributing diff --git a/dataloom/__init__.py b/dataloom/__init__.py index b4e4bf3..1ecf653 100644 --- a/dataloom/__init__.py +++ b/dataloom/__init__.py @@ -1,6 +1,5 @@ -from dataloom.loom import Dataloom - -from dataloom.types import Order, Include, Filter, ColumnValue +from dataloom.loom import Loom +from dataloom.types import Order, Include, Filter, ColumnValue, Group, Having from dataloom.model import Model from dataloom.columns import ( PrimaryKeyColumn, @@ -21,7 +20,9 @@ ForeignKeyColumn, UpdatedAtColumn, Column, - Dataloom, + Loom, TableColumn, Model, + Group, + Having, ] diff --git a/dataloom/decorators/__init__.py b/dataloom/decorators/__init__.py new file mode 100644 index 0000000..d3f7681 --- /dev/null +++ b/dataloom/decorators/__init__.py @@ -0,0 +1,137 @@ +from dataloom.exceptions import InvalidPropertyException +from dataloom.columns import ( + PrimaryKeyColumn, + Column, + CreatedAtColumn, + UpdatedAtColumn, + ForeignKeyColumn, +) +import typing, dataloom # noqa +import inspect + + +def initialize( + to_dict: bool = False, + init: bool = True, + repr: bool = False, + repr_identifier: str | None = None, +): + """ + initialize + ---------- + Constructor method for the initialize decorator. + + Parameters + ---------- + to_dict : bool, optional + If True, generates a to_dict method for the decorated class. Default is False. + init : bool, optional + If True, generates an __init__ method for the decorated class. Default is True. + repr : bool, optional + If True, generates a __repr__ method for the decorated class. Default is False. + repr_identifier : str | None, optional + The identifier to be used in the __repr__ method. Default is None. + + Returns + ------- + Callable[[Any], type[wrapper]] + A callable that takes a class and returns a wrapped version of it. + + Examples + -------- + >>> from dataloom import ( + ... Loom, + ... Model, + ... PrimaryKeyColumn, + ... Column, + ... CreatedAtColumn, + ... UpdatedAtColumn, + ... TableColumn, + ... ForeignKeyColumn, + ... Filter, + ... ColumnValue, + ... Include, + ... Order, + ... experimental_decorators, + ... ) + ... + ... @initialize(repr=True, to_dict=True, init=True, repr_identifier="id") + ... class Profile(Model): + ... __tablename__: Optional[TableColumn] = TableColumn(name="profiles") + ... id = PrimaryKeyColumn(type="int", auto_increment=True) + ... avatar = Column(type="text", nullable=False) + ... userId = ForeignKeyColumn( + ... User, + ... maps_to="1-1", + ... type="int", + ... required=True, + ... onDelete="CASCADE", + ... onUpdate="CASCADE", + ... ) + + + """ + + def fn(cls): + args = [] + for name, field in inspect.getmembers(cls): + if isinstance(field, PrimaryKeyColumn): + args.append(name) + elif isinstance(field, Column): + args.append(name) + elif isinstance(field, CreatedAtColumn): + args.append(name) + elif isinstance(field, UpdatedAtColumn): + args.append(name) + elif isinstance(field, ForeignKeyColumn): + args.append(name) + + class wrapper(cls): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + init_code = "" + + if init: + init_args = ", ".join([f"{p} = None" for p in args]) + init_code += f"def __init__(self, {init_args}) -> None:\n" + for attr_name in args: + init_code += f" self.{attr_name} = {attr_name}\n" + + init_code += "\n" + if to_dict: + init_code += "@property\n" + init_code += "def to_dict(self) -> dict:\n" + init_code += " return {\n" + for key in args: + init_code += f" '{key}' : self.{key},\n" + init_code += " }\n\n" + + if repr_identifier is None: + identifier = args[0] + else: + identifier = repr_identifier + if repr_identifier not in args: + raise InvalidPropertyException( + f"'{cls.__name__}' has no property '{repr_identifier}'." + ) + + if repr: + init_code += "def __repr__(self) -> str:\n" + init_code += ( + f" return f'<{cls.__name__}:{identifier}={{self.{identifier}}}>'\n\n" + ) + + local_ns = {} + # Execute the dynamically generated methods + exec(init_code, globals(), local_ns) + wrapper.__init__ = local_ns["__init__"] + wrapper.__repr__ = local_ns.get("__repr__") + wrapper.to_dict = local_ns.get("to_dict") + wrapper.__name__ = cls.__name__ + return wrapper + + return fn + + +__all__ = [initialize] diff --git a/dataloom/exceptions/__init__.py b/dataloom/exceptions/__init__.py index 8dea18c..2ee3982 100644 --- a/dataloom/exceptions/__init__.py +++ b/dataloom/exceptions/__init__.py @@ -6,6 +6,10 @@ class InvalidArgumentsException(Exception): pass +class InvalidPropertyException(Exception): + pass + + class TooManyPkException(Exception): pass @@ -26,9 +30,17 @@ class InvalidColumnValuesException(ValueError): pass +class InvalidFilterValuesException(ValueError): + pass + + class UnknownColumnException(ValueError): pass class InvalidOperatorException(ValueError): pass + + +class UnknownRelationException(Exception): + pass diff --git a/dataloom/keys.py b/dataloom/keys.py index a519747..6129f81 100644 --- a/dataloom/keys.py +++ b/dataloom/keys.py @@ -1,4 +1,4 @@ -push = False +push = True class PgConfig: diff --git a/dataloom/loom/__init__.py b/dataloom/loom/__init__.py index 6c36244..7e86f0a 100644 --- a/dataloom/loom/__init__.py +++ b/dataloom/loom/__init__.py @@ -23,13 +23,14 @@ Filter, ColumnValue, SQL_LOGGER_LITERAL, + Group, ) -from dataloom.loom.interfaces import IDataloom +from dataloom.loom.interfaces import ILoom -class Dataloom(IDataloom): +class Loom(ILoom): """ - Dataloom + Loom -------- This class allows you to define a loom object for your database connection. @@ -55,8 +56,8 @@ class Dataloom(IDataloom): Examples -------- - >>> from dataloom import Dataloom - ... loom = Dataloom( + >>> from dataloom import Loom + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -77,43 +78,6 @@ def __init__( sql_logger: Optional[SQL_LOGGER_LITERAL] = None, logs_filename: Optional[str] = "dataloom.sql", ) -> None: - """ - Dataloom - -------- - - This class allows you to define a loom object for your database connection. - - Parameters - ---------- - database : str - The name of the database to which you will connect, for PostgreSQL or MySQL, and the file name for SQLite. - dialect : "mysql" | "postgres" | "sqlite" - The database dialect to which you want to connect; it is required. - user : str, optional - The database username with which you want to connect. It defaults to the dialect's default values. - host : str, optional - The database host to which you will connect. It defaults to the dialect's default values. - port : int, optional - The database port to which you will connect. It defaults to the dialect's default values. - password : str, optional - The database password for the specified user. It defaults to the dialect's default value. - sql_logger : "console" | "file" - The default logging platform for SQL statements. It defaults to None for no logs on either file or console. - logs_filename : str, optional - The logging file name for SQL statement logs if the sql_logger is set to "file"; otherwise, it defaults to "dataloom.sql". - - Examples - -------- - >>> from dataloom import Dataloom - ... loom = Dataloom( - ... dialect="postgres", - ... database="hi", - ... password="root", - ... user="postgres", - ... sql_logger="console", - ... ) - - """ self.database = database self.conn = None self.sql_logger = sql_logger @@ -170,7 +134,7 @@ def insert_one( Examples -------- - >>> from dataloom import Dataloom, Model, ColumnValue, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, ColumnValue, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional ... ... class User(Model): @@ -179,7 +143,7 @@ def insert_one( ... name = Column(type="text", nullable=False) ... username = Column(type="varchar", unique=True, length=255) ... - ... loom = Dataloom( + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -222,7 +186,7 @@ def insert_bulk(self, instance: Model, values: list[list[ColumnValue]]): Examples -------- - >>> from dataloom import Dataloom, Model, ColumnValue, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, ColumnValue, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional ... ... class User(Model): @@ -231,7 +195,7 @@ def insert_bulk(self, instance: Model, values: list[list[ColumnValue]]): ... name = Column(type="text", nullable=False) ... username = Column(type="varchar", unique=True, length=255) ... - ... loom = Dataloom( + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -256,12 +220,12 @@ def find_many( self, instance: Model, filters: Optional[Filter | list[Filter]] = None, - select: list[str] = [], - include: list[Model] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], + group: Optional[list[Group] | Group] = [], ) -> list: """ find_many @@ -275,22 +239,22 @@ def find_many( An instance of a Model class representing the table from which the rows need to be retrieved. filters : Filter | list[Filter] | None, optional Filters to apply when selecting the rows. It can be a single Filter object, a list of Filter objects, or None to apply no filters. Default is None. - select : list[str], optional + select : list[str] | str, optional Columns to select in the query. Default is an empty list, which selects all columns. - include : list[Model], optional - Models to include in the query (e.g., for JOIN operations). - return_dict : bool, optional - If True, returns results as dictionaries. If False, returns results as instances of the Model class. Default is True. + include : list[Include] | Include, optional + Include instances that contain Models to include in the query (e.g., for JOIN operations). limit : int | None, optional The maximum number of rows to retrieve. Default is None. offset : int | None, optional The offset of the rows to retrieve, useful for pagination. Default is None. - order : list[Order] | None, optional + order : list[Order] | Order, optional The order in which to retrieve rows. Default is an empty list. + group : list[Group] | Group, optional + The grouping of the retrieved rows. Default is an empty list. Returns ------- - rows: list + rows : list A list of retrieved rows. See Also @@ -301,17 +265,17 @@ def find_many( Examples -------- - >>> from dataloom import Dataloom, Model, Filter, TableColumn, PrimaryKeyColumn, Column - ... from typing import Optional - ... - ... class User(Model): + >>> from dataloom import Loom, Model, Filter, TableColumn, PrimaryKeyColumn, Column + >>> from typing import Optional + >>> + >>> 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) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + >>> loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -319,10 +283,9 @@ def find_many( ... sql_logger="console", ... ) ... - ... # Retrieving users where id is greater than 2 - ... users = loom.find_many(User, filters=[Filter(column="id", value=2, operator="gt")]) - ... print(users) - + >>> # Retrieving users where id is greater than 2 + >>> users = loom.find_many(User, filters=[Filter(column="id", value=2, operator="gt")]) + >>> print(users) """ return query(dialect=self.dialect, _execute_sql=self._execute_sql).find_many( instance=instance, @@ -331,19 +294,19 @@ def find_many( include=include, offset=offset, filters=filters, - return_dict=return_dict, order=order, + group=group, ) def find_all( self, instance: Model, - select: list[str] = [], - include: list[Include] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], + group: Optional[list[Group] | Group] = [], ) -> list: """ find_all @@ -359,8 +322,6 @@ def find_all( Columns to select in the query. Default is an empty list, which selects all columns. include : list[Include], optional Models to include in the query (e.g., for JOIN operations). - return_dict : bool, optional - If True, returns results as dictionaries. If False, returns results as instances of the Model class. Default is True. limit : int | None, optional The maximum number of rows to retrieve. Default is None. offset : int | None, optional @@ -381,7 +342,7 @@ def find_all( Examples -------- - >>> from dataloom import Dataloom, Model, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional ... ... class User(Model): @@ -391,7 +352,7 @@ def find_all( ... username = Column(type="varchar", unique=True, length=255) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -408,19 +369,18 @@ def find_all( instance=instance, select=select, include=include, - return_dict=return_dict, limit=limit, offset=offset, order=order, + group=group, ) def find_by_pk( self, instance: Model, pk, - select: list[str] = [], - include: list[Include] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], ): """ find_by_pk @@ -434,12 +394,10 @@ def find_by_pk( An instance of a Model class representing the table from which the row needs to be retrieved. pk : Any The primary key value of the row to be retrieved. - select : list[str], optional + select : list[str] | str, optional Columns to select in the query. Default is an empty list, which selects all columns. - include : list[Include], optional + include : list[Include] | Include, optional Models to include in the query (e.g., for JOIN operations). - return_dict : bool, optional - If True, returns the result as a dictionary. If False, returns the result as an instance of the Model class. Default is True. Returns ------- @@ -454,17 +412,17 @@ def find_by_pk( Examples -------- - >>> from dataloom import Dataloom, Model, TableColumn, PrimaryKeyColumn, Column - ... from typing import Optional - ... - ... class User(Model): + >>> from dataloom import Loomdel, TableColumn, PrimaryKeyColumn, Column + >>> from typing import Optional + >>> + >>> 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) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + >>> loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -472,15 +430,14 @@ def find_by_pk( ... sql_logger="console", ... ) ... - ... # Retrieving the user with id=1 - ... user = loom.find_by_pk(User, pk=1) - ... print(user) - + >>> # Retrieving the user with id=1 + >>> user = loom.find_by_pk(User, pk=1) + >>> print(user) """ + return query(dialect=self.dialect, _execute_sql=self._execute_sql).find_by_pk( include=include, pk=pk, - return_dict=return_dict, select=select, instance=instance, ) @@ -489,9 +446,8 @@ def find_one( self, instance: Model, filters: Optional[Filter | list[Filter]] = None, - select: list[str] = [], - include: list[Include] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], offset: Optional[int] = None, ): """ @@ -506,12 +462,10 @@ def find_one( An instance of a Model class representing the table from which the row needs to be retrieved. filters : Filter | list[Filter] | None, optional Filters to apply when selecting the row. It can be a single Filter object, a list of Filter objects, or None to apply no filters. Default is None. - select : list[str], optional + select : list[str] | str, optional Columns to select in the query. Default is an empty list, which selects all columns. - include : list[Include], optional + include : list[Include] | Include, optional Models to include in the query (e.g., for JOIN operations). - return_dict : bool, optional - If True, returns the result as a dictionary. If False, returns the result as an instance of the Model class. Default is True. offset : int | None, optional The offset of the row to retrieve, useful for pagination. Default is None. @@ -528,17 +482,17 @@ def find_one( Examples -------- - >>> from dataloom import Dataloom, Model, Filter, TableColumn, PrimaryKeyColumn, Column - ... from typing import Optional - ... - ... class User(Model): + >>> from dataloom import Loomter, TableColumn, PrimaryKeyColumn, Column + >>> from typing import Optional + >>> + >>> 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) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + >>> loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -546,10 +500,9 @@ def find_one( ... sql_logger="console", ... ) ... - ... # Retrieving a single user based on filters - ... user = loom.find_one(User, filters=[Filter(column="id", value=1, operator="eq")]) - ... print(user) - + >>> # Retrieving a single user based on filters + >>> user = loom.find_one(User, filters=[Filter(column="id", value=1, operator="eq")]) + >>> print(user) """ return query(dialect=self.dialect, _execute_sql=self._execute_sql).find_one( instance=instance, @@ -557,7 +510,6 @@ def find_one( filters=filters, offset=offset, include=include, - return_dict=return_dict, ) def update_by_pk( @@ -592,7 +544,7 @@ def update_by_pk( Examples -------- - >>> from dataloom import Dataloom, Model, TableColumn, PrimaryKeyColumn, Column, ColumnValue + >>> from dataloom import Loom, Model, TableColumn, PrimaryKeyColumn, Column, ColumnValue ... from typing import Optional ... ... class User(Model): @@ -602,7 +554,7 @@ def update_by_pk( ... username = Column(type="varchar", unique=True, length=255) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -661,7 +613,7 @@ def update_one( Examples -------- - >>> from dataloom import Dataloom, Model, Filter, ColumnValue, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, Filter, ColumnValue, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional ... ... class User(Model): @@ -671,7 +623,7 @@ def update_one( ... username = Column(type="varchar", unique=True, length=255) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -730,7 +682,7 @@ def update_bulk( Examples -------- - >>> from dataloom import Dataloom, Model, Filter, ColumnValue, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, Filter, ColumnValue, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional ... ... class User(Model): @@ -740,7 +692,7 @@ def update_bulk( ... username = Column(type="varchar", unique=True, length=255) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -787,7 +739,7 @@ def delete_by_pk(self, instance: Model, pk) -> int: Examples -------- - >>> from dataloom import Dataloom, Model, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional ... ... class User(Model): @@ -797,7 +749,7 @@ def delete_by_pk(self, instance: Model, pk) -> int: ... username = Column(type="varchar", unique=True, length=255) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + ... loom = Loom ... dialect="postgres", ... database="hi", ... password="root", @@ -819,7 +771,7 @@ def delete_one( instance: Model, filters: Optional[Filter | list[Filter]] = [], offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ) -> int: """ delete_one @@ -840,7 +792,7 @@ def delete_one( Returns ------- - deleted_rows: int + deleted_rows : int The number of rows deleted (0 or 1). See Also @@ -850,17 +802,17 @@ def delete_one( Examples -------- - >>> from dataloom import Dataloom, Model, Filter, TableColumn, PrimaryKeyColumn, Column - ... from typing import Optional - ... - ... class User(Model): + >>> from dataloom import Loom, Model, Filter, TableColumn, PrimaryKeyColumn, Column + >>> from typing import Optional + >>> + >>> 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) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + >>> loom = Loom ... dialect="postgres", ... database="hi", ... password="root", @@ -868,10 +820,9 @@ def delete_one( ... sql_logger="console", ... ) ... - ... # Deleting a user based on filters - ... num_rows_deleted = loom.delete_one(User, filters=[Filter(column="id", value=1, operator="eq")]) - ... print(num_rows_deleted) - + >>> # Deleting a user based on filters + >>> num_rows_deleted = loom.delete_one(User, filters=[Filter(column="id", value=1, operator="eq")]) + >>> print(num_rows_deleted) """ return delete(dialect=self.dialect, _execute_sql=self._execute_sql).delete_one( instance=instance, offset=offset, order=order, filters=filters @@ -883,7 +834,7 @@ def delete_bulk( filters: Optional[Filter | list[Filter]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ) -> int: """ delete_bulk @@ -906,7 +857,7 @@ def delete_bulk( Returns ------- - affected_rows: int + affected_rows : int The number of rows deleted. See Also @@ -916,17 +867,17 @@ def delete_bulk( Examples -------- - >>> from dataloom import Dataloom, Model, Filter, TableColumn, PrimaryKeyColumn, Column - ... from typing import Optional - ... - ... class User(Model): + >>> from dataloom import Loom, Model, Filter, TableColumn, PrimaryKeyColumn, Column + >>> from typing import Optional + >>> + >>> 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) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + >>> loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -934,10 +885,9 @@ def delete_bulk( ... sql_logger="console", ... ) ... - ... # Deleting users based on filters - ... num_rows_deleted = loom.delete_bulk(User, filters=[Filter(column="tokenVersion", value=2, operator="eq")]) - ... print(num_rows_deleted) - + >>> # Deleting users based on filters + >>> num_rows_deleted = loom.delete_bulk(User, filters=[Filter(column="tokenVersion", value=2, operator="eq")]) + >>> print(num_rows_deleted) """ return delete(dialect=self.dialect, _execute_sql=self._execute_sql).delete_bulk( instance=instance, offset=offset, order=order, filters=filters, limit=limit @@ -978,7 +928,7 @@ def increment( Examples -------- - >>> from dataloom import Dataloom, Model, Filter, ColumnValue, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom Filter, ColumnValue, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional ... ... class User(Model): @@ -988,7 +938,7 @@ def increment( ... username = Column(type="varchar", unique=True, length=255) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -1046,7 +996,7 @@ def decrement( Examples -------- - >>> from dataloom import Dataloom, Model, Filter, ColumnValue, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, Filter, ColumnValue, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional ... ... class User(Model): @@ -1056,7 +1006,7 @@ def decrement( ... username = Column(type="varchar", unique=True, length=255) ... tokenVersion = Column(type="int", default=0) ... - ... loom = Dataloom( + ... loom = Loom ... dialect="postgres", ... database="hi", ... password="root", @@ -1112,7 +1062,7 @@ def inspect( Examples -------- - >>> from dataloom import Dataloom, Model, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional ... ... class Category(Model): @@ -1120,7 +1070,7 @@ def inspect( ... id = PrimaryKeyColumn(type="int", auto_increment=True, nullable=False, unique=True) ... type = Column(type="varchar", length=255, nullable=False) ... - ... loom = Dataloom( + ... loom = Loom ... dialect="postgres", ... database="hi", ... password="root", @@ -1161,8 +1111,8 @@ def tables(self) -> list[str]: Examples -------- - >>> from dataloom import Dataloom - ... loom = Dataloom( + >>> from dataloom import Loom + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -1190,6 +1140,7 @@ def _execute_sql( bulk: bool = False, affected_rows: bool = False, operation: Optional[str] = None, + _verbose: int = 1, ) -> Any: return self.sql_obj.execute_sql( sql=sql, @@ -1201,6 +1152,7 @@ def _execute_sql( fetchone=fetchone, fetchmany=fetchmany, affected_rows=affected_rows, + _verbose=_verbose, ) def connect( @@ -1225,8 +1177,8 @@ def connect( Examples -------- - >>> from dataloom import Dataloom - ... loom = Dataloom( + >>> from dataloom import Loom + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -1310,9 +1262,9 @@ def connect_and_sync( Examples -------- - >>> from dataloom import Dataloom, Model, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional - ... loom = Dataloom( + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", @@ -1415,9 +1367,9 @@ def sync( Examples -------- - >>> from dataloom import Dataloom, Model, TableColumn, PrimaryKeyColumn, Column + >>> from dataloom import Loom, Model, TableColumn, PrimaryKeyColumn, Column ... from typing import Optional - ... loom = Dataloom( + ... loom = Loom( ... dialect="postgres", ... database="hi", ... password="root", diff --git a/dataloom/loom/delete.py b/dataloom/loom/delete.py index 25f0695..539d910 100644 --- a/dataloom/loom/delete.py +++ b/dataloom/loom/delete.py @@ -2,7 +2,7 @@ from dataloom.model import Model from typing import Optional, Callable, Any from dataloom.types import Filter, DIALECT_LITERAL, Order -from dataloom.utils import get_args +from dataloom.utils import get_args, is_collection from abc import ABC, abstractclassmethod @@ -17,7 +17,7 @@ def delete_one( instance: Model, filters: Optional[Filter | list[Filter]] = [], offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ) -> int: raise NotImplementedError("The delete_one function not implemented.") @@ -28,7 +28,7 @@ def delete_bulk( filters: Optional[Filter | list[Filter]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ) -> int: raise NotImplementedError("The delete_one function not implemented.") @@ -52,14 +52,16 @@ def delete_one( instance: Model, filters: Optional[Filter | list[Filter]] = [], offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ) -> int: + if not is_collection(filters): + filters = [filters] + if not is_collection(order): + order = [order] sql, params = instance._get_delete_where_stm( dialect=self.dialect, filters=filters, offset=offset, order=order ) - args = [*get_args(params)] - if offset is not None: args.append(offset) affected_rows = self._execute_sql(sql, args=args, affected_rows=True) @@ -71,8 +73,13 @@ def delete_bulk( filters: Optional[Filter | list[Filter]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ) -> int: + if not is_collection(filters): + filters = [filters] + if not is_collection(order): + order = [order] + if offset is not None and limit is None and self.dialect == "mysql": raise InvalidArgumentsException( f"You can not apply offset without limit on dialect '{self.dialect}'." diff --git a/dataloom/loom/insert.py b/dataloom/loom/insert.py index 1602e20..25a0f35 100644 --- a/dataloom/loom/insert.py +++ b/dataloom/loom/insert.py @@ -3,7 +3,7 @@ from dataloom.types import DIALECT_LITERAL from typing import Callable, Any from dataloom.exceptions import InvalidColumnValuesException -from dataloom.utils import get_insert_bulk_attrs +from dataloom.utils import get_insert_bulk_attrs, is_collection from abc import ABC, abstractclassmethod @@ -40,11 +40,11 @@ def insert_one( def insert_bulk(self, instance: Model, values: list[list[ColumnValue]]) -> int: # ? ensure that the values that are passed is a list of a list and they inner list have the same length - if not isinstance(values, list): + if not is_collection(values): raise InvalidColumnValuesException( "The insert_bulk method takes in values as lists of lists." ) - all_list = [isinstance(v, list) for v in values] + all_list = [is_collection(v) for v in values] if not all(all_list): raise InvalidColumnValuesException( "The insert_bulk method takes in values as lists of lists." diff --git a/dataloom/loom/interfaces.py b/dataloom/loom/interfaces.py index 58041c3..911168f 100644 --- a/dataloom/loom/interfaces.py +++ b/dataloom/loom/interfaces.py @@ -7,7 +7,7 @@ from mysql.connector.connection import MySQLConnectionAbstract -class IDataloom(ABC): +class ILoom(ABC): @abstractclassmethod def increment( self, @@ -72,7 +72,6 @@ def find_many( filters: Optional[Filter | list[Filter]] = None, select: list[str] = [], include: list[Model] = [], - return_dict: bool = True, limit: Optional[int] = None, offset: Optional[int] = None, order: Optional[list[Order]] = [], @@ -85,7 +84,6 @@ def find_all( instance: Model, select: list[str] = [], include: list[Include] = [], - return_dict: bool = True, limit: Optional[int] = None, offset: Optional[int] = None, order: Optional[list[Order]] = [], @@ -99,7 +97,6 @@ def find_by_pk( pk, select: list[str] = [], include: list[Include] = [], - return_dict: bool = True, ) -> dict | None: raise NotImplementedError("The find_by_pk method was not implemented.") @@ -110,7 +107,6 @@ def find_one( filters: Optional[Filter | list[Filter]] = None, select: list[str] = [], include: list[Include] = [], - return_dict: bool = True, offset: Optional[int] = None, ) -> dict | None: raise NotImplementedError("The find_one method was not implemented.") diff --git a/dataloom/loom/query.py b/dataloom/loom/query.py index f4acb71..3a8caf4 100644 --- a/dataloom/loom/query.py +++ b/dataloom/loom/query.py @@ -1,9 +1,9 @@ from dataloom.model import Model -from dataloom.types import Filter, Order, Include -from dataloom.types import DIALECT_LITERAL +from dataloom.types import Filter, Order, Include, Group, DIALECT_LITERAL from typing import Callable, Any, Optional -from dataloom.utils import get_child_table_columns, get_args +from dataloom.utils import get_args, is_collection from abc import ABC, abstractclassmethod +from dataloom.loom.subqueries import subquery class Query(ABC): @@ -12,12 +12,11 @@ def find_many( self, instance: Model, filters: Optional[Filter | list[Filter]] = None, - select: list[str] = [], + select: Optional[list[str] | str] = [], include: list[Model] = [], - return_dict: bool = True, limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ) -> list: raise NotImplementedError("The find_many method was not implemented.") @@ -25,12 +24,11 @@ def find_many( def find_all( self, instance: Model, - select: list[str] = [], - include: list[Include] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ) -> list: raise NotImplementedError("The find_all method was not implemented.") @@ -39,9 +37,8 @@ def find_by_pk( self, instance: Model, pk, - select: list[str] = [], - include: list[Include] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], ) -> dict | None: raise NotImplementedError("The find_by_pk method was not implemented.") @@ -50,9 +47,8 @@ def find_one( self, instance: Model, filters: Optional[Filter | list[Filter]] = None, - select: list[str] = [], - include: list[Include] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], offset: Optional[int] = None, ) -> dict | None: raise NotImplementedError("The find_one method was not implemented.") @@ -69,147 +65,141 @@ def find_many( self, instance: Model, filters: Optional[Filter | list[Filter]] = None, - select: list[str] = [], + select: Optional[list[str] | str] = [], include: list[Model] = [], - return_dict: bool = True, limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], + group: Optional[list[Group] | Group] = [], ) -> list: - return_dict = True - include = [] - sql, params, fields = instance._get_select_where_stm( - dialect=self.dialect, - filters=filters, - select=select, - limit=limit, - offset=offset, - order=order, - include=include, - ) data = [] - args = get_args(params) - rows = self._execute_sql(sql, fetchall=True, args=args) - for row in rows: - res = self.__map_relationships( - instance=instance, - row=row, - parent_fields=fields, - include=include, - return_dict=return_dict, + + if not is_collection(include): + include = [include] + if not is_collection(group): + group = [group] + + if len(include) == 0: + sql, params, fields, having_values = instance._get_select_where_stm( + dialect=self.dialect, + filters=filters, + select=select, + limit=limit, + offset=offset, + order=order, + group=group, + ) + args = list(get_args(params)) + having_values + rows = self._execute_sql(sql, fetchall=True, args=args) + for row in rows: + d = dict(zip(fields, row)) + data.append(d) + else: + # run sub queries instead + data = subquery( + dialect=self.dialect, _execute_sql=self._execute_sql + ).get_find_many_relations( + parent=instance, + includes=include, + filters=filters, + select=select, + limit=limit, + order=order, + offset=offset, + group=group, ) - data.append(res) return data def find_all( self, instance: Model, - select: list[str] = [], - include: list[Include] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], + group: Optional[list[Group] | Group] = [], ) -> list: - return_dict = True - include = [] - sql, params, fields = instance._get_select_where_stm( - dialect=self.dialect, - select=select, - limit=limit, - offset=offset, - order=order, - include=include, - ) data = [] - rows = self._execute_sql(sql, fetchall=True) - for row in rows: - res = self.__map_relationships( - instance=instance, - row=row, - parent_fields=fields, - include=include, - return_dict=return_dict, + if not is_collection(include): + include = [include] + if not is_collection(group): + group = [group] + + if len(include) == 0: + sql, params, fields, having_values = instance._get_select_where_stm( + dialect=self.dialect, + select=select, + limit=limit, + offset=offset, + order=order, + group=group, + ) + args = list(get_args(params)) + having_values + rows = self._execute_sql(sql, fetchall=True, args=args) + for row in rows: + data.append(dict(zip(fields, row))) + else: + # run sub queries instead + data = subquery( + dialect=self.dialect, _execute_sql=self._execute_sql + ).get_find_all_relations( + parent=instance, + includes=include, + select=select, + limit=limit, + order=order, + offset=offset, + group=group, ) - data.append(res) return data - def __map_relationships( - self, - instance: Model, - row: tuple, - parent_fields: list, - include: list[dict] = [], - return_dict: bool = True, - ): - # how are relations are mapped? - json = dict(zip(parent_fields, row[: len(parent_fields)])) - result = json if return_dict else instance(**json) - row = row[len(parent_fields) :] - for _include in include: - alias, selected = [v for v in get_child_table_columns(_include).items()][0] - child_json = dict(zip(selected, row[: len(selected)])) - row = row[len(selected) :] - if return_dict: - result[alias] = child_json - else: - result[alias] = _include.model(**child_json) - return result - def find_by_pk( self, instance: Model, pk, - select: list[str] = [], - include: list[Include] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], ) -> dict | None: - # """ - # This part will be added in the future version. - # """ - return_dict = True - include = [] # what is the name of the primary key column? well we will find out sql, fields, _includes = instance._get_select_by_pk_stm( - dialect=self.dialect, select=select, include=include + dialect=self.dialect, select=select, include=[] ) rows = self._execute_sql(sql, args=(pk,), fetchone=True) if rows is None: return None - return self.__map_relationships( - instance=instance, - row=rows, - parent_fields=fields, - include=_includes, - return_dict=return_dict, - ) + result = dict(zip(fields, rows)) + relations = subquery( + dialect=self.dialect, _execute_sql=self._execute_sql + ).get_find_by_pk_relations(parent=instance, includes=include, pk=pk) + return {**result, **relations} def find_one( self, instance: Model, filters: Optional[Filter | list[Filter]] = None, - select: list[str] = [], - include: list[Include] = [], - return_dict: bool = True, + select: Optional[list[str] | str] = [], + include: Optional[list[Include] | Include] = [], offset: Optional[int] = None, ) -> dict | None: - return_dict = True - include = [] - sql, params, fields = instance._get_select_where_stm( + sql, params, fields, having_values = instance._get_select_where_stm( dialect=self.dialect, filters=filters, select=select, offset=offset, - include=include, ) + args = get_args(params) row = self._execute_sql(sql, args=args, fetchone=True) if row is None: return None - return self.__map_relationships( - instance=instance, - row=row, - parent_fields=fields, - include=include, - return_dict=return_dict, + result = dict(zip(fields, row)) + relations = subquery( + dialect=self.dialect, _execute_sql=self._execute_sql + ).get_find_one_relations( + parent=instance, + includes=include, + filters=filters, + offset=offset, ) + return {**result, **relations} diff --git a/dataloom/loom/sql.py b/dataloom/loom/sql.py index 25ba7d3..f432dab 100644 --- a/dataloom/loom/sql.py +++ b/dataloom/loom/sql.py @@ -60,9 +60,10 @@ def execute_sql( bulk: bool = False, affected_rows: bool = False, operation: Optional[str] = None, + _verbose: int = 1, ) -> Any: # do we need to log the executed SQL? - if self.sql_logger is not None: + if self.sql_logger is not None and _verbose > 0: if self.sql_logger == "console": index = console_logger( index=self.__logger_index, diff --git a/dataloom/loom/subqueries.py b/dataloom/loom/subqueries.py new file mode 100644 index 0000000..795d701 --- /dev/null +++ b/dataloom/loom/subqueries.py @@ -0,0 +1,386 @@ +from dataloom.utils import get_table_fields, get_args, is_collection +from dataloom.types import DIALECT_LITERAL, Include, Filter, Order, Group +from dataloom.model import Model +from dataclasses import dataclass +from typing import Callable, Any, Optional +from dataloom.exceptions import UnknownRelationException +import re + + +@dataclass(kw_only=True) +class subquery: + dialect: DIALECT_LITERAL + _execute_sql: Callable[..., Any] + + def get_find_all_relations( + self, + parent: Model, + includes: list[Include] | Include = [], + offset: Optional[int] = None, + limit: Optional[int] = None, + select: list[str] | str = [], + order: Optional[list[Order] | Order] = [], + group: Optional[list[Group] | Group] = [], + ): + if not is_collection(includes): + includes = [includes] + if not is_collection(select): + select = [select] + sql, params = parent._get_select_pk_stm( + dialect=self.dialect, + limit=limit, + offset=offset, + order=order, + ) + pks = self._execute_sql(sql, fetchall=True, args=None, _verbose=0) + data = [] + for (pk,) in pks: + sql2, fields, _ = parent._get_select_by_pk_stm( + dialect=self.dialect, select=select + ) + row = self._execute_sql(sql2, fetchone=True, args=(pk,), _verbose=0) + relations = self.get_find_by_pk_relations( + parent=parent, pk=pk, includes=includes + ) + data.append({**dict(zip(fields, row)), **relations}) + return data + + def get_find_many_relations( + self, + parent: Model, + filters: Optional[Filter | list[Filter]] = None, + includes: list[Include] | Include = [], + offset: Optional[int] = None, + limit: Optional[int] = None, + select: list[str] | str = [], + order: Optional[list[Order] | Order] = [], + group: Optional[list[Group] | Group] = [], + ): + if not is_collection(includes): + includes = [includes] + if not is_collection(select): + select = [select] + + sql, params = parent._get_select_pk_stm( + dialect=self.dialect, + filters=filters, + limit=limit, + offset=offset, + order=order, + ) + args = get_args(params) + pks = self._execute_sql(sql, fetchall=True, args=args, _verbose=0) + data = [] + for (pk,) in pks: + sql2, fields, _ = parent._get_select_by_pk_stm( + dialect=self.dialect, select=select + ) + row = self._execute_sql(sql2, fetchone=True, args=(pk,), _verbose=0) + relations = self.get_find_by_pk_relations( + parent=parent, pk=pk, includes=includes + ) + allowed = self.get_name_and_alias(includes) + delete_them = [] + for key, value in relations.items(): + if isinstance(value, dict): + if key not in allowed: + delete_them.append(key) + elif is_collection(value): + if key not in allowed: + delete_them.append(key) + + for key in delete_them: + del relations[key] + + data.append({**dict(zip(fields, row)), **relations}) + return data + + def get_name_and_alias(self, includes: list[Include]) -> list[tuple]: + allowed = [] + for include in includes: + table = include.model._get_table_name() + alias = include.model.__name__.lower() + allowed.append(table) + allowed.append(alias) + return allowed + + def get_find_one_relations( + self, + parent: Model, + filters: Optional[Filter | list[Filter]] = None, + includes: list[Include] | Include = [], + offset: Optional[int] = None, + ): + if not is_collection(includes): + includes = [includes] + + _, _, fks, _ = get_table_fields(parent, dialect=self.dialect) + sql, params = parent._get_select_pk_stm( + dialect=self.dialect, + filters=filters, + limit=1, + offset=offset, + order=[], + ) + args = get_args(params) + row = self._execute_sql(sql, args=args, fetchone=True, _verbose=0) + if row is None: + return {} + relations = dict() + (pk,) = row + for include in includes: + _, parent_pk_name, fks, _ = get_table_fields( + include.model, dialect=self.dialect + ) + if len(include.include) == 0: + relations = { + **relations, + **self.get_one_by_pk( + parent=parent, pk=pk, include=include, foreign_keys=fks + ), + } + 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 + relations = { + **relations, + **self.get_one_by_pk( + parent=parent, pk=pk, include=include, foreign_keys=fks + ), + } + _, parent_pk_name, parent_fks, _ = get_table_fields( + parent, dialect=self.dialect + ) + + if isinstance(relations[key], dict): + _pk = relations[key][re.sub(r'`|"', "", parent_pk_name)] + relations[key] = { + **relations[key], + **self.get_find_by_pk_relations( + include.model, _pk, includes=include.include + ), + } + else: + _pk = ( + relations[key][0][re.sub(r'`|"', "", parent_pk_name)] + if len(relations[key]) != 0 + else None + ) + if _pk is not None: + relations[key] = { + **relations[key], + **self.get_find_by_pk_relations( + include.model, _pk, includes=include.include + ), + } + return relations + + def get_find_by_pk_relations(self, parent: Model, pk, includes: list[Include] = []): + if not is_collection(includes): + includes = [includes] + + relations = dict() + for include in includes: + _, parent_pk_name, fks, _ = get_table_fields( + include.model, dialect=self.dialect + ) + if len(include.include) == 0: + relations = { + **relations, + **self.get_one_by_pk( + parent=parent, pk=pk, include=include, foreign_keys=fks + ), + } + 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 + relations = { + **relations, + **self.get_one_by_pk( + parent=parent, pk=pk, include=include, foreign_keys=fks + ), + } + _, parent_pk_name, parent_fks, _ = get_table_fields( + parent, dialect=self.dialect + ) + + if isinstance(relations[key], dict): + _pk = relations[key][re.sub(r'`|"', "", parent_pk_name)] + relations[key] = { + **relations[key], + **self.get_find_by_pk_relations( + include.model, _pk, includes=include.include + ), + } + + else: + try: + _pk = ( + relations[key][0][re.sub(r'`|"', "", parent_pk_name)] + if len(relations[key]) != 0 + else None + ) + if _pk is not None: + if isinstance(relations[key], dict): + relations[key] = { + **relations[key], + **self.get_find_by_pk_relations( + include.model, _pk, includes=include.include + ), + } + + else: + _, parent_pk_name, parent_fks, _ = get_table_fields( + include.model, dialect=self.dialect + ) + t_name = ( + f'"{include.model._get_table_name()}"' + if self.dialect == "postgres" + else f"`{include.model._get_table_name()}`" + ) + fk_name = None + for _fk in fks: + if parent._get_table_name() in _fk: + fk_name = ( + f'"{_fk[parent._get_table_name()]}"' + if self.dialect == "postgres" + else f"`{_fk[parent._get_table_name()]}`" + ) + placeholder = "?" if self.dialect == "sqlite" else "%s" + orders = [ + f'{f'"{order.column}"' if self.dialect == 'postgres' else f"`{order.column}`"} {order.order}' + for order in include.order + ] + options = [ + "" + if include.limit is None + else f"LIMIT {placeholder}", + "" + if include.offset is None + else f"OFFSET {placeholder}", + ] + orderby = ( + "" + if len(orders) == 0 + else " ORDER BY " + " ".join(orders) + ) + stmt = f""" + select {parent_pk_name} from {t_name} where {fk_name} = {placeholder}{orderby} {' '.join(options)} + """ + + data = [] + _args = [ + a + for a in [pk, include.limit, include.offset] + if a is not None + ] + pks = self._execute_sql( + stmt, args=_args, fetchall=True, _verbose=1 + ) + for (_pk,) in pks: + ( + sql2, + fields, + _, + ) = include.model._get_select_by_pk_stm( + dialect=self.dialect, select=include.select + ) + row = self._execute_sql( + sql2, fetchone=True, args=(_pk,), _verbose=0 + ) + relations = self.get_find_by_pk_relations( + parent=include.model, + pk=_pk, + includes=include.include, + ) + data.append({**dict(zip(fields, row)), **relations}) + relations[key] = data + except Exception: + pass + + return relations + + def get_one_by_pk( + self, parent: Model, include: Include, pk: Any, foreign_keys: list[dict] + ): + _, parent_pk_name, parent_fks, _ = get_table_fields( + parent, 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() + relations = dict() + + 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 + 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] + # this table is a child table meaning that we don't have a foreign key here + try: + fk = parent_fks[table_name] + except KeyError: + raise UnknownRelationException( + f'The table "{parent._get_table_name()}" does not have relations "{table_name}".' + ) + sql, selected = include.model._get_select_child_by_pk_stm( + dialect=self.dialect, + select=include.select, + parent_pk_name=parent_pk_name, + parent_table_name=parent._get_table_name(), + child_foreign_key_name=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 + elif has_many: + args = [ + arg + for arg in [pk, include.limit, include.offset] + if arg is not None + ] + rows = self._execute_sql(sql, args=args, fetchone=has_one) + try: + relations[key] = [dict(zip(selected, row)) for row in rows] + except TypeError: + raise UnknownRelationException( + f'The model "{parent._get_table_name()}" does not maps to "{include.has}" of "{include.model._get_table_name()}".' + ) + + 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=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 + 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 + ] + rows = self._execute_sql(sql, args=args, fetchall=True) + relations[key] = [dict(zip(selected, row)) for row in rows] + + return relations diff --git a/dataloom/model/__init__.py b/dataloom/model/__init__.py index b110ec3..fcd8f19 100644 --- a/dataloom/model/__init__.py +++ b/dataloom/model/__init__.py @@ -6,19 +6,24 @@ TableColumn, ) from dataloom.statements import GetStatement -from dataloom.types import Order, Include + from typing import Optional from dataloom.types import ( DIALECT_LITERAL, Filter, ColumnValue, INCREMENT_DECREMENT_LITERAL, + Order, + Include, + Group, ) from dataloom.utils import ( get_table_filters, get_column_values, get_child_table_params, get_table_fields, + get_groups, + is_collection, ) @@ -142,19 +147,17 @@ def _get_select_where_stm( cls, dialect: DIALECT_LITERAL, filters: Optional[Filter | list[Filter]] = None, - select: list[str] = [], + select: Optional[list[str] | str] = [], limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], - include: list[Include] = [], + order: Optional[list[Order] | Order] = [], + group: Optional[list[Group] | Group] = [], ): + if not is_collection(select): + select = [select] orders = [] - includes = [] # what are the foreign keys? - for _include in include: - includes.append(get_child_table_params(_include, dialect=dialect)) - fields, pk_name, fks, updatedAtColumName = get_table_fields( cls, dialect=dialect ) @@ -174,12 +177,27 @@ def _get_select_where_stm( raise UnknownColumnException( f'The table "{cls._get_table_name()}" does not have a column "{column}".' ) + placeholder_filters, placeholder_filter_values = get_table_filters( table_name=cls._get_table_name(), dialect=dialect, fields=fields, filters=filters, ) + ( + group_fns, + group_columns, + having_columns, + having_values, + return_aggregation_column, + ) = get_groups( + fields=fields, + dialect=dialect, + select=select, + group=group, + table_name=cls._get_table_name(), + ) + if dialect == "postgres" or "mysql" or "sqlite": if len(placeholder_filters) == 0: sql = GetStatement( @@ -189,9 +207,9 @@ def _get_select_where_stm( limit=limit, offset=offset, orders=orders, - includes=includes, - fks=fks, pk_name=pk_name, + groups=(group_columns, group_fns), + having=having_columns, ) else: sql = GetStatement( @@ -202,27 +220,34 @@ def _get_select_where_stm( limit=limit, offset=offset, orders=orders, - includes=includes, - fks=fks, - pk_name=pk_name, + groups=(group_columns, group_fns), + having=having_columns, ) else: raise UnsupportedDialectException( "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" ) - return ( - sql, - placeholder_filter_values, - fields if len(select) == 0 else select, - ) + + selected = [] + if len(select) == 0: + selected = fields + group_fns if return_aggregation_column else fields + else: + selected = ( + list(select) + group_fns if return_aggregation_column else list(select) + ) + + return (sql, placeholder_filter_values, selected, having_values) @classmethod def _get_select_by_pk_stm( cls, dialect: DIALECT_LITERAL, - select: list[str] = [], + select: Optional[list[str] | str] = [], include: list[Include] = [], ): + if not is_collection(select): + select = [select] + # what is the pk name? # what are the foreign keys? includes = [] @@ -232,7 +257,15 @@ def _get_select_by_pk_stm( fields, pk_name, fks, updatedAtColumName = get_table_fields( cls, dialect=dialect ) - for column in select: + if is_collection(select): + for column in select: + if column not in fields: + raise UnknownColumnException( + f'The table "{cls._get_table_name()}" does not have a column "{column}".' + ) + else: + column = select + select = [select] if column not in fields: raise UnknownColumnException( f'The table "{cls._get_table_name()}" does not have a column "{column}".' @@ -401,7 +434,7 @@ def _get_delete_where_stm( dialect: DIALECT_LITERAL, filters: Optional[Filter | list[Filter]] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ): fields, pk_name, fks, updatedAtColumName = get_table_fields( cls, dialect=dialect @@ -454,7 +487,7 @@ def _get_delete_bulk_where_stm( filters: Optional[Filter | list[Filter]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - order: Optional[list[Order]] = [], + order: Optional[list[Order] | Order] = [], ): fields, pk_name, fks, updatedAtColumName = get_table_fields( cls, dialect=dialect @@ -561,3 +594,160 @@ def _get_describe_stm(cls, dialect: DIALECT_LITERAL, fields: list[str] = []): "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" ) return sql + + @classmethod + def _get_select_child_by_pk_stm( + cls, + dialect: DIALECT_LITERAL, + parent_pk_name: str, + parent_table_name: str, + child_foreign_key_name: str, + select: Optional[list[str] | str] = [], + limit: Optional[int] = None, + offset: Optional[int] = None, + order: Optional[list[Order] | Order] = [], + ): + if not is_collection(select): + select = [select] + # what is the pk name? + # what are the foreign keys? + fields, pk_name, fks, updatedAtColumName = get_table_fields( + cls, dialect=dialect + ) + orders = [] + if order is not None: + for _order in order: + if _order.column not in fields: + raise UnknownColumnException( + f'The table "{cls._get_table_name()}" does not have a column "{_order.column}".' + ) + orders.append( + f'"{_order.column}" {_order.order}' + if dialect == "postgres" + else f"`{_order.column}` {_order.order}" + ) + + for column in select: + if column not in fields: + raise UnknownColumnException( + f'The table "{cls._get_table_name()}" does not have a column "{column}".' + ) + if dialect == "postgres" or "mysql" or "sqlite": + sql = GetStatement( + dialect=dialect, model=cls, table_name=cls._get_table_name() + )._get_select_child_by_pk_command( + fields=select if len(select) != 0 else fields, + child_pk_name=pk_name, + parent_pk_name=parent_pk_name, + parent_table_name=parent_table_name, + child_foreign_key_name=child_foreign_key_name, + limit=limit, + offset=offset, + orders=orders, + ) + else: + raise UnsupportedDialectException( + "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" + ) + return sql, fields if len(select) == 0 else select + + @classmethod + def _get_select_parent_by_pk_stm( + cls, + dialect: DIALECT_LITERAL, + child_pk_name: str, + child_table_name: str, + parent_fk_name: str, + select: Optional[list[str] | str] = [], + limit: Optional[int] = None, + offset: Optional[int] = None, + order: list[Order] = [], + ): + if not is_collection(select): + select = [select] + # what is the pk name? + # what are the foreign keys? + fields, pk_name, fks, updatedAtColumName = get_table_fields( + cls, dialect=dialect + ) + orders = [] + if order is not None: + for _order in order: + if _order.column not in fields: + raise UnknownColumnException( + f'The table "{cls._get_table_name()}" does not have a column "{_order.column}".' + ) + orders.append( + f'"{_order.column}" {_order.order}' + if dialect == "postgres" + else f"`{_order.column}` {_order.order}" + ) + + for column in select: + if column not in fields: + raise UnknownColumnException( + f'The table "{cls._get_table_name()}" does not have a column "{column}".' + ) + if dialect == "postgres" or "mysql" or "sqlite": + sql = GetStatement( + dialect=dialect, model=cls, table_name=cls._get_table_name() + )._get_select_parent_by_pk_stm( + fields=select if len(select) != 0 else fields, + child_pk_name=child_pk_name, + child_table_name=child_table_name, + parent_fk_name=parent_fk_name, + limit=limit, + offset=offset, + orders=orders, + ) + else: + raise UnsupportedDialectException( + "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" + ) + return sql, fields if len(select) == 0 else select + + @classmethod + def _get_select_pk_stm( + cls, + dialect: DIALECT_LITERAL, + filters: Optional[Filter | list[Filter]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + order: Optional[list[Order] | Order] = [], + ): + orders = [] + fields, pk_name, fks, updatedAtColumName = get_table_fields( + cls, dialect=dialect + ) + for _order in order: + if _order.column not in fields: + raise UnknownColumnException( + f'The table "{cls._get_table_name()}" does not have a column "{_order.column}".' + ) + orders.append( + f'"{_order.column}" {_order.order}' + if dialect == "postgres" + else f"`{_order.column}` {_order.order}" + ) + + placeholder_filters, placeholder_filter_values = get_table_filters( + table_name=cls._get_table_name(), + dialect=dialect, + fields=fields, + filters=filters, + ) + if dialect == "postgres" or "mysql" or "sqlite": + sql = GetStatement( + dialect=dialect, model=cls, table_name=cls._get_table_name() + )._get_pk_command( + filters=placeholder_filters, + limit=limit, + offset=offset, + orders=orders, + pk_name=pk_name, + ) + else: + raise UnsupportedDialectException( + "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" + ) + return sql, placeholder_filter_values diff --git a/dataloom/statements/__init__.py b/dataloom/statements/__init__.py index c3e83f2..c45334d 100644 --- a/dataloom/statements/__init__.py +++ b/dataloom/statements/__init__.py @@ -231,61 +231,39 @@ def _get_create_table_command(self) -> Optional[str]: def _get_select_where_command( self, - pk_name: str, placeholder_filters: list[str], fields: list = [], limit: Optional[int] = None, offset: Optional[int] = None, orders: Optional[list[str]] = [], - includes: list[dict] = [], - fks: dict = {}, + groups: list[tuple[str]] = [], + having: list[str] = [], ): + (group_columns, group_fns) = groups options = [ + "" if len(group_columns) == 0 else f"GROUP BY {', '.join(group_columns)}", + "" if len(having) == 0 else f"HAVING {' '.join(having)}", "" if len(orders) == 0 else f"ORDER BY {', '.join(orders)}", "" if limit is None else f"LIMIT {limit}", "" if offset is None else f"OFFSET { offset}", ] - if len(includes) != 0: - relationships = get_relationships(includes=includes, fks=fks) - table_names = { - "parent_table_name": self.table_name, - "parent_columns": fields, - "parent_pk_name": pk_name, - "parent_pk": "?" if self.dialect == "sqlite" else "%s", - } - options = [ - "" - if len(orders) == 0 - else f"ORDER BY {', '.join([f"parent.{o}" for o in orders])}", - "" if limit is None else f"LIMIT {limit}", - "" if offset is None else f"OFFSET { offset}", - ] - sql = get_formatted_query( - dialect=self.dialect, - table_names=table_names, - relationships=relationships, - filters=placeholder_filters, - options=options, - ) - return sql - if self.dialect == "postgres": sql = PgStatements.SELECT_WHERE_COMMAND.format( - column_names=", ".join([f'"{f}"' for f in fields]), + column_names=", ".join([f'"{f}"' for f in fields] + group_fns), table_name=f'"{self.table_name}"', filters=" ".join(placeholder_filters), options=" ".join(options), ) elif self.dialect == "mysql": sql = MySqlStatements.SELECT_WHERE_COMMAND.format( - column_names=", ".join([f"`{name}`" for name in fields]), + column_names=", ".join([f"`{name}`" for name in fields] + group_fns), table_name=f"`{self.table_name}`", filters=" ".join(placeholder_filters), options=" ".join(options), ) elif self.dialect == "sqlite": sql = Sqlite3Statements.SELECT_WHERE_COMMAND.format( - column_names=", ".join([f"`{name}`" for name in fields]), + column_names=", ".join([f"`{name}`" for name in fields] + group_fns), table_name=f"`{self.table_name}`", filters=" ".join(placeholder_filters), options=" ".join(options), @@ -303,35 +281,13 @@ def _get_select_command( limit: Optional[int] = None, offset: Optional[int] = None, orders: Optional[list[str]] = [], - includes: list[dict] = [], - fks: dict = {}, + groups: list[tuple[str]] = [], + having: list[str] = [], ): - if len(includes) != 0: - relationships = get_relationships(includes=includes, fks=fks) - - table_names = { - "parent_table_name": self.table_name, - "parent_columns": fields, - "parent_pk_name": pk_name, - "parent_pk": "?" if self.dialect == "sqlite" else "%s", - } - options = [ - "" - if len(orders) == 0 - else f"ORDER BY {', '.join([f"parent.{o}" for o in orders])}", - "" if limit is None else f"LIMIT {limit}", - "" if offset is None else f"OFFSET { offset}", - ] - - sql = get_formatted_query( - dialect=self.dialect, - table_names=table_names, - relationships=relationships, - options=options, - ) - return sql - + (group_columns, group_fns) = groups options = [ + "" if len(group_columns) == 0 else f"GROUP BY {', '.join(group_columns)}", + "" if len(having) == 0 else f"HAVING {' '.join(having)}", "" if len(orders) == 0 else f"ORDER BY {', '.join(orders)}", "" if limit is None else f"LIMIT {limit}", "" if offset is None else f"OFFSET { offset}", @@ -339,19 +295,19 @@ def _get_select_command( if self.dialect == "postgres": sql = PgStatements.SELECT_COMMAND.format( - column_names=", ".join([f'"{name}"' for name in fields]), + column_names=", ".join([f'"{name}"' for name in fields] + group_fns), table_name=f'"{self.table_name}"', options=" ".join(options), ) elif self.dialect == "mysql": sql = MySqlStatements.SELECT_COMMAND.format( - column_names=", ".join([f"`{name}`" for name in fields]), + column_names=", ".join([f"`{name}`" for name in fields] + group_fns), table_name=f"`{self.table_name}`", options=" ".join(options), ) elif self.dialect == "sqlite": sql = Sqlite3Statements.SELECT_COMMAND.format( - column_names=", ".join([f"`{name}`" for name in fields]), + column_names=", ".join([f"`{name}`" for name in fields] + group_fns), table_name=f"`{self.table_name}`", options=" ".join(options), ) @@ -744,3 +700,154 @@ def _get_increment_decrement_command( "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" ) return sql + + def _get_select_child_by_pk_command( + self, + child_pk_name: str, + parent_pk_name: str, + parent_table_name: str, + child_foreign_key_name: str, + fields: list = [], + limit: Optional[int] = None, + offset: Optional[int] = None, + orders: list[str] = [], + ): + if self.dialect == "postgres": + sql = PgStatements.SELECT_CHILD_BY_PK.format( + child_column_names=", ".join([f'"{name}"' for name in fields]), + child_table_name=f'"{self.table_name}"', + child_pk_name=child_pk_name, + child_foreign_key_name=f'"{child_foreign_key_name}"', + parent_table_name=f'"{parent_table_name}"', + parent_pk_name=parent_pk_name, + parent_pk="%s", + limit="" if limit is None else "LIMIT %s", + offset="" if offset is None else "OFFSET %s", + orders="" if len(orders) == 0 else f"ORDER BY {', '.join(orders)}", + ) + elif self.dialect == "mysql": + sql = MySqlStatements.SELECT_CHILD_BY_PK.format( + child_column_names=", ".join([f"`{name}`" for name in fields]), + child_table_name=f"`{self.table_name}`", + child_pk_name=child_pk_name, + child_foreign_key_name=f"`{child_foreign_key_name}`", + parent_table_name=f"`{parent_table_name}`", + parent_pk_name=parent_pk_name, + parent_pk="%s", + limit="" if limit is None else "LIMIT %s", + offset="" if offset is None else "OFFSET %s", + orders="" if len(orders) == 0 else f"ORDER BY {', '.join(orders)}", + ) + elif self.dialect == "sqlite": + sql = Sqlite3Statements.SELECT_CHILD_BY_PK.format( + child_column_names=", ".join([f"`{name}`" for name in fields]), + child_table_name=f"`{self.table_name}`", + child_pk_name=child_pk_name, + child_foreign_key_name=f"`{child_foreign_key_name}`", + parent_table_name=f"`{parent_table_name}`", + parent_pk_name=parent_pk_name, + parent_pk="?", + limit="" if limit is None else "LIMIT ?", + offset="" if offset is None else "OFFSET ?", + orders="" if len(orders) == 0 else f"ORDER BY {', '.join(orders)}", + ) + else: + raise UnsupportedDialectException( + "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" + ) + return sql + + def _get_select_parent_by_pk_stm( + self, + child_pk_name: str, + child_table_name: str, + parent_fk_name: str, + fields: list = [], + limit: Optional[int] = None, + offset: Optional[int] = None, + orders: list[str] = [], + ): + if self.dialect == "postgres": + sql = PgStatements.SELECT_PARENT_BY_PK.format( + parent_column_names=", ".join([f'"{name}"' for name in fields]), + parent_table_name=f'"{self.table_name}"', + parent_fk_name=f'"{parent_fk_name}"', + child_table_name=f'"{child_table_name}"', + child_pk_name=child_pk_name, + child_pk="%s", + limit="" if limit is None else "LIMIT %s", + offset="" if offset is None else "OFFSET %s", + orders="" if len(orders) == 0 else f"ORDER BY {', '.join(orders)}", + ) + elif self.dialect == "mysql": + sql = MySqlStatements.SELECT_PARENT_BY_PK.format( + parent_column_names=", ".join([f"`{name}`" for name in fields]), + parent_table_name=f"`{self.table_name}`", + parent_fk_name=f"`{parent_fk_name}`", + child_table_name=f"`{child_table_name}`", + child_pk_name=child_pk_name, + child_pk="%s", + limit="" if limit is None else "LIMIT %s", + offset="" if offset is None else "OFFSET %s", + orders="" if len(orders) == 0 else f"ORDER BY {', '.join(orders)}", + ) + elif self.dialect == "sqlite": + sql = Sqlite3Statements.SELECT_PARENT_BY_PK.format( + parent_column_names=", ".join([f"`{name}`" for name in fields]), + parent_table_name=f"`{self.table_name}`", + parent_fk_name=f"`{parent_fk_name}`", + child_table_name=f"`{child_table_name}`", + child_pk_name=child_pk_name, + child_pk="?", + limit="" if limit is None else "LIMIT ?", + offset="" if offset is None else "OFFSET ?", + orders="" if len(orders) == 0 else f"ORDER BY {', '.join(orders)}", + ) + else: + raise UnsupportedDialectException( + "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" + ) + return sql + + def _get_pk_command( + self, + pk_name: str, + filters: list[str], + limit: Optional[int] = None, + offset: Optional[int] = None, + orders: Optional[list[str]] = [], + ): + """ + Getting the primary key value of the table based on filters. + """ + options = [ + "" if len(orders) == 0 else f"ORDER BY {', '.join(orders)}", + "" if limit is None else f"LIMIT {limit}", + "" if offset is None else f"OFFSET { offset}", + ] + if self.dialect == "postgres": + sql = PgStatements.GET_PK_COMMAND.format( + table_name=f'"{self.table_name}"', + options=" ".join(options), + pk_name=pk_name, + filters="" if len(filters) == 0 else "WHERE " + " ".join(filters), + ) + elif self.dialect == "mysql": + sql = MySqlStatements.GET_PK_COMMAND.format( + table_name=f"`{self.table_name}`", + options=" ".join(options), + pk_name=pk_name, + filters="" if len(filters) == 0 else "WHERE " + " ".join(filters), + ) + elif self.dialect == "sqlite": + sql = Sqlite3Statements.GET_PK_COMMAND.format( + table_name=f"`{self.table_name}`", + options=" ".join(options), + pk_name=pk_name, + filters="" if len(filters) == 0 else "WHERE " + " ".join(filters), + ) + else: + raise UnsupportedDialectException( + "The dialect passed is not supported the supported dialects are: {'postgres', 'mysql', 'sqlite'}" + ) + return sql diff --git a/dataloom/statements/statements.py b/dataloom/statements/statements.py index f9e69b7..18591ed 100644 --- a/dataloom/statements/statements.py +++ b/dataloom/statements/statements.py @@ -82,6 +82,24 @@ class MySqlStatements: "SELECT {column_names} FROM {table_name} WHERE {filters} {options};".strip() ) + # ------------- child parent bidirectional sub queries + SELECT_CHILD_BY_PK = """ + SELECT {child_column_names} FROM {child_table_name} WHERE {child_pk_name} IN ( + SELECT {child_foreign_key_name} FROM ( + SELECT {child_foreign_key_name} FROM {parent_table_name} WHERE {parent_pk_name} = {parent_pk} + ) AS subquery + ) {orders} {limit} {offset}; + """ + 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} + ) AS subquery + ) {orders} {limit} {offset}; + """ + + GET_PK_COMMAND = "SELECT {pk_name} FROM {table_name} {filters} {options};".strip() + # -------------- subqueries SELECT_BY_PK_INCLUDE_COMMAND = """ @@ -168,6 +186,19 @@ class Sqlite3Statements: "SELECT {column_names} FROM {table_name} WHERE {filters} {options};".strip() ) + # ------------- child parent bidirectional sub queries + SELECT_CHILD_BY_PK = """ + SELECT {child_column_names} FROM {child_table_name} WHERE {child_pk_name} IN ( + SELECT {child_foreign_key_name} FROM {parent_table_name} WHERE {parent_pk_name} = {parent_pk} + ) {orders} {limit} {offset}; + """ + 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} + ) {orders} {limit} {offset}; + """ + GET_PK_COMMAND = "SELECT {pk_name} FROM {table_name} {filters} {options};".strip() + # -------------- subqueries SELECT_BY_PK_INCLUDE_COMMAND = """ @@ -274,6 +305,20 @@ class PgStatements: SELECT_WHERE_COMMAND = ( "SELECT {column_names} FROM {table_name} WHERE {filters} {options};".strip() ) + + # ------------- child parent bidirectional sub queries + SELECT_CHILD_BY_PK = """ + SELECT {child_column_names} FROM {child_table_name} WHERE {child_pk_name} IN ( + SELECT {child_foreign_key_name} FROM {parent_table_name} WHERE {parent_pk_name} = {parent_pk} + ) {orders} {limit} {offset}; + """ + 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} + ) {orders} {limit} {offset}; + """ + + GET_PK_COMMAND = "SELECT {pk_name} FROM {table_name} {filters} {options};".strip() # -------------- subqueries SELECT_BY_PK_INCLUDE_COMMAND = """ diff --git a/dataloom/tests/mysql/test_aggregation_mysql.py b/dataloom/tests/mysql/test_aggregation_mysql.py new file mode 100644 index 0000000..646b34a --- /dev/null +++ b/dataloom/tests/mysql/test_aggregation_mysql.py @@ -0,0 +1,242 @@ +class TestAggregationLoadingOnMySQL: + def test_find_many(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Filter, + Group, + Having, + ) + import pytest + from dataloom.exceptions import UnknownColumnException + from dataloom.keys import MySQLConfig + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class User(Model): + __tablename__: 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) + 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", + ) + + conn, tables = mysql_loom.connect_and_sync( + [ + User, + Post, + ], + drop=True, + force=True, + ) + userId = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + for title in ["Hello", "Hello", "What are you doing", "Coding"]: + mysql_loom.insert_one( + instance=Post, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="title", value=title), + ], + ) + + with pytest.raises(UnknownColumnException) as exc_info: + mysql_loom.find_many( + Post, + select="title", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert ( + str(exc_info.value) + == 'The column "id" was omitted in selection of records to be grouped.' + ) + + posts = mysql_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert posts == [{"id": 2}, {"id": 3}, {"id": 4}] + posts = mysql_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=True, + ), + ) + + assert posts == [ + {"id": 2, "MAX(`id`)": 2}, + {"id": 3, "MAX(`id`)": 3}, + {"id": 4, "MAX(`id`)": 4}, + ] + + conn.close() + + def test_find_all(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Group, + Having, + ) + import pytest + from dataloom.exceptions import UnknownColumnException + from dataloom.keys import MySQLConfig + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class User(Model): + __tablename__: 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) + 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", + ) + + conn, tables = mysql_loom.connect_and_sync( + [ + User, + Post, + ], + drop=True, + force=True, + ) + userId = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + for title in ["Hello", "Hello", "What are you doing", "Coding"]: + mysql_loom.insert_one( + instance=Post, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="title", value=title), + ], + ) + + with pytest.raises(UnknownColumnException) as exc_info: + mysql_loom.find_all( + Post, + select="title", + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert ( + str(exc_info.value) + == 'The column "id" was omitted in selection of records to be grouped.' + ) + + posts = mysql_loom.find_all( + Post, + select="id", + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert posts == [{"id": 2}, {"id": 3}, {"id": 4}] + posts = mysql_loom.find_all( + Post, + select="id", + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=True, + ), + ) + + assert posts == [ + {"id": 2, "MAX(`id`)": 2}, + {"id": 3, "MAX(`id`)": 3}, + {"id": 4, "MAX(`id`)": 4}, + ] + + conn.close() diff --git a/dataloom/tests/mysql/test_connection_mysql.py b/dataloom/tests/mysql/test_connection_mysql.py index a883fd5..3a8026c 100644 --- a/dataloom/tests/mysql/test_connection_mysql.py +++ b/dataloom/tests/mysql/test_connection_mysql.py @@ -4,10 +4,10 @@ class TestConnectionMySQL: def test_connect_with_non_existing_database(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database="non-exists", password=MySQLConfig.password, @@ -20,10 +20,10 @@ def test_connect_with_non_existing_database(self): assert exc_info.value.errno == 1049 def test_connect_with_wrong_password(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password="user", @@ -39,10 +39,10 @@ def test_connect_with_wrong_password(self): assert exc_info.value.errno == 1045 def test_connect_with_wrong_user(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -57,12 +57,12 @@ def test_connect_with_wrong_user(self): assert exc_info.value.errno == 1045 def test_connect_with_wrong_dialect(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.keys import MySQLConfig from dataloom.exceptions import UnsupportedDialectException with pytest.raises(UnsupportedDialectException) as exc_info: - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="peew", database=MySQLConfig.database, password="user", @@ -77,10 +77,10 @@ def test_connect_with_wrong_dialect(self): ) def test_connect_correct_connection(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, diff --git a/dataloom/tests/mysql/test_create_tables_mysql.py b/dataloom/tests/mysql/test_create_tables_mysql.py index f3fefd1..ef07460 100644 --- a/dataloom/tests/mysql/test_create_tables_mysql.py +++ b/dataloom/tests/mysql/test_create_tables_mysql.py @@ -1,10 +1,10 @@ class TestCreatingTableMysql: def test_2_pk_error(self): - from dataloom import Column, PrimaryKeyColumn, Dataloom, TableColumn, Model + from dataloom import Column, PrimaryKeyColumn, Loom, TableColumn, Model import pytest from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -29,10 +29,10 @@ class User(Model): def test_no_pk_error(self): import pytest - from dataloom import Model, Dataloom, Column, TableColumn + from dataloom import Model, Loom, Column, TableColumn from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -51,10 +51,10 @@ class User(Model): conn.close() def test_table_name(self): - from dataloom import Model, Dataloom, Column, PrimaryKeyColumn, TableColumn + from dataloom import Model, Loom, Column, PrimaryKeyColumn, TableColumn from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -77,7 +77,7 @@ class User(Model): conn.close() def test_connect_sync(self): - from dataloom import Dataloom, Model, TableColumn, Column, PrimaryKeyColumn + from dataloom import Loom, Model, TableColumn, Column, PrimaryKeyColumn from dataloom.keys import MySQLConfig class User(Model): @@ -92,7 +92,7 @@ class Post(Model): id = PrimaryKeyColumn(type="int", nullable=False, auto_increment=True) title = Column(type="text", nullable=False) - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -105,10 +105,10 @@ class Post(Model): conn.close() def test_syncing_tables(self): - from dataloom import Model, Dataloom, Column, PrimaryKeyColumn, TableColumn + from dataloom import Model, Loom, Column, PrimaryKeyColumn, TableColumn from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, diff --git a/dataloom/tests/mysql/test_delete_mysql.py b/dataloom/tests/mysql/test_delete_mysql.py index d4f8500..83a58ff 100644 --- a/dataloom/tests/mysql/test_delete_mysql.py +++ b/dataloom/tests/mysql/test_delete_mysql.py @@ -3,7 +3,7 @@ def test_delete_by_pk_fn(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -13,7 +13,7 @@ def test_delete_by_pk_fn(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -63,7 +63,7 @@ def test_delete_one_fn(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -74,7 +74,7 @@ def test_delete_one_fn(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -170,7 +170,7 @@ def test_delete_bulk_fn(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -181,7 +181,7 @@ def test_delete_bulk_fn(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -294,7 +294,7 @@ def test_delete_with_limit(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -308,7 +308,7 @@ def test_delete_with_limit(self): import pytest from dataloom.exceptions import InvalidArgumentsException - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -397,7 +397,7 @@ def test_delete_with_filters(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -409,7 +409,7 @@ def test_delete_with_filters(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, diff --git a/dataloom/tests/mysql/test_eager_loading_mysql.py b/dataloom/tests/mysql/test_eager_loading_mysql.py new file mode 100644 index 0000000..667cc64 --- /dev/null +++ b/dataloom/tests/mysql/test_eager_loading_mysql.py @@ -0,0 +1,1034 @@ +class TestEagerLoadingOnMySQL: + def test_find_by_pk(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + Order, + ) + from dataloom.keys import MySQLConfig + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = mysql_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + userId2 = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), + ) + + profileId = mysql_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + mysql_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=1), + ColumnValue(name="type", value=cat), + ], + ) + + profile = mysql_loom.find_by_pk( + instance=Profile, + pk=profileId, + include=[ + Include( + model=User, select=["id", "username", "tokenVersion"], has="one" + ) + ], + ) + assert profile == { + "avatar": "hello.jpg", + "id": 1, + "userId": 1, + "user": {"id": 1, "username": "@miller", "tokenVersion": 0}, + } + + user = mysql_loom.find_by_pk( + instance=User, + pk=userId, + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + user = mysql_loom.find_by_pk( + instance=User, + pk=userId, + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + offset=0, + limit=2, + order=[ + Order(column="createdAt", order="DESC"), + Order(column="id", order="DESC"), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "posts": [ + {"id": 4, "title": "Coding"}, + {"id": 3, "title": "What are you doing"}, + ], + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + post = mysql_loom.find_by_pk( + instance=Post, + pk=1, + select=["title", "id"], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["avatar", "id"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + ), + ], + ) + + assert post == { + "title": "Hey", + "id": 1, + "user": { + "id": 1, + "username": "@miller", + "profile": {"avatar": "hello.jpg", "id": 1}, + }, + "categories": [ + {"id": 4, "type": "sport"}, + {"id": 3, "type": "tech"}, + {"id": 2, "type": "education"}, + {"id": 1, "type": "general"}, + ], + } + + user = mysql_loom.find_by_pk( + instance=User, + pk=userId2, + select=["username", "id"], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ) + ], + ), + ], + ) + assert user == {"username": "bob", "id": 2, "posts": []} + + conn.close() + + def test_find_one(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + Order, + Filter, + ) + from dataloom.keys import MySQLConfig + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = mysql_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + userId2 = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), + ) + + profileId = mysql_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + mysql_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=1), + ColumnValue(name="type", value=cat), + ], + ) + + profile = mysql_loom.find_one( + instance=Profile, + filters=[Filter(column="userId", value=profileId)], + include=[ + Include( + model=User, select=["id", "username", "tokenVersion"], has="one" + ) + ], + ) + assert profile == { + "avatar": "hello.jpg", + "id": 1, + "userId": 1, + "user": {"id": 1, "username": "@miller", "tokenVersion": 0}, + } + + user = mysql_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + user = mysql_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + offset=0, + limit=2, + order=[ + Order(column="createdAt", order="DESC"), + Order(column="id", order="DESC"), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "posts": [ + {"id": 4, "title": "Coding"}, + {"id": 3, "title": "What are you doing"}, + ], + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + post = mysql_loom.find_one( + instance=Post, + filters=[Filter(column="userId", value=userId)], + select=["title", "id"], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["avatar", "id"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + ), + ], + ) + assert post == { + "title": "Hey", + "id": 1, + "user": { + "id": 1, + "username": "@miller", + "profile": {"avatar": "hello.jpg", "id": 1}, + }, + "categories": [ + {"id": 4, "type": "sport"}, + {"id": 3, "type": "tech"}, + {"id": 2, "type": "education"}, + {"id": 1, "type": "general"}, + ], + } + user = mysql_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId2)], + select=["username", "id"], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ) + ], + ), + ], + ) + assert user == {"username": "bob", "id": 2, "posts": []} + user = mysql_loom.find_all( + instance=User, + select=["username", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ), + Include( + model=User, + select=["username", "id"], + has="one", + ), + ], + ), + ], + ) + assert user == [ + { + "username": "@miller", + "id": 1, + "categories": [{"type": "sport", "id": 4}, {"type": "tech", "id": 3}], + "user": {"username": "@miller", "id": 1}, + "posts": [ + { + "id": 1, + "title": "Hey", + "categories": [ + {"type": "sport", "id": 4}, + {"type": "tech", "id": 3}, + ], + "user": {"username": "@miller", "id": 1}, + } + ], + } + ] + + conn.close() + + def test_find_many(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + Order, + Filter, + ) + from dataloom.keys import MySQLConfig + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = mysql_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + userId2 = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), + ) + + profileId = mysql_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + mysql_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=profileId), + ColumnValue(name="type", value=cat), + ], + ) + + profile = mysql_loom.find_many( + instance=Profile, + filters=[Filter(column="userId", value=1)], + include=[ + Include( + model=User, select=["id", "username", "tokenVersion"], has="one" + ) + ], + ) + + assert profile == [ + { + "avatar": "hello.jpg", + "id": 1, + "userId": 1, + "user": {"id": 1, "username": "@miller", "tokenVersion": 0}, + } + ] + + user = mysql_loom.find_many( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + assert user == [ + { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + ] + + user = mysql_loom.find_many( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + offset=0, + limit=2, + order=[ + Order(column="createdAt", order="DESC"), + Order(column="id", order="DESC"), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], + ) + assert user == [ + { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "posts": [ + {"id": 4, "title": "Coding"}, + {"id": 3, "title": "What are you doing"}, + ], + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + ] + post = mysql_loom.find_many( + instance=Post, + filters=[Filter(column="userId", value=userId)], + select=["title", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="DESC")], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["avatar", "id"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + ), + ], + ) + + assert post == [ + { + "title": "Coding", + "id": 4, + "user": { + "id": 1, + "username": "@miller", + "profile": {"avatar": "hello.jpg", "id": 1}, + }, + "categories": [], + } + ] + + user = mysql_loom.find_many( + instance=User, + filters=[Filter(column="id", value=userId2)], + select=["username", "id"], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ) + ], + ), + ], + ) + + assert user == [{"username": "bob", "id": 2, "posts": []}] + + posts = mysql_loom.find_many(Post, select=["id", "completed"]) + assert posts == [ + {"id": 1, "completed": 0}, + {"id": 2, "completed": 0}, + {"id": 3, "completed": 0}, + {"id": 4, "completed": 0}, + ] + + user = mysql_loom.find_many( + instance=User, + filters=[Filter(column="id", value=1)], + select=["username", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ), + Include( + model=User, + select=["username", "id"], + has="one", + ), + ], + ), + ], + ) + assert user == [ + { + "username": "@miller", + "id": 1, + "posts": [ + { + "id": 1, + "title": "Hey", + "categories": [ + {"type": "sport", "id": 4}, + {"type": "tech", "id": 3}, + ], + "user": {"username": "@miller", "id": 1}, + } + ], + } + ] + + def test_unknown_relations(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + ) + from dataloom.keys import MySQLConfig + import pytest + from dataloom.exceptions import UnknownRelationException + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = mysql_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + profileId = mysql_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + mysql_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=profileId), + ColumnValue(name="type", value=cat), + ], + ) + + with pytest.raises(UnknownRelationException) as exec_info: + mysql_loom.find_by_pk( + Profile, + pk=1, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + mysql_loom.find_many( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + mysql_loom.find_by_pk( + Profile, + pk=1, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + mysql_loom.find_all( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + mysql_loom.find_one( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + + with pytest.raises(UnknownRelationException) as exec_info: + mysql_loom.find_all( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=User, has="many", include=[Include(model=Post, has="one")] + ) + ], + ) + assert ( + str(exec_info.value) + == 'The model "profiles" does not maps to "many" of "users".' + ) + conn.close() diff --git a/dataloom/tests/mysql/test_experimental_decorators_mysql.py b/dataloom/tests/mysql/test_experimental_decorators_mysql.py new file mode 100644 index 0000000..a0d8e13 --- /dev/null +++ b/dataloom/tests/mysql/test_experimental_decorators_mysql.py @@ -0,0 +1,70 @@ +class TestExperimentalDecoratorsOnMySQL: + def test_initialize_decorator_fn(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + ) + from dataloom.decorators import initialize + from dataloom.keys import MySQLConfig + + mysql_loom = Loom( + dialect="mysql", + database=MySQLConfig.database, + password=MySQLConfig.password, + user=MySQLConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + @initialize(repr=True, to_dict=True, init=True, repr_identifier="id") + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = mysql_loom.connect_and_sync( + [ + User, + Profile, + ], + drop=True, + force=True, + ) + userId = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + profileId = mysql_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + + res = mysql_loom.find_by_pk(Profile, pk=profileId, select={"id", "avatar"}) + + profile = Profile(**res) + assert str(profile) == "" + assert profile.avatar == "hello.jpg" + assert profile.id == 1 + assert profile.to_dict == {"avatar": "hello.jpg", "id": 1, "userId": None} + conn.close() diff --git a/dataloom/tests/mysql/test_insert_mysql.py b/dataloom/tests/mysql/test_insert_mysql.py index 0370368..17ce332 100644 --- a/dataloom/tests/mysql/test_insert_mysql.py +++ b/dataloom/tests/mysql/test_insert_mysql.py @@ -1,7 +1,7 @@ class TestInsertingOnMySQL: def test_insetting_single_document(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -13,7 +13,7 @@ def test_insetting_single_document(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -62,7 +62,7 @@ class Post(Model): def test_insetting_multiple_document(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -74,7 +74,7 @@ def test_insetting_multiple_document(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -127,13 +127,13 @@ def test_relational_instances(self): ForeignKeyColumn, PrimaryKeyColumn, Column, - Dataloom, + Loom, ColumnValue, TableColumn, ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -176,7 +176,7 @@ class Post(Model): def test_insert_bulk_with_errors(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -190,7 +190,7 @@ def test_insert_bulk_with_errors(self): import pytest from dataloom.exceptions import InvalidColumnValuesException - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, diff --git a/dataloom/tests/mysql/test_inspect_table_mysql.py b/dataloom/tests/mysql/test_inspect_table_mysql.py index 0839732..a67bc6a 100644 --- a/dataloom/tests/mysql/test_inspect_table_mysql.py +++ b/dataloom/tests/mysql/test_inspect_table_mysql.py @@ -3,7 +3,7 @@ def testing_inspecting_table(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -13,7 +13,7 @@ def testing_inspecting_table(self): import pytest from dataloom.exceptions import UnknownColumnException - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, diff --git a/dataloom/tests/mysql/test_operators_mysql.py b/dataloom/tests/mysql/test_operators_mysql.py index 2b0e829..9e6f5fa 100644 --- a/dataloom/tests/mysql/test_operators_mysql.py +++ b/dataloom/tests/mysql/test_operators_mysql.py @@ -3,7 +3,7 @@ def testing_operators(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -14,7 +14,7 @@ def testing_operators(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, diff --git a/dataloom/tests/mysql/test_query_msql.py b/dataloom/tests/mysql/test_query_msql.py index 24c3057..abd0308 100644 --- a/dataloom/tests/mysql/test_query_msql.py +++ b/dataloom/tests/mysql/test_query_msql.py @@ -1,7 +1,7 @@ class TestQueryingMySQL: def test_find_by_pk_fn(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -16,7 +16,7 @@ def test_find_by_pk_fn(self): from dataloom.keys import MySQLConfig from typing import Optional - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -73,7 +73,7 @@ class Post(Model): def test_find_all_fn(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -88,7 +88,7 @@ def test_find_all_fn(self): import pytest from dataloom.exceptions import UnknownColumnException - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -150,7 +150,7 @@ class Post(Model): def test_find_one_fn(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -166,7 +166,7 @@ def test_find_one_fn(self): import pytest from dataloom.exceptions import UnknownColumnException - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -255,7 +255,7 @@ class Post(Model): def test_find_many(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -271,7 +271,7 @@ def test_find_many(self): import pytest from dataloom.exceptions import UnknownColumnException - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, diff --git a/dataloom/tests/mysql/test_update_mysql.py b/dataloom/tests/mysql/test_update_mysql.py index 6f56adf..7f82b41 100644 --- a/dataloom/tests/mysql/test_update_mysql.py +++ b/dataloom/tests/mysql/test_update_mysql.py @@ -5,7 +5,7 @@ def test_update_by_pk_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, ForeignKeyColumn, Model, PrimaryKeyColumn, @@ -15,7 +15,7 @@ def test_update_by_pk_fn(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -80,7 +80,7 @@ def test_update_one_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, ForeignKeyColumn, Model, PrimaryKeyColumn, @@ -92,7 +92,7 @@ def test_update_one_fn(self): from dataloom.keys import MySQLConfig from dataloom.exceptions import UnknownColumnException - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -175,7 +175,7 @@ def test_update_bulk_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, ForeignKeyColumn, Model, PrimaryKeyColumn, @@ -187,7 +187,7 @@ def test_update_bulk_fn(self): from dataloom.keys import MySQLConfig from dataloom.exceptions import UnknownColumnException - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -274,7 +274,7 @@ def test_increment_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, Model, PrimaryKeyColumn, TableColumn, @@ -284,7 +284,7 @@ def test_increment_fn(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, @@ -341,7 +341,7 @@ def test_decrement_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, Model, PrimaryKeyColumn, TableColumn, @@ -351,7 +351,7 @@ def test_decrement_fn(self): ) from dataloom.keys import MySQLConfig - mysql_loom = Dataloom( + mysql_loom = Loom( dialect="mysql", database=MySQLConfig.database, password=MySQLConfig.password, diff --git a/dataloom/tests/postgres/test_aggregation_pg.py b/dataloom/tests/postgres/test_aggregation_pg.py new file mode 100644 index 0000000..01b3a75 --- /dev/null +++ b/dataloom/tests/postgres/test_aggregation_pg.py @@ -0,0 +1,250 @@ +class TestAggregationLoadingOnPG: + def test_find_many(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Filter, + Group, + Having, + ) + import pytest + from dataloom.exceptions import UnknownColumnException + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class User(Model): + __tablename__: 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) + 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", + ) + + conn, tables = pg_loom.connect_and_sync( + [ + User, + Post, + ], + drop=True, + force=True, + ) + userId = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + for title in ["Hello", "Hello", "What are you doing", "Coding"]: + pg_loom.insert_one( + instance=Post, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="title", value=title), + ], + ) + + with pytest.raises(UnknownColumnException) as exc_info: + pg_loom.find_many( + Post, + select="title", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert ( + str(exc_info.value) + == 'The column "id" was omitted in selection of records to be grouped.' + ) + + posts = pg_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert sorted(posts, key=lambda d: d["id"]) == sorted( + [{"id": 2}, {"id": 3}, {"id": 4}], key=lambda d: d["id"] + ) + posts = pg_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=True, + ), + ) + assert sorted(posts, key=lambda d: d["id"]) == sorted( + [ + {"id": 2, 'MAX("id")': 2}, + {"id": 3, 'MAX("id")': 3}, + {"id": 4, 'MAX("id")': 4}, + ], + key=lambda d: d["id"], + ) + conn.close() + + def test_find_all(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Group, + Having, + ) + import pytest + from dataloom.exceptions import UnknownColumnException + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class User(Model): + __tablename__: 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) + 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", + ) + + conn, tables = pg_loom.connect_and_sync( + [ + User, + Post, + ], + drop=True, + force=True, + ) + userId = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + for title in ["Hello", "Hello", "What are you doing", "Coding"]: + pg_loom.insert_one( + instance=Post, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="title", value=title), + ], + ) + + with pytest.raises(UnknownColumnException) as exc_info: + pg_loom.find_all( + Post, + select="title", + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert ( + str(exc_info.value) + == 'The column "id" was omitted in selection of records to be grouped.' + ) + + posts = pg_loom.find_all( + Post, + select="id", + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert sorted(posts, key=lambda d: d["id"]) == sorted( + [{"id": 2}, {"id": 3}, {"id": 4}], key=lambda d: d["id"] + ) + posts = pg_loom.find_all( + Post, + select="id", + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=True, + ), + ) + + assert sorted(posts, key=lambda d: d["id"]) == sorted( + [ + {"id": 2, 'MAX("id")': 2}, + {"id": 3, 'MAX("id")': 3}, + {"id": 4, 'MAX("id")': 4}, + ], + key=lambda d: d["id"], + ) + + conn.close() diff --git a/dataloom/tests/postgres/test_connection_pg.py b/dataloom/tests/postgres/test_connection_pg.py index d35f7cf..87ae43f 100644 --- a/dataloom/tests/postgres/test_connection_pg.py +++ b/dataloom/tests/postgres/test_connection_pg.py @@ -3,10 +3,10 @@ class TestConnectionPG: def test_connect_with_non_existing_database(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database="mew", password=PgConfig.password, @@ -21,10 +21,10 @@ def test_connect_with_non_existing_database(self): ) def test_connect_with_wrong_password(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password="root-", @@ -40,10 +40,10 @@ def test_connect_with_wrong_password(self): ) def test_connect_with_wrong_user(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -59,12 +59,12 @@ def test_connect_with_wrong_user(self): ) def test_connect_with_wrong_dialect(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.exceptions import UnsupportedDialectException from dataloom.keys import PgConfig with pytest.raises(UnsupportedDialectException) as exc_info: - pg_loom = Dataloom( + pg_loom = Loom( dialect="peew", database=PgConfig.database, password=PgConfig.password, @@ -78,10 +78,10 @@ def test_connect_with_wrong_dialect(self): ) def test_connect_correct_connection(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, diff --git a/dataloom/tests/postgres/test_create_table_pg.py b/dataloom/tests/postgres/test_create_table_pg.py index d6ac5c9..aab3320 100644 --- a/dataloom/tests/postgres/test_create_table_pg.py +++ b/dataloom/tests/postgres/test_create_table_pg.py @@ -1,10 +1,10 @@ class TestCreatingTablePG: def test_2_pk_error(self): - from dataloom import Column, PrimaryKeyColumn, Dataloom, TableColumn, Model + from dataloom import Column, PrimaryKeyColumn, Loom, TableColumn, Model import pytest from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -29,10 +29,10 @@ class User(Model): def test_no_pk_error(self): import pytest - from dataloom import Model, Dataloom, Column, TableColumn + from dataloom import Model, Loom, Column, TableColumn from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -51,10 +51,10 @@ class User(Model): conn.close() def test_table_name(self): - from dataloom import Model, Dataloom, Column, PrimaryKeyColumn, TableColumn + from dataloom import Model, Loom, Column, PrimaryKeyColumn, TableColumn from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -77,7 +77,7 @@ class User(Model): conn.close() def test_connect_sync(self): - from dataloom import Dataloom, Model, TableColumn, Column, PrimaryKeyColumn + from dataloom import Loom, Model, TableColumn, Column, PrimaryKeyColumn from dataloom.keys import PgConfig class User(Model): @@ -92,7 +92,7 @@ class Post(Model): id = PrimaryKeyColumn(type="int", nullable=False, auto_increment=True) title = Column(type="text", nullable=False) - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -105,10 +105,10 @@ class Post(Model): conn.close() def test_syncing_tables(self): - from dataloom import Model, Dataloom, Column, PrimaryKeyColumn, TableColumn + from dataloom import Model, Loom, Column, PrimaryKeyColumn, TableColumn from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, diff --git a/dataloom/tests/postgres/test_delete_pg.py b/dataloom/tests/postgres/test_delete_pg.py index 5cccf5e..51a50ed 100644 --- a/dataloom/tests/postgres/test_delete_pg.py +++ b/dataloom/tests/postgres/test_delete_pg.py @@ -3,7 +3,7 @@ def test_delete_by_pk_fn(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -14,7 +14,7 @@ def test_delete_by_pk_fn(self): from dataloom.keys import PgConfig from typing import Optional - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -62,7 +62,7 @@ def test_delete_one_fn(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -74,7 +74,7 @@ def test_delete_one_fn(self): from dataloom.keys import PgConfig from typing import Optional - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -169,7 +169,7 @@ def test_delete_bulk_fn(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -182,7 +182,7 @@ def test_delete_bulk_fn(self): from dataloom.keys import PgConfig from typing import Optional - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -295,7 +295,7 @@ def test_delete_with_limit(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -307,7 +307,7 @@ def test_delete_with_limit(self): ) from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -384,7 +384,7 @@ def test_delete_with_filters(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -396,7 +396,7 @@ def test_delete_with_filters(self): ) from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, diff --git a/dataloom/tests/postgres/test_eager_loading_pg.py b/dataloom/tests/postgres/test_eager_loading_pg.py new file mode 100644 index 0000000..1233c67 --- /dev/null +++ b/dataloom/tests/postgres/test_eager_loading_pg.py @@ -0,0 +1,1090 @@ +class TestEagerLoadingOnPG: + def test_find_by_pk(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + Order, + ) + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = pg_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + userId2 = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), + ) + + profileId = pg_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + pg_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=1), + ColumnValue(name="type", value=cat), + ], + ) + + profile = pg_loom.find_by_pk( + instance=Profile, + pk=profileId, + include=[ + Include( + model=User, select=["id", "username", "tokenVersion"], has="one" + ) + ], + ) + assert profile == { + "avatar": "hello.jpg", + "id": 1, + "userId": 1, + "user": {"id": 1, "username": "@miller", "tokenVersion": 0}, + } + + user = pg_loom.find_by_pk( + instance=User, + pk=userId, + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + user = pg_loom.find_by_pk( + instance=User, + pk=userId, + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + offset=0, + limit=2, + order=[ + Order(column="createdAt", order="DESC"), + Order(column="id", order="DESC"), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "posts": [ + {"id": 4, "title": "Coding"}, + {"id": 3, "title": "What are you doing"}, + ], + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + post = pg_loom.find_by_pk( + instance=Post, + pk=1, + select=["title", "id"], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["avatar", "id"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + ), + ], + ) + + assert post == { + "title": "Hey", + "id": 1, + "user": { + "id": 1, + "username": "@miller", + "profile": {"avatar": "hello.jpg", "id": 1}, + }, + "categories": [ + {"id": 4, "type": "sport"}, + {"id": 3, "type": "tech"}, + {"id": 2, "type": "education"}, + {"id": 1, "type": "general"}, + ], + } + + user = pg_loom.find_by_pk( + instance=User, + pk=userId2, + select=["username", "id"], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ) + ], + ), + ], + ) + assert user == {"username": "bob", "id": 2, "posts": []} + + conn.close() + + def test_find_one(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + Order, + Filter, + ) + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = pg_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + userId2 = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), + ) + + profileId = pg_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + pg_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=1), + ColumnValue(name="type", value=cat), + ], + ) + + profile = pg_loom.find_one( + instance=Profile, + filters=[Filter(column="userId", value=profileId)], + include=[ + Include( + model=User, select=["id", "username", "tokenVersion"], has="one" + ) + ], + ) + assert profile == { + "avatar": "hello.jpg", + "id": 1, + "userId": 1, + "user": {"id": 1, "username": "@miller", "tokenVersion": 0}, + } + + user = pg_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + user = pg_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + offset=0, + limit=2, + order=[ + Order(column="createdAt", order="DESC"), + Order(column="id", order="DESC"), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "posts": [ + {"id": 4, "title": "Coding"}, + {"id": 3, "title": "What are you doing"}, + ], + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + post = pg_loom.find_one( + instance=Post, + filters=[Filter(column="userId", value=userId)], + select=["title", "id"], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["avatar", "id"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + ), + ], + ) + assert post == { + "title": "Hey", + "id": 1, + "user": { + "id": 1, + "username": "@miller", + "profile": {"avatar": "hello.jpg", "id": 1}, + }, + "categories": [ + {"id": 4, "type": "sport"}, + {"id": 3, "type": "tech"}, + {"id": 2, "type": "education"}, + {"id": 1, "type": "general"}, + ], + } + user = pg_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId2)], + select=["username", "id"], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ) + ], + ), + ], + ) + assert user == {"username": "bob", "id": 2, "posts": []} + + user = pg_loom.find_all( + instance=User, + select=["username", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ), + Include( + model=User, + select=["username", "id"], + has="one", + ), + ], + ), + ], + ) + assert user == [ + { + "username": "@miller", + "id": 1, + "categories": [{"type": "sport", "id": 4}, {"type": "tech", "id": 3}], + "user": {"username": "@miller", "id": 1}, + "posts": [ + { + "id": 1, + "title": "Hey", + "categories": [ + {"type": "sport", "id": 4}, + {"type": "tech", "id": 3}, + ], + "user": {"username": "@miller", "id": 1}, + } + ], + } + ] + + conn.close() + + def test_find_many(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + Order, + Filter, + ) + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = pg_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + userId2 = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), + ) + + profileId = pg_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + pg_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=profileId), + ColumnValue(name="type", value=cat), + ], + ) + + profile = pg_loom.find_many( + instance=Profile, + filters=[Filter(column="userId", value=1)], + include=[ + Include( + model=User, select=["id", "username", "tokenVersion"], has="one" + ) + ], + ) + + assert profile == [ + { + "avatar": "hello.jpg", + "id": 1, + "userId": 1, + "user": {"id": 1, "username": "@miller", "tokenVersion": 0}, + } + ] + + user = pg_loom.find_many( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + assert user == [ + { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + ] + + user = pg_loom.find_many( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + offset=0, + limit=2, + order=[ + Order(column="createdAt", order="DESC"), + Order(column="id", order="DESC"), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], + ) + assert user == [ + { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "posts": [ + {"id": 4, "title": "Coding"}, + {"id": 3, "title": "What are you doing"}, + ], + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + ] + post = pg_loom.find_many( + instance=Post, + filters=[Filter(column="userId", value=userId)], + select=["title", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="DESC")], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["avatar", "id"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + ), + ], + ) + + assert post == [ + { + "title": "Coding", + "id": 4, + "user": { + "id": 1, + "username": "@miller", + "profile": {"avatar": "hello.jpg", "id": 1}, + }, + "categories": [], + } + ] + + user = pg_loom.find_many( + instance=User, + filters=[Filter(column="id", value=userId2)], + select=["username", "id"], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ) + ], + ), + ], + ) + + assert user == [{"username": "bob", "id": 2, "posts": []}] + + posts = pg_loom.find_many(Post, select=["id", "completed"]) + assert posts == [ + {"id": 1, "completed": 0}, + {"id": 2, "completed": 0}, + {"id": 3, "completed": 0}, + {"id": 4, "completed": 0}, + ] + + user = pg_loom.find_all( + instance=User, + select=["username", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ), + Include( + model=User, + select=["username", "id"], + has="one", + ), + ], + ), + ], + ) + assert user == [ + { + "username": "@miller", + "id": 1, + "categories": [{"type": "sport", "id": 4}, {"type": "tech", "id": 3}], + "user": {"username": "@miller", "id": 1}, + "posts": [ + { + "id": 1, + "title": "Hey", + "categories": [ + {"type": "sport", "id": 4}, + {"type": "tech", "id": 3}, + ], + "user": {"username": "@miller", "id": 1}, + } + ], + } + ] + user = pg_loom.find_many( + instance=User, + filters=[Filter(column="id", value=1)], + select=["username", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ), + Include( + model=User, + select=["username", "id"], + has="one", + ), + ], + ), + ], + ) + assert user == [ + { + "username": "@miller", + "id": 1, + "posts": [ + { + "id": 1, + "title": "Hey", + "categories": [ + {"type": "sport", "id": 4}, + {"type": "tech", "id": 3}, + ], + "user": {"username": "@miller", "id": 1}, + } + ], + } + ] + + def test_unknown_relations(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + ) + + import pytest + from dataloom.exceptions import UnknownRelationException + + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = pg_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + profileId = pg_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + pg_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=profileId), + ColumnValue(name="type", value=cat), + ], + ) + + with pytest.raises(UnknownRelationException) as exec_info: + pg_loom.find_by_pk( + Profile, + pk=1, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + pg_loom.find_many( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + pg_loom.find_by_pk( + Profile, + pk=1, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + pg_loom.find_all( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + pg_loom.find_one( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + + with pytest.raises(UnknownRelationException) as exec_info: + pg_loom.find_all( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=User, has="many", include=[Include(model=Post, has="one")] + ) + ], + ) + assert ( + str(exec_info.value) + == 'The model "profiles" does not maps to "many" of "users".' + ) + conn.close() + + conn.close() diff --git a/dataloom/tests/postgres/test_experimental_decorators_pg.py b/dataloom/tests/postgres/test_experimental_decorators_pg.py new file mode 100644 index 0000000..d88dbe0 --- /dev/null +++ b/dataloom/tests/postgres/test_experimental_decorators_pg.py @@ -0,0 +1,70 @@ +class TestExperimentalDecoratorsOnPG: + def test_initialize_decorator_fn(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + ) + from dataloom.decorators import initialize + from dataloom.keys import PgConfig + + pg_loom = Loom( + dialect="postgres", + database=PgConfig.database, + password=PgConfig.password, + user=PgConfig.user, + ) + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + @initialize(repr=True, to_dict=True, init=True, repr_identifier="id") + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = pg_loom.connect_and_sync( + [ + User, + Profile, + ], + drop=True, + force=True, + ) + userId = pg_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + profileId = pg_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + + res = pg_loom.find_by_pk(Profile, pk=profileId, select={"id", "avatar"}) + + profile = Profile(**res) + assert str(profile) == "" + assert profile.avatar == "hello.jpg" + assert profile.id == 1 + assert profile.to_dict == {"avatar": "hello.jpg", "id": 1, "userId": None} + conn.close() diff --git a/dataloom/tests/postgres/test_insert_pg.py b/dataloom/tests/postgres/test_insert_pg.py index e54a2ab..c5f5a6e 100644 --- a/dataloom/tests/postgres/test_insert_pg.py +++ b/dataloom/tests/postgres/test_insert_pg.py @@ -1,7 +1,7 @@ class TestInsertingOnPG: def test_insetting_single_document(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -13,7 +13,7 @@ def test_insetting_single_document(self): ) from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -60,7 +60,7 @@ class Post(Model): def test_insetting_multiple_document(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -72,7 +72,7 @@ def test_insetting_multiple_document(self): ) from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -125,13 +125,13 @@ def test_relational_instances(self): ForeignKeyColumn, PrimaryKeyColumn, Column, - Dataloom, + Loom, ColumnValue, TableColumn, ) from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -172,7 +172,7 @@ class Post(Model): def test_insert_bulk_with_errors(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -186,7 +186,7 @@ def test_insert_bulk_with_errors(self): from dataloom.keys import PgConfig import pytest - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, diff --git a/dataloom/tests/postgres/test_inspecting_table_pg.py b/dataloom/tests/postgres/test_inspecting_table_pg.py index 3896a1f..876a4ab 100644 --- a/dataloom/tests/postgres/test_inspecting_table_pg.py +++ b/dataloom/tests/postgres/test_inspecting_table_pg.py @@ -3,7 +3,7 @@ def testing_inspecting_table(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -13,7 +13,7 @@ def testing_inspecting_table(self): from dataloom.exceptions import UnknownColumnException import pytest - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, diff --git a/dataloom/tests/postgres/test_operators_pg.py b/dataloom/tests/postgres/test_operators_pg.py index 39a1204..a86d540 100644 --- a/dataloom/tests/postgres/test_operators_pg.py +++ b/dataloom/tests/postgres/test_operators_pg.py @@ -3,7 +3,7 @@ def testing_operators(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -14,7 +14,7 @@ def testing_operators(self): ) from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, diff --git a/dataloom/tests/postgres/test_query_pg.py b/dataloom/tests/postgres/test_query_pg.py index b89d3bf..6555d02 100644 --- a/dataloom/tests/postgres/test_query_pg.py +++ b/dataloom/tests/postgres/test_query_pg.py @@ -1,7 +1,7 @@ class TestQueryingPG: def test_find_by_pk_fn(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -16,7 +16,7 @@ def test_find_by_pk_fn(self): from dataloom.keys import PgConfig from typing import Optional - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -70,7 +70,7 @@ class Post(Model): def test_find_all_fn(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -85,7 +85,7 @@ def test_find_all_fn(self): import pytest from dataloom.exceptions import UnknownColumnException - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -145,7 +145,7 @@ class Post(Model): def test_find_one_fn(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -161,7 +161,7 @@ def test_find_one_fn(self): import pytest from dataloom.exceptions import UnknownColumnException - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -248,7 +248,7 @@ class Post(Model): def test_find_many(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -264,7 +264,7 @@ def test_find_many(self): import pytest from dataloom.exceptions import UnknownColumnException - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, diff --git a/dataloom/tests/postgres/test_update_pg.py b/dataloom/tests/postgres/test_update_pg.py index 3be2eec..030e1ca 100644 --- a/dataloom/tests/postgres/test_update_pg.py +++ b/dataloom/tests/postgres/test_update_pg.py @@ -5,7 +5,7 @@ def test_update_by_pk_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, ForeignKeyColumn, Model, PrimaryKeyColumn, @@ -15,7 +15,7 @@ def test_update_by_pk_fn(self): ) from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -79,7 +79,7 @@ def test_update_one_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, ForeignKeyColumn, Model, PrimaryKeyColumn, @@ -91,7 +91,7 @@ def test_update_one_fn(self): from dataloom.exceptions import UnknownColumnException from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -171,7 +171,7 @@ def test_update_bulk_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, ForeignKeyColumn, Model, PrimaryKeyColumn, @@ -183,7 +183,7 @@ def test_update_bulk_fn(self): from dataloom.exceptions import UnknownColumnException from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -265,7 +265,7 @@ def test_increment_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, Model, PrimaryKeyColumn, TableColumn, @@ -275,7 +275,7 @@ def test_increment_fn(self): ) from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, @@ -332,7 +332,7 @@ def test_decrement_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, Model, PrimaryKeyColumn, TableColumn, @@ -342,7 +342,7 @@ def test_decrement_fn(self): ) from dataloom.keys import PgConfig - pg_loom = Dataloom( + pg_loom = Loom( dialect="postgres", database=PgConfig.database, password=PgConfig.password, diff --git a/dataloom/tests/sqlite3/test_aggregation_sqlite.py b/dataloom/tests/sqlite3/test_aggregation_sqlite.py new file mode 100644 index 0000000..09af132 --- /dev/null +++ b/dataloom/tests/sqlite3/test_aggregation_sqlite.py @@ -0,0 +1,230 @@ +class TestAggregationLoadingOnSQLite: + def test_find_many(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Filter, + Group, + Having, + ) + import pytest + from dataloom.exceptions import UnknownColumnException + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class User(Model): + __tablename__: 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) + 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", + ) + + conn, tables = sqlite_loom.connect_and_sync( + [ + User, + Post, + ], + drop=True, + force=True, + ) + userId = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + for title in ["Hello", "Hello", "What are you doing", "Coding"]: + sqlite_loom.insert_one( + instance=Post, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="title", value=title), + ], + ) + + with pytest.raises(UnknownColumnException) as exc_info: + sqlite_loom.find_many( + Post, + select="title", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert ( + str(exc_info.value) + == 'The column "id" was omitted in selection of records to be grouped.' + ) + + posts = sqlite_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert posts == [{"id": 2}, {"id": 3}, {"id": 4}] + posts = sqlite_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=True, + ), + ) + + assert posts == [ + {"id": 2, "MAX(`id`)": 2}, + {"id": 3, "MAX(`id`)": 3}, + {"id": 4, "MAX(`id`)": 4}, + ] + + conn.close() + + def test_find_all(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Group, + Having, + ) + import pytest + from dataloom.exceptions import UnknownColumnException + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class User(Model): + __tablename__: 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) + 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", + ) + + conn, tables = sqlite_loom.connect_and_sync( + [ + User, + Post, + ], + drop=True, + force=True, + ) + userId = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + for title in ["Hello", "Hello", "What are you doing", "Coding"]: + sqlite_loom.insert_one( + instance=Post, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="title", value=title), + ], + ) + + with pytest.raises(UnknownColumnException) as exc_info: + sqlite_loom.find_all( + Post, + select="title", + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert ( + str(exc_info.value) + == 'The column "id" was omitted in selection of records to be grouped.' + ) + + posts = sqlite_loom.find_all( + Post, + select="id", + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), + ) + + assert posts == [{"id": 2}, {"id": 3}, {"id": 4}] + posts = sqlite_loom.find_all( + Post, + select="id", + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=True, + ), + ) + + assert posts == [ + {"id": 2, "MAX(`id`)": 2}, + {"id": 3, "MAX(`id`)": 3}, + {"id": 4, "MAX(`id`)": 4}, + ] + + conn.close() diff --git a/dataloom/tests/sqlite3/test_connection_sqlite.py b/dataloom/tests/sqlite3/test_connection_sqlite.py index fdbb8bb..a848842 100644 --- a/dataloom/tests/sqlite3/test_connection_sqlite.py +++ b/dataloom/tests/sqlite3/test_connection_sqlite.py @@ -3,11 +3,11 @@ class TestConnectionSQLite: def test_connect_with_wrong_dialect(self): - from dataloom import Dataloom + from dataloom import Loom from dataloom.exceptions import UnsupportedDialectException with pytest.raises(UnsupportedDialectException) as exc_info: - sqlite_loom = Dataloom(dialect="hay", database="hi.db") + sqlite_loom = Loom(dialect="hay", database="hi.db") conn = sqlite_loom.connect() conn.close() @@ -17,9 +17,9 @@ def test_connect_with_wrong_dialect(self): ) def test_connect_correct_connection(self): - from dataloom import Dataloom + from dataloom import Loom - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") conn = sqlite_loom.connect() conn.close() assert conn is not None diff --git a/dataloom/tests/sqlite3/test_create_table_sqlite.py b/dataloom/tests/sqlite3/test_create_table_sqlite.py index c112f20..0ae8c10 100644 --- a/dataloom/tests/sqlite3/test_create_table_sqlite.py +++ b/dataloom/tests/sqlite3/test_create_table_sqlite.py @@ -1,10 +1,10 @@ class TestCreatingTableSQlite: def test_2_pk_error(self): - from dataloom import Column, PrimaryKeyColumn, Dataloom, TableColumn, Model + from dataloom import Column, PrimaryKeyColumn, Loom, TableColumn, Model import pytest from typing import Optional - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") conn = sqlite_loom.connect() class User(Model): @@ -24,10 +24,10 @@ class User(Model): def test_no_pk_error(self): import pytest - from dataloom import Model, Dataloom, Column, TableColumn + from dataloom import Model, Loom, Column, TableColumn from typing import Optional - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") conn = sqlite_loom.connect() class User(Model): @@ -41,10 +41,10 @@ class User(Model): conn.close() def test_table_name(self): - from dataloom import Model, Dataloom, Column, PrimaryKeyColumn, TableColumn + from dataloom import Model, Loom, Column, PrimaryKeyColumn, TableColumn from typing import Optional - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") conn = sqlite_loom.connect() class Posts(Model): @@ -62,7 +62,7 @@ class User(Model): conn.close() def test_connect_sync(self): - from dataloom import Dataloom, Model, TableColumn, Column, PrimaryKeyColumn + from dataloom import Loom, Model, TableColumn, Column, PrimaryKeyColumn from typing import Optional class User(Model): @@ -77,7 +77,7 @@ class Post(Model): id = PrimaryKeyColumn(type="int", nullable=False, auto_increment=True) title = Column(type="text", nullable=False) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") conn, tables = sqlite_loom.connect_and_sync([User, Post], drop=True, force=True) assert len(tables) >= 2 assert "users" in tables and "posts" in tables @@ -85,10 +85,10 @@ class Post(Model): conn.close() def test_syncing_tables(self): - from dataloom import Model, Dataloom, Column, PrimaryKeyColumn, TableColumn + from dataloom import Model, Loom, Column, PrimaryKeyColumn, TableColumn from typing import Optional - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") conn = sqlite_loom.connect() class Post(Model): diff --git a/dataloom/tests/sqlite3/test_delete_sqlite.py b/dataloom/tests/sqlite3/test_delete_sqlite.py index f333c27..ed65c9d 100644 --- a/dataloom/tests/sqlite3/test_delete_sqlite.py +++ b/dataloom/tests/sqlite3/test_delete_sqlite.py @@ -3,7 +3,7 @@ def test_delete_by_pk_fn(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -14,7 +14,7 @@ def test_delete_by_pk_fn(self): from typing import Optional - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -58,7 +58,7 @@ def test_delete_one_fn(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -70,7 +70,7 @@ def test_delete_one_fn(self): from dataloom.exceptions import UnknownColumnException from typing import Optional - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -160,7 +160,7 @@ def test_delete_bulk_fn(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -173,7 +173,7 @@ def test_delete_bulk_fn(self): from typing import Optional from dataloom.exceptions import UnknownColumnException - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -281,7 +281,7 @@ def test_delete_with_limit(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -292,7 +292,7 @@ def test_delete_with_limit(self): Order, ) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: TableColumn = TableColumn(name="users") @@ -364,7 +364,7 @@ def test_delete_with_filters(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -375,7 +375,7 @@ def test_delete_with_filters(self): Filter, ) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: TableColumn = TableColumn(name="users") diff --git a/dataloom/tests/sqlite3/test_eager_loading_sqlite.py b/dataloom/tests/sqlite3/test_eager_loading_sqlite.py new file mode 100644 index 0000000..839d551 --- /dev/null +++ b/dataloom/tests/sqlite3/test_eager_loading_sqlite.py @@ -0,0 +1,1010 @@ +class TestEagerLoadingOnSQLite: + def test_find_by_pk(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + Order, + ) + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = sqlite_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + userId2 = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), + ) + + profileId = sqlite_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + sqlite_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=1), + ColumnValue(name="type", value=cat), + ], + ) + + profile = sqlite_loom.find_by_pk( + instance=Profile, + pk=profileId, + include=[ + Include( + model=User, select=["id", "username", "tokenVersion"], has="one" + ) + ], + ) + assert profile == { + "avatar": "hello.jpg", + "id": 1, + "userId": 1, + "user": {"id": 1, "username": "@miller", "tokenVersion": 0}, + } + + user = sqlite_loom.find_by_pk( + instance=User, + pk=userId, + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + user = sqlite_loom.find_by_pk( + instance=User, + pk=userId, + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + offset=0, + limit=2, + order=[ + Order(column="createdAt", order="DESC"), + Order(column="id", order="DESC"), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "posts": [ + {"id": 4, "title": "Coding"}, + {"id": 3, "title": "What are you doing"}, + ], + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + post = sqlite_loom.find_by_pk( + instance=Post, + pk=1, + select=["title", "id"], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["avatar", "id"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + ), + ], + ) + + assert post == { + "title": "Hey", + "id": 1, + "user": { + "id": 1, + "username": "@miller", + "profile": {"avatar": "hello.jpg", "id": 1}, + }, + "categories": [ + {"id": 4, "type": "sport"}, + {"id": 3, "type": "tech"}, + {"id": 2, "type": "education"}, + {"id": 1, "type": "general"}, + ], + } + + user = sqlite_loom.find_by_pk( + instance=User, + pk=userId2, + select=["username", "id"], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ) + ], + ), + ], + ) + assert user == {"username": "bob", "id": 2, "posts": []} + + conn.close() + + def test_find_one(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + Order, + Filter, + ) + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = sqlite_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + userId2 = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), + ) + + profileId = sqlite_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + sqlite_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=1), + ColumnValue(name="type", value=cat), + ], + ) + + profile = sqlite_loom.find_one( + instance=Profile, + filters=[Filter(column="userId", value=profileId)], + include=[ + Include( + model=User, select=["id", "username", "tokenVersion"], has="one" + ) + ], + ) + assert profile == { + "avatar": "hello.jpg", + "id": 1, + "userId": 1, + "user": {"id": 1, "username": "@miller", "tokenVersion": 0}, + } + + user = sqlite_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + user = sqlite_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + offset=0, + limit=2, + order=[ + Order(column="createdAt", order="DESC"), + Order(column="id", order="DESC"), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], + ) + assert user == { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "posts": [ + {"id": 4, "title": "Coding"}, + {"id": 3, "title": "What are you doing"}, + ], + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + + post = sqlite_loom.find_one( + instance=Post, + filters=[Filter(column="userId", value=userId)], + select=["title", "id"], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["avatar", "id"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + ), + ], + ) + assert post == { + "title": "Hey", + "id": 1, + "user": { + "id": 1, + "username": "@miller", + "profile": {"avatar": "hello.jpg", "id": 1}, + }, + "categories": [ + {"id": 4, "type": "sport"}, + {"id": 3, "type": "tech"}, + {"id": 2, "type": "education"}, + {"id": 1, "type": "general"}, + ], + } + user = sqlite_loom.find_one( + instance=User, + filters=[Filter(column="id", value=userId2)], + select=["username", "id"], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ) + ], + ), + ], + ) + assert user == {"username": "bob", "id": 2, "posts": []} + + user = sqlite_loom.find_all( + instance=User, + select=["username", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ), + Include( + model=User, + select=["username", "id"], + has="one", + ), + ], + ), + ], + ) + assert user == [ + { + "username": "@miller", + "id": 1, + "categories": [{"type": "sport", "id": 4}, {"type": "tech", "id": 3}], + "user": {"username": "@miller", "id": 1}, + "posts": [ + { + "id": 1, + "title": "Hey", + "categories": [ + {"type": "sport", "id": 4}, + {"type": "tech", "id": 3}, + ], + "user": {"username": "@miller", "id": 1}, + } + ], + } + ] + + conn.close() + + def test_find_many(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + Order, + Filter, + ) + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = sqlite_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + userId2 = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), + ) + + profileId = sqlite_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + sqlite_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=profileId), + ColumnValue(name="type", value=cat), + ], + ) + + profile = sqlite_loom.find_many( + instance=Profile, + filters=[Filter(column="userId", value=1)], + include=[ + Include( + model=User, select=["id", "username", "tokenVersion"], has="one" + ) + ], + ) + + assert profile == [ + { + "avatar": "hello.jpg", + "id": 1, + "userId": 1, + "user": {"id": 1, "username": "@miller", "tokenVersion": 0}, + } + ] + + user = sqlite_loom.find_many( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[Include(model=Profile, select=["id", "avatar"], has="one")], + ) + assert user == [ + { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + ] + + user = sqlite_loom.find_many( + instance=User, + filters=[Filter(column="id", value=userId)], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + offset=0, + limit=2, + order=[ + Order(column="createdAt", order="DESC"), + Order(column="id", order="DESC"), + ], + ), + Include(model=Profile, select=["id", "avatar"], has="one"), + ], + ) + assert user == [ + { + "id": 1, + "name": "Bob", + "tokenVersion": 0, + "username": "@miller", + "posts": [ + {"id": 4, "title": "Coding"}, + {"id": 3, "title": "What are you doing"}, + ], + "profile": {"id": 1, "avatar": "hello.jpg"}, + } + ] + post = sqlite_loom.find_many( + instance=Post, + filters=[Filter(column="userId", value=userId)], + select=["title", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="DESC")], + include=[ + Include( + model=User, + select=["id", "username"], + has="one", + include=[ + Include(model=Profile, select=["avatar", "id"], has="one") + ], + ), + Include( + model=Category, + select=["id", "type"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + ), + ], + ) + + assert post == [ + { + "title": "Coding", + "id": 4, + "user": { + "id": 1, + "username": "@miller", + "profile": {"avatar": "hello.jpg", "id": 1}, + }, + "categories": [], + } + ] + + user = sqlite_loom.find_many( + instance=User, + filters=[Filter(column="id", value=userId2)], + select=["username", "id"], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ) + ], + ), + ], + ) + assert user == [{"username": "bob", "id": 2, "posts": []}] + posts = sqlite_loom.find_many(Post, select=["id", "completed"]) + assert posts == [ + {"id": 1, "completed": 0}, + {"id": 2, "completed": 0}, + {"id": 3, "completed": 0}, + {"id": 4, "completed": 0}, + ] + user = sqlite_loom.find_many( + instance=User, + filters=[Filter(column="id", value=1)], + select=["username", "id"], + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Post, + select=["id", "title"], + has="many", + limit=1, + offset=0, + order=[Order(column="id", order="ASC")], + include=[ + Include( + model=Category, + select=["type", "id"], + has="many", + order=[Order(column="id", order="DESC")], + limit=2, + offset=0, + ), + Include( + model=User, + select=["username", "id"], + has="one", + ), + ], + ), + ], + ) + assert user == [ + { + "username": "@miller", + "id": 1, + "posts": [ + { + "id": 1, + "title": "Hey", + "categories": [ + {"type": "sport", "id": 4}, + {"type": "tech", "id": 3}, + ], + "user": {"username": "@miller", "id": 1}, + } + ], + } + ] + + def test_unknown_relations(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + CreatedAtColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + Include, + ) + + import pytest + from dataloom.exceptions import UnknownRelationException + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + 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", + ) + + class Category(Model): + __tablename__: TableColumn = TableColumn(name="categories") + id = PrimaryKeyColumn( + type="int", auto_increment=True, nullable=False, unique=True + ) + type = Column(type="varchar", length=255, nullable=False) + + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = sqlite_loom.connect_and_sync( + [User, Profile, Post, Category], drop=True, force=True + ) + + userId = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + + profileId = sqlite_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + 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), + ], + ) + + for cat in ["general", "education", "tech", "sport"]: + sqlite_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=profileId), + ColumnValue(name="type", value=cat), + ], + ) + + with pytest.raises(UnknownRelationException) as exec_info: + sqlite_loom.find_by_pk( + Profile, + pk=1, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + sqlite_loom.find_many( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + sqlite_loom.find_by_pk( + Profile, + pk=1, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + sqlite_loom.find_all( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + sqlite_loom.find_one( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=Category, + ) + ], + ) + assert ( + str(exec_info.value) + == 'The table "profiles" does not have relations "categories".' + ) + with pytest.raises(UnknownRelationException) as exec_info: + sqlite_loom.find_all( + Profile, + select=["avatar", "id"], + include=[ + Include( + model=User, has="many", include=[Include(model=Post, has="one")] + ) + ], + ) + assert ( + str(exec_info.value) + == 'The model "profiles" does not maps to "many" of "users".' + ) + conn.close() + + conn.close() diff --git a/dataloom/tests/sqlite3/test_experimental_decorators_sqlite.py b/dataloom/tests/sqlite3/test_experimental_decorators_sqlite.py new file mode 100644 index 0000000..224c11d --- /dev/null +++ b/dataloom/tests/sqlite3/test_experimental_decorators_sqlite.py @@ -0,0 +1,64 @@ +class TestExperimentalDecoratorsOnSQLite: + def test_initialize_decorator_fn(self): + from dataloom import ( + Loom, + Model, + Column, + PrimaryKeyColumn, + TableColumn, + ForeignKeyColumn, + ColumnValue, + ) + from dataloom.decorators import initialize + + sqlite_loom = Loom(dialect="sqlite", database="hi.db") + + class User(Model): + __tablename__: 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) + tokenVersion = Column(type="int", default=0) + + @initialize(repr=True, to_dict=True, init=True, repr_identifier="id") + class Profile(Model): + __tablename__: TableColumn = TableColumn(name="profiles") + id = PrimaryKeyColumn(type="int", auto_increment=True) + avatar = Column(type="text", nullable=False) + userId = ForeignKeyColumn( + User, + maps_to="1-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + + conn, tables = sqlite_loom.connect_and_sync( + [ + User, + Profile, + ], + drop=True, + force=True, + ) + userId = sqlite_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="@miller"), + ) + profileId = sqlite_loom.insert_one( + instance=Profile, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), + ], + ) + + res = sqlite_loom.find_by_pk(Profile, pk=profileId, select={"id", "avatar"}) + + profile = Profile(**res) + assert str(profile) == "" + assert profile.avatar == "hello.jpg" + assert profile.id == 1 + assert profile.to_dict == {"avatar": "hello.jpg", "id": 1, "userId": None} + conn.close() diff --git a/dataloom/tests/sqlite3/test_insert_sqlite.py b/dataloom/tests/sqlite3/test_insert_sqlite.py index 1e3a4e7..f2429ef 100644 --- a/dataloom/tests/sqlite3/test_insert_sqlite.py +++ b/dataloom/tests/sqlite3/test_insert_sqlite.py @@ -1,7 +1,7 @@ class TestInsertingOnPG: def test_insetting_single_document(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -12,7 +12,7 @@ def test_insetting_single_document(self): ColumnValue, ) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: TableColumn = TableColumn(name="users") @@ -56,7 +56,7 @@ class Post(Model): def test_insetting_multiple_document(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -67,7 +67,7 @@ def test_insetting_multiple_document(self): ColumnValue, ) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: TableColumn = TableColumn(name="users") @@ -115,12 +115,12 @@ def test_relational_instances(self): ForeignKeyColumn, PrimaryKeyColumn, Column, - Dataloom, + Loom, ColumnValue, TableColumn, ) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: TableColumn = TableColumn(name="users") @@ -157,7 +157,7 @@ class Post(Model): def test_insert_bulk_with_errors(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -171,7 +171,7 @@ def test_insert_bulk_with_errors(self): import pytest - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: TableColumn = TableColumn(name="users") diff --git a/dataloom/tests/sqlite3/test_inspect_table_sqlite.py b/dataloom/tests/sqlite3/test_inspect_table_sqlite.py index 1ca5cad..0e3ee91 100644 --- a/dataloom/tests/sqlite3/test_inspect_table_sqlite.py +++ b/dataloom/tests/sqlite3/test_inspect_table_sqlite.py @@ -3,7 +3,7 @@ def testing_inspecting_table(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -12,7 +12,7 @@ def testing_inspecting_table(self): import pytest from dataloom.exceptions import UnknownColumnException - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: TableColumn = TableColumn(name="users") diff --git a/dataloom/tests/sqlite3/test_operators_sqlite.py b/dataloom/tests/sqlite3/test_operators_sqlite.py index 311e2d7..4c8f879 100644 --- a/dataloom/tests/sqlite3/test_operators_sqlite.py +++ b/dataloom/tests/sqlite3/test_operators_sqlite.py @@ -3,7 +3,7 @@ def testing_operators(self): from dataloom import ( Column, PrimaryKeyColumn, - Dataloom, + Loom, TableColumn, Model, CreatedAtColumn, @@ -13,7 +13,7 @@ def testing_operators(self): Filter, ) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: TableColumn = TableColumn(name="users") diff --git a/dataloom/tests/sqlite3/test_query_sqlite.py b/dataloom/tests/sqlite3/test_query_sqlite.py index 1536b3f..8ac05dd 100644 --- a/dataloom/tests/sqlite3/test_query_sqlite.py +++ b/dataloom/tests/sqlite3/test_query_sqlite.py @@ -1,7 +1,7 @@ class TestQueryingSQLite: def test_find_by_pk_fn(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -15,7 +15,7 @@ def test_find_by_pk_fn(self): from typing import Optional from dataloom.exceptions import UnknownColumnException - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -66,7 +66,7 @@ class Post(Model): def test_find_all_fn(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -80,7 +80,7 @@ def test_find_all_fn(self): from dataloom.exceptions import UnknownColumnException import pytest - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -137,7 +137,7 @@ class Post(Model): def test_find_one_fn(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -152,7 +152,7 @@ def test_find_one_fn(self): import pytest from dataloom.exceptions import UnknownColumnException - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -236,7 +236,7 @@ class Post(Model): def test_find_many(self): from dataloom import ( - Dataloom, + Loom, Model, Column, PrimaryKeyColumn, @@ -251,7 +251,7 @@ def test_find_many(self): from dataloom.exceptions import UnknownColumnException import pytest - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") diff --git a/dataloom/tests/sqlite3/test_update_sqlite.py b/dataloom/tests/sqlite3/test_update_sqlite.py index 2122d3f..7473e80 100644 --- a/dataloom/tests/sqlite3/test_update_sqlite.py +++ b/dataloom/tests/sqlite3/test_update_sqlite.py @@ -6,7 +6,7 @@ def test_update_by_pk_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, ForeignKeyColumn, Model, PrimaryKeyColumn, @@ -15,7 +15,7 @@ def test_update_by_pk_fn(self): ColumnValue, ) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -74,7 +74,7 @@ def test_update_one_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, ForeignKeyColumn, Model, PrimaryKeyColumn, @@ -85,7 +85,7 @@ def test_update_one_fn(self): ) from dataloom.exceptions import UnknownColumnException - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -160,7 +160,7 @@ def test_update_bulk_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, ForeignKeyColumn, Model, PrimaryKeyColumn, @@ -171,7 +171,7 @@ def test_update_bulk_fn(self): ) from dataloom.exceptions import UnknownColumnException - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -242,7 +242,7 @@ def test_increment_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, Model, PrimaryKeyColumn, TableColumn, @@ -251,7 +251,7 @@ def test_increment_fn(self): ColumnValue, ) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") @@ -304,7 +304,7 @@ def test_decrement_fn(self): from dataloom import ( Column, CreatedAtColumn, - Dataloom, + Loom, Model, PrimaryKeyColumn, TableColumn, @@ -313,7 +313,7 @@ def test_decrement_fn(self): ColumnValue, ) - sqlite_loom = Dataloom(dialect="sqlite", database="hi.db") + sqlite_loom = Loom(dialect="sqlite", database="hi.db") class User(Model): __tablename__: Optional[TableColumn] = TableColumn(name="users") diff --git a/dataloom/types/__init__.py b/dataloom/types/__init__.py index 92b8112..d68c4e0 100644 --- a/dataloom/types/__init__.py +++ b/dataloom/types/__init__.py @@ -10,6 +10,7 @@ CASCADE_LITERAL = Literal["NO ACTION", "CASCADE", "SET NULL"] DIALECT_LITERAL = Literal["postgres", "mysql", "sqlite"] RELATIONSHIP_LITERAL = Literal["1-1", "1-N", "N-1", "N-N"] +INCLUDE_LITERAL = Literal["one", "many"] SLQ_OPERATORS = { @@ -28,6 +29,114 @@ "OR": "OR", } +AGGREGATION_LITERALS = Literal["AVG", "COUNT", "SUM", "MAX", "MIN"] + + +@dataclass(kw_only=True, repr=False) +class Having: + """ + Having + ------ + + This class method is used to specify the filters to be applied on Grouped data during aggregation in dataloom. + + Parameters + ---------- + column : str + The name of the column to filter on. + operator : OPERATOR_LITERAL, optional + The operator to use for the filter. Default is "eq". + value : Any + The value to compare against. + join_next_with : "AND" | "OR", optional + The SQL operand to join the next filter with. Default is "AND". + + Returns + ------- + None + This method does not return any value. + + See Also + -------- + Filter : Class used to define filter conditions. + ColumnValue : Class for defining column values. + Order : Class for defining order specifications. + + Examples + -------- + >>> from dataloom import Group, Having + ... + ... posts = pg_loom.find_many( + ... Post, + ... select="id", + ... filters=Filter(column="id", operator="gt", value=1), + ... group=Group( + ... column="id", + ... function="MAX", + ... having=Having(column="id", operator="in", value=(2, 3, 4)), + ... return_aggregation_column=True, + ... ), + ... ) + """ + + column: str = field(repr=False) + operator: OPERATOR_LITERAL = field(repr=False, default="eq") + value: Any = field(repr=False) + join_next_with: Optional[SLQ_OPERAND_LITERAL] = field(default="AND") + + +@dataclass(repr=False, kw_only=True) +class Group: + """ + Group + ----- + + This class is used for data aggregation and grouping data in dataloom. + + Parameters + ---------- + column : str + The name of the column to group by. + function : "COUNT" | "AVG" | "SUM" | "MIN" | "MAX", optional + The aggregation function to apply on the grouped data. Default is "COUNT". + having : list[Having] | Having | None, optional + Filters to apply to the grouped data. It can be a single Having object, a list of Having objects, or None to apply no filters. Default is None. + return_aggregation_column : bool, optional + Whether to return the aggregation column in the result. Default is False. + + Returns + ------- + None + This method does not return any value. + + See Also + -------- + Having : Class used to filter grouped data. + ColumnValue : Class for defining column values. + Order : Class for defining order specifications. + + Examples + -------- + >>> from dataloom import Group, Having + ... + ... posts = pg_loom.find_many( + ... Post, + ... select="id", + ... filters=Filter(column="id", operator="gt", value=1), + ... group=Group( + ... column="id", + ... function="MAX", + ... having=Having(column="id", operator="in", value=(2, 3, 4)), + ... return_aggregation_column=True, + ... ), + ... ) + """ + + column: str = field(repr=False) + function: AGGREGATION_LITERALS = field(default="COUNT", repr=False) + having: Optional[list[Having] | Having] = field(default=None, repr=False) + return_aggregation_column: Optional[bool] = field(default=False, repr=True) + @dataclass(kw_only=True, repr=False) class Filter: @@ -45,7 +154,7 @@ class Filter: The operator to use for the filter. value : Any The value to compare against. - join_next_filter_with : "AND" | "OR" | None, optional + join_next_with : "AND" | "OR" | None, optional The SQL operand to join the next filter with. Default is "AND". Returns @@ -55,6 +164,8 @@ class Filter: See Also -------- + Group : Class used to group data. + Having : Class used to filter grouped data. ColumnValue : Class for defining column values. Order : Class for defining order specifications. @@ -66,7 +177,7 @@ class Filter: ... affected_rows = loom.update_one( ... User, ... filters=[ - ... Filter(column="id", value=1, operator="eq", join_next_filter_with="OR"), + ... Filter(column="id", value=1, operator="eq", join_next_with="OR"), ... Filter(column="username", value="miller"), ... ], ... values=[ @@ -83,67 +194,7 @@ class Filter: column: str = field(repr=False) operator: OPERATOR_LITERAL = field(repr=False, default="eq") value: Any = field(repr=False) - join_next_filter_with: Optional[SLQ_OPERAND_LITERAL] = field(default="AND") - - def __init__( - self, - column: str, - value: Any, - operator: OPERATOR_LITERAL = "eq", - join_next_filter_with: Optional[SLQ_OPERAND_LITERAL] = "AND", - ) -> None: - """ - Filter - ------ - - Constructor method for the Filter class. - - Parameters - ---------- - column : str - The name of the column to filter on. - operator : "eq" |"neq" |"lt" |"gt" |"leq" |"geq" |"in" |"notIn" |"like" - The operator to use for the filter. - value : Any - The value to compare against. - join_next_filter_with : "AND" | "OR" | None, optional - The SQL operand to join the next filter with. Default is "AND". - - Returns - ------- - None - This method does not return any value. - - See Also - -------- - ColumnValue : Class for defining column values. - Order : Class for defining order specifications. - - Examples - -------- - >>> from dataloom import Filter, ColumnValue, Order, User - ... - ... # Creating a filter for users with id equals 1 or username equals 'miller' - ... affected_rows = loom.update_one( - ... User, - ... filters=[ - ... Filter(column="id", value=1, operator="eq", join_next_filter_with="OR"), - ... Filter(column="username", value="miller"), - ... ], - ... values=[ - ... [ - ... ColumnValue(name="username", value="Mario"), - ... ColumnValue(name="name", value="Mario"), - ... ] - ... ], - ... ) - ... print(affected_rows) - - """ - self.column = column - self.value = value - self.join_next_filter_with = join_next_filter_with - self.operator = operator + join_next_with: Optional[SLQ_OPERAND_LITERAL] = field(default="AND") @dataclass(kw_only=True, repr=False) @@ -170,6 +221,8 @@ class ColumnValue[T]: -------- Filter : Class for defining filters. Order : Class for defining order specifications. + Group : Class used to group data. + Having : Class used to filter grouped data. Examples -------- @@ -207,65 +260,6 @@ class ColumnValue[T]: name: str = field(repr=False) value: T = field(repr=False) - def __init__(self, name: str, value: T) -> None: - """ - ColumnValue - ----------- - - Constructor method for the ColumnValue class. - - Parameters - ---------- - name : str - The name of the column. - value : Any - The value to assign to the column. - - Returns - ------- - None - This method does not return any value. - - See Also - -------- - Filter : Class for defining filters. - Order : Class for defining order specifications. - - Examples - -------- - >>> from dataloom import ColumnValue, Filter, Order - ... - ... # Model definitions - ... class User(Model): - ... __tablename__: Optional[TableColumn] = TableColumn(name="users") - ... id = PrimaryKeyColumn(type="int", auto_increment=True) - ... name = Column(type="text", nullable=False) - ... username = Column(type="varchar", unique=True, length=255) - ... - ... class Post(Model): - ... __tablename__: Optional[TableColumn] = TableColumn(name="posts") - ... id = PrimaryKeyColumn(type="int", auto_increment=True) - ... title = Column(type="text", nullable=False) - ... content = Column(type="text", nullable=False) - ... userId = ForeignKeyColumn(User, maps_to="1-N", type="int", required=False, onDelete="CASCADE", onUpdate="CASCADE") - ... - ... # Updating the username and name columns for the user with ID 1 - ... affected_rows = loom.update_one( - ... User, - ... filters=Filter(column="id", value=1), - ... values=[ - ... [ - ... ColumnValue(name="username", value="Mario"), - ... ColumnValue(name="name", value="Mario"), - ... ] - ... ], - ... ) - ... print(affected_rows) - - """ - self.name = name - self.value = value - @dataclass(kw_only=True, repr=False) class Order: @@ -292,6 +286,8 @@ class Order: Include : Class for defining included models. Filter : Class for defining filters. ColumnValue : Class for defining column values. + Group : Class used to group data. + Having : Class used to filter grouped data. Examples -------- @@ -324,61 +320,6 @@ class Order: column: str = field(repr=False) order: Literal["ASC", "DESC"] = field(repr=False, default="ASC") - def __init__(self, column: str, order: Literal["ASC", "DESC"] = "ASC") -> None: - """ - Order - ----- - - Constructor method for the Order class. - - Parameters - ---------- - column : str - The name of the column to order by. - order : Literal['ASC', 'DESC'], optional - The order direction. Default is "ASC" (ascending). - - Returns - ------- - None - This method does not return any value. - - See Also - -------- - Include : Class for defining included models. - Filter : Class for defining filters. - ColumnValue : Class for defining column values. - - Examples - -------- - >>> from dataloom import Order, Include, Model - ... - ... class User(Model): - ... __tablename__: Optional[TableColumn] = TableColumn(name="users") - ... id = PrimaryKeyColumn(type="int", auto_increment=True) - ... name = Column(type="text", nullable=False) - ... username = Column(type="varchar", unique=True, length=255) - ... - ... class Post(Model): - ... __tablename__: Optional[TableColumn] = TableColumn(name="posts") - ... id = PrimaryKeyColumn(type="int", auto_increment=True) - ... title = Column(type="text", nullable=False) - ... content = Column(type="text", nullable=False) - ... userId = ForeignKeyColumn(User, maps_to="1-N", type="int", required=False, onDelete="CASCADE", onUpdate="CASCADE") - ... - ... # Including posts for a user with ID 1 and ordering by ID in descending order - ... # and then by createdAt in descending order - ... users = loom.find_many( - ... User, - ... pk=1, - ... include=[Include(Post, limit=2, offset=0, maps_to="1-N")], - ... order=[Order(column="id", order="DESC"), Order(column="createdAt", order="DESC")] - ... ) - - """ - self.column = column - self.order = order - @dataclass(kw_only=True, repr=False) class Include[Model]: @@ -400,8 +341,10 @@ class Include[Model]: The number of records to skip before including. Default is 0 (no offset). select : list[str] | None, optional The list of columns to include. Default is None (include all columns). - maps_to : RELATIONSHIP_LITERAL, optional - The relationship type between the current model and the included model. Default is "1-N" (one-to-many). + has : INCLUDE_LITERAL, optional + The relationship type between the current model and the included model. Default is "many". + include : list[Include], optional + The extra included models. Returns ------- @@ -413,83 +356,27 @@ class Include[Model]: Order: Class for defining order specifications. Filter : Class for defining filters. ColumnValue : Class for defining column values. + Group : Class used to group data. + Having : Class used to filter grouped data. Examples -------- >>> from dataloom import Include, Model, Order ... - ... # Including posts for a user with ID 1 - ... mysql_loom.find_by_pk( - ... User, pk=1, include=[Include(Post, limit=2, offset=0, select=["id", "title"], maps_to="1-N")] + ... # get the profile and the user of that profile in one eager query. + ... profile = mysql_loom.find_many( + ... instance=Profile, + ... include=[Include(model=User, select=["id", "username", "tokenVersion"], has="one")], ... ) - """ model: Model = field(repr=False) order: list[Order] = field(repr=False, default_factory=list) - limit: Optional[int] = field(default=0) - offset: Optional[int] = field(default=0) + limit: Optional[int] = field(default=None) + offset: Optional[int] = field(default=None) select: Optional[list[str]] = field(default_factory=list) - maps_to: RELATIONSHIP_LITERAL = field(default="1-N") - - def __init__( - self, - model: Model, - order: list[Order] = [], - limit: Optional[int] = 0, - offset: Optional[int] = 0, - select: Optional[list[str]] = [], - maps_to: RELATIONSHIP_LITERAL = "1-N", - ): - """ - Include - ------- - - Constructor method for the Include class. - - Parameters - ---------- - model : Model - The model to be included when eger fetching records. - order : list[Order], optional - The list of order specifications for sorting the included data. Default is an empty list. - limit : int | None, optional - The maximum number of records to include. Default is 0 (no limit). - offset : int | None, optional - The number of records to skip before including. Default is 0 (no offset). - select : list[str] | None, optional - The list of columns to include. Default is None (include all columns). - maps_to : RELATIONSHIP_LITERAL, optional - The relationship type between the current model and the included model. Default is "1-N" (one-to-many). - - Returns - ------- - None - This method does not return any value. - - See Also - -------- - Order: Class for defining order specifications. - Filter : Class for defining filters. - ColumnValue : Class for defining column values. - - Examples - -------- - >>> from dataloom import Include, Model, Order - ... - ... # Including posts for a user with ID 1 - ... loom.find_by_pk( - ... User, pk=1, include=[Include(Post, limit=2, offset=0, select=["id", "title"], maps_to="1-N")] - ... ) - - """ - - self.select = select - self.model = model - self.order = order - self.limit = limit - self.offset = offset - self.maps_to = maps_to + include: list["Include"] = field(default_factory=list) + has: INCLUDE_LITERAL = field(default="many") POSTGRES_SQL_TYPES = { diff --git a/dataloom/utils/__init__.py b/dataloom/utils/__init__.py index 667075b..c48402b 100644 --- a/dataloom/utils/__init__.py +++ b/dataloom/utils/__init__.py @@ -2,6 +2,8 @@ from dataloom.utils.logger import console_logger, file_logger from dataloom.utils.create_table import get_create_table_params +from dataloom.utils.aggregations import get_groups +from dataloom.utils.helpers import is_collection from dataloom.utils.tables import ( get_child_table_columns, get_child_table_params, @@ -158,4 +160,6 @@ def get_formatted_query( get_create_table_params, get_args, print_pretty_table, + is_collection, + get_groups, ] diff --git a/dataloom/utils/aggregations.py b/dataloom/utils/aggregations.py new file mode 100644 index 0000000..6470934 --- /dev/null +++ b/dataloom/utils/aggregations.py @@ -0,0 +1,108 @@ +from typing import Optional +from dataloom.types import Group, DIALECT_LITERAL +from dataloom.exceptions import UnknownColumnException, InvalidFilterValuesException +from dataloom.utils.helpers import is_collection +from dataloom.utils.tables import get_operator + + +def get_groups( + fields: list[str], + dialect: DIALECT_LITERAL, + table_name: str, + select: list[str] = [], + group: Optional[list[Group] | Group] = [], +): + group_fns = [] + group_columns = [] + having_columns = [] + having_values = [] + return_aggregation_column = True + + n_having = 0 + for g in group: + if g.having is not None: + if is_collection(g.having): + n_having += len(g.having) + else: + n_having += 1 + + for _group in group: + if _group.column not in fields: + raise UnknownColumnException( + f'The table "{table_name}" does not have a column "{_group.column}".' + ) + if len(select) != 0 and _group.column not in select: + raise UnknownColumnException( + f'The column "{_group.column}" was omitted in selection of records to be grouped.' + ) + fn = ( + f'{_group.function}("{_group.column}")' + if dialect == "postgres" + else f"{_group.function}(`{_group.column}`)" + ) + return_aggregation_column = _group.return_aggregation_column + group_fns.append(fn) + + col = f'"{_group.column}"' if dialect == "postgres" else f"`{_group.column}`" + ph = "?" if dialect == "sqlite" else "%s" + group_columns.append(col) + if _group.having is None: + pass + elif is_collection(_group.having): + for hav in _group.having: + op = get_operator(hav.operator) + if op == "IN" or op == "NOT IN": + if is_collection(hav.value): + n_having -= 1 + having_columns.append( + f"{fn} {op} ({', '.join([ph for _ in hav.value])}) {hav.join_next_with if n_having >0 else ''}".strip() + ) + having_values += hav.value + else: + raise InvalidFilterValuesException( + f'The operator "{hav.operator}" value can only be a list, tuple or dictionary but got {type(hav.value)}.' + ) + else: + if not is_collection(hav.value): + n_having -= 1 + having_columns.append( + f"{fn} {op} {ph} {hav.join_next_with if n_having >0 else ''}" + ) + having_values.append(hav.value) + else: + raise InvalidFilterValuesException( + f'The operator "{hav.operator}" value can not be a collection.' + ) + else: + hav = _group.having + op = get_operator(hav.operator) + if op == "IN" or op == "NOT IN": + if is_collection(hav.value): + n_having -= 1 + having_columns.append( + f"{fn} {op} ({', '.join([ph for _ in hav.value])}) {hav.join_next_with if n_having >0 else '' }".strip() + ) + having_values += hav.value + else: + raise InvalidFilterValuesException( + f'The operator "{filter.operator}" value can only be a list, tuple or dictionary but got {type(filter.value)} .' + ) + else: + if not is_collection(hav.value): + n_having -= 1 + having_columns.append( + f"{fn} {op} {ph} {hav.join_next_with if n_having > 0 else '' }" + ) + having_values.append(hav.value) + else: + raise InvalidFilterValuesException( + f'The operator "{hav.operator}" value can not be a collection.' + ) + + return ( + group_fns, + group_columns, + having_columns, + having_values, + return_aggregation_column, + ) diff --git a/dataloom/utils/helpers.py b/dataloom/utils/helpers.py new file mode 100644 index 0000000..36fe97d --- /dev/null +++ b/dataloom/utils/helpers.py @@ -0,0 +1,5 @@ +from typing import Any + + +def is_collection(arg: Any) -> bool: + return isinstance(arg, list) or isinstance(arg, set) or isinstance(arg, tuple) diff --git a/dataloom/utils/logger.py b/dataloom/utils/logger.py index 3fbba40..3c845fb 100644 --- a/dataloom/utils/logger.py +++ b/dataloom/utils/logger.py @@ -22,7 +22,7 @@ def wrapper(*args, **kwargs): sql_statement, file_name, dialect = fn(*args, **kwargs) with open(file_name, "a+") as f: f.write( - "[{time}] : Dataloom[{dialect}]: {sql_statement}\n".format( + "[{time}] : Loom[{dialect}]: {sql_statement}\n".format( dialect=dialect, time=datetime.now().time(), sql_statement=sql_statement, diff --git a/dataloom/utils/loom.py b/dataloom/utils/loom.py index 878df7d..00ef33a 100644 --- a/dataloom/utils/loom.py +++ b/dataloom/utils/loom.py @@ -1,9 +1,10 @@ +from dataloom.utils import is_collection + + def get_args(params: list) -> list | tuple: args = [] for arg in params: - if isinstance(arg, list): - args += arg - elif isinstance(arg, tuple): + if is_collection(arg): args += list(arg) else: args.append(arg) diff --git a/dataloom/utils/tables.py b/dataloom/utils/tables.py index 5ac7232..9f77c92 100644 --- a/dataloom/utils/tables.py +++ b/dataloom/utils/tables.py @@ -1,4 +1,5 @@ import inspect +from dataloom.utils.helpers import is_collection from dataloom.columns import ( Column, CreatedAtColumn, @@ -14,7 +15,11 @@ Filter, ColumnValue, ) -from dataloom.exceptions import InvalidOperatorException, UnknownColumnException +from dataloom.exceptions import ( + InvalidOperatorException, + UnknownColumnException, + InvalidFilterValuesException, +) from typing import Optional @@ -27,7 +32,7 @@ def get_table_filters( placeholder_filter_values = [] placeholder_filters = [] if filters is not None: - if isinstance(filters, list): + if is_collection(filters): for idx, filter in enumerate(filters): key = filter.column if key not in fields: @@ -35,27 +40,33 @@ def get_table_filters( f"Table {table_name} does not have column '{key}'." ) op = get_operator(filter.operator) - join = ( - "" - if len(filters) == idx + 1 - else f" {filter.join_next_filter_with}" - ) + join = "" if len(filters) == idx + 1 else f" {filter.join_next_with}" if op == "IN" or op == "NOT IN": - _list = ", ".join( - ["?" if dialect == "sqlite" else "%s" for i in filter.value] - ) - _key = ( - f'"{key}" {op} ({_list}) {join}' - if dialect == "postgres" - else f"`{key}` {op} ({_list}) {join}" - ) + if is_collection(filter.value): + _list = ", ".join( + ["?" if dialect == "sqlite" else "%s" for i in filter.value] + ) + _key = ( + f'"{key}" {op} ({_list}) {join}' + if dialect == "postgres" + else f"`{key}` {op} ({_list}) {join}" + ) + else: + raise InvalidFilterValuesException( + f'The column "{filter.column}" value can only be a list, tuple or dictionary but got {type(filter.value)} .' + ) else: - _key = ( - f'"{key}" {op} %s {join}' - if dialect == "postgres" - else f"`{key}` {op} {'%s' if dialect == 'mysql' else '?'} {join}" - ) + if not is_collection(filter.value): + _key = ( + f'"{key}" {op} %s {join}' + if dialect == "postgres" + else f"`{key}` {op} {'%s' if dialect == 'mysql' else '?'} {join}" + ) + else: + raise InvalidFilterValuesException( + f'The column "{filter.column}" value can not be a collection.' + ) placeholder_filter_values.append(filter.value) placeholder_filters.append(_key) else: @@ -67,20 +78,30 @@ def get_table_filters( ) op = get_operator(filter.operator) if op == "IN" or op == "NOT IN": - _list = ", ".join( - ["?" if dialect == "sqlite" else "%s" for i in filter.value] - ) - _key = ( - f'"{key}" {op} ({_list})' - if dialect == "postgres" - else f"`{key}` {op} ({_list})" - ) + if is_collection(filter.value): + _list = ", ".join( + ["?" if dialect == "sqlite" else "%s" for i in filter.value] + ) + _key = ( + f'"{key}" {op} ({_list})' + if dialect == "postgres" + else f"`{key}` {op} ({_list})" + ) + else: + raise InvalidFilterValuesException( + f'The column "{filter.column}" value can only be a list, tuple or dictionary but got {type(filter.value)} .' + ) else: - _key = ( - f'"{key}" {op} %s' - if dialect == "postgres" - else f"`{key}` {op} {'%s' if dialect == 'mysql' else '?'}" - ) + if not is_collection(filter.value): + _key = ( + f'"{key}" {op} %s' + if dialect == "postgres" + else f"`{key}` {op} {'%s' if dialect == 'mysql' else '?'}" + ) + else: + raise InvalidFilterValuesException( + f'The column "{filter.column}" value can not be a collection.' + ) placeholder_filter_values.append(filter.value) placeholder_filters.append(_key) return placeholder_filters, placeholder_filter_values @@ -97,7 +118,7 @@ def get_column_values( column_names = [] if values is not None: - if isinstance(values, list): + if is_collection(values): for value in values: key = value.name v = value.value @@ -170,15 +191,14 @@ def get_table_fields(model, dialect: DIALECT_LITERAL): pk_name = None updatedAtColumName = None fields = [] - fks = dict() + fks = [] for name, field in inspect.getmembers(model): if isinstance(field, Column): fields.append(name) elif isinstance(field, ForeignKeyColumn): fields.append(name) table_name = field.table._get_table_name() - fks[table_name] = name - fks["mapped_to"] = field.maps_to + fks.append({table_name: name, "mapped_to": field.maps_to}) elif isinstance(field, PrimaryKeyColumn): fields.append(name) pk_name = f'"{name}"' if dialect == "postgres" else f"`{name}`" @@ -195,7 +215,6 @@ def get_relationships( includes: list[dict], fks: dict, parent_table_name: str | None = None ): relationships = [] - (includes) for include in includes: if parent_table_name is not None: fks = include["foreign_keys"] @@ -236,14 +255,14 @@ def get_child_table_params(include: Include, dialect: DIALECT_LITERAL): orders = None select = include.select pk_name = None - table__name = include.model._get_table_name() + table_name = include.model._get_table_name() alias = include.model.__name__.lower() fields, pk_name, fks, _ = get_table_fields(include.model, dialect=dialect) for column in select: if column not in fields: raise UnknownColumnException( - f'The table "{table__name}" does not have a column "{column}".' + f'The table "{table_name}" does not have a column "{column}".' ) return { @@ -254,7 +273,7 @@ def get_child_table_params(include: Include, dialect: DIALECT_LITERAL): "limit": limit, "orders": orders, "select": select, - "table": table__name, + "table": table_name, "pk_name": pk_name, "foreign_keys": fks, "maps_to": include.maps_to, diff --git a/hi.db b/hi.db index 96dd4ce..f3e1069 100644 Binary files a/hi.db and b/hi.db differ diff --git a/playground.py b/playground.py index 58c8508..e62538b 100644 --- a/playground.py +++ b/playground.py @@ -1,5 +1,5 @@ from dataloom import ( - Dataloom, + Loom, Model, PrimaryKeyColumn, Column, @@ -11,18 +11,30 @@ ColumnValue, Include, Order, + Group, + Having, ) +from dataloom.decorators import initialize +import json, time from typing import Optional +from dataclasses import dataclass +sqlite_loom = Loom( + dialect="sqlite", + database="hi.db", + logs_filename="sqlite-logs.sql", + sql_logger="console", +) -pg_loom = Dataloom( +pg_loom = Loom( dialect="postgres", database="hi", password="root", user="postgres", sql_logger="console", ) -mysql_loom = Dataloom( + +mysql_loom = Loom( dialect="mysql", database="hi", password="root", @@ -30,12 +42,6 @@ host="localhost", logs_filename="logs.sql", port=3306, - sql_logger="file", -) -sqlite_loom = Dataloom( - dialect="sqlite", - database="hi.db", - logs_filename="sqlite-logs.sql", sql_logger="console", ) @@ -48,6 +54,7 @@ class User(Model): tokenVersion = Column(type="int", default=0) +@initialize(repr=True, to_dict=True, init=True, repr_identifier="id") class Profile(Model): __tablename__: Optional[TableColumn] = TableColumn(name="profiles") id = PrimaryKeyColumn(type="int", auto_increment=True) @@ -56,7 +63,7 @@ class Profile(Model): User, maps_to="1-1", type="int", - required=False, + required=True, onDelete="CASCADE", onUpdate="CASCADE", ) @@ -69,7 +76,6 @@ class Post(Model): title = Column(type="varchar", length=255, nullable=False) # timestamps createdAt = CreatedAtColumn() - updatedAt = UpdatedAtColumn() # relations userId = ForeignKeyColumn( User, @@ -86,9 +92,18 @@ class Category(Model): id = PrimaryKeyColumn(type="int", auto_increment=True, nullable=False, unique=True) type = Column(type="varchar", length=255, nullable=False) + postId = ForeignKeyColumn( + Post, + maps_to="N-1", + type="int", + required=True, + onDelete="CASCADE", + onUpdate="CASCADE", + ) + conn, tables = mysql_loom.connect_and_sync( - [Post, User, Category, Profile], drop=True, force=True + [User, Profile, Post, Category], drop=True, force=True ) @@ -97,89 +112,54 @@ class Category(Model): values=ColumnValue(name="username", value="@miller"), ) -affected_rows = mysql_loom.decrement( - User, - filters=[Filter(column="id", value=1, operator="eq")], - column=ColumnValue(name="tokenVersion", value=2), +aff = mysql_loom.delete_bulk( + instance=User, + filters=Filter(column="id", value=1), ) +print(aff) -affected_rows = mysql_loom.update_one( - User, - filters=[ - Filter(column="id", value=1, operator="eq", join_next_filter_with="OR"), - Filter(column="username", value="miller"), - ], + +userId2 = mysql_loom.insert_one( + instance=User, + values=ColumnValue(name="username", value="bob"), +) + +profileId = mysql_loom.insert_one( + instance=Profile, values=[ - [ - ColumnValue(name="username", value="Mario"), - ColumnValue(name="name", value="Mario"), - ] + ColumnValue(name="userId", value=userId), + ColumnValue(name="avatar", value="hello.jpg"), ], ) -print(affected_rows) - - -# categories = ["general", "education", "sport", "culture"] -# cats = [] -# for cat in categories: -# pId = mysql_loom.insert_one( -# instance=Post, -# values=[ -# ColumnValue(name="title", value=f"What are you doing {cat}?"), -# ColumnValue(name="userId", value=userId), -# ], -# ) -# # cats.append( -# # [ColumnValue(name="type", value=cat), ColumnValue(name="postId", value=pId)] -# # ) - - -# table = mysql_loom.inspect(instance=User) - -# print(table) - - -# # post = mysql_loom.find_by_pk( -# # Post, -# # pk=1, -# # include=[Include(model=User, select=["id", "username"], maps_to="N-1")], -# # select=["title", "completed"], -# # ) -# # print("---- post", post) - - -# # if __name__ == "__main__": -# # conn.close() - - -# categories = ["general", "education", "sport", "culture"] -# cats = [] -# for cat in categories: -# pId = mysql_loom.insert_one( -# instance=Post, -# values=[ -# ColumnValue(name="title", value=f"What are you doing {cat}?"), -# ColumnValue(name="userId", value=userId), -# ], -# ) -# # cats.append( -# # [ColumnValue(name="type", value=cat), ColumnValue(name="postId", value=pId)] -# # ) - - -# table = mysql_loom.inspect(instance=User) - -# print(table) +for title in ["Hello", "Hello", "What are you doing", "Coding"]: + mysql_loom.insert_one( + instance=Post, + values=[ + ColumnValue(name="userId", value=userId), + ColumnValue(name="title", value=title), + ], + ) -# # post = mysql_loom.find_by_pk( -# # Post, -# # pk=1, -# # include=[Include(model=User, select=["id", "username"], maps_to="N-1")], -# # select=["title", "completed"], -# # ) -# # print("---- post", post) +for cat in ["general", "education", "tech", "sport"]: + mysql_loom.insert_one( + instance=Category, + values=[ + ColumnValue(name="postId", value=1), + ColumnValue(name="type", value=cat), + ], + ) +posts = mysql_loom.find_many( + Post, + select="id", + filters=Filter(column="id", operator="gt", value=1), + group=Group( + column="id", + function="MAX", + having=Having(column="id", operator="in", value=(2, 3, 4)), + return_aggregation_column=False, + ), +) -# # if __name__ == "__main__": -# # conn.close() +print(posts) diff --git a/pyproject.toml b/pyproject.toml index 3331e3c..c1e14e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dataloom" -version = "1.0.2" +version = "2.0.0" authors = [ {name = "Crispen Gari", email = "crispengari@gmail.com"}, ] diff --git a/todo.txt b/todo.txt index f7bb364..4c563fd 100644 --- a/todo.txt +++ b/todo.txt @@ -4,7 +4,7 @@ 4. foreign key and primary key ✅ 5. Foreign key Column ✅ 6. Delete table data ✅ -7. querying data in relational tables +7. querying data in relational tables ✅ 8. increment and decrement function (that increments) updated ✅ 10. querying with operations like (OR LIKE > < etc) ✅ 11. limit and pagination ✅