Skip to content

Commit 6281829

Browse files
committed
fix: Adjust the pagination logic to use a limit+1 strategy for determining if more results are available
- Deleted the `.gitmodules` file as it is no longer needed. - Updated the `get_paginated_inverse_references` function to simplify the return structure by removing the total count from the response, focusing on pagination with `has_more`. - Adjusted the pagination logic to use a limit+1 strategy for determining if more results are available. - Enhanced error handling in the `find_similar_resources` function to validate limit and offset parameters, ensuring they are positive integers. - Improved test cases for linked resources and similar resources to reflect the updated response structure and pagination logic.
1 parent 71fa6ee commit 6281829

File tree

10 files changed

+474
-438
lines changed

10 files changed

+474
-438
lines changed

.gitmodules

Whitespace-only changes.

heritrace/routes/linked_resources.py

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -12,53 +12,28 @@
1212

1313
linked_resources_bp = Blueprint("linked_resources", __name__, url_prefix="/api/linked-resources")
1414

15-
def get_paginated_inverse_references(subject_uri: str, limit: int, offset: int) -> tuple[list[dict], int, bool]:
15+
def get_paginated_inverse_references(subject_uri: str, limit: int, offset: int) -> tuple[list[dict], bool]:
1616
"""
17-
Get paginated entities that reference this entity.
17+
Get paginated entities that reference this entity using the limit+1 strategy.
1818
1919
Args:
2020
subject_uri: URI of the entity to find references to.
21-
limit: Maximum number of references to return.
21+
limit: Maximum number of references to return per page.
2222
offset: Number of references to skip.
2323
2424
Returns:
2525
A tuple containing:
26-
- List of dictionaries containing reference information.
27-
- Total count of references.
26+
- List of dictionaries containing reference information (max 'limit' items).
2827
- Boolean indicating if there are more references.
2928
"""
3029
sparql = get_sparql()
3130
custom_filter = get_custom_filter()
32-
total_count = 0
3331
references = []
32+
# Fetch limit + 1 to check if there are more results
33+
query_limit = limit + 1
3434

3535
try:
36-
# Count Query
37-
count_query_parts = [
38-
"SELECT (COUNT(DISTINCT ?s) as ?count) WHERE {",
39-
]
40-
if is_virtuoso:
41-
count_query_parts.append(" GRAPH ?g { ?s ?p ?o . }")
42-
count_query_parts.append(f" FILTER(?g NOT IN (<{'>, <'.join(VIRTUOSO_EXCLUDED_GRAPHS)}>))")
43-
else:
44-
count_query_parts.append(" ?s ?p ?o .")
45-
46-
count_query_parts.extend([
47-
f" FILTER(?o = <{subject_uri}>)",
48-
" FILTER(?p != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>)",
49-
"}"
50-
])
51-
count_query = "\n".join(count_query_parts)
52-
53-
sparql.setQuery(count_query)
54-
sparql.setReturnFormat(JSON)
55-
count_results = sparql.query().convert()
56-
if count_results.get("results", {}).get("bindings", []):
57-
count_binding = count_results["results"]["bindings"][0]
58-
if "count" in count_binding:
59-
total_count = int(count_binding["count"]["value"])
60-
61-
# Main Query with pagination
36+
# Main Query with pagination (limit + 1)
6237
query_parts = [
6338
"SELECT DISTINCT ?s ?p WHERE {",
6439
]
@@ -71,7 +46,7 @@ def get_paginated_inverse_references(subject_uri: str, limit: int, offset: int)
7146
query_parts.extend([
7247
f" FILTER(?o = <{subject_uri}>)",
7348
" FILTER(?p != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>)",
74-
f"}} ORDER BY ?s OFFSET {offset} LIMIT {limit}"
49+
f"}} ORDER BY ?s OFFSET {offset} LIMIT {query_limit}" # Use query_limit
7550
])
7651
main_query = "\n".join(query_parts)
7752

@@ -80,7 +55,14 @@ def get_paginated_inverse_references(subject_uri: str, limit: int, offset: int)
8055
results = sparql.query().convert()
8156

8257
bindings = results.get("results", {}).get("bindings", [])
83-
for result in bindings:
58+
59+
# Determine if there are more results
60+
has_more = len(bindings) > limit
61+
62+
# Process only up to 'limit' results
63+
results_to_process = bindings[:limit]
64+
65+
for result in results_to_process:
8466
subject = result["s"]["value"]
8567
predicate = result["p"]["value"]
8668

@@ -100,13 +82,12 @@ def get_paginated_inverse_references(subject_uri: str, limit: int, offset: int)
10082
"label": label
10183
})
10284

103-
has_more = offset + limit < total_count
104-
return references, total_count, has_more
85+
return references, has_more
10586

10687
except Exception as e:
10788
tb_str = traceback.format_exc()
10889
current_app.logger.error(f"Error fetching inverse references for {subject_uri}: {e}\n{tb_str}")
109-
return [], 0, False
90+
return [], False
11091

11192
@linked_resources_bp.route("/", methods=["GET"])
11293
@login_required
@@ -125,11 +106,10 @@ def get_linked_resources_api():
125106
if limit <= 0 or offset < 0:
126107
return jsonify({"status": "error", "message": gettext("Limit must be positive and offset non-negative")}), 400
127108

128-
references, total_count, has_more = get_paginated_inverse_references(subject_uri, limit, offset)
109+
references, has_more = get_paginated_inverse_references(subject_uri, limit, offset)
129110

130111
return jsonify({
131112
"status": "success",
132113
"results": references,
133-
"total_count": total_count,
134114
"has_more": has_more
135115
})

heritrace/routes/merge.py

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,18 @@ def find_similar_resources():
213213
"""Find resources potentially similar to a given subject based on shared literal properties."""
214214
subject_uri = request.args.get("subject_uri")
215215
entity_type = request.args.get("entity_type") # Primary entity type
216-
limit = int(request.args.get("limit", 5))
217-
offset = int(request.args.get("offset", 0))
216+
try:
217+
limit = int(request.args.get("limit", 5))
218+
offset = int(request.args.get("offset", 0))
219+
except ValueError:
220+
return jsonify({"status": "error", "message": gettext("Invalid limit or offset parameter")}), 400
218221

219222
if not subject_uri or not entity_type:
220223
return jsonify({"status": "error", "message": gettext("Missing required parameters (subject_uri, entity_type)")}), 400
221224

225+
if limit <= 0 or offset < 0:
226+
return jsonify({"status": "error", "message": gettext("Limit must be positive and offset non-negative")}), 400
227+
222228
try:
223229
sparql = get_sparql()
224230
custom_filter = get_custom_filter()
@@ -272,46 +278,33 @@ def find_similar_resources():
272278
if not similarity_conditions:
273279
return jsonify({"status": "success", "results": []})
274280

275-
# Get total count query (for pagination)
276-
count_query_parts = [
277-
"SELECT (COUNT(DISTINCT ?similar) as ?count) WHERE {",
278-
f" ?similar a <{entity_type}> .",
279-
f" FILTER(?similar != <{subject_uri}>)",
280-
" {",
281-
" " + " \n UNION\n ".join(similarity_conditions),
282-
" }",
283-
"}"
284-
]
285-
count_query = "\n".join(count_query_parts)
286-
287-
sparql.setQuery(count_query)
288-
sparql.setReturnFormat(JSON)
289-
count_results = sparql.query().convert()
290-
total_count = 0
291-
if count_results.get("results", {}).get("bindings", []):
292-
count_binding = count_results["results"]["bindings"][0]
293-
if "count" in count_binding:
294-
total_count = int(count_binding["count"]["value"])
295-
296-
# Main query with pagination
281+
# Main query with pagination (fetch limit + 1)
282+
query_limit = limit + 1
297283
query_parts = [
298284
"SELECT DISTINCT ?similar WHERE {",
299285
f" ?similar a <{entity_type}> .",
300286
f" FILTER(?similar != <{subject_uri}>)",
301287
" {",
302288
" " + " \n UNION\n ".join(similarity_conditions),
303289
" }",
304-
f"}} OFFSET {offset} LIMIT {limit}"
290+
f"}} ORDER BY ?similar OFFSET {offset} LIMIT {query_limit}" # Use query_limit
305291
]
306292
final_query = "\n".join(query_parts)
307293

308294
sparql.setQuery(final_query)
309295
sparql.setReturnFormat(JSON)
310296
results = sparql.query().convert()
311297

312-
candidate_uris = [item["similar"]["value"] for item in results.get("results", {}).get("bindings", [])]
298+
bindings = results.get("results", {}).get("bindings", [])
299+
candidate_uris = [item["similar"]["value"] for item in bindings]
300+
301+
# Determine if there are more results
302+
has_more = len(candidate_uris) > limit
303+
304+
# Process only up to 'limit' results
305+
results_to_process = candidate_uris[:limit]
313306
transformed_results = []
314-
for uri in candidate_uris:
307+
for uri in results_to_process:
315308
readable_label = custom_filter.human_readable_entity(uri, [entity_type])
316309
sim_types = get_entity_types(uri)
317310
type_labels = [custom_filter.human_readable_predicate(type_uri, sim_types) for type_uri in sim_types]
@@ -322,12 +315,10 @@ def find_similar_resources():
322315
"type_labels": type_labels
323316
})
324317

325-
has_more = offset + limit < total_count
326318
return jsonify({
327319
"status": "success",
328320
"results": transformed_results,
329321
"has_more": has_more,
330-
"total_count": total_count
331322
})
332323

333324
except Exception as e:
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Reusable function to load resources via AJAX with pagination.
3+
*
4+
* @param {object} options - Configuration options.
5+
* @param {string} options.containerSelector - jQuery selector for the list container.
6+
* @param {string} options.noResultsSelector - jQuery selector for the "no results" message element.
7+
* @param {string} options.loadMoreSelector - jQuery selector for the "load more" button.
8+
* @param {string} options.apiUrl - The URL for the AJAX request.
9+
* @param {object} options.ajaxData - An object containing static data to send with the AJAX request (e.g., { subject_uri: '...' }).
10+
* @param {function} options.renderItemCallback - A function that takes a single result item and returns the HTML string for it.
11+
* @param {number} [options.resultsPerPage=5] - Number of items per page.
12+
* @param {string} [options.loadingHtml='<div class="text-center my-3"><div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div></div>'] - HTML for the loading indicator.
13+
* @param {string} [options.errorText='Error loading resources.'] - Text to display on AJAX error.
14+
*/
15+
function loadResources(options) {
16+
const {
17+
containerSelector,
18+
noResultsSelector,
19+
loadMoreSelector,
20+
apiUrl,
21+
ajaxData,
22+
renderItemCallback,
23+
resultsPerPage = 5,
24+
loadingHtml = '<div class="text-center my-3 resource-loading-indicator"><div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div></div>',
25+
errorText = 'Error loading resources.' // Default error text
26+
} = options;
27+
28+
const $container = $(containerSelector);
29+
const $noResults = $(noResultsSelector);
30+
const $loadMoreButton = $(loadMoreSelector);
31+
const $parent = $container.parent(); // Get parent to append loading indicator
32+
33+
let currentPage = 1;
34+
let totalLoaded = 0;
35+
let hasMore = true;
36+
let $loadingIndicator = null;
37+
38+
function fetchData(page) {
39+
if (!hasMore) return;
40+
41+
// Remove existing indicator and add new one
42+
if ($loadingIndicator) {
43+
$loadingIndicator.remove();
44+
}
45+
$loadingIndicator = $(loadingHtml);
46+
$parent.append($loadingIndicator);
47+
$loadMoreButton.hide();
48+
49+
const requestData = {
50+
...ajaxData, // Include static data
51+
limit: resultsPerPage,
52+
offset: (page - 1) * resultsPerPage
53+
};
54+
55+
$.ajax({
56+
url: apiUrl,
57+
method: 'GET',
58+
data: requestData,
59+
success: function(response) {
60+
if ($loadingIndicator) $loadingIndicator.remove();
61+
62+
if (response.status === 'success' && response.results && response.results.length > 0) {
63+
if (page === 1) {
64+
$container.empty(); // Clear only on first load
65+
}
66+
$noResults.hide();
67+
68+
response.results.forEach(function(item) {
69+
const itemHtml = renderItemCallback(item);
70+
if (itemHtml) {
71+
$container.append(itemHtml);
72+
}
73+
});
74+
75+
totalLoaded += response.results.length;
76+
// Use response.has_more if available, otherwise estimate based on resultsPerPage
77+
hasMore = response.has_more !== undefined ? response.has_more : (response.results.length === resultsPerPage);
78+
79+
80+
$container.show();
81+
if (hasMore) {
82+
$loadMoreButton.show();
83+
} else {
84+
$loadMoreButton.hide();
85+
}
86+
87+
} else {
88+
hasMore = false; // Stop loading if error or no results
89+
if (page === 1) { // Only show "no results" if it's the first page and no results came back
90+
if (response.status === 'success' && (!response.results || response.results.length === 0)) {
91+
// Keep the original "no results" text if the API call was successful but empty
92+
} else {
93+
// Otherwise, show a generic error or the specific API message
94+
$noResults.text(response.message || errorText);
95+
}
96+
$noResults.show();
97+
$container.hide(); // Hide container if empty on first load
98+
}
99+
$loadMoreButton.hide(); // Hide button if error or no results on subsequent pages
100+
101+
if (response.status !== 'success') {
102+
console.error(`Error fetching resources from ${apiUrl}:`, response.message);
103+
}
104+
}
105+
},
106+
error: function(jqXHR) {
107+
if ($loadingIndicator) $loadingIndicator.remove();
108+
hasMore = false; // Stop loading on error
109+
if (page === 1) {
110+
$container.hide();
111+
$noResults.text(errorText).show(); // Show generic error on first page AJAX fail
112+
}
113+
$loadMoreButton.hide(); // Hide button on error
114+
console.error(`AJAX error fetching resources from ${apiUrl}:`, jqXHR.statusText);
115+
}
116+
});
117+
}
118+
119+
// Initial load
120+
fetchData(currentPage);
121+
122+
// Load more on button click
123+
$loadMoreButton.on('click', function() {
124+
currentPage++;
125+
fetchData(currentPage);
126+
});
127+
}

0 commit comments

Comments
 (0)