From ec7e5b1ad2bce1fb61f2b916ade0fffe230398e4 Mon Sep 17 00:00:00 2001 From: James Hammond Date: Mon, 15 Jul 2024 15:44:50 +0100 Subject: [PATCH] Fix nested arrays --- src/genSchema/generateZodSchemaCode.test.ts | 208 ++++++++++++++++++++ src/genSchema/generateZodSchemaCode.ts | 24 ++- src/genSchema/mergeNested.test.ts | 2 +- 3 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 src/genSchema/generateZodSchemaCode.test.ts diff --git a/src/genSchema/generateZodSchemaCode.test.ts b/src/genSchema/generateZodSchemaCode.test.ts new file mode 100644 index 0000000..29e699b --- /dev/null +++ b/src/genSchema/generateZodSchemaCode.test.ts @@ -0,0 +1,208 @@ +import { getDetailsFromDefinition } from './getDetailsFromDefinition.js' +import {generateZodSchemaCode} from "./generateZodSchemaCode.js"; + +describe('generateZodSchemaCode', () => { + describe('basic schema', () => { + it('returns schema for simple object', () => { + const definition = ` + DEFINE FIELD reviews ON TABLE product TYPE array; + DEFINE FIELD user ON TABLE product TYPE record; + DEFINE FIELD rating ON TABLE product TYPE number; + ` + const fields = definition.split(';').filter(x => x.trim().length).map(def => getDetailsFromDefinition(def, false)) + const generatedSchema = generateZodSchemaCode(fields, 'schema') + + expect(generatedSchema).toEqualIgnoringWhitespace(` + const schema = z.object({ + reviews: z.array(z.string()), + user: recordId('user'), + rating: z.number() + }) + `) + }) + }) + + describe('object schema', () => { + it('returns schema for simple object', () => { + const definition = ` + DEFINE FIELD review ON TABLE product TYPE object; + DEFINE FIELD review.rating ON TABLE product TYPE number; + DEFINE FIELD review.comment ON TABLE product TYPE string; + ` + const fields = definition.split(';').filter(x => x.trim().length).map(def => getDetailsFromDefinition(def, false)) + console.log("fields", fields) + const generatedSchema = generateZodSchemaCode(fields, 'schema') + + console.log("generatedSchema", generatedSchema) + + expect(generatedSchema).toEqualIgnoringWhitespace(` + const schema = z.object({ + review: z.object({ + rating: z.number(), + comment: z.string() + }) + }) + `) + }) + + it('returns schema for optional object', () => { + const definition = ` + DEFINE FIELD review ON TABLE product TYPE option; + DEFINE FIELD review.rating ON TABLE product TYPE number; + DEFINE FIELD review.comment ON TABLE product TYPE string; + ` + const fields = definition.split(';').filter(x => x.trim().length).map(def => getDetailsFromDefinition(def, false)) + console.log("fields", fields) + const generatedSchema = generateZodSchemaCode(fields, 'schema') + + console.log("generatedSchema", generatedSchema) + + expect(generatedSchema).toEqualIgnoringWhitespace(` + const schema = z.object({ + review: z.object({ + rating: z.number(), + comment: z.string() + }).optional() + }) + `) + }) + + it('returns schema for optional object derived from all values being optional', () => { + const definition = ` + DEFINE FIELD review ON TABLE product TYPE object; + DEFINE FIELD review.rating ON TABLE product TYPE option; + DEFINE FIELD review.comment ON TABLE product TYPE option; + ` + const fields = definition.split(';').filter(x => x.trim().length).map(def => getDetailsFromDefinition(def, false)) + console.log("fields", fields) + const generatedSchema = generateZodSchemaCode(fields, 'schema') + + console.log("generatedSchema", generatedSchema) + + expect(generatedSchema).toEqualIgnoringWhitespace(` + const schema = z.object({ + review: z.object({ + rating: z.number().optional(), + comment: z.string().optional() + }).optional() + }) + `) + }) + + it('returns schema for object with nested array', () => { + const definition = ` + DEFINE FIELD review ON TABLE product TYPE object; + DEFINE FIELD review.related ON TABLE product TYPE array; + DEFINE FIELD review.related[*].name ON TABLE product TYPE string; + DEFINE FIELD review.related[*].rating ON TABLE product TYPE number; + ` + const fields = definition.split(';').filter(x => x.trim().length).map(def => getDetailsFromDefinition(def, false)) + const generatedSchema = generateZodSchemaCode(fields, 'schema') + + + expect(generatedSchema).toEqualIgnoringWhitespace(` + const schema = z.object({ + review: z.object({ + related: z.object({ + name: z.string(), + rating: z.number() + }).array() + }) + }) + `) + }) + + it('returns schema for complex object', () => { + const definition = ` + DEFINE FIELD name ON TABLE product TYPE string; + DEFINE FIELD price ON TABLE product TYPE number; + DEFINE FIELD published_at ON TABLE product TYPE datetime; + DEFINE FIELD is_published ON TABLE product TYPE bool; + DEFINE FIELD related_authors ON TABLE product TYPE option>>; + DEFINE FIELD review ON TABLE product TYPE object; + DEFINE FIELD review.rating ON TABLE product TYPE number; + DEFINE FIELD review.comment ON TABLE product TYPE string; + DEFINE FIELD review.author ON TABLE product TYPE object; + DEFINE FIELD review.author.name ON TABLE product TYPE string; + DEFINE FIELD review.author.email ON TABLE product TYPE string; + DEFINE FIELD review.author.tags ON TABLE product TYPE array; + DEFINE FIELD review.author.user ON TABLE product TYPE record; + DEFINE FIELD review.related ON TABLE product TYPE array; + DEFINE FIELD review.related[*].name ON TABLE product TYPE string; + DEFINE FIELD review.related[*].rating ON TABLE product TYPE number; + DEFINE FIELD review.related[*].book ON TABLE product TYPE record; + DEFINE FIELD review.related[*].meta ON TABLE product type object; + DEFINE FIELD review.related[*].meta.rating ON TABLE product TYPE number; + DEFINE FIELD review.related[*].meta.comment ON TABLE product TYPE string; + DEFINE FIELD review.related[*].meta.tags ON TABLE product TYPE array; + ` + const fields = definition.split(';').filter(x => x.trim().length).map(def => getDetailsFromDefinition(def, false)) + console.log("fields", fields) + const generatedSchema = generateZodSchemaCode(fields, 'schema') + + console.log("generatedSchema", generatedSchema) + + expect(generatedSchema).toEqualIgnoringWhitespace(` + const schema = z.object({ + name: z.string(), + price: z.number(), + published_at: z.string().datetime(), + is_published: z.boolean(), + related_authors: recordId('author').array().optional(), + review: z.object({ + rating: z.number(), + comment: z.string(), + author: z.object({ + name: z.string(), + email: z.string(), + tags: z.array(z.string()), + user: recordId('user') + }), + related: z.object({ + name: z.string(), + rating: z.number(), + book: recordId('book'), + meta: z.object({ + rating: z.number(), + comment: z.string(), + tags: z.array(z.string()) + }) + }).array() + }) + }) + `) + }) + + it('returns schema for complex object with duplicate field with asterisk syntax', () => { + const definition = ` + DEFINE FIELD review ON TABLE product TYPE object; + DEFINE FIELD review.rating ON TABLE product TYPE number; + DEFINE FIELD review.comment ON TABLE product TYPE string; + DEFINE FIELD review.author ON TABLE product TYPE object; + DEFINE FIELD review.author.name ON TABLE product TYPE string; + DEFINE FIELD review.author.email ON TABLE product TYPE string; + DEFINE FIELD review.author.tags ON TABLE product TYPE array; + DEFINE FIELD review.author.tags[*] ON TABLE product TYPE string; + DEFINE FIELD review.author.user ON TABLE product TYPE record; + ` + const fields = definition.split(';').filter(x => x.trim().length).map(def => getDetailsFromDefinition(def, false)) + const generatedSchema = generateZodSchemaCode(fields, 'schema') + + expect(generatedSchema).toEqualIgnoringWhitespace(` + const schema = z.object({ + review: z.object({ + rating: z.number(), + comment: z.string(), + author: z.object({ + name: z.string(), + email: z.string(), + tags: z.string().array(), + user: recordId('user') + }) + }) + }) + `) + }) + + }) +}) diff --git a/src/genSchema/generateZodSchemaCode.ts b/src/genSchema/generateZodSchemaCode.ts index 88e11a3..03f5de8 100644 --- a/src/genSchema/generateZodSchemaCode.ts +++ b/src/genSchema/generateZodSchemaCode.ts @@ -1,6 +1,16 @@ import type { FieldDetail } from './getDetailsFromDefinition.js' +const escapeRegExp = (string:string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}; + +const createRegex = (key:string) => { + const escapedKey = escapeRegExp(key); + return new RegExp(`(? { + console.log("fields", fields) // biome-ignore lint/suspicious/noExplicitAny: const buildSchema = (fieldMap: { [key: string]: any }, fields: FieldDetail[]) => { for (const field of fields) { @@ -15,9 +25,6 @@ export const generateZodSchemaCode = (fields: FieldDetail[], schemaName: string) if (i === parts.length - 1) { // Leaf node let zodString = field.zodString - if (field.type?.startsWith('array')) { - zodString = `z.array(${zodString})` - } if (fieldDefault !== undefined) { zodString += `.default(${fieldDefault})` } @@ -39,8 +46,13 @@ export const generateZodSchemaCode = (fields: FieldDetail[], schemaName: string) const buildObject = (obj: { [key: string]: any }, parentKey = ''): string => { const entries = Object.entries(obj).map(([key, value]) => { const fullKey = parentKey ? `${parentKey}.${key}` : key + const regex = createRegex(key) + const isArray = fields.some(f => { + return f.name.replace(regex, '').includes(`${fullKey}[*]`) + }) + if (typeof value === 'string') { - return `${key}: ${value}` + return `${key}: ${value}${isArray ? '.array()' : ''}` } const innerObject = buildObject(value, fullKey) let objectSchema = `z.object({\n${innerObject}\n })` @@ -52,8 +64,7 @@ export const generateZodSchemaCode = (fields: FieldDetail[], schemaName: string) const fieldSchema = fields.find(f => f.name === fullKey) const isOptionalFromSchema = fieldSchema?.zodString.includes('.optional()') - // Check if this object should be an array - if (fields.some(f => f.name.includes(`${fullKey}[*]`))) { + if (isArray) { objectSchema += '.array()' } @@ -66,6 +77,7 @@ export const generateZodSchemaCode = (fields: FieldDetail[], schemaName: string) return entries.join(',\n ') } + const schema = `z.object({\n${buildObject(fieldMap)}\n})` return `const ${schemaName} = ${schema}` } diff --git a/src/genSchema/mergeNested.test.ts b/src/genSchema/mergeNested.test.ts index 250f7e4..5aa6688 100644 --- a/src/genSchema/mergeNested.test.ts +++ b/src/genSchema/mergeNested.test.ts @@ -43,7 +43,7 @@ describe('mergeNested', () => { price: z.number(), ratings: z.object({ score: z.number().optional() - }).optional() + }).array().optional() }).array().optional() }) `)