Skip to content

Refactor: address PR #1457 review feedback (rebased on main, with tests)#1559

Open
firaskafri wants to merge 12 commits intographql-python:mainfrom
firaskafri:fix/pr-1457-rebased-with-tests
Open

Refactor: address PR #1457 review feedback (rebased on main, with tests)#1559
firaskafri wants to merge 12 commits intographql-python:mainfrom
firaskafri:fix/pr-1457-rebased-with-tests

Conversation

@firaskafri
Copy link
Copy Markdown
Collaborator

Summary

This PR is a clean, rebased-on-main re-application of the work in #1457, with the regressions and breaking changes flagged in @sjdemartini's review fixed and full unit-test coverage added for every extracted helper.

It supersedes the changes from #1457 that have not already been merged via the follow-up #1459.

What's in this PR

Bug fixes (real defects)

Refactors with preserved public API

  • refactor(types): extract _validate_only_fields / _validate_exclude_fields from validate_fields. Public signature unchanged.
  • refactor(filter/utils): extract _get_field_type_from_model_field, _get_form_field, _get_field_type_and_form_field_for_implicit_filter, _get_field_type_for_explicit_filter, _is_filter_list_or_range, and _is_foreign_key_form_field. Rename module-private get_field_type -> get_field_type_from_registry.
  • refactor(views): extract _should_short_circuit_get_request and _is_atomic_mutation from execute_graphql_request. Three regressions from Reduce cognitive complexity and typo fixes #1457 are explicitly avoided and pinned by tests (see "Regression pins" below).

Non-breaking renames / aliases

  • refactor(debug): rename formating.py -> formatting.py, with a backwards-compat shim re-exporting wrap_exception from the legacy import path.
  • feat(testing): add additive assert_response_no_errors / assert_response_has_errors aliases. camelCase methods remain canonical (matching Django's assertEqual / assertNumQueries convention) so this is non-breaking (#1457 review).

Tooling

  • chore(deps): pytest-repeat and pytest-xdist go to the tests_require (and therefore the test / dev extras), not install_requires — so library consumers aren't forced to install pytest plugins.
  • New tests-repeat Makefile target (pytest --count 100 -n logical) for surfacing flaky tests.

Content fixes

  • Snake_case starwars example locals (homeOne -> home_one, etc.).
  • filter/utils.py docstring: Graphql -> GraphQL; no explicitly declared -> not explicitly declared.
  • (Other typo fixes from Reduce cognitive complexity and typo fixes #1457Dictonary, Confuesd, Millenium, etc. — were already merged via Typo fixes #1459 so they're not duplicated here.)

Regression pins (from #1457 review)

ID Bug in #1457 Pinned by
B1 Unconditional if show_graphiql: return None after extracting validation, dropping queries with GraphiQL silently test_should_short_circuit_returns_false_for_get_query, test_execute_graphql_request_get_with_query_and_graphiql_executes
B2 Second get_operation_ast call introduced inside the extracted validation method test_execute_graphql_request_calls_get_operation_ast_once
B3 if middleware is not None guard removed from __init__, breaking MIDDLEWARE = None settings test_init_with_middleware_setting_none_does_not_raise

The views.py on main has diverged significantly since #1457 was opened (post-#1439, post-#1475), so the refactor is freshly applied on top of the current code shape.

Tests

54 new tests added (all green; full suite goes from 397 -> 451 passing):

  • 10 TestValidateFieldsHelpers in graphene_django/tests/test_types.py
  • 23 in graphene_django/filter/tests/test_utils.py (new file) — including a regression test for filtering on a non-model field
  • 5 TestAssertionAliases in graphene_django/utils/tests/test_testing.py (identity + 4 end-to-end behaviour cases)
  • 16 TestExecuteGraphqlRequestRefactor in graphene_django/tests/test_views.py (B1, B2, B3, plus parametrised _is_atomic_mutation over 6 settings combinations)

Every test carries a docstring with Name / Description / Assumptions / Expectations; class and module docstrings spell out scenarios and edge cases.

Test plan

  • make tests (using project addopts) — 451 passed
  • pytest -p random_order — 451 passed
  • pytest --cov=graphene_django — 451 passed, 95% total coverage
  • Direct test of every new helper via dedicated unit test
  • No public API breakage: existing assertResponseNoErrors / assertResponseHasErrors / formating import path / validate_fields signature all still work

Commits

12 logical commits, one per scope, ordered so each refactor's tests follow the corresponding code change for clean review.

Made with Cursor

- examples/starwars/data.py: rename camelCase locals (homeOne,
  tieFighter, tieInterceptor) to snake_case to match Python style.
- graphene_django/filter/utils.py: fix "Graphql" -> "GraphQL" in the
  get_field_type docstring and "no explicitly declared" -> "not
  explicitly declared" inline comment.

Other typo fixes from PR graphql-python#1457 (Dictonary, Confuesd, Millenium,
firtsnaMe, perserved, compatability, choises, inheritence, bellow)
were already merged into main since 2023 via the follow-up PR graphql-python#1459.

Made-with: Cursor
…compat shim

The module was previously misspelled as ``formating``. The canonical file
is now :mod:`graphene_django.debug.exception.formatting`, and
``middleware.py`` imports from the corrected path.

A thin shim is left in place at the legacy ``formating`` import path
re-exporting :func:`wrap_exception` so any third-party code importing
the misspelled module continues to work. The shim is documented as
deprecated.

Made-with: Cursor
``resolve_*`` methods on a Graphene ``ObjectType`` are implicit static
methods and are invoked by graphene-core with ``(parent, info, **args)``.
The previous ``def resolve_test():`` declaration accepted zero
arguments, so any code path that actually invoked it would raise
``TypeError``. Use ``parent`` (the documented convention for implicit
static methods on ObjectType subclasses, of which Connection is one)
rather than ``self``.

Originally flagged by sjdemartini in PR graphql-python#1457:
graphql-python#1457 (comment)

Made-with: Cursor
…elds helpers

``validate_fields`` previously held two unrelated validation loops (for
``Meta.fields`` and ``Meta.exclude``) inside a single function with
nested ``if/else`` branches. Extract each loop into a private helper
(``_validate_only_fields`` and ``_validate_exclude_fields``) so each
warning case can be reasoned about and tested in isolation.

The public ``validate_fields`` signature and behaviour are preserved;
``DjangoObjectType.__init_subclass_with_meta__`` and any other callers
are unaffected.

Each new helper carries a docstring describing the cases it covers and
the warning message it emits, making future maintenance easier.

Made-with: Cursor
``get_filtering_args_from_filterset`` previously held all filter-type
resolution logic in a single deeply-nested function. Extract it into
small, individually-testable helpers:

* ``get_field_type_from_registry`` (renamed from the misleadingly
  generic ``get_field_type``) — resolves a GraphQL type from the
  DjangoObjectType registry, unwrapping NonNull.
* ``_is_foreign_key_form_field`` — predicate for FK-style form fields.
* ``_get_field_type_from_model_field`` — routes FK fields through the
  related model's ``id``, plain fields through their owning model.
* ``_get_form_field`` — resolves which form field to use to validate
  filter input (model formfield first, filter field second).
* ``_get_field_type_and_form_field_for_implicit_filter`` — handles
  implicit (Meta.fields-derived) filters, special-casing ``isnull``.
* ``_get_field_type_for_explicit_filter`` — converts a form field into
  a GraphQL type, used for explicitly-declared filters and the implicit
  fallback.
* ``_is_filter_list_or_range`` — predicate identifying ``ListFilter`` /
  ``RangeFilter`` whose argument needs ``graphene.List`` wrapping.

The convert_form_field import is moved into
``_get_field_type_for_explicit_filter`` so it stays lazy and avoids
circular-import issues just like the previous in-function import.

Behaviour is preserved end-to-end: existing filter test suite (64 tests
in graphene_django/filter/, 36 in test_fields.py) still passes.

The renamed ``get_field_type`` was a module-private symbol with no
external callers, so the rename is non-breaking.

Made-with: Cursor
Some users prefer PEP 8 snake_case for test-helper names, but the
existing camelCase methods are part of the public API documented in
README.md and docs/testing.rst, and renaming them would be a hard
breaking change for every downstream test suite.

Add ``assert_response_no_errors`` and ``assert_response_has_errors``
as additive aliases pointing at the same underlying callables. The
camelCase methods remain canonical (matching Django's own
``assertEqual`` / ``assertNumQueries`` convention); both spellings now
work identically with no deprecation warning on either one.

Originally requested as a rename in PR graphql-python#1457; addressed here as
non-breaking aliases per the reviewer feedback:
graphql-python#1457 (comment)
graphql-python#1457 (comment)

Made-with: Cursor
…pers

``execute_graphql_request`` previously held two unrelated decision
trees (the GET-vs-non-query operation check and the atomic-mutation
check) inline alongside the rest of the execution flow. Extract each
into a focused static helper:

* ``_should_short_circuit_get_request(request, operation_ast,
  show_graphiql)`` returns a boolean signalling whether the caller
  should return ``None`` (GraphiQL rendering a non-query GET), and
  raises ``HttpError`` for the disallowed GET-of-a-mutation/subscription
  case. By returning a sentinel rather than ``None``, the public
  semantics are preserved exactly: queries on GET requests still execute
  normally, and the GraphiQL no-op short-circuit is still gated on the
  operation actually being a non-query.
* ``_is_atomic_mutation(operation_ast)`` consolidates the mutation +
  ATOMIC_MUTATIONS check so it can be reasoned about and unit-tested
  in isolation.

Both helpers receive the already-parsed ``operation_ast`` rather than
re-parsing the document, so ``get_operation_ast`` is still called
exactly once per request.

This addresses the regressions identified in the review of PR graphql-python#1457:

* B1: the original PR introduced ``if show_graphiql: return None``
  unconditionally after extracting the validation, breaking the
  GET-with-query-and-GraphiQL execution path. The sentinel-return
  design avoids that.
* B2: the original PR added a second ``get_operation_ast`` call. The
  AST is now computed once and passed into both helpers.
* B3: ``__init__``'s ``if middleware is not None`` guard is preserved
  on main and remains untouched here.

Made-with: Cursor
… tests-repeat target

Add ``pytest-repeat`` and ``pytest-xdist`` to ``tests_require`` so they
are installed via ``pip install -e ".[test]"`` (and transitively via
``[dev]``) without polluting ``install_requires`` — these are
developer-tooling dependencies and library consumers must not be forced
to install pytest plugins they will never use.

Add a ``tests-repeat`` Makefile target that runs the suite 100 times in
parallel using these plugins. Useful for surfacing flaky tests; the
target docstring documents the install hint so the dependency on the
test extras is discoverable.

Made-with: Cursor
Add ``TestValidateFieldsHelpers`` exercising the new
``_validate_only_fields`` / ``_validate_exclude_fields`` helpers and
the ``validate_fields`` orchestrator directly (i.e. without going
through ``DjangoObjectType.__init_subclass_with_meta__``), so each
warning branch fails a focused test rather than only being detected
via integration coverage.

Coverage:

* ``_validate_only_fields``: known field (no warning), model attribute
  (attribute warning), unknown name (missing-field warning), ``None``
  (no-op).
* ``_validate_exclude_fields``: real field (no warning), custom field
  (no-effect warning), unknown name (missing-attribute warning),
  ``None`` (no-op).
* ``validate_fields`` orchestrator: ``ALL_FIELDS`` sentinel
  normalisation; combined call delegating to both helpers.

Each test follows the team docstring template (Name / Description /
Assumptions / Expectations) and asserts on the warning message text via
``pytest.warns(..., match=...)`` so a regression in either the
extraction or the message wording surfaces a focused failure.

Made-with: Cursor
…ield regression

Add ``graphene_django/filter/tests/test_utils.py`` with focused unit
tests for every helper extracted from
``get_filtering_args_from_filterset``:

* ``get_field_type_from_registry`` (registered model, NonNull
  unwrapping, missing model, missing field).
* ``_is_foreign_key_form_field`` (parametrised over the four
  FK-related form-field classes plus a non-FK negative case).
* ``_get_form_field`` (model_field.formfield wins, falls back to
  filter_field.field when model_field is None or the factory yields
  falsy).
* ``_get_field_type_from_model_field`` (FK -> related-model id,
  non-FK -> owning model + field name).
* ``_get_field_type_and_form_field_for_implicit_filter`` (isnull
  short-circuit, real model field, missing model field).
* ``_get_field_type_for_explicit_filter`` (uses passed form_field,
  falls back to filter_field.field).
* ``_is_filter_list_or_range`` (ListFilter, RangeFilter, plain
  CharFilter).

Plus an integration regression test that exercises
``get_filtering_args_from_filterset`` end-to-end for a method-backed
filter targeting a non-model field — pinning the subtle
behavioural-parity concern flagged in PR graphql-python#1457 review (the
implicit-filter path must still allow the explicit-filter fallback to
build the Argument when the filter has no underlying model field).

Each test follows the team docstring template (Name / Description /
Assumptions / Expectations); the file's module docstring spells out
the assumptions and scenarios that apply file-wide.

All 23 tests pass; existing 64 filter tests continue to pass.

Made-with: Cursor
Add ``TestAssertionAliases`` covering the new
``assert_response_no_errors`` / ``assert_response_has_errors`` aliases:

* Identity check: each alias must be the **same callable** as its
  camelCase counterpart, so the two spellings cannot drift apart.
* End-to-end behaviour for both spellings on a clean response and on
  an errors response, asserting both the silent-pass and
  ``AssertionError`` branches.

Uses a tiny ``_FakeHttpResponse`` stand-in so the alias tests stay
free of any dependency on routing, schemas, or the test client. Each
test follows the team docstring template.

Made-with: Cursor
…ingle AST parse

Add ``TestExecuteGraphqlRequestRefactor`` pinning every behaviour the
``execute_graphql_request`` extraction in views.py is meant to
preserve, including the three regressions identified in the PR graphql-python#1457
review.

* ``_should_short_circuit_get_request`` (5 tests):
  - POST never short-circuits.
  - GET + query never short-circuits regardless of show_graphiql
    (B1 regression pin).
  - GET + mutation + show_graphiql=True returns True so the caller
    short-circuits.
  - GET + mutation + show_graphiql=False raises HttpError carrying
    a 405 with the documented message.
  - operation_ast=None never short-circuits.
* ``_is_atomic_mutation`` (7 tests via parametrize + None case):
  - All 6 combinations of (operation kind, ATOMIC_MUTATIONS global,
    ATOMIC_MUTATIONS per-db).
  - None operation_ast returns False.
* ``execute_graphql_request`` end-to-end:
  - GET + query + show_graphiql=True executes the query and returns
    the result (B1 pin).
  - GET + mutation + show_graphiql=True returns None (existing
    behaviour preserved).
  - get_operation_ast called exactly once per request (B2 pin).
* ``GraphQLView.__init__``:
  - graphene_settings.MIDDLEWARE = None succeeds and yields
    view.middleware is None (B3 pin).

Each test follows the team docstring template (Name / Description /
Assumptions / Expectations); the class docstring spells out which
scenarios and edge cases are covered. All 16 new tests pass.

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant