Repository: PrefectHQ/prefect

Issue:
Ability to filter by deployments -> deployment_id -> is_null
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar request and didn't find it.
- [X] I searched the Prefect documentation for this feature.

### Prefect Version

2.x

### Describe the current behavior

There's a [feature request](https://github.com/PrefectHQ/prefect/issues/9517) to be able to filter the flows page by Flows that either do or do not have a deployment currently associated with them.

There is a way to do this, but it won't work. We could pass:
```
{
  flow_runs: {
    deployment_ids: {
      is_null: bool
    }
  }
}
```
For this use case though, there are scenarios where the filter isn't quite accurate. You may have a flow with no associated deployment, but the flow was previously ran with a deployment. Or you may have a flow that is associated with a deployment, but just hasn't been ran from one yet.

### Describe the proposed behavior

We need a way to filter like this instead:
```
{
  deployments: {
    ids: {
      is_null: bool
    }
  }
}
```
That way we only get flows that do or do-not have deployments associated with them.

### Example Use

The Flows page could be filtered by flows that are or are not currently deployed.

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/src/prefect/server/schemas/filters.py b/src/prefect/server/schemas/filters.py
--- a/src/prefect/server/schemas/filters.py
+++ b/src/prefect/server/schemas/filters.py
@@ -80,6 +80,34 @@ def _get_filter_list(self, db: "PrefectDBInterface") -> List:
         return filters
 
 
+class FlowFilterDeployment(PrefectOperatorFilterBaseModel):
+    """Filter by flows by deployment"""
+
+    is_null_: Optional[bool] = Field(
+        default=None,
+        description="If true, only include flows without deployments",
+    )
+
+    def _get_filter_list(self, db: "PrefectDBInterface") -> List:
+        filters = []
+
+        if self.is_null_ is not None:
+            deployments_subquery = (
+                sa.select(db.Deployment.flow_id).distinct().subquery()
+            )
+
+            if self.is_null_:
+                filters.append(
+                    db.Flow.id.not_in(sa.select(deployments_subquery.c.flow_id))
+                )
+            else:
+                filters.append(
+                    db.Flow.id.in_(sa.select(deployments_subquery.c.flow_id))
+                )
+
+        return filters
+
+
 class FlowFilterName(PrefectFilterBaseModel):
     """Filter by `Flow.name`."""
 
@@ -140,6 +168,9 @@ class FlowFilter(PrefectOperatorFilterBaseModel):
     id: Optional[FlowFilterId] = Field(
         default=None, description="Filter criteria for `Flow.id`"
     )
+    deployment: Optional[FlowFilterDeployment] = Field(
+        default=None, description="Filter criteria for Flow deployments"
+    )
     name: Optional[FlowFilterName] = Field(
         default=None, description="Filter criteria for `Flow.name`"
     )
@@ -152,6 +183,8 @@ def _get_filter_list(self, db: "PrefectDBInterface") -> List:
 
         if self.id is not None:
             filters.append(self.id.as_sql_filter(db))
+        if self.deployment is not None:
+            filters.append(self.deployment.as_sql_filter(db))
         if self.name is not None:
             filters.append(self.name.as_sql_filter(db))
         if self.tags is not None:


Test patch:
diff --git a/tests/server/api/test_flows.py b/tests/server/api/test_flows.py
--- a/tests/server/api/test_flows.py
+++ b/tests/server/api/test_flows.py
@@ -316,6 +316,46 @@ async def test_read_flows_applies_work_pool(self, client, session, work_pool):
         assert len(response.json()) == 1
         assert UUID(response.json()[0]["id"]) == flow_1.id
 
+    async def test_read_flows_applies_deployment_is_null(self, client, session):
+        undeployed_flow = await models.flows.create_flow(
+            session=session,
+            flow=schemas.core.Flow(name="undeployment_flow"),
+        )
+        deployed_flow = await models.flows.create_flow(
+            session=session, flow=schemas.core.Flow(name="deployed_flow")
+        )
+        await session.commit()
+
+        await models.deployments.create_deployment(
+            session=session,
+            deployment=schemas.core.Deployment(
+                name="Mr. Deployment",
+                manifest_path="file.json",
+                flow_id=deployed_flow.id,
+            ),
+        )
+        await session.commit()
+
+        deployment_filter_isnull = dict(
+            flows=schemas.filters.FlowFilter(
+                deployment=schemas.filters.FlowFilterDeployment(is_null_=True)
+            ).dict(json_compatible=True)
+        )
+        response = await client.post("/flows/filter", json=deployment_filter_isnull)
+        assert response.status_code == status.HTTP_200_OK
+        assert len(response.json()) == 1
+        assert UUID(response.json()[0]["id"]) == undeployed_flow.id
+
+        deployment_filter_not_isnull = dict(
+            flows=schemas.filters.FlowFilter(
+                deployment=schemas.filters.FlowFilterDeployment(is_null_=False)
+            ).dict(json_compatible=True)
+        )
+        response = await client.post("/flows/filter", json=deployment_filter_not_isnull)
+        assert response.status_code == status.HTTP_200_OK
+        assert len(response.json()) == 1
+        assert UUID(response.json()[0]["id"]) == deployed_flow.id
+
     async def test_read_flows_offset(self, flows, client):
         # right now this works because flows are ordered by name
         # by default, when ordering is actually implemented, this test