Add support for reverse ordering in link queries (Issue #1) #1

closed
opened by maxh.site targeting main from maxh.site/microcosm-rs: order_query

The following adds support for reverse-chronological ordering when fetching back-links.

We add a non-required "reverse" query parameter that allows reversing the default order of returned back-links.

Supports both current storage backends (mem_store and rocks_store).

+12 -3
constellation/src/server/mod.rs
··· 239 239 /// Set the max number of links to return per page of results 240 240 #[serde(default = "get_default_cursor_limit")] 241 241 limit: u64, 242 + /// Allow returning links in reverse order (default: false) 243 + #[serde(default)] 244 + reverse: bool, 242 245 } 243 246 #[derive(Serialize)] 244 247 struct OtherSubjectCount { ··· 301 304 collection, 302 305 &path, 303 306 &path_to_other, 307 + query.reverse, 304 308 limit, 305 309 cursor_key, 306 310 &filter_dids, ··· 409 413 /// Set the max number of links to return per page of results 410 414 #[serde(default = "get_default_cursor_limit")] 411 415 limit: u64, 412 - // TODO: allow reverse (er, forward) order as well 416 + /// Allow returning links in reverse order (default: false) 417 + #[serde(default)] 418 + reverse: bool, 413 419 } 414 420 #[derive(Template, Serialize)] 415 421 #[template(path = "get-backlinks.html.j2")] ··· 460 466 &query.subject, 461 467 collection, 462 468 &path, 469 + query.reverse, 463 470 limit, 464 471 until, 465 472 &filter_dids, ··· 508 515 from_dids: Option<String>, // comma separated: gross 509 516 #[serde(default = "get_default_cursor_limit")] 510 517 limit: u64, 511 - // TODO: allow reverse (er, forward) order as well 518 + /// Allow returning links in reverse order (default: false) 519 + #[serde(default)] 520 + reverse: bool, 512 521 } 513 522 #[derive(Template, Serialize)] 514 523 #[template(path = "links.html.j2")] ··· 562 571 &query.target, 563 572 &query.collection, 564 573 &query.path, 574 + query.reverse, 565 575 limit, 566 576 until, 567 577 &filter_dids, ··· 594 604 path: String, 595 605 cursor: Option<OpaqueApiCursor>, 596 606 limit: Option<u64>, 597 - // TODO: allow reverse (er, forward) order as well 598 607 } 599 608 #[derive(Template, Serialize)] 600 609 #[template(path = "dids.html.j2")]
+22 -6
constellation/src/storage/mem_store.rs
··· 140 140 collection: &str, 141 141 path: &str, 142 142 path_to_other: &str, 143 + reverse: bool, 143 144 limit: u64, 144 145 after: Option<String>, 145 146 filter_dids: &HashSet<Did>, ··· 157 158 let filter_to_targets: HashSet<Target> = 158 159 HashSet::from_iter(filter_to_targets.iter().map(|s| Target::new(s))); 159 160 160 - let mut grouped_counts: HashMap<Target, (u64, HashSet<Did>)> = HashMap::new(); 161 - for (did, rkey) in linkers.iter().flatten().cloned() { 161 + // the last type field here acts as an index to allow keeping track of the order in which 162 + // we encountred single elements 163 + let mut grouped_counts: HashMap<Target, (u64, HashSet<Did>, usize)> = HashMap::new(); 164 + for (idx, (did, rkey)) in linkers.iter().flatten().cloned().enumerate() { 162 165 if !filter_dids.is_empty() && !filter_dids.contains(&did) { 163 166 continue; 164 167 } ··· 184 187 .take(1) 185 188 .next() 186 189 { 187 - let e = grouped_counts.entry(fwd_target.clone()).or_default(); 190 + let e = 191 + grouped_counts 192 + .entry(fwd_target.clone()) 193 + .or_insert((0, HashSet::new(), idx)); 188 194 e.0 += 1; 189 195 e.1.insert(did.clone()); 190 196 } 191 197 } 192 198 let mut items: Vec<(String, u64, u64)> = grouped_counts 193 199 .iter() 194 - .map(|(k, (n, u))| (k.0.clone(), *n, u.len() as u64)) 200 + .map(|(k, (n, u, _))| (k.0.clone(), *n, u.len() as u64)) 195 201 .collect(); 196 - items.sort(); 202 + // sort in reverse order to show entries from oldest to newest 203 + if reverse { 204 + items.sort_by(|a, b| b.cmp(a)); 205 + } else { 206 + items.sort(); 207 + } 197 208 items = items 198 209 .into_iter() 199 210 .skip_while(|(t, _, _)| after.as_ref().map(|a| t <= a).unwrap_or(false)) ··· 239 250 target: &str, 240 251 collection: &str, 241 252 path: &str, 253 + reverse: bool, 242 254 limit: u64, 243 255 until: Option<u64>, 244 256 filter_dids: &HashSet<Did>, ··· 261 273 }); 262 274 }; 263 275 264 - let did_rkeys: Vec<_> = if !filter_dids.is_empty() { 276 + let mut did_rkeys: Vec<_> = if !filter_dids.is_empty() { 265 277 did_rkeys 266 278 .iter() 267 279 .filter(|m| { ··· 285 297 let alive = did_rkeys.iter().flatten().count(); 286 298 let gone = total - alive; 287 299 300 + if reverse { 301 + did_rkeys.reverse(); 302 + } 303 + 288 304 let items: Vec<_> = did_rkeys[begin..end] 289 305 .iter() 290 306 .rev()
+2
constellation/src/storage/mod.rs
··· 72 72 collection: &str, 73 73 path: &str, 74 74 path_to_other: &str, 75 + reverse: bool, 75 76 limit: u64, 76 77 after: Option<String>, 77 78 filter_dids: &HashSet<Did>, ··· 87 88 target: &str, 88 89 collection: &str, 89 90 path: &str, 91 + reverse: bool, 90 92 limit: u64, 91 93 until: Option<u64>, 92 94 filter_dids: &HashSet<Did>,
+15 -1
constellation/src/storage/rocks_store.rs
··· 941 941 collection: &str, 942 942 path: &str, 943 943 path_to_other: &str, 944 + reverse: bool, 944 945 limit: u64, 945 946 after: Option<String>, 946 947 filter_dids: &HashSet<Did>, ··· 1071 1072 } 1072 1073 1073 1074 let mut items: Vec<(String, u64, u64)> = Vec::with_capacity(grouped_counts.len()); 1075 + 1074 1076 for (target_id, (n, dids)) in &grouped_counts { 1075 1077 let Some(target) = self 1076 1078 .target_id_table ··· 1082 1084 items.push((target.0 .0, *n, dids.len() as u64)); 1083 1085 } 1084 1086 1087 + // Sort in desired direction 1088 + if reverse { 1089 + items.sort_by(|a, b| b.cmp(a)); // descending 1090 + } else { 1091 + items.sort(); // ascending 1092 + } 1093 + 1085 1094 let next = if grouped_counts.len() as u64 >= limit { 1086 1095 // yeah.... it's a number saved as a string......sorry 1087 1096 grouped_counts ··· 1127 1136 target: &str, 1128 1137 collection: &str, 1129 1138 path: &str, 1139 + reverse: bool, 1130 1140 limit: u64, 1131 1141 until: Option<u64>, 1132 1142 filter_dids: &HashSet<Did>, ··· 1171 1181 let begin = end.saturating_sub(limit as usize); 1172 1182 let next = if begin == 0 { None } else { Some(begin as u64) }; 1173 1183 1174 - let did_id_rkeys = linkers.0[begin..end].iter().rev().collect::<Vec<_>>(); 1184 + let mut did_id_rkeys = linkers.0[begin..end].iter().rev().collect::<Vec<_>>(); 1185 + 1186 + if reverse { 1187 + did_id_rkeys.reverse(); 1188 + } 1175 1189 1176 1190 let mut items = Vec::with_capacity(did_id_rkeys.len()); 1177 1191 // TODO: use get-many (or multi-get or whatever it's called)
+4
constellation/templates/base.html.j2
··· 40 40 padding: 0.5em 0.3em; 41 41 max-width: 100%; 42 42 } 43 + pre.code input { 44 + margin: 0; 45 + padding: 0; 46 + } 43 47 .stat { 44 48 color: #f90; 45 49 font-size: 1.618rem;
+2 -1
constellation/templates/get-backlinks.html.j2
··· 6 6 7 7 {% block content %} 8 8 9 - {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit) %} 9 + {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit, query.reverse) %} 10 10 11 11 <h2> 12 12 Links to <code>{{ query.subject }}</code> ··· 40 40 <input type="hidden" name="did" value="{{ did }}" /> 41 41 {% endfor %} 42 42 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 43 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 43 44 <button type="submit">next page&hellip;</button> 44 45 </form> 45 46 {% else %}
+2
constellation/templates/get-many-to-many-counts.html.j2
··· 13 13 query.did, 14 14 query.other_subject, 15 15 query.limit, 16 + query.reverse, 16 17 ) %} 17 18 18 19 <h2> ··· 53 54 {% endfor %} 54 55 <input type="hidden" name="limit" value="{{ query.limit }}" /> 55 56 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 57 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 56 58 <button type="submit">next page&hellip;</button> 57 59 </form> 58 60 {% else %}
+7 -2
constellation/templates/hello.html.j2
··· 49 49 <li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li> 50 50 <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 51 51 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 52 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 52 53 </ul> 53 54 54 55 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 55 - {% call try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16) %} 56 + {% call 57 + try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16, false) %} 56 58 57 59 58 60 <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getManyToManyCounts</code></h3> ··· 68 70 <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 69 71 <li><p><code>otherSubject</code>: optional, filter secondary links to specific subjects. Include multiple times to filter by multiple users. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li> 70 72 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 73 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 71 74 </ul> 72 75 73 76 <p style="margin-bottom: 0"><strong>Try it:</strong></p> ··· 78 81 [""], 79 82 [""], 80 83 25, 84 + false, 81 85 ) %} 82 86 83 87 ··· 96 100 <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 97 101 <li><p><code>from_dids</code> [deprecated]: optional. Use <code>did</code> instead. Example: <code>from_dids=did:plc:vc7f4oafdgxsihk4cry2xpze,did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 98 102 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 103 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 99 104 </ul> 100 105 101 106 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 102 - {% call try_it::links("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like", ".subject.uri", [""], 16) %} 107 + {% call try_it::links("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like", ".subject.uri", [""], 16, false) %} 103 108 104 109 105 110 <h3 class="route"><code>GET /links/distinct-dids</code></h3>
+2 -1
constellation/templates/links.html.j2
··· 6 6 7 7 {% block content %} 8 8 9 - {% call try_it::links(query.target, query.collection, query.path, query.did, query.limit) %} 9 + {% call try_it::links(query.target, query.collection, query.path, query.did, query.limit, query.reverse) %} 10 10 11 11 <h2> 12 12 Links to <code>{{ query.target }}</code> ··· 37 37 <input type="hidden" name="collection" value="{{ query.collection }}" /> 38 38 <input type="hidden" name="path" value="{{ query.path }}" /> 39 39 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 40 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 40 41 <button type="submit">next page&hellip;</button> 41 42 </form> 42 43 {% else %}
+10 -6
constellation/templates/try-it-macros.html.j2
··· 1 - {% macro get_backlinks(subject, source, dids, limit) %} 1 + {% macro get_backlinks(subject, source, dids, limit, reverse) %} 2 2 <form method="get" action="/xrpc/blue.microcosm.links.getBacklinks"> 3 3 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getBacklinks 4 4 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 6 6 {%- for did in dids %}{% if !did.is_empty() %} 7 7 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 8 8 <span id="did-placeholder"></span> <button id="add-did">+ did filter</button> 9 - &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre> 9 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> 10 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"><button type="submit">get links</button></pre> 10 11 </form> 11 12 <script> 12 13 const addDidButton = document.getElementById('add-did'); ··· 24 25 </script> 25 26 {% endmacro %} 26 27 27 - {% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit) %} 28 + {% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit, reverse) %} 28 29 <form method="get" action="/xrpc/blue.microcosm.links.getManyToManyCounts"> 29 30 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getManyToManyCounts 30 31 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 36 37 {%- for otherSubject in otherSubjects %}{% if !otherSubject.is_empty() %} 37 38 &otherSubject= <input type="text" name="did" value="{{ otherSubject }}" placeholder="at-uri, did, uri..." />{% endif %}{% endfor %} 38 39 <span id="m2m-did-placeholder"></span> <button id="m2m-add-did">+ did filter</button> 39 - &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre> 40 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> 41 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"><button type="submit">get links</button></pre> 40 42 </form> 41 43 <script> 42 44 const m2mAddDidButton = document.getElementById('m2m-add-did'); ··· 66 68 </script> 67 69 {% endmacro %} 68 70 69 - {% macro links(target, collection, path, dids, limit) %} 71 + {% macro links(target, collection, path, dids, limit, reverse) %} 70 72 <form method="get" action="/links"> 71 73 <pre class="code"><strong>GET</strong> /links 72 74 ?target= <input type="text" name="target" value="{{ target }}" placeholder="target" /> ··· 75 77 {%- for did in dids %}{% if !did.is_empty() %} 76 78 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 77 79 <span id="did-placeholder"></span> <button id="add-did">+ did filter</button> 78 - &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre> 80 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> 81 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"> 82 + <button type="submit">get links</button></pre> 79 83 </form> 80 84 <script> 81 85 const addDidButton = document.getElementById('add-did');