Skip to content

Commit

Permalink
feat(data-types): added support for records and tuples
Browse files Browse the repository at this point in the history
  • Loading branch information
jharlow committed Oct 18, 2024
1 parent 2fb9f00 commit 99e5992
Show file tree
Hide file tree
Showing 12 changed files with 455 additions and 32 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zod-to-dynamodb-onetable-schema",
"version": "0.0.9",
"version": "0.0.10",
"description": "Auto-generate `dynamodb-onetable` model schemas using `zod`, with best-in-class autocomplete",
"keywords": [
"dynamo",
Expand Down
8 changes: 8 additions & 0 deletions src/converter-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
ZodNumber,
ZodObject,
ZodOptional,
ZodRecord,
ZodSet,
ZodString,
ZodTuple,
ZodTypeAny,
} from "zod";
import { ZodStringOneField } from "./converters/string";
Expand All @@ -27,6 +29,8 @@ import { ZodSetOneField } from "./converters/set";
import { ZodNativeEnumOneField } from "./converters/native-enum";
import { ZodDefaultOneField } from "./converters/default";
import { ZodLiteralOneField } from "./converters/literal";
import { ZodRecordOneField } from "./converters/record";
import { ZodTupleOneField } from "./converters/tuple";

export type Ref = { currentPath: string[] };
export type Opts = { logger?: Logger };
Expand All @@ -42,8 +46,12 @@ export type ZodToOneField<T extends ZodTypeAny> =
? ZodBooleanOneField
: T extends ZodDate
? ZodDateOneField
: T extends ZodTuple<infer Items, infer Rest>
? ZodTupleOneField<Items, Rest>
: T extends ZodArray<infer Item>
? ZodArrayOneField<Item>
: T extends ZodRecord
? ZodRecordOneField
: T extends ZodObject<infer Shape>
? ZodObjectOneField<Shape>
: T extends ZodOptional<infer Schema>
Expand Down
23 changes: 23 additions & 0 deletions src/converters/record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Opts, Ref, ZodToOneField } from "../converter-type";
import { KeySchema, ZodRecord, ZodTypeAny } from "zod";

export type ZodRecordOneField = {
type: "object";
required: true;
};

export const convertRecordSchema = <
Key extends KeySchema,
Value extends ZodTypeAny,
>(
_: ZodRecord<Key, Value>,
ref: Ref,
opts: Opts,
): ZodToOneField<ZodRecord<Key, Value>> => {
opts.logger?.debug(
`A record is specified at \`${ref.currentPath.join(".")}\`. Records cannot only be represented as a generic object in OneTable, so it will be typed as \`Record<any, any>\` instead, clobbering typing on all internal keys and values.`,
);
return { type: "object", required: true } as ZodToOneField<
ZodRecord<Key, Value>
>;
};
120 changes: 120 additions & 0 deletions src/converters/tuple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { zodOneFieldSchema } from "../";
import { Opts, Ref, ZodToOneField } from "../converter-type";
import { ZodTuple, ZodTypeAny } from "zod";

type AreAllSame<T extends [ZodTypeAny, ...ZodTypeAny[]]> = T extends [
infer First,
...infer Rest,
]
? Rest[number] extends First // Check if all rest elements extend First
? First extends Rest[number] // Ensure both directions to handle empty tuples correctly
? true
: false
: false
: true; // Base case for single element

export type ZodTupleOneField<
T extends [ZodTypeAny, ...ZodTypeAny[]] | [],
Rest extends ZodTypeAny | null = null,
> = Rest extends null
? T extends [ZodTypeAny]
? { type: "array"; required: true; items: ZodToOneField<T[number]> }
: T extends [ZodTypeAny, ...ZodTypeAny[]]
? AreAllSame<T> extends false
? { type: "array"; required: true }
: { type: "array"; required: true; items: ZodToOneField<T[number]> }
: { type: "array"; required: true } // base case
: Rest extends ZodTypeAny
? T extends [ZodTypeAny]
? AreAllSame<[Rest, ...T]> extends false
? { type: "array"; required: true }
: { type: "array"; required: true; items: ZodToOneField<Rest> }
: T extends [ZodTypeAny, ...ZodTypeAny[]]
? AreAllSame<[Rest, ...T]> extends false
? { type: "array"; required: true }
: { type: "array"; required: true; items: ZodToOneField<T[number]> }
: { type: "array"; required: true } // base case
: { type: "array"; required: true }; // base case

const zodSchemasAreSame = (
schema1: ZodTypeAny,
schema2: ZodTypeAny,
): boolean => {
// Check if they are the same Zod type
if (schema1._def.typeName !== schema2._def.typeName) {
return false;
}

// Special case for ZodObject, where we want to compare shapes
if (
schema1._def.typeName === "ZodObject" &&
schema2._def.typeName === "ZodObject"
) {
return (
JSON.stringify(schema1._def.shape()) ===
JSON.stringify(schema2._def.shape())
);
}

// For other types, compare the definitions directly
return JSON.stringify(schema1._def) === JSON.stringify(schema2._def);
};

export const convertTupleSchema = <
T extends [ZodTypeAny, ...ZodTypeAny[]] | [],
Rest extends ZodTypeAny | null = null,
>(
zodSchema: ZodTuple<T, Rest>,
ref: Ref,
opts: Opts,
): ZodToOneField<ZodTuple<T, Rest>> => {
opts.logger?.debug(
`A tuple is specified at \`${ref.currentPath.join(".")}\`. OneTable does not support tuples natively, will cast to an array instead.`,
);
const { items, rest } = zodSchema._def;
const allItems = rest == null ? items : [rest as ZodTypeAny, ...items];
if (allItems.length === 0) {
opts.logger?.debug(
`A tuple with no internal schema is specified at \`${ref.currentPath.join(".")}\`. Cannot infer an \`items\` value with a tuple without an internal schema, will type as \`any[]\`.`,
);
return { type: "array", required: true } as ZodToOneField<
ZodTuple<T, Rest>
>;
}
if (allItems.length === 1) {
const innnerType = allItems[0];
const items = zodOneFieldSchema(
innnerType,
{ currentPath: [...ref.currentPath, "0"] },
opts,
);
return { type: "array", required: true, items } as ZodToOneField<
ZodTuple<T, Rest>
>;
}
const { allIdentical } = allItems.reduce(
({ lastType, allIdentical }, curr) => ({
lastType: curr,
allIdentical: allIdentical && zodSchemasAreSame(lastType, curr),
}),
{ lastType: allItems[0], allIdentical: true },
);
if (allIdentical) {
const innnerType = allItems[0];
const items = zodOneFieldSchema(
innnerType,
{ currentPath: [...ref.currentPath, "0"] },
opts,
);
return { type: "array", required: true, items } as ZodToOneField<
ZodTuple<T, Rest>
>;
} else {
opts.logger?.debug(
`A tuple with various internal schemas is specified at \`${ref.currentPath.join(".")}\`. OneTable does not support multiple data-types in arrays - will use \`any[]\` instead.`,
);
return { type: "array", required: true } as ZodToOneField<
ZodTuple<T, Rest>
>;
}
};
14 changes: 8 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { convertSetSchema } from "./converters/set";
import { convertNativeEnumSchema } from "./converters/native-enum";
import { convertDefaultSchema } from "./converters/default";
import { convertLiteralSchema } from "./converters/literal";
import { convertRecordSchema } from "./converters/record";
import { convertTupleSchema } from "./converters/tuple";

type ConverterFunction = <T extends ZodSchema>(
schema: ZodSchema,
Expand Down Expand Up @@ -55,11 +57,13 @@ const getConverterFunction = <T extends ZodSchema>(
return convertDefaultSchema as ConverterFunction;
case ZodFirstPartyTypeKind.ZodLiteral:
return convertLiteralSchema as ConverterFunction;
case ZodFirstPartyTypeKind.ZodRecord: // TODO: Can be coersed to object
case ZodFirstPartyTypeKind.ZodMap: // TODO: Can be coersed to object
case ZodFirstPartyTypeKind.ZodIntersection: // TODO: Can be coersed to object
case ZodFirstPartyTypeKind.ZodTuple: // TODO: Can be coersed to array
case ZodFirstPartyTypeKind.ZodRecord:
return convertRecordSchema as ConverterFunction;
case ZodFirstPartyTypeKind.ZodTuple:
return convertTupleSchema as ConverterFunction;
case ZodFirstPartyTypeKind.ZodNull: // WARN: These types are unrepresentable in `dynamodb-onetable`
case ZodFirstPartyTypeKind.ZodIntersection:
case ZodFirstPartyTypeKind.ZodMap:
case ZodFirstPartyTypeKind.ZodNaN:
case ZodFirstPartyTypeKind.ZodBigInt:
case ZodFirstPartyTypeKind.ZodSymbol:
Expand Down Expand Up @@ -116,5 +120,3 @@ export const zodOneModelSchema = <T extends ZodRawShape>(
};

export type { ZodToOneField };

// TODO: Replace strings with constructors
12 changes: 9 additions & 3 deletions test/converters/array.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,17 @@ describe("convertArraySchema", () => {

// TODO: Fill out remaining
const schemaTypes = [
["number", z.number()],
["string", z.string()],
["object", z.object({})],
["array", z.array(z.string())],
["boolean", z.boolean()],
["date", z.date()],
["enum", z.enum(["foo", "bar"])],
["literal", z.literal("foo")],
["number", z.number()],
["object", z.object({})],
["record", z.record(z.string(), z.unknown())],
["set", z.set(z.string())],
["string", z.string()],
["tuple", z.tuple([z.string()])],
] as const;
it.each(schemaTypes)(
"should return array field with items when %s schema is supplied",
Expand Down
42 changes: 35 additions & 7 deletions test/converters/object.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,56 @@ describe("convertObjectSchema", () => {

it("should return all keys with their own onefield schemas filled in", () => {
// Assemble
enum ValidEnum {
Test = "Test",
}
const zodObjectSchema = z.object({
string: z.string(),
number: z.number(),
array: z.array(z.string()),
boolean: z.boolean(),
optional: z.boolean().optional(),
date: z.date(),
default: z.string().default("test"),
enum: z.enum(["foo", "bar"]),
literal: z.literal("literal"),
nativeEnum: z.nativeEnum(ValidEnum),
nullable: z.boolean().nullable(),
number: z.number(),
optional: z.boolean().optional(),
record: z.record(z.string(), z.unknown()),
set: z.set(z.number()),
string: z.string(),
tuple: z.tuple([z.string()]),
});

// Act
const onefield = convertObjectSchema(zodObjectSchema, mockRefs, mockOpts);

// TODO: Add items for other datatypes
// Assert
expect(onefield).toEqual({
type: "object",
required: true,
schema: {
string: { type: "string", required: true },
number: { type: "number", required: true },
array: {
items: { required: true, type: "string", validate: undefined },
required: true,
type: Array,
},
boolean: { type: "boolean", required: true },
optional: { type: "boolean" },
date: { type: "date", required: true },
default: { type: "string", required: true, default: "test" },
enum: { enum: ["foo", "bar"], required: true, type: "string" },
literal: { type: "string", value: "literal", required: true },
nativeEnum: { type: "string", enum: ["Test"], required: true },
nullable: { type: "boolean" },
number: { type: "number", required: true },
optional: { type: "boolean" },
record: { type: "object", required: true },
set: { type: Set, required: true },
string: { type: "string", required: true },
tuple: {
type: "array",
required: true,
items: { type: "string", required: true },
},
},
});
});
Expand Down
53 changes: 53 additions & 0 deletions test/converters/record.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { convertRecordSchema } from "../../src/converters/record";
import { mock } from "vitest-mock-extended";
import { Logger } from "winston";

const mockLogger = mock<Logger>();
const mockOpts = { logger: mockLogger };
const mockRefs = { currentPath: [] };

describe("convertRecordSchema", () => {
const keyTypes = [z.string(), z.number(), z.symbol()];
const testValueTypes = [
z.string(),
z.number(),
z.object({ hello: z.string() }),
z.record(z.string(), z.unknown()),
z.unknown(),
z.array(z.string()),
z.symbol(),
z.set(z.string()),
];
const testableZodRecordMatrix = keyTypes.flatMap((keySchema) =>
testValueTypes.map((valueSchema) => z.record(keySchema, valueSchema)),
);

it.each(testableZodRecordMatrix)(
"should return schemaless object supplied for keyType $_def.keyType._def.typeName and valueType $_def.valueType._def.typeName",
(zodRecordSchema) => {
// Act
const onefield = convertRecordSchema(zodRecordSchema, mockRefs, mockOpts);

expect(onefield).toEqual({ type: "object", required: true });
},
);

it("should notify the user that `z.record()` clobbers OneTable typing via debug", () => {
// Assemble
const zodRecordSchema = z.record(z.string(), z.unknown());

// Act
convertRecordSchema(
zodRecordSchema,
{ currentPath: ["hello", "world"] },
mockOpts,
);

// Assert
expect(mockLogger.debug.mock.lastCall).toEqual([
"A record is specified at `hello.world`. Records cannot only be represented as a generic object in OneTable, so it will be typed as `Record<any, any>` instead, clobbering typing on all internal keys and values.",
]);
});
});
Loading

0 comments on commit 99e5992

Please sign in to comment.