sanity-io / sanity

Sanity Studio – Rapidly configure content workspaces powered by structured content
https://www.sanity.io
MIT License
5.19k stars 419 forks source link

Typegen not correctly typing groq query when using dynamic param on an object attribute #6365

Open oliviercperrier opened 5 months ago

oliviercperrier commented 5 months ago

If you find a security vulnerability, do NOT open an issue. Email security@sanity.io instead.

Describe the bug

i have the following query:

export const postQuery = groq`*[_type == "post" && slug.current == $slug] [0] {
  "body": body[$locale],
  ${postFields}
}`;

*** This is working if i do directly access a locale: body.en

body is LocaleString which normally return

{
  "en": "...",
  "fr": "..."
}

But i pass a locale to only get the currently selected lang in my website, so body is a string. But for some reason, typegen is still generatin this type:

export type PostQueryResult = {
  body: LocaleString | null;
  ...
}

To Reproduce

Steps to reproduce the behavior:

  1. Create LocaleString type
export default defineType({
  title: "Localized string",
  name: "localeString",
  type: "object",
  fieldsets: [
    {
      title: "Translations",
      name: "translations",
      options: { collapsible: true },
    },
  ],
  fields: supportedLanguages.map((lang) => ({
    title: lang.title,
    name: lang.id,
    type: "string",
    fieldset: lang.isDefault ? undefined : "translations",
  })),
});
  1. Defined a field
 defineField({
      name: "title",
      title: "Title",
      type: "localeString",
      validation: (rule) => rule.required(),
    }),
  1. Make a query for a single locale at a type using a dynamic param

Expected behavior

I expect the typegen to generate the following type:

export type PostQueryResult = {
  body: string | null;
  ...
}

Which versions of Sanity are you using?

@sanity/cli (global)          3.37.2 (up to date)
@sanity/eslint-config-studio   4.0.0 (up to date)
@sanity/vision                3.37.2 (up to date)
sanity                        3.37.2 (up to date)

What operating system are you using?

Mac OS

Which versions of Node.js / npm are you running?

10.2.3 v18.19.0

obrassard commented 5 months ago

I have the same issue, this could be very useful for field level translations !

Qocotzxin commented 5 months ago

Similar to this, the following query generates isCurrentLanguage: unknown: "toasts": toasts[]->{"isCurrentLanguage": language == $language, code, title, content},

While if I use a string literal instead, the type is correctly generated as isCurrentLanguage: string: "toasts": toasts[]->{"isCurrentLanguage": language == "en", code, title, content},

I also noticed while debugging this query that when using the parent accessor, the type results in Array<never>: "toasts": *[_type == "toast" && language == $language && _id in ^.toasts[]{_ref}._ref]{code, heading, content, language}

In all these cases queries correctly work and bring the expected results.

sgulseth commented 5 months ago

Hi! This is is a tricky feature because of the dynamic nature of GROQ. Given the look up attribute[$param] it's impossible to parse the query correctly without knowing the type of $param. If $param is a string it's an attribute access(object), if it's a number it's an element access(array), and by default it's a filter. Since we rely on the GROQ javascript parser we would need to know the value of $param while parsing, which we don't at the moment

obrassard commented 4 months ago

One workaround I tried was to take my initial query with $lang parameters and create a new "localized query" by replacing all references to [$lang] with .en. I thought this would work and then I'd be able to use the type generated for the localized query in the code.

import groq from 'groq';

const fullWidthTextBlockFragment = `
_type == 'module.fullWidthTextBlock' => {
    _key,
    _type,
    "title": title[$lang],
    "body": body[$lang],
}
`;

const SanityProductModulesQuery = groq`
*[_type == 'product' && store.slug.current == $handle][0] {
  modules[] {
    ${fullWidthTextBlockFragment},
  }
}`;

const LocalizedProductModulesQuery = groq`
${SanityProductModulesQuery.replaceAll('[$lang]', '.en')}
`;

⚠️ However it seems the Groq parser does not (yet?) allow those kind of expressions :

✖ Unsupported expression type: MemberExpression in ../app/groq/product.localized.ts:22:4  ⚠ Encountered errors in 1 files while generating types


groq(`
*[_type == 'product' && store.slug.current == $handle][0] {
  _type == 'module.fullWidthTextBlock' => {
    "title": title[$lang],
    "body": body[$lang],
  }
}`,
  {
    lang: 'en',
  },
);
Qocotzxin commented 4 months ago

It would be nice being able to at least define parameters in JSDoc (or similar) format for this feature work:

/**
* @param language {string}
*/
const someQuery = groq`
*[_type == 'someType' && language == $language] {...}
`;

This approach might not be enough to unblock the usage of typed fragments though, which would be ideal.

maxijonson commented 4 months ago

As a workaround, you could try to use select instead of body[$locale]

export const testQuery = groq`
  *[_type == "localizedDocument"][0] {
    "body": select(
        $locale == "en" => body.en,
        $locale == "fr" => body.fr,
    )
  }
`;

The resulting type of body is as you expected:

export type TestQueryResult = {
  body: string | null;
} | null;

However, this is inconvenient, because you always need to specify this selection. You could create a simple utility that reduces the burden of writing it. However, do not map over your supportedLanguges to generate these selections. I've found that TypeGen throws an error when you do: ✖ Unsupported expression type: MemberExpression in sanity/schemas/documents/test.ts:48:6 in "sanity/schemas/documents/test.ts"

export const selectLocalizedString = (field: string) =>
  `select(
    $locale == "en" => ${field}.en,
    $locale == "fr" => ${field}.fr,
)`;

// ⚠ Does not work with TypeGen
// export const selectLocalizedString = (field: string) =>
//   `select(
//     ${supportedLanguages.map((lang) => `$locale == "${lang}" => ${field}.${lang},`).join("\n")}
// )`;

export const testQuery = groq`
  *[_type == "localizedDocument"][0] {
    "body": ${selectLocalizedString("body")}
  }
`;

Here's the full final file I used to reproduce your issue and find a working solution:

import { groq } from "next-sanity";
import { defineField, defineType } from "sanity";

const supportedLanguages = ["en", "fr"] as const;

export const localizedString = defineType({
  title: "Localized string",
  name: "localizedString",
  type: "object",
  fields: supportedLanguages.map((lang) => ({
    title: lang,
    name: lang,
    type: "string",
  })),
});

export default defineType({
  name: "localizedDocument",
  title: "Localized Document",
  type: "document",
  fields: [
    defineField({
      name: "body",
      title: "Body",
      type: localizedString.name,
      validation: (rule) => rule.required(),
    }),
  ],
});

// export const testQuery = groq`
//   *[_type == "localizedDocument"][0] {
//     "body": select(
//         $locale == "en" => body.en,
//         $locale == "fr" => body.fr,
//     )
//   }
// `;

export const selectLocalizedString = (field: string) =>
  `select(
    $locale == "en" => ${field}.en,
    $locale == "fr" => ${field}.fr,
)`;

// ⚠ Does not work with TypeGen
// export const selectLocalizedString = (field: string) =>
//   `select(
//     ${supportedLanguages.map((lang) => `$locale == "${lang}" => ${field}.${lang},`).join("\n")}
// )`;

export const testQuery = groq`
  *[_type == "localizedDocument"][0] {
    "body": ${selectLocalizedString("body")}
  }
`;
obrassard commented 3 months ago

@maxijonson solutions works, however it seems that if you import selectLocalizedString from another file, the TypeGen parser doesn't handle it properly :

✖ Could not find binding for node "field" in /.../common.ts:3:23

We are therefore constrained to redefine this function in all the files where we want to use it

garethm123 commented 2 months ago

Another workaround if you want to keep the coalesce function in the query until it's fixed;

An example query with ["$lang"] so type gen works as expected and returns a string for the coalesced title:

export const exampleQuery = groq`
  *[_type == "aContentType"][0] {
    "title": coalesce(title["$lang"], title.en, "Missing translation"),
  }
;`

Then for the query to actually return the localised content and not always fallback to .en, create a small util function;

export const replaceDynamicLangParam = ({
  query,
  lang,
}: {
  query: string;
  lang: string;
}) => {
  const placeholderPattern = /\["\$lang"\]/g;
  const replacement = `["${lang}"]`;
  return query.replace(placeholderPattern, replacement);
};

And then use the above in a sanityFetch util function;

"use server";

import { sanityClient } from "@/lib/sanity-clients";
import { revalidateTag } from "next/cache";
import { replaceDynamicLangParam } from "./utils/replace-dynamic-lang-param";

type OptionalParams = {
  [key: string]: string;
};

export async function sanityFetch<T>({
  query,
  params = {},
  cacheOptions = "default",
  cacheTag = "sanity",
}: {
  query: string;
  params?: OptionalParams;
  cacheOptions?:
    | "no-cache"
    | "no-store"
    | "default"
    | "reload"
    | "force-cache"
    | "only-if-cached";
  cacheTag?: string;
}): Promise<T> {

  // Workaround for typegen issue with dynamic params (with lang)
  const queryToUse = replaceDynamicLangParam({
    query: query,
    lang: params?.lang || "en",
  });

  return await sanityClient.fetch(queryToUse, params, {
    tag: cacheTag,
    cache: cacheOptions,
  });
}

And example usage to fetch data in a page/server component;

  const data = await sanityFetch<ContactPageQueryResult>({
    query: contactPageQuery,
    params: {
      lang: locale,
    },
  });