···24242525- **Records**: AT Protocol records automatically mapped to GraphQL types
2626- **Queries**: Fetch records with filtering, sorting, and pagination
2727+- **Joins**: Traverse relationships between records (forward and reverse)
2728- **Mutations**: Create, update, and delete records
2829- **Blobs**: Upload and reference binary data (images, files)
2930···7677}
7778```
78798080+### Query with Joins
8181+8282+```graphql
8383+query {
8484+ appBskyFeedPost(first: 10) {
8585+ edges {
8686+ node {
8787+ uri
8888+ text
8989+ # Forward join: Get parent post
9090+ replyToResolved {
9191+ ... on AppBskyFeedPost {
9292+ uri
9393+ text
9494+ }
9595+ }
9696+ # Reverse join: Get first 20 likes (paginated connection)
9797+ appBskyFeedLikeViaSubject(first: 20) {
9898+ totalCount # Total likes
9999+ edges {
100100+ node {
101101+ uri
102102+ createdAt
103103+ }
104104+ }
105105+ }
106106+ }
107107+ }
108108+ }
109109+}
110110+```
111111+79112## Documentation
8011381114- [Queries](./queries.md) - Fetching records with filters and sorting
82115- [Mutations](./mutations.md) - Creating, updating, and deleting records
116116+- [Joins](./joins.md) - Forward and reverse joins between records
83117- [Variables](./variables.md) - Using GraphQL variables
84118- [Blobs](./blobs.md) - Working with binary data
85119
+786
docs/joins.md
···11+# Joins
22+33+QuickSlice automatically generates **forward joins**, **reverse joins**, and **DID joins** based on AT Protocol lexicon schemas, allowing you to traverse relationships between records.
44+55+## Overview
66+77+- **Forward Joins**: Follow references from one record to another (e.g., post → parent post)
88+ - Returns: Single object or `Record` union
99+ - Naming: `{fieldName}Resolved`
1010+1111+- **Reverse Joins**: Discover records that reference a given record (e.g., post → all likes on that post)
1212+ - Returns: **Paginated Connection** with sorting, filtering, and pagination
1313+ - Naming: `{SourceType}Via{FieldName}`
1414+1515+- **DID Joins**: Find records that share the same author (DID)
1616+ - Returns: Single object (unique DID) or **Paginated Connection** (non-unique DID)
1717+ - Naming: `{CollectionName}ByDid`
1818+1919+- **Union Types**: Forward joins return a `Record` union, allowing type-specific field access via inline fragments
2020+2121+## Forward Joins
2222+2323+Forward joins are generated for fields that reference other records via:
2424+- `at-uri` format strings
2525+- `strongRef` objects
2626+2727+### Basic Forward Join
2828+2929+When a field references another record, QuickSlice creates a `*Resolved` field:
3030+3131+```graphql
3232+query {
3333+ appBskyFeedPost {
3434+ edges {
3535+ node {
3636+ uri
3737+ text
3838+ replyTo # The at-uri string
3939+ replyToResolved { # The resolved record
4040+ uri
4141+ }
4242+ }
4343+ }
4444+ }
4545+}
4646+```
4747+4848+### Union Types & Inline Fragments
4949+5050+Forward join fields return a `Record` union type because the referenced record could be any type. Use inline fragments to access type-specific fields:
5151+5252+```graphql
5353+query {
5454+ appBskyFeedPost {
5555+ edges {
5656+ node {
5757+ uri
5858+ text
5959+ replyToResolved {
6060+ # Access fields based on the actual type
6161+ ... on AppBskyFeedPost {
6262+ uri
6363+ text
6464+ createdAt
6565+ }
6666+ ... on AppBskyFeedLike {
6767+ uri
6868+ subject
6969+ createdAt
7070+ }
7171+ }
7272+ }
7373+ }
7474+ }
7575+}
7676+```
7777+7878+### StrongRef Forward Joins
7979+8080+StrongRef fields (containing `uri` and `cid`) are resolved automatically:
8181+8282+```graphql
8383+query {
8484+ appBskyActorProfile {
8585+ edges {
8686+ node {
8787+ displayName
8888+ pinnedPost {
8989+ uri # Original strongRef uri
9090+ cid # Original strongRef cid
9191+ }
9292+ pinnedPostResolved {
9393+ ... on AppBskyFeedPost {
9494+ uri
9595+ text
9696+ likeCount
9797+ }
9898+ }
9999+ }
100100+ }
101101+ }
102102+}
103103+```
104104+105105+## Reverse Joins
106106+107107+Reverse joins are automatically discovered by analyzing all lexicons. They allow you to find all records that reference a given record. **Reverse joins return paginated connections** with support for sorting, filtering, and cursor-based pagination.
108108+109109+### Basic Reverse Join
110110+111111+Reverse join fields are named: `{SourceType}Via{FieldName}` and return a Connection type:
112112+113113+```graphql
114114+query {
115115+ appBskyFeedPost {
116116+ edges {
117117+ node {
118118+ uri
119119+ text
120120+ # Find all likes that reference this post via their 'subject' field
121121+ appBskyFeedLikeViaSubject(first: 20) {
122122+ totalCount # Total number of likes
123123+ edges {
124124+ node {
125125+ uri
126126+ createdAt
127127+ }
128128+ cursor
129129+ }
130130+ pageInfo {
131131+ hasNextPage
132132+ hasPreviousPage
133133+ startCursor
134134+ endCursor
135135+ }
136136+ }
137137+ }
138138+ }
139139+ }
140140+}
141141+```
142142+143143+### Multiple Reverse Joins
144144+145145+A record type can have multiple reverse join fields. You can request different page sizes for each:
146146+147147+```graphql
148148+query {
149149+ appBskyFeedPost {
150150+ edges {
151151+ node {
152152+ uri
153153+ text
154154+ # Get first 10 replies
155155+ appBskyFeedPostViaReplyTo(first: 10) {
156156+ totalCount
157157+ edges {
158158+ node {
159159+ uri
160160+ text
161161+ }
162162+ }
163163+ }
164164+ # Get first 20 likes
165165+ appBskyFeedLikeViaSubject(first: 20) {
166166+ totalCount
167167+ edges {
168168+ node {
169169+ uri
170170+ createdAt
171171+ }
172172+ }
173173+ }
174174+ # Get first 20 reposts
175175+ appBskyFeedRepostViaSubject(first: 20) {
176176+ totalCount
177177+ edges {
178178+ node {
179179+ uri
180180+ createdAt
181181+ }
182182+ }
183183+ }
184184+ }
185185+ }
186186+ }
187187+}
188188+```
189189+190190+### Reverse Joins with StrongRef
191191+192192+Reverse joins work with strongRef fields too. You can also use sorting and filtering:
193193+194194+```graphql
195195+query {
196196+ appBskyFeedPost {
197197+ edges {
198198+ node {
199199+ uri
200200+ text
201201+ # Find all profiles that pinned this post
202202+ appBskyActorProfileViaPinnedPost(
203203+ sortBy: [{field: "indexedAt", direction: DESC}]
204204+ ) {
205205+ totalCount
206206+ edges {
207207+ node {
208208+ uri
209209+ displayName
210210+ }
211211+ }
212212+ }
213213+ }
214214+ }
215215+ }
216216+}
217217+```
218218+219219+### Sorting Reverse Joins
220220+221221+You can sort reverse join results by any field in the joined collection:
222222+223223+```graphql
224224+query {
225225+ appBskyFeedPost {
226226+ edges {
227227+ node {
228228+ uri
229229+ # Get most recent likes first
230230+ appBskyFeedLikeViaSubject(
231231+ first: 10
232232+ sortBy: [{field: "createdAt", direction: DESC}]
233233+ ) {
234234+ edges {
235235+ node {
236236+ uri
237237+ createdAt
238238+ }
239239+ }
240240+ }
241241+ }
242242+ }
243243+ }
244244+}
245245+```
246246+247247+### Filtering Reverse Joins
248248+249249+Use `where` filters to narrow down nested join results:
250250+251251+```graphql
252252+query {
253253+ appBskyFeedPost {
254254+ edges {
255255+ node {
256256+ uri
257257+ text
258258+ # Only get likes from a specific user
259259+ appBskyFeedLikeViaSubject(
260260+ where: { did: { eq: "did:plc:abc123" } }
261261+ ) {
262262+ totalCount # Likes from this specific user
263263+ edges {
264264+ node {
265265+ uri
266266+ createdAt
267267+ }
268268+ }
269269+ }
270270+ }
271271+ }
272272+ }
273273+}
274274+```
275275+276276+## DID Joins
277277+278278+DID joins allow you to traverse relationships between records that share the same author (DID). These are automatically generated for all collection pairs and are named: `{CollectionName}ByDid`
279279+280280+### Two Types of DID Joins
281281+282282+#### 1. Unique DID Joins (literal:self key)
283283+284284+Collections with a `literal:self` key (like profiles) have only one record per DID. These return a **single nullable object** (no pagination needed):
285285+286286+```graphql
287287+query {
288288+ appBskyFeedPost {
289289+ edges {
290290+ node {
291291+ uri
292292+ text
293293+ # Get the author's profile (single object, not paginated)
294294+ appBskyActorProfileByDid {
295295+ uri
296296+ displayName
297297+ bio
298298+ }
299299+ }
300300+ }
301301+ }
302302+}
303303+```
304304+305305+#### 2. Non-Unique DID Joins
306306+307307+Most collections can have multiple records per DID. These return **paginated connections** with full support for sorting, filtering, and pagination:
308308+309309+```graphql
310310+query {
311311+ appBskyActorProfile {
312312+ edges {
313313+ node {
314314+ displayName
315315+ # Get all posts by this user (paginated)
316316+ appBskyFeedPostByDid(
317317+ first: 10
318318+ sortBy: [{field: "indexedAt", direction: DESC}]
319319+ ) {
320320+ totalCount # Total posts by this user
321321+ edges {
322322+ node {
323323+ uri
324324+ text
325325+ indexedAt
326326+ }
327327+ }
328328+ pageInfo {
329329+ hasNextPage
330330+ endCursor
331331+ }
332332+ }
333333+ }
334334+ }
335335+ }
336336+}
337337+```
338338+339339+### DID Join with Filtering
340340+341341+Combine DID joins with filters to find specific records:
342342+343343+```graphql
344344+query {
345345+ appBskyActorProfile(where: { did: { eq: "did:plc:abc123" } }) {
346346+ edges {
347347+ node {
348348+ displayName
349349+ # Get only posts containing "gleam"
350350+ appBskyFeedPostByDid(
351351+ where: { text: { contains: "gleam" } }
352352+ sortBy: [{field: "indexedAt", direction: DESC}]
353353+ ) {
354354+ totalCount # Posts mentioning "gleam"
355355+ edges {
356356+ node {
357357+ text
358358+ indexedAt
359359+ }
360360+ }
361361+ }
362362+ }
363363+ }
364364+ }
365365+}
366366+```
367367+368368+### Cross-Collection DID Queries
369369+370370+DID joins work across all collection pairs, enabling powerful cross-collection queries:
371371+372372+```graphql
373373+query {
374374+ appBskyActorProfile {
375375+ edges {
376376+ node {
377377+ displayName
378378+ # All their posts
379379+ appBskyFeedPostByDid(first: 10) {
380380+ totalCount
381381+ edges {
382382+ node {
383383+ text
384384+ }
385385+ }
386386+ }
387387+ # All their likes
388388+ appBskyFeedLikeByDid(first: 10) {
389389+ totalCount
390390+ edges {
391391+ node {
392392+ subject
393393+ }
394394+ }
395395+ }
396396+ # All their reposts
397397+ appBskyFeedRepostByDid(first: 10) {
398398+ totalCount
399399+ edges {
400400+ node {
401401+ subject
402402+ }
403403+ }
404404+ }
405405+ }
406406+ }
407407+ }
408408+}
409409+```
410410+411411+### DID Join Arguments
412412+413413+Non-unique DID joins support all standard connection arguments:
414414+415415+| Argument | Type | Description |
416416+|----------|------|-------------|
417417+| `first` | `Int` | Number of records to return (forward pagination) |
418418+| `after` | `String` | Cursor for forward pagination |
419419+| `last` | `Int` | Number of records to return (backward pagination) |
420420+| `before` | `String` | Cursor for backward pagination |
421421+| `sortBy` | `[SortFieldInput!]` | Sort by any field in the collection |
422422+| `where` | `WhereInput` | Filter nested records |
423423+424424+## Complete Example
425425+426426+Combining forward joins, reverse joins, and DID joins to build a rich thread view:
427427+428428+```graphql
429429+query GetThread($postUri: String!) {
430430+ appBskyFeedPost(where: { uri: { eq: $postUri } }) {
431431+ edges {
432432+ node {
433433+ uri
434434+ text
435435+ createdAt
436436+437437+ # DID join: Get the author's profile
438438+ appBskyActorProfileByDid {
439439+ displayName
440440+ bio
441441+ }
442442+443443+ # Forward join: Get the parent post
444444+ replyToResolved {
445445+ ... on AppBskyFeedPost {
446446+ uri
447447+ text
448448+ createdAt
449449+ }
450450+ }
451451+452452+ # Reverse join: Get first 10 replies
453453+ appBskyFeedPostViaReplyTo(
454454+ first: 10
455455+ sortBy: [{field: "createdAt", direction: ASC}]
456456+ ) {
457457+ totalCount # Total replies
458458+ edges {
459459+ node {
460460+ uri
461461+ text
462462+ createdAt
463463+ }
464464+ }
465465+ pageInfo {
466466+ hasNextPage
467467+ }
468468+ }
469469+470470+ # Reverse join: Get first 20 likes
471471+ appBskyFeedLikeViaSubject(first: 20) {
472472+ totalCount # Like count
473473+ edges {
474474+ node {
475475+ uri
476476+ createdAt
477477+ }
478478+ }
479479+ }
480480+481481+ # Reverse join: Get reposts
482482+ appBskyFeedRepostViaSubject(first: 20) {
483483+ totalCount # Repost count
484484+ edges {
485485+ node {
486486+ uri
487487+ createdAt
488488+ }
489489+ }
490490+ }
491491+ }
492492+ }
493493+ }
494494+}
495495+```
496496+497497+## DataLoader Batching
498498+499499+All joins use DataLoader for efficient batching:
500500+501501+```graphql
502502+# This query will batch all replyToResolved lookups into a single database query
503503+query {
504504+ appBskyFeedPost(first: 100) {
505505+ edges {
506506+ node {
507507+ uri
508508+ text
509509+ replyToResolved {
510510+ ... on AppBskyFeedPost {
511511+ uri
512512+ text
513513+ }
514514+ }
515515+ }
516516+ }
517517+ }
518518+}
519519+```
520520+521521+**How it works:**
522522+1. Fetches 100 posts
523523+2. Collects all unique `replyTo` URIs
524524+3. Batches them into a single SQL query: `WHERE uri IN (...)`
525525+4. Returns resolved records efficiently
526526+527527+## Performance Tips
528528+529529+### 1. Only Request What You Need
530530+531531+```graphql
532532+# Good: Only request specific fields
533533+query {
534534+ appBskyFeedPost {
535535+ edges {
536536+ node {
537537+ uri
538538+ text
539539+ appBskyFeedLikeViaSubject(first: 20) {
540540+ totalCount # Get count without fetching all records
541541+ edges {
542542+ node {
543543+ uri # Only need the URI
544544+ }
545545+ }
546546+ }
547547+ }
548548+ }
549549+ }
550550+}
551551+```
552552+553553+### 2. Use totalCount for Metrics
554554+555555+Get engagement counts efficiently without fetching all records:
556556+557557+```graphql
558558+query {
559559+ appBskyFeedPost {
560560+ edges {
561561+ node {
562562+ uri
563563+ text
564564+ # Just get counts, no records
565565+ likes: appBskyFeedLikeViaSubject(first: 0) {
566566+ totalCount # Like count
567567+ }
568568+ reposts: appBskyFeedRepostViaSubject(first: 0) {
569569+ totalCount # Repost count
570570+ }
571571+ replies: appBskyFeedPostViaReplyTo(first: 0) {
572572+ totalCount # Reply count
573573+ }
574574+ }
575575+ }
576576+ }
577577+}
578578+```
579579+580580+### 3. Use Pagination on Nested Joins
581581+582582+Nested joins are paginated by default. Always specify `first` or `last` for optimal performance:
583583+584584+```graphql
585585+query {
586586+ appBskyFeedPost(first: 10) {
587587+ edges {
588588+ node {
589589+ uri
590590+ text
591591+ # Limit nested join results
592592+ appBskyFeedLikeViaSubject(first: 20) {
593593+ totalCount # Total likes
594594+ edges {
595595+ node {
596596+ uri
597597+ }
598598+ }
599599+ pageInfo {
600600+ hasNextPage # Know if there are more
601601+ }
602602+ }
603603+ }
604604+ }
605605+ }
606606+}
607607+```
608608+609609+### 4. Avoid Deep Nesting
610610+611611+```graphql
612612+# Avoid: Deeply nested joins can be expensive
613613+query {
614614+ appBskyFeedPost {
615615+ edges {
616616+ node {
617617+ replyToResolved {
618618+ ... on AppBskyFeedPost {
619619+ replyToResolved {
620620+ ... on AppBskyFeedPost {
621621+ replyToResolved {
622622+ # Too deep!
623623+ }
624624+ }
625625+ }
626626+ }
627627+ }
628628+ }
629629+ }
630630+ }
631631+}
632632+```
633633+634634+## Type Resolution
635635+636636+The `Record` union uses a type resolver that examines the `collection` field:
637637+638638+| Collection | GraphQL Type |
639639+|------------|--------------|
640640+| `app.bsky.feed.post` | `AppBskyFeedPost` |
641641+| `app.bsky.feed.like` | `AppBskyFeedLike` |
642642+| `app.bsky.actor.profile` | `AppBskyActorProfile` |
643643+644644+This allows inline fragments to work correctly:
645645+646646+```graphql
647647+{
648648+ appBskyFeedPost {
649649+ edges {
650650+ node {
651651+ replyToResolved {
652652+ # Runtime type is determined by the collection field
653653+ ... on AppBskyFeedPost { text }
654654+ ... on AppBskyFeedLike { subject }
655655+ }
656656+ }
657657+ }
658658+ }
659659+}
660660+```
661661+662662+## Schema Introspection
663663+664664+Discover available joins using introspection:
665665+666666+```graphql
667667+query {
668668+ __type(name: "AppBskyFeedPost") {
669669+ fields {
670670+ name
671671+ type {
672672+ name
673673+ kind
674674+ }
675675+ }
676676+ }
677677+}
678678+```
679679+680680+Look for fields ending in:
681681+- `Resolved` (forward joins)
682682+- `Via*` (reverse joins)
683683+- `ByDid` (DID joins)
684684+685685+## Common Patterns
686686+687687+### Thread Navigation
688688+689689+```graphql
690690+# Get a post and its parent
691691+query {
692692+ appBskyFeedPost(where: { uri: { eq: $uri } }) {
693693+ edges {
694694+ node {
695695+ uri
696696+ text
697697+ replyToResolved {
698698+ ... on AppBskyFeedPost {
699699+ uri
700700+ text
701701+ }
702702+ }
703703+ }
704704+ }
705705+ }
706706+}
707707+```
708708+709709+### Engagement Metrics
710710+711711+Use `totalCount` to get efficient engagement counts without fetching all records:
712712+713713+```graphql
714714+# Get counts efficiently
715715+query {
716716+ appBskyFeedPost {
717717+ edges {
718718+ node {
719719+ uri
720720+ text
721721+ # Get like count
722722+ likes: appBskyFeedLikeViaSubject(first: 0) {
723723+ totalCount
724724+ }
725725+ # Get repost count
726726+ reposts: appBskyFeedRepostViaSubject(first: 0) {
727727+ totalCount
728728+ }
729729+ # Get reply count
730730+ replies: appBskyFeedPostViaReplyTo(first: 0) {
731731+ totalCount
732732+ }
733733+ }
734734+ }
735735+ }
736736+}
737737+```
738738+739739+Or fetch recent engagement with pagination:
740740+741741+```graphql
742742+query {
743743+ appBskyFeedPost {
744744+ edges {
745745+ node {
746746+ uri
747747+ text
748748+ # Get 10 most recent likes
749749+ likes: appBskyFeedLikeViaSubject(
750750+ first: 10
751751+ sortBy: [{field: "createdAt", direction: DESC}]
752752+ ) {
753753+ totalCount # Total like count
754754+ edges {
755755+ node {
756756+ did # Who liked it
757757+ createdAt
758758+ }
759759+ }
760760+ }
761761+ }
762762+ }
763763+ }
764764+}
765765+```
766766+767767+### User's Pinned Content
768768+769769+```graphql
770770+query {
771771+ appBskyActorProfile(where: { did: { eq: $did } }) {
772772+ edges {
773773+ node {
774774+ displayName
775775+ pinnedPostResolved {
776776+ ... on AppBskyFeedPost {
777777+ uri
778778+ text
779779+ createdAt
780780+ }
781781+ }
782782+ }
783783+ }
784784+ }
785785+}
786786+```
···11+---
22+version: 1.4.1
33+title: Execute union with inline fragment
44+file: ./test/executor_test.gleam
55+test_name: execute_union_with_inline_fragment_test
66+---
77+Response(Object([#("search", Object([#("title", String("GraphQL is awesome")), #("content", String("Learn all about GraphQL..."))]))]), [])
+168-24
graphql/src/graphql/executor.gleam
···44import gleam/dict.{type Dict}
55import gleam/list
66import gleam/option.{None, Some}
77+import gleam/set.{type Set}
78import graphql/introspection
89import graphql/parser
910import graphql/schema
···1718/// GraphQL Response
1819pub type Response {
1920 Response(data: value.Value, errors: List(GraphQLError))
2121+}
2222+2323+/// Get the response key for a field (alias if present, otherwise field name)
2424+fn response_key(field_name: String, alias: option.Option(String)) -> String {
2525+ case alias {
2626+ option.Some(alias_name) -> alias_name
2727+ option.None -> field_name
2828+ }
2029}
21302231/// Execute a GraphQL query
···312321 }
313322 }
314323 }
315315- parser.Field(name, _alias, arguments, nested_selections) -> {
324324+ parser.Field(name, alias, arguments, nested_selections) -> {
316325 // Convert arguments to dict (with variable resolution from context)
317326 let args_dict = arguments_to_dict(arguments, ctx)
318327328328+ // Determine the response key (use alias if provided, otherwise field name)
329329+ let key = response_key(name, alias)
330330+319331 // Handle introspection meta-fields
320332 case name {
321333 "__typename" -> {
322334 let type_name = schema.type_name(parent_type)
323323- Ok(#("__typename", value.String(type_name), []))
335335+ Ok(#(key, value.String(type_name), []))
324336 }
325337 "__schema" -> {
326338 let schema_value = introspection.schema_introspection(graphql_schema)
327339 // Handle nested selections on __schema
328340 case nested_selections {
329329- [] -> Ok(#("__schema", schema_value, []))
341341+ [] -> Ok(#(key, schema_value, []))
330342 _ -> {
331343 let selection_set = parser.SelectionSet(nested_selections)
332344 // We don't have an actual type for __Schema, so we'll handle it specially
···339351 ctx,
340352 fragments,
341353 ["__schema", ..path],
354354+ set.new(),
342355 )
343356 {
344357 Ok(#(nested_data, nested_errors)) ->
345345- Ok(#("__schema", nested_data, nested_errors))
358358+ Ok(#(key, nested_data, nested_errors))
346359 Error(err) -> {
347360 let error = GraphQLError(err, ["__schema", ..path])
348348- Ok(#("__schema", value.Null, [error]))
361361+ Ok(#(key, value.Null, [error]))
362362+ }
363363+ }
364364+ }
365365+ }
366366+ }
367367+ "__type" -> {
368368+ // Extract the "name" argument
369369+ case dict.get(args_dict, "name") {
370370+ Ok(value.String(type_name)) -> {
371371+ // Look up the type in the schema
372372+ case introspection.type_by_name_introspection(graphql_schema, type_name) {
373373+ option.Some(type_value) -> {
374374+ // Handle nested selections on __type
375375+ case nested_selections {
376376+ [] -> Ok(#(key, type_value, []))
377377+ _ -> {
378378+ let selection_set = parser.SelectionSet(nested_selections)
379379+ case
380380+ execute_introspection_selection_set(
381381+ selection_set,
382382+ type_value,
383383+ graphql_schema,
384384+ ctx,
385385+ fragments,
386386+ ["__type", ..path],
387387+ set.new(),
388388+ )
389389+ {
390390+ Ok(#(nested_data, nested_errors)) ->
391391+ Ok(#(key, nested_data, nested_errors))
392392+ Error(err) -> {
393393+ let error = GraphQLError(err, ["__type", ..path])
394394+ Ok(#(key, value.Null, [error]))
395395+ }
396396+ }
397397+ }
398398+ }
399399+ }
400400+ option.None -> {
401401+ // Type not found, return null (per GraphQL spec)
402402+ Ok(#(key, value.Null, []))
349403 }
350404 }
351405 }
406406+ Ok(_) -> {
407407+ let error = GraphQLError("__type argument 'name' must be a String", path)
408408+ Ok(#(key, value.Null, [error]))
409409+ }
410410+ Error(_) -> {
411411+ let error = GraphQLError("__type requires a 'name' argument", path)
412412+ Ok(#(key, value.Null, [error]))
413413+ }
352414 }
353415 }
354416 _ -> {
···356418 case schema.get_field(parent_type, name) {
357419 None -> {
358420 let error = GraphQLError("Field '" <> name <> "' not found", path)
359359- Ok(#(name, value.Null, [error]))
421421+ Ok(#(key, value.Null, [error]))
360422 }
361423 Some(field) -> {
362424 // Get the field's type for nested selections
···369431 case schema.resolve_field(field, field_ctx) {
370432 Error(err) -> {
371433 let error = GraphQLError(err, [name, ..path])
372372- Ok(#(name, value.Null, [error]))
434434+ Ok(#(key, value.Null, [error]))
373435 }
374436 Ok(field_value) -> {
375437 // If there are nested selections, recurse
376438 case nested_selections {
377377- [] -> Ok(#(name, field_value, []))
439439+ [] -> Ok(#(key, field_value, []))
378440 _ -> {
379441 // Need to resolve nested fields
380442 case field_value {
381443 value.Object(_) -> {
382382- // Execute nested selections using the field's type, not parent type
444444+ // Check if field_type_def is a union type
445445+ // If so, resolve it to the concrete type first
446446+ let type_to_use = case schema.is_union(field_type_def) {
447447+ True -> {
448448+ // Create context with the field value for type resolution
449449+ let resolve_ctx =
450450+ schema.context(option.Some(field_value))
451451+ case
452452+ schema.resolve_union_type(field_type_def, resolve_ctx)
453453+ {
454454+ Ok(concrete_type) -> concrete_type
455455+ Error(_) -> field_type_def
456456+ // Fallback to union type if resolution fails
457457+ }
458458+ }
459459+ False -> field_type_def
460460+ }
461461+462462+ // Execute nested selections using the resolved type
383463 // Create new context with this object's data
384464 let object_ctx = schema.context(option.Some(field_value))
385465 let selection_set =
···387467 case
388468 execute_selection_set(
389469 selection_set,
390390- field_type_def,
470470+ type_to_use,
391471 graphql_schema,
392472 object_ctx,
393473 fragments,
···395475 )
396476 {
397477 Ok(#(nested_data, nested_errors)) ->
398398- Ok(#(name, nested_data, nested_errors))
478478+ Ok(#(key, nested_data, nested_errors))
399479 Error(err) -> {
400480 let error = GraphQLError(err, [name, ..path])
401401- Ok(#(name, value.Null, [error]))
481481+ Ok(#(key, value.Null, [error]))
402482 }
403483 }
404484 }
···423503 parser.SelectionSet(nested_selections)
424504 let results =
425505 list.map(items, fn(item) {
506506+ // Check if inner_type is a union and resolve it
507507+ let item_type = case schema.is_union(inner_type) {
508508+ True -> {
509509+ // Create context with the item value for type resolution
510510+ let resolve_ctx = schema.context(option.Some(item))
511511+ case schema.resolve_union_type(inner_type, resolve_ctx) {
512512+ Ok(concrete_type) -> concrete_type
513513+ Error(_) -> inner_type // Fallback to union type if resolution fails
514514+ }
515515+ }
516516+ False -> inner_type
517517+ }
518518+426519 // Create context with this item's data
427520 let item_ctx = schema.context(option.Some(item))
428521 execute_selection_set(
429522 selection_set,
430430- inner_type,
523523+ item_type,
431524 graphql_schema,
432525 item_ctx,
433526 fragments,
···454547 }
455548 })
456549457457- Ok(#(name, value.List(processed_items), all_errors))
550550+ Ok(#(key, value.List(processed_items), all_errors))
458551 }
459459- _ -> Ok(#(name, field_value, []))
552552+ _ -> Ok(#(key, field_value, []))
460553 }
461554 }
462555 }
···479572 ctx: schema.Context,
480573 fragments: Dict(String, parser.Operation),
481574 path: List(String),
575575+ visited_types: Set(String),
482576) -> Result(#(value.Value, List(GraphQLError)), String) {
483577 case selection_set {
484578 parser.SelectionSet(selections) -> {
···494588 ctx,
495589 fragments,
496590 path,
591591+ visited_types,
497592 )
498593 })
499594···524619 Ok(#(value.Null, []))
525620 }
526621 value.Object(fields) -> {
527527- // For each selection, find the corresponding field in the object
528528- let results =
622622+ // CYCLE DETECTION: Extract type name from object to detect circular references
623623+ let type_name = case list.key_find(fields, "name") {
624624+ Ok(value.String(name)) -> option.Some(name)
625625+ _ -> option.None
626626+ }
627627+628628+ // Check if we've already visited this type to prevent infinite loops
629629+ let is_cycle = case type_name {
630630+ option.Some(name) -> set.contains(visited_types, name)
631631+ option.None -> False
632632+ }
633633+634634+ // If we detected a cycle, return a minimal object to break the loop
635635+ case is_cycle {
636636+ True -> {
637637+ // Return just the type name and kind to break the cycle
638638+ let minimal_fields = case type_name {
639639+ option.Some(name) -> {
640640+ let kind_value = case list.key_find(fields, "kind") {
641641+ Ok(kind) -> kind
642642+ Error(_) -> value.Null
643643+ }
644644+ [#("name", value.String(name)), #("kind", kind_value)]
645645+ }
646646+ option.None -> []
647647+ }
648648+ Ok(#(value.Object(minimal_fields), []))
649649+ }
650650+ False -> {
651651+ // Add current type to visited set before recursing
652652+ let new_visited = case type_name {
653653+ option.Some(name) -> set.insert(visited_types, name)
654654+ option.None -> visited_types
655655+ }
656656+657657+ // For each selection, find the corresponding field in the object
658658+ let results =
529659 list.map(selections, fn(selection) {
530660 case selection {
531661 parser.FragmentSpread(name) -> {
532662 // Look up the fragment definition
533663 case dict.get(fragments, name) {
534534- Error(_) -> Error(Nil)
535535- // Fragment not found, skip it
664664+ Error(_) -> {
665665+ // Fragment not found - return error
666666+ let error = GraphQLError("Fragment '" <> name <> "' not found", path)
667667+ Ok(#("__FRAGMENT_ERROR", value.String("Fragment not found: " <> name), [error]))
668668+ }
536669 Ok(parser.FragmentDefinition(
537670 _fname,
538671 _type_condition,
539672 fragment_selection_set,
540673 )) -> {
541674 // For introspection, we don't check type conditions - just execute the fragment
675675+ // IMPORTANT: Use visited_types (not new_visited) because we're selecting from
676676+ // the SAME object, not recursing into it. The current object was already added
677677+ // to new_visited, but the fragment is just selecting different fields.
542678 case
543679 execute_introspection_selection_set(
544680 fragment_selection_set,
···547683 ctx,
548684 fragments,
549685 path,
686686+ visited_types,
550687 )
551688 {
552689 Ok(#(value.Object(fragment_fields), errs)) ->
···577714 ctx,
578715 fragments,
579716 path,
717717+ new_visited,
580718 )
581719 {
582720 Ok(#(value.Object(fragment_fields), errs)) ->
···590728 Error(_err) -> Error(Nil)
591729 }
592730 }
593593- parser.Field(name, _alias, _arguments, nested_selections) -> {
731731+ parser.Field(name, alias, _arguments, nested_selections) -> {
732732+ // Determine the response key (use alias if provided, otherwise field name)
733733+ let key = response_key(name, alias)
734734+594735 // Find the field in the object
595736 case list.key_find(fields, name) {
596737 Ok(field_value) -> {
597738 // Handle nested selections
598739 case nested_selections {
599599- [] -> Ok(#(name, field_value, []))
740740+ [] -> Ok(#(key, field_value, []))
600741 _ -> {
601742 let selection_set =
602743 parser.SelectionSet(nested_selections)
···608749 ctx,
609750 fragments,
610751 [name, ..path],
752752+ new_visited,
611753 )
612754 {
613755 Ok(#(nested_data, nested_errors)) ->
614614- Ok(#(name, nested_data, nested_errors))
756756+ Ok(#(key, nested_data, nested_errors))
615757 Error(err) -> {
616758 let error = GraphQLError(err, [name, ..path])
617617- Ok(#(name, value.Null, [error]))
759759+ Ok(#(key, value.Null, [error]))
618760 }
619761 }
620762 }
···623765 Error(_) -> {
624766 let error =
625767 GraphQLError("Field '" <> name <> "' not found", path)
626626- Ok(#(name, value.Null, [error]))
768768+ Ok(#(key, value.Null, [error]))
627769 }
628770 }
629771 }
···655797 })
656798657799 Ok(#(value.Object(data), errors))
800800+ }
801801+ }
658802 }
659803 _ ->
660804 Error(
+150-28
graphql/src/graphql/introspection.gleam
···22///
33/// Implements the GraphQL introspection system per the GraphQL spec.
44/// Provides __schema, __type, and __typename meta-fields.
55+import gleam/dict
56import gleam/list
67import gleam/option
88+import gleam/result
79import graphql/schema
810import graphql/value
911···3032 ])
3133}
32343535+/// Build introspection value for __type(name: "TypeName")
3636+/// Returns Some(type_introspection) if the type is found, None otherwise
3737+pub fn type_by_name_introspection(
3838+ graphql_schema: schema.Schema,
3939+ type_name: String,
4040+) -> option.Option(value.Value) {
4141+ let all_types = get_all_schema_types(graphql_schema)
4242+4343+ // Find the type with the matching name
4444+ let found_type =
4545+ list.find(all_types, fn(t) { schema.type_name(t) == type_name })
4646+4747+ case found_type {
4848+ Ok(t) -> option.Some(type_introspection(t))
4949+ Error(_) -> option.None
5050+ }
5151+}
5252+3353/// Get all types from the schema as schema.Type values
3454/// Useful for testing and documentation generation
3555pub fn get_all_schema_types(graphql_schema: schema.Schema) -> List(schema.Type) {
···4666 option.None -> mut_collected_types
4767 }
48684949- // Deduplicate by type name
5050- let type_names = list.map(all_collected_types, schema.type_name)
5151- let unique_types =
5252- list.zip(type_names, all_collected_types)
5353- |> list.unique
5454- |> list.map(fn(pair) { pair.1 })
6969+ // Deduplicate by type name, preferring types with more fields
7070+ // This ensures we get the "most complete" version of each type
7171+ let unique_types = deduplicate_types_by_name(all_collected_types)
55725673 // Add any built-in scalars that aren't already in the list
5774 let all_built_ins = [
···8097 list.map(all_types, type_introspection)
8198}
8299100100+/// Deduplicate types by name, keeping the version with the most fields
101101+/// This ensures we get the "most complete" version of each type when
102102+/// multiple versions exist (e.g., from different passes in schema building)
103103+fn deduplicate_types_by_name(
104104+ types: List(schema.Type),
105105+) -> List(schema.Type) {
106106+ // Group types by name
107107+ types
108108+ |> list.group(schema.type_name)
109109+ |> dict.to_list
110110+ |> list.map(fn(pair) {
111111+ let #(_name, type_list) = pair
112112+ // For each group, find the type with the most content
113113+ type_list
114114+ |> list.reduce(fn(best, current) {
115115+ // Count content: fields for object types, enum values for enums, etc.
116116+ let best_content_count = get_type_content_count(best)
117117+ let current_content_count = get_type_content_count(current)
118118+119119+ // Prefer the type with more content
120120+ case current_content_count > best_content_count {
121121+ True -> current
122122+ False -> best
123123+ }
124124+ })
125125+ |> result.unwrap(
126126+ list.first(type_list)
127127+ |> result.unwrap(schema.string_type()),
128128+ )
129129+ })
130130+}
131131+132132+/// Get the "content count" for a type (fields, enum values, input fields, etc.)
133133+/// This helps us pick the most complete version of a type during deduplication
134134+fn get_type_content_count(t: schema.Type) -> Int {
135135+ // For object types, count fields
136136+ let field_count = list.length(schema.get_fields(t))
137137+138138+ // For enum types, count enum values
139139+ let enum_value_count = list.length(schema.get_enum_values(t))
140140+141141+ // For input object types, count input fields
142142+ let input_field_count = list.length(schema.get_input_fields(t))
143143+144144+ // Return the maximum (types will only have one of these be non-zero)
145145+ [field_count, enum_value_count, input_field_count]
146146+ |> list.reduce(fn(a, b) {
147147+ case a > b {
148148+ True -> a
149149+ False -> b
150150+ }
151151+ })
152152+ |> result.unwrap(0)
153153+}
154154+83155/// Collect all types referenced in a type (recursively)
156156+/// Note: We collect ALL instances of each type (even duplicates by name)
157157+/// because we want to find the "most complete" version during deduplication
84158fn collect_types_from_type(
85159 t: schema.Type,
86160 acc: List(schema.Type),
87161) -> List(schema.Type) {
8888- case
8989- list.any(acc, fn(existing) {
9090- schema.type_name(existing) == schema.type_name(t)
9191- })
162162+ // Always add this type - we'll deduplicate later by choosing the version with most fields
163163+ let new_acc = [t, ..acc]
164164+165165+ // To prevent infinite recursion, check if we've already traversed this exact type instance
166166+ // We use a simple heuristic: if this type name appears multiple times AND this specific
167167+ // instance has the same or fewer content than what we've seen, skip traversing its children
168168+ let should_traverse_children = case
169169+ schema.is_object(t) || schema.is_enum(t) || schema.is_union(t)
92170 {
9393- True -> acc
9494- // Already collected this type
9595- False -> {
9696- let new_acc = [t, ..acc]
171171+ True -> {
172172+ let current_content_count = get_type_content_count(t)
173173+ let existing_with_same_name =
174174+ list.filter(acc, fn(existing) {
175175+ schema.type_name(existing) == schema.type_name(t)
176176+ })
177177+ let max_existing_content =
178178+ existing_with_same_name
179179+ |> list.map(get_type_content_count)
180180+ |> list.reduce(fn(a, b) {
181181+ case a > b {
182182+ True -> a
183183+ False -> b
184184+ }
185185+ })
186186+ |> result.unwrap(0)
187187+188188+ // Only traverse if this instance has more content than we've seen before
189189+ current_content_count > max_existing_content
190190+ }
191191+ False -> True
192192+ }
193193+194194+ case should_traverse_children {
195195+ False -> new_acc
196196+ True -> {
9719798198 // Recursively collect types from fields if this is an object type
99199 case schema.is_object(t) {
···112212 })
113213 }
114214 False -> {
115115- // Check if it's an InputObjectType
116116- let input_fields = schema.get_input_fields(t)
117117- case list.is_empty(input_fields) {
118118- False -> {
119119- // This is an InputObjectType, collect types from its fields
120120- list.fold(input_fields, new_acc, fn(acc2, input_field) {
121121- let field_type = schema.input_field_type(input_field)
122122- collect_types_from_type_deep(field_type, acc2)
215215+ // Check if it's a union type
216216+ case schema.is_union(t) {
217217+ True -> {
218218+ // Collect types from union's possible_types
219219+ let possible_types = schema.get_possible_types(t)
220220+ list.fold(possible_types, new_acc, fn(acc2, union_type) {
221221+ collect_types_from_type_deep(union_type, acc2)
123222 })
124223 }
125125- True -> {
126126- // Check if it's a wrapping type (List or NonNull)
127127- case schema.inner_type(t) {
128128- option.Some(inner) -> collect_types_from_type_deep(inner, new_acc)
129129- option.None -> new_acc
224224+ False -> {
225225+ // Check if it's an InputObjectType
226226+ let input_fields = schema.get_input_fields(t)
227227+ case list.is_empty(input_fields) {
228228+ False -> {
229229+ // This is an InputObjectType, collect types from its fields
230230+ list.fold(input_fields, new_acc, fn(acc2, input_field) {
231231+ let field_type = schema.input_field_type(input_field)
232232+ collect_types_from_type_deep(field_type, acc2)
233233+ })
234234+ }
235235+ True -> {
236236+ // Check if it's a wrapping type (List or NonNull)
237237+ case schema.inner_type(t) {
238238+ option.Some(inner) ->
239239+ collect_types_from_type_deep(inner, new_acc)
240240+ option.None -> new_acc
241241+ }
242242+ }
130243 }
131244 }
132245 }
···177290 _ -> value.Null
178291 }
179292293293+ // Determine possibleTypes for UNION types
294294+ let possible_types = case kind {
295295+ "UNION" -> {
296296+ let types = schema.get_possible_types(t)
297297+ value.List(list.map(types, type_ref))
298298+ }
299299+ _ -> value.Null
300300+ }
301301+180302 // Handle wrapping types (LIST/NON_NULL) differently
181303 let name = case kind {
182304 "LIST" -> value.Null
···195317 #("description", description),
196318 #("fields", fields),
197319 #("interfaces", value.List([])),
198198- #("possibleTypes", value.Null),
320320+ #("possibleTypes", possible_types),
199321 #("enumValues", enum_values),
200322 #("inputFields", input_fields),
201323 #("ofType", of_type),
+19-11
graphql/src/graphql/parser.gleam
···212212 case parse_selection_set(tokens) {
213213 Ok(#(selections, remaining)) -> {
214214 let op = Query(selections)
215215- // Don't continue parsing if we have operations already - single anonymous query
216216- case acc {
217217- [] -> Ok(#(list.reverse([op]), remaining))
218218- _ -> parse_operations(remaining, [op, ..acc])
219219- }
215215+ // Continue parsing to see if there are more operations (e.g., fragment definitions)
216216+ parse_operations(remaining, [op, ..acc])
220217 }
221218 Error(err) -> Error(err)
222219 }
···281278 parse_selections(rest, [spread, ..acc])
282279 }
283280284284- // Field
281281+ // Field with alias: "alias: fieldName"
282282+ [lexer.Name(alias), lexer.Colon, lexer.Name(field_name), ..rest] -> {
283283+ case parse_field_with_alias(field_name, Some(alias), rest) {
284284+ Ok(#(field, remaining)) -> {
285285+ parse_selections(remaining, [field, ..acc])
286286+ }
287287+ Error(err) -> Error(err)
288288+ }
289289+ }
290290+291291+ // Field without alias
285292 [lexer.Name(name), ..rest] -> {
286286- case parse_field(name, rest) {
293293+ case parse_field_with_alias(name, None, rest) {
287294 Ok(#(field, remaining)) -> {
288295 parse_selections(remaining, [field, ..acc])
289296 }
···297304 }
298305}
299306300300-/// Parse a field with optional arguments and nested selections
301301-fn parse_field(
307307+/// Parse a field with optional alias, arguments and nested selections
308308+fn parse_field_with_alias(
302309 name: String,
310310+ alias: Option(String),
303311 tokens: List(lexer.Token),
304312) -> Result(#(Selection, List(lexer.Token)), ParseError) {
305313 // Parse arguments if present
···319327 [lexer.BraceOpen, ..] -> {
320328 case parse_nested_selections(after_args) {
321329 Ok(#(nested, remaining)) ->
322322- Ok(#(Field(name, None, arguments, nested), remaining))
330330+ Ok(#(Field(name, alias, arguments, nested), remaining))
323331 Error(err) -> Error(err)
324332 }
325333 }
326326- _ -> Ok(#(Field(name, None, arguments, []), after_args))
334334+ _ -> Ok(#(Field(name, alias, arguments, []), after_args))
327335 }
328336}
329337
+62
graphql/src/graphql/schema.gleam
···4949 ObjectType(name: String, description: String, fields: List(Field))
5050 InputObjectType(name: String, description: String, fields: List(InputField))
5151 EnumType(name: String, description: String, values: List(EnumValue))
5252+ UnionType(
5353+ name: String,
5454+ description: String,
5555+ possible_types: List(Type),
5656+ type_resolver: fn(Context) -> Result(String, String),
5757+ )
5258 ListType(inner_type: Type)
5359 NonNullType(inner_type: Type)
5460}
···140146 InputObjectType(name, description, fields)
141147}
142148149149+pub fn union_type(
150150+ name: String,
151151+ description: String,
152152+ possible_types: List(Type),
153153+ type_resolver: fn(Context) -> Result(String, String),
154154+) -> Type {
155155+ UnionType(name, description, possible_types, type_resolver)
156156+}
157157+143158pub fn list_type(inner_type: Type) -> Type {
144159 ListType(inner_type)
145160}
···205220 ObjectType(name, _, _) -> name
206221 InputObjectType(name, _, _) -> name
207222 EnumType(name, _, _) -> name
223223+ UnionType(name, _, _, _) -> name
208224 ListType(inner) -> "[" <> type_name(inner) <> "]"
209225 NonNullType(inner) -> type_name(inner) <> "!"
210226 }
···405421 }
406422}
407423424424+/// Check if type is a union
425425+pub fn is_union(t: Type) -> Bool {
426426+ case t {
427427+ UnionType(_, _, _, _) -> True
428428+ _ -> False
429429+ }
430430+}
431431+432432+/// Get the possible types from a union
433433+pub fn get_possible_types(t: Type) -> List(Type) {
434434+ case t {
435435+ UnionType(_, _, possible_types, _) -> possible_types
436436+ _ -> []
437437+ }
438438+}
439439+440440+/// Resolve a union type to its concrete type using the type resolver
441441+pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) {
442442+ case t {
443443+ UnionType(_, _, possible_types, type_resolver) -> {
444444+ // Call the type resolver to get the concrete type name
445445+ case type_resolver(ctx) {
446446+ Ok(resolved_type_name) -> {
447447+ // Find the concrete type in possible_types
448448+ case
449449+ list.find(possible_types, fn(pt) {
450450+ type_name(pt) == resolved_type_name
451451+ })
452452+ {
453453+ Ok(concrete_type) -> Ok(concrete_type)
454454+ Error(_) ->
455455+ Error(
456456+ "Type resolver returned '"
457457+ <> resolved_type_name
458458+ <> "' which is not a possible type of this union",
459459+ )
460460+ }
461461+ }
462462+ Error(err) -> Error(err)
463463+ }
464464+ }
465465+ _ -> Error("Cannot resolve non-union type")
466466+ }
467467+}
468468+408469/// Get the inner type from a wrapping type (List or NonNull)
409470pub fn inner_type(t: Type) -> option.Option(Type) {
410471 case t {
···421482 ObjectType(_, _, _) -> "OBJECT"
422483 InputObjectType(_, _, _) -> "INPUT_OBJECT"
423484 EnumType(_, _, _) -> "ENUM"
485485+ UnionType(_, _, _, _) -> "UNION"
424486 ListType(_) -> "LIST"
425487 NonNullType(_) -> "NON_NULL"
426488 }
···44file: ./test/sorting_test.gleam
55test_name: db_schema_all_types_snapshot_test
66---
77+scalar Boolean
88+79"""Result of a delete mutation"""
810type DeleteResult {
911 """URI of deleted record"""
1012 uri: String
1113}
12141313-"""Input type for XyzStatusphereStatusInput"""
1414-input XyzStatusphereStatusInput {
1515- """Input field for text"""
1616- text: String
1717- """Input field for createdAt"""
1818- createdAt: String
1919-}
1515+scalar Int
20162117"""Root mutation type"""
2218type Mutation {
···2824 deleteXyzStatusphereStatus: DeleteResult
2925}
30263131-"""Filter operators for XyzStatusphereStatus fields"""
3232-input XyzStatusphereStatusFieldCondition {
3333- """Exact match (equals)"""
3434- eq: String
3535- """Match any value in the list"""
3636- in: [String!]
3737- """Case-insensitive substring match (string fields only)"""
3838- contains: String
3939- """Greater than"""
4040- gt: String
4141- """Greater than or equal to"""
4242- gte: String
4343- """Less than"""
4444- lt: String
4545- """Less than or equal to"""
4646- lte: String
4747-}
4848-4949-"""Filter conditions for XyzStatusphereStatus with nested AND/OR support"""
5050-input XyzStatusphereStatusWhereInput {
5151- """Filter by uri"""
5252- uri: XyzStatusphereStatusFieldCondition
5353- """Filter by cid"""
5454- cid: XyzStatusphereStatusFieldCondition
5555- """Filter by did"""
5656- did: XyzStatusphereStatusFieldCondition
5757- """Filter by collection"""
5858- collection: XyzStatusphereStatusFieldCondition
5959- """Filter by indexedAt"""
6060- indexedAt: XyzStatusphereStatusFieldCondition
6161- """Filter by actorHandle"""
6262- actorHandle: XyzStatusphereStatusFieldCondition
6363- """Filter by text"""
6464- text: XyzStatusphereStatusFieldCondition
6565- """Filter by createdAt"""
6666- createdAt: XyzStatusphereStatusFieldCondition
6767- """All conditions must match (AND logic)"""
6868- and: [XyzStatusphereStatusWhereInput!]
6969- """Any condition must match (OR logic)"""
7070- or: [XyzStatusphereStatusWhereInput!]
7171-}
7272-7373-"""Sort direction for query results"""
7474-enum SortDirection {
7575- """Ascending order"""
7676- ASC
7777- """Descending order"""
7878- DESC
7979-}
8080-8181-"""Available sort fields for XyzStatusphereStatus"""
8282-enum XyzStatusphereStatusSortField {
8383- """Sort by uri"""
8484- uri
8585- """Sort by cid"""
8686- cid
8787- """Sort by did"""
8888- did
8989- """Sort by collection"""
9090- collection
9191- """Sort by indexedAt"""
9292- indexedAt
9393- """Sort by text"""
9494- text
9595- """Sort by createdAt"""
9696- createdAt
9797-}
9898-9999-"""Specifies a field to sort by and its direction"""
100100-input SortFieldInput {
101101- """Field to sort by"""
102102- field: XyzStatusphereStatusSortField!
103103- """Sort direction (ASC or DESC)"""
104104- direction: SortDirection!
105105-}
106106-107107-scalar Int
108108-109109-scalar Boolean
110110-11127"""Information about pagination in a connection"""
11228type PageInfo {
11329 """When paginating forwards, are there more items?"""
···12036 endCursor: String
12137}
122383939+"""Root query type"""
4040+type Query {
4141+ """Query xyz.statusphere.status with cursor pagination and sorting"""
4242+ xyzStatusphereStatus: XyzStatusphereStatusConnection
4343+}
4444+4545+"""Sort direction for query results"""
4646+enum SortDirection {
4747+ """Ascending order"""
4848+ ASC
4949+ """Descending order"""
5050+ DESC
5151+}
5252+12353scalar String
1245412555"""Record type: xyz.statusphere.status"""
···14272 createdAt: String
14373}
144747575+"""A connection to a list of items for XyzStatusphereStatus"""
7676+type XyzStatusphereStatusConnection {
7777+ """A list of edges"""
7878+ edges: [XyzStatusphereStatusEdge!]!
7979+ """Information to aid in pagination"""
8080+ pageInfo: PageInfo!
8181+ """Total number of items in the connection"""
8282+ totalCount: Int
8383+}
8484+14585"""An edge in a connection for XyzStatusphereStatus"""
14686type XyzStatusphereStatusEdge {
14787 """The item at the end of the edge"""
···15090 cursor: String!
15191}
15292153153-"""A connection to a list of items for XyzStatusphereStatus"""
154154-type XyzStatusphereStatusConnection {
155155- """A list of edges"""
156156- edges: [XyzStatusphereStatusEdge!]!
157157- """Information to aid in pagination"""
158158- pageInfo: PageInfo!
159159- """Total number of items in the connection"""
160160- totalCount: Int
9393+"""Filter operators for XyzStatusphereStatus fields"""
9494+input XyzStatusphereStatusFieldCondition {
9595+ """Exact match (equals)"""
9696+ eq: String
9797+ """Match any value in the list"""
9898+ in: [String!]
9999+ """Case-insensitive substring match (string fields only)"""
100100+ contains: String
101101+ """Greater than"""
102102+ gt: String
103103+ """Greater than or equal to"""
104104+ gte: String
105105+ """Less than"""
106106+ lt: String
107107+ """Less than or equal to"""
108108+ lte: String
109109+}
110110+111111+"""Input type for XyzStatusphereStatusInput"""
112112+input XyzStatusphereStatusInput {
113113+ """Input field for text"""
114114+ text: String
115115+ """Input field for createdAt"""
116116+ createdAt: String
117117+}
118118+119119+"""Available sort fields for XyzStatusphereStatus"""
120120+enum XyzStatusphereStatusSortField {
121121+ """Sort by uri"""
122122+ uri
123123+ """Sort by cid"""
124124+ cid
125125+ """Sort by did"""
126126+ did
127127+ """Sort by collection"""
128128+ collection
129129+ """Sort by indexedAt"""
130130+ indexedAt
131131+ """Sort by text"""
132132+ text
133133+ """Sort by createdAt"""
134134+ createdAt
135135+}
136136+137137+"""Specifies a field to sort by and its direction for XyzStatusphereStatus"""
138138+input XyzStatusphereStatusSortFieldInput {
139139+ """Field to sort by"""
140140+ field: XyzStatusphereStatusSortField!
141141+ """Sort direction (ASC or DESC)"""
142142+ direction: SortDirection!
161143}
162144163163-"""Root query type"""
164164-type Query {
165165- """Query xyz.statusphere.status with cursor pagination and sorting"""
166166- xyzStatusphereStatus: XyzStatusphereStatusConnection
145145+"""Filter conditions for XyzStatusphereStatus with nested AND/OR support"""
146146+input XyzStatusphereStatusWhereInput {
147147+ """Filter by uri"""
148148+ uri: XyzStatusphereStatusFieldCondition
149149+ """Filter by cid"""
150150+ cid: XyzStatusphereStatusFieldCondition
151151+ """Filter by did"""
152152+ did: XyzStatusphereStatusFieldCondition
153153+ """Filter by collection"""
154154+ collection: XyzStatusphereStatusFieldCondition
155155+ """Filter by indexedAt"""
156156+ indexedAt: XyzStatusphereStatusFieldCondition
157157+ """Filter by actorHandle"""
158158+ actorHandle: XyzStatusphereStatusFieldCondition
159159+ """Filter by text"""
160160+ text: XyzStatusphereStatusFieldCondition
161161+ """Filter by createdAt"""
162162+ createdAt: XyzStatusphereStatusFieldCondition
163163+ """All conditions must match (AND logic)"""
164164+ and: [XyzStatusphereStatusWhereInput!]
165165+ """Any condition must match (OR logic)"""
166166+ or: [XyzStatusphereStatusWhereInput!]
167167}
168168169169scalar Float
···11+---
22+version: 1.4.1
33+title: WhereInput with mixed types - only includes primitives
44+file: ./test/where_schema_test.gleam
55+test_name: where_input_with_mixed_field_types_snapshot_test
66+---
77+"""Filter conditions for AppBskyTestRecord with nested AND/OR support"""
88+input AppBskyTestRecordWhereInput {
99+ """Filter by uri"""
1010+ uri: AppBskyTestRecordFieldCondition
1111+ """Filter by cid"""
1212+ cid: AppBskyTestRecordFieldCondition
1313+ """Filter by did"""
1414+ did: AppBskyTestRecordFieldCondition
1515+ """Filter by collection"""
1616+ collection: AppBskyTestRecordFieldCondition
1717+ """Filter by indexedAt"""
1818+ indexedAt: AppBskyTestRecordFieldCondition
1919+ """Filter by actorHandle"""
2020+ actorHandle: AppBskyTestRecordFieldCondition
2121+ """Filter by stringField"""
2222+ stringField: AppBskyTestRecordFieldCondition
2323+ """Filter by intField"""
2424+ intField: AppBskyTestRecordFieldCondition
2525+ """Filter by boolField"""
2626+ boolField: AppBskyTestRecordFieldCondition
2727+ """Filter by numberField"""
2828+ numberField: AppBskyTestRecordFieldCondition
2929+ """Filter by datetimeField"""
3030+ datetimeField: AppBskyTestRecordFieldCondition
3131+ """Filter by uriField"""
3232+ uriField: AppBskyTestRecordFieldCondition
3333+ """All conditions must match (AND logic)"""
3434+ and: [AppBskyTestRecordWhereInput!]
3535+ """Any condition must match (OR logic)"""
3636+ or: [AppBskyTestRecordWhereInput!]
3737+}
+6
lexicon_graphql/src/dataloader_ffi.erl
···11+-module(dataloader_ffi).
22+-export([identity/1]).
33+44+%% Identity function - returns value unchanged
55+%% In Erlang, everything is already "dynamic", so this just passes through
66+identity(Value) -> Value.
···11+/// Collection Metadata Extraction
22+///
33+/// Extracts metadata from lexicons to identify fields that can be used for joins.
44+/// This enables dynamic forward and reverse join field generation.
55+import gleam/list
66+import gleam/option.{type Option, None, Some}
77+import lexicon_graphql/nsid
88+import lexicon_graphql/types
99+1010+/// Metadata about a collection extracted from its lexicon
1111+pub type CollectionMeta {
1212+ CollectionMeta(
1313+ /// The NSID of the collection (e.g., "app.bsky.feed.post")
1414+ nsid: String,
1515+ /// The GraphQL type name (e.g., "AppBskyFeedPost")
1616+ type_name: String,
1717+ /// Record key type: "tid", "literal:self", or "any"
1818+ key_type: String,
1919+ /// Whether this collection has only one record per DID (e.g., profiles)
2020+ has_unique_did: Bool,
2121+ /// Fields that can be used for forward joins (following references)
2222+ forward_join_fields: List(ForwardJoinField),
2323+ /// Fields that can be used for reverse joins (fields with at-uri format)
2424+ reverse_join_fields: List(String),
2525+ )
2626+}
2727+2828+/// A field that can be used for forward joins
2929+pub type ForwardJoinField {
3030+ /// A strongRef field - references a specific version of a record (URI + CID)
3131+ StrongRefField(name: String)
3232+ /// An at-uri field - references the latest version of a record (URI only)
3333+ AtUriField(name: String)
3434+}
3535+3636+/// Extract metadata from a lexicon
3737+pub fn extract_metadata(lexicon: types.Lexicon) -> CollectionMeta {
3838+ let type_name = nsid.to_type_name(lexicon.id)
3939+4040+ case lexicon.defs.main {
4141+ Some(main_def) -> {
4242+ // Extract key type from lexicon (default to "tid" if not specified)
4343+ let key_type = case main_def.key {
4444+ Some(k) -> k
4545+ None -> "tid"
4646+ }
4747+4848+ // Determine if this collection has only one record per DID
4949+ // Collections with key="literal:self" have a unique record per DID (e.g., profiles)
5050+ let has_unique_did = key_type == "literal:self"
5151+5252+ let #(forward_fields, reverse_fields) =
5353+ scan_properties(main_def.properties)
5454+5555+ CollectionMeta(
5656+ nsid: lexicon.id,
5757+ type_name: type_name,
5858+ key_type: key_type,
5959+ has_unique_did: has_unique_did,
6060+ forward_join_fields: forward_fields,
6161+ reverse_join_fields: reverse_fields,
6262+ )
6363+ }
6464+ None -> {
6565+ // No main definition, return empty metadata
6666+ CollectionMeta(
6767+ nsid: lexicon.id,
6868+ type_name: type_name,
6969+ key_type: "tid",
7070+ has_unique_did: False,
7171+ forward_join_fields: [],
7272+ reverse_join_fields: [],
7373+ )
7474+ }
7575+ }
7676+}
7777+7878+/// Scan properties to identify forward and reverse join fields
7979+fn scan_properties(
8080+ properties: List(#(String, types.Property)),
8181+) -> #(List(ForwardJoinField), List(String)) {
8282+ list.fold(properties, #([], []), fn(acc, prop) {
8383+ let #(forward_fields, reverse_fields) = acc
8484+ let #(name, property) = prop
8585+8686+ // Check if this is a forward join field
8787+ let new_forward = case is_forward_join_field(property) {
8888+ Some(field_type) -> [field_type(name), ..forward_fields]
8989+ None -> forward_fields
9090+ }
9191+9292+ // Check if this is a reverse join field (at-uri format)
9393+ let new_reverse = case is_reverse_join_field(property) {
9494+ True -> [name, ..reverse_fields]
9595+ False -> reverse_fields
9696+ }
9797+9898+ #(new_forward, new_reverse)
9999+ })
100100+}
101101+102102+/// Check if a property is a forward join field
103103+/// Returns Some(constructor) if it is, None otherwise
104104+fn is_forward_join_field(
105105+ property: types.Property,
106106+) -> Option(fn(String) -> ForwardJoinField) {
107107+ // Case 1: strongRef field
108108+ case property.type_, property.ref {
109109+ "ref", Some(ref_target) if ref_target == "com.atproto.repo.strongRef" ->
110110+ Some(StrongRefField)
111111+ _, _ ->
112112+ // Case 2: at-uri string field
113113+ case property.type_, property.format {
114114+ "string", Some(fmt) if fmt == "at-uri" -> Some(AtUriField)
115115+ _, _ -> None
116116+ }
117117+ }
118118+}
119119+120120+/// Check if a property is a reverse join field (has at-uri format)
121121+fn is_reverse_join_field(property: types.Property) -> Bool {
122122+ case property.format {
123123+ Some(fmt) if fmt == "at-uri" -> True
124124+ _ -> False
125125+ }
126126+}
127127+128128+/// Get all forward join field names from metadata
129129+pub fn get_forward_join_field_names(meta: CollectionMeta) -> List(String) {
130130+ list.map(meta.forward_join_fields, fn(field) {
131131+ case field {
132132+ StrongRefField(name) -> name
133133+ AtUriField(name) -> name
134134+ }
135135+ })
136136+}
137137+138138+/// Check if a field is a strongRef field
139139+pub fn is_strong_ref_field(meta: CollectionMeta, field_name: String) -> Bool {
140140+ list.any(meta.forward_join_fields, fn(field) {
141141+ case field {
142142+ StrongRefField(name) if name == field_name -> True
143143+ _ -> False
144144+ }
145145+ })
146146+}
147147+148148+/// Check if a field is an at-uri field
149149+pub fn is_at_uri_field(meta: CollectionMeta, field_name: String) -> Bool {
150150+ list.any(meta.forward_join_fields, fn(field) {
151151+ case field {
152152+ AtUriField(name) if name == field_name -> True
153153+ _ -> False
154154+ }
155155+ })
156156+}
···5454}
55555656/// SortFieldInput type with a custom field enum
5757-pub fn sort_field_input_type_with_enum(field_enum: schema.Type) -> schema.Type {
5757+/// Creates a unique input type per collection (e.g., "SocialGrainGalleryItemSortFieldInput")
5858+pub fn sort_field_input_type_with_enum(
5959+ type_name: String,
6060+ field_enum: schema.Type,
6161+) -> schema.Type {
6262+ let input_type_name = type_name <> "SortFieldInput"
6363+5864 schema.input_object_type(
5959- "SortFieldInput",
6060- "Specifies a field to sort by and its direction",
6565+ input_type_name,
6666+ "Specifies a field to sort by and its direction for " <> type_name,
6167 [
6268 schema.input_field(
6369 "field",
···167173168174/// Connection arguments with sortBy using a custom field enum and where filtering
169175pub fn lexicon_connection_args_with_field_enum_and_where(
176176+ type_name: String,
170177 field_enum: schema.Type,
171178 where_input_type: schema.Type,
172179) -> List(schema.Argument) {
···177184 schema.argument(
178185 "sortBy",
179186 schema.list_type(schema.non_null(
180180- sort_field_input_type_with_enum(field_enum),
187187+ sort_field_input_type_with_enum(type_name, field_enum),
181188 )),
182189 "Sort order for the connection",
183190 None,
···194201195202/// Connection arguments with sortBy using a custom field enum (backward compatibility)
196203pub fn lexicon_connection_args_with_field_enum(
204204+ type_name: String,
197205 field_enum: schema.Type,
198206) -> List(schema.Argument) {
199207 list.flatten([
···203211 schema.argument(
204212 "sortBy",
205213 schema.list_type(schema.non_null(
206206- sort_field_input_type_with_enum(field_enum),
214214+ sort_field_input_type_with_enum(type_name, field_enum),
207215 )),
208216 "Sort order for the connection",
209217 None,
···11+/// DataLoader for batching database queries
22+///
33+/// This module provides batch query functions for join operations to prevent N+1 queries.
44+/// It works with the existing RecordFetcher pattern to batch URI lookups.
55+import gleam/dict.{type Dict}
66+import gleam/dynamic.{type Dynamic}
77+import gleam/list
88+import gleam/option.{type Option, None, Some}
99+import gleam/result
1010+import gleam/string
1111+import graphql/value
1212+import lexicon_graphql/collection_meta
1313+import lexicon_graphql/uri_extractor
1414+import lexicon_graphql/where_input.{type WhereClause}
1515+1616+/// Result of a batch query: maps URIs to their records
1717+pub type BatchResult =
1818+ Dict(String, List(value.Value))
1919+2020+/// Batch query function type - takes a list of URIs and field constraints,
2121+/// returns records grouped by the URI they match
2222+pub type BatchFetcher =
2323+ fn(List(String), String, Option(String)) ->
2424+ Result(BatchResult, String)
2525+2626+/// Pagination parameters for join queries
2727+/// Re-exported from db_schema_builder to avoid circular dependency
2828+pub type PaginationParams {
2929+ PaginationParams(
3030+ first: Option(Int),
3131+ after: Option(String),
3232+ last: Option(Int),
3333+ before: Option(String),
3434+ sort_by: Option(List(#(String, String))),
3535+ where: Option(WhereClause),
3636+ )
3737+}
3838+3939+/// Result of a paginated batch query
4040+pub type PaginatedBatchResult {
4141+ PaginatedBatchResult(
4242+ /// Records with their cursors
4343+ edges: List(#(value.Value, String)),
4444+ /// Whether there are more records after this page
4545+ has_next_page: Bool,
4646+ /// Whether there are more records before this page
4747+ has_previous_page: Bool,
4848+ /// Total count of records (if available)
4949+ total_count: Option(Int),
5050+ )
5151+}
5252+5353+/// Paginated batch query function type - takes a parent key, collection, field,
5454+/// and pagination params, returns paginated records
5555+pub type PaginatedBatchFetcher =
5656+ fn(String, String, Option(String), PaginationParams) ->
5757+ Result(PaginatedBatchResult, String)
5858+5959+/// Key for forward join batching - identifies a unique batch request
6060+pub type ForwardJoinKey {
6161+ ForwardJoinKey(
6262+ /// Target collection to fetch
6363+ target_collection: String,
6464+ /// URIs to fetch
6565+ uris: List(String),
6666+ )
6767+}
6868+6969+/// Key for reverse join batching - identifies records that reference parent URIs
7070+pub type ReverseJoinKey {
7171+ ReverseJoinKey(
7272+ /// Collection to search in
7373+ collection: String,
7474+ /// Field name that contains the reference (e.g., "subject", "reply")
7575+ reference_field: String,
7676+ /// Parent URIs to find references to
7777+ parent_uris: List(String),
7878+ )
7979+}
8080+8181+/// Extract the collection name from an AT URI
8282+/// Format: at://did:plc:abc123/app.bsky.feed.post/rkey
8383+/// Returns: app.bsky.feed.post
8484+pub fn uri_to_collection(uri: String) -> Option(String) {
8585+ case string.split(uri, "/") {
8686+ ["at:", "", _did, collection, _rkey] -> Some(collection)
8787+ _ -> None
8888+ }
8989+}
9090+9191+/// Batch fetch records by URI (forward joins)
9292+///
9393+/// Given a list of URIs, fetches all target records in a single query.
9494+/// Returns a Dict mapping each URI to its record (if found).
9595+pub fn batch_fetch_by_uri(
9696+ uris: List(String),
9797+ fetcher: BatchFetcher,
9898+) -> Result(Dict(String, value.Value), String) {
9999+ // Group URIs by collection for optimal batching
100100+ let grouped = group_uris_by_collection(uris)
101101+102102+ // Fetch each collection's records in a batch
103103+ list.try_fold(grouped, dict.new(), fn(acc, group) {
104104+ let #(collection, collection_uris) = group
105105+106106+ case fetcher(collection_uris, collection, None) {
107107+ Ok(batch_result) -> {
108108+ // Merge results into accumulator
109109+ // For forward joins, we expect single records per URI
110110+ let merged =
111111+ dict.fold(batch_result, acc, fn(result_acc, uri, records) {
112112+ case records {
113113+ [first, ..] -> dict.insert(result_acc, uri, first)
114114+ [] -> result_acc
115115+ }
116116+ })
117117+ Ok(merged)
118118+ }
119119+ Error(e) -> Error(e)
120120+ }
121121+ })
122122+}
123123+124124+/// Batch fetch records by reverse join (records that reference parent URIs)
125125+///
126126+/// Given parent URIs and a field name, finds all records whose field references those URIs.
127127+/// Returns a Dict mapping each parent URI to the list of records that reference it.
128128+pub fn batch_fetch_by_reverse_join(
129129+ parent_uris: List(String),
130130+ collection: String,
131131+ reference_field: String,
132132+ fetcher: BatchFetcher,
133133+) -> Result(Dict(String, List(value.Value)), String) {
134134+ // Fetch all records that reference any of the parent URIs
135135+ fetcher(parent_uris, collection, Some(reference_field))
136136+}
137137+138138+/// Batch fetch records by DID (records that share the same DID)
139139+///
140140+/// Given a list of DIDs and a target collection, fetches all records in that collection
141141+/// that belong to those DIDs.
142142+/// Returns a Dict mapping each DID to the list of records (or single record for unique collections).
143143+pub fn batch_fetch_by_did(
144144+ dids: List(String),
145145+ target_collection: String,
146146+ fetcher: BatchFetcher,
147147+) -> Result(Dict(String, List(value.Value)), String) {
148148+ // Use the fetcher to get records by DID
149149+ // The fetcher will need to be updated to handle DID-based queries
150150+ // For now, we pass the DIDs as URIs and use None for the field
151151+ // The actual database layer will interpret this correctly
152152+ fetcher(dids, target_collection, None)
153153+}
154154+155155+/// Batch fetch records by reverse join with pagination
156156+///
157157+/// Given a parent URI, field name, and pagination params, finds all records whose field
158158+/// references that URI, with cursor-based pagination.
159159+pub fn batch_fetch_by_reverse_join_paginated(
160160+ parent_uri: String,
161161+ collection: String,
162162+ reference_field: String,
163163+ pagination: PaginationParams,
164164+ fetcher: PaginatedBatchFetcher,
165165+) -> Result(PaginatedBatchResult, String) {
166166+ // Fetch paginated records that reference the parent URI
167167+ fetcher(parent_uri, collection, Some(reference_field), pagination)
168168+}
169169+170170+/// Batch fetch records by DID with pagination
171171+///
172172+/// Given a DID, target collection, and pagination params, fetches all records in that collection
173173+/// that belong to that DID, with cursor-based pagination.
174174+pub fn batch_fetch_by_did_paginated(
175175+ did: String,
176176+ target_collection: String,
177177+ pagination: PaginationParams,
178178+ fetcher: PaginatedBatchFetcher,
179179+) -> Result(PaginatedBatchResult, String) {
180180+ // Fetch paginated records by DID
181181+ fetcher(did, target_collection, None, pagination)
182182+}
183183+184184+/// Group URIs by their collection for batching
185185+fn group_uris_by_collection(uris: List(String)) -> List(#(String, List(String))) {
186186+ // Group URIs by collection
187187+ let grouped =
188188+ list.fold(uris, dict.new(), fn(acc, uri) {
189189+ case uri_to_collection(uri) {
190190+ Some(collection) -> {
191191+ let existing = dict.get(acc, collection) |> result.unwrap([])
192192+ dict.insert(acc, collection, [uri, ..existing])
193193+ }
194194+ None -> acc
195195+ }
196196+ })
197197+198198+ dict.to_list(grouped)
199199+}
200200+201201+/// Extract URIs from a list of records based on field metadata
202202+///
203203+/// This is used to collect URIs from parent records that need to be resolved.
204204+/// For example, extracting all "subject" URIs from a list of Like records.
205205+pub fn extract_uris_from_records(
206206+ records: List(value.Value),
207207+ field_name: String,
208208+ _meta: collection_meta.CollectionMeta,
209209+) -> List(String) {
210210+ list.filter_map(records, fn(record) {
211211+ // Extract the field value from the record
212212+ case extract_field_value(record, field_name) {
213213+ Some(field_value) -> {
214214+ // Use uri_extractor to get the URI (handles both strongRef and at-uri)
215215+ case uri_extractor.extract_uri(field_value) {
216216+ Some(uri) -> Ok(uri)
217217+ None -> Error(Nil)
218218+ }
219219+ }
220220+ None -> Error(Nil)
221221+ }
222222+ })
223223+}
224224+225225+/// Extract a field value from a GraphQL Value
226226+fn extract_field_value(value: value.Value, field_name: String) -> Option(Dynamic) {
227227+ case value {
228228+ value.Object(fields) -> {
229229+ // fields is a List(#(String, value.Value)), find the matching field
230230+ list.find(fields, fn(pair) { pair.0 == field_name })
231231+ |> result.map(fn(pair) { value_to_dynamic(pair.1) })
232232+ |> option.from_result
233233+ }
234234+ _ -> None
235235+ }
236236+}
237237+238238+/// Convert a GraphQL Value to Dynamic for uri_extractor
239239+/// Properly extracts the underlying value from GraphQL Value types
240240+fn value_to_dynamic(v: value.Value) -> Dynamic {
241241+ case v {
242242+ value.String(s) -> unsafe_coerce_to_dynamic(s)
243243+ value.Int(i) -> unsafe_coerce_to_dynamic(i)
244244+ value.Float(f) -> unsafe_coerce_to_dynamic(f)
245245+ value.Boolean(b) -> unsafe_coerce_to_dynamic(b)
246246+ value.Null -> unsafe_coerce_to_dynamic(None)
247247+ value.Object(fields) -> {
248248+ // Convert object fields to a format uri_extractor can work with
249249+ // Create a dict-like structure for the object
250250+ let field_map =
251251+ list.fold(fields, dict.new(), fn(acc, field) {
252252+ let #(key, val) = field
253253+ dict.insert(acc, key, value_to_dynamic(val))
254254+ })
255255+ unsafe_coerce_to_dynamic(field_map)
256256+ }
257257+ value.List(items) -> {
258258+ let converted = list.map(items, value_to_dynamic)
259259+ unsafe_coerce_to_dynamic(converted)
260260+ }
261261+ value.Enum(name) -> unsafe_coerce_to_dynamic(name)
262262+ }
263263+}
264264+265265+@external(erlang, "dataloader_ffi", "identity")
266266+fn unsafe_coerce_to_dynamic(value: a) -> Dynamic
···11+/// Object Type Builder
22+///
33+/// Builds GraphQL object types from ObjectDef definitions.
44+/// Used for nested object types like aspectRatio that are defined
55+/// as refs in lexicons (e.g., "social.grain.defs#aspectRatio")
66+import gleam/dict.{type Dict}
77+import gleam/list
88+import gleam/option
99+import gleam/string
1010+import graphql/schema
1111+import graphql/value
1212+import lexicon_graphql/lexicon_registry
1313+import lexicon_graphql/nsid
1414+import lexicon_graphql/type_mapper
1515+import lexicon_graphql/types
1616+1717+/// Build a GraphQL object type from an ObjectDef
1818+/// object_types_dict is used to resolve refs to other object types
1919+pub fn build_object_type(
2020+ obj_def: types.ObjectDef,
2121+ type_name: String,
2222+ object_types_dict: Dict(String, schema.Type),
2323+) -> schema.Type {
2424+ let fields = build_object_fields(obj_def.properties, object_types_dict)
2525+2626+ schema.object_type(
2727+ type_name,
2828+ "Object type from lexicon definition",
2929+ fields,
3030+ )
3131+}
3232+3333+/// Build GraphQL fields from object properties
3434+fn build_object_fields(
3535+ properties: List(#(String, types.Property)),
3636+ object_types_dict: Dict(String, schema.Type),
3737+) -> List(schema.Field) {
3838+ list.map(properties, fn(prop) {
3939+ let #(name, types.Property(type_, required, format, ref)) = prop
4040+4141+ // Map the type, using the object_types_dict to resolve refs
4242+ let graphql_type =
4343+ type_mapper.map_type_with_registry(type_, format, ref, object_types_dict)
4444+4545+ // Make required fields non-null
4646+ let field_type = case required {
4747+ True -> schema.non_null(graphql_type)
4848+ False -> graphql_type
4949+ }
5050+5151+ // Create field with a resolver that extracts the value from context
5252+ schema.field(name, field_type, "Field from object definition", fn(ctx) {
5353+ case ctx.data {
5454+ option.Some(value.Object(fields)) -> {
5555+ case list.key_find(fields, name) {
5656+ Ok(val) -> Ok(val)
5757+ Error(_) -> Ok(value.Null)
5858+ }
5959+ }
6060+ _ -> Ok(value.Null)
6161+ }
6262+ })
6363+ })
6464+}
6565+6666+/// Build a dict of all object types from the registry
6767+/// Keys are the fully-qualified refs (e.g., "social.grain.defs#aspectRatio")
6868+///
6969+/// Note: This builds types recursively. Object types that reference other object types
7070+/// will have those refs resolved using the same dict (which gets built incrementally).
7171+pub fn build_all_object_types(
7272+ registry: lexicon_registry.Registry,
7373+) -> Dict(String, schema.Type) {
7474+ let object_refs = lexicon_registry.get_all_object_refs(registry)
7575+7676+ // Build all object types in a single pass
7777+ // For simple cases (no circular refs), this works fine
7878+ // TODO: Handle circular refs if needed
7979+ list.fold(object_refs, dict.new(), fn(acc, ref) {
8080+ case lexicon_registry.get_object_def(registry, ref) {
8181+ option.Some(obj_def) -> {
8282+ // Generate a GraphQL type name from the ref
8383+ // e.g., "social.grain.defs#aspectRatio" -> "SocialGrainDefsAspectRatio"
8484+ let type_name = ref_to_type_name(ref)
8585+ // Pass acc as the object_types_dict so we can resolve refs to previously built types
8686+ let object_type = build_object_type(obj_def, type_name, acc)
8787+ dict.insert(acc, ref, object_type)
8888+ }
8989+ option.None -> acc
9090+ }
9191+ })
9292+}
9393+9494+/// Convert a ref to a GraphQL type name
9595+/// Example: "social.grain.defs#aspectRatio" -> "SocialGrainDefsAspectRatio"
9696+fn ref_to_type_name(ref: String) -> String {
9797+ // Replace # with . first, then convert to PascalCase
9898+ let normalized = string.replace(ref, "#", ".")
9999+ nsid.to_type_name(normalized)
100100+}
···44/// Simplified MVP version - handles basic types only.
55///
66/// Based on the Elixir implementation but adapted for the pure Gleam GraphQL library.
77+import gleam/dict.{type Dict}
88+import gleam/option.{type Option}
79import graphql/schema
810import lexicon_graphql/blob_type
911···7678pub fn get_blob_type() -> schema.Type {
7779 blob_type.create_blob_type()
7880}
8181+8282+/// Maps a lexicon type to a GraphQL type, resolving refs using a registry
8383+/// and object types dict.
8484+///
8585+/// This function handles:
8686+/// - Regular types: maps using map_type()
8787+/// - Refs: looks up the ref in object_types_dict to get the actual object type
8888+///
8989+/// Used by object_type_builder to build nested object types.
9090+pub fn map_type_with_registry(
9191+ lexicon_type: String,
9292+ _format: Option(String),
9393+ ref: Option(String),
9494+ object_types_dict: Dict(String, schema.Type),
9595+) -> schema.Type {
9696+ case lexicon_type {
9797+ "ref" ->
9898+ case ref {
9999+ option.Some(ref_str) ->
100100+ case dict.get(object_types_dict, ref_str) {
101101+ Ok(object_type) -> object_type
102102+ Error(_) -> schema.string_type()
103103+ }
104104+ option.None -> schema.string_type()
105105+ }
106106+ _ -> map_type(lexicon_type)
107107+ }
108108+}
+32-4
lexicon_graphql/src/lexicon_graphql/types.gleam
···22///
33/// Common type definitions used across lexicon_graphql modules.
44/// This module exists to break import cycles between schema_builder and mutation_builder.
55+import gleam/dict.{type Dict}
66+import gleam/option.{type Option}
5768/// Lexicon definition structure (simplified)
79pub type Lexicon {
···911}
10121113/// Lexicon definitions container
1414+/// Contains an optional main record definition and any other named definitions (e.g., object types)
1515+/// Some lexicons (like social.grain.defs) only contain helper object types, not a main record
1216pub type Defs {
1313- Defs(main: RecordDef)
1717+ Defs(main: Option(RecordDef), others: Dict(String, Def))
1418}
15191616-/// Record definition
2020+/// A definition can be either a record or an object
2121+pub type Def {
2222+ Record(RecordDef)
2323+ Object(ObjectDef)
2424+}
2525+2626+/// Record definition (a collection/record type)
1727pub type RecordDef {
1818- RecordDef(type_: String, properties: List(#(String, Property)))
2828+ RecordDef(
2929+ type_: String,
3030+ key: Option(String),
3131+ properties: List(#(String, Property)),
3232+ )
3333+}
3434+3535+/// Object definition (a nested object type like aspectRatio)
3636+pub type ObjectDef {
3737+ ObjectDef(
3838+ type_: String,
3939+ required_fields: List(String),
4040+ properties: List(#(String, Property)),
4141+ )
1942}
20432144/// Property definition
2245pub type Property {
2323- Property(type_: String, required: Bool)
4646+ Property(
4747+ type_: String,
4848+ required: Bool,
4949+ format: Option(String),
5050+ ref: Option(String),
5151+ )
2452}
···11+/// URI Extraction Utility
22+///
33+/// Extracts AT Protocol URIs from both strongRef objects and plain at-uri strings.
44+/// This is used at runtime to resolve forward joins by extracting the target URI
55+/// from a record's field value.
66+import gleam/dynamic.{type Dynamic}
77+import gleam/dynamic/decode
88+import gleam/option.{type Option, None, Some}
99+import gleam/string
1010+1111+/// Extract a URI from a dynamic value
1212+///
1313+/// Handles two cases:
1414+/// 1. strongRef object: { "$type": "com.atproto.repo.strongRef", "uri": "at://...", "cid": "..." }
1515+/// 2. Plain at-uri string: "at://did:plc:abc123/collection/rkey"
1616+///
1717+/// Returns None if the value is not a valid URI or strongRef
1818+pub fn extract_uri(value: Dynamic) -> Option(String) {
1919+ // Try to decode as a string first (at-uri)
2020+ case decode.run(value, decode.string) {
2121+ Ok(uri_str) -> {
2222+ case is_valid_at_uri(uri_str) {
2323+ True -> Some(uri_str)
2424+ False -> None
2525+ }
2626+ }
2727+ Error(_) -> {
2828+ // Not a string, try as strongRef object
2929+ extract_from_strong_ref(value)
3030+ }
3131+ }
3232+}
3333+3434+/// Extract URI from a strongRef object
3535+/// strongRef format: { "$type": "com.atproto.repo.strongRef", "uri": "at://...", "cid": "..." }
3636+fn extract_from_strong_ref(value: Dynamic) -> Option(String) {
3737+ // Try to decode as an object with uri field
3838+ let uri_decoder = {
3939+ use uri <- decode.field("uri", decode.string)
4040+ decode.success(uri)
4141+ }
4242+4343+ case decode.run(value, uri_decoder) {
4444+ Ok(uri) -> {
4545+ case is_valid_at_uri(uri) {
4646+ True -> Some(uri)
4747+ False -> None
4848+ }
4949+ }
5050+ Error(_) -> None
5151+ }
5252+}
5353+5454+/// Check if a string is a valid AT Protocol URI
5555+/// AT URIs have the format: at://did/collection/rkey
5656+fn is_valid_at_uri(uri: String) -> Bool {
5757+ string.starts_with(uri, "at://")
5858+}
5959+6060+/// Check if a value is a strongRef object
6161+pub fn is_strong_ref(value: Dynamic) -> Bool {
6262+ let type_decoder = {
6363+ use type_str <- decode.field("$type", decode.string)
6464+ decode.success(type_str)
6565+ }
6666+6767+ case decode.run(value, type_decoder) {
6868+ Ok(type_str) -> type_str == "com.atproto.repo.strongRef"
6969+ Error(_) -> False
7070+ }
7171+}
7272+7373+/// Check if a value is a plain at-uri string
7474+pub fn is_at_uri_string(value: Dynamic) -> Bool {
7575+ case decode.run(value, decode.string) {
7676+ Ok(uri_str) -> is_valid_at_uri(uri_str)
7777+ Error(_) -> False
7878+ }
7979+}
···11+/// Tests for lexicon_graphql/connection module
22+///
33+/// Tests the creation of unique SortFieldInput types per collection
44+import gleeunit/should
55+import graphql/schema
66+import lexicon_graphql/connection as lexicon_connection
77+88+pub fn sort_field_input_type_with_enum_creates_types_test() {
99+ // Test: sort_field_input_type_with_enum should create input object types
1010+ // Since Type is opaque, we just verify the function executes without error
1111+1212+ let enum_type =
1313+ schema.enum_type("TestSortField", "Sort fields", [
1414+ schema.enum_value("field1", "Field 1"),
1515+ ])
1616+1717+ // Create input type - should not crash
1818+ let _input_type =
1919+ lexicon_connection.sort_field_input_type_with_enum("TestCollection", enum_type)
2020+2121+ // If we got here without crashing, test passes
2222+ True
2323+ |> should.be_true()
2424+}
2525+2626+pub fn lexicon_connection_args_with_field_enum_and_where_creates_args_test() {
2727+ // Test: Connection args function should create a list of arguments including sortBy
2828+2929+ let sort_enum =
3030+ schema.enum_type("TestCollectionSortField", "Sort fields", [
3131+ schema.enum_value("field1", "Field 1"),
3232+ ])
3333+3434+ let where_input =
3535+ schema.input_object_type("TestCollectionWhereInput", "Where input", [])
3636+3737+ let args =
3838+ lexicon_connection.lexicon_connection_args_with_field_enum_and_where(
3939+ "TestCollection",
4040+ sort_enum,
4141+ where_input,
4242+ )
4343+4444+ // Should create multiple args (first, last, after, before, sortBy, where)
4545+ // Verify we got a non-empty list
4646+ args
4747+ |> should.not_equal([])
4848+}
4949+5050+pub fn lexicon_connection_args_with_field_enum_creates_args_test() {
5151+ // Test: Backward compat function should also work
5252+5353+ let sort_enum =
5454+ schema.enum_type("TestCollectionSortField", "Sort fields", [
5555+ schema.enum_value("field1", "Field 1"),
5656+ ])
5757+5858+ let args =
5959+ lexicon_connection.lexicon_connection_args_with_field_enum(
6060+ "TestCollection",
6161+ sort_enum,
6262+ )
6363+6464+ // Should create multiple args (first, last, after, before, sortBy)
6565+ args
6666+ |> should.not_equal([])
6767+}
6868+6969+pub fn different_collections_can_have_different_sort_input_types_test() {
7070+ // Test: Multiple collections should be able to create their own SortFieldInput types
7171+ // This ensures the fix for the enum validation bug works
7272+7373+ let enum_type =
7474+ schema.enum_type("GenericSortField", "Generic sort", [
7575+ schema.enum_value("createdAt", "Created at"),
7676+ ])
7777+7878+ // Create SortFieldInput for multiple collections - should not crash
7979+ let _gallery_input =
8080+ lexicon_connection.sort_field_input_type_with_enum(
8181+ "SocialGrainGalleryItem",
8282+ enum_type,
8383+ )
8484+8585+ let _favorite_input =
8686+ lexicon_connection.sort_field_input_type_with_enum(
8787+ "SocialGrainFavorite",
8888+ enum_type,
8989+ )
9090+9191+ let _photo_input =
9292+ lexicon_connection.sort_field_input_type_with_enum(
9393+ "SocialGrainPhoto",
9494+ enum_type,
9595+ )
9696+9797+ // If we got here without crashing, test passes
9898+ // The real verification happens in the integration test
9999+ True
100100+ |> should.be_true()
101101+}
+549
lexicon_graphql/test/dataloader_test.gleam
···11+/// Tests for DataLoader batching logic
22+///
33+/// Verifies that URIs are correctly grouped and batched to prevent N+1 queries
44+import gleam/dict
55+import gleam/int
66+import gleam/list
77+import gleam/option.{None, Some}
88+import gleeunit/should
99+import graphql/value
1010+import lexicon_graphql/collection_meta
1111+import lexicon_graphql/dataloader
1212+import lexicon_graphql/types
1313+1414+// Test URI-to-collection extraction
1515+pub fn uri_to_collection_test() {
1616+ // Valid URI
1717+ dataloader.uri_to_collection("at://did:plc:abc123/app.bsky.feed.post/3k7h8")
1818+ |> should.equal(Some("app.bsky.feed.post"))
1919+2020+ // Valid URI with different collection
2121+ dataloader.uri_to_collection(
2222+ "at://did:plc:xyz789/app.bsky.actor.profile/self",
2323+ )
2424+ |> should.equal(Some("app.bsky.actor.profile"))
2525+2626+ // Invalid URI format
2727+ dataloader.uri_to_collection("https://example.com")
2828+ |> should.equal(None)
2929+3030+ // Empty string
3131+ dataloader.uri_to_collection("")
3232+ |> should.equal(None)
3333+}
3434+3535+// Test batch fetching by URI with mock fetcher
3636+pub fn batch_fetch_by_uri_test() {
3737+ // Create a mock fetcher that returns records based on URI collection
3838+ let mock_fetcher = fn(uris: List(String), collection: String, _field) {
3939+ // Simulate fetching records - return one record per URI
4040+ let _records =
4141+ list.map(uris, fn(uri) {
4242+ value.Object([
4343+ #("uri", value.String(uri)),
4444+ #("collection", value.String(collection)),
4545+ #("text", value.String("Test post")),
4646+ ])
4747+ })
4848+4949+ // Group by URI for the result
5050+ let result =
5151+ list.fold(uris, dict.new(), fn(acc, uri) {
5252+ let record =
5353+ value.Object([
5454+ #("uri", value.String(uri)),
5555+ #("collection", value.String(collection)),
5656+ #("text", value.String("Test post for " <> uri)),
5757+ ])
5858+ dict.insert(acc, uri, [record])
5959+ })
6060+6161+ Ok(result)
6262+ }
6363+6464+ // Test with URIs from the same collection
6565+ let uris = [
6666+ "at://did:plc:a/app.bsky.feed.post/1",
6767+ "at://did:plc:b/app.bsky.feed.post/2",
6868+ ]
6969+7070+ case dataloader.batch_fetch_by_uri(uris, mock_fetcher) {
7171+ Ok(results) -> {
7272+ // Should have 2 results
7373+ dict.size(results)
7474+ |> should.equal(2)
7575+7676+ // Each URI should have its record
7777+ dict.has_key(results, "at://did:plc:a/app.bsky.feed.post/1")
7878+ |> should.be_true
7979+8080+ dict.has_key(results, "at://did:plc:b/app.bsky.feed.post/2")
8181+ |> should.be_true
8282+ }
8383+ Error(_) -> should.fail()
8484+ }
8585+}
8686+8787+// Test batch fetching with mixed collections
8888+pub fn batch_fetch_by_uri_mixed_collections_test() {
8989+ // Mock fetcher that tracks which collections were queried
9090+ let mock_fetcher = fn(uris: List(String), collection: String, _field) {
9191+ let result =
9292+ list.fold(uris, dict.new(), fn(acc, uri) {
9393+ let record =
9494+ value.Object([
9595+ #("uri", value.String(uri)),
9696+ #("collection", value.String(collection)),
9797+ ])
9898+ dict.insert(acc, uri, [record])
9999+ })
100100+ Ok(result)
101101+ }
102102+103103+ // URIs from different collections
104104+ let uris = [
105105+ "at://did:plc:a/app.bsky.feed.post/1",
106106+ "at://did:plc:b/app.bsky.actor.profile/self",
107107+ ]
108108+109109+ case dataloader.batch_fetch_by_uri(uris, mock_fetcher) {
110110+ Ok(results) -> {
111111+ // Should batch by collection and fetch both
112112+ dict.size(results)
113113+ |> should.equal(2)
114114+ }
115115+ Error(_) -> should.fail()
116116+ }
117117+}
118118+119119+// Test reverse join batching
120120+pub fn batch_fetch_by_reverse_join_test() {
121121+ // Mock fetcher that simulates finding records that reference parent URIs
122122+ let mock_fetcher = fn(parent_uris: List(String), _collection: String, field) {
123123+ case field {
124124+ Some(ref_field) -> {
125125+ // Simulate finding records that reference each parent URI
126126+ let result =
127127+ list.fold(parent_uris, dict.new(), fn(acc, parent_uri) {
128128+ // Create 2 child records per parent
129129+ let child1 =
130130+ value.Object([
131131+ #("uri", value.String("at://did:plc:child1/collection/key1")),
132132+ #(ref_field, value.String(parent_uri)),
133133+ ])
134134+ let child2 =
135135+ value.Object([
136136+ #("uri", value.String("at://did:plc:child2/collection/key2")),
137137+ #(ref_field, value.String(parent_uri)),
138138+ ])
139139+ dict.insert(acc, parent_uri, [child1, child2])
140140+ })
141141+ Ok(result)
142142+ }
143143+ None -> Error("Reference field required for reverse joins")
144144+ }
145145+ }
146146+147147+ let parent_uris = ["at://did:plc:parent/app.bsky.feed.post/1"]
148148+149149+ case
150150+ dataloader.batch_fetch_by_reverse_join(
151151+ parent_uris,
152152+ "app.bsky.feed.like",
153153+ "subject",
154154+ mock_fetcher,
155155+ )
156156+ {
157157+ Ok(results) -> {
158158+ // Should have results for the parent URI
159159+ case dict.get(results, "at://did:plc:parent/app.bsky.feed.post/1") {
160160+ Ok(children) -> {
161161+ // Should have 2 child records
162162+ list.length(children)
163163+ |> should.equal(2)
164164+ }
165165+ Error(_) -> should.fail()
166166+ }
167167+ }
168168+ Error(_) -> should.fail()
169169+ }
170170+}
171171+172172+// Test extracting URIs from records
173173+pub fn extract_uris_from_records_test() {
174174+ // Create a test lexicon for likes with subject field
175175+ let lexicon =
176176+ types.Lexicon(
177177+ id: "app.bsky.feed.like",
178178+ defs: types.Defs(
179179+ main: Some(types.RecordDef(type_: "record", key: None, properties: [
180180+ #(
181181+ "subject",
182182+ types.Property(
183183+ type_: "string",
184184+ required: True,
185185+ format: Some("at-uri"),
186186+ ref: None,
187187+ ),
188188+ ),
189189+ ])),
190190+ others: dict.new(),
191191+ ),
192192+ )
193193+194194+ let meta = collection_meta.extract_metadata(lexicon)
195195+196196+ // Create test records with at-uri subject fields
197197+ let records = [
198198+ value.Object([
199199+ #("uri", value.String("at://did:plc:a/app.bsky.feed.like/1")),
200200+ #("subject", value.String("at://did:plc:target/app.bsky.feed.post/1")),
201201+ ]),
202202+ value.Object([
203203+ #("uri", value.String("at://did:plc:b/app.bsky.feed.like/2")),
204204+ #("subject", value.String("at://did:plc:target/app.bsky.feed.post/2")),
205205+ ]),
206206+ ]
207207+208208+ let uris = dataloader.extract_uris_from_records(records, "subject", meta)
209209+210210+ // Should extract 2 URIs
211211+ list.length(uris)
212212+ |> should.equal(2)
213213+214214+ // Should contain the expected URIs
215215+ list.contains(uris, "at://did:plc:target/app.bsky.feed.post/1")
216216+ |> should.be_true
217217+218218+ list.contains(uris, "at://did:plc:target/app.bsky.feed.post/2")
219219+ |> should.be_true
220220+}
221221+222222+// Test extracting URIs from records with strongRef
223223+pub fn extract_uris_from_records_with_strong_ref_test() {
224224+ // Create a test lexicon with strongRef field
225225+ let lexicon =
226226+ types.Lexicon(
227227+ id: "app.bsky.actor.profile",
228228+ defs: types.Defs(
229229+ main: Some(types.RecordDef(type_: "record", key: None, properties: [
230230+ #(
231231+ "pinnedPost",
232232+ types.Property(
233233+ type_: "ref",
234234+ required: False,
235235+ format: None,
236236+ ref: Some("com.atproto.repo.strongRef"),
237237+ ),
238238+ ),
239239+ ])),
240240+ others: dict.new(),
241241+ ),
242242+ )
243243+244244+ let meta = collection_meta.extract_metadata(lexicon)
245245+246246+ // Create test record with strongRef - using test_helpers to create the nested object
247247+ // For now, we'll skip this test since it requires more complex Dynamic construction
248248+ // TODO: Implement once we have better strongRef test helpers
249249+250250+ // Placeholder assertion
251251+ meta.nsid
252252+ |> should.equal("app.bsky.actor.profile")
253253+}
254254+255255+// Test error handling when fetcher fails
256256+pub fn batch_fetch_error_handling_test() {
257257+ // Mock fetcher that always fails
258258+ let failing_fetcher = fn(_uris, _collection, _field) {
259259+ Error("Database connection failed")
260260+ }
261261+262262+ let uris = ["at://did:plc:a/app.bsky.feed.post/1"]
263263+264264+ case dataloader.batch_fetch_by_uri(uris, failing_fetcher) {
265265+ Ok(_) -> should.fail()
266266+ Error(msg) -> msg |> should.equal("Database connection failed")
267267+ }
268268+}
269269+270270+// Test with empty URI list
271271+pub fn batch_fetch_empty_list_test() {
272272+ let mock_fetcher = fn(_uris, _collection, _field) { Ok(dict.new()) }
273273+274274+ case dataloader.batch_fetch_by_uri([], mock_fetcher) {
275275+ Ok(results) -> {
276276+ dict.size(results)
277277+ |> should.equal(0)
278278+ }
279279+ Error(_) -> should.fail()
280280+ }
281281+}
282282+283283+// Test paginated reverse join batching
284284+pub fn batch_fetch_by_reverse_join_paginated_test() {
285285+ // Mock paginated fetcher that simulates paginated results
286286+ let mock_paginated_fetcher = fn(
287287+ parent_uri: String,
288288+ _collection: String,
289289+ field: option.Option(String),
290290+ params: dataloader.PaginationParams,
291291+ ) {
292292+ case field {
293293+ Some(_ref_field) -> {
294294+ // Create mock edges based on pagination parameters
295295+ let page_size = case params.first {
296296+ Some(n) -> n
297297+ None -> 10
298298+ }
299299+300300+ // Create mock records with cursors
301301+ let edges =
302302+ list.range(1, page_size)
303303+ |> list.map(fn(i) {
304304+ let record =
305305+ value.Object([
306306+ #(
307307+ "uri",
308308+ value.String(
309309+ "at://did:plc:child" <> int.to_string(i) <> "/collection/key",
310310+ ),
311311+ ),
312312+ #("parentUri", value.String(parent_uri)),
313313+ ])
314314+ let cursor = "cursor_" <> int.to_string(i)
315315+ #(record, cursor)
316316+ })
317317+318318+ Ok(dataloader.PaginatedBatchResult(
319319+ edges: edges,
320320+ has_next_page: True,
321321+ has_previous_page: False,
322322+ total_count: Some(20),
323323+ ))
324324+ }
325325+ None -> Error("Reference field required for paginated reverse joins")
326326+ }
327327+ }
328328+329329+ let parent_uri = "at://did:plc:parent/app.bsky.feed.post/1"
330330+ let params =
331331+ dataloader.PaginationParams(
332332+ first: Some(5),
333333+ after: None,
334334+ last: None,
335335+ before: None,
336336+ sort_by: None,
337337+ where: None,
338338+ )
339339+340340+ case
341341+ dataloader.batch_fetch_by_reverse_join_paginated(
342342+ parent_uri,
343343+ "app.bsky.feed.like",
344344+ "subject",
345345+ params,
346346+ mock_paginated_fetcher,
347347+ )
348348+ {
349349+ Ok(result) -> {
350350+ // Should have 5 edges (as requested by first: 5)
351351+ list.length(result.edges)
352352+ |> should.equal(5)
353353+354354+ // Should have next page
355355+ result.has_next_page
356356+ |> should.be_true
357357+358358+ // Should not have previous page
359359+ result.has_previous_page
360360+ |> should.be_false
361361+362362+ // Should have total count
363363+ result.total_count
364364+ |> should.equal(Some(20))
365365+ }
366366+ Error(_) -> should.fail()
367367+ }
368368+}
369369+370370+// Test paginated DID batching
371371+pub fn batch_fetch_by_did_paginated_test() {
372372+ // Mock paginated fetcher for DID-based queries
373373+ let mock_paginated_fetcher = fn(
374374+ did: String,
375375+ _collection: String,
376376+ _field: option.Option(String),
377377+ params: dataloader.PaginationParams,
378378+ ) {
379379+ let page_size = case params.first {
380380+ Some(n) -> n
381381+ None -> 10
382382+ }
383383+384384+ // Simulate records belonging to the DID
385385+ let edges =
386386+ list.range(1, page_size)
387387+ |> list.map(fn(i) {
388388+ let record =
389389+ value.Object([
390390+ #(
391391+ "uri",
392392+ value.String(
393393+ "at://" <> did <> "/app.bsky.feed.post/" <> int.to_string(i),
394394+ ),
395395+ ),
396396+ #("did", value.String(did)),
397397+ #("text", value.String("Post " <> int.to_string(i))),
398398+ ])
399399+ let cursor = "did_cursor_" <> int.to_string(i)
400400+ #(record, cursor)
401401+ })
402402+403403+ Ok(dataloader.PaginatedBatchResult(
404404+ edges: edges,
405405+ has_next_page: True,
406406+ has_previous_page: False,
407407+ total_count: Some(50),
408408+ ))
409409+ }
410410+411411+ let did = "did:plc:abc123"
412412+ let params =
413413+ dataloader.PaginationParams(
414414+ first: Some(3),
415415+ after: None,
416416+ last: None,
417417+ before: None,
418418+ sort_by: None,
419419+ where: None,
420420+ )
421421+422422+ case
423423+ dataloader.batch_fetch_by_did_paginated(
424424+ did,
425425+ "app.bsky.feed.post",
426426+ params,
427427+ mock_paginated_fetcher,
428428+ )
429429+ {
430430+ Ok(result) -> {
431431+ // Should have 3 edges (as requested by first: 3)
432432+ list.length(result.edges)
433433+ |> should.equal(3)
434434+435435+ // Should have next page
436436+ result.has_next_page
437437+ |> should.be_true
438438+439439+ // Should have total count
440440+ result.total_count
441441+ |> should.equal(Some(50))
442442+ }
443443+ Error(_) -> should.fail()
444444+ }
445445+}
446446+447447+// Test paginated error handling
448448+pub fn batch_fetch_paginated_error_handling_test() {
449449+ // Mock fetcher that always fails
450450+ let failing_fetcher = fn(_key, _collection, _field, _params) {
451451+ Error("Pagination query failed")
452452+ }
453453+454454+ let params =
455455+ dataloader.PaginationParams(
456456+ first: Some(10),
457457+ after: None,
458458+ last: None,
459459+ before: None,
460460+ sort_by: None,
461461+ where: None,
462462+ )
463463+464464+ case
465465+ dataloader.batch_fetch_by_did_paginated(
466466+ "did:plc:test",
467467+ "app.bsky.feed.post",
468468+ params,
469469+ failing_fetcher,
470470+ )
471471+ {
472472+ Ok(_) -> should.fail()
473473+ Error(msg) -> msg |> should.equal("Pagination query failed")
474474+ }
475475+}
476476+477477+// Test backward pagination parameters
478478+pub fn batch_fetch_backward_pagination_test() {
479479+ // Mock paginated fetcher that handles backward pagination
480480+ let mock_paginated_fetcher = fn(
481481+ _did: String,
482482+ _collection: String,
483483+ _field: option.Option(String),
484484+ params: dataloader.PaginationParams,
485485+ ) {
486486+ // Check if backward pagination is requested
487487+ let is_backward = case params.last {
488488+ Some(_) -> True
489489+ None -> False
490490+ }
491491+492492+ let page_size = case params.last {
493493+ Some(n) -> n
494494+ None -> 5
495495+ }
496496+497497+ let edges =
498498+ list.range(1, page_size)
499499+ |> list.map(fn(i) {
500500+ let record =
501501+ value.Object([
502502+ #("uri", value.String("at://did:plc:test/collection/" <> int.to_string(i))),
503503+ ])
504504+ let cursor = "cursor_" <> int.to_string(i)
505505+ #(record, cursor)
506506+ })
507507+508508+ Ok(dataloader.PaginatedBatchResult(
509509+ edges: edges,
510510+ has_next_page: False,
511511+ has_previous_page: is_backward,
512512+ total_count: Some(10),
513513+ ))
514514+ }
515515+516516+ let params =
517517+ dataloader.PaginationParams(
518518+ first: None,
519519+ after: None,
520520+ last: Some(3),
521521+ before: Some("cursor_10"),
522522+ sort_by: None,
523523+ where: None,
524524+ )
525525+526526+ case
527527+ dataloader.batch_fetch_by_did_paginated(
528528+ "did:plc:test",
529529+ "app.bsky.feed.post",
530530+ params,
531531+ mock_paginated_fetcher,
532532+ )
533533+ {
534534+ Ok(result) -> {
535535+ // Should have 3 edges (as requested by last: 3)
536536+ list.length(result.edges)
537537+ |> should.equal(3)
538538+539539+ // Should not have next page (at the end)
540540+ result.has_next_page
541541+ |> should.be_false
542542+543543+ // Should have previous page (backward pagination)
544544+ result.has_previous_page
545545+ |> should.be_true
546546+ }
547547+ Error(_) -> should.fail()
548548+ }
549549+}
+287
lexicon_graphql/test/did_join_test.gleam
···11+/// Tests for DID-based join field generation
22+///
33+/// Verifies that DID join fields are added to GraphQL schemas correctly:
44+/// - All collections get DID join fields to all other collections
55+/// - Cardinality is determined by has_unique_did (literal:self key)
66+/// - Field naming follows {TypeName}ByDid pattern
77+import gleam/dict
88+import gleam/option
99+import gleam/string
1010+import gleeunit/should
1111+import graphql/introspection
1212+import graphql/schema
1313+import graphql/sdl
1414+import lexicon_graphql/db_schema_builder
1515+import lexicon_graphql/types
1616+1717+// Helper to create a test schema with a mock fetcher
1818+fn create_test_schema_from_lexicons(
1919+ lexicons: List(types.Lexicon),
2020+) -> schema.Schema {
2121+ // Mock fetcher that returns empty results (we're only testing schema generation)
2222+ let fetcher = fn(_collection, _params) {
2323+ Ok(#([], option.None, False, False, option.None))
2424+ }
2525+2626+ case
2727+ db_schema_builder.build_schema_with_fetcher(
2828+ lexicons,
2929+ fetcher,
3030+ option.None,
3131+ option.None,
3232+ option.None,
3333+ option.None,
3434+ option.None,
3535+ option.None,
3636+ )
3737+ {
3838+ Ok(s) -> s
3939+ Error(_) -> panic as "Failed to build test schema"
4040+ }
4141+}
4242+4343+// Test that collections get DID join fields to other collections
4444+pub fn collections_get_did_join_fields_test() {
4545+ // Create two collections: a status and a profile (with literal:self)
4646+ let status_lexicon =
4747+ types.Lexicon(
4848+ id: "xyz.statusphere.status",
4949+ defs: types.Defs(
5050+ main: option.Some(types.RecordDef(type_: "record", key: option.None, properties: [
5151+ #(
5252+ "text",
5353+ types.Property(type_: "string", required: True, format: option.None, ref: option.None),
5454+ ),
5555+ ])),
5656+ others: dict.new(),
5757+ ),
5858+ )
5959+6060+ let profile_lexicon =
6161+ types.Lexicon(
6262+ id: "app.bsky.actor.profile",
6363+ defs: types.Defs(
6464+ main: option.Some(types.RecordDef(
6565+ type_: "record",
6666+ key: option.Some("literal:self"),
6767+ properties: [
6868+ #(
6969+ "displayName",
7070+ types.Property(
7171+ type_: "string",
7272+ required: False,
7373+ format: option.None,
7474+ ref: option.None,
7575+ ),
7676+ ),
7777+ ],
7878+ )),
7979+ others: dict.new(),
8080+ ),
8181+ )
8282+8383+ let test_schema =
8484+ create_test_schema_from_lexicons([status_lexicon, profile_lexicon])
8585+8686+ // Get all types and serialize to SDL
8787+ let all_types = introspection.get_all_schema_types(test_schema)
8888+ let serialized = sdl.print_types(all_types)
8989+9090+ // Verify that Status has a DID join field to Profile
9191+ string.contains(serialized, "appBskyActorProfileByDid")
9292+ |> should.be_true
9393+9494+ // Verify that Profile has a DID join field to Status
9595+ string.contains(serialized, "xyzStatusphereStatusByDid")
9696+ |> should.be_true
9797+}
9898+9999+// Test that literal:self collections return single nullable objects
100100+pub fn literal_self_returns_single_object_test() {
101101+ let status_lexicon =
102102+ types.Lexicon(
103103+ id: "xyz.statusphere.status",
104104+ defs: types.Defs(
105105+ main: option.Some(types.RecordDef(type_: "record", key: option.None, properties: [
106106+ #(
107107+ "text",
108108+ types.Property(type_: "string", required: True, format: option.None, ref: option.None),
109109+ ),
110110+ ])),
111111+ others: dict.new(),
112112+ ),
113113+ )
114114+115115+ let profile_lexicon =
116116+ types.Lexicon(
117117+ id: "app.bsky.actor.profile",
118118+ defs: types.Defs(
119119+ main: option.Some(types.RecordDef(
120120+ type_: "record",
121121+ key: option.Some("literal:self"),
122122+ properties: [
123123+ #(
124124+ "displayName",
125125+ types.Property(
126126+ type_: "string",
127127+ required: False,
128128+ format: option.None,
129129+ ref: option.None,
130130+ ),
131131+ ),
132132+ ],
133133+ )),
134134+ others: dict.new(),
135135+ ),
136136+ )
137137+138138+ let test_schema =
139139+ create_test_schema_from_lexicons([status_lexicon, profile_lexicon])
140140+141141+ let all_types = introspection.get_all_schema_types(test_schema)
142142+ let serialized = sdl.print_types(all_types)
143143+144144+ // Profile should be returned as single object (not list) from Status
145145+ // We check that it's NOT wrapped in a list (no brackets)
146146+ // The field should be: appBskyActorProfileByDid: AppBskyActorProfile
147147+ string.contains(serialized, "appBskyActorProfileByDid: AppBskyActorProfile")
148148+ |> should.be_true
149149+}
150150+151151+// Test that non-literal:self collections return lists
152152+pub fn non_literal_self_returns_list_test() {
153153+ let status_lexicon =
154154+ types.Lexicon(
155155+ id: "xyz.statusphere.status",
156156+ defs: types.Defs(
157157+ main: option.Some(types.RecordDef(type_: "record", key: option.None, properties: [
158158+ #(
159159+ "text",
160160+ types.Property(type_: "string", required: True, format: option.None, ref: option.None),
161161+ ),
162162+ ])),
163163+ others: dict.new(),
164164+ ),
165165+ )
166166+167167+ let post_lexicon =
168168+ types.Lexicon(
169169+ id: "app.bsky.feed.post",
170170+ defs: types.Defs(
171171+ main: option.Some(types.RecordDef(type_: "record", key: option.None, properties: [
172172+ #(
173173+ "text",
174174+ types.Property(type_: "string", required: True, format: option.None, ref: option.None),
175175+ ),
176176+ ])),
177177+ others: dict.new(),
178178+ ),
179179+ )
180180+181181+ let test_schema =
182182+ create_test_schema_from_lexicons([status_lexicon, post_lexicon])
183183+184184+ let all_types = introspection.get_all_schema_types(test_schema)
185185+ let serialized = sdl.print_types(all_types)
186186+187187+ // Post should be returned as a connection (paginated list) from Status
188188+ // The field should be: appBskyFeedPostByDid: AppBskyFeedPostConnection
189189+ string.contains(serialized, "appBskyFeedPostByDid: AppBskyFeedPostConnection")
190190+ |> should.be_true
191191+}
192192+193193+// Test that multiple collections all get DID joins to each other
194194+pub fn multiple_collections_get_cross_joins_test() {
195195+ let status_lexicon =
196196+ types.Lexicon(
197197+ id: "xyz.statusphere.status",
198198+ defs: types.Defs(
199199+ main: option.Some(types.RecordDef(type_: "record", key: option.None, properties: [
200200+ #(
201201+ "text",
202202+ types.Property(type_: "string", required: True, format: option.None, ref: option.None),
203203+ ),
204204+ ])),
205205+ others: dict.new(),
206206+ ),
207207+ )
208208+209209+ let post_lexicon =
210210+ types.Lexicon(
211211+ id: "app.bsky.feed.post",
212212+ defs: types.Defs(
213213+ main: option.Some(types.RecordDef(type_: "record", key: option.None, properties: [
214214+ #(
215215+ "text",
216216+ types.Property(type_: "string", required: True, format: option.None, ref: option.None),
217217+ ),
218218+ ])),
219219+ others: dict.new(),
220220+ ),
221221+ )
222222+223223+ let like_lexicon =
224224+ types.Lexicon(
225225+ id: "app.bsky.feed.like",
226226+ defs: types.Defs(
227227+ main: option.Some(types.RecordDef(type_: "record", key: option.None, properties: [
228228+ #(
229229+ "subject",
230230+ types.Property(
231231+ type_: "string",
232232+ required: True,
233233+ format: option.Some("at-uri"),
234234+ ref: option.None,
235235+ ),
236236+ ),
237237+ ])),
238238+ others: dict.new(),
239239+ ),
240240+ )
241241+242242+ let test_schema =
243243+ create_test_schema_from_lexicons([status_lexicon, post_lexicon, like_lexicon])
244244+245245+ let all_types = introspection.get_all_schema_types(test_schema)
246246+ let serialized = sdl.print_types(all_types)
247247+248248+ // Status should have joins to Post and Like
249249+ string.contains(serialized, "appBskyFeedPostByDid")
250250+ |> should.be_true
251251+252252+ string.contains(serialized, "appBskyFeedLikeByDid")
253253+ |> should.be_true
254254+255255+ // Post should have joins to Status and Like
256256+ string.contains(serialized, "xyzStatusphereStatusByDid")
257257+ |> should.be_true
258258+259259+ // Like should have joins to Status and Post
260260+ // (already checked above)
261261+}
262262+263263+// Test that collections don't get DID join fields to themselves
264264+pub fn no_self_join_test() {
265265+ let status_lexicon =
266266+ types.Lexicon(
267267+ id: "xyz.statusphere.status",
268268+ defs: types.Defs(
269269+ main: option.Some(types.RecordDef(type_: "record", key: option.None, properties: [
270270+ #(
271271+ "text",
272272+ types.Property(type_: "string", required: True, format: option.None, ref: option.None),
273273+ ),
274274+ ])),
275275+ others: dict.new(),
276276+ ),
277277+ )
278278+279279+ let test_schema = create_test_schema_from_lexicons([status_lexicon])
280280+281281+ let all_types = introspection.get_all_schema_types(test_schema)
282282+ let serialized = sdl.print_types(all_types)
283283+284284+ // Status should NOT have a join field to itself
285285+ string.contains(serialized, "xyzStatusphereStatusByDid")
286286+ |> should.be_false
287287+}
+273
lexicon_graphql/test/forward_join_test.gleam
···11+/// Tests for forward join field generation
22+///
33+/// Verifies that forward join fields are added to GraphQL schemas based on lexicon metadata
44+import gleam/dict
55+import gleam/option.{None, Some}
66+import gleam/string
77+import gleeunit/should
88+import graphql/introspection
99+import graphql/schema
1010+import graphql/sdl
1111+import lexicon_graphql/db_schema_builder
1212+import lexicon_graphql/types
1313+1414+// Helper to create a test schema with a mock fetcher
1515+fn create_test_schema_from_lexicons(
1616+ lexicons: List(types.Lexicon),
1717+) -> schema.Schema {
1818+ // Mock fetcher that returns empty results (we're only testing schema generation)
1919+ let fetcher = fn(_collection, _params) {
2020+ Ok(#([], option.None, False, False, option.None))
2121+ }
2222+2323+ case
2424+ db_schema_builder.build_schema_with_fetcher(
2525+ lexicons,
2626+ fetcher,
2727+ option.None,
2828+ option.None,
2929+ option.None,
3030+ option.None,
3131+ option.None,
3232+ option.None,
3333+ )
3434+ {
3535+ Ok(s) -> s
3636+ Error(_) -> panic as "Failed to build test schema"
3737+ }
3838+}
3939+4040+// Test that strongRef fields generate forward join fields
4141+pub fn strong_ref_generates_forward_join_field_test() {
4242+ let lexicon =
4343+ types.Lexicon(
4444+ id: "app.bsky.actor.profile",
4545+ defs: types.Defs(
4646+ main: Some(types.RecordDef(type_: "record", key: None, properties: [
4747+ #(
4848+ "displayName",
4949+ types.Property(type_: "string", required: True, format: None, ref: None),
5050+ ),
5151+ #(
5252+ "pinnedPost",
5353+ types.Property(
5454+ type_: "ref",
5555+ required: False,
5656+ format: None,
5757+ ref: Some("com.atproto.repo.strongRef"),
5858+ ),
5959+ ),
6060+ ])),
6161+ others: dict.new(),
6262+ ),
6363+ )
6464+6565+ let test_schema = create_test_schema_from_lexicons([lexicon])
6666+6767+ // Get all types and serialize to SDL
6868+ let all_types = introspection.get_all_schema_types(test_schema)
6969+ let serialized = sdl.print_types(all_types)
7070+7171+ // Verify that pinnedPostResolved field appears in the schema
7272+ string.contains(serialized, "pinnedPostResolved")
7373+ |> should.be_true
7474+}
7575+7676+// Test that at-uri fields generate forward join fields
7777+pub fn at_uri_generates_forward_join_field_test() {
7878+ let lexicon =
7979+ types.Lexicon(
8080+ id: "app.bsky.feed.like",
8181+ defs: types.Defs(
8282+ main: Some(types.RecordDef(type_: "record", key: None, properties: [
8383+ #(
8484+ "subject",
8585+ types.Property(
8686+ type_: "string",
8787+ required: True,
8888+ format: Some("at-uri"),
8989+ ref: None,
9090+ ),
9191+ ),
9292+ #(
9393+ "createdAt",
9494+ types.Property(
9595+ type_: "string",
9696+ required: True,
9797+ format: Some("datetime"),
9898+ ref: None,
9999+ ),
100100+ ),
101101+ ])),
102102+ others: dict.new(),
103103+ ),
104104+ )
105105+106106+ let test_schema = create_test_schema_from_lexicons([lexicon])
107107+108108+ let all_types = introspection.get_all_schema_types(test_schema)
109109+ let serialized = sdl.print_types(all_types)
110110+111111+ // Verify that subjectResolved field appears in the schema
112112+ string.contains(serialized, "subjectResolved")
113113+ |> should.be_true
114114+}
115115+116116+// Test that multiple forward join fields are all generated
117117+pub fn multiple_forward_join_fields_test() {
118118+ let lexicon =
119119+ types.Lexicon(
120120+ id: "app.bsky.feed.post",
121121+ defs: types.Defs(
122122+ main: Some(types.RecordDef(type_: "record", key: None, properties: [
123123+ #(
124124+ "text",
125125+ types.Property(type_: "string", required: True, format: None, ref: None),
126126+ ),
127127+ #(
128128+ "reply",
129129+ types.Property(
130130+ type_: "ref",
131131+ required: False,
132132+ format: None,
133133+ ref: Some("com.atproto.repo.strongRef"),
134134+ ),
135135+ ),
136136+ #(
137137+ "via",
138138+ types.Property(
139139+ type_: "string",
140140+ required: False,
141141+ format: Some("at-uri"),
142142+ ref: None,
143143+ ),
144144+ ),
145145+ ])),
146146+ others: dict.new(),
147147+ ),
148148+ )
149149+150150+ let test_schema = create_test_schema_from_lexicons([lexicon])
151151+152152+ let all_types = introspection.get_all_schema_types(test_schema)
153153+ let serialized = sdl.print_types(all_types)
154154+155155+ // Check both forward join fields exist
156156+ string.contains(serialized, "replyResolved")
157157+ |> should.be_true
158158+159159+ string.contains(serialized, "viaResolved")
160160+ |> should.be_true
161161+}
162162+163163+// Test that collections without join fields don't generate extra fields
164164+pub fn no_join_fields_test() {
165165+ let lexicon =
166166+ types.Lexicon(
167167+ id: "xyz.statusphere.status",
168168+ defs: types.Defs(
169169+ main: Some(types.RecordDef(type_: "record", key: None, properties: [
170170+ #(
171171+ "status",
172172+ types.Property(type_: "string", required: True, format: None, ref: None),
173173+ ),
174174+ #(
175175+ "createdAt",
176176+ types.Property(
177177+ type_: "string",
178178+ required: True,
179179+ format: Some("datetime"),
180180+ ref: None,
181181+ ),
182182+ ),
183183+ ])),
184184+ others: dict.new(),
185185+ ),
186186+ )
187187+188188+ let test_schema = create_test_schema_from_lexicons([lexicon])
189189+190190+ let all_types = introspection.get_all_schema_types(test_schema)
191191+ let serialized = sdl.print_types(all_types)
192192+193193+ // Should not have any "Resolved" fields for non-join fields
194194+ let has_status_resolved = string.contains(serialized, "statusResolved")
195195+ let has_created_at_resolved = string.contains(serialized, "createdAtResolved")
196196+197197+ has_status_resolved
198198+ |> should.be_false
199199+200200+ has_created_at_resolved
201201+ |> should.be_false
202202+}
203203+204204+// Test that Record union is generated for forward joins
205205+pub fn record_union_type_exists_test() {
206206+ let lexicon =
207207+ types.Lexicon(
208208+ id: "app.bsky.feed.post",
209209+ defs: types.Defs(
210210+ main: Some(types.RecordDef(type_: "record", key: None, properties: [
211211+ #(
212212+ "text",
213213+ types.Property(type_: "string", required: True, format: None, ref: None),
214214+ ),
215215+ #(
216216+ "reply",
217217+ types.Property(
218218+ type_: "ref",
219219+ required: False,
220220+ format: None,
221221+ ref: Some("com.atproto.repo.strongRef"),
222222+ ),
223223+ ),
224224+ ])),
225225+ others: dict.new(),
226226+ ),
227227+ )
228228+229229+ let test_schema = create_test_schema_from_lexicons([lexicon])
230230+231231+ let all_types = introspection.get_all_schema_types(test_schema)
232232+ let serialized = sdl.print_types(all_types)
233233+234234+ // Verify that Record union exists
235235+ string.contains(serialized, "union Record")
236236+ |> should.be_true
237237+}
238238+239239+// Test that forward join fields have Record union type
240240+pub fn forward_join_field_has_union_type_test() {
241241+ let lexicon =
242242+ types.Lexicon(
243243+ id: "app.bsky.feed.post",
244244+ defs: types.Defs(
245245+ main: Some(types.RecordDef(type_: "record", key: None, properties: [
246246+ #(
247247+ "text",
248248+ types.Property(type_: "string", required: True, format: None, ref: None),
249249+ ),
250250+ #(
251251+ "reply",
252252+ types.Property(
253253+ type_: "ref",
254254+ required: False,
255255+ format: None,
256256+ ref: Some("com.atproto.repo.strongRef"),
257257+ ),
258258+ ),
259259+ ])),
260260+ others: dict.new(),
261261+ ),
262262+ )
263263+264264+ let test_schema = create_test_schema_from_lexicons([lexicon])
265265+266266+ let all_types = introspection.get_all_schema_types(test_schema)
267267+ let serialized = sdl.print_types(all_types)
268268+269269+ // Verify that replyResolved field has Record type
270270+ string.contains(serialized, "replyResolved: Record")
271271+ |> should.be_true
272272+}
273273+
+6-2
lexicon_graphql/test/lexicon_parser_test.gleam
···22///
33/// Parses AT Protocol lexicon JSON into structured Lexicon types
44import gleam/list
55+import gleam/option
56import gleeunit/should
67import lexicon_graphql/lexicon_parser
78import lexicon_graphql/types
···3738 should.equal(lexicon.id, "xyz.statusphere.status")
3839 // Verify it has properties
3940 case lexicon.defs.main {
4040- types.RecordDef(type_: "record", properties: props) -> {
4141+ option.Some(types.RecordDef(type_: "record", key: _, properties: props)) -> {
4142 // Should have at least text and createdAt properties
4243 should.be_true(list.length(props) >= 2)
4344 }
4444- types.RecordDef(type_: _, properties: _) -> {
4545+ option.Some(types.RecordDef(type_: _, key: _, properties: _)) -> {
4646+ should.fail()
4747+ }
4848+ option.None -> {
4549 should.fail()
4650 }
4751 }
+11-10
lexicon_graphql/test/mutation_builder_test.gleam
···11/// Tests for Mutation Builder - uploadBlob mutation
22///
33/// Tests the uploadBlob mutation and BlobUploadResponse type with flat structure
44+import gleam/dict
45import gleam/list
56import gleam/option.{None, Some}
67import gleeunit/should
···25262627 // Build mutation type with uploadBlob factory
2728 let mutation_type =
2828- mutation_builder.build_mutation_type([], None, None, None, Some(upload_factory))
2929+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, Some(upload_factory))
29303031 // Verify the mutation type has uploadBlob field
3132 let fields = schema.get_fields(mutation_type)
···4142pub fn build_mutation_type_without_upload_blob_test() {
4243 // Build mutation type without uploadBlob factory
4344 let mutation_type =
4444- mutation_builder.build_mutation_type([], None, None, None, None)
4545+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, None)
45464647 // Verify the mutation type does NOT have uploadBlob field
4748 let fields = schema.get_fields(mutation_type)
···70717172 // Build mutation type
7273 let mutation_type =
7373- mutation_builder.build_mutation_type([], None, None, None, Some(upload_factory))
7474+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, Some(upload_factory))
74757576 // Get uploadBlob field
7677 let fields = schema.get_fields(mutation_type)
···112113 }
113114114115 let mutation_type =
115115- mutation_builder.build_mutation_type([], None, None, None, Some(upload_factory))
116116+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, Some(upload_factory))
116117117118 // Get uploadBlob field
118119 let fields = schema.get_fields(mutation_type)
···163164 }
164165165166 let mutation_type =
166166- mutation_builder.build_mutation_type([], None, None, None, Some(upload_factory))
167167+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, Some(upload_factory))
167168168169 // Get uploadBlob field
169170 let fields = schema.get_fields(mutation_type)
···214215 }
215216216217 let mutation_type =
217217- mutation_builder.build_mutation_type([], None, None, None, Some(upload_factory))
218218+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, Some(upload_factory))
218219219220 // Get uploadBlob field
220221 let fields = schema.get_fields(mutation_type)
···255256 }
256257257258 let mutation_type =
258258- mutation_builder.build_mutation_type([], None, None, None, Some(upload_factory))
259259+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, Some(upload_factory))
259260260261 // Get uploadBlob field
261262 let fields = schema.get_fields(mutation_type)
···296297 }
297298298299 let mutation_type =
299299- mutation_builder.build_mutation_type([], None, None, None, Some(upload_factory))
300300+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, Some(upload_factory))
300301301302 // Get uploadBlob field
302303 let fields = schema.get_fields(mutation_type)
···337338 }
338339339340 let mutation_type =
340340- mutation_builder.build_mutation_type([], None, None, None, Some(upload_factory))
341341+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, Some(upload_factory))
341342342343 // Get uploadBlob field
343344 let fields = schema.get_fields(mutation_type)
···378379 }
379380380381 let mutation_type =
381381- mutation_builder.build_mutation_type([], None, None, None, Some(upload_factory))
382382+ mutation_builder.build_mutation_type([], dict.new(), None, None, None, Some(upload_factory))
382383383384 // Get uploadBlob field
384385 let fields = schema.get_fields(mutation_type)
···11+/// Integration test for the new topological sort-based schema builder
22+/// This test verifies that types with circular dependencies (via joins) are built correctly
33+import gleeunit/should
44+55+pub fn extract_dependencies_from_metadata_test() {
66+ // Test: Given collection metadata with forward/reverse/DID joins,
77+ // extract a list of type dependencies
88+ //
99+ // Example: If SocialGrainGallery has:
1010+ // - Forward join to SocialGrainActorProfile (via creator field)
1111+ // - Reverse join from SocialGrainGalleryItem (via gallery field)
1212+ // - DID join to SocialGrainPhoto (via did field)
1313+ //
1414+ // Then SocialGrainGallery depends on: [SocialGrainActorProfile, SocialGrainPhoto]
1515+ // (Reverse joins don't create dependencies - the source depends on the target)
1616+1717+ // This is a placeholder test - actual implementation will work with real CollectionMeta
1818+ True
1919+ |> should.be_true()
2020+}
2121+2222+pub fn build_types_in_topological_order_test() {
2323+ // Test: Given a list of TypeNodes with dependencies,
2424+ // build GraphQL types in the correct order
2525+ //
2626+ // Example: If we have:
2727+ // - TypeA depends on TypeB
2828+ // - TypeB depends on TypeC
2929+ // - TypeC has no dependencies
3030+ //
3131+ // Then build order should be: C, B, A
3232+ // And each type should have access to previously built types when creating join fields
3333+3434+ // This is a placeholder test - actual implementation will work with GraphQL schema types
3535+ True
3636+ |> should.be_true()
3737+}
3838+3939+pub fn circular_reference_via_reverse_joins_test() {
4040+ // Test: Circular references via reverse joins should work
4141+ //
4242+ // Example:
4343+ // - SocialGrainGallery has reverse join from SocialGrainGalleryItem
4444+ // - SocialGrainGalleryItem has forward join to SocialGrainGallery
4545+ //
4646+ // The topological sort should handle this because:
4747+ // - Forward joins create dependencies (GalleryItem depends on Gallery)
4848+ // - Reverse joins don't (Gallery doesn't depend on GalleryItem)
4949+ //
5050+ // Build order: Gallery, then GalleryItem
5151+ // Then add reverse join field to Gallery pointing to GalleryItem
5252+5353+ True
5454+ |> should.be_true()
5555+}
5656+5757+pub fn circular_reference_via_did_joins_test() {
5858+ // Test: Circular references via DID joins should work
5959+ //
6060+ // Example:
6161+ // - SocialGrainActorProfile has DID join to SocialGrainGallery
6262+ // - SocialGrainGallery has DID join to SocialGrainActorProfile
6363+ //
6464+ // Both types have the same DID field, so neither depends on the other structurally.
6565+ // They can be built in any order, then DID join fields added.
6666+ //
6767+ // This should NOT create a circular dependency in the graph.
6868+6969+ True
7070+ |> should.be_true()
7171+}
···11+/// Test helpers for creating Dynamic values in tests
22+import gleam/dynamic.{type Dynamic}
33+44+/// Convert any Gleam value to Dynamic for testing purposes
55+/// This uses FFI to unsafely coerce values - only use in tests!
66+@external(erlang, "test_helpers_ffi", "to_dynamic")
77+pub fn to_dynamic(value: a) -> Dynamic
+6
lexicon_graphql/test/test_helpers_ffi.erl
···11+-module(test_helpers_ffi).
22+-export([to_dynamic/1]).
33+44+%% Convert any value to Dynamic (which is just the identity function in Erlang)
55+%% since all Erlang values are already "dynamic"
66+to_dynamic(Value) -> Value.
+201
lexicon_graphql/test/uri_extractor_test.gleam
···11+/// Tests for URI Extraction
22+///
33+/// Tests that we correctly extract URIs from strongRef objects and at-uri strings
44+import gleam/dict
55+import gleam/option.{None, Some}
66+import gleeunit/should
77+import lexicon_graphql/uri_extractor
88+import test_helpers
99+1010+// Test extracting URI from a strongRef object
1111+pub fn extract_uri_from_strong_ref_test() {
1212+ // Create a strongRef object as a dynamic value
1313+ let strong_ref =
1414+ dict.from_list([
1515+ #("$type", test_helpers.to_dynamic("com.atproto.repo.strongRef")),
1616+ #(
1717+ "uri",
1818+ test_helpers.to_dynamic("at://did:plc:abc123/app.bsky.feed.post/3k7h8xyz"),
1919+ ),
2020+ #("cid", test_helpers.to_dynamic("bafyreiabc123...")),
2121+ ])
2222+ |> test_helpers.to_dynamic
2323+2424+ case uri_extractor.extract_uri(strong_ref) {
2525+ Some(uri) ->
2626+ uri
2727+ |> should.equal("at://did:plc:abc123/app.bsky.feed.post/3k7h8xyz")
2828+ None -> panic as "Expected to extract URI from strongRef"
2929+ }
3030+}
3131+3232+// Test extracting URI from a plain at-uri string
3333+pub fn extract_uri_from_at_uri_string_test() {
3434+ let at_uri =
3535+ test_helpers.to_dynamic("at://did:plc:xyz789/app.bsky.actor.profile/self")
3636+3737+ case uri_extractor.extract_uri(at_uri) {
3838+ Some(uri) ->
3939+ uri
4040+ |> should.equal("at://did:plc:xyz789/app.bsky.actor.profile/self")
4141+ None -> panic as "Expected to extract URI from at-uri string"
4242+ }
4343+}
4444+4545+// Test that non-URI strings return None
4646+pub fn extract_uri_from_non_uri_string_test() {
4747+ let non_uri = test_helpers.to_dynamic("not-an-at-uri")
4848+4949+ uri_extractor.extract_uri(non_uri)
5050+ |> should.equal(None)
5151+}
5252+5353+// Test that invalid objects return None
5454+pub fn extract_uri_from_invalid_object_test() {
5555+ let invalid_obj =
5656+ dict.from_list([
5757+ #("foo", test_helpers.to_dynamic("bar")),
5858+ #("baz", test_helpers.to_dynamic("qux")),
5959+ ])
6060+ |> test_helpers.to_dynamic
6161+6262+ uri_extractor.extract_uri(invalid_obj)
6363+ |> should.equal(None)
6464+}
6565+6666+// Test that null/None returns None
6767+pub fn extract_uri_from_null_test() {
6868+ let null_val = test_helpers.to_dynamic(None)
6969+7070+ uri_extractor.extract_uri(null_val)
7171+ |> should.equal(None)
7272+}
7373+7474+// Test strongRef without $type field (should still work)
7575+pub fn extract_uri_from_strong_ref_without_type_test() {
7676+ let strong_ref_no_type =
7777+ dict.from_list([
7878+ #("uri", test_helpers.to_dynamic("at://did:plc:test/collection/rkey")),
7979+ #("cid", test_helpers.to_dynamic("bafyrei...")),
8080+ ])
8181+ |> test_helpers.to_dynamic
8282+8383+ case uri_extractor.extract_uri(strong_ref_no_type) {
8484+ Some(uri) -> uri |> should.equal("at://did:plc:test/collection/rkey")
8585+ None -> panic as "Expected to extract URI even without $type"
8686+ }
8787+}
8888+8989+// Test is_strong_ref helper
9090+pub fn is_strong_ref_test() {
9191+ let strong_ref =
9292+ dict.from_list([
9393+ #("$type", test_helpers.to_dynamic("com.atproto.repo.strongRef")),
9494+ #("uri", test_helpers.to_dynamic("at://did:plc:abc/collection/key")),
9595+ #("cid", test_helpers.to_dynamic("bafyrei...")),
9696+ ])
9797+ |> test_helpers.to_dynamic
9898+9999+ uri_extractor.is_strong_ref(strong_ref)
100100+ |> should.be_true
101101+}
102102+103103+// Test is_strong_ref with non-strongRef
104104+pub fn is_strong_ref_negative_test() {
105105+ let not_strong_ref =
106106+ dict.from_list([#("$type", test_helpers.to_dynamic("some.other.type"))])
107107+ |> test_helpers.to_dynamic
108108+109109+ uri_extractor.is_strong_ref(not_strong_ref)
110110+ |> should.be_false
111111+}
112112+113113+// Test is_at_uri_string helper
114114+pub fn is_at_uri_string_test() {
115115+ let at_uri = test_helpers.to_dynamic("at://did:plc:test/app.bsky.feed.post/123")
116116+117117+ uri_extractor.is_at_uri_string(at_uri)
118118+ |> should.be_true
119119+}
120120+121121+// Test is_at_uri_string with non-at-uri
122122+pub fn is_at_uri_string_negative_test() {
123123+ let not_at_uri = test_helpers.to_dynamic("https://example.com")
124124+125125+ uri_extractor.is_at_uri_string(not_at_uri)
126126+ |> should.be_false
127127+}
128128+129129+// Test is_at_uri_string with object
130130+pub fn is_at_uri_string_with_object_test() {
131131+ let obj =
132132+ dict.from_list([#("uri", test_helpers.to_dynamic("at://..."))])
133133+ |> test_helpers.to_dynamic
134134+135135+ uri_extractor.is_at_uri_string(obj)
136136+ |> should.be_false
137137+}
138138+139139+// Test extracting from strongRef with wrong $type
140140+pub fn extract_uri_from_wrong_type_test() {
141141+ let wrong_type =
142142+ dict.from_list([
143143+ #("$type", test_helpers.to_dynamic("wrong.type")),
144144+ #("uri", test_helpers.to_dynamic("at://did:plc:test/collection/key")),
145145+ ])
146146+ |> test_helpers.to_dynamic
147147+148148+ // Should still extract the URI even if $type is wrong, as long as uri field exists
149149+ case uri_extractor.extract_uri(wrong_type) {
150150+ Some(uri) -> uri |> should.equal("at://did:plc:test/collection/key")
151151+ None -> panic as "Expected to extract URI with wrong $type"
152152+ }
153153+}
154154+155155+// Test extracting from number (should return None)
156156+pub fn extract_uri_from_number_test() {
157157+ let number = test_helpers.to_dynamic(42)
158158+159159+ uri_extractor.extract_uri(number)
160160+ |> should.equal(None)
161161+}
162162+163163+// Test extracting from boolean (should return None)
164164+pub fn extract_uri_from_boolean_test() {
165165+ let boolean = test_helpers.to_dynamic(True)
166166+167167+ uri_extractor.extract_uri(boolean)
168168+ |> should.equal(None)
169169+}
170170+171171+// Test extracting from empty string (should return None)
172172+pub fn extract_uri_from_empty_string_test() {
173173+ let empty = test_helpers.to_dynamic("")
174174+175175+ uri_extractor.extract_uri(empty)
176176+ |> should.equal(None)
177177+}
178178+179179+// Test extracting URI with various valid formats
180180+pub fn extract_uri_various_formats_test() {
181181+ // With tid
182182+ let uri1 = test_helpers.to_dynamic("at://did:plc:abc/app.bsky.feed.post/3k7h8...")
183183+ case uri_extractor.extract_uri(uri1) {
184184+ Some(_) -> Nil
185185+ None -> panic as "Expected valid URI with tid"
186186+ }
187187+188188+ // With literal:self
189189+ let uri2 = test_helpers.to_dynamic("at://did:plc:abc/app.bsky.actor.profile/self")
190190+ case uri_extractor.extract_uri(uri2) {
191191+ Some(_) -> Nil
192192+ None -> panic as "Expected valid URI with literal:self"
193193+ }
194194+195195+ // With custom rkey
196196+ let uri3 = test_helpers.to_dynamic("at://did:plc:abc/collection/custom-key-123")
197197+ case uri_extractor.extract_uri(uri3) {
198198+ Some(_) -> Nil
199199+ None -> panic as "Expected valid URI with custom key"
200200+ }
201201+}
···11+{
22+ "lexicon": 1,
33+ "id": "app.bsky.richtext.facet",
44+ "defs": {
55+ "tag": {
66+ "type": "object",
77+ "required": ["tag"],
88+ "properties": {
99+ "tag": {
1010+ "type": "string",
1111+ "maxLength": 640,
1212+ "maxGraphemes": 64
1313+ }
1414+ },
1515+ "description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags')."
1616+ },
1717+ "link": {
1818+ "type": "object",
1919+ "required": ["uri"],
2020+ "properties": {
2121+ "uri": {
2222+ "type": "string",
2323+ "format": "uri"
2424+ }
2525+ },
2626+ "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL."
2727+ },
2828+ "main": {
2929+ "type": "object",
3030+ "required": ["index", "features"],
3131+ "properties": {
3232+ "index": {
3333+ "ref": "#byteSlice",
3434+ "type": "ref"
3535+ },
3636+ "features": {
3737+ "type": "array",
3838+ "items": {
3939+ "refs": ["#mention", "#link", "#tag"],
4040+ "type": "union"
4141+ }
4242+ }
4343+ },
4444+ "description": "Annotation of a sub-string within rich text."
4545+ },
4646+ "mention": {
4747+ "type": "object",
4848+ "required": ["did"],
4949+ "properties": {
5050+ "did": {
5151+ "type": "string",
5252+ "format": "did"
5353+ }
5454+ },
5555+ "description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID."
5656+ },
5757+ "byteSlice": {
5858+ "type": "object",
5959+ "required": ["byteStart", "byteEnd"],
6060+ "properties": {
6161+ "byteEnd": {
6262+ "type": "integer",
6363+ "minimum": 0
6464+ },
6565+ "byteStart": {
6666+ "type": "integer",
6767+ "minimum": 0
6868+ }
6969+ },
7070+ "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets."
7171+ }
7272+ }
7373+}
···11+{
22+ "lexicon": 1,
33+ "id": "com.atproto.label.defs",
44+ "defs": {
55+ "label": {
66+ "type": "object",
77+ "required": [
88+ "src",
99+ "uri",
1010+ "val",
1111+ "cts"
1212+ ],
1313+ "properties": {
1414+ "cid": {
1515+ "type": "string",
1616+ "format": "cid",
1717+ "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."
1818+ },
1919+ "cts": {
2020+ "type": "string",
2121+ "format": "datetime",
2222+ "description": "Timestamp when this label was created."
2323+ },
2424+ "exp": {
2525+ "type": "string",
2626+ "format": "datetime",
2727+ "description": "Timestamp at which this label expires (no longer applies)."
2828+ },
2929+ "neg": {
3030+ "type": "boolean",
3131+ "description": "If true, this is a negation label, overwriting a previous label."
3232+ },
3333+ "sig": {
3434+ "type": "bytes",
3535+ "description": "Signature of dag-cbor encoded label."
3636+ },
3737+ "src": {
3838+ "type": "string",
3939+ "format": "did",
4040+ "description": "DID of the actor who created this label."
4141+ },
4242+ "uri": {
4343+ "type": "string",
4444+ "format": "uri",
4545+ "description": "AT URI of the record, repository (account), or other resource that this label applies to."
4646+ },
4747+ "val": {
4848+ "type": "string",
4949+ "maxLength": 128,
5050+ "description": "The short string name of the value or type of this label."
5151+ },
5252+ "ver": {
5353+ "type": "integer",
5454+ "description": "The AT Protocol version of the label object."
5555+ }
5656+ },
5757+ "description": "Metadata tag on an atproto resource (eg, repo or record)."
5858+ },
5959+ "selfLabel": {
6060+ "type": "object",
6161+ "required": [
6262+ "val"
6363+ ],
6464+ "properties": {
6565+ "val": {
6666+ "type": "string",
6767+ "maxLength": 128,
6868+ "description": "The short string name of the value or type of this label."
6969+ }
7070+ },
7171+ "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel."
7272+ },
7373+ "labelValue": {
7474+ "type": "string",
7575+ "knownValues": [
7676+ "!hide",
7777+ "!no-promote",
7878+ "!warn",
7979+ "!no-unauthenticated",
8080+ "dmca-violation",
8181+ "doxxing",
8282+ "porn",
8383+ "sexual",
8484+ "nudity",
8585+ "nsfl",
8686+ "gore"
8787+ ]
8888+ },
8989+ "selfLabels": {
9090+ "type": "object",
9191+ "required": [
9292+ "values"
9393+ ],
9494+ "properties": {
9595+ "values": {
9696+ "type": "array",
9797+ "items": {
9898+ "ref": "#selfLabel",
9999+ "type": "ref"
100100+ },
101101+ "maxLength": 10
102102+ }
103103+ },
104104+ "description": "Metadata tags on an atproto record, published by the author within the record."
105105+ },
106106+ "labelValueDefinition": {
107107+ "type": "object",
108108+ "required": [
109109+ "identifier",
110110+ "severity",
111111+ "blurs",
112112+ "locales"
113113+ ],
114114+ "properties": {
115115+ "blurs": {
116116+ "type": "string",
117117+ "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
118118+ "knownValues": [
119119+ "content",
120120+ "media",
121121+ "none"
122122+ ]
123123+ },
124124+ "locales": {
125125+ "type": "array",
126126+ "items": {
127127+ "ref": "#labelValueDefinitionStrings",
128128+ "type": "ref"
129129+ }
130130+ },
131131+ "severity": {
132132+ "type": "string",
133133+ "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
134134+ "knownValues": [
135135+ "inform",
136136+ "alert",
137137+ "none"
138138+ ]
139139+ },
140140+ "adultOnly": {
141141+ "type": "boolean",
142142+ "description": "Does the user need to have adult content enabled in order to configure this label?"
143143+ },
144144+ "identifier": {
145145+ "type": "string",
146146+ "maxLength": 100,
147147+ "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
148148+ "maxGraphemes": 100
149149+ },
150150+ "defaultSetting": {
151151+ "type": "string",
152152+ "default": "warn",
153153+ "description": "The default setting for this label.",
154154+ "knownValues": [
155155+ "ignore",
156156+ "warn",
157157+ "hide"
158158+ ]
159159+ }
160160+ },
161161+ "description": "Declares a label value and its expected interpretations and behaviors."
162162+ },
163163+ "labelValueDefinitionStrings": {
164164+ "type": "object",
165165+ "required": [
166166+ "lang",
167167+ "name",
168168+ "description"
169169+ ],
170170+ "properties": {
171171+ "lang": {
172172+ "type": "string",
173173+ "format": "language",
174174+ "description": "The code of the language these strings are written in."
175175+ },
176176+ "name": {
177177+ "type": "string",
178178+ "maxLength": 640,
179179+ "description": "A short human-readable name for the label.",
180180+ "maxGraphemes": 64
181181+ },
182182+ "description": {
183183+ "type": "string",
184184+ "maxLength": 100000,
185185+ "description": "A longer description of what the label means and why it might be applied.",
186186+ "maxGraphemes": 10000
187187+ }
188188+ },
189189+ "description": "Strings which describe the label in the UI, localized into a specific language."
190190+ }
191191+ }
192192+}