diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc0875e4..fc1c8606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,9 @@ jobs: - name: Build run: | python -m build + - name: Deploy docs + run: | + mkdocs gh-deploy -c - name: Upload to pypi run: | twine upload -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index adf2d800..03242bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## [0.4.3] - 2022-12-29 + +### Added + +- Added mkdocs documentation + +### Changed + +### Fixed + +- Fixed docs building in CI + ## [0.4.2] - 2022-12-29 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5126ea4..44892029 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,50 @@ People *love* thorough bug reports. I'm not even kidding. By contributing, you agree that your contributions will be licensed under its MIT License. +## How to test + +- Clone the repo and enter its root folder + + ```bash + git clone https://github.com/sopherapps/pydantic-redis.git && cd pydantic-redis + ``` + +- Create a virtual environment and activate it + + ```bash + virtualenv -p /usr/bin/python3.6 env && source env/bin/activate + ``` + +- Install the dependencies + + ```bash + pip install -r requirements.txt + ``` + +- Run the pre-commit installation + + ```bash + pre-commit install + ``` + +- Run the tests command + + ```bash + pytest --benchmark-disable + ``` + +- Run benchmarks + + ```bash + pytest --benchmark-compare --benchmark-autosave + ``` + +- Or run to get benchmarks summary + + ```shell + pytest test/test_benchmarks.py --benchmark-columns=mean,min,max --benchmark-name=short + ``` + ## References This document was adapted from [a gist by Brian A. Danielak](https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62) which diff --git a/README.md b/README.md index ab2ecbf5..a58e0b06 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,15 @@ A simple declarative ORM for Redis -## Main Dependencies +--- -- [Python +3.6](https://www.python.org) -- [redis](https://pypi.org/project/redis/) -- [pydantic](https://github.com/samuelcolvin/pydantic/) +**Documentation:** [https://sopherapps.github.io/pydantic-redis](https://sopherapps.github.io/pydantic-redis) -## Most Notable Features +**Source Code:** [https://github.com/sopherapps/pydantic-redis](https://github.com/sopherapps/pydantic-redis) + +--- + +Most Notable Features are: - Define business domain objects as [pydantic](https://github.com/samuelcolvin/pydantic/) and automatically get ability to save them as is in [redis](https://pypi.org/project/redis/) with an intuitive API of `insert`, `update`, `delete`, @@ -20,462 +22,6 @@ A simple declarative ORM for Redis them when queried again from redis. - Both synchronous and asynchronous APIs available. -## Getting Started (Synchronous Version) - -- Install the package - - ```bash - pip install pydantic-redis - ``` - -- Import the `Store`, the `RedisConfig` and the `Model` classes from `pydantic_redis` and use accordingly - -```python -from datetime import date -from typing import Tuple, List, Optional -from pydantic_redis import RedisConfig, Model, Store - - -class Author(Model): - """ - An Author model, just like a pydantic model with appropriate - type annotations - NOTE: The `_primary_key_field` is mandatory - """ - _primary_key_field: str = 'name' - name: str - active_years: Tuple[int, int] - - -class Book(Model): - """ - A Book model. - - Models can have the following field types - - The usual i.e. float, int, dict, list, date, str, dict, Optional etc - as long as they are serializable by orjson - - Nested models e.g. `author: Author` or `author: Optional[Author]` - - List of nested models e.g. `authors: List[Author]` - or `authors: Optional[List[Author]]` - - Tuples including nested models e.g. `access_log: Tuple[Author, date]` - or `access_log: Optional[Tuple[Author, date]]]` - - NOTE: 1. Any nested model whether plain or in a list or tuple will automatically - inserted into the redis store when the parent model is inserted. - e.g. a Book with an author field, when inserted, will also insert - the author. The author can then be queried directly if that's something - one wishes to do. - - 2. When a parent model is inserted with a nested model instance that - already exists, the older nested model instance is overwritten. - This is one way of updating nested models. - All parent models that contain that nested model instance will see the change. - """ - _primary_key_field: str = 'title' - title: str - author: Author - rating: float - published_on: date - tags: List[str] = [] - in_stock: bool = True - - -class Library(Model): - """ - A library model. - - It shows a number of complicated nested models. - - About Nested Model Performance - --- - To minimize the performance penalty for nesting models, - we use REDIS EVALSHA to eagerly load the nested models - before the response is returned to the client. - This ensures that only ONE network call is made every time. - """ - _primary_key_field: str = 'name' - name: str - address: str - books: List[Book] = None - lost: Optional[List[Book]] = None - popular: Optional[Tuple[Book, Book]] = None - new: Tuple[Book, Author, int] = None - - -# Create the store -store = Store( - name='some_name', - redis_config=RedisConfig(db=5, host='localhost', port=6379), - life_span_in_seconds=3600) - -# register your models. DON'T FORGET TO DO THIS. -store.register_model(Book) -store.register_model(Library) -store.register_model(Author) - -# sample authors. You can create as many as you wish anywhere in the code -authors = { - "charles": Author(name="Charles Dickens", active_years=(1220, 1280)), - "jane": Author(name="Jane Austen", active_years=(1580, 1640)), -} - -# Sample books. -books = [ - Book( - title="Oliver Twist", - author=authors["charles"], - published_on=date(year=1215, month=4, day=4), - in_stock=False, - rating=2, - tags=["Classic"], - ), - Book( - title="Great Expectations", - author=authors["charles"], - published_on=date(year=1220, month=4, day=4), - rating=5, - tags=["Classic"], - ), - Book( - title="Jane Eyre", - author=authors["charles"], - published_on=date(year=1225, month=6, day=4), - in_stock=False, - rating=3.4, - tags=["Classic", "Romance"], - ), - Book( - title="Wuthering Heights", - author=authors["jane"], - published_on=date(year=1600, month=4, day=4), - rating=4.0, - tags=["Classic", "Romance"], - ), -] - -# Some library objects -libraries = [ - Library( - name="The Grand Library", - address="Kinogozi, Hoima, Uganda", - lost=[books[1]], - ), - Library( - name="Christian Library", - address="Buhimba, Hoima, Uganda", - new=(books[0], authors["jane"], 30), - ) -] - -# Insert Many. You can given them a TTL (life_span_seconds). -Book.insert(books, life_span_seconds=3600) -Library.insert(libraries) - -# Insert One. You can also given it a TTL (life_span_seconds). -Author.insert(Author(name="Jack Myers", active_years=(1240, 1300))) - -# Update One. You can also given it a TTL (life_span_seconds). -Book.update(_id="Oliver Twist", data={"author": authors["jane"]}) - -# Update nested model indirectly -updated_jane = Author(**authors["jane"].dict()) -updated_jane.active_years = (1999, 2008) -Book.update(_id="Oliver Twist", data={"author": updated_jane}) - -# Query the data -# Get all, with all fields shown. Data returned is a list of models instances. -all_books = Book.select() -print(all_books) -# Prints [Book(title="Oliver Twist", author="Charles Dickens", published_on=date(year=1215, month=4, day=4), -# in_stock=False), Book(...] - -# or paginate i.e. skip some books and return only upto a given number -paginated_books = Book.select(skip=2, limit=2) -print(paginated_books) - -# Get some, with all fields shown. Data returned is a list of models instances. -some_books = Book.select(ids=["Oliver Twist", "Jane Eyre"]) -print(some_books) - -# Note: Pagination does not work when ids are provided i.e. -assert some_books == Book.select(ids=["Oliver Twist", "Jane Eyre"], skip=100, limit=10) - -# Get all, with only a few fields shown. Data returned is a list of dictionaries. -books_with_few_fields = Book.select(columns=["author", "in_stock"]) -print(books_with_few_fields) -# Prints [{"author": "'Charles Dickens", "in_stock": "True"},...] - -# or paginate i.e. skip some books and return only upto a given number -paginated_books_with_few_fields = Book.select(columns=["author", "in_stock"], skip=2, limit=2) -print(paginated_books_with_few_fields) - -# Get some, with only some fields shown. Data returned is a list of dictionaries. -some_books_with_few_fields = Book.select(ids=["Oliver Twist", "Jane Eyre"], columns=["author", "in_stock"]) -print(some_books_with_few_fields) - -# Query the nested models directly. -some_authors = Author.select(ids=["Jane Austen"]) -print(some_authors) - -# Delete any number of items -Library.delete(ids=["The Grand Library"]) -``` - -## Getting Started (Asynchronous Version) - -- Install the package - - ```bash - pip install pydantic-redis - ``` - -- Import the `Store`, the `RedisConfig` and the `Model` classes from `pydantic_redis.asyncio` and use accordingly - -```python -import asyncio -from datetime import date -from typing import Tuple, List, Optional -from pydantic_redis.asyncio import RedisConfig, Model, Store - -# The features are exactly the same as the synchronous version, -# except for the ability to return coroutines when `insert`, -# `update`, `select` or `delete` are called. - - -class Author(Model): - """ - An Author model, just like a pydantic model with appropriate - type annotations - NOTE: The `_primary_key_field` is mandatory - """ - _primary_key_field: str = 'name' - name: str - active_years: Tuple[int, int] - - -class Book(Model): - """ - A Book model. - - Models can have the following field types - - The usual i.e. float, int, dict, list, date, str, dict, Optional etc - as long as they are serializable by orjson - - Nested models e.g. `author: Author` or `author: Optional[Author]` - - List of nested models e.g. `authors: List[Author]` - or `authors: Optional[List[Author]]` - - Tuples including nested models e.g. `access_log: Tuple[Author, date]` - or `access_log: Optional[Tuple[Author, date]]]` - - NOTE: 1. Any nested model whether plain or in a list or tuple will automatically - inserted into the redis store when the parent model is inserted. - e.g. a Book with an author field, when inserted, will also insert - the author. The author can then be queried directly if that's something - one wishes to do. - - 2. When a parent model is inserted with a nested model instance that - already exists, the older nested model instance is overwritten. - This is one way of updating nested models. - All parent models that contain that nested model instance will see the change. - """ - _primary_key_field: str = 'title' - title: str - author: Author - rating: float - published_on: date - tags: List[str] = [] - in_stock: bool = True - - -class Library(Model): - """ - A library model. - - It shows a number of complicated nested models. - - About Nested Model Performance - --- - To minimize the performance penalty for nesting models, - we use REDIS EVALSHA to eagerly load the nested models - before the response is returned to the client. - This ensures that only ONE network call is made every time. - """ - _primary_key_field: str = 'name' - name: str - address: str - books: List[Book] = None - lost: Optional[List[Book]] = None - popular: Optional[Tuple[Book, Book]] = None - new: Tuple[Book, Author, int] = None - - -async def run_async(): - """The async coroutine""" - # Create the store - store = Store( - name='some_name', - redis_config=RedisConfig(db=5, host='localhost', port=6379), - life_span_in_seconds=3600) - - # register your models. DON'T FORGET TO DO THIS. - store.register_model(Book) - store.register_model(Library) - store.register_model(Author) - - # sample authors. You can create as many as you wish anywhere in the code - authors = { - "charles": Author(name="Charles Dickens", active_years=(1220, 1280)), - "jane": Author(name="Jane Austen", active_years=(1580, 1640)), - } - - # Sample books. - books = [ - Book( - title="Oliver Twist", - author=authors["charles"], - published_on=date(year=1215, month=4, day=4), - in_stock=False, - rating=2, - tags=["Classic"], - ), - Book( - title="Great Expectations", - author=authors["charles"], - published_on=date(year=1220, month=4, day=4), - rating=5, - tags=["Classic"], - ), - Book( - title="Jane Eyre", - author=authors["charles"], - published_on=date(year=1225, month=6, day=4), - in_stock=False, - rating=3.4, - tags=["Classic", "Romance"], - ), - Book( - title="Wuthering Heights", - author=authors["jane"], - published_on=date(year=1600, month=4, day=4), - rating=4.0, - tags=["Classic", "Romance"], - ), - ] - - # Some library objects - libraries = [ - Library( - name="The Grand Library", - address="Kinogozi, Hoima, Uganda", - lost=[books[1]], - ), - Library( - name="Christian Library", - address="Buhimba, Hoima, Uganda", - new=(books[0], authors["jane"], 30), - ) - ] - - # Insert Many. You can given them a TTL (life_span_seconds). - await Book.insert(books, life_span_seconds=3600) - await Library.insert(libraries) - - # Insert One. You can also given it a TTL (life_span_seconds). - await Author.insert(Author(name="Jack Myers", active_years=(1240, 1300))) - - # Update One. You can also given it a TTL (life_span_seconds). - await Book.update(_id="Oliver Twist", data={"author": authors["jane"]}) - - # Update nested model indirectly - updated_jane = Author(**authors["jane"].dict()) - updated_jane.active_years = (1999, 2008) - await Book.update(_id="Oliver Twist", data={"author": updated_jane}) - - # Query the data - # Get all, with all fields shown. Data returned is a list of models instances. - all_books = await Book.select() - print(all_books) - # Prints [Book(title="Oliver Twist", author="Charles Dickens", published_on=date(year=1215, month=4, day=4), - # in_stock=False), Book(...] - - # or paginate i.e. skip some books and return only upto a given number - paginated_books = await Book.select(skip=2, limit=2) - print(paginated_books) - - # Get some, with all fields shown. Data returned is a list of models instances. - some_books = await Book.select(ids=["Oliver Twist", "Jane Eyre"]) - print(some_books) - - # Note: Pagination does not work when ids are provided i.e. - assert some_books == await Book.select(ids=["Oliver Twist", "Jane Eyre"], skip=100, limit=10) - - # Get all, with only a few fields shown. Data returned is a list of dictionaries. - books_with_few_fields = await Book.select(columns=["author", "in_stock"]) - print(books_with_few_fields) - # Prints [{"author": "'Charles Dickens", "in_stock": "True"},...] - - # or paginate i.e. skip some books and return only upto a given number - paginated_books_with_few_fields = await Book.select(columns=["author", "in_stock"], skip=2, limit=2) - print(paginated_books_with_few_fields) - - # Get some, with only some fields shown. Data returned is a list of dictionaries. - some_books_with_few_fields = await Book.select(ids=["Oliver Twist", "Jane Eyre"], columns=["author", "in_stock"]) - print(some_books_with_few_fields) - - # Query the nested models directly. - some_authors = await Author.select(ids=["Jane Austen"]) - print(some_authors) - - # Delete any number of items - await Library.delete(ids=["The Grand Library"]) - - -asyncio.run(run_async()) -``` - -## How to test - -- Clone the repo and enter its root folder - - ```bash - git clone https://github.com/sopherapps/pydantic-redis.git && cd pydantic-redis - ``` - -- Create a virtual environment and activate it - - ```bash - virtualenv -p /usr/bin/python3.6 env && source env/bin/activate - ``` - -- Install the dependencies - - ```bash - pip install -r requirements.txt - ``` - -- Run the pre-commit installation - - ```bash - pre-commit install - ``` - -- Run the tests command - - ```bash - pytest --benchmark-disable - ``` - -- Run benchmarks - - ```bash - pytest --benchmark-compare --benchmark-autosave - ``` - -- Or run to get benchmarks summary - - ```shell - pytest test/test_benchmarks.py --benchmark-columns=mean,min,max --benchmark-name=short - ``` - ## Benchmarks On an average PC ~16GB RAM, i7 Core diff --git a/docs/change-log.md b/docs/change-log.md new file mode 100644 index 00000000..f062fd7c --- /dev/null +++ b/docs/change-log.md @@ -0,0 +1,106 @@ +# Change Log + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [Unreleased] + +## [0.4.3] - 2022-12-29 + +### Added + +- Added mkdocs documentation + +### Changed + +### Fixed + +- Fixed docs building in CI + +## [0.4.2] - 2022-12-29 + +### Added + +### Changed + +### Fixed + +- Fixed unexpected data error when selecting some columns for some records + +## [0.4.1] - 2022-12-29 + +### Added + +### Changed + +### Fixed + +## [0.4.0] - 2022-12-17 + +### Added + +- Added pagination + +### Changed + +- Changed redis index to use sorted sets instead of ordinary sets + +### Fixed + +## [0.3.0] - 2022-12-15 + +### Added + +- Added asyncio support, to be got from the `pydantic_redis.asyncio` module + +### Changed + +- Moved the synchronous version to the `pydantic_redis.syncio` module, but kept its contents exposed in pydantic_redis + for backward-compatibility + +### Fixed + +## [0.2.0] - 2022-12-15 + +### Added + +### Changed + +- Changed the `NESTED_MODEL_LIST_FIELD_PREFIX` to "___" and `NESTED_MODEL_TUPLE_FIELD_PREFIX` to "____" +- Changed all queries (selects) to use lua scripts +- Changed `Model.deserialize_partially` to receive data either as a dict or as a flattened list of key-values + +### Fixed + +## [0.1.8] - 2022-12-13 + +### Added + +- Add support for model fields that are tuples of nested models + +### Changed + +### Fixed + +## [0.1.7] - 2022-12-12 + +### Added + +### Changed + +### Fixed + +- Fixed support for model properties that are *Optional* lists of nested models +- Fixed issue with field names being disfigured by `lstrip` when attempting to strip nested-mode-prefixes + +## [0.1.6] - 2022-11-01 + +### Added + +- Support for model properties that are lists of nested models + +### Changed + +### Fixed \ No newline at end of file diff --git a/docs/css/custom.css b/docs/css/custom.css new file mode 100644 index 00000000..07470ee9 --- /dev/null +++ b/docs/css/custom.css @@ -0,0 +1,40 @@ +.termynal-comment { + color: #4a968f; + font-style: italic; + display: block; +} + +.termy { + direction: ltr; +} + +.termy [data-termynal] { + white-space: pre-wrap; +} + +/* Indentation. */ +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: .05rem solid var(--md-typeset-table-color); +} + +/* Mark external links as such. */ +a.autorefs-external::after { + /* https://primer.style/octicons/arrow-up-right-24 */ + background-image: url('data:image/svg+xml,'); + content: ' '; + + display: inline-block; + position: relative; + top: 0.1em; + margin-left: 0.2em; + margin-right: 0.1em; + + height: 1em; + width: 1em; + border-radius: 100%; + background-color: var(--md-typeset-a-color); +} +a.autorefs-external:hover::after { + background-color: var(--md-accent-fg-color); +} diff --git a/docs/css/termynal.css b/docs/css/termynal.css new file mode 100644 index 00000000..406c0089 --- /dev/null +++ b/docs/css/termynal.css @@ -0,0 +1,109 @@ +/** + * termynal.js + * + * @author Ines Montani + * @version 0.0.1 + * @license MIT + */ + +:root { + --color-bg: #252a33; + --color-text: #eee; + --color-text-subtle: #a2a2a2; +} + +[data-termynal] { + width: 750px; + max-width: 100%; + background: var(--color-bg); + color: var(--color-text); + /* font-size: 18px; */ + font-size: 15px; + /* font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; */ + font-family: 'Roboto Mono', 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; + border-radius: 4px; + padding: 75px 45px 35px; + position: relative; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} + +[data-termynal]:before { + content: ''; + position: absolute; + top: 15px; + left: 15px; + display: inline-block; + width: 15px; + height: 15px; + border-radius: 50%; + /* A little hack to display the window buttons in one pseudo element. */ + background: #d9515d; + -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; + box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; +} + +[data-termynal]:after { + content: 'bash'; + position: absolute; + color: var(--color-text-subtle); + top: 5px; + left: 0; + width: 100%; + text-align: center; +} + +a[data-terminal-control] { + text-align: right; + display: block; + color: #aebbff; +} + +[data-ty] { + display: block; + line-height: 2; +} + +[data-ty]:before { + /* Set up defaults and ensure empty lines are displayed. */ + content: ''; + display: inline-block; + vertical-align: middle; +} + +[data-ty="input"]:before, +[data-ty-prompt]:before { + margin-right: 0.75em; + color: var(--color-text-subtle); +} + +[data-ty="input"]:before { + content: '$'; +} + +[data-ty][data-ty-prompt]:before { + content: attr(data-ty-prompt); +} + +[data-ty-cursor]:after { + content: attr(data-ty-cursor); + font-family: monospace; + margin-left: 0.5em; + -webkit-animation: blink 1s infinite; + animation: blink 1s infinite; +} + + +/* Cursor animation */ + +@-webkit-keyframes blink { + 50% { + opacity: 0; + } +} + +@keyframes blink { + 50% { + opacity: 0; + } +} diff --git a/docs/explanation/why-use-orms.md b/docs/explanation/why-use-orms.md new file mode 100644 index 00000000..0d4fe6be --- /dev/null +++ b/docs/explanation/why-use-orms.md @@ -0,0 +1,83 @@ +# Why Use ORMs + +An object-relational-mapping (ORM) makes writing business logic intuitive +because the data representation is closer to what the real-life situation is. +It helps decouple the way such data is programmed from the way such data is +actually persisted in any of the data persistence technologies we have, +typically a database. + +Take the example of a book. +In code, one will represent a book as an object with a number of properties such as "title", "edition", "author" etc. + +```python +class Book(Model): + title: str + edition: int + author: Author +``` + +However, in the underlying data store, the same book could be saved as say, +a row in a table for a relational database like PostgreSQL, +or as a document in a document-based NoSQL databases like MongoDB +or as a hashmap in redis. +Of these, the document-based NoSQL databases are the closest to the definition in code. + +For MongoDB, the same book might be represented as the object below: + +```json +{ + "id": "some-random-string", + "title": "the title of the book", + "edition": 2, + "author": { + "name": "Charles Payne", + "yearsActive": [ + 1992, + 2008 + ] + } +} +``` + +As you can see, it is still quite different. + +However, for redis, the representation is even going to be further off. +It will most likely be saved as hashmap, with a given key. +The properties of book will be 'fields' for that hashmap. + +In order to interact with the book representation in the redis server, +one has to write commands like: + +```shell +# to save the book in the data store +HSET "some key" "title" "the title of the book" "edition" 2 "author" "{\"name\":\"Charles Payne\",\"yearsActive\":[1992,2008]}" +# to retrieve the entire book +HGETALL "some key" +# to retrieve just a few details of the book +HMGET "some key" "title" "edition" +# to update the book - see the confusion? are you saving a new book or updating one? +HSET "some key" "edition" 2 +# to delete the book +DEL "some key" +``` + +The above is so unrelated to the business logic that most of us +will take a number of minutes or hours trying to understand what +kind of data is even being saved. + +Is it a book or some random stuff? + +Now consider something like this: + +```python +book = Book(title="some title", edition=2, author=Author(name="Charles Payne", years_active=(1992, 2008))) +store = Store(url="redis://localhost:6379/0", pool_size=5, default_ttl=3000, timeout=1) +store.register_model(Book) + +Book.insert(data=book) +response = Book.select(ids=["some title"]) +Book.update(_id="some title", data={"edition": 1}) +Book.delete(ids=["some title", "another title"]) +``` + +Beautiful, isn't it? diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..bf3a8f6d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,92 @@ +# Pydantic-redis + +A simple declarative ORM for redis based on pydantic + +## Features + +1. A subclass-able `Model` class to create Object Relational Mapping to redis hashes +2. A redis `Store` class to mutate and query `Model`'s registered in it +3. A `RedisConfig` class to pass to the `Store` constructor to connect to a redis instance +4. A synchronous `syncio` and an asynchronous `asyncio` interface to the above classes + +### Installation +
+ +```console +$ pip install pydantic-redis + +---> 100% +``` +
+ +### Synchronous Example + +#### Create it + +- Create a file `main.py` with: + +```Python +{!../docs_src/index/sync_main.py!} +``` + +#### Run it + +Run the example with: + +
+ +```console +$ python main.py +All: +[ Book(title='Wuthering Heights', author=Author(name='Jane Austen', active_years=(1580, 1640)), rating=4.0, published_on=datetime.date(1600, 4, 4), tags=['Classic', 'Romance'], in_stock=True), + Book(title='Oliver Twist', author=Author(name='Charles Dickens', active_years=(1220, 1280)), rating=2.0, published_on=datetime.date(1215, 4, 4), tags=['Classic'], in_stock=False), + Book(title='Jane Eyre', author=Author(name='Charles Dickens', active_years=(1220, 1280)), rating=3.4, published_on=datetime.date(1225, 6, 4), tags=['Classic', 'Romance'], in_stock=False), + Book(title='Great Expectations', author=Author(name='Charles Dickens', active_years=(1220, 1280)), rating=5.0, published_on=datetime.date(1220, 4, 4), tags=['Classic'], in_stock=True)] + +Paginated: +[ Book(title='Jane Eyre', author=Author(name='Charles Dickens', active_years=(1220, 1280)), rating=3.4, published_on=datetime.date(1225, 6, 4), tags=['Classic', 'Romance'], in_stock=False), + Book(title='Wuthering Heights', author=Author(name='Jane Austen', active_years=(1580, 1640)), rating=4.0, published_on=datetime.date(1600, 4, 4), tags=['Classic', 'Romance'], in_stock=True)] + +Paginated but with few fields: +[ { 'author': Author(name='Charles Dickens', active_years=(1220, 1280)), + 'in_stock': False}, + { 'author': Author(name='Jane Austen', active_years=(1580, 1640)), + 'in_stock': True}] +``` +
+ +### Asynchronous Example + +#### Create it + +- Create a file `main.py` with: + +```Python +{!../docs_src/index/async_main.py!} +``` + +#### Run it + +Run the example with: + +
+ +```console +$ python main.py +All: +[ Book(title='Wuthering Heights', author=Author(name='Jane Austen', active_years=(1580, 1640)), rating=4.0, published_on=datetime.date(1600, 4, 4), tags=['Classic', 'Romance'], in_stock=True), + Book(title='Oliver Twist', author=Author(name='Charles Dickens', active_years=(1220, 1280)), rating=2.0, published_on=datetime.date(1215, 4, 4), tags=['Classic'], in_stock=False), + Book(title='Jane Eyre', author=Author(name='Charles Dickens', active_years=(1220, 1280)), rating=3.4, published_on=datetime.date(1225, 6, 4), tags=['Classic', 'Romance'], in_stock=False), + Book(title='Great Expectations', author=Author(name='Charles Dickens', active_years=(1220, 1280)), rating=5.0, published_on=datetime.date(1220, 4, 4), tags=['Classic'], in_stock=True)] + +Paginated: +[ Book(title='Jane Eyre', author=Author(name='Charles Dickens', active_years=(1220, 1280)), rating=3.4, published_on=datetime.date(1225, 6, 4), tags=['Classic', 'Romance'], in_stock=False), + Book(title='Wuthering Heights', author=Author(name='Jane Austen', active_years=(1580, 1640)), rating=4.0, published_on=datetime.date(1600, 4, 4), tags=['Classic', 'Romance'], in_stock=True)] + +Paginated but with few fields: +[ { 'author': Author(name='Charles Dickens', active_years=(1220, 1280)), + 'in_stock': False}, + { 'author': Author(name='Jane Austen', active_years=(1580, 1640)), + 'in_stock': True}] +``` +
diff --git a/docs/js/custom.js b/docs/js/custom.js new file mode 100644 index 00000000..1ac2a50c --- /dev/null +++ b/docs/js/custom.js @@ -0,0 +1,114 @@ +function setupTermynal() { + document.querySelectorAll(".use-termynal").forEach(node => { + node.style.display = "block"; + new Termynal(node, { + lineDelay: 500 + }); + }); + const progressLiteralStart = "---> 100%"; + const promptLiteralStart = "$ "; + const customPromptLiteralStart = "# "; + const termynalActivateClass = "termy"; + let termynals = []; + + function createTermynals() { + document + .querySelectorAll(`.${termynalActivateClass} .highlight`) + .forEach(node => { + const text = node.textContent; + const lines = text.split("\n"); + const useLines = []; + let buffer = []; + function saveBuffer() { + if (buffer.length) { + let isBlankSpace = true; + buffer.forEach(line => { + if (line) { + isBlankSpace = false; + } + }); + dataValue = {}; + if (isBlankSpace) { + dataValue["delay"] = 0; + } + if (buffer[buffer.length - 1] === "") { + // A last single
won't have effect + // so put an additional one + buffer.push(""); + } + const bufferValue = buffer.join("
"); + dataValue["value"] = bufferValue; + useLines.push(dataValue); + buffer = []; + } + } + for (let line of lines) { + if (line === progressLiteralStart) { + saveBuffer(); + useLines.push({ + type: "progress" + }); + } else if (line.startsWith(promptLiteralStart)) { + saveBuffer(); + const value = line.replace(promptLiteralStart, "").trimEnd(); + useLines.push({ + type: "input", + value: value + }); + } else if (line.startsWith("// ")) { + saveBuffer(); + const value = "💬 " + line.replace("// ", "").trimEnd(); + useLines.push({ + value: value, + class: "termynal-comment", + delay: 0 + }); + } else if (line.startsWith(customPromptLiteralStart)) { + saveBuffer(); + const promptStart = line.indexOf(promptLiteralStart); + if (promptStart === -1) { + console.error("Custom prompt found but no end delimiter", line) + } + const prompt = line.slice(0, promptStart).replace(customPromptLiteralStart, "") + let value = line.slice(promptStart + promptLiteralStart.length); + useLines.push({ + type: "input", + value: value, + prompt: prompt + }); + } else { + buffer.push(line); + } + } + saveBuffer(); + const div = document.createElement("div"); + node.replaceWith(div); + const termynal = new Termynal(div, { + lineData: useLines, + noInit: true, + lineDelay: 500 + }); + termynals.push(termynal); + }); + } + + function loadVisibleTermynals() { + termynals = termynals.filter(termynal => { + if (termynal.container.getBoundingClientRect().top - innerHeight <= 0) { + termynal.init(); + return false; + } + return true; + }); + } + window.addEventListener("scroll", loadVisibleTermynals); + createTermynals(); + loadVisibleTermynals(); +} + + +async function main() { + setupTermynal(); +} + +main() diff --git a/docs/js/termynal.js b/docs/js/termynal.js new file mode 100644 index 00000000..4ac32708 --- /dev/null +++ b/docs/js/termynal.js @@ -0,0 +1,264 @@ +/** + * termynal.js + * A lightweight, modern and extensible animated terminal window, using + * async/await. + * + * @author Ines Montani + * @version 0.0.1 + * @license MIT + */ + +'use strict'; + +/** Generate a terminal widget. */ +class Termynal { + /** + * Construct the widget's settings. + * @param {(string|Node)=} container - Query selector or container element. + * @param {Object=} options - Custom settings. + * @param {string} options.prefix - Prefix to use for data attributes. + * @param {number} options.startDelay - Delay before animation, in ms. + * @param {number} options.typeDelay - Delay between each typed character, in ms. + * @param {number} options.lineDelay - Delay between each line, in ms. + * @param {number} options.progressLength - Number of characters displayed as progress bar. + * @param {string} options.progressChar – Character to use for progress bar, defaults to █. + * @param {number} options.progressPercent - Max percent of progress. + * @param {string} options.cursor – Character to use for cursor, defaults to ▋. + * @param {Object[]} lineData - Dynamically loaded line data objects. + * @param {boolean} options.noInit - Don't initialise the animation. + */ + constructor(container = '#termynal', options = {}) { + this.container = (typeof container === 'string') ? document.querySelector(container) : container; + this.pfx = `data-${options.prefix || 'ty'}`; + this.originalStartDelay = this.startDelay = options.startDelay + || parseFloat(this.container.getAttribute(`${this.pfx}-startDelay`)) || 600; + this.originalTypeDelay = this.typeDelay = options.typeDelay + || parseFloat(this.container.getAttribute(`${this.pfx}-typeDelay`)) || 90; + this.originalLineDelay = this.lineDelay = options.lineDelay + || parseFloat(this.container.getAttribute(`${this.pfx}-lineDelay`)) || 1500; + this.progressLength = options.progressLength + || parseFloat(this.container.getAttribute(`${this.pfx}-progressLength`)) || 40; + this.progressChar = options.progressChar + || this.container.getAttribute(`${this.pfx}-progressChar`) || '█'; + this.progressPercent = options.progressPercent + || parseFloat(this.container.getAttribute(`${this.pfx}-progressPercent`)) || 100; + this.cursor = options.cursor + || this.container.getAttribute(`${this.pfx}-cursor`) || '▋'; + this.lineData = this.lineDataToElements(options.lineData || []); + this.loadLines() + if (!options.noInit) this.init() + } + + loadLines() { + // Load all the lines and create the container so that the size is fixed + // Otherwise it would be changing and the user viewport would be constantly + // moving as she/he scrolls + const finish = this.generateFinish() + finish.style.visibility = 'hidden' + this.container.appendChild(finish) + // Appends dynamically loaded lines to existing line elements. + this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData); + for (let line of this.lines) { + line.style.visibility = 'hidden' + this.container.appendChild(line) + } + const restart = this.generateRestart() + restart.style.visibility = 'hidden' + this.container.appendChild(restart) + this.container.setAttribute('data-termynal', ''); + } + + /** + * Initialise the widget, get lines, clear container and start animation. + */ + init() { + /** + * Calculates width and height of Termynal container. + * If container is empty and lines are dynamically loaded, defaults to browser `auto` or CSS. + */ + const containerStyle = getComputedStyle(this.container); + this.container.style.width = containerStyle.width !== '0px' ? + containerStyle.width : undefined; + this.container.style.minHeight = containerStyle.height !== '0px' ? + containerStyle.height : undefined; + + this.container.setAttribute('data-termynal', ''); + this.container.innerHTML = ''; + for (let line of this.lines) { + line.style.visibility = 'visible' + } + this.start(); + } + + /** + * Start the animation and rener the lines depending on their data attributes. + */ + async start() { + this.addFinish() + await this._wait(this.startDelay); + + for (let line of this.lines) { + const type = line.getAttribute(this.pfx); + const delay = line.getAttribute(`${this.pfx}-delay`) || this.lineDelay; + + if (type == 'input') { + line.setAttribute(`${this.pfx}-cursor`, this.cursor); + await this.type(line); + await this._wait(delay); + } + + else if (type == 'progress') { + await this.progress(line); + await this._wait(delay); + } + + else { + this.container.appendChild(line); + await this._wait(delay); + } + + line.removeAttribute(`${this.pfx}-cursor`); + } + this.addRestart() + this.finishElement.style.visibility = 'hidden' + this.lineDelay = this.originalLineDelay + this.typeDelay = this.originalTypeDelay + this.startDelay = this.originalStartDelay + } + + generateRestart() { + const restart = document.createElement('a') + restart.onclick = (e) => { + e.preventDefault() + this.container.innerHTML = '' + this.init() + } + restart.href = '#' + restart.setAttribute('data-terminal-control', '') + restart.innerHTML = "restart ↻" + return restart + } + + generateFinish() { + const finish = document.createElement('a') + finish.onclick = (e) => { + e.preventDefault() + this.lineDelay = 0 + this.typeDelay = 0 + this.startDelay = 0 + } + finish.href = '#' + finish.setAttribute('data-terminal-control', '') + finish.innerHTML = "fast →" + this.finishElement = finish + return finish + } + + addRestart() { + const restart = this.generateRestart() + this.container.appendChild(restart) + } + + addFinish() { + const finish = this.generateFinish() + this.container.appendChild(finish) + } + + /** + * Animate a typed line. + * @param {Node} line - The line element to render. + */ + async type(line) { + const chars = [...line.textContent]; + line.textContent = ''; + this.container.appendChild(line); + + for (let char of chars) { + const delay = line.getAttribute(`${this.pfx}-typeDelay`) || this.typeDelay; + await this._wait(delay); + line.textContent += char; + } + } + + /** + * Animate a progress bar. + * @param {Node} line - The line element to render. + */ + async progress(line) { + const progressLength = line.getAttribute(`${this.pfx}-progressLength`) + || this.progressLength; + const progressChar = line.getAttribute(`${this.pfx}-progressChar`) + || this.progressChar; + const chars = progressChar.repeat(progressLength); + const progressPercent = line.getAttribute(`${this.pfx}-progressPercent`) + || this.progressPercent; + line.textContent = ''; + this.container.appendChild(line); + + for (let i = 1; i < chars.length + 1; i++) { + await this._wait(this.typeDelay); + const percent = Math.round(i / chars.length * 100); + line.textContent = `${chars.slice(0, i)} ${percent}%`; + if (percent>progressPercent) { + break; + } + } + } + + /** + * Helper function for animation delays, called with `await`. + * @param {number} time - Timeout, in ms. + */ + _wait(time) { + return new Promise(resolve => setTimeout(resolve, time)); + } + + /** + * Converts line data objects into line elements. + * + * @param {Object[]} lineData - Dynamically loaded lines. + * @param {Object} line - Line data object. + * @returns {Element[]} - Array of line elements. + */ + lineDataToElements(lineData) { + return lineData.map(line => { + let div = document.createElement('div'); + div.innerHTML = `${line.value || ''}`; + + return div.firstElementChild; + }); + } + + /** + * Helper function for generating attributes string. + * + * @param {Object} line - Line data object. + * @returns {string} - String of attributes. + */ + _attributes(line) { + let attrs = ''; + for (let prop in line) { + // Custom add class + if (prop === 'class') { + attrs += ` class=${line[prop]} ` + continue + } + if (prop === 'type') { + attrs += `${this.pfx}="${line[prop]}" ` + } else if (prop !== 'value') { + attrs += `${this.pfx}-${prop}="${line[prop]}" ` + } + } + + return attrs; + } +} + +/** +* HTML API: If current script has container(s) specified, initialise Termynal. +*/ +if (document.currentScript.hasAttribute('data-termynal-container')) { + const containers = document.currentScript.getAttribute('data-termynal-container'); + containers.split('|') + .forEach(container => new Termynal(container)) +} diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 00000000..8dcfa5cb --- /dev/null +++ b/docs/reference.md @@ -0,0 +1 @@ +::: pydantic_redis \ No newline at end of file diff --git a/docs/tutorials/asynchronous/delete.md b/docs/tutorials/asynchronous/delete.md new file mode 100644 index 00000000..088710dd --- /dev/null +++ b/docs/tutorials/asynchronous/delete.md @@ -0,0 +1,48 @@ +# Delete + +Pydantic-redis can be used to delete model instances from redis. + +## Create and register the Model + +A model is a class that inherits from `Model` with its `_primary_key_field` attribute set. + +In order for the store to know the existence of the given model, +register it using the `register_model` method of `Store` + +!!! warning + The imports are from `pydantic_redis.asyncio` NOT `pydantic_redis` + +```Python hl_lines="6-9 18" +{!../docs_src/tutorials/asynchronous/delete.py!} +``` + +## Delete Records + +To delete many records from redis, pass a list of primary keys (`ids`) of the records to the model's `delete` method. + +```Python hl_lines="30" +{!../docs_src/tutorials/asynchronous/delete.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +pre-delete: +[ Book(title='Jane Eyre', author='Emily Bronte'), + Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Utah Blaine', author="Louis L'Amour"), + Book(title='Pride and Prejudice', author='Jane Austen')] + +post-delete: +[ Book(title='Jane Eyre', author='Emily Bronte'), + Book(title='Utah Blaine', author="Louis L'Amour")] +``` +
\ No newline at end of file diff --git a/docs/tutorials/asynchronous/insert.md b/docs/tutorials/asynchronous/insert.md new file mode 100644 index 00000000..c562feec --- /dev/null +++ b/docs/tutorials/asynchronous/insert.md @@ -0,0 +1,87 @@ +# Insert + +Pydantic-redis can be used to insert new model instances into redis. + +## Create and register the Model + +A model is a class that inherits from `Model` with its `_primary_key_field` attribute set. + +In order for the store to know the existence of the given model, +register it using the `register_model` method of `Store` + +!!! warning + The imports are from `pydantic_redis.asyncio` NOT `pydantic_redis` + +```Python hl_lines="6-9 18" +{!../docs_src/tutorials/asynchronous/insert.py!} +``` + +## Insert One Record + +To add a single record to the redis instance, pass that model's instance as first argument to the model's `insert` +method + +```Python hl_lines="20" +{!../docs_src/tutorials/asynchronous/insert.py!} +``` + +## Insert One Record With TTL + +To make the record added to redis temporary, add a `life_span_seconds` (Time To Live i.e. TTL) key-word argument +when calling the model's `insert` method. + +!!! info + When the `life_span_seconds` argument is not specified, the `life_span_in_seconds` passed to the store during + initialization is used. + + The `life_span_in_seconds` in both cases is `None` by default. This means records never expire by default. + +```Python hl_lines="21-24" +{!../docs_src/tutorials/asynchronous/insert.py!} +``` + +## Insert Many Records + +To add many records to the redis instance, pass a list of that model's instances as first argument to the model's +`insert` method. + +!!! info + Adding many records at once is more performant than adding one record at a time repeatedly because less network requests + are made in the former. + +```Python hl_lines="25-30" +{!../docs_src/tutorials/asynchronous/insert.py!} +``` + +## Insert Many Records With TTL + +To add temporary records to redis, add a `life_span_seconds` (Time To Live i.e. TTL) argument +when calling the model's `insert` method. + +!!! info + When the `life_span_seconds` argument is not specified, the `life_span_in_seconds` passed to the store during + initialization is used. + + The `life_span_in_seconds` in both cases is `None` by default. This means records never expire by default. + +```Python hl_lines="31-37" +{!../docs_src/tutorials/asynchronous/insert.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +[ Book(title='Jane Eyre', author='Emily Bronte'), + Book(title='Great Expectations', author='Charles Dickens'), + Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Pride and Prejudice', author='Jane Austen')] +``` +
\ No newline at end of file diff --git a/docs/tutorials/asynchronous/list-of-nested-models.md b/docs/tutorials/asynchronous/list-of-nested-models.md new file mode 100644 index 00000000..c451edaf --- /dev/null +++ b/docs/tutorials/asynchronous/list-of-nested-models.md @@ -0,0 +1,156 @@ +# Lists of Nested Models + +Sometimes, one might need to have models (schemas) that have lists of other models (schemas). + +An example is a `Folder` model that can have child `Folder`'s and `File`'s. + +This can easily be pulled off with pydantic-redis. + +## Import Pydantic-redis' `Model` + +First, import `pydantic-redis.asyncio`'s `Model` + +!!! warning + The imports are from `pydantic_redis.asyncio` NOT `pydantic_redis` + +```Python hl_lines="6" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Create the Child Model + +Next, declare a new model as a class that inherits from `Model`. + +Use standard Python types for all attributes. + +```Python hl_lines="15-18" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Set the `_primary_key_field` of the Child Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the Model. + +!!! example + In this case, there can be no two `File`'s with the same `path`. + +```Python hl_lines="16" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Create the Parent Model + +Next, declare another model as a class that inherits from `Model`. + +Use standard Python types for all attributes, as before. + +```Python hl_lines="21-25" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Add the Nested Model List to the Parent Model + +Annotate the field that is to hold the child model list with the List of child class. + +!!! example + In this case, the field `files` is annotated with `List[File]`. + + And the field `folders` is annotated with `"Folder"` class i.e. itself. + +```Python hl_lines="24-25" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Set the `_primary_key_field` of the Parent Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the parent Model. + +!!! example + In this case, there can be no two `Folder`'s with the same `path`. + +```Python hl_lines="22" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Register the Models in the Store + +Then, in order for the store to know the existence of each given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="36-37" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Use the Parent Model + +Then you can use the parent model class to: + +- `insert` into the store +- `update` an instance of the model +- `delete` from store +- `select` from store + +!!! info + The child models will be automatically inserted, or updated if they already exist + +!!! info + The store is connected to the Redis instance, so any changes you make will + reflect in redis itself. + +```Python hl_lines="39-61" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Use the Child Model Independently + +You can also use the child model independently. + +!!! info + Any mutation on the child model will also be reflected in the any parent model instances + fetched from redis after that mutation. + +```Python hl_lines="63-68" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Indirectly Update Child Model + +A child model can be indirectly updated via the parent model. + +Set the attribute containing the child model list with a list of instances of the child model + +If there is any new instance of the child model that has a pre-existing primary key, it will be updated in redis. + +```Python hl_lines="63-66" +{!../docs_src/tutorials/asynchronous/list-of-nested-models.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +parent folder: +[ Folder(path='path/to/parent-folder', files=[File(path='path/to/bar.txt', type=), File(path='path/to/bar.jpg', type=)], folders=[Folder(path='path/to/child-folder', files=[File(path='path/to/foo.txt', type=), File(path='path/to/foo.jpg', type=)], folders=[])])] + +files: +[ File(path='path/to/foo.txt', type=), + File(path='path/to/foo.jpg', type=), + File(path='path/to/bar.txt', type=), + File(path='path/to/bar.jpg', type=)] + +indirectly updated parent folder: +[ Folder(path='path/to/parent-folder', files=[File(path='path/to/bar.txt', type=), File(path='path/to/bar.jpg', type=)], folders=[Folder(path='path/to/child-folder', files=[File(path='path/to/foo.txt', type=)], folders=[])])] + +indirectly updated files: +[File(path='path/to/foo.txt', type=)] +``` +
diff --git a/docs/tutorials/asynchronous/models.md b/docs/tutorials/asynchronous/models.md new file mode 100644 index 00000000..0ae6519e --- /dev/null +++ b/docs/tutorials/asynchronous/models.md @@ -0,0 +1,80 @@ +# Models + +The very first thing you need to create for pydantic-redis are the models (or schemas) that +the data you are to save in redis is to be based on. + +These models are derived from [pydantic's](https://docs.pydantic.dev/) `BaseModel`. + +## Import Pydantic-redis' `Model` + +First, import pydantic-redis.asyncio's `Model` + +!!! warning + The imports are from `pydantic_redis.asyncio` NOT `pydantic_redis` + +```Python hl_lines="6" +{!../docs_src/tutorials/asynchronous/models.py!} +``` + +## Create the Model + +Next, declare a new model as a class that inherits from `Model`. + +Use standard Python types for all attributes. + +```Python hl_lines="9-16" +{!../docs_src/tutorials/asynchronous/models.py!} +``` + +## Specify the `_primary_key_field` Attribute + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the Model. + +!!! example + In this case, there can be no two books with the same `title`. + +```Python hl_lines="10" +{!../docs_src/tutorials/asynchronous/models.py!} +``` + +## Register the Model in the Store + +Then, in order for the store to know the existence of the given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="27" +{!../docs_src/tutorials/asynchronous/models.py!} +``` + +## Use the Model + +Then you can use the model class to: + +- `insert` into the store +- `update` an instance of the model +- `delete` from store +- `select` from store + +!!! info + The store is connected to the Redis instance, so any changes you make will + reflect in redis itself. + +```Python hl_lines="29-40" +{!../docs_src/tutorials/asynchronous/models.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +[ Book(title='Oliver Twist', author='Charles Dickens', rating=2.0, published_on=datetime.date(1215, 4, 4), tags=['Classic'], in_stock=False)] +``` +
\ No newline at end of file diff --git a/docs/tutorials/asynchronous/nested-models.md b/docs/tutorials/asynchronous/nested-models.md new file mode 100644 index 00000000..9955beb6 --- /dev/null +++ b/docs/tutorials/asynchronous/nested-models.md @@ -0,0 +1,152 @@ +# Nested Models + +The very first thing you need to create for pydantic-redis are the models (or schemas) that +the data you are to save in redis is to be based on. + +It is possible to refer one model in another model in a parent-child relationship. + +## Import Pydantic-redis' `Model` + +First, import `pydantic-redis.asyncio`'s `Model`. + +!!! warning + The imports are from `pydantic_redis.asyncio` NOT `pydantic_redis` + +```Python hl_lines="6" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Create the Child Model + +Next, declare a new model as a class that inherits from `Model`. + +Use standard Python types for all attributes. + +```Python hl_lines="9-12" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Set the `_primary_key_field` of the Child Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the Model. + +!!! example + In this case, there can be no two authors with the same `name`. + +```Python hl_lines="10" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Create the Parent Model + +Next, declare another model as a class that inherits from `Model`. + +Use standard Python types for all attributes, as before. + +```Python hl_lines="15-22" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Add the Nested Model to the Parent Model + +Annotate the field that is to hold the child model with the child class. + +!!! example + In this case, the field `author` is annotated with `Author` class. + +```Python hl_lines="18" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Set the `_primary_key_field` of the Parent Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the parent Model. + +!!! example + In this case, there can be no two books with the same `title`. + +```Python hl_lines="16" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Register the Models in the Store + +Then, in order for the store to know the existence of each given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="33-34" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Use the Parent Model + +Then you can use the parent model class to: + +- `insert` into the store +- `update` an instance of the model +- `delete` from store +- `select` from store + +!!! note + The child model will be automatically inserted, or updated if it already exists + +!!! info + The store is connected to the Redis instance, so any changes you make will + reflect in redis itself. + +```Python hl_lines="36-48" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Use the Child Model Independently + +You can also use the child model independently. + +!!! info + Any mutation on the child model will also be reflected in the any parent model instances + fetched from redis after that mutation. + +```Python hl_lines="50-51" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Indirectly Update Child Model + +A child model can be indirectly updated via the parent model. + +Set the attribute containing the child model with an instance of the child model + +!!! note + The new instance of the child model should have the **SAME** primary key as the original + child model. + +```Python hl_lines="53-57" +{!../docs_src/tutorials/asynchronous/nested-models.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +book: +[ Book(title='Oliver Twist', author=Author(name='Charles Dickens', active_years=(1999, 2007)), rating=2.0, published_on=datetime.date(1215, 4, 4), tags=['Classic'], in_stock=False)] + +author: +[Author(name='Charles Dickens', active_years=(1999, 2007))] + +indirectly updated book: +[ Book(title='Oliver Twist', author=Author(name='Charles Dickens', active_years=(1227, 1277)), rating=2.0, published_on=datetime.date(1215, 4, 4), tags=['Classic'], in_stock=False)] + +indirectly updated author: +[Author(name='Charles Dickens', active_years=(1969, 1999))] +``` +
diff --git a/docs/tutorials/asynchronous/select.md b/docs/tutorials/asynchronous/select.md new file mode 100644 index 00000000..7d5c7d58 --- /dev/null +++ b/docs/tutorials/asynchronous/select.md @@ -0,0 +1,125 @@ +# Select + +Pydantic-redis can be used to retrieve model instances from redis. + +## Create and register the Model + +A model is a class that inherits from `Model` with its `_primary_key_field` attribute set. + +In order for the store to know the existence of the given model, +register it using the `register_model` method of `Store`. + +!!! warning + The imports are from `pydantic_redis.asyncio` NOT `pydantic_redis` + +```Python hl_lines="6-9 18" +{!../docs_src/tutorials/asynchronous/select-records.py!} +``` + +## Select All Records + +To select all records for the given model in redis, call the model's `select` method without any arguments. + +```Python hl_lines="29" +{!../docs_src/tutorials/asynchronous/select-records.py!} +``` + +## Select Some Fields for All Records + +To select some fields for all records for the given model in redis, pass the desired fields (`columns`) to the model's +`select` method. + +!!! info + This returns dictionaries instead of Model instances. + +```Python hl_lines="34" +{!../docs_src/tutorials/asynchronous/select-records.py!} +``` + +## Select Some Records + +To select some records for the given model in redis, pass a list of the primary keys (`ids`) of the desired records to +the model's `select` method. + +```Python hl_lines="30-32" +{!../docs_src/tutorials/asynchronous/select-records.py!} +``` + +## Select Some Fields for Some Records + +We can go further and limit the fields returned for the desired records. + +We pass the desired fields (`columns`) to the model's `select` method, together with the list of the primary keys +(`ids`) of the desired records. + +!!! info + This returns dictionaries instead of Model instances. + +```Python hl_lines="35-37" +{!../docs_src/tutorials/asynchronous/select-records.py!} +``` + +## Select Records Page by Page + +In order to avoid overwhelming the server's memory resources, we can get the records one page at a time i.e. pagination. + +We do this by specifying the number of records per page (`limit`) and the number of records to skip (`skip`) +when calling the model's `select` method + +!!! info + Records are ordered by timestamp of their insert into redis. + + For batch inserts, the time difference is quite small but consistent. + +!!! tip + You don't have to pass the `skip` if you wish to get the first records. `skip` defaults to 0. + + `limit`, however is mandatory. + +!!! warning + When both `ids` and `limit` are supplied, pagination is ignored. + + It wouldn't make any sense otherwise. + +```Python hl_lines="39-42" +{!../docs_src/tutorials/asynchronous/select-records.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +all: +[ Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Utah Blaine', author="Louis L'Amour"), + Book(title='Jane Eyre', author='Emily Bronte'), + Book(title='Pride and Prejudice', author='Jane Austen')] + +by id: +[ Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Pride and Prejudice', author='Jane Austen')] + +some fields for all: +[ {'author': 'Charles Dickens'}, + {'author': "Louis L'Amour"}, + {'author': 'Emily Bronte'}, + {'author': 'Jane Austen'}] + +some fields for given ids: +[{'author': 'Charles Dickens'}, {'author': 'Jane Austen'}] + +paginated; skip: 0, limit: 2: +[ Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Jane Eyre', author='Emily Bronte')] + +paginated returning some fields for each; skip: 2, limit: 2: +[{'author': 'Jane Austen'}, {'author': "Louis L'Amour"}] +``` +
\ No newline at end of file diff --git a/docs/tutorials/asynchronous/tuple-of-nested-models.md b/docs/tutorials/asynchronous/tuple-of-nested-models.md new file mode 100644 index 00000000..27bef053 --- /dev/null +++ b/docs/tutorials/asynchronous/tuple-of-nested-models.md @@ -0,0 +1,154 @@ +# Tuples of Nested Models + +Sometimes, one might need to have models (schemas) that have tuples of other models (schemas). + +An example is a `ScoreBoard` model that can have Tuples of player name and `Scores`'. + +This can easily be pulled off with pydantic-redis. + +## Import Pydantic-redis' `Model` + +First, import `pydantic-redis.asyncio`'s `Model`. + +!!! warning + The imports are from `pydantic_redis.asyncio` NOT `pydantic_redis` + +```Python hl_lines="4" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Create the Child Model + +Next, declare a new model as a class that inherits from `Model`. + +Use standard Python types for all attributes. + +```Python hl_lines="7-10" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Set the `_primary_key_field` of the Child Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the Model. + +!!! example + In this case, there can be no two `Score`'s with the same `id`. + +```Python hl_lines="8" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Create the Parent Model + +Next, declare another model as a class that inherits from `Model`. + +Use standard Python types for all attributes, as before. + +```Python hl_lines="13-16" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Add the Nested Model Tuple to the Parent Model + +Annotate the field that is to hold the tuple of child models with the Tuple of child class. + +!!! example + In this case, the field `scores` is annotated with `Tuple[str, Score]` class. + +!!! info + The `str` is the player's name. + +```Python hl_lines="16" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Set the `_primary_key_field` of the Parent Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the parent Model. + +!!! example + In this case, there can be no two `ScoreBoard`'s with the same `id`. + +```Python hl_lines="14" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Register the Models in the Store + +Then, in order for the store to know the existence of each given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="23-24" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Use the Parent Model + +Then you can use the parent model class to: + +- `insert` into the store +- `update` an instance of the model +- `delete` from store +- `select` from store + +!!! info + The child models will be automatically inserted, or updated if they already exist + +!!! info + The store is connected to the Redis instance, so any changes you make will + reflect in redis itself. + +```Python hl_lines="26-36" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Use the Child Model Independently + +You can also use the child model independently. + +!!! info + Any mutation on the child model will also be reflected in the any parent model instances + fetched from redis after that mutation. + +```Python hl_lines="38-39" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Indirectly Update Child Model + +A child model can be indirectly updated via the parent model. + +Set the attribute containing the child model tuple with a tuple of instances of the child model + +If there is any new instance of the child model that has a pre-existing primary key, it will be updated in redis. + +```Python hl_lines="41-50" +{!../docs_src/tutorials/asynchronous/tuple-of-nested-models.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +score board: +[ScoreBoard(id='test', scores=('mark', Score(id='some id', total=50)))] + +scores: +[Score(id='some id', total=50)] + +indirectly updated score board: +[ScoreBoard(id='test', scores=('mark', Score(id='some id', total=78)))] + +indirectly updated score: +[Score(id='some id', total=60)] +``` +
diff --git a/docs/tutorials/asynchronous/update.md b/docs/tutorials/asynchronous/update.md new file mode 100644 index 00000000..b004c459 --- /dev/null +++ b/docs/tutorials/asynchronous/update.md @@ -0,0 +1,86 @@ +# Update + +Pydantic-redis can be used to update model instances in redis. + +## Create and register the Model + +A model is a class that inherits from `Model` with its `_primary_key_field` attribute set. + +In order for the store to know the existence of the given model, +register it using the `register_model` method of `Store`. + +!!! warning + The imports are from `pydantic_redis.asyncio` NOT `pydantic_redis` + +```Python hl_lines="6-9 18" +{!../docs_src/tutorials/asynchronous/update.py!} +``` + +## Update One Record + +To update a single record in redis, pass the primary key (`_id`) of that record and the new changes to the model's `update` +method + +```Python hl_lines="27" +{!../docs_src/tutorials/asynchronous/update.py!} +``` + +## Update One Record With TTL + +To update the record's time-to-live (TTL) also, pass the `life_span_seconds` argument to the model's `update` method. + +!!! info + When the `life_span_seconds` argument is not specified, the `life_span_in_seconds` passed to the store during + initialization is used. + + The `life_span_in_seconds` in both cases is `None` by default. This means records never expire by default. + +```Python hl_lines="28-30" +{!../docs_src/tutorials/asynchronous/update.py!} +``` + +## Update/Upsert Many Records + +To update many records in redis, pass a list of that model's instances as first argument to the model's +`insert` method. + +Technically, this will insert any records that don't exist and overwrite any that exist already. + +!!! info + Updating many records at once is more performant than adding one record at a time repeatedly because less network requests + are made in the former. + +!!! warning + Calling `insert` always overwrites the time-to-live of the records updated. + + When the `life_span_seconds` argument is not specified, the `life_span_in_seconds` passed to the store during + initialization is used. + + By default `life_span_seconds` is `None` i.e. the time-to-live is removed and the updated records never expire. + +```Python hl_lines="33-40" +{!../docs_src/tutorials/asynchronous/update.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +single update: +[ Book(title='Jane Eyre', author='Daniel McKenzie'), + Book(title='Oliver Twist', author='Charlie Ickens'), + Book(title='Pride and Prejudice', author='Jane Austen')] + +multi update: +[ Book(title='Jane Eyre', author='Emiliano Bronte'), + Book(title='Oliver Twist', author='Chuck Dickens'), + Book(title='Pride and Prejudice', author='Janey Austen')] +``` +
\ No newline at end of file diff --git a/docs/tutorials/intro.md b/docs/tutorials/intro.md new file mode 100644 index 00000000..1eeda945 --- /dev/null +++ b/docs/tutorials/intro.md @@ -0,0 +1,65 @@ +# Intro + +This tutorial shows you how to use **pydantic-redis** step by step. + +There are two tutorials: Synchronous API and Asynchronous API. + +In either tutorials, each child section gradually builds on the previous one. These child sections are separate topics +so that one can go directly to a specific topic, just like a reference. + +## Synchronous API + +In case you are looking to use pydantic-redis without async/await, you can read the **Synchronous API** version of this +tutorial. + +!!! info + This is the default API for pydantic-redis. + +## Asynchronous API + +In case you are looking to use pydantic-redis with async/await, e.g. in [FastAPI](https://fastapi.tiangolo.com) +or [asyncio](https://docs.python.org/3/library/asyncio.html) , you can read the **Asynchronous API** version of this +tutorial. + +## Install Python + +Pydantic-redis requires python 3.6 and above. The latest stable python version is the recommended version. + +You can install python from [the official python downloads site](https://www.python.org/downloads/). + +## Install Redis + +In order to use pydantic-redis, you need a redis server instance running. You can install a local instance +via [the official redis stack](https://redis.io/docs/stack/get-started/install/) instructions. + +!!! info + You may also need a visual client to view the data in redis. The recommended app to use + is [RedisInsight](https://redis.com/redis-enterprise/redis-insight/). + +## Run the Code + +All the code blocks can be copied and used directly. + +To run any of the examples, copy the code to a file `main.py`, and run the command below in your terminal: + +
+ +```console +$ python main.py +``` + +
+ +## Install Pydantic-redis + +First install pydantic-redis + +
+ +```console +$ pip install pydantic-redis + +---> 100% +``` + +
diff --git a/docs/tutorials/synchronous/delete.md b/docs/tutorials/synchronous/delete.md new file mode 100644 index 00000000..1fbacb28 --- /dev/null +++ b/docs/tutorials/synchronous/delete.md @@ -0,0 +1,45 @@ +# Delete + +Pydantic-redis can be used to delete model instances from redis. + +## Create and register the Model + +A model is a class that inherits from `Model` with its `_primary_key_field` attribute set. + +In order for the store to know the existence of the given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="5-8 17" +{!../docs_src/tutorials/synchronous/delete.py!} +``` + +## Delete Records + +To delete many records from redis, pass a list of primary keys (`ids`) of the records to the model's `delete` method. + +```Python hl_lines="29" +{!../docs_src/tutorials/synchronous/delete.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +pre-delete: +[ Book(title='Jane Eyre', author='Emily Bronte'), + Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Utah Blaine', author="Louis L'Amour"), + Book(title='Pride and Prejudice', author='Jane Austen')] + +post-delete: +[ Book(title='Jane Eyre', author='Emily Bronte'), + Book(title='Utah Blaine', author="Louis L'Amour")] +``` +
\ No newline at end of file diff --git a/docs/tutorials/synchronous/insert.md b/docs/tutorials/synchronous/insert.md new file mode 100644 index 00000000..e62dd706 --- /dev/null +++ b/docs/tutorials/synchronous/insert.md @@ -0,0 +1,84 @@ +# Insert + +Pydantic-redis can be used to insert new model instances into redis. + +## Create and register the Model + +A model is a class that inherits from `Model` with its `_primary_key_field` attribute set. + +In order for the store to know the existence of the given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="5-8 17" +{!../docs_src/tutorials/synchronous/insert.py!} +``` + +## Insert One Record + +To add a single record to the redis instance, pass that model's instance as first argument to the model's `insert` +method + +```Python hl_lines="19" +{!../docs_src/tutorials/synchronous/insert.py!} +``` + +## Insert One Record With TTL + +To make the record added to redis temporary, add a `life_span_seconds` (Time To Live i.e. TTL) key-word argument +when calling the model's `insert` method. + +!!! info + When the `life_span_seconds` argument is not specified, the `life_span_in_seconds` passed to the store during + initialization is used. + + The `life_span_in_seconds` in both cases is `None` by default. This means records never expire by default. + +```Python hl_lines="20-23" +{!../docs_src/tutorials/synchronous/insert.py!} +``` + +## Insert Many Records + +To add many records to the redis instance, pass a list of that model's instances as first argument to the model's +`insert` method. + +!!! info + Adding many records at once is more performant than adding one record at a time repeatedly because less network requests + are made in the former. + +```Python hl_lines="24-29" +{!../docs_src/tutorials/synchronous/insert.py!} +``` + +## Insert Many Records With TTL + +To add temporary records to redis, add a `life_span_seconds` (Time To Live i.e. TTL) argument +when calling the model's `insert` method. + +!!! info + When the `life_span_seconds` argument is not specified, the `life_span_in_seconds` passed to the store during + initialization is used. + + The `life_span_in_seconds` in both cases is `None` by default. This means records never expire by default. + +```Python hl_lines="30-36" +{!../docs_src/tutorials/synchronous/insert.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +[ Book(title='Jane Eyre', author='Emily Bronte'), + Book(title='Great Expectations', author='Charles Dickens'), + Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Pride and Prejudice', author='Jane Austen')] +``` +
\ No newline at end of file diff --git a/docs/tutorials/synchronous/list-of-nested-models.md b/docs/tutorials/synchronous/list-of-nested-models.md new file mode 100644 index 00000000..4a1d82c5 --- /dev/null +++ b/docs/tutorials/synchronous/list-of-nested-models.md @@ -0,0 +1,153 @@ +# Lists of Nested Models + +Sometimes, one might need to have models (schemas) that have lists of other models (schemas). + +An example is a `Folder` model that can have child `Folder`'s and `File`'s. + +This can easily be pulled off with pydantic-redis. + +## Import Pydantic-redis' `Model` + +First, import pydantic-redis' `Model` + +```Python hl_lines="5" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Create the Child Model + +Next, declare a new model as a class that inherits from `Model`. + +Use standard Python types for all attributes. + +```Python hl_lines="14-17" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Set the `_primary_key_field` of the Child Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the Model. + +!!! example + In this case, there can be no two `File`'s with the same `path`. + +```Python hl_lines="15" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Create the Parent Model + +Next, declare another model as a class that inherits from `Model`. + +Use standard Python types for all attributes, as before. + +```Python hl_lines="20-24" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Add the Nested Model List to the Parent Model + +Annotate the field that is to hold the child model list with the List of child class. + +!!! example + In this case, the field `files` is annotated with `List[File]`. + + And the field `folders` is annotated with `"Folder"` class i.e. itself. + +```Python hl_lines="23-24" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Set the `_primary_key_field` of the Parent Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the parent Model. + +!!! example + In this case, there can be no two `Folder`'s with the same `path`. + +```Python hl_lines="21" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Register the Models in the Store + +Then, in order for the store to know the existence of each given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="35-36" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Use the Parent Model + +Then you can use the parent model class to: + +- `insert` into the store +- `update` an instance of the model +- `delete` from store +- `select` from store + +!!! info + The child models will be automatically inserted, or updated if they already exist + +!!! info + The store is connected to the Redis instance, so any changes you make will + reflect in redis itself. + +```Python hl_lines="38-60" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Use the Child Model Independently + +You can also use the child model independently. + +!!! info + Any mutation on the child model will also be reflected in the any parent model instances + fetched from redis after that mutation. + +```Python hl_lines="62-67" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Indirectly Update Child Model + +A child model can be indirectly updated via the parent model. + +Set the attribute containing the child model list with a list of instances of the child model + +If there is any new instance of the child model that has a pre-existing primary key, it will be updated in redis. + +```Python hl_lines="62-65" +{!../docs_src/tutorials/synchronous/list-of-nested-models.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +parent folder: +[ Folder(path='path/to/parent-folder', files=[File(path='path/to/bar.txt', type=), File(path='path/to/bar.jpg', type=)], folders=[Folder(path='path/to/child-folder', files=[File(path='path/to/foo.txt', type=), File(path='path/to/foo.jpg', type=)], folders=[])])] + +files: +[ File(path='path/to/foo.txt', type=), + File(path='path/to/foo.jpg', type=), + File(path='path/to/bar.txt', type=), + File(path='path/to/bar.jpg', type=)] + +indirectly updated parent folder: +[ Folder(path='path/to/parent-folder', files=[File(path='path/to/bar.txt', type=), File(path='path/to/bar.jpg', type=)], folders=[Folder(path='path/to/child-folder', files=[File(path='path/to/foo.txt', type=)], folders=[])])] + +indirectly updated files: +[File(path='path/to/foo.txt', type=)] +``` +
diff --git a/docs/tutorials/synchronous/models.md b/docs/tutorials/synchronous/models.md new file mode 100644 index 00000000..4635b4c4 --- /dev/null +++ b/docs/tutorials/synchronous/models.md @@ -0,0 +1,77 @@ +# Models + +The very first thing you need to create for pydantic-redis are the models (or schemas) that +the data you are to save in redis is to be based on. + +These models are derived from [pydantic's](https://docs.pydantic.dev/) `BaseModel`. + +## Import Pydantic-redis' `Model` + +First, import pydantic-redis' `Model` + +```Python hl_lines="5" +{!../docs_src/tutorials/synchronous/models.py!} +``` + +## Create the Model + +Next, declare a new model as a class that inherits from `Model`. + +Use standard Python types for all attributes. + +```Python hl_lines="8-15" +{!../docs_src/tutorials/synchronous/models.py!} +``` + +## Specify the `_primary_key_field` Attribute + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the Model. + +!!! example + In this case, there can be no two books with the same `title`. + +```Python hl_lines="9" +{!../docs_src/tutorials/synchronous/models.py!} +``` + +## Register the Model in the Store + +Then, in order for the store to know the existence of the given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="26" +{!../docs_src/tutorials/synchronous/models.py!} +``` + +## Use the Model + +Then you can use the model class to: + +- `insert` into the store +- `update` an instance of the model +- `delete` from store +- `select` from store + +!!! info + The store is connected to the Redis instance, so any changes you make will + reflect in redis itself. + +```Python hl_lines="28-39" +{!../docs_src/tutorials/synchronous/models.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +[ Book(title='Oliver Twist', author='Charles Dickens', rating=2.0, published_on=datetime.date(1215, 4, 4), tags=['Classic'], in_stock=False)] +``` +
\ No newline at end of file diff --git a/docs/tutorials/synchronous/nested-models.md b/docs/tutorials/synchronous/nested-models.md new file mode 100644 index 00000000..c6416fc4 --- /dev/null +++ b/docs/tutorials/synchronous/nested-models.md @@ -0,0 +1,149 @@ +# Nested Models + +The very first thing you need to create for pydantic-redis are the models (or schemas) that +the data you are to save in redis is to be based on. + +It is possible to refer one model in another model in a parent-child relationship. + +## Import Pydantic-redis' `Model` + +First, import pydantic-redis' `Model` + +```Python hl_lines="5" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Create the Child Model + +Next, declare a new model as a class that inherits from `Model`. + +Use standard Python types for all attributes. + +```Python hl_lines="8-11" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Set the `_primary_key_field` of the Child Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the Model. + +!!! example + In this case, there can be no two authors with the same `name`. + +```Python hl_lines="9" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Create the Parent Model + +Next, declare another model as a class that inherits from `Model`. + +Use standard Python types for all attributes, as before. + +```Python hl_lines="14-21" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Add the Nested Model to the Parent Model + +Annotate the field that is to hold the child model with the child class. + +!!! example + In this case, the field `author` is annotated with `Author` class. + +```Python hl_lines="17" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Set the `_primary_key_field` of the Parent Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the parent Model. + +!!! example + In this case, there can be no two books with the same `title`. + +```Python hl_lines="15" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Register the Models in the Store + +Then, in order for the store to know the existence of each given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="32-33" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Use the Parent Model + +Then you can use the parent model class to: + +- `insert` into the store +- `update` an instance of the model +- `delete` from store +- `select` from store + +!!! note + The child model will be automatically inserted, or updated if it already exists + +!!! info + The store is connected to the Redis instance, so any changes you make will + reflect in redis itself. + +```Python hl_lines="35-47" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Use the Child Model Independently + +You can also use the child model independently. + +!!! info + Any mutation on the child model will also be reflected in the any parent model instances + fetched from redis after that mutation. + +```Python hl_lines="49-50" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Indirectly Update Child Model + +A child model can be indirectly updated via the parent model. + +Set the attribute containing the child model with an instance of the child model + +!!! note + The new instance of the child model should have the **SAME** primary key as the original + child model. + +```Python hl_lines="52-56" +{!../docs_src/tutorials/synchronous/nested-models.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +book: +[ Book(title='Oliver Twist', author=Author(name='Charles Dickens', active_years=(1999, 2007)), rating=2.0, published_on=datetime.date(1215, 4, 4), tags=['Classic'], in_stock=False)] + +author: +[Author(name='Charles Dickens', active_years=(1999, 2007))] + +indirectly updated book: +[ Book(title='Oliver Twist', author=Author(name='Charles Dickens', active_years=(1227, 1277)), rating=2.0, published_on=datetime.date(1215, 4, 4), tags=['Classic'], in_stock=False)] + +indirectly updated author: +[Author(name='Charles Dickens', active_years=(1969, 1999))] +``` +
diff --git a/docs/tutorials/synchronous/select.md b/docs/tutorials/synchronous/select.md new file mode 100644 index 00000000..f8ca4883 --- /dev/null +++ b/docs/tutorials/synchronous/select.md @@ -0,0 +1,122 @@ +# Select + +Pydantic-redis can be used to retrieve model instances from redis. + +## Create and register the Model + +A model is a class that inherits from `Model` with its `_primary_key_field` attribute set. + +In order for the store to know the existence of the given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="5-8 17" +{!../docs_src/tutorials/synchronous/select-records.py!} +``` + +## Select All Records + +To select all records for the given model in redis, call the model's `select` method without any arguments. + +```Python hl_lines="28" +{!../docs_src/tutorials/synchronous/select-records.py!} +``` + +## Select Some Fields for All Records + +To select some fields for all records for the given model in redis, pass the desired fields (`columns`) to the model's +`select` method. + +!!! info + This returns dictionaries instead of Model instances. + +```Python hl_lines="31" +{!../docs_src/tutorials/synchronous/select-records.py!} +``` + +## Select Some Records + +To select some records for the given model in redis, pass a list of the primary keys (`ids`) of the desired records to +the model's `select` method. + +```Python hl_lines="29" +{!../docs_src/tutorials/synchronous/select-records.py!} +``` + +## Select Some Fields for Some Records + +We can go further and limit the fields returned for the desired records. + +We pass the desired fields (`columns`) to the model's `select` method, together with the list of the primary keys +(`ids`) of the desired records. + +!!! info + This returns dictionaries instead of Model instances. + +```Python hl_lines="32-34" +{!../docs_src/tutorials/synchronous/select-records.py!} +``` + +## Select Records Page by Page + +In order to avoid overwhelming the server's memory resources, we can get the records one page at a time i.e. pagination. + +We do this by specifying the number of records per page (`limit`) and the number of records to skip (`skip`) +when calling the model's `select` method + +!!! info + Records are ordered by timestamp of their insert into redis. + + For batch inserts, the time difference is quite small but consistent. + +!!! tip + You don't have to pass the `skip` if you wish to get the first records. `skip` defaults to 0. + + `limit`, however is mandatory. + +!!! warning + When both `ids` and `limit` are supplied, pagination is ignored. + + It wouldn't make any sense otherwise. + +```Python hl_lines="36-39" +{!../docs_src/tutorials/synchronous/select-records.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +all: +[ Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Utah Blaine', author="Louis L'Amour"), + Book(title='Jane Eyre', author='Emily Bronte'), + Book(title='Pride and Prejudice', author='Jane Austen')] + +by id: +[ Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Pride and Prejudice', author='Jane Austen')] + +some fields for all: +[ {'author': 'Charles Dickens'}, + {'author': "Louis L'Amour"}, + {'author': 'Emily Bronte'}, + {'author': 'Jane Austen'}] + +some fields for given ids: +[{'author': 'Charles Dickens'}, {'author': 'Jane Austen'}] + +paginated; skip: 0, limit: 2: +[ Book(title='Oliver Twist', author='Charles Dickens'), + Book(title='Jane Eyre', author='Emily Bronte')] + +paginated returning some fields for each; skip: 2, limit: 2: +[{'author': 'Jane Austen'}, {'author': "Louis L'Amour"}] +``` +
\ No newline at end of file diff --git a/docs/tutorials/synchronous/tuple-of-nested-models.md b/docs/tutorials/synchronous/tuple-of-nested-models.md new file mode 100644 index 00000000..45210fa2 --- /dev/null +++ b/docs/tutorials/synchronous/tuple-of-nested-models.md @@ -0,0 +1,151 @@ +# Tuples of Nested Models + +Sometimes, one might need to have models (schemas) that have tuples of other models (schemas). + +An example is a `ScoreBoard` model that can have Tuples of player name and `Scores`'. + +This can easily be pulled off with pydantic-redis. + +## Import Pydantic-redis' `Model` + +First, import pydantic-redis' `Model` + +```Python hl_lines="3" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Create the Child Model + +Next, declare a new model as a class that inherits from `Model`. + +Use standard Python types for all attributes. + +```Python hl_lines="6-9" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Set the `_primary_key_field` of the Child Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the Model. + +!!! example + In this case, there can be no two `Score`'s with the same `id`. + +```Python hl_lines="7" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Create the Parent Model + +Next, declare another model as a class that inherits from `Model`. + +Use standard Python types for all attributes, as before. + +```Python hl_lines="12-15" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Add the Nested Model Tuple to the Parent Model + +Annotate the field that is to hold the tuple of child models with the Tuple of child class. + +!!! example + In this case, the field `scores` is annotated with `Tuple[str, Score]` class. + +!!! info + The `str` is the player's name. + +```Python hl_lines="15" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Set the `_primary_key_field` of the Parent Model + +Set the `_primary_key_field` attribute to the name of the attribute +that is to act as a unique identifier for each instance of the parent Model. + +!!! example + In this case, there can be no two `ScoreBoard`'s with the same `id`. + +```Python hl_lines="13" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Register the Models in the Store + +Then, in order for the store to know the existence of each given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="22-23" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Use the Parent Model + +Then you can use the parent model class to: + +- `insert` into the store +- `update` an instance of the model +- `delete` from store +- `select` from store + +!!! info + The child models will be automatically inserted, or updated if they already exist + +!!! info + The store is connected to the Redis instance, so any changes you make will + reflect in redis itself. + +```Python hl_lines="25-35" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Use the Child Model Independently + +You can also use the child model independently. + +!!! info + Any mutation on the child model will also be reflected in the any parent model instances + fetched from redis after that mutation. + +```Python hl_lines="37-38" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Indirectly Update Child Model + +A child model can be indirectly updated via the parent model. + +Set the attribute containing the child model tuple with a tuple of instances of the child model + +If there is any new instance of the child model that has a pre-existing primary key, it will be updated in redis. + +```Python hl_lines="40-49" +{!../docs_src/tutorials/synchronous/tuple-of-nested-models.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +score board: +[ScoreBoard(id='test', scores=('mark', Score(id='some id', total=50)))] + +scores: +[Score(id='some id', total=50)] + +indirectly updated score board: +[ScoreBoard(id='test', scores=('mark', Score(id='some id', total=78)))] + +indirectly updated score: +[Score(id='some id', total=60)] +``` +
diff --git a/docs/tutorials/synchronous/update.md b/docs/tutorials/synchronous/update.md new file mode 100644 index 00000000..c8571a71 --- /dev/null +++ b/docs/tutorials/synchronous/update.md @@ -0,0 +1,83 @@ +# Update + +Pydantic-redis can be used to update model instances in redis. + +## Create and register the Model + +A model is a class that inherits from `Model` with its `_primary_key_field` attribute set. + +In order for the store to know the existence of the given model, +register it using the `register_model` method of `Store` + +```Python hl_lines="5-8 17" +{!../docs_src/tutorials/synchronous/update.py!} +``` + +## Update One Record + +To update a single record in redis, pass the primary key (`_id`) of that record and the new changes to the model's `update` +method + +```Python hl_lines="26" +{!../docs_src/tutorials/synchronous/update.py!} +``` + +## Update One Record With TTL + +To update the record's time-to-live (TTL) also, pass the `life_span_seconds` argument to the model's `update` method. + +!!! info + When the `life_span_seconds` argument is not specified, the `life_span_in_seconds` passed to the store during + initialization is used. + + The `life_span_in_seconds` in both cases is `None` by default. This means records never expire by default. + +```Python hl_lines="27-29" +{!../docs_src/tutorials/synchronous/update.py!} +``` + +## Update/Upsert Many Records + +To update many records in redis, pass a list of that model's instances as first argument to the model's +`insert` method. + +Technically, this will insert any records that don't exist and overwrite any that exist already. + +!!! info + Updating many records at once is more performant than adding one record at a time repeatedly because less network requests + are made in the former. + +!!! warning + Calling `insert` always overwrites the time-to-live of the records updated. + + When the `life_span_seconds` argument is not specified, the `life_span_in_seconds` passed to the store during + initialization is used. + + By default `life_span_seconds` is `None` i.e. the time-to-live is removed and the updated records never expire. + +```Python hl_lines="32-39" +{!../docs_src/tutorials/synchronous/update.py!} +``` + +## Run the App + +Running the above code in a file `main.py` would produce: + +!!! tip + Probably [FLUSHALL](https://redis.io/commands/flushall/) redis first + +
+ +```console +$ python main.py +single update: +[ Book(title='Jane Eyre', author='Daniel McKenzie'), + Book(title='Oliver Twist', author='Charlie Ickens'), + Book(title='Pride and Prejudice', author='Jane Austen')] + +multi update: +[ Book(title='Jane Eyre', author='Emiliano Bronte'), + Book(title='Oliver Twist', author='Chuck Dickens'), + Book(title='Pride and Prejudice', author='Janey Austen')] +``` +
\ No newline at end of file diff --git a/docs_src/index/async_main.py b/docs_src/index/async_main.py new file mode 100644 index 00000000..e553776e --- /dev/null +++ b/docs_src/index/async_main.py @@ -0,0 +1,89 @@ +import asyncio +import pprint +from datetime import date +from typing import Tuple, List +from pydantic_redis.asyncio import RedisConfig, Model, Store + + +class Author(Model): + _primary_key_field: str = "name" + name: str + active_years: Tuple[int, int] + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: Author + rating: float + published_on: date + tags: List[str] = [] + in_stock: bool = True + + +async def run_async(): + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", + redis_config=RedisConfig(db=5, host="localhost", port=6379), + life_span_in_seconds=3600, + ) + + store.register_model(Book) + store.register_model(Author) + + authors = { + "charles": Author(name="Charles Dickens", active_years=(1220, 1280)), + "jane": Author(name="Jane Austen", active_years=(1580, 1640)), + } + + books = [ + Book( + title="Oliver Twist", + author=authors["charles"], + published_on=date(year=1215, month=4, day=4), + in_stock=False, + rating=2, + tags=["Classic"], + ), + Book( + title="Great Expectations", + author=authors["charles"], + published_on=date(year=1220, month=4, day=4), + rating=5, + tags=["Classic"], + ), + Book( + title="Jane Eyre", + author=authors["charles"], + published_on=date(year=1225, month=6, day=4), + in_stock=False, + rating=3.4, + tags=["Classic", "Romance"], + ), + Book( + title="Wuthering Heights", + author=authors["jane"], + published_on=date(year=1600, month=4, day=4), + rating=4.0, + tags=["Classic", "Romance"], + ), + ] + + await Book.insert(books, life_span_seconds=3600) + all_books = await Book.select() + paginated_books = await Book.select(skip=2, limit=2) + paginated_books_with_few_fields = await Book.select( + columns=["author", "in_stock"], skip=2, limit=2 + ) + print("All:") + pp.pprint(all_books) + print("\nPaginated:") + pp.pprint(paginated_books) + print("\nPaginated but with few fields:") + pp.pprint(paginated_books_with_few_fields) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(run_async()) diff --git a/docs_src/index/sync_main.py b/docs_src/index/sync_main.py new file mode 100644 index 00000000..f83df7a7 --- /dev/null +++ b/docs_src/index/sync_main.py @@ -0,0 +1,83 @@ +import pprint +from datetime import date +from typing import Tuple, List +from pydantic_redis import RedisConfig, Model, Store + + +class Author(Model): + _primary_key_field: str = "name" + name: str + active_years: Tuple[int, int] + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: Author + rating: float + published_on: date + tags: List[str] = [] + in_stock: bool = True + + +if __name__ == "__main__": + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", + redis_config=RedisConfig(db=5, host="localhost", port=6379), + life_span_in_seconds=3600, + ) + + store.register_model(Book) + store.register_model(Author) + + authors = { + "charles": Author(name="Charles Dickens", active_years=(1220, 1280)), + "jane": Author(name="Jane Austen", active_years=(1580, 1640)), + } + + books = [ + Book( + title="Oliver Twist", + author=authors["charles"], + published_on=date(year=1215, month=4, day=4), + in_stock=False, + rating=2, + tags=["Classic"], + ), + Book( + title="Great Expectations", + author=authors["charles"], + published_on=date(year=1220, month=4, day=4), + rating=5, + tags=["Classic"], + ), + Book( + title="Jane Eyre", + author=authors["charles"], + published_on=date(year=1225, month=6, day=4), + in_stock=False, + rating=3.4, + tags=["Classic", "Romance"], + ), + Book( + title="Wuthering Heights", + author=authors["jane"], + published_on=date(year=1600, month=4, day=4), + rating=4.0, + tags=["Classic", "Romance"], + ), + ] + + Book.insert(books, life_span_seconds=3600) + all_books = Book.select() + paginated_books = Book.select(skip=2, limit=2) + paginated_books_with_few_fields = Book.select( + columns=["author", "in_stock"], skip=2, limit=2 + ) + print("All:") + pp.pprint(all_books) + print("\nPaginated:") + pp.pprint(paginated_books) + print("\nPaginated but with few fields:") + pp.pprint(paginated_books_with_few_fields) diff --git a/docs_src/tutorials/asynchronous/delete.py b/docs_src/tutorials/asynchronous/delete.py new file mode 100644 index 00000000..2ffef33e --- /dev/null +++ b/docs_src/tutorials/asynchronous/delete.py @@ -0,0 +1,42 @@ +import asyncio +import pprint +from pydantic_redis.asyncio import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + + +async def main(): + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", redis_config=RedisConfig(), life_span_in_seconds=86400 + ) + + store.register_model(Book) + + await Book.insert( + [ + Book(title="Oliver Twist", author="Charles Dickens"), + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + Book(title="Utah Blaine", author="Louis L'Amour"), + ] + ) + pre_delete_response = await Book.select() + + await Book.delete(ids=["Oliver Twist", "Pride and Prejudice"]) + post_delete_response = await Book.select() + + print("pre-delete:") + pp.pprint(pre_delete_response) + + print("\npost-delete:") + pp.pprint(post_delete_response) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/docs_src/tutorials/asynchronous/insert.py b/docs_src/tutorials/asynchronous/insert.py new file mode 100644 index 00000000..8690b2fd --- /dev/null +++ b/docs_src/tutorials/asynchronous/insert.py @@ -0,0 +1,45 @@ +import asyncio +import pprint +from pydantic_redis.asyncio import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + + +async def main(): + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", redis_config=RedisConfig(), life_span_in_seconds=86400 + ) + + store.register_model(Book) + + await Book.insert(Book(title="Oliver Twist", author="Charles Dickens")) + await Book.insert( + Book(title="Great Expectations", author="Charles Dickens"), + life_span_seconds=1800, + ) + await Book.insert( + [ + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + ] + ) + await Book.insert( + [ + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + ], + life_span_seconds=3600, + ) + + response = await Book.select() + pp.pprint(response) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/docs_src/tutorials/asynchronous/list-of-nested-models.py b/docs_src/tutorials/asynchronous/list-of-nested-models.py new file mode 100644 index 00000000..e5c53497 --- /dev/null +++ b/docs_src/tutorials/asynchronous/list-of-nested-models.py @@ -0,0 +1,83 @@ +import asyncio +import pprint +from enum import Enum +from typing import List + +from pydantic_redis.asyncio import Model, Store, RedisConfig + + +class FileType(Enum): + TEXT = "text" + IMAGE = "image" + EXEC = "executable" + + +class File(Model): + _primary_key_field: str = "path" + path: str + type: FileType + + +class Folder(Model): + _primary_key_field: str = "path" + path: str + files: List[File] = [] + folders: List["Folder"] = [] + + +async def main(): + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", + redis_config=RedisConfig(db=5, host="localhost", port=6379), + life_span_in_seconds=3600, + ) + + store.register_model(File) + store.register_model(Folder) + + child_folder = Folder( + path="path/to/child-folder", + files=[ + File(path="path/to/foo.txt", type=FileType.TEXT), + File(path="path/to/foo.jpg", type=FileType.IMAGE), + ], + ) + + await Folder.insert( + Folder( + path="path/to/parent-folder", + files=[ + File(path="path/to/bar.txt", type=FileType.TEXT), + File(path="path/to/bar.jpg", type=FileType.IMAGE), + ], + folders=[child_folder], + ) + ) + + parent_folder_response = await Folder.select(ids=["path/to/parent-folder"]) + files_response = await File.select( + ids=["path/to/foo.txt", "path/to/foo.jpg", "path/to/bar.txt", "path/to/bar.jpg"] + ) + + await Folder.update( + _id="path/to/child-folder", + data={"files": [File(path="path/to/foo.txt", type=FileType.EXEC)]}, + ) + updated_parent_folder_response = await Folder.select(ids=["path/to/parent-folder"]) + updated_file_response = await File.select(ids=["path/to/foo.txt"]) + + print("parent folder:") + pp.pprint(parent_folder_response) + print("\nfiles:") + pp.pprint(files_response) + + print("\nindirectly updated parent folder:") + pp.pprint(updated_parent_folder_response) + print("\nindirectly updated files:") + pp.pprint(updated_file_response) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/docs_src/tutorials/asynchronous/models.py b/docs_src/tutorials/asynchronous/models.py new file mode 100644 index 00000000..6113f3a7 --- /dev/null +++ b/docs_src/tutorials/asynchronous/models.py @@ -0,0 +1,46 @@ +import asyncio +import pprint +from datetime import date +from typing import List + +from pydantic_redis.asyncio import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + rating: float + published_on: date + tags: List[str] = [] + in_stock: bool = True + + +async def main(): + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", + redis_config=RedisConfig(db=5, host="localhost", port=6379), + life_span_in_seconds=3600, + ) + + store.register_model(Book) + + await Book.insert( + Book( + title="Oliver Twist", + author="Charles Dickens", + published_on=date(year=1215, month=4, day=4), + in_stock=False, + rating=2, + tags=["Classic"], + ) + ) + + response = await Book.select(ids=["Oliver Twist"]) + pp.pprint(response) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/docs_src/tutorials/asynchronous/nested-models.py b/docs_src/tutorials/asynchronous/nested-models.py new file mode 100644 index 00000000..c417fee8 --- /dev/null +++ b/docs_src/tutorials/asynchronous/nested-models.py @@ -0,0 +1,72 @@ +import asyncio +import pprint +from datetime import date +from typing import List, Tuple + +from pydantic_redis.asyncio import Model, Store, RedisConfig + + +class Author(Model): + _primary_key_field: str = "name" + name: str + active_years: Tuple[int, int] + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: Author + rating: float + published_on: date + tags: List[str] = [] + in_stock: bool = True + + +async def main(): + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", + redis_config=RedisConfig(db=5, host="localhost", port=6379), + life_span_in_seconds=3600, + ) + + store.register_model(Author) + store.register_model(Book) + + await Book.insert( + Book( + title="Oliver Twist", + author=Author(name="Charles Dickens", active_years=(1999, 2007)), + published_on=date(year=1215, month=4, day=4), + in_stock=False, + rating=2, + tags=["Classic"], + ) + ) + + book_response = await Book.select(ids=["Oliver Twist"]) + author_response = await Author.select(ids=["Charles Dickens"]) + + await Author.update(_id="Charles Dickens", data={"active_years": (1227, 1277)}) + updated_book_response = await Book.select(ids=["Oliver Twist"]) + + await Book.update( + _id="Oliver Twist", + data={"author": Author(name="Charles Dickens", active_years=(1969, 1999))}, + ) + updated_author_response = await Author.select(ids=["Charles Dickens"]) + + print("book:") + pp.pprint(book_response) + print("\nauthor:") + pp.pprint(author_response) + + print("\nindirectly updated book:") + pp.pprint(updated_book_response) + print("\nindirectly updated author:") + pp.pprint(updated_author_response) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/docs_src/tutorials/asynchronous/select-records.py b/docs_src/tutorials/asynchronous/select-records.py new file mode 100644 index 00000000..585b09c4 --- /dev/null +++ b/docs_src/tutorials/asynchronous/select-records.py @@ -0,0 +1,60 @@ +import asyncio +import pprint +from pydantic_redis.asyncio import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + + +async def main(): + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", redis_config=RedisConfig(), life_span_in_seconds=86400 + ) + + store.register_model(Book) + + await Book.insert( + [ + Book(title="Oliver Twist", author="Charles Dickens"), + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + Book(title="Utah Blaine", author="Louis L'Amour"), + ] + ) + + select_all_response = await Book.select() + select_by_id_response = await Book.select( + ids=["Oliver Twist", "Pride and Prejudice"] + ) + + select_some_fields_response = await Book.select(columns=["author"]) + select_some_fields_for_ids_response = await Book.select( + ids=["Oliver Twist", "Pride and Prejudice"], columns=["author"] + ) + + paginated_select_all_response = await Book.select(skip=0, limit=2) + paginated_select_some_fields_response = await Book.select( + columns=["author"], skip=2, limit=2 + ) + + print("all:") + pp.pprint(select_all_response) + print("\nby id:") + pp.pprint(select_by_id_response) + print("\nsome fields for all:") + pp.pprint(select_some_fields_response) + print("\nsome fields for given ids:") + pp.pprint(select_some_fields_for_ids_response) + print("\npaginated; skip: 0, limit: 2:") + pp.pprint(paginated_select_all_response) + print("\npaginated returning some fields for each; skip: 2, limit: 2:") + pp.pprint(paginated_select_some_fields_response) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/docs_src/tutorials/asynchronous/tuple-of-nested-models.py b/docs_src/tutorials/asynchronous/tuple-of-nested-models.py new file mode 100644 index 00000000..a9486e6c --- /dev/null +++ b/docs_src/tutorials/asynchronous/tuple-of-nested-models.py @@ -0,0 +1,65 @@ +import asyncio +import pprint +from typing import Tuple +from pydantic_redis.asyncio import RedisConfig, Model, Store + + +class Score(Model): + _primary_key_field: str = "id" + id: str + total: int + + +class ScoreBoard(Model): + _primary_key_field: str = "id" + id: str + scores: Tuple[str, Score] + + +async def main(): + pp = pprint.PrettyPrinter(indent=4) + store = Store(name="test", redis_config=RedisConfig()) + + store.register_model(Score) + store.register_model(ScoreBoard) + + await ScoreBoard.insert( + data=ScoreBoard( + id="test", + scores=( + "mark", + Score(id="some id", total=50), + ), + ) + ) + score_board_response = await ScoreBoard.select(ids=["test"]) + scores_response = await Score.select(ids=["some id"]) + + await Score.update(_id="some id", data={"total": 78}) + updated_score_board_response = await ScoreBoard.select(ids=["test"]) + + await ScoreBoard.update( + _id="test", + data={ + "scores": ( + "tom", + Score(id="some id", total=60), + ) + }, + ) + updated_score_response = await Score.select(ids=["some id"]) + + print("score board:") + pp.pprint(score_board_response) + print("\nscores:") + pp.pprint(scores_response) + + print("\nindirectly updated score board:") + pp.pprint(updated_score_board_response) + print("\nindirectly updated score:") + pp.pprint(updated_score_response) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/docs_src/tutorials/asynchronous/update.py b/docs_src/tutorials/asynchronous/update.py new file mode 100644 index 00000000..1cc2cc5e --- /dev/null +++ b/docs_src/tutorials/asynchronous/update.py @@ -0,0 +1,52 @@ +import asyncio +import pprint +from pydantic_redis.asyncio import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + + +async def main(): + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", redis_config=RedisConfig(), life_span_in_seconds=86400 + ) + + store.register_model(Book) + + await Book.insert( + [ + Book(title="Oliver Twist", author="Charles Dickens"), + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + ] + ) + await Book.update(_id="Oliver Twist", data={"author": "Charlie Ickens"}) + await Book.update( + _id="Jane Eyre", data={"author": "Daniel McKenzie"}, life_span_seconds=1800 + ) + single_update_response = await Book.select() + + await Book.insert( + [ + Book(title="Oliver Twist", author="Chuck Dickens"), + Book(title="Jane Eyre", author="Emiliano Bronte"), + Book(title="Pride and Prejudice", author="Janey Austen"), + ], + life_span_seconds=3600, + ) + multi_update_response = await Book.select() + + print("single update:") + pp.pprint(single_update_response) + + print("\nmulti update:") + pp.pprint(multi_update_response) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/docs_src/tutorials/synchronous/delete.py b/docs_src/tutorials/synchronous/delete.py new file mode 100644 index 00000000..2087a642 --- /dev/null +++ b/docs_src/tutorials/synchronous/delete.py @@ -0,0 +1,36 @@ +import pprint +from pydantic_redis import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + + +if __name__ == "__main__": + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", redis_config=RedisConfig(), life_span_in_seconds=86400 + ) + + store.register_model(Book) + + Book.insert( + [ + Book(title="Oliver Twist", author="Charles Dickens"), + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + Book(title="Utah Blaine", author="Louis L'Amour"), + ] + ) + pre_delete_response = Book.select() + + Book.delete(ids=["Oliver Twist", "Pride and Prejudice"]) + post_delete_response = Book.select() + + print("pre-delete:") + pp.pprint(pre_delete_response) + + print("\npost-delete:") + pp.pprint(post_delete_response) diff --git a/docs_src/tutorials/synchronous/insert.py b/docs_src/tutorials/synchronous/insert.py new file mode 100644 index 00000000..6c87d792 --- /dev/null +++ b/docs_src/tutorials/synchronous/insert.py @@ -0,0 +1,39 @@ +import pprint +from pydantic_redis import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + + +if __name__ == "__main__": + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", redis_config=RedisConfig(), life_span_in_seconds=86400 + ) + + store.register_model(Book) + + Book.insert(Book(title="Oliver Twist", author="Charles Dickens")) + Book.insert( + Book(title="Great Expectations", author="Charles Dickens"), + life_span_seconds=1800, + ) + Book.insert( + [ + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + ] + ) + Book.insert( + [ + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + ], + life_span_seconds=3600, + ) + + response = Book.select() + pp.pprint(response) diff --git a/docs_src/tutorials/synchronous/list-of-nested-models.py b/docs_src/tutorials/synchronous/list-of-nested-models.py new file mode 100644 index 00000000..fa4961a0 --- /dev/null +++ b/docs_src/tutorials/synchronous/list-of-nested-models.py @@ -0,0 +1,77 @@ +import pprint +from enum import Enum +from typing import List + +from pydantic_redis import Model, Store, RedisConfig + + +class FileType(Enum): + TEXT = "text" + IMAGE = "image" + EXEC = "executable" + + +class File(Model): + _primary_key_field: str = "path" + path: str + type: FileType + + +class Folder(Model): + _primary_key_field: str = "path" + path: str + files: List[File] = [] + folders: List["Folder"] = [] + + +if __name__ == "__main__": + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", + redis_config=RedisConfig(db=5, host="localhost", port=6379), + life_span_in_seconds=3600, + ) + + store.register_model(File) + store.register_model(Folder) + + child_folder = Folder( + path="path/to/child-folder", + files=[ + File(path="path/to/foo.txt", type=FileType.TEXT), + File(path="path/to/foo.jpg", type=FileType.IMAGE), + ], + ) + + Folder.insert( + Folder( + path="path/to/parent-folder", + files=[ + File(path="path/to/bar.txt", type=FileType.TEXT), + File(path="path/to/bar.jpg", type=FileType.IMAGE), + ], + folders=[child_folder], + ) + ) + + parent_folder_response = Folder.select(ids=["path/to/parent-folder"]) + files_response = File.select( + ids=["path/to/foo.txt", "path/to/foo.jpg", "path/to/bar.txt", "path/to/bar.jpg"] + ) + + Folder.update( + _id="path/to/child-folder", + data={"files": [File(path="path/to/foo.txt", type=FileType.EXEC)]}, + ) + updated_parent_folder_response = Folder.select(ids=["path/to/parent-folder"]) + updated_file_response = File.select(ids=["path/to/foo.txt"]) + + print("parent folder:") + pp.pprint(parent_folder_response) + print("\nfiles:") + pp.pprint(files_response) + + print("\nindirectly updated parent folder:") + pp.pprint(updated_parent_folder_response) + print("\nindirectly updated files:") + pp.pprint(updated_file_response) diff --git a/docs_src/tutorials/synchronous/models.py b/docs_src/tutorials/synchronous/models.py new file mode 100644 index 00000000..63f987d2 --- /dev/null +++ b/docs_src/tutorials/synchronous/models.py @@ -0,0 +1,40 @@ +import pprint +from datetime import date +from typing import List + +from pydantic_redis import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + rating: float + published_on: date + tags: List[str] = [] + in_stock: bool = True + + +if __name__ == "__main__": + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", + redis_config=RedisConfig(db=5, host="localhost", port=6379), + life_span_in_seconds=3600, + ) + + store.register_model(Book) + + Book.insert( + Book( + title="Oliver Twist", + author="Charles Dickens", + published_on=date(year=1215, month=4, day=4), + in_stock=False, + rating=2, + tags=["Classic"], + ) + ) + + response = Book.select(ids=["Oliver Twist"]) + pp.pprint(response) diff --git a/docs_src/tutorials/synchronous/nested-models.py b/docs_src/tutorials/synchronous/nested-models.py new file mode 100644 index 00000000..2327595c --- /dev/null +++ b/docs_src/tutorials/synchronous/nested-models.py @@ -0,0 +1,66 @@ +import pprint +from datetime import date +from typing import List, Tuple + +from pydantic_redis import Model, Store, RedisConfig + + +class Author(Model): + _primary_key_field: str = "name" + name: str + active_years: Tuple[int, int] + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: Author + rating: float + published_on: date + tags: List[str] = [] + in_stock: bool = True + + +if __name__ == "__main__": + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", + redis_config=RedisConfig(db=5, host="localhost", port=6379), + life_span_in_seconds=3600, + ) + + store.register_model(Author) + store.register_model(Book) + + Book.insert( + Book( + title="Oliver Twist", + author=Author(name="Charles Dickens", active_years=(1999, 2007)), + published_on=date(year=1215, month=4, day=4), + in_stock=False, + rating=2, + tags=["Classic"], + ) + ) + + book_response = Book.select(ids=["Oliver Twist"]) + author_response = Author.select(ids=["Charles Dickens"]) + + Author.update(_id="Charles Dickens", data={"active_years": (1227, 1277)}) + updated_book_response = Book.select(ids=["Oliver Twist"]) + + Book.update( + _id="Oliver Twist", + data={"author": Author(name="Charles Dickens", active_years=(1969, 1999))}, + ) + updated_author_response = Author.select(ids=["Charles Dickens"]) + + print("book:") + pp.pprint(book_response) + print("\nauthor:") + pp.pprint(author_response) + + print("\nindirectly updated book:") + pp.pprint(updated_book_response) + print("\nindirectly updated author:") + pp.pprint(updated_author_response) diff --git a/docs_src/tutorials/synchronous/select-records.py b/docs_src/tutorials/synchronous/select-records.py new file mode 100644 index 00000000..1f4bc5fc --- /dev/null +++ b/docs_src/tutorials/synchronous/select-records.py @@ -0,0 +1,52 @@ +import pprint +from pydantic_redis import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + + +if __name__ == "__main__": + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", redis_config=RedisConfig(), life_span_in_seconds=86400 + ) + + store.register_model(Book) + + Book.insert( + [ + Book(title="Oliver Twist", author="Charles Dickens"), + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + Book(title="Utah Blaine", author="Louis L'Amour"), + ] + ) + + select_all_response = Book.select() + select_by_id_response = Book.select(ids=["Oliver Twist", "Pride and Prejudice"]) + + select_some_fields_response = Book.select(columns=["author"]) + select_some_fields_for_ids_response = Book.select( + ids=["Oliver Twist", "Pride and Prejudice"], columns=["author"] + ) + + paginated_select_all_response = Book.select(skip=0, limit=2) + paginated_select_some_fields_response = Book.select( + columns=["author"], skip=2, limit=2 + ) + + print("all:") + pp.pprint(select_all_response) + print("\nby id:") + pp.pprint(select_by_id_response) + print("\nsome fields for all:") + pp.pprint(select_some_fields_response) + print("\nsome fields for given ids:") + pp.pprint(select_some_fields_for_ids_response) + print("\npaginated; skip: 0, limit: 2:") + pp.pprint(paginated_select_all_response) + print("\npaginated returning some fields for each; skip: 2, limit: 2:") + pp.pprint(paginated_select_some_fields_response) diff --git a/docs_src/tutorials/synchronous/tuple-of-nested-models.py b/docs_src/tutorials/synchronous/tuple-of-nested-models.py new file mode 100644 index 00000000..89f95313 --- /dev/null +++ b/docs_src/tutorials/synchronous/tuple-of-nested-models.py @@ -0,0 +1,59 @@ +import pprint +from typing import Tuple +from pydantic_redis import RedisConfig, Model, Store + + +class Score(Model): + _primary_key_field: str = "id" + id: str + total: int + + +class ScoreBoard(Model): + _primary_key_field: str = "id" + id: str + scores: Tuple[str, Score] + + +if __name__ == "__main__": + pp = pprint.PrettyPrinter(indent=4) + store = Store(name="test", redis_config=RedisConfig()) + + store.register_model(Score) + store.register_model(ScoreBoard) + + ScoreBoard.insert( + data=ScoreBoard( + id="test", + scores=( + "mark", + Score(id="some id", total=50), + ), + ) + ) + score_board_response = ScoreBoard.select(ids=["test"]) + scores_response = Score.select(ids=["some id"]) + + Score.update(_id="some id", data={"total": 78}) + updated_score_board_response = ScoreBoard.select(ids=["test"]) + + ScoreBoard.update( + _id="test", + data={ + "scores": ( + "tom", + Score(id="some id", total=60), + ) + }, + ) + updated_score_response = Score.select(ids=["some id"]) + + print("score board:") + pp.pprint(score_board_response) + print("\nscores:") + pp.pprint(scores_response) + + print("\nindirectly updated score board:") + pp.pprint(updated_score_board_response) + print("\nindirectly updated score:") + pp.pprint(updated_score_response) diff --git a/docs_src/tutorials/synchronous/update.py b/docs_src/tutorials/synchronous/update.py new file mode 100644 index 00000000..ff310e17 --- /dev/null +++ b/docs_src/tutorials/synchronous/update.py @@ -0,0 +1,46 @@ +import pprint +from pydantic_redis import Model, Store, RedisConfig + + +class Book(Model): + _primary_key_field: str = "title" + title: str + author: str + + +if __name__ == "__main__": + pp = pprint.PrettyPrinter(indent=4) + store = Store( + name="some_name", redis_config=RedisConfig(), life_span_in_seconds=86400 + ) + + store.register_model(Book) + + Book.insert( + [ + Book(title="Oliver Twist", author="Charles Dickens"), + Book(title="Jane Eyre", author="Emily Bronte"), + Book(title="Pride and Prejudice", author="Jane Austen"), + ] + ) + Book.update(_id="Oliver Twist", data={"author": "Charlie Ickens"}) + Book.update( + _id="Jane Eyre", data={"author": "Daniel McKenzie"}, life_span_seconds=1800 + ) + single_update_response = Book.select() + + Book.insert( + [ + Book(title="Oliver Twist", author="Chuck Dickens"), + Book(title="Jane Eyre", author="Emiliano Bronte"), + Book(title="Pride and Prejudice", author="Janey Austen"), + ], + life_span_seconds=3600, + ) + multi_update_response = Book.select() + + print("single update:") + pp.pprint(single_update_response) + + print("\nmulti update:") + pp.pprint(multi_update_response) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..3f856184 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,71 @@ +site_name: Pydantic-redis +site_description: Pydantic-redis, simple declarative ORM for redis +site_url: https://sopherapps.github.io/pydantic-redis/ + +theme: + name: material + palette: + - media: '(prefers-color-scheme: light)' + scheme: default + toggle: + icon: material/lightbulb + name: Switch to light mode + - media: '(prefers-color-scheme: dark)' + scheme: slate + toggle: + icon: material/lightbulb-outline + name: Switch to dark mode + features: + - search.suggest + - search.highlight + - content.tabs.link + +plugins: + - search + - mkdocstrings + +repo_name: sopherapps/pydantic-redis +repo_url: https://github.com/sopherapps/pydantic-redis + +nav: + - 'Pydantic-redis': index.md + - Tutorials: + - tutorials/intro.md + - Synchronous API: + - tutorials/synchronous/models.md + - tutorials/synchronous/insert.md + - tutorials/synchronous/update.md + - tutorials/synchronous/delete.md + - tutorials/synchronous/select.md + - tutorials/synchronous/nested-models.md + - tutorials/synchronous/list-of-nested-models.md + - tutorials/synchronous/tuple-of-nested-models.md + - Asynchronous API: + - tutorials/asynchronous/models.md + - tutorials/asynchronous/insert.md + - tutorials/asynchronous/update.md + - tutorials/asynchronous/delete.md + - tutorials/asynchronous/select.md + - tutorials/asynchronous/nested-models.md + - tutorials/asynchronous/list-of-nested-models.md + - tutorials/asynchronous/tuple-of-nested-models.md + - 'Explanation': + - explanation/why-use-orms.md + - reference.md + - change-log.md + +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - mdx_include: + base_path: docs +extra_css: + - css/termynal.css + - css/custom.css +extra_javascript: + - js/termynal.js + - js/custom.js \ No newline at end of file diff --git a/pydantic_redis/__init__.py b/pydantic_redis/__init__.py index 8873263a..0d09af08 100644 --- a/pydantic_redis/__init__.py +++ b/pydantic_redis/__init__.py @@ -1,4 +1,15 @@ -"""Entry point for redisy""" +"""A simple declarative ORM for redis based on pydantic. + +Provides: + +1. A subclass-able `Model` class to create Object Relational Mapping to +redis hashes +2. A redis `Store` class to mutate and query `Model`'s registered in it +3. A `RedisConfig` class to pass to the `Store` constructor to connect +to a redis instance +4. A synchronous `syncio` and an asynchronous `asyncio` interface to the +above classes +""" from pydantic_redis.syncio import Store, Model, RedisConfig import pydantic_redis.asyncio diff --git a/pydantic_redis/_shared/__init__.py b/pydantic_redis/_shared/__init__.py new file mode 100644 index 00000000..cfecf191 --- /dev/null +++ b/pydantic_redis/_shared/__init__.py @@ -0,0 +1,7 @@ +"""Exposes shared utilities and base classes for pydantic-redis + +This includes basic functionality of mutating and querying +redis via the pydantic-redis ORM regardless of whether this +is done asynchronously or synchronously. +This is a private package. +""" diff --git a/pydantic_redis/shared/lua_scripts.py b/pydantic_redis/_shared/lua_scripts.py similarity index 89% rename from pydantic_redis/shared/lua_scripts.py rename to pydantic_redis/_shared/lua_scripts.py index 8221e501..433c8646 100644 --- a/pydantic_redis/shared/lua_scripts.py +++ b/pydantic_redis/_shared/lua_scripts.py @@ -1,4 +1,18 @@ -"""Module containing the lua scripts used in select queries""" +"""Exposes the redis lua scripts to be used in select queries. + +Attributes: + SELECT_ALL_FIELDS_FOR_ALL_IDS_SCRIPT: the script for selecting all records from redis + PAGINATED_SELECT_ALL_FIELDS_FOR_ALL_IDS_SCRIPT: the script for selecting a slice of all records from redis, + given a `limit` maximum number of records to return and a `skip` number of records to skip. + SELECT_ALL_FIELDS_FOR_SOME_IDS_SCRIPT: the script for selecting some records from redis, given a bunch of `ids` + SELECT_SOME_FIELDS_FOR_ALL_IDS_SCRIPT: the script for selecting all records, but returning only a subset of + the fields in each record. + PAGINATED_SELECT_SOME_FIELDS_FOR_ALL_IDS_SCRIPT: the script for selecting a slice of all records from redis, + given a `limit` maximum number of records to return and a `skip` number of records to skip, but returning + only a subset of the fields in each record. + SELECT_SOME_FIELDS_FOR_SOME_IDS_SCRIPT: the script for selecting some records from redis, given a bunch of `ids`, + but returning only a subset of the fields in each record. +""" SELECT_ALL_FIELDS_FOR_ALL_IDS_SCRIPT = """ local s_find = string.find diff --git a/pydantic_redis/_shared/model/__init__.py b/pydantic_redis/_shared/model/__init__.py new file mode 100644 index 00000000..c6d48444 --- /dev/null +++ b/pydantic_redis/_shared/model/__init__.py @@ -0,0 +1,8 @@ +"""Exposes the utilities and the base classes for models. + +This includes basic functionality of mutating and querying +redis via the pydantic-redis ORM regardless of whether this +is done asynchronously or synchronously. +""" + +from .base import AbstractModel diff --git a/pydantic_redis/_shared/model/base.py b/pydantic_redis/_shared/model/base.py new file mode 100644 index 00000000..95f3c3f9 --- /dev/null +++ b/pydantic_redis/_shared/model/base.py @@ -0,0 +1,258 @@ +"""Exposes the Base `Model` common to both async and sync APIs + +""" +import typing +from typing import Dict, Tuple, Any, Type, Union, List, Optional + +from pydantic import BaseModel + +from pydantic_redis._shared.utils import ( + typing_get_origin, + typing_get_args, + from_any_to_valid_redis_type, + from_dict_to_key_value_list, + from_bytes_to_str, + from_str_or_bytes_to_any, +) + + +from ..store import AbstractStore + + +class AbstractModel(BaseModel): + """A base class for all Models, sync and async alike. + + See the child classes for more. + + Attributes: + _primary_key_field (str): the field that can uniquely identify each record + for the current Model + _field_types (Dict[str, Any]): a mapping of the fields and their types for + the current model + _store (AbstractStore): the Store in which the current model is registered. + _nested_model_tuple_fields (Dict[str, Tuple[Any, ...]]): a mapping of + fields and their types for fields that have tuples of nested models + _nested_model_list_fields (Dict[str, Type["AbstractModel"]]): a mapping of + fields and their associated nested models for fields that have + lists of nested models + _nested_model_fields (Dict[str, Type["AbstractModel"]]): a mapping of + fields and their associated nested models for fields that have nested models + """ + + _primary_key_field: str + _field_types: Dict[str, Any] = {} + _store: AbstractStore + _nested_model_tuple_fields: Dict[str, Tuple[Any, ...]] = {} + _nested_model_list_fields: Dict[str, Type["AbstractModel"]] = {} + _nested_model_fields: Dict[str, Type["AbstractModel"]] = {} + + class Config: + arbitrary_types_allowed = True + + @classmethod + def get_store(cls) -> AbstractStore: + """Gets the Store in which the current model is registered. + + Returns: + the instance of the store for this model + """ + return cls._store + + @classmethod + def get_nested_model_tuple_fields(cls): + """Gets the mapping for fields that have tuples of nested models. + + Returns: + The mapping of field name and field type of a form similar to + `Tuple[str, Book, date]` + """ + return cls._nested_model_tuple_fields + + @classmethod + def get_nested_model_list_fields(cls): + """Gets the mapping for fields that have lists of nested models. + + Returns: + The mapping of field name and model class nested in that field. + """ + return cls._nested_model_list_fields + + @classmethod + def get_nested_model_fields(cls): + """Gets the mapping for fields that have nested models. + + Returns: + The mapping of field name and model class nested in that field. + """ + return cls._nested_model_fields + + @classmethod + def get_primary_key_field(cls): + """Gets the field that can uniquely identify each record of current Model + + Returns: + the field that can be used to uniquely identify each record of current Model + """ + return cls._primary_key_field + + @classmethod + def get_field_types(cls) -> Dict[str, Any]: + """Gets the mapping of field and field_type for current Model. + + Returns: + the mapping of field and field_type for current Model + """ + return cls._field_types + + @classmethod + def initialize(cls): + """Initializes class-wide variables for performance's reasons. + + This is a performance hack that initializes an variables that are common + to all instances of the current Model e.g. the field types. + """ + cls._field_types = typing.get_type_hints(cls) + + cls._nested_model_list_fields = {} + cls._nested_model_tuple_fields = {} + cls._nested_model_fields = {} + + for field, field_type in cls._field_types.items(): + try: + # In case the annotation is Optional, an alias of Union[X, None], extract the X + is_generic = hasattr(field_type, "__origin__") + if ( + is_generic + and typing_get_origin(field_type) == Union + and typing_get_args(field_type)[-1] == None.__class__ + ): + field_type = typing_get_args(field_type)[0] + is_generic = hasattr(field_type, "__origin__") + + if ( + is_generic + and typing_get_origin(field_type) in (List, list) + and issubclass(typing_get_args(field_type)[0], AbstractModel) + ): + cls._nested_model_list_fields[field] = typing_get_args(field_type)[ + 0 + ] + + elif ( + is_generic + and typing_get_origin(field_type) in (Tuple, tuple) + and any( + [ + issubclass(v, AbstractModel) + for v in typing_get_args(field_type) + ] + ) + ): + cls._nested_model_tuple_fields[field] = typing_get_args(field_type) + + elif issubclass(field_type, AbstractModel): + cls._nested_model_fields[field] = field_type + + except (TypeError, AttributeError): + pass + + @classmethod + def serialize_partially(cls, data: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Casts complex data types within a given dictionary to valid redis types. + + Args: + data: the dictionary containing data with complex data types + + Returns: + the transformed dictionary + """ + return {key: from_any_to_valid_redis_type(value) for key, value in data.items()} + + @classmethod + def deserialize_partially( + cls, data: Union[List[Any], Dict[Any, Any]] = () + ) -> Dict[str, Any]: + """Casts str or bytes in a dict or flattened key-value list to expected data types. + + Converts str or bytes to their expected data types + + Args: + data: flattened list of key-values or dictionary of data to cast. + Keeping it as potentially a dictionary ensures backward compatibility. + + Returns: + the dictionary of properly parsed key-values. + """ + if isinstance(data, dict): + # for backward compatibility + data = from_dict_to_key_value_list(data) + + parsed_dict = {} + + nested_model_list_fields = cls.get_nested_model_list_fields() + nested_model_tuple_fields = cls.get_nested_model_tuple_fields() + nested_model_fields = cls.get_nested_model_fields() + + for i in range(0, len(data), 2): + key = from_bytes_to_str(data[i]) + field_type = cls._field_types.get(key) + value = from_str_or_bytes_to_any(value=data[i + 1], field_type=field_type) + + if key in nested_model_list_fields and value is not None: + value = _cast_lists(value, nested_model_list_fields[key]) + + elif key in nested_model_tuple_fields and value is not None: + value = _cast_tuples(value, nested_model_tuple_fields[key]) + + elif key in nested_model_fields and value is not None: + value = _cast_to_model(value=value, model=nested_model_fields[key]) + + parsed_dict[key] = value + + return parsed_dict + + +def _cast_lists(value: List[Any], _type: Type[AbstractModel]) -> List[AbstractModel]: + """Casts a list of flattened key-value lists into a list of _type. + + Args: + _type: the type to cast the records to. + value: the value to convert + + Returns: + a list of records of the given _type + """ + return [_type(**_type.deserialize_partially(item)) for item in value] + + +def _cast_tuples(value: List[Any], _type: Tuple[Any, ...]) -> Tuple[Any, ...]: + """Casts a list of flattened key-value lists into a list of tuple of _type,. + + Args: + _type: the tuple signature type to cast the records to + e.g. Tuple[str, Book, int] + value: the value to convert + + Returns: + a list of records of tuple signature specified by `_type` + """ + items = [] + for field_type, value in zip(_type, value): + if issubclass(field_type, AbstractModel) and value is not None: + value = field_type(**field_type.deserialize_partially(value)) + items.append(value) + + return tuple(items) + + +def _cast_to_model(value: List[Any], model: Type[AbstractModel]) -> AbstractModel: + """Converts a list of flattened key-value lists into a list of models,. + + Args: + model: the model class to cast to + value: the value to cast + + Returns: + a list of model instances of type `model` + """ + return model(**model.deserialize_partially(value)) diff --git a/pydantic_redis/_shared/model/delete_utils.py b/pydantic_redis/_shared/model/delete_utils.py new file mode 100644 index 00000000..c3905590 --- /dev/null +++ b/pydantic_redis/_shared/model/delete_utils.py @@ -0,0 +1,38 @@ +"""Exposes shared utilities for deleting records from redis""" +from typing import Type, Union, List + +from redis.client import Pipeline +from redis.asyncio.client import Pipeline as AioPipeline + +from pydantic_redis._shared.model import AbstractModel +from pydantic_redis._shared.model.prop_utils import get_redis_key, get_model_index_key + + +def delete_on_pipeline( + model: Type[AbstractModel], pipeline: Union[Pipeline, AioPipeline], ids: List[str] +): + """Adds delete operations for the given ids to the redis pipeline. + + Args: + model: the Model from which the given records are to be deleted. + pipeline: the Redis pipeline on which the delete operations are + to be added. + ids: the list of primary keys of the records that are to be removed. + + Later when pipeline.execute is called later, the actual deletion occurs + """ + primary_keys = [] + + if isinstance(ids, list): + primary_keys = ids + elif ids is not None: + primary_keys = [ids] + + names = [ + get_redis_key(model=model, primary_key_value=primary_key_value) + for primary_key_value in primary_keys + ] + pipeline.delete(*names) + # remove the primary keys from the indexz + table_index_key = get_model_index_key(model=model) + pipeline.zrem(table_index_key, *names) diff --git a/pydantic_redis/shared/model/insert_utils.py b/pydantic_redis/_shared/model/insert_utils.py similarity index 60% rename from pydantic_redis/shared/model/insert_utils.py rename to pydantic_redis/_shared/model/insert_utils.py index 2492d6db..ef45f767 100644 --- a/pydantic_redis/shared/model/insert_utils.py +++ b/pydantic_redis/_shared/model/insert_utils.py @@ -1,4 +1,6 @@ -"""Module containing the mixin for insert functionality in model""" +"""Exposes the utility functions for inserting records into redis. + +""" from datetime import datetime from typing import Union, Optional, Any, Dict, Tuple, List, Type @@ -6,8 +8,8 @@ from redis.client import Pipeline from .prop_utils import ( - get_primary_key, - get_table_index_key, + get_redis_key, + get_model_index_key, NESTED_MODEL_PREFIX, NESTED_MODEL_LIST_FIELD_PREFIX, NESTED_MODEL_TUPLE_FIELD_PREFIX, @@ -23,23 +25,37 @@ def insert_on_pipeline( record: Union[AbstractModel, Dict[str, Any]], life_span: Optional[Union[float, int]] = None, ) -> Any: - """ - Creates insert commands for the given record on the given pipeline but does not execute - thus the data is not yet persisted in redis - Returns the key of the created item + """Add an insert operation to the redis pipeline. + + Later when the pipeline.execute is called, the actual inserts occur. + This reduces the number of network requests to the redis server thus + improving performance. + + Args: + model: the Model whose records are to be inserted into redis. + pipeline: the Redis pipeline on which the insert operation + is to be added. + _id: the primary key of the record to be inserted in redis. + It is None when inserting, and some value when updating. + record: the model instance or dictionary to be inserted into redis. + life_span: the time-to-live in seconds for the record to be inserted. + (default: None) + + Returns: + the primary key of the record that is to be inserted. """ key = _id if _id is not None else getattr(record, model.get_primary_key_field()) - data = _get_serializable_dict( + data = _serialize_nested_models( model=model, pipeline=pipeline, record=record, life_span=life_span ) - name = get_primary_key(model=model, primary_key_value=key) + name = get_redis_key(model=model, primary_key_value=key) mapping = model.serialize_partially(data) pipeline.hset(name=name, mapping=mapping) if life_span is not None: pipeline.expire(name=name, time=life_span) # save the primary key in an index: a sorted set, whose score is current timestamp - table_index_key = get_table_index_key(model) + table_index_key = get_model_index_key(model) timestamp = datetime.utcnow().timestamp() pipeline.zadd(table_index_key, {name: timestamp}) if life_span is not None: @@ -48,23 +64,36 @@ def insert_on_pipeline( return name -def _get_serializable_dict( +def _serialize_nested_models( model: Type[AbstractModel], pipeline: Union[Pipeline, AioPipeline], record: Union[AbstractModel, Dict[str, Any]], life_span: Optional[Union[float, int]] = None, ) -> Dict[str, Any]: - """ - Returns a dictionary that can be serialized. + """Converts nested models into their primary keys. + + In order to make the record serializable, all nested models including those in + lists and tuples of nested models are converted to their primary keys, + after being their insert operations have been added to the pipeline. + A few cleanups it does include: - Upserting any nested records in `record` - - Replacing the keys of nested records with their NESTED_MODEL_PREFIX suffixed versions + - Replacing the keys of nested records with their NESTED_MODEL_PREFIX prefixed versions e.g. `__author` instead of author - - Replacing the keys of lists of nested records with their NESTED_MODEL_LIST_FIELD_PREFIX suffixed versions + - Replacing the keys of lists of nested records with their NESTED_MODEL_LIST_FIELD_PREFIX prefixed versions e.g. `__%&l_author` instead of author - - Replacing the keys of tuples of nested records with their NESTED_MODEL_TUPLE_FIELD_PREFIX suffixed versions + - Replacing the keys of tuples of nested records with their NESTED_MODEL_TUPLE_FIELD_PREFIX prefixed versions e.g. `__%&l_author` instead of author - Replacing the values of nested records with their foreign keys + + Args: + model: the model the given record belongs to. + pipeline: the redis pipeline on which the redis operations are to be done. + record: the model or dictionary whose nested models are to be serialized. + life_span: the time-to-live in seconds for the given record (default: None). + + Returns: + the partially serialized dict that has no nested models """ data = record.items() if isinstance(record, dict) else record new_data = {} @@ -77,11 +106,11 @@ def _get_serializable_dict( key, value = k, v if key in nested_model_list_fields: - key, value = _serialize_nested_model_list_field( + key, value = _serialize_list( key=key, value=value, pipeline=pipeline, life_span=life_span ) elif key in nested_model_tuple_fields: - key, value = _serialize_nested_model_tuple_field( + key, value = _serialize_tuple( key=key, value=value, pipeline=pipeline, @@ -89,7 +118,7 @@ def _get_serializable_dict( tuple_fields=nested_model_tuple_fields, ) elif key in nested_model_fields: - key, value = _serialize_nested_model_field( + key, value = _serialize_model( key=key, value=value, pipeline=pipeline, life_span=life_span ) @@ -97,14 +126,22 @@ def _get_serializable_dict( return new_data -def _serialize_nested_model_tuple_field( +def _serialize_tuple( key: str, value: Tuple[AbstractModel], pipeline: Union[Pipeline, AioPipeline], life_span: Optional[Union[float, int]], tuple_fields: Dict[str, Tuple[Any, ...]], ) -> Tuple[str, List[Any]]: - """Serializes a key-value pair for a field that has a tuple of nested models""" + """Replaces models in a tuple with strings. + + It adds insert operations for the records in the tuple onto the pipeline + and returns the tuple with the models replaced by their primary keys as value. + + Returns: + key: the original `key` prefixed with NESTED_MODEL_TUPLE_FIELD_PREFIX + value: tthe tuple with the models replaced by their primary keys + """ try: field_types = tuple_fields.get(key, ()) value = [ @@ -127,13 +164,21 @@ def _serialize_nested_model_tuple_field( return key, value -def _serialize_nested_model_list_field( +def _serialize_list( key: str, value: List[AbstractModel], pipeline: Union[Pipeline, AioPipeline], life_span: Optional[Union[float, int]], ) -> Tuple[str, List[Any]]: - """Serializes a key-value pair for a field that has a list of nested models""" + """Casts a list of models into a list of strings + + It adds insert operations for the records in the list onto the pipeline + and returns a list of their primary keys as value. + + Returns: + key: the original `key` prefixed with NESTED_MODEL_LIST_FIELD_PREFIX + value: the list of primary keys of the records to be inserted + """ try: value = [ insert_on_pipeline( @@ -153,13 +198,21 @@ def _serialize_nested_model_list_field( return key, value -def _serialize_nested_model_field( +def _serialize_model( key: str, value: AbstractModel, pipeline: Union[Pipeline, AioPipeline], life_span: Optional[Union[float, int]], ) -> Tuple[str, str]: - """Serializes a key-value pair for a field that has a nested model""" + """Casts a model into a string + + It adds an insert operation for the given model onto the pipeline + and returns its primary key as value. + + Returns: + key: the original `key` prefixed with NESTED_MODEL_PREFIX + value: the primary key of the model + """ try: value = insert_on_pipeline( model=value.__class__, diff --git a/pydantic_redis/_shared/model/prop_utils.py b/pydantic_redis/_shared/model/prop_utils.py new file mode 100644 index 00000000..9256a247 --- /dev/null +++ b/pydantic_redis/_shared/model/prop_utils.py @@ -0,0 +1,69 @@ +"""Exposes utils for getting properties of the Model + +Attributes: + NESTED_MODEL_PREFIX (str): the prefix for fields with single nested models + NESTED_MODEL_LIST_FIELD_PREFIX (str): the prefix for fields with lists of nested models + NESTED_MODEL_TUPLE_FIELD_PREFIX (str): the prefix for fields with tuples of nested models +""" + +from typing import Type, Any + +from .base import AbstractModel + +NESTED_MODEL_PREFIX = "__" +NESTED_MODEL_LIST_FIELD_PREFIX = "___" +NESTED_MODEL_TUPLE_FIELD_PREFIX = "____" + + +def get_redis_key(model: Type[AbstractModel], primary_key_value: Any): + """Gets the key used internally in redis for the `primary_key_value` of `model`. + + Args: + model: the model for which the key is to be generated + primary_key_value: the external facing primary key value + + Returns: + the primary key internally used for `primary_key_value` of `model` + """ + return f"{get_redis_key_prefix(model)}{primary_key_value}" + + +def get_redis_key_prefix(model: Type[AbstractModel]): + """Gets the prefix for keys used internally in redis for records of `model`. + + Args: + model: the model for which the redis key prefix is to be generated + + Returns: + the prefix of the all the redis keys that are associated with this model + """ + model_name = model.__name__.lower() + return f"{model_name}_%&_" + + +def get_redis_keys_regex(model: Type[AbstractModel]): + """Gets the regex for all keys of records of `model` used internally in redis. + + Args: + model: the model for which the redis key regex is to be generated + + Returns: + the regular expression for all keys of records of `model` used internally in redis + """ + return f"{get_redis_key_prefix(model)}*" + + +def get_model_index_key(model: Type[AbstractModel]): + """Gets the key for the index set of `model` used internally in redis. + + The index for each given model stores the primary keys for all records + that belong to the given model. + + Args: + model: the model whose index is wanted. + + Returns: + the the key for the index set of `model` used internally in redis. + """ + table_name = model.__name__.lower() + return f"{table_name}__index" diff --git a/pydantic_redis/_shared/model/select_utils.py b/pydantic_redis/_shared/model/select_utils.py new file mode 100644 index 00000000..a1f5e77f --- /dev/null +++ b/pydantic_redis/_shared/model/select_utils.py @@ -0,0 +1,213 @@ +"""Exposes utilities for selecting records from redis using lua scripts. + +""" +from typing import List, Any, Type, Union, Awaitable, Optional + +from pydantic_redis._shared.model.prop_utils import ( + NESTED_MODEL_PREFIX, + NESTED_MODEL_LIST_FIELD_PREFIX, + NESTED_MODEL_TUPLE_FIELD_PREFIX, + get_redis_keys_regex, + get_redis_key_prefix, + get_model_index_key, +) + + +from .base import AbstractModel + + +def get_select_fields(model: Type[AbstractModel], columns: List[str]) -> List[str]: + """Gets the fields to be used for selecting HMAP fields in Redis. + + It replaces any fields in `columns` that correspond to nested records with their + `__` prefixed versions. + + Args: + model: the model for which the fields for selecting are to be derived. + columns: the fields that are to be transformed into fields for selecting. + + Returns: + the fields for selecting, with nested fields being given appropriate prefixes. + """ + fields = [] + nested_model_list_fields = model.get_nested_model_list_fields() + nested_model_tuple_fields = model.get_nested_model_tuple_fields() + nested_model_fields = model.get_nested_model_fields() + + for col in columns: + + if col in nested_model_fields: + col = f"{NESTED_MODEL_PREFIX}{col}" + elif col in nested_model_list_fields: + col = f"{NESTED_MODEL_LIST_FIELD_PREFIX}{col}" + elif col in nested_model_tuple_fields: + col = f"{NESTED_MODEL_TUPLE_FIELD_PREFIX}{col}" + + fields.append(col) + return fields + + +def select_all_fields_all_ids( + model: Type[AbstractModel], + skip: int = 0, + limit: Optional[int] = None, +) -> Union[List[List[Any]], Awaitable[List[List[Any]]]]: + """Retrieves all records of the given model in the redis database. + + Args: + model: the Model whose records are to be retrieved. + skip: the number of records to skip. + limit: the maximum number of records to return. If None, limit is infinity. + + Returns: + the list of records from redis, each record being a flattened list of key-values. + In case we are using async, an Awaitable of that list is returned instead. + """ + if isinstance(limit, int): + return _select_all_ids_all_fields_paginated(model=model, limit=limit, skip=skip) + else: + table_keys_regex = get_redis_keys_regex(model=model) + args = [table_keys_regex] + store = model.get_store() + return store.select_all_fields_for_all_ids_script(args=args) + + +def select_all_fields_some_ids( + model: Type[AbstractModel], ids: List[str] +) -> Union[List[List[Any]], Awaitable[List[List[Any]]]]: + """Retrieves some records from redis. + + Args: + model: the Model whose records are to be retrieved. + ids: the list of primary keys of the records to be retrieved. + + Returns: + the list of records where each record is a flattened key-value list. + In case we are using async, an Awaitable of that list is returned instead. + """ + table_prefix = get_redis_key_prefix(model=model) + keys = [f"{table_prefix}{key}" for key in ids] + store = model.get_store() + return store.select_all_fields_for_some_ids_script(keys=keys) + + +def select_some_fields_all_ids( + model: Type[AbstractModel], + fields: List[str], + skip: int = 0, + limit: Optional[int] = None, +) -> Union[List[List[Any]], Awaitable[List[List[Any]]]]: + """Retrieves records of model from redis, each as with a subset of the fields. + + Args: + model: the Model whose records are to be retrieved. + fields: the subset of fields to return for each record. + skip: the number of records to skip. + limit: the maximum number of records to return. If None, limit is infinity. + + Returns: + the list of records from redis, each record being a flattened list of key-values. + In case we are using async, an Awaitable of that list is returned instead. + """ + columns = get_select_fields(model=model, columns=fields) + + if isinstance(limit, int): + return _select_some_fields_all_ids_paginated( + model=model, columns=columns, limit=limit, skip=skip + ) + else: + table_keys_regex = get_redis_keys_regex(model=model) + args = [table_keys_regex, *columns] + store = model.get_store() + return store.select_some_fields_for_all_ids_script(args=args) + + +def select_some_fields_some_ids( + model: Type[AbstractModel], fields: List[str], ids: List[str] +) -> Union[List[List[Any]], Awaitable[List[List[Any]]]]: + """Retrieves some records of current model from redis, each as with a subset of the fields. + + Args: + model: the Model whose records are to be retrieved. + fields: the subset of fields to return for each record. + ids: the list of primary keys of the records to be retrieved. + + Returns: + the list of records from redis, each record being a flattened list of key-values. + In case we are using async, an Awaitable of that list is returned instead. + """ + table_prefix = get_redis_key_prefix(model=model) + keys = [f"{table_prefix}{key}" for key in ids] + columns = get_select_fields(model=model, columns=fields) + store = model.get_store() + return store.select_some_fields_for_some_ids_script(keys=keys, args=columns) + + +def parse_select_response( + model: Type[AbstractModel], response: List[List], as_models: bool +): + """Casts a list of flattened key-value lists into a list of models or dicts. + + It replaces any foreign keys with the related model instances, + and converts the list of flattened key-value lists into a list of models or dicts. + e.g. [["foo", "bar", "head", 9]] => [{"foo": "bar", "head": 9}] + + Returns: + If `as_models` is true, list of models else list of dicts + """ + if len(response) == 0: + return None + + if as_models: + return [ + model(**model.deserialize_partially(record)) + for record in response + if record != [] + ] + + return [model.deserialize_partially(record) for record in response if record != []] + + +def _select_all_ids_all_fields_paginated( + model: Type[AbstractModel], limit: int, skip: Optional[int] +): + """Retrieves a slice of all records of the given model in the redis database. + + Args: + model: the Model whose records are to be retrieved. + skip: the number of records to skip. + limit: the maximum number of records to return. If None, limit is infinity. + + Returns: + the list of records from redis, each record being a flattened list of key-values. + In case we are using async, an Awaitable of that list is returned instead. + """ + if skip is None: + skip = 0 + table_index_key = get_model_index_key(model) + args = [table_index_key, skip, limit] + store = model.get_store() + return store.paginated_select_all_fields_for_all_ids_script(args=args) + + +def _select_some_fields_all_ids_paginated( + model: Type[AbstractModel], columns: List[str], limit: int, skip: int +): + """Retrieves a slice of all records of model from redis, each as with a subset of the fields. + + Args: + model: the Model whose records are to be retrieved. + columns: the subset of fields to return for each record. + skip: the number of records to skip. + limit: the maximum number of records to return. If None, limit is infinity. + + Returns: + the list of records from redis, each record being a flattened list of key-values. + In case we are using async, an Awaitable of that list is returned instead. + """ + if skip is None: + skip = 0 + table_index_key = get_model_index_key(model) + args = [table_index_key, skip, limit, *columns] + store = model.get_store() + return store.paginated_select_some_fields_for_all_ids_script(args=args) diff --git a/pydantic_redis/shared/store.py b/pydantic_redis/_shared/store.py similarity index 74% rename from pydantic_redis/shared/store.py rename to pydantic_redis/_shared/store.py index 65e10c68..08dba776 100644 --- a/pydantic_redis/shared/store.py +++ b/pydantic_redis/_shared/store.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from redis.commands.core import Script, AsyncScript -from .config import RedisConfig +from ..config import RedisConfig from .lua_scripts import ( SELECT_ALL_FIELDS_FOR_ALL_IDS_SCRIPT, SELECT_ALL_FIELDS_FOR_SOME_IDS_SCRIPT, @@ -23,8 +23,9 @@ class AbstractStore(BaseModel): - """ - An abstract class of a store + """An abstract class of a store. + + Check the child classes for more definition. """ name: str @@ -67,11 +68,24 @@ def __init__( self._register_lua_scripts() def _connect_to_redis(self) -> Union[Redis, AioRedis]: - """Connects the store to redis, returning a proper connection""" + """Connects the store to redis. + + Connects to the redis database basing on the `redis_config` + attribute of this instance. + + Returns: + A connection object to a redis database + """ raise NotImplementedError("implement _connect_to_redis first") def _register_lua_scripts(self): - """Registers the lua scripts for this redis instance""" + """Registers the lua scripts for this redis instance. + + In order to save on memory and bandwidth, the redis lua scripts + need to be called using EVALSHA instead of EVAL. The latter transfers + the scripts to the redis server on every invocation while the former + saves the script in redis itself and invokes it using a hashed (SHA) value. + """ self.select_all_fields_for_all_ids_script = self.redis_store.register_script( SELECT_ALL_FIELDS_FOR_ALL_IDS_SCRIPT ) @@ -96,7 +110,15 @@ def _register_lua_scripts(self): ) def register_model(self, model_class: Type["AbstractModel"]): - """Registers the model to this store""" + """Registers the model to this store. + + Each store manages a number of models. In order to associate + a model to a redis database, a Store must register it. + + Args: + model_class: the class which represents a given schema of + a certain type of records to be saved in redis. + """ if not isinstance(model_class.get_primary_key_field(), str): raise NotImplementedError( f"{model_class.__name__} should have a _primary_key_field" @@ -107,5 +129,12 @@ def register_model(self, model_class: Type["AbstractModel"]): self.models[model_class.__name__.lower()] = model_class def model(self, name: str) -> Type["AbstractModel"]: - """Gets a model by name: case insensitive""" + """Gets a model by name. This is case insensitive. + + Args: + name: the case-insensitive name of the model class + + Returns: + the class corresponding to the given name + """ return self.models[name.lower()] diff --git a/pydantic_redis/_shared/utils.py b/pydantic_redis/_shared/utils.py new file mode 100644 index 00000000..5ecc2130 --- /dev/null +++ b/pydantic_redis/_shared/utils.py @@ -0,0 +1,143 @@ +"""Exposes common utilities. + +""" +import typing +from typing import Any, Tuple, Optional, Union, Dict, Callable, Type, List + +import orjson + + +def strip_leading(word: str, substring: str) -> str: + """Strips the leading substring if it exists. + + This is contrary to rstrip which removes each character in the substring + + Args: + word: the string to strip from + substring: the string to be stripped from the word. + + Returns: + the stripped word + """ + if word.startswith(substring): + return word[len(substring) :] + return word + + +def typing_get_args(v: Any) -> Tuple[Any, ...]: + """Gets the __args__ of the annotations of a given typing. + + Args: + v: the typing object whose __args__ are required. + + Returns: + the __args__ of the item passed + """ + try: + return typing.get_args(v) + except AttributeError: + return getattr(v, "__args__", ()) if v is not typing.Generic else typing.Generic + + +def typing_get_origin(v: Any) -> Optional[Any]: + """Gets the __origin__ of the annotations of a given typing. + + Args: + v: the typing object whose __origin__ are required. + + Returns: + the __origin__ of the item passed + """ + try: + return typing.get_origin(v) + except AttributeError: + return getattr(v, "__origin__", None) + + +def from_bytes_to_str(value: Union[str, bytes]) -> str: + """Converts bytes to str. + + Args: + value: the potentially bytes object to transform. + + Returns: + the string value of the argument passed + """ + if isinstance(value, bytes): + return str(value, "utf-8") + return value + + +def from_str_or_bytes_to_any(value: Any, field_type: Type) -> Any: + """Converts str or bytes to arbitrary data. + + Converts the the `value` from a string or bytes to the `field_type`. + + Args: + value: the string or bytes to be transformed to the `field_type` + field_type: the type to which value is to be converted + + Returns: + the `field_type` version of the `value`. + """ + if isinstance(value, (bytes, bytearray, memoryview)): + return orjson.loads(value) + elif isinstance(value, str) and field_type != str: + return orjson.loads(value) + return value + + +def from_any_to_valid_redis_type(value: Any) -> Union[str, bytes, List[Any]]: + """Converts arbitrary data into valid redis types + + Converts the the `value` from any type to a type that + are acceptable by redis. + By default, complex data is transformed to bytes. + + Args: + value: the value to be transformed to a valid redis data type + + Returns: + the transformed version of the `value`. + """ + if isinstance(value, str): + return value + elif isinstance(value, set): + return list(value) + return orjson.dumps(value, default=default_json_dump) + + +def default_json_dump(obj: Any): + """Serializes objects orjson cannot serialize. + + Args: + obj: the object to serialize + + Returns: + the bytes or string value of the object + """ + if hasattr(obj, "json") and isinstance(obj.json, Callable): + return obj.json() + return obj + + +def from_dict_to_key_value_list(data: Dict[str, Any]) -> List[Any]: + """Converts dict to flattened list of key, values. + + {"foo": "bar", "hen": "rooster"} becomes ["foo", "bar", "hen", "rooster"] + When redis lua scripts are used, the value returned is a flattened list, + similar to that shown above. + + Args: + data: the dictionary to flatten + + Returns: + the flattened list version of `data` + """ + parsed_list = [] + + for k, v in data.items(): + parsed_list.append(k) + parsed_list.append(v) + + return parsed_list diff --git a/pydantic_redis/asyncio/__init__.py b/pydantic_redis/asyncio/__init__.py index 168f33ff..1da65b74 100644 --- a/pydantic_redis/asyncio/__init__.py +++ b/pydantic_redis/asyncio/__init__.py @@ -1,7 +1,34 @@ -"""Package containing the async version of pydantic_redis""" +"""Asynchronous API for pydantic-redis ORM. + +Typical usage example: + +```python +import asyncio +from pydantic_redis.asyncio import Store, Model, RedisConfig + +class Book(Model): + _primary_key_field = 'title' + title: str + +async def main(): + store = Store(name="sample", redis_config=RedisConfig()) + store.register_model(Book) + + await Book.insert(Book(title="Oliver Twist", author="Charles Dickens")) + await Book.update( + _id="Oliver Twist", data={"author": "Jane Austen"}, life_span_seconds=3600 + ) + results = await Book.select() + await Book.delete(ids=["Oliver Twist", "Great Expectations"]) + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) +``` +""" from .model import Model from .store import Store -from ..shared.config import RedisConfig +from ..config import RedisConfig __all__ = [Model, Store, RedisConfig] diff --git a/pydantic_redis/asyncio/model.py b/pydantic_redis/asyncio/model.py index ea04ddea..5f31a19f 100644 --- a/pydantic_redis/asyncio/model.py +++ b/pydantic_redis/asyncio/model.py @@ -1,12 +1,13 @@ -"""Module containing the model classes""" -from typing import Optional, List, Any, Union, Dict, Tuple, Type +"""Exposes the Base `Model` class for creating custom asynchronous models. -import redis.asyncio +This module contains the `Model` class which should be inherited when +creating model's for use in the asynchronous API of pydantic-redis. +""" +from typing import Optional, List, Any, Union, Dict -from pydantic_redis.shared.model import AbstractModel -from pydantic_redis.shared.model.insert_utils import insert_on_pipeline -from pydantic_redis.shared.model.prop_utils import get_primary_key, get_table_index_key -from pydantic_redis.shared.model.select_utils import ( +from .._shared.model import AbstractModel +from .._shared.model.insert_utils import insert_on_pipeline +from .._shared.model.select_utils import ( select_all_fields_all_ids, select_all_fields_some_ids, select_some_fields_all_ids, @@ -15,12 +16,15 @@ ) from .store import Store -from ..shared.model.delete_utils import delete_on_pipeline +from .._shared.model.delete_utils import delete_on_pipeline class Model(AbstractModel): - """ - The section in the store that saves rows of the same kind + """The Base class for all Asynchronous models. + + Inherit this class when creating a new model. + The new model should have `_primary_key_field` defined. + Any interaction with redis is done through `Model`'s. """ _store: Store @@ -31,8 +35,18 @@ async def insert( data: Union[List[AbstractModel], AbstractModel], life_span_seconds: Optional[float] = None, ): - """ - Inserts a given row or sets of rows into the table + """Inserts a given record or list of records into the redis. + + Can add a single record or multiple records into redis. + The records must be instances of this class. i.e. a `Book` + model can only insert `Book` instances. + + Args: + data: a model instance or list of model instances to put + into the redis store + life_span_seconds: the time-to-live in seconds of the records + to be inserted. If not specified, it defaults to the `Store`'s + life_span_seconds. """ store = cls.get_store() life_span = ( @@ -64,8 +78,17 @@ async def insert( async def update( cls, _id: Any, data: Dict[str, Any], life_span_seconds: Optional[float] = None ): - """ - Updates a given row or sets of rows in the table + """Updates the record whose primary key is `_id`. + + Updates the record of this Model in redis whose primary key is equal to the `_id` provided. + The record is partially updated from the `data`. + If `life_span_seconds` is provided, it will also update the time-to-live of + the record. + + Args: + _id: the primary key of record to be updated. + data: the new changes + life_span_seconds: the new time-to-live for the record """ store = cls.get_store() life_span = ( @@ -87,8 +110,13 @@ async def update( @classmethod async def delete(cls, ids: Union[Any, List[Any]]): - """ - deletes a given row or sets of rows in the table + """Removes a list of this Model's records from redis + + Removes all the records for the current Model whose primary keys + have been included in the `ids` passed. + + Args: + ids: list of primary keys of the records to remove """ store = cls.get_store() @@ -104,17 +132,31 @@ async def select( skip: int = 0, limit: Optional[int] = None, **kwargs, - ): - """ - Selects given rows or sets of rows in the table + ) -> Union["Model", Dict[str, Any]]: + """etrieves records of this Model from redis. + + Retrieves the records for this Model from redis. + + Args: + columns: the fields to return for each record + ids: the primary keys of the records to returns + skip: the number of records to skip. (default: 0) + limit: the maximum number of records to return + + Returns: + By default, it returns all records that belong to current Model. + + If `ids` are specified, it returns only records whose primary keys + have been listed in `ids`. + If `skip` and `limit` are specified WITHOUT `ids`, a slice of + all records are returned. - However, if `limit` is set, the number of items - returned will be less or equal to `limit`. - `skip` defaults to 0. It is the number of items to skip. - `skip` is only relevant when limit is specified. + If `limit` and `ids` are specified, `limit` is ignored. - `skip` and `limit` are irrelevant when `ids` are provided. + If `columns` are specified, a list of dictionaries containing only + the fields specified in `columns` is returned. Otherwise, instances + of the current Model are returned. """ if columns is None and ids is None: response = await select_all_fields_all_ids( diff --git a/pydantic_redis/asyncio/store.py b/pydantic_redis/asyncio/store.py index 4c24ecae..599bbcbd 100644 --- a/pydantic_redis/asyncio/store.py +++ b/pydantic_redis/asyncio/store.py @@ -1,23 +1,46 @@ -"""Module containing the store class for async io""" +"""Exposes the `Store` for managing a collection of asynchronous Model's. + +Stores represent a collection of different kinds of records saved in +a redis database. They only expose records whose `Model`'s have been +registered in them. Thus redis server can have multiple stores each +have a different image of the actual data in redis. + +A model must be registered with a store before it can interact with +a redis database. +""" from typing import Dict, Type, TYPE_CHECKING from redis import asyncio as redis -from ..shared.store import AbstractStore +from .._shared.store import AbstractStore if TYPE_CHECKING: from .model import Model class Store(AbstractStore): - """ - A store that allows a declarative way of querying for data in redis + """Manages a collection of Model's, connecting them to a redis database + + A Model can only interact with a redis database when it is registered + with a `Store` that is connected to that database. + + Attributes: + models (Dict[str, Type[pydantic_redis.syncio.Model]]): a mapping of registered `Model`'s, with the keys being the + Model name + name (str): the name of this Store + redis_config (pydantic_redis.syncio.RedisConfig): the configuration for connecting to a redis database + redis_store (Optional[redis.Redis]): an Redis instance associated with this store (default: None) + life_span_in_seconds (Optional[int]): the default time-to-live for the records inserted in this store + (default: None) """ models: Dict[str, Type["Model"]] = {} def _connect_to_redis(self) -> redis.Redis: - """Connects the store to redis, returning a proper connection""" + """Connects the store to redis. + + See the base class. + """ return redis.from_url( self.redis_config.redis_url, encoding=self.redis_config.encoding, diff --git a/pydantic_redis/config.py b/pydantic_redis/config.py new file mode 100644 index 00000000..b9c88676 --- /dev/null +++ b/pydantic_redis/config.py @@ -0,0 +1,43 @@ +"""Exposes the configuration for connecting to a redis database. +""" +from typing import Optional + +from pydantic import BaseModel + + +class RedisConfig(BaseModel): + """Configuration for connecting to redis database. + + Inorder to connect to a redis database, there are a number of + configurations that are needed including the server's host address + and port. `RedisConfig` computes a redis-url similar to + `redis://:password@host:self.port/db` + + Attributes: + host (str): the host address where the redis server is found (default: 'localhost'). + port (int): the port on which the redis server is running (default: 6379). + db (int): the redis database identifier (default: 0). + password (Optional[int]): the password for connecting to the + redis server (default: None). + ssl (bool): whether the connection to the redis server is to be via TLS (default: False) + encoding: (Optional[str]): the string encoding used with the redis database + (default: utf-8) + """ + + host: str = "localhost" + port: int = 6379 + db: int = 0 + password: Optional[str] = None + ssl: bool = False + encoding: Optional[str] = "utf-8" + + @property + def redis_url(self) -> str: + """a redis URL of form `redis://:password@host:port/db`. (`rediss://..` if TLS).""" + proto = "rediss" if self.ssl else "redis" + if self.password is None: + return f"{proto}://{self.host}:{self.port}/{self.db}" + return f"{proto}://:{self.password}@{self.host}:{self.port}/{self.db}" + + class Config: + orm_mode = True diff --git a/pydantic_redis/shared/__init__.py b/pydantic_redis/shared/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pydantic_redis/shared/config.py b/pydantic_redis/shared/config.py deleted file mode 100644 index 73081cb0..00000000 --- a/pydantic_redis/shared/config.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Module containing the main config classes""" -from typing import Optional - -from pydantic import BaseModel - - -class RedisConfig(BaseModel): - host: str = "localhost" - port: int = 6379 - db: int = 0 - password: Optional[str] = None - ssl: bool = False - encoding: Optional[str] = "utf-8" - - @property - def redis_url(self) -> str: - """Returns a redis url to connect to""" - proto = "rediss" if self.ssl else "redis" - if self.password is None: - return f"{proto}://{self.host}:{self.port}/{self.db}" - return f"{proto}://:{self.password}@{self.host}:{self.port}/{self.db}" - - class Config: - orm_mode = True diff --git a/pydantic_redis/shared/model/__init__.py b/pydantic_redis/shared/model/__init__.py deleted file mode 100644 index 4b43caa9..00000000 --- a/pydantic_redis/shared/model/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .base import AbstractModel diff --git a/pydantic_redis/shared/model/base.py b/pydantic_redis/shared/model/base.py deleted file mode 100644 index 4b68698c..00000000 --- a/pydantic_redis/shared/model/base.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Module containing the base model""" -import typing -from typing import Dict, Tuple, Any, Type, Union, List, Optional - -from pydantic import BaseModel - -from pydantic_redis.shared.utils import ( - typing_get_origin, - typing_get_args, - from_any_to_str_or_bytes, - from_dict_to_key_value_list, - from_bytes_to_str, - from_str_or_bytes_to_any, -) - - -from ..store import AbstractStore - - -class AbstractModel(BaseModel): - """ - An abstract class to help with typings for Model class - """ - - _primary_key_field: str - _field_types: Dict[str, Any] = {} - _store: AbstractStore - _nested_model_tuple_fields: Dict[str, Tuple[Any, ...]] = {} - _nested_model_list_fields: Dict[str, Type["AbstractModel"]] = {} - _nested_model_fields: Dict[str, Type["AbstractModel"]] = {} - - class Config: - arbitrary_types_allowed = True - - @classmethod - def get_store(cls) -> AbstractStore: - """Returns the instance of the store for this model""" - return cls._store - - @classmethod - def get_nested_model_tuple_fields(cls): - """Returns the fields that have tuples of nested models""" - return cls._nested_model_tuple_fields - - @classmethod - def get_nested_model_list_fields(cls): - """Returns the fields that have list of nested models""" - return cls._nested_model_list_fields - - @classmethod - def get_nested_model_fields(cls): - """Returns the fields that have nested models""" - return cls._nested_model_fields - - @classmethod - def get_primary_key_field(cls): - """Gets the protected _primary_key_field""" - return cls._primary_key_field - - @classmethod - def get_field_types(cls) -> Dict[str, Any]: - """Returns the fields types of this model""" - return cls._field_types - - @classmethod - def initialize(cls): - """Initializes class-wide variables for performance's reasons e.g. it caches the nested model fields""" - cls._field_types = typing.get_type_hints(cls) - - cls._nested_model_list_fields = {} - cls._nested_model_tuple_fields = {} - cls._nested_model_fields = {} - - for field, field_type in cls._field_types.items(): - try: - # In case the annotation is Optional, an alias of Union[X, None], extract the X - is_generic = hasattr(field_type, "__origin__") - if ( - is_generic - and typing_get_origin(field_type) == Union - and typing_get_args(field_type)[-1] == None.__class__ - ): - field_type = typing_get_args(field_type)[0] - is_generic = hasattr(field_type, "__origin__") - - if ( - is_generic - and typing_get_origin(field_type) in (List, list) - and issubclass(typing_get_args(field_type)[0], AbstractModel) - ): - cls._nested_model_list_fields[field] = typing_get_args(field_type)[ - 0 - ] - - elif ( - is_generic - and typing_get_origin(field_type) in (Tuple, tuple) - and any( - [ - issubclass(v, AbstractModel) - for v in typing_get_args(field_type) - ] - ) - ): - cls._nested_model_tuple_fields[field] = typing_get_args(field_type) - - elif issubclass(field_type, AbstractModel): - cls._nested_model_fields[field] = field_type - - except (TypeError, AttributeError): - pass - - @classmethod - def serialize_partially(cls, data: Optional[Dict[str, Any]]) -> Dict[str, Any]: - """Converts non primitive data types into str""" - return {key: from_any_to_str_or_bytes(value) for key, value in data.items()} - - @classmethod - def deserialize_partially( - cls, data: Union[List[Any], Dict[Any, Any]] = () - ) -> Dict[str, Any]: - """ - Converts str or bytes to their expected data types, given a list of properties got from the - list of lists got after calling EVAL on redis. - - EVAL returns a List of Lists of key, values where the value for a given key is in the position - just after the key e.g. [["foo", "bar", "head", 9]] => [{"foo": "bar", "head": 9}] - - Note: For backward compatibility, data can also be a dict. - """ - if isinstance(data, dict): - # for backward compatibility - data = from_dict_to_key_value_list(data) - - parsed_dict = {} - - nested_model_list_fields = cls.get_nested_model_list_fields() - nested_model_tuple_fields = cls.get_nested_model_tuple_fields() - nested_model_fields = cls.get_nested_model_fields() - - for i in range(0, len(data), 2): - key = from_bytes_to_str(data[i]) - field_type = cls._field_types.get(key) - value = from_str_or_bytes_to_any(value=data[i + 1], field_type=field_type) - - if key in nested_model_list_fields and value is not None: - value = deserialize_nested_model_list( - field_type=nested_model_list_fields[key], value=value - ) - - elif key in nested_model_tuple_fields and value is not None: - value = deserialize_nested_model_tuple( - field_types=nested_model_tuple_fields[key], value=value - ) - - elif key in nested_model_fields and value is not None: - value = deserialize_nested_model( - field_type=nested_model_fields[key], value=value - ) - - parsed_dict[key] = value - - return parsed_dict - - -def deserialize_nested_model_list( - field_type: Type[AbstractModel], value: List[Any] -) -> List[AbstractModel]: - """Deserializes a list of key values for the given field returning a list of nested models""" - return [field_type(**field_type.deserialize_partially(item)) for item in value] - - -def deserialize_nested_model_tuple( - field_types: Tuple[Any, ...], value: List[Any] -) -> Tuple[Any, ...]: - """Deserializes a list of key values for the given field returning a tuple of nested models among others""" - items = [] - for field_type, value in zip(field_types, value): - if issubclass(field_type, AbstractModel) and value is not None: - value = field_type(**field_type.deserialize_partially(value)) - items.append(value) - - return tuple(items) - - -def deserialize_nested_model( - field_type: Type[AbstractModel], value: List[Any] -) -> AbstractModel: - """Deserializes a list of key values for the given field returning the nested model""" - return field_type(**field_type.deserialize_partially(value)) diff --git a/pydantic_redis/shared/model/delete_utils.py b/pydantic_redis/shared/model/delete_utils.py deleted file mode 100644 index 19709111..00000000 --- a/pydantic_redis/shared/model/delete_utils.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Module containing common functionality for deleting records""" -from typing import Type, Union, List - -from redis.client import Pipeline -from redis.asyncio.client import Pipeline as AioPipeline - -from pydantic_redis.shared.model import AbstractModel -from pydantic_redis.shared.model.prop_utils import get_primary_key, get_table_index_key - - -def delete_on_pipeline( - model: Type[AbstractModel], pipeline: Union[Pipeline, AioPipeline], ids: List[str] -): - """ - Pipelines the deletion of the given ids, so that when pipeline.execute - is called later, deletion occurs - """ - primary_keys = [] - - if isinstance(ids, list): - primary_keys = ids - elif ids is not None: - primary_keys = [ids] - - names = [ - get_primary_key(model=model, primary_key_value=primary_key_value) - for primary_key_value in primary_keys - ] - pipeline.delete(*names) - # remove the primary keys from the indexz - table_index_key = get_table_index_key(model=model) - pipeline.zrem(table_index_key, *names) diff --git a/pydantic_redis/shared/model/prop_utils.py b/pydantic_redis/shared/model/prop_utils.py deleted file mode 100644 index 335b3dda..00000000 --- a/pydantic_redis/shared/model/prop_utils.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Module containing utils for getting properties of the Model""" - -from typing import Type, Any - -from .base import AbstractModel - -NESTED_MODEL_PREFIX = "__" -NESTED_MODEL_LIST_FIELD_PREFIX = "___" -NESTED_MODEL_TUPLE_FIELD_PREFIX = "____" - - -def get_primary_key(model: Type[AbstractModel], primary_key_value: Any): - """ - Returns the primary key value concatenated to the table name for uniqueness - """ - return f"{get_table_prefix(model)}{primary_key_value}" - - -def get_table_prefix(model: Type[AbstractModel]): - """ - Returns the prefix of the all the redis keys that are associated with this table - """ - table_name = model.__name__.lower() - return f"{table_name}_%&_" - - -def get_table_keys_regex(model: Type[AbstractModel]): - """ - Returns the table name regex to get all keys that belong to this table - """ - return f"{get_table_prefix(model)}*" - - -def get_table_index_key(model: Type[AbstractModel]): - """Returns the key for the set in which the primary keys of the given table have been saved""" - table_name = model.__name__.lower() - return f"{table_name}__index" diff --git a/pydantic_redis/shared/model/select_utils.py b/pydantic_redis/shared/model/select_utils.py deleted file mode 100644 index e0b21416..00000000 --- a/pydantic_redis/shared/model/select_utils.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Module containing the mixin functionality for selecting""" -from typing import List, Any, Type, Union, Awaitable, Optional - -from pydantic_redis.shared.model.prop_utils import ( - NESTED_MODEL_PREFIX, - NESTED_MODEL_LIST_FIELD_PREFIX, - NESTED_MODEL_TUPLE_FIELD_PREFIX, - get_table_keys_regex, - get_table_prefix, - get_table_index_key, -) - - -from .base import AbstractModel - - -def get_select_fields(model: Type[AbstractModel], columns: List[str]) -> List[str]: - """ - Gets the fields to be used for selecting HMAP fields in Redis - It replaces any fields in `columns` that correspond to nested records with their - `__` suffixed versions - """ - fields = [] - nested_model_list_fields = model.get_nested_model_list_fields() - nested_model_tuple_fields = model.get_nested_model_tuple_fields() - nested_model_fields = model.get_nested_model_fields() - - for col in columns: - - if col in nested_model_fields: - col = f"{NESTED_MODEL_PREFIX}{col}" - elif col in nested_model_list_fields: - col = f"{NESTED_MODEL_LIST_FIELD_PREFIX}{col}" - elif col in nested_model_tuple_fields: - col = f"{NESTED_MODEL_TUPLE_FIELD_PREFIX}{col}" - - fields.append(col) - return fields - - -def select_all_fields_all_ids( - model: Type[AbstractModel], - skip: int = 0, - limit: Optional[int] = None, -) -> Union[List[List[Any]], Awaitable[List[List[Any]]]]: - """ - Selects all items in the database, returning all their fields - - However, if `limit` is set, the number of items - returned will be less or equal to `limit`. - `skip` defaults to 0. It is the number of items to skip. - `skip` is only relevant when limit is specified. - """ - if isinstance(limit, int): - return _select_all_ids_all_fields_paginated(model=model, limit=limit, skip=skip) - else: - table_keys_regex = get_table_keys_regex(model=model) - args = [table_keys_regex] - store = model.get_store() - return store.select_all_fields_for_all_ids_script(args=args) - - -def select_all_fields_some_ids( - model: Type[AbstractModel], ids: List[str] -) -> Union[List[List[Any]], Awaitable[List[List[Any]]]]: - """Selects some items in the database, returning all their fields""" - table_prefix = get_table_prefix(model=model) - keys = [f"{table_prefix}{key}" for key in ids] - store = model.get_store() - return store.select_all_fields_for_some_ids_script(keys=keys) - - -def select_some_fields_all_ids( - model: Type[AbstractModel], - fields: List[str], - skip: int = 0, - limit: Optional[int] = None, -) -> Union[List[List[Any]], Awaitable[List[List[Any]]]]: - """ - Selects all items in the database, returning only the specified fields. - - However, if `limit` is set, the number of items - returned will be less or equal to `limit`. - `skip` defaults to 0. It is the number of items to skip. - `skip` is only relevant when limit is specified. - """ - columns = get_select_fields(model=model, columns=fields) - - if isinstance(limit, int): - return _select_some_fields_all_ids_paginated( - model=model, columns=columns, limit=limit, skip=skip - ) - else: - table_keys_regex = get_table_keys_regex(model=model) - args = [table_keys_regex, *columns] - store = model.get_store() - return store.select_some_fields_for_all_ids_script(args=args) - - -def select_some_fields_some_ids( - model: Type[AbstractModel], fields: List[str], ids: List[str] -) -> Union[List[List[Any]], Awaitable[List[List[Any]]]]: - """Selects some of items in the database, returning only the specified fields""" - table_prefix = get_table_prefix(model=model) - keys = [f"{table_prefix}{key}" for key in ids] - columns = get_select_fields(model=model, columns=fields) - store = model.get_store() - return store.select_some_fields_for_some_ids_script(keys=keys, args=columns) - - -def parse_select_response( - model: Type[AbstractModel], response: List[List], as_models: bool -): - """ - Converts a list of lists of key-values into a list of models if `as_models` is true or leaves them as dicts - with foreign keys replaced by model instances. The list is got from calling EVAL on Redis . - - EVAL returns a List of Lists of key, values where the value for a given key is in the position - just after the key e.g. [["foo", "bar", "head", 9]] => [{"foo": "bar", "head": 9}] - """ - if len(response) == 0: - return None - - if as_models: - return [ - model(**model.deserialize_partially(record)) - for record in response - if record != [] - ] - - return [model.deserialize_partially(record) for record in response if record != []] - - -def _select_all_ids_all_fields_paginated( - model: Type[AbstractModel], limit: int, skip: Optional[int] -): - """Selects all fields for at most `limit` number of items after skipping `skip` items""" - if skip is None: - skip = 0 - table_index_key = get_table_index_key(model) - args = [table_index_key, skip, limit] - store = model.get_store() - return store.paginated_select_all_fields_for_all_ids_script(args=args) - - -def _select_some_fields_all_ids_paginated( - model: Type[AbstractModel], columns: List[str], limit: int, skip: int -): - """Selects some fields for at most `limit` number of items after skipping `skip` items""" - if skip is None: - skip = 0 - table_index_key = get_table_index_key(model) - args = [table_index_key, skip, limit, *columns] - store = model.get_store() - return store.paginated_select_some_fields_for_all_ids_script(args=args) diff --git a/pydantic_redis/shared/utils.py b/pydantic_redis/shared/utils.py deleted file mode 100644 index a76de887..00000000 --- a/pydantic_redis/shared/utils.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Module containing common utilities""" -import typing -from typing import Any, Tuple, Optional, Union, Dict, Callable, Type, List - -import orjson - - -def strip_leading(word: str, substring: str) -> str: - """ - Strips the leading substring if it exists. - This is contrary to rstrip which can looks at removes each character in the substring - """ - if word.startswith(substring): - return word[len(substring) :] - return word - - -def typing_get_args(v: Any) -> Tuple[Any, ...]: - """Gets the __args__ of the annotations of a given typing""" - try: - return typing.get_args(v) - except AttributeError: - return getattr(v, "__args__", ()) if v is not typing.Generic else typing.Generic - - -def typing_get_origin(v: Any) -> Optional[Any]: - """Gets the __origin__ of the annotations of a given typing""" - try: - return typing.get_origin(v) - except AttributeError: - return getattr(v, "__origin__", None) - - -def from_bytes_to_str(value: Union[str, bytes]) -> str: - """Converts bytes to str""" - if isinstance(value, bytes): - return str(value, "utf-8") - return value - - -def from_str_or_bytes_to_any(value: Any, field_type: Type) -> Any: - """Converts str or bytes to arbitrary data""" - if isinstance(value, (bytes, bytearray, memoryview)): - return orjson.loads(value) - elif isinstance(value, str) and field_type != str: - return orjson.loads(value) - return value - - -def from_any_to_str_or_bytes(value: Any) -> Union[str, bytes]: - """Converts arbitrary data into str or bytes""" - if isinstance(value, str): - return value - return orjson.dumps(value, default=default_json_dump) - - -def default_json_dump(obj): - """Default JSON dump for orjson""" - if hasattr(obj, "json") and isinstance(obj.json, Callable): - return obj.json() - elif isinstance(obj, set): - # Set does not exist in JSON. - # It's fine to use list instead, it becomes a Set when deserializing. - return list(obj) - - -def from_dict_to_key_value_list(data: Dict[str, Any]) -> List[Any]: - """Converts dict to flattened list of key, values where the value after the key""" - parsed_list = [] - - for k, v in data.items(): - parsed_list.append(k) - parsed_list.append(v) - - return parsed_list diff --git a/pydantic_redis/syncio/__init__.py b/pydantic_redis/syncio/__init__.py index 2af01c3f..09ab5db6 100644 --- a/pydantic_redis/syncio/__init__.py +++ b/pydantic_redis/syncio/__init__.py @@ -1,6 +1,30 @@ -"""Package containing the synchronous and thus default version of pydantic_redis""" +"""Synchronous API for pydantic-redis ORM. + +Typical usage example: + +```python +# from pydantic_redis import Store, Model, RedisConfig +from pydantic_redis.syncio import Store, Model, RedisConfig + +class Book(Model): + _primary_key_field = 'title' + title: str + +if __name__ == '__main__': + store = Store(name="sample", redis_config=RedisConfig()) + store.register_model(Book) + + Book.insert(Book(title="Oliver Twist", author="Charles Dickens")) + Book.update( + _id="Oliver Twist", data={"author": "Jane Austen"}, life_span_seconds=3600 + ) + results = Book.select() + Book.delete(ids=["Oliver Twist", "Great Expectations"]) +``` +""" + from .model import Model from .store import Store -from ..shared.config import RedisConfig +from ..config import RedisConfig __all__ = [Model, Store, RedisConfig] diff --git a/pydantic_redis/syncio/model.py b/pydantic_redis/syncio/model.py index 25cbe545..f525d91d 100644 --- a/pydantic_redis/syncio/model.py +++ b/pydantic_redis/syncio/model.py @@ -1,10 +1,13 @@ -"""Module containing the model classes""" +"""Exposes the Base `Model` class for creating custom synchronous models. + +This module contains the `Model` class which should be inherited when +creating model's for use in the synchronous API of pydantic-redis +""" from typing import Optional, List, Any, Union, Dict -from pydantic_redis.shared.model import AbstractModel -from pydantic_redis.shared.model.insert_utils import insert_on_pipeline -from pydantic_redis.shared.model.prop_utils import get_primary_key, get_table_index_key -from pydantic_redis.shared.model.select_utils import ( +from .._shared.model import AbstractModel +from .._shared.model.insert_utils import insert_on_pipeline +from .._shared.model.select_utils import ( select_all_fields_all_ids, select_all_fields_some_ids, select_some_fields_all_ids, @@ -13,12 +16,16 @@ ) from .store import Store -from ..shared.model.delete_utils import delete_on_pipeline +from .._shared.model.delete_utils import delete_on_pipeline class Model(AbstractModel): """ - The section in the store that saves rows of the same kind + The Base class for all Synchronous models. + + Inherit this class when creating a new model. + The new model should have `_primary_key_field` defined. + Any interaction with redis is done through `Model`'s. """ _store: Store @@ -29,8 +36,18 @@ def insert( data: Union[List[AbstractModel], AbstractModel], life_span_seconds: Optional[float] = None, ): - """ - Inserts a given row or sets of rows into the table + """Inserts a given record or list of records into the redis. + + Can add a single record or multiple records into redis. + The records must be instances of this class. i.e. a `Book` + model can only insert `Book` instances. + + Args: + data: a model instance or list of model instances to put + into the redis store + life_span_seconds: the time-to-live in seconds of the records + to be inserted. If not specified, it defaults to the `Store`'s + life_span_seconds. """ store = cls.get_store() @@ -62,8 +79,17 @@ def insert( def update( cls, _id: Any, data: Dict[str, Any], life_span_seconds: Optional[float] = None ): - """ - Updates a given row or sets of rows in the table + """Updates the record whose primary key is `_id`. + + Updates the record of this Model in redis whose primary key is equal to the `_id` provided. + The record is partially updated from the `data`. + If `life_span_seconds` is provided, it will also update the time-to-live of + the record. + + Args: + _id: the primary key of record to be updated. + data: the new changes + life_span_seconds: the new time-to-live for the record """ store = cls.get_store() life_span = ( @@ -85,8 +111,13 @@ def update( @classmethod def delete(cls, ids: Union[Any, List[Any]]): - """ - deletes a given row or sets of rows in the table + """Removes a list of this Model's records from redis + + Removes all the records for the current Model whose primary keys + have been included in the `ids` passed. + + Args: + ids: list of primary keys of the records to remove """ store = cls.get_store() with store.redis_store.pipeline() as pipeline: @@ -101,16 +132,31 @@ def select( skip: int = 0, limit: Optional[int] = None, **kwargs, - ): - """ - Selects given rows or sets of rows in the table + ) -> Union["Model", Dict[str, Any]]: + """Retrieves records of this Model from redis. + + Retrieves the records for this Model from redis. + + Args: + columns: the fields to return for each record + ids: the primary keys of the records to returns + skip: the number of records to skip. (default: 0) + limit: the maximum number of records to return + + Returns: + By default, it returns all records that belong to current Model. + + If `ids` are specified, it returns only records whose primary keys + have been listed in `ids`. + + If `skip` and `limit` are specified WITHOUT `ids`, a slice of + all records are returned. - However, if `limit` is set, the number of items - returned will be less or equal to `limit`. - `skip` defaults to 0. It is the number of items to skip. - `skip` is only relevant when limit is specified. + If `limit` and `ids` are specified, `limit` is ignored. - `skip` and `limit` are irrelevant when `ids` are provided. + If `columns` are specified, a list of dictionaries containing only + the fields specified in `columns` is returned. Otherwise, instances + of the current Model are returned. """ if columns is None and ids is None: response = select_all_fields_all_ids(model=cls, skip=skip, limit=limit) diff --git a/pydantic_redis/syncio/store.py b/pydantic_redis/syncio/store.py index 0b36cc73..8cd1b71c 100644 --- a/pydantic_redis/syncio/store.py +++ b/pydantic_redis/syncio/store.py @@ -1,23 +1,46 @@ -"""Module containing the store classes for sync io""" +"""Exposes the `Store` class for managing a collection of Model's. + +Stores represent a collection of different kinds of records saved in +a redis database. They only expose records whose `Model`'s have been +registered in them. Thus redis server can have multiple stores each +have a different image of the actual data in redis. + +A model must be registered with a store before it can interact with +a redis database. +""" from typing import Dict, Type, TYPE_CHECKING import redis -from ..shared.store import AbstractStore +from .._shared.store import AbstractStore if TYPE_CHECKING: from .model import Model class Store(AbstractStore): - """ - A store that allows a declarative way of querying for data in redis + """Manages a collection of Model's, connecting them to a redis database + + A Model can only interact with a redis database when it is registered + with a `Store` that is connected to that database. + + Attributes: + models (Dict[str, Type[pydantic_redis.syncio.Model]]): a mapping of registered `Model`'s, with the keys being the + Model name + name (str): the name of this Store + redis_config (pydantic_redis.syncio.RedisConfig): the configuration for connecting to a redis database + redis_store (Optional[redis.Redis]): an Redis instance associated with this store (default: None) + life_span_in_seconds (Optional[int]): the default time-to-live for the records inserted in this store + (default: None) """ models: Dict[str, Type["Model"]] = {} def _connect_to_redis(self) -> redis.Redis: - """Connects the store to redis, returning a proper connection""" + """Connects the store to redis. + + See base class. + """ return redis.from_url( self.redis_config.redis_url, encoding=self.redis_config.encoding, diff --git a/requirements.txt b/requirements.txt index 6e52ed5f..d023eccf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,7 @@ black==22.8.0 pre-commit build pytest-asyncio +mkdocs +mkdocstrings +mkdocs-material +mdx_include diff --git a/test/test_async_pydantic_redis.py b/test/test_async_pydantic_redis.py index 4208075f..0d8e3a1e 100644 --- a/test/test_async_pydantic_redis.py +++ b/test/test_async_pydantic_redis.py @@ -4,8 +4,8 @@ import pytest -from pydantic_redis.shared.model.prop_utils import NESTED_MODEL_PREFIX -from pydantic_redis.shared.utils import strip_leading +from pydantic_redis._shared.model.prop_utils import NESTED_MODEL_PREFIX # noqa +from pydantic_redis._shared.utils import strip_leading # noqa from pydantic_redis.asyncio import Model, RedisConfig from test.conftest import ( async_redis_store_fixture, diff --git a/test/test_pydantic_redis.py b/test/test_pydantic_redis.py index a3715769..af5eec7f 100644 --- a/test/test_pydantic_redis.py +++ b/test/test_pydantic_redis.py @@ -4,9 +4,9 @@ import pytest -from pydantic_redis.shared.config import RedisConfig -from pydantic_redis.shared.model.prop_utils import NESTED_MODEL_PREFIX -from pydantic_redis.shared.utils import strip_leading +from pydantic_redis.config import RedisConfig # noqa +from pydantic_redis._shared.model.prop_utils import NESTED_MODEL_PREFIX # noqa +from pydantic_redis._shared.utils import strip_leading # noqa from pydantic_redis.syncio.model import Model from test.conftest import ( redis_store_fixture,