mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-12 04:43:45 +08:00
* Implement tags_all/tags_any/tags_none on the assets list API (BE-6600) Adds the three canonically-named tag filter params to GET /api/assets and GET /api/assets/tags/refine: - tags_all: asset carries every tag (replaces include_tags) - tags_any: asset carries at least one tag (new) - tags_none: asset carries no tag (replaces exclude_tags) Clauses intersect; tags_none always wins. include_tags/exclude_tags remain as permanent deprecated aliases and behave exactly as before when used on their own. Invalid combinations return 400 INVALID_TAG_FILTER, but only when the request uses at least one new-name parameter (non-empty after normalisation): - mixed spellings of one slot (include_tags with tags_all, exclude_tags with tags_none) - the same tag in the effective all-list and none-list (query can never match) Old-names-only requests gain no new error paths: include_tags=a&exclude_tags=a still returns an empty 200. tags_any/tags_none overlap stays valid (dead term, not a dead query). * Address review findings: positional-compat, deprecation metadata, test matrix - Move any_tags to the end of the four touched signatures: inserting it mid-signature silently misbound pre-existing positional callers (e.g. a caller passing name_contains positionally would have it consumed as any_tags). - Mark include_tags/exclude_tags Field(deprecated=True) on both list schemas so generated schema metadata matches the contract, not just a comment (schemas_out.py already uses this form for Asset.name). - Add tests: legal cross-slot old/new combinations, repeated query-key concatenation (pins Core behavior; outside the cross-platform contract), tags_any two-page cursor consistency (total/has_more/ no-overlap), refine-route mixed-spelling rejection + legacy-conflict preservation, and schema deprecation metadata. * Pin tag-value opacity: case-sensitive matching, byte-exact conflict check The prod tag survey (~/comfy/prod-model-tag-shape.md) found live case-distinct tag pairs (SEEDVR2/seedvr2) that resolve differently, so the contract now states tag values are opaque byte-strings. Pin that: case-distinct tags filter separately, and a case-distinct all/none pair is not an INVALID_TAG_FILTER conflict. * Document tags_all/tags_any/tags_none in openapi.yaml, deprecate aliases Add the three tag-filter parameters to both listAssets and getAssetTagHistogram parameter blocks and mark include_tags/exclude_tags deprecated: true, keeping the spec in step with the runtime schemas so generated clients can discover the new filters while the aliases stay present for existing consumers. * Move schemas_in import to module scope in test_list_filter Review feedback: no import cycle requires the local import. * Silence per-request DeprecationWarning in the tag-filter remap shim Reading the deprecated include_tags/exclude_tags fields by attribute fires pydantic's DeprecationWarning on every list/refine request even for callers using only the new names. The warning is aimed at API clients, not the server's own remap; read via model_dump instead. * Cap tag-filter lists at 100 entries, all spellings Review finding: unbounded tag lists fan out into one correlated EXISTS per tag on both page and count statements. Cap each list at 100 normalized entries with 400 INVALID_TAG_FILTER naming the parameter. Applies to the legacy spellings as well — a deliberate, decided exception to the old-names-behave-identically rule, since a cap only on new names would leave the same fan-out reachable through the aliases. * Strip process narration from comments Comments carried decision dates, contract cross-references, and review context. Keep only the constraints the code cannot show, one line each.
139 lines
4.6 KiB
Python
139 lines
4.6 KiB
Python
"""Shared utilities for database query modules."""
|
|
|
|
import os
|
|
from decimal import Decimal
|
|
from typing import Iterable, Sequence
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import exists
|
|
|
|
from app.assets.database.models import AssetReference, AssetReferenceMeta, AssetReferenceTag
|
|
from app.assets.helpers import escape_sql_like_string, normalize_tags
|
|
|
|
MAX_BIND_PARAMS = 800
|
|
|
|
|
|
def calculate_rows_per_statement(cols: int) -> int:
|
|
"""Calculate how many rows can fit in one statement given column count."""
|
|
return max(1, MAX_BIND_PARAMS // max(1, cols))
|
|
|
|
|
|
def iter_chunks(seq, n: int):
|
|
"""Yield successive n-sized chunks from seq."""
|
|
for i in range(0, len(seq), n):
|
|
yield seq[i : i + n]
|
|
|
|
|
|
def iter_row_chunks(rows: list[dict], cols_per_row: int) -> Iterable[list[dict]]:
|
|
"""Yield chunks of rows sized to fit within bind param limits."""
|
|
if not rows:
|
|
return
|
|
yield from iter_chunks(rows, calculate_rows_per_statement(cols_per_row))
|
|
|
|
|
|
def build_visible_owner_clause(owner_id: str) -> sa.sql.ClauseElement:
|
|
"""Build owner visibility predicate for reads.
|
|
|
|
Owner-less rows are visible to everyone.
|
|
"""
|
|
owner_id = (owner_id or "").strip()
|
|
if owner_id == "":
|
|
return AssetReference.owner_id == ""
|
|
return AssetReference.owner_id.in_(["", owner_id])
|
|
|
|
|
|
def build_prefix_like_conditions(
|
|
prefixes: list[str],
|
|
) -> list[sa.sql.ColumnElement]:
|
|
"""Build LIKE conditions for matching file paths under directory prefixes."""
|
|
conds = []
|
|
for p in prefixes:
|
|
base = os.path.abspath(p)
|
|
if not base.endswith(os.sep):
|
|
base += os.sep
|
|
escaped, esc = escape_sql_like_string(base)
|
|
conds.append(AssetReference.file_path.like(escaped + "%", escape=esc))
|
|
return conds
|
|
|
|
|
|
def apply_tag_filters(
|
|
stmt: sa.sql.Select,
|
|
include_tags: Sequence[str] | None = None,
|
|
exclude_tags: Sequence[str] | None = None,
|
|
any_tags: Sequence[str] | None = None,
|
|
) -> sa.sql.Select:
|
|
"""include_tags: every tag must be present; any_tags: at least one must be
|
|
present; exclude_tags: none may be present."""
|
|
include_tags = normalize_tags(include_tags)
|
|
exclude_tags = normalize_tags(exclude_tags)
|
|
any_tags = normalize_tags(any_tags)
|
|
|
|
if include_tags:
|
|
for tag_name in include_tags:
|
|
stmt = stmt.where(
|
|
exists().where(
|
|
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
|
& (AssetReferenceTag.tag_name == tag_name)
|
|
)
|
|
)
|
|
|
|
if any_tags:
|
|
stmt = stmt.where(
|
|
exists().where(
|
|
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
|
& (AssetReferenceTag.tag_name.in_(any_tags))
|
|
)
|
|
)
|
|
|
|
if exclude_tags:
|
|
stmt = stmt.where(
|
|
~exists().where(
|
|
(AssetReferenceTag.asset_reference_id == AssetReference.id)
|
|
& (AssetReferenceTag.tag_name.in_(exclude_tags))
|
|
)
|
|
)
|
|
return stmt
|
|
|
|
|
|
def apply_metadata_filter(
|
|
stmt: sa.sql.Select,
|
|
metadata_filter: dict | None = None,
|
|
) -> sa.sql.Select:
|
|
"""Apply filters using asset_reference_meta projection table."""
|
|
if not metadata_filter:
|
|
return stmt
|
|
|
|
def _exists_for_pred(key: str, *preds) -> sa.sql.ClauseElement:
|
|
return sa.exists().where(
|
|
AssetReferenceMeta.asset_reference_id == AssetReference.id,
|
|
AssetReferenceMeta.key == key,
|
|
*preds,
|
|
)
|
|
|
|
def _exists_clause_for_value(key: str, value) -> sa.sql.ClauseElement:
|
|
if value is None:
|
|
return sa.not_(
|
|
sa.exists().where(
|
|
AssetReferenceMeta.asset_reference_id == AssetReference.id,
|
|
AssetReferenceMeta.key == key,
|
|
)
|
|
)
|
|
|
|
if isinstance(value, bool):
|
|
return _exists_for_pred(key, AssetReferenceMeta.val_bool == bool(value))
|
|
if isinstance(value, (int, float, Decimal)):
|
|
num = value if isinstance(value, Decimal) else Decimal(str(value))
|
|
return _exists_for_pred(key, AssetReferenceMeta.val_num == num)
|
|
if isinstance(value, str):
|
|
return _exists_for_pred(key, AssetReferenceMeta.val_str == value)
|
|
return _exists_for_pred(key, AssetReferenceMeta.val_json == value)
|
|
|
|
for k, v in metadata_filter.items():
|
|
if isinstance(v, list):
|
|
ors = [_exists_clause_for_value(k, elem) for elem in v]
|
|
if ors:
|
|
stmt = stmt.where(sa.or_(*ors))
|
|
else:
|
|
stmt = stmt.where(_exists_clause_for_value(k, v))
|
|
return stmt
|