Coverage for polar/kit/metadata.py: 43%
70 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
1import inspect 1ba
2import re 1ba
3from typing import Annotated, Any, TypeAlias 1ba
5from fastapi import Depends, Request 1ba
6from pydantic import AliasChoices, BaseModel, Field, StringConstraints 1ba
7from sqlalchemy import ColumnExpressionArgument, Select, and_, or_, true 1ba
8from sqlalchemy.dialects.postgresql import JSONB 1ba
9from sqlalchemy.orm import Mapped, mapped_column 1ba
11MetadataColumn = Annotated[ 1ba
12 dict[str, Any], mapped_column(JSONB, nullable=False, default=dict)
13]
16class MetadataMixin: 1ba
17 user_metadata: Mapped[MetadataColumn] 1ba
20MAXIMUM_KEYS = 50 1ba
21_MINIMUM_KEY_LENGTH = 1 1ba
22_MAXIMUM_KEY_LENGTH = 40 1ba
23_MINIMUM_VALUE_LENGTH = 1 1ba
24_MAXIMUM_VALUE_LENGTH = 500 1ba
25MetadataKey = Annotated[ 1ba
26 str,
27 StringConstraints(min_length=_MINIMUM_KEY_LENGTH, max_length=_MAXIMUM_KEY_LENGTH),
28]
29_MetadataValueString = Annotated[ 1ba
30 str,
31 StringConstraints(
32 min_length=_MINIMUM_VALUE_LENGTH, max_length=_MAXIMUM_VALUE_LENGTH
33 ),
34]
35MetadataValue = _MetadataValueString | int | float | bool 1ba
37METADATA_DESCRIPTION = inspect.cleandoc( 1ba
38 f"""
39 {{heading}}
41 The key must be a string with a maximum length of **{_MAXIMUM_KEY_LENGTH} characters**.
42 The value must be either:
44 * A string with a maximum length of **{_MAXIMUM_VALUE_LENGTH} characters**
45 * An integer
46 * A floating-point number
47 * A boolean
49 You can store up to **{MAXIMUM_KEYS} key-value pairs**.
50 """
51)
52_description = METADATA_DESCRIPTION.format( 1ba
53 heading="Key-value object allowing you to store additional information."
54)
57MetadataField = Annotated[ 1ba
58 dict[MetadataKey, MetadataValue],
59 Field(max_length=MAXIMUM_KEYS, description=_description),
60]
63class MetadataInputMixin(BaseModel): 1ba
64 metadata: MetadataField = Field( 1ba
65 default_factory=dict, serialization_alias="user_metadata"
66 )
69MetadataOutputType: TypeAlias = dict[str, str | int | float | bool] 1ba
72class MetadataOutputMixin(BaseModel): 1ba
73 metadata: MetadataOutputType = Field( 1ba
74 validation_alias=AliasChoices("user_metadata", "metadata")
75 )
78def add_metadata_query_schema(openapi_schema: dict[str, Any]) -> dict[str, Any]: 1ba
79 openapi_schema["components"]["schemas"]["MetadataQuery"] = { 1c
80 "anyOf": [
81 {
82 "type": "object",
83 "additionalProperties": {
84 "anyOf": [
85 {"type": "string"},
86 {"type": "integer"},
87 {"type": "boolean"},
88 {"type": "array", "items": {"type": "string"}},
89 {"type": "array", "items": {"type": "integer"}},
90 {"type": "array", "items": {"type": "boolean"}},
91 ]
92 },
93 },
94 {"type": "null"},
95 ],
96 "title": "MetadataQuery",
97 }
98 return openapi_schema 1c
101def get_metadata_query_openapi_schema() -> dict[str, Any]: 1ba
102 return { 1a
103 "name": "metadata",
104 "in": "query",
105 "required": False,
106 "style": "deepObject",
107 "schema": {
108 "$ref": "#/components/schemas/MetadataQuery",
109 },
110 "description": (
111 "Filter by metadata key-value pairs. "
112 "It uses the `deepObject` style, e.g. `?metadata[key]=value`."
113 ),
114 }
117_metadata_pattern = r"metadata\[([^\]]+)\]" 1ba
120def _get_metadata_query(request: Request) -> dict[str, list[str]] | None: 1ba
121 query_params = request.query_params
122 metadata: dict[str, list[str]] = {}
124 for key, value in query_params.multi_items():
125 if match := re.match(_metadata_pattern, key):
126 metadata_key = match.group(1)
127 try:
128 metadata[metadata_key] = [*metadata[metadata_key], value]
129 except KeyError:
130 metadata[metadata_key] = [value]
132 return metadata
135MetadataQuery = Annotated[dict[str, list[str]], Depends(_get_metadata_query)] 1ba
138def get_metadata_clause[M: MetadataMixin]( 1ba
139 model: type[M], query: MetadataQuery
140) -> ColumnExpressionArgument[bool]:
141 clauses: list[ColumnExpressionArgument[bool]] = []
142 for key, values in query.items():
143 sub_clauses: list[ColumnExpressionArgument[bool]] = []
144 for value in values:
145 sub_clauses.append(model.user_metadata[key].as_string() == value)
146 clauses.append(or_(*sub_clauses))
148 if not clauses:
149 return true()
151 return and_(*clauses)
154def apply_metadata_clause[M: MetadataMixin]( 1ba
155 model: type[M], statement: Select[tuple[M]], query: MetadataQuery
156) -> Select[tuple[M]]:
157 clause = get_metadata_clause(model, query)
158 return statement.where(clause)
161def extract_metadata_value( 1ba
162 metadata: dict[str, Any], property_selector: str
163) -> str | None:
164 """
165 Extract a value from metadata using a property selector.
167 Supports:
168 - Simple keys: "subject" -> metadata["subject"]
169 - Nested keys: "metadata.subject" -> metadata["metadata"]["subject"]
170 - Dot-separated paths of any depth
172 Returns the value as a string if found, None otherwise.
173 """
174 if not property_selector:
175 return None
177 keys = property_selector.split(".")
178 current: Any = metadata
180 for key in keys:
181 if not isinstance(current, dict):
182 return None
183 current = current.get(key)
184 if current is None:
185 return None
187 return str(current) if current is not None else None