gql-dynamic-query-builder 1.2.0
Dynamic GraphQL query builder
Requires Python: >=3.10

## About this project

This project emerged from the frustrating experience when trying to
write gql queries with dynamic filters. In some cases, think of a UI
to beautifully display database contents, one desires to be able to set filters 
dynamically if some value is provided, yet just ignore it if not.

This behavior was guaranteed by Hasura versions 1.3.3v and below - but disabled by default in later versions,
as described [here](https://hasura.io/docs/2.0/queries/postgres/filters/index/#pg-null-value-evaluation).

The env var `HASURA_GRAPHQL_V1_BOOLEAN_NULL_COLLAPSE` preserves this functionality globally. However,
this as well is not always desired, when a more fine-grained control over when a value is strictly necessary
for where conditions, and when not, is of importance.

This project therefore provides a **lightweight query builder** that takes a simple gql query string
as input and inserts the respective clauses if a value is presented, and does nothing, if not.
This avoids nasty, error-prone, string concatenations while avoiding heavy-weight ASTs and the necessity
of schema declarations as with [gql-DSL](https://gql.readthedocs.io/en/v3.0.0/modules/dsl.html).

## New in v1.2.0

**Support for `_and` & `_or` operations**:

DSL:
~~~
result = (
    dynamic_query(query)
    .table('product')
    ._or([
        _and((
            where('name', is_optional=True)
            ._eq('abc'),
            where('price')
            ._gt(10)
        )),
        where('name')
        ._ilike('def'),
        where('brand')
        ._eq('my_favorite_brand'),
        where('tag')
        ._in(['technology', 'houselhold']),
        ]
    )
    .build()
)
~~~

**Note:** With the `is_optional` flag set to `True` in a `where` subquery,
it is not included in the generated query-string if the provided value is 'None'.

builder:
~~~
    builder.with_where_clause(
        'product',
        (
            ('name', 'price'),
            'name',
            'brand',
            'tag'
        ),
        (('abc', 10), 'def', 'my_favorite_brand', ['technology', 'houselhold']),
        (('_eq', '_gt'), '_ilike', '_eq', '_in')),
        skip_if_none=((True, False), False, False, False)
        wrap_in_or=True,
    )
~~~
The builder works by alternatingly apply `_or` & `_and` operations
based on the tuple structure, beginning with `_or`. E.g.
`(field_1, (field_2, (field_3, field_4))` results in a clause like:
~~~
_or {[
    field_1 ...
    _and {[
        field_2 ...
        _or {[
            field_3 ...
            field_4 ...
        ]}
    ]}
    ]}
~~~

This is a working system, yet too unreadable for production code.
Therefore, it is highly encouraged to use the DSL for complicated queries.

## Usage

All a user should be interacting with is located in the `src.api` package.
As of now it only contains the `GQLDynamicQueryBuilder` which is the heart of the project and
provides all the necessary functionality, while `api.dsl` provides a more user-friendly and convenient wrapper around the builder, as described [here](#dsl). To extend a query by an optional where clause one can do the
following:

~~~
    query = """
        query TestQuery {
            product {
                name
                brand
            }
        }
    """
    
    builder = GQLDynamicQueryBuilder(query)
    builder = builder.with_where_clause(
                table_name='product', 
                field_name='name', 
                value='tomato', 
                operation='_ilike',
                skip_if_none=True
              )
    result = builder.build() # returns the transformed query as a string
~~~

This also works for queries with existing where clauses and other query parameters (e.g. limit).
To access nested fields, simply use '.' as the delimiter:

~~~
    builder.with_where_clause(
        'product', 
        'brand.name', # will build brand { name: ...
        'ABC', 
        '_eq',
        skip_if_none=True
    )
    result = builder.build()
~~~

Furthermore, it is also possible to provide the following to `with_where_clause`:
- `values: list` and `operation: str` which allows for set-operations like `_in`
- `values: list` and `operation: list[str]` which allows for multiple operations for the same field
  (e.g. `timestamp {_gte: <ts1> _lt: <ts2>}`)

As a fallback also explicit where clauses are supported via `table_name: clause` dictionaries:

~~~
    builder.with_where_clauses(
        {'product': 'name: {_ilike : "tomato"}'}
    )
    result = builder.build()
~~~

### DSL

Since version `1.1.0` a more user-friendly DSL is provided under `api.dsl`.
The DSL is a wrapper around the builder pattern that enables a continuous flow of information, such as the table
name for which a filter is to be set. E.g. look at this builder-based query:

~~~
    result = (GQLDynamicQueryBuilder(query)
                  .with_where_clause('product', 'name', ['a', 'b', 'c'], '_in', skip_if_none=True)
                  .with_offset('product', 15)
                  .with_limit('product', 25)
                  .with_where_clause('product', 'price', 5, '_gte')
                  ).build()
~~~

All function calls in the above examples return a `GQLDynamicQueryBuilder` object, whose
state is modified along the way. Thereby, the table name is repeated 4 times and the code
is only marginally readable. Using the DSL on the other hand the same functionality can be achieved
through the following:

~~~
    result = (dynamic_query(query).table('subquery_to_test')
                  .where('test', is_optional=True)
                  ._in(['a', 'b', 'c'])
                  .offset(15)
                  .limit(25)
                  .where('test2')
                  ._gte(5)
                  .build()
                  )
~~~

This way the query construction becomes much more readable and concise.
Furthermore, the method chaining is guarded by the type system, i.e.
there is no need to worry to e.g. chain `build()` to a `where()` as the dsl
is designed to only allow cohesive queries.