|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import type { FilterCondition } from '@objectstack/spec/data'; |
| 4 | +// [#19995] The engine's own shared comparand faces — run on a read scope, alone, |
| 5 | +// at the ObjectQL merge sites by {@link assertReadScopeComparandsRunnable}. |
| 6 | +import { assertListComparandShapes, normalizeFilterComparandTypes } from '@objectstack/spec/data'; |
4 | 7 | import type { RegisteredErrorCode } from '@objectstack/spec/api'; |
5 | 8 | import { type LikeShape } from './like-pattern.js'; |
6 | 9 | import { textMatchPredicateSql, normalizeSqlDialect } from './text-match-sql.js'; |
@@ -414,6 +417,20 @@ import { |
414 | 417 | * already refused by the bare-array arm. {@link assertNoListInEqualitySlot} |
415 | 418 | * refuses it in this module's envelope. See there for the measured answers, the |
416 | 419 | * reachability reading, and why `$ne` is not judged here. |
| 420 | + * |
| 421 | + * ## The ObjectQL ENGINE path refuses a bad comparand in THIS envelope too (#19995) |
| 422 | + * |
| 423 | + * The #13640 section above is the vacancy half of the engine path; this is the |
| 424 | + * comparand half. A scope carrying a comparand the ENGINE's shared comparand |
| 425 | + * faces refuse was handed to `engine.aggregate` and came back as the engine's |
| 426 | + * `INVALID_FILTER` / 400, a 4xx whose prose the HTTP doors relay — while the |
| 427 | + * NativeSQL face and the echo refused the same scope in the withheld envelope |
| 428 | + * above. One scope, two envelopes, and the 400 one is the disclosure #5367 |
| 429 | + * closed. The engine's refusal does not read the `'policy'` provenance mark |
| 430 | + * (#8220); only `driver-sql`'s cross-field and bind refusals do. |
| 431 | + * {@link assertReadScopeComparandsRunnable} closes it at the two engine-bound |
| 432 | + * merge sites. See there for why its refusal set is exactly the engine's, and |
| 433 | + * for what it deliberately leaves to the engine. |
417 | 434 | */ |
418 | 435 |
|
419 | 436 | const IDENT = /^[a-z_][a-z0-9_]*$/i; |
@@ -647,6 +664,63 @@ export function assertReadScopeCannotVacate(scope: unknown, objectName: string): |
647 | 664 | ); |
648 | 665 | } |
649 | 666 |
|
| 667 | +/** |
| 668 | + * [#19995] Refuse, in this module's envelope, a read scope the ENGINE would |
| 669 | + * refuse for one of its comparands — judged on the scope ALONE, before it is |
| 670 | + * composed with the caller's filter. |
| 671 | + * |
| 672 | + * The door for the two ENGINE-bound merges: `ObjectQLStrategy.withReadScope` |
| 673 | + * (the direct and the cross-object base aggregate) and `resolveFkAttr` (the |
| 674 | + * referenced object's scope). There the scope used to reach `engine.aggregate` |
| 675 | + * unjudged, and a comparand the engine refuses came back as its |
| 676 | + * `INVALID_FILTER` / 400, message relayed. At the merge site the scope is |
| 677 | + * still a distinguishable object; one line later it is `$and`-composed with |
| 678 | + * the caller's own filter and no consumer can tell whose clause a refusal |
| 679 | + * came from — which is why this is a judgement here and ⛔ never a catch |
| 680 | + * around `executeAggregate`: the caller's own `where` still reaches the |
| 681 | + * engine's doors for some shapes, and those refusals are the caller's to read. |
| 682 | + * |
| 683 | + * ## Why the refusal set is exactly the engine's |
| 684 | + * |
| 685 | + * The two faces called below are the ones the engine runs on every |
| 686 | + * object-form `where` (`lowerWhereFilterArray`, `@objectstack/objectql`): |
| 687 | + * `@objectstack/spec/data`'s list-shape face and comparand-type face. Both are |
| 688 | + * pure walks whose verdict on a subtree does not depend on the rest of the |
| 689 | + * tree or on any object's schema, so the scope alone answers exactly as the |
| 690 | + * scope inside `{ $and: [userFilter, scope] }` does. Same functions, same |
| 691 | + * verdicts: nothing the engine serves is refused here, and a `{ $field }` |
| 692 | + * reference (served on this path under #7598 Q1 = B) is stepped around by |
| 693 | + * both, as the engine steps around it. |
| 694 | + * |
| 695 | + * ## What it deliberately does not judge |
| 696 | + * |
| 697 | + * The engine refuses other scope shapes through doors that read the object's |
| 698 | + * SCHEMA or the request's CONTEXT (text operators on non-text fields, temporal |
| 699 | + * comparands, filter placeholders), and `driver-sql` refuses more at compile |
| 700 | + * time. Judging those here would mean a second copy of rules this package |
| 701 | + * cannot see; their envelope is the engine's and the driver's to give. |
| 702 | + * |
| 703 | + * Anything the two walks throw is attributable to the scope — they read |
| 704 | + * nothing else — so every throw is re-raised in the one envelope, the walk's |
| 705 | + * own sentence kept for the operator's log. |
| 706 | + * |
| 707 | + * @param scope the `StrategyContext.getReadScope` output, exactly as returned |
| 708 | + * @param objectName the object the scope was requested for — for the operator's |
| 709 | + * log only; withheld from the response by the `READ_SCOPE_COMPILE_FAILED` / |
| 710 | + * 500 declaration, like every message in this module. |
| 711 | + */ |
| 712 | +export function assertReadScopeComparandsRunnable(scope: unknown, objectName: string): void { |
| 713 | + try { |
| 714 | + assertListComparandShapes(scope, undefined, 'readScope'); |
| 715 | + normalizeFilterComparandTypes(scope, undefined, 'readScope'); |
| 716 | + } catch (e) { |
| 717 | + throw readScopeCompileError( |
| 718 | + `[read-scope-sql] read scope for "${objectName}" carries a comparand the engine refuses — ` + |
| 719 | + `${e instanceof Error ? e.message : String(e)} (fail-closed).`, |
| 720 | + ); |
| 721 | + } |
| 722 | +} |
| 723 | + |
650 | 724 | /** |
651 | 725 | * Compile a child node into its OWN bind buffer. |
652 | 726 | * |
|
0 commit comments