···11221122 }
11231123 }
1124112411251125+ fn get_many_to_many(
11261126+ &self,
11271127+ target: &str,
11281128+ collection: &str,
11291129+ path: &str,
11301130+ path_to_other: &str,
11311131+ limit: u64,
11321132+ after: Option<String>,
11331133+ filter_dids: &HashSet<Did>,
11341134+ filter_to_targets: &HashSet<String>,
11351135+ ) -> Result<PagedOrderedCollection<(String, Vec<RecordId>), String>> {
11361136+ let collection = Collection(collection.to_string());
11371137+ let path = RPath(path.to_string());
11381138+11391139+ let target_key = TargetKey(Target(target.to_string()), collection.clone(), path);
11401140+11411141+ let after = after.map(|s| s.parse::<u64>().map(TargetId)).transpose()?;
11421142+11431143+ let Some(target_id) = self.target_id_table.get_id_val(&self.db, &target_key)? else {
11441144+ eprintln!("Target not found for {target_key:?}");
11451145+ return Ok(Default::default());
11461146+ };
11471147+11481148+ let filter_did_ids: HashMap<DidId, bool> = filter_dids
11491149+ .iter()
11501150+ .filter_map(|did| self.did_id_table.get_id_val(&self.db, did).transpose())
11511151+ .collect::<Result<Vec<DidIdValue>>>()?
11521152+ .into_iter()
11531153+ .map(|DidIdValue(id, active)| (id, active))
11541154+ .collect();
11551155+11561156+ let mut filter_to_target_ids: HashSet<TargetId> = HashSet::new();
11571157+ for t in filter_to_targets {
11581158+ for (_, target_id) in self.iter_targets_for_target(&Target(t.to_string())) {
11591159+ filter_to_target_ids.insert(target_id);
11601160+ }
11611161+ }
11621162+11631163+ let linkers = self.get_target_linkers(&target_id)?;
11641164+11651165+ // we want to provide many to many which effectively means that we want to show a specific
11661166+ // list of reords that is linked to by a specific number of linkers
11671167+ let mut grouped_links: BTreeMap<TargetId, Vec<RecordId>> = BTreeMap::new();
11681168+ for (did_id, rkey) in linkers.0 {
11691169+ if did_id.is_empty() {
11701170+ continue;
11711171+ }
11721172+11731173+ if !filter_did_ids.is_empty() && filter_did_ids.get(&did_id) != Some(&true) {
11741174+ continue;
11751175+ }
11761176+11771177+ // Make sure the current did is active
11781178+ let Some(did) = self.did_id_table.get_val_from_id(&self.db, did_id.0)? else {
11791179+ eprintln!("failed to look up did from did_id {did_id:?}");
11801180+ continue;
11811181+ };
11821182+ let Some(DidIdValue(_, active)) = self.did_id_table.get_id_val(&self.db, &did)? else {
11831183+ eprintln!("failed to look up did_value from did_id {did_id:?}: {did:?}: data consistency bug?");
11841184+ continue;
11851185+ };
11861186+ if !active {
11871187+ continue;
11881188+ }
11891189+11901190+ let record_link_key = RecordLinkKey(did_id, collection.clone(), rkey.clone());
11911191+ let Some(targets) = self.get_record_link_targets(&record_link_key)? else {
11921192+ continue;
11931193+ };
11941194+11951195+ let Some(fwd_target) = targets
11961196+ .0
11971197+ .into_iter()
11981198+ .filter_map(|RecordLinkTarget(rpath, target_id)| {
11991199+ if rpath.0 == path_to_other
12001200+ && (filter_to_target_ids.is_empty()
12011201+ || filter_to_target_ids.contains(&target_id))
12021202+ {
12031203+ Some(target_id)
12041204+ } else {
12051205+ None
12061206+ }
12071207+ })
12081208+ .take(1)
12091209+ .next()
12101210+ else {
12111211+ eprintln!("no forward match found.");
12121212+ continue;
12131213+ };
12141214+12151215+ // pagination logic mirrors what is currently done in get_many_to_many_counts
12161216+ if after.as_ref().map(|a| fwd_target <= *a).unwrap_or(false) {
12171217+ continue;
12181218+ }
12191219+ let page_is_full = grouped_links.len() as u64 >= limit;
12201220+ if page_is_full {
12211221+ let current_max = grouped_links.keys().next_back().unwrap();
12221222+ if fwd_target > *current_max {
12231223+ continue;
12241224+ }
12251225+ }
12261226+12271227+ // pagination, continued
12281228+ let mut should_evict = false;
12291229+ let entry = grouped_links.entry(fwd_target.clone()).or_insert_with(|| {
12301230+ should_evict = page_is_full;
12311231+ Vec::default()
12321232+ });
12331233+ entry.push(RecordId {
12341234+ did,
12351235+ collection: collection.0.clone(),
12361236+ rkey: rkey.0,
12371237+ });
12381238+12391239+ if should_evict {
12401240+ grouped_links.pop_last();
12411241+ }
12421242+ }
12431243+12441244+ let mut items: Vec<(String, Vec<RecordId>)> = Vec::with_capacity(grouped_links.len());
12451245+ for (fwd_target_id, records) in &grouped_links {
12461246+ let Some(target_key) = self
12471247+ .target_id_table
12481248+ .get_val_from_id(&self.db, fwd_target_id.0)?
12491249+ else {
12501250+ eprintln!("failed to look up target from target_id {fwd_target_id:?}");
12511251+ continue;
12521252+ };
12531253+12541254+ let target_string = target_key.0 .0;
12551255+12561256+ items.push((target_string, records.clone()));
12571257+ }
12581258+12591259+ let next = if grouped_links.len() as u64 >= limit {
12601260+ grouped_links.keys().next_back().map(|k| format!("{}", k.0))
12611261+ } else {
12621262+ None
12631263+ };
12641264+12651265+ Ok(PagedOrderedCollection { items, next })
12661266+ }
12671267+11251268 fn get_links(
11261269 &self,
11271270 target: &str,
+60
constellation/templates/get-many-to-many.html.j2
···11+{% extends "base.html.j2" %}
22+{% import "try-it-macros.html.j2" as try_it %}
33+44+{% block title %}Many-to-Many Links{% endblock %}
55+{% block description %}All {{ query.source }} records with many-to-many links to {{ query.subject }} joining through {{ query.path_to_other }}{% endblock %}
66+77+{% block content %}
88+99+ {% call try_it::get_many_to_many(query.subject, query.source, query.path_to_other, query.did, query.other_subject, query.limit) %}
1010+1111+ <h2>
1212+ Many-to-many links to <code>{{ query.subject }}</code>
1313+ {% if let Some(browseable_uri) = query.subject|to_browseable %}
1414+ <small style="font-weight: normal; font-size: 1rem"><a href="{{ browseable_uri }}">browse record</a></small>
1515+ {% endif %}
1616+ </h2>
1717+1818+ <p><strong>Many-to-many links</strong> from <code>{{ query.source }}</code> joining through <code>{{ query.path_to_other }}</code></p>
1919+2020+ <ul>
2121+ <li>See all links to this target at <code>/links/all</code>: <a href="/links/all?target={{ query.subject|urlencode }}">/links/all?target={{ query.subject }}</a></li>
2222+ </ul>
2323+2424+ <h3>Many-to-many links, most recent first:</h3>
2525+2626+ {% for (target, records) in linking_records %}
2727+ <h4>Target: <code>{{ target }}</code> <small>(<a href="/links/all?target={{ target|urlencode }}">view all links</a>)</small></h4>
2828+ {% for record in records %}
2929+ <pre style="display: block; margin: 1em 2em" class="code"><strong>DID</strong>: {{ record.did().0 }}
3030+<strong>Collection</strong>: {{ record.collection }}
3131+<strong>RKey</strong>: {{ record.rkey }}
3232+-> <a href="https://pdsls.dev/at://{{ record.did().0 }}/{{ record.collection }}/{{ record.rkey }}">browse record</a></pre>
3333+ {% endfor %}
3434+ {% endfor %}
3535+3636+ {% if let Some(c) = cursor %}
3737+ <form method="get" action="/xrpc/blue.microcosm.links.getManyToMany">
3838+ <input type="hidden" name="subject" value="{{ query.subject }}" />
3939+ <input type="hidden" name="source" value="{{ query.source }}" />
4040+ <input type="hidden" name="pathToOther" value="{{ query.path_to_other }}" />
4141+ {% for did in query.did %}
4242+ <input type="hidden" name="did" value="{{ did }}" />
4343+ {% endfor %}
4444+ {% for other in query.other_subject %}
4545+ <input type="hidden" name="otherSubject" value="{{ other }}" />
4646+ {% endfor %}
4747+ <input type="hidden" name="limit" value="{{ query.limit }}" />
4848+ <input type="hidden" name="cursor" value={{ c|json|safe }} />
4949+ <button type="submit">next page…</button>
5050+ </form>
5151+ {% else %}
5252+ <button disabled><em>end of results</em></button>
5353+ {% endif %}
5454+5555+ <details>
5656+ <summary>Raw JSON response</summary>
5757+ <pre class="code">{{ self|tojson }}</pre>
5858+ </details>
5959+6060+{% endblock %}
+19
constellation/templates/hello.html.j2
···8181 ) %}
828283838484+ <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getManyToMany</code></h3>
8585+8686+ <p>A list of many-to-many join records linking to a target and a secondary target.</p>
8787+8888+ <h4>Query parameters:</h4>
8989+9090+ <ul>
9191+ <li><p><code>subject</code>: required, must url-encode. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li>
9292+ <li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li>
9393+ <li><p><code>pathToOther</code>: required. Path to the secondary link in the many-to-many record. Example: <code>otherThing.uri</code></p></li>
9494+ <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>
9595+ <li><p><code>otherSubject</code>: optional, filter secondary links to specific subjects. Include multiple times to filter by multiple subjects. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li>
9696+ <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li>
9797+ </ul>
9898+9999+ <p style="margin-bottom: 0"><strong>Try it:</strong></p>
100100+ {% call try_it::get_many_to_many("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", "reply.parent.uri", [""], [""], 16) %}
101101+102102+84103 <h3 class="route"><code>GET /links</code></h3>
8510486105 <p>A list of records linking to a target.</p>