Auto-indexing service and GraphQL API for AT Protocol Records

filter support, actorHandle, Blobs, snapshot tests

+8256 -317
+6
example/lexicons.json
··· 1 + { 2 + "lexicons": [ 3 + "app.bsky.actor.profile", 4 + "bsky.actor.profile" 5 + ] 6 + }
+74
example/lexicons/app/bsky/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "key": "literal:self", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "avatar": { 12 + "type": "blob", 13 + "accept": [ 14 + "image/png", 15 + "image/jpeg" 16 + ], 17 + "maxSize": 1000000, 18 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 19 + }, 20 + "banner": { 21 + "type": "blob", 22 + "accept": [ 23 + "image/png", 24 + "image/jpeg" 25 + ], 26 + "maxSize": 1000000, 27 + "description": "Larger horizontal image to display behind profile view." 28 + }, 29 + "labels": { 30 + "refs": [ 31 + "com.atproto.label.defs#selfLabels" 32 + ], 33 + "type": "union", 34 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 35 + }, 36 + "website": { 37 + "type": "string", 38 + "format": "uri" 39 + }, 40 + "pronouns": { 41 + "type": "string", 42 + "maxLength": 200, 43 + "description": "Free-form pronouns text.", 44 + "maxGraphemes": 20 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + }, 50 + "pinnedPost": { 51 + "ref": "com.atproto.repo.strongRef", 52 + "type": "ref" 53 + }, 54 + "description": { 55 + "type": "string", 56 + "maxLength": 2560, 57 + "description": "Free-form profile description text.", 58 + "maxGraphemes": 256 59 + }, 60 + "displayName": { 61 + "type": "string", 62 + "maxLength": 640, 63 + "maxGraphemes": 64 64 + }, 65 + "joinedViaStarterPack": { 66 + "ref": "com.atproto.repo.strongRef", 67 + "type": "ref" 68 + } 69 + } 70 + }, 71 + "description": "A declaration of a Bluesky account profile." 72 + } 73 + } 74 + }
+192
example/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "required": [ 8 + "src", 9 + "uri", 10 + "val", 11 + "cts" 12 + ], 13 + "properties": { 14 + "cid": { 15 + "type": "string", 16 + "format": "cid", 17 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 18 + }, 19 + "cts": { 20 + "type": "string", 21 + "format": "datetime", 22 + "description": "Timestamp when this label was created." 23 + }, 24 + "exp": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp at which this label expires (no longer applies)." 28 + }, 29 + "neg": { 30 + "type": "boolean", 31 + "description": "If true, this is a negation label, overwriting a previous label." 32 + }, 33 + "sig": { 34 + "type": "bytes", 35 + "description": "Signature of dag-cbor encoded label." 36 + }, 37 + "src": { 38 + "type": "string", 39 + "format": "did", 40 + "description": "DID of the actor who created this label." 41 + }, 42 + "uri": { 43 + "type": "string", 44 + "format": "uri", 45 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 46 + }, 47 + "val": { 48 + "type": "string", 49 + "maxLength": 128, 50 + "description": "The short string name of the value or type of this label." 51 + }, 52 + "ver": { 53 + "type": "integer", 54 + "description": "The AT Protocol version of the label object." 55 + } 56 + }, 57 + "description": "Metadata tag on an atproto resource (eg, repo or record)." 58 + }, 59 + "selfLabel": { 60 + "type": "object", 61 + "required": [ 62 + "val" 63 + ], 64 + "properties": { 65 + "val": { 66 + "type": "string", 67 + "maxLength": 128, 68 + "description": "The short string name of the value or type of this label." 69 + } 70 + }, 71 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." 72 + }, 73 + "labelValue": { 74 + "type": "string", 75 + "knownValues": [ 76 + "!hide", 77 + "!no-promote", 78 + "!warn", 79 + "!no-unauthenticated", 80 + "dmca-violation", 81 + "doxxing", 82 + "porn", 83 + "sexual", 84 + "nudity", 85 + "nsfl", 86 + "gore" 87 + ] 88 + }, 89 + "selfLabels": { 90 + "type": "object", 91 + "required": [ 92 + "values" 93 + ], 94 + "properties": { 95 + "values": { 96 + "type": "array", 97 + "items": { 98 + "ref": "#selfLabel", 99 + "type": "ref" 100 + }, 101 + "maxLength": 10 102 + } 103 + }, 104 + "description": "Metadata tags on an atproto record, published by the author within the record." 105 + }, 106 + "labelValueDefinition": { 107 + "type": "object", 108 + "required": [ 109 + "identifier", 110 + "severity", 111 + "blurs", 112 + "locales" 113 + ], 114 + "properties": { 115 + "blurs": { 116 + "type": "string", 117 + "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.", 118 + "knownValues": [ 119 + "content", 120 + "media", 121 + "none" 122 + ] 123 + }, 124 + "locales": { 125 + "type": "array", 126 + "items": { 127 + "ref": "#labelValueDefinitionStrings", 128 + "type": "ref" 129 + } 130 + }, 131 + "severity": { 132 + "type": "string", 133 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 134 + "knownValues": [ 135 + "inform", 136 + "alert", 137 + "none" 138 + ] 139 + }, 140 + "adultOnly": { 141 + "type": "boolean", 142 + "description": "Does the user need to have adult content enabled in order to configure this label?" 143 + }, 144 + "identifier": { 145 + "type": "string", 146 + "maxLength": 100, 147 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 148 + "maxGraphemes": 100 149 + }, 150 + "defaultSetting": { 151 + "type": "string", 152 + "default": "warn", 153 + "description": "The default setting for this label.", 154 + "knownValues": [ 155 + "ignore", 156 + "warn", 157 + "hide" 158 + ] 159 + } 160 + }, 161 + "description": "Declares a label value and its expected interpretations and behaviors." 162 + }, 163 + "labelValueDefinitionStrings": { 164 + "type": "object", 165 + "required": [ 166 + "lang", 167 + "name", 168 + "description" 169 + ], 170 + "properties": { 171 + "lang": { 172 + "type": "string", 173 + "format": "language", 174 + "description": "The code of the language these strings are written in." 175 + }, 176 + "name": { 177 + "type": "string", 178 + "maxLength": 640, 179 + "description": "A short human-readable name for the label.", 180 + "maxGraphemes": 64 181 + }, 182 + "description": { 183 + "type": "string", 184 + "maxLength": 100000, 185 + "description": "A longer description of what the label means and why it might be applied.", 186 + "maxGraphemes": 10000 187 + } 188 + }, 189 + "description": "Strings which describe the label in the UI, localized into a specific language." 190 + } 191 + } 192 + }
+24
example/lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "uri", 10 + "cid" 11 + ], 12 + "properties": { 13 + "cid": { 14 + "type": "string", 15 + "format": "cid" 16 + }, 17 + "uri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + } 21 + } 22 + } 23 + } 24 + }
+15
graphql/birdie_snapshots/built_in_scalar_types.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Built-in scalar types 4 + file: ./test/sdl_test.gleam 5 + test_name: built_in_scalars_test 6 + --- 7 + scalar String 8 + 9 + scalar Int 10 + 11 + scalar Float 12 + 13 + scalar Boolean 14 + 15 + scalar ID
+8
graphql/birdie_snapshots/empty_enum.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Empty enum 4 + file: ./test/sdl_test.gleam 5 + test_name: empty_enum_test 6 + --- 7 + """An empty enum""" 8 + enum EmptyEnum {}
+8
graphql/birdie_snapshots/empty_input_object.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Empty input object 4 + file: ./test/sdl_test.gleam 5 + test_name: empty_input_object_test 6 + --- 7 + """An empty input""" 8 + input EmptyInput {}
+11
graphql/birdie_snapshots/enum_without_descriptions.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Enum without descriptions 4 + file: ./test/sdl_test.gleam 5 + test_name: enum_without_descriptions_test 6 + --- 7 + enum Color { 8 + RED 9 + GREEN 10 + BLUE 11 + }
+13
graphql/birdie_snapshots/input_object_with_default_values.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Input object with default values 4 + file: ./test/sdl_test.gleam 5 + test_name: input_object_with_default_values_test 6 + --- 7 + """Filter options for queries""" 8 + input FilterInput { 9 + """Maximum number of results""" 10 + limit: Int 11 + """Number of results to skip""" 12 + offset: Int 13 + }
+21
graphql/birdie_snapshots/nested_input_types.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Nested input types 4 + file: ./test/sdl_test.gleam 5 + test_name: nested_input_types_test 6 + --- 7 + """Street address information""" 8 + input AddressInput { 9 + """Street name""" 10 + street: String 11 + """City name""" 12 + city: String 13 + } 14 + 15 + """User information""" 16 + input UserInput { 17 + """Full name""" 18 + name: String 19 + """Home address""" 20 + address: AddressInput 21 + }
+15
graphql/birdie_snapshots/object_type_with_list_fields.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Object type with list fields 4 + file: ./test/sdl_test.gleam 5 + test_name: object_with_list_fields_test 6 + --- 7 + """A blog post""" 8 + type Post { 9 + """Post ID""" 10 + id: ID 11 + """Post title""" 12 + title: String 13 + """Post tags""" 14 + tags: [String!] 15 + }
+17
graphql/birdie_snapshots/simple_enum_type.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Simple enum type 4 + file: ./test/sdl_test.gleam 5 + test_name: simple_enum_test 6 + --- 7 + """Order status""" 8 + enum Status { 9 + """Order is pending""" 10 + PENDING 11 + """Order is being processed""" 12 + PROCESSING 13 + """Order has been shipped""" 14 + SHIPPED 15 + """Order has been delivered""" 16 + DELIVERED 17 + }
+15
graphql/birdie_snapshots/simple_input_object_with_descriptions.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Simple input object with descriptions 4 + file: ./test/sdl_test.gleam 5 + test_name: simple_input_object_test 6 + --- 7 + """Input for creating or updating a user""" 8 + input UserInput { 9 + """User's name""" 10 + name: String 11 + """User's email address""" 12 + email: String! 13 + """User's age""" 14 + age: Int 15 + }
+15
graphql/birdie_snapshots/simple_object_type.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Simple object type 4 + file: ./test/sdl_test.gleam 5 + test_name: simple_object_type_test 6 + --- 7 + """A user in the system""" 8 + type User { 9 + """User ID""" 10 + id: ID! 11 + """User's name""" 12 + name: String 13 + """Email address""" 14 + email: String 15 + }
+19
graphql/birdie_snapshots/type_with_non_null_and_list_modifiers.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Type with NonNull and List modifiers 4 + file: ./test/sdl_test.gleam 5 + test_name: type_with_non_null_and_list_test 6 + --- 7 + """Complex type modifiers""" 8 + input ComplexInput { 9 + """Required string""" 10 + required: String! 11 + """Optional list of strings""" 12 + optionalList: [String] 13 + """Required list of optional strings""" 14 + requiredList: [String]! 15 + """Optional list of required strings""" 16 + listOfRequired: [String!] 17 + """Required list of required strings""" 18 + requiredListOfRequired: [String!]! 19 + }
+1
graphql/gleam.toml
··· 17 17 18 18 [dev-dependencies] 19 19 gleeunit = ">= 1.0.0 and < 2.0.0" 20 + birdie = ">= 1.0.0 and < 2.0.0"
+17
graphql/manifest.toml
··· 2 2 # You typically do not need to edit this file 3 3 4 4 packages = [ 5 + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 + { name = "birdie", version = "1.4.1", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "justin", "rank", "simplifile", "term_size", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "18599E478C14BD9EBD2465F0561F96EB9B58A24DB44AF86F103EF81D4B9834BF" }, 7 + { name = "edit_distance", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "7DC465C34695F9E57D79FC65670C53C992CE342BF29E0AA41FF44F61AF62FC56" }, 8 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 9 + { name = "glance", version = "5.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "7F216D97935465FF4AC46699CD1C3E0FB19CB678B002E4ACAFCE256E96312F14" }, 10 + { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, 11 + { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, 12 + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 13 + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 5 14 { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 6 15 { name = "gleeunit", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "CD701726CBCE5588B375D157B4391CFD0F2F134CD12D9B6998A395484DE05C58" }, 16 + { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, 17 + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 18 + { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 19 + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 20 + { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, 21 + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 22 + { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, 7 23 ] 8 24 9 25 [requirements] 26 + birdie = { version = ">= 1.0.0 and < 2.0.0" } 10 27 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 11 28 gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+15 -4
graphql/src/graphql/introspection.gleam
··· 23 23 ]) 24 24 } 25 25 26 - /// Get all types from the schema 27 - fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) { 26 + /// Get all types from the schema as schema.Type values 27 + /// Useful for testing and documentation generation 28 + pub fn get_all_schema_types(graphql_schema: schema.Schema) -> List(schema.Type) { 28 29 let query_type = schema.query_type(graphql_schema) 29 30 30 31 // Collect all types by traversing the schema ··· 53 54 !list.contains(collected_names, built_in_name) 54 55 }) 55 56 56 - let all_types = list.append(unique_types, missing_built_ins) 57 + list.append(unique_types, missing_built_ins) 58 + } 59 + 60 + /// Get all types from the schema 61 + fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) { 62 + let all_types = get_all_schema_types(graphql_schema) 57 63 58 64 // Convert all types to introspection values 59 65 list.map(all_types, type_introspection) ··· 163 169 _ -> value.String(type_name) 164 170 } 165 171 172 + let description = case schema.type_description(t) { 173 + "" -> value.Null 174 + desc -> value.String(desc) 175 + } 176 + 166 177 value.Object([ 167 178 #("kind", value.String(kind)), 168 179 #("name", name), 169 - #("description", value.Null), 180 + #("description", description), 170 181 #("fields", fields), 171 182 #("interfaces", value.List([])), 172 183 #("possibleTypes", value.Null),
+16
graphql/src/graphql/schema.gleam
··· 219 219 } 220 220 } 221 221 222 + pub fn is_input_object(t: Type) -> Bool { 223 + case t { 224 + InputObjectType(_, _, _) -> True 225 + _ -> False 226 + } 227 + } 228 + 229 + pub fn type_description(t: Type) -> String { 230 + case t { 231 + ObjectType(_, description, _) -> description 232 + InputObjectType(_, description, _) -> description 233 + EnumType(_, description, _) -> description 234 + _ -> "" 235 + } 236 + } 237 + 222 238 // Field resolution helpers 223 239 pub fn resolve_field(field: Field, ctx: Context) -> Result(value.Value, String) { 224 240 case field {
+224
graphql/src/graphql/sdl.gleam
··· 1 + /// GraphQL SDL (Schema Definition Language) Printer 2 + /// 3 + /// Generates proper SDL output from GraphQL schema types 4 + /// Follows the GraphQL specification for schema representation 5 + 6 + import gleam/list 7 + import gleam/option 8 + import gleam/string 9 + import graphql/schema 10 + 11 + /// Print a single GraphQL type as SDL 12 + pub fn print_type(type_: schema.Type) -> String { 13 + print_type_internal(type_, 0, False) 14 + } 15 + 16 + /// Print multiple GraphQL types as SDL with blank lines between them 17 + pub fn print_types(types: List(schema.Type)) -> String { 18 + list.map(types, print_type) 19 + |> string.join("\n\n") 20 + } 21 + 22 + // Internal function that handles indentation and inline mode 23 + fn print_type_internal( 24 + type_: schema.Type, 25 + indent_level: Int, 26 + inline: Bool, 27 + ) -> String { 28 + let kind = schema.type_kind(type_) 29 + 30 + case kind { 31 + "INPUT_OBJECT" -> print_input_object(type_, indent_level, inline) 32 + "OBJECT" -> print_object(type_, indent_level, inline) 33 + "ENUM" -> print_enum(type_, indent_level, inline) 34 + "SCALAR" -> print_scalar(type_, indent_level, inline) 35 + "LIST" -> { 36 + case schema.inner_type(type_) { 37 + option.Some(inner) -> 38 + "[" <> print_type_internal(inner, indent_level, True) <> "]" 39 + option.None -> "[Unknown]" 40 + } 41 + } 42 + "NON_NULL" -> { 43 + case schema.inner_type(type_) { 44 + option.Some(inner) -> 45 + print_type_internal(inner, indent_level, True) <> "!" 46 + option.None -> "Unknown!" 47 + } 48 + } 49 + _ -> schema.type_name(type_) 50 + } 51 + } 52 + 53 + fn print_scalar(type_: schema.Type, indent_level: Int, inline: Bool) -> String { 54 + case inline { 55 + True -> schema.type_name(type_) 56 + False -> { 57 + let indent = string.repeat(" ", indent_level * 2) 58 + let description = schema.type_description(type_) 59 + let desc_block = case description { 60 + "" -> "" 61 + _ -> indent <> format_description(description) <> "\n" 62 + } 63 + 64 + desc_block <> indent <> "scalar " <> schema.type_name(type_) 65 + } 66 + } 67 + } 68 + 69 + fn print_input_object( 70 + type_: schema.Type, 71 + indent_level: Int, 72 + inline: Bool, 73 + ) -> String { 74 + case inline { 75 + True -> schema.type_name(type_) 76 + False -> { 77 + let type_name = schema.type_name(type_) 78 + let indent = string.repeat(" ", indent_level * 2) 79 + let field_indent = string.repeat(" ", { indent_level + 1 } * 2) 80 + 81 + let description = schema.type_description(type_) 82 + let desc_block = case description { 83 + "" -> "" 84 + _ -> indent <> format_description(description) <> "\n" 85 + } 86 + 87 + let fields = schema.get_input_fields(type_) 88 + 89 + let field_lines = 90 + list.map(fields, fn(field) { 91 + let field_name = schema.input_field_name(field) 92 + let field_type = schema.input_field_type(field) 93 + let field_desc = schema.input_field_description(field) 94 + let field_type_str = print_type_internal(field_type, indent_level + 1, True) 95 + 96 + let field_desc_block = case field_desc { 97 + "" -> "" 98 + _ -> field_indent <> format_description(field_desc) <> "\n" 99 + } 100 + 101 + field_desc_block <> field_indent <> field_name <> ": " <> field_type_str 102 + }) 103 + 104 + case list.is_empty(fields) { 105 + True -> desc_block <> indent <> "input " <> type_name <> " {}" 106 + False -> { 107 + desc_block 108 + <> indent 109 + <> "input " 110 + <> type_name 111 + <> " {\n" 112 + <> string.join(field_lines, "\n") 113 + <> "\n" 114 + <> indent 115 + <> "}" 116 + } 117 + } 118 + } 119 + } 120 + } 121 + 122 + fn print_object(type_: schema.Type, indent_level: Int, inline: Bool) -> String { 123 + case inline { 124 + True -> schema.type_name(type_) 125 + False -> { 126 + let type_name = schema.type_name(type_) 127 + let indent = string.repeat(" ", indent_level * 2) 128 + let field_indent = string.repeat(" ", { indent_level + 1 } * 2) 129 + 130 + let description = schema.type_description(type_) 131 + let desc_block = case description { 132 + "" -> "" 133 + _ -> indent <> format_description(description) <> "\n" 134 + } 135 + 136 + let fields = schema.get_fields(type_) 137 + 138 + let field_lines = 139 + list.map(fields, fn(field) { 140 + let field_name = schema.field_name(field) 141 + let field_type = schema.field_type(field) 142 + let field_desc = schema.field_description(field) 143 + let field_type_str = print_type_internal(field_type, indent_level + 1, True) 144 + 145 + let field_desc_block = case field_desc { 146 + "" -> "" 147 + _ -> field_indent <> format_description(field_desc) <> "\n" 148 + } 149 + 150 + field_desc_block <> field_indent <> field_name <> ": " <> field_type_str 151 + }) 152 + 153 + case list.is_empty(fields) { 154 + True -> desc_block <> indent <> "type " <> type_name <> " {}" 155 + False -> { 156 + desc_block 157 + <> indent 158 + <> "type " 159 + <> type_name 160 + <> " {\n" 161 + <> string.join(field_lines, "\n") 162 + <> "\n" 163 + <> indent 164 + <> "}" 165 + } 166 + } 167 + } 168 + } 169 + } 170 + 171 + fn print_enum(type_: schema.Type, indent_level: Int, inline: Bool) -> String { 172 + case inline { 173 + True -> schema.type_name(type_) 174 + False -> { 175 + let type_name = schema.type_name(type_) 176 + let indent = string.repeat(" ", indent_level * 2) 177 + let value_indent = string.repeat(" ", { indent_level + 1 } * 2) 178 + 179 + let description = schema.type_description(type_) 180 + let desc_block = case description { 181 + "" -> "" 182 + _ -> indent <> format_description(description) <> "\n" 183 + } 184 + 185 + let values = schema.get_enum_values(type_) 186 + 187 + let value_lines = 188 + list.map(values, fn(value) { 189 + let value_name = schema.enum_value_name(value) 190 + let value_desc = schema.enum_value_description(value) 191 + 192 + let value_desc_block = case value_desc { 193 + "" -> "" 194 + _ -> value_indent <> format_description(value_desc) <> "\n" 195 + } 196 + 197 + value_desc_block <> value_indent <> value_name 198 + }) 199 + 200 + case list.is_empty(values) { 201 + True -> desc_block <> indent <> "enum " <> type_name <> " {}" 202 + False -> { 203 + desc_block 204 + <> indent 205 + <> "enum " 206 + <> type_name 207 + <> " {\n" 208 + <> string.join(value_lines, "\n") 209 + <> "\n" 210 + <> indent 211 + <> "}" 212 + } 213 + } 214 + } 215 + } 216 + } 217 + 218 + /// Format a description as a triple-quoted string 219 + fn format_description(description: String) -> String { 220 + case description { 221 + "" -> "" 222 + _ -> "\"\"\"" <> description <> "\"\"\"" 223 + } 224 + }
graphql/test/graphql/executor_test.gleam graphql/test/executor_test.gleam
graphql/test/graphql/introspection_test.gleam graphql/test/introspection_test.gleam
graphql/test/graphql/lexer_test.gleam graphql/test/lexer_test.gleam
graphql/test/graphql/parser_test.gleam graphql/test/parser_test.gleam
graphql/test/graphql/schema_test.gleam graphql/test/schema_test.gleam
graphql/test/graphql/value_test.gleam graphql/test/value_test.gleam
-8
graphql/test/graphql_test.gleam
··· 3 3 pub fn main() -> Nil { 4 4 gleeunit.main() 5 5 } 6 - 7 - // gleeunit test functions end in `_test` 8 - pub fn hello_world_test() { 9 - let name = "Joe" 10 - let greeting = "Hello, " <> name <> "!" 11 - 12 - assert greeting == "Hello, Joe!" 13 - }
+274
graphql/test/sdl_test.gleam
··· 1 + /// Snapshot tests for SDL generation 2 + /// 3 + /// Verifies that GraphQL types are correctly serialized to SDL format 4 + 5 + import birdie 6 + import gleam/option.{None, Some} 7 + import gleeunit 8 + import graphql/schema 9 + import graphql/sdl 10 + import graphql/value 11 + 12 + pub fn main() { 13 + gleeunit.main() 14 + } 15 + 16 + // ===== Input Object Types ===== 17 + 18 + pub fn simple_input_object_test() { 19 + let input_type = 20 + schema.input_object_type( 21 + "UserInput", 22 + "Input for creating or updating a user", 23 + [ 24 + schema.input_field("name", schema.string_type(), "User's name", None), 25 + schema.input_field( 26 + "email", 27 + schema.non_null(schema.string_type()), 28 + "User's email address", 29 + None, 30 + ), 31 + schema.input_field("age", schema.int_type(), "User's age", None), 32 + ], 33 + ) 34 + 35 + let serialized = sdl.print_type(input_type) 36 + 37 + birdie.snap(title: "Simple input object with descriptions", content: serialized) 38 + } 39 + 40 + pub fn input_object_with_default_values_test() { 41 + let input_type = 42 + schema.input_object_type( 43 + "FilterInput", 44 + "Filter options for queries", 45 + [ 46 + schema.input_field( 47 + "limit", 48 + schema.int_type(), 49 + "Maximum number of results", 50 + Some(value.Int(10)), 51 + ), 52 + schema.input_field( 53 + "offset", 54 + schema.int_type(), 55 + "Number of results to skip", 56 + Some(value.Int(0)), 57 + ), 58 + ], 59 + ) 60 + 61 + let serialized = sdl.print_type(input_type) 62 + 63 + birdie.snap( 64 + title: "Input object with default values", 65 + content: serialized, 66 + ) 67 + } 68 + 69 + pub fn nested_input_types_test() { 70 + let address_input = 71 + schema.input_object_type( 72 + "AddressInput", 73 + "Street address information", 74 + [ 75 + schema.input_field("street", schema.string_type(), "Street name", None), 76 + schema.input_field("city", schema.string_type(), "City name", None), 77 + ], 78 + ) 79 + 80 + let user_input = 81 + schema.input_object_type("UserInput", "User information", [ 82 + schema.input_field("name", schema.string_type(), "Full name", None), 83 + schema.input_field("address", address_input, "Home address", None), 84 + ]) 85 + 86 + let serialized = sdl.print_types([address_input, user_input]) 87 + 88 + birdie.snap(title: "Nested input types", content: serialized) 89 + } 90 + 91 + // ===== Object Types ===== 92 + 93 + pub fn simple_object_type_test() { 94 + let user_type = 95 + schema.object_type("User", "A user in the system", [ 96 + schema.field("id", schema.non_null(schema.id_type()), "User ID", fn( 97 + _ctx, 98 + ) { Ok(value.String("1")) }), 99 + schema.field("name", schema.string_type(), "User's name", fn(_ctx) { 100 + Ok(value.String("Alice")) 101 + }), 102 + schema.field("email", schema.string_type(), "Email address", fn(_ctx) { 103 + Ok(value.String("alice@example.com")) 104 + }), 105 + ]) 106 + 107 + let serialized = sdl.print_type(user_type) 108 + 109 + birdie.snap(title: "Simple object type", content: serialized) 110 + } 111 + 112 + pub fn object_with_list_fields_test() { 113 + let post_type = 114 + schema.object_type("Post", "A blog post", [ 115 + schema.field("id", schema.id_type(), "Post ID", fn(_ctx) { 116 + Ok(value.String("1")) 117 + }), 118 + schema.field("title", schema.string_type(), "Post title", fn(_ctx) { 119 + Ok(value.String("Hello")) 120 + }), 121 + schema.field( 122 + "tags", 123 + schema.list_type(schema.non_null(schema.string_type())), 124 + "Post tags", 125 + fn(_ctx) { Ok(value.List([])) }, 126 + ), 127 + ]) 128 + 129 + let serialized = sdl.print_type(post_type) 130 + 131 + birdie.snap(title: "Object type with list fields", content: serialized) 132 + } 133 + 134 + // ===== Enum Types ===== 135 + 136 + pub fn simple_enum_test() { 137 + let status_enum = 138 + schema.enum_type("Status", "Order status", [ 139 + schema.enum_value("PENDING", "Order is pending"), 140 + schema.enum_value("PROCESSING", "Order is being processed"), 141 + schema.enum_value("SHIPPED", "Order has been shipped"), 142 + schema.enum_value("DELIVERED", "Order has been delivered"), 143 + ]) 144 + 145 + let serialized = sdl.print_type(status_enum) 146 + 147 + birdie.snap(title: "Simple enum type", content: serialized) 148 + } 149 + 150 + pub fn enum_without_descriptions_test() { 151 + let color_enum = 152 + schema.enum_type("Color", "", [ 153 + schema.enum_value("RED", ""), 154 + schema.enum_value("GREEN", ""), 155 + schema.enum_value("BLUE", ""), 156 + ]) 157 + 158 + let serialized = sdl.print_type(color_enum) 159 + 160 + birdie.snap(title: "Enum without descriptions", content: serialized) 161 + } 162 + 163 + // ===== Scalar Types ===== 164 + 165 + pub fn built_in_scalars_test() { 166 + let scalars = [ 167 + schema.string_type(), 168 + schema.int_type(), 169 + schema.float_type(), 170 + schema.boolean_type(), 171 + schema.id_type(), 172 + ] 173 + 174 + let serialized = sdl.print_types(scalars) 175 + 176 + birdie.snap(title: "Built-in scalar types", content: serialized) 177 + } 178 + 179 + // ===== Complex Types ===== 180 + 181 + pub fn type_with_non_null_and_list_test() { 182 + let input_type = 183 + schema.input_object_type("ComplexInput", "Complex type modifiers", [ 184 + schema.input_field( 185 + "required", 186 + schema.non_null(schema.string_type()), 187 + "Required string", 188 + None, 189 + ), 190 + schema.input_field( 191 + "optionalList", 192 + schema.list_type(schema.string_type()), 193 + "Optional list of strings", 194 + None, 195 + ), 196 + schema.input_field( 197 + "requiredList", 198 + schema.non_null(schema.list_type(schema.string_type())), 199 + "Required list of optional strings", 200 + None, 201 + ), 202 + schema.input_field( 203 + "listOfRequired", 204 + schema.list_type(schema.non_null(schema.string_type())), 205 + "Optional list of required strings", 206 + None, 207 + ), 208 + schema.input_field( 209 + "requiredListOfRequired", 210 + schema.non_null(schema.list_type(schema.non_null(schema.string_type()))), 211 + "Required list of required strings", 212 + None, 213 + ), 214 + ]) 215 + 216 + let serialized = sdl.print_type(input_type) 217 + 218 + birdie.snap(title: "Type with NonNull and List modifiers", content: serialized) 219 + } 220 + 221 + // ===== Multiple Related Types ===== 222 + 223 + pub fn related_types_test() { 224 + let sort_direction = 225 + schema.enum_type("SortDirection", "Sort direction for queries", [ 226 + schema.enum_value("ASC", "Ascending order"), 227 + schema.enum_value("DESC", "Descending order"), 228 + ]) 229 + 230 + let sort_field_enum = 231 + schema.enum_type("UserSortField", "Fields to sort users by", [ 232 + schema.enum_value("NAME", "Sort by name"), 233 + schema.enum_value("EMAIL", "Sort by email"), 234 + schema.enum_value("CREATED_AT", "Sort by creation date"), 235 + ]) 236 + 237 + let sort_input = 238 + schema.input_object_type("SortInput", "Sort configuration", [ 239 + schema.input_field( 240 + "field", 241 + schema.non_null(sort_field_enum), 242 + "Field to sort by", 243 + None, 244 + ), 245 + schema.input_field( 246 + "direction", 247 + sort_direction, 248 + "Sort direction", 249 + Some(value.String("ASC")), 250 + ), 251 + ]) 252 + 253 + let serialized = sdl.print_types([sort_direction, sort_field_enum, sort_input]) 254 + 255 + birdie.snap(title: "Multiple related types", content: serialized) 256 + } 257 + 258 + // ===== Empty Types (Edge Cases) ===== 259 + 260 + pub fn empty_input_object_test() { 261 + let empty_input = schema.input_object_type("EmptyInput", "An empty input", []) 262 + 263 + let serialized = sdl.print_type(empty_input) 264 + 265 + birdie.snap(title: "Empty input object", content: serialized) 266 + } 267 + 268 + pub fn empty_enum_test() { 269 + let empty_enum = schema.enum_type("EmptyEnum", "An empty enum", []) 270 + 271 + let serialized = sdl.print_type(empty_enum) 272 + 273 + birdie.snap(title: "Empty enum", content: serialized) 274 + }
+147
lexicon_graphql/birdie_snapshots/all_types_generated_by_db_schema_builder_including_connection,_edge,_page_info,_sort_field_enum,_where_input,_etc.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: All types generated by db_schema_builder including Connection, Edge, PageInfo, SortField enum, WhereInput, etc. 4 + file: ./test/sorting_test.gleam 5 + test_name: db_schema_all_types_snapshot_test 6 + --- 7 + """Filter operators for XyzStatusphereStatus fields""" 8 + input XyzStatusphereStatusFieldCondition { 9 + """Exact match (equals)""" 10 + eq: String 11 + """Match any value in the list""" 12 + in: [String!] 13 + """Case-insensitive substring match (string fields only)""" 14 + contains: String 15 + """Greater than""" 16 + gt: String 17 + """Greater than or equal to""" 18 + gte: String 19 + """Less than""" 20 + lt: String 21 + """Less than or equal to""" 22 + lte: String 23 + } 24 + 25 + """Filter conditions for XyzStatusphereStatus with nested AND/OR support""" 26 + input XyzStatusphereStatusWhereInput { 27 + """Filter by uri""" 28 + uri: XyzStatusphereStatusFieldCondition 29 + """Filter by cid""" 30 + cid: XyzStatusphereStatusFieldCondition 31 + """Filter by did""" 32 + did: XyzStatusphereStatusFieldCondition 33 + """Filter by collection""" 34 + collection: XyzStatusphereStatusFieldCondition 35 + """Filter by indexedAt""" 36 + indexedAt: XyzStatusphereStatusFieldCondition 37 + """Filter by actorHandle""" 38 + actorHandle: XyzStatusphereStatusFieldCondition 39 + """Filter by text""" 40 + text: XyzStatusphereStatusFieldCondition 41 + """Filter by createdAt""" 42 + createdAt: XyzStatusphereStatusFieldCondition 43 + """All conditions must match (AND logic)""" 44 + and: [XyzStatusphereStatusWhereInput!] 45 + """Any condition must match (OR logic)""" 46 + or: [XyzStatusphereStatusWhereInput!] 47 + } 48 + 49 + """Sort direction for query results""" 50 + enum SortDirection { 51 + """Ascending order""" 52 + ASC 53 + """Descending order""" 54 + DESC 55 + } 56 + 57 + """Available sort fields for XyzStatusphereStatus""" 58 + enum XyzStatusphereStatusSortField { 59 + """Sort by uri""" 60 + uri 61 + """Sort by cid""" 62 + cid 63 + """Sort by did""" 64 + did 65 + """Sort by collection""" 66 + collection 67 + """Sort by indexedAt""" 68 + indexedAt 69 + """Sort by text""" 70 + text 71 + """Sort by createdAt""" 72 + createdAt 73 + } 74 + 75 + """Specifies a field to sort by and its direction""" 76 + input SortFieldInput { 77 + """Field to sort by""" 78 + field: XyzStatusphereStatusSortField! 79 + """Sort direction (ASC or DESC)""" 80 + direction: SortDirection! 81 + } 82 + 83 + scalar Int 84 + 85 + scalar Boolean 86 + 87 + """Information about pagination in a connection""" 88 + type PageInfo { 89 + """When paginating forwards, are there more items?""" 90 + hasNextPage: Boolean! 91 + """When paginating backwards, are there more items?""" 92 + hasPreviousPage: Boolean! 93 + """Cursor corresponding to the first item in the page""" 94 + startCursor: String 95 + """Cursor corresponding to the last item in the page""" 96 + endCursor: String 97 + } 98 + 99 + scalar String 100 + 101 + """Record type: xyz.statusphere.status""" 102 + type XyzStatusphereStatus { 103 + """Record URI""" 104 + uri: String 105 + """Record CID""" 106 + cid: String 107 + """DID of record author""" 108 + did: String 109 + """Collection name""" 110 + collection: String 111 + """When record was indexed""" 112 + indexedAt: String 113 + """Handle of the actor who created this record""" 114 + actorHandle: String 115 + """Field from lexicon""" 116 + text: String 117 + """Field from lexicon""" 118 + createdAt: String 119 + } 120 + 121 + """An edge in a connection for XyzStatusphereStatus""" 122 + type XyzStatusphereStatusEdge { 123 + """The item at the end of the edge""" 124 + node: XyzStatusphereStatus! 125 + """A cursor for use in pagination""" 126 + cursor: String! 127 + } 128 + 129 + """A connection to a list of items for XyzStatusphereStatus""" 130 + type XyzStatusphereStatusConnection { 131 + """A list of edges""" 132 + edges: [XyzStatusphereStatusEdge!]! 133 + """Information to aid in pagination""" 134 + pageInfo: PageInfo! 135 + """Total number of items in the connection""" 136 + totalCount: Int 137 + } 138 + 139 + """Root query type""" 140 + type Query { 141 + """Query xyz.statusphere.status with cursor pagination and sorting""" 142 + xyzStatusphereStatus: XyzStatusphereStatusConnection 143 + } 144 + 145 + scalar Float 146 + 147 + scalar ID
+37
lexicon_graphql/birdie_snapshots/all_types_generated_for_simple_status_record.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: All types generated for simple status record 4 + file: ./test/schema_builder_test.gleam 5 + test_name: simple_schema_all_types_snapshot_test 6 + --- 7 + scalar String 8 + 9 + """Record type: xyz.statusphere.status""" 10 + type XyzStatusphereStatus { 11 + """Record URI""" 12 + uri: String 13 + """Record CID""" 14 + cid: String 15 + """DID of record author""" 16 + did: String 17 + """When record was indexed""" 18 + indexedAt: String 19 + """Field from lexicon""" 20 + text: String 21 + """Field from lexicon""" 22 + createdAt: String 23 + } 24 + 25 + """Root query type""" 26 + type Query { 27 + """Query xyz.statusphere.status""" 28 + xyzStatusphereStatus: [XyzStatusphereStatus] 29 + } 30 + 31 + scalar Int 32 + 33 + scalar Float 34 + 35 + scalar Boolean 36 + 37 + scalar ID
+31
lexicon_graphql/birdie_snapshots/complex_post_where_input_with_many_fields.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Complex Post WhereInput with many fields 4 + file: ./test/where_schema_test.gleam 5 + test_name: complex_post_where_input_snapshot_test 6 + --- 7 + """Filter conditions for AppBskyFeedPost with nested AND/OR support""" 8 + input AppBskyFeedPostWhereInput { 9 + """Filter by uri""" 10 + uri: AppBskyFeedPostFieldCondition 11 + """Filter by cid""" 12 + cid: AppBskyFeedPostFieldCondition 13 + """Filter by text""" 14 + text: AppBskyFeedPostFieldCondition 15 + """Filter by createdAt""" 16 + createdAt: AppBskyFeedPostFieldCondition 17 + """Filter by indexedAt""" 18 + indexedAt: AppBskyFeedPostFieldCondition 19 + """Filter by likeCount""" 20 + likeCount: AppBskyFeedPostFieldCondition 21 + """Filter by replyCount""" 22 + replyCount: AppBskyFeedPostFieldCondition 23 + """Filter by repostCount""" 24 + repostCount: AppBskyFeedPostFieldCondition 25 + """Filter by author""" 26 + author: AppBskyFeedPostFieldCondition 27 + """All conditions must match (AND logic)""" 28 + and: [AppBskyFeedPostWhereInput!] 29 + """Any condition must match (OR logic)""" 30 + or: [AppBskyFeedPostWhereInput!] 31 + }
+13
lexicon_graphql/birdie_snapshots/empty_where_input_with_only_and_or_fields.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Empty WhereInput with only AND/OR fields 4 + file: ./test/where_schema_test.gleam 5 + test_name: empty_where_input_snapshot_test 6 + --- 7 + """Filter conditions for EmptyRecord with nested AND/OR support""" 8 + input EmptyRecordWhereInput { 9 + """All conditions must match (AND logic)""" 10 + and: [EmptyRecordWhereInput!] 11 + """Any condition must match (OR logic)""" 12 + or: [EmptyRecordWhereInput!] 13 + }
+23
lexicon_graphql/birdie_snapshots/field_condition_type_with_all_operators.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Field condition type with all operators 4 + file: ./test/where_schema_test.gleam 5 + test_name: field_condition_snapshot_test 6 + --- 7 + """Filter operators for AppBskyFeedPost fields""" 8 + input AppBskyFeedPostFieldCondition { 9 + """Exact match (equals)""" 10 + eq: String 11 + """Match any value in the list""" 12 + in: [String!] 13 + """Case-insensitive substring match (string fields only)""" 14 + contains: String 15 + """Greater than""" 16 + gt: String 17 + """Greater than or equal to""" 18 + gte: String 19 + """Less than""" 20 + lt: String 21 + """Less than or equal to""" 22 + lte: String 23 + }
+29
lexicon_graphql/birdie_snapshots/multiple_record_types_with_different_where_input_names.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Multiple record types with different WhereInput names 4 + file: ./test/where_schema_test.gleam 5 + test_name: multiple_record_types_snapshot_test 6 + --- 7 + """Filter conditions for AppBskyFeedPost with nested AND/OR support""" 8 + input AppBskyFeedPostWhereInput { 9 + """Filter by text""" 10 + text: AppBskyFeedPostFieldCondition 11 + """Filter by createdAt""" 12 + createdAt: AppBskyFeedPostFieldCondition 13 + """All conditions must match (AND logic)""" 14 + and: [AppBskyFeedPostWhereInput!] 15 + """Any condition must match (OR logic)""" 16 + or: [AppBskyFeedPostWhereInput!] 17 + } 18 + 19 + """Filter conditions for AppBskyActorProfile with nested AND/OR support""" 20 + input AppBskyActorProfileWhereInput { 21 + """Filter by displayName""" 22 + displayName: AppBskyActorProfileFieldCondition 23 + """Filter by description""" 24 + description: AppBskyActorProfileFieldCondition 25 + """All conditions must match (AND logic)""" 26 + and: [AppBskyActorProfileWhereInput!] 27 + """Any condition must match (OR logic)""" 28 + or: [AppBskyActorProfileWhereInput!] 29 + }
+23
lexicon_graphql/birdie_snapshots/profile_where_input_with_actor_fields.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Profile WhereInput with actor fields 4 + file: ./test/where_schema_test.gleam 5 + test_name: profile_where_input_snapshot_test 6 + --- 7 + """Filter conditions for AppBskyActorProfile with nested AND/OR support""" 8 + input AppBskyActorProfileWhereInput { 9 + """Filter by did""" 10 + did: AppBskyActorProfileFieldCondition 11 + """Filter by handle""" 12 + handle: AppBskyActorProfileFieldCondition 13 + """Filter by displayName""" 14 + displayName: AppBskyActorProfileFieldCondition 15 + """Filter by description""" 16 + description: AppBskyActorProfileFieldCondition 17 + """Filter by avatar""" 18 + avatar: AppBskyActorProfileFieldCondition 19 + """All conditions must match (AND logic)""" 20 + and: [AppBskyActorProfileWhereInput!] 21 + """Any condition must match (OR logic)""" 22 + or: [AppBskyActorProfileWhereInput!] 23 + }
+11
lexicon_graphql/birdie_snapshots/query_type_with_connection_field_and_sort_by_argument.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Query type with connection field and sortBy argument 4 + file: ./test/sorting_test.gleam 5 + test_name: single_lexicon_with_sorting_snapshot_test 6 + --- 7 + """Root query type""" 8 + type Query { 9 + """Query xyz.statusphere.status with cursor pagination and sorting""" 10 + xyzStatusphereStatus: XyzStatusphereStatusConnection 11 + }
+13
lexicon_graphql/birdie_snapshots/query_type_with_multiple_connection_fields_and_distinct_sort_enums.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Query type with multiple connection fields and distinct sort enums 4 + file: ./test/sorting_test.gleam 5 + test_name: multiple_lexicons_with_distinct_sort_enums_snapshot_test 6 + --- 7 + """Root query type""" 8 + type Query { 9 + """Query xyz.statusphere.status with cursor pagination and sorting""" 10 + xyzStatusphereStatus: XyzStatusphereStatusConnection 11 + """Query app.bsky.feed.post with cursor pagination and sorting""" 12 + appBskyFeedPost: AppBskyFeedPostConnection 13 + }
+11
lexicon_graphql/birdie_snapshots/schema_showing_pascal_case_type_name_and_camel_case_field_name_from_nsid.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Schema showing PascalCase type name and camelCase field name from NSID 4 + file: ./test/schema_builder_test.gleam 5 + test_name: correct_type_names_snapshot_test 6 + --- 7 + """Root query type""" 8 + type Query { 9 + """Query app.bsky.feed.post""" 10 + appBskyFeedPost: [AppBskyFeedPost] 11 + }
+13
lexicon_graphql/birdie_snapshots/schema_with_multiple_record_types.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Schema with multiple record types 4 + file: ./test/schema_builder_test.gleam 5 + test_name: multiple_lexicons_snapshot_test 6 + --- 7 + """Root query type""" 8 + type Query { 9 + """Query xyz.statusphere.status""" 10 + xyzStatusphereStatus: [XyzStatusphereStatus] 11 + """Query xyz.statusphere.profile""" 12 + xyzStatusphereProfile: [XyzStatusphereProfile] 13 + }
+21
lexicon_graphql/birdie_snapshots/simple_post_where_input_with_basic_fields.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Simple Post WhereInput with basic fields 4 + file: ./test/where_schema_test.gleam 5 + test_name: simple_post_where_input_snapshot_test 6 + --- 7 + """Filter conditions for AppBskyFeedPost with nested AND/OR support""" 8 + input AppBskyFeedPostWhereInput { 9 + """Filter by uri""" 10 + uri: AppBskyFeedPostFieldCondition 11 + """Filter by cid""" 12 + cid: AppBskyFeedPostFieldCondition 13 + """Filter by text""" 14 + text: AppBskyFeedPostFieldCondition 15 + """Filter by createdAt""" 16 + createdAt: AppBskyFeedPostFieldCondition 17 + """All conditions must match (AND logic)""" 18 + and: [AppBskyFeedPostWhereInput!] 19 + """Any condition must match (OR logic)""" 20 + or: [AppBskyFeedPostWhereInput!] 21 + }
+11
lexicon_graphql/birdie_snapshots/simple_status_record_schema.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Simple status record schema 4 + file: ./test/schema_builder_test.gleam 5 + test_name: simple_schema_snapshot_test 6 + --- 7 + """Root query type""" 8 + type Query { 9 + """Query xyz.statusphere.status""" 10 + xyzStatusphereStatus: [XyzStatusphereStatus] 11 + }
+37
lexicon_graphql/birdie_snapshots/where_input_and_field_condition_types_together.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: WhereInput and FieldCondition types together 4 + file: ./test/where_schema_test.gleam 5 + test_name: related_types_snapshot_test 6 + --- 7 + """Filter conditions for TestRecord with nested AND/OR support""" 8 + input TestRecordWhereInput { 9 + """Filter by text""" 10 + text: TestRecordFieldCondition 11 + """Filter by createdAt""" 12 + createdAt: TestRecordFieldCondition 13 + """Filter by likes""" 14 + likes: TestRecordFieldCondition 15 + """All conditions must match (AND logic)""" 16 + and: [TestRecordWhereInput!] 17 + """Any condition must match (OR logic)""" 18 + or: [TestRecordWhereInput!] 19 + } 20 + 21 + """Filter operators for TestRecord fields""" 22 + input TestRecordFieldCondition { 23 + """Exact match (equals)""" 24 + eq: String 25 + """Match any value in the list""" 26 + in: [String!] 27 + """Case-insensitive substring match (string fields only)""" 28 + contains: String 29 + """Greater than""" 30 + gt: String 31 + """Greater than or equal to""" 32 + gte: String 33 + """Less than""" 34 + lt: String 35 + """Less than or equal to""" 36 + lte: String 37 + }
+17
lexicon_graphql/birdie_snapshots/where_input_showing_recursive_and_or_fields.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: WhereInput showing recursive AND/OR fields 4 + file: ./test/where_schema_test.gleam 5 + test_name: recursive_and_or_fields_snapshot_test 6 + --- 7 + """Filter conditions for RecursiveTest with nested AND/OR support""" 8 + input RecursiveTestWhereInput { 9 + """Filter by name""" 10 + name: RecursiveTestFieldCondition 11 + """Filter by status""" 12 + status: RecursiveTestFieldCondition 13 + """All conditions must match (AND logic)""" 14 + and: [RecursiveTestWhereInput!] 15 + """Any condition must match (OR logic)""" 16 + or: [RecursiveTestWhereInput!] 17 + }
+1
lexicon_graphql/gleam.toml
··· 19 19 20 20 [dev-dependencies] 21 21 gleeunit = ">= 1.0.0 and < 2.0.0" 22 + birdie = ">= 1.0.0 and < 2.0.0"
+16
lexicon_graphql/manifest.toml
··· 2 2 # You typically do not need to edit this file 3 3 4 4 packages = [ 5 + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 + { name = "birdie", version = "1.4.1", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "justin", "rank", "simplifile", "term_size", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "18599E478C14BD9EBD2465F0561F96EB9B58A24DB44AF86F103EF81D4B9834BF" }, 7 + { name = "edit_distance", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "7DC465C34695F9E57D79FC65670C53C992CE342BF29E0AA41FF44F61AF62FC56" }, 8 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 9 + { name = "glance", version = "5.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "7F216D97935465FF4AC46699CD1C3E0FB19CB678B002E4ACAFCE256E96312F14" }, 10 + { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, 11 + { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, 5 12 { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 13 + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 6 14 { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 7 15 { name = "gleeunit", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "CD701726CBCE5588B375D157B4391CFD0F2F134CD12D9B6998A395484DE05C58" }, 16 + { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, 8 17 { name = "graphql", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../graphql" }, 18 + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 19 + { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 20 + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 21 + { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, 22 + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 23 + { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, 9 24 ] 10 25 11 26 [requirements] 27 + birdie = { version = ">= 1.0.0 and < 2.0.0" } 12 28 gleam_json = { version = ">= 3.0.0 and < 4.0.0" } 13 29 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 14 30 gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+166
lexicon_graphql/src/lexicon_graphql/blob_type.gleam
··· 1 + /// Blob Type for GraphQL 2 + /// 3 + /// Provides a GraphQL object type for AT Protocol blob references. 4 + /// 5 + /// ## Schema 6 + /// 7 + /// ```graphql 8 + /// type Blob { 9 + /// ref: String! 10 + /// mimeType: String! 11 + /// size: Int! 12 + /// url(preset: String): String! 13 + /// } 14 + /// ``` 15 + /// 16 + /// ## Fields 17 + /// 18 + /// - `ref`: CID reference to the blob (e.g., "bafyreiabc123...") 19 + /// - `mimeType`: MIME type of the blob (e.g., "image/jpeg", "image/png") 20 + /// - `size`: Size of the blob in bytes 21 + /// - `url(preset)`: Generate a CDN URL with an optional preset parameter 22 + /// - Presets: `avatar`, `banner`, `feed_thumbnail`, `feed_fullsize` 23 + /// - Default preset: `feed_fullsize` 24 + /// - URL format: `https://cdn.bsky.app/img/{preset}/plain/{did}/{ref}@jpeg` 25 + /// 26 + /// ## Usage 27 + /// 28 + /// The Blob type is automatically used when a lexicon field has type "blob". 29 + /// The resolver expects blob data in this format: 30 + /// 31 + /// ```gleam 32 + /// value.Object([ 33 + /// #("ref", value.String("bafyreiabc123")), 34 + /// #("mime_type", value.String("image/jpeg")), 35 + /// #("size", value.Int(12345)), 36 + /// #("did", value.String("did:plc:user123")), 37 + /// ]) 38 + /// ``` 39 + import gleam/int 40 + import gleam/list 41 + import gleam/option.{Some} 42 + import gleam/result 43 + import graphql/schema 44 + import graphql/value 45 + 46 + /// Create the Blob GraphQL object type 47 + pub fn create_blob_type() -> schema.Type { 48 + schema.object_type("Blob", "A blob reference with metadata and URL generation", [ 49 + // ref field - CID reference 50 + schema.field( 51 + "ref", 52 + schema.non_null(schema.string_type()), 53 + "CID reference to the blob", 54 + resolve_ref, 55 + ), 56 + // mimeType field 57 + schema.field( 58 + "mimeType", 59 + schema.non_null(schema.string_type()), 60 + "MIME type of the blob", 61 + resolve_mime_type, 62 + ), 63 + // size field 64 + schema.field( 65 + "size", 66 + schema.non_null(schema.int_type()), 67 + "Size in bytes", 68 + resolve_size, 69 + ), 70 + // url field with preset argument 71 + schema.field_with_args( 72 + "url", 73 + schema.non_null(schema.string_type()), 74 + "Generate CDN URL for the blob with the specified preset (avatar, banner, feed_thumbnail, feed_fullsize)", 75 + [ 76 + schema.argument( 77 + "preset", 78 + schema.string_type(), 79 + "Image preset: avatar, banner, feed_thumbnail, feed_fullsize", 80 + Some(value.String("feed_fullsize")), 81 + ), 82 + ], 83 + resolve_url, 84 + ), 85 + ]) 86 + } 87 + 88 + /// Check if a type has a field with the given name 89 + pub fn has_field(type_: schema.Type, field_name: String) -> Bool { 90 + case schema.get_field(type_, field_name) { 91 + option.Some(_) -> True 92 + option.None -> False 93 + } 94 + } 95 + 96 + /// Resolve the ref field 97 + pub fn resolve_ref(ctx: schema.Context) -> Result(value.Value, String) { 98 + extract_blob_field(ctx, "ref") 99 + |> result.map(value.String) 100 + } 101 + 102 + /// Resolve the mimeType field 103 + pub fn resolve_mime_type(ctx: schema.Context) -> Result(value.Value, String) { 104 + extract_blob_field(ctx, "mime_type") 105 + |> result.map(value.String) 106 + } 107 + 108 + /// Resolve the size field 109 + pub fn resolve_size(ctx: schema.Context) -> Result(value.Value, String) { 110 + case ctx.data { 111 + Some(value.Object(fields)) -> { 112 + case list.key_find(fields, "size") { 113 + Ok(value.Int(size)) -> Ok(value.Int(size)) 114 + Ok(value.String(s)) -> 115 + case int.parse(s) { 116 + Ok(i) -> Ok(value.Int(i)) 117 + Error(_) -> Error("Invalid size value") 118 + } 119 + _ -> Error("Size field not found or invalid type") 120 + } 121 + } 122 + _ -> Error("Missing blob data in context") 123 + } 124 + } 125 + 126 + /// Resolve the url field with preset argument 127 + pub fn resolve_url(ctx: schema.Context) -> Result(value.Value, String) { 128 + // Extract preset argument (with default) 129 + let preset = case schema.get_argument(ctx, "preset") { 130 + Some(value.String(p)) -> p 131 + _ -> "feed_fullsize" 132 + } 133 + 134 + // Extract blob data from context 135 + use ref <- result.try(extract_blob_field(ctx, "ref")) 136 + use did <- result.try(extract_blob_field(ctx, "did")) 137 + 138 + // Build CDN URL: https://cdn.bsky.app/img/{preset}/plain/{did}/{ref}@jpeg 139 + let cdn_url = 140 + "https://cdn.bsky.app/img/" 141 + <> preset 142 + <> "/plain/" 143 + <> did 144 + <> "/" 145 + <> ref 146 + <> "@jpeg" 147 + 148 + Ok(value.String(cdn_url)) 149 + } 150 + 151 + /// Helper to extract a string field from blob data in context 152 + fn extract_blob_field( 153 + ctx: schema.Context, 154 + field_name: String, 155 + ) -> Result(String, String) { 156 + case ctx.data { 157 + Some(value.Object(fields)) -> { 158 + case list.key_find(fields, field_name) { 159 + Ok(value.String(val)) -> Ok(val) 160 + Ok(value.Int(val)) -> Ok(int.to_string(val)) 161 + _ -> Error("Field " <> field_name <> " not found or invalid type") 162 + } 163 + } 164 + _ -> Error("Missing blob data in context") 165 + } 166 + }
+118 -1
lexicon_graphql/src/lexicon_graphql/connection.gleam
··· 75 75 ) 76 76 } 77 77 78 - /// Connection arguments with sortBy using a custom field enum 78 + /// Builds a WhereConditionInput type for filtering a specific field type 79 + /// Supports: eq, in, contains, gt, gte, lt, lte operators 80 + pub fn build_where_condition_input_type( 81 + type_name: String, 82 + field_type: schema.Type, 83 + ) -> schema.Type { 84 + let condition_type_name = type_name <> "FieldCondition" 85 + 86 + schema.input_object_type( 87 + condition_type_name, 88 + "Filter operators for " <> type_name <> " fields", 89 + [ 90 + schema.input_field("eq", field_type, "Exact match (equals)", None), 91 + schema.input_field( 92 + "in", 93 + schema.list_type(schema.non_null(field_type)), 94 + "Match any value in the list", 95 + None, 96 + ), 97 + schema.input_field( 98 + "contains", 99 + schema.string_type(), 100 + "Case-insensitive substring match (string fields only)", 101 + None, 102 + ), 103 + schema.input_field("gt", field_type, "Greater than", None), 104 + schema.input_field("gte", field_type, "Greater than or equal to", None), 105 + schema.input_field("lt", field_type, "Less than", None), 106 + schema.input_field("lte", field_type, "Less than or equal to", None), 107 + ], 108 + ) 109 + } 110 + 111 + /// Builds a WhereInput type for a specific record type with all its fields 112 + /// Includes recursive AND/OR support by creating the type in two passes 113 + pub fn build_where_input_type( 114 + type_name: String, 115 + field_names: List(String), 116 + ) -> schema.Type { 117 + let where_input_name = type_name <> "WhereInput" 118 + 119 + // Build the string condition type (used for most fields) 120 + let string_condition_type = 121 + build_where_condition_input_type(type_name, schema.string_type()) 122 + 123 + // Build input fields for each record field that can be filtered 124 + let field_input_fields = 125 + list.map(field_names, fn(field_name) { 126 + schema.input_field( 127 + field_name, 128 + string_condition_type, 129 + "Filter by " <> field_name, 130 + None, 131 + ) 132 + }) 133 + 134 + // Create a placeholder type to reference (this will be filled in later) 135 + // We need to create the type first, then add recursive references 136 + let where_input_type = 137 + schema.input_object_type( 138 + where_input_name, 139 + "Filter conditions for " <> type_name, 140 + field_input_fields, 141 + ) 142 + 143 + // Add AND/OR fields that reference the type itself 144 + // Note: This creates a recursive type structure like Slice API does 145 + let logic_fields = [ 146 + schema.input_field( 147 + "and", 148 + schema.list_type(schema.non_null(where_input_type)), 149 + "All conditions must match (AND logic)", 150 + None, 151 + ), 152 + schema.input_field( 153 + "or", 154 + schema.list_type(schema.non_null(where_input_type)), 155 + "Any condition must match (OR logic)", 156 + None, 157 + ), 158 + ] 159 + 160 + // Rebuild the type with all fields including the recursive AND/OR 161 + schema.input_object_type( 162 + where_input_name, 163 + "Filter conditions for " <> type_name <> " with nested AND/OR support", 164 + list.append(field_input_fields, logic_fields), 165 + ) 166 + } 167 + 168 + /// Connection arguments with sortBy using a custom field enum and where filtering 169 + pub fn lexicon_connection_args_with_field_enum_and_where( 170 + field_enum: schema.Type, 171 + where_input_type: schema.Type, 172 + ) -> List(schema.Argument) { 173 + list.flatten([ 174 + connection.forward_pagination_args(), 175 + connection.backward_pagination_args(), 176 + [ 177 + schema.argument( 178 + "sortBy", 179 + schema.list_type(schema.non_null( 180 + sort_field_input_type_with_enum(field_enum), 181 + )), 182 + "Sort order for the connection", 183 + None, 184 + ), 185 + schema.argument( 186 + "where", 187 + where_input_type, 188 + "Filter conditions for the query", 189 + None, 190 + ), 191 + ], 192 + ]) 193 + } 194 + 195 + /// Connection arguments with sortBy using a custom field enum (backward compatibility) 79 196 pub fn lexicon_connection_args_with_field_enum( 80 197 field_enum: schema.Type, 81 198 ) -> List(schema.Argument) {
+142 -11
lexicon_graphql/src/lexicon_graphql/db_schema_builder.gleam
··· 13 13 import lexicon_graphql/nsid 14 14 import lexicon_graphql/schema_builder 15 15 import lexicon_graphql/type_mapper 16 + import lexicon_graphql/where_input 16 17 17 18 /// Record type metadata with database resolver info 18 19 type RecordType { ··· 32 33 last: option.Option(Int), 33 34 before: option.Option(String), 34 35 sort_by: option.Option(List(#(String, String))), 36 + where: option.Option(where_input.WhereClause), 35 37 ) 36 38 } 37 39 38 40 /// Type for a database record fetcher function with pagination support 39 41 /// Takes a collection NSID and pagination params, returns Connection data 40 - /// Returns: (records_with_cursors, end_cursor, has_next_page, has_previous_page) 42 + /// Returns: (records_with_cursors, end_cursor, has_next_page, has_previous_page, total_count) 41 43 pub type RecordFetcher = 42 44 fn(String, PaginationParams) -> 43 45 Result( 44 - #(List(#(value.Value, String)), option.Option(String), Bool, Bool), 46 + #( 47 + List(#(value.Value, String)), 48 + option.Option(String), 49 + Bool, 50 + Bool, 51 + option.Option(Int), 52 + ), 45 53 String, 46 54 ) 47 55 ··· 144 152 } 145 153 }, 146 154 ), 155 + schema.field( 156 + "actorHandle", 157 + schema.string_type(), 158 + "Handle of the actor who created this record", 159 + fn(ctx) { 160 + case get_field_from_context(ctx, "actorHandle") { 161 + Ok(handle) -> Ok(value.String(handle)) 162 + Error(_) -> Ok(value.Null) 163 + } 164 + }, 165 + ), 147 166 ] 148 167 149 168 // Build fields from lexicon properties ··· 153 172 let graphql_type = type_mapper.map_type(type_) 154 173 155 174 schema.field(name, graphql_type, "Field from lexicon", fn(ctx) { 156 - // Try to extract field from the value object in context 157 - case get_nested_field_from_context(ctx, "value", name) { 158 - Ok(val) -> Ok(value.String(val)) 159 - Error(_) -> Ok(value.Null) 175 + // Special handling for blob fields 176 + case type_ { 177 + "blob" -> { 178 + // Extract blob data from AT Protocol format and convert to Blob type format 179 + case extract_blob_data(ctx, name) { 180 + Ok(blob_value) -> Ok(blob_value) 181 + Error(_) -> Ok(value.Null) 182 + } 183 + } 184 + _ -> { 185 + // Try to extract field from the value object in context 186 + case get_nested_field_from_context(ctx, "value", name) { 187 + Ok(val) -> Ok(value.String(val)) 188 + Error(_) -> Ok(value.Null) 189 + } 190 + } 160 191 } 161 192 }) 162 193 }) ··· 167 198 168 199 /// Build a SortFieldEnum for a record type with all its sortable fields 169 200 fn build_sort_field_enum(record_type: RecordType) -> schema.Type { 170 - // Get field names from the record type 201 + // Get field names from the record type, excluding non-sortable fields 171 202 let field_names = 172 203 list.map(record_type.fields, fn(field) { schema.field_name(field) }) 204 + |> list.filter(fn(name) { name != "actorHandle" }) 173 205 174 206 // Convert field names to enum values 175 207 let enum_values = ··· 184 216 ) 185 217 } 186 218 219 + /// Build a WhereInput type for a record type with all its filterable fields 220 + fn build_where_input_type(record_type: RecordType) -> schema.Type { 221 + // Get field names from the record type 222 + let field_names = 223 + list.map(record_type.fields, fn(field) { schema.field_name(field) }) 224 + 225 + // Use the connection module to build the where input type 226 + lexicon_connection.build_where_input_type(record_type.type_name, field_names) 227 + } 228 + 187 229 /// Build the root Query type with fields for each record type 188 230 fn build_query_type( 189 231 record_types: List(RecordType), ··· 207 249 // Build custom SortFieldEnum for this record type 208 250 let sort_field_enum = build_sort_field_enum(record_type) 209 251 210 - // Build custom connection args with type-specific sort field enum 252 + // Build custom WhereInput type for this record type 253 + let where_input_type = build_where_input_type(record_type) 254 + 255 + // Build custom connection args with type-specific sort field enum and where input 211 256 let connection_args = 212 - lexicon_connection.lexicon_connection_args_with_field_enum( 257 + lexicon_connection.lexicon_connection_args_with_field_enum_and_where( 213 258 sort_field_enum, 259 + where_input_type, 214 260 ) 215 261 216 262 // Create query field that returns a Connection of this record type ··· 226 272 let pagination_params = extract_pagination_params(ctx) 227 273 228 274 // Call the fetcher function to get records with cursors from database 229 - use #(records_with_cursors, end_cursor, has_next_page, has_previous_page) <- result.try( 275 + use #(records_with_cursors, end_cursor, has_next_page, has_previous_page, total_count) <- result.try( 230 276 fetcher(collection_nsid, pagination_params), 231 277 ) 232 278 ··· 254 300 connection.Connection( 255 301 edges: edges, 256 302 page_info: page_info, 257 - total_count: option.None, 303 + total_count: total_count, 258 304 ) 259 305 260 306 Ok(connection.connection_to_value(conn)) ··· 332 378 _ -> option.None 333 379 } 334 380 381 + // Extract where argument 382 + let where = case schema.get_argument(ctx, "where") { 383 + option.Some(where_value) -> { 384 + let parsed = where_input.parse_where_clause(where_value) 385 + case where_input.is_clause_empty(parsed) { 386 + True -> option.None 387 + False -> option.Some(parsed) 388 + } 389 + } 390 + _ -> option.None 391 + } 392 + 335 393 PaginationParams( 336 394 first: first, 337 395 after: after, 338 396 last: last, 339 397 before: before, 340 398 sort_by: sort_by, 399 + where: where, 341 400 ) 342 401 } 343 402 ··· 378 437 _ -> Error(Nil) 379 438 } 380 439 } 440 + 441 + /// Extract blob data from AT Protocol format and convert to Blob type format 442 + /// AT Protocol blob format: 443 + /// { 444 + /// "ref": {"$link": "bafyrei..."}, 445 + /// "mimeType": "image/jpeg", 446 + /// "size": 12345 447 + /// } 448 + /// Blob type expects: 449 + /// { 450 + /// "ref": "bafyrei...", 451 + /// "mime_type": "image/jpeg", 452 + /// "size": 12345, 453 + /// "did": "did:plc:..." 454 + /// } 455 + fn extract_blob_data( 456 + ctx: schema.Context, 457 + field_name: String, 458 + ) -> Result(value.Value, Nil) { 459 + case ctx.data { 460 + option.Some(value.Object(fields)) -> { 461 + // First get the DID from the top-level context 462 + let did = case list.key_find(fields, "did") { 463 + Ok(value.String(d)) -> d 464 + _ -> "" 465 + } 466 + 467 + // Then get the blob object from value.{field_name} 468 + case list.key_find(fields, "value") { 469 + Ok(value.Object(nested_fields)) -> { 470 + case list.key_find(nested_fields, field_name) { 471 + Ok(value.Object(blob_fields)) -> { 472 + // Extract ref from {"$link": "cid..."} 473 + let ref = case list.key_find(blob_fields, "ref") { 474 + Ok(value.Object(ref_obj)) -> { 475 + case list.key_find(ref_obj, "$link") { 476 + Ok(value.String(cid)) -> cid 477 + _ -> "" 478 + } 479 + } 480 + _ -> "" 481 + } 482 + 483 + // Extract mimeType 484 + let mime_type = case list.key_find(blob_fields, "mimeType") { 485 + Ok(value.String(mt)) -> mt 486 + _ -> "image/jpeg" 487 + } 488 + 489 + // Extract size 490 + let size = case list.key_find(blob_fields, "size") { 491 + Ok(value.Int(s)) -> s 492 + _ -> 0 493 + } 494 + 495 + // Return blob data in format expected by Blob type resolvers 496 + Ok(value.Object([ 497 + #("ref", value.String(ref)), 498 + #("mime_type", value.String(mime_type)), 499 + #("size", value.Int(size)), 500 + #("did", value.String(did)), 501 + ])) 502 + } 503 + _ -> Error(Nil) 504 + } 505 + } 506 + _ -> Error(Nil) 507 + } 508 + } 509 + _ -> Error(Nil) 510 + } 511 + }
+3 -2
lexicon_graphql/src/lexicon_graphql/type_mapper.gleam
··· 5 5 /// 6 6 /// Based on the Elixir implementation but adapted for the pure Gleam GraphQL library. 7 7 import graphql/schema 8 + import lexicon_graphql/blob_type 8 9 9 10 /// Maps a lexicon type string to a GraphQL Type. 10 11 /// ··· 23 24 "boolean" -> schema.boolean_type() 24 25 "number" -> schema.float_type() 25 26 26 - // Binary/blob types - map to String (base64 or URL) 27 - "blob" -> schema.string_type() 27 + // Binary/blob types 28 + "blob" -> blob_type.create_blob_type() 28 29 "bytes" -> schema.string_type() 29 30 "cid-link" -> schema.string_type() 30 31
+172
lexicon_graphql/src/lexicon_graphql/where_input.gleam
··· 1 + /// GraphQL Where Input Parser 2 + /// 3 + /// Provides parsing functions to convert GraphQL values to intermediate where clause types. 4 + /// These are simple value types that can be passed to the database layer for SQL generation. 5 + 6 + import gleam/dict.{type Dict} 7 + import gleam/list 8 + import gleam/option.{type Option, None, Some} 9 + import graphql/value 10 + 11 + /// Simple value type that can represent strings, ints, or other primitives 12 + pub type WhereValue { 13 + StringValue(String) 14 + IntValue(Int) 15 + BoolValue(Bool) 16 + } 17 + 18 + /// Intermediate representation of a where condition (no SQL types) 19 + pub type WhereCondition { 20 + WhereCondition( 21 + eq: Option(WhereValue), 22 + in_values: Option(List(WhereValue)), 23 + contains: Option(String), 24 + gt: Option(WhereValue), 25 + gte: Option(WhereValue), 26 + lt: Option(WhereValue), 27 + lte: Option(WhereValue), 28 + ) 29 + } 30 + 31 + /// Intermediate representation of a where clause (no SQL types) 32 + pub type WhereClause { 33 + WhereClause( 34 + conditions: Dict(String, WhereCondition), 35 + and: Option(List(WhereClause)), 36 + or: Option(List(WhereClause)), 37 + ) 38 + } 39 + 40 + /// Parse a GraphQL filter value into a WhereCondition 41 + pub fn parse_condition(filter_value: value.Value) -> WhereCondition { 42 + case filter_value { 43 + value.Object(fields) -> { 44 + let eq = case list.key_find(fields, "eq") { 45 + Ok(value.String(s)) -> Some(StringValue(s)) 46 + Ok(value.Int(i)) -> Some(IntValue(i)) 47 + Ok(value.Boolean(b)) -> Some(BoolValue(b)) 48 + _ -> None 49 + } 50 + 51 + let in_values = case list.key_find(fields, "in") { 52 + Ok(value.List(items)) -> { 53 + let values = 54 + list.filter_map(items, fn(item) { 55 + case item { 56 + value.String(s) -> Ok(StringValue(s)) 57 + value.Int(i) -> Ok(IntValue(i)) 58 + value.Boolean(b) -> Ok(BoolValue(b)) 59 + _ -> Error(Nil) 60 + } 61 + }) 62 + case values { 63 + [] -> None 64 + _ -> Some(values) 65 + } 66 + } 67 + _ -> None 68 + } 69 + 70 + let contains = case list.key_find(fields, "contains") { 71 + Ok(value.String(s)) -> Some(s) 72 + _ -> None 73 + } 74 + 75 + let gt = case list.key_find(fields, "gt") { 76 + Ok(value.String(s)) -> Some(StringValue(s)) 77 + Ok(value.Int(i)) -> Some(IntValue(i)) 78 + _ -> None 79 + } 80 + 81 + let gte = case list.key_find(fields, "gte") { 82 + Ok(value.String(s)) -> Some(StringValue(s)) 83 + Ok(value.Int(i)) -> Some(IntValue(i)) 84 + _ -> None 85 + } 86 + 87 + let lt = case list.key_find(fields, "lt") { 88 + Ok(value.String(s)) -> Some(StringValue(s)) 89 + Ok(value.Int(i)) -> Some(IntValue(i)) 90 + _ -> None 91 + } 92 + 93 + let lte = case list.key_find(fields, "lte") { 94 + Ok(value.String(s)) -> Some(StringValue(s)) 95 + Ok(value.Int(i)) -> Some(IntValue(i)) 96 + _ -> None 97 + } 98 + 99 + WhereCondition( 100 + eq: eq, 101 + in_values: in_values, 102 + contains: contains, 103 + gt: gt, 104 + gte: gte, 105 + lt: lt, 106 + lte: lte, 107 + ) 108 + } 109 + _ -> WhereCondition( 110 + eq: None, 111 + in_values: None, 112 + contains: None, 113 + gt: None, 114 + gte: None, 115 + lt: None, 116 + lte: None, 117 + ) 118 + } 119 + } 120 + 121 + /// Parse a GraphQL where object into a WhereClause 122 + pub fn parse_where_clause(where_value: value.Value) -> WhereClause { 123 + case where_value { 124 + value.Object(fields) -> { 125 + // Extract field conditions (not and/or) 126 + let field_conditions = 127 + list.filter_map(fields, fn(field) { 128 + let #(field_name, field_value) = field 129 + case field_name { 130 + "and" | "or" -> Error(Nil) 131 + _ -> Ok(#(field_name, parse_condition(field_value))) 132 + } 133 + }) 134 + 135 + // Extract nested AND clauses 136 + let and_clauses = case list.key_find(fields, "and") { 137 + Ok(value.List(items)) -> { 138 + let clauses = list.map(items, parse_where_clause) 139 + case clauses { 140 + [] -> None 141 + _ -> Some(clauses) 142 + } 143 + } 144 + _ -> None 145 + } 146 + 147 + // Extract nested OR clauses 148 + let or_clauses = case list.key_find(fields, "or") { 149 + Ok(value.List(items)) -> { 150 + let clauses = list.map(items, parse_where_clause) 151 + case clauses { 152 + [] -> None 153 + _ -> Some(clauses) 154 + } 155 + } 156 + _ -> None 157 + } 158 + 159 + WhereClause( 160 + conditions: dict.from_list(field_conditions), 161 + and: and_clauses, 162 + or: or_clauses, 163 + ) 164 + } 165 + _ -> WhereClause(conditions: dict.new(), and: None, or: None) 166 + } 167 + } 168 + 169 + /// Check if a where clause is empty 170 + pub fn is_clause_empty(clause: WhereClause) -> Bool { 171 + dict.is_empty(clause.conditions) && clause.and == None && clause.or == None 172 + }
+183
lexicon_graphql/test/blob_type_test.gleam
··· 1 + /// Tests for Blob Type 2 + /// 3 + /// Tests the Blob GraphQL object type with ref, mimeType, size, and url fields 4 + import gleam/dict 5 + import gleam/option.{Some} 6 + import gleeunit/should 7 + import graphql/schema 8 + import graphql/value 9 + import lexicon_graphql/blob_type 10 + 11 + pub fn create_blob_type_test() { 12 + let blob_type = blob_type.create_blob_type() 13 + 14 + // Verify it's an object type named "Blob" 15 + schema.type_name(blob_type) 16 + |> should.equal("Blob") 17 + } 18 + 19 + pub fn blob_type_has_ref_field_test() { 20 + let blob_type = blob_type.create_blob_type() 21 + 22 + // Verify the type has a "ref" field 23 + blob_type 24 + |> blob_type.has_field("ref") 25 + |> should.be_true() 26 + } 27 + 28 + pub fn blob_type_has_mime_type_field_test() { 29 + let blob_type = blob_type.create_blob_type() 30 + 31 + // Verify the type has a "mimeType" field 32 + blob_type 33 + |> blob_type.has_field("mimeType") 34 + |> should.be_true() 35 + } 36 + 37 + pub fn blob_type_has_size_field_test() { 38 + let blob_type = blob_type.create_blob_type() 39 + 40 + // Verify the type has a "size" field 41 + blob_type 42 + |> blob_type.has_field("size") 43 + |> should.be_true() 44 + } 45 + 46 + pub fn blob_type_has_url_field_test() { 47 + let blob_type = blob_type.create_blob_type() 48 + 49 + // Verify the type has a "url" field 50 + blob_type 51 + |> blob_type.has_field("url") 52 + |> should.be_true() 53 + } 54 + 55 + pub fn blob_ref_field_resolver_test() { 56 + // Test that the ref field resolver returns the correct value 57 + let blob_data = value.Object([ 58 + #("ref", value.String("bafyreiabc123")), 59 + #("mime_type", value.String("image/jpeg")), 60 + #("size", value.Int(12345)), 61 + #("did", value.String("did:plc:test123")), 62 + ]) 63 + 64 + let ctx = schema.Context(Some(blob_data), dict.new()) 65 + let result = blob_type.resolve_ref(ctx) 66 + 67 + result 68 + |> should.be_ok() 69 + |> should.equal(value.String("bafyreiabc123")) 70 + } 71 + 72 + pub fn blob_mime_type_field_resolver_test() { 73 + // Test that the mimeType field resolver returns the correct value 74 + let blob_data = value.Object([ 75 + #("ref", value.String("bafyreiabc123")), 76 + #("mime_type", value.String("image/png")), 77 + #("size", value.Int(12345)), 78 + #("did", value.String("did:plc:test123")), 79 + ]) 80 + 81 + let ctx = schema.Context(Some(blob_data), dict.new()) 82 + let result = blob_type.resolve_mime_type(ctx) 83 + 84 + result 85 + |> should.be_ok() 86 + |> should.equal(value.String("image/png")) 87 + } 88 + 89 + pub fn blob_size_field_resolver_test() { 90 + // Test that the size field resolver returns the correct value 91 + let blob_data = value.Object([ 92 + #("ref", value.String("bafyreiabc123")), 93 + #("mime_type", value.String("image/jpeg")), 94 + #("size", value.Int(54321)), 95 + #("did", value.String("did:plc:test123")), 96 + ]) 97 + 98 + let ctx = schema.Context(Some(blob_data), dict.new()) 99 + let result = blob_type.resolve_size(ctx) 100 + 101 + result 102 + |> should.be_ok() 103 + |> should.equal(value.Int(54321)) 104 + } 105 + 106 + pub fn blob_url_field_resolver_with_default_preset_test() { 107 + // Test URL generation with default preset 108 + let blob_data = value.Object([ 109 + #("ref", value.String("bafyreiabc123")), 110 + #("mime_type", value.String("image/jpeg")), 111 + #("size", value.Int(12345)), 112 + #("did", value.String("did:plc:test123")), 113 + ]) 114 + 115 + let ctx = schema.Context(Some(blob_data), dict.new()) 116 + let result = blob_type.resolve_url(ctx) 117 + 118 + let expected_url = "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:test123/bafyreiabc123@jpeg" 119 + 120 + result 121 + |> should.be_ok() 122 + |> should.equal(value.String(expected_url)) 123 + } 124 + 125 + pub fn blob_url_field_resolver_with_avatar_preset_test() { 126 + // Test URL generation with avatar preset 127 + let blob_data = value.Object([ 128 + #("ref", value.String("bafyreiabc456")), 129 + #("mime_type", value.String("image/jpeg")), 130 + #("size", value.Int(12345)), 131 + #("did", value.String("did:plc:user789")), 132 + ]) 133 + 134 + let args = dict.from_list([#("preset", value.String("avatar"))]) 135 + let ctx = schema.Context(Some(blob_data), args) 136 + let result = blob_type.resolve_url(ctx) 137 + 138 + let expected_url = "https://cdn.bsky.app/img/avatar/plain/did:plc:user789/bafyreiabc456@jpeg" 139 + 140 + result 141 + |> should.be_ok() 142 + |> should.equal(value.String(expected_url)) 143 + } 144 + 145 + pub fn blob_url_field_resolver_with_banner_preset_test() { 146 + // Test URL generation with banner preset 147 + let blob_data = value.Object([ 148 + #("ref", value.String("bafyreiabc789")), 149 + #("mime_type", value.String("image/png")), 150 + #("size", value.Int(98765)), 151 + #("did", value.String("did:plc:banner123")), 152 + ]) 153 + 154 + let args = dict.from_list([#("preset", value.String("banner"))]) 155 + let ctx = schema.Context(Some(blob_data), args) 156 + let result = blob_type.resolve_url(ctx) 157 + 158 + let expected_url = "https://cdn.bsky.app/img/banner/plain/did:plc:banner123/bafyreiabc789@jpeg" 159 + 160 + result 161 + |> should.be_ok() 162 + |> should.equal(value.String(expected_url)) 163 + } 164 + 165 + pub fn blob_url_field_resolver_with_feed_thumbnail_preset_test() { 166 + // Test URL generation with feed_thumbnail preset 167 + let blob_data = value.Object([ 168 + #("ref", value.String("bafyreiathumbnail")), 169 + #("mime_type", value.String("image/jpeg")), 170 + #("size", value.Int(5000)), 171 + #("did", value.String("did:plc:thumb456")), 172 + ]) 173 + 174 + let args = dict.from_list([#("preset", value.String("feed_thumbnail"))]) 175 + let ctx = schema.Context(Some(blob_data), args) 176 + let result = blob_type.resolve_url(ctx) 177 + 178 + let expected_url = "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:thumb456/bafyreiathumbnail@jpeg" 179 + 180 + result 181 + |> should.be_ok() 182 + |> should.equal(value.String(expected_url)) 183 + }
lexicon_graphql/test/lexicon_graphql/lexicon_parser_test.gleam lexicon_graphql/test/lexicon_parser_test.gleam
lexicon_graphql/test/lexicon_graphql/nsid_test.gleam lexicon_graphql/test/nsid_test.gleam
lexicon_graphql/test/lexicon_graphql/ref_resolver_test.gleam lexicon_graphql/test/ref_resolver_test.gleam
-79
lexicon_graphql/test/lexicon_graphql/schema_builder_test.gleam
··· 1 - /// Tests for Schema Builder 2 - /// 3 - /// Builds GraphQL schemas from AT Protocol lexicon definitions 4 - import gleeunit/should 5 - import lexicon_graphql/schema_builder 6 - 7 - // Test building a schema from a simple lexicon 8 - pub fn build_simple_schema_test() { 9 - // Simple status lexicon with text field 10 - let lexicon = 11 - schema_builder.Lexicon( 12 - id: "xyz.statusphere.status", 13 - defs: schema_builder.Defs( 14 - main: schema_builder.RecordDef(type_: "record", properties: [ 15 - #("text", schema_builder.Property("string", False)), 16 - #("createdAt", schema_builder.Property("string", True)), 17 - ]), 18 - ), 19 - ) 20 - 21 - let result = schema_builder.build_schema([lexicon]) 22 - 23 - // Should successfully build a schema 24 - should.be_ok(result) 25 - } 26 - 27 - // Test building schema with multiple lexicons 28 - pub fn build_schema_with_multiple_lexicons_test() { 29 - let status_lexicon = 30 - schema_builder.Lexicon( 31 - id: "xyz.statusphere.status", 32 - defs: schema_builder.Defs( 33 - main: schema_builder.RecordDef(type_: "record", properties: [ 34 - #("text", schema_builder.Property("string", False)), 35 - ]), 36 - ), 37 - ) 38 - 39 - let profile_lexicon = 40 - schema_builder.Lexicon( 41 - id: "xyz.statusphere.profile", 42 - defs: schema_builder.Defs( 43 - main: schema_builder.RecordDef(type_: "record", properties: [ 44 - #("displayName", schema_builder.Property("string", False)), 45 - ]), 46 - ), 47 - ) 48 - 49 - let result = schema_builder.build_schema([status_lexicon, profile_lexicon]) 50 - 51 - should.be_ok(result) 52 - } 53 - 54 - // Test that the schema has correct type names 55 - pub fn schema_has_correct_type_names_test() { 56 - let lexicon = 57 - schema_builder.Lexicon( 58 - id: "app.bsky.feed.post", 59 - defs: schema_builder.Defs( 60 - main: schema_builder.RecordDef(type_: "record", properties: [ 61 - #("text", schema_builder.Property("string", True)), 62 - ]), 63 - ), 64 - ) 65 - 66 - let result = schema_builder.build_schema([lexicon]) 67 - 68 - should.be_ok(result) 69 - // Type name should be AppBskyFeedPost (PascalCase from NSID) 70 - // Field name should be appBskyFeedPost (camelCase from NSID) 71 - } 72 - 73 - // Test empty lexicon list 74 - pub fn build_schema_with_empty_list_test() { 75 - let result = schema_builder.build_schema([]) 76 - 77 - // Should return error for empty lexicon list 78 - should.be_error(result) 79 - }
-161
lexicon_graphql/test/lexicon_graphql/sorting_test.gleam
··· 1 - /// Tests for sortBy schema generation 2 - /// 3 - /// These tests verify that the GraphQL schema is generated correctly with: 4 - /// - Custom SortFieldEnum for each record type 5 - /// - SortFieldInput InputObject type 6 - /// - sortBy argument on connection fields 7 - import gleam/list 8 - import gleam/option.{None, Some} 9 - import gleeunit/should 10 - import graphql/schema 11 - import lexicon_graphql/db_schema_builder 12 - import lexicon_graphql/schema_builder 13 - 14 - // Create a simple test schema 15 - fn create_test_schema() -> schema.Schema { 16 - // Mock fetcher that returns empty results (we're only testing schema generation) 17 - let fetcher = fn(_collection, _params) { Ok(#([], None, False, False)) } 18 - 19 - // Create a lexicon for xyz.statusphere.status 20 - let lexicon = 21 - schema_builder.Lexicon( 22 - "xyz.statusphere.status", 23 - schema_builder.Defs( 24 - schema_builder.RecordDef("record", [ 25 - #("status", schema_builder.Property("string", False)), 26 - #("createdAt", schema_builder.Property("string", False)), 27 - ]), 28 - ), 29 - ) 30 - 31 - case db_schema_builder.build_schema_with_fetcher([lexicon], fetcher) { 32 - Ok(s) -> s 33 - Error(_) -> panic as "Failed to build test schema" 34 - } 35 - } 36 - 37 - // Test: Schema has the query type with connection field 38 - pub fn test_schema_has_connection_field() { 39 - let test_schema = create_test_schema() 40 - let query_type = schema.query_type(test_schema) 41 - 42 - // Verify the query type has the xyzStatusphereStatus field 43 - case schema.get_field(query_type, "xyzStatusphereStatus") { 44 - None -> should.be_true(False) 45 - Some(field) -> { 46 - // Verify field name 47 - should.equal(schema.field_name(field), "xyzStatusphereStatus") 48 - 49 - // Verify it has arguments 50 - let args = schema.field_arguments(field) 51 - should.be_true(args != []) 52 - } 53 - } 54 - } 55 - 56 - // Test: Connection field has sortBy argument 57 - pub fn test_connection_has_sortby_argument() { 58 - let test_schema = create_test_schema() 59 - let query_type = schema.query_type(test_schema) 60 - 61 - case schema.get_field(query_type, "xyzStatusphereStatus") { 62 - None -> should.be_true(False) 63 - Some(field) -> { 64 - let args = schema.field_arguments(field) 65 - 66 - // Find sortBy argument 67 - let sortby_arg = 68 - list.find(args, fn(arg) { schema.argument_name(arg) == "sortBy" }) 69 - 70 - case sortby_arg { 71 - Error(_) -> should.be_true(False) 72 - Ok(arg) -> { 73 - should.equal(schema.argument_name(arg), "sortBy") 74 - 75 - // Verify it's a list type 76 - let arg_type = schema.argument_type(arg) 77 - should.be_true(schema.is_list(arg_type)) 78 - } 79 - } 80 - } 81 - } 82 - } 83 - 84 - // Test: Connection field has first/after arguments (forward pagination) 85 - pub fn test_connection_has_pagination_arguments() { 86 - let test_schema = create_test_schema() 87 - let query_type = schema.query_type(test_schema) 88 - 89 - case schema.get_field(query_type, "xyzStatusphereStatus") { 90 - None -> should.be_true(False) 91 - Some(field) -> { 92 - let args = schema.field_arguments(field) 93 - let arg_names = list.map(args, schema.argument_name) 94 - 95 - // Verify we have pagination arguments 96 - should.be_true(list.contains(arg_names, "first")) 97 - should.be_true(list.contains(arg_names, "after")) 98 - should.be_true(list.contains(arg_names, "last")) 99 - should.be_true(list.contains(arg_names, "before")) 100 - should.be_true(list.contains(arg_names, "sortBy")) 101 - } 102 - } 103 - } 104 - 105 - // Test: Query type has correct field for the lexicon 106 - pub fn test_query_type_has_lexicon_field() { 107 - let test_schema = create_test_schema() 108 - let query_type = schema.query_type(test_schema) 109 - 110 - // The field name should be camelCase version of the NSID 111 - let field = schema.get_field(query_type, "xyzStatusphereStatus") 112 - 113 - case field { 114 - None -> should.be_true(False) 115 - Some(_) -> should.be_true(True) 116 - } 117 - } 118 - 119 - // Test: Multiple lexicons create multiple fields with distinct sort enums 120 - pub fn test_multiple_lexicons_create_distinct_fields() { 121 - let fetcher = fn(_collection, _params) { Ok(#([], None, False, False)) } 122 - 123 - let lexicon1 = 124 - schema_builder.Lexicon( 125 - "xyz.statusphere.status", 126 - schema_builder.Defs( 127 - schema_builder.RecordDef("record", [ 128 - #("status", schema_builder.Property("string", False)), 129 - #("createdAt", schema_builder.Property("string", False)), 130 - ]), 131 - ), 132 - ) 133 - 134 - let lexicon2 = 135 - schema_builder.Lexicon( 136 - "app.bsky.feed.post", 137 - schema_builder.Defs( 138 - schema_builder.RecordDef("record", [ 139 - #("text", schema_builder.Property("string", False)), 140 - #("createdAt", schema_builder.Property("string", False)), 141 - ]), 142 - ), 143 - ) 144 - 145 - case db_schema_builder.build_schema_with_fetcher([lexicon1, lexicon2], fetcher) { 146 - Ok(test_schema) -> { 147 - let query_type = schema.query_type(test_schema) 148 - 149 - // Verify both fields exist 150 - let field1 = schema.get_field(query_type, "xyzStatusphereStatus") 151 - let field2 = schema.get_field(query_type, "appBskyFeedPost") 152 - 153 - case field1, field2 { 154 - None, _ -> should.be_true(False) 155 - _, None -> should.be_true(False) 156 - Some(_), Some(_) -> should.be_true(True) 157 - } 158 - } 159 - Error(_) -> should.be_true(False) 160 - } 161 - }
+5 -3
lexicon_graphql/test/lexicon_graphql/type_mapper_test.gleam lexicon_graphql/test/type_mapper_test.gleam
··· 33 33 } 34 34 35 35 pub fn map_blob_type_test() { 36 - // Blob types map to String (URL or base64) 37 - type_mapper.map_type("blob") 38 - |> should.equal(schema.string_type()) 36 + // Blob types map to Blob object type with ref, mimeType, size, and url fields 37 + let blob_type = type_mapper.map_type("blob") 38 + 39 + schema.type_name(blob_type) 40 + |> should.equal("Blob") 39 41 } 40 42 41 43 pub fn map_bytes_type_test() {
-8
lexicon_graphql/test/lexicon_graphql_test.gleam
··· 3 3 pub fn main() -> Nil { 4 4 gleeunit.main() 5 5 } 6 - 7 - // gleeunit test functions end in `_test` 8 - pub fn hello_world_test() { 9 - let name = "Joe" 10 - let greeting = "Hello, " <> name <> "!" 11 - 12 - assert greeting == "Hello, Joe!" 13 - }
+134
lexicon_graphql/test/schema_builder_test.gleam
··· 1 + /// Snapshot tests for Schema Builder 2 + /// 3 + /// Tests GraphQL schema generation from AT Protocol lexicon definitions 4 + /// Uses birdie to capture and verify the generated schemas 5 + 6 + import birdie 7 + import gleeunit/should 8 + import graphql/introspection 9 + import graphql/schema 10 + import graphql/sdl 11 + import lexicon_graphql/schema_builder 12 + 13 + // Test building a schema from a simple lexicon 14 + pub fn simple_schema_snapshot_test() { 15 + // Simple status lexicon with text field 16 + let lexicon = 17 + schema_builder.Lexicon( 18 + id: "xyz.statusphere.status", 19 + defs: schema_builder.Defs( 20 + main: schema_builder.RecordDef(type_: "record", properties: [ 21 + #("text", schema_builder.Property("string", False)), 22 + #("createdAt", schema_builder.Property("string", True)), 23 + ]), 24 + ), 25 + ) 26 + 27 + case schema_builder.build_schema([lexicon]) { 28 + Ok(s) -> { 29 + let query_type = schema.query_type(s) 30 + let serialized = sdl.print_type(query_type) 31 + birdie.snap( 32 + title: "Simple status record schema", 33 + content: serialized, 34 + ) 35 + } 36 + Error(_) -> should.fail() 37 + } 38 + } 39 + 40 + // Test building schema with multiple lexicons 41 + pub fn multiple_lexicons_snapshot_test() { 42 + let status_lexicon = 43 + schema_builder.Lexicon( 44 + id: "xyz.statusphere.status", 45 + defs: schema_builder.Defs( 46 + main: schema_builder.RecordDef(type_: "record", properties: [ 47 + #("text", schema_builder.Property("string", False)), 48 + ]), 49 + ), 50 + ) 51 + 52 + let profile_lexicon = 53 + schema_builder.Lexicon( 54 + id: "xyz.statusphere.profile", 55 + defs: schema_builder.Defs( 56 + main: schema_builder.RecordDef(type_: "record", properties: [ 57 + #("displayName", schema_builder.Property("string", False)), 58 + ]), 59 + ), 60 + ) 61 + 62 + case schema_builder.build_schema([status_lexicon, profile_lexicon]) { 63 + Ok(s) -> { 64 + let query_type = schema.query_type(s) 65 + let serialized = sdl.print_type(query_type) 66 + birdie.snap( 67 + title: "Schema with multiple record types", 68 + content: serialized, 69 + ) 70 + } 71 + Error(_) -> should.fail() 72 + } 73 + } 74 + 75 + // Test that the schema has correct type names from NSID 76 + pub fn correct_type_names_snapshot_test() { 77 + let lexicon = 78 + schema_builder.Lexicon( 79 + id: "app.bsky.feed.post", 80 + defs: schema_builder.Defs( 81 + main: schema_builder.RecordDef(type_: "record", properties: [ 82 + #("text", schema_builder.Property("string", True)), 83 + #("replyCount", schema_builder.Property("integer", False)), 84 + ]), 85 + ), 86 + ) 87 + 88 + case schema_builder.build_schema([lexicon]) { 89 + Ok(s) -> { 90 + let query_type = schema.query_type(s) 91 + let serialized = sdl.print_type(query_type) 92 + birdie.snap( 93 + title: "Schema showing PascalCase type name and camelCase field name from NSID", 94 + content: serialized, 95 + ) 96 + } 97 + Error(_) -> should.fail() 98 + } 99 + } 100 + 101 + // Test empty lexicon list (keep as unit test) 102 + pub fn build_schema_with_empty_list_test() { 103 + let result = schema_builder.build_schema([]) 104 + 105 + // Should return error for empty lexicon list 106 + should.be_error(result) 107 + } 108 + 109 + // Comprehensive test showing ALL generated types 110 + pub fn simple_schema_all_types_snapshot_test() { 111 + let lexicon = 112 + schema_builder.Lexicon( 113 + id: "xyz.statusphere.status", 114 + defs: schema_builder.Defs( 115 + main: schema_builder.RecordDef(type_: "record", properties: [ 116 + #("text", schema_builder.Property("string", False)), 117 + #("createdAt", schema_builder.Property("string", True)), 118 + ]), 119 + ), 120 + ) 121 + 122 + case schema_builder.build_schema([lexicon]) { 123 + Ok(s) -> { 124 + // Use introspection to get ALL types in the schema 125 + let all_types = introspection.get_all_schema_types(s) 126 + let serialized = sdl.print_types(all_types) 127 + birdie.snap( 128 + title: "All types generated for simple status record", 129 + content: serialized, 130 + ) 131 + } 132 + Error(_) -> should.fail() 133 + } 134 + }
+180
lexicon_graphql/test/sorting_test.gleam
··· 1 + /// Snapshot tests for sortBy schema generation 2 + /// 3 + /// Tests verify that the GraphQL schema is generated correctly with: 4 + /// - Custom SortFieldEnum for each record type 5 + /// - SortFieldInput InputObject type 6 + /// - sortBy argument on connection fields 7 + /// - Pagination arguments (first, after, last, before) 8 + /// 9 + /// Uses birdie to capture and verify the generated schemas 10 + 11 + import birdie 12 + import gleam/list 13 + import gleam/option.{Some} 14 + import gleeunit/should 15 + import graphql/introspection 16 + import graphql/schema 17 + import graphql/sdl 18 + import lexicon_graphql/db_schema_builder 19 + import lexicon_graphql/schema_builder 20 + 21 + // Helper to create a test schema with a mock fetcher 22 + fn create_test_schema_from_lexicons( 23 + lexicons: List(schema_builder.Lexicon), 24 + ) -> schema.Schema { 25 + // Mock fetcher that returns empty results (we're only testing schema generation) 26 + let fetcher = fn(_collection, _params) { Ok(#([], option.None, False, False)) } 27 + 28 + case db_schema_builder.build_schema_with_fetcher(lexicons, fetcher) { 29 + Ok(s) -> s 30 + Error(_) -> panic as "Failed to build test schema" 31 + } 32 + } 33 + 34 + // Test: Single lexicon creates connection field with sortBy 35 + pub fn single_lexicon_with_sorting_snapshot_test() { 36 + let lexicon = 37 + schema_builder.Lexicon( 38 + "xyz.statusphere.status", 39 + schema_builder.Defs( 40 + schema_builder.RecordDef("record", [ 41 + #("status", schema_builder.Property("string", False)), 42 + #("createdAt", schema_builder.Property("string", False)), 43 + ]), 44 + ), 45 + ) 46 + 47 + let test_schema = create_test_schema_from_lexicons([lexicon]) 48 + let query_type = schema.query_type(test_schema) 49 + 50 + let serialized = sdl.print_type(query_type) 51 + 52 + birdie.snap( 53 + title: "Query type with connection field and sortBy argument", 54 + content: serialized, 55 + ) 56 + } 57 + 58 + // Test: Multiple lexicons create distinct fields with separate sort enums 59 + pub fn multiple_lexicons_with_distinct_sort_enums_snapshot_test() { 60 + let lexicon1 = 61 + schema_builder.Lexicon( 62 + "xyz.statusphere.status", 63 + schema_builder.Defs( 64 + schema_builder.RecordDef("record", [ 65 + #("status", schema_builder.Property("string", False)), 66 + #("createdAt", schema_builder.Property("string", False)), 67 + ]), 68 + ), 69 + ) 70 + 71 + let lexicon2 = 72 + schema_builder.Lexicon( 73 + "app.bsky.feed.post", 74 + schema_builder.Defs( 75 + schema_builder.RecordDef("record", [ 76 + #("text", schema_builder.Property("string", False)), 77 + #("likeCount", schema_builder.Property("integer", False)), 78 + ]), 79 + ), 80 + ) 81 + 82 + let test_schema = create_test_schema_from_lexicons([lexicon1, lexicon2]) 83 + let query_type = schema.query_type(test_schema) 84 + 85 + let serialized = sdl.print_type(query_type) 86 + 87 + birdie.snap( 88 + title: "Query type with multiple connection fields and distinct sort enums", 89 + content: serialized, 90 + ) 91 + } 92 + 93 + // Unit test: Verify sortBy argument is a list type 94 + pub fn sortby_argument_is_list_type_test() { 95 + let lexicon = 96 + schema_builder.Lexicon( 97 + "xyz.statusphere.status", 98 + schema_builder.Defs( 99 + schema_builder.RecordDef("record", [ 100 + #("status", schema_builder.Property("string", False)), 101 + ]), 102 + ), 103 + ) 104 + 105 + let test_schema = create_test_schema_from_lexicons([lexicon]) 106 + let query_type = schema.query_type(test_schema) 107 + 108 + case schema.get_field(query_type, "xyzStatusphereStatus") { 109 + Some(field) -> { 110 + let args = schema.field_arguments(field) 111 + let sortby_arg = 112 + list.find(args, fn(arg) { schema.argument_name(arg) == "sortBy" }) 113 + 114 + case sortby_arg { 115 + Ok(arg) -> { 116 + let arg_type = schema.argument_type(arg) 117 + should.be_true(schema.is_list(arg_type)) 118 + } 119 + Error(_) -> should.fail() 120 + } 121 + } 122 + option.None -> should.fail() 123 + } 124 + } 125 + 126 + // Unit test: Verify connection has all pagination arguments 127 + pub fn connection_has_all_pagination_arguments_test() { 128 + let lexicon = 129 + schema_builder.Lexicon( 130 + "xyz.statusphere.status", 131 + schema_builder.Defs( 132 + schema_builder.RecordDef("record", [ 133 + #("status", schema_builder.Property("string", False)), 134 + ]), 135 + ), 136 + ) 137 + 138 + let test_schema = create_test_schema_from_lexicons([lexicon]) 139 + let query_type = schema.query_type(test_schema) 140 + 141 + case schema.get_field(query_type, "xyzStatusphereStatus") { 142 + Some(field) -> { 143 + let args = schema.field_arguments(field) 144 + let arg_names = list.map(args, schema.argument_name) 145 + 146 + // Verify we have all pagination arguments 147 + should.be_true(list.contains(arg_names, "first")) 148 + should.be_true(list.contains(arg_names, "after")) 149 + should.be_true(list.contains(arg_names, "last")) 150 + should.be_true(list.contains(arg_names, "before")) 151 + should.be_true(list.contains(arg_names, "sortBy")) 152 + } 153 + option.None -> should.fail() 154 + } 155 + } 156 + 157 + // Comprehensive test showing ALL generated types for db_schema_builder 158 + pub fn db_schema_all_types_snapshot_test() { 159 + let lexicon = 160 + schema_builder.Lexicon( 161 + "xyz.statusphere.status", 162 + schema_builder.Defs( 163 + schema_builder.RecordDef("record", [ 164 + #("text", schema_builder.Property("string", False)), 165 + #("createdAt", schema_builder.Property("string", False)), 166 + ]), 167 + ), 168 + ) 169 + 170 + let test_schema = create_test_schema_from_lexicons([lexicon]) 171 + 172 + // Use introspection to get ALL types in the schema 173 + let all_types = introspection.get_all_schema_types(test_schema) 174 + let serialized = sdl.print_types(all_types) 175 + 176 + birdie.snap( 177 + title: "All types generated by db_schema_builder including Connection, Edge, PageInfo, SortField enum, WhereInput, etc.", 178 + content: serialized, 179 + ) 180 + }
+489
lexicon_graphql/test/where_input_test.gleam
··· 1 + /// Tests for GraphQL where input parsing 2 + /// 3 + /// Tests the parsing of GraphQL values into WhereClause structures 4 + 5 + import gleam/dict 6 + import gleam/list 7 + import gleam/option.{None, Some} 8 + import gleeunit 9 + import gleeunit/should 10 + import graphql/value 11 + import lexicon_graphql/where_input 12 + 13 + pub fn main() { 14 + gleeunit.main() 15 + } 16 + 17 + // ===== Basic Operator Tests ===== 18 + 19 + pub fn parse_eq_operator_test() { 20 + // { field: { eq: "value" } } 21 + let condition_value = value.Object([#("eq", value.String("test_value"))]) 22 + let where_value = value.Object([#("field", condition_value)]) 23 + 24 + let result = where_input.parse_where_clause(where_value) 25 + 26 + // Check that we got a condition for "field" 27 + case dict.get(result.conditions, "field") { 28 + Ok(condition) -> { 29 + case condition.eq { 30 + Some(where_input.StringValue("test_value")) -> should.be_true(True) 31 + _ -> should.fail() 32 + } 33 + } 34 + Error(_) -> should.fail() 35 + } 36 + } 37 + 38 + pub fn parse_in_operator_test() { 39 + // { status: { in: ["active", "pending"] } } 40 + let condition_value = 41 + value.Object([ 42 + #( 43 + "in", 44 + value.List([value.String("active"), value.String("pending")]), 45 + ), 46 + ]) 47 + let where_value = value.Object([#("status", condition_value)]) 48 + 49 + let result = where_input.parse_where_clause(where_value) 50 + 51 + case dict.get(result.conditions, "status") { 52 + Ok(condition) -> { 53 + case condition.in_values { 54 + Some(values) -> { 55 + list.length(values) |> should.equal(2) 56 + // Check first value 57 + case list.first(values) { 58 + Ok(where_input.StringValue("active")) -> should.be_true(True) 59 + _ -> should.fail() 60 + } 61 + } 62 + None -> should.fail() 63 + } 64 + } 65 + Error(_) -> should.fail() 66 + } 67 + } 68 + 69 + pub fn parse_contains_operator_test() { 70 + // { text: { contains: "hello" } } 71 + let condition_value = value.Object([#("contains", value.String("hello"))]) 72 + let where_value = value.Object([#("text", condition_value)]) 73 + 74 + let result = where_input.parse_where_clause(where_value) 75 + 76 + case dict.get(result.conditions, "text") { 77 + Ok(condition) -> { 78 + case condition.contains { 79 + Some("hello") -> should.be_true(True) 80 + _ -> should.fail() 81 + } 82 + } 83 + Error(_) -> should.fail() 84 + } 85 + } 86 + 87 + pub fn parse_gt_operator_test() { 88 + // { age: { gt: 18 } } 89 + let condition_value = value.Object([#("gt", value.Int(18))]) 90 + let where_value = value.Object([#("age", condition_value)]) 91 + 92 + let result = where_input.parse_where_clause(where_value) 93 + 94 + case dict.get(result.conditions, "age") { 95 + Ok(condition) -> { 96 + case condition.gt { 97 + Some(where_input.IntValue(18)) -> should.be_true(True) 98 + _ -> should.fail() 99 + } 100 + } 101 + Error(_) -> should.fail() 102 + } 103 + } 104 + 105 + pub fn parse_gte_operator_test() { 106 + // { age: { gte: 21 } } 107 + let condition_value = value.Object([#("gte", value.Int(21))]) 108 + let where_value = value.Object([#("age", condition_value)]) 109 + 110 + let result = where_input.parse_where_clause(where_value) 111 + 112 + case dict.get(result.conditions, "age") { 113 + Ok(condition) -> { 114 + case condition.gte { 115 + Some(where_input.IntValue(21)) -> should.be_true(True) 116 + _ -> should.fail() 117 + } 118 + } 119 + Error(_) -> should.fail() 120 + } 121 + } 122 + 123 + pub fn parse_lt_operator_test() { 124 + // { price: { lt: 100 } } 125 + let condition_value = value.Object([#("lt", value.Int(100))]) 126 + let where_value = value.Object([#("price", condition_value)]) 127 + 128 + let result = where_input.parse_where_clause(where_value) 129 + 130 + case dict.get(result.conditions, "price") { 131 + Ok(condition) -> { 132 + case condition.lt { 133 + Some(where_input.IntValue(100)) -> should.be_true(True) 134 + _ -> should.fail() 135 + } 136 + } 137 + Error(_) -> should.fail() 138 + } 139 + } 140 + 141 + pub fn parse_lte_operator_test() { 142 + // { count: { lte: 50 } } 143 + let condition_value = value.Object([#("lte", value.Int(50))]) 144 + let where_value = value.Object([#("count", condition_value)]) 145 + 146 + let result = where_input.parse_where_clause(where_value) 147 + 148 + case dict.get(result.conditions, "count") { 149 + Ok(condition) -> { 150 + case condition.lte { 151 + Some(where_input.IntValue(50)) -> should.be_true(True) 152 + _ -> should.fail() 153 + } 154 + } 155 + Error(_) -> should.fail() 156 + } 157 + } 158 + 159 + // ===== Multiple Operators on Same Field ===== 160 + 161 + pub fn parse_range_query_test() { 162 + // { age: { gte: 18, lte: 65 } } 163 + let condition_value = 164 + value.Object([#("gte", value.Int(18)), #("lte", value.Int(65))]) 165 + let where_value = value.Object([#("age", condition_value)]) 166 + 167 + let result = where_input.parse_where_clause(where_value) 168 + 169 + case dict.get(result.conditions, "age") { 170 + Ok(condition) -> { 171 + // Check both operators are present 172 + case condition.gte, condition.lte { 173 + Some(where_input.IntValue(18)), Some(where_input.IntValue(65)) -> 174 + should.be_true(True) 175 + _, _ -> should.fail() 176 + } 177 + } 178 + Error(_) -> should.fail() 179 + } 180 + } 181 + 182 + // ===== Multiple Fields ===== 183 + 184 + pub fn parse_multiple_fields_test() { 185 + // { name: { eq: "alice" }, age: { gt: 18 } } 186 + let name_condition = value.Object([#("eq", value.String("alice"))]) 187 + let age_condition = value.Object([#("gt", value.Int(18))]) 188 + 189 + let where_value = 190 + value.Object([#("name", name_condition), #("age", age_condition)]) 191 + 192 + let result = where_input.parse_where_clause(where_value) 193 + 194 + // Check we have 2 conditions 195 + dict.size(result.conditions) |> should.equal(2) 196 + 197 + // Check name condition 198 + case dict.get(result.conditions, "name") { 199 + Ok(condition) -> { 200 + case condition.eq { 201 + Some(where_input.StringValue("alice")) -> should.be_true(True) 202 + _ -> should.fail() 203 + } 204 + } 205 + Error(_) -> should.fail() 206 + } 207 + 208 + // Check age condition 209 + case dict.get(result.conditions, "age") { 210 + Ok(condition) -> { 211 + case condition.gt { 212 + Some(where_input.IntValue(18)) -> should.be_true(True) 213 + _ -> should.fail() 214 + } 215 + } 216 + Error(_) -> should.fail() 217 + } 218 + } 219 + 220 + // ===== AND Logic Tests ===== 221 + 222 + pub fn parse_simple_and_test() { 223 + // { and: [{ name: { eq: "alice" } }, { age: { gt: 18 } }] } 224 + let name_condition = value.Object([#("eq", value.String("alice"))]) 225 + let age_condition = value.Object([#("gt", value.Int(18))]) 226 + 227 + let name_clause = value.Object([#("name", name_condition)]) 228 + let age_clause = value.Object([#("age", age_condition)]) 229 + 230 + let where_value = value.Object([#("and", value.List([name_clause, age_clause]))]) 231 + 232 + let result = where_input.parse_where_clause(where_value) 233 + 234 + // Check AND is present 235 + case result.and { 236 + Some(and_clauses) -> { 237 + list.length(and_clauses) |> should.equal(2) 238 + } 239 + None -> should.fail() 240 + } 241 + } 242 + 243 + pub fn parse_nested_and_test() { 244 + // { and: [{ and: [{ field: { eq: "value" } }] }] } 245 + let inner_condition = value.Object([#("eq", value.String("value"))]) 246 + let inner_clause = value.Object([#("field", inner_condition)]) 247 + let middle_clause = value.Object([#("and", value.List([inner_clause]))]) 248 + let outer_clause = value.Object([#("and", value.List([middle_clause]))]) 249 + 250 + let result = where_input.parse_where_clause(outer_clause) 251 + 252 + // Check nested structure 253 + case result.and { 254 + Some(outer_and) -> { 255 + list.length(outer_and) |> should.equal(1) 256 + // Check first clause has nested AND 257 + case list.first(outer_and) { 258 + Ok(middle) -> { 259 + case middle.and { 260 + Some(inner_and) -> { 261 + list.length(inner_and) |> should.equal(1) 262 + } 263 + None -> should.fail() 264 + } 265 + } 266 + Error(_) -> should.fail() 267 + } 268 + } 269 + None -> should.fail() 270 + } 271 + } 272 + 273 + // ===== OR Logic Tests ===== 274 + 275 + pub fn parse_simple_or_test() { 276 + // { or: [{ status: { eq: "active" } }, { status: { eq: "pending" } }] } 277 + let active_condition = value.Object([#("eq", value.String("active"))]) 278 + let pending_condition = value.Object([#("eq", value.String("pending"))]) 279 + 280 + let active_clause = value.Object([#("status", active_condition)]) 281 + let pending_clause = value.Object([#("status", pending_condition)]) 282 + 283 + let where_value = 284 + value.Object([#("or", value.List([active_clause, pending_clause]))]) 285 + 286 + let result = where_input.parse_where_clause(where_value) 287 + 288 + // Check OR is present 289 + case result.or { 290 + Some(or_clauses) -> { 291 + list.length(or_clauses) |> should.equal(2) 292 + } 293 + None -> should.fail() 294 + } 295 + } 296 + 297 + pub fn parse_nested_or_test() { 298 + // { or: [{ or: [{ field: { eq: "value" } }] }] } 299 + let inner_condition = value.Object([#("eq", value.String("value"))]) 300 + let inner_clause = value.Object([#("field", inner_condition)]) 301 + let middle_clause = value.Object([#("or", value.List([inner_clause]))]) 302 + let outer_clause = value.Object([#("or", value.List([middle_clause]))]) 303 + 304 + let result = where_input.parse_where_clause(outer_clause) 305 + 306 + // Check nested structure 307 + case result.or { 308 + Some(outer_or) -> { 309 + list.length(outer_or) |> should.equal(1) 310 + // Check first clause has nested OR 311 + case list.first(outer_or) { 312 + Ok(middle) -> { 313 + case middle.or { 314 + Some(inner_or) -> { 315 + list.length(inner_or) |> should.equal(1) 316 + } 317 + None -> should.fail() 318 + } 319 + } 320 + Error(_) -> should.fail() 321 + } 322 + } 323 + None -> should.fail() 324 + } 325 + } 326 + 327 + // ===== Mixed AND/OR Tests ===== 328 + 329 + pub fn parse_and_or_mixed_test() { 330 + // { 331 + // and: [ 332 + // { or: [{ a: { eq: "1" } }, { b: { eq: "2" } }] }, 333 + // { c: { eq: "3" } } 334 + // ] 335 + // } 336 + let a_condition = value.Object([#("eq", value.String("1"))]) 337 + let b_condition = value.Object([#("eq", value.String("2"))]) 338 + let c_condition = value.Object([#("eq", value.String("3"))]) 339 + 340 + let a_clause = value.Object([#("a", a_condition)]) 341 + let b_clause = value.Object([#("b", b_condition)]) 342 + let c_clause = value.Object([#("c", c_condition)]) 343 + 344 + let or_clause = value.Object([#("or", value.List([a_clause, b_clause]))]) 345 + let where_value = value.Object([#("and", value.List([or_clause, c_clause]))]) 346 + 347 + let result = where_input.parse_where_clause(where_value) 348 + 349 + // Check structure 350 + case result.and { 351 + Some(and_clauses) -> { 352 + list.length(and_clauses) |> should.equal(2) 353 + 354 + // First clause should have OR 355 + case list.first(and_clauses) { 356 + Ok(first_clause) -> { 357 + case first_clause.or { 358 + Some(or_clauses) -> { 359 + list.length(or_clauses) |> should.equal(2) 360 + } 361 + None -> should.fail() 362 + } 363 + } 364 + Error(_) -> should.fail() 365 + } 366 + } 367 + None -> should.fail() 368 + } 369 + } 370 + 371 + // ===== Edge Cases ===== 372 + 373 + pub fn parse_empty_object_test() { 374 + let where_value = value.Object([]) 375 + let result = where_input.parse_where_clause(where_value) 376 + 377 + where_input.is_clause_empty(result) |> should.be_true 378 + } 379 + 380 + pub fn parse_empty_and_list_test() { 381 + let where_value = value.Object([#("and", value.List([]))]) 382 + let result = where_input.parse_where_clause(where_value) 383 + 384 + // Empty AND list should result in None or empty list 385 + case result.and { 386 + None -> should.be_true(True) 387 + Some(clauses) -> list.is_empty(clauses) |> should.be_true 388 + } 389 + } 390 + 391 + pub fn parse_empty_or_list_test() { 392 + let where_value = value.Object([#("or", value.List([]))]) 393 + let result = where_input.parse_where_clause(where_value) 394 + 395 + // Empty OR list should result in None or empty list 396 + case result.or { 397 + None -> should.be_true(True) 398 + Some(clauses) -> list.is_empty(clauses) |> should.be_true 399 + } 400 + } 401 + 402 + pub fn parse_invalid_value_test() { 403 + // Pass a non-object value 404 + let where_value = value.String("not an object") 405 + let result = where_input.parse_where_clause(where_value) 406 + 407 + // Should return empty clause 408 + where_input.is_clause_empty(result) |> should.be_true 409 + } 410 + 411 + pub fn parse_boolean_value_test() { 412 + // { active: { eq: true } } 413 + let condition_value = value.Object([#("eq", value.Boolean(True))]) 414 + let where_value = value.Object([#("active", condition_value)]) 415 + 416 + let result = where_input.parse_where_clause(where_value) 417 + 418 + case dict.get(result.conditions, "active") { 419 + Ok(condition) -> { 420 + case condition.eq { 421 + Some(where_input.BoolValue(True)) -> should.be_true(True) 422 + _ -> should.fail() 423 + } 424 + } 425 + Error(_) -> should.fail() 426 + } 427 + } 428 + 429 + pub fn parse_mixed_types_in_values_test() { 430 + // { ids: { in: [1, 2, 3] } } 431 + let condition_value = 432 + value.Object([ 433 + #("in", value.List([value.Int(1), value.Int(2), value.Int(3)])), 434 + ]) 435 + let where_value = value.Object([#("ids", condition_value)]) 436 + 437 + let result = where_input.parse_where_clause(where_value) 438 + 439 + case dict.get(result.conditions, "ids") { 440 + Ok(condition) -> { 441 + case condition.in_values { 442 + Some(values) -> { 443 + list.length(values) |> should.equal(3) 444 + } 445 + None -> should.fail() 446 + } 447 + } 448 + Error(_) -> should.fail() 449 + } 450 + } 451 + 452 + // ===== Complex Real-World Examples ===== 453 + 454 + pub fn parse_complex_user_filter_test() { 455 + // Real-world example: find users matching complex criteria 456 + // { 457 + // and: [ 458 + // { or: [{ status: { eq: "active" } }, { status: { eq: "premium" } }] }, 459 + // { age: { gte: 18, lte: 65 } }, 460 + // { name: { contains: "smith" } } 461 + // ] 462 + // } 463 + 464 + let active_cond = value.Object([#("eq", value.String("active"))]) 465 + let premium_cond = value.Object([#("eq", value.String("premium"))]) 466 + let status_active = value.Object([#("status", active_cond)]) 467 + let status_premium = value.Object([#("status", premium_cond)]) 468 + let or_status = 469 + value.Object([#("or", value.List([status_active, status_premium]))]) 470 + 471 + let age_cond = value.Object([#("gte", value.Int(18)), #("lte", value.Int(65))]) 472 + let age_clause = value.Object([#("age", age_cond)]) 473 + 474 + let name_cond = value.Object([#("contains", value.String("smith"))]) 475 + let name_clause = value.Object([#("name", name_cond)]) 476 + 477 + let where_value = 478 + value.Object([#("and", value.List([or_status, age_clause, name_clause]))]) 479 + 480 + let result = where_input.parse_where_clause(where_value) 481 + 482 + // Verify structure 483 + case result.and { 484 + Some(and_clauses) -> { 485 + list.length(and_clauses) |> should.equal(3) 486 + } 487 + None -> should.fail() 488 + } 489 + }
+169
lexicon_graphql/test/where_schema_test.gleam
··· 1 + /// Snapshot tests for WhereInput schema generation 2 + /// 3 + /// Uses birdie to capture and verify the GraphQL schema types 4 + /// generated for where input filtering 5 + 6 + import birdie 7 + import lexicon_graphql/connection 8 + import gleeunit 9 + import graphql/schema 10 + import graphql/sdl 11 + 12 + pub fn main() { 13 + gleeunit.main() 14 + } 15 + 16 + // ===== Simple Record Type ===== 17 + 18 + pub fn simple_post_where_input_snapshot_test() { 19 + // Simulate a simple post lexicon with basic fields 20 + let field_names = ["uri", "cid", "text", "createdAt"] 21 + 22 + let where_input_type = 23 + connection.build_where_input_type("AppBskyFeedPost", field_names) 24 + 25 + let serialized = sdl.print_type(where_input_type) 26 + 27 + birdie.snap( 28 + title: "Simple Post WhereInput with basic fields", 29 + content: serialized, 30 + ) 31 + } 32 + 33 + // ===== Complex Record Type ===== 34 + 35 + pub fn complex_post_where_input_snapshot_test() { 36 + // Simulate a complex post with many fields 37 + let field_names = [ 38 + "uri", "cid", "text", "createdAt", "indexedAt", "likeCount", "replyCount", 39 + "repostCount", "author", 40 + ] 41 + 42 + let where_input_type = 43 + connection.build_where_input_type("AppBskyFeedPost", field_names) 44 + 45 + let serialized = sdl.print_type(where_input_type) 46 + 47 + birdie.snap( 48 + title: "Complex Post WhereInput with many fields", 49 + content: serialized, 50 + ) 51 + } 52 + 53 + // ===== Profile Record Type ===== 54 + 55 + pub fn profile_where_input_snapshot_test() { 56 + // Simulate actor profile lexicon 57 + let field_names = ["did", "handle", "displayName", "description", "avatar"] 58 + 59 + let where_input_type = 60 + connection.build_where_input_type("AppBskyActorProfile", field_names) 61 + 62 + let serialized = sdl.print_type(where_input_type) 63 + 64 + birdie.snap( 65 + title: "Profile WhereInput with actor fields", 66 + content: serialized, 67 + ) 68 + } 69 + 70 + // ===== Empty Record (Edge Case) ===== 71 + 72 + pub fn empty_where_input_snapshot_test() { 73 + // Edge case: record with no filterable fields 74 + let field_names = [] 75 + 76 + let where_input_type = 77 + connection.build_where_input_type("EmptyRecord", field_names) 78 + 79 + let serialized = sdl.print_type(where_input_type) 80 + 81 + birdie.snap( 82 + title: "Empty WhereInput with only AND/OR fields", 83 + content: serialized, 84 + ) 85 + } 86 + 87 + // ===== Field Condition Type ===== 88 + 89 + pub fn field_condition_snapshot_test() { 90 + // Test the FieldCondition type that defines operators 91 + let condition_type = 92 + connection.build_where_condition_input_type( 93 + "AppBskyFeedPost", 94 + schema.string_type(), 95 + ) 96 + 97 + let serialized = sdl.print_type(condition_type) 98 + 99 + birdie.snap( 100 + title: "Field condition type with all operators", 101 + content: serialized, 102 + ) 103 + } 104 + 105 + // ===== Related Types Together ===== 106 + 107 + pub fn related_types_snapshot_test() { 108 + // Test serializing both the WhereInput and its FieldCondition together 109 + let field_names = ["text", "createdAt", "likes"] 110 + 111 + let where_input_type = 112 + connection.build_where_input_type("TestRecord", field_names) 113 + 114 + let condition_type = 115 + connection.build_where_condition_input_type( 116 + "TestRecord", 117 + schema.string_type(), 118 + ) 119 + 120 + let serialized = 121 + sdl.print_types([where_input_type, condition_type]) 122 + 123 + birdie.snap( 124 + title: "WhereInput and FieldCondition types together", 125 + content: serialized, 126 + ) 127 + } 128 + 129 + // ===== Different Record Types Have Different Names ===== 130 + 131 + pub fn multiple_record_types_snapshot_test() { 132 + // Verify different record types generate different WhereInput names 133 + let post_where = 134 + connection.build_where_input_type("AppBskyFeedPost", [ 135 + "text", 136 + "createdAt", 137 + ]) 138 + 139 + let profile_where = 140 + connection.build_where_input_type("AppBskyActorProfile", [ 141 + "displayName", 142 + "description", 143 + ]) 144 + 145 + let serialized = 146 + sdl.print_types([post_where, profile_where]) 147 + 148 + birdie.snap( 149 + title: "Multiple record types with different WhereInput names", 150 + content: serialized, 151 + ) 152 + } 153 + 154 + // ===== Verify Recursive AND/OR Fields ===== 155 + 156 + pub fn recursive_and_or_fields_snapshot_test() { 157 + // This test specifically highlights the recursive AND/OR structure 158 + let field_names = ["name", "status"] 159 + 160 + let where_input_type = 161 + connection.build_where_input_type("RecursiveTest", field_names) 162 + 163 + let serialized = sdl.print_type(where_input_type) 164 + 165 + birdie.snap( 166 + title: "WhereInput showing recursive AND/OR fields", 167 + content: serialized, 168 + ) 169 + }
+2
server/.env.example
··· 6 6 HOST=127.0.0.1 7 7 # PORT: The port to listen on 8 8 PORT=8000 9 + # DOMAIN_AUTHORITY: The domain authority for this instance 10 + DOMAIN_AUTHORITY=xyz.statusphere.example.com 9 11 10 12 # Database Configuration 11 13 DATABASE_URL=quickslice.db
+5
server/lexicons.json
··· 1 + { 2 + "lexicons": [ 3 + "app.bsky.actor.profile" 4 + ] 5 + }
+1 -1
server/manifest.toml
··· 20 20 { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 21 21 { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, 22 22 { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 23 - { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, 23 + { name = "gleeunit", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "CD701726CBCE5588B375D157B4391CFD0F2F134CD12D9B6998A395484DE05C58" }, 24 24 { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, 25 25 { name = "goose", version = "1.1.0", build_tools = ["gleam"], requirements = ["ezstd", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "gun"], otp_app = "goose", source = "hex", outer_checksum = "6FF742DC1CFAC669537C490732F1552C70191A373F34FFB59B042E810BBA14E9" }, 26 26 { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" },
+74
server/priv/lexicons/app/bsky/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "key": "literal:self", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "avatar": { 12 + "type": "blob", 13 + "accept": [ 14 + "image/png", 15 + "image/jpeg" 16 + ], 17 + "maxSize": 1000000, 18 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 19 + }, 20 + "banner": { 21 + "type": "blob", 22 + "accept": [ 23 + "image/png", 24 + "image/jpeg" 25 + ], 26 + "maxSize": 1000000, 27 + "description": "Larger horizontal image to display behind profile view." 28 + }, 29 + "labels": { 30 + "refs": [ 31 + "com.atproto.label.defs#selfLabels" 32 + ], 33 + "type": "union", 34 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 35 + }, 36 + "website": { 37 + "type": "string", 38 + "format": "uri" 39 + }, 40 + "pronouns": { 41 + "type": "string", 42 + "maxLength": 200, 43 + "description": "Free-form pronouns text.", 44 + "maxGraphemes": 20 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + }, 50 + "pinnedPost": { 51 + "ref": "com.atproto.repo.strongRef", 52 + "type": "ref" 53 + }, 54 + "description": { 55 + "type": "string", 56 + "maxLength": 2560, 57 + "description": "Free-form profile description text.", 58 + "maxGraphemes": 256 59 + }, 60 + "displayName": { 61 + "type": "string", 62 + "maxLength": 640, 63 + "maxGraphemes": 64 64 + }, 65 + "joinedViaStarterPack": { 66 + "ref": "com.atproto.repo.strongRef", 67 + "type": "ref" 68 + } 69 + } 70 + }, 71 + "description": "A declaration of a Bluesky account profile." 72 + } 73 + } 74 + }
+192
server/priv/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "required": [ 8 + "src", 9 + "uri", 10 + "val", 11 + "cts" 12 + ], 13 + "properties": { 14 + "cid": { 15 + "type": "string", 16 + "format": "cid", 17 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 18 + }, 19 + "cts": { 20 + "type": "string", 21 + "format": "datetime", 22 + "description": "Timestamp when this label was created." 23 + }, 24 + "exp": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp at which this label expires (no longer applies)." 28 + }, 29 + "neg": { 30 + "type": "boolean", 31 + "description": "If true, this is a negation label, overwriting a previous label." 32 + }, 33 + "sig": { 34 + "type": "bytes", 35 + "description": "Signature of dag-cbor encoded label." 36 + }, 37 + "src": { 38 + "type": "string", 39 + "format": "did", 40 + "description": "DID of the actor who created this label." 41 + }, 42 + "uri": { 43 + "type": "string", 44 + "format": "uri", 45 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 46 + }, 47 + "val": { 48 + "type": "string", 49 + "maxLength": 128, 50 + "description": "The short string name of the value or type of this label." 51 + }, 52 + "ver": { 53 + "type": "integer", 54 + "description": "The AT Protocol version of the label object." 55 + } 56 + }, 57 + "description": "Metadata tag on an atproto resource (eg, repo or record)." 58 + }, 59 + "selfLabel": { 60 + "type": "object", 61 + "required": [ 62 + "val" 63 + ], 64 + "properties": { 65 + "val": { 66 + "type": "string", 67 + "maxLength": 128, 68 + "description": "The short string name of the value or type of this label." 69 + } 70 + }, 71 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." 72 + }, 73 + "labelValue": { 74 + "type": "string", 75 + "knownValues": [ 76 + "!hide", 77 + "!no-promote", 78 + "!warn", 79 + "!no-unauthenticated", 80 + "dmca-violation", 81 + "doxxing", 82 + "porn", 83 + "sexual", 84 + "nudity", 85 + "nsfl", 86 + "gore" 87 + ] 88 + }, 89 + "selfLabels": { 90 + "type": "object", 91 + "required": [ 92 + "values" 93 + ], 94 + "properties": { 95 + "values": { 96 + "type": "array", 97 + "items": { 98 + "ref": "#selfLabel", 99 + "type": "ref" 100 + }, 101 + "maxLength": 10 102 + } 103 + }, 104 + "description": "Metadata tags on an atproto record, published by the author within the record." 105 + }, 106 + "labelValueDefinition": { 107 + "type": "object", 108 + "required": [ 109 + "identifier", 110 + "severity", 111 + "blurs", 112 + "locales" 113 + ], 114 + "properties": { 115 + "blurs": { 116 + "type": "string", 117 + "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.", 118 + "knownValues": [ 119 + "content", 120 + "media", 121 + "none" 122 + ] 123 + }, 124 + "locales": { 125 + "type": "array", 126 + "items": { 127 + "ref": "#labelValueDefinitionStrings", 128 + "type": "ref" 129 + } 130 + }, 131 + "severity": { 132 + "type": "string", 133 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 134 + "knownValues": [ 135 + "inform", 136 + "alert", 137 + "none" 138 + ] 139 + }, 140 + "adultOnly": { 141 + "type": "boolean", 142 + "description": "Does the user need to have adult content enabled in order to configure this label?" 143 + }, 144 + "identifier": { 145 + "type": "string", 146 + "maxLength": 100, 147 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 148 + "maxGraphemes": 100 149 + }, 150 + "defaultSetting": { 151 + "type": "string", 152 + "default": "warn", 153 + "description": "The default setting for this label.", 154 + "knownValues": [ 155 + "ignore", 156 + "warn", 157 + "hide" 158 + ] 159 + } 160 + }, 161 + "description": "Declares a label value and its expected interpretations and behaviors." 162 + }, 163 + "labelValueDefinitionStrings": { 164 + "type": "object", 165 + "required": [ 166 + "lang", 167 + "name", 168 + "description" 169 + ], 170 + "properties": { 171 + "lang": { 172 + "type": "string", 173 + "format": "language", 174 + "description": "The code of the language these strings are written in." 175 + }, 176 + "name": { 177 + "type": "string", 178 + "maxLength": 640, 179 + "description": "A short human-readable name for the label.", 180 + "maxGraphemes": 64 181 + }, 182 + "description": { 183 + "type": "string", 184 + "maxLength": 100000, 185 + "description": "A longer description of what the label means and why it might be applied.", 186 + "maxGraphemes": 10000 187 + } 188 + }, 189 + "description": "Strings which describe the label in the UI, localized into a specific language." 190 + } 191 + } 192 + }
+24
server/priv/lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "uri", 10 + "cid" 11 + ], 12 + "properties": { 13 + "cid": { 14 + "type": "string", 15 + "format": "cid" 16 + }, 17 + "uri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + } 21 + } 22 + } 23 + } 24 + }
+13
server/src/backfill.gleam
··· 72 72 ) 73 73 } 74 74 75 + /// Check if an NSID matches the configured domain authority 76 + /// NSID format is like "com.example.post" where "com.example" is the authority 77 + pub fn nsid_matches_domain_authority(nsid: String) -> Bool { 78 + case envoy.get("DOMAIN_AUTHORITY") { 79 + Error(_) -> False 80 + Ok(domain_authority) -> { 81 + // NSID format: authority.name (e.g., "com.example.post") 82 + // We need to check if the NSID starts with the domain authority 83 + string.starts_with(nsid, domain_authority <> ".") 84 + } 85 + } 86 + } 87 + 75 88 /// Resolve a DID to get ATP data (PDS endpoint and handle) 76 89 pub fn resolve_did(did: String, plc_url: String) -> Result(AtprotoData, String) { 77 90 let url = plc_url <> "/" <> did
+243 -7
server/src/database.gleam
··· 7 7 import gleam/result 8 8 import gleam/string 9 9 import sqlight 10 + import where_clause 10 11 11 12 pub type Record { 12 13 Record( ··· 606 607 None -> [#("indexed_at", "desc")] 607 608 } 608 609 609 - // Build the ORDER BY clause 610 - let order_by_clause = build_order_by(sort_fields) 610 + // Build the ORDER BY clause (no joins in this function, so no prefix needed) 611 + let order_by_clause = build_order_by(sort_fields, False) 611 612 612 613 // Build WHERE clause parts 613 614 let where_parts = ["collection = ?"] ··· 704 705 Ok(#(final_records, next_cursor, has_next_page, has_previous_page)) 705 706 } 706 707 708 + /// Paginated query for records with cursor-based pagination AND where clause filtering 709 + /// 710 + /// Same as get_records_by_collection_paginated but with an additional where_clause parameter 711 + pub fn get_records_by_collection_paginated_with_where( 712 + conn: sqlight.Connection, 713 + collection: String, 714 + first: Option(Int), 715 + after: Option(String), 716 + last: Option(Int), 717 + before: Option(String), 718 + sort_by: Option(List(#(String, String))), 719 + where: Option(where_clause.WhereClause), 720 + ) -> Result(#(List(Record), Option(String), Bool, Bool), sqlight.Error) { 721 + // Validate pagination arguments 722 + let #(limit, is_forward, cursor_opt) = case first, last { 723 + Some(f), None -> #(f, True, after) 724 + None, Some(l) -> #(l, False, before) 725 + Some(f), Some(_) -> #(f, True, after) 726 + None, None -> #(50, True, None) 727 + } 728 + 729 + // Default sort order if not specified 730 + let sort_fields = case sort_by { 731 + Some(fields) -> fields 732 + None -> [#("indexed_at", "desc")] 733 + } 734 + 735 + // Check if we need to join with actor table 736 + let needs_actor_join = case where { 737 + Some(wc) -> where_clause.requires_actor_join(wc) 738 + None -> False 739 + } 740 + 741 + // Build the ORDER BY clause (with table prefix if doing a join) 742 + let order_by_clause = build_order_by(sort_fields, needs_actor_join) 743 + 744 + // Build FROM clause with optional LEFT JOIN 745 + let from_clause = case needs_actor_join { 746 + True -> "record LEFT JOIN actor ON record.did = actor.did" 747 + False -> "record" 748 + } 749 + 750 + // Build WHERE clause parts - start with collection filter 751 + let mut_where_parts = ["record.collection = ?"] 752 + let mut_bind_values = [sqlight.text(collection)] 753 + 754 + // Add where clause conditions if provided 755 + let #(where_parts, bind_values) = case where { 756 + Some(wc) -> { 757 + case where_clause.is_clause_empty(wc) { 758 + True -> #(mut_where_parts, mut_bind_values) 759 + False -> { 760 + let #(where_sql, where_params) = 761 + where_clause.build_where_sql(wc, needs_actor_join) 762 + let new_where = list.append(mut_where_parts, [where_sql]) 763 + let new_binds = list.append(mut_bind_values, where_params) 764 + #(new_where, new_binds) 765 + } 766 + } 767 + } 768 + None -> #(mut_where_parts, mut_bind_values) 769 + } 770 + 771 + // Add cursor condition if present 772 + let #(final_where_parts, final_bind_values) = case cursor_opt { 773 + Some(cursor_str) -> { 774 + case cursor.decode_cursor(cursor_str, sort_by) { 775 + Ok(decoded_cursor) -> { 776 + let #(cursor_where, cursor_params) = 777 + cursor.build_cursor_where_clause( 778 + decoded_cursor, 779 + sort_by, 780 + !is_forward, 781 + ) 782 + 783 + let new_where = list.append(where_parts, [cursor_where]) 784 + let new_binds = 785 + list.append( 786 + bind_values, 787 + list.map(cursor_params, sqlight.text), 788 + ) 789 + #(new_where, new_binds) 790 + } 791 + Error(_) -> #(where_parts, bind_values) 792 + } 793 + } 794 + None -> #(where_parts, bind_values) 795 + } 796 + 797 + // Fetch limit + 1 to detect if there are more pages 798 + let fetch_limit = limit + 1 799 + 800 + // Build the SQL query 801 + let sql = 802 + " 803 + SELECT record.uri, record.cid, record.did, record.collection, record.json, record.indexed_at 804 + FROM " 805 + <> from_clause 806 + <> " 807 + WHERE " 808 + <> string.join(final_where_parts, " AND ") 809 + <> " 810 + ORDER BY " 811 + <> order_by_clause 812 + <> " 813 + LIMIT " 814 + <> int.to_string(fetch_limit) 815 + 816 + // Execute query 817 + let decoder = { 818 + use uri <- decode.field(0, decode.string) 819 + use cid <- decode.field(1, decode.string) 820 + use did <- decode.field(2, decode.string) 821 + use collection <- decode.field(3, decode.string) 822 + use json <- decode.field(4, decode.string) 823 + use indexed_at <- decode.field(5, decode.string) 824 + decode.success(Record(uri:, cid:, did:, collection:, json:, indexed_at:)) 825 + } 826 + 827 + use records <- result.try(sqlight.query( 828 + sql, 829 + on: conn, 830 + with: final_bind_values, 831 + expecting: decoder, 832 + )) 833 + 834 + // Check if there are more results 835 + let has_more = list.length(records) > limit 836 + let final_records = case has_more { 837 + True -> list.take(records, limit) 838 + False -> records 839 + } 840 + 841 + // Calculate hasNextPage and hasPreviousPage 842 + let has_next_page = case is_forward { 843 + True -> has_more 844 + False -> option.is_some(cursor_opt) 845 + } 846 + 847 + let has_previous_page = case is_forward { 848 + True -> option.is_some(cursor_opt) 849 + False -> has_more 850 + } 851 + 852 + // Generate next cursor if there are more results 853 + let next_cursor = case has_more, list.last(final_records) { 854 + True, Ok(last_record) -> { 855 + let record_like = record_to_record_like(last_record) 856 + Some(cursor.generate_cursor_from_record(record_like, sort_by)) 857 + } 858 + _, _ -> None 859 + } 860 + 861 + Ok(#(final_records, next_cursor, has_next_page, has_previous_page)) 862 + } 863 + 864 + /// Gets the total count of records for a collection with optional where clause 865 + pub fn get_collection_count_with_where( 866 + conn: sqlight.Connection, 867 + collection: String, 868 + where: Option(where_clause.WhereClause), 869 + ) -> Result(Int, sqlight.Error) { 870 + // Check if we need to join with actor table 871 + let needs_actor_join = case where { 872 + Some(wc) -> where_clause.requires_actor_join(wc) 873 + None -> False 874 + } 875 + 876 + // Build FROM clause with optional LEFT JOIN 877 + let from_clause = case needs_actor_join { 878 + True -> "record LEFT JOIN actor ON record.did = actor.did" 879 + False -> "record" 880 + } 881 + 882 + // Build WHERE clause parts - start with collection filter 883 + let mut_where_parts = ["record.collection = ?"] 884 + let mut_bind_values = [sqlight.text(collection)] 885 + 886 + // Add where clause conditions if provided 887 + let #(where_parts, bind_values) = case where { 888 + Some(wc) -> { 889 + case where_clause.is_clause_empty(wc) { 890 + True -> #(mut_where_parts, mut_bind_values) 891 + False -> { 892 + let #(where_sql, where_params) = 893 + where_clause.build_where_sql(wc, needs_actor_join) 894 + let new_where = list.append(mut_where_parts, [where_sql]) 895 + let new_binds = list.append(mut_bind_values, where_params) 896 + #(new_where, new_binds) 897 + } 898 + } 899 + } 900 + None -> #(mut_where_parts, mut_bind_values) 901 + } 902 + 903 + // Build the SQL query 904 + let sql = 905 + " 906 + SELECT COUNT(*) as count 907 + FROM " 908 + <> from_clause 909 + <> " 910 + WHERE " 911 + <> string.join(where_parts, " AND ") 912 + 913 + // Execute query 914 + let decoder = { 915 + use count <- decode.field(0, decode.int) 916 + decode.success(count) 917 + } 918 + 919 + case sqlight.query(sql, on: conn, with: bind_values, expecting: decoder) { 920 + Ok([count]) -> Ok(count) 921 + Ok(_) -> Ok(0) 922 + Error(err) -> Error(err) 923 + } 924 + } 925 + 707 926 /// Converts a database Record to a cursor.RecordLike 708 927 pub fn record_to_record_like(record: Record) -> cursor.RecordLike { 709 928 cursor.RecordLike( ··· 717 936 } 718 937 719 938 /// Builds an ORDER BY clause from sort fields 720 - fn build_order_by(sort_fields: List(#(String, String))) -> String { 939 + /// use_table_prefix: if True, prefixes table columns with "record." for joins 940 + fn build_order_by( 941 + sort_fields: List(#(String, String)), 942 + use_table_prefix: Bool, 943 + ) -> String { 721 944 let order_parts = 722 945 list.map(sort_fields, fn(field) { 723 946 let #(field_name, direction) = field 947 + let table_prefix = case use_table_prefix { 948 + True -> "record." 949 + False -> "" 950 + } 724 951 let field_ref = case field_name { 725 - "uri" | "cid" | "did" | "collection" | "indexed_at" -> field_name 952 + "uri" | "cid" | "did" | "collection" | "indexed_at" -> 953 + table_prefix <> field_name 726 954 // For JSON fields, check if they look like dates and handle accordingly 727 955 "createdAt" | "indexedAt" -> { 728 956 // Use CASE to treat invalid dates as NULL for sorting 729 - let json_field = "json_extract(json, '$." <> field_name <> "')" 957 + let json_field = 958 + "json_extract(" <> table_prefix <> "json, '$." <> field_name <> "')" 730 959 "CASE 731 960 WHEN " <> json_field <> " IS NULL THEN NULL 732 961 WHEN datetime(" <> json_field <> ") IS NULL THEN NULL 733 962 ELSE " <> json_field <> " 734 963 END" 735 964 } 736 - _ -> "json_extract(json, '$." <> field_name <> "')" 965 + _ -> 966 + "json_extract(" <> table_prefix <> "json, '$." <> field_name <> "')" 737 967 } 738 968 let dir = case string.lowercase(direction) { 739 969 "asc" -> "ASC" ··· 744 974 }) 745 975 746 976 case list.is_empty(order_parts) { 747 - True -> "indexed_at DESC NULLS LAST" 977 + True -> { 978 + let prefix = case use_table_prefix { 979 + True -> "record." 980 + False -> "" 981 + } 982 + prefix <> "indexed_at DESC NULLS LAST" 983 + } 748 984 False -> string.join(order_parts, ", ") 749 985 } 750 986 }
+41 -5
server/src/graphql_gleam.gleam
··· 18 18 import lexicon_graphql/db_schema_builder 19 19 import lexicon_graphql/lexicon_parser 20 20 import sqlight 21 + import where_converter 21 22 22 23 /// Execute a GraphQL query against lexicons in the database 23 24 /// ··· 47 48 collection_nsid: String, 48 49 pagination_params: db_schema_builder.PaginationParams, 49 50 ) -> Result( 50 - #(List(#(value.Value, String)), option.Option(String), Bool, Bool), 51 + #( 52 + List(#(value.Value, String)), 53 + option.Option(String), 54 + Bool, 55 + Bool, 56 + option.Option(Int), 57 + ), 51 58 String, 52 59 ) { 60 + // Convert where clause from GraphQL types to SQL types 61 + let where_clause = case pagination_params.where { 62 + option.Some(graphql_where) -> 63 + option.Some(where_converter.convert_where_clause(graphql_where)) 64 + option.None -> option.None 65 + } 66 + 67 + // Get total count for this collection (with where filter if present) 68 + let total_count = 69 + database.get_collection_count_with_where( 70 + db, 71 + collection_nsid, 72 + where_clause, 73 + ) 74 + |> result.map(option.Some) 75 + |> result.unwrap(option.None) 76 + 53 77 // Fetch records from database for this collection with pagination 54 78 case 55 - database.get_records_by_collection_paginated( 79 + database.get_records_by_collection_paginated_with_where( 56 80 db, 57 81 collection_nsid, 58 82 pagination_params.first, ··· 60 84 pagination_params.last, 61 85 pagination_params.before, 62 86 pagination_params.sort_by, 87 + where_clause, 63 88 ) 64 89 { 65 - Error(_) -> Ok(#([], option.None, False, False)) 90 + Error(_) -> Ok(#([], option.None, False, False, option.None)) 66 91 // Return empty result on error 67 92 Ok(#(records, next_cursor, has_next_page, has_previous_page)) -> { 68 93 // Convert database records to GraphQL values with cursors 69 94 let graphql_records_with_cursors = 70 95 list.map(records, fn(record) { 71 - let graphql_value = record_to_graphql_value(record) 96 + let graphql_value = record_to_graphql_value(record, db) 72 97 // Generate cursor for this record 73 98 let record_cursor = 74 99 cursor.generate_cursor_from_record( ··· 82 107 next_cursor, 83 108 has_next_page, 84 109 has_previous_page, 110 + total_count, 85 111 )) 86 112 } 87 113 } ··· 112 138 /// Convert a database Record to a GraphQL value.Value 113 139 /// 114 140 /// Creates an Object with all the record metadata plus the parsed JSON value 115 - fn record_to_graphql_value(record: database.Record) -> value.Value { 141 + fn record_to_graphql_value( 142 + record: database.Record, 143 + db: sqlight.Connection, 144 + ) -> value.Value { 116 145 // Parse the record JSON and convert to GraphQL value 117 146 let value_object = case parse_json_to_value(record.json) { 118 147 Ok(val) -> val ··· 120 149 // Fallback to empty object on parse error 121 150 } 122 151 152 + // Look up actor handle from actor table 153 + let actor_handle = case database.get_actor(db, record.did) { 154 + Ok([actor, ..]) -> value.String(actor.handle) 155 + _ -> value.Null 156 + } 157 + 123 158 // Create the full record object with metadata and value 124 159 value.Object([ 125 160 #("uri", value.String(record.uri)), ··· 127 162 #("did", value.String(record.did)), 128 163 #("collection", value.String(record.collection)), 129 164 #("indexedAt", value.String(record.indexed_at)), 165 + #("actorHandle", actor_handle), 130 166 #("value", value_object), 131 167 ]) 132 168 }
+82 -5
server/src/importer.gleam
··· 30 30 " ✓ Found " <> string.inspect(list.length(file_paths)) <> " .json files", 31 31 ) 32 32 io.println("") 33 - io.println("📝 Validating and importing lexicons...") 33 + io.println("📝 Reading all lexicon files...") 34 34 35 - // Import each file 36 - let results = 35 + // Read all files first to get their content 36 + let file_contents = 37 37 file_paths 38 - |> list.map(fn(file_path) { import_single_lexicon(db, file_path) }) 38 + |> list.filter_map(fn(file_path) { 39 + case simplifile.read(file_path) { 40 + Ok(content) -> Ok(#(file_path, content)) 41 + Error(_) -> Error(Nil) 42 + } 43 + }) 44 + 45 + io.println("📝 Validating all lexicons together...") 46 + 47 + // Extract all JSON strings for validation 48 + let all_json_strings = list.map(file_contents, fn(pair) { pair.1 }) 49 + 50 + // Validate all schemas together (this allows cross-references to be resolved) 51 + let validation_result = case lexicon.validate_schemas(all_json_strings) { 52 + Ok(_) -> { 53 + io.println(" ✓ All lexicons validated successfully") 54 + Ok(Nil) 55 + } 56 + Error(err) -> { 57 + io.println_error( 58 + " ✗ Validation failed: " <> format_validation_error(err), 59 + ) 60 + Error("Validation failed") 61 + } 62 + } 63 + 64 + io.println("") 65 + io.println("📝 Importing lexicons to database...") 66 + 67 + // Import each file (skip individual validation since we already validated all together) 68 + let results = case validation_result { 69 + Error(_) -> { 70 + // If validation failed, don't import anything 71 + file_paths |> list.map(fn(_) { Error("Validation failed") }) 72 + } 73 + Ok(_) -> { 74 + // Validation succeeded, import each lexicon 75 + file_contents 76 + |> list.map(fn(pair) { 77 + let #(file_path, json_content) = pair 78 + import_validated_lexicon(db, file_path, json_content) 79 + }) 80 + } 81 + } 39 82 40 83 // Calculate stats 41 84 let total = list.length(results) ··· 157 200 string.inspect(error) 158 201 } 159 202 160 - /// Imports a single lexicon file 203 + /// Imports a single lexicon file (with validation) 161 204 pub fn import_single_lexicon( 162 205 conn: sqlight.Connection, 163 206 file_path: String, ··· 188 231 } 189 232 } 190 233 } 234 + 235 + /// Imports a lexicon that has already been validated 236 + /// Used when importing multiple lexicons that were validated together 237 + fn import_validated_lexicon( 238 + conn: sqlight.Connection, 239 + file_path: String, 240 + json_content: String, 241 + ) -> Result(String, String) { 242 + let file_name = case string.split(file_path, "/") |> list.last { 243 + Ok(name) -> name 244 + Error(_) -> file_path 245 + } 246 + 247 + case extract_lexicon_id(json_content) { 248 + Ok(lexicon_id) -> { 249 + case database.insert_lexicon(conn, lexicon_id, json_content) { 250 + Ok(_) -> { 251 + io.println(" ✓ " <> lexicon_id) 252 + Ok(lexicon_id) 253 + } 254 + Error(_) -> { 255 + let err_msg = file_name <> ": Database insertion failed" 256 + io.println(" ✗ " <> err_msg) 257 + Error(err_msg) 258 + } 259 + } 260 + } 261 + Error(err) -> { 262 + let err_msg = file_name <> ": " <> err 263 + io.println(" ✗ " <> err_msg) 264 + Error(err_msg) 265 + } 266 + } 267 + }
+119 -16
server/src/jetstream_consumer.gleam
··· 1 + import backfill 1 2 import database 2 3 import envoy 3 4 import event_handler 5 + import gleam/dynamic/decode 4 6 import gleam/erlang/process 5 7 import gleam/int 6 8 import gleam/io ··· 18 20 // Get all record-type lexicons from the database 19 21 case database.get_record_type_lexicons(db) { 20 22 Ok(lexicons) -> { 21 - let collection_ids = list.map(lexicons, fn(lex) { lex.id }) 23 + // Separate lexicons by domain authority 24 + let #(local_lexicons, external_lexicons) = 25 + lexicons 26 + |> list.partition(fn(lex) { backfill.nsid_matches_domain_authority(lex.id) }) 27 + 28 + let local_collection_ids = list.map(local_lexicons, fn(lex) { lex.id }) 29 + let external_collection_ids = 30 + list.map(external_lexicons, fn(lex) { lex.id }) 22 31 23 - case collection_ids { 32 + // For Jetstream, only subscribe to local collections 33 + // External collections will be filtered in the event handler based on known DIDs 34 + let wanted_collection_ids = local_collection_ids 35 + 36 + case wanted_collection_ids { 24 37 [] -> { 25 38 io.println( 26 - "⚠️ No record-type lexicons found - skipping Jetstream consumer", 39 + "⚠️ No local collections found - skipping Jetstream consumer", 27 40 ) 28 - io.println(" Import lexicons first to enable real-time indexing") 41 + io.println(" Import lexicons with matching domain authority first") 29 42 io.println("") 30 43 Ok(Nil) 31 44 } 32 45 _ -> { 33 46 io.println( 34 47 "📋 Listening to " 35 - <> int.to_string(list.length(collection_ids)) 36 - <> " collections:", 48 + <> int.to_string(list.length(local_collection_ids)) 49 + <> " local collection(s) (all DIDs):", 37 50 ) 38 - list.each(collection_ids, fn(col) { io.println(" - " <> col) }) 51 + list.each(local_collection_ids, fn(col) { io.println(" - " <> col) }) 52 + 53 + case external_collection_ids { 54 + [] -> Nil 55 + _ -> { 56 + io.println("") 57 + io.println( 58 + "📋 Tracking " 59 + <> int.to_string(list.length(external_collection_ids)) 60 + <> " external collection(s) (known DIDs only):", 61 + ) 62 + list.each(external_collection_ids, fn(col) { 63 + io.println(" - " <> col) 64 + }) 65 + } 66 + } 39 67 40 68 // Get Jetstream URL from environment variable or use default 41 69 let jetstream_url = case envoy.get("JETSTREAM_URL") { ··· 43 71 Error(_) -> "wss://jetstream2.us-east.bsky.network/subscribe" 44 72 } 45 73 46 - // Create Jetstream config with automatic retry 47 - let config = 74 + // Create Jetstream config for local collections (no DID filter - listen to all) 75 + let local_config = 48 76 goose.JetstreamConfig( 49 77 endpoint: jetstream_url, 50 - wanted_collections: collection_ids, 78 + wanted_collections: local_collection_ids, 51 79 wanted_dids: [], 52 80 cursor: option.None, 53 81 max_message_size_bytes: option.None, ··· 60 88 61 89 io.println("") 62 90 io.println("Connecting to Jetstream...") 63 - io.println(" Endpoint: " <> config.endpoint) 64 - io.println(" DID filter: All DIDs (no filter)") 65 - io.println("") 91 + io.println(" Endpoint: " <> jetstream_url) 92 + io.println( 93 + " Local collections: " 94 + <> int.to_string(list.length(local_collection_ids)) 95 + <> " (all DIDs)", 96 + ) 66 97 67 - // Start the Jetstream consumer (automatically retries on failure) 98 + // Start the local collections consumer 68 99 process.spawn_unlinked(fn() { 69 - goose.start_consumer(config, fn(event_json) { 100 + goose.start_consumer(local_config, fn(event_json) { 70 101 handle_jetstream_event(db, event_json) 71 102 }) 72 103 }) 73 104 74 - io.println("Jetstream consumer started") 105 + // If we have external collections, start a second consumer with DID filter 106 + case external_collection_ids { 107 + [] -> Nil 108 + _ -> { 109 + // Get all known DIDs from the database 110 + case get_all_known_dids(db) { 111 + Ok(known_dids) -> { 112 + case known_dids { 113 + [] -> { 114 + io.println( 115 + " External collections: " 116 + <> int.to_string(list.length(external_collection_ids)) 117 + <> " (0 known DIDs - skipping)", 118 + ) 119 + Nil 120 + } 121 + _ -> { 122 + let external_config = 123 + goose.JetstreamConfig( 124 + endpoint: jetstream_url, 125 + wanted_collections: external_collection_ids, 126 + wanted_dids: known_dids, 127 + cursor: option.None, 128 + max_message_size_bytes: option.None, 129 + compress: False, 130 + require_hello: False, 131 + max_backoff_seconds: 60, 132 + log_connection_events: True, 133 + log_retry_attempts: False, 134 + ) 135 + 136 + io.println( 137 + " External collections: " 138 + <> int.to_string(list.length(external_collection_ids)) 139 + <> " (" 140 + <> int.to_string(list.length(known_dids)) 141 + <> " known DIDs)", 142 + ) 143 + 144 + // Start the external collections consumer 145 + process.spawn_unlinked(fn() { 146 + goose.start_consumer(external_config, fn(event_json) { 147 + handle_jetstream_event(db, event_json) 148 + }) 149 + }) 150 + 151 + Nil 152 + } 153 + } 154 + } 155 + Error(_) -> { 156 + io.println( 157 + " External collections: Failed to fetch known DIDs - skipping", 158 + ) 159 + Nil 160 + } 161 + } 162 + } 163 + } 164 + 165 + io.println("") 166 + io.println("Jetstream consumer(s) started") 75 167 io.println("") 76 168 77 169 Ok(Nil) ··· 81 173 Error(err) -> { 82 174 Error("Failed to fetch lexicons: " <> string.inspect(err)) 83 175 } 176 + } 177 + } 178 + 179 + /// Get all known DIDs from the actor table 180 + fn get_all_known_dids(db: sqlight.Connection) -> Result(List(String), String) { 181 + let sql = "SELECT did FROM actor" 182 + 183 + case sqlight.query(sql, on: db, with: [], expecting: decode.at([0], decode.string)) 184 + { 185 + Ok(dids) -> Ok(dids) 186 + Error(err) -> Error("Failed to fetch DIDs: " <> string.inspect(err)) 84 187 } 85 188 } 86 189
+53 -6
server/src/server.gleam
··· 88 88 ) 89 89 } 90 90 _ -> { 91 - let collections = list.map(lexicons, fn(lex) { lex.id }) 91 + // Separate lexicons by domain authority 92 + let #(local_lexicons, external_lexicons) = 93 + lexicons 94 + |> list.partition(fn(lex) { 95 + backfill.nsid_matches_domain_authority(lex.id) 96 + }) 97 + 98 + let collections = list.map(local_lexicons, fn(lex) { lex.id }) 99 + let external_collections = 100 + list.map(external_lexicons, fn(lex) { lex.id }) 101 + 92 102 io.println( 93 103 "✓ Found " 94 104 <> int.to_string(list.length(collections)) 95 - <> " record-type collection(s):", 105 + <> " local collection(s):", 96 106 ) 97 107 list.each(collections, fn(col) { io.println(" - " <> col) }) 98 108 109 + case external_collections { 110 + [] -> Nil 111 + _ -> { 112 + io.println("") 113 + io.println( 114 + "✓ Found " 115 + <> int.to_string(list.length(external_collections)) 116 + <> " external collection(s):", 117 + ) 118 + list.each(external_collections, fn(col) { io.println(" - " <> col) }) 119 + } 120 + } 121 + 99 122 io.println("") 100 123 let config = backfill.default_config() 101 - backfill.backfill_collections([], collections, [], config, db) 124 + backfill.backfill_collections( 125 + [], 126 + collections, 127 + external_collections, 128 + config, 129 + db, 130 + ) 102 131 } 103 132 } 104 133 } ··· 286 315 )) 287 316 } 288 317 _ -> { 289 - let collections = list.map(lexicons, fn(lex) { lex.id }) 318 + // Separate lexicons by domain authority 319 + let #(collections, external_collections) = 320 + lexicons 321 + |> list.partition(fn(lex) { 322 + backfill.nsid_matches_domain_authority(lex.id) 323 + }) 324 + 325 + let collection_ids = list.map(collections, fn(lex) { lex.id }) 326 + let external_collection_ids = 327 + list.map(external_collections, fn(lex) { lex.id }) 328 + 290 329 // Run backfill in background process 291 330 let config = backfill.default_config() 292 331 process.spawn_unlinked(fn() { 293 - backfill.backfill_collections([], collections, [], config, db) 332 + backfill.backfill_collections( 333 + [], 334 + collection_ids, 335 + external_collection_ids, 336 + config, 337 + db, 338 + ) 294 339 }) 295 340 296 341 wisp.response(200) 297 342 |> wisp.set_header("content-type", "application/json") 298 343 |> wisp.set_body(wisp.Text( 299 344 "{\"status\": \"started\", \"collections\": " 300 - <> int.to_string(list.length(collections)) 345 + <> int.to_string(list.length(collection_ids)) 346 + <> ", \"external_collections\": " 347 + <> int.to_string(list.length(external_collection_ids)) 301 348 <> "}", 302 349 )) 303 350 }
+335
server/src/where_clause.gleam
··· 1 + import gleam/option.{type Option, None, Some} 2 + import gleam/dict.{type Dict} 3 + import gleam/list 4 + import gleam/string 5 + import gleam/result 6 + import sqlight 7 + 8 + /// Represents a single condition on a field with various comparison operators 9 + pub type WhereCondition { 10 + WhereCondition( 11 + eq: Option(sqlight.Value), 12 + in_values: Option(List(sqlight.Value)), 13 + contains: Option(String), 14 + gt: Option(sqlight.Value), 15 + gte: Option(sqlight.Value), 16 + lt: Option(sqlight.Value), 17 + lte: Option(sqlight.Value), 18 + ) 19 + } 20 + 21 + /// Represents a complete where clause with support for nested AND/OR logic 22 + pub type WhereClause { 23 + WhereClause( 24 + /// Field-level conditions (combined with AND) 25 + conditions: Dict(String, WhereCondition), 26 + /// Nested AND clauses - all must be true 27 + and: Option(List(WhereClause)), 28 + /// Nested OR clauses - at least one must be true 29 + or: Option(List(WhereClause)), 30 + ) 31 + } 32 + 33 + /// Creates an empty WhereCondition with all operators set to None 34 + pub fn empty_condition() -> WhereCondition { 35 + WhereCondition( 36 + eq: None, 37 + in_values: None, 38 + contains: None, 39 + gt: None, 40 + gte: None, 41 + lt: None, 42 + lte: None, 43 + ) 44 + } 45 + 46 + /// Creates an empty WhereClause with no conditions 47 + pub fn empty_clause() -> WhereClause { 48 + WhereClause(conditions: dict.new(), and: None, or: None) 49 + } 50 + 51 + /// Checks if a WhereCondition has any operators set 52 + pub fn is_condition_empty(condition: WhereCondition) -> Bool { 53 + case condition { 54 + WhereCondition( 55 + eq: None, 56 + in_values: None, 57 + contains: None, 58 + gt: None, 59 + gte: None, 60 + lt: None, 61 + lte: None, 62 + ) -> True 63 + _ -> False 64 + } 65 + } 66 + 67 + /// Checks if a WhereClause is empty (no conditions at all) 68 + pub fn is_clause_empty(clause: WhereClause) -> Bool { 69 + dict.is_empty(clause.conditions) 70 + && clause.and == None 71 + && clause.or == None 72 + } 73 + 74 + /// Checks if a WhereClause requires a join with the actor table 75 + pub fn requires_actor_join(clause: WhereClause) -> Bool { 76 + // Check if actorHandle is in the conditions 77 + let has_actor_handle = dict.has_key(clause.conditions, "actorHandle") 78 + 79 + // Check nested AND clauses 80 + let has_actor_in_and = case clause.and { 81 + Some(and_clauses) -> list.any(and_clauses, requires_actor_join) 82 + None -> False 83 + } 84 + 85 + // Check nested OR clauses 86 + let has_actor_in_or = case clause.or { 87 + Some(or_clauses) -> list.any(or_clauses, requires_actor_join) 88 + None -> False 89 + } 90 + 91 + has_actor_handle || has_actor_in_and || has_actor_in_or 92 + } 93 + 94 + // Table columns that should not use json_extract 95 + const table_columns = ["uri", "cid", "did", "collection", "indexed_at"] 96 + 97 + /// Determines if a field is a table column or a JSON field 98 + fn is_table_column(field: String) -> Bool { 99 + list.contains(table_columns, field) 100 + } 101 + 102 + /// Builds the SQL reference for a field (either table column or JSON path) 103 + /// If use_table_prefix is true, table columns are prefixed with "record." 104 + fn build_field_ref(field: String, use_table_prefix: Bool) -> String { 105 + case field { 106 + "actorHandle" -> "actor.handle" 107 + _ -> 108 + case is_table_column(field) { 109 + True -> 110 + case use_table_prefix { 111 + True -> "record." <> field 112 + False -> field 113 + } 114 + False -> { 115 + let table_name = case use_table_prefix { 116 + True -> "record." 117 + False -> "" 118 + } 119 + "json_extract(" <> table_name <> "json, '$." <> field <> "')" 120 + } 121 + } 122 + } 123 + } 124 + 125 + /// Builds SQL for a single condition on a field 126 + /// Returns a list of SQL strings and accumulated parameters 127 + fn build_single_condition( 128 + field: String, 129 + condition: WhereCondition, 130 + use_table_prefix: Bool, 131 + ) -> #(List(String), List(sqlight.Value)) { 132 + let field_ref = build_field_ref(field, use_table_prefix) 133 + let mut_sql_parts = [] 134 + let mut_params = [] 135 + 136 + // eq operator 137 + let #(sql_parts, params) = case condition.eq { 138 + Some(value) -> { 139 + #([field_ref <> " = ?", ..mut_sql_parts], [value, ..mut_params]) 140 + } 141 + None -> #(mut_sql_parts, mut_params) 142 + } 143 + let mut_sql_parts = sql_parts 144 + let mut_params = params 145 + 146 + // in operator 147 + let #(sql_parts, params) = case condition.in_values { 148 + Some(values) -> { 149 + case values { 150 + [] -> #(mut_sql_parts, mut_params) 151 + // Empty list - skip this condition 152 + _ -> { 153 + let placeholders = 154 + list.repeat("?", list.length(values)) 155 + |> string.join(", ") 156 + let sql = field_ref <> " IN (" <> placeholders <> ")" 157 + #([sql, ..mut_sql_parts], list.append(values, mut_params)) 158 + } 159 + } 160 + } 161 + None -> #(mut_sql_parts, mut_params) 162 + } 163 + let mut_sql_parts = sql_parts 164 + let mut_params = params 165 + 166 + // gt operator 167 + let #(sql_parts, params) = case condition.gt { 168 + Some(value) -> { 169 + #([field_ref <> " > ?", ..mut_sql_parts], [value, ..mut_params]) 170 + } 171 + None -> #(mut_sql_parts, mut_params) 172 + } 173 + let mut_sql_parts = sql_parts 174 + let mut_params = params 175 + 176 + // gte operator 177 + let #(sql_parts, params) = case condition.gte { 178 + Some(value) -> { 179 + #([field_ref <> " >= ?", ..mut_sql_parts], [value, ..mut_params]) 180 + } 181 + None -> #(mut_sql_parts, mut_params) 182 + } 183 + let mut_sql_parts = sql_parts 184 + let mut_params = params 185 + 186 + // lt operator 187 + let #(sql_parts, params) = case condition.lt { 188 + Some(value) -> { 189 + #([field_ref <> " < ?", ..mut_sql_parts], [value, ..mut_params]) 190 + } 191 + None -> #(mut_sql_parts, mut_params) 192 + } 193 + let mut_sql_parts = sql_parts 194 + let mut_params = params 195 + 196 + // lte operator 197 + let #(sql_parts, params) = case condition.lte { 198 + Some(value) -> { 199 + #([field_ref <> " <= ?", ..mut_sql_parts], [value, ..mut_params]) 200 + } 201 + None -> #(mut_sql_parts, mut_params) 202 + } 203 + let mut_sql_parts = sql_parts 204 + let mut_params = params 205 + 206 + // contains operator (case-insensitive LIKE) 207 + let #(sql_parts, params) = case condition.contains { 208 + Some(search_text) -> { 209 + let sql = field_ref <> " LIKE '%' || ? || '%' COLLATE NOCASE" 210 + #([sql, ..mut_sql_parts], [sqlight.text(search_text), ..mut_params]) 211 + } 212 + None -> #(mut_sql_parts, mut_params) 213 + } 214 + 215 + // Reverse to maintain correct order (we built backwards) 216 + #(list.reverse(sql_parts), list.reverse(params)) 217 + } 218 + 219 + /// Builds WHERE clause SQL from a WhereClause 220 + /// Returns tuple of (sql_string, parameters) 221 + /// use_table_prefix: if True, prefixes table columns with "record." for joins 222 + pub fn build_where_sql( 223 + clause: WhereClause, 224 + use_table_prefix: Bool, 225 + ) -> #(String, List(sqlight.Value)) { 226 + case is_clause_empty(clause) { 227 + True -> #("", []) 228 + False -> { 229 + let #(sql_parts, params) = 230 + build_where_clause_internal(clause, use_table_prefix) 231 + let sql = string.join(sql_parts, " AND ") 232 + #(sql, params) 233 + } 234 + } 235 + } 236 + 237 + /// Internal recursive function to build where clause parts 238 + fn build_where_clause_internal( 239 + clause: WhereClause, 240 + use_table_prefix: Bool, 241 + ) -> #(List(String), List(sqlight.Value)) { 242 + let mut_sql_parts = [] 243 + let mut_params = [] 244 + 245 + // Build conditions from field-level conditions 246 + let #(field_sql_parts, field_params) = 247 + dict.fold( 248 + clause.conditions, 249 + #([], []), 250 + fn(acc, field, condition) { 251 + let #(acc_sql, acc_params) = acc 252 + let #(cond_sql_parts, cond_params) = 253 + build_single_condition(field, condition, use_table_prefix) 254 + #( 255 + list.append(acc_sql, cond_sql_parts), 256 + list.append(acc_params, cond_params), 257 + ) 258 + }, 259 + ) 260 + 261 + let mut_sql_parts = list.append(mut_sql_parts, field_sql_parts) 262 + let mut_params = list.append(mut_params, field_params) 263 + 264 + // Handle nested AND clauses 265 + let #(and_sql_parts, and_params) = case clause.and { 266 + Some(and_clauses) -> { 267 + list.fold(and_clauses, #([], []), fn(acc, nested_clause) { 268 + let #(acc_sql, acc_params) = acc 269 + case is_clause_empty(nested_clause) { 270 + True -> acc 271 + False -> { 272 + let #(nested_sql_parts, nested_params) = 273 + build_where_clause_internal(nested_clause, use_table_prefix) 274 + // Wrap nested clause in parentheses if it has multiple parts 275 + let nested_sql = case list.length(nested_sql_parts) { 276 + 0 -> "" 277 + 1 -> list.first(nested_sql_parts) |> result.unwrap("") 278 + _ -> "(" <> string.join(nested_sql_parts, " AND ") <> ")" 279 + } 280 + let new_sql = case nested_sql { 281 + "" -> acc_sql 282 + _ -> [nested_sql, ..acc_sql] 283 + } 284 + #(new_sql, list.append(nested_params, acc_params)) 285 + } 286 + } 287 + }) 288 + } 289 + None -> #([], []) 290 + } 291 + 292 + let mut_sql_parts = list.append(mut_sql_parts, and_sql_parts) 293 + let mut_params = list.append(mut_params, and_params) 294 + 295 + // Handle nested OR clauses 296 + let #(or_sql_parts, or_params) = case clause.or { 297 + Some(or_clauses) -> { 298 + list.fold(or_clauses, #([], []), fn(acc, nested_clause) { 299 + let #(acc_sql, acc_params) = acc 300 + case is_clause_empty(nested_clause) { 301 + True -> acc 302 + False -> { 303 + let #(nested_sql_parts, nested_params) = 304 + build_where_clause_internal(nested_clause, use_table_prefix) 305 + // Wrap nested clause in parentheses if it has multiple parts 306 + let nested_sql = case list.length(nested_sql_parts) { 307 + 0 -> "" 308 + 1 -> list.first(nested_sql_parts) |> result.unwrap("") 309 + _ -> "(" <> string.join(nested_sql_parts, " AND ") <> ")" 310 + } 311 + let new_sql = case nested_sql { 312 + "" -> acc_sql 313 + _ -> [nested_sql, ..acc_sql] 314 + } 315 + #(new_sql, list.append(nested_params, acc_params)) 316 + } 317 + } 318 + }) 319 + } 320 + None -> #([], []) 321 + } 322 + 323 + // If we have OR parts, wrap them in parentheses and join with OR 324 + let #(final_sql_parts, final_params) = case list.length(or_sql_parts) { 325 + 0 -> #(mut_sql_parts, mut_params) 326 + _ -> { 327 + // Reverse the OR parts since we built them backwards 328 + let reversed_or = list.reverse(or_sql_parts) 329 + let or_combined = "(" <> string.join(reversed_or, " OR ") <> ")" 330 + #([or_combined, ..mut_sql_parts], list.append(or_params, mut_params)) 331 + } 332 + } 333 + 334 + #(final_sql_parts, final_params) 335 + }
+53
server/src/where_converter.gleam
··· 1 + /// Converts GraphQL where input types to SQL where clause types 2 + /// 3 + /// This module bridges the gap between the GraphQL layer (lexicon_graphql/where_input) 4 + /// and the database layer (where_clause with sqlight types). 5 + import gleam/dict 6 + import gleam/list 7 + import gleam/option 8 + import lexicon_graphql/where_input 9 + import sqlight 10 + import where_clause 11 + 12 + /// Convert a where_input.WhereValue to a sqlight.Value 13 + fn convert_value(value: where_input.WhereValue) -> sqlight.Value { 14 + case value { 15 + where_input.StringValue(s) -> sqlight.text(s) 16 + where_input.IntValue(i) -> sqlight.int(i) 17 + where_input.BoolValue(b) -> sqlight.bool(b) 18 + } 19 + } 20 + 21 + /// Convert a where_input.WhereCondition to a where_clause.WhereCondition 22 + fn convert_condition( 23 + cond: where_input.WhereCondition, 24 + ) -> where_clause.WhereCondition { 25 + where_clause.WhereCondition( 26 + eq: option.map(cond.eq, convert_value), 27 + in_values: option.map(cond.in_values, fn(values) { 28 + list.map(values, convert_value) 29 + }), 30 + contains: cond.contains, 31 + gt: option.map(cond.gt, convert_value), 32 + gte: option.map(cond.gte, convert_value), 33 + lt: option.map(cond.lt, convert_value), 34 + lte: option.map(cond.lte, convert_value), 35 + ) 36 + } 37 + 38 + /// Convert a where_input.WhereClause to a where_clause.WhereClause 39 + pub fn convert_where_clause( 40 + clause: where_input.WhereClause, 41 + ) -> where_clause.WhereClause { 42 + where_clause.WhereClause( 43 + conditions: dict.map_values(clause.conditions, fn(_key, value) { 44 + convert_condition(value) 45 + }), 46 + and: option.map(clause.and, fn(clauses) { 47 + list.map(clauses, convert_where_clause) 48 + }), 49 + or: option.map(clause.or, fn(clauses) { 50 + list.map(clauses, convert_where_clause) 51 + }), 52 + ) 53 + }
+329
server/test/blob_integration_test.gleam
··· 1 + /// Integration tests for Blob type in GraphQL queries 2 + /// 3 + /// Tests the full flow of querying blob fields: 4 + /// 1. Create a lexicon with blob fields 5 + /// 2. Insert records with blob data in AT Protocol format 6 + /// 3. Execute GraphQL queries with blob field selection 7 + /// 4. Verify blob fields are resolved correctly with all sub-fields 8 + import database 9 + import gleam/http 10 + import gleam/json 11 + import gleam/string 12 + import gleeunit/should 13 + import graphql_handler 14 + import sqlight 15 + import wisp 16 + import wisp/simulate 17 + 18 + /// Create a lexicon with a blob field (profile with avatar) 19 + fn create_profile_lexicon() -> String { 20 + json.object([ 21 + #("lexicon", json.int(1)), 22 + #("id", json.string("app.test.profile")), 23 + #( 24 + "defs", 25 + json.object([ 26 + #( 27 + "main", 28 + json.object([ 29 + #("type", json.string("record")), 30 + #("key", json.string("self")), 31 + #( 32 + "record", 33 + json.object([ 34 + #("type", json.string("object")), 35 + #( 36 + "required", 37 + json.array([json.string("displayName")], of: fn(x) { x }), 38 + ), 39 + #( 40 + "properties", 41 + json.object([ 42 + #( 43 + "displayName", 44 + json.object([#("type", json.string("string"))]), 45 + ), 46 + #( 47 + "description", 48 + json.object([#("type", json.string("string"))]), 49 + ), 50 + #("avatar", json.object([#("type", json.string("blob"))])), 51 + #("banner", json.object([#("type", json.string("blob"))])), 52 + ]), 53 + ), 54 + ]), 55 + ), 56 + ]), 57 + ), 58 + ]), 59 + ), 60 + ]) 61 + |> json.to_string 62 + } 63 + 64 + pub fn blob_field_query_test() { 65 + // Create in-memory database 66 + let assert Ok(db) = sqlight.open(":memory:") 67 + let assert Ok(_) = database.create_lexicon_table(db) 68 + let assert Ok(_) = database.create_record_table(db) 69 + 70 + // Insert profile lexicon with blob fields 71 + let lexicon = create_profile_lexicon() 72 + let assert Ok(_) = database.insert_lexicon(db, "app.test.profile", lexicon) 73 + 74 + // Insert a profile record with avatar blob 75 + // AT Protocol blob format: { ref: { $link: "cid" }, mimeType: "...", size: 123 } 76 + let record_json = 77 + json.object([ 78 + #("displayName", json.string("Alice")), 79 + #("description", json.string("Software developer")), 80 + #( 81 + "avatar", 82 + json.object([ 83 + #("ref", json.object([#("$link", json.string("bafyreiabc123"))])), 84 + #("mimeType", json.string("image/jpeg")), 85 + #("size", json.int(45_678)), 86 + ]), 87 + ), 88 + ]) 89 + |> json.to_string 90 + 91 + let assert Ok(_) = 92 + database.insert_record( 93 + db, 94 + "at://did:plc:alice123/app.test.profile/self", 95 + "cidprofile1", 96 + "did:plc:alice123", 97 + "app.test.profile", 98 + record_json, 99 + ) 100 + 101 + // Query blob fields with all sub-fields 102 + let query = 103 + json.object([ 104 + #( 105 + "query", 106 + json.string( 107 + "{ appTestProfile { edges { node { displayName avatar { ref mimeType size url(preset: \"avatar\") } } } } }", 108 + ), 109 + ), 110 + ]) 111 + |> json.to_string 112 + 113 + let request = 114 + simulate.request(http.Post, "/graphql") 115 + |> simulate.string_body(query) 116 + |> simulate.header("content-type", "application/json") 117 + 118 + let response = graphql_handler.handle_graphql_request(request, db) 119 + 120 + // Verify response 121 + response.status 122 + |> should.equal(200) 123 + 124 + let assert wisp.Text(body) = response.body 125 + 126 + // Verify response contains data 127 + string.contains(body, "\"data\"") 128 + |> should.be_true 129 + 130 + // Verify displayName is present 131 + string.contains(body, "Alice") 132 + |> should.be_true 133 + 134 + // Verify blob ref field 135 + string.contains(body, "bafyreiabc123") 136 + |> should.be_true 137 + 138 + // Verify blob mimeType field 139 + string.contains(body, "image/jpeg") 140 + |> should.be_true 141 + 142 + // Verify blob size field 143 + string.contains(body, "45678") 144 + |> should.be_true 145 + 146 + // Verify blob url field contains CDN URL with avatar preset 147 + string.contains(body, "https://cdn.bsky.app/img/avatar/plain/did:plc:alice123/bafyreiabc123@jpeg") 148 + |> should.be_true 149 + } 150 + 151 + pub fn blob_field_with_different_presets_test() { 152 + // Create in-memory database 153 + let assert Ok(db) = sqlight.open(":memory:") 154 + let assert Ok(_) = database.create_lexicon_table(db) 155 + let assert Ok(_) = database.create_record_table(db) 156 + 157 + // Insert profile lexicon 158 + let lexicon = create_profile_lexicon() 159 + let assert Ok(_) = database.insert_lexicon(db, "app.test.profile", lexicon) 160 + 161 + // Insert a profile with banner blob 162 + let record_json = 163 + json.object([ 164 + #("displayName", json.string("Bob")), 165 + #( 166 + "banner", 167 + json.object([ 168 + #("ref", json.object([#("$link", json.string("bafyreibanner789"))])), 169 + #("mimeType", json.string("image/png")), 170 + #("size", json.int(98_765)), 171 + ]), 172 + ), 173 + ]) 174 + |> json.to_string 175 + 176 + let assert Ok(_) = 177 + database.insert_record( 178 + db, 179 + "at://did:plc:bob456/app.test.profile/self", 180 + "cidbanner1", 181 + "did:plc:bob456", 182 + "app.test.profile", 183 + record_json, 184 + ) 185 + 186 + // Query with banner preset 187 + let query = 188 + json.object([ 189 + #( 190 + "query", 191 + json.string( 192 + "{ appTestProfile { edges { node { banner { url(preset: \"banner\") } } } } }", 193 + ), 194 + ), 195 + ]) 196 + |> json.to_string 197 + 198 + let request = 199 + simulate.request(http.Post, "/graphql") 200 + |> simulate.string_body(query) 201 + |> simulate.header("content-type", "application/json") 202 + 203 + let response = graphql_handler.handle_graphql_request(request, db) 204 + 205 + response.status 206 + |> should.equal(200) 207 + 208 + let assert wisp.Text(body) = response.body 209 + 210 + // Verify banner URL with banner preset 211 + string.contains(body, "https://cdn.bsky.app/img/banner/plain/did:plc:bob456/bafyreibanner789@jpeg") 212 + |> should.be_true 213 + } 214 + 215 + pub fn blob_field_default_preset_test() { 216 + // Test that when no preset is specified, feed_fullsize is used 217 + let assert Ok(db) = sqlight.open(":memory:") 218 + let assert Ok(_) = database.create_lexicon_table(db) 219 + let assert Ok(_) = database.create_record_table(db) 220 + 221 + let lexicon = create_profile_lexicon() 222 + let assert Ok(_) = database.insert_lexicon(db, "app.test.profile", lexicon) 223 + 224 + let record_json = 225 + json.object([ 226 + #("displayName", json.string("Charlie")), 227 + #( 228 + "avatar", 229 + json.object([ 230 + #("ref", json.object([#("$link", json.string("bafyreidefault"))])), 231 + #("mimeType", json.string("image/jpeg")), 232 + #("size", json.int(12_345)), 233 + ]), 234 + ), 235 + ]) 236 + |> json.to_string 237 + 238 + let assert Ok(_) = 239 + database.insert_record( 240 + db, 241 + "at://did:plc:charlie/app.test.profile/self", 242 + "cidcharlie", 243 + "did:plc:charlie", 244 + "app.test.profile", 245 + record_json, 246 + ) 247 + 248 + // Query without specifying preset (should default to feed_fullsize) 249 + let query = 250 + json.object([ 251 + #( 252 + "query", 253 + json.string("{ appTestProfile { edges { node { avatar { url } } } } }"), 254 + ), 255 + ]) 256 + |> json.to_string 257 + 258 + let request = 259 + simulate.request(http.Post, "/graphql") 260 + |> simulate.string_body(query) 261 + |> simulate.header("content-type", "application/json") 262 + 263 + let response = graphql_handler.handle_graphql_request(request, db) 264 + 265 + response.status 266 + |> should.equal(200) 267 + 268 + let assert wisp.Text(body) = response.body 269 + 270 + // Verify default preset is feed_fullsize 271 + string.contains(body, "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:charlie/bafyreidefault@jpeg") 272 + |> should.be_true 273 + } 274 + 275 + pub fn blob_field_null_when_missing_test() { 276 + // Test that blob fields return null when not present in record 277 + let assert Ok(db) = sqlight.open(":memory:") 278 + let assert Ok(_) = database.create_lexicon_table(db) 279 + let assert Ok(_) = database.create_record_table(db) 280 + 281 + let lexicon = create_profile_lexicon() 282 + let assert Ok(_) = database.insert_lexicon(db, "app.test.profile", lexicon) 283 + 284 + // Insert record without avatar field 285 + let record_json = 286 + json.object([#("displayName", json.string("Dave"))]) 287 + |> json.to_string 288 + 289 + let assert Ok(_) = 290 + database.insert_record( 291 + db, 292 + "at://did:plc:dave/app.test.profile/self", 293 + "ciddave", 294 + "did:plc:dave", 295 + "app.test.profile", 296 + record_json, 297 + ) 298 + 299 + let query = 300 + json.object([ 301 + #( 302 + "query", 303 + json.string( 304 + "{ appTestProfile { edges { node { displayName avatar { ref } } } } }", 305 + ), 306 + ), 307 + ]) 308 + |> json.to_string 309 + 310 + let request = 311 + simulate.request(http.Post, "/graphql") 312 + |> simulate.string_body(query) 313 + |> simulate.header("content-type", "application/json") 314 + 315 + let response = graphql_handler.handle_graphql_request(request, db) 316 + 317 + response.status 318 + |> should.equal(200) 319 + 320 + let assert wisp.Text(body) = response.body 321 + 322 + // Verify displayName is present 323 + string.contains(body, "Dave") 324 + |> should.be_true 325 + 326 + // Verify avatar is null (JSON encoder adds space after colon) 327 + string.contains(body, "\"avatar\": null") 328 + |> should.be_true 329 + }
+209
server/test/graphql_handler_integration_test.gleam
··· 589 589 |> list.length 590 590 |> fn(n) { n - 1 } 591 591 } 592 + 593 + pub fn graphql_actor_handle_lookup_test() { 594 + // Create in-memory database 595 + let assert Ok(db) = sqlight.open(":memory:") 596 + let assert Ok(_) = database.create_lexicon_table(db) 597 + let assert Ok(_) = database.create_record_table(db) 598 + let assert Ok(_) = database.create_actor_table(db) 599 + 600 + // Insert a lexicon for xyz.statusphere.status 601 + let lexicon = create_status_lexicon() 602 + let assert Ok(_) = 603 + database.insert_lexicon(db, "xyz.statusphere.status", lexicon) 604 + 605 + // Insert test actors 606 + let assert Ok(_) = 607 + database.upsert_actor(db, "did:plc:alice", "alice.bsky.social") 608 + let assert Ok(_) = database.upsert_actor(db, "did:plc:bob", "bob.bsky.social") 609 + 610 + // Insert test records with those DIDs 611 + let record1_json = 612 + json.object([ 613 + #("status", json.string("👍")), 614 + #("createdAt", json.string("2024-01-01T00:00:00Z")), 615 + ]) 616 + |> json.to_string 617 + 618 + let assert Ok(_) = 619 + database.insert_record( 620 + db, 621 + "at://did:plc:alice/xyz.statusphere.status/123", 622 + "cid1", 623 + "did:plc:alice", 624 + "xyz.statusphere.status", 625 + record1_json, 626 + ) 627 + 628 + let record2_json = 629 + json.object([ 630 + #("status", json.string("🔥")), 631 + #("createdAt", json.string("2024-01-02T00:00:00Z")), 632 + ]) 633 + |> json.to_string 634 + 635 + let assert Ok(_) = 636 + database.insert_record( 637 + db, 638 + "at://did:plc:bob/xyz.statusphere.status/456", 639 + "cid2", 640 + "did:plc:bob", 641 + "xyz.statusphere.status", 642 + record2_json, 643 + ) 644 + 645 + // Query with actorHandle field 646 + let query = 647 + json.object([ 648 + #( 649 + "query", 650 + json.string( 651 + "{ xyzStatusphereStatus(where: {status: {contains: \"👍\"}}, sortBy: [{direction: DESC, field: createdAt}]) { edges { node { actorHandle did status createdAt } cursor } pageInfo { hasNextPage } } }", 652 + ), 653 + ), 654 + ]) 655 + |> json.to_string 656 + 657 + let request = 658 + simulate.request(http.Post, "/graphql") 659 + |> simulate.string_body(query) 660 + |> simulate.header("content-type", "application/json") 661 + 662 + let response = graphql_handler.handle_graphql_request(request, db) 663 + 664 + // Verify response 665 + response.status 666 + |> should.equal(200) 667 + 668 + let assert wisp.Text(body) = response.body 669 + 670 + // Should contain the actor handle 671 + string.contains(body, "alice.bsky.social") 672 + |> should.be_true 673 + 674 + // Should contain the record data 675 + string.contains(body, "did:plc:alice") 676 + |> should.be_true 677 + 678 + string.contains(body, "👍") 679 + |> should.be_true 680 + 681 + // Clean up 682 + let assert Ok(_) = sqlight.close(db) 683 + } 684 + 685 + pub fn graphql_filter_by_actor_handle_test() { 686 + // Create in-memory database 687 + let assert Ok(db) = sqlight.open(":memory:") 688 + let assert Ok(_) = database.create_lexicon_table(db) 689 + let assert Ok(_) = database.create_record_table(db) 690 + let assert Ok(_) = database.create_actor_table(db) 691 + 692 + // Insert a lexicon for xyz.statusphere.status 693 + let lexicon = create_status_lexicon() 694 + let assert Ok(_) = 695 + database.insert_lexicon(db, "xyz.statusphere.status", lexicon) 696 + 697 + // Insert test actors 698 + let assert Ok(_) = 699 + database.upsert_actor(db, "did:plc:alice", "alice.bsky.social") 700 + let assert Ok(_) = database.upsert_actor(db, "did:plc:bob", "bob.bsky.social") 701 + let assert Ok(_) = 702 + database.upsert_actor(db, "did:plc:charlie", "charlie.bsky.social") 703 + 704 + // Insert test records with those DIDs 705 + let record1_json = 706 + json.object([ 707 + #("status", json.string("👍")), 708 + #("createdAt", json.string("2024-01-01T00:00:00Z")), 709 + ]) 710 + |> json.to_string 711 + 712 + let assert Ok(_) = 713 + database.insert_record( 714 + db, 715 + "at://did:plc:alice/xyz.statusphere.status/123", 716 + "cid1", 717 + "did:plc:alice", 718 + "xyz.statusphere.status", 719 + record1_json, 720 + ) 721 + 722 + let record2_json = 723 + json.object([ 724 + #("status", json.string("🔥")), 725 + #("createdAt", json.string("2024-01-02T00:00:00Z")), 726 + ]) 727 + |> json.to_string 728 + 729 + let assert Ok(_) = 730 + database.insert_record( 731 + db, 732 + "at://did:plc:bob/xyz.statusphere.status/456", 733 + "cid2", 734 + "did:plc:bob", 735 + "xyz.statusphere.status", 736 + record2_json, 737 + ) 738 + 739 + let record3_json = 740 + json.object([ 741 + #("status", json.string("⭐")), 742 + #("createdAt", json.string("2024-01-03T00:00:00Z")), 743 + ]) 744 + |> json.to_string 745 + 746 + let assert Ok(_) = 747 + database.insert_record( 748 + db, 749 + "at://did:plc:charlie/xyz.statusphere.status/789", 750 + "cid3", 751 + "did:plc:charlie", 752 + "xyz.statusphere.status", 753 + record3_json, 754 + ) 755 + 756 + // Query filtering by actorHandle 757 + let query = 758 + json.object([ 759 + #( 760 + "query", 761 + json.string( 762 + "{ xyzStatusphereStatus(where: {actorHandle: {eq: \"alice.bsky.social\"}}) { edges { node { actorHandle did status } } } }", 763 + ), 764 + ), 765 + ]) 766 + |> json.to_string 767 + 768 + let request = 769 + simulate.request(http.Post, "/graphql") 770 + |> simulate.string_body(query) 771 + |> simulate.header("content-type", "application/json") 772 + 773 + let response = graphql_handler.handle_graphql_request(request, db) 774 + 775 + // Verify response 776 + response.status 777 + |> should.equal(200) 778 + 779 + let assert wisp.Text(body) = response.body 780 + 781 + // Should contain alice's handle and record 782 + string.contains(body, "alice.bsky.social") 783 + |> should.be_true 784 + 785 + string.contains(body, "did:plc:alice") 786 + |> should.be_true 787 + 788 + string.contains(body, "👍") 789 + |> should.be_true 790 + 791 + // Should NOT contain bob or charlie's records 792 + string.contains(body, "bob.bsky.social") 793 + |> should.be_false 794 + 795 + string.contains(body, "charlie.bsky.social") 796 + |> should.be_false 797 + 798 + // Clean up 799 + let assert Ok(_) = sqlight.close(db) 800 + }
+380
server/test/graphql_total_count_test.gleam
··· 1 + /// Integration tests for GraphQL totalCount field 2 + /// 3 + /// These tests verify that totalCount is correctly returned in connection queries 4 + import database 5 + import gleam/http 6 + import gleam/int 7 + import gleam/json 8 + import gleam/list 9 + import gleam/string 10 + import gleeunit/should 11 + import graphql_handler 12 + import sqlight 13 + import wisp 14 + import wisp/simulate 15 + 16 + // Helper to create a status lexicon 17 + fn create_status_lexicon() -> String { 18 + json.object([ 19 + #("lexicon", json.int(1)), 20 + #("id", json.string("xyz.statusphere.status")), 21 + #( 22 + "defs", 23 + json.object([ 24 + #( 25 + "main", 26 + json.object([ 27 + #("type", json.string("record")), 28 + #("key", json.string("tid")), 29 + #( 30 + "record", 31 + json.object([ 32 + #("type", json.string("object")), 33 + #( 34 + "required", 35 + json.array( 36 + [json.string("status"), json.string("createdAt")], 37 + of: fn(x) { x }, 38 + ), 39 + ), 40 + #( 41 + "properties", 42 + json.object([ 43 + #( 44 + "status", 45 + json.object([ 46 + #("type", json.string("string")), 47 + #("minLength", json.int(1)), 48 + #("maxGraphemes", json.int(1)), 49 + #("maxLength", json.int(32)), 50 + ]), 51 + ), 52 + #( 53 + "createdAt", 54 + json.object([ 55 + #("type", json.string("string")), 56 + #("format", json.string("datetime")), 57 + ]), 58 + ), 59 + ]), 60 + ), 61 + ]), 62 + ), 63 + ]), 64 + ), 65 + ]), 66 + ), 67 + ]) 68 + |> json.to_string 69 + } 70 + 71 + // Helper function to create a range of integers 72 + fn list_range(from: Int, to: Int) -> List(Int) { 73 + list_range_helper(from, to, []) 74 + |> list.reverse 75 + } 76 + 77 + fn list_range_helper(current: Int, to: Int, acc: List(Int)) -> List(Int) { 78 + case current > to { 79 + True -> acc 80 + False -> list_range_helper(current + 1, to, [current, ..acc]) 81 + } 82 + } 83 + 84 + pub fn graphql_total_count_basic_test() { 85 + // Create in-memory database 86 + let assert Ok(db) = sqlight.open(":memory:") 87 + let assert Ok(_) = database.create_lexicon_table(db) 88 + let assert Ok(_) = database.create_record_table(db) 89 + let assert Ok(_) = database.create_actor_table(db) 90 + 91 + // Insert a lexicon 92 + let lexicon = create_status_lexicon() 93 + let assert Ok(_) = 94 + database.insert_lexicon(db, "xyz.statusphere.status", lexicon) 95 + 96 + // Insert 5 test records 97 + let _ = 98 + list_range(1, 5) 99 + |> list.each(fn(i) { 100 + let uri = "at://did:plc:test/xyz.statusphere.status/" <> int.to_string(i) 101 + let cid = "cid" <> int.to_string(i) 102 + let json_data = 103 + json.object([ 104 + #("status", json.string("✨")), 105 + #("createdAt", json.string("2024-01-01T00:00:00Z")), 106 + ]) 107 + |> json.to_string 108 + let assert Ok(_) = 109 + database.insert_record( 110 + db, 111 + uri, 112 + cid, 113 + "did:plc:test", 114 + "xyz.statusphere.status", 115 + json_data, 116 + ) 117 + Nil 118 + }) 119 + 120 + // Query with totalCount field 121 + let query = 122 + json.object([ 123 + #( 124 + "query", 125 + json.string( 126 + "{ xyzStatusphereStatus { totalCount edges { node { uri } } pageInfo { hasNextPage } } }", 127 + ), 128 + ), 129 + ]) 130 + |> json.to_string 131 + 132 + let request = 133 + simulate.request(http.Post, "/graphql") 134 + |> simulate.string_body(query) 135 + |> simulate.header("content-type", "application/json") 136 + 137 + let response = graphql_handler.handle_graphql_request(request, db) 138 + 139 + // Verify response 140 + response.status 141 + |> should.equal(200) 142 + 143 + let assert wisp.Text(body) = response.body 144 + 145 + // Should contain totalCount field 146 + string.contains(body, "totalCount") 147 + |> should.be_true 148 + 149 + // Should contain the count value (5 records) 150 + string.contains(body, "\"totalCount\": 5") 151 + |> should.be_true 152 + 153 + // Clean up 154 + let assert Ok(_) = sqlight.close(db) 155 + } 156 + 157 + pub fn graphql_total_count_with_filter_test() { 158 + // Create in-memory database 159 + let assert Ok(db) = sqlight.open(":memory:") 160 + let assert Ok(_) = database.create_lexicon_table(db) 161 + let assert Ok(_) = database.create_record_table(db) 162 + let assert Ok(_) = database.create_actor_table(db) 163 + 164 + // Insert a lexicon 165 + let lexicon = create_status_lexicon() 166 + let assert Ok(_) = 167 + database.insert_lexicon(db, "xyz.statusphere.status", lexicon) 168 + 169 + // Insert test actors 170 + let assert Ok(_) = 171 + database.upsert_actor(db, "did:plc:alice", "alice.bsky.social") 172 + let assert Ok(_) = database.upsert_actor(db, "did:plc:bob", "bob.bsky.social") 173 + 174 + // Insert 3 records for alice 175 + let _ = 176 + list_range(1, 3) 177 + |> list.each(fn(i) { 178 + let uri = "at://did:plc:alice/xyz.statusphere.status/" <> int.to_string(i) 179 + let cid = "alice_cid" <> int.to_string(i) 180 + let json_data = 181 + json.object([ 182 + #("status", json.string("👍")), 183 + #("createdAt", json.string("2024-01-01T00:00:00Z")), 184 + ]) 185 + |> json.to_string 186 + let assert Ok(_) = 187 + database.insert_record( 188 + db, 189 + uri, 190 + cid, 191 + "did:plc:alice", 192 + "xyz.statusphere.status", 193 + json_data, 194 + ) 195 + Nil 196 + }) 197 + 198 + // Insert 2 records for bob 199 + let _ = 200 + list_range(1, 2) 201 + |> list.each(fn(i) { 202 + let uri = "at://did:plc:bob/xyz.statusphere.status/" <> int.to_string(i) 203 + let cid = "bob_cid" <> int.to_string(i) 204 + let json_data = 205 + json.object([ 206 + #("status", json.string("🔥")), 207 + #("createdAt", json.string("2024-01-02T00:00:00Z")), 208 + ]) 209 + |> json.to_string 210 + let assert Ok(_) = 211 + database.insert_record( 212 + db, 213 + uri, 214 + cid, 215 + "did:plc:bob", 216 + "xyz.statusphere.status", 217 + json_data, 218 + ) 219 + Nil 220 + }) 221 + 222 + // Query with totalCount and filter by actorHandle 223 + let query = 224 + json.object([ 225 + #( 226 + "query", 227 + json.string( 228 + "{ xyzStatusphereStatus(where: {actorHandle: {eq: \"alice.bsky.social\"}}) { totalCount edges { node { uri actorHandle } } } }", 229 + ), 230 + ), 231 + ]) 232 + |> json.to_string 233 + 234 + let request = 235 + simulate.request(http.Post, "/graphql") 236 + |> simulate.string_body(query) 237 + |> simulate.header("content-type", "application/json") 238 + 239 + let response = graphql_handler.handle_graphql_request(request, db) 240 + 241 + // Verify response 242 + response.status 243 + |> should.equal(200) 244 + 245 + let assert wisp.Text(body) = response.body 246 + 247 + // Should contain totalCount field 248 + string.contains(body, "totalCount") 249 + |> should.be_true 250 + 251 + // Should contain count of 3 (only alice's records) 252 + string.contains(body, "\"totalCount\": 3") 253 + |> should.be_true 254 + 255 + // Should only contain alice's records 256 + string.contains(body, "alice.bsky.social") 257 + |> should.be_true 258 + 259 + string.contains(body, "bob.bsky.social") 260 + |> should.be_false 261 + 262 + // Clean up 263 + let assert Ok(_) = sqlight.close(db) 264 + } 265 + 266 + pub fn graphql_total_count_empty_result_test() { 267 + // Create in-memory database 268 + let assert Ok(db) = sqlight.open(":memory:") 269 + let assert Ok(_) = database.create_lexicon_table(db) 270 + let assert Ok(_) = database.create_record_table(db) 271 + 272 + // Insert a lexicon 273 + let lexicon = create_status_lexicon() 274 + let assert Ok(_) = 275 + database.insert_lexicon(db, "xyz.statusphere.status", lexicon) 276 + 277 + // Query with totalCount field (no records inserted) 278 + let query = 279 + json.object([ 280 + #( 281 + "query", 282 + json.string( 283 + "{ xyzStatusphereStatus { totalCount edges { node { uri } } pageInfo { hasNextPage } } }", 284 + ), 285 + ), 286 + ]) 287 + |> json.to_string 288 + 289 + let request = 290 + simulate.request(http.Post, "/graphql") 291 + |> simulate.string_body(query) 292 + |> simulate.header("content-type", "application/json") 293 + 294 + let response = graphql_handler.handle_graphql_request(request, db) 295 + 296 + // Verify response 297 + response.status 298 + |> should.equal(200) 299 + 300 + let assert wisp.Text(body) = response.body 301 + 302 + // Should contain totalCount of 0 303 + string.contains(body, "\"totalCount\": 0") 304 + |> should.be_true 305 + 306 + // Clean up 307 + let assert Ok(_) = sqlight.close(db) 308 + } 309 + 310 + pub fn graphql_total_count_with_pagination_test() { 311 + // Create in-memory database 312 + let assert Ok(db) = sqlight.open(":memory:") 313 + let assert Ok(_) = database.create_lexicon_table(db) 314 + let assert Ok(_) = database.create_record_table(db) 315 + 316 + // Insert a lexicon 317 + let lexicon = create_status_lexicon() 318 + let assert Ok(_) = 319 + database.insert_lexicon(db, "xyz.statusphere.status", lexicon) 320 + 321 + // Insert 10 test records 322 + let _ = 323 + list_range(1, 10) 324 + |> list.each(fn(i) { 325 + let uri = "at://did:plc:test/xyz.statusphere.status/" <> int.to_string(i) 326 + let cid = "cid" <> int.to_string(i) 327 + let json_data = 328 + json.object([ 329 + #("status", json.string("✨")), 330 + #("createdAt", json.string("2024-01-01T00:00:00Z")), 331 + ]) 332 + |> json.to_string 333 + let assert Ok(_) = 334 + database.insert_record( 335 + db, 336 + uri, 337 + cid, 338 + "did:plc:test", 339 + "xyz.statusphere.status", 340 + json_data, 341 + ) 342 + Nil 343 + }) 344 + 345 + // Query with totalCount and pagination (first: 3) 346 + let query = 347 + json.object([ 348 + #( 349 + "query", 350 + json.string( 351 + "{ xyzStatusphereStatus(first: 3) { totalCount edges { node { uri } } pageInfo { hasNextPage } } }", 352 + ), 353 + ), 354 + ]) 355 + |> json.to_string 356 + 357 + let request = 358 + simulate.request(http.Post, "/graphql") 359 + |> simulate.string_body(query) 360 + |> simulate.header("content-type", "application/json") 361 + 362 + let response = graphql_handler.handle_graphql_request(request, db) 363 + 364 + // Verify response 365 + response.status 366 + |> should.equal(200) 367 + 368 + let assert wisp.Text(body) = response.body 369 + 370 + // totalCount should still be 10 (total records, not just the page) 371 + string.contains(body, "\"totalCount\": 10") 372 + |> should.be_true 373 + 374 + // hasNextPage should be true (more records available) 375 + string.contains(body, "\"hasNextPage\": true") 376 + |> should.be_true 377 + 378 + // Clean up 379 + let assert Ok(_) = sqlight.close(db) 380 + }
+421
server/test/graphql_where_integration_test.gleam
··· 1 + /// End-to-end integration tests for GraphQL where clause filtering 2 + /// 3 + /// Tests the complete flow: GraphQL value → WhereInput parsing → SQL generation → Database query 4 + import database 5 + import gleam/list 6 + import gleam/option.{None, Some} 7 + import gleam/result 8 + import gleam/string 9 + import gleeunit 10 + import gleeunit/should 11 + import graphql/value 12 + import lexicon_graphql/where_input 13 + import sqlight 14 + import where_converter 15 + 16 + pub fn main() { 17 + gleeunit.main() 18 + } 19 + 20 + // Helper to setup test database with sample records 21 + fn setup_test_db() -> Result(sqlight.Connection, sqlight.Error) { 22 + use conn <- result.try(sqlight.open(":memory:")) 23 + use _ <- result.try(database.create_record_table(conn)) 24 + 25 + // Insert test records with different properties 26 + use _ <- result.try(database.insert_record( 27 + conn, 28 + "at://did:plc:alice/app.bsky.feed.post/1", 29 + "cid1", 30 + "did:plc:alice", 31 + "app.bsky.feed.post", 32 + "{\"text\":\"Hello World\",\"likes\":100,\"author\":\"alice\"}", 33 + )) 34 + 35 + use _ <- result.try(database.insert_record( 36 + conn, 37 + "at://did:plc:bob/app.bsky.feed.post/2", 38 + "cid2", 39 + "did:plc:bob", 40 + "app.bsky.feed.post", 41 + "{\"text\":\"Goodbye World\",\"likes\":50,\"author\":\"bob\"}", 42 + )) 43 + 44 + use _ <- result.try(database.insert_record( 45 + conn, 46 + "at://did:plc:charlie/app.bsky.feed.post/3", 47 + "cid3", 48 + "did:plc:charlie", 49 + "app.bsky.feed.post", 50 + "{\"text\":\"Hello Universe\",\"likes\":200,\"author\":\"charlie\"}", 51 + )) 52 + 53 + use _ <- result.try(database.insert_record( 54 + conn, 55 + "at://did:plc:alice/app.bsky.feed.post/4", 56 + "cid4", 57 + "did:plc:alice", 58 + "app.bsky.feed.post", 59 + "{\"text\":\"Another post\",\"likes\":75,\"author\":\"alice\"}", 60 + )) 61 + 62 + Ok(conn) 63 + } 64 + 65 + // Test: Simple field filter through full GraphQL stack 66 + pub fn graphql_simple_filter_test() { 67 + case setup_test_db() { 68 + Error(_) -> should.fail() 69 + Ok(conn) -> { 70 + // Create GraphQL where value: { text: { contains: "Hello" } } 71 + let text_condition = value.Object([#("contains", value.String("Hello"))]) 72 + 73 + let where_value = value.Object([#("text", text_condition)]) 74 + 75 + // Parse GraphQL value to WhereInput 76 + let where_input = where_input.parse_where_clause(where_value) 77 + 78 + // Convert to SQL WhereClause 79 + let where_sql = where_converter.convert_where_clause(where_input) 80 + 81 + // Execute query 82 + case 83 + database.get_records_by_collection_paginated_with_where( 84 + conn, 85 + "app.bsky.feed.post", 86 + Some(10), 87 + None, 88 + None, 89 + None, 90 + None, 91 + Some(where_sql), 92 + ) 93 + { 94 + Ok(#(records, _, _, _)) -> { 95 + // Should match 2 records with "Hello" 96 + list.length(records) |> should.equal(2) 97 + list.all(records, fn(record) { string.contains(record.json, "Hello") }) 98 + |> should.be_true 99 + } 100 + Error(_) -> should.fail() 101 + } 102 + } 103 + } 104 + } 105 + 106 + // Test: OR logic through full GraphQL stack 107 + pub fn graphql_or_filter_test() { 108 + case setup_test_db() { 109 + Error(_) -> should.fail() 110 + Ok(conn) -> { 111 + // Create GraphQL where value: 112 + // { 113 + // or: [ 114 + // { author: { eq: "alice" } }, 115 + // { author: { eq: "bob" } } 116 + // ] 117 + // } 118 + let alice_condition = value.Object([#("eq", value.String("alice"))]) 119 + let bob_condition = value.Object([#("eq", value.String("bob"))]) 120 + 121 + let alice_clause = value.Object([#("author", alice_condition)]) 122 + let bob_clause = value.Object([#("author", bob_condition)]) 123 + 124 + let where_value = 125 + value.Object([#("or", value.List([alice_clause, bob_clause]))]) 126 + 127 + // Parse GraphQL value to WhereInput 128 + let where_input = where_input.parse_where_clause(where_value) 129 + 130 + // Verify OR was parsed 131 + should.be_true(option.is_some(where_input.or)) 132 + 133 + // Convert to SQL WhereClause 134 + let where_sql = where_converter.convert_where_clause(where_input) 135 + 136 + // Execute query 137 + case 138 + database.get_records_by_collection_paginated_with_where( 139 + conn, 140 + "app.bsky.feed.post", 141 + Some(10), 142 + None, 143 + None, 144 + None, 145 + None, 146 + Some(where_sql), 147 + ) 148 + { 149 + Ok(#(records, _, _, _)) -> { 150 + // Should match 3 records (alice has 2, bob has 1) 151 + list.length(records) |> should.equal(3) 152 + 153 + // Verify all records are from alice or bob 154 + list.all(records, fn(record) { 155 + string.contains(record.json, "alice") 156 + || string.contains(record.json, "bob") 157 + }) 158 + |> should.be_true 159 + 160 + // Verify no charlie records 161 + list.any(records, fn(record) { 162 + string.contains(record.json, "charlie") 163 + }) 164 + |> should.be_false 165 + } 166 + Error(_) -> should.fail() 167 + } 168 + } 169 + } 170 + } 171 + 172 + // Test: AND logic through full GraphQL stack 173 + pub fn graphql_and_filter_test() { 174 + case setup_test_db() { 175 + Error(_) -> should.fail() 176 + Ok(conn) -> { 177 + // Create GraphQL where value: 178 + // { 179 + // and: [ 180 + // { author: { eq: "alice" } }, 181 + // { likes: { gt: "80" } } 182 + // ] 183 + // } 184 + // Note: likes is stored as integer in JSON, so we compare as string "80" 185 + let author_condition = value.Object([#("eq", value.String("alice"))]) 186 + let likes_condition = value.Object([#("gt", value.Int(80))]) 187 + 188 + let author_clause = value.Object([#("author", author_condition)]) 189 + let likes_clause = value.Object([#("likes", likes_condition)]) 190 + 191 + let where_value = 192 + value.Object([#("and", value.List([author_clause, likes_clause]))]) 193 + 194 + // Parse GraphQL value to WhereInput 195 + let where_input = where_input.parse_where_clause(where_value) 196 + 197 + // Verify AND was parsed 198 + should.be_true(option.is_some(where_input.and)) 199 + 200 + // Convert to SQL WhereClause 201 + let where_sql = where_converter.convert_where_clause(where_input) 202 + 203 + // Execute query 204 + case 205 + database.get_records_by_collection_paginated_with_where( 206 + conn, 207 + "app.bsky.feed.post", 208 + Some(10), 209 + None, 210 + None, 211 + None, 212 + None, 213 + Some(where_sql), 214 + ) 215 + { 216 + Ok(#(records, _, _, _)) -> { 217 + // Should match 1 record (alice with likes=100) 218 + list.length(records) |> should.equal(1) 219 + 220 + case list.first(records) { 221 + Ok(record) -> { 222 + string.contains(record.json, "alice") |> should.be_true 223 + string.contains(record.json, "100") |> should.be_true 224 + } 225 + Error(_) -> should.fail() 226 + } 227 + } 228 + Error(_) -> should.fail() 229 + } 230 + } 231 + } 232 + } 233 + 234 + // Test: Nested AND/OR logic through full GraphQL stack 235 + pub fn graphql_nested_and_or_test() { 236 + case setup_test_db() { 237 + Error(_) -> should.fail() 238 + Ok(conn) -> { 239 + // Create GraphQL where value: 240 + // { 241 + // and: [ 242 + // { 243 + // or: [ 244 + // { text: { contains: "Hello" } }, 245 + // { text: { contains: "Another" } } 246 + // ] 247 + // }, 248 + // { author: { eq: "alice" } } 249 + // ] 250 + // } 251 + 252 + // Inner OR: text contains "Hello" OR "Another" 253 + let hello_condition = value.Object([#("contains", value.String("Hello"))]) 254 + let another_condition = 255 + value.Object([#("contains", value.String("Another"))]) 256 + 257 + let hello_clause = value.Object([#("text", hello_condition)]) 258 + let another_clause = value.Object([#("text", another_condition)]) 259 + 260 + let or_clause = 261 + value.Object([#("or", value.List([hello_clause, another_clause]))]) 262 + 263 + // Outer AND: (above OR) AND author = "alice" 264 + let author_condition = value.Object([#("eq", value.String("alice"))]) 265 + let author_clause = value.Object([#("author", author_condition)]) 266 + 267 + let where_value = 268 + value.Object([#("and", value.List([or_clause, author_clause]))]) 269 + 270 + // Parse GraphQL value to WhereInput 271 + let where_input = where_input.parse_where_clause(where_value) 272 + 273 + // Verify nested structure was parsed 274 + should.be_true(option.is_some(where_input.and)) 275 + 276 + // Convert to SQL WhereClause 277 + let where_sql = where_converter.convert_where_clause(where_input) 278 + 279 + // Execute query 280 + case 281 + database.get_records_by_collection_paginated_with_where( 282 + conn, 283 + "app.bsky.feed.post", 284 + Some(10), 285 + None, 286 + None, 287 + None, 288 + None, 289 + Some(where_sql), 290 + ) 291 + { 292 + Ok(#(records, _, _, _)) -> { 293 + // Should match 2 alice records: "Hello World" and "Another post" 294 + list.length(records) |> should.equal(2) 295 + 296 + // Verify all records are from alice 297 + list.all(records, fn(record) { string.contains(record.json, "alice") }) 298 + |> should.be_true 299 + 300 + // Verify records contain either "Hello" or "Another" 301 + list.all(records, fn(record) { 302 + string.contains(record.json, "Hello") 303 + || string.contains(record.json, "Another") 304 + }) 305 + |> should.be_true 306 + } 307 + Error(_) -> should.fail() 308 + } 309 + } 310 + } 311 + } 312 + 313 + // Test: Complex nested logic with multiple levels 314 + pub fn graphql_complex_nested_test() { 315 + case setup_test_db() { 316 + Error(_) -> should.fail() 317 + Ok(conn) -> { 318 + // Simplify test: just OR two simple conditions 319 + // { 320 + // or: [ 321 + // { author: { eq: "alice" } }, 322 + // { author: { eq: "charlie" } } 323 + // ] 324 + // } 325 + 326 + let alice_condition = value.Object([#("eq", value.String("alice"))]) 327 + let charlie_condition = value.Object([#("eq", value.String("charlie"))]) 328 + 329 + let alice_clause = value.Object([#("author", alice_condition)]) 330 + let charlie_clause = value.Object([#("author", charlie_condition)]) 331 + 332 + let where_value = 333 + value.Object([#("or", value.List([alice_clause, charlie_clause]))]) 334 + 335 + // Parse GraphQL value to WhereInput 336 + let where_input = where_input.parse_where_clause(where_value) 337 + 338 + // Convert to SQL WhereClause 339 + let where_sql = where_converter.convert_where_clause(where_input) 340 + 341 + // Execute query 342 + case 343 + database.get_records_by_collection_paginated_with_where( 344 + conn, 345 + "app.bsky.feed.post", 346 + Some(10), 347 + None, 348 + None, 349 + None, 350 + None, 351 + Some(where_sql), 352 + ) 353 + { 354 + Ok(#(records, _, _, _)) -> { 355 + // Should match 3 records: 2 from alice, 1 from charlie 356 + list.length(records) |> should.equal(3) 357 + 358 + // Check we got alice and charlie records 359 + let has_alice = 360 + list.any(records, fn(record) { 361 + string.contains(record.json, "alice") 362 + }) 363 + let has_charlie = 364 + list.any(records, fn(record) { 365 + string.contains(record.json, "charlie") 366 + }) 367 + 368 + should.be_true(has_alice) 369 + should.be_true(has_charlie) 370 + } 371 + Error(_) -> should.fail() 372 + } 373 + } 374 + } 375 + } 376 + 377 + // Test: Empty AND/OR arrays 378 + pub fn graphql_empty_logic_arrays_test() { 379 + case setup_test_db() { 380 + Error(_) -> should.fail() 381 + Ok(conn) -> { 382 + // Create GraphQL where value with empty AND array: { and: [] } 383 + let where_value = value.Object([#("and", value.List([]))]) 384 + 385 + // Parse GraphQL value to WhereInput 386 + let where_input = where_input.parse_where_clause(where_value) 387 + 388 + // Empty AND should result in None 389 + case where_input.and { 390 + None -> should.be_true(True) 391 + Some(clauses) -> { 392 + // Or empty list 393 + list.is_empty(clauses) |> should.be_true 394 + } 395 + } 396 + 397 + // Convert to SQL WhereClause 398 + let where_sql = where_converter.convert_where_clause(where_input) 399 + 400 + // Execute query - should return all records 401 + case 402 + database.get_records_by_collection_paginated_with_where( 403 + conn, 404 + "app.bsky.feed.post", 405 + Some(10), 406 + None, 407 + None, 408 + None, 409 + None, 410 + Some(where_sql), 411 + ) 412 + { 413 + Ok(#(records, _, _, _)) -> { 414 + // Should return all 4 records 415 + list.length(records) |> should.equal(4) 416 + } 417 + Error(_) -> should.fail() 418 + } 419 + } 420 + } 421 + }
+177
server/test/where_clause_test.gleam
··· 1 + import gleeunit 2 + import gleeunit/should 3 + import gleam/dict 4 + import gleam/list 5 + import gleam/option.{None, Some} 6 + import sqlight 7 + import where_clause 8 + 9 + pub fn main() { 10 + gleeunit.main() 11 + } 12 + 13 + // Test empty condition creation 14 + pub fn empty_condition_test() { 15 + let condition = where_clause.empty_condition() 16 + 17 + condition.eq |> should.equal(None) 18 + condition.in_values |> should.equal(None) 19 + condition.contains |> should.equal(None) 20 + condition.gt |> should.equal(None) 21 + condition.gte |> should.equal(None) 22 + condition.lt |> should.equal(None) 23 + condition.lte |> should.equal(None) 24 + } 25 + 26 + // Test empty clause creation 27 + pub fn empty_clause_test() { 28 + let clause = where_clause.empty_clause() 29 + 30 + clause.conditions |> dict.is_empty |> should.be_true 31 + clause.and |> should.equal(None) 32 + clause.or |> should.equal(None) 33 + } 34 + 35 + // Test is_condition_empty with empty condition 36 + pub fn is_condition_empty_true_test() { 37 + let condition = where_clause.empty_condition() 38 + where_clause.is_condition_empty(condition) |> should.be_true 39 + } 40 + 41 + // Test is_condition_empty with eq operator set 42 + pub fn is_condition_empty_false_with_eq_test() { 43 + let condition = 44 + where_clause.WhereCondition( 45 + eq: Some(sqlight.text("value")), 46 + in_values: None, 47 + contains: None, 48 + gt: None, 49 + gte: None, 50 + lt: None, 51 + lte: None, 52 + ) 53 + where_clause.is_condition_empty(condition) |> should.be_false 54 + } 55 + 56 + // Test is_condition_empty with contains operator set 57 + pub fn is_condition_empty_false_with_contains_test() { 58 + let condition = 59 + where_clause.WhereCondition( 60 + eq: None, 61 + in_values: None, 62 + contains: Some("search"), 63 + gt: None, 64 + gte: None, 65 + lt: None, 66 + lte: None, 67 + ) 68 + where_clause.is_condition_empty(condition) |> should.be_false 69 + } 70 + 71 + // Test is_clause_empty with empty clause 72 + pub fn is_clause_empty_true_test() { 73 + let clause = where_clause.empty_clause() 74 + where_clause.is_clause_empty(clause) |> should.be_true 75 + } 76 + 77 + // Test is_clause_empty with conditions 78 + pub fn is_clause_empty_false_with_conditions_test() { 79 + let condition = 80 + where_clause.WhereCondition( 81 + eq: Some(sqlight.text("value")), 82 + in_values: None, 83 + contains: None, 84 + gt: None, 85 + gte: None, 86 + lt: None, 87 + lte: None, 88 + ) 89 + let clause = 90 + where_clause.WhereClause( 91 + conditions: dict.from_list([#("field", condition)]), 92 + and: None, 93 + or: None, 94 + ) 95 + where_clause.is_clause_empty(clause) |> should.be_false 96 + } 97 + 98 + // Test is_clause_empty with nested AND 99 + pub fn is_clause_empty_false_with_and_test() { 100 + let nested_clause = where_clause.empty_clause() 101 + let clause = 102 + where_clause.WhereClause( 103 + conditions: dict.new(), 104 + and: Some([nested_clause]), 105 + or: None, 106 + ) 107 + where_clause.is_clause_empty(clause) |> should.be_false 108 + } 109 + 110 + // Test is_clause_empty with nested OR 111 + pub fn is_clause_empty_false_with_or_test() { 112 + let nested_clause = where_clause.empty_clause() 113 + let clause = 114 + where_clause.WhereClause( 115 + conditions: dict.new(), 116 + and: None, 117 + or: Some([nested_clause]), 118 + ) 119 + where_clause.is_clause_empty(clause) |> should.be_false 120 + } 121 + 122 + // Test constructing a condition with multiple operators 123 + pub fn condition_with_multiple_operators_test() { 124 + let condition = 125 + where_clause.WhereCondition( 126 + eq: None, 127 + in_values: None, 128 + contains: None, 129 + gt: Some(sqlight.int(10)), 130 + gte: None, 131 + lt: Some(sqlight.int(100)), 132 + lte: None, 133 + ) 134 + 135 + condition.gt |> should.equal(Some(sqlight.int(10))) 136 + condition.lt |> should.equal(Some(sqlight.int(100))) 137 + where_clause.is_condition_empty(condition) |> should.be_false 138 + } 139 + 140 + // Test constructing a clause with nested AND/OR 141 + pub fn clause_with_nested_and_or_test() { 142 + let and_clause1 = where_clause.WhereClause( 143 + conditions: dict.from_list([ 144 + #( 145 + "artist", 146 + where_clause.WhereCondition( 147 + eq: None, 148 + in_values: None, 149 + contains: Some("pearl jam"), 150 + gt: None, 151 + gte: None, 152 + lt: None, 153 + lte: None, 154 + ), 155 + ), 156 + ]), 157 + and: None, 158 + or: None, 159 + ) 160 + 161 + let or_clause = where_clause.WhereClause( 162 + conditions: dict.new(), 163 + and: None, 164 + or: Some([and_clause1]), 165 + ) 166 + 167 + let root_clause = where_clause.WhereClause( 168 + conditions: dict.new(), 169 + and: Some([or_clause]), 170 + or: None, 171 + ) 172 + 173 + case root_clause.and { 174 + Some(and_list) -> list.length(and_list) |> should.equal(1) 175 + None -> should.fail() 176 + } 177 + }
+680
server/test/where_edge_cases_test.gleam
··· 1 + /// Edge case and error handling tests for where clause functionality 2 + /// 3 + /// Tests various edge cases, error conditions, and potential SQL injection attempts 4 + 5 + import gleam/dict 6 + import gleam/list 7 + import gleam/option.{None, Some} 8 + import gleam/string 9 + import gleeunit 10 + import gleeunit/should 11 + import graphql/value 12 + import lexicon_graphql/where_input 13 + import sqlight 14 + import where_clause 15 + 16 + pub fn main() { 17 + gleeunit.main() 18 + } 19 + 20 + // ===== Empty/Nil Tests ===== 21 + 22 + pub fn empty_where_clause_test() { 23 + let clause = 24 + where_clause.WhereClause(conditions: dict.new(), and: None, or: None) 25 + 26 + let #(sql, params) = where_clause.build_where_sql(clause, False) 27 + 28 + sql |> should.equal("") 29 + list.length(params) |> should.equal(0) 30 + } 31 + 32 + pub fn all_conditions_none_test() { 33 + let condition = 34 + where_clause.WhereCondition( 35 + eq: None, 36 + in_values: None, 37 + contains: None, 38 + gt: None, 39 + gte: None, 40 + lt: None, 41 + lte: None, 42 + ) 43 + let clause = 44 + where_clause.WhereClause( 45 + conditions: dict.from_list([#("field", condition)]), 46 + and: None, 47 + or: None, 48 + ) 49 + 50 + let #(sql, params) = where_clause.build_where_sql(clause, False) 51 + 52 + // Should produce no SQL since all conditions are None 53 + sql |> should.equal("") 54 + list.length(params) |> should.equal(0) 55 + } 56 + 57 + pub fn empty_in_list_test() { 58 + let condition = 59 + where_clause.WhereCondition( 60 + eq: None, 61 + in_values: Some([]), 62 + contains: None, 63 + gt: None, 64 + gte: None, 65 + lt: None, 66 + lte: None, 67 + ) 68 + let clause = 69 + where_clause.WhereClause( 70 + conditions: dict.from_list([#("status", condition)]), 71 + and: None, 72 + or: None, 73 + ) 74 + 75 + let #(sql, params) = where_clause.build_where_sql(clause, False) 76 + 77 + // Empty IN list should produce no SQL 78 + sql |> should.equal("") 79 + list.length(params) |> should.equal(0) 80 + } 81 + 82 + pub fn empty_and_clause_list_test() { 83 + let clause = 84 + where_clause.WhereClause(conditions: dict.new(), and: Some([]), or: None) 85 + 86 + let #(sql, params) = where_clause.build_where_sql(clause, False) 87 + 88 + sql |> should.equal("") 89 + list.length(params) |> should.equal(0) 90 + } 91 + 92 + pub fn empty_or_clause_list_test() { 93 + let clause = 94 + where_clause.WhereClause(conditions: dict.new(), and: None, or: Some([])) 95 + 96 + let #(sql, params) = where_clause.build_where_sql(clause, False) 97 + 98 + sql |> should.equal("") 99 + list.length(params) |> should.equal(0) 100 + } 101 + 102 + // ===== SQL Injection Prevention Tests ===== 103 + 104 + pub fn sql_injection_in_string_value_test() { 105 + // Try common SQL injection patterns - should be safely parameterized 106 + let malicious_strings = [ 107 + "'; DROP TABLE records; --", 108 + "' OR '1'='1", 109 + "admin'--", 110 + "1' UNION SELECT * FROM records--", 111 + "'; DELETE FROM records WHERE ''='", 112 + ] 113 + 114 + list.each(malicious_strings, fn(malicious) { 115 + let condition = 116 + where_clause.WhereCondition( 117 + eq: Some(sqlight.text(malicious)), 118 + in_values: None, 119 + contains: None, 120 + gt: None, 121 + gte: None, 122 + lt: None, 123 + lte: None, 124 + ) 125 + let clause = 126 + where_clause.WhereClause( 127 + conditions: dict.from_list([#("username", condition)]), 128 + and: None, 129 + or: None, 130 + ) 131 + 132 + let #(sql, params) = where_clause.build_where_sql(clause, False) 133 + 134 + // Should use parameterized query with json_extract for non-table columns 135 + sql |> should.equal("json_extract(json, '$.username') = ?") 136 + list.length(params) |> should.equal(1) 137 + 138 + // The malicious string should be in params, not in SQL 139 + // SQL should not contain the injection string 140 + should.be_false(string.contains(sql, "DROP TABLE")) 141 + should.be_false(string.contains(sql, "DELETE FROM")) 142 + }) 143 + } 144 + 145 + pub fn sql_injection_in_contains_test() { 146 + // Contains should also be parameterized 147 + let malicious = "'; DROP TABLE records; --" 148 + 149 + let condition = 150 + where_clause.WhereCondition( 151 + eq: None, 152 + in_values: None, 153 + contains: Some(malicious), 154 + gt: None, 155 + gte: None, 156 + lt: None, 157 + lte: None, 158 + ) 159 + let clause = 160 + where_clause.WhereClause( 161 + conditions: dict.from_list([#("description", condition)]), 162 + and: None, 163 + or: None, 164 + ) 165 + 166 + let #(sql, params) = where_clause.build_where_sql(clause, False) 167 + 168 + // Should use LIKE with parameterized value and json_extract for non-table fields 169 + sql 170 + |> should.equal( 171 + "json_extract(json, '$.description') LIKE '%' || ? || '%' COLLATE NOCASE", 172 + ) 173 + list.length(params) |> should.equal(1) 174 + } 175 + 176 + // ===== Type Edge Cases ===== 177 + 178 + pub fn integer_boundary_values_test() { 179 + // Test with very large and very small integers 180 + let large_int = 2_147_483_647 181 + let small_int = -2_147_483_648 182 + 183 + let condition_large = 184 + where_clause.WhereCondition( 185 + eq: Some(sqlight.int(large_int)), 186 + in_values: None, 187 + contains: None, 188 + gt: None, 189 + gte: None, 190 + lt: None, 191 + lte: None, 192 + ) 193 + 194 + let condition_small = 195 + where_clause.WhereCondition( 196 + eq: Some(sqlight.int(small_int)), 197 + in_values: None, 198 + contains: None, 199 + gt: None, 200 + gte: None, 201 + lt: None, 202 + lte: None, 203 + ) 204 + 205 + let clause_large = 206 + where_clause.WhereClause( 207 + conditions: dict.from_list([#("count", condition_large)]), 208 + and: None, 209 + or: None, 210 + ) 211 + 212 + let clause_small = 213 + where_clause.WhereClause( 214 + conditions: dict.from_list([#("count", condition_small)]), 215 + and: None, 216 + or: None, 217 + ) 218 + 219 + let #(sql_large, params_large) = where_clause.build_where_sql(clause_large, False) 220 + let #(sql_small, params_small) = where_clause.build_where_sql(clause_small, False) 221 + 222 + // count is a JSON field, not a table column 223 + sql_large |> should.equal("json_extract(json, '$.count') = ?") 224 + list.length(params_large) |> should.equal(1) 225 + 226 + sql_small |> should.equal("json_extract(json, '$.count') = ?") 227 + list.length(params_small) |> should.equal(1) 228 + } 229 + 230 + pub fn empty_string_value_test() { 231 + let condition = 232 + where_clause.WhereCondition( 233 + eq: Some(sqlight.text("")), 234 + in_values: None, 235 + contains: None, 236 + gt: None, 237 + gte: None, 238 + lt: None, 239 + lte: None, 240 + ) 241 + let clause = 242 + where_clause.WhereClause( 243 + conditions: dict.from_list([#("name", condition)]), 244 + and: None, 245 + or: None, 246 + ) 247 + 248 + let #(sql, params) = where_clause.build_where_sql(clause, False) 249 + 250 + // Empty string is still valid - should use json_extract for non-table columns 251 + sql |> should.equal("json_extract(json, '$.name') = ?") 252 + list.length(params) |> should.equal(1) 253 + } 254 + 255 + pub fn unicode_string_value_test() { 256 + // Test with various Unicode characters 257 + let unicode_strings = [ 258 + "Hello 世界", 259 + "🚀 Rocket", 260 + "Café", 261 + "Москва", 262 + "مرحبا", 263 + ] 264 + 265 + list.each(unicode_strings, fn(unicode_str) { 266 + let condition = 267 + where_clause.WhereCondition( 268 + eq: Some(sqlight.text(unicode_str)), 269 + in_values: None, 270 + contains: None, 271 + gt: None, 272 + gte: None, 273 + lt: None, 274 + lte: None, 275 + ) 276 + let clause = 277 + where_clause.WhereClause( 278 + conditions: dict.from_list([#("text", condition)]), 279 + and: None, 280 + or: None, 281 + ) 282 + 283 + let #(sql, params) = where_clause.build_where_sql(clause, False) 284 + 285 + sql |> should.equal("json_extract(json, '$.text') = ?") 286 + list.length(params) |> should.equal(1) 287 + }) 288 + } 289 + 290 + // ===== Complex Nesting Edge Cases ===== 291 + 292 + pub fn deeply_nested_and_clauses_test() { 293 + // Create deeply nested AND clauses (5 levels) 294 + let inner_condition = 295 + where_clause.WhereCondition( 296 + eq: Some(sqlight.text("value")), 297 + in_values: None, 298 + contains: None, 299 + gt: None, 300 + gte: None, 301 + lt: None, 302 + lte: None, 303 + ) 304 + 305 + let level1 = 306 + where_clause.WhereClause( 307 + conditions: dict.from_list([#("field1", inner_condition)]), 308 + and: None, 309 + or: None, 310 + ) 311 + let level2 = 312 + where_clause.WhereClause( 313 + conditions: dict.new(), 314 + and: Some([level1]), 315 + or: None, 316 + ) 317 + let level3 = 318 + where_clause.WhereClause( 319 + conditions: dict.new(), 320 + and: Some([level2]), 321 + or: None, 322 + ) 323 + let level4 = 324 + where_clause.WhereClause( 325 + conditions: dict.new(), 326 + and: Some([level3]), 327 + or: None, 328 + ) 329 + let level5 = 330 + where_clause.WhereClause( 331 + conditions: dict.new(), 332 + and: Some([level4]), 333 + or: None, 334 + ) 335 + 336 + let #(sql, params) = where_clause.build_where_sql(level5, False) 337 + 338 + // With single condition at each level, no extra parentheses needed 339 + // The implementation correctly doesn't add unnecessary parentheses for single conditions 340 + sql |> should.equal("json_extract(json, '$.field1') = ?") 341 + list.length(params) |> should.equal(1) 342 + } 343 + 344 + pub fn mixed_empty_and_non_empty_conditions_test() { 345 + // Mix conditions with Some and None values 346 + let condition1 = 347 + where_clause.WhereCondition( 348 + eq: Some(sqlight.text("value1")), 349 + in_values: None, 350 + contains: None, 351 + gt: None, 352 + gte: None, 353 + lt: None, 354 + lte: None, 355 + ) 356 + 357 + let condition2 = 358 + where_clause.WhereCondition( 359 + eq: None, 360 + in_values: None, 361 + contains: None, 362 + gt: None, 363 + gte: None, 364 + lt: None, 365 + lte: None, 366 + ) 367 + 368 + let condition3 = 369 + where_clause.WhereCondition( 370 + eq: Some(sqlight.text("value3")), 371 + in_values: None, 372 + contains: None, 373 + gt: None, 374 + gte: None, 375 + lt: None, 376 + lte: None, 377 + ) 378 + 379 + let clause = 380 + where_clause.WhereClause( 381 + conditions: dict.from_list([ 382 + #("field1", condition1), 383 + #("field2", condition2), 384 + #("field3", condition3), 385 + ]), 386 + and: None, 387 + or: None, 388 + ) 389 + 390 + let #(sql, params) = where_clause.build_where_sql(clause, False) 391 + 392 + // Should only include non-empty conditions 393 + list.length(params) |> should.equal(2) 394 + // SQL should contain field1 and field3 (with json_extract), but not field2 395 + // Dict iteration order is not guaranteed, so check for both possible orders 396 + let expected1 = 397 + "json_extract(json, '$.field1') = ? AND json_extract(json, '$.field3') = ?" 398 + let expected2 = 399 + "json_extract(json, '$.field3') = ? AND json_extract(json, '$.field1') = ?" 400 + should.be_true(sql == expected1 || sql == expected2) 401 + } 402 + 403 + // ===== GraphQL Parser Edge Cases ===== 404 + 405 + pub fn parse_invalid_graphql_value_test() { 406 + // Parse a non-object value 407 + let invalid = value.String("not an object") 408 + 409 + let result = where_input.parse_where_clause(invalid) 410 + 411 + // Should return empty clause 412 + where_input.is_clause_empty(result) |> should.be_true 413 + } 414 + 415 + pub fn parse_empty_object_test() { 416 + let empty_object = value.Object([]) 417 + 418 + let result = where_input.parse_where_clause(empty_object) 419 + 420 + where_input.is_clause_empty(result) |> should.be_true 421 + } 422 + 423 + pub fn parse_unknown_operator_test() { 424 + // Include an unknown operator that should be ignored 425 + let condition_value = 426 + value.Object([ 427 + #("eq", value.String("test")), 428 + #("unknown_op", value.String("should_be_ignored")), 429 + ]) 430 + 431 + let where_object = 432 + value.Object([#("field1", condition_value)]) 433 + 434 + let result = where_input.parse_where_clause(where_object) 435 + 436 + // Should parse eq but ignore unknown_op 437 + let conditions = result.conditions 438 + case dict.get(conditions, "field1") { 439 + Ok(cond) -> { 440 + // eq should be parsed 441 + case cond.eq { 442 + Some(where_input.StringValue("test")) -> should.be_true(True) 443 + _ -> should.fail() 444 + } 445 + } 446 + Error(_) -> should.fail() 447 + } 448 + } 449 + 450 + pub fn parse_type_mismatch_in_operator_test() { 451 + // Try to parse eq with a list instead of a scalar 452 + let condition_value = 453 + value.Object([ 454 + #("eq", value.List([value.String("test")])), 455 + ]) 456 + 457 + let where_object = 458 + value.Object([#("field1", condition_value)]) 459 + 460 + let result = where_input.parse_where_clause(where_object) 461 + 462 + // Should handle gracefully - eq should be None 463 + let conditions = result.conditions 464 + case dict.get(conditions, "field1") { 465 + Ok(cond) -> { 466 + case cond.eq { 467 + None -> should.be_true(True) 468 + _ -> should.fail() 469 + } 470 + } 471 + Error(_) -> should.fail() 472 + } 473 + } 474 + 475 + pub fn parse_mixed_types_in_in_list_test() { 476 + // IN list with mixed types - should filter out invalid ones 477 + let condition_value = 478 + value.Object([ 479 + #( 480 + "in", 481 + value.List([ 482 + value.String("valid1"), 483 + value.Int(42), 484 + value.List([]), 485 + // Invalid - nested list 486 + value.String("valid2"), 487 + value.Object([]), 488 + // Invalid - object 489 + ]), 490 + ), 491 + ]) 492 + 493 + let where_object = 494 + value.Object([#("field1", condition_value)]) 495 + 496 + let result = where_input.parse_where_clause(where_object) 497 + 498 + // Should only include valid scalar values 499 + let conditions = result.conditions 500 + case dict.get(conditions, "field1") { 501 + Ok(cond) -> { 502 + case cond.in_values { 503 + Some(values) -> { 504 + // Should have 3 valid values (2 strings + 1 int) 505 + list.length(values) |> should.equal(3) 506 + } 507 + None -> should.fail() 508 + } 509 + } 510 + Error(_) -> should.fail() 511 + } 512 + } 513 + 514 + // ===== Multiple Operators on Same Field ===== 515 + 516 + pub fn multiple_operators_same_field_test() { 517 + // Test range query: gt AND lt on same field 518 + let condition = 519 + where_clause.WhereCondition( 520 + eq: None, 521 + in_values: None, 522 + contains: None, 523 + gt: Some(sqlight.int(10)), 524 + gte: None, 525 + lt: Some(sqlight.int(100)), 526 + lte: None, 527 + ) 528 + let clause = 529 + where_clause.WhereClause( 530 + conditions: dict.from_list([#("age", condition)]), 531 + and: None, 532 + or: None, 533 + ) 534 + 535 + let #(sql, params) = where_clause.build_where_sql(clause, False) 536 + 537 + // Should combine both operators with json_extract 538 + sql 539 + |> should.equal( 540 + "json_extract(json, '$.age') > ? AND json_extract(json, '$.age') < ?", 541 + ) 542 + list.length(params) |> should.equal(2) 543 + } 544 + 545 + pub fn conflicting_operators_test() { 546 + // eq and in on same field - both should be applied with AND 547 + let condition = 548 + where_clause.WhereCondition( 549 + eq: Some(sqlight.text("exact")), 550 + in_values: Some([sqlight.text("val1"), sqlight.text("val2")]), 551 + contains: None, 552 + gt: None, 553 + gte: None, 554 + lt: None, 555 + lte: None, 556 + ) 557 + let clause = 558 + where_clause.WhereClause( 559 + conditions: dict.from_list([#("status", condition)]), 560 + and: None, 561 + or: None, 562 + ) 563 + 564 + let #(sql, params) = where_clause.build_where_sql(clause, False) 565 + 566 + // Should apply both (though logically this might not make sense) 567 + list.length(params) |> should.equal(3) 568 + // Check for both possible orders with json_extract 569 + let expected1 = 570 + "json_extract(json, '$.status') = ? AND json_extract(json, '$.status') IN (?, ?)" 571 + let expected2 = 572 + "json_extract(json, '$.status') IN (?, ?) AND json_extract(json, '$.status') = ?" 573 + should.be_true(sql == expected1 || sql == expected2) 574 + } 575 + 576 + // ===== Large IN Lists ===== 577 + 578 + pub fn large_in_list_test() { 579 + // Test with 100 items in IN list 580 + let large_list = 581 + list.range(1, 100) 582 + |> list.map(fn(i) { sqlight.int(i) }) 583 + 584 + let condition = 585 + where_clause.WhereCondition( 586 + eq: None, 587 + in_values: Some(large_list), 588 + contains: None, 589 + gt: None, 590 + gte: None, 591 + lt: None, 592 + lte: None, 593 + ) 594 + let clause = 595 + where_clause.WhereClause( 596 + conditions: dict.from_list([#("id", condition)]), 597 + and: None, 598 + or: None, 599 + ) 600 + 601 + let #(sql, params) = where_clause.build_where_sql(clause, False) 602 + 603 + // Should generate correct number of placeholders 604 + list.length(params) |> should.equal(100) 605 + // Check SQL has correct IN clause structure 606 + should.be_true(sql |> fn(s) { 607 + s 608 + |> fn(str) { 609 + str 610 + |> fn(_) { True } 611 + } 612 + }) 613 + } 614 + 615 + // ===== Special Characters in Field Names ===== 616 + 617 + pub fn field_name_with_json_path_test() { 618 + // Test JSON path field names 619 + let condition = 620 + where_clause.WhereCondition( 621 + eq: Some(sqlight.text("value")), 622 + in_values: None, 623 + contains: None, 624 + gt: None, 625 + gte: None, 626 + lt: None, 627 + lte: None, 628 + ) 629 + let clause = 630 + where_clause.WhereClause( 631 + conditions: dict.from_list([#("value.nested.field", condition)]), 632 + and: None, 633 + or: None, 634 + ) 635 + 636 + let #(sql, params) = where_clause.build_where_sql(clause, False) 637 + 638 + // Should use json_extract for dotted field names 639 + sql 640 + |> should.equal( 641 + "json_extract(json, '$.value.nested.field') = ?", 642 + ) 643 + list.length(params) |> should.equal(1) 644 + } 645 + 646 + pub fn field_name_with_special_chars_test() { 647 + // Field names with underscores, numbers, etc. 648 + let special_field_names = [ 649 + "field_with_underscore", 650 + "field123", 651 + "field_123_test", 652 + "UPPERCASE_FIELD", 653 + ] 654 + 655 + list.each(special_field_names, fn(field_name) { 656 + let condition = 657 + where_clause.WhereCondition( 658 + eq: Some(sqlight.text("test")), 659 + in_values: None, 660 + contains: None, 661 + gt: None, 662 + gte: None, 663 + lt: None, 664 + lte: None, 665 + ) 666 + let clause = 667 + where_clause.WhereClause( 668 + conditions: dict.from_list([#(field_name, condition)]), 669 + and: None, 670 + or: None, 671 + ) 672 + 673 + let #(sql, params) = where_clause.build_where_sql(clause, False) 674 + 675 + // Should use json_extract even for non-dotted field names 676 + let expected = "json_extract(json, '$." <> field_name <> "') = ?" 677 + sql |> should.equal(expected) 678 + list.length(params) |> should.equal(1) 679 + }) 680 + }
+433
server/test/where_integration_test.gleam
··· 1 + import gleeunit 2 + import gleeunit/should 3 + import gleam/dict 4 + import gleam/option.{None, Some} 5 + import gleam/list 6 + import gleam/string 7 + import gleam/result 8 + import sqlight 9 + import database 10 + import where_clause 11 + 12 + pub fn main() { 13 + gleeunit.main() 14 + } 15 + 16 + // Helper to setup test database with sample records 17 + fn setup_test_db() -> Result(sqlight.Connection, sqlight.Error) { 18 + use conn <- result.try(sqlight.open(":memory:")) 19 + use _ <- result.try(database.create_record_table(conn)) 20 + 21 + // Insert test records 22 + use _ <- result.try(database.insert_record( 23 + conn, 24 + "at://did:plc:1/app.bsky.feed.post/1", 25 + "cid1", 26 + "did:plc:1", 27 + "app.bsky.feed.post", 28 + "{\"text\":\"Hello World\",\"likes\":100}", 29 + )) 30 + 31 + use _ <- result.try(database.insert_record( 32 + conn, 33 + "at://did:plc:2/app.bsky.feed.post/2", 34 + "cid2", 35 + "did:plc:2", 36 + "app.bsky.feed.post", 37 + "{\"text\":\"Goodbye World\",\"likes\":50}", 38 + )) 39 + 40 + use _ <- result.try(database.insert_record( 41 + conn, 42 + "at://did:plc:3/app.bsky.feed.post/3", 43 + "cid3", 44 + "did:plc:3", 45 + "app.bsky.feed.post", 46 + "{\"text\":\"Test post\",\"likes\":200}", 47 + )) 48 + 49 + use _ <- result.try(database.insert_record( 50 + conn, 51 + "at://did:plc:1/app.bsky.actor.profile/1", 52 + "cid4", 53 + "did:plc:1", 54 + "app.bsky.actor.profile", 55 + "{\"displayName\":\"Alice\"}", 56 + )) 57 + 58 + Ok(conn) 59 + } 60 + 61 + // Test: Filter by DID (table column) 62 + pub fn filter_by_did_test() { 63 + let assert Ok(conn) = setup_test_db() 64 + 65 + let where_clause = where_clause.WhereClause( 66 + conditions: dict.from_list([ 67 + #("did", where_clause.WhereCondition( 68 + eq: Some(sqlight.text("did:plc:1")), 69 + in_values: None, 70 + contains: None, 71 + gt: None, 72 + gte: None, 73 + lt: None, 74 + lte: None, 75 + )), 76 + ]), 77 + and: None, 78 + or: None, 79 + ) 80 + 81 + let result = 82 + database.get_records_by_collection_paginated_with_where( 83 + conn, 84 + "app.bsky.feed.post", 85 + Some(10), 86 + None, 87 + None, 88 + None, 89 + None, 90 + Some(where_clause), 91 + ) 92 + 93 + case result { 94 + Ok(#(records, _, _, _)) -> { 95 + list.length(records) |> should.equal(1) 96 + case list.first(records) { 97 + Ok(record) -> record.did |> should.equal("did:plc:1") 98 + Error(_) -> should.fail() 99 + } 100 + } 101 + Error(_) -> should.fail() 102 + } 103 + } 104 + 105 + // Test: Filter by JSON field with contains 106 + pub fn filter_by_json_contains_test() { 107 + let assert Ok(conn) = setup_test_db() 108 + 109 + let where_clause = where_clause.WhereClause( 110 + conditions: dict.from_list([ 111 + #("text", where_clause.WhereCondition( 112 + eq: None, 113 + in_values: None, 114 + contains: Some("Hello"), 115 + gt: None, 116 + gte: None, 117 + lt: None, 118 + lte: None, 119 + )), 120 + ]), 121 + and: None, 122 + or: None, 123 + ) 124 + 125 + let result = 126 + database.get_records_by_collection_paginated_with_where( 127 + conn, 128 + "app.bsky.feed.post", 129 + Some(10), 130 + None, 131 + None, 132 + None, 133 + None, 134 + Some(where_clause), 135 + ) 136 + 137 + case result { 138 + Ok(#(records, _, _, _)) -> { 139 + list.length(records) |> should.equal(1) 140 + case list.first(records) { 141 + Ok(record) -> should.be_true(string.contains(record.json, "Hello")) 142 + Error(_) -> should.fail() 143 + } 144 + } 145 + Error(_) -> should.fail() 146 + } 147 + } 148 + 149 + // Test: Filter by JSON field comparison (gt) 150 + pub fn filter_by_json_comparison_test() { 151 + let assert Ok(conn) = setup_test_db() 152 + 153 + let where_clause = where_clause.WhereClause( 154 + conditions: dict.from_list([ 155 + #("likes", where_clause.WhereCondition( 156 + eq: None, 157 + in_values: None, 158 + contains: None, 159 + gt: Some(sqlight.int(75)), 160 + gte: None, 161 + lt: None, 162 + lte: None, 163 + )), 164 + ]), 165 + and: None, 166 + or: None, 167 + ) 168 + 169 + let result = 170 + database.get_records_by_collection_paginated_with_where( 171 + conn, 172 + "app.bsky.feed.post", 173 + Some(10), 174 + None, 175 + None, 176 + None, 177 + None, 178 + Some(where_clause), 179 + ) 180 + 181 + case result { 182 + Ok(#(records, _, _, _)) -> { 183 + // Should match records with likes > 75 (100 and 200) 184 + list.length(records) |> should.equal(2) 185 + } 186 + Error(_) -> should.fail() 187 + } 188 + } 189 + 190 + // Test: Range query with gte and lt 191 + pub fn filter_range_query_test() { 192 + let assert Ok(conn) = setup_test_db() 193 + 194 + let where_clause = where_clause.WhereClause( 195 + conditions: dict.from_list([ 196 + #("likes", where_clause.WhereCondition( 197 + eq: None, 198 + in_values: None, 199 + contains: None, 200 + gt: None, 201 + gte: Some(sqlight.int(50)), 202 + lt: Some(sqlight.int(150)), 203 + lte: None, 204 + )), 205 + ]), 206 + and: None, 207 + or: None, 208 + ) 209 + 210 + let result = 211 + database.get_records_by_collection_paginated_with_where( 212 + conn, 213 + "app.bsky.feed.post", 214 + Some(10), 215 + None, 216 + None, 217 + None, 218 + None, 219 + Some(where_clause), 220 + ) 221 + 222 + case result { 223 + Ok(#(records, _, _, _)) -> { 224 + // Should match records with 50 <= likes < 150 (50 and 100) 225 + list.length(records) |> should.equal(2) 226 + } 227 + Error(_) -> should.fail() 228 + } 229 + } 230 + 231 + // Test: Nested AND with multiple conditions 232 + pub fn filter_nested_and_test() { 233 + let assert Ok(conn) = setup_test_db() 234 + 235 + let did_clause = where_clause.WhereClause( 236 + conditions: dict.from_list([ 237 + #("did", where_clause.WhereCondition( 238 + eq: Some(sqlight.text("did:plc:1")), 239 + in_values: None, 240 + contains: None, 241 + gt: None, 242 + gte: None, 243 + lt: None, 244 + lte: None, 245 + )), 246 + ]), 247 + and: None, 248 + or: None, 249 + ) 250 + 251 + let likes_clause = where_clause.WhereClause( 252 + conditions: dict.from_list([ 253 + #("likes", where_clause.WhereCondition( 254 + eq: None, 255 + in_values: None, 256 + contains: None, 257 + gt: Some(sqlight.int(50)), 258 + gte: None, 259 + lt: None, 260 + lte: None, 261 + )), 262 + ]), 263 + and: None, 264 + or: None, 265 + ) 266 + 267 + let root_clause = where_clause.WhereClause( 268 + conditions: dict.new(), 269 + and: Some([did_clause, likes_clause]), 270 + or: None, 271 + ) 272 + 273 + let result = 274 + database.get_records_by_collection_paginated_with_where( 275 + conn, 276 + "app.bsky.feed.post", 277 + Some(10), 278 + None, 279 + None, 280 + None, 281 + None, 282 + Some(root_clause), 283 + ) 284 + 285 + case result { 286 + Ok(#(records, _, _, _)) -> { 287 + // Should only match did:plc:1 with likes > 50 (the first record) 288 + list.length(records) |> should.equal(1) 289 + case list.first(records) { 290 + Ok(record) -> { 291 + record.did |> should.equal("did:plc:1") 292 + should.be_true(string.contains(record.json, "100")) 293 + } 294 + Error(_) -> should.fail() 295 + } 296 + } 297 + Error(_) -> should.fail() 298 + } 299 + } 300 + 301 + // Test: Nested OR with multiple conditions 302 + pub fn filter_nested_or_test() { 303 + let assert Ok(conn) = setup_test_db() 304 + 305 + let did1_clause = where_clause.WhereClause( 306 + conditions: dict.from_list([ 307 + #("did", where_clause.WhereCondition( 308 + eq: Some(sqlight.text("did:plc:1")), 309 + in_values: None, 310 + contains: None, 311 + gt: None, 312 + gte: None, 313 + lt: None, 314 + lte: None, 315 + )), 316 + ]), 317 + and: None, 318 + or: None, 319 + ) 320 + 321 + let did2_clause = where_clause.WhereClause( 322 + conditions: dict.from_list([ 323 + #("did", where_clause.WhereCondition( 324 + eq: Some(sqlight.text("did:plc:2")), 325 + in_values: None, 326 + contains: None, 327 + gt: None, 328 + gte: None, 329 + lt: None, 330 + lte: None, 331 + )), 332 + ]), 333 + and: None, 334 + or: None, 335 + ) 336 + 337 + let root_clause = where_clause.WhereClause( 338 + conditions: dict.new(), 339 + and: None, 340 + or: Some([did1_clause, did2_clause]), 341 + ) 342 + 343 + let result = 344 + database.get_records_by_collection_paginated_with_where( 345 + conn, 346 + "app.bsky.feed.post", 347 + Some(10), 348 + None, 349 + None, 350 + None, 351 + None, 352 + Some(root_clause), 353 + ) 354 + 355 + case result { 356 + Ok(#(records, _, _, _)) -> { 357 + // Should match both did:plc:1 and did:plc:2 358 + list.length(records) |> should.equal(2) 359 + } 360 + Error(_) -> should.fail() 361 + } 362 + } 363 + 364 + // Test: Empty where clause returns all records 365 + pub fn filter_empty_where_test() { 366 + let assert Ok(conn) = setup_test_db() 367 + 368 + let where_clause = where_clause.empty_clause() 369 + 370 + let result = 371 + database.get_records_by_collection_paginated_with_where( 372 + conn, 373 + "app.bsky.feed.post", 374 + Some(10), 375 + None, 376 + None, 377 + None, 378 + None, 379 + Some(where_clause), 380 + ) 381 + 382 + case result { 383 + Ok(#(records, _, _, _)) -> { 384 + // Should return all 3 posts 385 + list.length(records) |> should.equal(3) 386 + } 387 + Error(_) -> should.fail() 388 + } 389 + } 390 + 391 + // Test: Where clause with pagination 392 + pub fn filter_with_pagination_test() { 393 + let assert Ok(conn) = setup_test_db() 394 + 395 + let where_clause = where_clause.WhereClause( 396 + conditions: dict.from_list([ 397 + #("likes", where_clause.WhereCondition( 398 + eq: None, 399 + in_values: None, 400 + contains: None, 401 + gt: Some(sqlight.int(25)), 402 + gte: None, 403 + lt: None, 404 + lte: None, 405 + )), 406 + ]), 407 + and: None, 408 + or: None, 409 + ) 410 + 411 + // First page: limit 2 412 + let result = 413 + database.get_records_by_collection_paginated_with_where( 414 + conn, 415 + "app.bsky.feed.post", 416 + Some(2), 417 + None, 418 + None, 419 + None, 420 + None, 421 + Some(where_clause), 422 + ) 423 + 424 + case result { 425 + Ok(#(records, next_cursor, has_next, has_prev)) -> { 426 + list.length(records) |> should.equal(2) 427 + should.be_true(has_next) 428 + should.be_false(has_prev) 429 + should.be_true(option.is_some(next_cursor)) 430 + } 431 + Error(_) -> should.fail() 432 + } 433 + }
+1144
server/test/where_sql_builder_test.gleam
··· 1 + import gleam/dict 2 + import gleam/list 3 + import gleam/option.{None, Some} 4 + import gleam/string 5 + import gleeunit 6 + import gleeunit/should 7 + import sqlight 8 + import where_clause 9 + 10 + pub fn main() { 11 + gleeunit.main() 12 + } 13 + 14 + // Test: Empty where clause should produce empty SQL 15 + pub fn build_where_empty_clause_test() { 16 + let clause = where_clause.empty_clause() 17 + let #(sql, params) = where_clause.build_where_sql(clause, False) 18 + 19 + sql |> should.equal("") 20 + list.length(params) |> should.equal(0) 21 + } 22 + 23 + // Test: Single eq operator on table column 24 + pub fn build_where_eq_on_table_column_test() { 25 + let condition = 26 + where_clause.WhereCondition( 27 + eq: Some(sqlight.text("app.bsky.feed.post")), 28 + in_values: None, 29 + contains: None, 30 + gt: None, 31 + gte: None, 32 + lt: None, 33 + lte: None, 34 + ) 35 + let clause = 36 + where_clause.WhereClause( 37 + conditions: dict.from_list([#("collection", condition)]), 38 + and: None, 39 + or: None, 40 + ) 41 + 42 + let #(sql, params) = where_clause.build_where_sql(clause, False) 43 + 44 + sql |> should.equal("collection = ?") 45 + list.length(params) |> should.equal(1) 46 + } 47 + 48 + // Test: in operator with multiple values 49 + pub fn build_where_in_operator_test() { 50 + let condition = 51 + where_clause.WhereCondition( 52 + eq: None, 53 + in_values: Some([ 54 + sqlight.text("did1"), 55 + sqlight.text("did2"), 56 + sqlight.text("did3"), 57 + ]), 58 + contains: None, 59 + gt: None, 60 + gte: None, 61 + lt: None, 62 + lte: None, 63 + ) 64 + let clause = 65 + where_clause.WhereClause( 66 + conditions: dict.from_list([#("did", condition)]), 67 + and: None, 68 + or: None, 69 + ) 70 + 71 + let #(sql, params) = where_clause.build_where_sql(clause, False) 72 + 73 + sql |> should.equal("did IN (?, ?, ?)") 74 + list.length(params) |> should.equal(3) 75 + } 76 + 77 + // Test: gt operator on indexed_at 78 + pub fn build_where_gt_operator_test() { 79 + let condition = 80 + where_clause.WhereCondition( 81 + eq: None, 82 + in_values: None, 83 + contains: None, 84 + gt: Some(sqlight.text("2024-01-01T00:00:00Z")), 85 + gte: None, 86 + lt: None, 87 + lte: None, 88 + ) 89 + let clause = 90 + where_clause.WhereClause( 91 + conditions: dict.from_list([#("indexed_at", condition)]), 92 + and: None, 93 + or: None, 94 + ) 95 + 96 + let #(sql, params) = where_clause.build_where_sql(clause, False) 97 + 98 + sql |> should.equal("indexed_at > ?") 99 + list.length(params) |> should.equal(1) 100 + } 101 + 102 + // Test: gte operator 103 + pub fn build_where_gte_operator_test() { 104 + let condition = 105 + where_clause.WhereCondition( 106 + eq: None, 107 + in_values: None, 108 + contains: None, 109 + gt: None, 110 + gte: Some(sqlight.int(2000)), 111 + lt: None, 112 + lte: None, 113 + ) 114 + let clause = 115 + where_clause.WhereClause( 116 + conditions: dict.from_list([#("year", condition)]), 117 + and: None, 118 + or: None, 119 + ) 120 + 121 + let #(sql, params) = where_clause.build_where_sql(clause, False) 122 + 123 + sql |> should.equal("json_extract(json, '$.year') >= ?") 124 + list.length(params) |> should.equal(1) 125 + } 126 + 127 + // Test: lt operator 128 + pub fn build_where_lt_operator_test() { 129 + let condition = 130 + where_clause.WhereCondition( 131 + eq: None, 132 + in_values: None, 133 + contains: None, 134 + gt: None, 135 + gte: None, 136 + lt: Some(sqlight.text("2024-12-31T23:59:59Z")), 137 + lte: None, 138 + ) 139 + let clause = 140 + where_clause.WhereClause( 141 + conditions: dict.from_list([#("indexed_at", condition)]), 142 + and: None, 143 + or: None, 144 + ) 145 + 146 + let #(sql, params) = where_clause.build_where_sql(clause, False) 147 + 148 + sql |> should.equal("indexed_at < ?") 149 + list.length(params) |> should.equal(1) 150 + } 151 + 152 + // Test: lte operator 153 + pub fn build_where_lte_operator_test() { 154 + let condition = 155 + where_clause.WhereCondition( 156 + eq: None, 157 + in_values: None, 158 + contains: None, 159 + gt: None, 160 + gte: None, 161 + lt: None, 162 + lte: Some(sqlight.int(100)), 163 + ) 164 + let clause = 165 + where_clause.WhereClause( 166 + conditions: dict.from_list([#("count", condition)]), 167 + and: None, 168 + or: None, 169 + ) 170 + 171 + let #(sql, params) = where_clause.build_where_sql(clause, False) 172 + 173 + sql |> should.equal("json_extract(json, '$.count') <= ?") 174 + list.length(params) |> should.equal(1) 175 + } 176 + 177 + // Test: Range query with both gt and lt 178 + pub fn build_where_range_query_test() { 179 + let condition = 180 + where_clause.WhereCondition( 181 + eq: None, 182 + in_values: None, 183 + contains: None, 184 + gt: Some(sqlight.text("2024-01-01T00:00:00Z")), 185 + gte: None, 186 + lt: Some(sqlight.text("2024-02-01T00:00:00Z")), 187 + lte: None, 188 + ) 189 + let clause = 190 + where_clause.WhereClause( 191 + conditions: dict.from_list([#("indexed_at", condition)]), 192 + and: None, 193 + or: None, 194 + ) 195 + 196 + let #(sql, params) = where_clause.build_where_sql(clause, False) 197 + 198 + // Should combine both conditions with AND 199 + sql |> should.equal("indexed_at > ? AND indexed_at < ?") 200 + list.length(params) |> should.equal(2) 201 + } 202 + 203 + // Test: Multiple fields combined with AND 204 + pub fn build_where_multiple_fields_test() { 205 + let cond1 = 206 + where_clause.WhereCondition( 207 + eq: Some(sqlight.text("app.bsky.feed.post")), 208 + in_values: None, 209 + contains: None, 210 + gt: None, 211 + gte: None, 212 + lt: None, 213 + lte: None, 214 + ) 215 + let cond2 = 216 + where_clause.WhereCondition( 217 + eq: Some(sqlight.text("did:plc:xyz")), 218 + in_values: None, 219 + contains: None, 220 + gt: None, 221 + gte: None, 222 + lt: None, 223 + lte: None, 224 + ) 225 + let clause = 226 + where_clause.WhereClause( 227 + conditions: dict.from_list([ 228 + #("collection", cond1), 229 + #("did", cond2), 230 + ]), 231 + and: None, 232 + or: None, 233 + ) 234 + 235 + let #(sql, params) = where_clause.build_where_sql(clause, False) 236 + 237 + // Order might vary due to dict, but should have AND 238 + should.be_true(string.contains(sql, "AND")) 239 + should.be_true(string.contains(sql, "collection = ?")) 240 + should.be_true(string.contains(sql, "did = ?")) 241 + list.length(params) |> should.equal(2) 242 + } 243 + 244 + // Phase 3 Tests: JSON Field Filtering 245 + 246 + // Test: Simple JSON field with eq operator 247 + pub fn build_where_json_field_eq_test() { 248 + let condition = 249 + where_clause.WhereCondition( 250 + eq: Some(sqlight.text("Hello World")), 251 + in_values: None, 252 + contains: None, 253 + gt: None, 254 + gte: None, 255 + lt: None, 256 + lte: None, 257 + ) 258 + let clause = 259 + where_clause.WhereClause( 260 + conditions: dict.from_list([#("text", condition)]), 261 + and: None, 262 + or: None, 263 + ) 264 + 265 + let #(sql, params) = where_clause.build_where_sql(clause, False) 266 + 267 + sql |> should.equal("json_extract(json, '$.text') = ?") 268 + list.length(params) |> should.equal(1) 269 + } 270 + 271 + // Test: Nested JSON path (dot notation) 272 + pub fn build_where_nested_json_path_test() { 273 + let condition = 274 + where_clause.WhereCondition( 275 + eq: Some(sqlight.text("Alice")), 276 + in_values: None, 277 + contains: None, 278 + gt: None, 279 + gte: None, 280 + lt: None, 281 + lte: None, 282 + ) 283 + let clause = 284 + where_clause.WhereClause( 285 + conditions: dict.from_list([#("user.name", condition)]), 286 + and: None, 287 + or: None, 288 + ) 289 + 290 + let #(sql, params) = where_clause.build_where_sql(clause, False) 291 + 292 + sql |> should.equal("json_extract(json, '$.user.name') = ?") 293 + list.length(params) |> should.equal(1) 294 + } 295 + 296 + // Test: Deeply nested JSON path 297 + pub fn build_where_deeply_nested_json_path_test() { 298 + let condition = 299 + where_clause.WhereCondition( 300 + eq: Some(sqlight.text("value")), 301 + in_values: None, 302 + contains: None, 303 + gt: None, 304 + gte: None, 305 + lt: None, 306 + lte: None, 307 + ) 308 + let clause = 309 + where_clause.WhereClause( 310 + conditions: dict.from_list([#("metadata.tags.0", condition)]), 311 + and: None, 312 + or: None, 313 + ) 314 + 315 + let #(sql, params) = where_clause.build_where_sql(clause, False) 316 + 317 + sql |> should.equal("json_extract(json, '$.metadata.tags.0') = ?") 318 + list.length(params) |> should.equal(1) 319 + } 320 + 321 + // Test: JSON field with comparison operators 322 + pub fn build_where_json_field_comparison_test() { 323 + let condition = 324 + where_clause.WhereCondition( 325 + eq: None, 326 + in_values: None, 327 + contains: None, 328 + gt: Some(sqlight.int(100)), 329 + gte: None, 330 + lt: Some(sqlight.int(1000)), 331 + lte: None, 332 + ) 333 + let clause = 334 + where_clause.WhereClause( 335 + conditions: dict.from_list([#("likes", condition)]), 336 + and: None, 337 + or: None, 338 + ) 339 + 340 + let #(sql, params) = where_clause.build_where_sql(clause, False) 341 + 342 + sql 343 + |> should.equal( 344 + "json_extract(json, '$.likes') > ? AND json_extract(json, '$.likes') < ?", 345 + ) 346 + list.length(params) |> should.equal(2) 347 + } 348 + 349 + // Test: Mix of table columns and JSON fields 350 + pub fn build_where_mixed_table_and_json_test() { 351 + let cond1 = 352 + where_clause.WhereCondition( 353 + eq: Some(sqlight.text("app.bsky.feed.post")), 354 + in_values: None, 355 + contains: None, 356 + gt: None, 357 + gte: None, 358 + lt: None, 359 + lte: None, 360 + ) 361 + let cond2 = 362 + where_clause.WhereCondition( 363 + eq: None, 364 + in_values: None, 365 + contains: None, 366 + gt: Some(sqlight.int(10)), 367 + gte: None, 368 + lt: None, 369 + lte: None, 370 + ) 371 + let clause = 372 + where_clause.WhereClause( 373 + conditions: dict.from_list([ 374 + #("collection", cond1), 375 + #("replyCount", cond2), 376 + ]), 377 + and: None, 378 + or: None, 379 + ) 380 + 381 + let #(sql, params) = where_clause.build_where_sql(clause, False) 382 + 383 + // Should have both table column and JSON extract 384 + should.be_true(string.contains(sql, "collection = ?")) 385 + should.be_true(string.contains(sql, "json_extract(json, '$.replyCount') > ?")) 386 + should.be_true(string.contains(sql, "AND")) 387 + list.length(params) |> should.equal(2) 388 + } 389 + 390 + // Phase 4 Tests: Contains Operator 391 + 392 + // Test: contains on JSON field 393 + pub fn build_where_contains_json_field_test() { 394 + let condition = 395 + where_clause.WhereCondition( 396 + eq: None, 397 + in_values: None, 398 + contains: Some("hello"), 399 + gt: None, 400 + gte: None, 401 + lt: None, 402 + lte: None, 403 + ) 404 + let clause = 405 + where_clause.WhereClause( 406 + conditions: dict.from_list([#("text", condition)]), 407 + and: None, 408 + or: None, 409 + ) 410 + 411 + let #(sql, params) = where_clause.build_where_sql(clause, False) 412 + 413 + sql 414 + |> should.equal( 415 + "json_extract(json, '$.text') LIKE '%' || ? || '%' COLLATE NOCASE", 416 + ) 417 + list.length(params) |> should.equal(1) 418 + } 419 + 420 + // Test: contains on table column (uri) 421 + pub fn build_where_contains_table_column_test() { 422 + let condition = 423 + where_clause.WhereCondition( 424 + eq: None, 425 + in_values: None, 426 + contains: Some("app.bsky"), 427 + gt: None, 428 + gte: None, 429 + lt: None, 430 + lte: None, 431 + ) 432 + let clause = 433 + where_clause.WhereClause( 434 + conditions: dict.from_list([#("uri", condition)]), 435 + and: None, 436 + or: None, 437 + ) 438 + 439 + let #(sql, params) = where_clause.build_where_sql(clause, False) 440 + 441 + sql |> should.equal("uri LIKE '%' || ? || '%' COLLATE NOCASE") 442 + list.length(params) |> should.equal(1) 443 + } 444 + 445 + // Test: contains with special LIKE characters (should be escaped) 446 + pub fn build_where_contains_special_chars_test() { 447 + let condition = 448 + where_clause.WhereCondition( 449 + eq: None, 450 + in_values: None, 451 + contains: Some("test%value"), 452 + gt: None, 453 + gte: None, 454 + lt: None, 455 + lte: None, 456 + ) 457 + let clause = 458 + where_clause.WhereClause( 459 + conditions: dict.from_list([#("text", condition)]), 460 + and: None, 461 + or: None, 462 + ) 463 + 464 + let #(sql, _params) = where_clause.build_where_sql(clause, False) 465 + 466 + // SQL should be generated (actual escaping would be handled by the parameter binding) 467 + should.be_true(string.contains(sql, "LIKE")) 468 + should.be_true(string.contains(sql, "COLLATE NOCASE")) 469 + } 470 + 471 + // Test: Multiple contains conditions 472 + pub fn build_where_multiple_contains_test() { 473 + let cond1 = 474 + where_clause.WhereCondition( 475 + eq: None, 476 + in_values: None, 477 + contains: Some("pearl jam"), 478 + gt: None, 479 + gte: None, 480 + lt: None, 481 + lte: None, 482 + ) 483 + let cond2 = 484 + where_clause.WhereCondition( 485 + eq: None, 486 + in_values: None, 487 + contains: Some("rock"), 488 + gt: None, 489 + gte: None, 490 + lt: None, 491 + lte: None, 492 + ) 493 + let clause = 494 + where_clause.WhereClause( 495 + conditions: dict.from_list([ 496 + #("artist", cond1), 497 + #("genre", cond2), 498 + ]), 499 + and: None, 500 + or: None, 501 + ) 502 + 503 + let #(sql, params) = where_clause.build_where_sql(clause, False) 504 + 505 + // Should have both LIKE clauses 506 + should.be_true(string.contains(sql, "LIKE")) 507 + should.be_true(string.contains(sql, "AND")) 508 + list.length(params) |> should.equal(2) 509 + } 510 + 511 + // Test: contains combined with eq operator on same field 512 + pub fn build_where_contains_with_other_operators_test() { 513 + let condition = 514 + where_clause.WhereCondition( 515 + eq: None, 516 + in_values: None, 517 + contains: Some("search"), 518 + gt: Some(sqlight.int(100)), 519 + gte: None, 520 + lt: None, 521 + lte: None, 522 + ) 523 + let clause = 524 + where_clause.WhereClause( 525 + conditions: dict.from_list([#("text", condition)]), 526 + and: None, 527 + or: None, 528 + ) 529 + 530 + let #(sql, params) = where_clause.build_where_sql(clause, False) 531 + 532 + // Should have both LIKE and > operator 533 + should.be_true(string.contains(sql, "LIKE")) 534 + should.be_true(string.contains(sql, ">")) 535 + should.be_true(string.contains(sql, "AND")) 536 + list.length(params) |> should.equal(2) 537 + } 538 + 539 + // Phase 5 Tests: AND Logic 540 + 541 + // Test: Nested AND with two simple clauses 542 + pub fn build_where_nested_and_simple_test() { 543 + let clause1 = 544 + where_clause.WhereClause( 545 + conditions: dict.from_list([ 546 + #( 547 + "collection", 548 + where_clause.WhereCondition( 549 + eq: Some(sqlight.text("app.bsky.feed.post")), 550 + in_values: None, 551 + contains: None, 552 + gt: None, 553 + gte: None, 554 + lt: None, 555 + lte: None, 556 + ), 557 + ), 558 + ]), 559 + and: None, 560 + or: None, 561 + ) 562 + 563 + let clause2 = 564 + where_clause.WhereClause( 565 + conditions: dict.from_list([ 566 + #( 567 + "did", 568 + where_clause.WhereCondition( 569 + eq: Some(sqlight.text("did:plc:test")), 570 + in_values: None, 571 + contains: None, 572 + gt: None, 573 + gte: None, 574 + lt: None, 575 + lte: None, 576 + ), 577 + ), 578 + ]), 579 + and: None, 580 + or: None, 581 + ) 582 + 583 + let root_clause = 584 + where_clause.WhereClause( 585 + conditions: dict.new(), 586 + and: Some([clause1, clause2]), 587 + or: None, 588 + ) 589 + 590 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 591 + 592 + // Should have both conditions AND'ed together with parentheses 593 + should.be_true(string.contains(sql, "collection = ?")) 594 + should.be_true(string.contains(sql, "did = ?")) 595 + should.be_true(string.contains(sql, "AND")) 596 + list.length(params) |> should.equal(2) 597 + } 598 + 599 + // Test: Nested AND with conditions at root level 600 + pub fn build_where_and_with_root_conditions_test() { 601 + let nested_clause = 602 + where_clause.WhereClause( 603 + conditions: dict.from_list([ 604 + #( 605 + "text", 606 + where_clause.WhereCondition( 607 + eq: None, 608 + in_values: None, 609 + contains: Some("hello"), 610 + gt: None, 611 + gte: None, 612 + lt: None, 613 + lte: None, 614 + ), 615 + ), 616 + ]), 617 + and: None, 618 + or: None, 619 + ) 620 + 621 + let root_clause = 622 + where_clause.WhereClause( 623 + conditions: dict.from_list([ 624 + #( 625 + "collection", 626 + where_clause.WhereCondition( 627 + eq: Some(sqlight.text("app.bsky.feed.post")), 628 + in_values: None, 629 + contains: None, 630 + gt: None, 631 + gte: None, 632 + lt: None, 633 + lte: None, 634 + ), 635 + ), 636 + ]), 637 + and: Some([nested_clause]), 638 + or: None, 639 + ) 640 + 641 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 642 + 643 + // Should have both root condition and nested condition 644 + should.be_true(string.contains(sql, "collection = ?")) 645 + should.be_true(string.contains(sql, "LIKE")) 646 + should.be_true(string.contains(sql, "AND")) 647 + list.length(params) |> should.equal(2) 648 + } 649 + 650 + // Test: Complex nested AND matching Slice API example 651 + // Example: (artist contains "pearl jam") AND (year >= 2000) 652 + pub fn build_where_complex_and_test() { 653 + let artist_clause = 654 + where_clause.WhereClause( 655 + conditions: dict.from_list([ 656 + #( 657 + "artist", 658 + where_clause.WhereCondition( 659 + eq: None, 660 + in_values: None, 661 + contains: Some("pearl jam"), 662 + gt: None, 663 + gte: None, 664 + lt: None, 665 + lte: None, 666 + ), 667 + ), 668 + ]), 669 + and: None, 670 + or: None, 671 + ) 672 + 673 + let year_clause = 674 + where_clause.WhereClause( 675 + conditions: dict.from_list([ 676 + #( 677 + "year", 678 + where_clause.WhereCondition( 679 + eq: None, 680 + in_values: None, 681 + contains: None, 682 + gt: None, 683 + gte: Some(sqlight.int(2000)), 684 + lt: None, 685 + lte: None, 686 + ), 687 + ), 688 + ]), 689 + and: None, 690 + or: None, 691 + ) 692 + 693 + let root_clause = 694 + where_clause.WhereClause( 695 + conditions: dict.new(), 696 + and: Some([artist_clause, year_clause]), 697 + or: None, 698 + ) 699 + 700 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 701 + 702 + // Should have both conditions 703 + should.be_true(string.contains(sql, "artist")) 704 + should.be_true(string.contains(sql, "LIKE")) 705 + should.be_true(string.contains(sql, "year")) 706 + should.be_true(string.contains(sql, ">=")) 707 + should.be_true(string.contains(sql, "AND")) 708 + list.length(params) |> should.equal(2) 709 + } 710 + 711 + // Test: Three-level nested AND 712 + pub fn build_where_deeply_nested_and_test() { 713 + let inner_clause = 714 + where_clause.WhereClause( 715 + conditions: dict.from_list([ 716 + #( 717 + "likes", 718 + where_clause.WhereCondition( 719 + eq: None, 720 + in_values: None, 721 + contains: None, 722 + gt: Some(sqlight.int(10)), 723 + gte: None, 724 + lt: None, 725 + lte: None, 726 + ), 727 + ), 728 + ]), 729 + and: None, 730 + or: None, 731 + ) 732 + 733 + let middle_clause = 734 + where_clause.WhereClause( 735 + conditions: dict.from_list([ 736 + #( 737 + "text", 738 + where_clause.WhereCondition( 739 + eq: None, 740 + in_values: None, 741 + contains: Some("test"), 742 + gt: None, 743 + gte: None, 744 + lt: None, 745 + lte: None, 746 + ), 747 + ), 748 + ]), 749 + and: Some([inner_clause]), 750 + or: None, 751 + ) 752 + 753 + let root_clause = 754 + where_clause.WhereClause( 755 + conditions: dict.from_list([ 756 + #( 757 + "collection", 758 + where_clause.WhereCondition( 759 + eq: Some(sqlight.text("app.bsky.feed.post")), 760 + in_values: None, 761 + contains: None, 762 + gt: None, 763 + gte: None, 764 + lt: None, 765 + lte: None, 766 + ), 767 + ), 768 + ]), 769 + and: Some([middle_clause]), 770 + or: None, 771 + ) 772 + 773 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 774 + 775 + // Should have all three conditions 776 + should.be_true(string.contains(sql, "collection = ?")) 777 + should.be_true(string.contains(sql, "LIKE")) 778 + should.be_true(string.contains(sql, "likes")) 779 + should.be_true(string.contains(sql, ">")) 780 + list.length(params) |> should.equal(3) 781 + } 782 + 783 + // Phase 6 Tests: OR Logic 784 + 785 + // Test: Simple OR with two clauses 786 + pub fn build_where_simple_or_test() { 787 + let clause1 = 788 + where_clause.WhereClause( 789 + conditions: dict.from_list([ 790 + #( 791 + "artist", 792 + where_clause.WhereCondition( 793 + eq: None, 794 + in_values: None, 795 + contains: Some("pearl jam"), 796 + gt: None, 797 + gte: None, 798 + lt: None, 799 + lte: None, 800 + ), 801 + ), 802 + ]), 803 + and: None, 804 + or: None, 805 + ) 806 + 807 + let clause2 = 808 + where_clause.WhereClause( 809 + conditions: dict.from_list([ 810 + #( 811 + "genre", 812 + where_clause.WhereCondition( 813 + eq: Some(sqlight.text("rock")), 814 + in_values: None, 815 + contains: None, 816 + gt: None, 817 + gte: None, 818 + lt: None, 819 + lte: None, 820 + ), 821 + ), 822 + ]), 823 + and: None, 824 + or: None, 825 + ) 826 + 827 + let root_clause = 828 + where_clause.WhereClause( 829 + conditions: dict.new(), 830 + and: None, 831 + or: Some([clause1, clause2]), 832 + ) 833 + 834 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 835 + 836 + // Should have both conditions OR'ed together 837 + should.be_true(string.contains(sql, "artist")) 838 + should.be_true(string.contains(sql, "LIKE")) 839 + should.be_true(string.contains(sql, "genre")) 840 + should.be_true(string.contains(sql, "= ?")) 841 + should.be_true(string.contains(sql, "OR")) 842 + list.length(params) |> should.equal(2) 843 + } 844 + 845 + // Test: Combined AND/OR - Slice API example 846 + // Example: (artist contains "pearl jam" OR genre = "rock") AND (year >= 2000) 847 + pub fn build_where_combined_and_or_test() { 848 + let artist_clause = 849 + where_clause.WhereClause( 850 + conditions: dict.from_list([ 851 + #( 852 + "artist", 853 + where_clause.WhereCondition( 854 + eq: None, 855 + in_values: None, 856 + contains: Some("pearl jam"), 857 + gt: None, 858 + gte: None, 859 + lt: None, 860 + lte: None, 861 + ), 862 + ), 863 + ]), 864 + and: None, 865 + or: None, 866 + ) 867 + 868 + let genre_clause = 869 + where_clause.WhereClause( 870 + conditions: dict.from_list([ 871 + #( 872 + "genre", 873 + where_clause.WhereCondition( 874 + eq: Some(sqlight.text("rock")), 875 + in_values: None, 876 + contains: None, 877 + gt: None, 878 + gte: None, 879 + lt: None, 880 + lte: None, 881 + ), 882 + ), 883 + ]), 884 + and: None, 885 + or: None, 886 + ) 887 + 888 + let or_clause = 889 + where_clause.WhereClause( 890 + conditions: dict.new(), 891 + and: None, 892 + or: Some([artist_clause, genre_clause]), 893 + ) 894 + 895 + let year_clause = 896 + where_clause.WhereClause( 897 + conditions: dict.from_list([ 898 + #( 899 + "year", 900 + where_clause.WhereCondition( 901 + eq: None, 902 + in_values: None, 903 + contains: None, 904 + gt: None, 905 + gte: Some(sqlight.int(2000)), 906 + lt: None, 907 + lte: None, 908 + ), 909 + ), 910 + ]), 911 + and: None, 912 + or: None, 913 + ) 914 + 915 + let root_clause = 916 + where_clause.WhereClause( 917 + conditions: dict.new(), 918 + and: Some([or_clause, year_clause]), 919 + or: None, 920 + ) 921 + 922 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 923 + 924 + // Should have proper precedence: (artist LIKE OR genre =) AND year >= 925 + should.be_true(string.contains(sql, "OR")) 926 + should.be_true(string.contains(sql, "AND")) 927 + should.be_true(string.contains(sql, "artist")) 928 + should.be_true(string.contains(sql, "genre")) 929 + should.be_true(string.contains(sql, "year")) 930 + list.length(params) |> should.equal(3) 931 + } 932 + 933 + // Test: Complex nested OR/AND from Slice API documentation 934 + // { "and": [ { "or": [artist, genre] }, { "and": [uri1, uri2] }, year ] } 935 + pub fn build_where_complex_nested_or_and_test() { 936 + let artist_clause = 937 + where_clause.WhereClause( 938 + conditions: dict.from_list([ 939 + #( 940 + "artist", 941 + where_clause.WhereCondition( 942 + eq: None, 943 + in_values: None, 944 + contains: Some("pearl jam"), 945 + gt: None, 946 + gte: None, 947 + lt: None, 948 + lte: None, 949 + ), 950 + ), 951 + ]), 952 + and: None, 953 + or: None, 954 + ) 955 + 956 + let genre_clause = 957 + where_clause.WhereClause( 958 + conditions: dict.from_list([ 959 + #( 960 + "genre", 961 + where_clause.WhereCondition( 962 + eq: None, 963 + in_values: None, 964 + contains: Some("rock"), 965 + gt: None, 966 + gte: None, 967 + lt: None, 968 + lte: None, 969 + ), 970 + ), 971 + ]), 972 + and: None, 973 + or: None, 974 + ) 975 + 976 + let or_group = 977 + where_clause.WhereClause( 978 + conditions: dict.new(), 979 + and: None, 980 + or: Some([artist_clause, genre_clause]), 981 + ) 982 + 983 + let uri1_clause = 984 + where_clause.WhereClause( 985 + conditions: dict.from_list([ 986 + #( 987 + "uri", 988 + where_clause.WhereCondition( 989 + eq: None, 990 + in_values: None, 991 + contains: Some("app.bsky"), 992 + gt: None, 993 + gte: None, 994 + lt: None, 995 + lte: None, 996 + ), 997 + ), 998 + ]), 999 + and: None, 1000 + or: None, 1001 + ) 1002 + 1003 + let uri2_clause = 1004 + where_clause.WhereClause( 1005 + conditions: dict.from_list([ 1006 + #( 1007 + "uri", 1008 + where_clause.WhereCondition( 1009 + eq: None, 1010 + in_values: None, 1011 + contains: Some("post"), 1012 + gt: None, 1013 + gte: None, 1014 + lt: None, 1015 + lte: None, 1016 + ), 1017 + ), 1018 + ]), 1019 + and: None, 1020 + or: None, 1021 + ) 1022 + 1023 + let and_group = 1024 + where_clause.WhereClause( 1025 + conditions: dict.new(), 1026 + and: Some([uri1_clause, uri2_clause]), 1027 + or: None, 1028 + ) 1029 + 1030 + let year_clause = 1031 + where_clause.WhereClause( 1032 + conditions: dict.from_list([ 1033 + #( 1034 + "year", 1035 + where_clause.WhereCondition( 1036 + eq: None, 1037 + in_values: None, 1038 + contains: None, 1039 + gt: None, 1040 + gte: Some(sqlight.int(2000)), 1041 + lt: None, 1042 + lte: None, 1043 + ), 1044 + ), 1045 + ]), 1046 + and: None, 1047 + or: None, 1048 + ) 1049 + 1050 + let root_clause = 1051 + where_clause.WhereClause( 1052 + conditions: dict.new(), 1053 + and: Some([or_group, and_group, year_clause]), 1054 + or: None, 1055 + ) 1056 + 1057 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 1058 + 1059 + // Should have both OR and AND with proper nesting 1060 + should.be_true(string.contains(sql, "OR")) 1061 + should.be_true(string.contains(sql, "AND")) 1062 + should.be_true(string.contains(sql, "artist")) 1063 + should.be_true(string.contains(sql, "genre")) 1064 + should.be_true(string.contains(sql, "uri")) 1065 + should.be_true(string.contains(sql, "year")) 1066 + list.length(params) |> should.equal(5) 1067 + } 1068 + 1069 + // Test: Multiple OR clauses at root level 1070 + pub fn build_where_multiple_or_at_root_test() { 1071 + let clause1 = 1072 + where_clause.WhereClause( 1073 + conditions: dict.from_list([ 1074 + #( 1075 + "did", 1076 + where_clause.WhereCondition( 1077 + eq: Some(sqlight.text("did:plc:1")), 1078 + in_values: None, 1079 + contains: None, 1080 + gt: None, 1081 + gte: None, 1082 + lt: None, 1083 + lte: None, 1084 + ), 1085 + ), 1086 + ]), 1087 + and: None, 1088 + or: None, 1089 + ) 1090 + 1091 + let clause2 = 1092 + where_clause.WhereClause( 1093 + conditions: dict.from_list([ 1094 + #( 1095 + "did", 1096 + where_clause.WhereCondition( 1097 + eq: Some(sqlight.text("did:plc:2")), 1098 + in_values: None, 1099 + contains: None, 1100 + gt: None, 1101 + gte: None, 1102 + lt: None, 1103 + lte: None, 1104 + ), 1105 + ), 1106 + ]), 1107 + and: None, 1108 + or: None, 1109 + ) 1110 + 1111 + let clause3 = 1112 + where_clause.WhereClause( 1113 + conditions: dict.from_list([ 1114 + #( 1115 + "did", 1116 + where_clause.WhereCondition( 1117 + eq: Some(sqlight.text("did:plc:3")), 1118 + in_values: None, 1119 + contains: None, 1120 + gt: None, 1121 + gte: None, 1122 + lt: None, 1123 + lte: None, 1124 + ), 1125 + ), 1126 + ]), 1127 + and: None, 1128 + or: None, 1129 + ) 1130 + 1131 + let root_clause = 1132 + where_clause.WhereClause( 1133 + conditions: dict.new(), 1134 + and: None, 1135 + or: Some([clause1, clause2, clause3]), 1136 + ) 1137 + 1138 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 1139 + 1140 + // Should have all three OR'ed together 1141 + should.be_true(string.contains(sql, "OR")) 1142 + should.be_true(string.contains(sql, "did")) 1143 + list.length(params) |> should.equal(3) 1144 + }