The BigQuery adapter transforms filter definitions into parameterized SQL that works with the @google-cloud/bigquery Node.js client. It generates safe, type-checked queries with @paramName placeholders.
Installation
Install the packages
npm install @filter-def/bigquery @google-cloud/bigquery
# or
pnpm add @filter-def/bigquery @google-cloud/bigquery
@google-cloud/bigquery is a peer dependency and must be installed separately.
Basic Usage
Define your filters and use them with BigQuery queries:
import { bigqueryFilter } from "@filter-def/bigquery" ;
import type { BigQueryFilterInput } from "@filter-def/bigquery" ;
import { BigQuery } from "@google-cloud/bigquery" ;
interface User {
id : number ;
name : string ;
email : string ;
age : number ;
isActive : boolean ;
}
// Create a filter definition
const userFilter = bigqueryFilter < User >(). def ({
name: { kind: "eq" },
emailContains: { kind: "contains" , field: "email" },
minAge: { kind: "gte" , field: "age" },
isActive: { kind: "eq" },
});
// Generate SQL and params
const where = userFilter ({
name: "John" ,
emailContains: "@example.com" ,
minAge: 18 ,
isActive: true ,
});
// where = {
// sql: 'name = @name AND email LIKE @emailContains AND age >= @minAge AND isActive = @isActive',
// params: { name: 'John', emailContains: '%@example.com%', minAge: 18, isActive: true }
// }
// Use with BigQuery
const bigquery = new BigQuery ();
const [ rows ] = await bigquery . query ({
query: `SELECT * FROM \` myproject.dataset.users \` WHERE ${ where . sql } ` ,
params: where . params ,
});
Filter Output
The filter function always returns { sql: string, params: Record<string, unknown> }:
const where = userFilter ({ name: "John" });
// { sql: 'name = @name', params: { name: 'John' } }
const empty = userFilter ({});
// { sql: 'true', params: {} }
const noInput = userFilter ();
// { sql: 'true', params: {} }
Empty filters return { sql: "true", params: {} } which matches all rows. This simplifies query composition by eliminating conditional logic.
Filter Types
The BigQuery adapter supports all standard filter kinds:
Kind SQL Output Description Example eqcolumn = @paramExact equality { name: { kind: "eq" } }neqcolumn != @paramNot equal { status: { kind: "neq" } }containscolumn LIKE @paramString contains { email: { kind: "contains" } }inArraycolumn IN UNNEST(@param)Value in array { status: { kind: "inArray" } }gtcolumn > @paramGreater than { age: { kind: "gt" } }gtecolumn >= @paramGreater than or equal { price: { kind: "gte" } }ltcolumn < @paramLess than { age: { kind: "lt" } }ltecolumn <= @paramLess than or equal { price: { kind: "lte" } }isNullcolumn IS NULLCheck if null (no param) { deletedAt: { kind: "isNull" } }isNotNullcolumn IS NOT NULLCheck not null (no param) { email: { kind: "isNotNull" } }
Case-Insensitive String Matching
Use caseInsensitive: true to use LOWER() for case-insensitive matching:
const userFilter = bigqueryFilter < User >(). def ({
nameSearch: {
kind: "contains" ,
field: "name" ,
caseInsensitive: true , // Uses LOWER(column) LIKE LOWER(@param)
},
});
const where = userFilter ({ nameSearch: "JOHN" });
// { sql: 'LOWER(name) LIKE LOWER(@nameSearch)', params: { nameSearch: '%JOHN%' } }
Nested Fields (STRUCT)
Use dot-separated paths to filter on nested STRUCT fields in BigQuery:
interface UserWithAddress {
name : { first : string ; last : string };
address : { city : string ; geo : { lat : number ; lng : number } };
}
const userFilter = bigqueryFilter < UserWithAddress >(). def ({
firstName: { kind: "eq" , field: "name.first" },
city: { kind: "eq" , field: "address.city" },
lat: { kind: "eq" , field: "address.geo.lat" },
});
const where = userFilter ({ firstName: "Bob" , lat: 25 });
// {
// sql: 'name.first = @name_first AND address.geo.lat = @address_geo_lat',
// params: { name_first: 'Bob', address_geo_lat: 25 }
// }
Dots in field paths are used directly as column references in SQL. Parameter names replace dots with underscores to comply with BigQuery naming rules.
Boolean Filters (AND/OR)
Combine multiple conditions with logical operators:
const userFilter = bigqueryFilter < User >(). def ({
// OR: match any condition
searchTerm: {
kind: "or" ,
conditions: [
{ kind: "contains" , field: "name" },
{ kind: "contains" , field: "email" },
],
},
// AND: match all conditions
ageRange: {
kind: "and" ,
conditions: [
{ kind: "gte" , field: "age" },
{ kind: "lte" , field: "age" },
],
},
});
const where = userFilter ({
searchTerm: "john" ,
ageRange: 30 ,
});
// {
// sql: '(name LIKE @searchTerm_0 OR email LIKE @searchTerm_1) AND (age >= @ageRange_0 AND age <= @ageRange_1)',
// params: { searchTerm_0: '%john%', searchTerm_1: '%john%', ageRange_0: 30, ageRange_1: 30 }
// }
All conditions in boolean filters must have explicit field properties.
Custom Filters
Define custom SQL expressions for complex filtering logic:
import type { BigQueryFilterResult } from "@filter-def/bigquery" ;
interface Event {
id : number ;
name : string ;
userId : number ;
timestamp : string ;
metadata : Record < string , unknown >;
}
const eventFilter = bigqueryFilter < Event >(). def ({
// Standard filters
id: { kind: "eq" },
userId: { kind: "eq" },
nameContains: { kind: "contains" , field: "name" },
// Custom filter: Modulo operation
idDivisibleBy : ( divisor : number ) : BigQueryFilterResult => ({
sql: "MOD(id, @divisor) = 0" ,
params: { divisor },
}),
// Custom filter: Return 'true' when no filter needed
optionalStatus : ( status : string | "all" ) : BigQueryFilterResult =>
status === "all"
? { sql: "true" , params: {} }
: { sql: "status = @status" , params: { status } },
// Custom filter: Date filtering
createdAfter : ( date : Date ) : BigQueryFilterResult => ({
sql: "timestamp > @createdAfter" ,
params: { createdAfter: date . toISOString () },
}),
// Custom filter: Date range
createdBetween : ( range : { start : Date ; end : Date }) : BigQueryFilterResult => ({
sql: "timestamp BETWEEN @start AND @end" ,
params: {
start: range . start . toISOString (),
end: range . end . toISOString (),
},
}),
// Custom filter: JSON field extraction
hasMetadataKey : ( key : string ) : BigQueryFilterResult => ({
sql: `JSON_EXTRACT(metadata, '$. ${ key } ') IS NOT NULL` ,
params: {},
}),
// Custom filter: Array length check
hasMinEvents : ( minCount : number ) : BigQueryFilterResult => ({
sql: "(SELECT COUNT(*) FROM UNNEST(events)) >= @minCount" ,
params: { minCount },
}),
});
// Usage
const where = eventFilter ({
userId: 123 ,
createdAfter: new Date ( "2024-01-01" ),
idDivisibleBy: 10 ,
});
const [ rows ] = await bigquery . query ({
query: `SELECT * FROM \` myproject.dataset.events \` WHERE ${ where . sql } ` ,
params: where . params ,
});
Complete Example
Here’s a real-world product analytics implementation:
import { bigqueryFilter } from "@filter-def/bigquery" ;
import type { BigQueryFilterInput } from "@filter-def/bigquery" ;
import { BigQuery } from "@google-cloud/bigquery" ;
// Entity type
interface Product {
id : number ;
name : string ;
description : string | null ;
price : number ;
category : string ;
inStock : boolean ;
createdAt : string ;
}
// Filter definition
const productFilter = bigqueryFilter < Product >(). def ({
// Inferred fields
name: { kind: "eq" },
category: { kind: "eq" },
inStock: { kind: "eq" },
// Explicit fields
nameContains: {
kind: "contains" ,
field: "name" ,
caseInsensitive: true ,
},
minPrice: { kind: "gte" , field: "price" },
maxPrice: { kind: "lte" , field: "price" },
inCategories: { kind: "inArray" , field: "category" },
// Boolean filter for search
search: {
kind: "or" ,
conditions: [
{ kind: "contains" , field: "name" },
{ kind: "contains" , field: "description" },
],
},
});
type ProductFilterInput = BigQueryFilterInput < typeof productFilter >;
// Usage
const bigquery = new BigQuery ();
async function searchProducts (
input : ProductFilterInput
) : Promise < Product []> {
const where = productFilter ( input );
// Empty filters return { sql: 'true', params: {} }, which matches all rows
const [ rows ] = await bigquery . query ({
query: `SELECT * FROM \` myproject.dataset.products \` WHERE ${ where . sql } ` ,
params: where . params ,
});
return rows as Product [];
}
// Example queries
const electronics = await searchProducts ({
category: "electronics" ,
inStock: true ,
maxPrice: 500 ,
});
const searchResults = await searchProducts ({
search: "laptop" ,
minPrice: 200 ,
maxPrice: 1000 ,
});
Type Utilities
Use BigQueryFilterInput to extract the input type:
import type { BigQueryFilterInput } from "@filter-def/bigquery" ;
const userFilter = bigqueryFilter < User >(). def ({
name: { kind: "eq" },
minAge: { kind: "gte" , field: "age" },
});
type UserFilterInput = BigQueryFilterInput < typeof userFilter >;
// { name?: string; minAge?: number }
async function searchUsers ( filters : UserFilterInput ) {
const where = userFilter ( filters );
const [ rows ] = await bigquery . query ({
query: `SELECT * FROM \` users \` WHERE ${ where . sql } ` ,
params: where . params ,
});
return rows ;
}
Filter Result Type
Use BigQueryFilterResult to type the filter output:
import type { BigQueryFilterResult } from "@filter-def/bigquery" ;
// { sql: string; params: Record<string, unknown> }
const where : BigQueryFilterResult = userFilter ({ name: "John" });
Custom Filter Type
Use BigQueryCustomFilter to type custom filter functions:
import type { BigQueryCustomFilter } from "@filter-def/bigquery" ;
type DivisibleByFilter = BigQueryCustomFilter < number >;
// (input: number) => BigQueryFilterResult
const idDivisibleBy : DivisibleByFilter = ( divisor ) => ({
sql: "MOD(id, @divisor) = 0" ,
params: { divisor },
});
BigQuery-Specific Features
Array Parameters with UNNEST
BigQuery uses UNNEST() for array parameters in IN clauses:
const userFilter = bigqueryFilter < User >(). def ({
ageIn: { kind: "inArray" , field: "age" },
});
const where = userFilter ({ ageIn: [ 25 , 30 , 35 ] });
// { sql: 'age IN UNNEST(@ageIn)', params: { ageIn: [25, 30, 35] } }
Timestamp Handling
BigQuery expects timestamps in ISO format. Use custom filters for date handling:
const eventFilter = bigqueryFilter < Event >(). def ({
after : ( date : Date ) => ({
sql: "timestamp > @after" ,
params: { after: date . toISOString () },
}),
before : ( date : Date ) => ({
sql: "timestamp < @before" ,
params: { before: date . toISOString () },
}),
});
NULL Handling
isNull and isNotNull filters don’t use parameters:
const where = userFilter ({ phoneIsNull: true });
// { sql: 'phone IS NULL', params: {} }
Parameter Naming
Parameter names are sanitized by replacing dots with underscores:
const filter = bigqueryFilter < User >(). def ({
firstName: { kind: "eq" , field: "name.first" },
});
const where = filter ({ firstName: "John" });
// { sql: 'name.first = @name_first', params: { name_first: 'John' } }
Best Practices
Always Use Parameters Never concatenate user input directly into SQL. Always use the params object for safe parameterization.
Handle Empty Filters Empty filters return { sql: "true", params: {} } which matches all rows. No conditional logic needed.
Use Custom Filters Leverage custom filters for complex BigQuery functions like JSON extraction, UNNEST, and window functions.
Type Your Entities Define TypeScript interfaces for your BigQuery tables to get full type safety.
Limitations
SQL Injection Protection When using custom filters, always use parameterized queries. Never concatenate user input directly into SQL strings. // ✅ Safe: Uses parameterization
const filter = bigqueryFilter < User >(). def ({
custom : ( value : string ) => ({
sql: "column = @value" ,
params: { value },
}),
});
// ❌ Unsafe: SQL injection risk
const filter = bigqueryFilter < User >(). def ({
custom : ( value : string ) => ({
sql: `column = ' ${ value } '` ,
params: {},
}),
});
Performance Considerations
Use LIMIT clauses to control result set size
Add appropriate indexes on filtered columns
Consider partitioning large tables by frequently filtered date columns
Use UNNEST judiciously as it can impact query performance