Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions api/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
from db.group import Group
from schemas.group import UpdateGroup, CreateGroup, GroupResponse
from services.crud_helper import model_patcher, model_deleter, model_adder
from services.query_helper import (
simple_get_by_id,
paginated_all_getter,
from services.group_helper import (
get_well_counts_by_group_id,
group_to_response,
paginated_groups_getter,
)
from services.query_helper import simple_get_by_id

router = APIRouter(prefix="/group", tags=["group"])

Expand Down Expand Up @@ -74,7 +76,7 @@ def get_groups(
"""
Retrieve all groups from the database.
"""
return paginated_all_getter(session, Group, filter_=filter_)
return paginated_groups_getter(session, filter_=filter_)


@router.get("/{group_id}", summary="Get group by ID")
Expand All @@ -84,7 +86,9 @@ def get_group_by_id(
"""
Retrieve a group by ID from the database.
"""
return simple_get_by_id(session, Group, group_id)
group = simple_get_by_id(session, Group, group_id)
counts = get_well_counts_by_group_id(session, [group.id])
return group_to_response(group, counts.get(group.id, 0))


# @router.get(
Expand Down
4 changes: 4 additions & 0 deletions docs/refine-json-filters-and-virtual-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Associations are stored in **`ThingContactAssociation`** (`thing_id`, `contact_i
| List resource | Virtual `field` | Meaning | Implementation sketch |
|---------------|------------------|---------|------------------------|
| Thing (wells) | `contacts` | “Does **any** linked contact’s **name** match?” | EXISTS over `ThingContactAssociation` joining `Contact`, predicate on **`Contact.name`** |
| Thing (wells) | `groups` | “Does **any** linked project (**Group**) match?” | EXISTS over `GroupThingAssociation` joining `Group`, predicate on **`Group.id`** or **`Group.name`** |
| Contact | `things` | “Does **any** linked monitoring site (**thing**) **name** match?” | EXISTS over **`ThingContactAssociation`** joining **`Thing`**, predicate on **`Thing.name`** |

We keep naming aligned with ORM accessors (`Thing.contacts`-style summaries in API responses use **contacts**, and **`Contact`** side uses **`things`** for parity with the association proxy).
Expand Down Expand Up @@ -77,6 +78,7 @@ Those paths previously raised **500**. Virtual sorts are implemented in **`_appl
| `monitoring_status`, `well_status`, `datalogger_suitability_status` | Same “latest open” **`StatusHistory.status_value`** subquery as filters; **`lower(...)`**, **`nulls_last`** |
| `site_name` | **`ThingIdLink.alternate_id`** where **`alternate_organization = 'NMBGMR'`**, smallest link **`id`** (matches **`Thing.site_name`**) |
| `contacts` | **`min(lower(Contact.name))`** over **`ThingContactAssociation`** (first name alphabetically among linked contacts) |
| `groups` | **`min(lower(Group.name))`** over **`GroupThingAssociation`** (first project name alphabetically among linked groups) |
| `aquifers` | **`min(lower(AquiferSystem.name))`** over **`ThingAquiferAssociation`** |
| `open_status` | Latest open **“Open Status”** row; rank **Open** before **Closed**, then unknown strings, then no row |
| `measuring_point_height` | Latest **`MeasuringPointHistory`** row with non-null height (**`start_date` desc**, limit 1) |
Expand Down Expand Up @@ -104,6 +106,7 @@ Each filter **must** include **`field`**, **`operator`**, and **`value`** keys (
| Merge **`filter_`** + **`filters`**, sorting, pagination hook | **`order_sort_filter`** in **`services/query_helper.py`** |
| Dispatch virtual fields | **`_apply_json_filter_clause`** in **`services/query_helper.py`** |
| **`Thing` + contacts** | **`_apply_thing_contacts_filter`** |
| **`Thing` + groups** | **`_apply_thing_groups_filter`** |
| **`Contact` + things** | **`_apply_contact_things_filter`** |
| Contact list accepts repeated **`filter`** | **`GET`** **`/contact`** in **`api/contact.py`**, **`get_db_contacts`** in **`services/contact_helper.py`** |
| Wells list pattern (reference) | **`GET`** **`/thing/water-well`** in **`api/thing.py`**, **`get_db_things`** in **`services/thing_helper.py`** |
Expand All @@ -113,6 +116,7 @@ Each filter **must** include **`field`**, **`operator`**, and **`value`** keys (

- **`tests/test_contact_filters.py`**: **`things`** filters, **`things`** sort, multiple **`filter`** params on **`GET /contact`**.
- **`tests/test_thing.py`** (contacts on wells): **`contacts`** **`contains`**, **`ncontains`**, **`nnull`**, and **`sort`** on **`monitoring_status`**, **`site_name`**, **`contacts`**, **`aquifers`**, etc.
- **`tests/test_thing.py`** (groups on wells): **`groups`** **`eq`** by project id or name when filtering wells by project.

## When you change this

Expand Down
1 change: 1 addition & 0 deletions schemas/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class GroupResponse(BaseResponseModel):
project_area: str | None
group_type: GroupType | None
parent_group_id: int | None
well_count: int = 0

@model_validator(mode="before")
def project_area_to_wkt(self: Self) -> Self:
Expand Down
65 changes: 65 additions & 0 deletions services/group_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# ===============================================================================
# Copyright 2025 ross
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
from typing import Any

from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from db.group import Group, GroupThingAssociation
from db.thing import Thing
from schemas.group import GroupResponse
from services.query_helper import order_sort_filter


def get_well_counts_by_group_id(
session: Session, group_ids: list[int]
) -> dict[int, int]:
if not group_ids:
return {}

stmt = (
select(
GroupThingAssociation.group_id,
func.count(Thing.id),
)
.join(Thing, GroupThingAssociation.thing_id == Thing.id)
.where(GroupThingAssociation.group_id.in_(group_ids))
.where(Thing.thing_type == "water well")
.group_by(GroupThingAssociation.group_id)
)
return {row[0]: int(row[1]) for row in session.execute(stmt).all()}


def group_to_response(group: Group, well_count: int = 0) -> GroupResponse:
response = GroupResponse.model_validate(group)
return response.model_copy(update={"well_count": well_count})


def paginated_groups_getter(
session: Session,
filter_: str | None = None,
*,
filters: list[str] | None = None,
) -> Any:
sql = select(Group)
sql = order_sort_filter(sql, Group, None, None, filter_, filters=filters)

def transformer(groups: list[Group]) -> list[GroupResponse]:
counts = get_well_counts_by_group_id(session, [group.id for group in groups])
return [group_to_response(group, counts.get(group.id, 0)) for group in groups]

return paginate(query=sql, conn=session, transformer=transformer)
181 changes: 153 additions & 28 deletions services/query_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,25 @@ def _thing_contacts_min_name_sort_scalar(thing_table: type):
)


def _thing_groups_min_name_sort_scalar(thing_table: type):
"""Minimum ``lower(Group.name)`` across linked projects (stable proxy for display order)."""
from db.group import Group, GroupThingAssociation

gta = GroupThingAssociation
g = Group
return (
select(func.min(func.lower(g.name)))
.select_from(gta)
.join(g, gta.group_id == g.id)
.where(
gta.thing_id == thing_table.id,
g.name.isnot(None),
)
.correlate(thing_table)
.scalar_subquery()
)


def _thing_aquifers_min_name_sort_scalar(thing_table: type):
"""Minimum ``lower(AquiferSystem.name)`` across linked aquifers."""
from db.aquifer_system import AquiferSystem
Expand Down Expand Up @@ -417,6 +436,7 @@ def _contact_things_min_name_sort_scalar(contact_table: type):
"datalogger_suitability_status",
"site_name",
"contacts",
"groups",
"aquifers",
"open_status",
"measuring_point_height",
Expand Down Expand Up @@ -486,6 +506,9 @@ def num_order(expr):
if sort == "contacts":
return str_order(_thing_contacts_min_name_sort_scalar(thing_table))

if sort == "groups":
return str_order(_thing_groups_min_name_sort_scalar(thing_table))

if sort == "aquifers":
return str_order(_thing_aquifers_min_name_sort_scalar(thing_table))

Expand Down Expand Up @@ -527,6 +550,37 @@ def _apply_contact_virtual_sort(
)


def _build_assoc_exists(
assoc_table,
target_table,
assoc_join_col,
assoc_owner_col,
owner_pk,
predicate=None,
extra: list | None = None,
):
"""Correlated EXISTS subquery for many-to-many association filters.

Builds ``SELECT 1 FROM assoc JOIN target ON assoc_join_col = target.id
WHERE assoc_owner_col = owner_pk [AND extra...] [AND predicate]``.

Shared by _apply_thing_contacts_filter, _apply_thing_groups_filter, and
_apply_contact_things_filter to avoid repeating the same subquery shape.
Omit ``predicate`` to get an unconditional existence check (null/nnull).
"""
where_clauses = [assoc_owner_col == owner_pk]
if extra:
where_clauses.extend(extra)
if predicate is not None:
where_clauses.append(predicate)
return (
select(1)
.select_from(assoc_table)
.join(target_table, assoc_join_col == target_table.id)
.where(*where_clauses)
)


def _apply_thing_contacts_filter(
sql: Select[Any],
thing_table: type,
Expand Down Expand Up @@ -557,22 +611,18 @@ def _apply_thing_contacts_filter(
c = Contact

def _linked_contact_select(predicate):
return (
select(1)
.select_from(tca)
.join(c, tca.contact_id == c.id)
.where(
tca.thing_id == thing_table.id,
c.name.isnot(None),
predicate,
)
return _build_assoc_exists(
tca,
c,
tca.contact_id,
tca.thing_id,
thing_table.id,
predicate,
extra=[c.name.isnot(None)],
)

any_linked_contact = (
select(1)
.select_from(tca)
.join(c, tca.contact_id == c.id)
.where(tca.thing_id == thing_table.id)
any_linked_contact = _build_assoc_exists(
tca, c, tca.contact_id, tca.thing_id, thing_table.id
)

if operator == "nnull":
Expand Down Expand Up @@ -610,6 +660,82 @@ def _linked_contact_select(predicate):
return sql.where(exists(_linked_contact_select(pred)))


def _apply_thing_groups_filter(
sql: Select[Any],
thing_table: type,
operator: str,
value: Any,
) -> Select[Any]:
"""Filter ``Thing`` rows using linked groups / projects (many-to-many).

Refine sends ``field=groups`` from the wells list when filtering by project.
Match **any** linked ``Group`` by id (numeric ``eq``) or by ``Group.name``.
"""
from db.group import Group, GroupThingAssociation

gta = GroupThingAssociation
g = Group

def _linked_group_select(predicate):
return _build_assoc_exists(
gta, g, gta.group_id, gta.thing_id, thing_table.id, predicate
)

any_linked_group = _build_assoc_exists(
gta, g, gta.group_id, gta.thing_id, thing_table.id
)

if operator == "nnull":
return sql.where(exists(any_linked_group))

if operator == "null":
return sql.where(~exists(any_linked_group))

if operator == "eq":

def _eq_predicate():
try:
group_id = int(value)
return g.id == group_id
except (TypeError, ValueError):
return g.name == str(value)

return sql.where(exists(_linked_group_select(_eq_predicate())))

if operator == "ne":

def _ne_predicate():
try:
group_id = int(value)
return g.id == group_id
except (TypeError, ValueError):
return g.name == str(value)

return sql.where(~exists(_linked_group_select(_ne_predicate())))

if operator == "ncontains":
nlg = _linked_group_select(g.name.ilike(f"%{value}%"))
return sql.where(~exists(nlg))

if operator == "contains":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this functionality is duplicated in _linked_thing_select. consider moving to a separate function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This has been updated @jirhiker !

pred = g.name.ilike(f"%{value}%")
elif operator == "startswith":
pred = g.name.ilike(f"{value}%")
elif operator == "endswith":
pred = g.name.ilike(f"%{value}")
else:
raise HTTPException(
status_code=400,
detail=(
f"Operator {operator!r} is not supported for groups "
"filters (contains, ncontains, eq, ne, startswith, endswith, "
"null, nnull)"
),
)

return sql.where(exists(_linked_group_select(pred)))


def _apply_contact_things_filter(
sql: Select[Any],
contact_table: type,
Expand Down Expand Up @@ -642,22 +768,18 @@ def _apply_contact_things_filter(
t = Thing

def _linked_thing_select(predicate):
return (
select(1)
.select_from(tca)
.join(t, tca.thing_id == t.id)
.where(
tca.contact_id == contact_table.id,
t.name.isnot(None),
predicate,
)
return _build_assoc_exists(
tca,
t,
tca.thing_id,
tca.contact_id,
contact_table.id,
predicate,
extra=[t.name.isnot(None)],
)

any_linked_thing = (
select(1)
.select_from(tca)
.join(t, tca.thing_id == t.id)
.where(tca.contact_id == contact_table.id)
any_linked_thing = _build_assoc_exists(
tca, t, tca.thing_id, tca.contact_id, contact_table.id
)

if operator == "nnull":
Expand Down Expand Up @@ -739,6 +861,9 @@ def _apply_json_filter_clause(
if getattr(table, "__name__", None) == "Thing" and field == "contacts":
return _apply_thing_contacts_filter(sql, table, operator, value)

if getattr(table, "__name__", None) == "Thing" and field == "groups":
return _apply_thing_groups_filter(sql, table, operator, value)

try:
column = getattr(table, field)
except AttributeError as exc:
Expand Down
1 change: 1 addition & 0 deletions services/thing_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def is_debug_timing_enabled() -> bool:
selectinload(Thing.contact_associations).selectinload(
ThingContactAssociation.contact
),
selectinload(Thing.group_associations).selectinload(GroupThingAssociation.group),
selectinload(Thing.well_purposes),
selectinload(Thing.well_casing_materials),
selectinload(Thing.links),
Expand Down
Loading
Loading