@kronor/hasura-graphql
v0.1.2
Published
Typed GraphQL and Hasura AST primitives: filter expressions, query documents, row-typed DSLs and schema type generation.
Readme
@kronor/hasura-graphql
Typed GraphQL and Hasura AST primitives for declarative, schema-driven UIs.
This package holds the parts of @kronor/dtv that are not about
tables: building and comparing Hasura boolean expressions, describing what to
select from a Hasura root field, rendering that to a GraphQL query string, and
the type-level machinery that ties both to a generated row type.
It has no UI dependencies — graphql is its only runtime dependency.
Install
npm install @kronor/hasura-graphqlWhat's in it
hasura/ — boolean expressions
HasuraFilterExpression is an algebra you can build, normalize and compare
before lowering it to the nested *_bool_exp shape Hasura expects.
import { Hasura, hasuraFilterExpressionToObject } from '@kronor/hasura-graphql';
const expr = Hasura.and(
Hasura.condition('status', Hasura.eq('PAID')),
Hasura.scope('customer', Hasura.condition('email', Hasura.ilike('%@example.com')))
);
hasuraFilterExpressionToObject(expr);
// { _and: [ { status: { _eq: 'PAID' } }, { customer: { email: { _ilike: '%@example.com' } } } ] }Empty branches are dropped and single-member _and / _or nodes collapse, so
conditionally-built expressions stay clean without special-casing at the call
site. hasuraFilterExpressionsAreEqual compares two expressions structurally,
ignoring member order and scope/path spelling.
query/ — selection sets and documents
Query nodes describe a selection: valueQuery (scalar), objectQuery
(nested object) and arrayQuery (related collection, which also carries
where / orderBy / distinctOn / limit). fieldAlias adds GraphQL
aliasing.
import {
valueQuery, objectQuery, arrayQuery,
buildSelectionSet, renderGraphQLQuery, Hasura
} from '@kronor/hasura-graphql';
const selectionSet = buildSelectionSet([
{ fieldQuery: valueQuery({ field: 'id' }) },
{ fieldQuery: objectQuery({ field: 'customer', selectionSet: [valueQuery({ field: 'email' })] }) },
{ fieldQuery: arrayQuery({
field: 'lines',
selectionSet: [valueQuery({ field: 'sku' })],
where: Hasura.condition('qty', Hasura.gt(0)),
limit: 10
}) }
]);
renderGraphQLQuery({
operation: 'query',
variables: [{ name: 'conditions', type: 'order_bool_exp!' }],
rootField: { field: 'orders', args: [{ name: 'where', value: { type: 'variable', name: 'conditions' } }] },
selectionSet
});buildSelectionSet de-duplicates structurally identical selections, so several
independent consumers of the same row can contribute overlapping selections
without emitting duplicate fields. ensureSelectionPath guarantees a field is
selected when the caller needs it for its own bookkeeping (a pagination cursor,
a record id).
Selections that address the same field but select different things from it are
kept side by side by default, since a consumer that declares a whole sub-tree
(one per table column, say) means them to stay distinct. Consumers that instead
contribute independent field paths want them combined — vendor.name and
vendor.id should reach the server as one vendor { name id }. Pass
mergeNestedSelections for that, or use mergeSelectionSetItem directly:
buildSelectionSet(inputs, { mergeNestedSelections: true });Items whose arguments differ — the same collection filtered two ways — are never merged, whatever the option says.
Enum arguments
GraphQL enums cannot be passed as strings: Hasura's order_by directions and
distinct_on columns need ASC, not "ASC". graphqlEnumValue marks a value
as an enum wherever an argument or literal is accepted, and
orderByArgumentValue lowers a whole order_by object for you, upper-casing
the directions on the way:
import { graphqlEnumValue, orderByArgumentValue } from '@kronor/hasura-graphql';
rootField: {
field: 'currencies',
args: [{ name: 'orderBy', value: orderByArgumentValue({ code: 'asc' }) }]
}
// currencies(orderBy: {code: ASC})Enum values and variable references both render bare, including inside the
operator values of a where clause — so a filter can compare against a query
variable (Hasura.eq(graphqlVariableReference('id'))).
Several root fields
renderGraphQLQuery takes either document shape. Give it rootFields to fetch
unrelated collections in one round trip — an entity together with the option
lists a form needs, say. Root fields naming the same collection need distinct
aliases; rootFieldsOf normalizes either shape to a list.
renderGraphQLQuery({
operation: 'query',
variables: [{ name: 'id', type: 'bigint!' }],
rootFields: [
{ field: 'orders', args: [{ name: 'where', value: { id: { _eq: graphqlVariableReference('id') } } }],
selectionSet: buildSelectionSet([{ fieldQuery: valueQuery({ field: 'reference' }) }]) },
{ field: 'currencies', alias: 'currencyOptions',
args: [{ name: 'orderBy', value: orderByArgumentValue({ code: 'asc' }) }],
selectionSet: buildSelectionSet([{ fieldQuery: valueQuery({ field: 'code' }) }]) }
]
});dsl/ — row-typed builders
Given a row type — typically generated from your schema — the builders constrain field paths and selection sets to what actually exists.
import { queryForRowType, hasuraDSLforRowType, rowType } from '@kronor/hasura-graphql';
type Order = {
id: string;
status: string;
customer: { email: string | null } | null;
lines: Array<{ sku: string; qty: number | null }>;
};
const q = queryForRowType(rowType<Order>());
q.value({ field: 'id' });
q.object({ field: 'customer', selectionSet: c => [c.value({ field: 'email' })] });
q.array({
field: 'lines',
selectionSet: l => [l.value({ field: 'sku' })],
// `h` is scoped to the line element type
where: h => h.condition('qty', h.gt(0))
});
const h = hasuraDSLforRowType<Order>();
h.condition('customer.email', h.ilike('%@example.com')); // checked against OrderFieldPath<Row> and PathValue<Row, Path> are available directly if you need
to build your own row-typed APIs on top.
typegen — schema to TypeScript
The @kronor/hasura-graphql/typegen entry point holds the schema-side half of
type generation: introspecting an endpoint and rendering GraphQL types as
TypeScript. Deciding which types to generate is left to the caller, since
that depends on how your project declares its views or forms.
import { fetchSchema, collectReachableTypes, renderTsFromSchema, unwrapCollectionElementType }
from '@kronor/hasura-graphql/typegen';
const schema = await fetchSchema({ endpoint, headers });
const rowType = unwrapCollectionElementType(schema.getQueryType()!.getFields()['orders'].type);
const ts = renderTsFromSchema(collectReachableTypes(schema, [rowType]), { scalars: { uuid: 'string' } });Development
This package lives in the declarative-table-view monorepo.
npm install # from the repo root
npm run build --workspace @kronor/hasura-graphql
npm run test-unit --workspace @kronor/hasura-graphql