Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(where): add isNull filter support for ref fields

Add ability to filter on ref type fields (like `reply` in app.bsky.feed.post)
using the isNull operator to check for presence/absence of the reference.

- Add RefFieldCondition GraphQL input type with only isNull operator
- Add build_where_input_type_with_field_types for ref vs primitive fields
- Add is_filterable_property that includes RefField (unlike sortable/groupable)
- Update get_filterable_field_names to return (field_name, is_ref) tuples
- Add isNull support to WhereCondition SQL generation
- Add comprehensive tests for isNull filtering

+2296 -26
+1079
dev-docs/plans/2025-12-03-is-null-filter.md
··· 1 + # isNull Filter Operator Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `isNull: Boolean` operator to where clause field conditions to filter on JSON null values or missing fields. 6 + 7 + **Architecture:** Add `isNull` field to the `WhereCondition` type across all layers (GraphQL schema → parsing → SQL generation). The operator generates `IS NULL` / `IS NOT NULL` SQL without bound parameters. 8 + 9 + **Tech Stack:** Gleam, GraphQL (swell), SQLite 10 + 11 + --- 12 + 13 + ## Task 1: Add isNull to GraphQL Schema 14 + 15 + **Files:** 16 + - Modify: `lexicon_graphql/src/lexicon_graphql/input/connection.gleam:86-114` 17 + 18 + **Step 1: Write the failing test** 19 + 20 + Create test file `lexicon_graphql/test/where_is_null_schema_test.gleam`: 21 + 22 + ```gleam 23 + /// Tests for isNull field in where condition schema 24 + import gleeunit 25 + import gleeunit/should 26 + import lexicon_graphql/input/connection 27 + import swell/schema 28 + 29 + pub fn main() { 30 + gleeunit.main() 31 + } 32 + 33 + pub fn where_condition_has_is_null_field_test() { 34 + // Build a where condition type 35 + let condition_type = 36 + connection.build_where_condition_input_type("Test", schema.string_type()) 37 + 38 + // Get the input object fields 39 + case condition_type { 40 + schema.InputObject(_, _, fields) -> { 41 + // Find the isNull field 42 + let has_is_null = 43 + fields 44 + |> list.any(fn(field) { 45 + case field { 46 + schema.InputField(name, _, _, _) -> name == "isNull" 47 + } 48 + }) 49 + has_is_null |> should.be_true 50 + } 51 + _ -> should.fail() 52 + } 53 + } 54 + ``` 55 + 56 + **Step 2: Run test to verify it fails** 57 + 58 + Run: `cd lexicon_graphql && gleam test -- --filter="where_is_null_schema"` 59 + Expected: FAIL - test can't find `isNull` field 60 + 61 + **Step 3: Add isNull field to schema builder** 62 + 63 + In `lexicon_graphql/src/lexicon_graphql/input/connection.gleam`, update `build_where_condition_input_type`: 64 + 65 + ```gleam 66 + /// Builds a WhereConditionInput type for filtering a specific field type 67 + /// Supports: eq, in, contains, gt, gte, lt, lte, isNull operators 68 + pub fn build_where_condition_input_type( 69 + type_name: String, 70 + field_type: schema.Type, 71 + ) -> schema.Type { 72 + let condition_type_name = type_name <> "FieldCondition" 73 + 74 + schema.input_object_type( 75 + condition_type_name, 76 + "Filter operators for " <> type_name <> " fields", 77 + [ 78 + schema.input_field("eq", field_type, "Exact match (equals)", None), 79 + schema.input_field( 80 + "in", 81 + schema.list_type(schema.non_null(field_type)), 82 + "Match any value in the list", 83 + None, 84 + ), 85 + schema.input_field( 86 + "contains", 87 + schema.string_type(), 88 + "Case-insensitive substring match (string fields only)", 89 + None, 90 + ), 91 + schema.input_field("gt", field_type, "Greater than", None), 92 + schema.input_field("gte", field_type, "Greater than or equal to", None), 93 + schema.input_field("lt", field_type, "Less than", None), 94 + schema.input_field("lte", field_type, "Less than or equal to", None), 95 + schema.input_field( 96 + "isNull", 97 + schema.boolean_type(), 98 + "Filter for null or missing values (true) or non-null values (false)", 99 + None, 100 + ), 101 + ], 102 + ) 103 + } 104 + ``` 105 + 106 + **Step 4: Run test to verify it passes** 107 + 108 + Run: `cd lexicon_graphql && gleam test -- --filter="where_is_null_schema"` 109 + Expected: PASS 110 + 111 + **Step 5: Commit** 112 + 113 + ```bash 114 + git add lexicon_graphql/src/lexicon_graphql/input/connection.gleam lexicon_graphql/test/where_is_null_schema_test.gleam 115 + git commit -m "feat(where): add isNull field to GraphQL schema" 116 + ``` 117 + 118 + --- 119 + 120 + ## Task 2: Add isNull to GraphQL Parsing Layer 121 + 122 + **Files:** 123 + - Modify: `lexicon_graphql/src/lexicon_graphql/input/where.gleam:19-29, 41-121` 124 + - Test: `lexicon_graphql/test/where_input_test.gleam` 125 + 126 + **Step 1: Write the failing test** 127 + 128 + Add to `lexicon_graphql/test/where_input_test.gleam`: 129 + 130 + ```gleam 131 + // ===== isNull Operator Tests ===== 132 + 133 + pub fn parse_is_null_true_test() { 134 + // { field: { isNull: true } } 135 + let condition_value = value.Object([#("isNull", value.Boolean(True))]) 136 + let where_value = value.Object([#("field", condition_value)]) 137 + 138 + let result = where_input.parse_where_clause(where_value) 139 + 140 + case dict.get(result.conditions, "field") { 141 + Ok(condition) -> { 142 + case condition.is_null { 143 + Some(True) -> should.be_true(True) 144 + _ -> should.fail() 145 + } 146 + } 147 + Error(_) -> should.fail() 148 + } 149 + } 150 + 151 + pub fn parse_is_null_false_test() { 152 + // { field: { isNull: false } } 153 + let condition_value = value.Object([#("isNull", value.Boolean(False))]) 154 + let where_value = value.Object([#("field", condition_value)]) 155 + 156 + let result = where_input.parse_where_clause(where_value) 157 + 158 + case dict.get(result.conditions, "field") { 159 + Ok(condition) -> { 160 + case condition.is_null { 161 + Some(False) -> should.be_true(True) 162 + _ -> should.fail() 163 + } 164 + } 165 + Error(_) -> should.fail() 166 + } 167 + } 168 + ``` 169 + 170 + **Step 2: Run test to verify it fails** 171 + 172 + Run: `cd lexicon_graphql && gleam test -- --filter="parse_is_null"` 173 + Expected: FAIL - `is_null` field doesn't exist on WhereCondition 174 + 175 + **Step 3: Add isNull to WhereCondition type and parsing** 176 + 177 + In `lexicon_graphql/src/lexicon_graphql/input/where.gleam`: 178 + 179 + Update the `WhereCondition` type (around line 19): 180 + 181 + ```gleam 182 + /// Intermediate representation of a where condition (no SQL types) 183 + pub type WhereCondition { 184 + WhereCondition( 185 + eq: Option(WhereValue), 186 + in_values: Option(List(WhereValue)), 187 + contains: Option(String), 188 + gt: Option(WhereValue), 189 + gte: Option(WhereValue), 190 + lt: Option(WhereValue), 191 + lte: Option(WhereValue), 192 + is_null: Option(Bool), 193 + ) 194 + } 195 + ``` 196 + 197 + Update `parse_condition` function (around line 41) to parse `isNull`: 198 + 199 + ```gleam 200 + /// Parse a GraphQL filter value into a WhereCondition 201 + pub fn parse_condition(filter_value: value.Value) -> WhereCondition { 202 + case filter_value { 203 + value.Object(fields) -> { 204 + let eq = case list.key_find(fields, "eq") { 205 + Ok(value.String(s)) -> Some(StringValue(s)) 206 + Ok(value.Int(i)) -> Some(IntValue(i)) 207 + Ok(value.Boolean(b)) -> Some(BoolValue(b)) 208 + _ -> None 209 + } 210 + 211 + let in_values = case list.key_find(fields, "in") { 212 + Ok(value.List(items)) -> { 213 + let values = 214 + list.filter_map(items, fn(item) { 215 + case item { 216 + value.String(s) -> Ok(StringValue(s)) 217 + value.Int(i) -> Ok(IntValue(i)) 218 + value.Boolean(b) -> Ok(BoolValue(b)) 219 + _ -> Error(Nil) 220 + } 221 + }) 222 + case values { 223 + [] -> None 224 + _ -> Some(values) 225 + } 226 + } 227 + _ -> None 228 + } 229 + 230 + let contains = case list.key_find(fields, "contains") { 231 + Ok(value.String(s)) -> Some(s) 232 + _ -> None 233 + } 234 + 235 + // For comparison operators, try to parse string values as integers 236 + // This allows numeric comparisons even when the GraphQL schema uses String types 237 + let gt = case list.key_find(fields, "gt") { 238 + Ok(value.String(s)) -> Some(parse_string_or_int(s)) 239 + Ok(value.Int(i)) -> Some(IntValue(i)) 240 + _ -> None 241 + } 242 + 243 + let gte = case list.key_find(fields, "gte") { 244 + Ok(value.String(s)) -> Some(parse_string_or_int(s)) 245 + Ok(value.Int(i)) -> Some(IntValue(i)) 246 + _ -> None 247 + } 248 + 249 + let lt = case list.key_find(fields, "lt") { 250 + Ok(value.String(s)) -> Some(parse_string_or_int(s)) 251 + Ok(value.Int(i)) -> Some(IntValue(i)) 252 + _ -> None 253 + } 254 + 255 + let lte = case list.key_find(fields, "lte") { 256 + Ok(value.String(s)) -> Some(parse_string_or_int(s)) 257 + Ok(value.Int(i)) -> Some(IntValue(i)) 258 + _ -> None 259 + } 260 + 261 + let is_null = case list.key_find(fields, "isNull") { 262 + Ok(value.Boolean(b)) -> Some(b) 263 + _ -> None 264 + } 265 + 266 + WhereCondition( 267 + eq: eq, 268 + in_values: in_values, 269 + contains: contains, 270 + gt: gt, 271 + gte: gte, 272 + lt: lt, 273 + lte: lte, 274 + is_null: is_null, 275 + ) 276 + } 277 + _ -> 278 + WhereCondition( 279 + eq: None, 280 + in_values: None, 281 + contains: None, 282 + gt: None, 283 + gte: None, 284 + lt: None, 285 + lte: None, 286 + is_null: None, 287 + ) 288 + } 289 + } 290 + ``` 291 + 292 + **Step 4: Run test to verify it passes** 293 + 294 + Run: `cd lexicon_graphql && gleam test -- --filter="parse_is_null"` 295 + Expected: PASS 296 + 297 + **Step 5: Commit** 298 + 299 + ```bash 300 + git add lexicon_graphql/src/lexicon_graphql/input/where.gleam lexicon_graphql/test/where_input_test.gleam 301 + git commit -m "feat(where): add isNull parsing in GraphQL layer" 302 + ``` 303 + 304 + --- 305 + 306 + ## Task 3: Add isNull to SQL Where Clause Type 307 + 308 + **Files:** 309 + - Modify: `server/src/where_clause.gleam:9-21, 36-47, 55-69` 310 + - Test: `server/test/where_clause_test.gleam` 311 + 312 + **Step 1: Write the failing test** 313 + 314 + Add to `server/test/where_clause_test.gleam`: 315 + 316 + ```gleam 317 + // Test is_condition_empty with is_null operator set 318 + pub fn is_condition_empty_false_with_is_null_test() { 319 + let condition = 320 + where_clause.WhereCondition( 321 + eq: None, 322 + in_values: None, 323 + contains: None, 324 + gt: None, 325 + gte: None, 326 + lt: None, 327 + lte: None, 328 + is_null: Some(True), 329 + is_numeric: False, 330 + ) 331 + where_clause.is_condition_empty(condition) |> should.be_false 332 + } 333 + ``` 334 + 335 + **Step 2: Run test to verify it fails** 336 + 337 + Run: `cd server && gleam test -- --filter="is_condition_empty_false_with_is_null"` 338 + Expected: FAIL - `is_null` field doesn't exist 339 + 340 + **Step 3: Add isNull to WhereCondition type** 341 + 342 + In `server/src/where_clause.gleam`: 343 + 344 + Update the `WhereCondition` type (around line 9): 345 + 346 + ```gleam 347 + /// Represents a single condition on a field with various comparison operators 348 + pub type WhereCondition { 349 + WhereCondition( 350 + eq: Option(sqlight.Value), 351 + in_values: Option(List(sqlight.Value)), 352 + contains: Option(String), 353 + gt: Option(sqlight.Value), 354 + gte: Option(sqlight.Value), 355 + lt: Option(sqlight.Value), 356 + lte: Option(sqlight.Value), 357 + is_null: Option(Bool), 358 + /// Whether the comparison values are numeric (affects JSON field casting) 359 + is_numeric: Bool, 360 + ) 361 + } 362 + ``` 363 + 364 + Update `empty_condition` function (around line 36): 365 + 366 + ```gleam 367 + /// Creates an empty WhereCondition with all operators set to None 368 + pub fn empty_condition() -> WhereCondition { 369 + WhereCondition( 370 + eq: None, 371 + in_values: None, 372 + contains: None, 373 + gt: None, 374 + gte: None, 375 + lt: None, 376 + lte: None, 377 + is_null: None, 378 + is_numeric: False, 379 + ) 380 + } 381 + ``` 382 + 383 + Update `is_condition_empty` function (around line 55): 384 + 385 + ```gleam 386 + /// Checks if a WhereCondition has any operators set 387 + pub fn is_condition_empty(condition: WhereCondition) -> Bool { 388 + case condition { 389 + WhereCondition( 390 + eq: None, 391 + in_values: None, 392 + contains: None, 393 + gt: None, 394 + gte: None, 395 + lt: None, 396 + lte: None, 397 + is_null: None, 398 + is_numeric: _, 399 + ) -> True 400 + _ -> False 401 + } 402 + } 403 + ``` 404 + 405 + **Step 4: Run test to verify it passes** 406 + 407 + Run: `cd server && gleam test -- --filter="is_condition_empty_false_with_is_null"` 408 + Expected: PASS 409 + 410 + **Step 5: Commit** 411 + 412 + ```bash 413 + git add server/src/where_clause.gleam server/test/where_clause_test.gleam 414 + git commit -m "feat(where): add isNull to SQL WhereCondition type" 415 + ``` 416 + 417 + --- 418 + 419 + ## Task 4: Add isNull to Where Converter 420 + 421 + **Files:** 422 + - Modify: `server/src/where_converter.gleam:45-58` 423 + 424 + **Step 1: Write the failing test** 425 + 426 + Add test file `server/test/where_converter_test.gleam`: 427 + 428 + ```gleam 429 + /// Tests for where clause conversion 430 + import gleam/dict 431 + import gleam/option.{None, Some} 432 + import gleeunit 433 + import gleeunit/should 434 + import lexicon_graphql/input/where as where_input 435 + import where_clause 436 + import where_converter 437 + 438 + pub fn main() { 439 + gleeunit.main() 440 + } 441 + 442 + pub fn convert_is_null_true_test() { 443 + let input_condition = 444 + where_input.WhereCondition( 445 + eq: None, 446 + in_values: None, 447 + contains: None, 448 + gt: None, 449 + gte: None, 450 + lt: None, 451 + lte: None, 452 + is_null: Some(True), 453 + ) 454 + 455 + let input_clause = 456 + where_input.WhereClause( 457 + conditions: dict.from_list([#("field", input_condition)]), 458 + and: None, 459 + or: None, 460 + ) 461 + 462 + let result = where_converter.convert_where_clause(input_clause) 463 + 464 + case dict.get(result.conditions, "field") { 465 + Ok(condition) -> { 466 + condition.is_null |> should.equal(Some(True)) 467 + } 468 + Error(_) -> should.fail() 469 + } 470 + } 471 + 472 + pub fn convert_is_null_false_test() { 473 + let input_condition = 474 + where_input.WhereCondition( 475 + eq: None, 476 + in_values: None, 477 + contains: None, 478 + gt: None, 479 + gte: None, 480 + lt: None, 481 + lte: None, 482 + is_null: Some(False), 483 + ) 484 + 485 + let input_clause = 486 + where_input.WhereClause( 487 + conditions: dict.from_list([#("field", input_condition)]), 488 + and: None, 489 + or: None, 490 + ) 491 + 492 + let result = where_converter.convert_where_clause(input_clause) 493 + 494 + case dict.get(result.conditions, "field") { 495 + Ok(condition) -> { 496 + condition.is_null |> should.equal(Some(False)) 497 + } 498 + Error(_) -> should.fail() 499 + } 500 + } 501 + ``` 502 + 503 + **Step 2: Run test to verify it fails** 504 + 505 + Run: `cd server && gleam test -- --filter="convert_is_null"` 506 + Expected: FAIL - missing is_null field in conversion 507 + 508 + **Step 3: Add isNull to converter** 509 + 510 + In `server/src/where_converter.gleam`, update `convert_condition` (around line 45): 511 + 512 + ```gleam 513 + /// Convert a where.WhereCondition to a where_clause.WhereCondition 514 + fn convert_condition(cond: where.WhereCondition) -> where_clause.WhereCondition { 515 + where_clause.WhereCondition( 516 + eq: option.map(cond.eq, convert_value), 517 + in_values: option.map(cond.in_values, fn(values) { 518 + list.map(values, convert_value) 519 + }), 520 + contains: cond.contains, 521 + gt: option.map(cond.gt, convert_value), 522 + gte: option.map(cond.gte, convert_value), 523 + lt: option.map(cond.lt, convert_value), 524 + lte: option.map(cond.lte, convert_value), 525 + is_null: cond.is_null, 526 + is_numeric: has_numeric_comparison(cond), 527 + ) 528 + } 529 + ``` 530 + 531 + **Step 4: Run test to verify it passes** 532 + 533 + Run: `cd server && gleam test -- --filter="convert_is_null"` 534 + Expected: PASS 535 + 536 + **Step 5: Commit** 537 + 538 + ```bash 539 + git add server/src/where_converter.gleam server/test/where_converter_test.gleam 540 + git commit -m "feat(where): add isNull to where converter" 541 + ``` 542 + 543 + --- 544 + 545 + ## Task 5: Add isNull SQL Generation 546 + 547 + **Files:** 548 + - Modify: `server/src/where_clause.gleam:156-252` 549 + - Test: `server/test/where_sql_builder_test.gleam` 550 + 551 + **Step 1: Write the failing tests** 552 + 553 + Add to `server/test/where_sql_builder_test.gleam`: 554 + 555 + ```gleam 556 + // ===== isNull Operator Tests ===== 557 + 558 + // Test: isNull true on JSON field 559 + pub fn build_where_is_null_true_json_field_test() { 560 + let condition = 561 + where_clause.WhereCondition( 562 + eq: None, 563 + in_values: None, 564 + contains: None, 565 + gt: None, 566 + gte: None, 567 + lt: None, 568 + lte: None, 569 + is_null: Some(True), 570 + is_numeric: False, 571 + ) 572 + let clause = 573 + where_clause.WhereClause( 574 + conditions: dict.from_list([#("replyParent", condition)]), 575 + and: None, 576 + or: None, 577 + ) 578 + 579 + let #(sql, params) = where_clause.build_where_sql(clause, False) 580 + 581 + sql |> should.equal("json_extract(json, '$.replyParent') IS NULL") 582 + list.length(params) |> should.equal(0) 583 + } 584 + 585 + // Test: isNull false on JSON field 586 + pub fn build_where_is_null_false_json_field_test() { 587 + let condition = 588 + where_clause.WhereCondition( 589 + eq: None, 590 + in_values: None, 591 + contains: None, 592 + gt: None, 593 + gte: None, 594 + lt: None, 595 + lte: None, 596 + is_null: Some(False), 597 + is_numeric: False, 598 + ) 599 + let clause = 600 + where_clause.WhereClause( 601 + conditions: dict.from_list([#("replyParent", condition)]), 602 + and: None, 603 + or: None, 604 + ) 605 + 606 + let #(sql, params) = where_clause.build_where_sql(clause, False) 607 + 608 + sql |> should.equal("json_extract(json, '$.replyParent') IS NOT NULL") 609 + list.length(params) |> should.equal(0) 610 + } 611 + 612 + // Test: isNull true on table column 613 + pub fn build_where_is_null_true_table_column_test() { 614 + let condition = 615 + where_clause.WhereCondition( 616 + eq: None, 617 + in_values: None, 618 + contains: None, 619 + gt: None, 620 + gte: None, 621 + lt: None, 622 + lte: None, 623 + is_null: Some(True), 624 + is_numeric: False, 625 + ) 626 + let clause = 627 + where_clause.WhereClause( 628 + conditions: dict.from_list([#("cid", condition)]), 629 + and: None, 630 + or: None, 631 + ) 632 + 633 + let #(sql, params) = where_clause.build_where_sql(clause, False) 634 + 635 + sql |> should.equal("cid IS NULL") 636 + list.length(params) |> should.equal(0) 637 + } 638 + 639 + // Test: isNull false on table column 640 + pub fn build_where_is_null_false_table_column_test() { 641 + let condition = 642 + where_clause.WhereCondition( 643 + eq: None, 644 + in_values: None, 645 + contains: None, 646 + gt: None, 647 + gte: None, 648 + lt: None, 649 + lte: None, 650 + is_null: Some(False), 651 + is_numeric: False, 652 + ) 653 + let clause = 654 + where_clause.WhereClause( 655 + conditions: dict.from_list([#("uri", condition)]), 656 + and: None, 657 + or: None, 658 + ) 659 + 660 + let #(sql, params) = where_clause.build_where_sql(clause, False) 661 + 662 + sql |> should.equal("uri IS NOT NULL") 663 + list.length(params) |> should.equal(0) 664 + } 665 + 666 + // Test: isNull with table prefix (for joins) 667 + pub fn build_where_is_null_with_table_prefix_test() { 668 + let condition = 669 + where_clause.WhereCondition( 670 + eq: None, 671 + in_values: None, 672 + contains: None, 673 + gt: None, 674 + gte: None, 675 + lt: None, 676 + lte: None, 677 + is_null: Some(True), 678 + is_numeric: False, 679 + ) 680 + let clause = 681 + where_clause.WhereClause( 682 + conditions: dict.from_list([#("text", condition)]), 683 + and: None, 684 + or: None, 685 + ) 686 + 687 + let #(sql, params) = where_clause.build_where_sql(clause, True) 688 + 689 + sql |> should.equal("json_extract(record.json, '$.text') IS NULL") 690 + list.length(params) |> should.equal(0) 691 + } 692 + 693 + // Test: isNull in nested AND clause 694 + pub fn build_where_is_null_in_and_clause_test() { 695 + let is_null_clause = 696 + where_clause.WhereClause( 697 + conditions: dict.from_list([ 698 + #( 699 + "replyParent", 700 + where_clause.WhereCondition( 701 + eq: None, 702 + in_values: None, 703 + contains: None, 704 + gt: None, 705 + gte: None, 706 + lt: None, 707 + lte: None, 708 + is_null: Some(True), 709 + is_numeric: False, 710 + ), 711 + ), 712 + ]), 713 + and: None, 714 + or: None, 715 + ) 716 + 717 + let text_clause = 718 + where_clause.WhereClause( 719 + conditions: dict.from_list([ 720 + #( 721 + "text", 722 + where_clause.WhereCondition( 723 + eq: None, 724 + in_values: None, 725 + contains: Some("hello"), 726 + gt: None, 727 + gte: None, 728 + lt: None, 729 + lte: None, 730 + is_null: None, 731 + is_numeric: False, 732 + ), 733 + ), 734 + ]), 735 + and: None, 736 + or: None, 737 + ) 738 + 739 + let root_clause = 740 + where_clause.WhereClause( 741 + conditions: dict.new(), 742 + and: Some([is_null_clause, text_clause]), 743 + or: None, 744 + ) 745 + 746 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 747 + 748 + should.be_true(string.contains(sql, "IS NULL")) 749 + should.be_true(string.contains(sql, "LIKE")) 750 + should.be_true(string.contains(sql, "AND")) 751 + list.length(params) |> should.equal(1) 752 + } 753 + ``` 754 + 755 + **Step 2: Run tests to verify they fail** 756 + 757 + Run: `cd server && gleam test -- --filter="build_where_is_null"` 758 + Expected: FAIL - isNull SQL generation not implemented 759 + 760 + **Step 3: Add isNull SQL generation** 761 + 762 + In `server/src/where_clause.gleam`, update `build_single_condition` function (around line 156). 763 + 764 + Find the function and add `is_null` handling after the `contains` operator handling: 765 + 766 + ```gleam 767 + /// Builds SQL for a single condition on a field 768 + /// Returns a list of SQL strings and accumulated parameters 769 + fn build_single_condition( 770 + field: String, 771 + condition: WhereCondition, 772 + use_table_prefix: Bool, 773 + ) -> #(List(String), List(sqlight.Value)) { 774 + // Check if numeric casting is needed (for gt/gte/lt/lte operators) 775 + let has_numeric_comparison = should_cast_numeric(condition) 776 + 777 + let field_ref = 778 + build_field_ref_with_cast(field, use_table_prefix, has_numeric_comparison) 779 + 780 + // For isNull, we need the field ref without numeric cast 781 + let field_ref_no_cast = build_field_ref(field, use_table_prefix) 782 + 783 + let mut_sql_parts = [] 784 + let mut_params = [] 785 + 786 + // eq operator 787 + let #(sql_parts, params) = case condition.eq { 788 + Some(value) -> { 789 + #([field_ref <> " = ?", ..mut_sql_parts], [value, ..mut_params]) 790 + } 791 + None -> #(mut_sql_parts, mut_params) 792 + } 793 + let mut_sql_parts = sql_parts 794 + let mut_params = params 795 + 796 + // in operator 797 + let #(sql_parts, params) = case condition.in_values { 798 + Some(values) -> { 799 + case values { 800 + [] -> #(mut_sql_parts, mut_params) 801 + // Empty list - skip this condition 802 + _ -> { 803 + let placeholders = 804 + list.repeat("?", list.length(values)) 805 + |> string.join(", ") 806 + let sql = field_ref <> " IN (" <> placeholders <> ")" 807 + #([sql, ..mut_sql_parts], list.append(values, mut_params)) 808 + } 809 + } 810 + } 811 + None -> #(mut_sql_parts, mut_params) 812 + } 813 + let mut_sql_parts = sql_parts 814 + let mut_params = params 815 + 816 + // gt operator 817 + let #(sql_parts, params) = case condition.gt { 818 + Some(value) -> { 819 + #([field_ref <> " > ?", ..mut_sql_parts], [value, ..mut_params]) 820 + } 821 + None -> #(mut_sql_parts, mut_params) 822 + } 823 + let mut_sql_parts = sql_parts 824 + let mut_params = params 825 + 826 + // gte operator 827 + let #(sql_parts, params) = case condition.gte { 828 + Some(value) -> { 829 + #([field_ref <> " >= ?", ..mut_sql_parts], [value, ..mut_params]) 830 + } 831 + None -> #(mut_sql_parts, mut_params) 832 + } 833 + let mut_sql_parts = sql_parts 834 + let mut_params = params 835 + 836 + // lt operator 837 + let #(sql_parts, params) = case condition.lt { 838 + Some(value) -> { 839 + #([field_ref <> " < ?", ..mut_sql_parts], [value, ..mut_params]) 840 + } 841 + None -> #(mut_sql_parts, mut_params) 842 + } 843 + let mut_sql_parts = sql_parts 844 + let mut_params = params 845 + 846 + // lte operator 847 + let #(sql_parts, params) = case condition.lte { 848 + Some(value) -> { 849 + #([field_ref <> " <= ?", ..mut_sql_parts], [value, ..mut_params]) 850 + } 851 + None -> #(mut_sql_parts, mut_params) 852 + } 853 + let mut_sql_parts = sql_parts 854 + let mut_params = params 855 + 856 + // contains operator (case-insensitive LIKE) 857 + let #(sql_parts, params) = case condition.contains { 858 + Some(search_text) -> { 859 + let sql = field_ref <> " LIKE '%' || ? || '%' COLLATE NOCASE" 860 + #([sql, ..mut_sql_parts], [sqlight.text(search_text), ..mut_params]) 861 + } 862 + None -> #(mut_sql_parts, mut_params) 863 + } 864 + let mut_sql_parts = sql_parts 865 + let mut_params = params 866 + 867 + // isNull operator (no parameters needed) 868 + let #(sql_parts, params) = case condition.is_null { 869 + Some(True) -> { 870 + let sql = field_ref_no_cast <> " IS NULL" 871 + #([sql, ..mut_sql_parts], mut_params) 872 + } 873 + Some(False) -> { 874 + let sql = field_ref_no_cast <> " IS NOT NULL" 875 + #([sql, ..mut_sql_parts], mut_params) 876 + } 877 + None -> #(mut_sql_parts, mut_params) 878 + } 879 + 880 + // Reverse to maintain correct order (we built backwards) 881 + #(list.reverse(sql_parts), list.reverse(params)) 882 + } 883 + ``` 884 + 885 + **Step 4: Run tests to verify they pass** 886 + 887 + Run: `cd server && gleam test -- --filter="build_where_is_null"` 888 + Expected: PASS 889 + 890 + **Step 5: Commit** 891 + 892 + ```bash 893 + git add server/src/where_clause.gleam server/test/where_sql_builder_test.gleam 894 + git commit -m "feat(where): add isNull SQL generation" 895 + ``` 896 + 897 + --- 898 + 899 + ## Task 6: Fix Existing Tests 900 + 901 + **Files:** 902 + - Modify: Various test files that construct WhereCondition 903 + 904 + After adding `is_null` field, existing tests that construct `WhereCondition` manually will fail because they're missing the new field. 905 + 906 + **Step 1: Run all tests to find failures** 907 + 908 + Run: `cd server && gleam test` 909 + Run: `cd lexicon_graphql && gleam test` 910 + 911 + **Step 2: Update all WhereCondition constructors in tests** 912 + 913 + Search for `WhereCondition(` in test files and add `is_null: None,` to each one. 914 + 915 + Files likely needing updates: 916 + - `server/test/where_clause_test.gleam` 917 + - `server/test/where_sql_builder_test.gleam` 918 + - `server/test/where_edge_cases_test.gleam` 919 + - `server/test/where_integration_test.gleam` 920 + 921 + Example fix for each occurrence: 922 + ```gleam 923 + // Before 924 + where_clause.WhereCondition( 925 + eq: Some(sqlight.text("value")), 926 + in_values: None, 927 + contains: None, 928 + gt: None, 929 + gte: None, 930 + lt: None, 931 + lte: None, 932 + is_numeric: False, 933 + ) 934 + 935 + // After 936 + where_clause.WhereCondition( 937 + eq: Some(sqlight.text("value")), 938 + in_values: None, 939 + contains: None, 940 + gt: None, 941 + gte: None, 942 + lt: None, 943 + lte: None, 944 + is_null: None, 945 + is_numeric: False, 946 + ) 947 + ``` 948 + 949 + Similarly for `lexicon_graphql/input/where.gleam` tests: 950 + ```gleam 951 + // Before (if any manual constructions exist) 952 + where_input.WhereCondition( 953 + eq: None, 954 + in_values: None, 955 + contains: None, 956 + gt: None, 957 + gte: None, 958 + lt: None, 959 + lte: None, 960 + ) 961 + 962 + // After 963 + where_input.WhereCondition( 964 + eq: None, 965 + in_values: None, 966 + contains: None, 967 + gt: None, 968 + gte: None, 969 + lt: None, 970 + lte: None, 971 + is_null: None, 972 + ) 973 + ``` 974 + 975 + **Step 3: Run all tests to verify they pass** 976 + 977 + Run: `cd server && gleam test` 978 + Run: `cd lexicon_graphql && gleam test` 979 + Expected: All PASS 980 + 981 + **Step 4: Commit** 982 + 983 + ```bash 984 + git add -A 985 + git commit -m "fix(tests): update WhereCondition constructors with is_null field" 986 + ``` 987 + 988 + --- 989 + 990 + ## Task 7: Full Integration Test 991 + 992 + **Files:** 993 + - Test: `server/test/where_integration_test.gleam` or new file 994 + 995 + **Step 1: Write integration test** 996 + 997 + Add to `server/test/where_integration_test.gleam` (or create if needed): 998 + 999 + ```gleam 1000 + pub fn is_null_end_to_end_test() { 1001 + // Test the full flow: GraphQL input -> parsing -> conversion -> SQL 1002 + 1003 + // Simulate GraphQL input: { replyParent: { isNull: true } } 1004 + let condition_value = value.Object([#("isNull", value.Boolean(True))]) 1005 + let where_value = value.Object([#("replyParent", condition_value)]) 1006 + 1007 + // Parse 1008 + let parsed = where_input.parse_where_clause(where_value) 1009 + 1010 + // Convert 1011 + let converted = where_converter.convert_where_clause(parsed) 1012 + 1013 + // Generate SQL 1014 + let #(sql, params) = where_clause.build_where_sql(converted, False) 1015 + 1016 + sql |> should.equal("json_extract(json, '$.replyParent') IS NULL") 1017 + list.length(params) |> should.equal(0) 1018 + } 1019 + 1020 + pub fn is_not_null_end_to_end_test() { 1021 + // Simulate GraphQL input: { replyParent: { isNull: false } } 1022 + let condition_value = value.Object([#("isNull", value.Boolean(False))]) 1023 + let where_value = value.Object([#("replyParent", condition_value)]) 1024 + 1025 + // Parse 1026 + let parsed = where_input.parse_where_clause(where_value) 1027 + 1028 + // Convert 1029 + let converted = where_converter.convert_where_clause(parsed) 1030 + 1031 + // Generate SQL 1032 + let #(sql, params) = where_clause.build_where_sql(converted, False) 1033 + 1034 + sql |> should.equal("json_extract(json, '$.replyParent') IS NOT NULL") 1035 + list.length(params) |> should.equal(0) 1036 + } 1037 + ``` 1038 + 1039 + **Step 2: Run integration tests** 1040 + 1041 + Run: `cd server && gleam test -- --filter="is_null_end_to_end"` 1042 + Expected: PASS 1043 + 1044 + **Step 3: Commit** 1045 + 1046 + ```bash 1047 + git add server/test/where_integration_test.gleam 1048 + git commit -m "test(where): add isNull integration tests" 1049 + ``` 1050 + 1051 + --- 1052 + 1053 + ## Task 8: Final Verification 1054 + 1055 + **Step 1: Run full test suite** 1056 + 1057 + ```bash 1058 + cd lexicon_graphql && gleam test 1059 + cd ../server && gleam test 1060 + ``` 1061 + 1062 + Expected: All tests PASS 1063 + 1064 + **Step 2: Build the project** 1065 + 1066 + ```bash 1067 + gleam build 1068 + ``` 1069 + 1070 + Expected: Build succeeds with no errors 1071 + 1072 + **Step 3: Final commit (if any remaining changes)** 1073 + 1074 + ```bash 1075 + git status 1076 + # If clean, done. Otherwise: 1077 + git add -A 1078 + git commit -m "chore: final cleanup for isNull feature" 1079 + ```
+433
dev-docs/plans/2025-12-03-ref-field-is-null-filter.md
··· 1 + # RefField isNull Filter Support 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Allow filtering on `ref` type fields (like `reply` in `app.bsky.feed.post`) with the `isNull` operator. 6 + 7 + **Architecture:** Create a separate `RefFieldCondition` GraphQL input type with only `isNull` (no eq, contains, etc.). Update schema generation to include RefFields in filterable fields and assign them this limited condition type. 8 + 9 + **Tech Stack:** Gleam, GraphQL schema generation (swell), lexicon_graphql 10 + 11 + --- 12 + 13 + ## Task 1: Add RefFieldCondition Type Builder 14 + 15 + **Files:** 16 + - Modify: `lexicon_graphql/src/lexicon_graphql/input/connection.gleam` 17 + 18 + **Step 1: Write the failing test** 19 + 20 + Create test file `lexicon_graphql/test/ref_field_condition_test.gleam`: 21 + 22 + ```gleam 23 + import gleam/list 24 + import gleeunit 25 + import gleeunit/should 26 + import lexicon_graphql/input/connection 27 + import swell/schema 28 + 29 + pub fn main() { 30 + gleeunit.main() 31 + } 32 + 33 + pub fn ref_field_condition_has_only_is_null_test() { 34 + let condition_type = connection.build_ref_where_condition_input_type("Test") 35 + 36 + let fields = schema.get_input_fields(condition_type) 37 + 38 + // Should have exactly 1 field 39 + list.length(fields) |> should.equal(1) 40 + 41 + // That field should be isNull 42 + let has_is_null = 43 + fields 44 + |> list.any(fn(field) { schema.input_field_name(field) == "isNull" }) 45 + 46 + has_is_null |> should.be_true 47 + } 48 + 49 + pub fn ref_field_condition_no_eq_operator_test() { 50 + let condition_type = connection.build_ref_where_condition_input_type("Test") 51 + 52 + let fields = schema.get_input_fields(condition_type) 53 + 54 + // Should NOT have eq field 55 + let has_eq = 56 + fields 57 + |> list.any(fn(field) { schema.input_field_name(field) == "eq" }) 58 + 59 + has_eq |> should.be_false 60 + } 61 + ``` 62 + 63 + **Step 2: Run test to verify it fails** 64 + 65 + ```bash 66 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test -- --filter="ref_field_condition" 67 + ``` 68 + 69 + Expected: Compile error - `build_ref_where_condition_input_type` does not exist 70 + 71 + **Step 3: Add the RefFieldCondition builder function** 72 + 73 + In `lexicon_graphql/src/lexicon_graphql/input/connection.gleam`, add after `build_where_condition_input_type` (around line 121): 74 + 75 + ```gleam 76 + /// Builds a WhereConditionInput type for ref fields (objects) 77 + /// Only supports isNull operator - ref fields can only check for presence/absence 78 + pub fn build_ref_where_condition_input_type(type_name: String) -> schema.Type { 79 + let condition_type_name = type_name <> "RefFieldCondition" 80 + 81 + schema.input_object_type( 82 + condition_type_name, 83 + "Filter for " <> type_name <> " reference fields (presence check only)", 84 + [ 85 + schema.input_field( 86 + "isNull", 87 + schema.boolean_type(), 88 + "Filter for null (true) or non-null (false) values", 89 + None, 90 + ), 91 + ], 92 + ) 93 + } 94 + ``` 95 + 96 + **Step 4: Run test to verify it passes** 97 + 98 + ```bash 99 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test -- --filter="ref_field_condition" 100 + ``` 101 + 102 + Expected: PASS 103 + 104 + **Step 5: Commit** 105 + 106 + ```bash 107 + git add lexicon_graphql/src/lexicon_graphql/input/connection.gleam lexicon_graphql/test/ref_field_condition_test.gleam && git commit -m "feat(where): add RefFieldCondition type with isNull only" 108 + ``` 109 + 110 + --- 111 + 112 + ## Task 2: Update build_where_input_type to Handle Field Types 113 + 114 + **Files:** 115 + - Modify: `lexicon_graphql/src/lexicon_graphql/input/connection.gleam` 116 + 117 + **Step 1: Write the failing test** 118 + 119 + Add to `lexicon_graphql/test/ref_field_condition_test.gleam`: 120 + 121 + ```gleam 122 + pub fn where_input_with_ref_field_uses_ref_condition_test() { 123 + // Fields: text is primitive (False), reply is ref (True) 124 + let fields = [#("text", False), #("reply", True)] 125 + 126 + let where_input = connection.build_where_input_type_with_field_types("Test", fields) 127 + 128 + let input_fields = schema.get_input_fields(where_input) 129 + 130 + // Find the reply field 131 + let reply_field = 132 + list.find(input_fields, fn(field) { 133 + schema.input_field_name(field) == "reply" 134 + }) 135 + 136 + case reply_field { 137 + Ok(field) -> { 138 + // The type name should contain "RefFieldCondition" 139 + let type_name = schema.get_type_name(schema.input_field_type(field)) 140 + case type_name { 141 + Ok(name) -> should.be_true(string.contains(name, "RefFieldCondition")) 142 + Error(_) -> should.fail() 143 + } 144 + } 145 + Error(_) -> should.fail() 146 + } 147 + } 148 + ``` 149 + 150 + Add import at top: `import gleam/string` 151 + 152 + **Step 2: Run test to verify it fails** 153 + 154 + ```bash 155 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test -- --filter="where_input_with_ref_field" 156 + ``` 157 + 158 + Expected: Compile error - `build_where_input_type_with_field_types` does not exist 159 + 160 + **Step 3: Add the new function** 161 + 162 + In `lexicon_graphql/src/lexicon_graphql/input/connection.gleam`, add after `build_ref_where_condition_input_type`: 163 + 164 + ```gleam 165 + /// Builds a WhereInput type with support for different field types 166 + /// field_info is a list of (field_name, is_ref_field) tuples 167 + pub fn build_where_input_type_with_field_types( 168 + type_name: String, 169 + field_info: List(#(String, Bool)), 170 + ) -> schema.Type { 171 + let where_input_name = type_name <> "WhereInput" 172 + 173 + // Build the condition types 174 + let string_condition_type = 175 + build_where_condition_input_type(type_name, schema.string_type()) 176 + let ref_condition_type = build_ref_where_condition_input_type(type_name) 177 + 178 + // Build input fields - use ref condition for ref fields, string condition otherwise 179 + let field_input_fields = 180 + list.map(field_info, fn(info) { 181 + let #(field_name, is_ref) = info 182 + let condition_type = case is_ref { 183 + True -> ref_condition_type 184 + False -> string_condition_type 185 + } 186 + schema.input_field( 187 + field_name, 188 + condition_type, 189 + "Filter by " <> field_name, 190 + None, 191 + ) 192 + }) 193 + 194 + // Create placeholder type for recursive reference 195 + let where_input_type = 196 + schema.input_object_type( 197 + where_input_name, 198 + "Filter conditions for " <> type_name, 199 + field_input_fields, 200 + ) 201 + 202 + // Add AND/OR fields 203 + let logic_fields = [ 204 + schema.input_field( 205 + "and", 206 + schema.list_type(schema.non_null(where_input_type)), 207 + "All conditions must match (AND logic)", 208 + None, 209 + ), 210 + schema.input_field( 211 + "or", 212 + schema.list_type(schema.non_null(where_input_type)), 213 + "Any condition must match (OR logic)", 214 + None, 215 + ), 216 + ] 217 + 218 + schema.input_object_type( 219 + where_input_name, 220 + "Filter conditions for " <> type_name <> " with nested AND/OR support", 221 + list.append(field_input_fields, logic_fields), 222 + ) 223 + } 224 + ``` 225 + 226 + **Step 4: Run test to verify it passes** 227 + 228 + ```bash 229 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test -- --filter="where_input_with_ref_field" 230 + ``` 231 + 232 + Expected: PASS 233 + 234 + **Step 5: Commit** 235 + 236 + ```bash 237 + git add lexicon_graphql/src/lexicon_graphql/input/connection.gleam lexicon_graphql/test/ref_field_condition_test.gleam && git commit -m "feat(where): add build_where_input_type_with_field_types for ref support" 238 + ``` 239 + 240 + --- 241 + 242 + ## Task 3: Add is_filterable_property Function 243 + 244 + **Files:** 245 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 246 + 247 + **Step 1: Add is_filterable_property function** 248 + 249 + In `lexicon_graphql/src/lexicon_graphql/schema/database.gleam`, add after `is_groupable_property` (around line 1328): 250 + 251 + ```gleam 252 + /// Check if a lexicon property is filterable based on its type 253 + /// Filterable types: string, integer, boolean, number, ref (primitives + refs for isNull) 254 + /// Non-filterable types: blob, array (complex types) 255 + fn is_filterable_property(property: types.Property) -> Bool { 256 + case property_to_field_type(property) { 257 + StringField | IntField | BoolField | NumberField | DateTimeField | RefField -> 258 + True 259 + BlobField | ArrayField -> False 260 + } 261 + } 262 + ``` 263 + 264 + **Step 2: Verify it compiles** 265 + 266 + ```bash 267 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build 268 + ``` 269 + 270 + Expected: Compiles successfully 271 + 272 + **Step 3: Commit** 273 + 274 + ```bash 275 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam && git commit -m "feat(where): add is_filterable_property including RefField" 276 + ``` 277 + 278 + --- 279 + 280 + ## Task 4: Update get_filterable_field_names to Return Field Type Info 281 + 282 + **Files:** 283 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 284 + 285 + **Step 1: Update the function to return tuples** 286 + 287 + Replace `get_filterable_field_names` (lines 1356-1381) with: 288 + 289 + ```gleam 290 + /// Get all filterable field names for WHERE inputs with their type info 291 + /// Returns list of (field_name, is_ref_field) tuples 292 + /// Includes primitive fields, ref fields, and computed fields like actorHandle 293 + fn get_filterable_field_names(record_type: RecordType) -> List(#(String, Bool)) { 294 + // Filter properties to only filterable types, return with is_ref flag 295 + let filterable_property_info = 296 + list.filter_map(record_type.properties, fn(prop) { 297 + let #(field_name, property) = prop 298 + case is_filterable_property(property) { 299 + True -> { 300 + let is_ref = property_to_field_type(property) == RefField 301 + Ok(#(field_name, is_ref)) 302 + } 303 + False -> Error(Nil) 304 + } 305 + }) 306 + 307 + // Add standard filterable fields from AT Protocol (all are non-ref) 308 + // Note: actorHandle IS included because WHERE clauses support filtering by it 309 + let standard_filterable_fields = [ 310 + #("uri", False), 311 + #("cid", False), 312 + #("did", False), 313 + #("collection", False), 314 + #("indexedAt", False), 315 + #("actorHandle", False), 316 + ] 317 + list.append(standard_filterable_fields, filterable_property_info) 318 + } 319 + ``` 320 + 321 + **Step 2: Verify it compiles (will fail - callers need update)** 322 + 323 + ```bash 324 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build 2>&1 | head -30 325 + ``` 326 + 327 + Expected: Type mismatch errors at call sites 328 + 329 + **Step 3: Commit (partial - will fix callers next)** 330 + 331 + ```bash 332 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam && git commit -m "feat(where): update get_filterable_field_names to return type info" 333 + ``` 334 + 335 + --- 336 + 337 + ## Task 5: Update build_where_input_type Caller 338 + 339 + **Files:** 340 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 341 + 342 + **Step 1: Update the local build_where_input_type function** 343 + 344 + Replace `build_where_input_type` (lines 1480-1489) with: 345 + 346 + ```gleam 347 + /// Build a WhereInput type for a record type with all its filterable fields 348 + /// Includes primitive fields (string, integer, boolean, number) and ref fields 349 + /// Excludes complex types (blob, array) but includes computed fields like actorHandle 350 + fn build_where_input_type(record_type: RecordType) -> schema.Type { 351 + // Get filterable field info (includes type info for ref vs primitive) 352 + let field_info = get_filterable_field_names(record_type) 353 + 354 + // Use the connection module to build the where input type with field types 355 + lexicon_connection.build_where_input_type_with_field_types( 356 + record_type.type_name, 357 + field_info, 358 + ) 359 + } 360 + ``` 361 + 362 + **Step 2: Run tests to verify** 363 + 364 + ```bash 365 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test 366 + ``` 367 + 368 + Expected: All tests pass (may need to accept birdie snapshots) 369 + 370 + **Step 3: Accept any snapshot updates** 371 + 372 + ```bash 373 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam run -m birdie -- accept-all 374 + ``` 375 + 376 + **Step 4: Run tests again** 377 + 378 + ```bash 379 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test 380 + ``` 381 + 382 + Expected: All tests pass 383 + 384 + **Step 5: Commit** 385 + 386 + ```bash 387 + git add lexicon_graphql/ && git commit -m "feat(where): wire up RefField support in schema generation" 388 + ``` 389 + 390 + --- 391 + 392 + ## Task 6: Full Test Suite Verification 393 + 394 + **Step 1: Run lexicon_graphql tests** 395 + 396 + ```bash 397 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test 398 + ``` 399 + 400 + Expected: All tests pass 401 + 402 + **Step 2: Run server tests** 403 + 404 + ```bash 405 + cd /Users/chadmiller/code/quickslice/server && gleam test 406 + ``` 407 + 408 + Expected: All tests pass 409 + 410 + **Step 3: Build both projects** 411 + 412 + ```bash 413 + cd /Users/chadmiller/code/quickslice/server && gleam build 414 + cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build 415 + ``` 416 + 417 + Expected: Both compile successfully 418 + 419 + --- 420 + 421 + ## Files Modified Summary 422 + 423 + 1. `lexicon_graphql/src/lexicon_graphql/input/connection.gleam` 424 + - Added `build_ref_where_condition_input_type` 425 + - Added `build_where_input_type_with_field_types` 426 + 427 + 2. `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 428 + - Added `is_filterable_property` 429 + - Updated `get_filterable_field_names` to return `List(#(String, Bool))` 430 + - Updated `build_where_input_type` to use new function 431 + 432 + 3. `lexicon_graphql/test/ref_field_condition_test.gleam` (new) 433 + - Tests for RefFieldCondition type
+4
lexicon_graphql/birdie_snapshots/all_types_generated_by_db_schema_builder_including_connection,_edge,_page_info,_sort_field_enum,_where_input,_etc.accepted
··· 1 1 --- 2 2 version: 1.4.1 3 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 4 6 --- 5 7 """Order aggregation results by count""" 6 8 input AggregationOrderBy { ··· 154 156 lt: String 155 157 """Less than or equal to""" 156 158 lte: String 159 + """Filter for null or missing values (true) or non-null values (false)""" 160 + isNull: Boolean 157 161 } 158 162 159 163 """Available groupBy fields for XyzStatusphereStatus"""
+2
lexicon_graphql/birdie_snapshots/field_condition_type_with_all_operators.accepted
··· 20 20 lt: String 21 21 """Less than or equal to""" 22 22 lte: String 23 + """Filter for null or missing values (true) or non-null values (false)""" 24 + isNull: Boolean 23 25 }
+2
lexicon_graphql/birdie_snapshots/query_type_with_connection_field_and_sort_by_argument.accepted
··· 1 1 --- 2 2 version: 1.4.1 3 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 4 6 --- 5 7 """Root query type""" 6 8 type Query {
+2
lexicon_graphql/birdie_snapshots/query_type_with_multiple_connection_fields_and_distinct_sort_enums.accepted
··· 1 1 --- 2 2 version: 1.4.1 3 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 4 6 --- 5 7 """Root query type""" 6 8 type Query {
+2
lexicon_graphql/birdie_snapshots/where_input_and_field_condition_types_together.accepted
··· 34 34 lt: String 35 35 """Less than or equal to""" 36 36 lte: String 37 + """Filter for null or missing values (true) or non-null values (false)""" 38 + isNull: Boolean 37 39 }
+2
lexicon_graphql/birdie_snapshots/where_input_with_mixed_types_only_includes_primitives.accepted
··· 30 30 datetimeField: AppBskyTestRecordFieldCondition 31 31 """Filter by uriField""" 32 32 uriField: AppBskyTestRecordFieldCondition 33 + """Filter by refField""" 34 + refField: AppBskyTestRecordRefFieldCondition 33 35 """All conditions must match (AND logic)""" 34 36 and: [AppBskyTestRecordWhereInput!] 35 37 """Any condition must match (OR logic)"""
+86 -1
lexicon_graphql/src/lexicon_graphql/input/connection.gleam
··· 82 82 } 83 83 84 84 /// Builds a WhereConditionInput type for filtering a specific field type 85 - /// Supports: eq, in, contains, gt, gte, lt, lte operators 85 + /// Supports: eq, in, contains, gt, gte, lt, lte, isNull operators 86 86 pub fn build_where_condition_input_type( 87 87 type_name: String, 88 88 field_type: schema.Type, ··· 110 110 schema.input_field("gte", field_type, "Greater than or equal to", None), 111 111 schema.input_field("lt", field_type, "Less than", None), 112 112 schema.input_field("lte", field_type, "Less than or equal to", None), 113 + schema.input_field( 114 + "isNull", 115 + schema.boolean_type(), 116 + "Filter for null or missing values (true) or non-null values (false)", 117 + None, 118 + ), 113 119 ], 120 + ) 121 + } 122 + 123 + /// Builds a WhereConditionInput type for ref fields (objects) 124 + /// Only supports isNull operator - ref fields can only check for presence/absence 125 + pub fn build_ref_where_condition_input_type(type_name: String) -> schema.Type { 126 + let condition_type_name = type_name <> "RefFieldCondition" 127 + 128 + schema.input_object_type( 129 + condition_type_name, 130 + "Filter for " <> type_name <> " reference fields (presence check only)", 131 + [ 132 + schema.input_field( 133 + "isNull", 134 + schema.boolean_type(), 135 + "Filter for null (true) or non-null (false) values", 136 + None, 137 + ), 138 + ], 139 + ) 140 + } 141 + 142 + /// Builds a WhereInput type with support for different field types 143 + /// field_info is a list of (field_name, is_ref_field) tuples 144 + pub fn build_where_input_type_with_field_types( 145 + type_name: String, 146 + field_info: List(#(String, Bool)), 147 + ) -> schema.Type { 148 + let where_input_name = type_name <> "WhereInput" 149 + 150 + // Build the condition types 151 + let string_condition_type = 152 + build_where_condition_input_type(type_name, schema.string_type()) 153 + let ref_condition_type = build_ref_where_condition_input_type(type_name) 154 + 155 + // Build input fields - use ref condition for ref fields, string condition otherwise 156 + let field_input_fields = 157 + list.map(field_info, fn(info) { 158 + let #(field_name, is_ref) = info 159 + let condition_type = case is_ref { 160 + True -> ref_condition_type 161 + False -> string_condition_type 162 + } 163 + schema.input_field( 164 + field_name, 165 + condition_type, 166 + "Filter by " <> field_name, 167 + None, 168 + ) 169 + }) 170 + 171 + // Create placeholder type for recursive reference 172 + let where_input_type = 173 + schema.input_object_type( 174 + where_input_name, 175 + "Filter conditions for " <> type_name, 176 + field_input_fields, 177 + ) 178 + 179 + // Add AND/OR fields 180 + let logic_fields = [ 181 + schema.input_field( 182 + "and", 183 + schema.list_type(schema.non_null(where_input_type)), 184 + "All conditions must match (AND logic)", 185 + None, 186 + ), 187 + schema.input_field( 188 + "or", 189 + schema.list_type(schema.non_null(where_input_type)), 190 + "Any condition must match (OR logic)", 191 + None, 192 + ), 193 + ] 194 + 195 + schema.input_object_type( 196 + where_input_name, 197 + "Filter conditions for " <> type_name <> " with nested AND/OR support", 198 + list.append(field_input_fields, logic_fields), 114 199 ) 115 200 } 116 201
+8
lexicon_graphql/src/lexicon_graphql/input/where.gleam
··· 25 25 gte: Option(WhereValue), 26 26 lt: Option(WhereValue), 27 27 lte: Option(WhereValue), 28 + is_null: Option(Bool), 28 29 ) 29 30 } 30 31 ··· 98 99 _ -> None 99 100 } 100 101 102 + let is_null = case list.key_find(fields, "isNull") { 103 + Ok(value.Boolean(b)) -> Some(b) 104 + _ -> None 105 + } 106 + 101 107 WhereCondition( 102 108 eq: eq, 103 109 in_values: in_values, ··· 106 112 gte: gte, 107 113 lt: lt, 108 114 lte: lte, 115 + is_null: is_null, 109 116 ) 110 117 } 111 118 _ -> ··· 117 124 gte: None, 118 125 lt: None, 119 126 lte: None, 127 + is_null: None, 120 128 ) 121 129 } 122 130 }
+39 -22
lexicon_graphql/src/lexicon_graphql/schema/database.gleam
··· 1327 1327 } 1328 1328 } 1329 1329 1330 + /// Check if a lexicon property is filterable based on its type 1331 + /// Filterable types: string, integer, boolean, number, ref (primitives + refs for isNull) 1332 + /// Non-filterable types: blob, array (complex types) 1333 + fn is_filterable_property(property: types.Property) -> Bool { 1334 + case property_to_field_type(property) { 1335 + StringField | IntField | BoolField | NumberField | DateTimeField | RefField -> 1336 + True 1337 + BlobField | ArrayField -> False 1338 + } 1339 + } 1340 + 1330 1341 /// Get all sortable field names for sort enums 1331 1342 /// Returns only database-sortable fields (excludes computed fields like actorHandle) 1332 1343 fn get_sortable_field_names_for_sorting(record_type: RecordType) -> List(String) { ··· 1353 1364 list.append(standard_sortable_fields, sortable_property_names) 1354 1365 } 1355 1366 1356 - /// Get all filterable field names for WHERE inputs 1357 - /// Returns all primitive fields including computed fields like actorHandle 1358 - fn get_filterable_field_names(record_type: RecordType) -> List(String) { 1359 - // Filter properties to only sortable types, then get their field names 1360 - let filterable_property_names = 1367 + /// Get all filterable field names for WHERE inputs with their type info 1368 + /// Returns list of (field_name, is_ref_field) tuples 1369 + /// Includes primitive fields, ref fields, and computed fields like actorHandle 1370 + fn get_filterable_field_names(record_type: RecordType) -> List(#(String, Bool)) { 1371 + // Filter properties to only filterable types, return with is_ref flag 1372 + let filterable_property_info = 1361 1373 list.filter_map(record_type.properties, fn(prop) { 1362 1374 let #(field_name, property) = prop 1363 - case is_sortable_property(property) { 1364 - True -> Ok(field_name) 1375 + case is_filterable_property(property) { 1376 + True -> { 1377 + let is_ref = property_to_field_type(property) == RefField 1378 + Ok(#(field_name, is_ref)) 1379 + } 1365 1380 False -> Error(Nil) 1366 1381 } 1367 1382 }) 1368 1383 1369 - // Add standard filterable fields from AT Protocol 1384 + // Add standard filterable fields from AT Protocol (all are non-ref) 1370 1385 // Note: actorHandle IS included because WHERE clauses support filtering by it 1371 - // via a join with the actor table 1372 1386 let standard_filterable_fields = [ 1373 - "uri", 1374 - "cid", 1375 - "did", 1376 - "collection", 1377 - "indexedAt", 1378 - "actorHandle", 1387 + #("uri", False), 1388 + #("cid", False), 1389 + #("did", False), 1390 + #("collection", False), 1391 + #("indexedAt", False), 1392 + #("actorHandle", False), 1379 1393 ] 1380 - list.append(standard_filterable_fields, filterable_property_names) 1394 + list.append(standard_filterable_fields, filterable_property_info) 1381 1395 } 1382 1396 1383 1397 /// Get all groupable field names for aggregation GROUP BY ··· 1478 1492 } 1479 1493 1480 1494 /// Build a WhereInput type for a record type with all its filterable fields 1481 - /// Only includes primitive fields (string, integer, boolean, number) from the original lexicon 1482 - /// Excludes complex types (blob, ref) and join fields, but includes computed fields like actorHandle 1495 + /// Includes primitive fields (string, integer, boolean, number) and ref fields 1496 + /// Excludes complex types (blob, array) but includes computed fields like actorHandle 1483 1497 fn build_where_input_type(record_type: RecordType) -> schema.Type { 1484 - // Get filterable field names (includes actorHandle and other filterable computed fields) 1485 - let field_names = get_filterable_field_names(record_type) 1498 + // Get filterable field info (includes type info for ref vs primitive) 1499 + let field_info = get_filterable_field_names(record_type) 1486 1500 1487 - // Use the connection module to build the where input type 1488 - lexicon_connection.build_where_input_type(record_type.type_name, field_names) 1501 + // Use the connection module to build the where input type with field types 1502 + lexicon_connection.build_where_input_type_with_field_types( 1503 + record_type.type_name, 1504 + field_info, 1505 + ) 1489 1506 } 1490 1507 1491 1508 /// Build the root Query type with fields for each record type
+64
lexicon_graphql/test/ref_field_condition_test.gleam
··· 1 + import gleam/list 2 + import gleam/string 3 + import gleeunit 4 + import gleeunit/should 5 + import lexicon_graphql/input/connection 6 + import swell/schema 7 + 8 + pub fn main() { 9 + gleeunit.main() 10 + } 11 + 12 + pub fn ref_field_condition_has_only_is_null_test() { 13 + let condition_type = connection.build_ref_where_condition_input_type("Test") 14 + 15 + let fields = schema.get_input_fields(condition_type) 16 + 17 + // Should have exactly 1 field 18 + list.length(fields) |> should.equal(1) 19 + 20 + // That field should be isNull 21 + let has_is_null = 22 + fields 23 + |> list.any(fn(field) { schema.input_field_name(field) == "isNull" }) 24 + 25 + has_is_null |> should.be_true 26 + } 27 + 28 + pub fn ref_field_condition_no_eq_operator_test() { 29 + let condition_type = connection.build_ref_where_condition_input_type("Test") 30 + 31 + let fields = schema.get_input_fields(condition_type) 32 + 33 + // Should NOT have eq field 34 + let has_eq = 35 + fields 36 + |> list.any(fn(field) { schema.input_field_name(field) == "eq" }) 37 + 38 + has_eq |> should.be_false 39 + } 40 + 41 + pub fn where_input_with_ref_field_uses_ref_condition_test() { 42 + // Fields: text is primitive (False), reply is ref (True) 43 + let fields = [#("text", False), #("reply", True)] 44 + 45 + let where_input = 46 + connection.build_where_input_type_with_field_types("Test", fields) 47 + 48 + let input_fields = schema.get_input_fields(where_input) 49 + 50 + // Find the reply field 51 + let reply_field = 52 + list.find(input_fields, fn(field) { 53 + schema.input_field_name(field) == "reply" 54 + }) 55 + 56 + case reply_field { 57 + Ok(field) -> { 58 + // The type name should contain "RefFieldCondition" 59 + let type_name = schema.type_name(schema.input_field_type(field)) 60 + should.be_true(string.contains(type_name, "RefFieldCondition")) 61 + } 62 + Error(_) -> should.fail() 63 + } 64 + }
+38
lexicon_graphql/test/where_input_test.gleam
··· 485 485 None -> should.fail() 486 486 } 487 487 } 488 + 489 + // ===== isNull Operator Tests ===== 490 + 491 + pub fn parse_is_null_true_test() { 492 + // { field: { isNull: true } } 493 + let condition_value = value.Object([#("isNull", value.Boolean(True))]) 494 + let where_value = value.Object([#("field", condition_value)]) 495 + 496 + let result = where_input.parse_where_clause(where_value) 497 + 498 + case dict.get(result.conditions, "field") { 499 + Ok(condition) -> { 500 + case condition.is_null { 501 + Some(True) -> should.be_true(True) 502 + _ -> should.fail() 503 + } 504 + } 505 + Error(_) -> should.fail() 506 + } 507 + } 508 + 509 + pub fn parse_is_null_false_test() { 510 + // { field: { isNull: false } } 511 + let condition_value = value.Object([#("isNull", value.Boolean(False))]) 512 + let where_value = value.Object([#("field", condition_value)]) 513 + 514 + let result = where_input.parse_where_clause(where_value) 515 + 516 + case dict.get(result.conditions, "field") { 517 + Ok(condition) -> { 518 + case condition.is_null { 519 + Some(False) -> should.be_true(True) 520 + _ -> should.fail() 521 + } 522 + } 523 + Error(_) -> should.fail() 524 + } 525 + }
+26
lexicon_graphql/test/where_is_null_schema_test.gleam
··· 1 + /// Tests for isNull field in where condition schema 2 + import gleam/list 3 + import gleeunit 4 + import gleeunit/should 5 + import lexicon_graphql/input/connection 6 + import swell/schema 7 + 8 + pub fn main() { 9 + gleeunit.main() 10 + } 11 + 12 + pub fn where_condition_has_is_null_field_test() { 13 + // Build a where condition type 14 + let condition_type = 15 + connection.build_where_condition_input_type("Test", schema.string_type()) 16 + 17 + // Get the input object fields using accessor 18 + let fields = schema.get_input_fields(condition_type) 19 + 20 + // Find the isNull field 21 + let has_is_null = 22 + fields 23 + |> list.any(fn(field) { schema.input_field_name(field) == "isNull" }) 24 + 25 + has_is_null |> should.be_true 26 + }
+5 -3
lexicon_graphql/test/where_schema_test.gleam
··· 211 211 // ===== Integration Tests with db_schema_builder ===== 212 212 213 213 // Test: WHERE input only includes primitive types (string, integer, boolean, number) 214 - pub fn where_input_excludes_blob_and_ref_types_test() { 214 + pub fn where_input_excludes_blob_includes_ref_types_test() { 215 215 let lexicon = 216 216 types.Lexicon( 217 217 "app.bsky.test.record", ··· 335 335 should.be_true(list.contains(field_names, "and")) 336 336 should.be_true(list.contains(field_names, "or")) 337 337 338 - // Should NOT include blob or ref fields 338 + // Should NOT include blob fields 339 339 should.be_false(list.contains(field_names, "blobField")) 340 - should.be_false(list.contains(field_names, "refField")) 340 + 341 + // Should include ref fields (with limited operators - isNull only) 342 + should.be_true(list.contains(field_names, "refField")) 341 343 } 342 344 Error(_) -> should.fail() 343 345 }
+22
server/src/where_clause.gleam
··· 15 15 gte: Option(sqlight.Value), 16 16 lt: Option(sqlight.Value), 17 17 lte: Option(sqlight.Value), 18 + is_null: Option(Bool), 18 19 /// Whether the comparison values are numeric (affects JSON field casting) 19 20 is_numeric: Bool, 20 21 ) ··· 42 43 gte: None, 43 44 lt: None, 44 45 lte: None, 46 + is_null: None, 45 47 is_numeric: False, 46 48 ) 47 49 } ··· 62 64 gte: None, 63 65 lt: None, 64 66 lte: None, 67 + is_null: None, 65 68 is_numeric: _, 66 69 ) -> True 67 70 _ -> False ··· 165 168 166 169 let field_ref = 167 170 build_field_ref_with_cast(field, use_table_prefix, has_numeric_comparison) 171 + 172 + // For isNull, we need the field ref without numeric cast 173 + let field_ref_no_cast = build_field_ref(field, use_table_prefix) 174 + 168 175 let mut_sql_parts = [] 169 176 let mut_params = [] 170 177 ··· 243 250 Some(search_text) -> { 244 251 let sql = field_ref <> " LIKE '%' || ? || '%' COLLATE NOCASE" 245 252 #([sql, ..mut_sql_parts], [sqlight.text(search_text), ..mut_params]) 253 + } 254 + None -> #(mut_sql_parts, mut_params) 255 + } 256 + let mut_sql_parts = sql_parts 257 + let mut_params = params 258 + 259 + // isNull operator (no parameters needed) 260 + let #(sql_parts, params) = case condition.is_null { 261 + Some(True) -> { 262 + let sql = field_ref_no_cast <> " IS NULL" 263 + #([sql, ..mut_sql_parts], mut_params) 264 + } 265 + Some(False) -> { 266 + let sql = field_ref_no_cast <> " IS NOT NULL" 267 + #([sql, ..mut_sql_parts], mut_params) 246 268 } 247 269 None -> #(mut_sql_parts, mut_params) 248 270 }
+1
server/src/where_converter.gleam
··· 53 53 gte: option.map(cond.gte, convert_value), 54 54 lt: option.map(cond.lt, convert_value), 55 55 lte: option.map(cond.lte, convert_value), 56 + is_null: cond.is_null, 56 57 is_numeric: has_numeric_comparison(cond), 57 58 ) 58 59 }
+1
server/test/database_aggregation_test.gleam
··· 177 177 gte: None, 178 178 lt: None, 179 179 lte: None, 180 + is_null: None, 180 181 is_numeric: False, 181 182 ) 182 183
+22
server/test/where_clause_test.gleam
··· 49 49 gte: None, 50 50 lt: None, 51 51 lte: None, 52 + is_null: None, 52 53 is_numeric: False, 53 54 ) 54 55 where_clause.is_condition_empty(condition) |> should.be_false ··· 65 66 gte: None, 66 67 lt: None, 67 68 lte: None, 69 + is_null: None, 68 70 is_numeric: False, 69 71 ) 70 72 where_clause.is_condition_empty(condition) |> should.be_false ··· 87 89 gte: None, 88 90 lt: None, 89 91 lte: None, 92 + is_null: None, 90 93 is_numeric: False, 91 94 ) 92 95 let clause = ··· 133 136 gte: None, 134 137 lt: Some(sqlight.int(100)), 135 138 lte: None, 139 + is_null: None, 136 140 is_numeric: False, 137 141 ) 138 142 ··· 156 160 gte: None, 157 161 lt: None, 158 162 lte: None, 163 + is_null: None, 159 164 is_numeric: False, 160 165 ), 161 166 ), ··· 183 188 None -> should.fail() 184 189 } 185 190 } 191 + 192 + // Test is_condition_empty with is_null operator set 193 + pub fn is_condition_empty_false_with_is_null_test() { 194 + let condition = 195 + where_clause.WhereCondition( 196 + eq: None, 197 + in_values: None, 198 + contains: None, 199 + gt: None, 200 + gte: None, 201 + lt: None, 202 + lte: None, 203 + is_null: Some(True), 204 + is_numeric: False, 205 + ) 206 + where_clause.is_condition_empty(condition) |> should.be_false 207 + }
+17
server/test/where_edge_cases_test.gleam
··· 38 38 gte: None, 39 39 lt: None, 40 40 lte: None, 41 + is_null: None, 41 42 is_numeric: False, 42 43 ) 43 44 let clause = ··· 64 65 gte: None, 65 66 lt: None, 66 67 lte: None, 68 + is_null: None, 67 69 is_numeric: False, 68 70 ) 69 71 let clause = ··· 122 124 gte: None, 123 125 lt: None, 124 126 lte: None, 127 + is_null: None, 125 128 is_numeric: False, 126 129 ) 127 130 let clause = ··· 157 160 gte: None, 158 161 lt: None, 159 162 lte: None, 163 + is_null: None, 160 164 is_numeric: False, 161 165 ) 162 166 let clause = ··· 192 196 gte: None, 193 197 lt: None, 194 198 lte: None, 199 + is_null: None, 195 200 is_numeric: False, 196 201 ) 197 202 ··· 204 209 gte: None, 205 210 lt: None, 206 211 lte: None, 212 + is_null: None, 207 213 is_numeric: False, 208 214 ) 209 215 ··· 244 250 gte: None, 245 251 lt: None, 246 252 lte: None, 253 + is_null: None, 247 254 is_numeric: False, 248 255 ) 249 256 let clause = ··· 280 287 gte: None, 281 288 lt: None, 282 289 lte: None, 290 + is_null: None, 283 291 is_numeric: False, 284 292 ) 285 293 let clause = ··· 309 317 gte: None, 310 318 lt: None, 311 319 lte: None, 320 + is_null: None, 312 321 is_numeric: False, 313 322 ) 314 323 ··· 362 371 gte: None, 363 372 lt: None, 364 373 lte: None, 374 + is_null: None, 365 375 is_numeric: False, 366 376 ) 367 377 ··· 374 384 gte: None, 375 385 lt: None, 376 386 lte: None, 387 + is_null: None, 377 388 is_numeric: False, 378 389 ) 379 390 ··· 386 397 gte: None, 387 398 lt: None, 388 399 lte: None, 400 + is_null: None, 389 401 is_numeric: False, 390 402 ) 391 403 ··· 534 546 gte: None, 535 547 lt: Some(sqlight.int(100)), 536 548 lte: None, 549 + is_null: None, 537 550 is_numeric: True, 538 551 ) 539 552 let clause = ··· 564 577 gte: None, 565 578 lt: None, 566 579 lte: None, 580 + is_null: None, 567 581 is_numeric: False, 568 582 ) 569 583 let clause = ··· 602 616 gte: None, 603 617 lt: None, 604 618 lte: None, 619 + is_null: None, 605 620 is_numeric: False, 606 621 ) 607 622 let clause = ··· 641 656 gte: None, 642 657 lt: None, 643 658 lte: None, 659 + is_null: None, 644 660 is_numeric: False, 645 661 ) 646 662 let clause = ··· 677 693 gte: None, 678 694 lt: None, 679 695 lte: None, 696 + is_null: None, 680 697 is_numeric: False, 681 698 ) 682 699 let clause =
+200
server/test/where_integration_test.gleam
··· 77 77 gte: None, 78 78 lt: None, 79 79 lte: None, 80 + is_null: None, 80 81 is_numeric: False, 81 82 ), 82 83 ), ··· 126 127 gte: None, 127 128 lt: None, 128 129 lte: None, 130 + is_null: None, 129 131 is_numeric: False, 130 132 ), 131 133 ), ··· 175 177 gte: None, 176 178 lt: None, 177 179 lte: None, 180 + is_null: None, 178 181 is_numeric: False, 179 182 ), 180 183 ), ··· 221 224 gte: Some(sqlight.int(50)), 222 225 lt: Some(sqlight.int(150)), 223 226 lte: None, 227 + is_null: None, 224 228 is_numeric: False, 225 229 ), 226 230 ), ··· 267 271 gte: None, 268 272 lt: None, 269 273 lte: None, 274 + is_null: None, 270 275 is_numeric: False, 271 276 ), 272 277 ), ··· 288 293 gte: None, 289 294 lt: None, 290 295 lte: None, 296 + is_null: None, 291 297 is_numeric: False, 292 298 ), 293 299 ), ··· 348 354 gte: None, 349 355 lt: None, 350 356 lte: None, 357 + is_null: None, 351 358 is_numeric: False, 352 359 ), 353 360 ), ··· 369 376 gte: None, 370 377 lt: None, 371 378 lte: None, 379 + is_null: None, 372 380 is_numeric: False, 373 381 ), 374 382 ), ··· 449 457 gte: None, 450 458 lt: None, 451 459 lte: None, 460 + is_null: None, 452 461 is_numeric: False, 453 462 ), 454 463 ), ··· 499 508 gte: None, 500 509 lt: None, 501 510 lte: None, 511 + is_null: None, 502 512 is_numeric: True, 503 513 ), 504 514 ), ··· 575 585 gte: Some(sqlight.text("2024-06-01T00:00:00Z")), 576 586 lt: None, 577 587 lte: None, 588 + is_null: None, 578 589 is_numeric: False, 579 590 ), 580 591 ), ··· 607 618 Error(_) -> should.fail() 608 619 } 609 620 } 621 + 622 + // ===== isNull End-to-End Tests ===== 623 + 624 + /// Test isNull: true end-to-end from GraphQL parsing through SQL execution 625 + pub fn is_null_true_end_to_end_test() { 626 + let assert Ok(conn) = sqlight.open(":memory:") 627 + let assert Ok(_) = tables.create_record_table(conn) 628 + 629 + // Insert records - some with replyParent, some without 630 + let assert Ok(_) = 631 + sqlight.query( 632 + "INSERT INTO record (uri, cid, did, collection, json, indexed_at) 633 + VALUES (?, 'cid1', 'did:plc:1', 'app.bsky.feed.post', ?, datetime('now'))", 634 + on: conn, 635 + with: [ 636 + sqlight.text("at://did:plc:1/app.bsky.feed.post/1"), 637 + sqlight.text("{\"text\":\"Root post\"}"), 638 + ], 639 + expecting: decode.string, 640 + ) 641 + 642 + let assert Ok(_) = 643 + sqlight.query( 644 + "INSERT INTO record (uri, cid, did, collection, json, indexed_at) 645 + VALUES (?, 'cid2', 'did:plc:2', 'app.bsky.feed.post', ?, datetime('now'))", 646 + on: conn, 647 + with: [ 648 + sqlight.text("at://did:plc:2/app.bsky.feed.post/2"), 649 + sqlight.text( 650 + "{\"text\":\"Reply post\",\"replyParent\":\"at://did:plc:1/app.bsky.feed.post/1\"}", 651 + ), 652 + ], 653 + expecting: decode.string, 654 + ) 655 + 656 + let assert Ok(_) = 657 + sqlight.query( 658 + "INSERT INTO record (uri, cid, did, collection, json, indexed_at) 659 + VALUES (?, 'cid3', 'did:plc:3', 'app.bsky.feed.post', ?, datetime('now'))", 660 + on: conn, 661 + with: [ 662 + sqlight.text("at://did:plc:3/app.bsky.feed.post/3"), 663 + sqlight.text("{\"text\":\"Another root post\"}"), 664 + ], 665 + expecting: decode.string, 666 + ) 667 + 668 + // Filter for records where replyParent IS NULL (root posts only) 669 + let where_clause = 670 + where_clause.WhereClause( 671 + conditions: dict.from_list([ 672 + #( 673 + "replyParent", 674 + where_clause.WhereCondition( 675 + eq: None, 676 + in_values: None, 677 + contains: None, 678 + gt: None, 679 + gte: None, 680 + lt: None, 681 + lte: None, 682 + is_null: Some(True), 683 + is_numeric: False, 684 + ), 685 + ), 686 + ]), 687 + and: None, 688 + or: None, 689 + ) 690 + 691 + let result = 692 + records.get_by_collection_paginated_with_where( 693 + conn, 694 + "app.bsky.feed.post", 695 + Some(10), 696 + None, 697 + None, 698 + None, 699 + None, 700 + Some(where_clause), 701 + ) 702 + 703 + case result { 704 + Ok(#(records_list, _, _, _)) -> { 705 + // Should only match the 2 root posts (without replyParent) 706 + list.length(records_list) |> should.equal(2) 707 + // Verify none of them are replies 708 + list.all(records_list, fn(record) { 709 + !string.contains(record.json, "replyParent") 710 + }) 711 + |> should.be_true 712 + } 713 + Error(_) -> should.fail() 714 + } 715 + } 716 + 717 + /// Test isNull: false end-to-end from GraphQL parsing through SQL execution 718 + pub fn is_null_false_end_to_end_test() { 719 + let assert Ok(conn) = sqlight.open(":memory:") 720 + let assert Ok(_) = tables.create_record_table(conn) 721 + 722 + // Insert records - some with replyParent, some without 723 + let assert Ok(_) = 724 + sqlight.query( 725 + "INSERT INTO record (uri, cid, did, collection, json, indexed_at) 726 + VALUES (?, 'cid1', 'did:plc:1', 'app.bsky.feed.post', ?, datetime('now'))", 727 + on: conn, 728 + with: [ 729 + sqlight.text("at://did:plc:1/app.bsky.feed.post/1"), 730 + sqlight.text("{\"text\":\"Root post\"}"), 731 + ], 732 + expecting: decode.string, 733 + ) 734 + 735 + let assert Ok(_) = 736 + sqlight.query( 737 + "INSERT INTO record (uri, cid, did, collection, json, indexed_at) 738 + VALUES (?, 'cid2', 'did:plc:2', 'app.bsky.feed.post', ?, datetime('now'))", 739 + on: conn, 740 + with: [ 741 + sqlight.text("at://did:plc:2/app.bsky.feed.post/2"), 742 + sqlight.text( 743 + "{\"text\":\"Reply post\",\"replyParent\":\"at://did:plc:1/app.bsky.feed.post/1\"}", 744 + ), 745 + ], 746 + expecting: decode.string, 747 + ) 748 + 749 + let assert Ok(_) = 750 + sqlight.query( 751 + "INSERT INTO record (uri, cid, did, collection, json, indexed_at) 752 + VALUES (?, 'cid3', 'did:plc:3', 'app.bsky.feed.post', ?, datetime('now'))", 753 + on: conn, 754 + with: [ 755 + sqlight.text("at://did:plc:3/app.bsky.feed.post/3"), 756 + sqlight.text("{\"text\":\"Another root post\"}"), 757 + ], 758 + expecting: decode.string, 759 + ) 760 + 761 + // Filter for records where replyParent IS NOT NULL (replies only) 762 + let where_clause = 763 + where_clause.WhereClause( 764 + conditions: dict.from_list([ 765 + #( 766 + "replyParent", 767 + where_clause.WhereCondition( 768 + eq: None, 769 + in_values: None, 770 + contains: None, 771 + gt: None, 772 + gte: None, 773 + lt: None, 774 + lte: None, 775 + is_null: Some(False), 776 + is_numeric: False, 777 + ), 778 + ), 779 + ]), 780 + and: None, 781 + or: None, 782 + ) 783 + 784 + let result = 785 + records.get_by_collection_paginated_with_where( 786 + conn, 787 + "app.bsky.feed.post", 788 + Some(10), 789 + None, 790 + None, 791 + None, 792 + None, 793 + Some(where_clause), 794 + ) 795 + 796 + case result { 797 + Ok(#(records_list, _, _, _)) -> { 798 + // Should only match the 1 reply post (with replyParent) 799 + list.length(records_list) |> should.equal(1) 800 + // Verify it's a reply 801 + case list.first(records_list) { 802 + Ok(record) -> 803 + should.be_true(string.contains(record.json, "replyParent")) 804 + Error(_) -> should.fail() 805 + } 806 + } 807 + Error(_) -> should.fail() 808 + } 809 + }
+241
server/test/where_sql_builder_test.gleam
··· 31 31 gte: None, 32 32 lt: None, 33 33 lte: None, 34 + is_null: None, 34 35 is_numeric: False, 35 36 ) 36 37 let clause = ··· 61 62 gte: None, 62 63 lt: None, 63 64 lte: None, 65 + is_null: None, 64 66 is_numeric: False, 65 67 ) 66 68 let clause = ··· 87 89 gte: None, 88 90 lt: None, 89 91 lte: None, 92 + is_null: None, 90 93 is_numeric: False, 91 94 ) 92 95 let clause = ··· 113 116 gte: Some(sqlight.int(2000)), 114 117 lt: None, 115 118 lte: None, 119 + is_null: None, 116 120 is_numeric: True, 117 121 ) 118 122 let clause = ··· 140 144 gte: None, 141 145 lt: Some(sqlight.text("2024-12-31T23:59:59Z")), 142 146 lte: None, 147 + is_null: None, 143 148 is_numeric: False, 144 149 ) 145 150 let clause = ··· 166 171 gte: None, 167 172 lt: None, 168 173 lte: Some(sqlight.int(100)), 174 + is_null: None, 169 175 is_numeric: True, 170 176 ) 171 177 let clause = ··· 193 199 gte: None, 194 200 lt: Some(sqlight.text("2024-02-01T00:00:00Z")), 195 201 lte: None, 202 + is_null: None, 196 203 is_numeric: False, 197 204 ) 198 205 let clause = ··· 220 227 gte: None, 221 228 lt: None, 222 229 lte: None, 230 + is_null: None, 223 231 is_numeric: False, 224 232 ) 225 233 let cond2 = ··· 231 239 gte: None, 232 240 lt: None, 233 241 lte: None, 242 + is_null: None, 234 243 is_numeric: False, 235 244 ) 236 245 let clause = ··· 265 274 gte: None, 266 275 lt: None, 267 276 lte: None, 277 + is_null: None, 268 278 is_numeric: False, 269 279 ) 270 280 let clause = ··· 291 301 gte: None, 292 302 lt: None, 293 303 lte: None, 304 + is_null: None, 294 305 is_numeric: False, 295 306 ) 296 307 let clause = ··· 317 328 gte: None, 318 329 lt: None, 319 330 lte: None, 331 + is_null: None, 320 332 is_numeric: False, 321 333 ) 322 334 let clause = ··· 343 355 gte: None, 344 356 lt: Some(sqlight.int(1000)), 345 357 lte: None, 358 + is_null: None, 346 359 is_numeric: True, 347 360 ) 348 361 let clause = ··· 373 386 gte: None, 374 387 lt: None, 375 388 lte: None, 389 + is_null: None, 376 390 is_numeric: False, 377 391 ) 378 392 let cond2 = ··· 384 398 gte: None, 385 399 lt: None, 386 400 lte: None, 401 + is_null: None, 387 402 is_numeric: True, 388 403 ) 389 404 let clause = ··· 421 436 gte: None, 422 437 lt: None, 423 438 lte: None, 439 + is_null: None, 424 440 is_numeric: False, 425 441 ) 426 442 let clause = ··· 450 466 gte: None, 451 467 lt: None, 452 468 lte: None, 469 + is_null: None, 453 470 is_numeric: False, 454 471 ) 455 472 let clause = ··· 476 493 gte: None, 477 494 lt: None, 478 495 lte: None, 496 + is_null: None, 479 497 is_numeric: False, 480 498 ) 481 499 let clause = ··· 503 521 gte: None, 504 522 lt: None, 505 523 lte: None, 524 + is_null: None, 506 525 is_numeric: False, 507 526 ) 508 527 let cond2 = ··· 514 533 gte: None, 515 534 lt: None, 516 535 lte: None, 536 + is_null: None, 517 537 is_numeric: False, 518 538 ) 519 539 let clause = ··· 545 565 gte: None, 546 566 lt: None, 547 567 lte: None, 568 + is_null: None, 548 569 is_numeric: False, 549 570 ) 550 571 let clause = ··· 580 601 gte: None, 581 602 lt: None, 582 603 lte: None, 604 + is_null: None, 583 605 is_numeric: False, 584 606 ), 585 607 ), ··· 601 623 gte: None, 602 624 lt: None, 603 625 lte: None, 626 + is_null: None, 604 627 is_numeric: False, 605 628 ), 606 629 ), ··· 640 663 gte: None, 641 664 lt: None, 642 665 lte: None, 666 + is_null: None, 643 667 is_numeric: False, 644 668 ), 645 669 ), ··· 661 685 gte: None, 662 686 lt: None, 663 687 lte: None, 688 + is_null: None, 664 689 is_numeric: False, 665 690 ), 666 691 ), ··· 694 719 gte: None, 695 720 lt: None, 696 721 lte: None, 722 + is_null: None, 697 723 is_numeric: False, 698 724 ), 699 725 ), ··· 715 741 gte: Some(sqlight.int(2000)), 716 742 lt: None, 717 743 lte: None, 744 + is_null: None, 718 745 is_numeric: False, 719 746 ), 720 747 ), ··· 756 783 gte: None, 757 784 lt: None, 758 785 lte: None, 786 + is_null: None, 759 787 is_numeric: False, 760 788 ), 761 789 ), ··· 777 805 gte: None, 778 806 lt: None, 779 807 lte: None, 808 + is_null: None, 780 809 is_numeric: False, 781 810 ), 782 811 ), ··· 798 827 gte: None, 799 828 lt: None, 800 829 lte: None, 830 + is_null: None, 801 831 is_numeric: False, 802 832 ), 803 833 ), ··· 833 863 gte: None, 834 864 lt: None, 835 865 lte: None, 866 + is_null: None, 836 867 is_numeric: False, 837 868 ), 838 869 ), ··· 854 885 gte: None, 855 886 lt: None, 856 887 lte: None, 888 + is_null: None, 857 889 is_numeric: False, 858 890 ), 859 891 ), ··· 896 928 gte: None, 897 929 lt: None, 898 930 lte: None, 931 + is_null: None, 899 932 is_numeric: False, 900 933 ), 901 934 ), ··· 917 950 gte: None, 918 951 lt: None, 919 952 lte: None, 953 + is_null: None, 920 954 is_numeric: False, 921 955 ), 922 956 ), ··· 945 979 gte: Some(sqlight.int(2000)), 946 980 lt: None, 947 981 lte: None, 982 + is_null: None, 948 983 is_numeric: False, 949 984 ), 950 985 ), ··· 987 1022 gte: None, 988 1023 lt: None, 989 1024 lte: None, 1025 + is_null: None, 990 1026 is_numeric: False, 991 1027 ), 992 1028 ), ··· 1008 1044 gte: None, 1009 1045 lt: None, 1010 1046 lte: None, 1047 + is_null: None, 1011 1048 is_numeric: False, 1012 1049 ), 1013 1050 ), ··· 1036 1073 gte: None, 1037 1074 lt: None, 1038 1075 lte: None, 1076 + is_null: None, 1039 1077 is_numeric: False, 1040 1078 ), 1041 1079 ), ··· 1057 1095 gte: None, 1058 1096 lt: None, 1059 1097 lte: None, 1098 + is_null: None, 1060 1099 is_numeric: False, 1061 1100 ), 1062 1101 ), ··· 1085 1124 gte: Some(sqlight.int(2000)), 1086 1125 lt: None, 1087 1126 lte: None, 1127 + is_null: None, 1088 1128 is_numeric: False, 1089 1129 ), 1090 1130 ), ··· 1127 1167 gte: None, 1128 1168 lt: None, 1129 1169 lte: None, 1170 + is_null: None, 1130 1171 is_numeric: False, 1131 1172 ), 1132 1173 ), ··· 1148 1189 gte: None, 1149 1190 lt: None, 1150 1191 lte: None, 1192 + is_null: None, 1151 1193 is_numeric: False, 1152 1194 ), 1153 1195 ), ··· 1169 1211 gte: None, 1170 1212 lt: None, 1171 1213 lte: None, 1214 + is_null: None, 1172 1215 is_numeric: False, 1173 1216 ), 1174 1217 ), ··· 1191 1234 should.be_true(string.contains(sql, "did")) 1192 1235 list.length(params) |> should.equal(3) 1193 1236 } 1237 + 1238 + // ===== isNull Operator Tests ===== 1239 + 1240 + // Test: isNull true on JSON field 1241 + pub fn build_where_is_null_true_json_field_test() { 1242 + let condition = 1243 + where_clause.WhereCondition( 1244 + eq: None, 1245 + in_values: None, 1246 + contains: None, 1247 + gt: None, 1248 + gte: None, 1249 + lt: None, 1250 + lte: None, 1251 + is_null: Some(True), 1252 + is_numeric: False, 1253 + ) 1254 + let clause = 1255 + where_clause.WhereClause( 1256 + conditions: dict.from_list([#("replyParent", condition)]), 1257 + and: None, 1258 + or: None, 1259 + ) 1260 + 1261 + let #(sql, params) = where_clause.build_where_sql(clause, False) 1262 + 1263 + sql |> should.equal("json_extract(json, '$.replyParent') IS NULL") 1264 + list.length(params) |> should.equal(0) 1265 + } 1266 + 1267 + // Test: isNull false on JSON field 1268 + pub fn build_where_is_null_false_json_field_test() { 1269 + let condition = 1270 + where_clause.WhereCondition( 1271 + eq: None, 1272 + in_values: None, 1273 + contains: None, 1274 + gt: None, 1275 + gte: None, 1276 + lt: None, 1277 + lte: None, 1278 + is_null: Some(False), 1279 + is_numeric: False, 1280 + ) 1281 + let clause = 1282 + where_clause.WhereClause( 1283 + conditions: dict.from_list([#("replyParent", condition)]), 1284 + and: None, 1285 + or: None, 1286 + ) 1287 + 1288 + let #(sql, params) = where_clause.build_where_sql(clause, False) 1289 + 1290 + sql |> should.equal("json_extract(json, '$.replyParent') IS NOT NULL") 1291 + list.length(params) |> should.equal(0) 1292 + } 1293 + 1294 + // Test: isNull true on table column 1295 + pub fn build_where_is_null_true_table_column_test() { 1296 + let condition = 1297 + where_clause.WhereCondition( 1298 + eq: None, 1299 + in_values: None, 1300 + contains: None, 1301 + gt: None, 1302 + gte: None, 1303 + lt: None, 1304 + lte: None, 1305 + is_null: Some(True), 1306 + is_numeric: False, 1307 + ) 1308 + let clause = 1309 + where_clause.WhereClause( 1310 + conditions: dict.from_list([#("cid", condition)]), 1311 + and: None, 1312 + or: None, 1313 + ) 1314 + 1315 + let #(sql, params) = where_clause.build_where_sql(clause, False) 1316 + 1317 + sql |> should.equal("cid IS NULL") 1318 + list.length(params) |> should.equal(0) 1319 + } 1320 + 1321 + // Test: isNull false on table column 1322 + pub fn build_where_is_null_false_table_column_test() { 1323 + let condition = 1324 + where_clause.WhereCondition( 1325 + eq: None, 1326 + in_values: None, 1327 + contains: None, 1328 + gt: None, 1329 + gte: None, 1330 + lt: None, 1331 + lte: None, 1332 + is_null: Some(False), 1333 + is_numeric: False, 1334 + ) 1335 + let clause = 1336 + where_clause.WhereClause( 1337 + conditions: dict.from_list([#("uri", condition)]), 1338 + and: None, 1339 + or: None, 1340 + ) 1341 + 1342 + let #(sql, params) = where_clause.build_where_sql(clause, False) 1343 + 1344 + sql |> should.equal("uri IS NOT NULL") 1345 + list.length(params) |> should.equal(0) 1346 + } 1347 + 1348 + // Test: isNull with table prefix (for joins) 1349 + pub fn build_where_is_null_with_table_prefix_test() { 1350 + let condition = 1351 + where_clause.WhereCondition( 1352 + eq: None, 1353 + in_values: None, 1354 + contains: None, 1355 + gt: None, 1356 + gte: None, 1357 + lt: None, 1358 + lte: None, 1359 + is_null: Some(True), 1360 + is_numeric: False, 1361 + ) 1362 + let clause = 1363 + where_clause.WhereClause( 1364 + conditions: dict.from_list([#("text", condition)]), 1365 + and: None, 1366 + or: None, 1367 + ) 1368 + 1369 + let #(sql, params) = where_clause.build_where_sql(clause, True) 1370 + 1371 + sql |> should.equal("json_extract(record.json, '$.text') IS NULL") 1372 + list.length(params) |> should.equal(0) 1373 + } 1374 + 1375 + // Test: isNull in nested AND clause 1376 + pub fn build_where_is_null_in_and_clause_test() { 1377 + let is_null_clause = 1378 + where_clause.WhereClause( 1379 + conditions: dict.from_list([ 1380 + #( 1381 + "replyParent", 1382 + where_clause.WhereCondition( 1383 + eq: None, 1384 + in_values: None, 1385 + contains: None, 1386 + gt: None, 1387 + gte: None, 1388 + lt: None, 1389 + lte: None, 1390 + is_null: Some(True), 1391 + is_numeric: False, 1392 + ), 1393 + ), 1394 + ]), 1395 + and: None, 1396 + or: None, 1397 + ) 1398 + 1399 + let text_clause = 1400 + where_clause.WhereClause( 1401 + conditions: dict.from_list([ 1402 + #( 1403 + "text", 1404 + where_clause.WhereCondition( 1405 + eq: None, 1406 + in_values: None, 1407 + contains: Some("hello"), 1408 + gt: None, 1409 + gte: None, 1410 + lt: None, 1411 + lte: None, 1412 + is_null: None, 1413 + is_numeric: False, 1414 + ), 1415 + ), 1416 + ]), 1417 + and: None, 1418 + or: None, 1419 + ) 1420 + 1421 + let root_clause = 1422 + where_clause.WhereClause( 1423 + conditions: dict.new(), 1424 + and: Some([is_null_clause, text_clause]), 1425 + or: None, 1426 + ) 1427 + 1428 + let #(sql, params) = where_clause.build_where_sql(root_clause, False) 1429 + 1430 + should.be_true(string.contains(sql, "IS NULL")) 1431 + should.be_true(string.contains(sql, "LIKE")) 1432 + should.be_true(string.contains(sql, "AND")) 1433 + list.length(params) |> should.equal(1) 1434 + }