From b5955441c38dec1cca8a94d5ce9ef45ef48af32e Mon Sep 17 00:00:00 2001 From: ben troop Date: Wed, 13 Aug 2025 11:33:39 -0700 Subject: [PATCH] added countries field to languages type, improved filtering for countries, languages and continents (includes supporting utilities) --- src/schema.ts | 151 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 136 insertions(+), 15 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 9672236..0596006 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -170,6 +170,12 @@ builder.objectType(LanguageRef, { rtl: t.boolean({ resolve: (language) => language.rtl === 1, }), + countries: t.field({ + type: [CountryRef], + resolve: (language) => Object.entries(countries) + .filter(([, country]) => Array.isArray(country.languages) && country.languages.includes(language.code)) + .map(([code, country]) => ({...country, code})) + }), }), }); @@ -205,18 +211,24 @@ const CountryFilterInput = builder.inputType("CountryFilterInput", { name: t.field({ type: StringQueryOperatorInput }), currency: t.field({ type: StringQueryOperatorInput }), continent: t.field({ type: StringQueryOperatorInput }), + language: t.field({ type: StringQueryOperatorInput }), }), }); const ContinentFilterInput = builder.inputType("ContinentFilterInput", { fields: (t) => ({ code: t.field({ type: StringQueryOperatorInput }), + // A continent matches if it has at least one country satisfying this filter + country: t.field({ type: CountryFilterInput }), + // A continent matches if it has at least one language (spoken within it) satisfying this filter + language: t.field({ type: LanguageFilterInput }), }), }); const LanguageFilterInput = builder.inputType("LanguageFilterInput", { fields: (t) => ({ code: t.field({ type: StringQueryOperatorInput }), + country: t.field({ type: StringQueryOperatorInput }), }), }); @@ -240,6 +252,87 @@ const isValidLanguageCode = ( code: string | number ): code is keyof typeof languages => code in languages; + + +export type StringOps = { + eq?: string; + ne?: string; + in?: string[]; + nin?: string[]; + regex?: string; +}; + +// true if the array `values` satisfies the StringOps (ANY-match semantics) +const anyMatches = (values: string[], ops?: StringOps) => { + if (!ops) return true; + + const has = (v: string) => values.includes(v); + + if (ops.eq !== undefined && !has(ops.eq)) return false; + if (ops.in && !ops.in.some(has)) return false; + + if (ops.regex) { + const re = new RegExp(ops.regex); + if (!values.some((v) => re.test(v))) return false; + } + + if (ops.ne !== undefined && has(ops.ne)) return false; + if (ops.nin && ops.nin.some(has)) return false; + + return true; +}; + +// Given a language code, return a list of country codes where it's spoken +const countryCodesForLanguage = (langCode: string) => + Object.entries(countries) + .filter(([, c]) => Array.isArray(c.languages) && c.languages.includes(langCode)) + .map(([code]) => code); + +// Countries within a continent (as Country objects with `code`) +const countriesInContinent = (continentCode: string) => + Object.entries(countries) + .filter(([, c]) => c.continent === continentCode) + .map(([code, c]) => ({ ...c, code })); + +// Unique language codes used within a continent +const languageCodesInContinent = (continentCode: string) => { + const set = new Set(); + countriesInContinent(continentCode).forEach((c) => { + if (Array.isArray(c.languages)) c.languages.forEach((lc) => set.add(lc)); + }); + return Array.from(set); +}; + +// Language objects used within a continent (with `code`) +const languagesInContinent = (continentCode: string) => + languageCodesInContinent(continentCode).map((code) => ({ + ...(languages[code as keyof typeof languages] as Language), + code, + })); + +// Build a predicate to test a Country against CountryFilterInput (incl. language ops) +const makeCountryPredicate = ( + filter?: { language?: StringOps } & Record +) => { + if (!filter || Object.keys(filter).length === 0) return () => true; + const { language, ...rest } = filter; + const base = sift(JSON.parse(JSON.stringify(rest)), { operations }); + return (c: (Country & { code: string })) => + base(c) && anyMatches(Array.isArray(c.languages) ? c.languages : [], language); +}; + +// Build a predicate to test a Language against LanguageFilterInput (incl. country ops) +const makeLanguagePredicate = ( + filter?: { country?: StringOps } & Record +) => { + if (!filter || Object.keys(filter).length === 0) return () => true; + const { country, ...rest } = filter; + const base = sift(JSON.parse(JSON.stringify(rest)), { operations }); + return (l: (Language & { code: string })) => + base(l) && anyMatches(countryCodesForLanguage(l.code), country); +}; + + builder.queryType({ fields: (t) => ({ continents: t.field({ @@ -250,12 +343,37 @@ builder.queryType({ defaultValue: {}, }), }, - resolve: (_, { filter }) => - Object.entries(continents) + resolve: (_, { filter }) => { + const { country: countryFilter, language: languageFilter, ...rest } = + (filter ?? {}) as { + country?: { language?: StringOps } & Record; + language?: { country?: StringOps } & Record; + }; + + // base continent-level filter (currently only `code`) + const base = sift(JSON.parse(JSON.stringify(rest)), { operations }); + + const countryPred = makeCountryPredicate(countryFilter); + const languagePred = makeLanguagePredicate(languageFilter); + return Object.entries(continents) .map(([code, name]) => new Continent(code, name)) // need to parse and stringify because of some null prototype // see https://stackoverflow.com/q/53983315/8190832 - .filter(sift(JSON.parse(JSON.stringify(filter)), { operations })), + .filter((cont) => base(cont)) + .filter((cont) => { + // If country filter provided, continent must have at least one matching country + const okByCountry = countryFilter + ? countriesInContinent(cont.code).some(countryPred) + : true; + + // If language filter provided, continent must have at least one matching language + const okByLanguage = languageFilter + ? languagesInContinent(cont.code).some(languagePred) + : true; + + return okByCountry && okByLanguage; + }); + } }), continent: t.field({ type: Continent, @@ -277,12 +395,13 @@ builder.queryType({ }), }, resolve: (_, { filter }) => { + const { language, ...rest } = (filter ?? {}) as { language?: StringOps }; + const base = sift(JSON.parse(JSON.stringify(rest)), { operations }); + return Object.entries(countries) - .map(([code, country]) => ({ - ...country, - code, - })) - .filter(sift(JSON.parse(JSON.stringify(filter)), { operations })); + .map(([code, country]) => ({ ...country, code })) + .filter((c) => base(c)) + .filter((c) => anyMatches(Array.isArray(c.languages) ? c.languages : [], language)); }, }), country: t.field({ @@ -307,13 +426,15 @@ builder.queryType({ defaultValue: {}, }), }, - resolve: (_, { filter }) => - Object.entries(languages) - .map(([code, language]) => ({ - ...language, - code, - })) - .filter(sift(JSON.parse(JSON.stringify(filter)), { operations })), + resolve: (_, { filter }) => { + const { country, ...rest } = (filter ?? {}) as { country?: StringOps }; + const base = sift(JSON.parse(JSON.stringify(rest)), { operations }); + + return Object.entries(languages) + .map(([code, language]) => ({ ...language, code })) + .filter((l) => base(l)) + .filter((l) => anyMatches(countryCodesForLanguage(l.code), country)); + }, }), language: t.field({ type: LanguageRef,