Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(data-types): added support for records and tuples #28

Merged
merged 1 commit into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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