Skip to content
Merged
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
1 change: 1 addition & 0 deletions graphql/codegen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"pg-env": "workspace:^",
"pgsql-client": "workspace:^",
"pgsql-seed": "workspace:^",
"query-spec": "workspace:^",
"undici": "^8.3.0"
},
"peerDependencies": {
Expand Down
143 changes: 15 additions & 128 deletions graphql/codegen/src/types/query.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
/**
* Query-related types for building GraphQL operations
*
* The filter, ordering, and pagination grammar is defined by the shared
* `query-spec` package and re-exported here.
*/

import type { FieldSelection } from './selection';
import type { Filter, OrderByItem } from 'query-spec';

/**
* Relay-style pagination info
*/
export interface PageInfo {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor?: string | null;
endCursor?: string | null;
}
import type { FieldSelection } from './selection';

/**
* Relay-style connection result
*/
export interface ConnectionResult<T = unknown> {
nodes: T[];
totalCount: number;
pageInfo: PageInfo;
}
export type {
ConnectionResult,
FieldFilter,
Filter,
FilterOperator,
OrderByItem,
OrderDirection,
PageInfo,
RelationalFilter
} from 'query-spec';

/**
* Options for building SELECT queries
Expand Down Expand Up @@ -56,114 +53,4 @@ export interface QueryOptions {
includePageInfo?: boolean;
}

/**
* Single order by specification
*/
export interface OrderByItem {
field: string;
direction: 'asc' | 'desc';
}

// ============================================================================
// Filter Types (PostGraphile connection filters)
// ============================================================================

/**
* All supported filter operators
*/
export type FilterOperator =
// Null checks
| 'isNull'
// Equality
| 'equalTo'
| 'notEqualTo'
| 'distinctFrom'
| 'notDistinctFrom'
// List membership
| 'in'
| 'notIn'
// Comparison
| 'lessThan'
| 'lessThanOrEqualTo'
| 'greaterThan'
| 'greaterThanOrEqualTo'
// String operators
| 'includes'
| 'notIncludes'
| 'includesInsensitive'
| 'notIncludesInsensitive'
| 'startsWith'
| 'notStartsWith'
| 'startsWithInsensitive'
| 'notStartsWithInsensitive'
| 'endsWith'
| 'notEndsWith'
| 'endsWithInsensitive'
| 'notEndsWithInsensitive'
| 'like'
| 'notLike'
| 'likeInsensitive'
| 'notLikeInsensitive'
| 'equalToInsensitive'
| 'notEqualToInsensitive'
| 'distinctFromInsensitive'
| 'notDistinctFromInsensitive'
| 'inInsensitive'
| 'notInInsensitive'
// Array operators
| 'contains'
| 'containedBy'
| 'overlaps'
// PostGIS operators
| 'intersects'
| 'intersects3D'
| 'containsProperly'
| 'coveredBy'
| 'covers'
| 'crosses'
| 'disjoint'
| 'orderingEquals'
| 'touches'
| 'within'
| 'bboxIntersects2D'
| 'bboxIntersects3D'
| 'bboxOverlapsOrLeftOf'
| 'bboxOverlapsOrRightOf'
| 'bboxOverlapsOrBelow'
| 'bboxOverlapsOrAbove'
| 'bboxLeftOf'
| 'bboxRightOf'
| 'bboxBelow'
| 'bboxAbove'
| 'bboxContains'
| 'bboxEquals';

/**
* Filter on a single field
*/
export type FieldFilter = {
[K in FilterOperator]?: unknown;
};

/**
* Filter on related records
*/
export interface RelationalFilter {
/** All related records must match */
every?: Filter;
/** At least one related record must match */
some?: Filter;
/** No related records match */
none?: Filter;
}

/**
* Main filter type - can be nested
*/
export type Filter = {
[field: string]: FieldFilter | RelationalFilter | Filter;
} & {
and?: Filter[];
or?: Filter[];
not?: Filter;
};
139 changes: 139 additions & 0 deletions packages/query-spec/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# query-spec

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE">
<img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/>
</a>
<a href="https://www.npmjs.com/package/query-spec">
<img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=packages%2Fquery-spec%2Fpackage.json"/>
</a>
</p>

The Constructive query language specification. One shared grammar for filters, ordering, and pagination — used by the generated GraphQL SDK/ORM clients, the PostGraphile connection filters, and the SQL query builder, so the same `where` JSON means the same thing at every layer of the stack.

## Install

```sh
npm install query-spec
```

## The filter grammar

Sibling keys AND together; `or`/`not` introduce disjunction and negation, nestable arbitrarily:

```ts
import type { Filter } from 'query-spec';

const where: Filter = {
status: { in: ['queued', 'retry'] },
attempts: { lessThan: 5 },
completed_at: { isNull: true },
or: [
{ priority: { greaterThan: 0 } },
{ escalated: { equalTo: true } },
],
};
```

The same shape works in a generated SDK client (`api.jobs.findMany({ where })`) and in the SQL query builder (`new QueryBuilder().table('jobs').where(where)`).

### Operand generics

`Filter`, `FieldFilter`, and `RelationalFilter` are generic over the operand type, so each layer constrains what a field may be compared against:

```ts
import type { Filter } from 'query-spec';

type JsonFilter = Filter<string | number | boolean | null>; // SDK layer
type SqlFilter = Filter<SqlValue | Expr | QueryBuilder>; // SQL layer
```

### Operators

| Group | Operators |
|-------|-----------|
| Null check | `isNull` |
| Comparison | `equalTo`, `notEqualTo`, `lessThan`, `lessThanOrEqualTo`, `greaterThan`, `greaterThanOrEqualTo` |
| Distinct | `distinctFrom`, `notDistinctFrom` |
| List membership | `in`, `notIn` |
| LIKE | `like`, `notLike`, `likeInsensitive`, `notLikeInsensitive` |
| Pattern sugar | `includes`, `startsWith`, `endsWith` (+ `not*` / `*Insensitive` variants) |
| Case-insensitive | `equalToInsensitive`, `notEqualToInsensitive`, `distinctFromInsensitive`, `notDistinctFromInsensitive`, `inInsensitive`, `notInInsensitive` |
| Array | `contains`, `containedBy`, `overlaps` |
| PostGIS | `intersects`, `within`, `touches`, `covers`, `bboxIntersects2D`, ... |

Each group is exported both as a `const` array (for runtime validation) and a string-literal union type:

```ts
import {
FILTER_OPERATORS, // readonly string[] of every operator
COMPARISON_FILTER_OPERATORS,
isFilterOperator, // (op: string) => op is FilterOperator
isLogicalOperator, // 'and' | 'or' | 'not'
} from 'query-spec';

isFilterOperator('equalTo'); // true
isFilterOperator('bogus'); // false
```

### Relational filters

GraphQL/ORM layers filter on related records with `every` / `some` / `none`:

```ts
const where: Filter = {
tags: { some: { name: { startsWith: 'infra' } } },
};
```

(The SQL layer expresses the same thing with joins or `{ in: subquery }`.)

## Ordering and pagination

```ts
import type { OrderByItem, PageInfo, ConnectionResult } from 'query-spec';

const orderBy: OrderByItem[] = [{ field: 'name', direction: 'asc' }];

const page: ConnectionResult<{ id: string }> = {
nodes: [{ id: '1' }],
totalCount: 1,
pageInfo: { hasNextPage: false, hasPreviousPage: false },
};
```

## API Reference

### Types

| Type | Description |
|------|-------------|
| `Filter<TOperand>` | The main nestable filter shape (`and`/`or`/`not` + field filters) |
| `FieldFilter<TOperand>` | Operator → operand map for a single field |
| `RelationalFilter<TOperand>` | `every`/`some`/`none` filters on related records |
| `FilterOperator` | Union of every supported operator |
| `LogicalOperator` | `'and' \| 'or' \| 'not'` |
| `OrderByItem`, `OrderDirection` | Ordering specification |
| `PageInfo`, `ConnectionResult<T>` | Relay-style pagination shapes |

### Runtime

| Export | Description |
|--------|-------------|
| `FILTER_OPERATORS` | All operators as a readonly array |
| `NULL_FILTER_OPERATORS`, `COMPARISON_FILTER_OPERATORS`, `DISTINCT_FILTER_OPERATORS`, `LIST_FILTER_OPERATORS`, `LIKE_FILTER_OPERATORS`, `PATTERN_FILTER_OPERATORS`, `INSENSITIVE_FILTER_OPERATORS`, `ARRAY_FILTER_OPERATORS`, `POSTGIS_FILTER_OPERATORS` | Operator groups |
| `LOGICAL_OPERATORS` | `['and', 'or', 'not']` |
| `isFilterOperator(op)` | Type-guard for `FilterOperator` |
| `isLogicalOperator(op)` | Type-guard for `LogicalOperator` |

## Related

* [`@constructive-io/query-builder`](https://www.npmjs.com/package/@constructive-io/query-builder) — AST-backed SQL builder consuming this grammar for `WHERE`/`HAVING`
* [`@constructive-io/graphql-codegen`](https://www.npmjs.com/package/@constructive-io/graphql-codegen) — generates SDK/ORM clients whose `where` arguments conform to this grammar
42 changes: 42 additions & 0 deletions packages/query-spec/__tests__/query-spec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
COMPARISON_FILTER_OPERATORS,
FILTER_OPERATORS,
Filter,
isFilterOperator,
isLogicalOperator,
OrderByItem
} from '../src';

describe('query-spec', () => {
it('exposes the full operator list without duplicates', () => {
expect(new Set(FILTER_OPERATORS).size).toBe(FILTER_OPERATORS.length);
expect(FILTER_OPERATORS).toEqual(
expect.arrayContaining(['isNull', 'equalTo', 'in', 'like', 'startsWith', 'contains', 'intersects'])
);
});

it('groups operators', () => {
expect(COMPARISON_FILTER_OPERATORS).toContain('greaterThanOrEqualTo');
});

it('validates operators at runtime', () => {
expect(isFilterOperator('equalTo')).toBe(true);
expect(isFilterOperator('bogus')).toBe(false);
expect(isLogicalOperator('or')).toBe(true);
expect(isLogicalOperator('equalTo')).toBe(false);
});

it('types nested filters', () => {
const filter: Filter = {
status: { in: ['queued', 'retry'] },
attempts: { lessThan: 5 },
or: [{ priority: { greaterThan: 0 } }, { escalated: { equalTo: true } }],
not: { archived: { equalTo: true } },
tags: { some: { name: { startsWith: 'a' } } }
};
expect(filter).toBeDefined();

const order: OrderByItem[] = [{ field: 'name', direction: 'asc' }];
expect(order[0].direction).toBe('asc');
});
});
18 changes: 18 additions & 0 deletions packages/query-spec/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json',
},
],
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*']
};
Loading
Loading