@@ -14,6 +14,7 @@ import {
1414 columnTypeOf ,
1515 filterOperatorsFor ,
1616 MULTI_SELECT_OPERATORS ,
17+ normalizeFilterFragment ,
1718 predicateOperatorsFor ,
1819 SINGLE_SELECT_OPERATORS ,
1920 storesMultipleValues ,
@@ -474,12 +475,25 @@ export function fieldPredicate(
474475 )
475476 }
476477
478+ // Read the operand the way the column stores its cells. This is the one place
479+ // every filter reaches with its column in hand — both wire grammars compile
480+ // through here — so canonicalizing at this point covers callers that cannot
481+ // supply column definitions at all: the workflow Table blocks build a filter
482+ // from user input long before any schema is known, and a raw API caller need
483+ // not know the storage form either.
484+ //
485+ // The client-side pass in `converters.ts` is a convenience for the filter UI,
486+ // not the guarantee. When it was the only pass, a workflow filtering an Email
487+ // column for `Ada@Example.com` compiled to an operand that never met the
488+ // stored `ada@example.com` and silently returned no rows.
489+ const operand = column ? canonicalizeOperand ( value , op , column ) : value
490+
477491 if ( isMultiSelect ) {
478492 switch ( op ) {
479493 case 'contains' :
480- return buildArrayMembershipClause ( tableName , field , value as JsonValue )
494+ return buildArrayMembershipClause ( tableName , field , operand as JsonValue )
481495 case 'ncontains' :
482- return sql `NOT (${ buildArrayMembershipClause ( tableName , field , value as JsonValue ) } )`
496+ return sql `NOT (${ buildArrayMembershipClause ( tableName , field , operand as JsonValue ) } )`
483497 case 'isEmpty' :
484498 return buildEmptyClause ( tableName , field , true , true )
485499 case 'isNotEmpty' :
@@ -491,55 +505,55 @@ export function fieldPredicate(
491505
492506 switch ( op ) {
493507 case 'eq' :
494- return buildContainmentClause ( tableName , field , value as JsonValue )
508+ return buildContainmentClause ( tableName , field , operand as JsonValue )
495509
496510 case 'ne' :
497- return sql `NOT (${ buildContainmentClause ( tableName , field , value as JsonValue ) } )`
511+ return sql `NOT (${ buildContainmentClause ( tableName , field , operand as JsonValue ) } )`
498512
499513 case 'gt' :
500- return buildComparisonClause ( tableName , field , '>' , value as number | string , columnType )
514+ return buildComparisonClause ( tableName , field , '>' , operand as number | string , columnType )
501515 case 'gte' :
502- return buildComparisonClause ( tableName , field , '>=' , value as number | string , columnType )
516+ return buildComparisonClause ( tableName , field , '>=' , operand as number | string , columnType )
503517 case 'lt' :
504- return buildComparisonClause ( tableName , field , '<' , value as number | string , columnType )
518+ return buildComparisonClause ( tableName , field , '<' , operand as number | string , columnType )
505519 case 'lte' :
506- return buildComparisonClause ( tableName , field , '<=' , value as number | string , columnType )
520+ return buildComparisonClause ( tableName , field , '<=' , operand as number | string , columnType )
507521
508522 case 'in' : {
509- if ( ! Array . isArray ( value ) || value . length === 0 ) return undefined
510- if ( value . length === 1 ) return buildContainmentClause ( tableName , field , value [ 0 ] )
511- const inConditions = value . map ( ( v ) => buildContainmentClause ( tableName , field , v ) )
523+ if ( ! Array . isArray ( operand ) || operand . length === 0 ) return undefined
524+ if ( operand . length === 1 ) return buildContainmentClause ( tableName , field , operand [ 0 ] )
525+ const inConditions = operand . map ( ( v ) => buildContainmentClause ( tableName , field , v ) )
512526 return sql `(${ sql . join ( inConditions , sql . raw ( ' OR ' ) ) } )`
513527 }
514528
515529 case 'nin' : {
516- if ( ! Array . isArray ( value ) || value . length === 0 ) return undefined
517- const ninConditions = value . map (
530+ if ( ! Array . isArray ( operand ) || operand . length === 0 ) return undefined
531+ const ninConditions = operand . map (
518532 ( v ) => sql `NOT (${ buildContainmentClause ( tableName , field , v ) } )`
519533 )
520534 return sql `(${ sql . join ( ninConditions , sql . raw ( ' AND ' ) ) } )`
521535 }
522536
523537 case 'contains' :
524- return buildLikeClause ( tableName , field , value as string , 'contains' )
538+ return buildLikeClause ( tableName , field , operand as string , 'contains' )
525539 case 'ncontains' :
526- return buildLikeClause ( tableName , field , value as string , 'contains' , { negate : true } )
540+ return buildLikeClause ( tableName , field , operand as string , 'contains' , { negate : true } )
527541 case 'startsWith' :
528- return buildLikeClause ( tableName , field , value as string , 'startsWith' )
542+ return buildLikeClause ( tableName , field , operand as string , 'startsWith' )
529543 case 'endsWith' :
530- return buildLikeClause ( tableName , field , value as string , 'endsWith' )
544+ return buildLikeClause ( tableName , field , operand as string , 'endsWith' )
531545
532546 case 'like' :
533- return buildPatternClause ( tableName , field , value as string , { caseInsensitive : false } )
547+ return buildPatternClause ( tableName , field , operand as string , { caseInsensitive : false } )
534548 case 'ilike' :
535- return buildPatternClause ( tableName , field , value as string , { caseInsensitive : true } )
549+ return buildPatternClause ( tableName , field , operand as string , { caseInsensitive : true } )
536550 case 'nlike' :
537- return buildPatternClause ( tableName , field , value as string , {
551+ return buildPatternClause ( tableName , field , operand as string , {
538552 caseInsensitive : false ,
539553 negate : true ,
540554 } )
541555 case 'nilike' :
542- return buildPatternClause ( tableName , field , value as string , {
556+ return buildPatternClause ( tableName , field , operand as string , {
543557 caseInsensitive : true ,
544558 negate : true ,
545559 } )
@@ -754,6 +768,64 @@ function buildArrayMembershipClause(tableName: string, field: string, value: Jso
754768 * Cannot use the GIN index — falls back to a sequential scan over the table's
755769 * rows (bounded by the btree prefix on `table_id`).
756770 */
771+ /**
772+ * Canonicalizes a filter operand into the shape the column stores.
773+ *
774+ * Three cases, because "the value the user typed" relates to storage three
775+ * different ways:
776+ *
777+ * - **Opaque ids** (`select`) are resolved from option NAMES to ids upstream by
778+ * `resolveFilterSelectValues`, which needs the whole option set. Touching
779+ * them here would re-coerce an already-resolved id.
780+ * - **Text matches** take a FRAGMENT, which is not a whole value and so cannot
781+ * go through `coerce` — a partial phone number fails validation. The type
782+ * normalizes it instead, if its canonical form drops characters.
783+ * - **Everything else** goes through the type's own `coerce`, which is by
784+ * definition how the cell was written. Idempotent, so an operand a caller
785+ * already canonicalized passes through unchanged.
786+ *
787+ * An operand `coerce` rejects is left alone rather than nulled: the value is
788+ * the user's, and the range-operator validator below still gets to reject it
789+ * with a message naming what they actually typed.
790+ */
791+ function canonicalizeOperand (
792+ value : JsonValue | undefined ,
793+ op : FilterOp ,
794+ column : ColumnDefinition
795+ ) : JsonValue | undefined {
796+ if ( value === undefined || value === null ) return value
797+ const definition = columnTypeOf ( column )
798+ if ( definition . storesOpaqueIds || ! definition . canonicalizesValues ) return value
799+
800+ if ( TEXT_MATCH_OPS . has ( op ) ) {
801+ return typeof value === 'string' ? normalizeFilterFragment ( column , value ) : value
802+ }
803+ if ( Array . isArray ( value ) ) {
804+ return value . map ( ( entry ) =>
805+ typeof entry === 'string' ? canonicalizeText ( entry , column ) : entry
806+ )
807+ }
808+ return typeof value === 'string' ? canonicalizeText ( value , column ) : value
809+ }
810+
811+ /**
812+ * Runs one TEXT operand through the column's own coercion.
813+ *
814+ * Strings only, deliberately. Canonicalization exists to reconcile what a user
815+ * TYPED with how the cell was stored; an operand that arrived as another type
816+ * was not typed as text and keeps its existing path — which matters because
817+ * `date.coerce` accepts an epoch number, so canonicalizing one would silently
818+ * reinterpret `{ birthDate: { $gte: 1704067200000 } }` as a date rather than
819+ * letting `validateComparisonValue` reject a likely mistake.
820+ */
821+ function canonicalizeText ( value : string , column : ColumnDefinition ) : JsonValue {
822+ const coerced = columnTypeOf ( column ) . coerce ( value , column )
823+ return coerced . ok ? coerced . value : value
824+ }
825+
826+ /** Operators whose operand is a search fragment rather than a whole value. */
827+ const TEXT_MATCH_OPS = new Set < FilterOp > ( [ 'contains' , 'ncontains' , 'startsWith' , 'endsWith' ] )
828+
757829function buildComparisonClause (
758830 tableName : string ,
759831 field : string ,
0 commit comments