Matt Wilson
Matt Wilson

Reputation: 8319

GraphQL dynamic query building

I have a GraphQL server which is able to serve timeseries data for a specified source (for example, sensor data). An example query to fetch the data for a sensor might be:

query fetchData {
    timeseriesData(sourceId: "source1") {
      data {
        time
        value
      }
    }
}

In my frontend, I want to allow the user to select 1 or more sources and show a chart with a line for each one. It seems like this would be possible by using a query like this:

query fetchData {
    series1: timeseriesData(sourceId: "source1") {
      data {
        time
        value
      }
    }
    series2: timeseriesData(sourceId: "source2") {
      data {
        time
        value
      }
    }
}

Most GraphQL tutorials seem to focus on static queries (e.g. where the only thing that is changing is the variables, but not the actual shape of the request) - but in my case I need the query itself to be dynamic (one timeseriesData request for each of my selected ids).

I have the following constraints:

  1. Modifying the server's schema is not an option (so I can't pass an array of IDs to the resolver, for example)
  2. My query is specified using a template string e.g. gql`...`
  3. I don't want to have to manually build up the query as a string, because that seems like a recipe for disaster and would mean that I lose all tooling benefits (e.g. autocomplete, syntax highlighting, linting)

The stack I'm using is:

Ideally, what I want to do is have some way of merging two queries into one, so that I can define them as per the first example but then join them together in an abstraction layer so that I get a single query like the second example to be sent over the wire.

However I'm not sure how to achieve this because graphql-tag is parsing the query into an AST and I'm struggling to understand whether it's feasable to manipulate the query in this way.

What techniques are there for generating a dynamic query like this, where the shape of the query is not known upfront?

Upvotes: 58

Views: 48649

Answers (8)

Raybul Worker
Raybul Worker

Reputation: 11

Can you do something like this, it works for me.

import { gql, useLazyQuery, useQuery } from '@apollo/client';

const usersQueryBuilder = (props) => {
  return gql`
      query user($id: Int, $nickname: String) {
          user(data: { id: $id, nickname: $nickname }) {
              ${props.fields}
          }
      }`;
};

//query
const query = (props) => {
    return useQuery(usersQueryBuilder(props), {
        variables: props.variables,
    });
};

const user = query({
    variables: { nickname: 'killer' },
    fields: ['nickname', 'first_name'],
});

//lazyQuery
const lazyQuery = (props) => {
    const [get, data] = useLazyQuery(usersQueryBuilder(props));
    return { get, data };
};

const user = lazyQuery({
    fields: ['nickname', 'first_name', 'last_name'],
});

const getUser = async () => {
    const res = await user.get({ variables: { id: 555 } });
};

you can do something more generic if you don't want to write builders for each entity. But I think you need to play with this yourself))

const QueryBuilder = (props) => {
    let variables = '';
    let data = '';
    Object.entries(props.variables).forEach(([key, val]) => {
        if (typeof val === 'boolean') {
            variables += `$${key}: Boolean,`;
        }

        if (typeof val === 'string') {
            variables += `$${key}: String,`;
        }

        if (typeof val === 'number') {
            variables += `$${key}: Int,`;
        }

        data += `${key}: $${key},`;
    });

    const str = `
        query ${props.queryName}(${variables}){
        ${props.queryName}(data: {${data}}){
         ${props.fields}
        }}
        `;

  return gql`
      ${str}
  `;
};

Upvotes: 0

pavelmickevic
pavelmickevic

Reputation: 11

did you try the json-to-graphql-query. A simple module that takes a JavaScript object and turns it into a GraphQL query to be sent to a GraphQL server.

E.g.:

import { jsonToGraphQLQuery } from 'json-to-graphql-query';

const query = {
    query: {
        Posts: {
            id: true,
            title: true,
            post_date: true,
            secret: false, // ta-dam!
            author: {
              __directives: {
                include: {
                  if: new VariableType('show'),
                },
              },
            }
        }
    }
};
const graphql_query = jsonToGraphQLQuery(query, { pretty: true });

Resulting graphql_query

query {
    Posts {
        id
        title
        post_date
        author @include(if: $show)
    }
}

Upvotes: 0

Eggon
Eggon

Reputation: 2356

For anyone feeling like string operations in not the state-of-the-art way to handle coding there is a library for programmatic query building for GraphQL: gql-query-builder.

Upvotes: 2

Surya R Praveen
Surya R Praveen

Reputation: 3745

The solution is to run multiple post using GRAPHQL by splitting the first function response and passing the value to the second function by dynamically created query of GRAPHQL.

mainQueries.ts

export const generalLandingPageBySlugQuery: string = `
query ($slug: String!, $isPreview: Boolean = false, $locale: String!) {
  templateGeneralLandingPageCollection(where: {slug: $slug}, locale: $locale, preview: $isPreview, limit: 1) {
    items {
      title
      slug
      metadata {
        ...metadataFields
      }
      components: componentsCollection(limit: 10) {
        items {
          __typename
          ... on TextCardsComponent {
            ...textCardsFields
          }
          ... on TwoColumnImageTextComponent {
            ...twoColumnImageTextFields
          }
        }
      }
    }
  }
} ${metadataFields}
${components.twoColumnImageText}
${components.accordion}
`;

Fragments.ts

export const components = {
textCards: `fragment textCardsFields on TextCardsComponent {
    rtHeading: heading {
      json
    }
    backgroundColor
    links: linksCollection {
      items {
        title
        url
        openInNewTab
      }
    }
  }`,
  twoColumnImageText: `
  fragment twoColumnImageTextFields on TwoColumnImageTextComponent {
    rtTitle:title {
      json
    }
    variant
    backgroundColor
    rtHeading: heading {
      json
    }
    rtBlurb: blurb {
      json
    }
    cta {
      title
      url
      openInNewTab
    }
    eyebrow
    cardTitle
    cardDescription
    cardLink
    image {
      ...assetFields
    }
    videoType
    videoId
  }`,

Angular Service.ts Function one

   generalLandingPageBySlug(slug: string) {
        const queryVariables = JSON.stringify({
          slug,
          isPreview: this.isPreview,
          locale: this.coreService.locale.code,
        });
    
        return this.http
          .post<ContentfulApiResponse>( // REQUEST ONE
            environment.local ? LOCAL_GRAPHQL : `${GRAPHQL}/general-lp-${slug}`,
            {
              query: Utils.compressGraphQl(generalLandingPageBySlugQuery),
              variables: queryVariables,
            }
          )
          .pipe(
            map((response: ContentfulApiResponse) => {
              this.typename =
                response.data.templateGeneralLandingPageCollection?.items[0].components.items;
    
              //Components Lists
              const currentComponents = [
                ...new Map(
                  this.typename.map((obj: any) => [JSON.stringify(obj), obj])
                ).values(),
              ];
    
              this.typename = currentComponents;
              const choosenComponents = this.typename.map(function (typeName: {
                __typename: any;
              }) {
                return typeName.__typename;
              });
             
                 //Dynamic Query
                  const queryData = 
                'query ($slug: String!, $isPreview: Boolean = false, $locale: String!) {' +
                'templateGeneralLandingPageCollection(where: {slug: $slug}, locale: $locale, preview: $isPreview, limit: 1) {' +
                'items {' +
                'title ' +
                'slug ' +
                'metadata {' +
                '...metadataFields' +
                '}' +
                'components: componentsCollection(limit: 15) {' +
                'items {' +
                '__typename ' +
                (choosenComponents.includes('TextCardsComponent')
                  ? '... on TextCardsComponent {...textCardsFields}'
                  : '') +
                (choosenComponents.includes('TwoColumnImageTextComponent')
                  ? '... on TwoColumnImageTextComponent {...twoColumnImageTextFields}'
                  : '') +
                '}' +
                '}' +
                '}' +
                '}' +
                '}' +
                'fragment metadataFields on Metadata{title metaTitle keyword description facebookType image{...assetFields}canonical noIndex} ' +
                (choosenComponents.includes('TextCardsComponent')
                  ? 'fragment textCardsFields on TextCardsComponent{rtHeading:heading{json}backgroundColor links:linksCollection{items{title url openInNewTab}}}'
                  : '') +
                (choosenComponents.includes('TwoColumnImageTextComponent')
                  ? 'fragment twoColumnImageTextFields on TwoColumnImageTextComponent{rtTitle:title{json}variant backgroundColor rtHeading:heading{json}rtBlurb:blurb{json}cta{title url openInNewTab}eyebrow cardTitle cardDescription cardLink image{...assetFields}videoType videoId}'
                  : '') +
                 ';
              return queryData;
            }),
            mergeMap((payload) => {
              return this.generalFinalData(payload, slug);
            })
          );
      }

Function Two

  generalFinalData(payload: string, slug: string) {
    const queryVariables = JSON.stringify({
      slug,
      isPreview: this.isPreview,
      locale: this.coreService.locale.code,
    });
    return this.http
      .post<ContentfulApiResponse>( // REQUEST TWO
        environment.local ? LOCAL_GRAPHQL : `${GRAPHQL}/general-lp-${slug}`,
        {
          query: Utils.compressGraphQl(payload),
          variables: queryVariables,
        }
      )
      .pipe(
        map((response: ContentfulApiResponse) => {
          let contentfulResponse: ContentfulResponse = {
            __typename: '',
            pageData: {},
            components: [],
          };
          return this.hasError(
            response,
            response.data.templateGeneralLandingPageCollection
          )
            ? contentfulResponse
            : this.buildData(
                this.unwrapResponse(
                  response.data.templateGeneralLandingPageCollection
                ),
                contentfulResponse
              );
        })
      );
  }

Upvotes: 0

snnsnn
snnsnn

Reputation: 13630

GraqhQL provides directives for this very purpose.

Create a fragment to define common fields, use @include(if: Boolean) and @skip(if: Boolean) directives on that fragment to get dynamic fields. By dynamic fields we mean fields that are known at execution time.

According to spec, it is best to avoid manual string interpolation to construct dynamic queries.

Directives1 make it possible to include or skip a field based on a boolean expression passed as a query variable. A directive can be attached to a field or fragment inclusion, and can affect execution of the query in any way the server desires.

query Hero($episode: Episode, $withFriends: Boolean!) {
  hero(episode: $episode) {
    name
    friends @include(if: $withFriends) {
      name
    }
  }
}

And in variables:

{
  "episode": "JEDI",
  "withFriends": false
}

Of course you can send named queries as you did in your second example. Clients will batch the requests automatically.

Upvotes: 31

Marco Daniel
Marco Daniel

Reputation: 5765

I think you could use fragments for this! But you still have to write 2 "queries" in this case fragments.

First let's create a fragment for each timeSeries, please check your timeSeries query type, I'm going to refer to it as timeseriesDataQuery

const series1Q = gql`
  fragment series1 on timeseriesDataQuery {
    series1: timeseriesData(sourceId: "source1") {
      data {
        time
        value
      }
    }
  }
}

const series2Q = gql`
  fragment series2 on timeseriesDataQuery {
    series2: timeseriesData(sourceId: "source2") {
      data {
        time
        value
      }
    }
  }
}

And then just stitch them up in the query:

export const mainQuery = gql`
    query fetchData {
      ...series1 
      ...series2
    }
    ${series1Q}
    ${series2Q}
`  

Upvotes: 6

Alireza Hariri
Alireza Hariri

Reputation: 119

I think you have no choice except using String features when the user chooses sensors dynamically and even you don't know sensors on development time (not run-time).

const mainQuery = gql
  `query fetchData($sourceId: String!) {
    timeseriesData(sourceId: $sourceId) {
      data {
        time
        value
      }
    }
  }`;

const mainQueryStr = mainQuery.loc.source.body;

The mainQueryStr is the string value of your query (to handle dynamicity of your problem) Then loop on the sensors and replace $sourceId with the id of each sensor

// You have to remove the query wrapper first
// Then replace sensor id
const sensorsQueries = sensors.map(sid => mainQueryStr
  .split(`\n`)
  .slice(1, 7)
  .replace(`$sourceId`, sid)
)

Then you should join sensorQueries and make new GraphQL query

const finalQuery = gql`
  query fetchData {
    ${sensorsQueries.join(`\n`)}`
  };

In this case, you can use tooling benefits like autocomplete, syntax highlighting and ... for the mainQuery query, not finalQuery (Because of you create this one dynamically)

Upvotes: 6

cnotethegr8
cnotethegr8

Reputation: 7510

Keep in mind that the query is just a string. You can use simple template literals to achieve your dynamic query.

const generateQuery = () => {
  let query = ""

  for (let i = 1; i < 3; i++) {
    const series = `series${i}`
    const source = `source${i}`

    query += `
      ${series}: timeseriesData(sourceId: "${source}") {
        prices
        queried
      }
    `
  }

  return query
}

const fetchDataQuery = gql`
  query fetchData {
    ${generateQuery()}
  }
`

Upvotes: 4

Related Questions