analyzer.spec.js
1 import { Link, $, API, Memory, Analyzer, Task } from './lib.js' 2 import * as Inspector from './inspector.js' 3 4 /** 5 * @type {import('entail').Suite} 6 */ 7 export const testAnalyzer = { 8 'plans negation last': async (assert) => { 9 const plan = Analyzer.rule({ 10 match: { child: $.child, uncle: $.uncle }, 11 when: { 12 where: [ 13 { match: { the: 'semantic/type', of: $.child, is: 'child' } }, 14 { match: { the: 'relation/nephew', of: $.uncle, is: $.child } }, 15 { 16 not: { match: { the: 'legal/guardian', of: $.child, is: $.uncle } }, 17 }, 18 ], 19 }, 20 }) 21 .apply({ child: $.child, uncle: $.uncle }) 22 .prepare() 23 24 assert.deepEqual(plan.toJSON(), { 25 match: { child: $.child, uncle: $.uncle }, 26 rule: { 27 match: { child: $.child, uncle: $.uncle }, 28 when: { 29 where: [ 30 { match: { the: 'semantic/type', of: $.child, is: 'child' } }, 31 { match: { the: 'relation/nephew', of: $.uncle, is: $.child } }, 32 { 33 not: { 34 match: { the: 'legal/guardian', of: $.child, is: $.uncle }, 35 }, 36 }, 37 ], 38 }, 39 }, 40 }) 41 }, 42 43 'negation considered across scopes': async (assert) => { 44 const Allowed = /** @type {const} */ ({ 45 match: { this: $.x }, 46 when: { 47 draft: [{ match: { the: 'status', of: $.x, is: 'draft' } }], 48 activeOwner: [ 49 { match: { the: 'owner', of: $.x, is: $.user } }, 50 { not: { match: { the: 'status', of: $.user, is: 'blocked' } } }, 51 ], 52 }, 53 }) 54 55 const plan = Analyzer.rule({ 56 match: { x: $.x }, 57 when: { 58 where: [ 59 { match: { the: 'type', of: $.x, is: 'doc' } }, 60 { match: { this: $.x }, rule: Allowed }, 61 ], 62 }, 63 }) 64 .apply({ x: $.y }) 65 .prepare() 66 67 assert.deepEqual(plan.toJSON(), { 68 match: { x: $.y }, 69 rule: { 70 match: { x: $.x }, 71 when: { 72 where: [ 73 { match: { the: 'type', of: $.x, is: 'doc' } }, 74 { 75 match: { this: $.x }, 76 rule: { 77 match: { this: $.x }, 78 when: { 79 draft: [ 80 { 81 match: { 82 the: 'status', 83 of: $.x, 84 is: 'draft', 85 }, 86 }, 87 ], 88 activeOwner: [ 89 { 90 match: { 91 the: 'owner', 92 of: $.x, 93 is: $.user, 94 }, 95 }, 96 { 97 not: { 98 match: { 99 the: 'status', 100 of: $.user, 101 is: 'blocked', 102 }, 103 }, 104 }, 105 ], 106 }, 107 }, 108 }, 109 ], 110 }, 111 }, 112 }) 113 }, 114 115 'variables get bound before used in some disjuncts': async (assert) => { 116 const Allowed = /** @type {const} */ ({ 117 match: { this: $.x }, 118 when: { 119 draft: [{ match: { the: 'status', of: $.x, is: 'draft' } }], 120 activeOwner: [ 121 { match: { the: 'owner', of: $.x, is: $.user } }, 122 { not: { match: { the: 'status', of: $.user, is: 'blocked' } } }, 123 ], 124 }, 125 }) 126 127 const Test = /** @type {const} */ ({ 128 match: { x: $.x }, 129 when: { 130 where: [ 131 { match: { this: $.x }, rule: Allowed }, 132 { match: { the: 'type', of: $.x, is: 'doc' } }, 133 { match: { the: 'dept', of: $.user, is: 'eng' } }, 134 ], 135 }, 136 }) 137 138 const plan = Analyzer.rule(Test).apply({ x: $.myX }).prepare() 139 140 assert.deepEqual(plan.toJSON(), { 141 match: { x: $.myX }, 142 rule: { 143 match: { x: $.x }, 144 when: { 145 where: [ 146 { match: { the: 'type', of: $.x, is: 'doc' } }, 147 { 148 match: { this: $.x }, 149 rule: { 150 match: { this: $.x }, 151 when: { 152 draft: [{ match: { the: 'status', of: $.x, is: 'draft' } }], 153 activeOwner: [ 154 { match: { the: 'owner', of: $.x, is: $.user } }, 155 { 156 not: { 157 match: { the: 'status', of: $.user, is: 'blocked' }, 158 }, 159 }, 160 ], 161 }, 162 }, 163 }, 164 { match: { the: 'dept', of: $.user, is: 'eng' } }, 165 ], 166 }, 167 }, 168 }) 169 }, 170 171 'plans execution by cost': async (assert) => { 172 const plan = Analyzer.rule({ 173 match: { title: $.title, actor: $.actor }, 174 when: { 175 where: [ 176 { match: { the: 'movie/title', of: $.movie, is: $.title } }, 177 { match: { the: 'movie/cast', of: $.movie, is: $.actor } }, 178 { 179 match: { 180 the: 'person/name', 181 of: $.actor, 182 is: 'Arnold Schwarzenegger', 183 }, 184 }, 185 ], 186 }, 187 }) 188 .apply({ title: $.title, actor: $.actor }) 189 .prepare() 190 191 assert.deepEqual(plan.toJSON(), { 192 match: { title: $.title, actor: $.actor }, 193 rule: { 194 match: { title: $.title, actor: $.actor }, 195 when: { 196 where: [ 197 { 198 match: { 199 the: 'person/name', 200 of: $.actor, 201 is: 'Arnold Schwarzenegger', 202 }, 203 }, 204 { match: { the: 'movie/cast', of: $.movie, is: $.actor } }, 205 { match: { the: 'movie/title', of: $.movie, is: $.title } }, 206 ], 207 }, 208 }, 209 }) 210 }, 211 212 'nested Not considers outer scope': async (assert) => { 213 const plan = Analyzer.rule({ 214 match: { doc: $.doc }, 215 when: { 216 published: [ 217 { match: { the: 'type', of: $.doc, is: 'document' } }, 218 { match: { the: 'status', of: $.doc, is: 'published' } }, 219 ], 220 draft: [ 221 { match: { the: 'type', of: $.doc, is: 'document' } }, 222 { match: { the: 'draft', of: $.doc, is: $.version } }, 223 { 224 not: { 225 match: { the: 'approved-by', of: $.version, is: $.reviewer }, 226 }, 227 }, 228 { match: { the: 'role', of: $.reviewer, is: 'editor' } }, 229 ], 230 }, 231 }) 232 .apply({ doc: $.doc }) 233 .prepare() 234 235 assert.deepEqual( 236 plan.toJSON(), 237 { 238 match: { doc: $.doc }, 239 rule: { 240 match: { doc: $.doc }, 241 when: { 242 published: [ 243 { match: { the: 'type', of: $.doc, is: 'document' } }, 244 { match: { the: 'status', of: $.doc, is: 'published' } }, 245 ], 246 draft: [ 247 { match: { the: 'type', of: $.doc, is: 'document' } }, 248 { match: { the: 'draft', of: $.doc, is: $.version } }, 249 { match: { the: 'role', of: $.reviewer, is: 'editor' } }, 250 { 251 not: { 252 match: { the: 'approved-by', of: $.version, is: $.reviewer }, 253 }, 254 }, 255 ], 256 }, 257 }, 258 }, 259 'Verify Not runs after reviewer role is established' 260 ) 261 }, 262 263 'handles multiple Or branches with different locals': async (assert) => { 264 const plan = Analyzer.rule({ 265 match: { x: $.x }, 266 when: { 267 author: [ 268 { match: { the: 'author', of: $.x, is: $.author1 } }, 269 { match: { the: 'department', of: $.author1, is: 'eng' } }, 270 ], 271 reviewer: [ 272 { match: { the: 'reviewer', of: $.x, is: $.reviewer2 } }, 273 { match: { the: 'level', of: $.reviewer2, is: 'senior' } }, 274 ], 275 }, 276 }) 277 .apply({ x: $.doc }) 278 .prepare() 279 280 assert.deepEqual(plan.toJSON(), { 281 match: { x: $.doc }, 282 rule: { 283 match: { x: $.x }, 284 when: { 285 author: [ 286 { match: { the: 'department', of: $.author1, is: 'eng' } }, 287 { match: { the: 'author', of: $.x, is: $.author1 } }, 288 ], 289 reviewer: [ 290 { match: { the: 'level', of: $.reviewer2, is: 'senior' } }, 291 { match: { the: 'reviewer', of: $.x, is: $.reviewer2 } }, 292 ], 293 }, 294 }, 295 }) 296 }, 297 298 'handles multiple negations across scopes': async (assert) => { 299 const plan = Analyzer.rule({ 300 match: { doc: $.doc }, 301 when: { 302 branch1: [ 303 { match: { the: 'status', of: $.doc, is: 'draft' } }, 304 { not: { match: { the: 'deleted', of: $.doc, is: true } } }, 305 { match: { the: 'author', of: $.doc, is: $.user } }, 306 ], 307 branch2: [ 308 { not: { match: { the: 'archived', of: $.team, is: true } } }, 309 { match: { the: 'status', of: $.doc, is: 'draft' } }, 310 { not: { match: { the: 'deleted', of: $.doc, is: true } } }, 311 { match: { the: 'team', of: $.doc, is: $.team } }, 312 ], 313 }, 314 }) 315 .apply({ doc: $.document }) 316 .prepare() 317 318 assert.deepEqual( 319 plan.toJSON(), 320 { 321 match: { doc: $.document }, 322 rule: { 323 match: { doc: $.doc }, 324 when: { 325 branch1: [ 326 { match: { the: 'status', of: $.doc, is: 'draft' } }, 327 { not: { match: { the: 'deleted', of: $.doc, is: true } } }, 328 { match: { the: 'author', of: $.doc, is: $.user } }, 329 ], 330 branch2: [ 331 { match: { the: 'status', of: $.doc, is: 'draft' } }, 332 { not: { match: { the: 'deleted', of: $.doc, is: true } } }, 333 { match: { the: 'team', of: $.doc, is: $.team } }, 334 { not: { match: { the: 'archived', of: $.team, is: true } } }, 335 ], 336 }, 337 }, 338 }, 339 'Verify both negations run after their dependencies' 340 ) 341 }, 342 343 'plans operations requiring shared variables': async (assert) => { 344 const plan = Analyzer.rule({ 345 match: { x: $.x, user: $.user }, 346 when: { 347 where: [ 348 { match: { the: 'role', of: $.user, is: 'admin' } }, 349 { match: { the: 'review', of: $.x, is: $.review } }, 350 { match: { the: 'type', of: $.x, is: 'doc' } }, 351 { match: { the: 'owner', of: $.x, is: $.user } }, 352 { match: { the: 'status', of: $.x, is: 'draft' } }, 353 ], 354 }, 355 }) 356 .apply({ x: $.x, user: $.user }) 357 .prepare() 358 359 assert.deepEqual(plan.toJSON(), { 360 match: { x: $.x, user: $.user }, 361 rule: { 362 match: { x: $.x, user: $.user }, 363 when: { 364 where: [ 365 { match: { the: 'role', of: $.user, is: 'admin' } }, 366 { match: { the: 'type', of: $.x, is: 'doc' } }, 367 { match: { the: 'owner', of: $.x, is: $.user } }, 368 { match: { the: 'status', of: $.x, is: 'draft' } }, 369 { match: { the: 'review', of: $.x, is: $.review } }, 370 ], 371 }, 372 }, 373 }) 374 }, 375 376 'handles Match clauses with variable dependencies': async (assert) => { 377 const plan = Analyzer.rule({ 378 match: { doc: $.doc, count: $.count, size: $.size }, 379 when: { 380 where: [ 381 { match: { of: $.count, is: $.size }, operator: 'text/length' }, 382 { match: { of: $.size, is: 1000 }, operator: '==' }, 383 { match: { the: 'word-count', of: $.doc, is: $.count } }, 384 ], 385 }, 386 }) 387 .apply({ doc: $.doc, count: $.count, size: $.size }) 388 .prepare() 389 390 assert.deepEqual(plan.toJSON(), { 391 match: { doc: $.doc, count: $.count, size: $.size }, 392 rule: { 393 match: { doc: $.doc, count: $.count, size: $.size }, 394 when: { 395 where: [ 396 { match: { the: 'word-count', of: $.doc, is: $.count } }, 397 { match: { of: $.count, is: $.size }, operator: 'text/length' }, 398 { match: { of: $.size, is: 1000 }, operator: '==' }, 399 ], 400 }, 401 }, 402 }) 403 }, 404 405 'fails if variable is not defined': async (assert) => { 406 assert.throws(() => { 407 const plan = Analyzer.rule({ 408 match: { doc: $.doc }, 409 when: { 410 where: [ 411 { match: { the: 'status', of: $.doc, is: 'ready' } }, 412 { match: { of: $.user, is: 'admin' }, operator: '==' }, 413 ], 414 }, 415 }) 416 .apply({ doc: $.doc }) 417 .prepare() 418 }, /Unbound \?user variable referenced from { match: { of: \$.user, is: "admin" }, operator: "==" }/) 419 }, 420 421 'terms in match affect planning': async (assert) => { 422 const plan = Analyzer.rule({ 423 match: { user: $.user }, 424 when: { 425 author: [ 426 { match: { the: 'draft', of: $.doc, is: $.version } }, 427 { match: { the: 'author', of: $.doc, is: $.user } }, 428 ], 429 reviewer: [{ match: { the: 'reviewer', of: $.doc, is: $.user } }], 430 }, 431 }) 432 .apply({ user: 1 }) 433 .prepare() 434 435 assert.deepEqual(plan.toJSON(), { 436 match: { user: 1 }, 437 rule: { 438 match: { user: $.user }, 439 when: { 440 author: [ 441 { match: { the: 'author', of: $.doc, is: $.user } }, 442 { match: { the: 'draft', of: $.doc, is: $.version } }, 443 ], 444 reviewer: [{ match: { the: 'reviewer', of: $.doc, is: $.user } }], 445 }, 446 }, 447 }) 448 }, 449 450 'fails on unknown variable reference': async (assert) => { 451 assert.throws(() => { 452 const plan = Analyzer.rule({ 453 match: { doc: $.doc }, 454 when: { 455 branch1: [ 456 { match: { of: $.count, is: 100 }, operator: '==' }, 457 { match: { the: 'type', of: $.doc, is: 'counter' } }, 458 ], 459 branch2: [{ match: { the: 'type', of: $.doc, is: 'doc' } }], 460 }, 461 }) 462 .apply({ doc: $.doc }) 463 .prepare() 464 }, /Unbound \?count variable referenced from { match: { of: \$.count, is: 100 }, operator: "==" }/) 465 }, 466 467 'correctly maps variables across scopes': async (assert) => { 468 const plan = Analyzer.rule({ 469 match: { x: $.x, y: $.y }, 470 when: { 471 where: [ 472 { match: { the: 'type', of: $.y, is: 'person' } }, 473 { match: { the: 'name', of: $.y, is: $.x } }, 474 ], 475 }, 476 }) 477 .apply({ x: $.y, y: $.x }) 478 .prepare() 479 480 assert.deepEqual(plan.toJSON(), { 481 match: { x: $.y, y: $.x }, 482 rule: { 483 match: { x: $.x, y: $.y }, 484 when: { 485 where: [ 486 { match: { the: 'type', of: $.y, is: 'person' } }, 487 { match: { the: 'name', of: $.y, is: $.x } }, 488 ], 489 }, 490 }, 491 }) 492 }, 493 494 'throws if rule does not bind a variable': async (assert) => { 495 assert.throws( 496 () => 497 Analyzer.rule({ 498 match: { x: $.x, y: $.y }, 499 when: { where: [{ match: { the: 'type', of: $.y, is: 'person' } }] }, 500 }) 501 .apply({ x: $.output, y: $.input }) 502 .prepare(), 503 /Rule case "where" does not bind variable \?x that rule matches as "x"/ 504 ) 505 }, 506 507 'throws if rule branch does not handle match variable': async (assert) => { 508 assert.throws( 509 () => 510 Analyzer.rule({ 511 match: { x: $.x, y: $.y }, 512 when: { 513 base: [ 514 { match: { the: 'type', of: $.y, is: 'person' } }, 515 // Missing handling of $.x 516 ], 517 }, 518 }) 519 .apply({ x: $.output, y: $.input }) 520 .prepare(), 521 /Rule case "base" does not bind variable \?x that rule matches as "x"/ 522 ) 523 }, 524 525 'skip recursive rule must have a non-recursive branch': async (assert) => { 526 assert.throws( 527 () => 528 Analyzer.rule({ 529 match: { x: $.x }, 530 when: { 531 loop: [{ recur: { x: $.x } }], 532 }, 533 }) 534 .apply({ x: $.who }) 535 .prepare(), 536 /Recursive rule must have at least one non-recursive branch/ 537 ) 538 }, 539 540 'skip recursive rule must have non-recursive branch': async (assert) => { 541 assert.throws( 542 () => 543 Analyzer.rule({ 544 match: { x: $.x }, 545 when: { 546 other: [ 547 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, 548 { recur: { x: $.y } }, 549 ], 550 loop: [{ recur: { x: $.x } }], 551 }, 552 }) 553 .apply({ x: 5 }) 554 .prepare(), 555 /Recursive rule must have at least one non-recursive branch/ 556 ) 557 }, 558 559 'prefers efficient execution path based on bindings': async (assert) => { 560 /** 561 * @param {API.Term} x 562 * @returns 563 */ 564 const make = (x) => 565 Analyzer.rule({ 566 match: { x: $.x, y: $.y }, 567 when: { 568 where: [ 569 { match: { of: $.type, is: 'type' }, operator: '==' }, 570 { match: { the: $.type, of: $.y, is: 'person' } }, 571 { match: { the: 'name', of: $.y, is: $.x } }, 572 ], 573 }, 574 }) 575 .apply({ x, y: $.output }) 576 .prepare() 577 578 assert.deepEqual( 579 make($.input).toJSON(), 580 { 581 match: { x: $.input, y: $.output }, 582 rule: { 583 match: { x: $.x, y: $.y }, 584 when: { 585 where: [ 586 { match: { the: $.type, of: $.y, is: 'person' } }, 587 { match: { of: $.type, is: 'type' }, operator: '==' }, 588 { match: { the: 'name', of: $.y, is: $.x } }, 589 ], 590 }, 591 }, 592 }, 593 'without bindings order remains same' 594 ) 595 596 assert.deepEqual( 597 make('John').toJSON(), 598 { 599 match: { x: 'John', y: $.output }, 600 rule: { 601 match: { x: $.x, y: $.y }, 602 when: { 603 where: [ 604 { match: { the: 'name', of: $.y, is: $.x } }, 605 { match: { the: $.type, of: $.y, is: 'person' } }, 606 { match: { of: $.type, is: 'type' }, operator: '==' }, 607 ], 608 }, 609 }, 610 }, 611 'with bindings plans more more efficiently' 612 ) 613 }, 614 615 'estimates costs across complex rule paths': async (assert) => { 616 /** 617 * @param {API.Term} person 618 */ 619 const plan = (person) => 620 Analyzer.rule({ 621 match: { person: $.person }, 622 when: { 623 manager: [{ match: { the: 'role', of: $.person, is: 'manager' } }], 624 senior: [ 625 { match: { the: 'role', of: $.person, is: 'employee' } }, 626 { match: { the: 'level', of: $.person, is: 'senior' } }, 627 ], 628 }, 629 }) 630 .apply({ person }) 631 .prepare() 632 633 assert.ok(plan($.who).cost > plan('Alice').cost) 634 }, 635 636 'ensures rule scope is independent from outer scope': async (assert) => { 637 /** 638 * @param {API.Term} result 639 */ 640 const plan = (result) => 641 Analyzer.rule({ 642 match: { result: $.result }, 643 when: { 644 ref: [ 645 { 646 match: { of: $.result, is: 'reference' }, 647 operator: 'data/type', 648 }, 649 ], 650 else: [ 651 { match: { of: $.type, is: 'b' }, operator: '==' }, 652 { match: { the: $.type, of: $.x, is: 'b' } }, 653 { match: { the: 'value', of: $.result, is: $.x } }, 654 ], 655 }, 656 }) 657 .apply({ result }) 658 .prepare() 659 660 assert.deepEqual(plan('data').toJSON(), { 661 match: { result: 'data' }, 662 rule: { 663 match: { result: $.result }, 664 when: { 665 ref: [ 666 { match: { of: $.result, is: 'reference' }, operator: 'data/type' }, 667 ], 668 else: [ 669 { match: { the: 'value', of: $.result, is: $.x } }, 670 { match: { the: $.type, of: $.x, is: 'b' } }, 671 { match: { of: $.type, is: 'b' }, operator: '==' }, 672 ], 673 }, 674 }, 675 }) 676 677 assert.throws( 678 () => plan($.q), 679 /Unbound \?result variable referenced from { match: { of: \$.result, is: "reference" }, operator: "data\/type" }/ 680 ) 681 }, 682 683 'handles rule variable mappings correctly': async (assert) => { 684 assert.throws( 685 () => 686 Analyzer.rule({ 687 match: { x: $.x, y: $.y }, 688 when: { 689 where: [{ match: { this: $.x, than: $.y }, operator: '>' }], 690 }, 691 }) 692 // @ts-expect-error - missing match for y 693 .apply({ x: $.input }) 694 .prepare(), 695 696 // Analyzer.from({ 697 // match: { x: $.input }, 698 // rule: { 699 // match: { x: $.x, y: $.y }, 700 // when: [{ match: { this: $.x, than: $.y }, operator: '>' }], 701 // }, 702 // }) 703 /Rule application omits required parameter "y"/ 704 ) 705 }, 706 707 'rule output may be provided': async (assert) => { 708 const rule = Analyzer.rule({ 709 match: { x: $.x, y: $.y }, 710 when: { 711 where: [{ match: { of: $.x, by: 1, is: $.y }, operator: '-' }], 712 }, 713 }) 714 715 const application = rule.apply({ x: $.outX, y: $.outY }) 716 717 assert.throws(() => { 718 application.prepare() 719 }, /Unbound \?x variable referenced from { match: { of: \$.x, by: 1, is: \$.y }, operator: "-" }/) 720 721 assert.ok(rule.apply({ x: null, y: $.unbound }).prepare().toJSON()) 722 assert.ok(rule.apply({ x: 1, y: 2 }).prepare().toJSON()) 723 }, 724 725 'rule maps multi-variable input terms correctly': async (assert) => { 726 const rule = Analyzer.rule({ 727 match: { x: $.a, y: $.b }, 728 when: { 729 where: [ 730 { 731 match: { of: $.a, with: $.b, is: $.result }, 732 operator: 'text/concat', 733 }, 734 ], 735 }, 736 }) 737 738 assert.deepEqual(rule.apply({ x: 1, y: 2 }).prepare().toJSON(), { 739 match: { x: 1, y: 2 }, 740 rule: { 741 match: { x: $.a, y: $.b }, 742 when: { 743 where: [ 744 { 745 match: { of: $.a, with: $.b, is: $.result }, 746 operator: 'text/concat', 747 }, 748 ], 749 }, 750 }, 751 }) 752 753 assert.throws( 754 () => rule.apply({ x: 1, y: $.unbound }).prepare(), 755 /Unbound \?b variable referenced/ 756 ) 757 758 assert.throws( 759 () => rule.apply({ y: 1, x: $.unbound }).prepare(), 760 /Unbound \?a variable referenced/ 761 ) 762 }, 763 764 'handles unified variables in rule case': async (assert) => { 765 const Same = Analyzer.rule({ 766 match: { this: $.a, as: $.a }, 767 }) 768 769 assert.deepEqual(Same.apply({ this: 1, as: $.q }).prepare().toJSON(), { 770 match: { this: 1, as: $.q }, 771 rule: { 772 match: { this: $.a, as: $.a }, 773 }, 774 }) 775 776 assert.deepEqual(Same.apply({ this: $.q, as: 2 }).prepare().toJSON(), { 777 match: { this: $.q, as: 2 }, 778 rule: { 779 match: { this: $.a, as: $.a }, 780 }, 781 }) 782 783 assert.throws( 784 () => Same.apply({ this: $.x, as: $.y }).prepare(), 785 /Rule application requires binding for \?a/ 786 ) 787 }, 788 789 'unification + input': async (assert) => { 790 const rule = Analyzer.rule({ 791 match: { a: $.a, b: $.a, c: $.c }, // Same variable $.a in both positions 792 when: { 793 where: [{ match: { of: $.a, is: $.c }, operator: '==' }], 794 }, 795 }) 796 797 assert.ok( 798 rule.apply({ a: 1, b: 1, c: $.unbound }).prepare().cost >= 799 rule.apply({ a: 1, b: 1, c: 1 }).prepare().cost, 800 'output cell can be omitted' 801 ) 802 }, 803 804 'errors if rule branch references undefined variable': async (assert) => { 805 const rule = Analyzer.rule({ 806 match: { x: $.x, y: $.y }, 807 when: { 808 where: [ 809 { 810 match: { of: $.z, with: $.y, is: $.x }, // $.z not in case 811 operator: '+', 812 }, 813 ], 814 }, 815 }) 816 817 const match = rule.apply({ x: $.a, y: $.b }) 818 819 assert.throws( 820 () => rule.apply({ x: 'a', y: 'b' }).prepare(), 821 /Unbound \?z variable referenced from/ 822 ) 823 }, 824 825 'errors if deductive branch does not handle case variable': async ( 826 assert 827 ) => { 828 assert.throws( 829 () => 830 Analyzer.rule({ 831 match: { x: $.x, y: $.y }, 832 when: { 833 where: [ 834 { match: { the: 'type', of: $.x, is: 'person' } }, // Doesn't handle $.y 835 ], 836 }, 837 }) 838 .apply({ x: 'x', y: $.out }) 839 .prepare(), 840 /Rule case "where" does not bind variable \?y that rule matches as "y"/ 841 ) 842 }, 843 844 'recursive rule must have when that binds all variables': async (assert) => { 845 assert.throws( 846 () => 847 Analyzer.rule({ 848 match: { this: $.value, from: $.from, to: $.to }, 849 when: { 850 do: [{ match: { this: $.from, than: $.to }, operator: '<' }], 851 while: [ 852 { match: { of: $.from, with: 1, is: $.inc }, operator: '+' }, 853 { match: { this: $.inc, than: $.to }, operator: '<' }, 854 ], 855 }, 856 }), 857 /does not bind variable \?value that rule matches as "this"/ 858 ) 859 }, 860 861 'allows output variables to be omitted from match': async (assert) => { 862 const plan = Analyzer.rule({ 863 match: { x: $.x, y: $.y }, 864 when: { 865 where: [{ match: { of: $.x, is: $.y }, operator: 'math/absolute' }], 866 }, 867 }) 868 // @ts-expect-error - missing y 869 .apply({ x: 'test' }) 870 .prepare() 871 872 assert.ok(plan, 'Should allow omitting output variables') 873 }, 874 875 'detects cycles between branches': async (assert) => { 876 const rule = Analyzer.rule({ 877 match: { x: $.x, y: $.y }, 878 when: { 879 where: [ 880 { match: { of: $.y, is: $.x }, operator: 'math/absolute' }, 881 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, 882 ], 883 }, 884 }) 885 886 assert.throws(() => { 887 rule.apply({ x: $.in, y: $.out }).prepare() 888 }, /Unbound \?y variable referenced from { match: { of: \$.y, is: \$.x }, operator: "math\/absolute" }/) 889 890 assert.deepEqual( 891 rule.apply({ x: 1, y: $.out }).prepare().toJSON(), 892 { 893 match: { x: 1, y: $.out }, 894 rule: { 895 match: { x: $.x, y: $.y }, 896 when: { 897 where: [ 898 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, 899 { match: { of: $.y, is: $.x }, operator: 'math/absolute' }, 900 ], 901 }, 902 }, 903 }, 904 'resolves cycle through x' 905 ) 906 907 assert.deepEqual( 908 rule.apply({ x: $.q, y: 1 }).prepare().toJSON(), 909 { 910 match: { x: $.q, y: 1 }, 911 rule: { 912 match: { x: $.x, y: $.y }, 913 when: { 914 where: [ 915 { match: { of: $.y, is: $.x }, operator: 'math/absolute' }, 916 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, 917 ], 918 }, 919 }, 920 }, 921 'resolves cycle through y' 922 ) 923 }, 924 925 'resoles cycles from application': async (assert) => { 926 const rule = Analyzer.rule({ 927 match: { x: $.x, y: $.y }, 928 when: { 929 where: [ 930 { match: { the: 'type', of: $.x, is: 'person' } }, // Outputs $.x 931 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, // Uses $.x to produce $.y 932 { match: { of: $.y, by: 1, is: $.x }, operator: '-' }, // Creates cycle by producing $.x again 933 ], 934 }, 935 }) 936 937 assert.deepEqual(rule.apply({ x: $.outX, y: $.outY }).prepare().toJSON(), { 938 match: { x: $.outX, y: $.outY }, 939 rule: { 940 match: { x: $.x, y: $.y }, 941 when: { 942 where: [ 943 { match: { the: 'type', of: $.x, is: 'person' } }, 944 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, 945 { match: { of: $.y, by: 1, is: $.x }, operator: '-' }, 946 ], 947 }, 948 }, 949 }) 950 }, 951 952 'unresolvable cycles': async (assert) => { 953 const rule = Analyzer.rule({ 954 match: { is: $.is }, 955 when: { 956 where: [ 957 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, 958 { match: { of: $.y, by: 1, is: $.x }, operator: '-' }, 959 { match: { of: $.x, is: $.is }, operator: '==' }, 960 ], 961 }, 962 }) 963 964 assert.throws( 965 () => rule.apply({ is: $.q }).prepare(), 966 /Unbound \?x variable referenced from \{ match: { of: \$.x, with: 1, is: \$.y }, operator: "\+"/ 967 ) 968 }, 969 970 'resolvable through unification': async (assert) => { 971 const rule = Analyzer.rule({ 972 match: { is: $.is }, 973 when: { 974 where: [ 975 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, 976 { match: { of: $.y, by: 1, is: $.x }, operator: '-' }, 977 { 978 match: { this: $.x, as: $.is }, 979 rule: { match: { this: $.as, as: $.as }, when: {} }, 980 }, 981 ], 982 }, 983 }) 984 985 assert.deepEqual( 986 rule.apply({ is: 5 }).prepare().toJSON(), 987 { 988 match: { is: 5 }, 989 rule: { 990 match: { is: $.is }, 991 when: { 992 where: [ 993 { 994 match: { this: $.x, as: $.is }, 995 rule: { match: { this: $.as, as: $.as } }, 996 }, 997 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, 998 { match: { of: $.y, by: 1, is: $.x }, operator: '-' }, 999 ], 1000 }, 1001 }, 1002 }, 1003 'resolves cycle' 1004 ) 1005 1006 assert.throws( 1007 () => rule.apply({ is: $.q }).prepare(), 1008 /Unbound \?x variable/ 1009 ) 1010 }, 1011 1012 'cycle in formula': async (assert) => { 1013 assert.throws(() => { 1014 Analyzer.rule({ 1015 match: { x: $.x }, 1016 when: { 1017 where: [ 1018 { 1019 match: { of: $.x, with: 1, is: $.x }, 1020 operator: '+', 1021 }, 1022 ], 1023 }, 1024 }) 1025 .apply({ x: $.x }) 1026 .prepare() 1027 }, /Variable .* cannot appear in both input and output of Match clause/) 1028 }, 1029 1030 'cycles between disjunctive branches are valid': async (assert) => { 1031 const plan = Analyzer.rule({ 1032 match: { x: $.x, y: $.y }, 1033 when: { 1034 branch1: [ 1035 { 1036 match: { of: $.x, with: 1, is: $.y }, 1037 operator: '+', 1038 }, 1039 ], 1040 branch2: [{ match: { of: $.y, by: 1, is: $.x }, operator: '-' }], 1041 }, 1042 }) 1043 .apply({ x: 'x', y: 'y' }) 1044 .prepare() 1045 1046 assert.ok(plan, 'Rule should plan successfully') 1047 }, 1048 1049 'plans rule based on available bindings': async (assert) => { 1050 const plan = Analyzer.rule({ 1051 match: { x: $.x }, 1052 when: { 1053 where: [ 1054 { match: { the: 'type', of: $.x, is: 'person' } }, 1055 { match: { the: 'name', of: $.x, is: $.name } }, 1056 ], 1057 }, 1058 }) 1059 .apply({ x: $.x }) 1060 .prepare() 1061 1062 assert.ok(plan, 'Should plan successfully') 1063 }, 1064 1065 'fails to plan when required inputs missing': async (assert) => { 1066 assert.throws( 1067 () => 1068 Analyzer.rule({ 1069 match: { x: $.x }, 1070 when: { 1071 where: [ 1072 { 1073 match: { 1074 of: $.y, // y is not bound 1075 with: 1, 1076 is: $.x, 1077 }, 1078 operator: '+', 1079 }, 1080 ], 1081 }, 1082 }) 1083 .apply({ x: $.x }) 1084 .prepare(), 1085 /Unbound \?y variable referenced from { match: { of: \$.y, with: 1, is: \$.x }, operator: "\+" }/ 1086 ) 1087 }, 1088 1089 'plans rule when inputs are bound': async (assert) => { 1090 const rule = Analyzer.rule({ 1091 match: { x: $.x, y: $.y }, 1092 when: { 1093 where: [ 1094 // Needs $.x to produce $.y 1095 { match: { of: $.x, with: 1, is: $.y }, operator: '+' }, 1096 ], 1097 }, 1098 }) 1099 1100 const match = rule.apply({ x: $.in, y: $.out }) 1101 1102 assert.ok( 1103 rule.apply({ x: 'in', y: 'out' }).prepare(), 1104 'Plans when input is bound' 1105 ) 1106 assert.throws( 1107 () => rule.apply({ x: $.x, y: 'out' }).prepare(), 1108 /Unbound \?x variable referenced from { match: { of: \$.x, with: 1, is: \$.y }, operator: "\+" }/ 1109 ) 1110 }, 1111 1112 'inflates rule cost based on recursion': async (assert) => { 1113 /** 1114 * @param {object} source 1115 * @param {API.Term} source.from 1116 * @param {API.Term} source.to 1117 * @returns 1118 */ 1119 const Recursive = ({ from, to }) => 1120 Analyzer.rule({ 1121 match: { n: $.n, from: $.from, to: $.to }, 1122 when: { 1123 do: [ 1124 { match: { this: $.from, than: $.to }, operator: '<' }, 1125 { match: { of: $.from, is: $.n }, operator: '==' }, 1126 ], 1127 while: [ 1128 { match: { this: $.from, than: $.to }, operator: '<' }, 1129 { match: { of: $.from, with: 1, is: $.inc }, operator: '+' }, 1130 { recur: { n: $.n, from: $.inc, to: $.to } }, 1131 ], 1132 }, 1133 }) 1134 .apply({ n: $.n, from, to }) 1135 .prepare() 1136 1137 /** 1138 * @param {object} source 1139 * @param {API.Term} source.from 1140 * @param {API.Term} source.to 1141 * @returns 1142 */ 1143 const Deductive = ({ from, to }) => 1144 Analyzer.rule({ 1145 match: { n: $.n, from: $.from, to: $.to }, 1146 when: { 1147 base: [ 1148 { match: { this: $.from, than: $.to }, operator: '<' }, 1149 { match: { of: $.from, is: $.n }, operator: '==' }, 1150 ], 1151 else: [ 1152 { match: { of: $.from, with: 1, is: $.n }, operator: '+' }, 1153 { match: { this: $.n, than: $.to }, operator: '<' }, 1154 ], 1155 }, 1156 }) 1157 .apply({ n: $.n, from, to }) 1158 .prepare() 1159 1160 assert.ok( 1161 Recursive({ from: 0, to: 10 }).cost > Deductive({ from: 0, to: 10 }).cost, 1162 `Recursive rule should have higher cost ${ 1163 Recursive({ from: 0, to: 10 }).cost 1164 } > ${Deductive({ from: 0, to: 10 }).cost}}` 1165 ) 1166 }, 1167 1168 'considers variable mapping in cost estimation': async (assert) => { 1169 const rule = Analyzer.rule({ 1170 match: { x: $.x }, 1171 when: { 1172 where: [{ match: { of: $.x, with: 1, is: $.y }, operator: '+' }], 1173 }, 1174 }) 1175 1176 assert.ok( 1177 rule.apply({ x: 1 }).prepare(), 1178 'Should plan with mapped variables' 1179 ) 1180 assert.ok( 1181 rule.apply({ x: 'thing' }).prepare(), 1182 'Should plan with bound variables' 1183 ) 1184 }, 1185 1186 'plans rule with no body': async (assert) => { 1187 const Same = Analyzer.rule({ 1188 match: { this: $, as: $ }, 1189 }) 1190 1191 const plan = Same.apply({ this: $.x, as: $.x }).plan({ 1192 bindings: new Map([[$.x, 1]]), 1193 references: new Map(), 1194 }) 1195 1196 assert.ok( 1197 plan.cost < 1, 1198 'Empty rule should have very low cost as it just unifies variables' 1199 ) 1200 1201 assert.throws( 1202 () => Same.apply({ this: $.x, as: $.x }).prepare(), 1203 /Rule application requires binding for \?/ 1204 ) 1205 }, 1206 1207 'plan blank rules': async (assert) => { 1208 const Entity = Analyzer.rule({ 1209 match: { this: $.this }, 1210 }) 1211 1212 assert.throws( 1213 () => Entity.apply({ this: $.q }).prepare(), 1214 /Rule application requires binding for \?this/ 1215 ) 1216 }, 1217 1218 'compares iteration rule cost to against scan cost': async (assert) => { 1219 const Between = Analyzer.rule({ 1220 match: { 1221 value: $.value, 1222 from: $.from, 1223 to: $.to, 1224 }, 1225 when: { 1226 do: [ 1227 { match: { this: $.from, than: $.to }, operator: '<' }, 1228 { match: { of: $.from, is: $.value }, operator: '==' }, 1229 ], 1230 while: [ 1231 { match: { this: $.from, than: $.to }, operator: '<' }, 1232 { match: { of: $.from, with: 1, is: $.next }, operator: '+' }, 1233 { recur: { value: $.value, from: $.next, to: $.to } }, 1234 ], 1235 }, 1236 }) 1237 1238 const Scan = Analyzer.rule({ 1239 match: { x: $.x }, 1240 when: { where: [{ match: { the: 'type', of: $.x, is: 'document' } }] }, 1241 }) 1242 1243 const between = Between.apply({ from: 0, to: 100, value: $.n }).prepare() 1244 1245 const scan = Scan.apply({ x: $.x }).prepare() 1246 1247 assert.ok( 1248 between.cost < scan.cost, 1249 `Between rule using only formula operations should be cheaper than a full scan ${between.cost} < ${scan.cost}` 1250 ) 1251 }, 1252 1253 'estimates costs correctly for Case patterns': async (assert) => { 1254 // Helper to create a test case 1255 1256 const select = Analyzer.rule({ 1257 match: { the: $.attribute, of: $.entity, is: $.value }, 1258 when: { 1259 where: [{ match: { the: $.attribute, of: $.entity, is: $.value } }], 1260 }, 1261 }) 1262 1263 /** 1264 * @param {Omit<Required<API.Select>, 'this'>} terms 1265 * @param {Partial<API.Scope>} context 1266 */ 1267 const testCost = ( 1268 terms, 1269 { bindings = new Map(), references = new Map() } = {} 1270 ) => 1271 select.apply(terms).plan({ 1272 bindings, 1273 references, 1274 }).cost 1275 1276 // Test EAV index cases 1277 const entityId = Link.of('test-entity') 1278 assert.ok( 1279 testCost({ the: 'type', of: entityId, is: $.value }) < 1280 testCost({ the: 'type', of: $.entity, is: $.value }), 1281 'Known entity should be cheaper than unknown' 1282 ) 1283 1284 // Test attribute selectivity 1285 assert.ok( 1286 testCost({ the: 'type', of: $.entity, is: $.value }) < 1287 testCost({ the: $.attribute, of: $.entity, is: $.value }), 1288 'Known attribute should be cheaper than unknown' 1289 ) 1290 1291 // Test value types 1292 assert.ok( 1293 testCost({ the: $.attribute, of: $.entity, is: entityId }) == 1294 testCost({ the: $.attribute, of: $.entity, is: 'some-string' }), 1295 1296 'Entity value should be as selective as string' 1297 ) 1298 1299 assert.ok( 1300 testCost({ the: $.attribute, of: $.entity, is: 'string' }) == 1301 testCost({ the: $.attribute, of: $.entity, is: true }), 1302 'String should be as selective as boolean' 1303 ) 1304 1305 // Test index usage 1306 assert.ok( 1307 testCost({ the: 'type', of: entityId, is: $.value }) < 1308 testCost({ the: $.attribute, of: entityId, is: $.value }), 1309 'EAV index should be cheaper than scanning entity' 1310 ) 1311 1312 assert.ok( 1313 testCost({ the: 'type', of: $.entity, is: entityId }) < 1314 testCost({ the: $.attribute, of: $.entity, is: entityId }), 1315 'VAE index should be cheaper than scanning value' 1316 ) 1317 1318 // Test bound variables 1319 assert.ok( 1320 testCost({ the: 'type', of: entityId, is: $.value }) == 1321 testCost( 1322 { the: 'type', of: $.entity, is: $.value }, 1323 { 1324 bindings: new Map([[$.entity, entityId]]), 1325 } 1326 ), 1327 'Known entity should cost same as bound entity variable' 1328 ) 1329 1330 assert.ok( 1331 testCost({ the: 'type', of: $.entity, is: entityId }) == 1332 testCost( 1333 { the: 'type', of: $.entity, is: $.value }, 1334 { 1335 bindings: new Map([[$.value, 2]]), 1336 } 1337 ), 1338 'Known value should cost same as bound value variable' 1339 ) 1340 }, 1341 1342 'test select circuit': (assert) => { 1343 const plan = Analyzer.rule({ 1344 match: { x: $.x }, 1345 when: { 1346 basic: [ 1347 { match: { the: 'type', of: $.x, is: 'document' } }, 1348 { match: { the: 'status', of: $.x, is: $.status } }, 1349 ], 1350 advanced: [ 1351 { match: { the: 'type', of: $.x, is: 'document' } }, 1352 { match: { the: 'status', of: $.x, is: $.status } }, 1353 { match: { the: 'author', of: $.x, is: $.author } }, 1354 ], 1355 }, 1356 }) 1357 .apply({ x: $.x }) 1358 .prepare() 1359 }, 1360 1361 'test correctly merges cost estimates': (assert) => { 1362 const rule = Analyzer.rule({ 1363 match: { of: $.of }, 1364 when: { 1365 where: [ 1366 { match: { the: 'name', of: $.of, is: $.name } }, 1367 { match: { of: $.name, is: 'string' }, operator: 'data/type' }, 1368 ], 1369 }, 1370 }) 1371 1372 const application = rule.apply({ of: $.subject }) 1373 1374 assert.ok(application.cost < Infinity) 1375 }, 1376 1377 'unblocks when referenced remote variable is unblocked': (assert) => { 1378 const plan = Analyzer.rule({ 1379 match: { actual: $.actual, expect: $.expect }, 1380 when: { 1381 where: [ 1382 // Here we `$.expect` to be bound by the second conjunct. 1383 { match: { of: $.expect, is: 'actual' }, operator: '==' }, 1384 // This sets a binding of the $.actual 1385 { match: { of: 'actual', is: $.actual }, operator: '==' }, 1386 ], 1387 }, 1388 }) 1389 .apply({ actual: $.same, expect: $.same }) 1390 .prepare() 1391 1392 assert.deepEqual(plan.toJSON(), { 1393 match: { actual: $.same, expect: $.same }, 1394 rule: { 1395 match: { actual: $.actual, expect: $.expect }, 1396 when: { 1397 where: [ 1398 { match: { of: 'actual', is: $.actual }, operator: '==' }, 1399 { match: { of: $.expect, is: 'actual' }, operator: '==' }, 1400 ], 1401 }, 1402 }, 1403 }) 1404 }, 1405 1406 'negation references are inputs': (assert) => { 1407 assert.throws(() => { 1408 const plan = Analyzer.rule({ 1409 match: { q: $.q }, 1410 when: { 1411 where: [ 1412 { 1413 not: { match: { the: 'status/ready', of: $.q } }, 1414 }, 1415 ], 1416 }, 1417 }) 1418 .apply({ q: $.q }) 1419 .prepare() 1420 }, /Unbound \?q variable/) 1421 }, 1422 1423 'salary variable reuse test': async (assert) => { 1424 const Employee = /** @type {const} */ ({ 1425 match: { 1426 this: $.this, 1427 name: $.name, 1428 salary: $.salary, 1429 }, 1430 when: { 1431 where: [ 1432 { match: { the: 'name', of: $.this, is: $.name } }, 1433 { match: { the: 'salary', of: $.this, is: $.salary } }, 1434 ], 1435 }, 1436 }) 1437 1438 const Supervisor = Analyzer.rule({ 1439 match: { 1440 employee: $.employee, 1441 supervisor: $.supervisor, 1442 }, 1443 when: { 1444 where: [ 1445 // Using the same wildcard variable $._ for two different salary parameters 1446 { 1447 match: { this: $.subordinate, name: $.employee, salary: $._ }, 1448 rule: Employee, 1449 }, 1450 { match: { the: 'supervisor', of: $.subordinate, is: $.manager } }, 1451 { 1452 match: { this: $.manager, name: $.supervisor, salary: $._ }, 1453 rule: Employee, 1454 }, 1455 ], 1456 }, 1457 }) 1458 1459 const plan = Supervisor.apply().prepare() 1460 assert.ok( 1461 plan, 1462 'Should successfully create a plan with reused wildcard variables' 1463 ) 1464 }, 1465 1466 'formula input is required': (assert) => { 1467 assert.throws(() => { 1468 const plan = Analyzer.rule({ 1469 match: { q: $.q }, 1470 when: { 1471 where: [ 1472 { 1473 match: { of: $.q, is: 'string' }, 1474 operator: 'data/type', 1475 }, 1476 ], 1477 }, 1478 }) 1479 .apply({ q: $.q }) 1480 .prepare() 1481 }, /Unbound \?q variable referenced from { match: { of: \$.q, is: "string" }, operator: "data\/type" }/) 1482 }, 1483 1484 'discard variable still fails with required operators': (assert) => { 1485 const rule = Analyzer.rule({ 1486 match: { type: $.type }, 1487 when: { 1488 where: [ 1489 // Using $._ as the required 'of' parameter - this should still fail 1490 { match: { of: $._, is: $.type }, operator: 'data/type' }, 1491 ], 1492 }, 1493 }) 1494 1495 assert.throws(() => { 1496 rule.apply({ type: 'string' }).prepare() 1497 }, /Unbound \?_ variable referenced from/) 1498 }, 1499 1500 'test select resolution': async (assert) => { 1501 const plan = Analyzer.rule({ 1502 match: { person: $.person, name: $.name }, 1503 when: { 1504 where: [{ match: { the: 'person/name', of: $.person, is: $.name } }], 1505 }, 1506 }) 1507 .apply({ person: $.q, name: 'Irakli' }) 1508 .prepare() 1509 1510 const source = Memory.create({ 1511 import: { 1512 irakli: { 'person/name': 'Irakli' }, 1513 zoe: { 'person/name': 'Zoe' }, 1514 }, 1515 }) 1516 const inspector = Inspector.from(source) 1517 const results = await Task.perform(plan.query({ from: inspector })) 1518 1519 // console.log([...results.values()]) 1520 assert.deepEqual(results, [ 1521 new Map([[$.q, Link.of({ 'person/name': 'Irakli' })]]), 1522 ]) 1523 1524 // assert.deepEqual( 1525 // [...results.entries()], 1526 // [ 1527 // [$.q, Link.of({ 'person/name': 'Irakli' })], 1528 // [$.name, 'Irakli'], 1529 // ] 1530 // // [{ person: Link.of({ 'person/name': 'Irakli' }), name: 'Irakli' }] 1531 // ) 1532 1533 assert.deepEqual(inspector.queries(), [ 1534 { the: 'person/name', is: 'Irakli' }, 1535 ]) 1536 }, 1537 1538 'plan nested unification': async (assert) => { 1539 const plan = Analyzer.rule({ 1540 match: { counter: $.counter }, 1541 when: { 1542 where: [ 1543 // { not: { match: { the: 'counter/count', of: $.counter } } }, 1544 { 1545 match: { this: Link.of({}), as: $.counter }, 1546 rule: { match: { this: $.this, as: $.this }, when: {} }, 1547 }, 1548 ], 1549 }, 1550 }) 1551 .apply({ counter: $.q }) 1552 .prepare() 1553 1554 assert.ok(plan) 1555 }, 1556 1557 'test unification example': async (assert) => { 1558 const plan = Analyzer.rule({ 1559 match: { this: $.a, as: $.a }, 1560 when: {}, 1561 }) 1562 .apply({ this: $.q, as: 2 }) 1563 .prepare() 1564 1565 // @ts-expect-error - plan.bindings is not declared in types 1566 assert.equal(plan.bindings.get($.q), 2) 1567 }, 1568 1569 'unification in nested predicate propagates': async (assert) => { 1570 const plan = Analyzer.rule({ 1571 match: { name: $.userName, this: $.user }, 1572 when: { 1573 where: [ 1574 { match: { the: 'user/name', of: $.userAccount, is: $.userName } }, 1575 { 1576 match: { this: $.userAccount, as: $.user }, 1577 rule: { match: { this: $.this, as: $.this }, when: {} }, 1578 }, 1579 ], 1580 }, 1581 }) 1582 .apply() 1583 .prepare() 1584 1585 assert.ok(plan) 1586 }, 1587 1588 'should fail to plan if not bound': async (assert) => { 1589 const Person = Analyzer.rule({ 1590 match: { name: $.name, this: $.this }, 1591 when: { 1592 where: [{ match: { the: 'person/name', of: $.this, is: $.name } }], 1593 }, 1594 }) 1595 1596 assert.throws( 1597 () => { 1598 Analyzer.rule({ 1599 match: { name: $.name, this: $.this }, 1600 when: { 1601 where: [{ match: { this: $.this, name: 'Ben' }, rule: Person }], 1602 }, 1603 }) 1604 .apply() 1605 .prepare() 1606 }, 1607 /does not bind variable \?name/g, 1608 '$.name is not bound' 1609 ) 1610 }, 1611 'should be able to unify': async (assert) => { 1612 const plan = Analyzer.rule({ 1613 match: { this: $.this, count: $.count }, 1614 when: { 1615 where: [ 1616 { 1617 not: { 1618 match: { this: $.this }, 1619 rule: { 1620 match: { this: $.this, count: $.count, title: $.title }, 1621 when: { 1622 where: [ 1623 { 1624 match: { the: 'counter/count', of: $.this, is: $.count }, 1625 }, 1626 { 1627 match: { the: 'counter/title', of: $.this, is: $.title }, 1628 }, 1629 ], 1630 }, 1631 }, 1632 }, 1633 }, 1634 { 1635 match: { the: 'counter/count', of: $.this, is: $.count }, 1636 }, 1637 { 1638 match: { the: 'counter/title', of: $.this, is: $.title }, 1639 }, 1640 ], 1641 }, 1642 }) 1643 .apply() 1644 .prepare() 1645 1646 assert.ok(plan) 1647 }, 1648 1649 'skip recur with unbound variable should fail': async (assert) => { 1650 assert.throws(() => { 1651 Analyzer.rule({ 1652 match: { name: $.name, extra: $.extra }, 1653 when: { 1654 where: [ 1655 { match: { the: 'person/name', is: $.name }, fact: {} }, 1656 { recur: { name: $.name, extra: $.extra } }, 1657 ], 1658 }, 1659 }) 1660 .apply() 1661 .prepare() 1662 }, /Unbound \?extra variable referenced from { recur/) 1663 }, 1664 }