Package: crud-templates
I think you’re a very special young man, or I wouldn’t have asked you
to help me, now would I ?Installationpipinstallcrud-templatesUsageA thorough documentation is in constructionThe module provides template classes for CRUD controllers using
SQLAlchemy connexions to a database. They are meant to provide easy to
use methods for executingGET,PUT,PATCH,DELETEstatements on single a table, or searches for entities usingPOSTstatements, within a model-views-controllers framework.For all templates, the simplest usage is to inherit the template,
supercharge the__init__method to provide the target table and the
SQLAlchemy session, and use the appropriate CRUD method.ExamplesPUTentity creation or replacementDefine the controller usingInsertMapperTemplate:fromloggingimportgetLoggerfromcrudenceimportInsertMapperTemplatefromdb.sessionimportdb_sessionfromdb.modelsimportPersonsclassPersonsMapper(InsertMapperTemplate):IDENTIFYING_FIELDS=["id"]def__init__(self):super().__init__(target=Persons,session=db_session)self.logger=getLogger(__name__)The method for aPUTendpoint can then be something like :fromflaskimportapp,requestfromhttpimportHTTPStatusfrommapperimportPersonsMapper@app.put("/person")defput_person()->tuple:body=request.get_json()put_controller=PersonsMapper()# you might want to validate the input body before calling the mappercreated_id=put_controller.put(body=body)ifcreated_id:response={"message":"created","id":created_id},HTTPStatus.CREATEDelse:response={"message":"replaced"},HTTPStatus.NO_CONTENTreturnresponseIfiddoes not appear in the input body, a new row will be created
within the target table. If it is provided, the corresponding row will
be replaced.PATCHentity updateDefine the same controller as forPUTstatements, since the logic is
the same as the one for creation or replacement. AnEntityNotFoundwill be raised if the targeted entity is not found.The method for aPATCHendpoint can then be something like :fromflaskimportapp,requestfromhttpimportHTTPStatusfrommapperimportPersonsMapper@app.patch("/person")defpatch_person()->tuple:body=request.get_json()put_controller=PersonsMapper()# you might want to validate the input body before calling the mapperput_controller.patch(body=body)response={"message":"updated"},HTTPStatus.NO_CONTENTreturnresponseGETorDELETEa target entityGETandDELETEoperations revolve around the same logic to
identify a target row, then either serialize its informations to a json
output or to delete it. It inherits from theInsertMapperTemplateto
use its target finding methods.Define a controller usingGetterDeleterTemplate.TARGET_FIELDSmust contain the columns to serialize ;DATE_FIELDSrefers the date
fields that are to be serialized as isoformat strings ;ENUM_FIELDScontains the fields of class Enum that are to be serialized as strings.fromloggingimportgetLoggerfromcrudenceimportGetterDeleterTemplatefromdb.sessionimportdb_sessionfromdb.modelsimportPersonsclassPersonsGetter(GetterDeleterTemplate):IDENTIFYING_FIELDS=["id"]TARGET_FIELDS=["id","firstname","lastname","birthday"]DATE_FIELDS=["birthday"]ENUM_FIELDS=["gender"]def__init__(self):super().__init__(target=Persons,session=db_session)self.logger=getLogger(__name__)Thegetanddeletemethods accepts any keywords arguments so
that you can pass the target field(s) with its value from the endpoint
input.fromflaskimportappfromhttpimportHTTPStatusfromfinderimportPersonsGetter@app.get("/person/<person_id>")# id is given as path parameterdefget_person(person_id:int)->tuple:get_controller=PersonsGetter()try:serialised_entity=get_controller.get(id=person_id)exceptEntityNotFound:response={"message":"Entity not found"},HTTPStatus.NOT_FOUNDelse:response=serialised_entity,HTTPStatus.OKreturnresponseLet’s illustrate how you can easily use several identifying fields to
get or delete an entity (works for put and patch controllers too), for
instance for a relationship table where the primary key is composed of
two columns :fromloggingimportgetLoggerfromcrudenceimportGetterDeleterTemplatefromdb.sessionimportdb_sessionfromdb.modelsimportPersonRelativesclassRelativesDeleter(GetterDeleterTemplate):IDENTIFYING_FIELDS=["person_id","relative_id"]def__init__(self):super().__init__(target=PersonRelatives,session=db_session)self.logger=getLogger(__name__)The method for theDELETEendpoint would then be# identifiers are given as query parameters /person_relative?person_id=1&relative_id=2@app.delete("/person_relative")defdelete_relative()->tuple:controller=RelativesDeleter()try:# will be parsed as delete(person_id=1, relative_id=2)controller.delete(**request.args)exceptEntityNotFound:response={"message":"not found"},HTTPStatus.NOT_FOUNDexceptMultipleResultsFound:response={"message":"bad request"},HTTPStatus.BAD_REQUESTelse:response={"message","deleted"},HTTPStatus.OKreturnresponsePOSTto search entity ids (or other serialisable information)ThePostSearchControllerTemplateallows to search for entities
according to a set of customisable filters, that can easily be spread
across several joined tables.By default, filters are key-values pairs corresponding to the target
table’s columns. The typesstring, integers, dates, lists and enumsare automatically converted to the appropriate filtering behaviour, and
dictionary allows to define more complex filters (ranges, joins or
custom).Let’s define a search controller for persons that can filter on thePersonstable as well as thePersonRelativestable.fromloggingimportgetLoggerfromsqlalchemy.ormimportQueryfromcrudenceimportPostSearchControllerTemplatefromdb.sessionimportdb_sessionfromdb.modelsimportPersons,PersonRelativesclassSearchPersonsController(PostSearchControllerTemplate):JOINS_FILTERS=[{"slot":"person_relatives","target":PersonRelatives,"map_method":"map_relatives"}]RANGE_FILTERS=[{"slot":"birthday_range","target":Persons.birthday,"is_date":True}]DATE_FILTERS=["birthday"]def__init__(self):super().__init__(target=Persons,session=db_session)self.logger=getLogger(__name__)defmap_relatives(self,partial:Query,filters:dict)->Query:# This method defines how to join with the PersonRelatives in order to# apply filters onto it laterpartial=partial.join(PersonRelatives,Persons.id==PersonRelatives.person_id)returnpartialThe method for thePOSTcontroller can be simply called withsearch(body). Contrary to other controllers, the input body
structure is constrained to conformPostInputSchema, which must be
inherited from for your controller. See the schema itself for details on
available fields.Here is an example of an input search and the corresponding methodfromjson_modelsimportSearchPersonInputSchema,SearchPersonOutputSchemafromsearch_personsimportSearchPersonsControllerinput_search={"fields":["id","firstname","lastname","birthday"]"order_by":[{"field":"id","direction":"desc"}],"filters":{"firstname":["John","Mike"]"person_relatives":{"relative_id":[1,2,4,8]},"birthday_range":{"lower_bound":"1985-01-03","upper_bound":"1990-01-01"}},"pagination":{"size":10,"page":3,"compute":False# Compute total calculations only if you need it}}@app.post("/person")defsearch_persons()->tuple:body=request.get_json()validated_body=SearchPersonInputSchema().load(body)controller=SearchPersonsController()result=controller.search(**validated_body)returnSearchPersonOutputSchema().load(result),HTTPStatus.OKby default all filters are combined with anandoperator, if you
want otherwise addoperator_choice = "or"in the input bodyto serialise fields from any join table, supercharge the_serializemethodjoining with tablePersonRelativesoccurs onlyif a filter on
that table appear in the input body(and the join is defined inJOINS_FILTERS)the mapping method provided in the joins filter definition is where
you define the joining behaviour for that filter. The corresponding
filter from the input body has to be passed on as second argument to
the method, so you can define complex joins depending on values if
needed. This might need some heavy customisation though, see below
for a exampleCustomisationAll templates inherits from thePostSearchTemplateToolsthat
contains the filter building methods, which are used by all controllers
to find the target identities.This means that if you define the appropriate input body structure (as
json forPUTandPATCHor keywords parameters forGET) and
SQLAlchemy query, you can define complex filtering to look for entities
to update, to get, to delete. This implies to supercharge or rewrite
some methods from the controller as specified in each controller’s
documentation.Example customisations for aGETThe same goes forGETserialisation : itsTARGET_FIELDSare
passed as such as arguments to the_serializemethod. If you
supercharge or rewrite it, you could then serialise fields from other
tables than the target one, provided it was included in the partial
query as a join.Let’s define aGETcontroller to serialise found entities from aPOSTsearch response, continuing on the previous example.fromloggingimportgetLoggerfromcrudenceimportGetterDeleterTemplatefromdb.sessionimportdb_sessionfromdb.modelsimportPersons,PersonOccupations,OccupationsclassGetFamilyController(GetterDeleterTemplate):IDENTIFYING_FIELDS=["id"]TARGET_FIELDS=["firstname","lastname"]JOIN_FIELDS=[{"table":"Occupations","fields":["label"],"output_key":"occupation"}]def__init__(self):super().__init__(target=Persons,session=db_session)self.logger=getLogger(__name__)def_init_partial_query(self)->Query:# Adding Occupations to the query allow to get its columns in the resultreturnself.session.query(Persons,Occupations)\.join(PersonOccupations)\.join(Occupations)def_serialize(self,element:Tuple[Persons,Occupations],selected_fields:dict)->dict:# flat serialisation of fields from the main target tableoutput=super()._serialize(element.Persons,self.TARGET_FIELDS)# nested serialisation of fields from join table(s)forjoin_fields_definself.JOIN_FIELDS:join_table=getattr(element,join_fields_def["table"])# element.Occupationsserialised_join=super()._serialize(element=join_table,selected_fields=join_fields_def["fields"])output[join_fields_def["output_key"]]=serialised_joinreturnoutputThen a simpleGetFamilyController().get(id=2)would return :{"firstname":"John","lastname":"Doe","occupation":{"label":"secret agent"}}Beware that this can tremendously increase the charge on the database if
the join is heavy. In that case you should manually get the desired
fields from the join table after serialisation, using a simple filter on
the join table from the found entities.Here’s another way to customise the controller that avoids a join to
query attributes from another table, for the same input and output.fromloggingimportgetLoggerfromcrudenceimportGetterDeleterTemplatefromdb.sessionimportdb_sessionfromdb.modelsimportPersons,PersonOccupations,OccupationsclassGetFamilyController(GetterDeleterTemplate):IDENTIFYING_FIELDS=["id"]TARGET_FIELDS=["firstname","lastname"]def__init__(self):super().__init__(target=Persons,session=db_session)self.logger=getLogger(__name__)def_serialize(self,element:Persons,selected_fields:dict)->dict:# flat serialisation of fields from the main target tableoutput=super()._serialize(element.Persons,self.TARGET_FIELDS)# Manual get of the joined attributes for Occupations tableperson_occupation=self.session.query(Occupations)\.filter(Occupations.person_id==element.id)\.first()output["occupation"]=super()._serialize(element=person_occupation,selected_fields=["label"])returnoutputHandling heavy joinsThePostSearchControllerTemplateworks best for easy use cases when
the filtering query is not to costly to execute, often due to joins. The
default behaviour is to join only if the filter on the join table is
asked, but even this might not be sufficient to lighten the query.There are many ways to customise the controller so that it behaves the
way you need. In most cases writing appropriate mapping methods for the
join should be enough, but if not you would generally have to do one or
all of the following :emptyJOINS_FILTERSso that the native way of joining to the
query is deactivatedrewrite or supercharge_init_partial_queryto add joinssupercharge the wholesearchmethod to build the exact partial
query you need then execute the searchHere’s still an example of a controller with two joins : an easy join
that uses an intermediate table, and a two-step join using an
intermediary filter to avoid joining on huge tables.fromloggingimportgetLoggerfromsqlalchemy.ormimportQueryfromcrudenceimportPostSearchControllerTemplatefromdb.sessionimportdb_sessionfromdb.modelsimportPersons,PersonRelatives,RelativeStatus,PersonLocation,LocationsclassSearchPersonsController(PostSearchControllerTemplate):JOINS_FILTERS=[{"slot":"person_relatives","target":RelativeStatus,"map_method":"map_relatives"},{"slot":"person_locations","target":Locations,"map_method":"map_locations}]def__init__(self):super().__init__(target=Persons,session=db_session)self.logger=getLogger(__name__)defmap_relatives(self,partial:Query,filters:dict)->Query:# Simple join on relative status with an intermediary table# If the filter is provided in the input this will be added to the partial,# allowing to filter on relative statusespartial=partial\.join(PersonRelatives,Persons.id==PersonRelatives.person_id)\.join(RelativeStatus,RelativeStatus.relative_id==PersonRelatives.relative_id)returnpartialdefmap_locations(self,partial:Query,filters:dict)->Query:# The passed on filter is the whole filters slot in the input# The person_locations filter is removed from it since its filtering# action is made here and we don't want to apply it againtarget_locations=filters.pop("person_locations")["uid"]# Getting the target db ids to avoid joining on the Locations tablelocations_ids=self.session.query(Locations.id).filter(Locations.uid.in_(target_locations)).all()# Joining only with the PersonLocation table that is not huge. Or we could# add another intermediate query an straight away filter on found person ids# before returning the partialpartial=partial\.join(PersonLocation)\.filter(PersonLocation.location_id.in_(locations_ids))returnpartial