Skip to content

Conversation

@mfisher29
Copy link

@mfisher29 mfisher29 commented Aug 27, 2025

This pull request intends to resolve issue #86.

I have followed the discussion there and created what I believe to be a nice solution based on Option 3.

Primary things to note about this solution:

  • The current way of configuring a client/db via the configure() method remains in tact.
  • Support for multiple clients builds on top of that and relies on storing of a ConfigItem in the CONFIGURATIONS dict.
  • Upon creation of a config, both a sync and async client will be set for that config
  • Code examples introduced below are included in a multiple_clients_examples directory, and can be removed before merge.
  • I have added ~20 more unit tests, but there may be some gaps. Happy to add more with your suggestions.

Other small things:

  • readme was updated with some more small details, and I recommend using pytest instead for development. It may be that I don't know poetry well enough to fix this, but I found that upon running poetry run invoke test my current changes were rolled back to the current firedantic version. I suppose this is because poetry is locked to version = "0.11.0" in the pyproject.toml and so I used pytest instead.
  • Aside from testing support of multiple clients, some test cases were added to check more types of operands, i.e. op.EQ and "==". All work as expected.
  • Apologies for the added spacing everywhere. I believe one of the formatters from the poetry setup did that. If it bothers anyone i can go back and remove it.

Now for the examples...

Example of client creation:

## OLD WAY:
def configure_client():
    # Firestore emulator must be running if using locally.
    if environ.get("FIRESTORE_EMULATOR_HOST"):
        client = Client(
            project="firedantic-test",
            credentials=Mock(spec=google.auth.credentials.Credentials),
        )
    else:
        client = Client()

    configure(client, prefix="firedantic-test-")
    print(client)


def configure_async_client():
    # Firestore emulator must be running if using locally.
    if environ.get("FIRESTORE_EMULATOR_HOST"):
        client = AsyncClient(
            project="firedantic-test",
            credentials=Mock(spec=google.auth.credentials.Credentials),
        )
    else:
        client = AsyncClient()

    configure(client, prefix="firedantic-test-")
    print(client)


## NEW WAY:
def configure_multiple_clients():
    config = Configuration()

    # name = (default)
    config.create(
        prefix="firedantic-test-",
        project="firedantic-test",
        credentials=Mock(spec=google.auth.credentials.Credentials),
    )

    # name = billing
    config.create(
        name="billing",
        prefix="test-billing-",
        project="test-billing",
        credentials=Mock(spec=google.auth.credentials.Credentials),
    )

    config.get_client()  ## will pull the default client
    config.get_client("billing")  ## will pull the billing client
    config.get_async_client("billing")  ## will pull the billing async client

Under the hood of config.create():

  • (seeconfiguration.py for more details) Created suggested Configuration class for added support of multiple configurations/clients.
  • The general idea is to save each configuration to the CONFGURATIONS Dict. When using the singular configure method, it will be saved there as (default).
  • New single clients can also be configured with the new multi client method. Multi is not required to use multi.
class Configuration:
    def __init__(self):
        self.configurations: Dict[str, ConfigItem] = {}

    def create(
        self,
        name: str = "(default)",
        prefix: str = "",
        project: str = "",
        credentials: Credentials = None,
    ) -> None:
        self.configurations[name] = ConfigItem(
            prefix=prefix,
            project=project,
            credentials=credentials,
            client=Client(
                project=project,
                credentials=credentials,
            ),
            async_client=AsyncClient(
                project=project,
                credentials=credentials,
            ),
        )
        # add to global CONFIGURATIONS
        global CONFIGURATIONS
        CONFIGURATIONS[name] = self.configurations[name]

For saving/finding entries:

## With single client
def old_way():
    # Firestore emulator must be running if using locally.
    configure_client()

    # Now you can use the model to save it to Firestore
    owner = Owner(first_name="John", last_name="Doe")
    company = Company(company_id="1234567-8", owner=owner)
    company.save()

    # Prints out the firestore ID of the Company model
    print(f"\nFirestore ID: {company.id}")

    # Reloads model data from the database
    company.reload()


## Now with multiple clients/dbs:
def new_way():
    config = Configuration()

    # 1. Create first config with config_name = "(default)"
    config.create(
        prefix="firedantic-test-",
        project="firedantic-test",
        credentials=Mock(spec=google.auth.credentials.Credentials),
    )

    # Now you can use the model to save it to Firestore
    owner = Owner(first_name="Alice", last_name="Begone")
    company = Company(company_id="1234567-9", owner=owner)
    company.save()  # will use 'default' as config name

    # Reloads model data from the database
    company.reload()  # with no name supplied, config refers to "(default)"

    # 2. Create the second config with config_name = "billing"
    config_name = "billing"
    config.create(
        name=config_name,
        prefix="test-billing-",
        project="test-billing",
        credentials=Mock(spec=google.auth.credentials.Credentials),
    )

    # Now you can use the model to save it to Firestore
    account = BillingAccount(name="ABC Billing", billing_id="801048", owner="MFisher")
    bc = BillingCompany(company_id="1234567-8", billing_account=account)

    bc.save(config_name)

    # Reloads model data from the database
    bc.reload(config_name)  # with config name supplied, config refers to "billing"

    # 3. Finding data
    # Can retrieve info from either database/client
    # When config is not specified, it will default to '(default)' config
    # The models do not know which config you intended to use them for, and they
    # could be used for a multitude of configurations at once.
    print(Company.find({"owner.first_name": "Alice"}))

    print(BillingCompany.find({"company_id": "1234567-8"}, config=config_name))

    print(
        BillingCompany.find(
            {"billing_account.billing_id": "801048"}, config=config_name
        )
    )

A simplified example for the find() method:
Note here, the multi client method is using the (default) config, as no config was specified.

print("\n---- Running OLD way ----")
configure_client()

companies1 = Company.find({"owner.first_name": "John"})
companies2 = Company.find({"owner.first_name": {op.EQ: "John"}})
companies3 = Company.find({"owner.first_name": {"==": "John"}})
assert companies1 != []
assert companies1 == companies2 == companies3


print("\n---- Running NEW way ----")
configure_multiple_clients()

companies1 = Company.find({"owner.first_name": "Alice"})
companies2 = Company.find({"owner.first_name": {op.EQ: "Alice"}})
companies3 = Company.find({"owner.first_name": {"==": "Alice"}})
assert companies1 != []
assert companies1 == companies2 == companies3

@joakimnordling
Copy link
Contributor

A big thanks for the PR!

I'm really sorry to say we're pretty busy for the next month, so going to frankly say we're most likely not able to have a deeper look into the PR within the next month.

Copy link
Contributor

@joakimnordling joakimnordling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I finally manage to get some time to start looking through this PR.

First of all a big thanks for taking the time to get into this! And sorry for the delay!

I think there's some things missing and some things where I've likely not managed to express my idea very clearly in the suggestion I made originally (in #86) or then we might have had just a bit of different visions. I'll try to list those things below.

  • My idea was that we'd replace the CONFIGURATIONS dictionary in configurations.py with simply one instance of the Configuration class, by doing something similar to configuration = Configuration() and then use the get_*** methods instead of directly using the dictionary. I think this would hide the actual way we store the data and especially the add() method would be helping out a fair bit with making the configs easily either by providing the values for constructing the clients or then allowing you to pass in the pre-configured clients.
  • My idea was also that each model class would keep the info about which configuration it should be using, by referring to it by it's name, for example by using __db_config__ = "billing" you'd tell the class to use the named configuration called billing and it could then be fetched from the config with something like configuration.get_client("billing") where that billing would be looked up from the __db_config__ value of the class. This means you wouldn't need to pass in the name of the config to each of the methods that interact with the DB (save, reload, delete, find etc), which I think would sooner or later lead to bugs when you sometime accidentally forget to pass in the name (imagine reading a User from one DB/collection and when updating it saving it to another one by accident). In the use cases I have in mind I think you'd most of the time want all your models of the same kind to be in the same database + collection, not scattered around in different databases or collections. If you still do need to have let's say User's in two different databases/collections, I think using subclasses with different __db_config__ values would be a nicer solution and ensure you always load and save the same object from/to the same collection and database. Of course in some highly dymanic case where you'd have a lot of databases and collections, I can see that subclassing might not be an option; for example if you'd have dozens of customers and a software that would need to store User entries for each of those into different databases and you dynamically onboard new customers and thus create new databases on the fly. If you have a such need, I think firedantic has likely been more or less impossible to use so far. I was about to write that if there's a need for something like that, then I think adding the config parameter to the save/find/delete etc would be OK and that then the default value should not be set to (default), rather to None and if it's None, the value from __db_config__ should be used. But after a second thought I'm not sure that would either be a good idea, simply because if you have such dynamic use, then giving each connection a name might not be that easy, or it could lead to a lot of connections being created etc. If you have some kind of such need, I'd value if you could share some level of insight into the actual need.
  • You mentioned you had issues with your changes being wiped out by running the tests the way recommended in the repo. I believe you've been developing your changes in the _sync folder primarily, as there's a lot of changes in the model.py there that are not present in the _async version. The way the repo is set up and intended to be used is so that all changes are done in the _async folder hierarchy (or then outside the _async/_sync) folders. There's some tooling in place that actually generates the _sync content from the _async version, with a fair bit of find and replace operations. This is described in the About sync and async versions of library section of the README.
  • This PR also seems to be missing all the things to make the multiple connections work together with the index/TTL policy creation, which will require admin clients.
  • There seems to also be a fair bit of odd spacing (as you mentioned) that should likely be taken care of by the pre-commit hooks (which I guess you didn't want to run to not wipe out all the changes in the _sync folder, in which you should not do any manual changes).
  • Going to be honest and say I didn't really yet look into the changes in the README or tests due to all the issues mentioned above; I think it makes sense to first clear up the issues and only after that check those.

All in all, I'm sorry to say that it looks like there's a fair bit of work left still to get this finished. And this is a quite complex and big change to this library. I actually also had to spend quite a long time to read my (maybe too long) original suggestion to recap it etc. How do we proceed from this point forward? Do you think you have still time and energy to continue on this? I'll also need to check a bit with my colleagues how much time I could put on following up this or if I could offer to help out with some part of it or something such. But let's get back to that when I know a bit more about your status.

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.

2 participants