search-cop
v0.6.0
Published
A powerful search DSL that compiles to TypeORM SelectQueryBuilder queries
Readme
search-cop
A powerful search DSL that compiles to a TypeORM SelectQueryBuilder.
import { search } from 'search-cop';
const qb = search({
repository: ProductRepository,
query: 'status:online AND price:>100',
attributes: {
status: { type: 'enum', values: ['online', 'offline'] },
price: { type: 'number' },
createdAt: { type: 'datetime' },
},
});
const products = await qb.getMany();search() returns a plain TypeORM SelectQueryBuilder — nothing is wrapped.
repository, query, and attributes are required. alias is optional and defaults to
the entity's table name — pass it explicitly if you need the generated query to use a
specific alias (e.g. to match an alias already used elsewhere in a larger query). It only
affects the FROM/SELECT clauses TypeORM generates on its own; it has no effect on the
WHERE clause, since fields are always inserted verbatim.
Combining with your own queryBuilder
search() is a convenience for the common case: give it a repository and get back a
ready-to-go SelectQueryBuilder. If you've already built your own queryBuilder — with
joins, a specific alias, or other where conditions already in place — use searchCondition()
instead. It compiles a query to a standalone Brackets fragment rather
than a full query, which you merge in yourself via andWhere()/orWhere():
import { searchCondition } from 'search-cop';
const queryBuilder = ProductRepository.createQueryBuilder('product').leftJoinAndSelect('product.author', 'author');
queryBuilder.andWhere(searchCondition({
query: 'author.name:joe',
attributes: {
// A join means there's now more than one table, so this field is qualified —
// exactly as with any other field, search-cop inserts it verbatim (see below).
author: { type: 'string', fields: ['author.name'] },
},
}));
const products = await queryBuilder.getMany();Brackets (and its negated counterpart NotBrackets, used internally for NOT) is
TypeORM's own portable where-clause primitive — its callback isn't evaluated until it's
handed to andWhere()/orWhere()/where() on a real queryBuilder, so searchCondition()
needs no repository or queryBuilder of its own, and doesn't touch or know about the rest
of your query. Using andWhere() (rather than where()) to merge it in means any
conditions you already added stay in place — search-cop only ever adds to your WHERE
clause, never replaces it.
Attributes
Only attributes declared in attributes may be queried. Supported types:
| Type | Runtime value | Allowed operators |
| ---------- | -------------- | --------------------------------- |
| string | string | = > >= < <= |
| number | number | = > >= < <= |
| boolean | boolean | = |
| date | Date | = > >= < <= |
| datetime | Date | = > >= < <= |
| enum | string | = |
| uuid | string | = |
| null | none — compiles to IS NULL/IS NOT NULL | = |
| fulltext | string | : only |
| tag | string — redirected into a fulltext attribute | : only |
enum attributes also require a values: string[] list, or a values: Record<string, string>
map to translate the query-facing value into a different underlying value, e.g.
{ type: 'enum', values: { pending: 'waiting', completed: 'finished' } }.
null attributes require isNull: string[] and isNotNull: string[] — see
Null checks.
string attributes accept an optional caseSensitive: boolean | 'lower' | 'upper' (default
true) — see Case sensitivity — and optional wildcards/
leftWildcard/rightWildcard: boolean (all default false) — see
Implicit wildcards.
uuid values are validated against RFC 9562 (version 1-8 and variant nibbles, plus the nil
and max UUIDs) using the uuid package, and are
lowercased on the way out.
Any attribute type accepts an optional fields to match multiple underlying columns
instead of the attribute's own key — see
Multi-field attributes.
A query term with no field: prefix is matched against the conventional _all attribute
key — see Default field.
Query syntax
A predicate is field<op>value, or just a bare value to search the default field (see
Default field). : means equality:
status:online // equivalent to status:=online
price:>100
price:>=100
price:<100
price:<=100
createdAt:>=2026-01-01For most attribute types, status:online and status:=online compile identically. For a
string attribute they don't: the bare : form always compiles to a LIKE predicate (so
it can support wildcard syntax — see Wildcards) and enables the wildcards
option (see Implicit wildcards); an explicit = always compiles to
a plain = comparison and opts out of both.
Combine predicates with AND / OR (case-sensitive — lowercase and/or are rejected)
and parentheses. AND binds tighter than OR:
status:online AND price:>100
status:online OR status:pending
status:a OR status:b AND status:c // == status:a OR (status:b AND status:c)
(status:online OR status:pending) AND price:>100AND is the default combinator and may be omitted — juxtaposing predicates with whitespace
implies AND:
status:online price:>100 // == status:online AND price:>100Prefix a predicate or parenthesized group with NOT to negate it. NOT binds tighter than
AND/OR, so it only negates the next term or group — parenthesize to negate more than one:
NOT status:online // negates just this predicate
NOT status:online price:>100 // == (NOT status:online) AND price:>100
NOT (status:online OR status:pending) // negates the whole group
NOT NOT status:online // double negation cancels outNOT must be immediately followed by whitespace or ( — NOTstatus:online is a field
literally named NOTstatus, not a negation. NOT is reserved the same way AND/OR
are (see below) — double-quote it ("NOT") to search for the literal word.
- is shorthand for NOT, including on a bare term against _all (see
Default field):
-status:online // == NOT status:online
-cheap // negates a bare term
red -cheap // == red AND (NOT cheap)
-(status:online OR status:pending) // negates the whole groupUnlike NOT, - only counts as negation when directly attached to what follows — no
space. - cheap (a space after -) parses - itself as the literal value -, followed
by a separate cheap term; a lone - is likewise just the literal value -. This also
means - is never mistaken for negation inside a value — a negative number
(price:-5) or a hyphenated word (well-known) are unaffected, since this rule only
ever applies at the very start of a term. Searching for a literal leading - needs
quoting (-"AND" negates the literal word AND; "-test" searches for the literal
string -test).
Quoted values
Unquoted values end at the first (unescaped) whitespace or parenthesis, so a value containing either must be double-quoted:
name:"foo bar"
name:"(foo)"\ escapes the following character the same way in both quoted and unquoted values: \"
unescapes to a literal " (only meaningful inside quotes, to embed one without ending the
value early), \\ to a literal \, and \* to a literal * (see Wildcards
— the one case that matters outside quotes too). Any other \<char> just drops the
backslash, so a literal backslash needs doubling:
name:"foo \"bar\" baz" // foo "bar" baz
name:back\\slash // back\slash
name:back\slash // backslash (a single "\" is just dropped)Escaping in an unquoted value can't swallow a terminator — \ followed by a space or
parenthesis still ends the value there; quote the value instead if you need one included.
Wildcards
Wildcard syntax exists only for the bare : shorthand on a string attribute — every
explicit operator (=, >, >=, <, <=) always treats * as a plain literal
character, never a wildcard (see Query syntax). Because of that, : on a
string attribute always compiles to a LIKE predicate — even when the value has no *
at all — while every explicit operator always compiles to a plain comparison. Wildcards
don't apply to enum/uuid/other attribute types.
A * translates to SQL's %; any literal % or _ in the value is escaped so it's
matched literally rather than as a LIKE wildcard. A bare * is only valid at the very
start and/or end of the value — one anywhere else throws INVALID_WILDCARD rather than
silently falling back to a literal match:
name:Pet* // starts with "Pet"
name:*fred // ends with "fred"
name:*pet* // contains "pet"Escape a literal * with \* (see Quoted values):
name:Pet\* // literal value "Pet*", not a wildcard
name:*Pet\*Other // starts with "Pet*Other" (real wildcard + literal "*")Because : always compiles to LIKE, case-sensitivity of every bare-colon string
match (wildcarded or not) depends on the database's LIKE collation by default (e.g.
Postgres' LIKE is case-sensitive; SQLite's is case-insensitive for ASCII regardless of
collation) — see Case sensitivity for a portable, explicit alternative.
Implicit wildcards
Set wildcards: true on a string attribute to make the bare : shorthand a contains
match by default, without writing * yourself:
attributes: {
name: { type: 'string', wildcards: true },
}name:pet // contains "pet" — same as name:*pet*
name:pet* // explicit "*" is left exactly as written
name:=pet // explicit "=" always stays an exact matchwildcards: true is shorthand for setting both leftWildcard and rightWildcard, which
you can also set independently for a one-sided match:
attributes: {
endsWithName: { type: 'string', leftWildcard: true }, // endsWithName:pet -> *pet (ends with)
startsWithName: { type: 'string', rightWildcard: true }, // startsWithName:pet -> pet* (starts with)
}Precedence, in order: an explicit * anywhere in the value is always respected as-is (no
double-wrapping); otherwise an explicit = is always an exact match; only a bare : with
neither falls back to the implicit wrap. This also applies to a bare term against _all
(see Default field), since bare terms are : too.
Case sensitivity
By default, string attributes are matched case-sensitively (subject to the database's own
collation rules, as noted above for wildcards). Set caseSensitive: false on the attribute
definition to make =, < <= > >=, and wildcard matches case-insensitive:
attributes: {
name: { type: 'string', caseSensitive: false },
}This compiles to LOWER(column) <op> LOWER(value), using the standard SQL LOWER()
function so behavior is identical across Postgres, MySQL, and SQLite — rather than a
database-specific mechanism (e.g. Postgres' ILIKE or citext, or a COLLATE clause).
Set caseSensitive: 'upper' instead to fold through UPPER() rather than LOWER() — for
example, to match an existing functional index built on UPPER(column):
attributes: {
name: { type: 'string', caseSensitive: 'upper' },
}name:=Fred // UPPER(name) = 'FRED'caseSensitive: 'lower' is also accepted, and behaves exactly like false — it's just the
explicit spelling, useful if you'd rather not read false as "off".
Multi-field attributes
An attribute can match against multiple underlying columns instead of a single one — for
example, searching a "name" query across both firstName and lastName columns:
attributes: {
name: { type: 'string', fields: ['firstName', 'lastName'] },
}name:Fred // (firstName LIKE 'Fred' ...) OR (lastName LIKE 'Fred' ...)
name:Fred* // (firstName LIKE 'Fred%' ...) OR (lastName LIKE 'Fred%' ...)
name:=Fred // firstName = 'Fred' OR lastName = 'Fred'Only = is supported (including its wildcard form) — ordering operators (> >= <
<=) are rejected, since combining multiple columns with OR under an ordering comparison
doesn't have an unambiguous meaning.
Fields are raw SQL
Every fields entry — whether a plain string, or the implicit default when fields is
omitted (the attribute's own key) — is inserted into the generated SQL verbatim: no
escaping, and no alias-qualification. search-cop doesn't know whether your column name is
a safe unquoted identifier, doesn't know your database's quote character, and doesn't
create or know about any joins — you do, so quoting (e.g. '"createdAt"' to preserve case
on Postgres) and qualification (e.g. 'author.name' if you've added your own leftJoin)
are entirely your responsibility whenever you need them. A plain 'firstName' is exactly
as raw as any other entry; it's just already a valid bare identifier, so it needs nothing
extra. Querying a joined column works exactly like any other column — see the
searchCondition() example above — search-cop just never adds the join itself.
This also means a field entry doesn't have to be a column name at all — anything valid in
SQL works: a cast, a computed expression, string concatenation, and so on. A string-typed
multi-field attribute always binds the search value as a plain string parameter for every
field. If one of the underlying columns is actually a stricter type — a uuid or integer
primary key, say — comparing it directly against an arbitrary string can make the
database reject the query outright (e.g. Postgres: invalid input syntax for type uuid:
"foo") instead of the field simply not matching. Cast that field to text instead:
attributes: {
name: {
type: 'string',
fields: ['firstName', 'lastName', 'CAST(id AS TEXT)'],
},
}name:Fred // (firstName LIKE 'Fred' ...) OR (lastName LIKE 'Fred' ...) OR (CAST(id AS TEXT) LIKE 'Fred' ...)CAST itself is standard SQL and works identically everywhere, but the type-name argument
is dialect-specific: TEXT works on Postgres and SQLite, but MySQL's CAST doesn't accept
TEXT (use CHAR there instead); conversely a bare CHAR silently truncates to a single
character on Postgres. search-cop never inspects or validates a fields entry — it's
entirely your responsibility: you know your database; search-cop deliberately doesn't try
to.
Field-level type overrides
If you'd rather not write SQL at all, give a field its own type (and, for enum, its
own values) instead of casting. search-cop then validates that field independently, using
its own type's normal conversion — reusing the exact same logic as a regular attribute —
rather than the outer attribute's string type:
attributes: {
name: {
type: 'string',
fields: ['firstName', 'lastName', { field: 'id', type: 'uuid' }],
},
}name:Fred // (firstName LIKE 'Fred' ...) OR (lastName LIKE 'Fred' ...)
// OR (id is skipped: "Fred" isn't a valid uuid)
name:550e8400-e29b-41d4-a716-446655440000 // ...OR id = '550e8400-e29b-41d4-a716-446655440000'Unlike casting, a field-level type override needs no dialect knowledge and no CAST at
all — a value that doesn't fit the override's type just makes that one field not match
(see Unparseable values never error), while a value
that does fit gets bound normally, letting the database do its own natural type
coercion. The tradeoff: it only ever produces an exact = match, so (unlike casting to
text) it can't support wildcards against that field — a wildcarded query always fails
validation for anything other than string (there's always a literal * in the raw
value, and no uuid/number/date/etc. can ever contain one).
Trigram indexes (Postgres)
A bare-: string match compiles to LIKE '%...%' when wildcards
are used on both sides — a pattern a plain B-tree index can't accelerate at all, since the
leading % rules out any prefix-based lookup. Postgres' pg_trgm extension
provides a GIN/GiST index type built for exactly this case:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_name_trgm ON products USING gin (name gin_trgm_ops);attributes: {
name: { type: 'string', wildcards: true },
}name:pet // name LIKE '%pet%' — now served by idx_name_trgm instead of a seq scanThis is entirely a database-side concern — search-cop doesn't create or know about indexes, it just needs the compiled SQL expression to match whatever expression the index was built on, exactly. Two ways that expression can drift out from under an index without erroring (the query still runs, just without using the index):
A multi-field attribute compiles to separate
OR-edLIKEconditions per field (name LIKE ... OR description LIKE ...), which doesn't match an index built on a concatenated expression. To use an index on(name || ' ' || description), give that same concatenation as a single rawfieldsentry instead of two separate ones, so the compiled condition is oneLIKEagainst that exact expression:attributes: { search: { type: 'string', wildcards: true, fields: ["name || ' ' || description"] }, }CREATE INDEX idx_search_trgm ON products USING gin ((name || ' ' || description) gin_trgm_ops);caseSensitive: false/'upper'(see Case sensitivity) wraps the column inLOWER()/UPPER(), changing the compiled expression — build the index on the same wrapped expression (e.g.USING gin ((LOWER(name)) gin_trgm_ops)) to keep it matching.
Default field
A query term with no field: prefix compiles to a predicate against the conventional
_all attribute key (exported as DEFAULT_FIELD). Define it like any other attribute —
typically as a multi-field attribute — to opt in:
import { search, DEFAULT_FIELD } from 'search-cop';
const qb = search({
repository: ProductRepository,
query: 'red shoes status:online',
attributes: {
status: { type: 'enum', values: ['online', 'offline'] },
// Fold a uuid primary key into the default field too — casting it to text (see
// above) keeps a non-uuid-shaped term from erroring against that column.
[DEFAULT_FIELD]: { type: 'string', fields: ['name', 'description', 'CAST(id AS TEXT)'] },
},
});Bare terms can be freely combined with explicit field:value predicates and with each
other. Since AND is already implicit between predicates, multiple bare terms behave like
free-text search — each term ORs across the configured fields, and terms AND together:
red shoes // (name LIKE 'red' ... OR description LIKE 'red' ...)
// AND (name LIKE 'shoes' ... OR description LIKE 'shoes' ...)
red* shoes* // same, but with wildcards on each term
red status:online // (name LIKE 'red' ... OR description LIKE 'red' ...) AND status = 'online'Unlike any other undeclared field, a bare query never errors when _all isn't declared in
attributes — it's opt-in, so it just never matches (see
Unparseable values never error) rather than being
treated as a typo. AND/OR/NOT are always reserved as keywords, even as a bare term on
their own — double-quote them ("AND", "OR", "NOT") to search for the literal word.
UUIDs
id:550e8400-e29b-41d4-a716-446655440000
id:550E8400-E29B-41D4-A716-446655440000 // case-insensitive, lowercased on outputBooleans
active:true
active:falseNull checks
A null attribute compiles to an IS NULL/IS NOT NULL check on the underlying column
instead of a value comparison — no parameter is ever bound. isNull/isNotNull are each a
list of DSL values that trigger that check, letting you accept multiple synonyms for the
same check:
attributes: {
assigned: { type: 'null', isNull: ['false', 'no'], isNotNull: ['true', 'yes'], fields: ['assignedTo'] },
}assigned:no // assignedTo IS NULL
assigned:yes // assignedTo IS NOT NULLA value that's in neither list never errors — like any other attribute type, it just never
matches (see Unparseable values never error). Only = is
supported, same as enum/boolean/uuid.
Dates and datetimes
Date-only values (YYYY-MM-DD) are interpreted as UTC midnight. Datetime values accept
YYYY-MM-DDTHH:mm:ss[.sss](Z|±HH:mm); when no offset is given, the value is interpreted as UTC,
not the host machine's local timezone.
releaseDate:>=2026-01-01 // 2026-01-01T00:00:00.000Z
createdAt:>=2026-01-01T10:00:00 // 2026-01-01T10:00:00.000Z (UTC)
createdAt:>=2026-01-01T10:00:00+02:00 // 2026-01-01T08:00:00.000ZFull-text search (Postgres)
A fulltext attribute matches against a tsvector. fields must already evaluate to one —
either a precomputed/indexed column, or a SQL expression that builds one — search-cop never
wraps or casts it for you, same as any other raw fields entry. A
required dialect controls how a query term becomes the tsquery value compared against it:
'to_tsquery' hands the term to Postgres's own text-search parser and dictionaries;
'tsquery' bypasses them entirely for literal, tokenizer-controlled matching. There's no
default — dialect must always be set explicitly, since the two behave differently enough
that picking one silently on a caller's behalf would be surprising. Only : is supported for
either dialect (no =, no ordering operators).
dialect: 'to_tsquery'
Compiles to @@ to_tsquery(...):
attributes: {
_all: {
type: 'fulltext',
dialect: 'to_tsquery',
fields: ["to_tsvector('simple', title || ' ' || body)"],
},
}pizza // to_tsvector(...) @@ to_tsquery('simple', '''pizza''')
pizza pasta // ...@@ to_tsquery('simple', '''pizza'' & ''pasta''') (AND)
pizza OR pasta // ...@@ to_tsquery('simple', '''pizza'' | ''pasta''') (OR)
pizza -pasta // ...@@ to_tsquery('simple', '''pizza'' & !''pasta''') (NOT)
pizza* // ...@@ to_tsquery('simple', '''pizza'':*') (prefix wildcard)Every term is bound as a plain parameter and quoted as its own tsquery lexeme (any embedded
' is doubled, per tsquery's own quoting rule) before being spliced into the query text — a
term is never concatenated into that text raw. That matters for correctness, not just safety:
to_tsquery requires its own operators (&/|/!) between lexemes, so a value containing one
of those characters would otherwise either silently change the query's meaning or throw a syntax
error outright, depending on where it landed.
A trailing * on a bare term makes it a prefix match (to_tsquery's :*, applied after the
closing quote) — the only wildcard shape tsquery supports; there's no suffix/contains
equivalent. A * anywhere other than the end of a value throws INVALID_WILDCARD, same as for
a string attribute.
An optional language (default 'simple' — no stemming, no stopwords) sets the regconfig
passed to to_tsquery — it should match whatever produced the tsvector in fields. Set it to
'english' (or another language config) for stemming/stopword handling.
A multi-word quoted value (e.g. "red shoes") is left as one quoted lexeme and joined into a
phrase (<->) by to_tsquery's own parser — set phrases: false to join it with & (plain
AND, ignoring word order/adjacency) instead.
dialect: 'tsquery' — literal, tokenizer-controlled matching
to_tsquery always re-runs Postgres's text-search parser and dictionary over every lexeme it's
given — even a quoted one — so a term like status=online or foo&bar always gets split into
two lexemes no matter how it's escaped. dialect: 'tsquery' avoids the parser entirely by
casting straight to tsquery ((:query)::tsquery instead of to_tsquery(:language, :query))
— a raw cast takes each quoted lexeme completely literally, so whatever tokenized the tsvector
in fields is the only thing that decides what a token is:
attributes: {
tags: {
type: 'fulltext',
dialect: 'tsquery',
fields: ["array_to_tsvector(array_remove(regexp_split_to_array(tags, '\\s+'), ''))"],
},
}tags:"status=online" // array_to_tsvector(...) @@ ('''status=online''')::tsqueryarray_to_tsvector(regexp_split_to_array(...)) stores each array element as a literal lexeme
with no parsing or normalization at all, so status=online survives as one token instead of
being split on =. Since there's no config/dictionary involved, there's no language option
for this dialect — it's simply ignored if set.
An optional tokenize: (value: string) => string[] controls how a query term is split into
lexemes before being matched — it should mirror whatever produced the tokens in fields.
It defaults to splitting on whitespace, matching the regexp_split_to_array(..., '\s+') recipe
above. Because it's a function rather than a regex, it can also normalize case in the same
place, mirroring a transform applied on the SQL side (e.g. lower(...) before the split):
attributes: {
tags: {
type: 'fulltext',
dialect: 'tsquery',
fields: ["array_to_tsvector(array_remove(regexp_split_to_array(lower(tags), '\\s+'), ''))"],
tokenize: (value) => value.toLowerCase().split(/\s+/).filter(Boolean),
},
}array_to_tsvector does not store lexeme positions, so a multi-token term always joins with
& (plain AND) by default — phrases: true, which joins with <-> instead, silently never
matches anything against a position-less vector like the one above (not an error — see
Unparseable values never error for the general precedent).
Positions — and so real phrase search — need a different fields recipe, building the
tsvector's own literal syntax (which accepts explicit positions) from a tokenized array:
CREATE FUNCTION literal_tsvector(input text, sep text DEFAULT '\s+') RETURNS tsvector AS $$
SELECT COALESCE(
string_agg(quote_literal(tok) || ':' || ord::text, ' ' ORDER BY ord),
''
)::tsvector
FROM unnest(array_remove(regexp_split_to_array(input, sep), '')) WITH ORDINALITY AS t(tok, ord)
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;attributes: {
tags: {
type: 'fulltext',
dialect: 'tsquery',
phrases: true,
fields: ["literal_tsvector(tags)"],
},
}This still needs no extension — IMMUTABLE SQL functions like this one are indexable
(CREATE INDEX ... USING gin (literal_tsvector(tags)), same functional-index pattern as
Trigram indexes) — but it costs roughly 2x a plain
array_to_tsvector/to_tsvector call per row, since a function with a FROM/aggregate in its
body can't be inlined into the surrounding query the way a single-expression function can.
Postgres also flatly rejects a bare subquery as an index expression, which is why this needs a
wrapper function rather than an inline expression.
type: 'tag' — key:value syntax against a literal fulltext attribute
Typing status:online directly against a tsquery-dialect attribute doesn't do what it looks
like it should — search-cop's own grammar parses field<op>value first, so status:online as
a top-level query term means "attribute status, operator :, value online", not a literal
string to match. Getting the literal string status:online matched requires writing it
explicitly as a quoted value against the actual attribute, e.g. tags:"status:online".
A tag attribute makes the natural syntax work by rewriting the predicate before it's resolved:
status:online compiles exactly as if the query had been tags:"status:online" against the
attribute it points to (normally a dialect: 'tsquery' fulltext attribute, so the literal
: survives):
attributes: {
status: { type: 'tag', attribute: 'tags' },
priority: { type: 'tag', attribute: 'tags' },
tags: {
type: 'fulltext',
dialect: 'tsquery',
fields: ["array_to_tsvector(array_remove(regexp_split_to_array(tags, '\\s+'), ''))"],
},
}status:online // tags @@ ('''status:online''')::tsquery
status:online priority:high // tags @@ ('''status:online'' & ''priority:high''')::tsquery (fused)Only : is supported (no =, no ordering operators), same as fulltext. Wildcards, negation,
and escaping all work exactly as they do for any other bare-colon term — a trailing * (e.g.
status:on*) makes the whole reconstructed token a prefix match, not just the part after the
colon. Since the rewritten predicate is indistinguishable from an ordinary predicate against the
target attribute, it goes through the exact same resolution and fusion as any other
fulltext term — several tag attributes pointing at the same target (like status and
priority above) fuse into a single @@ call when combined in a query, exactly like two
predicates against the same named fulltext attribute already do.
search-cop doesn't validate that attribute points at a fulltext attribute (or that its
dialect is 'tsquery') — same "config is your responsibility" contract as fields being raw
SQL. Pointing it at a nonexistent attribute throws UNKNOWN_ATTRIBUTE
(the rewritten predicate is validated exactly like any other), but pointing it at, say, a
dialect: 'to_tsquery' attribute won't error — it'll just silently re-split the literal :
via Postgres's own parser, the same as using that dialect directly.
Fusion
Several bare terms against the same fulltext attribute, combined at the same AND/OR level
(including a single negated term), are fused into one @@ call instead of one call per
term — each term is rendered independently and then joined with &/|/!, never by
concatenating raw values into a shared string. This keeps a free-text query from re-evaluating
the tsvector expression (and re-scanning its index) once per word.
Fusion also folds an already-fused nested AND/OR group back up into an enclosing group when
both target the same attribute shape (field, language, dialect) — implicit AND binds tighter
than OR, so a b OR c parses as (a AND b) OR c, but still compiles to a single @@ call
against ('a' & 'b') | 'c' rather than two separate ones. A NOT wrapping a whole group, or a
nested group whose attribute shape doesn't match the enclosing level (a different field,
language, or dialect, or mixed in with a non-fulltext attribute), still compiles to its own
separate @@ call instead (still correct, just not fused). Fusion itself has no Postgres-specific
knowledge — the
rendering it calls into (per-term quoting, &/|/! glue, :* for wildcards, and the outer
@@ shape) is looked up by dialect. MySQL (MATCH ... AGAINST) and SQLite (FTS5) full-text —
which would need a different combined-query syntax and outer SQL shape for the same fused terms
— are not implemented yet, but would slot into that same per-dialect lookup rather than
changing how fusion decides what to combine. See Out of scope.
Unparseable values never error
A value that doesn't fit its attribute's type — id:foo where id is a uuid,
status:banana where status is an enum, price:>banana where price is a number,
and so on — never throws. It simply can't match anything, so it compiles to an
unconditional 1 = 0 instead of a real comparison. This applies to every attribute type,
not just multi-field ones or field-level type overrides —
it's the same reason those two features can gracefully skip a field instead of erroring
the whole query.
This is deliberate: search-cop treats "this value can never match this type" as a normal,
expected outcome of a search — the same as any other query that happens to match zero
rows — rather than a client error. If you want to reject malformed input before it reaches
search() (e.g. to return a 400 to an API caller), validate it yourself first.
Errors
Invalid queries throw a SearchCopError with a code:
INVALID_SYNTAX— the query does not parse (includes an approximate characterposition)UNKNOWN_ATTRIBUTE— the field is not declared inattributes(except a bare query against an undeclared_all— see Default field)INVALID_OPERATOR— the operator is not supported for the attribute's type (e.g.status:>onlinefor anenum)INVALID_WILDCARD— a bare*appears somewhere other than the start/end of a bare-colonstringvalue (e.g.name:Pet*Other; see Wildcards)CIRCULAR_TAG_REFERENCE— atagattribute'sattributepoints at itself (seetype: 'tag')
Catch them with instanceof:
import { SearchCopError } from 'search-cop';
try {
search({ query, attributes, queryBuilder });
} catch (error) {
if (error instanceof SearchCopError) {
// error.code, error.message, error.position
}
throw error;
}instanceof is safe even when more than one copy of search-cop is loaded in the same
process — duplicate installs, or a bundler emitting its own copy alongside the one in
node_modules. The class is resolved through a version-keyed global singleton
(Symbol.for('search-cop@<version>:SearchCopError')), so every copy of the same version
hands out one and the same class, and an error thrown by one copy is instanceof the
SearchCopError imported from another. No type guard needed. Copies of different
versions are deliberately distinct classes, since their code sets may differ.
Note there's no error for a value that doesn't fit its type (an invalid uuid, an unknown enum value, ...) — see Unparseable values never error.
Out of scope (for now)
Automatically generating joins for associations — search-cop never creates a join itself;
build it on your own queryBuilder and qualify the field (see
Combining with your own queryBuilder and
Fields are raw SQL) — full-text search on MySQL (MATCH ... AGAINST)
or SQLite (FTS5) — only Postgres full-text is implemented so
far — a != operator
(negate with NOT instead), range syntax (1..100), query
optimization/planning beyond the full-text fusion already
described, pagination/sorting, raw SQL as the top-level query language (a
fields entry is raw SQL, but the query string itself is always
the DSL, never passed through), and additional database adapters are intentionally not
implemented. The AST is designed so these can be added later.
Development
pnpm install
pnpm test # build the grammar and run the test suite (sqlite)
pnpm run lint
pnpm run typecheck
pnpm run buildPostgres-specific tests (full-text search, and the general integration suite run against Postgres instead of sqlite) need a real database:
docker compose up -d
DATABASE=postgres pnpm test