Package: cuckoo-hasura
Cuckoo Hasura... making the call :PAn opinionatedclient-sideGraphQL query builder for Hasura.Allows to issue complex queries and mutations by calling simple function - no strings required (nor attached :). Results are returned aspydantic models, which means using type-save objects rather than just dictionaries. Exposes a simple python API and it comes with a tiny code-generating executable for generating models from your GQL schema.Key features:clean and intuitiveAPI that is easy and unobstructive to usespeed & memoryefficient, by returning models async or as a
generatorrobustwith a built-inre-connect optionand by passing conditions to related objects securelyfun, since your code reads just so much nicer :)ContentExamplesGetting StartedUsageAPI ReferenceExamples# 1. Query with function calls, not long stringsauthor=Query(Author).one_by_pk(uuid=some_uuid).returning(["uuid","name",Include(Article).many().returning(["title"])])# 2. Work with pydantic models (classes) instead of untyped dictionariesassertisinstance(author.uuid,UUID)andisinstance(author.name,str)# 3. Call yielding and async methods to "finalize" your queryauthors_gen=Query(Author).many().yielding()assertisinstance(authors_gen,typing.Generator)authors_coro=awaitQuery(Author).many().returning_async()assertisinstance(authors_coro,typing.Coroutine)# 4. All variables, including those of sub-queries, will be passed to Hasura as suchauthor=Query(Author).one_by_pk(uuid="ABC").returning(["uuid",Include(Article).many(where={"title":{"_eq":"Cuckoo!"}}).returning(["title"]),])# => `variables = {"uuid": "ABC", "title": { "name": {"_eq": "Cuckoo!"} } }`# 5. Make batched calls in a transactionwithInsert.batch()asBatchInsert,_,_:num_author_rows=BatchInsert(Author).many(data=author_data).yield_affected_rows()articles=BatchInsert(Article).many(data=article_data).yielding(["title"])# =># mutation Mutation {#  insert_authors(..) { affectedRows }#  insert_articles(..) { returning { title } }# }# 6. Easy debugging: just stringify cuckoo!q=Query(Author)assertstr(q)=="query Query { }"q.one_by_pk(uuid=some_uuid).returning(["uuid"])assertstr(q)=="query Query($uuid: uuid!) { authors_by_pk(uuid: $uuid) { uuid } }"Getting StartedInstall CuckoopipinstallcuckooWrite or generate your models2.1 Let the code generator query your Hasura schema API to generate the models you need. See thecodegen/READMEfor more info.2.2 Implement models manually by extendingHasuraTableModeland save them in a schema-based folder structure. See thetests/fixture/sample_models/publicfolder for examples.Usage1. ConfigurationThe first step for using Cuckoo is making sure we can connect to your Hasura instance. The easiest way to provide connection settings to Cuckoo is by simply defining 3 environment variables:HASURA_URL=http://hasura:8080/v1/graphqlHASURA_ROLE=adminHASURA_ADMIN_SECRET=hasuraAlternatively, you can provide the connection settings when instantiating your query or mutation. This comes in handy, if you need to connect to different Hasura instances within the same project:Query(Author,config={"url":"http://hasura:8080/v1/graphql""headers":{"X-Hasura-Admin-Secret":"admin","X-Hasura-Role":"hasura",}}).one_by_pk(..).returning()2. Queries and MutationsThe Cuckoo API consists of 4 classes that allow you toQuery,Insert,UpdateandDeleterecords in Hasura. A fifthMutationclass is only useful when the goal is to combine multiple mutations in a single transaction. Finally, there is theIncludeclass that can only be used inside certain methods to help including other models - seesection 2.2below for details.
Each of the 4 builder classes takes as a first argument the pydantic model that it should return. It is the only required argument to instantiate a builder class.You start building your query or mutation by calling one of the few methods that these classes expose:Query().one_by_pk(): Find one record of a table by its primary key.Query().many(): Find many records by providing awhereclause and optionally other conditions likeorder_by,limitetc.Query().aggregate(): Calculate an aggregate likecount,min,maxetc of a set of records.Insert().one(): Insert one record into a table.Insert().many(): Insert many records into the same table.Update().one_by_pk(): Update one record of a table by its primary key.Update().many(): Update many records of a table by providing awhereclause and optionally other parameters for dealing with conflicts and appending data.Update().many_distinct(): The same asUpdate().many(), but accepts a list of inputs. Each input item consists of the data to update and awhereclause to match any records in a table.Delete().one_by_pk(): Delete one record of a table by its primary key.Delete().many(): Delete many records by providing awhereclause.Furthermore, each of these classes expose a staticbatch()method that is intended to be used in an execution context. All queries and mutations executed within the context are sent to Hasura in a transaction and results are therefore only available once code execution moves beyond the execution context. Thebatch()method takes the sameconfigargument as the class constructors.For more details on each of these methods, see the API reference.2.1 Returning fieldsAll query and mutation methods (with the exception ofQuery().aggregate()) allow you to finish the query with one of the following methods:returning(),returning_async(), andyielding(). All of these methods accept acolumnsparameter to select the fields of a model being returned and it defaults to["uuid"]if not provided. Whilereturning()returns a model or list of models directly, theyielding()method returns a generator that resolves to the requested model.returning_async()returns a coroutine and is intended to be used for parallel requests. Note that queries and mutations inside abatch()execution context provideonlytheyielding()method, as results will not be immediately available.2.2 Including sub modelsIn case you would like to select a field that is actually a relation object, relation array or relation aggregate of a sub model, you can use theIncludeclass.As an example, imagine theAuthorandAuthorDetailmodel having a one-to-one relationship. To get an author's UUID and country, you would build a query as follows:Query(Author).one_by_pk(uuid="ABC").returning(["uuid",Include(AuthorDetail).one().returning(["country"]),])Similarly, provided that theAuthorand theArticlemodel have a one-to-many relationship, an insert could look like this:Insert(Author).one(data={..}).returning([# `data` would be a nested object with a list of article data"uuid",Include(Article).many().returning(["uuid"]),])Finally, we can include sub model aggregates as well. Here an example of getting the average number of words of all the authors articles when updating an author by UUID:Update(Author).one_by_pk(uuid="ABC",data={..}).returning(["uuid",Include(Article).aggregate().on(avg={"word_count"}),])Note that theIncludeclass can act as a GraphQL Fragment when being wrapped in a function:avg_number_of_words=lambda:Include(Article).aggregate().on(avg={"word_count"})new_author=Insert(Author).one(data={..}).returning(["uuid",avg_number_of_words()])updated_author=Update(Author).one_by_pk(uuid=..,data={..}).returning(["uuid",avg_number_of_words()])2.3 AggregationsQuery().aggregate()andInclude().aggregate()allow you to calculate aggregates of a set of models or sub models respectively. To retrieve the aggregate you are looking for, the queries needs to be build in 2 steps:By callingaggregate()on the builder instance, you can specify awhereclause,limitthe number of records to aggregate on and so forth. It has the same purpose asQuery().many().The next step is to callon()(oryield_on()) for specifying whatkindof aggregate you are looking for. In case you just want the number of aggregated records, you can call it withon(count=True)and would get back an integer. For all other aggregation methods, you need to provide at least one field name (of a numeric field!) to aggregate on. To get the maximum of some numeric field, for example, you would callon(max={"field_name"}). Its perfectly fine to use more than one aggregation function.By default, the returning object of an aggregate query (except forcount) is an untyped object:aggregates=Query(Comment).aggregate(where=..).on(max={"likes"},min={"likes"})assertisinstance(aggregates.max,Any)assertisinstance(aggregates.max.likes,float)If you prefer typed objects to be returned from aggregate functions, you need to tell Cuckoo how they are structured. Let's build our model the following way:classCommentBase(BaseModel):uuid:Optional[UUID]article_uuid:Optional[UUID]content:Optional[str]likes:Optional[int]classCommentNumerics(BaseModel):likes:Optional[float]classComment(HasuraTableModel,CommentBase):_table_name="comments"article:Optional[Article]For theminandmaxaggregations, the returned object is the model without any sub model relations, soCommentBasein our case. Provide it as thebase_modelargument to get a typed object back:aggregates=Query(Comment,base_model=CommentBase).aggregate(where=..).on(max={"likes"})assertisinstance(aggregates.max,CommentBase)assertisinstance(aggregates.max.content,str)For all other aggregations, the returned object is a model containing only its numeric fields. The type of the numeric fields needs to befloat. This isCommentNumericsin our example. Provide it as thenumeric_modelargument to get the correct object back:aggregates=Query(Comment,numeric_model=CommentNumerics).aggregate(where=..).on(avg={"likes"})assertisinstance(aggregates.avg,CommentNumerics)assertisinstance(aggregates.avg.likes,float)Finally, you can ask for the aggregated models (Hasura calls themnodes) to be included in the response. Call thewith_nodes()(oryield_with_nodes()) method on theQuery().aggregate()builder instead of theon()method to do so. Here a simple example:aggregates,nodes=Query(Comment).aggregate(where=..).with_nodes(aggregates={"avg":{"likes"}},columns=["uuid",Include(Article).one().returning(["uuid"])])assertisinstance(aggregates.avg.likes,float)assertisinstance(nodes[0],Comment)assertisinstance(nodes[0].article,Article)As you can see from the above example, thecolumnsargument accepts the same input as thecolumnsargument of thereturning()method. This means you can useIncludeto include sub models when asking for nodes of an aggregation.API referenceclasscuckoo.Query(model: Type[TMODEL], config: dict = DEFAULT_CONFIG, logger: Logger = None, base_model: Type[TMODEL_BASE] = UntypedModel, numeric_model: Type[TNUM_PROPS] = UntypedModel)The builder for making Hasura queries.Args:model: The pydantic model of the return valueoptionalconfig: The connection details to the Hasura server and other configurationsoptionallogger: logs the query and any errorsoptionalbase_model: The model (without any relations) to be used forminandmaxaggregation results. Defaults to a blank model withextra="allow"for easy, but untyped access.optionalnumeric_model: A model that contains only the numeric properties of the base model as floats to return all aggregate results, except those ofminandmax. Defaults to a blank model withextra="allow"for easy, but untyped access.Query.many(where: Dict[str, Any] = None, distinct_on: Set[str] = None, limit: int = None, offset: int = None, order_by: Dict[str, "asc"|"desc"] = None) -> cuckoo.ReturningFinalizer[list[TMODEL]] | cuckoo.YieldingFinalizer[list[TMODEL]]Build a query for a list of models.Args:optionalwhere: The where clause to filter the result set withoptionaldistinct_on: The distinct clause to the queryoptionallimit: The maximum number of results returnedoptionaloffset: The offset being skipped from the result setoptionalorder_by: The order-by clause to the queryReturns:classcuckoo.ReturningFinalizer[list[TMODEL]] for queries outside of abatch()execution contextclasscuckoo.YieldingFinalizer[list[TMODEL]] for queries inside abatch()execution contextQuery.one_by_pk(uuid: UUID) -> cuckoo.ReturningFinalizer[TMODEL] | cuckoo.YieldingFinalizer[TMODEL]Build a query for finding a single model by its UUID.Args:uuid: The UUID to query forReturns:classcuckoo.ReturningFinalizer[TMODEL] for queries outside of abatch()execution contextclasscuckoo.YieldingFinalizer[TMODEL] for queries inside abatch()execution contextRaises:NotFoundErrorif no result was foundQuery.aggregate(where: Dict[str, Any] = None, distinct_on: Set[str] = None, limit: int = None, offset: int = None, order_by: Dict[str, "asc"|"desc"] = None) -> cuckoo.AggregateFinalizer| cuckoo.YieldingAggregateFinalizerBuild an aggregate query.Args:optionalwhere: The where clause to filteroptionaldistinct_on: The distinct clauseoptionallimit: The maximum number of recordsoptionaloffset: The offset being skipped from matching recordsoptionalorder_by: The order-by clauseReturns:classcuckoo.AggregateFinalizerfor queries outside of abatch()execution contextclasscuckoo.YieldingAggregateFinalizerfor queries inside abatch()execution contextQuery.sql_function(function_name: str, args: dict[str, Any] = None, where: WHERE = None, distinct_on: set[str] = None, limit: int = None, offset: int = None, order_by: ORDER_BY = None)Build a query for a custom defined function. Note that this method always returns a list of models, even if the function just returns a single row.Args:function_name: The name of the custom function to queryoptionalargs: The arguments passed to the function. Note that the utility functionutils.to_sql_function_args()is used to convert input into the expected format.optionalwhere: The where clause to filteroptionaldistinct_on: The distinct clauseoptionallimit: The maximum number of recordsoptionaloffset: The offset being skipped from matching recordsoptionalorder_by: The order-by clauseReturns:staticQuery.batch(config: dict = DEFAULT_CONFIG, logger: Logger = None) -> Generator[BatchQuery, None, None]Returns an execution context for running multiple queries in a transaction. This method is to be used in awithstatement and returns a transaction-boundQueryconstructor. Any instance of that constructor executes its query in the same transaction. Note that the query is submitted to Hasura on closing the execution context. Hence, the queries only support results as generators, as delivery of the results is delayed. Example:withQuery.batch()asBatchQuery:authors_over_50=BatchQuery(Author).many(where={"age":{"_gt":50}}).yielding(["age"])next(authors_over_50)# ERROR! The query has not been executed yet!next(authors_over_50)# OKstaticQuery.batch_async(config: dict = DEFAULT_CONFIG, logger: Logger = None) -> Generator[BatchQuery]A asynchronous version ofQuery.batch(). Example:asyncdefget_authors():asyncwithQuery.batch_async()asBatchQuery:authors_over_50=BatchQuery(Author).many(where={"age":{"_gt":50}}).yielding()special_author=BatchQuery(Author).one_by_pk(uuid=special_uuid).yielding()returnauthors_over_50,next(special_author)authors_over_50,special_author=awaitget_authors()forauthor_over_50inauthors_over_50:assertspecial_author.uuid!=author_over_50.uuidclasscuckoo.Insert(model: Type[TMODEL], config: dict = DEFAULT_CONFIG, logger: Logger = None)Insert.many(data: list[dict[str, Any]] = None, on_conflict: OnConflict = None):Insert.one(data: dict[str, Any] = None, on_conflict: dict[str, str] = None):classcuckoo.Update(model: Type[TMODEL], config: dict = DEFAULT_CONFIG, logger: Logger = None)Update.many(where: dict, data: dict = None, inc: dict = None, append: dict = None, prepend: dict = None, delete_key: dict = None, delete_elem: dict = None, delete_at_path: dict = None):Update.many_distinct(updates: list[dict]):Better method naming?Update.one_by_pk(pk_columns: dict = None, data: dict = None, append: dict = None, delete_at_path: dict = None, delete_elem: dict = None, delete_key: dict = None, inc: dict = None, prepend: dict = None):classcuckoo.Delete(model: Type[TMODEL], config: dict = DEFAULT_CONFIG, logger: Logger = None)Delete.many(where: dict = None):Delete.one_by_pk(uuid: UUID):classcuckoo.MutationstaticMutation.batch(config: dict = None, logger: Optional[Logger] = None):Returns an execution context for running multiple mutations in a transaction. This method is to be used in awithstatement and returns a tuple of transaction-bound constructors:(Insert, Update, Delete). Any instance of that constructor executes its query in the same transaction. Note that the query is submitted to Hasura on closing the execution context. Hence, the queries only support results as generators, as delivery of the results is delayed. Example:withMutation.batch()asBatchInsert,BatchUpdate,_:num_author_rows=BatchInsert(Author).many(data=author_data).yield_affected_rows()articles=BatchUpdate(Article).many(data=article_data,where={"created_at":{"_lt":"now"}}).yielding(["title"])next(num_author_rows)# ERROR! The query has not been executed yet!next(num_author_rows)# OKstaticMutation.batch_async(config: dict = None, logger: Optional[Logger] = None):A asynchronous version ofMutation.batch(). Example:asyncdefdo_in_transaction():asyncwithMutation.batch_async()asBatchInsert,BatchUpdate,_:num_author_rows=BatchInsert(Author).many(data=author_data).yield_affected_rows()articles=BatchUpdate(Article).many(data=article_data,where={"created_at":{"_lt":"now"}}).yielding(["title"])returnnext(num_author_rows),list(articles)num_author_rows,articles=awaitdo_in_transaction()classcuckoo.Include(model: Type[TMODEL], key: str = None)Include.many(where: WHERE = None, distinct_on: set[str] = None, limit: int = None, offset: int, order_by: ORDER_BY = None)Include.one()Include.aggregate(where: WHERE = None, distinct_on: set[str] = None, limit: int = None, offset: int = None, order_by: ORDER_BY = None)classcuckoo.YieldingFinalizerProvideyieldingmethod to finalize queries that require a list of columns.YieldingFinalizer.yielding(columns: list[str | TINCLUDE] = ["uuid"]) -> Generator[TMODEL]Get a generator that resolves to the result of the query with the columns provided.Args:columns: The columns of the result objectReturns:Generator resolving to a list or a single modelclasscuckoo.ReturningFinalizerextends:YieldingFinalizerProvidereturning/yieldingmethods to finalize queries that require a list of columns.ReturningFinalizer.returning(columns: list[str | TINCLUDE] = ["uuid"]) -> TMODEL | list[TMODEL]Get the result of the query with the columns provided.Args:columns: The columns of the result objectReturns:a list or a single modelReturningFinalizer.returning_async(columns: list[str | TINCLUDE] = ["uuid"]) -> TMODEL | list[TMODEL]An asynchronous version ofReturningFinalizer.returning().classcuckoo.YieldingAffectedRowsFinalizerextends:YieldingFinalizerProvideyielding,yield_affected_rows,yielding_with_rowsmethods to finalize queries that either return a number, a list of models, or both.YieldingAffectedRowsFinalizer.yield_affected_rows()Get a generator that resolves to the number of affected rows.Args:columns: The columns of the result objectReturns:a list or a single modelYieldingAffectedRowsFinalizer.yielding_with_rows(columns: list[str | TINCLUDE] = ["uuid"]) -> tuple[Generator[TMODEL], Generator[int]]Get the result of the query with the columns provided as well as the number of affected rows.Args:columns: The columns of the result objectReturns:a tuple of 2 generators, the first resolves to a list of model, the second to the number of affected rowsclasscuckoo.AffectedRowsFinalizerextends:YieldingFinalizer,YieldingAffectedRowsFinalizerProvidereturning,affected_rows,returning_with_rowsmethods (on top of their yielding counter parts) to finalize queries that either return a number, a list of models, or both.AffectedRowsFinalizer.affected_rows() -> intGet the number of affected rows.Returns:the number of affected rows.AffectedRowsFinalizer.affected_rows_async() -> intAn asynchronous version ofAffectedRowsFinalizer.affected_rows().AffectedRowsFinalizer.returning_with_rows(columns: list[str | "TINCLUDE"] = ["uuid"])Get the result of the query with the columns provided as well as the number of affected rows.Args:columns: The columns of the result objectReturns:a tuple with the first element being a list of models and the second being the number of affected rowsAffectedRowsFinalizer.returning_with_rows_async() -> intAn asynchronous version ofAffectedRowsFinalizer.returning_with_rows().classcuckoo.YieldingAggregateFinalizerProvideyield_onandyield_with_nodesmethods to finalize queries that return aggregates or aggregates along with their nodes.YieldingAggregateFinalizer.yield_on(count: Union[bool, CountDict] = None, avg: set[str] = None, max: set[str] = None, min: set[str] = None, stddev: set[str] = None, stddev_pop: set[str] = None, stddev_samp: set[str] = None, sum: set[str] = None, var_pop: set[str] = None, var_samp: set[str] = None, variance: set[str] = None)Get a generator that yields an aggregate model.Args:optionalcount: EitherTrueor a dict indicating the column to countoptionalavg: A set of column names to avarage onoptionalmax: A set of column names to find the maximum foroptionalmin: A set of column names to find the minimum foroptionalstddev: A set of column names to calculate the standard deviation onoptionalstddev_pop: A set of column names to calculate the standard deviation onoptionalstddev_samp: A set of column names to calculate the standard deviation onoptionalsum: A set of column names that should be summed upoptionalvar_pop: A set of column names to calculate the variance onoptionalvar_samp: A set of column names to calculate the variance onoptionalvariance: A set of column names to calculate the variance onReturns:A generator that yields anAggregateModel. Each provided aggregate argument of this method populates the corresponding property of the returning aggregate model.YieldingAggregateFinalizer.yield_with_nodes(aggregates: AggregatesDict, nodes: list[dict])Args:aggregates: The aggregates with their respective column(s) to do the aggregation onnodes: The columns of the nodes that should be returnedReturns:anAggregateModelwith the selected memebers being populatedclasscuckoo.AggregateFinalizerAggregateFinalizer.on(count: Union[bool, CountDict] = None, avg: set[str] = None, max: set[str] = None, min: set[str] = None, stddev: set[str] = None, stddev_pop: set[str] = None, stddev_samp: set[str] = None, sum: set[str] = None, var_pop: set[str] = None, var_samp: set[str] = None, variance: set[str] = None)see YieldingAggregateFinalizer.yield_on()AggregateFinalizer.on_async(count: Union[bool, CountDict] = None, avg: set[str] = None, max: set[str] = None, min: set[str] = None, stddev: set[str] = None, stddev_pop: set[str] = None, stddev_samp: set[str] = None, sum: set[str] = None, var_pop: set[str] = None, var_samp: set[str] = None, variance: set[str] = None)An asynchronous version ofAggregateFinalizer.on().AggregateFinalizer.with_nodes(aggregates: AggregatesDict, nodes: list[dict])see YieldingAggregateFinalizer.yield_with_nodes()AggregateFinalizer.with_nodes_async(aggregates: AggregatesDict, nodes: list[dict])An asynchronous version ofAggregateFinalizer.with_nodes().AggregateFinalizer.count(columns: set[str] = None, distinct: bool = None) -> intAggregateFinalizer.avg(columns: set[str]) -> floatAggregateFinalizer.max(columns: set[str]) -> floatAggregateFinalizer.min(columns: set[str]) -> floatAggregateFinalizer.sum(columns: set[str]) -> floatWishlisttype checkermaybe more syntactic sugar, like havingwhereandorder_bymethods (?):# Build queries dynamicallydefget_by_A_or_b(a=None,b=None):q=Query(Author).query_all(where={"deleted":{"_eq":False}})ifa:q.and_where({"a":{"_eq":a}}).order_by({"a":"asc"})elifb:q.and_where({"b":{"_eq":b}}).order_by({"b":"desc"})else:raiseValueError("Need either a or b!")returnq.returning()# query/insert large data sets in batches# TODO: think of a suiting API