Skip to content

Commit 2151e04

Browse files
Copilotdavidgamez
andauthored
feat: expose license tags in search endpoint results and filters (#1620)
Co-authored-by: davidgamez <1192523+davidgamez@users.noreply.github.com>
1 parent 3b67e1b commit 2151e04

8 files changed

Lines changed: 174 additions & 8 deletions

File tree

api/src/feeds/impl/search_api_impl.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,17 @@ def get_parsed_search_tsquery(search_query: str) -> str:
3232

3333
@staticmethod
3434
def add_search_query_filters(
35-
query, search_query, data_type, feed_id, status, is_official, features, version, license_ids, license_is_spdx
35+
query,
36+
search_query,
37+
data_type,
38+
feed_id,
39+
status,
40+
is_official,
41+
features,
42+
version,
43+
license_ids,
44+
license_is_spdx,
45+
license_tags,
3646
) -> Query:
3747
"""
3848
Add filters to the search query.
@@ -81,6 +91,17 @@ def add_search_query_filters(
8191
or_(t_feedsearch.c.license_is_spdx.is_(False), t_feedsearch.c.license_is_spdx.is_(None))
8292
)
8393

94+
if license_tags:
95+
tag_ids_list = [tid.strip() for tid in license_tags.split(",") if len(tid.strip()) > 0]
96+
if len(tag_ids_list) > 0:
97+
# license_tags is a text[] column – use the @> (contains) operator
98+
# so that ALL requested tags must be present (AND semantics).
99+
query = query.where(
100+
t_feedsearch.c.license_tags.op("@>")(
101+
func.cast(array(tag_ids_list), t_feedsearch.c.license_tags.type)
102+
)
103+
)
104+
84105
# Add feature filter with OR logic
85106
if features:
86107
features_list = [s.strip() for s in features[0].split(",") if s]
@@ -101,6 +122,7 @@ def create_count_search_query(
101122
search_query: str,
102123
license_ids: str,
103124
license_is_spdx: bool,
125+
license_tags: str,
104126
) -> Query:
105127
"""
106128
Create a search query for the database.
@@ -117,6 +139,7 @@ def create_count_search_query(
117139
version,
118140
license_ids,
119141
license_is_spdx,
142+
license_tags,
120143
)
121144

122145
@staticmethod
@@ -130,6 +153,7 @@ def create_search_query(
130153
version: str,
131154
license_ids: str,
132155
license_is_spdx: bool,
156+
license_tags: str,
133157
) -> Query:
134158
"""
135159
Create a search query for the database.
@@ -153,6 +177,7 @@ def create_search_query(
153177
version,
154178
license_ids,
155179
license_is_spdx,
180+
license_tags,
156181
)
157182
# If search query is provided, use it as secondary sort after timestamp
158183
if search_query and len(search_query.strip()) > 0:
@@ -177,11 +202,21 @@ def search_feeds(
177202
feature: List[str],
178203
license_ids: str,
179204
license_is_spdx: bool,
205+
license_tags: str,
180206
db_session: "Session",
181207
) -> SearchFeeds200Response:
182208
"""Search feeds using full-text search on feed, location and provider&#39;s information."""
183209
query = self.create_search_query(
184-
status, feed_id, data_type, is_official, search_query, feature, version, license_ids, license_is_spdx
210+
status,
211+
feed_id,
212+
data_type,
213+
is_official,
214+
search_query,
215+
feature,
216+
version,
217+
license_ids,
218+
license_is_spdx,
219+
license_tags,
185220
)
186221
feed_rows = Database().select(
187222
session=db_session,
@@ -192,7 +227,16 @@ def search_feeds(
192227
feed_total_count = Database().select(
193228
session=db_session,
194229
query=self.create_count_search_query(
195-
status, feed_id, data_type, is_official, feature, version, search_query, license_ids, license_is_spdx
230+
status,
231+
feed_id,
232+
data_type,
233+
is_official,
234+
feature,
235+
version,
236+
search_query,
237+
license_ids,
238+
license_is_spdx,
239+
license_tags,
196240
),
197241
)
198242
if feed_rows is None or feed_total_count is None:

api/src/shared/db_models/search_feed_item_result_impl.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def from_orm_gtfs(cls, feed_search_row: t_feedsearch):
4242
license_url=feed_search_row.license_url,
4343
license_id=feed_search_row.license_id,
4444
license_is_spdx=feed_search_row.license_is_spdx,
45+
license_tags=(sorted(feed_search_row.license_tags) if feed_search_row.license_tags else []),
4546
),
4647
redirects=feed_search_row.redirect_ids,
4748
locations=cls.resolve_locations(feed_search_row.locations),
@@ -95,6 +96,7 @@ def from_orm_gbfs(cls, feed_search_row):
9596
license_url=feed_search_row.license_url,
9697
license_id=feed_search_row.license_id,
9798
license_is_spdx=feed_search_row.license_is_spdx,
99+
license_tags=(sorted(feed_search_row.license_tags) if feed_search_row.license_tags else []),
98100
),
99101
redirects=feed_search_row.redirect_ids,
100102
locations=cls.resolve_locations(feed_search_row.locations),
@@ -124,6 +126,7 @@ def from_orm_gtfs_rt(cls, feed_search_row):
124126
license_url=feed_search_row.license_url,
125127
license_id=feed_search_row.license_id,
126128
license_is_spdx=feed_search_row.license_is_spdx,
129+
license_tags=(sorted(feed_search_row.license_tags) if feed_search_row.license_tags else []),
127130
),
128131
redirects=feed_search_row.redirect_ids,
129132
locations=cls.resolve_locations(feed_search_row.locations),

api/tests/integration/test_search_api.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,3 +564,72 @@ def test_search_filter_by_feature(client: TestClient, values: dict):
564564
assert requested_features.intersection(features), (
565565
f"Feed {result.id} with features {features} does not match " f"requested features {requested_features}"
566566
)
567+
568+
569+
@pytest.mark.parametrize(
570+
"values",
571+
[
572+
{"license_tags": "family:ODC", "expected_count": 1},
573+
{"license_tags": "license:open-data-commons", "expected_count": 1},
574+
# AND semantics: feed must contain ALL requested tags
575+
{"license_tags": "family:ODC,license:open-data-commons", "expected_count": 1},
576+
{"license_tags": "nonexistent:tag", "expected_count": 0},
577+
{"license_tags": "license:open-data-commons,nonexistent:tag", "expected_count": 0},
578+
{"license_tags": "", "expected_count": 16},
579+
],
580+
ids=[
581+
"Filter by single tag family:ODC",
582+
"Filter by single tag license:open-data-commons",
583+
"Filter by multiple tags (AND semantics)",
584+
"No feed matches nonexistent tag",
585+
"Mixed existing and nonexistent tag (AND semantics)",
586+
"No filter returns all feeds",
587+
],
588+
)
589+
def test_search_filter_by_license_tags(client: TestClient, values: dict):
590+
"""Retrieve feeds that have licenses associated with specific license tag IDs.
591+
592+
The ``license_tags`` parameter accepts a comma-separated list of tag IDs.
593+
The filter uses AND semantics: the feed's ``license_tags`` array must
594+
contain **all** of the requested tags for the feed to be returned.
595+
"""
596+
597+
params = None
598+
if values["license_tags"]:
599+
params = [("license_tags", values["license_tags"])]
600+
601+
headers = {"Authentication": "special-key"}
602+
response = client.request("GET", "/v1/search", headers=headers, params=params)
603+
604+
assert response.status_code == 200
605+
606+
response_body = SearchFeeds200Response.model_validate(response.json())
607+
expected_count = values["expected_count"]
608+
assert (
609+
response_body.total == expected_count
610+
), f"There should be {expected_count} feeds for license_tags={values['license_tags']}"
611+
612+
613+
def test_search_result_contains_license_tags(client: TestClient):
614+
"""
615+
Verify that the search results include license_tags for feeds with license tags.
616+
"""
617+
params = [
618+
("feed_id", "mdb-70"),
619+
]
620+
headers = {
621+
"Authentication": "special-key",
622+
}
623+
response = client.request(
624+
"GET",
625+
"/v1/search",
626+
headers=headers,
627+
params=params,
628+
)
629+
assert response.status_code == 200
630+
response_body = SearchFeeds200Response.parse_obj(response.json())
631+
assert response_body.total == 1
632+
result = response_body.results[0]
633+
assert result.source_info.license_tags is not None
634+
assert "family:ODC" in result.source_info.license_tags[0]
635+
assert "license:open-data-commons" in result.source_info.license_tags[1]

api/tests/unittest/models/test_search_feed_item_result_impl.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def __init__(self, **kwargs):
6262
municipality_translations=[],
6363
license_id=None,
6464
license_is_spdx=None,
65+
license_tags=None,
6566
)
6667

6768

@@ -86,6 +87,7 @@ def test_from_orm_gtfs(self):
8687
authentication_info_url=item.authentication_info_url,
8788
api_key_parameter_name=item.api_key_parameter_name,
8889
license_url=item.license_url,
90+
license_tags=[],
8991
),
9092
redirects=item.redirect_ids,
9193
locations=item.locations,
@@ -131,6 +133,7 @@ def test_from_orm_gtfs_rt(self):
131133
authentication_info_url=item.authentication_info_url,
132134
api_key_parameter_name=item.api_key_parameter_name,
133135
license_url=item.license_url,
136+
license_tags=[],
134137
),
135138
redirects=item.redirect_ids,
136139
locations=item.locations,
@@ -218,3 +221,27 @@ def test_from_orm_locations_country_invalid_code(self):
218221
assert result.locations == [
219222
Location(country_code="XY", country="", subdivision_name="subdivision_name", municipality="municipality")
220223
]
224+
225+
def test_from_orm_license_tags(self):
226+
"""Test that license_tags are correctly populated from the feed_search_row."""
227+
item = copy.deepcopy(search_item)
228+
item.data_type = "gtfs"
229+
item.license_tags = ["family:ODC", "license:open-data-commons"]
230+
result = SearchFeedItemResultImpl.from_orm(item)
231+
assert result.source_info.license_tags == ["family:ODC", "license:open-data-commons"]
232+
233+
def test_from_orm_license_tags_none(self):
234+
"""Test that license_tags defaults to empty list when None."""
235+
item = copy.deepcopy(search_item)
236+
item.data_type = "gtfs"
237+
item.license_tags = None
238+
result = SearchFeedItemResultImpl.from_orm(item)
239+
assert result.source_info.license_tags == []
240+
241+
def test_from_orm_gtfs_rt_license_tags(self):
242+
"""Test that license_tags are correctly populated for GTFS-RT feeds."""
243+
item = copy.deepcopy(search_item)
244+
item.data_type = "gtfs_rt"
245+
item.license_tags = ["family:ODC"]
246+
result = SearchFeedItemResultImpl.from_orm(item)
247+
assert result.source_info.license_tags == ["family:ODC"]

docs/DatabaseCatalogAPI.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ paths:
338338
- $ref: "#/components/parameters/feature"
339339
- $ref: "#/components/parameters/license_ids"
340340
- $ref: "#/components/parameters/license_is_spdx"
341+
- $ref: "#/components/parameters/license_tags"
341342
security:
342343
- Authentication: []
343344
responses:
@@ -1516,6 +1517,14 @@ components:
15161517
required: false
15171518
schema:
15181519
type: boolean
1520+
license_tags:
1521+
name: license_tags
1522+
in: query
1523+
description: Comma separated list of tags to filter feeds by their license tags.
1524+
required: false
1525+
schema:
1526+
type: string
1527+
example: family:ODC,license:open-data-commons
15191528
provider:
15201529
name: provider
15211530
in: query

liquibase/changelog.xml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,15 @@
9595
<include file="changes/fix_operation_status_constraint.sql" relativeToChangelogFile="true"/>
9696
<!-- Adding alt_name to the geopolygon table -->
9797
<include file="changes/feat_1479.sql" relativeToChangelogFile="true"/>
98-
<!-- Centralized materialized view definitions.
99-
Views are rebuilt from source SQL files using runOnChange. -->
100-
<!-- Keep this at the end to ensure all table and schema changes
101-
are applied before materialized views are rebuilt. -->
102-
<include file="materialized_views/materialized_views.xml" relativeToChangelogFile="true"/>
10398
<!-- Remove filename as the primary key on databasechangelog table and add composite(id, author, filename) -->
10499
<include file="changes/update_liquibase_changelog.sql" relativeToChangelogFile="true"/>
105100
<!-- Change gtfsfile table hosted_url column type to text to accomodate longer URLs. -->
106101
<include file="changes/feat_1542.sql" relativeToChangelogFile="true"/>
107102
<!-- Add license_tag table and license_license_tags join table for tag classification of licenses. -->
108103
<include file="changes/feat_1565.sql" relativeToChangelogFile="true"/>
104+
<!-- Centralized materialized view definitions.
105+
Views are rebuilt from source SQL files using runOnChange. -->
106+
<!-- Keep this at the very end to ensure all table and schema changes
107+
are applied before materialized views are (re)created. -->
108+
<include file="materialized_views/materialized_views.xml" relativeToChangelogFile="true"/>
109109
</databaseChangeLog>

liquibase/materialized_views/feed_search.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ SELECT
2828
Feed.license_id AS license_id,
2929
License.is_spdx AS license_is_spdx,
3030
License.name AS license_name,
31+
LicenseTagsJoin.license_tags AS license_tags,
3132

3233
-- latest_dataset
3334
Latest_dataset.stable_id AS latest_dataset_id,
@@ -91,6 +92,15 @@ FROM Feed
9192
-- license join
9293
LEFT JOIN License ON License.id = Feed.license_id
9394

95+
-- license tags join
96+
LEFT JOIN (
97+
SELECT
98+
llt.license_id,
99+
array_agg(llt.tag_id ORDER BY llt.tag_id) AS license_tags
100+
FROM license_license_tags llt
101+
GROUP BY llt.license_id
102+
) AS LicenseTagsJoin ON LicenseTagsJoin.license_id = Feed.license_id
103+
94104
-- Latest dataset
95105
LEFT JOIN gtfsfeed gtf ON gtf.id = Feed.id AND Feed.data_type = 'gtfs'
96106
LEFT JOIN gtfsdataset Latest_dataset ON Latest_dataset.id = gtf.latest_dataset_id
@@ -270,4 +280,5 @@ CREATE INDEX feedsearch_data_type ON FeedSearch(data_type);
270280
CREATE INDEX feedsearch_status ON FeedSearch(status);
271281
CREATE INDEX feedsearch_license_id ON FeedSearch(license_id);
272282
CREATE INDEX feedsearch_license_is_spdx ON FeedSearch(license_is_spdx);
283+
CREATE INDEX feedsearch_license_tags ON FeedSearch USING GIN(license_tags);
273284

web-app/src/app/services/feeds/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,8 @@ export interface components {
974974
license_ids?: string;
975975
/** @description Filter feeds by whether their license is an SPDX license. */
976976
license_is_spdx?: boolean;
977+
/** @description Comma separated list of tags to filter feeds by their license tags. */
978+
license_tags?: string;
977979
/** @description List only feeds with the specified value. Can be a partial match. Case insensitive. */
978980
provider?: string;
979981
/** @description List only feeds with the specified value. Can be a partial match. Case insensitive. */
@@ -1334,6 +1336,7 @@ export interface operations {
13341336
feature?: components['parameters']['feature'];
13351337
license_ids?: components['parameters']['license_ids'];
13361338
license_is_spdx?: components['parameters']['license_is_spdx'];
1339+
license_tags?: components['parameters']['license_tags'];
13371340
};
13381341
};
13391342
responses: {

0 commit comments

Comments
 (0)