diff --git a/.gitignore b/.gitignore index b80e2910..a59a51e4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ logs *.pyc .dccachea __pycache__ -db.sqlite3 +*.sqlite3 +*.sqlite3-journal media cache static-deploy diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8cfd43..f651748f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,9 +34,22 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Added + +- `use_query` now supports async functions. +- `use_mutation` now supports async functions. +- `reactpy_django.types.QueryOptions.thread_sensitive` option to customize how sync queries are executed. +- `reactpy_django.hooks.use_mutation` now accepts `reactpy_django.types.MutationOptions` option to customize how mutations are executed. + +### Changed + +- The `mutate` argument on `reactpy_django.hooks.use_mutation` has been renamed to `mutation`. + +### Fixed + +- Fix bug where ReactPy utilizes Django's default cache timeout, which can prematurely expire the component cache. -## [3.0.1] - 2023-03-31 +## [3.0.1] - 2023-04-06 ### Changed diff --git a/README.md b/README.md index 15228f75..90ec4754 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Django, - Jupyter, + Jupyter, Plotly-Dash diff --git a/docs/python/use-query-async.py b/docs/python/use-query-async.py new file mode 100644 index 00000000..3b532fc5 --- /dev/null +++ b/docs/python/use-query-async.py @@ -0,0 +1,23 @@ +from channels.db import database_sync_to_async +from example.models import TodoItem +from reactpy import component, html + +from reactpy_django.hooks import use_query + + +async def get_items(): + return await database_sync_to_async(TodoItem.objects.all)() + + +@component +def todo_list(): + item_query = use_query(get_items) + + if item_query.loading: + rendered_items = html.h2("Loading...") + elif item_query.error or not item_query.data: + rendered_items = html.h2("Error when loading!") + else: + rendered_items = html.ul([html.li(item, key=item) for item in item_query.data]) + + return html.div("Rendered items: ", rendered_items) diff --git a/docs/python/use-query-postprocessor-change.py b/docs/python/use-query-postprocessor-change.py index 4112ef5d..0c3f552a 100644 --- a/docs/python/use-query-postprocessor-change.py +++ b/docs/python/use-query-postprocessor-change.py @@ -17,7 +17,7 @@ def execute_io_intensive_operation(): @component -def todo_list(): +def my_component(): query = use_query( QueryOptions( postprocessor=my_postprocessor, diff --git a/docs/python/use-query-postprocessor-disable.py b/docs/python/use-query-postprocessor-disable.py index 33e5dc6d..722366f8 100644 --- a/docs/python/use-query-postprocessor-disable.py +++ b/docs/python/use-query-postprocessor-disable.py @@ -10,7 +10,7 @@ def execute_io_intensive_operation(): @component -def todo_list(): +def my_component(): query = use_query( QueryOptions(postprocessor=None), execute_io_intensive_operation, diff --git a/docs/python/use-query-postprocessor-kwargs.py b/docs/python/use-query-postprocessor-kwargs.py index 831799bf..39f0f965 100644 --- a/docs/python/use-query-postprocessor-kwargs.py +++ b/docs/python/use-query-postprocessor-kwargs.py @@ -16,7 +16,7 @@ def get_model_with_relationships(): @component -def todo_list(): +def my_component(): query = use_query( QueryOptions( postprocessor_kwargs={"many_to_many": False, "many_to_one": False} diff --git a/docs/python/use-query-thread-sensitive.py b/docs/python/use-query-thread-sensitive.py new file mode 100644 index 00000000..ab806bf4 --- /dev/null +++ b/docs/python/use-query-thread-sensitive.py @@ -0,0 +1,22 @@ +from reactpy import component + +from reactpy_django.hooks import use_query +from reactpy_django.types import QueryOptions + + +def execute_thread_safe_operation(): + """This is an example query function that does some thread-safe operation.""" + pass + + +@component +def my_component(): + query = use_query( + QueryOptions(thread_sensitive=False), + execute_thread_safe_operation, + ) + + if query.loading or query.error: + return None + + return str(query.data) diff --git a/docs/python/use-query.py b/docs/python/use-query.py index 6056773d..9a15c525 100644 --- a/docs/python/use-query.py +++ b/docs/python/use-query.py @@ -17,6 +17,6 @@ def todo_list(): elif item_query.error or not item_query.data: rendered_items = html.h2("Error when loading!") else: - rendered_items = html.ul(html.li(item, key=item) for item in item_query.data) + rendered_items = html.ul([html.li(item, key=item) for item in item_query.data]) return html.div("Rendered items: ", rendered_items) diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index d29d8a64..d4f5b4b8 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -30,6 +30,7 @@ postprocessing serializable postprocessor preprocessor +middleware backends backend frontend diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md index 7ab8f377..dd53ac61 100644 --- a/docs/src/features/hooks.md +++ b/docs/src/features/hooks.md @@ -57,13 +57,29 @@ The function you provide into this hook must return either a `Model` or `QuerySe {% include "../../python/use-query-args.py" %} ``` -??? question "Why does the example `get_items` function return `TodoItem.objects.all()`?" +??? question "Why does `get_items` in the example return `TodoItem.objects.all()`?" This was a technical design decision to based on [Apollo's `useQuery` hook](https://www.apollographql.com/docs/react/data/queries/), but ultimately helps avoid Django's `SynchronousOnlyOperation` exceptions. The `use_query` hook ensures the provided `Model` or `QuerySet` executes all [deferred](https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.get_deferred_fields)/[lazy queries](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) safely prior to reaching your components. -??? question "Can this hook be used for things other than the Django ORM?" +??? question "How can I use `QueryOptions` to customize fetching behavior?" + + **`thread_sensitive`** + + Whether to run your synchronous query function in thread-sensitive mode. Thread-sensitive mode is turned on by default due to Django ORM limitations. See Django's [`sync_to_async` docs](https://docs.djangoproject.com/en/dev/topics/async/#sync-to-async) docs for more information. + + This setting only applies to sync query functions, and will be ignored for async functions. + + === "components.py" + + ```python + {% include "../../python/use-query-thread-sensitive.py" %} + ``` + + --- + + **`postprocessor`** {% include-markdown "../../includes/orm.md" start="" end="" %} @@ -72,7 +88,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe 1. Want to use this hook to defer IO intensive tasks to be computed in the background 2. Want to to utilize `use_query` with a different ORM - ... then you can disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to `None` to disable postprocessing behavior. + ... then you can either set a custom `postprocessor`, or disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to `None` to disable postprocessing behavior. === "components.py" @@ -92,7 +108,9 @@ The function you provide into this hook must return either a `Model` or `QuerySe {% include "../../python/use-query-postprocessor-change.py" %} ``` -??? question "How can I prevent this hook from recursively fetching `ManyToMany` fields or `ForeignKey` relationships?" + --- + + **`postprocessor_kwargs`** {% include-markdown "../../includes/orm.md" start="" end="" %} @@ -108,6 +126,18 @@ The function you provide into this hook must return either a `Model` or `QuerySe _Note: In Django's ORM design, the field name to access foreign keys is [postfixed with `_set`](https://docs.djangoproject.com/en/dev/topics/db/examples/many_to_one/) by default._ +??? question "Can I define async query functions?" + + Async functions are supported by `use_query`. You can use them in the same way as a sync query function. + + However, be mindful of Django async ORM restrictions. + + === "components.py" + + ```python + {% include "../../python/use-query-async.py" %} + ``` + ??? question "Can I make ORM calls without hooks?" {% include-markdown "../../includes/orm.md" start="" end="" %} diff --git a/docs/src/get-started/render-view.md b/docs/src/get-started/register-view.md similarity index 88% rename from docs/src/get-started/render-view.md rename to docs/src/get-started/register-view.md index be3fd746..1c97d089 100644 --- a/docs/src/get-started/render-view.md +++ b/docs/src/get-started/register-view.md @@ -6,7 +6,7 @@ --- -## Render Your View +## Register a View We will assume you have [created a Django View](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) before, but here's a simple example below. @@ -28,8 +28,6 @@ We will add this new view into your [`urls.py`](https://docs.djangoproject.com/e {% include "../../python/example/urls.py" %} ``` -Now, navigate to `http://127.0.0.1:8000/example/`. If you copy-pasted the component from the previous example, you will now see your component display "Hello World". - ??? question "Which urls.py do I add my views to?" For simple **Django projects**, you can easily add all of your views directly into the **Django project's** `urls.py`. However, as you start increase your project's complexity you might end up with way too much within one file. diff --git a/docs/src/get-started/run-webserver.md b/docs/src/get-started/run-webserver.md new file mode 100644 index 00000000..1c532558 --- /dev/null +++ b/docs/src/get-started/run-webserver.md @@ -0,0 +1,23 @@ +## Overview + +!!! summary + + Run a webserver to display your Django view. + +--- + +## Run the Webserver + +To test your new Django view, run the following command to start up a development webserver. + +```bash linenums="0" +python manage.py runserver +``` + +Now you can navigate to your **Django project** URL that contains an ReactPy component, such as `http://127.0.0.1:8000/example/` (_from the previous step_). + +If you copy-pasted our example component, you will now see your component display "Hello World". + +??? warning "Do not use `manage.py runserver` for production." + + The webserver contained within `manage.py runserver` is only intended for development and testing purposes. For production deployments make sure to read [Django's documentation](https://docs.djangoproject.com/en/dev/howto/deployment/). diff --git a/mkdocs.yml b/mkdocs.yml index a0addc8f..bca3df26 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,7 +6,8 @@ nav: - Choose a Django App: get-started/choose-django-app.md - Create a Component: get-started/create-component.md - Use the Template Tag: get-started/use-template-tag.md - - Render Your View: get-started/render-view.md + - Register a View: get-started/register-view.md + - Run the Webserver: get-started/run-webserver.md - Learn More: get-started/learn-more.md - Reference: - Components: features/components.md diff --git a/noxfile.py b/noxfile.py index 2aa03270..36497d08 100644 --- a/noxfile.py +++ b/noxfile.py @@ -56,7 +56,7 @@ def test_suite(session: Session) -> None: posargs.append("--debug-mode") session.run("playwright", "install", "chromium") - session.run("python", "manage.py", "test", *posargs) + session.run("python", "manage.py", "test", *posargs, "-v 2") @nox.session diff --git a/requirements/test-env.txt b/requirements/test-env.txt index 5cad346d..f7552f1d 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -2,3 +2,4 @@ django playwright twisted channels[daphne]>=4.0.0 +tblib diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index c5c88d5e..33950b1e 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -6,7 +6,11 @@ from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core.types import ComponentConstructor -from reactpy_django.types import Postprocessor, ViewComponentIframe +from reactpy_django.types import ( + AsyncPostprocessor, + SyncPostprocessor, + ViewComponentIframe, +) from reactpy_django.utils import import_dotted_path @@ -37,10 +41,12 @@ "REACTPY_DATABASE", DEFAULT_DB_ALIAS, ) -REACTPY_DEFAULT_QUERY_POSTPROCESSOR: Postprocessor | None = import_dotted_path( - getattr( - settings, - "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", - "reactpy_django.utils.django_query_postprocessor", +REACTPY_DEFAULT_QUERY_POSTPROCESSOR: AsyncPostprocessor | SyncPostprocessor | None = ( + import_dotted_path( + getattr( + settings, + "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", + "reactpy_django.utils.django_query_postprocessor", + ) ) ) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 4bb3dc68..00e22bd9 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -13,7 +13,7 @@ overload, ) -from channels.db import database_sync_to_async as _database_sync_to_async +from channels.db import database_sync_to_async from reactpy import use_callback, use_ref from reactpy.backend.hooks import use_connection as _use_connection from reactpy.backend.hooks import use_location as _use_location @@ -24,6 +24,7 @@ from reactpy_django.types import ( Connection, Mutation, + MutationOptions, Query, QueryOptions, _Params, @@ -33,10 +34,6 @@ _logger = logging.getLogger(__name__) -database_sync_to_async = cast( - Callable[..., Callable[..., Awaitable[Any]]], - _database_sync_to_async, -) _REFETCH_CALLBACKS: DefaultDict[ Callable[..., Any], set[Callable[[], None]] ] = DefaultDict(set) @@ -82,8 +79,9 @@ def use_connection() -> Connection: @overload def use_query( options: QueryOptions, - query: Callable[_Params, _Result | None], /, + query: Callable[_Params, _Result | None] + | Callable[_Params, Awaitable[_Result | None]], *args: _Params.args, **kwargs: _Params.kwargs, ) -> Query[_Result | None]: @@ -92,8 +90,8 @@ def use_query( @overload def use_query( - query: Callable[_Params, _Result | None], - /, + query: Callable[_Params, _Result | None] + | Callable[_Params, Awaitable[_Result | None]], *args: _Params.args, **kwargs: _Params.kwargs, ) -> Query[_Result | None]: @@ -114,31 +112,81 @@ def use_query( Keyword Args: **kwargs: Keyword arguments to pass into `query`.""" + should_execute, set_should_execute = use_state(True) + data, set_data = use_state(cast(Union[_Result, None], None)) + loading, set_loading = use_state(True) + error, set_error = use_state(cast(Union[Exception, None], None)) if isinstance(args[0], QueryOptions): - query_options = args[0] - query = args[1] - args = args[2:] - + query_options, query, query_args, query_kwargs = _use_query_args_1( + *args, **kwargs + ) else: - query_options = QueryOptions() - query = args[0] - args = args[1:] - + query_options, query, query_args, query_kwargs = _use_query_args_2( + *args, **kwargs + ) query_ref = use_ref(query) if query_ref.current is not query: raise ValueError(f"Query function changed from {query_ref.current} to {query}.") - should_execute, set_should_execute = use_state(True) - data, set_data = use_state(cast(Union[_Result, None], None)) - loading, set_loading = use_state(True) - error, set_error = use_state(cast(Union[Exception, None], None)) + # The main "running" function for `use_query` + async def execute_query() -> None: + try: + # Run the query + if asyncio.iscoroutinefunction(query): + new_data = await query(*query_args, **query_kwargs) + else: + new_data = await database_sync_to_async( + query, + thread_sensitive=query_options.thread_sensitive, + )(*query_args, **query_kwargs) + + # Run the postprocessor + if query_options.postprocessor: + if asyncio.iscoroutinefunction(query_options.postprocessor): + new_data = await query_options.postprocessor( + new_data, **query_options.postprocessor_kwargs + ) + else: + new_data = await database_sync_to_async( + query_options.postprocessor, + thread_sensitive=query_options.thread_sensitive, + )(new_data, **query_options.postprocessor_kwargs) + + # Log any errors and set the error state + except Exception as e: + set_data(None) + set_loading(False) + set_error(e) + _logger.exception( + f"Failed to execute query: {generate_obj_name(query) or query}" + ) + return + + # Query was successful + else: + set_data(new_data) + set_loading(False) + set_error(None) + + # Schedule the query to be run when needed + @use_effect(dependencies=None) + def schedule_query() -> None: + # Make sure we don't re-execute the query unless we're told to + if not should_execute: + return + set_should_execute(False) + + # Execute the query in the background + asyncio.create_task(execute_query()) + # Used when the user has told us to refetch this query @use_callback def refetch() -> None: set_should_execute(True) set_loading(True) set_error(None) + # Track the refetch callback so we can re-execute the query @use_effect(dependencies=[]) def add_refetch_callback() -> Callable[[], None]: # By tracking callbacks globally, any usage of the query function will be re-run @@ -146,43 +194,30 @@ def add_refetch_callback() -> Callable[[], None]: _REFETCH_CALLBACKS[query].add(refetch) return lambda: _REFETCH_CALLBACKS[query].remove(refetch) - @use_effect(dependencies=None) - @database_sync_to_async - def execute_query() -> None: - if not should_execute: - return - - try: - # Run the initial query - new_data = query(*args, **kwargs) - - if query_options.postprocessor: - new_data = query_options.postprocessor( - new_data, **query_options.postprocessor_kwargs - ) - - except Exception as e: - set_data(None) - set_loading(False) - set_error(e) - _logger.exception( - f"Failed to execute query: {generate_obj_name(query) or query}" - ) - return - finally: - set_should_execute(False) + # The query's user API + return Query(data, loading, error, refetch) - set_data(new_data) - set_loading(False) - set_error(None) - return Query(data, loading, error, refetch) +@overload +def use_mutation( + options: MutationOptions, + mutation: Callable[_Params, bool | None] + | Callable[_Params, Awaitable[bool | None]], + refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, +) -> Mutation[_Params]: + ... +@overload def use_mutation( - mutate: Callable[_Params, bool | None], + mutation: Callable[_Params, bool | None] + | Callable[_Params, Awaitable[bool | None]], refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, ) -> Mutation[_Params]: + ... + + +def use_mutation(*args: Any, **kwargs: Any) -> Mutation[_Params]: """Hook to create, update, or delete Django ORM objects. Args: @@ -196,37 +231,99 @@ def use_mutation( loading, set_loading = use_state(False) error, set_error = use_state(cast(Union[Exception, None], None)) + if isinstance(args[0], MutationOptions): + mutation_options, mutation, refetch = _use_mutation_args_1(*args, **kwargs) + else: + mutation_options, mutation, refetch = _use_mutation_args_2(*args, **kwargs) - @use_callback - def call(*args: _Params.args, **kwargs: _Params.kwargs) -> None: - set_loading(True) - - @database_sync_to_async - def execute_mutation() -> None: - try: - should_refetch = mutate(*args, **kwargs) - except Exception as e: - set_loading(False) - set_error(e) - _logger.exception( - f"Failed to execute mutation: {generate_obj_name(mutate) or mutate}" - ) + # The main "running" function for `use_mutation` + async def execute_mutation(exec_args, exec_kwargs) -> None: + # Run the mutation + try: + if asyncio.iscoroutinefunction(mutation): + should_refetch = await mutation(*exec_args, **exec_kwargs) else: - set_loading(False) - set_error(None) + should_refetch = await database_sync_to_async( + mutation, thread_sensitive=mutation_options.thread_sensitive + )(*exec_args, **exec_kwargs) - # `refetch` will execute unless explicitly told not to - # or if `refetch` was not defined. - if should_refetch is not False and refetch: - for query in (refetch,) if callable(refetch) else refetch: - for callback in _REFETCH_CALLBACKS.get(query) or (): - callback() + # Log any errors and set the error state + except Exception as e: + set_loading(False) + set_error(e) + _logger.exception( + f"Failed to execute mutation: {generate_obj_name(mutation) or mutation}" + ) - asyncio.ensure_future(execute_mutation()) + # Mutation was successful + else: + set_loading(False) + set_error(None) + + # `refetch` will execute unless explicitly told not to + # or if `refetch` was not defined. + if should_refetch is not False and refetch: + for query in (refetch,) if callable(refetch) else refetch: + for callback in _REFETCH_CALLBACKS.get(query) or (): + callback() + + # Schedule the mutation to be run when needed + @use_callback + def schedule_mutation( + *exec_args: _Params.args, **exec_kwargs: _Params.kwargs + ) -> None: + # Set the loading state. + # It's okay to re-execute the mutation if we're told to. The user + # can use the `loading` state to prevent this. + set_loading(True) + + # Execute the mutation in the background + asyncio.ensure_future( + execute_mutation(exec_args=exec_args, exec_kwargs=exec_kwargs) + ) + # Used when the user has told us to reset this mutation @use_callback def reset() -> None: set_loading(False) set_error(None) - return Mutation(call, loading, error, reset) + # The mutation's user API + return Mutation(schedule_mutation, loading, error, reset) + + +def _use_query_args_1( + options: QueryOptions, + /, + query: Callable[_Params, _Result | None] + | Callable[_Params, Awaitable[_Result | None]], + *args: _Params.args, + **kwargs: _Params.kwargs, +): + return options, query, args, kwargs + + +def _use_query_args_2( + query: Callable[_Params, _Result | None] + | Callable[_Params, Awaitable[_Result | None]], + *args: _Params.args, + **kwargs: _Params.kwargs, +): + return QueryOptions(), query, args, kwargs + + +def _use_mutation_args_1( + options: MutationOptions, + mutation: Callable[_Params, bool | None] + | Callable[_Params, Awaitable[bool | None]], + refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, +): + return options, mutation, refetch + + +def _use_mutation_args_2( + mutation: Callable[_Params, bool | None] + | Callable[_Params, Awaitable[bool | None]], + refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, +): + return MutationOptions(), mutation, refetch diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 91cb6660..79d22072 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -2,7 +2,7 @@ class ComponentSession(models.Model): - """A model for storing component parameters. + """A model for storing component sessions. All queries must be routed through `reactpy_django.config.REACTPY_DATABASE`. """ diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 52c905ed..b508d0e9 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -5,7 +5,11 @@ from django.urls import reverse from reactpy_django import models -from reactpy_django.config import REACTPY_RECONNECT_MAX, REACTPY_WEBSOCKET_URL +from reactpy_django.config import ( + REACTPY_DATABASE, + REACTPY_RECONNECT_MAX, + REACTPY_WEBSOCKET_URL, +) from reactpy_django.types import ComponentParamData from reactpy_django.utils import _register_component, func_has_params @@ -47,7 +51,7 @@ def component(dotted_path: str, *args, **kwargs): params = ComponentParamData(args, kwargs) model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) model.full_clean() - model.save() + model.save(using=REACTPY_DATABASE) except TypeError as e: raise TypeError( f"The provided parameters are incompatible with component '{dotted_path}'." diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index e0c60f2b..b02b5161 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -30,8 +30,10 @@ "Mutation", "Connection", "ViewComponentIframe", - "Postprocessor", + "AsyncPostprocessor", + "SyncPostprocessor", "QueryOptions", + "MutationOptions", "ComponentParamData", ] @@ -79,7 +81,12 @@ class ViewComponentIframe: kwargs: dict -class Postprocessor(Protocol): +class AsyncPostprocessor(Protocol): + async def __call__(self, data: Any) -> Any: + ... + + +class SyncPostprocessor(Protocol): def __call__(self, data: Any) -> Any: ... @@ -90,7 +97,9 @@ class QueryOptions: from reactpy_django.config import REACTPY_DEFAULT_QUERY_POSTPROCESSOR - postprocessor: Postprocessor | None = REACTPY_DEFAULT_QUERY_POSTPROCESSOR + postprocessor: AsyncPostprocessor | SyncPostprocessor | None = ( + REACTPY_DEFAULT_QUERY_POSTPROCESSOR + ) """A callable that can modify the query `data` after the query has been executed. The first argument of postprocessor must be the query `data`. All proceeding arguments @@ -106,6 +115,17 @@ class QueryOptions: postprocessor_kwargs: MutableMapping[str, Any] = field(default_factory=lambda: {}) """Keyworded arguments directly passed into the `postprocessor` for configuration.""" + thread_sensitive: bool = True + """Whether to run the query in thread-sensitive mode. This setting only applies to sync query functions.""" + + +@dataclass +class MutationOptions: + """Configuration options that can be provided to `use_mutation`.""" + + thread_sensitive: bool = True + """Whether to run the mutation in thread-sensitive mode. This setting only applies to sync mutation functions.""" + @dataclass class ComponentParamData: diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 33e34038..86425552 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -13,9 +13,8 @@ from channels.db import database_sync_to_async from django.core.cache import caches -from django.db.models import ManyToManyField, prefetch_related_objects +from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects from django.db.models.base import Model -from django.db.models.fields.reverse_related import ManyToOneRel from django.db.models.query import QuerySet from django.http import HttpRequest, HttpResponse from django.template import engines @@ -65,16 +64,20 @@ async def render_view( elif getattr(view, "as_view", None): # MyPy does not know how to properly interpret this as a `View` type # And `isinstance(view, View)` does not work due to some weird Django internal shenanigans - async_cbv = database_sync_to_async(view.as_view()) # type: ignore + async_cbv = database_sync_to_async(view.as_view(), thread_sensitive=False) # type: ignore view_or_template_view = await async_cbv(request, *args, **kwargs) if getattr(view_or_template_view, "render", None): # TemplateView - response = await database_sync_to_async(view_or_template_view.render)() + response = await database_sync_to_async( + view_or_template_view.render, thread_sensitive=False + )() else: # View response = view_or_template_view # Render Check 4: Sync function view else: - response = await database_sync_to_async(view)(request, *args, **kwargs) + response = await database_sync_to_async(view, thread_sensitive=False)( + request, *args, **kwargs + ) return response @@ -250,10 +253,8 @@ def django_query_postprocessor( elif isinstance(data, Model): prefetch_fields: list[str] = [] for field in data._meta.get_fields(): - # `ForeignKey` relationships will cause an `AttributeError` - # This is handled within the `ManyToOneRel` conditional below. - with contextlib.suppress(AttributeError): - getattr(data, field.name) + # Force the query to execute + getattr(data, field.name, None) if many_to_one and type(field) == ManyToOneRel: prefetch_fields.append(field.related_name or f"{field.name}_set") @@ -310,7 +311,7 @@ def create_cache_key(*args): def db_cleanup(immediate: bool = False): - """Deletes expired component parameters from the database. + """Deletes expired component sessions from the database. This function may be expanded in the future to include additional cleanup tasks.""" from .config import REACTPY_CACHE, REACTPY_DATABASE, REACTPY_RECONNECT_MAX from .models import ComponentSession @@ -338,4 +339,4 @@ def db_cleanup(immediate: bool = False): ComponentSession.objects.using(REACTPY_DATABASE).filter( last_accessed__lte=expires_by ).delete() - caches[REACTPY_CACHE].set(cache_key, now_str) + caches[REACTPY_CACHE].set(cache_key, now_str, timeout=None) diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 9bdabf84..3679f00a 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -8,7 +8,7 @@ import dill as pickle from channels.auth import login -from channels.db import database_sync_to_async as convert_to_async +from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.utils import timezone from reactpy.backend.hooks import ConnectionContext @@ -35,7 +35,7 @@ async def connect(self) -> None: if user and user.is_authenticated: try: await login(self.scope, user) - await convert_to_async(self.scope["session"].save)() + await database_sync_to_async(self.scope["session"].save)() except Exception: _logger.exception("ReactPy websocket authentication has failed!") elif user is None: @@ -94,7 +94,7 @@ async def _run_dispatch_loop(self): if func_has_params(component_constructor): try: # Always clean up expired entries first - await convert_to_async(db_cleanup)() + await database_sync_to_async(db_cleanup, thread_sensitive=False)() # Get the queries from a DB params_query = await models.ComponentSession.objects.using( @@ -105,7 +105,9 @@ async def _run_dispatch_loop(self): - timedelta(seconds=REACTPY_RECONNECT_MAX), ) params_query.last_accessed = timezone.now() - await convert_to_async(params_query.save)() + await database_sync_to_async( + params_query.save, thread_sensitive=False + )() except models.ComponentSession.DoesNotExist: _logger.warning( f"Browser has attempted to access '{dotted_path}', " diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py index 2a512168..99f40e46 100644 --- a/tests/test_app/admin.py +++ b/tests/test_app/admin.py @@ -1,5 +1,14 @@ from django.contrib import admin -from test_app.models import ForiegnChild, RelationalChild, RelationalParent, TodoItem +from test_app.models import ( + AsyncForiegnChild, + AsyncRelationalChild, + AsyncRelationalParent, + AsyncTodoItem, + ForiegnChild, + RelationalChild, + RelationalParent, + TodoItem, +) from reactpy_django.models import ComponentSession @@ -24,6 +33,26 @@ class ForiegnChildAdmin(admin.ModelAdmin): pass +@admin.register(AsyncTodoItem) +class AsyncTodoItemAdmin(admin.ModelAdmin): + pass + + +@admin.register(AsyncRelationalChild) +class AsyncRelationalChildAdmin(admin.ModelAdmin): + pass + + +@admin.register(AsyncRelationalParent) +class AsyncRelationalParentAdmin(admin.ModelAdmin): + pass + + +@admin.register(AsyncForiegnChild) +class AsyncForiegnChildAdmin(admin.ModelAdmin): + pass + + @admin.register(ComponentSession) -class ComponentParamsAdmin(admin.ModelAdmin): +class ComponentSessionAdmin(admin.ModelAdmin): list_display = ("uuid", "last_accessed") diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 9e1b633e..6dddefaf 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,10 +1,21 @@ +import asyncio import inspect from pathlib import Path +from channels.db import database_sync_to_async from django.http import HttpRequest from django.shortcuts import render from reactpy import component, hooks, html, web -from test_app.models import ForiegnChild, RelationalChild, RelationalParent, TodoItem +from test_app.models import ( + AsyncForiegnChild, + AsyncRelationalChild, + AsyncRelationalParent, + AsyncTodoItem, + ForiegnChild, + RelationalChild, + RelationalParent, + TodoItem, +) import reactpy_django from reactpy_django.components import view_to_component @@ -201,10 +212,9 @@ def get_relational_parent_query(): def get_foriegn_child_query(): child = ForiegnChild.objects.first() if not child: - parent = RelationalParent.objects.first() - if not parent: - parent = get_relational_parent_query() - child = ForiegnChild.objects.create(parent=parent, text="Foriegn Child") + child = ForiegnChild.objects.create( + parent=get_relational_parent_query(), text="Foriegn Child" + ) child.save() return child @@ -227,6 +237,69 @@ def relational_query(): "id": "relational-query", "data-success": bool(mtm) and bool(oto) and bool(mto) and bool(fk), }, + html.p(inspect.currentframe().f_code.co_name), + html.div(f"Relational Parent Many To Many: {mtm}"), + html.div(f"Relational Parent One To One: {oto}"), + html.div(f"Relational Parent Many to One: {mto}"), + html.div(f"Relational Child Foreign Key: {fk}"), + html.hr(), + ) + + +async def async_get_or_create_relational_parent(): + parent = await AsyncRelationalParent.objects.afirst() + if parent: + return parent + + child_1 = await AsyncRelationalChild.objects.acreate(text="ManyToMany Child 1") + child_2 = await AsyncRelationalChild.objects.acreate(text="ManyToMany Child 2") + child_3 = await AsyncRelationalChild.objects.acreate(text="ManyToMany Child 3") + child_4 = await AsyncRelationalChild.objects.acreate(text="OneToOne Child") + parent = await AsyncRelationalParent.objects.acreate(one_to_one=child_4) + await parent.many_to_many.aset((child_1, child_2, child_3)) + await parent.asave() + return parent + + +async def async_get_relational_parent_query(): + # Sleep to avoid race conditions in the test + # Also serves as a good way of testing whether things are truly async + await asyncio.sleep(2) + return await async_get_or_create_relational_parent() + + +async def async_get_foriegn_child_query(): + child = await AsyncForiegnChild.objects.afirst() + if not child: + parent = await async_get_or_create_relational_parent() + child = await AsyncForiegnChild.objects.acreate( + parent=parent, text="Foriegn Child" + ) + await child.asave() + return child + + +@component +def async_relational_query(): + foriegn_child = reactpy_django.hooks.use_query(async_get_foriegn_child_query) + relational_parent = reactpy_django.hooks.use_query( + async_get_relational_parent_query + ) + + if not relational_parent.data or not foriegn_child.data: + return + + mtm = relational_parent.data.many_to_many.all() + oto = relational_parent.data.one_to_one + mto = relational_parent.data.many_to_one.all() + fk = foriegn_child.data.parent + + return html.div( + { + "id": "async-relational-query", + "data-success": bool(mtm) and bool(oto) and bool(mto) and bool(fk), + }, + html.p(inspect.currentframe().f_code.co_name), html.div(f"Relational Parent Many To Many: {mtm}"), html.div(f"Relational Parent One To One: {oto}"), html.div(f"Relational Parent Many to One: {mto}"), @@ -256,11 +329,42 @@ def toggle_todo_mutation(item: TodoItem): item.save() +def _render_todo_items(items, toggle_item): + return html.ul( + [ + html.li( + {"id": f"todo-item-{item.text}", "key": item.text}, + item.text, + html.input( + { + "id": f"todo-item-{item.text}-checkbox", + "type": "checkbox", + "checked": item.done, + "on_change": lambda event, i=item: toggle_item.execute(i), + } + ), + ) + for item in items + ] + ) + + @component def todo_list(): input_value, set_input_value = hooks.use_state("") items = reactpy_django.hooks.use_query(get_todo_query) toggle_item = reactpy_django.hooks.use_mutation(toggle_todo_mutation) + add_item = reactpy_django.hooks.use_mutation( + add_todo_mutation, refetch=get_todo_query + ) + + def on_submit(event): + if event["key"] == "Enter": + add_item.execute(text=event["target"]["value"]) + set_input_value("") + + def on_change(event): + set_input_value(event["target"]["value"]) if items.error: rendered_items = html.h2(f"Error when loading - {items.error}") @@ -274,10 +378,6 @@ def todo_list(): _render_todo_items([i for i in items.data if i.done], toggle_item), ) - add_item = reactpy_django.hooks.use_mutation( - add_todo_mutation, refetch=get_todo_query - ) - if add_item.loading: mutation_status = html.h2("Working...") elif add_item.error: @@ -285,20 +385,90 @@ def todo_list(): else: mutation_status = "" # type: ignore - def on_submit(event): + return html.div( + {"id": "todo-list"}, + html.p(inspect.currentframe().f_code.co_name), # type: ignore + html.label("Add an item:"), + html.input( + { + "type": "text", + "id": "todo-input", + "value": input_value, + "on_key_press": on_submit, + "on_change": on_change, + } + ), + mutation_status, + rendered_items, + html.hr(), + ) + + +async def async_get_todo_query(): + return await database_sync_to_async(AsyncTodoItem.objects.all)() + + +async def async_add_todo_mutation(text: str): + existing = await AsyncTodoItem.objects.filter(text=text).afirst() + if existing: + if existing.done: + existing.done = False + await existing.asave() + else: + return False + else: + await AsyncTodoItem(text=text, done=False).asave() + + +async def async_toggle_todo_mutation(item: AsyncTodoItem): + item.done = not item.done + await item.asave() + + +@component +def async_todo_list(): + input_value, set_input_value = hooks.use_state("") + items = reactpy_django.hooks.use_query(async_get_todo_query) + toggle_item = reactpy_django.hooks.use_mutation(async_toggle_todo_mutation) + add_item = reactpy_django.hooks.use_mutation( + async_add_todo_mutation, refetch=async_get_todo_query + ) + + async def on_submit(event): if event["key"] == "Enter": add_item.execute(text=event["target"]["value"]) set_input_value("") - def on_change(event): + async def on_change(event): set_input_value(event["target"]["value"]) + if items.error: + rendered_items = html.h2(f"Error when loading - {items.error}") + elif items.data is None: + rendered_items = html.h2("Loading...") + else: + rendered_items = html._( + html.h3("Not Done"), + _render_todo_items([i for i in items.data if not i.done], toggle_item), + html.h3("Done"), + _render_todo_items([i for i in items.data if i.done], toggle_item), + ) + + if add_item.loading: + mutation_status = html.h2("Working...") + elif add_item.error: + mutation_status = html.h2(f"Error when adding - {add_item.error}") + else: + mutation_status = "" # type: ignore + return html.div( + {"id": "async-todo-list"}, + html.p(inspect.currentframe().f_code.co_name), # type: ignore html.label("Add an item:"), html.input( { "type": "text", - "id": "todo-input", + "id": "async-todo-input", "value": input_value, "on_key_press": on_submit, "on_change": on_change, @@ -310,26 +480,6 @@ def on_change(event): ) -def _render_todo_items(items, toggle_item): - return html.ul( - [ - html.li( - {"id": f"todo-item-{item.text}", "key": item.text}, - item.text, - html.input( - { - "id": f"todo-item-{item.text}-checkbox", - "type": "checkbox", - "checked": item.done, - "on_change": lambda event, i=item: toggle_item.execute(i), - } - ), - ) - for item in items - ] - ) - - view_to_component_sync_func = view_to_component(views.view_to_component_sync_func) view_to_component_async_func = view_to_component(views.view_to_component_async_func) view_to_component_sync_class = view_to_component(views.ViewToComponentSyncClass) diff --git a/tests/test_app/migrations/0006_asynctodoitem.py b/tests/test_app/migrations/0006_asynctodoitem.py new file mode 100644 index 00000000..7c6f8e83 --- /dev/null +++ b/tests/test_app/migrations/0006_asynctodoitem.py @@ -0,0 +1,28 @@ +# Generated by Django 4.1.7 on 2023-03-15 01:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("test_app", "0005_alter_foriegnchild_parent"), + ] + + operations = [ + migrations.CreateModel( + name="AsyncTodoItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("done", models.BooleanField()), + ("text", models.CharField(max_length=1000)), + ], + ), + ] diff --git a/tests/test_app/migrations/0007_asyncrelationalchild_asyncrelationalparent_and_more.py b/tests/test_app/migrations/0007_asyncrelationalchild_asyncrelationalparent_and_more.py new file mode 100644 index 00000000..348f4586 --- /dev/null +++ b/tests/test_app/migrations/0007_asyncrelationalchild_asyncrelationalparent_and_more.py @@ -0,0 +1,81 @@ +# Generated by Django 4.1.7 on 2023-03-16 07:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("test_app", "0006_asynctodoitem"), + ] + + operations = [ + migrations.CreateModel( + name="AsyncRelationalChild", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.CharField(max_length=1000)), + ], + ), + migrations.CreateModel( + name="AsyncRelationalParent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("done", models.BooleanField(default=True)), + ( + "many_to_many", + models.ManyToManyField( + related_name="many_to_many", to="test_app.asyncrelationalchild" + ), + ), + ( + "one_to_one", + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="one_to_one", + to="test_app.asyncrelationalchild", + ), + ), + ], + ), + migrations.CreateModel( + name="AsyncForiegnChild", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.CharField(max_length=1000)), + ( + "parent", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="many_to_one", + to="test_app.asyncrelationalparent", + ), + ), + ], + ), + ] diff --git a/tests/test_app/models.py b/tests/test_app/models.py index ba62938b..4196864a 100644 --- a/tests/test_app/models.py +++ b/tests/test_app/models.py @@ -6,10 +6,19 @@ class TodoItem(models.Model): text = models.CharField(max_length=1000) # type: ignore +class AsyncTodoItem(models.Model): + done = models.BooleanField() # type: ignore + text = models.CharField(max_length=1000) # type: ignore + + class RelationalChild(models.Model): text = models.CharField(max_length=1000) # type: ignore +class AsyncRelationalChild(models.Model): + text = models.CharField(max_length=1000) # type: ignore + + class RelationalParent(models.Model): done = models.BooleanField(default=True) # type: ignore many_to_many = models.ManyToManyField(RelationalChild, related_name="many_to_many") # type: ignore @@ -18,6 +27,22 @@ class RelationalParent(models.Model): ) +class AsyncRelationalParent(models.Model): + done = models.BooleanField(default=True) # type: ignore + many_to_many = models.ManyToManyField(AsyncRelationalChild, related_name="many_to_many") # type: ignore + one_to_one = models.OneToOneField( # type: ignore + AsyncRelationalChild, + related_name="one_to_one", + on_delete=models.SET_NULL, + null=True, + ) + + class ForiegnChild(models.Model): text = models.CharField(max_length=1000) # type: ignore parent = models.ForeignKey(RelationalParent, related_name="many_to_one", on_delete=models.CASCADE) # type: ignore + + +class AsyncForiegnChild(models.Model): + text = models.CharField(max_length=1000) # type: ignore + parent = models.ForeignKey(AsyncRelationalParent, related_name="many_to_one", on_delete=models.CASCADE) # type: ignore diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index a83ca2b6..aa2c5196 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -25,7 +25,7 @@ SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = "test" not in sys.argv ALLOWED_HOSTS = ["*"] # Application definition @@ -71,14 +71,38 @@ # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases +# WARNING: There are overrides in `test_components.py` that require no in-memory +# databases are used for testing. Make sure all SQLite databases are on disk. DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - "TEST": {"NAME": os.path.join(BASE_DIR, "db_test.sqlite3")}, - "OPTIONS": {"timeout": 5}, + # Changing NAME is needed due to a bug related to `manage.py test` migrations + "NAME": os.path.join(BASE_DIR, "test_db.sqlite3") + if "test" in sys.argv + else os.path.join(BASE_DIR, "db.sqlite3"), + "TEST": { + "NAME": os.path.join(BASE_DIR, "test_db.sqlite3"), + "OPTIONS": {"timeout": 20}, + "DEPENDENCIES": [], + }, + "OPTIONS": {"timeout": 20}, }, } +if "test" in sys.argv: + DATABASES["reactpy"] = { + "ENGINE": "django.db.backends.sqlite3", + # Changing NAME is needed due to a bug related to `manage.py test` migrations + "NAME": os.path.join(BASE_DIR, "test_db_2.sqlite3") + if "test" in sys.argv + else os.path.join(BASE_DIR, "db_2.sqlite3"), + "TEST": { + "NAME": os.path.join(BASE_DIR, "test_db_2.sqlite3"), + "OPTIONS": {"timeout": 20}, + "DEPENDENCIES": [], + }, + "OPTIONS": {"timeout": 20}, + } + REACTPY_DATABASE = "reactpy" # Cache CACHES = { @@ -142,7 +166,7 @@ "loggers": { "reactpy_django": { "handlers": ["console"], - "level": "DEBUG", + "level": "DEBUG" if DEBUG else "WARNING", }, }, } diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 4d22cc1d..ba69185c 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -34,7 +34,9 @@

ReactPy Test Page

{% component "test_app.components.unauthorized_user" %}
{% component "test_app.components.authorized_user" %}
{% component "test_app.components.relational_query" %}
+
{% component "test_app.components.async_relational_query" %}
{% component "test_app.components.todo_list" %}
+
{% component "test_app.components.async_todo_list" %}
{% component "test_app.components.view_to_component_sync_func" %}
{% component "test_app.components.view_to_component_async_func" %}
{% component "test_app.components.view_to_component_sync_class" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 359b7dbe..2b378e37 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,25 +1,47 @@ import asyncio import os import sys +from functools import partial from channels.testing import ChannelsLiveServerTestCase +from channels.testing.live import make_application +from django.core.exceptions import ImproperlyConfigured +from django.core.management import call_command +from django.db import connections +from django.test.utils import modify_settings from playwright.sync_api import TimeoutError, sync_playwright -CLICK_DELAY = 250 # Delay in miliseconds. Needed for GitHub Actions. +CLICK_DELAY = 250 if os.getenv("GITHUB_ACTIONS") else 25 # Delay in miliseconds. class ComponentTests(ChannelsLiveServerTestCase): + databases = {"default"} + @classmethod def setUpClass(cls): + # Repurposed from ChannelsLiveServerTestCase._pre_setup + for connection in connections.all(): + if cls._is_in_memory_db(cls, connection): + raise ImproperlyConfigured( + "ChannelLiveServerTestCase can not be used with in memory databases" + ) + cls._live_server_modified_settings = modify_settings( + ALLOWED_HOSTS={"append": cls.host} + ) + cls._live_server_modified_settings.enable() + get_application = partial( + make_application, + static_wrapper=cls.static_wrapper if cls.serve_static else None, + ) + cls._server_process = cls.ProtocolServerProcess(cls.host, get_application) + cls._server_process.start() + cls._server_process.ready.wait() + cls._port = cls._server_process.port.value + + # Open a Playwright browser window if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # FIXME: This is required otherwise the tests will throw a `SynchronousOnlyOperation` - # error when discarding the test datatabase. Potentially a `ChannelsLiveServerTestCase` bug. - os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" - - super().setUpClass() cls.playwright = sync_playwright().start() headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 0))) cls.browser = cls.playwright.chromium.launch(headless=not headed) @@ -27,14 +49,35 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - super().tearDownClass() - cls.page.close() - cls.browser.close() + # Close the Playwright browser cls.playwright.stop() + # Repurposed from ChannelsLiveServerTestCase._post_teardown + cls._server_process.terminate() + cls._server_process.join() + cls._live_server_modified_settings.disable() + for db_name in {"default", "reactpy"}: + call_command( + "flush", + verbosity=0, + interactive=False, + database=db_name, + reset_sequences=False, + ) + + def _pre_setup(self): + """Handled manually in `setUpClass` to speed things up.""" + pass + + def _post_teardown(self): + """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing + database flushing. This is needed to prevent a `SynchronousOnlyOperation` from + occuring due to a bug within `ChannelsLiveServerTestCase`.""" + pass + def setUp(self): - super().setUp() - self.page.goto(self.live_server_url) + if self.page.url == "about:blank": + self.page.goto(self.live_server_url) def test_hello_world(self): self.page.wait_for_selector("#hello-world") @@ -97,6 +140,9 @@ def test_authorized_user(self): def test_relational_query(self): self.page.locator("#relational-query[data-success=true]").wait_for() + def test_async_relational_query(self): + self.page.locator("#async-relational-query[data-success=true]").wait_for() + def test_use_query_and_mutation(self): todo_input = self.page.wait_for_selector("#todo-input") @@ -105,12 +151,33 @@ def test_use_query_and_mutation(self): for i in item_ids: todo_input.type(f"sample-{i}", delay=CLICK_DELAY) todo_input.press("Enter", delay=CLICK_DELAY) - self.page.wait_for_selector(f"#todo-item-sample-{i}") - self.page.wait_for_selector(f"#todo-item-sample-{i}-checkbox").click() + self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}") + self.page.wait_for_selector( + f"#todo-list #todo-item-sample-{i}-checkbox" + ).click() + self.assertRaises( + TimeoutError, + self.page.wait_for_selector, + f"#todo-list #todo-item-sample-{i}", + timeout=1, + ) + + def test_async_use_query_and_mutation(self): + todo_input = self.page.wait_for_selector("#async-todo-input") + + item_ids = list(range(5)) + + for i in item_ids: + todo_input.type(f"sample-{i}", delay=CLICK_DELAY) + todo_input.press("Enter", delay=CLICK_DELAY) + self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}") + self.page.wait_for_selector( + f"#async-todo-list #todo-item-sample-{i}-checkbox" + ).click() self.assertRaises( TimeoutError, self.page.wait_for_selector, - f"#todo-item-sample-{i}", + f"#async-todo-list #todo-item-sample-{i}", timeout=1, ) diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 2ea278dc..4b30d58a 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -10,15 +10,19 @@ from reactpy_django.types import ComponentParamData -class DatabaseTests(TransactionTestCase): +class RoutedDatabaseTests(TransactionTestCase): + databases = {"reactpy"} + def test_component_params(self): + db = list(self.databases)[0] + # Make sure the ComponentParams table is empty - self.assertEqual(ComponentSession.objects.count(), 0) + self.assertEqual(ComponentSession.objects.using(db).count(), 0) params_1 = self._save_params_to_db(1) # Check if a component params are in the database - self.assertEqual(ComponentSession.objects.count(), 1) - self.assertEqual(pickle.loads(ComponentSession.objects.first().params), params_1) # type: ignore + self.assertEqual(ComponentSession.objects.using(db).count(), 1) + self.assertEqual(pickle.loads(ComponentSession.objects.using(db).first().params), params_1) # type: ignore # Force `params_1` to expire from reactpy_django import config @@ -28,19 +32,37 @@ def test_component_params(self): # Create a new, non-expired component params params_2 = self._save_params_to_db(2) - self.assertEqual(ComponentSession.objects.count(), 2) + self.assertEqual(ComponentSession.objects.using(db).count(), 2) # Delete the first component params based on expiration time utils.db_cleanup() # Don't use `immediate` to test cache timestamping logic # Make sure `params_1` has expired - self.assertEqual(ComponentSession.objects.count(), 1) - self.assertEqual(pickle.loads(ComponentSession.objects.first().params), params_2) # type: ignore + self.assertEqual(ComponentSession.objects.using(db).count(), 1) + self.assertEqual(pickle.loads(ComponentSession.objects.using(db).first().params), params_2) # type: ignore def _save_params_to_db(self, value: Any) -> ComponentParamData: + db = list(self.databases)[0] param_data = ComponentParamData((value,), {"test_value": value}) model = ComponentSession(uuid4().hex, params=pickle.dumps(param_data)) - model.full_clean() - model.save() + model.clean_fields() + model.clean() + model.save(using=db) return param_data + + +class DefaultDatabaseTests(RoutedDatabaseTests): + databases = {"default"} + + def setUp(self) -> None: + from reactpy_django import config + + config.REACTPY_DATABASE = "default" + return super().setUp() + + def tearDown(self) -> None: + from reactpy_django import config + + config.REACTPY_DATABASE = "reactpy" + return super().tearDown() diff --git a/tests/test_app/views.py b/tests/test_app/views.py index b172132a..0d013b1a 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -38,7 +38,7 @@ def get(self, request, *args, **kwargs): class ViewToComponentAsyncClass(View): async def get(self, request, *args, **kwargs): - return await database_sync_to_async(render)( + return await database_sync_to_async(render, thread_sensitive=False)( request, "view_to_component.html", {"test_name": self.__class__.__name__}, @@ -61,7 +61,7 @@ def view_to_component_sync_func_compatibility(request): async def view_to_component_async_func_compatibility(request): - return await database_sync_to_async(render)( + return await database_sync_to_async(render, thread_sensitive=False)( request, "view_to_component.html", {"test_name": inspect.currentframe().f_code.co_name}, # type:ignore @@ -79,7 +79,7 @@ def get(self, request, *args, **kwargs): class ViewToComponentAsyncClassCompatibility(View): async def get(self, request, *args, **kwargs): - return await database_sync_to_async(render)( + return await database_sync_to_async(render, thread_sensitive=False)( request, "view_to_component.html", {"test_name": self.__class__.__name__},