Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,13 @@ attribute @omit()
*/
attribute @fuzzy() @@@targetField([StringField]) @@@once

/**
* Marks a `String` field as full-text-searchable. Fields with this attribute can be used with the
* `fts` filter operator and the `_ftsRelevance` orderBy. Full-text search is currently
* supported only on the `postgresql` provider (uses `to_tsvector` / `to_tsquery` / `ts_rank`).
*/
attribute @fullText() @@@targetField([StringField]) @@@once

/**
* Automatically stores the time when a record was last updated.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,18 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
}
}

@check('@fullText')
private _checkFullText(attr: AttributeApplication, accept: ValidationAcceptor) {
const zmodel = AstUtils.getContainerOfType(attr, isModel);
if (!zmodel) {
return;
}
const provider = getDataSourceProvider(zmodel);
if (provider && provider !== 'postgresql') {
accept('error', `\`@fullText\` is only supported for the \`postgresql\` provider`, { node: attr });
}
}

@check('@@schema')
private _checkSchema(attr: AttributeApplication, accept: ValidationAcceptor) {
const schemaName = getStringLiteral(attr.args[0]?.value);
Expand Down
3 changes: 3 additions & 0 deletions packages/orm/src/client/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export const FILTER_PROPERTY_TO_KIND = {
// Fuzzy search operators
fuzzy: 'Fuzzy',

// Full-text search operators
fts: 'FullText',

// List operators
has: 'List',
hasEvery: 'List',
Expand Down
183 changes: 143 additions & 40 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,53 +377,94 @@ type FieldFilter<
AllowedKinds
>
: // primitive
AddFuzzyFilterIfSupported<
Schema,
Model,
Field,
AllowedKinds,
PrimitiveFilter<
GetModelFieldType<Schema, Model, Field>,
ModelFieldIsOptional<Schema, Model, Field>,
WithAggregations,
AllowedKinds
>
>;
GetModelFieldType<Schema, Model, Field> extends 'String'
? // string — additionally consider fuzzy / full-text augmentations
AddFullTextFilterIfSupported<
Schema,
Model,
Field,
AllowedKinds,
AddFuzzyFilterIfSupported<
Schema,
Model,
Field,
AllowedKinds,
PrimitiveFilter<
GetModelFieldType<Schema, Model, Field>,
ModelFieldIsOptional<Schema, Model, Field>,
WithAggregations,
AllowedKinds
>
>
>
: PrimitiveFilter<
GetModelFieldType<Schema, Model, Field>,
ModelFieldIsOptional<Schema, Model, Field>,
WithAggregations,
AllowedKinds
>;

/**
* Conditionally augments a primitive filter with the `fuzzy` operator when:
* 1. The field's type is `String`, AND
* 2. The `Fuzzy` filter kind is allowed for this field, AND
* 3. The schema's provider supports fuzzy search (postgres only), AND
* 4. The field is annotated with `@fuzzy` in the ZModel schema.
* Conditionally augments a string-field filter with the `fuzzy` operator when:
* 1. The `Fuzzy` filter kind is allowed for this field, AND
* 2. The schema's provider supports fuzzy search (postgres only), AND
* 3. The field is annotated with `@fuzzy` in the ZModel schema.
*
* Returns `Base` unchanged when any condition fails — never `Base & {}`,
* since intersecting with `{}` would strip `null`/`undefined` from `Base`.
* Caller is responsible for only invoking this on String-typed fields
* (the gate lives in `FieldFilter`).
*/
type AddFuzzyFilterIfSupported<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
AllowedKinds extends FilterKind,
Base,
> =
GetModelFieldType<Schema, Model, Field> extends 'String'
? 'Fuzzy' extends AllowedKinds
? ProviderSupportsFuzzy<Schema> extends true
? GetModelField<Schema, Model, Field>['fuzzy'] extends true
? Base & {
/**
* Performs a fuzzy search on the string field. Only available when
* the schema's provider is `postgresql` (requires `pg_trgm` extension)
* and the field is annotated with `@fuzzy` in the ZModel schema.
* See {@link FuzzyFilterPayload} for the full options reference.
*/
fuzzy?: FuzzyFilterPayload;
}
: Base
: Base
> = 'Fuzzy' extends AllowedKinds
? ProviderSupportsFuzzy<Schema> extends true
? GetModelField<Schema, Model, Field>['fuzzy'] extends true
? Base & {
/**
* Performs a fuzzy search on the string field. Only available when
* the schema's provider is `postgresql` (requires `pg_trgm` extension)
* and the field is annotated with `@fuzzy` in the ZModel schema.
* See {@link FuzzyFilterPayload} for the full options reference.
*/
fuzzy?: FuzzyFilterPayload;
}
: Base
: Base
: Base;

/**
* Conditionally augments a string-field filter with the `fts` operator when:
* 1. The `FullText` filter kind is allowed for this field, AND
* 2. The schema's provider supports full-text search (postgres only), AND
* 3. The field is annotated with `@fullText` in the ZModel schema.
*
* Caller is responsible for only invoking this on String-typed fields
* (the gate lives in `FieldFilter`).
*/
type AddFullTextFilterIfSupported<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
AllowedKinds extends FilterKind,
Base,
> = 'FullText' extends AllowedKinds
? ProviderSupportsFullText<Schema> extends true
? GetModelField<Schema, Model, Field>['fullText'] extends true
? Base & {
/**
* Performs a full-text search on the string field. Only available when
* the schema's provider is `postgresql` and the field is annotated with
* `@fullText` in the ZModel schema.
* See {@link FullTextFilterPayload} for the full options reference.
*/
fts?: FullTextFilterPayload;
}
: Base
: Base;
: Base
: Base;
Comment on lines 416 to +467
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

wc -l packages/orm/src/client/crud-types.ts

Repository: zenstackhq/zenstack

Length of output: 106


🏁 Script executed:

head -n 50 packages/orm/src/client/crud-types.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 1493


🏁 Script executed:

sed -n '380,480p' packages/orm/src/client/crud-types.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 4853


🏁 Script executed:

grep -n "type PrimitiveFilter" packages/orm/src/client/crud-types.ts

Repository: zenstackhq/zenstack

Length of output: 89


🏁 Script executed:

sed -n '200,350p' packages/orm/src/client/crud-types.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 6609


🏁 Script executed:

sed -n '558,700p' packages/orm/src/client/crud-types.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 5876


🏁 Script executed:

sed -n '78,126p' packages/orm/src/client/crud-types.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 2543


🏁 Script executed:

cat > /tmp/test-intersection.ts << 'EOF'
// Simplified test of the intersection behavior

type NullableIf<T, N extends boolean> = N extends true ? T | null : T;

// Mimics StringFilter structure
type StringFilterBase<Nullable extends boolean> =
  | (Nullable extends true ? string | null : string)
  | { equals?: string; contains?: string };

// Mimics the current implementation
type WithFuzzy<Base> = Base & { fuzzy?: { pattern: string } };

// Test case 1: Check if scalar branch survives
type Result1 = WithFuzzy<StringFilterBase<false>>;
// This should be: (string & { fuzzy?: ... }) | ({ equals?: string } & { fuzzy?: ... })
// The first part resolves to: never (because string can't have fuzzy property)
// The second part resolves to: { equals?: string; fuzzy?: ... }

// So the plain string shorthand "foo" should not work anymore

// Mimics the suggested fix
type ExtendObjectBranch<Base, Extra> = Base extends object ? Base & Extra : Base;
type WithFuzzyFixed<Base> = Base extends (infer Branch)[]
  ? Branch[]
  : Base extends (A | B)
  ? ExtendObjectBranch<A, { fuzzy?: { pattern: string } }> | ExtendObjectBranch<B, { fuzzy?: { pattern: string } }>
  : ExtendObjectBranch<Base, { fuzzy?: { pattern: string } }>;

// Let me try a simpler approach
type FixedWithFuzzy<Base> = Base extends object
  ? Base & { fuzzy?: { pattern: string } }
  : Base;

// This should preserve the scalar branch:
// - string → string (not object, so no intersection)
// - { equals?: string } → { equals?: string; fuzzy?: ... }

type Result2 = FixedWithFuzzy<StringFilterBase<false>>;
// Should be: string | { equals?: string; fuzzy?: ... }

EOF
cat /tmp/test-intersection.ts

Repository: zenstackhq/zenstack

Length of output: 1655


🏁 Script executed:

find packages/orm -name "*.test.ts" -o -name "*.spec.ts" | head -10

Repository: zenstackhq/zenstack

Length of output: 137


🏁 Script executed:

rg -l "AddFuzzyFilterIfSupported|AddFullTextFilterIfSupported" packages/orm/

Repository: zenstackhq/zenstack

Length of output: 101


🏁 Script executed:

sed -n '148,180p' packages/orm/src/client/crud-types.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 2174


🏁 Script executed:

sed -n '148,430p' packages/orm/src/client/crud-types.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 13649


🏁 Script executed:

grep -n "where.*title.*foo\|where.*{.*:.*['\"]" packages/orm/test/*.test.ts | head -20

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

rg "where\s*{" packages/orm/test -A 2 | head -30

Repository: zenstackhq/zenstack

Length of output: 145


🏁 Script executed:

cat > /tmp/verify-intersection.ts << 'EOF'
// Verify the union narrowing claim
type StringFilter = string | { equals?: string; contains?: string };

// Current implementation - intersects with whole union
type CurrentImpl<Base> = Base & { fuzzy?: string };

// Test: does plain string survive?
type Test1 = CurrentImpl<StringFilter>;
// Expected: string | { equals?: string; contains?: string; fuzzy?: string }
// Actual: { equals?: string; contains?: string; fuzzy?: string } (string disappears)

// Proposed fix
type FixedImpl<Base> = Base extends object ? Base & { fuzzy?: string } : Base;

// Test: does plain string survive?
type Test2 = FixedImpl<StringFilter>;
// Expected: string | { equals?: string; contains?: string; fuzzy?: string }
// Actual: string | { equals?: string; contains?: string; fuzzy?: string } (string preserved)

// Verify the broken case at the value level
const test1: Test1 = "hello"; // Should error - string is not in the union anymore
const test1b: Test1 = { equals: "world" }; // Should work

const test2: Test2 = "hello"; // Should work - string is preserved
const test2b: Test2 = { equals: "world" }; // Should work
EOF

cat /tmp/verify-intersection.ts

Repository: zenstackhq/zenstack

Length of output: 1172


🏁 Script executed:

find packages/orm/test -type f -name "*.ts" -exec grep -l "where.*:" {} \; | head -5

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

sed -n '1,100p' packages/orm/test/crud.test.ts | grep -A 5 "where"

Repository: zenstackhq/zenstack

Length of output: 137


🏁 Script executed:

find packages/orm/test -type f -name "*.test.ts" | head -5

Repository: zenstackhq/zenstack

Length of output: 137


🏁 Script executed:

ls -la packages/orm/test/*.ts | head -10

Repository: zenstackhq/zenstack

Length of output: 235


🏁 Script executed:

ls -la packages/orm/test/

Repository: zenstackhq/zenstack

Length of output: 367


🏁 Script executed:

cat packages/orm/test/procedures.test.ts

Repository: zenstackhq/zenstack

Length of output: 4375


🏁 Script executed:

rg "where.*:" packages/orm -A 1 -t ts | head -50

Repository: zenstackhq/zenstack

Length of output: 2942


🏁 Script executed:

rg "equals\|contains\|startsWith" packages/orm/src -B 2 | head -50

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

cat > /tmp/test-union-narrowing.ts << 'EOF'
// Test TypeScript's union narrowing behavior with intersections

// Simple union with scalar and object branch
type StringFilter = string | { equals?: string; contains?: string };

// Test 1: Current implementation (intersect entire union)
type AddFuzzyBroken<Base> = Base & { fuzzy?: string };

// What does TypeScript do with (string | object) & Extra?
// Result: (string & Extra) | (object & Extra)
// But string & { fuzzy } = never (string can't have fuzzy property)
// So: never | (object & Extra) = object & Extra
// Result loses the string branch!

type Result1 = AddFuzzyBroken<StringFilter>;
// Hovers reveal: { equals?: string | undefined; contains?: string | undefined; fuzzy?: string | undefined }
// The `string` branch is gone!

// Test 2: Proposed fix (only intersect object branches)
type AddFuzzyFixed<Base> = Base extends object ? Base & { fuzzy?: string } : Base;

// What happens here?
// When Base = (string | {equals, contains})
// This applies to the WHOLE union type, not distributed
// So: (string | object) extends object? NO
// Result: Base (unchanged) = string | object
// Wait, that's not right either...

// We need to distribute properly
type AddFuzzyFixedCorrect<Base> =
  Base extends (infer U)[]
    ? (U extends object ? U & { fuzzy?: string } : U)[]
    : Base extends object
      ? Base & { fuzzy?: string }
      : Base;

// Actually, the issue is that when you pass a union to a generic,
// it doesn't auto-distribute. Let me test this:

type TestUnion<T> = T extends object ? T & { fuzzy?: string } : T;

// When called with a union:
type Result2 = TestUnion<StringFilter>;
// This does NOT distribute!
// StringFilter (string | object) extends object? NO
// Result: StringFilter (unchanged)

// To make it work properly, we need to ensure distribution:
type AddFuzzyFixedProper<Base> = Base extends infer U
  ? U extends object
    ? U & { fuzzy?: string }
    : U
  : never;

type Result3 = AddFuzzyFixedProper<StringFilter>;
// Now it distributes! Result: string | { equals?: string; contains?: string; fuzzy?: string }

EOF
cat /tmp/test-union-narrowing.ts

Repository: zenstackhq/zenstack

Length of output: 2129


🏁 Script executed:

python3 << 'EOF'
# Let's analyze the TypeScript logic
logic = """
Key insight: In TypeScript, when you have a union like (A | B) and you intersect it:
- (A | B) & C = (A & C) | (B & C)  [distribution rule]

So for: (string | object) & { fuzzy }
Result: (string & { fuzzy }) | (object & { fuzzy })
       = never | (object & { fuzzy })  [because string can't have fuzzy property]
       = object & { fuzzy }

This LOSES the string branch!

With the fix:
type Fix<Base> = Base extends object ? Base & { fuzzy } : Base;

For Base = (string | {equals})
- This does NOT automatically distribute to each union member
- Need to use: Base extends infer U ? (U extends object ? U & {fuzzy} : U) : never

But the actual fix in the suggestion uses:
type ExtendObjectBranch<Base, Extra> = Base extends object ? Base & Extra : Base;

This only works if Base is ALREADY ONE OF THE UNION MEMBERS, not the union itself.

The real issue is that the helper is receiving a UNION as Base,
but the conditional doesn't distribute over the union automatically.
"""

print(logic)
EOF

Repository: zenstackhq/zenstack

Length of output: 1037


🏁 Script executed:

sed -n '233,260p' packages/orm/src/client/crud-types.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 1450


🏁 Script executed:

sed -n '233,260p' packages/orm/src/client/crud-types.ts

Repository: zenstackhq/zenstack

Length of output: 1254


🏁 Script executed:

cat > /tmp/verify-fix.ts << 'EOF'
// Verify the fix works with conditional distribution

type StringFilter = string | { equals?: string; contains?: string };

// Original broken approach
type AddFuzzyBroken = StringFilter & { fuzzy?: string };
// Result: { equals?: string; contains?: string; fuzzy?: string }
// String branch is LOST!

// Proposed fix using conditional that distributes
type ExtendObjectBranch<Base, Extra> = Base extends object ? Base & Extra : Base;
type AddFuzzyFixed = ExtendObjectBranch<StringFilter, { fuzzy?: string }>;

// When Base = string | object, and you use: Base extends object ? X : Base
// TypeScript distributes the conditional:
// = (string extends object ? string & Extra : string) | (object extends object ? object & Extra : object)
// = string | (object & Extra)
// String branch is PRESERVED!

// Verify at value level:
const test1: AddFuzzyBroken = "hello"; // ERROR: 'string' is not assignable
const test1b: AddFuzzyBroken = { equals: "world" }; // OK

const test2: AddFuzzyFixed = "hello"; // OK - string is preserved!
const test2b: AddFuzzyFixed = { equals: "world" }; // OK
const test2c: AddFuzzyFixed = { equals: "world", fuzzy: { pattern: "test" } }; // OK
EOF

cat /tmp/verify-fix.ts

Repository: zenstackhq/zenstack

Length of output: 1234


🏁 Script executed:

sed -n '636,656p' packages/orm/src/client/crud-types.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 1023


🏁 Script executed:

cat > /tmp/final-verification.ts << 'EOF'
// Complete verification of the review comment's claim

// StringFilter definition (from lines 636-656):
type StringFilter<Nullable extends boolean> =
  | (Nullable extends true ? string | null : string)  // <- scalar shorthand branch
  | { equals?: string; contains?: string };  // <- object branch with operators

// Current implementation (lines 278 and 301):
type AddFuzzyBroken<Base> = Base & { fuzzy?: string };

// When Base = StringFilter<false> = string | { equals?, contains? }
// TypeScript union distribution during intersection:
// (string | object) & { fuzzy }
// = (string & { fuzzy }) | (object & { fuzzy })
// = never | (object & { fuzzy })  [string can't have fuzzy property]
// = object & { fuzzy }
// Result: The string branch DISAPPEARS!

type ResultBroken = AddFuzzyBroken<StringFilter<false>>;
// Actual type: { equals?: string; contains?: string; fuzzy?: string }
// NO longer accepts plain string!

// Proposed fix (from the suggestion):
type ExtendObjectBranch<Base, Extra> = Base extends object ? Base & Extra : Base;

// When Base = StringFilter<false> = string | { equals?, contains? }
// TypeScript distributes the conditional to each union member:
// (string extends object ? string & { fuzzy } : string) 
// | (object extends object ? object & { fuzzy } : object)
// = string | (object & { fuzzy })
// The string branch is PRESERVED!

type ResultFixed = ExtendObjectBranch<StringFilter<false>, { fuzzy?: string }>;
// Actual type: string | { equals?: string; contains?: string; fuzzy?: string }
// Still accepts plain string!

// Value-level verification:
const test1: ResultBroken = "hello";  // ❌ ERROR
const test1b: ResultBroken = { equals: "world" };  // ✓ OK

const test2: ResultFixed = "hello";  // ✓ OK (fixed!)
const test2b: ResultFixed = { equals: "world" };  // ✓ OK
const test2c: ResultFixed = { equals: "world", fuzzy: { pattern: "test" } };  // ✓ OK
EOF

cat /tmp/final-verification.ts

Repository: zenstackhq/zenstack

Length of output: 1958


🏁 Script executed:

# Check if there are actual uses of string filters in a real test or example
rg "title|description|name" packages/orm/test -A 3 | grep -A 3 "where\|equals\|contains" | head -20

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

# Look for actual schema examples to see if string filters are used
find packages -name "*.zmodel" -o -name "*schema*" | head -10

Repository: zenstackhq/zenstack

Length of output: 577


Preserve the scalar shorthand when adding fuzzy/fts operators.

The current helpers intersect the extra operator bag directly into the entire StringFilter union, which causes TypeScript to distribute: (string | object) & Extra becomes (string & Extra) | (object & Extra). Since primitives cannot have extra properties, string & Extra resolves to never, leaving only the object branch. On fields annotated with @fuzzy or @fullText, this breaks the plain-string shorthand syntax where: { title: 'foo' } at the type level, even though runtime validation still accepts it.

Apply the augmentation only to object branches using a conditional that distributes across union members:

Suggested fix
+type ExtendObjectBranch<Base, Extra> = Base extends object ? Base & Extra : Base;
+
 type AddFuzzyFilterIfSupported<
     Schema extends SchemaDef,
     Model extends GetModels<Schema>,
     Field extends GetModelFields<Schema, Model>,
     AllowedKinds extends FilterKind,
     Base,
 > = 'Fuzzy' extends AllowedKinds
     ? ProviderSupportsFuzzy<Schema> extends true
         ? GetModelField<Schema, Model, Field>['fuzzy'] extends true
-            ? Base & {
+            ? ExtendObjectBranch<
+                  Base,
+                  {
                       fuzzy?: FuzzyFilterPayload;
-              }
+                  }
+              >
             : Base
         : Base
     : Base;
 
 type AddFullTextFilterIfSupported<
     Schema extends SchemaDef,
     Model extends GetModels<Schema>,
     Field extends GetModelFields<Schema, Model>,
     AllowedKinds extends FilterKind,
     Base,
 > = 'FullText' extends AllowedKinds
     ? ProviderSupportsFullText<Schema> extends true
         ? GetModelField<Schema, Model, Field>['fullText'] extends true
-            ? Base & {
+            ? ExtendObjectBranch<
+                  Base,
+                  {
                       fts?: FullTextFilterPayload;
-              }
+                  }
+              >
             : Base
         : Base
     : Base;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type AddFuzzyFilterIfSupported<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
AllowedKinds extends FilterKind,
Base,
> =
GetModelFieldType<Schema, Model, Field> extends 'String'
? 'Fuzzy' extends AllowedKinds
? ProviderSupportsFuzzy<Schema> extends true
? GetModelField<Schema, Model, Field>['fuzzy'] extends true
? Base & {
/**
* Performs a fuzzy search on the string field. Only available when
* the schema's provider is `postgresql` (requires `pg_trgm` extension)
* and the field is annotated with `@fuzzy` in the ZModel schema.
* See {@link FuzzyFilterPayload} for the full options reference.
*/
fuzzy?: FuzzyFilterPayload;
}
: Base
: Base
> = 'Fuzzy' extends AllowedKinds
? ProviderSupportsFuzzy<Schema> extends true
? GetModelField<Schema, Model, Field>['fuzzy'] extends true
? Base & {
/**
* Performs a fuzzy search on the string field. Only available when
* the schema's provider is `postgresql` (requires `pg_trgm` extension)
* and the field is annotated with `@fuzzy` in the ZModel schema.
* See {@link FuzzyFilterPayload} for the full options reference.
*/
fuzzy?: FuzzyFilterPayload;
}
: Base
: Base
: Base;
/**
* Conditionally augments a string-field filter with the `fts` operator when:
* 1. The `FullText` filter kind is allowed for this field, AND
* 2. The schema's provider supports full-text search (postgres only), AND
* 3. The field is annotated with `@fullText` in the ZModel schema.
*
* Caller is responsible for only invoking this on String-typed fields
* (the gate lives in `FieldFilter`).
*/
type AddFullTextFilterIfSupported<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
AllowedKinds extends FilterKind,
Base,
> = 'FullText' extends AllowedKinds
? ProviderSupportsFullText<Schema> extends true
? GetModelField<Schema, Model, Field>['fullText'] extends true
? Base & {
/**
* Performs a full-text search on the string field. Only available when
* the schema's provider is `postgresql` and the field is annotated with
* `@fullText` in the ZModel schema.
* See {@link FullTextFilterPayload} for the full options reference.
*/
fts?: FullTextFilterPayload;
}
: Base
: Base;
: Base
: Base;
type ExtendObjectBranch<Base, Extra> = Base extends object ? Base & Extra : Base;
type AddFuzzyFilterIfSupported<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
AllowedKinds extends FilterKind,
Base,
> = 'Fuzzy' extends AllowedKinds
? ProviderSupportsFuzzy<Schema> extends true
? GetModelField<Schema, Model, Field>['fuzzy'] extends true
? ExtendObjectBranch<
Base,
{
/**
* Performs a fuzzy search on the string field. Only available when
* the schema's provider is `postgresql` (requires `pg_trgm` extension)
* and the field is annotated with `@fuzzy` in the ZModel schema.
* See {`@link` FuzzyFilterPayload} for the full options reference.
*/
fuzzy?: FuzzyFilterPayload;
}
>
: Base
: Base
: Base;
/**
* Conditionally augments a string-field filter with the `fts` operator when:
* 1. The `FullText` filter kind is allowed for this field, AND
* 2. The schema's provider supports full-text search (postgres only), AND
* 3. The field is annotated with `@fullText` in the ZModel schema.
*
* Caller is responsible for only invoking this on String-typed fields
* (the gate lives in `FieldFilter`).
*/
type AddFullTextFilterIfSupported<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
AllowedKinds extends FilterKind,
Base,
> = 'FullText' extends AllowedKinds
? ProviderSupportsFullText<Schema> extends true
? GetModelField<Schema, Model, Field>['fullText'] extends true
? ExtendObjectBranch<
Base,
{
/**
* Performs a full-text search on the string field. Only available when
* the schema's provider is `postgresql` and the field is annotated with
* `@fullText` in the ZModel schema.
* See {`@link` FullTextFilterPayload} for the full options reference.
*/
fts?: FullTextFilterPayload;
}
>
: Base
: Base
: Base;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orm/src/client/crud-types.ts` around lines 416 - 467, The current
AddFuzzyFilterIfSupported and AddFullTextFilterIfSupported intersect the extra
operator bag directly into Base causing union distribution that drops primitive
shorthand (e.g., string); change the augmentation to only apply to object
branches by replacing "Base & { ... } : Base" with a distributive conditional
that checks whether Base extends object (or Record<string, unknown>) and only
then adds the fuzzy/fts operator (i.e., Base extends object ? Base & { fuzzy?:
FuzzyFilterPayload } : Base for AddFuzzyFilterIfSupported and similarly Base
extends object ? Base & { fts?: FullTextFilterPayload } : Base for
AddFullTextFilterIfSupported), keeping the existing guards that check
ProviderSupportsFuzzy/ProviderSupportsFullText and
GetModelField[...]['fuzzy'|'fullText'].


type EnumFilter<
Schema extends SchemaDef,
Expand Down Expand Up @@ -994,9 +1035,6 @@ export type FuzzyRelevanceOrderBy<Schema extends SchemaDef, Model extends GetMod
* Sorts by fuzzy search relevance using PostgreSQL `pg_trgm` similarity functions.
* Not supported on MySQL or SQLite (throws `NotSupported` at runtime).
* Cannot be combined with cursor-based pagination.
*
* The `_fuzzyRelevance` name is intentionally distinct from `_searchRelevance`
* (reserved for future full-text-search relevance) so the two can coexist.
*/
_fuzzyRelevance?: {
/**
Expand Down Expand Up @@ -1025,6 +1063,66 @@ export type FuzzyRelevanceOrderBy<Schema extends SchemaDef, Model extends GetMod
};
};

/**
* String fields that have been annotated with `@fullText` and are therefore eligible
* for `_ftsRelevance` ordering.
*/
type FullTextFields<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
[Key in StringFields<Schema, Model>]: GetModelField<Schema, Model, Key>['fullText'] extends true ? Key : never;
}[StringFields<Schema, Model>];

/**
* Payload for the `fts` string filter operator. Performs full-text search using
* PostgreSQL `to_tsvector` / `to_tsquery` (postgresql provider only).
*
* Query syntax follows `to_tsquery`: callers write raw `&` (AND), `|` (OR),
* `!` (NOT), `<->` (FOLLOWED BY). Malformed queries throw at SQL execution time.
*/
export type FullTextFilterPayload = {
/**
* Search query in `to_tsquery` syntax (must be a non-empty string).
*/
search: string;
/**
* Postgres text-search configuration (e.g. `'english'`, `'simple'`). When
* omitted, the database's `default_text_search_config` setting is used —
* the SQL is emitted as `to_tsvector(field) @@ to_tsquery(query)` without
* an explicit regconfig argument.
*/
config?: string;
};

export type FtsRelevanceOrderBy<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
/**
* Sorts by full-text-search relevance using PostgreSQL `ts_rank`.
*/
_ftsRelevance?: {
/**
* String fields annotated with `@fullText` to compute relevance against (must be non-empty).
*
* When multiple fields are provided, the fields are concatenated with a
* space separator and a single `ts_rank` is computed over the combined
* document — i.e. `ts_rank(to_tsvector(concat_ws(' ', f1, f2, ...)), q)`.
* This means an AND query (e.g. `'cat & dog'`) matches rows where the
* terms appear across different fields, not just within the same field.
*/
fields: [FullTextFields<Schema, Model>, ...FullTextFields<Schema, Model>[]];
/**
* The search term to compute relevance for (in `to_tsquery` syntax).
*/
search: string;
/**
* Postgres text-search configuration. When omitted, the database's
* `default_text_search_config` setting is used.
*/
config?: string;
/**
* Sort direction.
*/
sort: SortOrder;
};
};

export type OrderBy<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Expand Down Expand Up @@ -1377,7 +1475,8 @@ type SortAndTakeArgs<
*/
orderBy?: OrArray<
OrderBy<Schema, Model, true, false> &
(ProviderSupportsFuzzy<Schema> extends true ? FuzzyRelevanceOrderBy<Schema, Model> : {})
(ProviderSupportsFuzzy<Schema> extends true ? FuzzyRelevanceOrderBy<Schema, Model> : {}) &
(ProviderSupportsFullText<Schema> extends true ? FtsRelevanceOrderBy<Schema, Model> : {})
>;

/**
Expand Down Expand Up @@ -2757,6 +2856,10 @@ type ProviderSupportsDistinct<Schema extends SchemaDef> = Schema['provider']['ty

type ProviderSupportsFuzzy<Schema extends SchemaDef> = Schema['provider']['type'] extends 'postgresql' ? true : false;

type ProviderSupportsFullText<Schema extends SchemaDef> = Schema['provider']['type'] extends 'postgresql'
? true
: false;

/**
* Extracts extended query args for a specific operation.
*/
Expand Down
Loading
Loading