From a7214859ba95d2f8bda53e8f0b380daf3276eb08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:45:12 +0000 Subject: [PATCH] refactor(driver-sql, plugin-security): read the list-comparand split from the spec Both modules kept a hand copy of the list operators ($in, $nin, $between). They now call isListComparandOperator from @objectstack/spec/data, the one source the comparand faces read. The copies equalled the spec set, so behaviour is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbYnNFzqeb7KM8Lpd7j --- ...611-driver-sql-list-comparand-predicate.md | 10 +++++++ ...lugin-security-list-comparand-predicate.md | 10 +++++++ packages/drivers/driver-sql/src/sql-driver.ts | 27 +++++++++---------- .../src/rls-check-stored-form.ts | 10 +++---- 4 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 .changeset/29611-driver-sql-list-comparand-predicate.md create mode 100644 .changeset/29611-plugin-security-list-comparand-predicate.md diff --git a/.changeset/29611-driver-sql-list-comparand-predicate.md b/.changeset/29611-driver-sql-list-comparand-predicate.md new file mode 100644 index 00000000000..453fdb61d60 --- /dev/null +++ b/.changeset/29611-driver-sql-list-comparand-predicate.md @@ -0,0 +1,10 @@ +--- +'@objectstack/driver-sql': patch +--- + +refactor(driver-sql): the comparand bind test reads the list operators from the spec + +Clause-②: no +Metadata-protocol: none + +The SQL driver bind-tests each member of a `$in`, `$nin` or `$between` list as a comparand in its own right. It now asks `isListComparandOperator` from `@objectstack/spec/data` which operators take a list, instead of keeping its own copy of `$in` / `$nin` / `$between`. The set is the same, so no filter compiles or is refused differently. A later change to the spec's list operators now reaches this driver without a second edit. diff --git a/.changeset/29611-plugin-security-list-comparand-predicate.md b/.changeset/29611-plugin-security-list-comparand-predicate.md new file mode 100644 index 00000000000..16aeaa8ad67 --- /dev/null +++ b/.changeset/29611-plugin-security-list-comparand-predicate.md @@ -0,0 +1,10 @@ +--- +'@objectstack/plugin-security': patch +--- + +refactor(plugin-security): the RLS stored-form check reads the list operators from the spec + +Clause-②: no +Metadata-protocol: none + +The RLS check puts a value comparand into its column's storage form, and a `$in`, `$nin` or `$between` list member by member. It now asks `isListComparandOperator` from `@objectstack/spec/data` which operators take a list, instead of keeping its own copy of `$in` / `$nin` / `$between`. The set is the same, so no policy filter is judged differently. A later change to the spec's list operators now reaches this check without a second edit. diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 89b6a55f6ad..46b65c0e893 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -72,6 +72,11 @@ import { isAcceptedFilterComparand, ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, } from '@objectstack/spec/data'; +// [#29611] The operator split's list half (`$in` / `$nin` / `$between`): the +// operators whose comparand is a LIST, so each member is bind-tested on its own +// (see {@link assertCompilableComparand}). Asked of the spec, the one source the +// comparand faces read, so this driver keeps no copy to drift from it. +import { isListComparandOperator } from '@objectstack/spec/data'; // [#7536] `$like`/`$ilike`'s pattern language, defined once in the spec: the // dangling-escape gate every face refuses on, and the LIKE→GLOB translation the // SQLite dialects need because GLOB is the only case-exact pattern operator @@ -2487,7 +2492,7 @@ const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet = new Set([ * their own `whereIn` / `whereBetween` arms. Only their MEMBERS are inspected — * for `$field` (#5041) and, since #5234, for bindability — never their arity: * the existing descriptive `$between` refusal stays the one that answers a - * malformed range. See {@link LIST_COMPARAND_OPERATORS}. + * malformed range. See {@link isListComparandOperator}. */ const SCALAR_COMPARAND_OPERATORS: ReadonlySet = new Set([ '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', @@ -2569,17 +2574,6 @@ const TEXT_OPERATORS: ReadonlySet = new Set([ ...LIKE_PATTERN_OPERATORS, ]); -/** - * [#5234] Operators for which an ARRAY is the legitimate comparand, so it is - * each MEMBER that must be individually compilable. - * - * Scoping the member scan to these three is what keeps a scalar operator that - * received an array answering with its own message ("requires a single - * comparable value") instead of reporting the first bad member of a list it - * should never have been given. - */ -const LIST_COMPARAND_OPERATORS: ReadonlySet = new Set(['$in', '$nin', '$between']); - /** * [#5234] Does this value have a faithful rendering as the text of a LIKE * pattern? @@ -2687,10 +2681,13 @@ function assertCompilableComparand( if (nowAnchorOf(member) !== null) throw nowAnchorPositionError(field, op, member, index); // [#5234] Every member of a list operator's array is a comparand in its // own right and gets the same bind test the whole comparand gets. Scoped - // to the operators for which an array is legitimate, so a scalar operator - // handed an array keeps answering with its own message below. + // to the operators for which an array is legitimate (the spec's list + // half, {@link isListComparandOperator}), so a scalar operator handed an + // array keeps answering with its own message ("requires a single + // comparable value") instead of reporting the first bad member of a list + // it should never have been given. // [#20039] The member when it is an object, else the list that holds it. - if (LIST_COMPARAND_OPERATORS.has(op) && !isBindableComparand(member)) { + if (isListComparandOperator(op) && !isBindableComparand(member)) { throw unbindableListMemberError(field, op, member, index, refusalSubtree(member, value)); } } diff --git a/packages/plugins/plugin-security/src/rls-check-stored-form.ts b/packages/plugins/plugin-security/src/rls-check-stored-form.ts index 13157285af8..b70b9d1c7ab 100644 --- a/packages/plugins/plugin-security/src/rls-check-stored-form.ts +++ b/packages/plugins/plugin-security/src/rls-check-stored-form.ts @@ -163,6 +163,7 @@ import { INSTANT_TYPES, STRUCTURED_JSON_TYPES, filterSubtreeProvenanceOf, + isListComparandOperator, isMultiValueField, markFilterSubtreeProvenance, } from '@objectstack/spec/data'; @@ -179,15 +180,14 @@ const NO_MULTI_VALUE_COLUMNS: DeclaredMultiValueColumns = new Set(); /** * The operators whose comparand is a VALUE of the column, compared with the * stored value. The drivers put these comparands into the column's storage - * form; every other operator's operand is left as written. + * form; every other operator's operand is left as written. Which of them take + * a LIST of values is the spec's answer ({@link isListComparandOperator}), not + * a copy kept here. */ const VALUE_COMPARISON_OPERATORS: ReadonlySet = new Set([ '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$between', ]); -/** The value-comparison operators whose comparand is a LIST of values. */ -const LIST_COMPARAND_OPERATORS: ReadonlySet = new Set(['$in', '$nin', '$between']); - /** The storage rule a declared type takes, or `undefined` for a non-temporal type. */ function temporalKindOfDeclaredType(type: string): TemporalComparandKind | undefined { if (CALENDAR_DATE_TYPES.has(type)) return 'date'; @@ -412,7 +412,7 @@ function storedComparand(value: unknown, kind: TemporalComparandKind): unknown { /** A value-comparison operand in its column's storage form, a list member by member. */ function storedOperand(op: string, value: unknown, kind: TemporalComparandKind): unknown { - if (!LIST_COMPARAND_OPERATORS.has(op) || !Array.isArray(value)) return storedComparand(value, kind); + if (!isListComparandOperator(op) || !Array.isArray(value)) return storedComparand(value, kind); let copy: unknown[] | undefined; value.forEach((member, index) => { const stored = storedComparand(member, kind);