Package: model-connect
ModelConnectModelConnect is a library that enables users to connect their dataclass models with other libraries
without writing additional boilerplate code.ModelConnect goals and how it achieves them:SimplicityFluent APIMinimal Setup to StartReliabilityTested & TestableType AnnotatedExtensibleCustom Library Adapters & Functions BuilderIncludes testing utilitiesOverride all behavioursBefore ModelConnect- src/
  - ...
  - features/
    - user/
      - routes.py         # API routing
      - service.py        # business logic
      - models/
        - orm.py          # i.e. sqlalchemy (only required if using ORM libraries)
        - dtos.py         # i.e. pydantic, marshmallow (almost always required)
        - bl.py           # i.e. pydantic, dataclasses (can replace with other models, but recommended)
        - ui.py           # i.e. flask app builder (only required if using UI libraries)
        ...After ModelConnect- src/
  - ...
  - features/
    - user/
      - routes.py         # API routing
      - service.py        # business logic
      - model.py          # Plain old dataclass objectsQuick StartLet's start with the following project architecture that uses FastAPI and psycopg2 to create a CRUD API:- src/
    - db.py
    - models.py
    - routes.py
- main.pyInside themodels.pymodule, we define our models using dataclassesfromdataclassesimportdataclassfrommodel_connectimportconnect@dataclassclassUser:name:strage:intconnect(User)Let's also configure thedb.pymodule. This exposes a function that returns a new database connection.# db.pyfrompsycopg2importconnectdefopen_db_connection():returnconnect('...')# pass in custom DB detailsNow, with our models setup,
we can use these throughout our application and start replacing the boilerplate.Normally, most CRUD apps that follow N-tier architecture will have the following modules:controller.py- used for API endpoint routingservice.py- used for business logic and validationrepository.py- used for abstracting database CRUD operationsFrom a minimal setup perspective, everything we need is in our model.
Therefore, we can remove theservice.pyandrepository.pymodules and just use thecontroller.py.NOTE: This is not to say that theservice.pyandrepository.pymodules are not useful.
In fact, most complex applications will use these modules to handle unique business logic.
But what this is demonstrating is that for a simple setup (where most of the code in these layers are boilerplate),
you can remove these modules and just use the model as the scaffolding.fromfastapiimportDependsfrommodel_connect.integrations.fastapiimport(create_router,create_response_dtos,create_response_dto,get_from_post_request_dto,get_from_post_request_dtos,get_from_put_request_dto,get_from_patch_request_dto,get_filter_options,get_pagination_options,get_sort_options)frommodel_connect.integrations.psycopg2import(stream_select,stream_insert,stream_update,stream_partial_update,stream_delete,)fromsrc.modelsimportUserfromsrc.dbimportopen_db_connectionrouter=create_router(User)@router.get('')defget_users(filter_options:dict=Depends(get_filter_options(User)),pagination_options:dict=Depends(get_pagination_options(User)),sort_options:dict=Depends(get_sort_options(User))):connection=open_db_connection()withconnection.cursor()ascursor:returncreate_response_dtos(stream_select(User,cursor,filter_options=filter_options,pagination_options=pagination_options,sort_options=sort_options))@router.get('/{resource_id}')defget_user(resource_id:int):connection=open_db_connection()withconnection.cursor()ascursor:returncreate_response_dto(stream_select(User,cursor,filter_options={'id':resource_id}))@router.post('')defpost_user(user:Depends(get_from_post_request_dto(User))):connection=open_db_connection()withconnection.cursor()ascursor:returncreate_response_dto(stream_insert(cursor,user))@router.post('/bulk')defpost_users(users:Depends(get_from_post_request_dto(User))):connection=open_db_connection()withconnection.cursor()ascursor:returncreate_response_dtos(stream_insert(cursor,users,))@router.put('/{resource_id}')defput_user(user:Depends(get_from_put_request_dto(User))):connection=open_db_connection()withconnection.cursor()ascursor:returncreate_response_dto(stream_update(cursor,user))@router.patch('/{resource_id}')defpatch_user(user:Depends(get_from_patch_request_dto(User))):connection=open_db_connection()withconnection.cursor()ascursor:returncreate_response_dto(stream_partial_update(cursor,user))@router.delete('/{resource_id}')defdelete_user(resource_id:int):connection=open_db_connection()withconnection.cursor()ascursor:returncreate_response_dto(stream_delete(cursor,User,filter_options={'id':resource_id}))Done!And just like that, you've created CRUD API that exposes several endpoints for yourUserresource.
Normally, writing the DTOs, and the duplicate models (Pydantic, ORMs, etc.) would have taken hundreds of lines of code.
But with ModelConnect, you're given functions that auto generate this functionality for you.NOTE: Notice how ModelConnect does not assume the database or the API framework you are using.
This is done purposely to allow you to swap out the database or API framework at any time.
In the case you wanted to remove the boilerplate code even further,
you can create your own functions that wraps a series of ModelConnect functions for you.Library SupportAPI Frameworks:FastAPIFlask(Coming Soon)Django(Coming Soon)Database Libraries:Psycopg2SQLAlchemy(Coming Soon)PyMongo(Coming Soon)PyMySQL(Coming Soon)Validation Libraries:Pydantic(Coming Soon)Marshmallow(Coming Soon)SerializationJSON(Coming Soon)YAML(Coming Soon)Build Your Own IntegrationsUnderstanding the Options ChainWhen a model is connected, it goes through a series of resolutions.
These resolutions are referred to as the Options Chain.
Their purpose is to propagate the default behaviour down the options tree
where deeper nodes define more specific behaviour.
The order is as follows:# called once per project (not yet implemented)
connect_globals
|-- GlobalOptions
    |-- Model
    |   |-- Integrations
    |       |-- Integration
    |-- ModelFields
        |-- ModelField
            |-- Dtos
                |-- Dto
            |-- Validator
            |-- Integrations
                |-- Intgration

...

# called on each model
connect
|-- ConnectOptions
    |-- Model
    |   |-- Integrations
    |       |-- Integration
    |-- ModelFields
        |-- ModelField
            |-- Dtos
                |-- Dto
            |-- Validator
            |-- Integrations
                |-- IntgrationWhenconnect(...)is called, a few things happen:ConnectOptions is created (if not already)ConnectOptions.resolve()is calledWhenConnectOptions.resolveis called, a few (more) things happen:Model is created (if not already)ModelFields is created (if not already)Model.resolve()is calledModelFields.resolve()is calledThis continues down the tree until all nodes are resolved.If you are familiar with pre-order DFS traversal, this is essentially what is happening.
The "pre-order" functionality is theresolve()method.
By resolving the nodes in this way, downstream nodes have access to the default behaviour of upstream nodes
and can override them if necessary.ExamplesYou can see this model chain concept in action when you connect a model:fromdataclassesimportdataclassfrommodel_connectimportconnectfrommodel_connect.optionsimportConnectOptions,Model,ModelFields,ModelFieldfrommodel_connect.integrations.psycopg2importPsycopg2Model@dataclassclassUser:name:strage:intconnect(User,ConnectOptions(model=Model(override_integrations=(Psycopg2Model(tablename='users'# <- overrides default snake case dataclass name ('user')),)),model_fields=ModelFields(id=ModelField(is_identifier=True,# <- downstream nodes (i.e. Psycopg2ModelField) read this during resolution# validators=(),  # <- validators# request_dtos=RequestDtos(),  # <- request dtos if we needed them# integrations=(...),  # <- no overrides - ModelField will infer),name=ModelField(),# age=ModelField(), # <- not specified - ModelFields will infer)))In another case,
you may not need to override the inferred behaviour from the dataclass.
Then, you just connect the model without any options
and the same option chain will handle constructing the options with the defaults:fromdataclassesimportdataclassfrommodel_connectimportconnect@dataclassclassUser:name:strage:intconnect(User)# <- The options (ConnectOptions, Mode, etc.) are inferred from the dataclassDeveloper GuideTo rebuild distribution:pythonsetup.pysdistTo upload to PyPi:twineuploaddist/*# twine upload dist/<filename>To do both:pythonsetup.pysdist
twineuploaddist/*ContributingTBDQ&ALibrary or a framework?The goal was not to create another framework.Instead, the goal is to create a library that handles the integration of multiple frameworks / libraries.Why dataclasses?It's part of the Python standard library.
Other model libraries may be supported in the future later.